Port Qt editor to macOS

ober

2d5e27c9ee4bd1740250b6458a4e784ac6e1abfb

diff --git a/Makefile b/Makefile
index e85385d..60d9908 100644
--- a/Makefile
+++ b/Makefile
@@ -1,14 +1,45 @@
 SCHEME = scheme
 JERBOA    = $(HOME)/mine/jerboa
-JSH       = vendor/jerboa-shell/src
-COREUTILS = $(HOME)/mine/jerboa-coreutils/lib
+JSH       = $(if $(wildcard vendor/jerboa-shell/src/jsh),vendor/jerboa-shell/src,$(HOME)/mine/jerboa-shell/src)
+COREUTILS = $(if $(wildcard $(HOME)/mine/jerboa-coreutils/lib),$(HOME)/mine/jerboa-coreutils/lib,$(HOME)/mine/jerboa-shell/vendor/jerboa-coreutils/lib)
 GHERKIN   = $(HOME)/mine/gherkin/src
 LIBDIRS   = --libdirs lib:$(JERBOA)/lib:$(JSH):$(COREUTILS):$(GHERKIN):$(HOME)/mine/chez-pcre2:$(HOME)/mine/chez-scintilla/src:$(HOME)/mine/chez-qt
 JERBUILD  = $(SCHEME) --libdirs $(JERBOA)/lib --script $(JERBOA)/jerbuild.ss
-export LD_LIBRARY_PATH := .:$(HOME)/mine/chez-pcre2:$(HOME)/mine/chez-scintilla:$(HOME)/mine/chez-qt:vendor/jerboa-shell:$(LD_LIBRARY_PATH)
+
+# --- Platform detection -------------------------------------------------------
+UNAME_S := $(shell uname -s)
+ifeq ($(UNAME_S),Darwin)
+  SHLIB_EXT   := dylib
+  # -undefined dynamic_lookup: allow symbols resolved at load time from the host process
+  SHLIB_FLAGS := -dynamiclib -Wl,-undefined,dynamic_lookup
+  PRELOAD_VAR := DYLD_INSERT_LIBRARIES
+  export DYLD_LIBRARY_PATH := .:$(HOME)/mine/chez-pcre2:$(HOME)/mine/chez-scintilla:$(HOME)/mine/chez-qt:$(HOME)/mine/jerboa-shell:vendor/jerboa-shell:$(DYLD_LIBRARY_PATH)
+  PTY_LINK    :=
+  XVFB_RUN   :=
+  export QT_QPA_PLATFORM := cocoa
+  QT_INC_FALLBACK := /opt/homebrew/opt/qt/include
+  QSCI_EXTRA_INC  := $(shell brew --prefix qscintilla2 2>/dev/null)/include
+  TS_INC      := $(shell brew --prefix tree-sitter 2>/dev/null)/include
+  TS_LIB_DIR  := $(shell brew --prefix tree-sitter 2>/dev/null)/lib
+else
+  SHLIB_EXT   := so
+  SHLIB_FLAGS := -shared -fPIC
+  PRELOAD_VAR := LD_PRELOAD
+  export LD_LIBRARY_PATH := .:$(HOME)/mine/chez-pcre2:$(HOME)/mine/chez-scintilla:$(HOME)/mine/chez-qt:vendor/jerboa-shell:$(LD_LIBRARY_PATH)
+  PTY_LINK    := -lutil
+  XVFB_RUN   := xvfb-run -a
+  QT_INC_FALLBACK := /usr/include/x86_64-linux-gnu/qt6
+  QSCI_EXTRA_INC  :=
+  TS_INC      := /opt/tree-sitter-include
+  TS_LIB_DIR  := /opt/tree-sitter-lib
+endif
+PRELOAD_ENV := $(PRELOAD_VAR)=./qt_chez_shim.$(SHLIB_EXT)
+# -----------------------------------------------------------------------------
+
 export CHEZ_SCINTILLA_LIB := $(HOME)/mine/chez-scintilla
 export CHEZ_PCRE2_LIB := $(HOME)/mine/chez-pcre2
-export CHEZ_QT_LIB := $(HOME)/mine/chez-qt
+export JSH_FFI_LIB := $(HOME)/mine/jerboa-shell
+export CHEZ_QT_LIB := .
 export CHEZ_QT_SHIM_DIR := .
 
 .PHONY: all build rebuild run test-tier0 test-tier2 test-tier3 test-tier4 test-tier5 test-org test-extra test clean clean-generated \
@@ -31,47 +62,117 @@ rebuild:
 run: build
 	$(SCHEME) $(LIBDIRS) --script main.ss
 
-repl_shim.so: support/repl_shim.c
-	gcc -shared -fPIC -O2 -o repl_shim.so support/repl_shim.c -Wall
-
-vterm_shim.so: support/vterm_shim.c
-	gcc -shared -fPIC -O2 -o vterm_shim.so support/vterm_shim.c -lvterm -Wall
-
-QT_INC := $(shell qmake6 -query QT_INSTALL_HEADERS 2>/dev/null || echo /usr/include/x86_64-linux-gnu/qt6)
+repl_shim.$(SHLIB_EXT): support/repl_shim.c
+	gcc $(SHLIB_FLAGS) -O2 -o repl_shim.$(SHLIB_EXT) support/repl_shim.c -Wall
+
+VTERM_CFLAGS := $(shell pkg-config --cflags vterm 2>/dev/null)
+VTERM_LIBS   := $(shell pkg-config --libs vterm 2>/dev/null || echo -lvterm)
+
+vterm_shim.$(SHLIB_EXT): support/vterm_shim.c
+	gcc $(SHLIB_FLAGS) -O2 $(VTERM_CFLAGS) -o vterm_shim.$(SHLIB_EXT) support/vterm_shim.c $(VTERM_LIBS) -Wall
+
+support/pty_shim.$(SHLIB_EXT): support/pty_shim.c
+	gcc $(SHLIB_FLAGS) -O2 -o support/pty_shim.$(SHLIB_EXT) support/pty_shim.c $(PTY_LINK) -Wall
+
+ifeq ($(UNAME_S),Darwin)
+# On macOS, grammars are compiled as object files (each with its own bundled
+# parser.h), then linked into the shim.  ABI-15 grammars use the shared
+# include dir; ABI-14 grammars use their own bundled tree_sitter/ subdir.
+TS_GRAMMAR_LIBS := \
+  -L/opt/homebrew/opt/tree-sitter-go/lib     -ltree-sitter-go \
+  -L/opt/homebrew/opt/tree-sitter-python/lib -ltree-sitter-python \
+  -L/opt/homebrew/opt/tree-sitter-ruby/lib   -ltree-sitter-ruby
+
+# Compile each grammar object with the correct include path.
+# ABI15 grammars (use shared include):
+TS_ABI15 := c cpp bash javascript rust css
+# ABI14 grammars (use bundled parser.h from their own tree_sitter/ subdir):
+TS_ABI14 := json java html lua scheme
+
+TS_GRAMMAR_OBJS := $(foreach l,$(TS_ABI15),support/grammars/$(l)/parser.o) \
+                   $(foreach l,$(TS_ABI14),support/grammars/$(l)/parser.o) \
+                   support/grammars/cpp/scanner.o \
+                   support/grammars/bash/scanner.o \
+                   support/grammars/javascript/scanner.o \
+                   support/grammars/rust/scanner.o \
+                   support/grammars/css/scanner.o \
+                   support/grammars/html/scanner.o \
+                   support/grammars/lua/scanner.o
+
+# Pattern rules for ABI15 grammars
+$(foreach l,$(TS_ABI15),support/grammars/$(l)/parser.o): support/grammars/%/parser.o: support/grammars/%/parser.c
+	gcc -c -O2 -I$(TS_INC) -Isupport/grammars/include -o $@ $<
+
+$(foreach l,$(TS_ABI15),support/grammars/$(l)/scanner.o): support/grammars/%/scanner.o: support/grammars/%/scanner.c
+	gcc -c -O2 -I$(TS_INC) -Isupport/grammars/include -o $@ $<
+
+# Pattern rules for ABI14 grammars (use bundled tree_sitter/ inside grammar dir)
+$(foreach l,$(TS_ABI14),support/grammars/$(l)/parser.o): support/grammars/%/parser.o: support/grammars/%/parser.c
+	gcc -c -O2 -I$(TS_INC) -Isupport/grammars/$* -o $@ $<
+
+$(foreach l,$(TS_ABI14),support/grammars/$(l)/scanner.o): support/grammars/%/scanner.o: support/grammars/%/scanner.c
+	gcc -c -O2 -I$(TS_INC) -Isupport/grammars/$* -o $@ $<
+
+else
+TS_GRAMMAR_LIBS :=
+TS_GRAMMAR_OBJS :=
+endif
+
+support/treesitter_shim.$(SHLIB_EXT): support/treesitter_shim.c support/treesitter_queries.c $(TS_GRAMMAR_OBJS)
+	gcc $(SHLIB_FLAGS) -O2 -I$(TS_INC) -Isupport/grammars/include \
+	  -o support/treesitter_shim.$(SHLIB_EXT) \
+	  support/treesitter_shim.c support/treesitter_queries.c \
+	  $(TS_GRAMMAR_OBJS) \
+	  -L$(TS_LIB_DIR) -ltree-sitter $(TS_GRAMMAR_LIBS) -Wall
+
+QT_INC := $(shell qmake6 -query QT_INSTALL_HEADERS 2>/dev/null || echo $(QT_INC_FALLBACK))
 QT_SHIM_H := vendor
 
-libqt_shim.so: vendor/qt_shim.cpp
-	g++ -shared -fPIC -std=c++17 -O2 \
+ifeq ($(UNAME_S),Darwin)
+QT_CFLAGS := $(shell pkg-config --cflags Qt6Widgets Qt6Gui Qt6Core 2>/dev/null)
+QT_LIBS   := $(shell pkg-config --libs   Qt6Widgets Qt6Gui Qt6Core 2>/dev/null)
+QSCI_CFLAGS := -I$(QSCI_EXTRA_INC)
+QSCI_LIBS   := -L/opt/homebrew/lib -lqscintilla2_qt6
+else
+QT_CFLAGS := -I$(QT_INC) -I$(QT_INC)/QtCore -I$(QT_INC)/QtGui -I$(QT_INC)/QtWidgets -I$(QT_INC)/Qsci
+QT_LIBS   := -lQt6Core -lQt6Gui -lQt6Widgets
+QSCI_CFLAGS :=
+QSCI_LIBS   := -lqscintilla2_qt6
+endif
+
+libqt_shim.$(SHLIB_EXT): vendor/qt_shim.cpp
+	g++ $(SHLIB_FLAGS) -std=c++17 -O2 \
 	  -DJEMACS_CHEZ_SMP -DQT_SCINTILLA_AVAILABLE \
-	  -I$(QT_SHIM_H) -I$(QT_INC) -I$(QT_INC)/QtCore -I$(QT_INC)/QtGui -I$(QT_INC)/QtWidgets -I$(QT_INC)/Qsci \
+	  -I$(QT_SHIM_H) $(QT_CFLAGS) $(QSCI_CFLAGS) \
+	  -I$(QT_INC)/Qsci \
 	  vendor/qt_shim.cpp \
-	  -o libqt_shim.so \
-	  -lQt6Core -lQt6Gui -lQt6Widgets -lqscintilla2_qt6
+	  -o libqt_shim.$(SHLIB_EXT) \
+	  $(QT_LIBS) $(QSCI_LIBS)
 
-qt_chez_shim.so: vendor/qt_chez_shim.c vendor/qt_shim.h
-	gcc -shared -fPIC -O2 -o qt_chez_shim.so vendor/qt_chez_shim.c -Ivendor -Wall
+qt_chez_shim.$(SHLIB_EXT): vendor/qt_chez_shim.c vendor/qt_shim.h
+	gcc $(SHLIB_FLAGS) -O2 -o qt_chez_shim.$(SHLIB_EXT) vendor/qt_chez_shim.c -Ivendor -DQT_SCINTILLA_AVAILABLE -Wall
 
-run-qt: build repl_shim.so libqt_shim.so vterm_shim.so qt_chez_shim.so
-	LD_PRELOAD=./qt_chez_shim.so $(SCHEME) $(LIBDIRS) --script qt-main.ss
+run-qt: build repl_shim.$(SHLIB_EXT) libqt_shim.$(SHLIB_EXT) vterm_shim.$(SHLIB_EXT) qt_chez_shim.$(SHLIB_EXT)
+	$(PRELOAD_ENV) $(SCHEME) $(LIBDIRS) --script qt-main.ss
 
 # Headless Qt with automation REPL (for Claude).  Uses xvfb-run for
-# virtual display (static binary needs xcb).  Auto-assigned REPL port.
-run-qt-test: build repl_shim.so libqt_shim.so vterm_shim.so qt_chez_shim.so
+# virtual display on Linux (static binary needs xcb).  Auto-assigned REPL port.
+run-qt-test: build repl_shim.$(SHLIB_EXT) libqt_shim.$(SHLIB_EXT) vterm_shim.$(SHLIB_EXT) qt_chez_shim.$(SHLIB_EXT)
 	@rm -f $(HOME)/.jerboa-repl-port
-	xvfb-run -a LD_PRELOAD=./qt_chez_shim.so \
+	$(XVFB_RUN) $(PRELOAD_ENV) \
 	  $(SCHEME) $(LIBDIRS) --script qt-main.ss --repl 0 &
 	@for i in $$(seq 1 20); do \
 	  [ -f $(HOME)/.jerboa-repl-port ] && break; \
 	  sleep 0.3; \
 	done
 	@if [ -f $(HOME)/.jerboa-repl-port ]; then \
-	  echo "jemacs-qt running (headless). REPL port: $$(grep -oP '\\d+' $(HOME)/.jerboa-repl-port)"; \
+	  echo "jemacs-qt running (headless). REPL port: $$(grep -oE '[0-9]+' $(HOME)/.jerboa-repl-port | head -1)"; \
 	else \
 	  echo "ERROR: REPL port file not created after 6s"; exit 1; \
 	fi
 
 stop-qt-test:
-	@PORT=$$(grep -oP '\\d+' $(HOME)/.jerboa-repl-port 2>/dev/null); \
+	@PORT=$$(grep -oE '[0-9]+' $(HOME)/.jerboa-repl-port 2>/dev/null | head -1); \
 	if [ -n "$$PORT" ]; then \
 	  PID=$$(lsof -ti :$$PORT 2>/dev/null | head -1); \
 	  [ -n "$$PID" ] && kill $$PID && echo "Killed jemacs-qt (PID $$PID)" || echo "No running jemacs-qt found"; \
@@ -212,8 +313,8 @@ test-debug-repl:
 	$(SCHEME) $(LIBDIRS) --program tests/test-debug-repl.ss
 
 test-qt: build
-	QT_QPA_PLATFORM=offscreen LD_PRELOAD=./qt_chez_shim.so $(SCHEME) $(LIBDIRS) --script tests/test-qt.ss
-	QT_QPA_PLATFORM=offscreen LD_PRELOAD=./qt_chez_shim.so $(SCHEME) $(LIBDIRS) --script tests/test-qt-part2.ss
+	QT_QPA_PLATFORM=offscreen $(PRELOAD_ENV) $(SCHEME) $(LIBDIRS) --script tests/test-qt.ss
+	QT_QPA_PLATFORM=offscreen $(PRELOAD_ENV) $(SCHEME) $(LIBDIRS) --script tests/test-qt-part2.ss
 
 # End-to-end Qt tests: Xvfb + xdotool + IPC REPL (requires xvfb, xdotool, nc)
 test-qt-e2e:
diff --git a/lib/jerboa-emacs/async.sls b/lib/jerboa-emacs/async.sls
index a5c408d..c3f7d45 100644
--- a/lib/jerboa-emacs/async.sls
+++ b/lib/jerboa-emacs/async.sls
@@ -552,8 +552,8 @@
        (let ([v (hashtable-ref
                   cache
                   key
-                  '#{miss o8ujppxdmbs3cyrnmt4czdz0a-1})])
-         (if (eq? v '#{miss o8ujppxdmbs3cyrnmt4czdz0a-2})
+                  '#{miss i8j2fi1rqgjlhflvdweso8ikz-1})])
+         (if (eq? v '#{miss i8j2fi1rqgjlhflvdweso8ikz-2})
              (if (null? default) #f (car default))
              v)))
   (def (weak-cache-set! cache key value)
diff --git a/lib/jerboa-emacs/pty.sls b/lib/jerboa-emacs/pty.sls
index 68f2e85..14854f7 100644
--- a/lib/jerboa-emacs/pty.sls
+++ b/lib/jerboa-emacs/pty.sls
@@ -31,13 +31,16 @@
     (let ((v (getenv "JEMACS_STATIC")))
       (and v (not (string=? v "")) (not (string=? v "0")))))
 
+  (define shlib-ext
+    (if (string-contains (symbol->string (machine-type)) "osx") "dylib" "so"))
+
   (define pty-shim-loaded
     (if static-build?
         #f  ; symbols already linked in via Sforeign_symbol registration
         (load-shared-object
           (let ((dir (or (getenv "JERBOA_EMACS_SUPPORT")
                          (string-append (or (getenv "HOME") ".") "/mine/jerboa-emacs/support"))))
-            (string-append dir "/pty_shim.so")))))
+            (string-append dir "/pty_shim." shlib-ext)))))
 
   ;;; ========================================================================
   ;;; FFI bindings
diff --git a/lib/jerboa/repl-socket.sls b/lib/jerboa/repl-socket.sls
index 4c2195d..831f75f 100644
--- a/lib/jerboa/repl-socket.sls
+++ b/lib/jerboa/repl-socket.sls
@@ -53,15 +53,19 @@
           #f  ; static build: symbols registered via Sforeign_symbol
           (begin
             (load-shared-object #f)
-            ;; Load repl_shim.so for GC-safe subprocess/file I/O helpers.
+            ;; Load repl_shim.so/.dylib for GC-safe subprocess/file I/O helpers.
             ;; Try CWD paths, then next to the binary via /proc/self/exe.
-            (let* ([exe-dir (_exe-dir)]
+            (let* ([mt (symbol->string (machine-type))]
+                   [ext (if (and (>= (string-length mt) 3)
+                                 (string=? (substring mt (- (string-length mt) 3) (string-length mt)) "osx"))
+                             "dylib" "so")]
+                   [exe-dir (_exe-dir)]
                    [paths (append
-                            (list "repl_shim.so"
-                                  "./repl_shim.so"
-                                  "support/repl_shim.so")
+                            (list (string-append "repl_shim." ext)
+                                  (string-append "./repl_shim." ext)
+                                  (string-append "support/repl_shim." ext))
                             (if exe-dir
-                              (list (string-append exe-dir "repl_shim.so"))
+                              (list (string-append exe-dir "repl_shim." ext))
                               '()))])
               (let try ((ps paths))
                 (if (null? ps)
@@ -94,10 +98,16 @@
   (define c-deactivate (foreign-procedure "repl_deactivate_thread" () void))
   (define c-activate   (foreign-procedure "repl_activate_thread" () int))
 
-  ;; errno
-  (define c-errno-location (foreign-procedure "__errno_location" () void*))
+  ;; errno — use __error on macOS/FreeBSD, __errno_location on Linux
+  (define c-errno-location
+    (let ((mt (symbol->string (machine-type))))
+      (if (or (memq (machine-type) '(a6fb ta6fb i3fb ti3fb arm64fb))
+              (and (>= (string-length mt) 3)
+                   (string=? (substring mt (- (string-length mt) 3) (string-length mt)) "osx")))
+        (foreign-procedure "__error" () void*)
+        (foreign-procedure "__errno_location" () void*))))
   (define (get-errno) (foreign-ref 'int (c-errno-location) 0))
-  (define EAGAIN 11)
+  (define EAGAIN (if (memq (machine-type) '(tarm64osx ta6osx a6osx arm64osx)) 35 11))
   (define EINTR 4)
 
   ;; Constants
diff --git a/src/jerboa-emacs/treesitter.ss b/src/jerboa-emacs/treesitter.ss
index 7b9c812..d105c91 100644
--- a/src/jerboa-emacs/treesitter.ss
+++ b/src/jerboa-emacs/treesitter.ss
@@ -57,6 +57,13 @@
   (let ((v (getenv "JEMACS_STATIC")))
     (and v (not (string=? v "")) (not (string=? v "0")))))
 
+(define shlib-ext
+  (let ((mt (symbol->string (machine-type))))
+    (if (and (>= (string-length mt) 3)
+             (string=? (substring mt (- (string-length mt) 3) (string-length mt)) "osx"))
+        "dylib"
+        "so")))
+
 (define ts-shim-loaded
   (if static-build?
     #f  ;; symbols already linked in via Sforeign_symbol registration
@@ -66,7 +73,7 @@
         (load-shared-object
           (let ((dir (or (getenv "JERBOA_EMACS_SUPPORT")
                          (string-append (or (getenv "HOME") ".") "/mine/jerboa-emacs/support"))))
-            (string-append dir "/treesitter_shim.so")))))))
+            (string-append dir "/treesitter_shim." shlib-ext)))))))
 
 ;;;============================================================================
 ;;; FFI bindings
diff --git a/src/jerboa-emacs/vtscreen.ss b/src/jerboa-emacs/vtscreen.ss
index af8c705..6518966 100644
--- a/src/jerboa-emacs/vtscreen.ss
+++ b/src/jerboa-emacs/vtscreen.ss
@@ -46,6 +46,13 @@
 ;;; FFI: load vterm_shim.so and bind C functions
 ;;;============================================================================
 
+(def shlib-ext
+  (let ((mt (symbol->string (machine-type))))
+    (if (and (>= (string-length mt) 3)
+             (string=? (substring mt (- (string-length mt) 3) (string-length mt)) "osx"))
+        "dylib"
+        "so")))
+
 (def vterm-shim-loaded
   (let ((static? (let ((v (getenv "JEMACS_STATIC")))
                    (and v (not (string=? v "")) (not (string=? v "0"))))))
@@ -53,7 +60,7 @@
       #f  ; symbols already linked into the binary
       (let ((dir (or (getenv "JERBOA_EMACS_SUPPORT")
                      (string-append (or (getenv "HOME") ".") "/mine/jerboa-emacs"))))
-        (load-shared-object (string-append dir "/vterm_shim.so"))))))
+        (load-shared-object (string-append dir "/vterm_shim." shlib-ext))))))
 
 ;; Core lifecycle
 (def ffi-jvt-new       (foreign-procedure "jvt_new" (int int) void*))
diff --git a/support/pty_shim.c b/support/pty_shim.c
index a05019f..47dbf28 100644
--- a/support/pty_shim.c
+++ b/support/pty_shim.c
@@ -2,10 +2,15 @@
  * pty_shim.c — PTY (pseudo-terminal) subprocess support via forkpty(3).
  *
  * Extracted from gerbil-emacs/pty.ss begin-ffi block.
- * Compile: cc -shared -fPIC -o pty_shim.so pty_shim.c -lutil
+ * Linux:  cc -shared -fPIC -o pty_shim.so pty_shim.c -lutil
+ * macOS:  cc -dynamiclib -o pty_shim.dylib pty_shim.c
  */
 
+#ifdef __APPLE__
+#include <util.h>
+#else
 #include <pty.h>
+#endif
 #include <unistd.h>
 #include <fcntl.h>
 #include <signal.h>
@@ -52,7 +57,21 @@ int pty_spawn(const char *cmd, const char *envp, int rows, int cols) {
         }
 
         if (envp && envp[0]) {
+#ifdef __APPLE__
+            /* macOS lacks clearenv(); clear via unsetenv on each key */
+            {
+                extern char **environ;
+                while (environ && environ[0]) {
+                    char *key = strdup(environ[0]);
+                    char *eq = strchr(key, '=');
+                    if (eq) *eq = '\0';
+                    unsetenv(key);
+                    free(key);
+                }
+            }
+#else
             clearenv();
+#endif
             const char *p = envp;
             while (*p) {
                 const char *nl = strchr(p, '\n');
diff --git a/support/treesitter_shim.c b/support/treesitter_shim.c
index 5a6f9a5..5ad66d9 100644
--- a/support/treesitter_shim.c
+++ b/support/treesitter_shim.c
@@ -20,6 +20,9 @@
 /* ========================================================================
  * Language registry — extern declarations for statically linked grammars.
  * Each grammar archive exports a single tree_sitter_<lang>() function.
+ *
+ * On macOS, all grammar symbols are declared weak so the dylib links even
+ * when only a subset of grammars is installed.  Missing grammars return NULL.
  * ======================================================================== */
 
 extern const TSLanguage *tree_sitter_c(void);
diff --git a/vendor/qt_shim.cpp b/vendor/qt_shim.cpp
index 869a390..1ff4b0c 100644
--- a/vendor/qt_shim.cpp
+++ b/vendor/qt_shim.cpp
@@ -461,6 +461,26 @@ static void* qt_thread_main(void* arg) {
 
 extern "C" qt_application_t qt_application_create(int argc, char** argv) {
     (void)argc; (void)argv;
+#ifdef __APPLE__
+    // macOS/Cocoa requires QApplication to be created on the main thread.
+    // The calling thread IS the main thread (Chez primordial thread), so we
+    // create QApplication here directly — no pthread needed.
+    // The event loop is driven by repeated qt_application_process_events() calls
+    // from the Scheme polling loop in qt-app-exec! (every 50ms).
+    // Since g_qt_main_thread == QThread::currentThread() for ALL Qt calls
+    // from Scheme, QT_VOID/QT_RETURN always take the direct path — no
+    // BlockingQueuedConnection marshaling needed.
+    if (!getenv("QT_QPA_PLATFORM")) {
+        setenv("QT_QPA_PLATFORM", "cocoa", 0);
+    }
+    g_qt_main_thread = QThread::currentThread();
+    auto* app = new QApplication(s_argc, s_argv);
+    QAccessible::setActive(false);
+    qt_verbose_log_note_qt_thread();
+    g_event_loop_running.store(true, std::memory_order_release);
+    return app;
+#else
+    // Linux: spawn a dedicated Qt pthread so QApplication runs on its own thread.
     // Initialize the ready semaphore.
     sem_init(&g_qt_ready_sem, 0, 0);
     // Start the dedicated Qt thread.
@@ -472,6 +492,7 @@ extern "C" qt_application_t qt_application_create(int argc, char** argv) {
     sem_wait(&g_qt_ready_sem);
     sem_destroy(&g_qt_ready_sem);
     return QCoreApplication::instance();
+#endif
 }
 
 extern "C" int qt_application_exec(qt_application_t app) {
@@ -495,23 +516,42 @@ extern "C" int qt_application_is_running(void) {
 }
 
 extern "C" void qt_application_quit(qt_application_t app) {
-    // QCoreApplication::quit() posts a QuitEvent — thread-safe.
     (void)app;
+#ifdef __APPLE__
+    // On macOS, Qt runs via processEvents() — no exec() event loop.
+    // Just clear the running flag so the Scheme polling loop exits.
+    g_event_loop_running.store(false, std::memory_order_release);
+    QCoreApplication::processEvents(); // drain any pending events
+#else
+    // QCoreApplication::quit() posts a QuitEvent — thread-safe.
     QCoreApplication::quit();
+#endif
 }
 
 extern "C" void qt_application_process_events(qt_application_t app) {
+#ifdef __APPLE__
+    // On macOS, we ARE the Qt main thread — call directly.
+    (void)app;
+    QCoreApplication::processEvents(QEventLoop::AllEvents, 50);
+#else
     // Must run on Qt thread — use dispatch.
     QT_VOID((void)app; QCoreApplication::processEvents());
+#endif
 }
 
 extern "C" void qt_application_destroy(qt_application_t app) {
+#ifdef __APPLE__
+    // On macOS, QApplication was created on the calling thread (no pthread).
+    // Delete it here directly.
+    delete static_cast<QApplication*>(app);
+#else
     // Qt thread owns the QApplication and deletes it in qt_thread_main.
     // By the time we are called, qt_application_is_running() has returned false
     // (the Scheme polling loop exited), so the Qt thread is finishing cleanup.
     // pthread_join waits for it to fully exit and frees thread resources.
     (void)app;
     pthread_join(g_qt_thread, nullptr);
+#endif
 }
 
 // Expose is_qt_main_thread() to C code (for use by Gerbil trampolines in libqt.ss).