Replace launcher with a self-contained native Jerboa binary

ober

35d39937ad74dd99cb1dfa995efc2557d77370ea

diff --git a/.build.yml b/.build.yml
index 18499b1..37400ec 100644
--- a/.build.yml
+++ b/.build.yml
@@ -20,6 +20,9 @@ tasks:
       bash packaging/linux/build-tarball.sh
   - verify: |
       cd jerboa-browser
+      # This CI image has no Chez/Jerboa, so build-tarball.sh ships sources only
+      # (no native binary). Verify the FFI .so + the tarball; the self-contained
+      # jerboa-browser binary is built/checked on a host with the toolchain.
       so=qt-webengine/build/libjerboa_browser.so
       tb=qt-webengine/build/jerboa-browser-0.0.1-linux-x86_64.tar.gz
       test -f "$so" || { echo "MISSING $so"; exit 1; }
@@ -29,3 +32,5 @@ tasks:
       ldd "$so" | grep -i 'qt6\|webengine' || true
       echo "--- tarball ---"
       ls -lh "$tb"
+      tar tzf "$tb" | grep -E 'lib/libjerboa_browser.so$' \
+        || { echo "lib missing from tarball"; exit 1; }
diff --git a/.gitignore b/.gitignore
index 1356f66..9564f40 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,5 +2,14 @@
 build/
 **/build/
 
+# Native binary + Jerboa build artifacts (see build-binary.ss / Makefile)
+/jerboa-browser
+.bcache/
+*.so
+*.wpo
+*.boot
+jb-main.c
+jb_*.h
+
 # macOS
 .DS_Store
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..534e6d3
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,35 @@
+JERBOA_HOME ?= $(realpath $(CURDIR)/../jerboa)
+SCHEME      ?= $(JERBOA_HOME)/.chez/bin/scheme
+LIBDIRS     := $(CURDIR)/scheme:$(JERBOA_HOME)/lib
+
+.PHONY: binary test repl clean help
+.DEFAULT_GOAL := help
+
+# Build the self-contained native ./jerboa-browser (Chez + boot + (browser)).
+binary:
+	JERBOA_HOME=$(JERBOA_HOME) $(SCHEME) -q --libdirs $(LIBDIRS) --script build-binary.ss
+	@echo "" && ls -lh jerboa-browser && file jerboa-browser
+
+# Run the (browser) test suite in the interpreter (no binary needed).
+test:
+	JERBOA_HOME=$(JERBOA_HOME) $(SCHEME) -q --libdirs $(LIBDIRS) --script scheme/browser-test.ss
+
+# REPL with (browser) preloaded, in the interpreter.
+repl:
+	JERBOA_HOME=$(JERBOA_HOME) $(SCHEME) -q --libdirs $(LIBDIRS) --program scheme/browser-repl.ss
+
+clean:
+	rm -f jerboa-browser jb-main.c jb-main.o jb_*.h jerboa-browser.boot
+	rm -rf .bcache
+	find scheme -name '*.so' -delete 2>/dev/null || true
+	find scheme -name '*.wpo' -delete 2>/dev/null || true
+
+help:
+	@echo "jerboa-browser — programmable Qt WebEngine pane from the Jerboa REPL"
+	@echo ""
+	@echo "  make binary   build the self-contained native ./jerboa-browser"
+	@echo "  make test     run the (browser) test suite (interpreter)"
+	@echo "  make repl     REPL with (browser) preloaded (interpreter)"
+	@echo "  make clean    remove build artifacts"
+	@echo ""
+	@echo "  env: JERBOA_HOME (default ../jerboa), SCHEME, JERBOA_BROWSER_LIB"
diff --git a/bin/jerboa-browser b/bin/jerboa-browser
deleted file mode 100755
index 54b7452..0000000
--- a/bin/jerboa-browser
+++ /dev/null
@@ -1,57 +0,0 @@
-#!/usr/bin/env bash
-#
-# jerboa-browser — run a Jerboa REPL or script with the (browser) library and
-# its native dylib on the path.
-#
-# Resolves the repo-local Chez + Jerboa stdlib, puts scheme/ on --libdirs so
-# (import (browser)) works, and points JERBOA_BROWSER_LIB at the built dylib.
-#
-# Usage:
-#   bin/jerboa-browser              # REPL with (browser) preloaded
-#   bin/jerboa-browser repl
-#   bin/jerboa-browser test         # run scheme/browser-test.ss
-#   bin/jerboa-browser run <file>   # run a Jerboa script
-#
-# Override JERBOA_HOME / SCHEME / JERBOA_BROWSER_LIB via the environment.
-
-set -euo pipefail
-
-REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-JERBOA_HOME="${JERBOA_HOME:-$HOME/mine/jerboa}"
-SCHEME="${SCHEME:-$JERBOA_HOME/.chez/bin/scheme}"
-LIBDIRS="$JERBOA_HOME/lib:$REPO/scheme"
-
-export JERBOA_BROWSER_LIB="${JERBOA_BROWSER_LIB:-$REPO/qt-webengine/build/libjerboa_browser.dylib}"
-
-if [ ! -x "$SCHEME" ]; then
-    echo "Error: Chez Scheme not found at $SCHEME (set SCHEME or JERBOA_HOME)" >&2
-    exit 1
-fi
-if [ ! -f "$JERBOA_BROWSER_LIB" ]; then
-    echo "Error: backend dylib not found at $JERBOA_BROWSER_LIB" >&2
-    echo "Build it: cmake -S qt-webengine -B qt-webengine/build -DCMAKE_PREFIX_PATH=/opt/homebrew && cmake --build qt-webengine/build" >&2
-    exit 1
-fi
-
-case "${1:-repl}" in
-    repl)
-        exec "$SCHEME" --libdirs "$LIBDIRS" --program <(cat <<'REPL'
-(import (jerboa prelude) (browser) (std repl))
-(display ";; (browser) loaded — call (browser-init) then (browser-open-context ...)\n")
-(jerboa-repl)
-REPL
-        )
-        ;;
-    test)
-        exec "$SCHEME" --libdirs "$LIBDIRS" --script "$REPO/scheme/browser-test.ss"
-        ;;
-    run)
-        shift
-        [ $# -ge 1 ] || { echo "usage: jerboa-browser run <file>" >&2; exit 1; }
-        exec "$SCHEME" --libdirs "$LIBDIRS" --script "$1"
-        ;;
-    *)
-        echo "usage: jerboa-browser [repl|test|run <file>]" >&2
-        exit 1
-        ;;
-esac
diff --git a/build-binary.ss b/build-binary.ss
new file mode 100644
index 0000000..c944d5a
--- /dev/null
+++ b/build-binary.ss
@@ -0,0 +1,174 @@
+#!chezscheme
+;;; build-binary.ss — compile scheme/browser-main.ss into a self-contained
+;;; native `jerboa-browser` executable: the Chez kernel, the boot files, the
+;;; (browser) library AND the full library closure it (and the REPL) need are
+;;; all embedded, so `(import (browser))` works at the prompt with no library
+;;; path. No hand-written C/C++: this script generates a tiny C bootstrap and
+;;; links it (the gitsafe recipe). The native Qt backend (libjerboa_browser.*)
+;;; stays a runtime dlopen; the Chromium sandbox is left on.
+;;;
+;;; Usage:  make binary
+;;;   (scheme -q --libdirs scheme:$JERBOA_HOME/lib --script build-binary.ss)
+;;;
+;;; macOS  → statically links Chez kernel/lz4/zlib/ncurses (only libSystem +
+;;;          libiconv dynamic). Linux → dynamic link against the system Chez libs.
+;;; Compiled library objects go to ./.bcache so the read-only Jerboa tree is
+;;; never written to.
+
+(import (chezscheme))
+
+(define repo (current-directory))
+(define bcache (format "~a/.bcache" repo))
+
+(define machine (symbol->string (machine-type)))
+(define macos?
+  (or (string=? machine "ta6osx") (string=? machine "a6osx")
+      (string=? machine "tarm64osx") (string=? machine "arm64osx")))
+
+;; --- locate Jerboa + Chez ------------------------------------------------
+(define jerboa-dir
+  (or (getenv "JERBOA_HOME")
+      (let ((p (format "~a/mine/jerboa" (getenv "HOME"))))
+        (and (file-exists? p) p))
+      (begin (display "Error: cannot find Jerboa; set JERBOA_HOME\n") (exit 1))))
+
+(define (find-csv-dir lib-dir mt)
+  (let ((csv (let lp ((dirs (guard (e (#t '())) (directory-list lib-dir))))
+               (cond
+                 ((null? dirs) #f)
+                 ((and (> (string-length (car dirs)) 3)
+                       (string=? "csv" (substring (car dirs) 0 3)))
+                  (format "~a/~a/~a" lib-dir (car dirs) mt))
+                 (else (lp (cdr dirs)))))))
+    (and csv (file-exists? (format "~a/main.o" csv)) csv)))
+
+(define chez-dir
+  (or (getenv "CHEZ_DIR")
+      (find-csv-dir (format "~a/.chez/lib" jerboa-dir) machine)
+      (find-csv-dir (format "~a/.local/lib" (getenv "HOME")) machine)
+      (find-csv-dir "/opt/homebrew/lib" machine)
+      (find-csv-dir "/usr/local/lib" machine)
+      (find-csv-dir "/usr/lib" machine)
+      (begin (display "Error: cannot find Chez install dir; set CHEZ_DIR\n") (exit 1))))
+
+(printf "Jerboa dir: ~a\nChez dir:   ~a\nMachine:    ~a\n" jerboa-dir chez-dir machine)
+
+;; --- library search path -------------------------------------------------
+;; (source . object) pairs: read .ss/.sls from the repo + Jerboa stdlib, write
+;; the compiled .so under ./.bcache (NOT into the read-only Jerboa tree).
+(system (format "rm -rf ~a" bcache))
+(mkdir bcache)
+(library-directories
+  (list (cons (format "~a/scheme" repo) (format "~a/scheme" bcache))
+        (cons (format "~a/lib" jerboa-dir) (format "~a/jlib" bcache))))
+
+;; --- helpers -------------------------------------------------------------
+(define (file->c-header in out array size-name)
+  (let* ((p (open-file-input-port in))
+         (data (get-bytevector-all p))
+         (n (bytevector-length data)))
+    (close-port p)
+    (call-with-output-file out
+      (lambda (o)
+        (fprintf o "/* Auto-generated — do not edit */\n")
+        (fprintf o "static const unsigned char ~a[] = {\n" array)
+        (let lp ((i 0))
+          (when (< i n)
+            (when (= 0 (modulo i 16)) (fprintf o "  "))
+            (fprintf o "0x~2,'0x" (bytevector-u8-ref data i))
+            (when (< (+ i 1) n) (fprintf o ","))
+            (when (= 15 (modulo i 16)) (fprintf o "\n"))
+            (lp (+ i 1))))
+        (fprintf o "\n};\n")
+        (fprintf o "static const unsigned int ~a = ~a;\n" size-name n))
+      'replace)
+    (printf "  ~a: ~a bytes\n" out n)))
+
+;; All compiled .so under dir (recursively) — the library closure to embed.
+(define (find-so dir)
+  (let loop ((entries (guard (e (#t '())) (directory-list dir))) (acc '()))
+    (if (null? entries) acc
+        (let* ((name (car entries)) (path (string-append dir "/" name)))
+          (cond
+            ((file-directory? path) (loop (cdr entries) (append (find-so path) acc)))
+            ((and (> (string-length name) 3)
+                  (string=? ".so" (substring name (- (string-length name) 3) (string-length name))))
+             (loop (cdr entries) (cons path acc)))
+            (else (loop (cdr entries) acc)))))))
+
+;; --- [1/4] compile the program + its full imported-library closure -------
+(printf "\n[1/4] Compiling (optimize-level 3)...\n")
+(parameterize ((compile-imported-libraries         #t)
+               (optimize-level                     3)
+               (cp0-effort-limit                   500)
+               (cp0-score-limit                    50)
+               (cp0-outer-unroll-limit             1)
+               (commonization-level                4)
+               (enable-arithmetic-left-associative #t)
+               (debug-level                        0)
+               (generate-inspector-information     #f))
+  (compile-program "scheme/browser-main.ss"))
+
+;; --- [2/4] boot file (program closure) + C headers -----------------------
+(printf "[2/4] Boot file + C headers...\n")
+(define closure (find-so bcache))
+(printf "  embedding ~a libraries\n" (length closure))
+(apply make-boot-file "jerboa-browser.boot" '("scheme" "petite") closure)
+(file->c-header "scheme/browser-main.so"           "jb_program.h" "jb_program_data" "jb_program_size")
+(file->c-header (format "~a/petite.boot" chez-dir)  "jb_petite_boot.h" "petite_boot_data" "petite_boot_size")
+(file->c-header (format "~a/scheme.boot" chez-dir)  "jb_scheme_boot.h" "scheme_boot_data" "scheme_boot_size")
+(file->c-header "jerboa-browser.boot"              "jb_boot.h" "jb_boot_data" "jb_boot_size")
+
+;; --- [3/4] generate C bootstrap, compile + link --------------------------
+(printf "[3/4] Compiling + linking...\n")
+(call-with-output-file "jb-main.c"
+  (lambda (o)
+    (fprintf o "/* Auto-generated — do not edit */\n")
+    (fprintf o "#define _GNU_SOURCE\n#include <stdlib.h>\n#include <stdio.h>\n")
+    (fprintf o "#include <string.h>\n#include <unistd.h>\n#include \"scheme.h\"\n")
+    (fprintf o "#include \"jb_petite_boot.h\"\n#include \"jb_scheme_boot.h\"\n")
+    (fprintf o "#include \"jb_boot.h\"\n#include \"jb_program.h\"\n\n")
+    (fprintf o "int main(int argc, char *argv[]) {\n")
+    (fprintf o "  char prog_path[256];\n")
+    (fprintf o "  const char *tmpdir = getenv(\"TMPDIR\"); if (!tmpdir) tmpdir = \"/tmp\";\n")
+    (display  "  snprintf(prog_path, sizeof(prog_path), \"%s/jerboa-browser-XXXXXX\", tmpdir);\n" o)
+    (fprintf o "  int fd = mkstemp(prog_path);\n")
+    (fprintf o "  if (fd < 0) { perror(\"mkstemp\"); return 1; }\n")
+    (fprintf o "  if (write(fd, jb_program_data, jb_program_size) != (ssize_t)jb_program_size) {\n")
+    (fprintf o "    perror(\"write\"); close(fd); unlink(prog_path); return 1; }\n")
+    (fprintf o "  close(fd);\n")
+    (fprintf o "  Sscheme_init(NULL);\n")
+    (fprintf o "  Sregister_boot_file_bytes(\"petite\", (void*)petite_boot_data, petite_boot_size);\n")
+    (fprintf o "  Sregister_boot_file_bytes(\"scheme\", (void*)scheme_boot_data, scheme_boot_size);\n")
+    (fprintf o "  Sregister_boot_file_bytes(\"jerboa-browser\", (void*)jb_boot_data, jb_boot_size);\n")
+    (fprintf o "  Sbuild_heap(NULL, NULL);\n")
+    (fprintf o "  int status = Sscheme_script(prog_path, argc, (const char **)argv);\n")
+    (fprintf o "  unlink(prog_path); Sscheme_deinit(); return status;\n}\n"))
+  'replace)
+
+(define cc (or (getenv "CC") "cc"))
+(define (sh cmd) (printf "  ~a\n" cmd) (let ((rc (system cmd))) (unless (= rc 0) (printf "Error (rc=~a)\n" rc) (exit 1))))
+
+(sh (format "~a -c -O2 -I~a -o jb-main.o jb-main.c" cc chez-dir))
+(if macos?
+    (let* ((ncurses (or (let ((p (getenv "NCURSES_STATIC_PATH"))) (and p (> (string-length p) 0) (file-exists? p) p))
+                        (let ((p "/opt/homebrew/opt/ncurses/lib/libncurses.a")) (and (file-exists? p) p))
+                        (let ((p "/usr/local/opt/ncurses/lib/libncurses.a")) (and (file-exists? p) p))))
+           (static (format "~a/libkernel.a ~a/liblz4.a ~a/libz.a~a"
+                           chez-dir chez-dir chez-dir (if ncurses (string-append " " ncurses) "")))
+           (dynamic (string-append (if ncurses "" " -lncurses") " -liconv -lpthread -lm")))
+      (sh (format "~a -o jerboa-browser jb-main.o ~a~a" cc static dynamic))
+      (sh "strip -x jerboa-browser"))
+    (begin
+      (sh (format "~a -o jerboa-browser jb-main.o -L~a -lkernel -llz4 -lz -lm -ldl -lpthread -luuid -lncurses"
+                  cc chez-dir))
+      (sh "strip jerboa-browser")))
+
+;; --- [4/4] cleanup -------------------------------------------------------
+(printf "[4/4] Cleanup...\n")
+(for-each (lambda (f) (when (file-exists? f) (delete-file f)))
+  '("jb-main.c" "jb-main.o" "jb_program.h" "jb_petite_boot.h" "jb_scheme_boot.h"
+    "jb_boot.h" "jerboa-browser.boot" "scheme/browser-main.so"))
+(system (format "rm -rf ~a" bcache))
+
+(printf "\nDone! Binary: ./jerboa-browser\n")
diff --git a/jerboa-browser.md b/jerboa-browser.md
index 63b2f7a..38c0543 100644
--- a/jerboa-browser.md
+++ b/jerboa-browser.md
@@ -647,8 +647,12 @@ Result (macOS, the verified target):
   `icudtl.dat` resources, platform/imageformat/tls/etc. plugins) and documents
   the fixups and why macdeployqt alone SIGABRTs on first page load.
 - Linux: `packaging/linux/build-tarball.sh` stages backend `.so` + `scheme/` +
-  launcher relying on system Qt6 WebEngine; marked **not verified on the macOS
-  dev host**, with linuxdeployqt/AppImage noted as the no-prerequisite path.
+  `build-binary.ss`/`Makefile`, and — when the Jerboa toolchain is present —
+  compiles the self-contained native `jerboa-browser` binary (a Jerboa program
+  embedding Chez + boot + `(browser)`, built by `build-binary.ss`, not a C/C++
+  launcher; `make binary` otherwise). Relies on system Qt6 WebEngine; the
+  `.so`+tarball build is green on builds.sr.ht (amd64, no Chez there, so it ships
+  sources), with linuxdeployqt/AppImage noted as the no-prerequisite path.
 
 Goal: make deployment honest and reproducible.
 
diff --git a/packaging/README.md b/packaging/README.md
index 4d92cda..4b7b758 100644
--- a/packaging/README.md
+++ b/packaging/README.md
@@ -101,8 +101,13 @@ packaging/linux/build-tarball.sh      # → qt-webengine/build/jerboa-browser-<v
 > **Status: not verified on this host.** Development is on macOS, so the Linux
 > script is provided and reviewed but has not been executed end-to-end here. The
 > tarball bundles the backend `libjerboa_browser.so`, the Jerboa `scheme/`
-> sources, and the `bin/jerboa-browser` launcher; it relies on a **system Qt 6
-> WebEngine** install (documented prerequisite) rather than vendoring Qt.
+> sources, and `build-binary.ss` + `Makefile`. When the Jerboa toolchain (Chez +
+> Jerboa stdlib) is present on the build host it also compiles and bundles the
+> self-contained native `bin/jerboa-browser` — a Jerboa program with the Chez
+> kernel, boot image, and the `(browser)` library all embedded (built by
+> `build-binary.ss`, **not** a C/C++ launcher); otherwise unpack and run `make
+> binary` to produce it. Either way it relies on a **system Qt 6 WebEngine**
+> install (documented prerequisite) rather than vendoring Qt.
 
 For a fully self-contained Linux artifact, run the bundle through
 [`linuxdeployqt`](https://github.com/probonopd/linuxdeployqt) to produce an
diff --git a/packaging/linux/build-tarball.sh b/packaging/linux/build-tarball.sh
index 769f643..756ed5e 100755
--- a/packaging/linux/build-tarball.sh
+++ b/packaging/linux/build-tarball.sh
@@ -1,16 +1,16 @@
 #!/usr/bin/env bash
 #
-# build-tarball.sh — Ticket 3.1: package the Jerboa Browser for Linux.
+# build-tarball.sh — package the Jerboa Browser for Linux.
 #
-# Produces a relocatable tarball containing the browser backend shared object,
-# the Jerboa `(browser)` scheme sources, and a launcher. It relies on a SYSTEM
-# Qt 6 WebEngine install (documented prerequisite) rather than vendoring Qt; for
-# a fully self-contained artifact run the staged tree through linuxdeployqt to
-# get an AppImage (see packaging/README.md).
+# Builds the Qt WebEngine FFI backend (libjerboa_browser.so) via CMake, then —
+# when the Jerboa toolchain (Chez + Jerboa stdlib) is available — compiles the
+# self-contained native `jerboa-browser` binary with build-binary.ss. The
+# tarball always ships the sources + build files so the binary can be built
+# later with `make binary`. It relies on a SYSTEM Qt 6 WebEngine install rather
+# than vendoring Qt; for a fully self-contained artifact run the staged tree
+# through linuxdeployqt to get an AppImage (see packaging/README.md).
 #
-# STATUS: not verified on the macOS dev host — review before relying on it.
-#
-# Env overrides: BUILD, QT_PREFIX, VERSION.
+# Env overrides: BUILD, QT_PREFIX, VERSION, JERBOA_HOME, SCHEME.
 set -euo pipefail
 
 REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
@@ -19,8 +19,10 @@ VERSION="${VERSION:-0.0.1}"
 ARCH="$(uname -m)"
 STAGE="$BUILD/jerboa-browser-$VERSION-linux-$ARCH"
 TARBALL="$STAGE.tar.gz"
+JERBOA_HOME="${JERBOA_HOME:-$HOME/mine/jerboa}"
+SCHEME="${SCHEME:-$JERBOA_HOME/.chez/bin/scheme}"
 
-echo "==> configuring + building the backend (.so)"
+echo "==> configuring + building the FFI backend (.so)"
 cmake_args=(-S "$REPO/qt-webengine" -B "$BUILD")
 [ -n "${QT_PREFIX:-}" ] && cmake_args+=(-DCMAKE_PREFIX_PATH="$QT_PREFIX")
 cmake "${cmake_args[@]}" >/dev/null
@@ -34,20 +36,24 @@ rm -rf "$STAGE"
 mkdir -p "$STAGE/lib" "$STAGE/bin" "$STAGE/scheme"
 cp -f "$LIB" "$STAGE/lib/"
 cp -f "$REPO"/scheme/*.ss "$STAGE/scheme/" 2>/dev/null || true
+cp -f "$REPO/build-binary.ss" "$REPO/Makefile" "$STAGE/"
 
-# Relocatable launcher: resolves its own dir, points the Jerboa toolchain at the
-# bundled backend, and forwards to the repo's bin/jerboa-browser logic.
-cat > "$STAGE/bin/jerboa-browser" <<'LAUNCH'
-#!/usr/bin/env bash
-set -euo pipefail
-HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
-export JERBOA_BROWSER_LIB="$HERE/lib/libjerboa_browser.so"
-export LD_LIBRARY_PATH="$HERE/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
-# Jerboa toolchain + scheme path are expected from the environment / system
-# install; $HERE/scheme holds the (browser) module sources.
-exec "${JERBOA:-jerboa}" "$@"
-LAUNCH
-chmod +x "$STAGE/bin/jerboa-browser"
+# The native binary embeds Chez + the (browser) library; build it when the
+# Jerboa toolchain is present (e.g. a developer host). CI images without Chez
+# skip this — the tarball still carries the sources + build-binary.ss.
+HAVE_BIN=0
+if [ -x "$SCHEME" ]; then
+    echo "==> building the native jerboa-browser binary ($SCHEME)"
+    ( cd "$REPO" && JERBOA_HOME="$JERBOA_HOME" SCHEME="$SCHEME" make binary )
+    if [ -x "$REPO/jerboa-browser" ]; then
+        cp -f "$REPO/jerboa-browser" "$STAGE/bin/jerboa-browser"
+        chmod +x "$STAGE/bin/jerboa-browser"
+        HAVE_BIN=1
+    fi
+else
+    echo "==> Chez/Jerboa not found at $SCHEME — skipping the native binary"
+    echo "    (unpack, then run 'make binary' with the Jerboa toolchain installed)"
+fi
 
 cat > "$STAGE/README.txt" <<EOF
 Jerboa Browser $VERSION (linux-$ARCH)
@@ -57,14 +63,18 @@ Prerequisite: Qt 6 WebEngine runtime installed system-wide
    equivalent). The Chromium sandbox is left enabled.
 
 Layout:
-  lib/libjerboa_browser.so   browser backend (FFI target)
-  scheme/                    the (browser) Jerboa module
-  bin/jerboa-browser         launcher (sets JERBOA_BROWSER_LIB + LD_LIBRARY_PATH)
+  lib/libjerboa_browser.so   browser backend (the native binary dlopens this)
+  scheme/                    the (browser) Jerboa module + entry points
+  build-binary.ss, Makefile  build the native binary: \`make binary\`
+$([ "$HAVE_BIN" = 1 ] && echo "  bin/jerboa-browser         self-contained native binary (repl | test | run <file>)" || echo "  bin/                       (empty — build with 'make binary'; needs Chez + Jerboa)")
+
+The native jerboa-browser binary embeds Chez Scheme, the boot image, and the
+(browser) library; only libjerboa_browser.so + system Qt 6 are external.
 
 For a no-prerequisite AppImage, run this tree through linuxdeployqt.
 EOF
 
 echo "==> creating $TARBALL"
 tar -C "$BUILD" -czf "$TARBALL" "$(basename "$STAGE")"
-echo "    $TARBALL"
+echo "    $TARBALL  (native binary: $([ "$HAVE_BIN" = 1 ] && echo yes || echo no))"
 du -sh "$TARBALL"
diff --git a/scheme/browser-main.ss b/scheme/browser-main.ss
new file mode 100644
index 0000000..bc5a460
--- /dev/null
+++ b/scheme/browser-main.ss
@@ -0,0 +1,74 @@
+#!chezscheme
+;;; browser-main.ss — entry point for the compiled `jerboa-browser` binary.
+;;;
+;;; This is the program that build-binary.ss compiles + whole-program-optimizes
+;;; into a self-contained native executable (Chez kernel + boot files + the
+;;; (browser) library all embedded). At runtime only the native Qt backend
+;;; (libjerboa_browser.{dylib,so}) and a system Qt 6 WebEngine are needed; the
+;;; Chromium sandbox is left on.
+;;;
+;;;   jerboa-browser            REPL with (browser) preloaded
+;;;   jerboa-browser repl
+;;;   jerboa-browser test       run the (browser) test suite
+;;;   jerboa-browser run FILE   run a Jerboa script with (browser) available
+
+(import (chezscheme) (browser) (std repl))
+
+;; Directory of argv[0], or "." when it has no slash / is unavailable.
+(define (exe-dir)
+  (let ((cl (command-line)))
+    (if (pair? cl)
+        (let* ((p (car cl))
+               (i (let loop ((i (- (string-length p) 1)))
+                    (cond ((< i 0) #f)
+                          ((char=? (string-ref p i) #\/) i)
+                          (else (loop (- i 1)))))))
+          (if i (substring p 0 i) "."))
+        ".")))
+
+;; First existing candidate path for a bundled file `name`, else #f. Covers the
+;; in-tree run (cwd = repo root) and the relocated tarball (bin/ + scheme/).
+(define (locate name)
+  (let* ((d (exe-dir))
+         (cands (list (string-append d "/../scheme/" name)
+                      (string-append d "/scheme/" name)
+                      (string-append d "/" name)
+                      (string-append "scheme/" name))))
+    (let loop ((cs cands))
+      (cond ((null? cs) #f)
+            ((file-exists? (car cs)) (car cs))
+            (else (loop (cdr cs)))))))
+
+(define (run-repl)
+  ;; jerboa-repl evaluates in (interaction-environment); import (browser) into it
+  ;; so browser-* is callable at the prompt with no further imports.
+  (eval '(import (browser)) (interaction-environment))
+  (display ";; (browser) loaded — call (browser-init) then (browser-open-context ...)\n")
+  (jerboa-repl))
+
+(define (usage port code)
+  (display "usage: jerboa-browser [repl|test|run <file>]\n" port)
+  (exit code))
+
+(define (main)
+  (let ((args (cdr (command-line))))
+    (cond
+      ((or (null? args) (string=? (car args) "repl"))
+       (run-repl))
+      ((string=? (car args) "test")
+       (let ((f (locate "browser-test.ss")))
+         (if f
+             (load f)                 ; the suite calls (exit) with its own code
+             (begin (display "jerboa-browser: test suite (browser-test.ss) not found\n"
+                             (current-error-port))
+                    (exit 1)))))
+      ((string=? (car args) "run")
+       (if (pair? (cdr args))
+           (load (cadr args))
+           (usage (current-error-port) 1)))
+      ((member (car args) '("-h" "--help" "help"))
+       (usage (current-output-port) 0))
+      (else
+       (usage (current-error-port) 1)))))
+
+(main)
diff --git a/scheme/browser-repl.ss b/scheme/browser-repl.ss
new file mode 100644
index 0000000..fe9483a
--- /dev/null
+++ b/scheme/browser-repl.ss
@@ -0,0 +1,9 @@
+;;; browser-repl.ss — REPL bootstrap for the `jerboa-browser` CLI.
+;;;
+;;; Run as an R6RS top-level program (scheme --program): preload the Jerboa
+;;; prelude and the (browser) wrapper, print a one-line hint, then hand control
+;;; to the Jerboa REPL. The `jerboa-browser` binary sets --libdirs and
+;;; JERBOA_BROWSER_LIB before exec'ing Chez on this file.
+(import (jerboa prelude) (browser) (std repl))
+(displayln ";; (browser) loaded — call (browser-init) then (browser-open-context ...)")
+(jerboa-repl)
diff --git a/scheme/browser.ss b/scheme/browser.ss
index 1ef3cca..261f541 100644
--- a/scheme/browser.ss
+++ b/scheme/browser.ss
@@ -71,9 +71,43 @@
   ;; This MUST precede the define-c-lambda binds: foreign-procedure resolution
   ;; is eager in Chez, so the symbols have to be loaded first. Binding the load
   ;; to a def keeps it a definition (library bodies evaluate defs in order).
+  ;; Native backend extension: .dylib on macOS, .so elsewhere.
+  (def (jwb-lib-ext)
+    (let ((mt (symbol->string (machine-type))))
+      (if (or (string=? mt "ta6osx") (string=? mt "a6osx")
+              (string=? mt "tarm64osx") (string=? mt "arm64osx"))
+          "dylib" "so")))
+
+  ;; Directory of argv[0] ("." when it has no slash / is unavailable).
+  (def (jwb-exe-dir)
+    (let ((cl (command-line)))
+      (if (pair? cl)
+          (let* ((p (car cl))
+                 (i (let loop ((i (- (string-length p) 1)))
+                      (cond ((< i 0) #f)
+                            ((char=? (string-ref p i) #\/) i)
+                            (else (loop (- i 1)))))))
+            (if i (substring p 0 i) "."))
+          ".")))
+
+  ;; Find libjerboa_browser next to the binary (relocatable tarball: bin/ + lib/),
+  ;; in the in-tree build dir, else fall back to the first candidate for a clear
+  ;; error. JERBOA_BROWSER_LIB overrides all of this.
+  (def (jwb-find-lib)
+    (let* ((name (string-append "libjerboa_browser." (jwb-lib-ext)))
+           (d (jwb-exe-dir))
+           (cands (list (string-append d "/../lib/" name)
+                        (string-append d "/" name)
+                        (string-append d "/../qt-webengine/build/" name)
+                        (string-append "qt-webengine/build/" name))))
+      (let loop ((cs cands))
+        (cond ((null? cs) (car cands))
+              ((file-exists? (car cs)) (car cs))
+              (else (loop (cdr cs)))))))
+
   (def browser-lib-path
     (or (getenv "JERBOA_BROWSER_LIB")
-        "/Users/user/mine/jerboa-browser/qt-webengine/build/libjerboa_browser.dylib"))
+        (jwb-find-lib)))
 
   (def _loaded
     (begin