Writing
Building a JavaScript headless browser for the reMarkable 2
How I cross-compiled Obscura and V8 for the reMarkable 2's 32-bit ARM processor, then rendered a real webpage to PNG.
On this page
I wanted to know whether the reMarkable 2 could run a modern JavaScript page without turning the tablet into a general-purpose desktop computer.
Obscura looked like an interesting answer. It is a small headless browser written in Rust. It can fetch a page, execute its JavaScript, inspect the DOM, expose the Chrome DevTools Protocol and, when built with its software renderer, turn the result into a PNG.
There was one immediate problem: Obscura ships Linux binaries for x86-64 and ARM64. The reMarkable 2 has a 32-bit ARMv7 Cortex-A7 processor.
What I expected to be a Rust cross-compilation job became a journey through V8 host tools, target tools, C++ alignment assumptions, emulated build programs, and architecture-dependent startup snapshots. In the end it worked: Obscura initialized V8, fetched and laid out a real web page, and produced a screenshot using a native ARMv7 binary.
I extracted the build into a standalone Obscura for reMarkable 2 repository. It pins the Obscura source and contains the Docker image, patches, and scripts needed to reproduce the result.
A headless browser, not a tablet browser
Obscura does not provide a browser window, address bar, touch handling, or direct access to the e-ink display. It is the engine behind those things.
On the reMarkable it can:
- fetch pages and execute JavaScript;
- query or extract the resulting DOM;
- serve Chrome DevTools Protocol and MCP clients;
- lay out a page with a pure-Rust software renderer;
- generate viewport-sized or full-page screenshots.
Another application still has to put those pixels on the physical display and request the appropriate e-ink refresh. That separation suited my goal. I was interested in web content as something an application could render, transform, or display, rather than placing a conventional desktop browser on the tablet.
The architecture gap
The official reMarkable toolchain targets Cortex-A7 with NEON and hardware floating point. In Rust terms, the closest standard target is:
armv7-unknown-linux-gnueabihf
Rust itself handles that target well. The difficult dependency was V8, embedded by Obscura through deno_core and rusty_v8.
V8’s build is not simply a matter of asking the compiler to emit ARM instructions. It builds programs that run during compilation, including the machinery used to create its startup snapshot. This meant the build had to keep two worlds straight:
ARM64 host tools run inside Docker
│
├── build-time generators
│
└── cross-compile V8 and Obscura
│
▼
ARMv7 hard-float binary
for the reMarkable 2
The ARM64 host matters because I was building on Apple Silicon with an ARM64 Ubuntu container. The target remains the reMarkable’s 32-bit ARM environment.
Building V8 from source
I reused the Docker setup I already had for the official reMarkable SDK and added Rust, Clang, LLVM, LLD, Ninja, and the ARMv7 Rust standard library. The finished recipe asks rusty_v8 to compile V8 from source:
export V8_FROM_SOURCE=1
cargo build \
--release \
--target armv7-unknown-linux-gnueabihf \
-p obscura-cli \
--bins \
--no-default-features
The real command also points Cargo, C++, bindgen, and V8 at the correct compiler and sysroots. A small linker wrapper fixes the CPU, NEON, hard-float, time, and large-file options expected by the tablet.
The first build is substantial. V8 compiled a complete native set of host objects as well as the ARMv7 target objects. I limited Cargo to a small number of parallel jobs to keep memory use predictable, and preserved both Cargo’s registry and the source build tree so each correction could reuse the expensive work.
That cache mattered. The first genuine ARM failure arrived with only six V8 build actions remaining.
Finding the 64-bit assumptions
The first upstream issue was in rusty_v8’s C++ binding. It unconditionally defined a garbage-collected type aligned to 16 bytes. On ARMv7, std::max_align_t is 8 bytes, so the compiler correctly rejected it.
The patch disables only that optional 16-byte allocation branch when building with 32-bit pointers. The ordinary allocation path remains unchanged.
The next failure was a Rust assertion about the alignment of TypeId. TypeId is still 64 bits wide on ARMv7, but it has 32-bit alignment. The code accepted the layouts found on 64-bit platforms and rejected this valid ARM32 layout, so a second narrow patch admits pointer-width alignment.
Between those two failures, bindgen also needed to be told to use the same bundled libc++ headers and ARM sysroot as the V8 build. Otherwise it mixed the SDK’s GCC C++ headers with V8’s libc++ headers and produced a rather misleading wall of errors.
Once those pieces were aligned, librusty_v8.a built for ARMv7 and the rest of Obscura’s Rust dependency graph compiled normally.
The binary that started but could not browse
The first complete executable looked perfect from the outside. It was ARM EABI5, used the hard-float ABI, requested the reMarkable loader, and depended only on libraries in the SDK sysroot.
It could print --help. Then I asked it to fetch a page, and V8 rejected its embedded startup snapshot.
A V8 snapshot is not portable data. The program producing it has to agree with the target about details including pointer width. Our snapshot had been made in the builder’s world and then embedded in a 32-bit executable.
I tried the obvious paths first. The ARM-native mksnapshot binary could be run through QEMU, but trapped while producing the blob. V8 can also build special host-executable snapshot generators for another target layout, but the available combinations did not give this ARM64-host-to-ARM32-target build a reliable route. A 64-bit generator still has the wrong pointer width; the 32-bit simulator route eventually crashed under Docker’s emulation.
The better answer was to stop requiring a snapshot.
Bootstrapping JavaScript at runtime
Obscura’s startup snapshot contains its bootstrap JavaScript. For ARMv7, I changed its build script to emit an empty placeholder instead. When Obscura creates the V8 isolate, it now loads and executes bootstrap.js directly after registering the runtime state its operations need.
In simplified form:
#[cfg(target_pointer_width = "32")]
static BOOTSTRAP: &str = include_str!("../js/bootstrap.js");
// Create the runtime and register Obscura's operation state first.
#[cfg(target_pointer_width = "32")]
runtime.execute_script(
"<obscura:bootstrap>",
BOOTSTRAP.to_string(),
)?;
This adds a little work when a runtime starts, but removes a fragile cross-architecture build step. It is also easy to understand: the same JavaScript is initialized at runtime instead of being deserialized from a prebuilt heap image.
The order was important. My first attempt executed the bootstrap too early. It immediately called an Obscura operation before the corresponding state had been inserted into deno_core. Moving the call to just after state registration completed the fix.
My first test on the reMarkable itself was Learnalist:
./obscura fetch "https://learnalist.net" --eval "document.title"
Fetching https://learnalist.net...
Page loaded: https://learnalist.net/ - "Learnalist | Save What Matters And See It Again"
That small result exercised the whole path. The native ARMv7 executable started on the tablet, V8 initialized, Obscura fetched Learnalist, the page became a DOM, and JavaScript read its title. The browser was alive.
Adding rendering
I deliberately started without Obscura’s optional renderer. Proving JavaScript and DOM operation first kept native graphics libraries out of the investigation.
Once that worked, enabling rendering was pleasantly uneventful:
cargo build \
--release \
--target armv7-unknown-linux-gnueabihf \
-p obscura-cli \
--bins \
--features render \
--no-default-features
Obscura’s renderer is CPU-only and written in Rust. It does not need Qt, a GPU, xochitl, or the reMarkable framebuffer. The resulting stripped binary was about 65 MB and produced a visually correct 1280×720 RGBA PNG while running against the reMarkable SDK sysroot.
My first test of the render build returned to Learnalist and added a screenshot:
./obscura fetch "https://learnalist.net" --eval "document.title" --screenshot page.png
Fetching https://learnalist.net...
Page loaded: https://learnalist.net/ - "Learnalist | Save What Matters And See It Again"
{"evaluation":"Learnalist | Save What Matters And See It Again","controlledScroll":null,"resourceWarmup":{"performed":false,"discardedShots":0,"taskTurnMs":0,"phase":"before-final-scroll-reassert-and-state-sample"},"captureState":{"scrollX":0,"scrollY":0,"innerWidth":1280,"innerHeight":720,"scrollWidth":1280,"scrollHeight":1883}}
Screenshot written: page.png (53653 bytes)
The renderer saw a 1280×720 viewport and a page 1,883 pixels tall, then wrote a 53,653-byte PNG on the tablet. JavaScript evaluation and rendering worked in the same fetch.
The shorter form, when no evaluation is needed, is:
./obscura fetch https://example.com \
--screenshot page.png \
--wait 0
For a viewport matching the full portrait display:
OBSCURA_SHOT_W=1404 \
OBSCURA_SHOT_H=1872 \
./obscura fetch https://example.com \
--screenshot page.png \
--wait 0
Those dimensions create a reMarkable-sized viewport, allowing responsive sites to lay themselves out at the intended width. This is different from capturing the entire scrollable document.
CDP turns the port into a platform
The command-line screenshot proved the renderer worked, but Obscura’s support for the Chrome DevTools Protocol is where the port becomes much more valuable.
CDP separates the browser engine from the program controlling it. Obscura and V8 can run on the reMarkable, close to the content and the eventual display, while an application on the tablet or another computer drives the session over a WebSocket. The controller does not need to know that the browser behind the protocol is a Rust program on a 32-bit e-ink tablet.
Start the server on the reMarkable with:
./obscura serve --port 9222 --host '10.11.99.1'
From there, CDP provides a vocabulary for longer-lived browser work: create pages, navigate, evaluate JavaScript, inspect elements, set viewport dimensions, observe console and network activity, manage cookies, capture screenshots, and keep state between operations. Instead of constructing a special command for every use case, I can use an existing browser-automation library.
Controlling it with Playwright
Playwright can attach to an existing CDP endpoint. That means the familiar Playwright API can control the copy of Obscura running on the tablet:
import { chromium } from "playwright-core";
const remarkableTablet = "10.11.99.1";
const browser = await chromium.connectOverCDP(
`ws://${remarkableTablet}:9222`,
);
const context = browser.contexts()[0];
const page = context.pages()[0] ?? await context.newPage();
await page.goto(
"https://freshteapot.net/writing/building-obscura-for-remarkable-2/",
);
await page.screenshot({
path: "obscura-article-full-page.png",
fullPage: true,
});
Here, fullPage: true becomes a CDP Page.captureScreenshot request with capture beyond the current viewport. Obscura calculates the complete document dimensions and renders content below the fold. The result is not a stretched 1404×1872 screen capture; it is an image of the entire scrollable page.
This also lets existing Playwright code become part of a reMarkable workflow. A script can sign in, navigate to a particular view, wait for content, evaluate application state, and render the final page without bundling Chromium for the tablet.
There is an important boundary. Playwright describes CDP attachment as lower fidelity than its native Playwright protocol, and Obscura implements a useful subset of CDP rather than every feature of Chromium. The productive approach is to use the operations Obscura supports and test each automation flow, rather than assuming complete browser equivalence.
Controlling it from Go with Rod
For a Go application, Rod is especially interesting. Rod is a high-level driver built directly on CDP, can control a remote browser without depending on its filesystem, and still permits raw protocol calls when its higher-level API does not expose something.
The shape of the same full-page capture is compact:
package main
import (
"os"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto"
)
const remarkableTablet = "10.11.99.1"
func main() {
browser := rod.New().
ControlURL("ws://" + remarkableTablet + ":9222").
MustConnect()
defer browser.MustClose()
page := browser.MustPage()
navigation, err := proto.PageNavigate{
URL: "https://freshteapot.net/writing/building-obscura-for-remarkable-2/",
}.Call(page)
if err != nil {
panic(err)
}
if navigation.ErrorText != "" {
panic(navigation.ErrorText)
}
screenshot, err := proto.PageCaptureScreenshot{
Format: proto.PageCaptureScreenshotFormatPng,
CaptureBeyondViewport: true,
}.Call(page)
if err != nil {
panic(err)
}
if err := os.WriteFile("obscura-article-full-page.png", screenshot.Data, 0o644); err != nil {
panic(err)
}
}
The example deliberately uses Rod’s generated CDP types rather than MustPage(url) and MustScreenshotFullPage(). Those high-level helpers make additional Chromium-specific calls. With the pinned versions, Rod calls the unimplemented Page.stopLoading, injects a load-waiting helper that Obscura cannot execute as expected, and expects integer layout coordinates where Obscura returns values such as 0.0. Calling the underlying supported protocol methods avoids those compatibility assumptions while retaining Rod’s connection, targets, types, and event machinery.
Rod is attractive for a native service or command-line tool because the controller can remain a small Go binary. It offers both higher-level APIs and direct, typed CDP messages. As Obscura’s protocol compatibility grows, more of Rod’s convenience layer may work unchanged. Today, the direct methods give me a path from a successful shell experiment to a durable application without writing and maintaining a browser-control protocol myself.
Playwright and Rod offer different ergonomics, but the architectural value is the same:
Playwright script or Go program
│
│ CDP over WebSocket
▼
Obscura + V8 on the RM2
│
├── DOM and JavaScript
├── session and navigation state
└── full-page rendered pixels
That is the real payoff of getting Obscura onto ARMv7. The reMarkable is no longer limited to one-shot web requests. It can be the browser endpoint for existing automation ecosystems, with Playwright or Rod supplying the higher-level tools around it.
Making the result reproducible
The working code originally lived inside another reMarkable project. I moved the minimum useful pieces into the standalone repository:
- an ARM64 Ubuntu Docker image with the V8 build dependencies;
- a Makefile with normal and rendering targets;
- a pinned Obscura Git submodule;
- the Rust linker wrapper;
- three small, reviewable compatibility patches;
- a build script that applies the patches and packages stripped binaries with checksums.
The official reMarkable SDK is not redistributed. The repository’s setup command downloads the public ARM64-hosted RM2 installer, installs it once in a Docker volume, and verifies the compiler before the long V8 build begins:
git clone --recurse-submodules \
https://github.com/freshteapot/obscura-rm2.git
cd obscura-rm2
make setup
make obscura-rm2-render
There is no placeholder SDK path to fill in. Running make setup again verifies and reuses the installed SDK. The build emits dist/obscura-rm2-render and its SHA-256 file. The patches are tied to the pinned Obscura revision and rusty_v8 version, so upgrading either should be treated as a fresh porting exercise rather than an automatic dependency bump.
A surprisingly capable little machine
The final non-rendering binary was about 43 MB after stripping. It matched the reMarkable 2’s ARM hard-float ABI, used its ld-linux-armhf.so.3 loader, and had no unexpected dynamic dependencies. The rendering build was about 65 MB.
More importantly, this was not only an executable that could display its help text. V8 initialized. JavaScript ran. A remote page became a DOM. The software renderer turned layout, text, and styling into pixels.
There is still a gap between a PNG and an interactive e-ink browser. Input, navigation, display updates, and refresh policy belong to a surrounding application. But the difficult engine now fits on the other side of that boundary.
That opens up much more interesting possibilities than a conventional browser window: rendered study material, printable page captures, web-backed documents, local extraction, and full-page automation on a device that was never intended to host V8 at all.