WebKit Applications - macOS Only
In this chapter we build native macOS desktop applications using Common Lisp and WebKit. The webkit-cl library lets you create windows with embedded WKWebView panels, load HTML/CSS/JavaScript UIs, and communicate between Lisp and JavaScript through a bidirectional bridge. This approach gives you the full power of Common Lisp for application logic while using modern web technologies for the user interface.
Note 1: This library works only on macOS. It requires SBCL, CFFI, and cl-json.
Note 1: This example was vibe coded with AntiGravity and Claude Opus 4.6.
Architecture Overview
The webkit-cl framework is organized in four layers:
- Objective-C shim (
webkit_cl.m): Bridges macOS Cocoa and WebKit APIs to a flat C interface - CFFI bindings (
webkit-cl-ffi.lisp): Exposes the C functions to Common Lisp - Bridge (
bridge.lisp): Manages JS <—> Lisp command dispatch and JSON serialization - High-level API (
webkit-cl.lisp): Idiomatic Lisp functions:with-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 CFFI into Lisp, 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), SBCL, and Quicklisp with cffi and cl-json installed. Build the native library with:
1 cd src/webkit-cl
2 make
This compiles the Objective-C shim into libwebkit_cl.dylib:
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
Project Structure
The ASDF system definition ties the components together:
1 ;;; webkit-cl.asd — ASDF system definition for webkit-cl
2
3 (asdf:defsystem #:webkit-cl
4 :description "Lightweight WebKit GUI apps for Common Lisp (macOS)"
5 :author "Mark Watson"
6 :license "Apache-2.0"
7 :version "0.1.0"
8 :depends-on (#:cffi #:cl-json)
9 :serial t
10 :components ((:file "package")
11 (:file "webkit-cl-ffi")
12 (:file "bridge")
13 (:file "webkit-cl")))
The package exports the public API:
1 (defpackage #:webkit-cl
2 (:use #:cl)
3 (:export
4 ;; App lifecycle
5 #:with-app
6 #:make-app
7 #:app-run
8 #:app-quit
9 #:app-destroy
10 ;; Content loading
11 #:load-html
12 #:load-url
13 #:load-file
14 ;; JavaScript evaluation
15 #:eval-js
16 ;; Bridge
17 #:register-handler
18 #:unregister-handler
19 ;; Window management
20 #:set-title
21 #:set-size
22 #:set-resizable
23 ;; App accessors
24 #:app-handle
25 #:*current-app*))
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:.
Here is the bridge JavaScript that gets injected into every page:
1 window.webkit_cl = {
2 _callbackId: 0,
3 _callbacks: {},
4 invoke: function(command, payload) {
5 return new Promise(function(resolve, reject) {
6 var id = String(++window.webkit_cl._callbackId);
7 window.webkit_cl._callbacks[id] = {
8 resolve: resolve, reject: reject
9 };
10 var msg = {
11 command: command,
12 payload: JSON.stringify(payload || {}),
13 callbackId: id
14 };
15 window.webkit.messageHandlers.wkcl_bridge
16 .postMessage(msg);
17 });
18 },
19 _resolveCallback: function(id, result) {
20 var cb = window.webkit_cl._callbacks[id];
21 if (cb) {
22 cb.resolve(result);
23 delete window.webkit_cl._callbacks[id];
24 }
25 }
26 };
Each invoke() call returns a Promise. The Objective-C handler calls the C callback, gets a JSON result, and resolves the Promise by evaluating window.webkit_cl._resolveCallback(id, result).
CFFI Bindings
The FFI layer maps the C API to Common Lisp. The library is loaded from the same directory as the source files:
1 (cffi:define-foreign-library libwebkit-cl
2 (:darwin (:or "libwebkit_cl.dylib"
3 (:default "libwebkit_cl")))
4 (t (:default "libwebkit_cl")))
5
6 (defun load-native-library ()
7 "Load the webkit-cl native library."
8 (let* ((this-file (or *compile-file-pathname* *load-pathname*))
9 (lib-dir (when this-file
10 (namestring
11 (make-pathname
12 :directory (pathname-directory this-file))))))
13 (when lib-dir
14 (pushnew (pathname lib-dir)
15 cffi:*foreign-library-directories*
16 :test #'equal))
17 (cffi:use-foreign-library libwebkit-cl)))
18
19 (load-native-library)
The bridge callback is defined with cffi:defcallback. It dispatches to the Lisp-side handler registry:
1 (cffi:defcallback bridge-dispatch :pointer
2 ((command :string) (payload :string) (userdata :pointer))
3 "Master bridge callback that dispatches to registered Lisp handlers."
4 (declare (ignore userdata))
5 (let ((result (dispatch-bridge-command command payload)))
6 (if result
7 (cffi:foreign-string-alloc result)
8 (cffi:null-pointer))))
Note that the callback returns a cffi:foreign-string-alloc’d pointer — the C side will free() it after use.
The Bridge: JS <—> Lisp Communication
The bridge module maintains a hash table of named command handlers:
1 (defvar *bridge-handlers* (make-hash-table :test 'equal)
2 "Hash table mapping command names (strings) to handler functions.")
3
4 (defun register-handler (command handler-fn)
5 "Register a bridge handler for COMMAND.
6 HANDLER-FN takes one argument: the parsed payload (alist from cl-json).
7 It should return a JSON string to send back to JavaScript."
8 (setf (gethash command *bridge-handlers*) handler-fn)
9 command)
10
11 (defun unregister-handler (command)
12 "Remove the bridge handler for COMMAND."
13 (remhash command *bridge-handlers*)
14 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 cl-json, calls the handler, and returns the result:
1 (defun dispatch-bridge-command (command payload-json)
2 "Dispatch a bridge command to the registered handler."
3 (let ((handler (gethash command *bridge-handlers*)))
4 (if handler
5 (handler-case
6 (let* ((payload (handler-case
7 (json:decode-json-from-string payload-json)
8 (error () nil)))
9 (result (funcall handler payload)))
10 (if result result "null"))
11 (error (e)
12 (format nil "{\"error\": \"~a\"}"
13 (substitute-json-chars (format nil "~a" e)))))
14 (format nil "{\"error\": \"unknown command: ~a\"}"
15 (substitute-json-chars command)))))
The double handler-case nesting is deliberate: one catches JSON parse errors (which are non-fatal — the handler receives nil), the other catches handler execution errors and returns them as JSON error objects to JavaScript.
High-Level API
The main API provides a struct-based app object and a convenience macro:
1 (defvar *current-app* nil
2 "The currently active webkit-cl app handle.")
3
4 (defstruct (app (:constructor %make-app-internal))
5 "A webkit-cl application instance."
6 (handle (cffi:null-pointer) :type cffi:foreign-pointer)
7 (title "webkit-cl" :type string)
8 (width 800 :type integer)
9 (height 600 :type integer))
The make-app function creates the native window and installs the bridge callback:
1 (defun make-app (&key (title "webkit-cl") (width 800) (height 600))
2 "Create a new webkit-cl application (does not start the event loop)."
3 (%disable-fp-traps)
4 (let* ((handle (%wkcl-create title width height))
5 (app (%make-app-internal :handle handle
6 :title title
7 :width width
8 :height height)))
9 (%wkcl-set-bridge-callback handle
10 (cffi:callback bridge-dispatch)
11 (cffi:null-pointer))
12 app))
The %disable-fp-traps call is essential — macOS Cocoa/AppKit triggers floating-point operations that conflict with SBCL’s default trap settings.
The with-app macro is the recommended entry point. It creates the app, executes the body (where you register handlers and load content), runs the event loop, and cleans up:
1 (defmacro with-app ((&key (title "webkit-cl") (width 800) (height 600))
2 &body body)
3 "Create a webkit-cl app, execute BODY, then run the event loop.
4 The app is automatically destroyed when the window is closed."
5 (let ((app-var (gensym "APP")))
6 `(let* ((,app-var (make-app :title ,title
7 :width ,width :height ,height))
8 (*current-app* ,app-var))
9 (unwind-protect
10 (progn ,@body
11 (app-run ,app-var))
12 (app-destroy ,app-var)))))
Content loading and JavaScript evaluation are thin wrappers around the C API:
1 (defun load-html (html &optional (app *current-app*))
2 "Load inline HTML content into the WebView."
3 (when app
4 (%wkcl-load-html (app-handle app) html)))
5
6 (defun load-url (url &optional (app *current-app*))
7 "Navigate the WebView to a URL."
8 (when app
9 (%wkcl-load-url (app-handle app) url)))
10
11 (defun load-file (path &optional (app *current-app*))
12 "Load a local HTML file into the WebView."
13 (when app
14 (%wkcl-load-file (app-handle app) (namestring (truename path)))))
15
16 (defun eval-js (js &optional (app *current-app*))
17 "Evaluate JavaScript in the WebView (fire-and-forget)."
18 (when app
19 (%wkcl-eval-js (app-handle app) js)))
Example 1: Hello World
The simplest webkit-cl app loads inline HTML into a native window:
1 (require :asdf)
2 (push (make-pathname :directory (pathname-directory *load-pathname*))
3 asdf:*central-registry*)
4 (asdf:load-system :webkit-cl)
5
6 (webkit-cl:with-app (:title "Hello webkit-cl" :width 600 :height 400)
7 (webkit-cl: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, BlinkMacSystemFont, system-ui, sans-serif;
16 background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
17 color: #e0e0e0;
18 display: flex;
19 align-items: center;
20 justify-content: center;
21 height: 100vh;
22 }
23 .card {
24 text-align: center;
25 background: rgba(255,255,255,0.05);
26 backdrop-filter: blur(20px);
27 border: 1px solid rgba(255,255,255,0.1);
28 border-radius: 24px;
29 padding: 48px 64px;
30 box-shadow: 0 8px 32px rgba(0,0,0,0.3);
31 }
32 h1 {
33 font-size: 2.5em;
34 background: linear-gradient(90deg, #a78bfa, #60a5fa, #34d399);
35 -webkit-background-clip: text;
36 -webkit-text-fill-color: transparent;
37 margin-bottom: 12px;
38 }
39 p { font-size: 1.1em; color: rgba(255,255,255,0.6); }
40 .badge {
41 display: inline-block;
42 margin-top: 20px;
43 padding: 6px 16px;
44 font-size: 0.85em;
45 background: rgba(167,139,250,0.15);
46 border: 1px solid rgba(167,139,250,0.3);
47 border-radius: 999px;
48 color: #a78bfa;
49 }
50 </style>
51 </head>
52 <body>
53 <div class='card'>
54 <h1>Hello, webkit-cl!</h1>
55 <p>A native macOS window powered by Common Lisp<br>
56 and WebKit (WKWebView).</p>
57 <span class='badge'>SBCL + Cocoa + WebKit</span>
58 </div>
59 </body>
60 </html>"))
Run it with:
1 sbcl --load examples/hello-world.lisp
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. Lisp manages the application state (a counter), and JavaScript provides the UI:
1 (require :asdf)
2 (push (make-pathname :directory (pathname-directory
3 (make-pathname
4 :directory (butlast
5 (pathname-directory *load-pathname*)))))
6 asdf:*central-registry*)
7 (asdf:load-system :webkit-cl)
8
9 ;;; Application State
10 (defvar *counter* 0)
11
12 ;;; Bridge Handlers
13 (webkit-cl:register-handler "increment"
14 (lambda (payload)
15 (declare (ignore payload))
16 (incf *counter*)
17 (format nil "{\"count\": ~d}" *counter*)))
18
19 (webkit-cl:register-handler "decrement"
20 (lambda (payload)
21 (declare (ignore payload))
22 (decf *counter*)
23 (format nil "{\"count\": ~d}" *counter*)))
24
25 (webkit-cl:register-handler "reset"
26 (lambda (payload)
27 (declare (ignore payload))
28 (setf *counter* 0)
29 (format nil "{\"count\": ~d}" *counter*)))
30
31 (webkit-cl:register-handler "get-system-info"
32 (lambda (payload)
33 (declare (ignore payload))
34 (format nil "{\"lisp\": \"~a\", \"version\": \"~a\", \"machine\": \"~a\"}"
35 (lisp-implementation-type)
36 (lisp-implementation-version)
37 (machine-type))))
Each handler receives a parsed JSON payload (an alist from cl-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 // On startup, query the Lisp runtime
7 const sysInfo = await window.webkit_cl.invoke('get-system-info', {});
8 info.innerHTML = 'Powered by ' + sysInfo.lisp + ' ' + sysInfo.version;
The counter value lives entirely in Lisp — JavaScript only renders it. This pattern cleanly separates application logic (Lisp) from presentation (HTML/CSS/JS).
Run it with:
1 sbcl --load examples/counter-app.lisp
Example 3: Markdown File Viewer
The most complete example demonstrates filesystem access through the bridge. Two handlers let JavaScript list and read files:
1 (webkit-cl:register-handler "read-file"
2 (lambda (payload)
3 (let ((path (cdr (assoc :path payload))))
4 (if (and path (probe-file path))
5 (let ((content (with-open-file (s path :direction :input)
6 (let ((data (make-string (file-length s))))
7 (read-sequence data s)
8 data))))
9 (format nil "{\"content\": ~a, \"path\": ~a}"
10 (json:encode-json-to-string content)
11 (json:encode-json-to-string path)))
12 (format nil "{\"error\": \"File not found: ~a\"}"
13 (or path "nil"))))))
14
15 (webkit-cl:register-handler "list-files"
16 (lambda (payload)
17 (let* ((dir (or (cdr (assoc :directory payload)) "."))
18 (pattern (merge-pathnames "*.md" (pathname dir)))
19 (files (directory pattern)))
20 (format nil "{\"files\": [~{~a~^, ~}]}"
21 (mapcar (lambda (f)
22 (json:encode-json-to-string (namestring f)))
23 files)))))
The read-file handler uses json:encode-json-to-string to safely escape file contents for JSON embedding. The list-files handler uses directory with a wildcard pattern and the format directive ~{~a~^, ~} to build a JSON array from the results.
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) + '</pre>';
18 }
19 }
Run it with:
1 sbcl --load examples/markdown-viewer.lisp
This opens a native window with a dark sidebar listing .md files from the current directory. Clicking a file reads its content via the Lisp bridge and displays it in a styled code panel.
API Reference Summary
The webkit-cl public API:
| Function | Description |
|---|---|
(with-app (&key title width height) &body body) |
Create app, run body, enter event loop, auto-cleanup |
(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 |
(set-title title) |
Change window title |
(set-size width height) |
Resize window |
(set-resizable flag) |
Toggle window resizability |
From JavaScript, call Lisp handlers with:
1 const result = await window.webkit_cl.invoke("command-name", {key: "value"});
Key Takeaways
- CFFI + Objective-C — Common Lisp can drive native macOS frameworks through a thin C shim compiled as a dynamic library
- 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 Lisp logic from JavaScript UI
unwind-protect— Thewith-appmacro ensures native resources are freed even if an error occurs- Floating-point traps — SBCL’s default FP trap settings conflict with Cocoa;
%disable-fp-trapsis essential
This framework demonstrates that Common Lisp can build polished desktop applications. The web rendering layer handles the visual complexity while Lisp provides the computational backbone — a productive division of labor for tools, dashboards, and data viewers.