tools/qemu-wasm: PebbleOS emulator in the browser (QEMU→wasm, app install, PebbleKit JS) - #1910
tools/qemu-wasm: PebbleOS emulator in the browser (QEMU→wasm, app install, PebbleKit JS)#1910ericmigi wants to merge 27 commits into
Conversation
Resurrect the PebbleOS-in-the-browser emulator on top of main's QEMU instead of the ericmigi/pebble-qemu-wasm STM32-model port it derives from. coredevices/qemu pebble-10.1 (10.1.5-pebble17) builds for emscripten out of the box, and the generic virtual machines (pebble-emery/flint/gabbro) boot the qemu_* release flash images directly, so the browser build now tracks mainline firmware rather than a frozen SDK image. The shell renders the display by polling shared-memory exports and injects buttons through an atomics bitmask, both provided by a small emscripten-only bridge in the QEMU tree (patches/0001, to be upstreamed to coredevices/qemu): a virtual-clock timer drives console updates under -display none and the page blits the rendered surface, so no SDL/proxying machinery is involved. Included: reproducible native build script (emsdk 3.1.50 + wasm32 deps), release-firmware fetcher, COOP/COEP dev server, and a node headless smoke test. Measured on 4 cores under node: serial banner at ~3 s, launcher + 'Ready for communication.' at ~55 s, 200x228 frames rendering throughout (TCI interpreter, no JIT yet). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
Add an assembler that packs QEMU wasm, runtime JS, pthread worker, and the release firmware into one self-contained HTML page (gzip + base64, unpacked in-browser via DecompressionStream), with an environment probe that reports exactly which host capability is missing (COOP/COEP, SharedArrayBuffer, blob workers) instead of hanging on an incompatible host. Includes a headless chromium test driving both the boot and the diagnostics paths. To fit single-page size limits, document the Pebble-only QEMU build: --without-default-devices --with-devices-arm=pebble with a one-line devices config, which drops every non-Pebble ARM board and shrinks the wasm from 39 MB to 25 MB (13.8 MB page total). That surfaced a missing 'select I2C' in the PEBBLE Kconfig entry, now part of the refreshed browser-glue patch. Verified: the assembled page boots v4.35.0 emery firmware under chromium with pixels on canvas, and renders the correct diagnostic when served without cross-origin isolation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
Transport-only directory (see its README): the assembled emulator site for ericmigi/pebble-qemu-wasm's GitHub Pages, so a session with push access there can deploy without redoing the wasm build. Verified under a headerless static host with coi-serviceworker providing isolation: crossOriginIsolated true, firmware boots, display renders. Remove before merging this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
Fold the measured wasm performance work into the browser-glue patch: - pebble_display framebuffer becomes plain RAM (memory_region_init_ram_ptr) instead of trapping MMIO: guest pixel writes were tens of thousands of interpreted slow-path exits per frame. The CTRL_UPDATE doorbell is the only sync point the firmware relies on, so nothing needs to trap. - inline TLB fast path in the TCI interpreter's tci_qemu_ld/st (ported from the ericmigi/pebble-qemu-wasm work): on a TLB hit with no flags, access host memory directly instead of the helper_*_mmu call chain. - skip cpu_io_recompile under emscripten (longjmp is a JS exception). - -sASYNCIFY_REMOVE for the interpreter hot path, -Doptimization=3, mimalloc. Continuous launcher scroll on pebble-emery v4.35.0 under node (4 shared cores): 2.1 -> 4.5 fps; boot to 'Ready for communication' 91 s -> 38 s; boot animation 1.3 -> 6.9 fps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
The TCI interpreter plateaus at ~4.5 fps on continuous launcher scrolling. Port the generic-machine device set onto ktock/qemu-wasm branch wasm64-tcg-b (QEMU 10.2.50 with the TCG WebAssembly JIT backend), which compiles hot translation blocks to native wasm: boot to 'Ready for communication' 91 s -> 38 s -> 15 s continuous launcher scroll 2.1 -> 4.5 -> 16.9 fps (baseline TCI -> patched TCI -> JIT, node on 4 shared cores) tools/qemu-wasm/jit/ carries the overlay (device set ported to the QEMU 10.2 APIs: hw/core header moves, CharBackend->CharFrontend, machines must declare arm_machine_interfaces to be visible in the single-binary machine registry, pebble_audio rewritten as a register-compatible drain-only stub since the audio subsystem was rewritten, pebble_set_button_state moved into pebble_gpio.c), the meson/Kconfig wiring patch, the MEMORY64=2 dependency build script, and a README with the build recipe and the API-drift map. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
Under the JIT build (-sMEMORY64=2 with WASM_BIGINT) pointer-returning glue exports arrive in JS as BigInt, and the blit path's pointer arithmetic throws 'Cannot mix BigInt and other types'. Wrap the two pointer exports in Number(); a no-op for the wasm32 TCI build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
The page imported ./qemu-system-arm.mjs while every deployment lays the emscripten output down under its original .js name; browsers resolve module type from MIME, not extension, so standardize on .js. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
Swap the deployable bundle's emulator to the TCG-to-wasm JIT build (9 MB wasm, no separate worker file, boots to the watchface in ~15 s at ~17 fps scrolling) and carry the updated shell. Verified booting under a headerless static host with coi-serviceworker in chromium. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
The pebble-tool channel (UART1, QemuProtocol framing) previously had no transport in the browser build. Add an emscripten-only bridge inside pebble_control: pebble_wasm_serial_ctrl() exports a pair of ring buffers; the page produces host->guest bytes into the rx ring with Atomics and a 2 ms virtual-clock timer feeds them through the normal chardev receive path, while all watch-bound writes are mirrored into the tx ring for the page to drain. Works alongside a real chardev, and pebble_control is now created even when serial1 has none. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
Implement the phone side of the app-install flow in JS on top of the UART1 ring bridge: - pebble-transport.js: QemuProtocol framing (0xFEED/0xBEEF), Pebble Protocol stream reassembly in both directions, and the endpoint-17 version handshake. The response is V3 with capabilities 0xA3 because the firmware replaces (not ORs) session capabilities, and losing CommSessionRunState would break launch notifications. - pbw.js: .pbw (zip) reader on the native DecompressionStream with platform-directory selection (emery -> basalt -> diorite -> aplite). - app-install.js: BlobDB INSERT of the 126-byte AppDBEntry (retrying on TRY_LATER), app_run_state RUN to trigger the watch's AppFetch request, then PutBytes INIT/PUT/COMMIT/INSTALL per object with the legacy defective CRC (verified against the firmware's test vectors). Legacy struct-version binaries read the uuid at its pre-3.0 offset. - store.js: resolves apps.repebble.com / apps.rebble.io links via the CORS-enabled appstore API and downloads the release pbw. - index.html: install panel (paste a store link) plus drag-drop of .pbw files onto the page, with transfer progress. Verified headless under node and in Chromium against the JIT build: BlobDB ack, AppFetch round-trip, CRC-clean PutBytes commits, and the watch launching the installed watchface (app fetch result 0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
New JIT build with the serial bridge, the install JS modules, and the updated shell page; apply.sh now copies the module files. Document the serial bridge and install flow in the README. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
File-picker alternative to drag-drop for installing local .pbw files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
Apps with a pebble-js-app.js now get their JS executed when the watch launches them, wired to the watch over AppMessage: - appmessage.js: endpoint 0x30 client. PUSH carries the app uuid and a little-endian tuple dictionary; ACK/NACK are two bytes echoing the transaction id. Sends are serialized through a queue (one PUSH in flight, 10 s ACK timeout) and pushes that arrive while the app's JS is still starting are buffered and delivered after 'ready', since the watch's first outbox send races the run-state notification. - pkjs-runtime.js: sandbox runner + Pebble shim (addEventListener, sendAppMessage, tokens, getActiveWatchInfo, openURL; timeline APIs stubbed). Lifecycle follows app_run_state notifications on endpoint 52. In the browser the sandbox is a hidden same-origin iframe -- window globals like localStorage are getter-only, so the shim bindings are installed with defineProperty -- with per-app-scoped storage and an XHR-over-fetch shim that retries failed cross-origin requests through a configurable CORS proxy (?pkjs_proxy=). - config pages: openURL appends the return_to convention pointing at config-return.html, which posts the response fragment back to the page for the webviewclosed event. - pbw.js extracts pebble-js-app.js; index.html registers installed apps with the runtime and adds a settings button that triggers showConfiguration. Verified headless (node:vm sandbox) and in Chromium: BlobDB install, watch-initiated AppMessage push delivered to JS, XHR fetch, and the reply rendered by the C app on the emulated display. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
Cloudflare Worker the emulator page falls back to when a JS app's API call has no CORS headers. Origin-allowlisted, GET/HEAD/POST only, credentials stripped both directions, internal hostnames refused. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
Add the runtime modules and config-return page to site-dist and apply.sh; document the PKJS architecture in the README. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
apply.sh now copies pkjs-proxy/ into the target checkout so the worker source lives alongside the site it serves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 18dcb6284e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const frame = document.createElement('iframe'); | ||
| frame.style.display = 'none'; | ||
| frame.setAttribute('allow', 'geolocation *'); | ||
| document.body.appendChild(frame); |
There was a problem hiding this comment.
Isolate installed app code from the page origin
When a user installs any third-party PBW containing pebble-js-app.js, this iframe has neither a sandbox attribute nor a separate origin, and the code is later executed with w.eval. Such an app can access parent.document, the emulator's full API, and all localStorage belonging to the hosting origin; replacing the iframe's localStorage property does not restrict access through parent. Run PBW code in a genuinely isolated iframe or worker and expose only the Pebble bridge through messaging.
Useful? React with 👍 / 👎.
| console.log('screenshot: ' + shot); | ||
| await browser.close(); | ||
| server.close(); | ||
| process.exit(0); |
There was a problem hiding this comment.
Return failure when the browser boot test times out
If the page never reaches the expected display state—for example because QEMU aborts, pixels remain blank, or the loop simply reaches waitS—execution falls through and exits successfully anyway. This makes the documented coi browser test report a green status for broken builds; track whether the success predicate was reached and return nonzero when it was not.
Useful? React with 👍 / 👎.
| flint: ['diorite', 'aplite', 'basalt'], | ||
| }; |
There was a problem hiding this comment.
Add the Chalk fallback for Gabbro
When the board selector passes gabbro, the missing map entry makes the fallback list just ['gabbro'], so PBWs containing the compatible chalk/pebble-app.bin are rejected as having no Gabbro binary. The firmware explicitly maps Gabbro to PlatformTypeChalk for pre-4.2 SDKs in src/fw/process_management/pebble_process_md.c:230-250, so the browser should include Chalk in Gabbro's platform preference.
Useful? React with 👍 / 👎.
| EMSDK4=/home/user/emsdk-4 | ||
| TARGET=/home/user/wasm-deps64 | ||
| SRC=/home/user/wasm64-src |
There was a problem hiding this comment.
Make the JIT dependency paths configurable
Running the documented ./build-deps64.sh command under any account other than the original /home/user environment writes outside the caller's home and commonly fails at the initial mkdir with a permission error. Later steps also copy sources from /home/user/wasm-deps, so even changing permissions does not make the build self-contained; derive these paths from $HOME or accept a work directory argument.
Useful? React with 👍 / 👎.
Point the proxy fallback at pkjs-proxy.ericmigi.workers.dev and use it for store lookups and pbw downloads too, so store installs work before the asset bucket gets a CORS policy. Accept messageKeys as the appKeys fallback for modern pbws. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
Browsers block window.open on data: URLs, which is how Clay-based apps deliver their settings pages. Decode the data: URL, rewrite Clay's default pebblejs://close target to the same-origin return page, and render the page in a modal iframe; the return page now posts to parent as well as opener. Hosted config pages keep the popup + return_to path. Verified with a Clay-style page round-tripping its settings payload in Chromium. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
JS-side activity (app start, fetch failures, geolocation denials) was only visible in devtools; mirror it into the page's console panel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
A silent OS-level location denial or a doubly-failed weather fetch was invisible; wrap the sandbox geolocation methods and log XHR/fetch failures and proxy fallbacks into the page console. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
Call the top page's geolocation rather than the sandbox iframe's (removes the permissions-delegation variable), fall back to IP-based city-level location when the browser provider fails (common when OS location services are off), and support a ?loc=lat,lon override. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
Touch: pebble_touch.c gains a browser injection path — the page writes
{seq, down, x, y} in display pixels with Atomics and an 8 ms
virtual-clock poll raises the touch IRQ on state changes, mirroring
the input-layer handler. Canvas pointer events drive it; verified in
Chromium by touch-scrolling the launcher.
Audio: the pebble_audio drain stub now mirrors every DATA sample into
a shared int16 ring with rate/volume/playing mirrors; the page drains
it into WebAudio on a 60 ms poll, scheduling buffers at a running
playhead. The context is created on the first canvas touch to satisfy
autoplay policies.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M
Signed-off-by: Eric Migicovsky <eric@repebble.com>
- The 32 MB SPI flash is saved to OPFS when dirty (15 s cadence, strided FNV change detection) and restored on the next boot, so installed apps and settings survive reloads; PebbleKit JS sources are persisted alongside in localStorage. Factory reset wipes both. - The page boots automatically on load (?noauto restores the button); switching boards reloads onto the chosen machine. - gabbro (getafix_dvt) is a first-class board option with v4.35.0 firmware in the site bundle. - dbgserial (UART2) gains a browser input ring behind a wasm-console device property, giving the console panel a working prompt. - Sensors & phone panel injects battery, compass, tap and time-format QemuProtocol frames over the existing serial bridge. Verified in Chromium: autoboot, install -> OPFS save -> reload -> restore (no re-fetch, JS registry intact), and the dbgserial prompt answering 'help'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
- pulse-console.js: full PULSEv2 client. Frame layer (0x55 flags, COBS, 0x55<->0x00 transparency, CRC-32 residue check), PPP control FSMs for LCP and the reliable-transport NCP, and a stop-and-wait LAPB client for the TRAIN transport. Logs (push 0x5021 port 3, the 29-byte <c16sccQH record) render as [time] <lvl> file:line: message -- the qemu build is plaintext PULSE, not hashed, so no dictionary is needed. The console input line drives the dbgserial prompt over the reliable transport (port 0x3e20). - phone-extras.js: notifications and voice. buildNotification serializes a 46-byte SerializedTimelineItemHeader plus attribute and action lists (status must be 0, payload_length exact) into a BlobDB insert on the notifs db; the default notification carries a Response action with canned replies so the Reply -> Voice path works. VoiceService accepts dictation session setups on endpoint 11000, counts the (silent) Speex frames on 10000, and on stop returns a one-sentence transcription from browser speech recognition with a typed-prompt fallback, echoing app_initiated flags and app uuid. Verified in Chromium end to end: parsed log lines, a 'version' prompt round trip, a notification banner with working action menu, and Reply -> Voice dictation displaying the browser-provided transcript. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
Auto-boot disabled the dropdown before it could ever be used, making board switches impossible. The selector now stays enabled; changing it reloads the page onto the chosen machine, which is the only way to restart the emulator anyway. Verified in Chromium: switching from a running emery to gabbro boots the round 260x260 machine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
The global key handler consumed Enter and arrows even while typing in the install/console inputs, pressing Select on the watch instead of submitting the field. Ignore watch-key events when focus is in a form field, and blur buttons after clicks so the next Enter goes back to the watch rather than re-clicking the button. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
apply.sh listed JS files by name and was never taught about pulse-console.js and phone-extras.js, so deployed pages imported a 404 and the whole module script (install button included) died silently. Copy *.js wholesale, and surface module-load failures in the status line. Verified by running the full page test against an apply.sh-produced directory rather than a hand-staged one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
gmarull
left a comment
There was a problem hiding this comment.
Sources likely do not belong to this repo, this is massive, not a simple script. Maybe more patches to our qemu fork, and ship with pebbleos sdk as we do for other stuff.
Summary
Adds
tools/qemu-wasm/: a browser-based PebbleOS emulator built on the coredevices/qemu generic Pebble machines compiled to WebAssembly, plus the full toolchain and web shell around it. Live at https://ericmigi.github.io/pebble-qemu-wasm/. Everything is contained undertools/qemu-wasm/— no firmware or build-system changes.What's in here, layer by layer:
web/): bootspebble-emerywith release firmware, canvas display via shared-memory framebuffer exports, buttons via an atomics-polled bitmask, serial console.web/pebble-transport.js,app-install.js,pbw.js,store.js): a JS implementation of the phone side of the Pebble Protocol — QemuProtocol framing over a shared-memory UART bridge, phone-version handshake, and the full 4.x app-install flow (BlobDB → AppFetch → PutBytes with the legacy defective CRC, verified against the firmware's test vectors). Users install apps by pasting an apps.repebble.com link, uploading, or drag-dropping a.pbw.web/pkjs-runtime.js,appmessage.js): apps withpebble-js-app.jsget their JS run in a sandboxed iframe with aPebbleshim, wired to the watch over AppMessage (endpoint 0x30), lifecycle driven by app_run_state notifications. Includes config-page support via thereturn_toconvention and an optional CORS-proxy fallback (pkjs-proxy/, a Cloudflare Worker).patches/,jit/): TCI-interpreter patches (RAM-backed framebuffer, inline TLB fast path, ASYNCIFY_REMOVE lists) take scroll fps from 2.1 → 4.5; the TCG→wasm JIT overlay (ktock/qemu-wasm tree, QEMU 10.2 port of the pebble device set) reaches ~17 fps and 15 s boots. The JIT overlay documents the 10.1→10.2 API drift and should be retired when the wasm TCG backend lands upstream.site-dist/): prebuilt gzipped build +apply.shfor the GitHub Pages site. ~3.9 MB of binaries — flagging for maintainer judgment; happy to move these to release assets or CI artifacts in a follow-up if binaries in-tree are unwanted.Verification
.pbw(BlobDB ack → AppFetch round trip → CRC-clean PutBytes → app fetch result 0), C↔JS AppMessage round trip both directions.🤖 Generated with Claude Code
https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M
Generated by Claude Code