<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>V8 on Chris — freshteapot</title><link>https://freshteapot.net/tags/v8/</link><description>Chris makes things, ships them, and writes down what happened.</description><generator>Hugo 0.163.3</generator><language>en</language><atom:link href="https://freshteapot.net/tags/v8/" rel="self" type="application/rss+xml"/><item><title>Building a JavaScript headless browser for the reMarkable 2</title><link>https://freshteapot.net/writing/building-obscura-for-remarkable-2/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0000</pubDate><guid>https://freshteapot.net/writing/building-obscura-for-remarkable-2/</guid><description>How I cross-compiled Obscura and V8 for the reMarkable 2's 32-bit ARM processor, then rendered a real webpage to PNG.</description><content:encoded><![CDATA[<p>I wanted to know whether the reMarkable 2 could run a modern JavaScript page without turning the tablet into a general-purpose desktop computer.</p>
<p><a href="https://github.com/h4ckf0r0day/obscura">Obscura</a> 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.</p>
<p>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.</p>
<p>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.</p>
<p>I extracted the build into a standalone <a href="https://github.com/freshteapot/obscura-rm2">Obscura for reMarkable 2 repository</a>. It pins the Obscura source and contains the Docker image, patches, and scripts needed to reproduce the result.</p>
<h2 id="a-headless-browser-not-a-tablet-browser">A headless browser, not a tablet browser</h2>
<p>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.</p>
<p>On the reMarkable it can:</p>
<ul>
<li>fetch pages and execute JavaScript;</li>
<li>query or extract the resulting DOM;</li>
<li>serve Chrome DevTools Protocol and MCP clients;</li>
<li>lay out a page with a pure-Rust software renderer;</li>
<li>generate viewport-sized or full-page screenshots.</li>
</ul>
<p>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.</p>
<h2 id="the-architecture-gap">The architecture gap</h2>
<p>The official reMarkable toolchain targets Cortex-A7 with NEON and hardware floating point. In Rust terms, the closest standard target is:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">armv7-unknown-linux-gnueabihf
</span></span></code></pre></div><p>Rust itself handles that target well. The difficult dependency was V8, embedded by Obscura through <code>deno_core</code> and <code>rusty_v8</code>.</p>
<p>V8&rsquo;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:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">ARM64 host tools run inside Docker
</span></span><span class="line"><span class="cl">              │
</span></span><span class="line"><span class="cl">              ├── build-time generators
</span></span><span class="line"><span class="cl">              │
</span></span><span class="line"><span class="cl">              └── cross-compile V8 and Obscura
</span></span><span class="line"><span class="cl">                                │
</span></span><span class="line"><span class="cl">                                ▼
</span></span><span class="line"><span class="cl">                    ARMv7 hard-float binary
</span></span><span class="line"><span class="cl">                    for the reMarkable 2
</span></span></code></pre></div><p>The ARM64 host matters because I was building on Apple Silicon with an ARM64 Ubuntu container. The target remains the reMarkable&rsquo;s 32-bit ARM environment.</p>
<h2 id="building-v8-from-source">Building V8 from source</h2>
<p>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 <code>rusty_v8</code> to compile V8 from source:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">V8_FROM_SOURCE</span><span class="o">=</span><span class="m">1</span>
</span></span><span class="line"><span class="cl">cargo build <span class="se">\
</span></span></span><span class="line"><span class="cl">  --release <span class="se">\
</span></span></span><span class="line"><span class="cl">  --target armv7-unknown-linux-gnueabihf <span class="se">\
</span></span></span><span class="line"><span class="cl">  -p obscura-cli <span class="se">\
</span></span></span><span class="line"><span class="cl">  --bins <span class="se">\
</span></span></span><span class="line"><span class="cl">  --no-default-features
</span></span></code></pre></div><p>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.</p>
<p>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&rsquo;s registry and the source build tree so each correction could reuse the expensive work.</p>
<p>That cache mattered. The first genuine ARM failure arrived with only six V8 build actions remaining.</p>
<h2 id="finding-the-64-bit-assumptions">Finding the 64-bit assumptions</h2>
<p>The first upstream issue was in <code>rusty_v8</code>&rsquo;s C++ binding. It unconditionally defined a garbage-collected type aligned to 16 bytes. On ARMv7, <code>std::max_align_t</code> is 8 bytes, so the compiler correctly rejected it.</p>
<p>The patch disables only that optional 16-byte allocation branch when building with 32-bit pointers. The ordinary allocation path remains unchanged.</p>
<p>The next failure was a Rust assertion about the alignment of <code>TypeId</code>. <code>TypeId</code> 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.</p>
<p>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&rsquo;s GCC C++ headers with V8&rsquo;s libc++ headers and produced a rather misleading wall of errors.</p>
<p>Once those pieces were aligned, <code>librusty_v8.a</code> built for ARMv7 and the rest of Obscura&rsquo;s Rust dependency graph compiled normally.</p>
<h2 id="the-binary-that-started-but-could-not-browse">The binary that started but could not browse</h2>
<p>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.</p>
<p>It could print <code>--help</code>. Then I asked it to fetch a page, and V8 rejected its embedded startup snapshot.</p>
<p>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&rsquo;s world and then embedded in a 32-bit executable.</p>
<p>I tried the obvious paths first. The ARM-native <code>mksnapshot</code> 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&rsquo;s emulation.</p>
<p>The better answer was to stop requiring a snapshot.</p>
<h2 id="bootstrapping-javascript-at-runtime">Bootstrapping JavaScript at runtime</h2>
<p>Obscura&rsquo;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 <code>bootstrap.js</code> directly after registering the runtime state its operations need.</p>
<p>In simplified form:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-rust" data-lang="rust"><span class="line"><span class="cl"><span class="cp">#[cfg(target_pointer_width = </span><span class="s">&#34;32&#34;</span><span class="cp">)]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">static</span><span class="w"> </span><span class="no">BOOTSTRAP</span>: <span class="kp">&amp;</span><span class="kt">str</span> <span class="o">=</span><span class="w"> </span><span class="fm">include_str!</span><span class="p">(</span><span class="s">&#34;../js/bootstrap.js&#34;</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">// Create the runtime and register Obscura&#39;s operation state first.
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="cp">#[cfg(target_pointer_width = </span><span class="s">&#34;32&#34;</span><span class="cp">)]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="n">runtime</span><span class="p">.</span><span class="n">execute_script</span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="s">&#34;&lt;obscura:bootstrap&gt;&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="no">BOOTSTRAP</span><span class="p">.</span><span class="n">to_string</span><span class="p">(),</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">)</span><span class="o">?</span><span class="p">;</span><span class="w">
</span></span></span></code></pre></div><p>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.</p>
<p>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 <code>deno_core</code>. Moving the call to just after state registration completed the fix.</p>
<p>My first test on the reMarkable itself was Learnalist:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">./obscura fetch <span class="s2">&#34;https://learnalist.net&#34;</span> --eval <span class="s2">&#34;document.title&#34;</span>
</span></span><span class="line"><span class="cl">Fetching https://learnalist.net...
</span></span><span class="line"><span class="cl">Page loaded: https://learnalist.net/ - <span class="s2">&#34;Learnalist | Save What Matters And See It Again&#34;</span>
</span></span></code></pre></div><p>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.</p>
<h2 id="adding-rendering">Adding rendering</h2>
<p>I deliberately started without Obscura&rsquo;s optional renderer. Proving JavaScript and DOM operation first kept native graphics libraries out of the investigation.</p>
<p>Once that worked, enabling rendering was pleasantly uneventful:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">cargo build <span class="se">\
</span></span></span><span class="line"><span class="cl">  --release <span class="se">\
</span></span></span><span class="line"><span class="cl">  --target armv7-unknown-linux-gnueabihf <span class="se">\
</span></span></span><span class="line"><span class="cl">  -p obscura-cli <span class="se">\
</span></span></span><span class="line"><span class="cl">  --bins <span class="se">\
</span></span></span><span class="line"><span class="cl">  --features render <span class="se">\
</span></span></span><span class="line"><span class="cl">  --no-default-features
</span></span></code></pre></div><p>Obscura&rsquo;s renderer is CPU-only and written in Rust. It does not need Qt, a GPU, <code>xochitl</code>, 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.</p>
<p>My first test of the render build returned to Learnalist and added a screenshot:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">./obscura fetch <span class="s2">&#34;https://learnalist.net&#34;</span> --eval <span class="s2">&#34;document.title&#34;</span> --screenshot page.png
</span></span><span class="line"><span class="cl">Fetching https://learnalist.net...
</span></span><span class="line"><span class="cl">Page loaded: https://learnalist.net/ - <span class="s2">&#34;Learnalist | Save What Matters And See It Again&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">{</span><span class="s2">&#34;evaluation&#34;</span>:<span class="s2">&#34;Learnalist | Save What Matters And See It Again&#34;</span>,<span class="s2">&#34;controlledScroll&#34;</span>:null,<span class="s2">&#34;resourceWarmup&#34;</span>:<span class="o">{</span><span class="s2">&#34;performed&#34;</span>:false,<span class="s2">&#34;discardedShots&#34;</span>:0,<span class="s2">&#34;taskTurnMs&#34;</span>:0,<span class="s2">&#34;phase&#34;</span>:<span class="s2">&#34;before-final-scroll-reassert-and-state-sample&#34;</span><span class="o">}</span>,<span class="s2">&#34;captureState&#34;</span>:<span class="o">{</span><span class="s2">&#34;scrollX&#34;</span>:0,<span class="s2">&#34;scrollY&#34;</span>:0,<span class="s2">&#34;innerWidth&#34;</span>:1280,<span class="s2">&#34;innerHeight&#34;</span>:720,<span class="s2">&#34;scrollWidth&#34;</span>:1280,<span class="s2">&#34;scrollHeight&#34;</span>:1883<span class="o">}}</span>
</span></span><span class="line"><span class="cl">Screenshot written: page.png <span class="o">(</span><span class="m">53653</span> bytes<span class="o">)</span>
</span></span></code></pre></div><p>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.</p>
<p>The shorter form, when no evaluation is needed, is:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">./obscura fetch https://example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --screenshot page.png <span class="se">\
</span></span></span><span class="line"><span class="cl">  --wait <span class="m">0</span>
</span></span></code></pre></div><p>For a viewport matching the full portrait display:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="nv">OBSCURA_SHOT_W</span><span class="o">=</span><span class="m">1404</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="nv">OBSCURA_SHOT_H</span><span class="o">=</span><span class="m">1872</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">./obscura fetch https://example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --screenshot page.png <span class="se">\
</span></span></span><span class="line"><span class="cl">  --wait <span class="m">0</span>
</span></span></code></pre></div><p>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.</p>
<h2 id="cdp-turns-the-port-into-a-platform">CDP turns the port into a platform</h2>
<p>The command-line screenshot proved the renderer worked, but Obscura&rsquo;s support for the <a href="https://chromedevtools.github.io/devtools-protocol/">Chrome DevTools Protocol</a> is where the port becomes much more valuable.</p>
<p>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.</p>
<p>Start the server on the reMarkable with:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">./obscura serve --port <span class="m">9222</span> --host <span class="s1">&#39;10.11.99.1&#39;</span>
</span></span></code></pre></div><p>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.</p>
<h3 id="controlling-it-with-playwright">Controlling it with Playwright</h3>
<p><a href="https://playwright.dev/docs/api/class-browsertype#browser-type-connect-over-cdp">Playwright can attach to an existing CDP endpoint</a>. That means the familiar Playwright API can control the copy of Obscura running on the tablet:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">chromium</span> <span class="p">}</span> <span class="nx">from</span> <span class="s2">&#34;playwright-core&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">remarkableTablet</span> <span class="o">=</span> <span class="s2">&#34;10.11.99.1&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">browser</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">chromium</span><span class="p">.</span><span class="nx">connectOverCDP</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="sb">`ws://</span><span class="si">${</span><span class="nx">remarkableTablet</span><span class="si">}</span><span class="sb">:9222`</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">context</span> <span class="o">=</span> <span class="nx">browser</span><span class="p">.</span><span class="nx">contexts</span><span class="p">()[</span><span class="mi">0</span><span class="p">];</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">page</span> <span class="o">=</span> <span class="nx">context</span><span class="p">.</span><span class="nx">pages</span><span class="p">()[</span><span class="mi">0</span><span class="p">]</span> <span class="o">??</span> <span class="kr">await</span> <span class="nx">context</span><span class="p">.</span><span class="nx">newPage</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;https://freshteapot.net/writing/building-obscura-for-remarkable-2/&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">screenshot</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">path</span><span class="o">:</span> <span class="s2">&#34;obscura-article-full-page.png&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">fullPage</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span>
</span></span></code></pre></div><p>Here, <code>fullPage: true</code> becomes a CDP <code>Page.captureScreenshot</code> 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.</p>
<p>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.</p>
<p>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.</p>
<h3 id="controlling-it-from-go-with-rod">Controlling it from Go with Rod</h3>
<p>For a Go application, <a href="https://github.com/go-rod/rod">Rod</a> 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.</p>
<p>The shape of the same full-page capture is compact:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="kn">package</span><span class="w"> </span><span class="nx">main</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kn">import</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="s">&#34;os&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="s">&#34;github.com/go-rod/rod&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="s">&#34;github.com/go-rod/rod/lib/proto&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">const</span><span class="w"> </span><span class="nx">remarkableTablet</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">&#34;10.11.99.1&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="nf">main</span><span class="p">()</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="nx">browser</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">rod</span><span class="p">.</span><span class="nf">New</span><span class="p">().</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">		</span><span class="nf">ControlURL</span><span class="p">(</span><span class="s">&#34;ws://&#34;</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="nx">remarkableTablet</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="s">&#34;:9222&#34;</span><span class="p">).</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">		</span><span class="nf">MustConnect</span><span class="p">()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="k">defer</span><span class="w"> </span><span class="nx">browser</span><span class="p">.</span><span class="nf">MustClose</span><span class="p">()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="nx">page</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">browser</span><span class="p">.</span><span class="nf">MustPage</span><span class="p">()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="nx">navigation</span><span class="p">,</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">proto</span><span class="p">.</span><span class="nx">PageNavigate</span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">		</span><span class="nx">URL</span><span class="p">:</span><span class="w"> </span><span class="s">&#34;https://freshteapot.net/writing/building-obscura-for-remarkable-2/&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="p">}.</span><span class="nf">Call</span><span class="p">(</span><span class="nx">page</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="k">if</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="kc">nil</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">		</span><span class="nb">panic</span><span class="p">(</span><span class="nx">err</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="k">if</span><span class="w"> </span><span class="nx">navigation</span><span class="p">.</span><span class="nx">ErrorText</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="s">&#34;&#34;</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">		</span><span class="nb">panic</span><span class="p">(</span><span class="nx">navigation</span><span class="p">.</span><span class="nx">ErrorText</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="nx">screenshot</span><span class="p">,</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">proto</span><span class="p">.</span><span class="nx">PageCaptureScreenshot</span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">		</span><span class="nx">Format</span><span class="p">:</span><span class="w">                </span><span class="nx">proto</span><span class="p">.</span><span class="nx">PageCaptureScreenshotFormatPng</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">		</span><span class="nx">CaptureBeyondViewport</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="p">}.</span><span class="nf">Call</span><span class="p">(</span><span class="nx">page</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="k">if</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="kc">nil</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">		</span><span class="nb">panic</span><span class="p">(</span><span class="nx">err</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="k">if</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">os</span><span class="p">.</span><span class="nf">WriteFile</span><span class="p">(</span><span class="s">&#34;obscura-article-full-page.png&#34;</span><span class="p">,</span><span class="w"> </span><span class="nx">screenshot</span><span class="p">.</span><span class="nx">Data</span><span class="p">,</span><span class="w"> </span><span class="mi">0</span><span class="nx">o644</span><span class="p">);</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="kc">nil</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">		</span><span class="nb">panic</span><span class="p">(</span><span class="nx">err</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>The example deliberately uses Rod&rsquo;s generated CDP types rather than <code>MustPage(url)</code> and <code>MustScreenshotFullPage()</code>. Those high-level helpers make additional Chromium-specific calls. With the pinned versions, Rod calls the unimplemented <code>Page.stopLoading</code>, injects a load-waiting helper that Obscura cannot execute as expected, and expects integer layout coordinates where Obscura returns values such as <code>0.0</code>. Calling the underlying supported protocol methods avoids those compatibility assumptions while retaining Rod&rsquo;s connection, targets, types, and event machinery.</p>
<p>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&rsquo;s protocol compatibility grows, more of Rod&rsquo;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.</p>
<p>Playwright and Rod offer different ergonomics, but the architectural value is the same:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Playwright script or Go program
</span></span><span class="line"><span class="cl">              │
</span></span><span class="line"><span class="cl">              │ CDP over WebSocket
</span></span><span class="line"><span class="cl">              ▼
</span></span><span class="line"><span class="cl">      Obscura + V8 on the RM2
</span></span><span class="line"><span class="cl">              │
</span></span><span class="line"><span class="cl">              ├── DOM and JavaScript
</span></span><span class="line"><span class="cl">              ├── session and navigation state
</span></span><span class="line"><span class="cl">              └── full-page rendered pixels
</span></span></code></pre></div><p>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.</p>
<h2 id="making-the-result-reproducible">Making the result reproducible</h2>
<p>The working code originally lived inside another reMarkable project. I moved the minimum useful pieces into the <a href="https://github.com/freshteapot/obscura-rm2">standalone repository</a>:</p>
<ul>
<li>an ARM64 Ubuntu Docker image with the V8 build dependencies;</li>
<li>a Makefile with normal and rendering targets;</li>
<li>a pinned Obscura Git submodule;</li>
<li>the Rust linker wrapper;</li>
<li>three small, reviewable compatibility patches;</li>
<li>a build script that applies the patches and packages stripped binaries with checksums.</li>
</ul>
<p>The official reMarkable SDK is not redistributed. The repository&rsquo;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:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git clone --recurse-submodules <span class="se">\
</span></span></span><span class="line"><span class="cl">  https://github.com/freshteapot/obscura-rm2.git
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> obscura-rm2
</span></span><span class="line"><span class="cl">make setup
</span></span><span class="line"><span class="cl">make obscura-rm2-render
</span></span></code></pre></div><p>There is no placeholder SDK path to fill in. Running <code>make setup</code> again verifies and reuses the installed SDK. The build emits <code>dist/obscura-rm2-render</code> and its SHA-256 file. The patches are tied to the pinned Obscura revision and <code>rusty_v8</code> version, so upgrading either should be treated as a fresh porting exercise rather than an automatic dependency bump.</p>
<h2 id="a-surprisingly-capable-little-machine">A surprisingly capable little machine</h2>
<p>The final non-rendering binary was about 43 MB after stripping. It matched the reMarkable 2&rsquo;s ARM hard-float ABI, used its <code>ld-linux-armhf.so.3</code> loader, and had no unexpected dynamic dependencies. The rendering build was about 65 MB.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
]]></content:encoded></item></channel></rss>