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:

  1. Objective-C shim (webkit_cl.m): Bridges macOS Cocoa and WebKit APIs to a flat C interface
  2. Gambit FFI bindings (ffi.ss): Exposes the C functions to Gerbil Scheme via :std/foreign
  3. Bridge dispatch (built into webkit-gerbil.ss): Manages JS-to-Scheme command dispatch and JSON serialization
  4. 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

  1. Gambit FFI + Objective-C – Gerbil Scheme can drive native macOS frameworks through a thin C shim compiled as a dynamic library, using c-lambda and c-define from :std/foreign
  2. WKWebView – The system WebKit engine provides a modern, full-featured rendering surface without bundling a browser
  3. Bridge pattern – Named command handlers with JSON message passing cleanly separate Scheme logic from JavaScript UI
  4. c-define scoping – Bridge callback variables must be defined inside begin-ffi to be visible to c-define bodies; this is the key Gerbil-specific lesson from this project
  5. UTF-8-string – Always use UTF-8-string instead of char-string in FFI bindings when Unicode text is possible
  6. 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

  1. 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.
  2. 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.
  3. Dynamic Window Controls: Add FFI functions and Scheme wrappers in webkit-gerbil.ss to dynamically resize the window or change its title from Scheme code after the app has started running.