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:

  1. Objective-C shim (webkit_cl.m): Bridges macOS Cocoa and WebKit APIs to a flat C interface
  2. CFFI bindings (webkit-cl-ffi.lisp): Exposes the C functions to Common Lisp
  3. Bridge (bridge.lisp): Manages JS <—> Lisp command dispatch and JSON serialization
  4. 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

  1. CFFI + Objective-C — Common Lisp can drive native macOS frameworks through a thin C shim compiled as a dynamic library
  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 Lisp logic from JavaScript UI
  4. unwind-protect — The with-app macro ensures native resources are freed even if an error occurs
  5. Floating-point traps — SBCL’s default FP trap settings conflict with Cocoa; %disable-fp-traps is 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.