A real Chromium browser (via CEF) running as a Morphic morph in Pharo — GPU-composited, scriptable, with JS↔Pharo callbacks. macOS/arm64.
# pharo-webview
**A real web browser — full Chromium, via CEF — running as a first-class widget inside Pharo, in both Morphic and Bloc.**
Not a screenshot, not an embedded native window bolted on top: the page is
composited into the Pharo world from raw BGRA frames over shared memory (no PNG,
no per-frame decode). It behaves like any other widget — you can drag it, stack
things over it, put it in a window, script it, and get callbacks back from the
page's JavaScript. The same shared-memory bridge drives a **Morphic morph**
(`WebViewCefMorph`) and a **Bloc element** (`WvBlocView`).

> ✅ **Platforms: macOS (Apple Silicon / arm64) and Windows (x64).** Both build
> and run. All Pharo code is shared and branches on OS at runtime — never on
> architecture — so **Windows-ARM is almost entirely a native-build task**
> (pick the arm64 CEF dist + toolchain). See
> [`docs/plattform-strategi.md`](https://github.com/pegesund/leserom/blob/main/docs/plattform-strategi.md)
> in the leserom repo for the full mac/win/win-arm strategy. Linux is not done
> yet — help welcome.
---
## Contents
- [What you get](#what-you-get)
- [Features in pictures](#features-in-pictures)
- [Quick start](#quick-start) — [build the host](#1-build-the-cef-host-macos-arm64), [load the packages](#2-load-the-pharo-packages-pharo-14), [open a browser](#3-open-a-browser), [host it in Bloc](#4-or-host-it-in-bloc)
- [Scripting the page from Pharo](#scripting-the-page-from-pharo) — run JS, edit the DOM
- [Callbacks from the page](#callbacks-from-the-page) — page events + `window.pharo.emit`
- [Browser automation, the Smalltalk way](#browser-automation-the-smalltalk-way) — the `WvPage` DSL, [assertions & tests](#assertions--tests), [screenshots](#screenshots)
- [How it works](#how-it-works) — architecture & design decisions
- [Repository layout](#repository-layout)
- [Status & limitations](#status--limitations)
- [Credits](#credits)
---
## What you get
- 🌐 **Real Chromium** — modern JS, CSS, video, fonts, HTTPS. It's CEF 138 running
windowless (offscreen), not a toy renderer.
- 🧩 **A true morph** — lives in the Morphic world, composited every frame. Drag
it, resize it, embed it in your own widgets.
- 🪟 **Correct z-order** — morphs (menus, panels, halos) can sit **on top of** the
live page; bring the browser to front and it covers them again.
- ⌨️ **Full input** — mouse click / move / scroll, keyboard, focus, resize, and
browser navigation (back / forward / reload / address).
- 🔁 **Multiple simultaneous browsers** — each is its own morph and its own
isolated process; a tabbed multi-view example is included.
- 📜 **Scriptable from Pharo** — run arbitrary JavaScript, manipulate the DOM, and
get the expression's value back as a Pharo object.
- 📣 **Callbacks from the page** — page-load / title / error events, plus a
`window.pharo.emit(name, data)` bridge so page JS can push structured data
straight into your Pharo blocks.
- 🤖 **Automation & test DSL** — `WvPage` lets you script pages in linear
Smalltalk (`goto:`, `fill:with:`, `submit:`, `textOf:`, `count:` …) and assert
on them (`assertText:equals:`, `assertCount:atLeast:` …) with a PASS/FAIL
test runner that saves a PNG screenshot of the page on failure.
---
## Features in pictures
### Morphs compose over the live browser (z-order)
A plain Pharo `Morph` dropped on top of a live Chromium page. The browser stays
composited underneath, per-frame, respecting the real Morphic stacking order.

### A dropdown menu that overlays the page
`WebViewMenuBrowserMorph` — a browser widget with a "Steder" menu built from
morphs. The dropdown draws over the live page, and the bar shows the page title
live via an `onTitleChange:` callback.

### Run JavaScript & edit the DOM from Pharo
This banner and the recoloured headings were injected into the live page from
Pharo with `webview evalJs: '…'`.

### Many browsers at once
Independent browser morphs, each with its own toolbar and its own `cef_host`
process, running side by side.

---
## Quick start
### 1. Build the CEF host (macOS, arm64)
**Prerequisites:** Xcode command-line tools, `cmake`, `ninja`.
```bash
git clone https://github.com/pegesund/pharo-webview.git
cd pharo-webview
scripts/fetch-cef.sh # download the pinned CEF 138 distribution (~200 MB)
scripts/build-cef-host.sh # build + ad-hoc code-sign cef_host.app
```
This produces `cef_host/build/cef_host.app`. Ad-hoc signing is enough for local
use; distributing to other Macs needs Developer ID signing + notarization (see
[`docs/DISTRIBUTION.md`](docs/DISTRIBUTION.md)).
### 2. Load the Pharo packages (Pharo 14)
```smalltalk
Metacello new
repository: 'github://pegesund/pharo-webview:main/src';
baseline: 'WebView';
load.
```
Or, working from a local clone, add it in Iceberg (the source directory is
`src/`) and load the `WebView-*` packages.
### 3. Open a browser
```smalltalk
WebViewFormRenderer install. "activate the compositing renderer (once)"
WebViewBrowserMorph new openInWorld. "a browser widget: toolbar + address bar"
WebViewTabbedBrowserMorph new openInWorld. "a tabbed, multi-view browser"
WebViewMenuBrowserMorph open. "the 'Steder' dropdown-menu demo (z-order)"
```
Each morph launches and kills its own `cef_host` process. Point Pharo at a
specific binary with the `WV_CEF_HOST` environment variable or
`WebViewCefMorph binaryPath:`. It defaults to `cef_host/build/cef_host.app`
(macOS) or `cef_host/build/Release/cef_host.exe` (Windows).
### 4. …or host it in Bloc
The same browser runs as a `BlElement` — the Bloc counterpart to the Morphic
morph. Load the optional `bloc` group (pulls in Bloc) and open a window:
```smalltalk
"load the Bloc host (Morphic users can skip this — Bloc stays out of #default)"
Metacello new
repository: 'github://pegesund/pharo-webview:main/src';
baseline: 'WebView';
load: 'bloc'.
WvBlocView openOn: 'https://no.wikipedia.org/wiki/Lesing'.
```
`WvBlocView` (package `WebView-Bloc`) is a self-contained `BlElement`: a
background render loop blits each new CEF frame from shared memory onto the UI
thread, and mouse / wheel / keyboard / resize events are forwarded to `cef_host`.
It reuses `WebViewCefMorph` for the process + command channel, so scripting,
callbacks and everything below work identically. Same code on macOS and Windows.
---
## Scripting the page from Pharo
Push JavaScript into the page (an `eval` command → `CefFrame::ExecuteJavaScript`
in the main frame):
```smalltalk
"fire-and-forget — mutate the DOM"
webview evalJs: 'document.body.style.background = "#ffe000"'.
webview evalJs: 'document.querySelector("h1").textContent = "Hei fra Pharo"'.
"expression -> value back in Pharo (delivered on the UI process)"
webview evalJs: '2 + 40' then: [ :r | r "=> 42" ].
webview evalJs: 'document.title' then: [ :r | r "=> a String" ].
webview evalJs: 'JSON.parse(localStorage.foo || "{}")' then: [ :r | r "=> a Dictionary" ].
"multiple statements: use a function expression that returns"
webview
evalJs: '(function(){ document.body.insertAdjacentHTML("afterbegin","<h2>hi</h2>");
return document.querySelectorAll("h2").length })()'
then: [ :n | n "=> the count" ].
```
`evalJs:then:` ships the expression's result back through the `window.pharo`
bridge; JS numbers, strings, booleans, arrays and objects arrive as the matching
Pharo objects (objects → `Dictionary`). JS exceptions are caught and logged.
Commands are JSON-escaped, so the JS may contain quotes, braces and newlines
freely.
## Callbacks from the page
`cef_host` writes a reverse event channel that `WebViewCefMorph` tails and
dispatches to blocks you register. Lifecycle events come from CEF's load/display
handlers; page JavaScript can push its own via `window.pharo.emit(name, data)`
(the bridge is injected into every V8 context before page scripts run).
```smalltalk
webview onPageLoad: [ :e | Transcript showln: 'loaded ', (e at: 'url') ].
webview onTitleChange: [ :e | Transcript showln: (e at: 'title') ].
webview onLoadError: [ :e | Transcript showln: 'error ', (e at: 'errorText') ].
webview onJs: [ :e | (e at: 'data') inspect ]. "any emit()"
webview onJs: 'cart' do: [ :e | self updateCart: (e at: 'data') ]. "emit('cart', …)"
"generic: webview onEvent: #loadEnd do: [ :e | … ]"
```
```js
// in the page:
window.pharo.emit('cart', { items: 3, total: 49.9 }); // -> arrives as a Dictionary in Pharo
```
Event kinds: `loadStart`, `loadEnd` (`url`, `httpStatus`), `loadingState`
(`isLoading`, `canGoBack`, `canGoForward`), `loadError`, `title`, and `js`
(`name`, `data`). The block argument is the parsed event `Dictionary`; callbacks
run on the UI process, each wrapped so one failing block can't break stepping.
---
## Browser automation, the Smalltalk way
`WvPage` is a tiny automation DSL on top of a webview. Because page reads travel
through the async JS bridge, an automation script runs in a **background
process** (via `on:script:`) — so reads like `textOf:` return their value
*synchronously* without freezing the UI:
```smalltalk
| m |
m := WvPage openBrowserOn: 'https://en.wikipedia.org'.
WvPage on: m script: [ :page |
page fill: 'input[name=search]' with: 'Smalltalk-80'.
page submit: 'input[name=search]'. "types, submits, waits for the result page"
Transcript showln: (page textOf: '#firstHeading'). "=> 'Smalltalk'"
Transcript showln: (page count: 'a[href^="/wiki/"]') printString ].