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 2: 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 ;;; hello-world.lisp — Minimal webkit-cl example
2 ;;;
3 ;;; Displays a styled HTML page in a native macOS window.
4 ;;; This is the simplest possible webkit-cl app.
5
6 ;; Load the system
7 (require :asdf)
8 ;; Register the directory that holds webkit-cl.asd — the parent of examples/.
9 ;; *load-truename* is absolute, so this works from any current directory.
10 (push (make-pathname
11 :directory (butlast (pathname-directory
12 (or *load-truename* *load-pathname*))))
13 asdf:*central-registry*)
14 (asdf:load-system :webkit-cl)
15
16 (webkit-cl:with-app (:title "Hello webkit-cl" :width 600 :height 400)
17 (webkit-cl:load-html
18 "<!DOCTYPE html>
19 <html>
20 <head>
21 <meta charset='utf-8'>
22 <style>
23 * { margin: 0; padding: 0; box-sizing: border-box; }
24 body {
25 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
26 background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
27 color: #e0e0e0;
28 display: flex;
29 align-items: center;
30 justify-content: center;
31 height: 100vh;
32 overflow: hidden;
33 }
34 .card {
35 text-align: center;
36 background: rgba(255,255,255,0.05);
37 backdrop-filter: blur(20px);
38 -webkit-backdrop-filter: blur(20px);
39 border: 1px solid rgba(255,255,255,0.1);
40 border-radius: 24px;
41 padding: 48px 64px;
42 box-shadow: 0 8px 32px rgba(0,0,0,0.3);
43 animation: fadeIn 0.8s ease-out;
44 }
45 @keyframes fadeIn {
46 from { opacity: 0; transform: translateY(20px) scale(0.95); }
47 to { opacity: 1; transform: translateY(0) scale(1); }
48 }
49 h1 {
50 font-size: 2.5em;
51 font-weight: 700;
52 background: linear-gradient(90deg, #a78bfa, #60a5fa, #34d399);
53 -webkit-background-clip: text;
54 -webkit-text-fill-color: transparent;
55 margin-bottom: 12px;
56 }
57 p {
58 font-size: 1.1em;
59 color: rgba(255,255,255,0.6);
60 line-height: 1.6;
61 }
62 .badge {
63 display: inline-block;
64 margin-top: 20px;
65 padding: 6px 16px;
66 font-size: 0.85em;
67 background: rgba(167,139,250,0.15);
68 border: 1px solid rgba(167,139,250,0.3);
69 border-radius: 999px;
70 color: #a78bfa;
71 }
72 </style>
73 </head>
74 <body>
75 <div class='card'>
76 <h1>Hello, webkit-cl!</h1>
77 <p>A native macOS window powered by Common Lisp<br>
78 and WebKit (WKWebView).</p>
79 <span class='badge'>SBCL + Cocoa + WebKit</span>
80 </div>
81 </body>
82 </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 (defvar *counter* 0)
2
3 ;;; ── Bridge Handlers ────────────────────────────────────────────
4
5 (webkit-cl:register-handler "increment"
6 (lambda (payload)
7 (declare (ignore payload))
8 (incf *counter*)
9 (format nil "{\"count\": ~d}" *counter*)))
10
11 (webkit-cl:register-handler "decrement"
12 (lambda (payload)
13 (declare (ignore payload))
14 (decf *counter*)
15 (format nil "{\"count\": ~d}" *counter*)))
16
17 (webkit-cl:register-handler "reset"
18 (lambda (payload)
19 (declare (ignore payload))
20 (setf *counter* 0)
21 (format nil "{\"count\": ~d}" *counter*)))
22
23 (webkit-cl:register-handler "get-count"
24 (lambda (payload)
25 (declare (ignore payload))
26 (format nil "{\"count\": ~d}" *counter*)))
27
28 (webkit-cl:register-handler "get-system-info"
29 (lambda (payload)
30 (declare (ignore payload))
31 (format nil "{\"lisp\": \"~a\", \"version\": \"~a\", \"machine\": \"~a\"}"
32 (lisp-implementation-type)
33 (lisp-implementation-version)
34 (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 const display = document.getElementById('counter');
2 const info = document.getElementById('info');
3
4 function updateDisplay(count) {
5 display.textContent = count;
6 display.classList.add('bump');
7 setTimeout(() => display.classList.remove('bump'), 150);
8 }
9
10 async function increment() {
11 const result = await window.webkit_cl.invoke('increment', {});
12 updateDisplay(result.count);
13 }
14
15 async function decrement() {
16 const result = await window.webkit_cl.invoke('decrement', {});
17 updateDisplay(result.count);
18 }
19
20 async function reset() {
21 const result = await window.webkit_cl.invoke('reset', {});
22 updateDisplay(result.count);
23 }
24
25 // Load system info on startup
26 setTimeout(async () => {
27 try {
28 const sysInfo = await window.webkit_cl.invoke('get-system-info', {});
29 info.innerHTML =
30 'Powered by <span>' + sysInfo.lisp + ' ' + sysInfo.version + '</span>' +
31 '<br>Architecture: <span>' + sysInfo.machine + '</span>';
32 } catch(e) {
33 info.textContent = 'JS ↔ Lisp bridge active';
34 }
35 }, 500);
36
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 (defun read-text-file (path)
2 "Return the entire contents of PATH as a string, decoded as UTF-8.
3
4 FILE-LENGTH cannot be used to size the buffer: for a character stream it
5 reports the length in *bytes*, so a file containing multi-byte characters
6 would produce a string padded with NULs. Reading in chunks until EOF is
7 correct regardless of how many bytes each character occupies."
8 (with-open-file (stream path
9 :direction :input
10 :element-type 'character
11 :external-format :utf-8)
12 (with-output-to-string (out)
13 (let ((buffer (make-string 8192)))
14 (loop for count = (read-sequence buffer stream)
15 while (plusp count)
16 do (write-string buffer out :end count))))))
17
18 (webkit-cl:register-handler "read-file"
19 (lambda (payload)
20 (let ((path (cdr (assoc :path payload))))
21 (if (and (stringp path) (probe-file path))
22 (format nil "{\"content\": ~a, \"path\": ~a}"
23 (json:encode-json-to-string (read-text-file path))
24 (json:encode-json-to-string path))
25 ;; Encode the message with cl-json so a path containing a quote
26 ;; or backslash cannot produce malformed JSON.
27 (format nil "{\"error\": ~a}"
28 (json:encode-json-to-string
29 (format nil "File not found: ~a" (or path "nil"))))))))
30
31 (webkit-cl:register-handler "list-files"
32 (lambda (payload)
33 (let* ((dir (or (cdr (assoc :directory payload)) "."))
34 (pattern (merge-pathnames "*.md" (pathname dir)))
35 (files (directory pattern)))
36 (format nil "{\"files\": [~{~a~^, ~}]}"
37 (mapcar (lambda (f)
38 (json:encode-json-to-string (namestring f)))
39 files)))))
Reading a file correctly takes a little care. The obvious idiom — size a string with file-length and fill it with read-sequence — is wrong for text: file-length reports the length of a character stream in bytes, so any file containing multi-byte characters yields a string padded with NULs. Reading in chunks until read-sequence returns zero works for every file, whatever the byte-to-character ratio, which is why read-text-file does it that way. The :external-format :utf-8 argument makes the decoding explicit.
Both handlers then hand their results to json:encode-json-to-string rather than interpolating them with ~a. That matters for the file contents and for the error message: a path containing a double quote or a backslash would otherwise produce malformed JSON and the Promise on the JavaScript side would reject. 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. Note that every value that reaches innerHTML — file names, paths, file contents, error text — is passed through escapeHtml first, and single quotes in a file name are escaped before being embedded in the onclick attribute:
1 const fileList = document.getElementById('file-list');
2 const content = document.getElementById('content');
3
4 function escapeHtml(str) {
5 return str.replace(/&/g, '&')
6 .replace(/</g, '<')
7 .replace(/>/g, '>');
8 }
9
10 function basename(path) {
11 return path.split('/').pop();
12 }
13
14 async function loadFileList() {
15 try {
16 const result = await window.webkit_cl.invoke('list-files', { directory: '.' });
17 if (result.files && result.files.length > 0) {
18 fileList.innerHTML = result.files.map(f =>
19 '<div class="file-item" onclick="loadFile(\'' + f.replace(/'/g, '\\\'') + '\')">' +
20 '<div class="name">' + escapeHtml(basename(f)) + '</div>' +
21 '<div class="path">' + escapeHtml(f) + '</div>' +
22 '</div>'
23 ).join('');
24 } else {
25 fileList.innerHTML =
26 '<div class="empty-state" style="height:200px;font-size:0.85rem;">' +
27 'No .md files found</div>';
28 }
29 } catch(e) {
30 fileList.innerHTML =
31 '<div class="empty-state" style="height:200px;font-size:0.85rem;">' +
32 'Error: ' + e + '</div>';
33 }
34 }
35
36 async function loadFile(path) {
37 // Highlight active item
38 document.querySelectorAll('.file-item').forEach(el => {
39 el.classList.remove('active');
40 if (el.querySelector('.path').textContent === path) {
41 el.classList.add('active');
42 }
43 });
44
45 try {
46 const result = await window.webkit_cl.invoke('read-file', { path: path });
47 if (result.error) {
48 content.innerHTML = '<div class="empty-state">' + escapeHtml(result.error) + '</div>';
49 } else {
50 content.innerHTML = '<pre>' + escapeHtml(result.content) + '</pre>';
51 }
52 } catch(e) {
53 content.innerHTML = '<div class="empty-state">Error loading file</div>';
54 }
55 }
56
57 // Load file list on startup
58 setTimeout(loadFileList, 300);
59
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. Try it on a file containing accented letters, em dashes or CJK text — the UTF-8 path is exercised by any of them.
API Reference Summary
The webkit-cl public API:
App lifecycle
| Function | Description |
|---|---|
(with-app (&key title width height) &body body) |
Create app, run body, enter event loop, auto-cleanup |
(make-app &key title width height) |
Create the window and install the bridge callback; does not run |
(app-run app) |
Show the window and enter the event loop (blocks) |
(app-quit &optional app) |
Request the application to quit |
(app-destroy app) |
Destroy the app and free native resources |
Content loading
| Function | Description |
|---|---|
(load-html html &optional app) |
Load inline HTML string |
(load-url url &optional app) |
Navigate to a URL |
(load-file path &optional app) |
Load a local HTML file |
JavaScript
| Function | Description |
|---|---|
(eval-js js-string &optional app) |
Evaluate JavaScript (fire-and-forget) |
Bridge
| Function | Description |
|---|---|
(register-handler name fn) |
Register a bridge command handler |
(unregister-handler name) |
Remove a bridge command handler |
Window management
| Function | Description |
|---|---|
(set-title title &optional app) |
Change window title |
(set-size width height &optional app) |
Resize window |
(set-resizable flag &optional app) |
Toggle window resizability |
App accessors
| Name | Description |
|---|---|
*current-app* |
The app bound for the duration of app-run and with-app
|
(app-handle app) |
The underlying wkcl_app_t foreign pointer |
(app-title app), (app-width app), (app-height app)
|
The struct slots |
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.
Optional Practice Problems
Window Position and Center Helper: The current window management API in webkit-cl.lisp supports setting window size and title, but not positioning. Add foreign function declarations for setting window positions (
wkcl_set_positionor similar in the C shim) and export them in package.lisp and webkit-cl-ffi.lisp. Implement a Lisp-side functioncenter-windowthat centers the app window on the screen using system screen width and height information.JavaScript Evaluation with Callback/Promise (Bidirectional eval-js): The
eval-jsfunction in webkit-cl.lisp is “fire-and-forget” because it calls the underlying C function without returning the evaluation results back to Lisp. Design an asynchronous evaluation functioneval-js-asyncthat takes a JS expression and a callback. Trigger a special bridge handler in Lisp containing the evaluation result when the JavaScript execution resolves, linking it to the original call.Asynchronous Multi-Threading File Reader: In the markdown viewer example in markdown-viewer.lisp, the Lisp bridge handler
read-filereads file contents synchronously. If a file is very large, reading it synchronously blocks the Cocoa event loop, making the window temporarily unresponsive. Refactor this handler using SBCL threads (sb-thread:make-thread) to read the file asynchronously, and useeval-jsto push the completed content back to the JavaScript frontend when finished.Bi-directional State Synchronization with JSON-diff: Write a state synchronization helper in bridge.lisp. Rather than registering manually separate handlers like
increment,decrement, andreset(as in counter-app.lisp), design a state synchronization pattern. Store the application state as a nested alist in Lisp, and write a synchronization macro that broadcasts state diffs to the JavaScript frontend automatically whenever the state changes.Error Reporting with Stack Traces in the JS Console: The current command dispatch in
dispatch-bridge-command(in bridge.lisp) catches Lisp errors and returns a simple JSON error string. If an error occurs deep in Lisp, the developer loses the stack trace. Enhancedispatch-bridge-commandto capture the full SBCL stack trace usingsb-debug:backtraceand format it as a formatted JSON error object. Inject this stack trace into the WebKit inspector console via aconsole.errorcall usingeval-js.Custom URL Scheme Interceptor: Currently, loading local files relies on the C-level file loader (
wkcl_load_file), which requires resolving absolute local paths. Implement a custom URL scheme handler (such aslisp-resource://) inside the native Objective-C shim and bind it via CFFI. This allows the WebKit browser instance to request files (e.g.,<img src="lisp-resource://images/logo.png">) by intercepting these requests inside Common Lisp and streaming the binary content back directly from Lisp’s memory or an in-memory assets alist.