pharo-webview

Description

A real Chromium browser (via CEF) running as a Morphic morph in Pharo — GPU-composited, scriptable, with JS↔Pharo callbacks. macOS/arm64.

Details

Source
GitHub
Dialect
pharo (25% confidence)
Stars
5
Created
July 15, 2026
Updated
July 30, 2026

Categories

UI / Graphics IDE / Dev Tools System / OS

README excerpt

# 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`).

![A live Wikipedia page rendered as a Pharo morph with a working toolbar](docs/feature-browser-widget.png)

> ✅ **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 Pharo morph sitting on top of a live browser page](docs/feature-zorder.png)

### 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.

![A dropdown menu overlaying a live web page](docs/feature-menu-overlay.png)

### Run JavaScript & edit the DOM from Pharo

This banner and the recoloured headings were injected into the live page from
Pharo with `webview evalJs: '…'`.

![A banner injected into a live page from Pharo via evalJs](docs/feature-scripting.png)

### Many browsers at once

Independent browser morphs, each with its own toolbar and its own `cef_host`
process, running side by side.

![Two independent browser morphs side by side](docs/feature-multi-browser.png)

---

## 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 ].
← Back to results