WebKit Applications - macOS Only
In this chapter we build native macOS desktop applications using Gerbil Scheme and WebKit. The webkit-gerbil library lets you create windows with embedded WKWebView panels, load HTML/CSS/JavaScript UIs, and communicate between Scheme and JavaScript through a bidirectional bridge. This approach gives you the full power of Gerbil Scheme for application logic while using modern web technologies for the user interface.
This is a port of my Common Lisp webkit-cl library (covered in my book Loving Common Lisp). The two projects share the same Objective-C shim and C API, differing only in the FFI bindings and high-level API layer.
Note 1: This library works only on macOS. It requires Gerbil Scheme (with Gambit) and Xcode command-line tools.
Note 2: Because the FFI uses Gambit’s c-declare/c-define, these apps must be compiled as standalone executables via gxc. They cannot be run interactively with gxi.
Architecture Overview
The webkit-gerbil framework is organized in four layers:
- Objective-C shim (
webkit_cl.m): Bridges macOS Cocoa and WebKit APIs to a flat C interface - Gambit FFI bindings (
ffi.ss): Exposes the C functions to Gerbil Scheme via:std/foreign - Bridge dispatch (built into
webkit-gerbil.ss): Manages JS-to-Scheme command dispatch and JSON serialization - High-level API (
webkit-gerbil.ss): Idiomatic Gerbil functions:create-app,load-html,register-handler, etc.
When JavaScript calls window.webkit_cl.invoke("command", payload), the message travels through WKWebView’s script message handler into the C shim, through Gambit’s FFI into Scheme, where a registered handler processes it and returns a JSON response. The response flows back to JavaScript via a Promise.
Prerequisites and Building
You need macOS (Apple Silicon or Intel), Gerbil Scheme (with Gambit), and Xcode command-line tools (for clang and the Cocoa/WebKit frameworks). Build everything with:
1 cd source_code/webkit-gerbil
2 make
This compiles the Objective-C shim into libwebkit_cl.dylib and builds all three example executables:
1 clang -fobjc-arc -fPIC -O2 -Wall -framework Cocoa -framework WebKit \
2 -dynamiclib -install_name @rpath/libwebkit_cl.dylib \
3 -o libwebkit_cl.dylib webkit_cl.m
4 gxc -cc-options "..." -ld-options "..." -exe -o hello-world \
5 ffi.ss webkit-gerbil.ss examples/hello-world.ss
Project Structure
The project contains two core modules and three example applications:
1 webkit-gerbil/
2 Makefile # Build dylib + compile examples
3 webkit_cl.m # Objective-C shim (Cocoa + WKWebView -> C API)
4 webkit_cl.h # C API header
5 ffi.ss # Gambit FFI bindings (c-lambda, c-define)
6 webkit-gerbil.ss # High-level Gerbil API
7 examples/
8 hello-world.ss # Minimal inline HTML example
9 counter-app.ss # Interactive counter with bridge
10 markdown-viewer.ss # Local file viewer via bridge
Unlike the Common Lisp version which uses ASDF, Gerbil’s compilation model is simpler: gxc compiles all source files together into a standalone executable. The Makefile handles the build flags for linking against libwebkit_cl.dylib and the macOS frameworks.
The C Shim
The C header (webkit_cl.h) defines a minimal interface. All functions take an opaque wkcl_app_t handle:
1 typedef void* wkcl_app_t;
2
3 /* Callback for bridge invocations from JavaScript */
4 typedef const char* (*wkcl_bridge_callback_t)(const char* command,
5 const char* payload,
6 void* userdata);
7
8 /* Lifecycle */
9 wkcl_app_t wkcl_create(const char* title, int width, int height);
10 void wkcl_run(wkcl_app_t app);
11 void wkcl_quit(wkcl_app_t app);
12 void wkcl_destroy(wkcl_app_t app);
13
14 /* Content loading */
15 void wkcl_load_html(wkcl_app_t app, const char* html);
16 void wkcl_load_url(wkcl_app_t app, const char* url);
17 void wkcl_load_file(wkcl_app_t app, const char* path);
18
19 /* JavaScript & Bridge */
20 void wkcl_eval_js(wkcl_app_t app, const char* js);
21 void wkcl_set_bridge_callback(wkcl_app_t app,
22 wkcl_bridge_callback_t callback,
23 void* userdata);
24
25 /* Window management */
26 void wkcl_set_title(wkcl_app_t app, const char* title);
27 void wkcl_set_size(wkcl_app_t app, int width, int height);
28 void wkcl_set_resizable(wkcl_app_t app, int resizable);
The Objective-C implementation (webkit_cl.m) creates an NSApplication with a WKWebView inside an NSWindow. The bridge works by injecting a JavaScript snippet at document start that defines window.webkit_cl.invoke(). This function posts messages to a WKScriptMessageHandler, which routes them to the registered C callback. The callback returns a malloc’d JSON string that is sent back to JavaScript via evaluateJavaScript:.
This C API is identical to the one used by the Common Lisp version. Because it compiles to a standalone dynamic library (libwebkit_cl.dylib), the same .m and .h files can be reused by both language bindings without modification.
Gambit FFI Bindings
The FFI layer in ffi.ss maps the C API to Gerbil Scheme using Gambit’s c-lambda, c-define, and c-declare forms via Gerbil’s :std/foreign module. All string parameters use the UTF-8-string FFI type for proper Unicode support:
1 (import :std/foreign)
2
3 (export wkcl-create wkcl-run wkcl-quit wkcl-destroy
4 wkcl-load-html wkcl-load-url wkcl-load-file
5 wkcl-eval-js
6 wkcl-set-title wkcl-set-size wkcl-set-resizable
7 wkcl-install-bridge set-bridge-dispatcher!)
8
9 (begin-ffi (wkcl-create wkcl-run wkcl-quit wkcl-destroy
10 wkcl-load-html wkcl-load-url wkcl-load-file
11 wkcl-eval-js
12 wkcl-set-title wkcl-set-size wkcl-set-resizable
13 wkcl-install-bridge
14 set-bridge-dispatcher!)
15
16 (c-declare #<<'C'
17 #include <stdlib.h>
18 #include <string.h>
19 #include "webkit_cl.h"
20 'C'
21 )
22
23 ;; The dispatcher variable — defined INSIDE begin-ffi
24 ;; so c-define can see it
25 (define *bridge-dispatcher* #f)
26
27 (define (set-bridge-dispatcher! proc)
28 (set! *bridge-dispatcher* proc))
The bridge callback is defined with c-define, which is Gambit’s standard mechanism for creating Scheme functions callable from C. When JavaScript calls window.webkit_cl.invoke(), the C shim calls our scheme_bridge_cb function, which dispatches to the Scheme-level handler:
1 ;; c-define makes a Scheme function callable from C
2 (c-define (scheme-bridge-callback command payload)
3 (UTF-8-string UTF-8-string) UTF-8-string "scheme_bridge_cb" ""
4 (if *bridge-dispatcher*
5 (let ((result (*bridge-dispatcher* command payload)))
6 (if (string? result) result "null"))
7 "null"))
8
9 ;; C wrapper that adapts the wkcl_bridge_callback_t signature
10 (c-declare #<<'C2'
11 static const char* c_bridge_trampoline(const char* command,
12 const char* payload,
13 void* userdata) {
14 const char* result = scheme_bridge_cb((char*)command,
15 (char*)payload);
16 if (result && result[0] != '\0') {
17 return strdup(result);
18 }
19 return NULL;
20 }
21 'C2'
22 )
A key architectural detail: the *bridge-dispatcher* variable and set-bridge-dispatcher! function are defined inside the begin-ffi block. This is essential because c-define body expressions can only resolve identifiers that share the same scope. Defining them outside begin-ffi would cause #!unbound errors at runtime due to Gerbil’s module namespace separation.
The C-to-Scheme data flow requires a C trampoline function (c_bridge_trampoline) because the bridge callback signature includes a void* userdata parameter that c-define cannot directly match. The trampoline calls scheme_bridge_cb, then strdups the result so the Objective-C side can free() it after use.
The remaining FFI definitions are straightforward define-c-lambda wrappers:
1 (define-c-lambda wkcl-create
2 (UTF-8-string int int) (pointer void)
3 "wkcl_create")
4
5 (define-c-lambda wkcl-run
6 ((pointer void)) void
7 "wkcl_run")
8
9 (define-c-lambda wkcl-load-html
10 ((pointer void) UTF-8-string) void
11 "wkcl_load_html")
12
13 (define-c-lambda wkcl-eval-js
14 ((pointer void) UTF-8-string) void
15 "wkcl_eval_js")
16
17 ;; Registers the C trampoline as the bridge callback
18 (define-c-lambda wkcl-install-bridge
19 ((pointer void)) void
20 "wkcl_set_bridge_callback(___arg1,
21 c_bridge_trampoline, NULL);")
22 )
Note the use of UTF-8-string rather than char-string. The standard Gambit char-string type only handles ASCII; UTF-8-string properly converts Scheme strings containing Unicode characters (such as em dashes in window titles or special characters in HTML content).
The Bridge: JS to Scheme Communication
The bridge module in webkit-gerbil.ss maintains a hash table of named command handlers:
1 (def *bridge-handlers* (make-hash-table))
2
3 (def (register-handler command handler-fn)
4 "Register a bridge handler for COMMAND.
5 HANDLER-FN takes one argument: a parsed JSON payload.
6 It should return a JSON string to send back to JavaScript."
7 (hash-put! *bridge-handlers* command handler-fn)
8 command)
9
10 (def (unregister-handler command)
11 "Remove the bridge handler for COMMAND."
12 (hash-remove! *bridge-handlers* command)
13 command)
When JavaScript calls window.webkit_cl.invoke("greet", {name: "World"}), the dispatch function looks up the handler by command name, parses the JSON payload with Gerbil’s :std/text/json, calls the handler, and returns the result:
1 (def (dispatch-bridge-command command payload-json)
2 "Dispatch a bridge command to the registered handler."
3 (let ((handler (hash-get *bridge-handlers* command)))
4 (if handler
5 (with-catch
6 (lambda (e)
7 (format "{\"error\": \"~a\"}"
8 (escape-json-string (format "~a" e))))
9 (lambda ()
10 (let* ((payload (with-catch
11 (lambda (e) #f)
12 (lambda ()
13 (string->json-object payload-json))))
14 (result (handler payload)))
15 (if result result "null"))))
16 (format "{\"error\": \"unknown command: ~a\"}"
17 (escape-json-string command)))))
The double with-catch nesting mirrors the Common Lisp version’s handler-case pattern: one catches JSON parse errors (which are non-fatal, the handler receives #f), the other catches handler execution errors and returns them as JSON error objects to JavaScript.
High-Level API
The main API provides app lifecycle management and convenience functions. The create-app function creates the native window, installs the bridge dispatcher, and returns an app record:
1 (def *current-app* #f)
2
3 (def (create-app title: (title "webkit-gerbil")
4 width: (width 800)
5 height: (height 600))
6 "Create a new webkit-gerbil application."
7 (let* ((handle (wkcl-create title width height))
8 (a (%make-app handle title width height)))
9 (set-bridge-dispatcher! dispatch-bridge-command)
10 (wkcl-install-bridge handle)
11 a))
12
13 (def (app-run a)
14 "Start the event loop (blocks until the window is closed)."
15 (set! *current-app* a)
16 (wkcl-run (app-handle a))
17 (set! *current-app* #f))
18
19 (def (app-destroy a)
20 "Destroy the application and free native resources."
21 (when a
22 (wkcl-destroy (app-handle a))))
Content loading and JavaScript evaluation are thin wrappers around the C API. Each function accepts an optional app argument that defaults to *current-app*:
1 (def (load-html html (a *current-app*))
2 "Load inline HTML content into the WebView."
3 (when a
4 (wkcl-load-html (app-handle a) html)))
5
6 (def (load-url url (a *current-app*))
7 "Navigate the WebView to a URL."
8 (when a
9 (wkcl-load-url (app-handle a) url)))
10
11 (def (eval-js js (a *current-app*))
12 "Evaluate JavaScript in the WebView (fire-and-forget)."
13 (when a
14 (wkcl-eval-js (app-handle a) js)))
The json-response convenience function builds JSON strings from key/value pairs using Gerbil’s :std/text/json:
1 (def (json-response . pairs)
2 "Build a JSON object string from key/value pairs.
3 Example: (json-response \"message\" \"hello\" \"count\" 42)"
4 (let ((ht (make-hash-table)))
5 (let loop ((p pairs))
6 (when (and (pair? p) (pair? (cdr p)))
7 (hash-put! ht (car p) (cadr p))
8 (loop (cddr p))))
9 (json-object->string ht)))
Example 1: Hello World
The simplest webkit-gerbil app loads inline HTML into a native window. Each example exports a main function as required by gxc -exe:
1 (import "../webkit-gerbil")
2 (export main)
3
4 (def (main . args)
5 (let ((a (create-app title: "Hello webkit-gerbil"
6 width: 600 height: 400)))
7 (load-html
8 "<!DOCTYPE html>
9 <html>
10 <head>
11 <meta charset='utf-8'>
12 <style>
13 * { margin: 0; padding: 0; box-sizing: border-box; }
14 body {
15 font-family: -apple-system, system-ui, sans-serif;
16 background: linear-gradient(135deg,
17 #0f0c29 0%, #302b63 50%, #24243e 100%);
18 color: #e0e0e0;
19 display: flex;
20 align-items: center;
21 justify-content: center;
22 height: 100vh;
23 }
24 .card {
25 text-align: center;
26 background: rgba(255,255,255,0.05);
27 backdrop-filter: blur(20px);
28 border: 1px solid rgba(255,255,255,0.1);
29 border-radius: 24px;
30 padding: 48px 64px;
31 box-shadow: 0 8px 32px rgba(0,0,0,0.3);
32 }
33 h1 {
34 font-size: 2.5em;
35 background: linear-gradient(90deg, #a78bfa, #60a5fa, #34d399);
36 -webkit-background-clip: text;
37 -webkit-text-fill-color: transparent;
38 margin-bottom: 12px;
39 }
40 p { font-size: 1.1em; color: rgba(255,255,255,0.6); }
41 .badge {
42 display: inline-block; margin-top: 20px;
43 padding: 6px 16px; font-size: 0.85em;
44 background: rgba(167,139,250,0.15);
45 border: 1px solid rgba(167,139,250,0.3);
46 border-radius: 999px; color: #a78bfa;
47 }
48 </style>
49 </head>
50 <body>
51 <div class='card'>
52 <h1>Hello, webkit-gerbil!</h1>
53 <p>A native macOS window powered by Gerbil Scheme<br>
54 and WebKit (WKWebView).</p>
55 <span class='badge'>Gerbil + Gambit + Cocoa + WebKit</span>
56 </div>
57 </body>
58 </html>"
59 a)
60 (app-run a)
61 (app-destroy a)))
Build and run it with:
1 make hello
2 ./hello-world
A native macOS window appears with a gradient background, glassmorphism card, and gradient text, all rendered by the system WebKit engine.
Example 2: Counter App with Bridge
This example demonstrates bidirectional communication. Scheme manages the application state (a counter), and JavaScript provides the UI:
1 (import "../webkit-gerbil")
2 (export main)
3
4 (def *counter* 0)
5
6 (register-handler "increment"
7 (lambda (payload)
8 (set! *counter* (+ *counter* 1))
9 (json-response "count" *counter*)))
10
11 (register-handler "decrement"
12 (lambda (payload)
13 (set! *counter* (- *counter* 1))
14 (json-response "count" *counter*)))
15
16 (register-handler "reset"
17 (lambda (payload)
18 (set! *counter* 0)
19 (json-response "count" *counter*)))
20
21 (register-handler "get-count"
22 (lambda (payload)
23 (json-response "count" *counter*)))
Each handler receives a parsed JSON payload (a hash table from :std/text/json) and returns a JSON string. The JavaScript side calls these handlers through the bridge:
1 async function increment() {
2 const result = await window.webkit_cl.invoke('increment', {});
3 updateDisplay(result.count);
4 }
5
6 async function decrement() {
7 const result = await window.webkit_cl.invoke('decrement', {});
8 updateDisplay(result.count);
9 }
The counter value lives entirely in Scheme – JavaScript only renders it. This pattern cleanly separates application logic (Scheme) from presentation (HTML/CSS/JS).
Build and run it with:
1 make counter
2 ./counter-app
Example 3: Markdown File Viewer
The most complete example demonstrates filesystem access through the bridge. Two handlers let JavaScript list and read files:
1 (register-handler "read-file"
2 (lambda (payload)
3 (let ((path (and payload (hash-get payload 'path))))
4 (if (and path (file-exists? path))
5 (let ((content (read-file-string path)))
6 (let ((ht (make-hash-table)))
7 (hash-put! ht "content" content)
8 (hash-put! ht "path" path)
9 (json-object->string ht)))
10 (json-response "error"
11 (string-append "File not found: "
12 (or path "nil")))))))
13
14 (register-handler "list-files"
15 (lambda (payload)
16 (let* ((dir (or (and payload
17 (hash-get payload 'directory)) "."))
18 (entries (directory-files dir))
19 (md-files (filter
20 (lambda (f)
21 (let ((len (string-length f)))
22 (and (>= len 3)
23 (string=? ".md"
24 (substring f (- len 3) len)))))
25 entries))
26 (full-paths (map (lambda (f)
27 (string-append dir "/" f))
28 md-files)))
29 (let ((ht (make-hash-table)))
30 (hash-put! ht "files" (list->vector full-paths))
31 (json-object->string ht)))))
The read-file handler uses Gerbil’s read-file-string from :std/misc/ports to load file contents, then builds a JSON response manually using a hash table to ensure proper escaping. The list-files handler uses directory-files and filters for .md extensions.
The UI is a split-pane layout with a file sidebar and content area. JavaScript calls the bridge on startup:
1 async function loadFileList() {
2 const result = await window.webkit_cl.invoke('list-files',
3 { directory: '.' });
4 if (result.files && result.files.length > 0) {
5 fileList.innerHTML = result.files.map(f =>
6 '<div class="file-item" onclick="loadFile(\'' + f + '\')">' +
7 '<div class="name">' + basename(f) + '</div>' +
8 '</div>'
9 ).join('');
10 }
11 }
12
13 async function loadFile(path) {
14 const result = await window.webkit_cl.invoke('read-file',
15 { path: path });
16 if (result.content) {
17 content.innerHTML = '<pre>' + escapeHtml(result.content)
18 + '</pre>';
19 }
20 }
Build and run it with:
1 make viewer
2 ./markdown-viewer
This opens a native window with a dark sidebar listing .md files from the current directory. Clicking a file reads its content via the Scheme bridge and displays it in a styled code panel.
The Build System
The Makefile compiles the Objective-C shim and links the Gerbil source files into standalone executables:
1 CC = clang
2 CFLAGS = -fobjc-arc -fPIC -O2 -Wall
3 FRAMEWORKS = -framework Cocoa -framework WebKit
4 DYNLIB_LDFLAGS = -dynamiclib \
5 -install_name @rpath/libwebkit_cl.dylib
6
7 GERBIL_CC_OPTS = -I$(CURDIR)
8 GERBIL_LD_OPTS = -L$(CURDIR) -lwebkit_cl \
9 -Wl,-rpath,@loader_path \
10 -framework Cocoa -framework WebKit
11
12 $(DYLIB): $(SRC_M) webkit_cl.h
13 $(CC) $(CFLAGS) $(FRAMEWORKS) $(DYNLIB_LDFLAGS) -o $@ $<
14
15 hello: $(DYLIB)
16 gxc -cc-options "$(GERBIL_CC_OPTS)" \
17 -ld-options "$(GERBIL_LD_OPTS)" \
18 -exe -o hello-world \
19 ffi.ss webkit-gerbil.ss examples/hello-world.ss
Each example target passes all three .ss files to gxc together. The -exe flag produces a standalone executable that statically links the Gambit runtime. The -cc-options flag provides the include path for webkit_cl.h, and -ld-options links against libwebkit_cl.dylib with an @loader_path rpath so the executable can find the dylib at runtime.
Comparing Common Lisp and Gerbil Versions
The Common Lisp and Gerbil versions share the same architecture and C shim but differ in FFI mechanics:
| Aspect | Common Lisp (webkit-cl) | Gerbil (webkit-gerbil) |
|---|---|---|
| FFI system | CFFI | Gambit begin-ffi / :std/foreign
|
| Callback | cffi:defcallback |
c-define (inside begin-ffi) |
| String type |
:string (CFFI) |
UTF-8-string (Gambit) |
| JSON | cl-json |
:std/text/json |
| Build | ASDF + make (dylib only) |
gxc -exe + make (full executable) |
| Execution |
sbcl --load (interactive) |
Standalone executable only |
| App lifecycle |
with-app macro |
create-app / app-run / app-destroy
|
The most significant difference is in callback scoping: CFFI’s defcallback naturally lives in the package namespace, while Gambit’s c-define requires the dispatcher variable to be defined inside the same begin-ffi block to avoid cross-module resolution issues.
API Reference Summary
The webkit-gerbil public API:
| Function | Description |
|---|---|
(create-app title: width: height:) |
Create app with native window |
(app-run app) |
Enter event loop (blocks until window close) |
(app-quit app) |
Request the application to quit |
(app-destroy app) |
Free native resources |
(load-html html) |
Load inline HTML string |
(load-url url) |
Navigate to a URL |
(load-file path) |
Load a local HTML file |
(eval-js js-string) |
Evaluate JavaScript (fire-and-forget) |
(register-handler name fn) |
Register a bridge command handler |
(unregister-handler name) |
Remove a bridge command handler |
(json-response key val ...) |
Build JSON string from key/value pairs |
(set-title title) |
Change window title |
(set-size width height) |
Resize window |
(set-resizable flag) |
Toggle window resizability |
From JavaScript, call Scheme handlers with:
1 const result = await window.webkit_cl.invoke("command-name",
2 {key: "value"});
Key Takeaways
- Gambit FFI + Objective-C – Gerbil Scheme can drive native macOS frameworks through a thin C shim compiled as a dynamic library, using
c-lambdaandc-definefrom:std/foreign - WKWebView – The system WebKit engine provides a modern, full-featured rendering surface without bundling a browser
- Bridge pattern – Named command handlers with JSON message passing cleanly separate Scheme logic from JavaScript UI
c-definescoping – Bridge callback variables must be defined insidebegin-ffito be visible toc-definebodies; this is the key Gerbil-specific lesson from this projectUTF-8-string– Always useUTF-8-stringinstead ofchar-stringin FFI bindings when Unicode text is possible- Code reuse – The same C/Objective-C shim serves both Common Lisp and Gerbil Scheme, demonstrating the value of a clean C API as a language-agnostic boundary
This framework demonstrates that Gerbil Scheme can build polished desktop applications. The web rendering layer handles the visual complexity while Scheme provides the computational backbone – a productive division of labor for tools, dashboards, and data viewers.
Optional Practice Problems
- Interactive Click Counter: Create a small application where clicking a button in HTML sends a JSON message to increment a counter variable in Scheme, and then updates the DOM with the updated value.
- Error Logging Bridge: Implement a bridge handler that allows the web view’s JavaScript execution context to log stack traces and uncaught exceptions directly to the Gerbil Scheme stdout or a log file.
- Dynamic Window Controls: Add FFI functions and Scheme wrappers in
webkit-gerbil.ssto dynamically resize the window or change its title from Scheme code after the app has started running.