Commit205681b7Recorded21 Jul 2026Repositorylantern
lantern: opt-in page-driven window close (intercept-close)
Message
A WM close-request can be routed to the page instead of quitting directly. lantern-app gains an intercept-close: flag; when set, lantern-run registers a close-handler that emits an app.close-requested event and vetoes the immediate GTK close, so the page can prompt (e.g. save unsaved work) and call app.quit itself once the user confirms. The GTK backend's on-close-request consults the handler (return #t keeps the window open); a throwing or absent handler falls through to the default flag-and-close, so a broken page can never trap the window open. Adds a tolerant webview-set-close-handler op (no-op on backends that cannot veto) and a lantern-cli --intercept-close flag. +3 tests.
Changed
lantern-cli/src/lantern-cli/main.sgl | 8 +++++++-
lantern/src/lantern/app.sgl | 24 ++++++++++++++++++++++--
lantern/src/lantern/backend/gtk.sgl | 21 ++++++++++++++++++---
lantern/src/lantern/webview.sgl | 7 +++++++
lantern/test/test-lantern.sgl | 26 ++++++++++++++++++++++++++
5 files changed, 80 insertions(+), 6 deletions(-)Diff
lantern-cli/src/lantern-cli/main.sglmodified
@@ -93,11 +93,17 @@
93
(width (parse-int (opt-value args "--width" #f) 1024)) 94
(height (parse-int (opt-value args "--height" #f) 768)) 95
(decorated (not (has-flag? args "--no-decorations")))−96
(transparent (has-flag? args "--transparent")))+96
(transparent (has-flag? args "--transparent"))+97
;; --intercept-close: hand a WM close-request to the page (an+98
;; app.close-requested event) and veto the immediate close, so a page+99
;; that wires that event can prompt before exit. Off by default — a page+100
;; that ignores the event would otherwise be unclosable via the WM.+101
(intercept-close (has-flag? args "--intercept-close"))) 102
(lantern-run 103
(lantern-app name: title 104
assets: target 105
modules: '(core)+106
intercept-close: intercept-close 107
plugins: (system-plugins target args)) 108
title: title 109
width: widthlantern/src/lantern/app.sglmodified
@@ -50,19 +50,27 @@
50
(define (lantern-plugin name (keys: (commands (list)) (init #f) (js #f) (routes (list)))) 51
(dict lantern/plugin: #t name: name commands: commands init: init js: js routes: routes)) 52
+53
;; `intercept-close: #t` opts an app into PAGE-DRIVEN window close: instead of+54
;; the default flag-quit-and-close, a WM close-request is handed to the page as+55
;; an `app.close-requested` event and the immediate close is vetoed, so the page+56
;; can prompt (e.g. save unsaved work) and then call `app.quit` itself. Default+57
;; #f keeps the plain close-is-exit behavior. (Requires a backend that can veto+58
;; its window close; degrades to the default on backends that cannot.) 59
(define (lantern-app (keys: (name "lantern-app") 60
(assets "./dist") 61
(modules (list)) 62
(commands (list)) 63
(plugins (list))−58
(on-event #f)))+64
(on-event #f)+65
(intercept-close #f))) 66
(dict lantern/app: #t 67
name: name 68
assets: assets 69
modules: modules 70
commands: commands 71
plugins: plugins−65
on-event: on-event))+72
on-event: on-event+73
intercept-close: intercept-close)) 74
75
;; ---- built-in modules ------------------------------------------------- 76
;;@@ -198,6 +206,18 @@
206
207
;; Origin gate + load + show. 208
(webview-set-nav-handler wv win default-nav-handler)+209
+210
;; Opt-in page-driven close: hand a WM close-request to the page (an+211
;; `app.close-requested` event) and VETO the immediate close, so the page+212
;; can prompt before exit and call app.quit itself. Generic — the page+213
;; decides what a close means. A hung page cannot trap the window: a+214
;; stalled scheduler also stalls this pump, so the WM's own force-quit+215
;; still applies.+216
(when (dict-ref app intercept-close: #f)+217
(webview-set-close-handler wv win+218
(lambda ()+219
(bridge-emit bridge "app.close-requested" #{})+220
'veto))) 221
(webview-load-uri wv win (if is-url assets "lantern://app/index.html")) 222
(webview-window-op wv win 'present) 223
lantern/src/lantern/backend/gtk.sglmodified
@@ -132,6 +132,7 @@
132
(define *msg-handler* #f) ; json-string -> void 133
(define *nav-handler* #f) ; uri (string) -> 'allow | 'external 134
(define *quit-requested* #f)+135
(define *close-handler* #f) ; thunk -> 'veto (keep window open) | else 136
;; Retain served byte buffers so the input-stream data pointer stays valid 137
;; for the stream's lifetime (the resolver cache also holds them). 138
(define *retained* (list))@@ -211,12 +212,22 @@
212
#f)) ; allow: let WebKit apply the default policy 213
#f)))) 214
−214
;; close-request on the main window: flag quit, allow the window to close.+215
;; close-request on the main window. Default: flag quit and allow the close+216
;; (return #f) — a window app's close is its exit. But if the app registered a+217
;; close-handler (set-close-handler), consult it first: a handler that returns+218
;; 'veto means "I will decide whether to quit" (e.g. prompt to save unsaved+219
;; work), so we KEEP the window open (return #t = GTK stop-emission) and do NOT+220
;; set quit — the page drives teardown, calling app.quit once the user confirms.+221
;; Any other handler result (or a throwing handler, via guarded's #f fallback)+222
;; falls through to the default flag-and-close, so a broken handler can never+223
;; trap the window open. 224
(define (on-close-request window user-data) 225
(guarded "close-request" #f 226
(lambda ()−218
(set! *quit-requested* #t)−219
#f)))+227
(if (and *close-handler* (eq? (*close-handler*) 'veto))+228
#t+229
(begin (set! *quit-requested* #t)+230
#f))))) 231
232
;; key-pressed on a CAPTURE-phase controller on the window (5-arg: 233
;; controller keyval keycode state user-data -> gboolean).@@ -481,6 +492,9 @@
492
(define (set-nav-handler window handler) 493
(set! *nav-handler* handler)) 494
+495
(define (set-close-handler window handler)+496
(set! *close-handler* handler))+497
498
(define (window-op window op-name . args) 499
(let ((win (dict-ref window win: #f))) 500
(case op-name@@ -525,6 +539,7 @@
539
eval-js: eval-js 540
set-background: set-background 541
set-nav-handler: set-nav-handler+542
set-close-handler: set-close-handler 543
window-op: window-op 544
pump-once: pump-once 545
get-title: get-titlelantern/src/lantern/webview.sglmodified
@@ -46,6 +46,7 @@
46
webview-eval-js 47
webview-set-background 48
webview-set-nav-handler+49
webview-set-close-handler 50
webview-window-op 51
webview-pump-once 52
webview-get-title@@ -105,6 +106,12 @@
106
((op wv set-background:) window r g b a)) 107
(define (webview-set-nav-handler wv window handler) 108
((op wv set-nav-handler:) window handler))+109
;; Optional op: not every backend can veto a window close. No-op when the+110
;; backend does not implement it, so an app opting into close interception+111
;; degrades gracefully (the window just closes as before) instead of erroring.+112
(define (webview-set-close-handler wv window handler)+113
(let ((f (dict-ref wv set-close-handler: #f)))+114
(when f (f window handler)))) 115
(define (webview-window-op wv window op-name . args) 116
(apply (op wv window-op:) window op-name args)) 117
(define (webview-pump-once wv)lantern/test/test-lantern.sglmodified
@@ -433,6 +433,32 @@
433
(assert-true (string-contains? (last-eval) "window.lantern._resolve(42,")) 434
(assert-true (string-contains? (last-eval) "\"ok\":true")))) 435
+436
;; ============================================================+437
;; intercept-close — page-driven window close+438
;; ============================================================+439
+440
(test "lantern-app carries intercept-close (default off, opt-in on)"+441
;; default keeps the plain close-is-exit behavior; opting in is what routes a WM+442
;; close through the page (an app.close-requested event + a vetoed close).+443
(assert-false (dict-ref (lantern-app name: "a") intercept-close: 'missing))+444
(assert-true (dict-ref (lantern-app name: "a" intercept-close: #t) intercept-close: #f)))+445
+446
(test "webview-set-close-handler forwards to a backend that implements it"+447
(let* ((seen (make-vector 1 #f))+448
(h (lambda () 'veto))+449
(wv (make-backend 'fake+450
(dict eval-js: (lambda (w j) j)+451
set-close-handler: (lambda (win handler) (vector-set! seen 0 handler))))))+452
(webview-set-close-handler wv (dict fake-window: #t) h)+453
(assert-true (eq? (vector-ref seen 0) h))))+454
+455
(test "webview-set-close-handler is a safe no-op on a backend without it"+456
;; graceful degradation: an app opting into close interception must not error on+457
;; a backend that cannot veto its window close — it just closes as before.+458
(let ((wv (make-backend 'fake (dict eval-js: (lambda (w j) j)))))+459
(webview-set-close-handler wv (dict fake-window: #t) (lambda () 'veto))+460
(assert-true #t)))+461
462
;; ============================================================ 463
;; (lantern js) 464
;; ============================================================