Commit9af92b51Recorded21 Jul 2026Repositorylantern
lantern: add page-invokable app.quit command
Message
app.quit lives in the built-in core module and triggers the existing clean-exit path: webview-quit sets the quit flag the run-loop watcher polls, which then runs bridge-stop! + exit 0. A generic, reusable window-lifecycle capability exposed as a builder (app-quit-command) so a locked-down embedding can register it selectively; any app opting into 'core can be closed from its page with no extra wiring.
Tests drive the real command through the bridge and assert the quit flag flips (webview-should-quit? -> #t) plus a correlatable ack.
Changed
lantern/src/lantern/app.sgl | 28 +++++++++++++++++++++++++++-
lantern/test/test-lantern.sgl | 39 +++++++++++++++++++++++++++++++++++++++
2 files changed, 66 insertions(+), 1 deletion(-)Diff
lantern/src/lantern/app.sglmodified
@@ -34,6 +34,7 @@
34
lantern-plugin 35
lantern-app 36
lantern-run+37
app-quit-command 38
default-nav-handler) 39
(begin 40
@@ -70,10 +71,35 @@
71
;; `core` module so feature detection and a round-trip smoke test work out of 72
;; the box. Opting into an unknown module is a startup error, by design. 73
+74
;; `app.quit` — the generic, page-invokable window-lifecycle command. It+75
;; triggers the host's EXISTING clean-exit path: `webview-quit` sets the quit+76
;; flag that both `run-loop` and the app's quit-watcher poll, and the watcher+77
;; then runs `(bridge-stop! bridge) (exit 0)` (see lantern-run below). The+78
;; handler pulls the webview from ctx (the bridge hands every handler+79
;; `#{ wv: window: app: emit: emit-bulk: }`) and returns a small ack so the+80
;; caller can correlate completion.+81
;;+82
;; This is a REUSABLE Lantern capability, not app-specific: any container app+83
;; opting into the `core` module can be closed from its page. Exposed as a+84
;; builder so a locked-down embedding (or a test) can register it selectively.+85
;;+86
;; NOTE on ordering: `exit 0` is a hard process exit — it does NOT drain+87
;; in-flight command goroutines. A page with unsaved writes MUST therefore+88
;; sequence its `app.quit` AFTER those writes have been acked (the ack of a+89
;; synchronous fs.write-file means the bytes are already on disk), so no write+90
;; is in flight when the process exits. That sequencing is the caller's+91
;; responsibility (see Slate's `lantern-save-then-quit!`).+92
(define (app-quit-command)+93
(lantern-command "app.quit"+94
(lambda (args ctx)+95
(webview-quit (dict-ref ctx wv: #f))+96
(dict ok: #t))))+97
98
(define builtin-core-commands 99
(list 100
(lantern-command "lantern.ping" (lambda (args ctx) "pong"))−76
(lantern-command "lantern.echo" (lambda (args ctx) args))))+101
(lantern-command "lantern.echo" (lambda (args ctx) args))+102
(app-quit-command))) 103
104
(define *builtin-modules* 105
(list (cons 'core builtin-core-commands)))lantern/test/test-lantern.sglmodified
@@ -394,6 +394,45 @@
394
(assert-true (procedure? (dict-ref (registry-lookup reg "demo.hi") handler: #f))) 395
(assert-true (procedure? (dict-ref (registry-lookup reg "lantern.ping") handler: #f))))) 396
+397
;; ============================================================+398
;; app.quit — the page-invokable clean-exit command+399
;; ============================================================+400
+401
;; A fake backend that records eval-js AND models the quit flag, so we can prove+402
;; app.quit's handler triggers the SAME clean-exit path the run-loop watcher polls+403
;; (webview-should-quit? flips true, after which lantern-run does bridge-stop! ++404
;; exit 0). We drive the REAL registered command through the REAL bridge.+405
(define (quit-tracking-backend flag-box)+406
(make-backend 'fake+407
(dict eval-js: (lambda (win js)+408
(vector-set! *evals* 0 (cons js (vector-ref *evals* 0))))+409
quit: (lambda () (vector-set! flag-box 0 #t))+410
should-quit?: (lambda () (vector-ref flag-box 0)))))+411
+412
(test "app.quit is registered in the built-in core module"+413
;; core is the always-on baseline; opting into it must expose app.quit so a+414
;; container app (e.g. slate-desktop) can be closed from its page with no extra+415
;; wiring. Rebuild the registry the runner does and look the command up.+416
(let ((reg (make-registry (list (lantern-command "lantern.ping" (lambda (a c) "pong"))+417
(app-quit-command)))))+418
(assert-true (procedure? (dict-ref (registry-lookup reg "app.quit") handler: #f)))))+419
+420
(test "app.quit flips the host quit flag via webview-quit (the clean-exit path)"+421
(reset-evals!)+422
(let* ((flag (make-vector 1 #f))+423
(wv (quit-tracking-backend flag))+424
(bridge (make-bridge wv (dict fake-window: #t)+425
(make-registry (list (app-quit-command)))+426
(dict fake-app: #t))))+427
;; before: the watcher would keep looping (not quitting)+428
(assert-false (webview-should-quit? wv))+429
(bridge-handle bridge "{\"id\": 42, \"cmd\": \"app.quit\", \"args\": {}}")+430
;; after: the flag is set, so the watcher's next tick exits the process+431
(assert-true (webview-should-quit? wv))+432
;; and the caller gets a correlatable ack+433
(assert-true (string-contains? (last-eval) "window.lantern._resolve(42,"))+434
(assert-true (string-contains? (last-eval) "\"ok\":true"))))+435
436
;; ============================================================ 437
;; (lantern js) 438
;; ============================================================