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: width
lantern/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-title
lantern/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
;; ============================================================