detached e23543e

feat(cef): support JS alert, confirm, prompt and beforeunload dialogs (#462)

* feat(cef): support JS alert/confirm/prompt/beforeunload dialogs

Implement CEF JsdialogHandler and forward dialogs to the owning pane via
port.WebViewCallbacks.OnJSDialog. A per-pane overlay (JSDialogPopup) renders
plain-text dialogs with the requesting origin; Enter=OK, Escape=Cancel.
Further dialogs are suppressed while one is open; reset/destroy resolve the
pending CEF callback exactly once.

* fix(cef): follow CEF JS dialog reset semantics and shutdown paths

Ignore the OnResetDialogState echo CEF sends after a suppressed dialog and
after every Cont so a spammer or trailing reset cannot cancel the user's
dialog. Skip dialogs on a destroyed WebView, cancel on renderer termination,
call Cont directly (CEF already posts to its UI thread) and always show
Dumber's own beforeunload text.

* fix(ui): harden JS dialog popup lifecycle, focus and sizing

Answer an open dialog when its PaneView is cleaned up, only take focus in the
active pane (and restore it only if the dialog had it), let focused buttons
handle Enter themselves, truncate the origin from the left, cap page text,
clip and size the overlay with the modal helpers, and disconnect handlers on
Destroy. Resolve panes via the app resolver only.

* test(ui): cover JS dialog origin truncation, text caps and pane cleanup

* fix(cef): cancel JS dialogs on main-frame load and reject dialogs after destroy

CEF drops its handler after a suppress or rejected beforeunload, so no reset
follows a navigation; cancel the pending dialog on main-frame OnLoadStart.
Close the dialog state atomically in Destroy so begin cannot race it, and
correct the reset-ordering comment.

* fix(ui): keep JS dialog focus, track focus via controller, handle blob origins

PaneView.GrabFocus defers to a visible JS dialog so activation paths no longer
steal focus back to the WebView. Track focus-within with an
EventControllerFocus instead of leaking GetFocusChild refs, label blob:/
filesystem: origins with their inner host, refuse new dialogs after Cleanup and
document that Cleanup cancels open dialogs.

* fix(cef): skip stale JS dialog reset when a newer dialog is pending

The UI reset posted by cancelJSDialogsClosing carries no dialog identity; if a new dialog began before the GTK thread ran it, it would hide that dialog. Re-check pending state under the dialog mutex on the GTK thread and skip the reset when a dialog is pending.

* test(cef): drive stale JS dialog reset through a queued GTK loop
commit
e23543eb92693acd7bd1a51c77470e10d21762ab
author
bnema · · 2026-10-07 06:49 UTC
committer
GitHub · · 2026-10-07 06:49 UTC
parent
2740349
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
14 files · +1803 -29
A internal/application/port/jsdialog.go +31 -0
@@ -0,0 +1,31 @@
1+package port
2+
3+// JSDialogType identifies the kind of JavaScript dialog requested by a page.
4+type JSDialogType int
5+
6+const (
7+ // JSDialogAlert is window.alert(): message + OK.
8+ JSDialogAlert JSDialogType = iota
9+ // JSDialogConfirm is window.confirm(): message + OK/Cancel.
10+ JSDialogConfirm
11+ // JSDialogPrompt is window.prompt(): message + text input + OK/Cancel.
12+ JSDialogPrompt
13+ // JSDialogBeforeUnload is the "Leave page?" confirmation (Leave/Stay).
14+ JSDialogBeforeUnload
15+)
16+
17+// JSDialogRequest describes a JavaScript dialog requested by a page.
18+// All text is untrusted page content and must be rendered as plain text.
19+type JSDialogRequest struct {
20+ Type JSDialogType
21+ // Origin is the URL of the frame that requested the dialog (best effort).
22+ Origin string
23+ // Message is the page-provided message (may be empty). It is empty for
24+ // JSDialogBeforeUnload: CEF always passes a fixed string, so the UI shows
25+ // its own text (like Chrome/Firefox).
26+ Message string
27+ // DefaultPrompt is the prefilled input for JSDialogPrompt.
28+ DefaultPrompt string
29+ // IsReload is true for beforeunload triggered by a reload.
30+ IsReload bool
31+}
@@ -0,0 +1,31 @@
1+package port
2+
3+// JSDialogType identifies the kind of JavaScript dialog requested by a page.
4+type JSDialogType int
5+
6+const (
7+ // JSDialogAlert is window.alert(): message + OK.
8+ JSDialogAlert JSDialogType = iota
9+ // JSDialogConfirm is window.confirm(): message + OK/Cancel.
10+ JSDialogConfirm
11+ // JSDialogPrompt is window.prompt(): message + text input + OK/Cancel.
12+ JSDialogPrompt
13+ // JSDialogBeforeUnload is the "Leave page?" confirmation (Leave/Stay).
14+ JSDialogBeforeUnload
15+)
16+
17+// JSDialogRequest describes a JavaScript dialog requested by a page.
18+// All text is untrusted page content and must be rendered as plain text.
19+type JSDialogRequest struct {
20+ Type JSDialogType
21+ // Origin is the URL of the frame that requested the dialog (best effort).
22+ Origin string
23+ // Message is the page-provided message (may be empty). It is empty for
24+ // JSDialogBeforeUnload: CEF always passes a fixed string, so the UI shows
25+ // its own text (like Chrome/Firefox).
26+ Message string
27+ // DefaultPrompt is the prefilled input for JSDialogPrompt.
28+ DefaultPrompt string
29+ // IsReload is true for beforeunload triggered by a reload.
30+ IsReload bool
31+}
M internal/application/port/webview.go +13 -0
@@ -147,6 +147,19 @@ type WebViewCallbacks struct {
147147 // OnEditableFocusChanged is called when the page focus enters or leaves an
148148 // editable target such as input, textarea, or contenteditable content.
149149 OnEditableFocusChanged func(editable bool)
150+
151+ // OnJSDialog is called when the page requests a JavaScript dialog (alert,
152+ // confirm, prompt) or the beforeunload "Leave page?" confirmation. The UI
153+ // shows the dialog scoped to this WebView and calls respond exactly once
154+ // when the user answers (ok=false for cancel/dismiss; input is only
155+ // meaningful for prompts). Return false if no dialog could be shown; the
156+ // engine then answers with a cancel itself. respond is idempotent and safe
157+ // to call from the GTK thread.
158+ OnJSDialog func(req JSDialogRequest, respond func(ok bool, input string)) bool
159+ // OnJSDialogReset is called when the engine canceled a pending dialog (page
160+ // navigation, WebView destruction). The UI must hide any dialog shown for
161+ // this WebView without invoking respond.
162+ OnJSDialogReset func()
150163 }
151164
152165 // FindOptions configures search behavior.
@@ -147,6 +147,19 @@ type WebViewCallbacks struct {
147 // OnEditableFocusChanged is called when the page focus enters or leaves an147 // OnEditableFocusChanged is called when the page focus enters or leaves an
148 // editable target such as input, textarea, or contenteditable content.148 // editable target such as input, textarea, or contenteditable content.
149 OnEditableFocusChanged func(editable bool)149 OnEditableFocusChanged func(editable bool)
150+
151+ // OnJSDialog is called when the page requests a JavaScript dialog (alert,
152+ // confirm, prompt) or the beforeunload "Leave page?" confirmation. The UI
153+ // shows the dialog scoped to this WebView and calls respond exactly once
154+ // when the user answers (ok=false for cancel/dismiss; input is only
155+ // meaningful for prompts). Return false if no dialog could be shown; the
156+ // engine then answers with a cancel itself. respond is idempotent and safe
157+ // to call from the GTK thread.
158+ OnJSDialog func(req JSDialogRequest, respond func(ok bool, input string)) bool
159+ // OnJSDialogReset is called when the engine canceled a pending dialog (page
160+ // navigation, WebView destruction). The UI must hide any dialog shown for
161+ // this WebView without invoking respond.
162+ OnJSDialogReset func()
150 }163 }
151 164
152 // FindOptions configures search behavior.165 // FindOptions configures search behavior.
A internal/infrastructure/cef/jsdialog_handler.go +328 -0
@@ -0,0 +1,328 @@
1+package cef
2+
3+import (
4+ "sync"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// CEF JavaScript dialog support (alert/confirm/prompt/beforeunload).
13+//
14+// With a nil JsdialogHandler CEF falls back to its built-in runner, which has
15+// no usable window in windowless (OSR) mode on Linux, so dialogs are never
16+// shown to the user. We therefore implement the handler, forward requests to
17+// the UI through port.WebViewCallbacks.OnJSDialog and answer the CEF callback
18+// exactly once.
19+//
20+// Anti-abuse: while a dialog is open for a WebView, further OnJsdialog requests
21+// are suppressed (suppress_message=true) instead of being queued, so a page
22+// cannot stack or spam dialogs.
23+//
24+// CEF reset semantics (javascript_dialog_manager.cc): while CEF's handler_ is
25+// set, OnResetDialogState is also delivered (a) right after a suppressed
26+// OnJsdialog and (b) at the end of DialogClosed, i.e. inside/after a callback
27+// Cont. Those are not real resets and must not cancel the dialog the user is
28+// looking at, so jsDialogState carries a one-shot ignoreReset flag armed before
29+// suppressing, rejecting or continuing (it may stay unconsumed when CEF's
30+// handler_ was already cleared; begin resets it).
31+//
32+// After a suppress or a rejected beforeunload CEF clears handler_, so a later
33+// navigation sends no reset for the still-open dialog, and Chromium's
34+// main-frame commit does not reset state either. Main-frame OnLoadStart
35+// therefore cancels a pending dialog too. Destroy and renderer termination
36+// cancel unconditionally; Destroy also closes the state so no dialog can begin
37+// afterwards.
38+
39+// continueCEFJSDialog resolves a CEF dialog callback. It is a seam for tests.
40+// CEF's callback implementation re-posts to the CEF UI thread by itself.
41+var continueCEFJSDialog = func(callback purecef.JsdialogCallback, ok bool, input string) {
42+ success := int32(0)
43+ if ok {
44+ success = 1
45+ }
46+ callback.Cont(success, input)
47+}
48+
49+var _ purecef.JsdialogHandler = (*handlerSet)(nil)
50+
51+// jsDialogCall is one in-flight dialog. done is guarded by jsDialogState.mu.
52+type jsDialogCall struct {
53+ callback purecef.JsdialogCallback
54+ done bool
55+}
56+
57+// jsDialogState tracks the single pending dialog of a WebView.
58+type jsDialogState struct {
59+ mu sync.Mutex
60+ current *jsDialogCall
61+ ignoreReset bool // swallow the next OnResetDialogState (see file comment)
62+ closed bool // WebView destroyed: begin always fails
63+}
64+
65+// jsDialogBegin is the outcome of jsDialogState.begin.
66+type jsDialogBegin int
67+
68+const (
69+ jsDialogBeginOK jsDialogBegin = iota
70+ jsDialogBeginBusy // another dialog is open
71+ jsDialogBeginClosed // WebView destroyed
72+)
73+
74+// armIgnoreReset makes the next OnResetDialogState a no-op.
75+func (s *jsDialogState) armIgnoreReset() {
76+ s.mu.Lock()
77+ s.ignoreReset = true
78+ s.mu.Unlock()
79+}
80+
81+// consumeIgnoreReset reports (and clears) a pending ignoreReset.
82+func (s *jsDialogState) consumeIgnoreReset() bool {
83+ s.mu.Lock()
84+ defer s.mu.Unlock()
85+ was := s.ignoreReset
86+ s.ignoreReset = false
87+ return was
88+}
89+
90+// begin registers a new pending dialog.
91+func (s *jsDialogState) begin(cb purecef.JsdialogCallback) (*jsDialogCall, jsDialogBegin) {
92+ s.mu.Lock()
93+ defer s.mu.Unlock()
94+ if s.closed {
95+ return nil, jsDialogBeginClosed
96+ }
97+ if s.current != nil {
98+ return nil, jsDialogBeginBusy
99+ }
100+ call := &jsDialogCall{callback: cb}
101+ s.current = call
102+ // A new dialog means any reset owed by the previous one has been delivered
103+ // (the page is blocked until then); never carry a stale flag across.
104+ s.ignoreReset = false
105+ return call, jsDialogBeginOK
106+}
107+
108+// hasPending reports whether an unresolved dialog is registered.
109+func (s *jsDialogState) hasPending() bool {
110+ s.mu.Lock()
111+ defer s.mu.Unlock()
112+ return s.current != nil
113+}
114+
115+func (s *jsDialogState) isDone(call *jsDialogCall) bool {
116+ s.mu.Lock()
117+ defer s.mu.Unlock()
118+ return call.done
119+}
120+
121+// resolve answers the CEF callback exactly once; later calls are no-ops.
122+func (s *jsDialogState) resolve(call *jsDialogCall, ok bool, input string) bool {
123+ s.mu.Lock()
124+ if call == nil || call.done {
125+ s.mu.Unlock()
126+ return false
127+ }
128+ call.done = true
129+ if s.current == call {
130+ s.current = nil
131+ }
132+ // Armed before Cont: CEF may deliver DialogClosed + OnResetDialogState
133+ // synchronously from inside it.
134+ s.ignoreReset = true
135+ s.mu.Unlock()
136+ continueCEFJSDialog(call.callback, ok, input)
137+ return true
138+}
139+
140+// cancelPending resolves the pending dialog (if any) with a cancel. When
141+// closing is true it also rejects every later begin (closing is atomic with
142+// reading the pending call, so a concurrent begin cannot slip in).
143+func (s *jsDialogState) cancelPending(closing bool) bool {
144+ s.mu.Lock()
145+ if closing {
146+ s.closed = true
147+ }
148+ call := s.current
149+ s.mu.Unlock()
150+ if call == nil {
151+ return false
152+ }
153+ return s.resolve(call, false, "")
154+}
155+
156+func (wv *WebView) jsDialogUICallbacks() *port.WebViewCallbacks {
157+ wv.mu.RLock()
158+ defer wv.mu.RUnlock()
159+ return wv.callbacks
160+}
161+
162+// cancelJSDialogs resolves any pending dialog and tells the UI to hide it.
163+func (wv *WebView) cancelJSDialogs() { wv.cancelJSDialogsClosing(false) }
164+
165+// cancelJSDialogsClosing is cancelJSDialogs; with closing it also forbids any
166+// further dialog (WebView destruction).
167+func (wv *WebView) cancelJSDialogsClosing(closing bool) {
168+ if wv == nil {
169+ return
170+ }
171+ if !wv.jsDialogs.cancelPending(closing) {
172+ return
173+ }
174+ if cb := wv.jsDialogUICallbacks(); cb != nil && cb.OnJSDialogReset != nil {
175+ wv.runOnGTK(func() { wv.resetJSDialogUI(cb.OnJSDialogReset) })
176+ }
177+}
178+
179+// resetJSDialogUI runs on the GTK thread, after a cancel posted it. The reset
180+// carries no dialog identity, so if a newer dialog began in the meantime it is
181+// stale and must be skipped, or it would hide that dialog. A dialog that
182+// begins after this check has its presentation queued behind us on the GTK
183+// thread, so it is never hidden either.
184+func (wv *WebView) resetJSDialogUI(reset func()) {
185+ if wv.jsDialogs.hasPending() {
186+ return
187+ }
188+ reset()
189+}
190+
191+func jsDialogTypeFromCEF(t purecef.JsdialogType) (port.JSDialogType, bool) {
192+ switch t {
193+ case purecef.JsdialogTypeJsdialogtypeAlert:
194+ return port.JSDialogAlert, true
195+ case purecef.JsdialogTypeJsdialogtypeConfirm:
196+ return port.JSDialogConfirm, true
197+ case purecef.JsdialogTypeJsdialogtypePrompt:
198+ return port.JSDialogPrompt, true
199+ default:
200+ return 0, false
201+ }
202+}
203+
204+// present shows the dialog on the GTK thread and wires the user's answer back.
205+func (wv *WebView) presentJSDialog(call *jsDialogCall, req port.JSDialogRequest, ui *port.WebViewCallbacks) {
206+ wv.runOnGTK(func() {
207+ if wv.jsDialogs.isDone(call) {
208+ return // canceled before the UI got to run
209+ }
210+ respond := func(ok bool, input string) {
211+ if req.Type != port.JSDialogPrompt {
212+ input = ""
213+ }
214+ wv.jsDialogs.resolve(call, ok, input)
215+ }
216+ if !ui.OnJSDialog(req, respond) {
217+ respond(false, "")
218+ }
219+ })
220+}
221+
222+// OnJsdialog implements purecef.JsdialogHandler.
223+func (h *handlerSet) OnJsdialog(
224+ _ purecef.Browser,
225+ originURL string,
226+ dialogType purecef.JsdialogType,
227+ messageText string,
228+ defaultPromptText string,
229+ callback purecef.JsdialogCallback,
230+ suppressMessage *int32,
231+) int32 {
232+ if h == nil || h.wv == nil || callback == nil {
233+ return 0
234+ }
235+ if h.wv.destroyed.Load() {
236+ // Shutting down: the GTK loop may be blocked. Let CEF dismiss it.
237+ return 0
238+ }
239+ typ, ok := jsDialogTypeFromCEF(dialogType)
240+ if !ok {
241+ return 0
242+ }
243+ ui := h.wv.jsDialogUICallbacks()
244+ if ui == nil || ui.OnJSDialog == nil {
245+ return 0 // no UI: let CEF apply its default behavior
246+ }
247+ call, begin := h.wv.jsDialogs.begin(callback)
248+ if begin == jsDialogBeginClosed {
249+ return 0
250+ }
251+ if begin == jsDialogBeginBusy {
252+ // A dialog is already open: suppress rather than stack/spam. CEF will
253+ // follow up with OnResetDialogState, which must not cancel that dialog.
254+ h.wv.jsDialogs.armIgnoreReset()
255+ if suppressMessage != nil {
256+ *suppressMessage = 1
257+ }
258+ return 0
259+ }
260+ logging.FromContext(h.wv.ctx).Debug().Int32("type", dialogType).Msg("cef: js dialog requested")
261+ h.wv.presentJSDialog(call, port.JSDialogRequest{
262+ Type: typ,
263+ Origin: originURL,
264+ Message: messageText,
265+ DefaultPrompt: defaultPromptText,
266+ }, ui)
267+ return 1
268+}
269+
270+// OnBeforeUnloadDialog implements purecef.JsdialogHandler.
271+func (h *handlerSet) OnBeforeUnloadDialog(
272+ _ purecef.Browser,
273+ _ string, // CEF always passes a fixed string; the UI shows Dumber's own text.
274+ isReload int32,
275+ callback purecef.JsdialogCallback,
276+) bool {
277+ if h == nil || h.wv == nil || callback == nil {
278+ return false
279+ }
280+ if h.wv.destroyed.Load() {
281+ // CloseBrowser dispatches beforeunload; accept it instead of waiting
282+ // on UI that may never run. CEF accepts the unload when we return false.
283+ return false
284+ }
285+ ui := h.wv.jsDialogUICallbacks()
286+ if ui == nil || ui.OnJSDialog == nil {
287+ return false
288+ }
289+ call, begin := h.wv.jsDialogs.begin(callback)
290+ if begin == jsDialogBeginClosed {
291+ return false
292+ }
293+ if begin == jsDialogBeginBusy {
294+ // Another dialog is open: stay on the page rather than stacking.
295+ // The Cont below makes CEF send a reset that must not cancel it.
296+ h.wv.jsDialogs.armIgnoreReset()
297+ continueCEFJSDialog(callback, false, "")
298+ return true
299+ }
300+ h.wv.presentJSDialog(call, port.JSDialogRequest{
301+ Type: port.JSDialogBeforeUnload,
302+ Origin: h.wv.URI(),
303+ IsReload: isReload != 0,
304+ }, ui)
305+ return true
306+}
307+
308+// OnResetDialogState implements purecef.JsdialogHandler: CEF canceled pending
309+// dialogs (navigation, close). Close the UI and resolve the callback, unless
310+// this is the echo of a suppress/reject/Cont (see file comment).
311+//
312+// The UI reset (OnJSDialogReset) carries no dialog identity: it hides whatever
313+// the pane shows. That is safe because a pane shows at most one dialog and a
314+// new one is only presented after the previous call was resolved.
315+func (h *handlerSet) OnResetDialogState(_ purecef.Browser) {
316+ if h == nil || h.wv == nil {
317+ return
318+ }
319+ if h.wv.jsDialogs.consumeIgnoreReset() {
320+ return
321+ }
322+ h.wv.cancelJSDialogs()
323+}
324+
325+// OnDialogClosed implements purecef.JsdialogHandler. It intentionally does
326+// nothing: resolve already cleared the pending state, and a late notification
327+// must not clear a newer dialog.
328+func (h *handlerSet) OnDialogClosed(_ purecef.Browser) {}
@@ -0,0 +1,328 @@
1+package cef
2+
3+import (
4+ "sync"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// CEF JavaScript dialog support (alert/confirm/prompt/beforeunload).
13+//
14+// With a nil JsdialogHandler CEF falls back to its built-in runner, which has
15+// no usable window in windowless (OSR) mode on Linux, so dialogs are never
16+// shown to the user. We therefore implement the handler, forward requests to
17+// the UI through port.WebViewCallbacks.OnJSDialog and answer the CEF callback
18+// exactly once.
19+//
20+// Anti-abuse: while a dialog is open for a WebView, further OnJsdialog requests
21+// are suppressed (suppress_message=true) instead of being queued, so a page
22+// cannot stack or spam dialogs.
23+//
24+// CEF reset semantics (javascript_dialog_manager.cc): while CEF's handler_ is
25+// set, OnResetDialogState is also delivered (a) right after a suppressed
26+// OnJsdialog and (b) at the end of DialogClosed, i.e. inside/after a callback
27+// Cont. Those are not real resets and must not cancel the dialog the user is
28+// looking at, so jsDialogState carries a one-shot ignoreReset flag armed before
29+// suppressing, rejecting or continuing (it may stay unconsumed when CEF's
30+// handler_ was already cleared; begin resets it).
31+//
32+// After a suppress or a rejected beforeunload CEF clears handler_, so a later
33+// navigation sends no reset for the still-open dialog, and Chromium's
34+// main-frame commit does not reset state either. Main-frame OnLoadStart
35+// therefore cancels a pending dialog too. Destroy and renderer termination
36+// cancel unconditionally; Destroy also closes the state so no dialog can begin
37+// afterwards.
38+
39+// continueCEFJSDialog resolves a CEF dialog callback. It is a seam for tests.
40+// CEF's callback implementation re-posts to the CEF UI thread by itself.
41+var continueCEFJSDialog = func(callback purecef.JsdialogCallback, ok bool, input string) {
42+ success := int32(0)
43+ if ok {
44+ success = 1
45+ }
46+ callback.Cont(success, input)
47+}
48+
49+var _ purecef.JsdialogHandler = (*handlerSet)(nil)
50+
51+// jsDialogCall is one in-flight dialog. done is guarded by jsDialogState.mu.
52+type jsDialogCall struct {
53+ callback purecef.JsdialogCallback
54+ done bool
55+}
56+
57+// jsDialogState tracks the single pending dialog of a WebView.
58+type jsDialogState struct {
59+ mu sync.Mutex
60+ current *jsDialogCall
61+ ignoreReset bool // swallow the next OnResetDialogState (see file comment)
62+ closed bool // WebView destroyed: begin always fails
63+}
64+
65+// jsDialogBegin is the outcome of jsDialogState.begin.
66+type jsDialogBegin int
67+
68+const (
69+ jsDialogBeginOK jsDialogBegin = iota
70+ jsDialogBeginBusy // another dialog is open
71+ jsDialogBeginClosed // WebView destroyed
72+)
73+
74+// armIgnoreReset makes the next OnResetDialogState a no-op.
75+func (s *jsDialogState) armIgnoreReset() {
76+ s.mu.Lock()
77+ s.ignoreReset = true
78+ s.mu.Unlock()
79+}
80+
81+// consumeIgnoreReset reports (and clears) a pending ignoreReset.
82+func (s *jsDialogState) consumeIgnoreReset() bool {
83+ s.mu.Lock()
84+ defer s.mu.Unlock()
85+ was := s.ignoreReset
86+ s.ignoreReset = false
87+ return was
88+}
89+
90+// begin registers a new pending dialog.
91+func (s *jsDialogState) begin(cb purecef.JsdialogCallback) (*jsDialogCall, jsDialogBegin) {
92+ s.mu.Lock()
93+ defer s.mu.Unlock()
94+ if s.closed {
95+ return nil, jsDialogBeginClosed
96+ }
97+ if s.current != nil {
98+ return nil, jsDialogBeginBusy
99+ }
100+ call := &jsDialogCall{callback: cb}
101+ s.current = call
102+ // A new dialog means any reset owed by the previous one has been delivered
103+ // (the page is blocked until then); never carry a stale flag across.
104+ s.ignoreReset = false
105+ return call, jsDialogBeginOK
106+}
107+
108+// hasPending reports whether an unresolved dialog is registered.
109+func (s *jsDialogState) hasPending() bool {
110+ s.mu.Lock()
111+ defer s.mu.Unlock()
112+ return s.current != nil
113+}
114+
115+func (s *jsDialogState) isDone(call *jsDialogCall) bool {
116+ s.mu.Lock()
117+ defer s.mu.Unlock()
118+ return call.done
119+}
120+
121+// resolve answers the CEF callback exactly once; later calls are no-ops.
122+func (s *jsDialogState) resolve(call *jsDialogCall, ok bool, input string) bool {
123+ s.mu.Lock()
124+ if call == nil || call.done {
125+ s.mu.Unlock()
126+ return false
127+ }
128+ call.done = true
129+ if s.current == call {
130+ s.current = nil
131+ }
132+ // Armed before Cont: CEF may deliver DialogClosed + OnResetDialogState
133+ // synchronously from inside it.
134+ s.ignoreReset = true
135+ s.mu.Unlock()
136+ continueCEFJSDialog(call.callback, ok, input)
137+ return true
138+}
139+
140+// cancelPending resolves the pending dialog (if any) with a cancel. When
141+// closing is true it also rejects every later begin (closing is atomic with
142+// reading the pending call, so a concurrent begin cannot slip in).
143+func (s *jsDialogState) cancelPending(closing bool) bool {
144+ s.mu.Lock()
145+ if closing {
146+ s.closed = true
147+ }
148+ call := s.current
149+ s.mu.Unlock()
150+ if call == nil {
151+ return false
152+ }
153+ return s.resolve(call, false, "")
154+}
155+
156+func (wv *WebView) jsDialogUICallbacks() *port.WebViewCallbacks {
157+ wv.mu.RLock()
158+ defer wv.mu.RUnlock()
159+ return wv.callbacks
160+}
161+
162+// cancelJSDialogs resolves any pending dialog and tells the UI to hide it.
163+func (wv *WebView) cancelJSDialogs() { wv.cancelJSDialogsClosing(false) }
164+
165+// cancelJSDialogsClosing is cancelJSDialogs; with closing it also forbids any
166+// further dialog (WebView destruction).
167+func (wv *WebView) cancelJSDialogsClosing(closing bool) {
168+ if wv == nil {
169+ return
170+ }
171+ if !wv.jsDialogs.cancelPending(closing) {
172+ return
173+ }
174+ if cb := wv.jsDialogUICallbacks(); cb != nil && cb.OnJSDialogReset != nil {
175+ wv.runOnGTK(func() { wv.resetJSDialogUI(cb.OnJSDialogReset) })
176+ }
177+}
178+
179+// resetJSDialogUI runs on the GTK thread, after a cancel posted it. The reset
180+// carries no dialog identity, so if a newer dialog began in the meantime it is
181+// stale and must be skipped, or it would hide that dialog. A dialog that
182+// begins after this check has its presentation queued behind us on the GTK
183+// thread, so it is never hidden either.
184+func (wv *WebView) resetJSDialogUI(reset func()) {
185+ if wv.jsDialogs.hasPending() {
186+ return
187+ }
188+ reset()
189+}
190+
191+func jsDialogTypeFromCEF(t purecef.JsdialogType) (port.JSDialogType, bool) {
192+ switch t {
193+ case purecef.JsdialogTypeJsdialogtypeAlert:
194+ return port.JSDialogAlert, true
195+ case purecef.JsdialogTypeJsdialogtypeConfirm:
196+ return port.JSDialogConfirm, true
197+ case purecef.JsdialogTypeJsdialogtypePrompt:
198+ return port.JSDialogPrompt, true
199+ default:
200+ return 0, false
201+ }
202+}
203+
204+// present shows the dialog on the GTK thread and wires the user's answer back.
205+func (wv *WebView) presentJSDialog(call *jsDialogCall, req port.JSDialogRequest, ui *port.WebViewCallbacks) {
206+ wv.runOnGTK(func() {
207+ if wv.jsDialogs.isDone(call) {
208+ return // canceled before the UI got to run
209+ }
210+ respond := func(ok bool, input string) {
211+ if req.Type != port.JSDialogPrompt {
212+ input = ""
213+ }
214+ wv.jsDialogs.resolve(call, ok, input)
215+ }
216+ if !ui.OnJSDialog(req, respond) {
217+ respond(false, "")
218+ }
219+ })
220+}
221+
222+// OnJsdialog implements purecef.JsdialogHandler.
223+func (h *handlerSet) OnJsdialog(
224+ _ purecef.Browser,
225+ originURL string,
226+ dialogType purecef.JsdialogType,
227+ messageText string,
228+ defaultPromptText string,
229+ callback purecef.JsdialogCallback,
230+ suppressMessage *int32,
231+) int32 {
232+ if h == nil || h.wv == nil || callback == nil {
233+ return 0
234+ }
235+ if h.wv.destroyed.Load() {
236+ // Shutting down: the GTK loop may be blocked. Let CEF dismiss it.
237+ return 0
238+ }
239+ typ, ok := jsDialogTypeFromCEF(dialogType)
240+ if !ok {
241+ return 0
242+ }
243+ ui := h.wv.jsDialogUICallbacks()
244+ if ui == nil || ui.OnJSDialog == nil {
245+ return 0 // no UI: let CEF apply its default behavior
246+ }
247+ call, begin := h.wv.jsDialogs.begin(callback)
248+ if begin == jsDialogBeginClosed {
249+ return 0
250+ }
251+ if begin == jsDialogBeginBusy {
252+ // A dialog is already open: suppress rather than stack/spam. CEF will
253+ // follow up with OnResetDialogState, which must not cancel that dialog.
254+ h.wv.jsDialogs.armIgnoreReset()
255+ if suppressMessage != nil {
256+ *suppressMessage = 1
257+ }
258+ return 0
259+ }
260+ logging.FromContext(h.wv.ctx).Debug().Int32("type", dialogType).Msg("cef: js dialog requested")
261+ h.wv.presentJSDialog(call, port.JSDialogRequest{
262+ Type: typ,
263+ Origin: originURL,
264+ Message: messageText,
265+ DefaultPrompt: defaultPromptText,
266+ }, ui)
267+ return 1
268+}
269+
270+// OnBeforeUnloadDialog implements purecef.JsdialogHandler.
271+func (h *handlerSet) OnBeforeUnloadDialog(
272+ _ purecef.Browser,
273+ _ string, // CEF always passes a fixed string; the UI shows Dumber's own text.
274+ isReload int32,
275+ callback purecef.JsdialogCallback,
276+) bool {
277+ if h == nil || h.wv == nil || callback == nil {
278+ return false
279+ }
280+ if h.wv.destroyed.Load() {
281+ // CloseBrowser dispatches beforeunload; accept it instead of waiting
282+ // on UI that may never run. CEF accepts the unload when we return false.
283+ return false
284+ }
285+ ui := h.wv.jsDialogUICallbacks()
286+ if ui == nil || ui.OnJSDialog == nil {
287+ return false
288+ }
289+ call, begin := h.wv.jsDialogs.begin(callback)
290+ if begin == jsDialogBeginClosed {
291+ return false
292+ }
293+ if begin == jsDialogBeginBusy {
294+ // Another dialog is open: stay on the page rather than stacking.
295+ // The Cont below makes CEF send a reset that must not cancel it.
296+ h.wv.jsDialogs.armIgnoreReset()
297+ continueCEFJSDialog(callback, false, "")
298+ return true
299+ }
300+ h.wv.presentJSDialog(call, port.JSDialogRequest{
301+ Type: port.JSDialogBeforeUnload,
302+ Origin: h.wv.URI(),
303+ IsReload: isReload != 0,
304+ }, ui)
305+ return true
306+}
307+
308+// OnResetDialogState implements purecef.JsdialogHandler: CEF canceled pending
309+// dialogs (navigation, close). Close the UI and resolve the callback, unless
310+// this is the echo of a suppress/reject/Cont (see file comment).
311+//
312+// The UI reset (OnJSDialogReset) carries no dialog identity: it hides whatever
313+// the pane shows. That is safe because a pane shows at most one dialog and a
314+// new one is only presented after the previous call was resolved.
315+func (h *handlerSet) OnResetDialogState(_ purecef.Browser) {
316+ if h == nil || h.wv == nil {
317+ return
318+ }
319+ if h.wv.jsDialogs.consumeIgnoreReset() {
320+ return
321+ }
322+ h.wv.cancelJSDialogs()
323+}
324+
325+// OnDialogClosed implements purecef.JsdialogHandler. It intentionally does
326+// nothing: resolve already cleared the pending state, and a late notification
327+// must not clear a newer dialog.
328+func (h *handlerSet) OnDialogClosed(_ purecef.Browser) {}
A internal/infrastructure/cef/jsdialog_handler_test.go +456 -0
@@ -0,0 +1,456 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+)
12+
13+type stubJSDialogCallback struct {
14+ calls []jsDialogAnswer
15+ // onCont mimics CEF's DialogClosed ordering: OnDialogClosed and
16+ // OnResetDialogState are delivered from inside Cont.
17+ onCont func()
18+}
19+
20+type jsDialogAnswer struct {
21+ ok bool
22+ input string
23+}
24+
25+func (s *stubJSDialogCallback) Cont(success int32, userInput string) {
26+ s.calls = append(s.calls, jsDialogAnswer{ok: success != 0, input: userInput})
27+ if s.onCont != nil {
28+ s.onCont()
29+ }
30+}
31+
32+// echoCEFClose makes cb deliver CEF's DialogClosed + reset echo from Cont.
33+func echoCEFClose(h *handlerSet, cb *stubJSDialogCallback) {
34+ cb.onCont = func() {
35+ h.OnDialogClosed(nil)
36+ h.OnResetDialogState(nil)
37+ }
38+}
39+
40+// useDirectJSDialogContinue answers stub callbacks inline (no CEF UI thread).
41+func useDirectJSDialogContinue(t *testing.T) {
42+ t.Helper()
43+ prev := continueCEFJSDialog
44+ continueCEFJSDialog = func(cb purecef.JsdialogCallback, ok bool, input string) {
45+ success := int32(0)
46+ if ok {
47+ success = 1
48+ }
49+ cb.Cont(success, input)
50+ }
51+ t.Cleanup(func() { continueCEFJSDialog = prev })
52+}
53+
54+type jsDialogUIRecorder struct {
55+ reqs []port.JSDialogRequest
56+ respond []func(bool, string)
57+ resets int
58+ handled bool
59+}
60+
61+func newJSDialogWebView(ui *jsDialogUIRecorder) *WebView {
62+ return &WebView{
63+ ctx: context.Background(),
64+ callbacks: &port.WebViewCallbacks{
65+ OnJSDialog: func(req port.JSDialogRequest, respond func(bool, string)) bool {
66+ ui.reqs = append(ui.reqs, req)
67+ ui.respond = append(ui.respond, respond)
68+ return ui.handled
69+ },
70+ OnJSDialogReset: func() { ui.resets++ },
71+ },
72+ }
73+}
74+
75+func TestGetJsdialogHandlerEnabled(t *testing.T) {
76+ h := &handlerSet{}
77+ require.Same(t, h, h.GetJsdialogHandler())
78+}
79+
80+func TestJSDialogTypeMapping(t *testing.T) {
81+ cases := map[purecef.JsdialogType]port.JSDialogType{
82+ purecef.JsdialogTypeJsdialogtypeAlert: port.JSDialogAlert,
83+ purecef.JsdialogTypeJsdialogtypeConfirm: port.JSDialogConfirm,
84+ purecef.JsdialogTypeJsdialogtypePrompt: port.JSDialogPrompt,
85+ }
86+ for in, want := range cases {
87+ got, ok := jsDialogTypeFromCEF(in)
88+ require.True(t, ok)
89+ require.Equal(t, want, got)
90+ }
91+ _, ok := jsDialogTypeFromCEF(purecef.JsdialogTypeJsdialogtypeNumValues)
92+ require.False(t, ok)
93+}
94+
95+func TestOnJsdialogForwardsRequestAndRespondsOnce(t *testing.T) {
96+ useDirectJSDialogContinue(t)
97+ ui := &jsDialogUIRecorder{handled: true}
98+ h := &handlerSet{wv: newJSDialogWebView(ui)}
99+ cb := &stubJSDialogCallback{}
100+ var suppress int32
101+
102+ handled := h.OnJsdialog(nil, "https://example.com/p", purecef.JsdialogTypeJsdialogtypePrompt, "Name?", "bob", cb, &suppress)
103+
104+ require.Equal(t, int32(1), handled)
105+ require.Zero(t, suppress)
106+ require.Equal(t, []port.JSDialogRequest{{
107+ Type: port.JSDialogPrompt, Origin: "https://example.com/p", Message: "Name?", DefaultPrompt: "bob",
108+ }}, ui.reqs)
109+ require.Empty(t, cb.calls)
110+
111+ ui.respond[0](true, "alice")
112+ ui.respond[0](false, "late") // ignored: exactly once
113+ require.Equal(t, []jsDialogAnswer{{ok: true, input: "alice"}}, cb.calls)
114+}
115+
116+func TestOnJsdialogDropsInputForNonPrompt(t *testing.T) {
117+ useDirectJSDialogContinue(t)
118+ ui := &jsDialogUIRecorder{handled: true}
119+ h := &handlerSet{wv: newJSDialogWebView(ui)}
120+ cb := &stubJSDialogCallback{}
121+
122+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeConfirm, "ok?", "", cb, new(int32))
123+ ui.respond[0](true, "ignored")
124+
125+ require.Equal(t, []jsDialogAnswer{{ok: true}}, cb.calls)
126+}
127+
128+func TestOnJsdialogWithoutUICallbackUsesDefault(t *testing.T) {
129+ cb := &stubJSDialogCallback{}
130+ var suppress int32
131+
132+ for _, h := range []*handlerSet{
133+ nil,
134+ {},
135+ {wv: &WebView{ctx: context.Background()}},
136+ {wv: &WebView{ctx: context.Background(), callbacks: &port.WebViewCallbacks{}}},
137+ } {
138+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, &suppress))
139+ require.False(t, h.OnBeforeUnloadDialog(nil, "m", 0, cb))
140+ }
141+ require.Zero(t, suppress)
142+ require.Empty(t, cb.calls)
143+}
144+
145+func TestOnJsdialogRejectsNilCallbackAndUnknownType(t *testing.T) {
146+ ui := &jsDialogUIRecorder{handled: true}
147+ h := &handlerSet{wv: newJSDialogWebView(ui)}
148+
149+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", nil, new(int32)))
150+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeNumValues, "m", "", &stubJSDialogCallback{}, new(int32)))
151+ require.Empty(t, ui.reqs)
152+}
153+
154+func TestOnJsdialogUINotHandledCancels(t *testing.T) {
155+ useDirectJSDialogContinue(t)
156+ ui := &jsDialogUIRecorder{handled: false}
157+ h := &handlerSet{wv: newJSDialogWebView(ui)}
158+ cb := &stubJSDialogCallback{}
159+
160+ require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeConfirm, "m", "", cb, new(int32)))
161+ require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
162+}
163+
164+func TestOnJsdialogSuppressesWhileDialogOpen(t *testing.T) {
165+ useDirectJSDialogContinue(t)
166+ ui := &jsDialogUIRecorder{handled: true}
167+ h := &handlerSet{wv: newJSDialogWebView(ui)}
168+ first := &stubJSDialogCallback{}
169+ second := &stubJSDialogCallback{}
170+ var suppress int32
171+
172+ require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32)))
173+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", second, &suppress))
174+ require.Equal(t, int32(1), suppress)
175+ require.Len(t, ui.reqs, 1)
176+ require.Empty(t, second.calls)
177+
178+ // Once answered, new dialogs are allowed again.
179+ ui.respond[0](true, "")
180+ require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "3", "", second, new(int32)))
181+ require.Len(t, ui.reqs, 2)
182+}
183+
184+func TestOnBeforeUnloadDialog(t *testing.T) {
185+ useDirectJSDialogContinue(t)
186+ ui := &jsDialogUIRecorder{handled: true}
187+ wv := newJSDialogWebView(ui)
188+ wv.uri = "https://example.com/edit"
189+ h := &handlerSet{wv: wv}
190+ cb := &stubJSDialogCallback{}
191+
192+ require.True(t, h.OnBeforeUnloadDialog(nil, "unsaved", 1, cb))
193+ // CEF's fixed message text is not forwarded.
194+ require.Equal(t, []port.JSDialogRequest{{
195+ Type: port.JSDialogBeforeUnload, Origin: "https://example.com/edit", IsReload: true,
196+ }}, ui.reqs)
197+
198+ // A second beforeunload while open is answered "stay" immediately.
199+ other := &stubJSDialogCallback{}
200+ require.True(t, h.OnBeforeUnloadDialog(nil, "again", 0, other))
201+ require.Equal(t, []jsDialogAnswer{{ok: false}}, other.calls)
202+ require.Len(t, ui.reqs, 1)
203+
204+ ui.respond[0](true, "")
205+ require.Equal(t, []jsDialogAnswer{{ok: true}}, cb.calls)
206+}
207+
208+func TestOnResetDialogStateResolvesPendingAndClosesUI(t *testing.T) {
209+ useDirectJSDialogContinue(t)
210+ ui := &jsDialogUIRecorder{handled: true}
211+ h := &handlerSet{wv: newJSDialogWebView(ui)}
212+ cb := &stubJSDialogCallback{}
213+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypePrompt, "m", "d", cb, new(int32))
214+
215+ h.OnResetDialogState(nil)
216+
217+ require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
218+ require.Equal(t, 1, ui.resets)
219+
220+ // A late UI answer after reset must not resolve twice.
221+ ui.respond[0](true, "late")
222+ require.Len(t, cb.calls, 1)
223+
224+ // Reset with nothing pending is a no-op and does not touch the UI.
225+ h.OnResetDialogState(nil)
226+ require.Equal(t, 1, ui.resets)
227+ h.OnDialogClosed(nil)
228+}
229+
230+func TestDestroyCancelsPendingJSDialog(t *testing.T) {
231+ useDirectJSDialogContinue(t)
232+ ui := &jsDialogUIRecorder{handled: true}
233+ wv := newJSDialogWebView(ui)
234+ h := &handlerSet{wv: wv}
235+ cb := &stubJSDialogCallback{}
236+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, new(int32))
237+
238+ wv.Destroy()
239+
240+ require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
241+ require.Equal(t, 1, ui.resets)
242+}
243+
244+func TestJSDialogCancelledBeforeUIRunsIsNotShown(t *testing.T) {
245+ useDirectJSDialogContinue(t)
246+ ui := &jsDialogUIRecorder{handled: true}
247+ wv := newJSDialogWebView(ui)
248+ cb := &stubJSDialogCallback{}
249+ call, begin := wv.jsDialogs.begin(cb)
250+ require.Equal(t, jsDialogBeginOK, begin)
251+ wv.jsDialogs.resolve(call, false, "")
252+
253+ wv.presentJSDialog(call, port.JSDialogRequest{}, wv.callbacks)
254+
255+ require.Empty(t, ui.reqs)
256+ require.Len(t, cb.calls, 1)
257+}
258+
259+// useQueuedGTK makes runOnGTK queue callbacks; the returned func runs them in
260+// order, like the GTK main loop would.
261+func useQueuedGTK(t *testing.T, wv *WebView) (drain func()) {
262+ t.Helper()
263+ var queue []func()
264+ prev := postToGTK
265+ postToGTK = func(fn func()) { queue = append(queue, fn) }
266+ t.Cleanup(func() { postToGTK = prev })
267+ wv.engine = &Engine{}
268+ return func() {
269+ for len(queue) > 0 {
270+ fn := queue[0]
271+ queue = queue[1:]
272+ fn()
273+ }
274+ }
275+}
276+
277+func TestStaleUIResetSkippedWhenNewerDialogPending(t *testing.T) {
278+ useDirectJSDialogContinue(t)
279+ ui := &jsDialogUIRecorder{handled: true}
280+ wv := newJSDialogWebView(ui)
281+ drainGTK := useQueuedGTK(t, wv)
282+ h := &handlerSet{wv: wv}
283+ old := &stubJSDialogCallback{}
284+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "old", "", old, new(int32))
285+ drainGTK()
286+ require.Len(t, ui.reqs, 1)
287+
288+ // Cancel resolves the old dialog; its UI reset is queued for GTK.
289+ wv.cancelJSDialogs()
290+ require.Equal(t, []jsDialogAnswer{{ok: false}}, old.calls)
291+ require.Zero(t, ui.resets, "reset must wait for the GTK queue")
292+
293+ // A newer dialog arrives before the queued reset runs.
294+ newer := &stubJSDialogCallback{}
295+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "new", "", newer, new(int32))
296+
297+ // GTK runs the stale reset, then presents the newer dialog.
298+ drainGTK()
299+ require.Len(t, ui.reqs, 2)
300+ require.Zero(t, ui.resets, "stale reset must not hide the newer dialog")
301+ require.Empty(t, newer.calls, "newer dialog must stay pending")
302+
303+ // Canceling the newer dialog resets the UI once GTK runs.
304+ wv.cancelJSDialogs()
305+ drainGTK()
306+ require.Equal(t, []jsDialogAnswer{{ok: false}}, newer.calls)
307+ require.Equal(t, 1, ui.resets)
308+}
309+
310+func TestResetEchoAfterSuppressDoesNotCancelOpenDialog(t *testing.T) {
311+ useDirectJSDialogContinue(t)
312+ ui := &jsDialogUIRecorder{handled: true}
313+ h := &handlerSet{wv: newJSDialogWebView(ui)}
314+ first := &stubJSDialogCallback{}
315+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
316+
317+ // A spammer is suppressed; CEF then sends OnResetDialogState.
318+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", &stubJSDialogCallback{}, new(int32))
319+ h.OnResetDialogState(nil)
320+
321+ require.Empty(t, first.calls, "dialog #1 must stay pending")
322+ require.Zero(t, ui.resets)
323+
324+ // A real reset afterwards still cancels it.
325+ h.OnResetDialogState(nil)
326+ require.Equal(t, []jsDialogAnswer{{ok: false}}, first.calls)
327+ require.Equal(t, 1, ui.resets)
328+}
329+
330+func TestResetEchoAfterRejectedBeforeUnloadDoesNotCancelOpenDialog(t *testing.T) {
331+ useDirectJSDialogContinue(t)
332+ ui := &jsDialogUIRecorder{handled: true}
333+ h := &handlerSet{wv: newJSDialogWebView(ui)}
334+ first := &stubJSDialogCallback{}
335+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeConfirm, "1", "", first, new(int32))
336+
337+ other := &stubJSDialogCallback{}
338+ echoCEFClose(h, other)
339+ require.True(t, h.OnBeforeUnloadDialog(nil, "", 0, other))
340+
341+ require.Empty(t, first.calls)
342+ require.Zero(t, ui.resets)
343+}
344+
345+func TestCEFCloseEchoDoesNotCancelNewerDialog(t *testing.T) {
346+ useDirectJSDialogContinue(t)
347+ ui := &jsDialogUIRecorder{handled: true}
348+ h := &handlerSet{wv: newJSDialogWebView(ui)}
349+ first := &stubJSDialogCallback{}
350+ echoCEFClose(h, first)
351+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
352+
353+ ui.respond[0](true, "") // Cont -> OnDialogClosed + OnResetDialogState (echo)
354+ require.Equal(t, []jsDialogAnswer{{ok: true}}, first.calls)
355+ require.Zero(t, ui.resets, "echo must not hide the UI")
356+
357+ // The echo consumed the flag, so a genuine reset on a newer dialog cancels it.
358+ second := &stubJSDialogCallback{}
359+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", second, new(int32))
360+ h.OnResetDialogState(nil)
361+ require.Equal(t, []jsDialogAnswer{{ok: false}}, second.calls)
362+}
363+
364+func TestDeferredResetEchoIsConsumed(t *testing.T) {
365+ useDirectJSDialogContinue(t)
366+ ui := &jsDialogUIRecorder{handled: true}
367+ h := &handlerSet{wv: newJSDialogWebView(ui)}
368+ first := &stubJSDialogCallback{} // no synchronous echo
369+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
370+ ui.respond[0](true, "")
371+
372+ // The echo arrives later, while no dialog is open: consumed, UI untouched.
373+ h.OnDialogClosed(nil)
374+ h.OnResetDialogState(nil)
375+ require.Zero(t, ui.resets)
376+ require.Len(t, first.calls, 1)
377+ require.False(t, h.wv.jsDialogs.consumeIgnoreReset(), "flag must have been consumed")
378+}
379+
380+func TestMainFrameLoadStartCancelsPendingDialog(t *testing.T) {
381+ useDirectJSDialogContinue(t)
382+ ui := &jsDialogUIRecorder{handled: true}
383+ wv := newJSDialogWebView(ui)
384+ h := &handlerSet{wv: wv}
385+ first := &stubJSDialogCallback{}
386+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
387+ // A suppressed spammer makes CEF drop its handler_, so no reset will follow.
388+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", &stubJSDialogCallback{}, new(int32))
389+
390+ h.OnLoadStart(nil, stubFrame{main: false, url: "https://sub.example.com"}, 0)
391+ require.Empty(t, first.calls, "subframe loads must not cancel")
392+
393+ h.OnLoadStart(nil, stubFrame{main: true, url: "https://example.com/next"}, 0)
394+ require.Equal(t, []jsDialogAnswer{{ok: false}}, first.calls)
395+ require.Equal(t, 1, ui.resets)
396+}
397+
398+func TestDestroyedStateRejectsNewDialog(t *testing.T) {
399+ useDirectJSDialogContinue(t)
400+ ui := &jsDialogUIRecorder{handled: true}
401+ wv := newJSDialogWebView(ui)
402+ h := &handlerSet{wv: wv}
403+ wv.cancelJSDialogsClosing(true) // Destroy raced ahead of the destroyed check
404+ cb := &stubJSDialogCallback{}
405+ var suppress int32
406+
407+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, &suppress))
408+ require.False(t, h.OnBeforeUnloadDialog(nil, "", 0, cb))
409+ require.Zero(t, suppress)
410+ require.Empty(t, ui.reqs)
411+}
412+
413+func TestNoStaleIgnoreAcrossDialogs(t *testing.T) {
414+ useDirectJSDialogContinue(t)
415+ ui := &jsDialogUIRecorder{handled: true}
416+ h := &handlerSet{wv: newJSDialogWebView(ui)}
417+ // Answered without a CEF echo (e.g. reset-then-late-answer path).
418+ first := &stubJSDialogCallback{}
419+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
420+ ui.respond[0](true, "")
421+
422+ second := &stubJSDialogCallback{}
423+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", second, new(int32))
424+ h.OnResetDialogState(nil)
425+
426+ require.Equal(t, []jsDialogAnswer{{ok: false}}, second.calls, "real reset must cancel the new dialog")
427+}
428+
429+func TestDestroyedWebViewDoesNotShowDialogs(t *testing.T) {
430+ ui := &jsDialogUIRecorder{handled: true}
431+ wv := newJSDialogWebView(ui)
432+ wv.destroyed.Store(true)
433+ h := &handlerSet{wv: wv}
434+ cb := &stubJSDialogCallback{}
435+ var suppress int32
436+
437+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, &suppress))
438+ require.False(t, h.OnBeforeUnloadDialog(nil, "", 0, cb))
439+ require.Zero(t, suppress)
440+ require.Empty(t, ui.reqs)
441+ require.Empty(t, cb.calls)
442+}
443+
444+func TestRenderProcessTerminatedCancelsPendingJSDialog(t *testing.T) {
445+ useDirectJSDialogContinue(t)
446+ ui := &jsDialogUIRecorder{handled: true}
447+ wv := newJSDialogWebView(ui)
448+ h := &handlerSet{wv: wv}
449+ cb := &stubJSDialogCallback{}
450+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, new(int32))
451+
452+ h.OnRenderProcessTerminated(nil, purecef.TerminationStatusTsProcessCrashed, 0, "")
453+
454+ require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
455+ require.Equal(t, 1, ui.resets)
456+}
@@ -0,0 +1,456 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+)
12+
13+type stubJSDialogCallback struct {
14+ calls []jsDialogAnswer
15+ // onCont mimics CEF's DialogClosed ordering: OnDialogClosed and
16+ // OnResetDialogState are delivered from inside Cont.
17+ onCont func()
18+}
19+
20+type jsDialogAnswer struct {
21+ ok bool
22+ input string
23+}
24+
25+func (s *stubJSDialogCallback) Cont(success int32, userInput string) {
26+ s.calls = append(s.calls, jsDialogAnswer{ok: success != 0, input: userInput})
27+ if s.onCont != nil {
28+ s.onCont()
29+ }
30+}
31+
32+// echoCEFClose makes cb deliver CEF's DialogClosed + reset echo from Cont.
33+func echoCEFClose(h *handlerSet, cb *stubJSDialogCallback) {
34+ cb.onCont = func() {
35+ h.OnDialogClosed(nil)
36+ h.OnResetDialogState(nil)
37+ }
38+}
39+
40+// useDirectJSDialogContinue answers stub callbacks inline (no CEF UI thread).
41+func useDirectJSDialogContinue(t *testing.T) {
42+ t.Helper()
43+ prev := continueCEFJSDialog
44+ continueCEFJSDialog = func(cb purecef.JsdialogCallback, ok bool, input string) {
45+ success := int32(0)
46+ if ok {
47+ success = 1
48+ }
49+ cb.Cont(success, input)
50+ }
51+ t.Cleanup(func() { continueCEFJSDialog = prev })
52+}
53+
54+type jsDialogUIRecorder struct {
55+ reqs []port.JSDialogRequest
56+ respond []func(bool, string)
57+ resets int
58+ handled bool
59+}
60+
61+func newJSDialogWebView(ui *jsDialogUIRecorder) *WebView {
62+ return &WebView{
63+ ctx: context.Background(),
64+ callbacks: &port.WebViewCallbacks{
65+ OnJSDialog: func(req port.JSDialogRequest, respond func(bool, string)) bool {
66+ ui.reqs = append(ui.reqs, req)
67+ ui.respond = append(ui.respond, respond)
68+ return ui.handled
69+ },
70+ OnJSDialogReset: func() { ui.resets++ },
71+ },
72+ }
73+}
74+
75+func TestGetJsdialogHandlerEnabled(t *testing.T) {
76+ h := &handlerSet{}
77+ require.Same(t, h, h.GetJsdialogHandler())
78+}
79+
80+func TestJSDialogTypeMapping(t *testing.T) {
81+ cases := map[purecef.JsdialogType]port.JSDialogType{
82+ purecef.JsdialogTypeJsdialogtypeAlert: port.JSDialogAlert,
83+ purecef.JsdialogTypeJsdialogtypeConfirm: port.JSDialogConfirm,
84+ purecef.JsdialogTypeJsdialogtypePrompt: port.JSDialogPrompt,
85+ }
86+ for in, want := range cases {
87+ got, ok := jsDialogTypeFromCEF(in)
88+ require.True(t, ok)
89+ require.Equal(t, want, got)
90+ }
91+ _, ok := jsDialogTypeFromCEF(purecef.JsdialogTypeJsdialogtypeNumValues)
92+ require.False(t, ok)
93+}
94+
95+func TestOnJsdialogForwardsRequestAndRespondsOnce(t *testing.T) {
96+ useDirectJSDialogContinue(t)
97+ ui := &jsDialogUIRecorder{handled: true}
98+ h := &handlerSet{wv: newJSDialogWebView(ui)}
99+ cb := &stubJSDialogCallback{}
100+ var suppress int32
101+
102+ handled := h.OnJsdialog(nil, "https://example.com/p", purecef.JsdialogTypeJsdialogtypePrompt, "Name?", "bob", cb, &suppress)
103+
104+ require.Equal(t, int32(1), handled)
105+ require.Zero(t, suppress)
106+ require.Equal(t, []port.JSDialogRequest{{
107+ Type: port.JSDialogPrompt, Origin: "https://example.com/p", Message: "Name?", DefaultPrompt: "bob",
108+ }}, ui.reqs)
109+ require.Empty(t, cb.calls)
110+
111+ ui.respond[0](true, "alice")
112+ ui.respond[0](false, "late") // ignored: exactly once
113+ require.Equal(t, []jsDialogAnswer{{ok: true, input: "alice"}}, cb.calls)
114+}
115+
116+func TestOnJsdialogDropsInputForNonPrompt(t *testing.T) {
117+ useDirectJSDialogContinue(t)
118+ ui := &jsDialogUIRecorder{handled: true}
119+ h := &handlerSet{wv: newJSDialogWebView(ui)}
120+ cb := &stubJSDialogCallback{}
121+
122+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeConfirm, "ok?", "", cb, new(int32))
123+ ui.respond[0](true, "ignored")
124+
125+ require.Equal(t, []jsDialogAnswer{{ok: true}}, cb.calls)
126+}
127+
128+func TestOnJsdialogWithoutUICallbackUsesDefault(t *testing.T) {
129+ cb := &stubJSDialogCallback{}
130+ var suppress int32
131+
132+ for _, h := range []*handlerSet{
133+ nil,
134+ {},
135+ {wv: &WebView{ctx: context.Background()}},
136+ {wv: &WebView{ctx: context.Background(), callbacks: &port.WebViewCallbacks{}}},
137+ } {
138+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, &suppress))
139+ require.False(t, h.OnBeforeUnloadDialog(nil, "m", 0, cb))
140+ }
141+ require.Zero(t, suppress)
142+ require.Empty(t, cb.calls)
143+}
144+
145+func TestOnJsdialogRejectsNilCallbackAndUnknownType(t *testing.T) {
146+ ui := &jsDialogUIRecorder{handled: true}
147+ h := &handlerSet{wv: newJSDialogWebView(ui)}
148+
149+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", nil, new(int32)))
150+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeNumValues, "m", "", &stubJSDialogCallback{}, new(int32)))
151+ require.Empty(t, ui.reqs)
152+}
153+
154+func TestOnJsdialogUINotHandledCancels(t *testing.T) {
155+ useDirectJSDialogContinue(t)
156+ ui := &jsDialogUIRecorder{handled: false}
157+ h := &handlerSet{wv: newJSDialogWebView(ui)}
158+ cb := &stubJSDialogCallback{}
159+
160+ require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeConfirm, "m", "", cb, new(int32)))
161+ require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
162+}
163+
164+func TestOnJsdialogSuppressesWhileDialogOpen(t *testing.T) {
165+ useDirectJSDialogContinue(t)
166+ ui := &jsDialogUIRecorder{handled: true}
167+ h := &handlerSet{wv: newJSDialogWebView(ui)}
168+ first := &stubJSDialogCallback{}
169+ second := &stubJSDialogCallback{}
170+ var suppress int32
171+
172+ require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32)))
173+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", second, &suppress))
174+ require.Equal(t, int32(1), suppress)
175+ require.Len(t, ui.reqs, 1)
176+ require.Empty(t, second.calls)
177+
178+ // Once answered, new dialogs are allowed again.
179+ ui.respond[0](true, "")
180+ require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "3", "", second, new(int32)))
181+ require.Len(t, ui.reqs, 2)
182+}
183+
184+func TestOnBeforeUnloadDialog(t *testing.T) {
185+ useDirectJSDialogContinue(t)
186+ ui := &jsDialogUIRecorder{handled: true}
187+ wv := newJSDialogWebView(ui)
188+ wv.uri = "https://example.com/edit"
189+ h := &handlerSet{wv: wv}
190+ cb := &stubJSDialogCallback{}
191+
192+ require.True(t, h.OnBeforeUnloadDialog(nil, "unsaved", 1, cb))
193+ // CEF's fixed message text is not forwarded.
194+ require.Equal(t, []port.JSDialogRequest{{
195+ Type: port.JSDialogBeforeUnload, Origin: "https://example.com/edit", IsReload: true,
196+ }}, ui.reqs)
197+
198+ // A second beforeunload while open is answered "stay" immediately.
199+ other := &stubJSDialogCallback{}
200+ require.True(t, h.OnBeforeUnloadDialog(nil, "again", 0, other))
201+ require.Equal(t, []jsDialogAnswer{{ok: false}}, other.calls)
202+ require.Len(t, ui.reqs, 1)
203+
204+ ui.respond[0](true, "")
205+ require.Equal(t, []jsDialogAnswer{{ok: true}}, cb.calls)
206+}
207+
208+func TestOnResetDialogStateResolvesPendingAndClosesUI(t *testing.T) {
209+ useDirectJSDialogContinue(t)
210+ ui := &jsDialogUIRecorder{handled: true}
211+ h := &handlerSet{wv: newJSDialogWebView(ui)}
212+ cb := &stubJSDialogCallback{}
213+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypePrompt, "m", "d", cb, new(int32))
214+
215+ h.OnResetDialogState(nil)
216+
217+ require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
218+ require.Equal(t, 1, ui.resets)
219+
220+ // A late UI answer after reset must not resolve twice.
221+ ui.respond[0](true, "late")
222+ require.Len(t, cb.calls, 1)
223+
224+ // Reset with nothing pending is a no-op and does not touch the UI.
225+ h.OnResetDialogState(nil)
226+ require.Equal(t, 1, ui.resets)
227+ h.OnDialogClosed(nil)
228+}
229+
230+func TestDestroyCancelsPendingJSDialog(t *testing.T) {
231+ useDirectJSDialogContinue(t)
232+ ui := &jsDialogUIRecorder{handled: true}
233+ wv := newJSDialogWebView(ui)
234+ h := &handlerSet{wv: wv}
235+ cb := &stubJSDialogCallback{}
236+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, new(int32))
237+
238+ wv.Destroy()
239+
240+ require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
241+ require.Equal(t, 1, ui.resets)
242+}
243+
244+func TestJSDialogCancelledBeforeUIRunsIsNotShown(t *testing.T) {
245+ useDirectJSDialogContinue(t)
246+ ui := &jsDialogUIRecorder{handled: true}
247+ wv := newJSDialogWebView(ui)
248+ cb := &stubJSDialogCallback{}
249+ call, begin := wv.jsDialogs.begin(cb)
250+ require.Equal(t, jsDialogBeginOK, begin)
251+ wv.jsDialogs.resolve(call, false, "")
252+
253+ wv.presentJSDialog(call, port.JSDialogRequest{}, wv.callbacks)
254+
255+ require.Empty(t, ui.reqs)
256+ require.Len(t, cb.calls, 1)
257+}
258+
259+// useQueuedGTK makes runOnGTK queue callbacks; the returned func runs them in
260+// order, like the GTK main loop would.
261+func useQueuedGTK(t *testing.T, wv *WebView) (drain func()) {
262+ t.Helper()
263+ var queue []func()
264+ prev := postToGTK
265+ postToGTK = func(fn func()) { queue = append(queue, fn) }
266+ t.Cleanup(func() { postToGTK = prev })
267+ wv.engine = &Engine{}
268+ return func() {
269+ for len(queue) > 0 {
270+ fn := queue[0]
271+ queue = queue[1:]
272+ fn()
273+ }
274+ }
275+}
276+
277+func TestStaleUIResetSkippedWhenNewerDialogPending(t *testing.T) {
278+ useDirectJSDialogContinue(t)
279+ ui := &jsDialogUIRecorder{handled: true}
280+ wv := newJSDialogWebView(ui)
281+ drainGTK := useQueuedGTK(t, wv)
282+ h := &handlerSet{wv: wv}
283+ old := &stubJSDialogCallback{}
284+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "old", "", old, new(int32))
285+ drainGTK()
286+ require.Len(t, ui.reqs, 1)
287+
288+ // Cancel resolves the old dialog; its UI reset is queued for GTK.
289+ wv.cancelJSDialogs()
290+ require.Equal(t, []jsDialogAnswer{{ok: false}}, old.calls)
291+ require.Zero(t, ui.resets, "reset must wait for the GTK queue")
292+
293+ // A newer dialog arrives before the queued reset runs.
294+ newer := &stubJSDialogCallback{}
295+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "new", "", newer, new(int32))
296+
297+ // GTK runs the stale reset, then presents the newer dialog.
298+ drainGTK()
299+ require.Len(t, ui.reqs, 2)
300+ require.Zero(t, ui.resets, "stale reset must not hide the newer dialog")
301+ require.Empty(t, newer.calls, "newer dialog must stay pending")
302+
303+ // Canceling the newer dialog resets the UI once GTK runs.
304+ wv.cancelJSDialogs()
305+ drainGTK()
306+ require.Equal(t, []jsDialogAnswer{{ok: false}}, newer.calls)
307+ require.Equal(t, 1, ui.resets)
308+}
309+
310+func TestResetEchoAfterSuppressDoesNotCancelOpenDialog(t *testing.T) {
311+ useDirectJSDialogContinue(t)
312+ ui := &jsDialogUIRecorder{handled: true}
313+ h := &handlerSet{wv: newJSDialogWebView(ui)}
314+ first := &stubJSDialogCallback{}
315+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
316+
317+ // A spammer is suppressed; CEF then sends OnResetDialogState.
318+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", &stubJSDialogCallback{}, new(int32))
319+ h.OnResetDialogState(nil)
320+
321+ require.Empty(t, first.calls, "dialog #1 must stay pending")
322+ require.Zero(t, ui.resets)
323+
324+ // A real reset afterwards still cancels it.
325+ h.OnResetDialogState(nil)
326+ require.Equal(t, []jsDialogAnswer{{ok: false}}, first.calls)
327+ require.Equal(t, 1, ui.resets)
328+}
329+
330+func TestResetEchoAfterRejectedBeforeUnloadDoesNotCancelOpenDialog(t *testing.T) {
331+ useDirectJSDialogContinue(t)
332+ ui := &jsDialogUIRecorder{handled: true}
333+ h := &handlerSet{wv: newJSDialogWebView(ui)}
334+ first := &stubJSDialogCallback{}
335+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeConfirm, "1", "", first, new(int32))
336+
337+ other := &stubJSDialogCallback{}
338+ echoCEFClose(h, other)
339+ require.True(t, h.OnBeforeUnloadDialog(nil, "", 0, other))
340+
341+ require.Empty(t, first.calls)
342+ require.Zero(t, ui.resets)
343+}
344+
345+func TestCEFCloseEchoDoesNotCancelNewerDialog(t *testing.T) {
346+ useDirectJSDialogContinue(t)
347+ ui := &jsDialogUIRecorder{handled: true}
348+ h := &handlerSet{wv: newJSDialogWebView(ui)}
349+ first := &stubJSDialogCallback{}
350+ echoCEFClose(h, first)
351+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
352+
353+ ui.respond[0](true, "") // Cont -> OnDialogClosed + OnResetDialogState (echo)
354+ require.Equal(t, []jsDialogAnswer{{ok: true}}, first.calls)
355+ require.Zero(t, ui.resets, "echo must not hide the UI")
356+
357+ // The echo consumed the flag, so a genuine reset on a newer dialog cancels it.
358+ second := &stubJSDialogCallback{}
359+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", second, new(int32))
360+ h.OnResetDialogState(nil)
361+ require.Equal(t, []jsDialogAnswer{{ok: false}}, second.calls)
362+}
363+
364+func TestDeferredResetEchoIsConsumed(t *testing.T) {
365+ useDirectJSDialogContinue(t)
366+ ui := &jsDialogUIRecorder{handled: true}
367+ h := &handlerSet{wv: newJSDialogWebView(ui)}
368+ first := &stubJSDialogCallback{} // no synchronous echo
369+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
370+ ui.respond[0](true, "")
371+
372+ // The echo arrives later, while no dialog is open: consumed, UI untouched.
373+ h.OnDialogClosed(nil)
374+ h.OnResetDialogState(nil)
375+ require.Zero(t, ui.resets)
376+ require.Len(t, first.calls, 1)
377+ require.False(t, h.wv.jsDialogs.consumeIgnoreReset(), "flag must have been consumed")
378+}
379+
380+func TestMainFrameLoadStartCancelsPendingDialog(t *testing.T) {
381+ useDirectJSDialogContinue(t)
382+ ui := &jsDialogUIRecorder{handled: true}
383+ wv := newJSDialogWebView(ui)
384+ h := &handlerSet{wv: wv}
385+ first := &stubJSDialogCallback{}
386+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
387+ // A suppressed spammer makes CEF drop its handler_, so no reset will follow.
388+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", &stubJSDialogCallback{}, new(int32))
389+
390+ h.OnLoadStart(nil, stubFrame{main: false, url: "https://sub.example.com"}, 0)
391+ require.Empty(t, first.calls, "subframe loads must not cancel")
392+
393+ h.OnLoadStart(nil, stubFrame{main: true, url: "https://example.com/next"}, 0)
394+ require.Equal(t, []jsDialogAnswer{{ok: false}}, first.calls)
395+ require.Equal(t, 1, ui.resets)
396+}
397+
398+func TestDestroyedStateRejectsNewDialog(t *testing.T) {
399+ useDirectJSDialogContinue(t)
400+ ui := &jsDialogUIRecorder{handled: true}
401+ wv := newJSDialogWebView(ui)
402+ h := &handlerSet{wv: wv}
403+ wv.cancelJSDialogsClosing(true) // Destroy raced ahead of the destroyed check
404+ cb := &stubJSDialogCallback{}
405+ var suppress int32
406+
407+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, &suppress))
408+ require.False(t, h.OnBeforeUnloadDialog(nil, "", 0, cb))
409+ require.Zero(t, suppress)
410+ require.Empty(t, ui.reqs)
411+}
412+
413+func TestNoStaleIgnoreAcrossDialogs(t *testing.T) {
414+ useDirectJSDialogContinue(t)
415+ ui := &jsDialogUIRecorder{handled: true}
416+ h := &handlerSet{wv: newJSDialogWebView(ui)}
417+ // Answered without a CEF echo (e.g. reset-then-late-answer path).
418+ first := &stubJSDialogCallback{}
419+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
420+ ui.respond[0](true, "")
421+
422+ second := &stubJSDialogCallback{}
423+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", second, new(int32))
424+ h.OnResetDialogState(nil)
425+
426+ require.Equal(t, []jsDialogAnswer{{ok: false}}, second.calls, "real reset must cancel the new dialog")
427+}
428+
429+func TestDestroyedWebViewDoesNotShowDialogs(t *testing.T) {
430+ ui := &jsDialogUIRecorder{handled: true}
431+ wv := newJSDialogWebView(ui)
432+ wv.destroyed.Store(true)
433+ h := &handlerSet{wv: wv}
434+ cb := &stubJSDialogCallback{}
435+ var suppress int32
436+
437+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, &suppress))
438+ require.False(t, h.OnBeforeUnloadDialog(nil, "", 0, cb))
439+ require.Zero(t, suppress)
440+ require.Empty(t, ui.reqs)
441+ require.Empty(t, cb.calls)
442+}
443+
444+func TestRenderProcessTerminatedCancelsPendingJSDialog(t *testing.T) {
445+ useDirectJSDialogContinue(t)
446+ ui := &jsDialogUIRecorder{handled: true}
447+ wv := newJSDialogWebView(ui)
448+ h := &handlerSet{wv: wv}
449+ cb := &stubJSDialogCallback{}
450+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, new(int32))
451+
452+ h.OnRenderProcessTerminated(nil, purecef.TerminationStatusTsProcessCrashed, 0, "")
453+
454+ require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
455+ require.Equal(t, 1, ui.resets)
456+}
M internal/infrastructure/cef/webview.go +13 -7
@@ -221,6 +221,7 @@ type WebView struct {
221221 // Callbacks and browsing-context state set by the UI layer.
222222 mu sync.RWMutex
223223 callbacks *port.WebViewCallbacks
224+ jsDialogs jsDialogState
224225 browsingContextDecision dto.HostDecision
225226 hasBrowsingContextDecision bool
226227 nativePopupHostAbort func()
@@ -1332,6 +1333,7 @@ func (wv *WebView) Destroy() {
13321333 // calling thread; GTK-only cleanup follows through the owning
13331334 // dispatcher without waiting for the deferred native browser close.
13341335 wv.invalidateScrollMotion()
1336+ wv.cancelJSDialogsClosing(true)
13351337 wv.shutdownAccessibilityCapture()
13361338 wv.resetPageScrollQueue()
13371339 wv.syntheticPopupMu.Lock()
@@ -2602,6 +2604,16 @@ func (wv *WebView) releaseBeginFrameTickCallback() {
26022604 }
26032605 }
26042606
2607+// postToGTK queues fn on the GTK main loop. It is a seam for tests.
2608+var postToGTK = func(fn func()) {
2609+ // Heap-allocate the callback so it survives until glib invokes it.
2610+ cb := new(glib.SourceOnceFunc)
2611+ *cb = func(_ uintptr) {
2612+ fn()
2613+ }
2614+ glib.IdleAddOnce(cb, 0)
2615+}
2616+
26052617 func (wv *WebView) runOnGTK(fn func()) {
26062618 if fn == nil {
26072619 return
@@ -2611,13 +2623,7 @@ func (wv *WebView) runOnGTK(fn func()) {
26112623 fn()
26122624 return
26132625 }
2614-
2615- // Heap-allocate the callback so it survives until glib invokes it.
2616- cb := new(glib.SourceOnceFunc)
2617- *cb = func(_ uintptr) {
2618- fn()
2619- }
2620- glib.IdleAddOnce(cb, 0)
2626+ postToGTK(fn)
26212627 }
26222628
26232629 func (wv *WebView) isOnGTKThread() bool {
@@ -221,6 +221,7 @@ type WebView struct {
221 // Callbacks and browsing-context state set by the UI layer.221 // Callbacks and browsing-context state set by the UI layer.
222 mu sync.RWMutex222 mu sync.RWMutex
223 callbacks *port.WebViewCallbacks223 callbacks *port.WebViewCallbacks
224+ jsDialogs jsDialogState
224 browsingContextDecision dto.HostDecision225 browsingContextDecision dto.HostDecision
225 hasBrowsingContextDecision bool226 hasBrowsingContextDecision bool
226 nativePopupHostAbort func()227 nativePopupHostAbort func()
@@ -1332,6 +1333,7 @@ func (wv *WebView) Destroy() {
1332 // calling thread; GTK-only cleanup follows through the owning1333 // calling thread; GTK-only cleanup follows through the owning
1333 // dispatcher without waiting for the deferred native browser close.1334 // dispatcher without waiting for the deferred native browser close.
1334 wv.invalidateScrollMotion()1335 wv.invalidateScrollMotion()
1336+ wv.cancelJSDialogsClosing(true)
1335 wv.shutdownAccessibilityCapture()1337 wv.shutdownAccessibilityCapture()
1336 wv.resetPageScrollQueue()1338 wv.resetPageScrollQueue()
1337 wv.syntheticPopupMu.Lock()1339 wv.syntheticPopupMu.Lock()
@@ -2602,6 +2604,16 @@ func (wv *WebView) releaseBeginFrameTickCallback() {
2602 }2604 }
2603 }2605 }
2604 2606
2607+// postToGTK queues fn on the GTK main loop. It is a seam for tests.
2608+var postToGTK = func(fn func()) {
2609+ // Heap-allocate the callback so it survives until glib invokes it.
2610+ cb := new(glib.SourceOnceFunc)
2611+ *cb = func(_ uintptr) {
2612+ fn()
2613+ }
2614+ glib.IdleAddOnce(cb, 0)
2615+}
2616+
2605 func (wv *WebView) runOnGTK(fn func()) {2617 func (wv *WebView) runOnGTK(fn func()) {
2606 if fn == nil {2618 if fn == nil {
2607 return2619 return
@@ -2611,13 +2623,7 @@ func (wv *WebView) runOnGTK(fn func()) {
2611 fn()2623 fn()
2612 return2624 return
2613 }2625 }
2614-2626+ postToGTK(fn)
2615- // Heap-allocate the callback so it survives until glib invokes it.
2616- cb := new(glib.SourceOnceFunc)
2617- *cb = func(_ uintptr) {
2618- fn()
2619- }
2620- glib.IdleAddOnce(cb, 0)
2621 }2627 }
2622 2628
2623 func (wv *WebView) isOnGTKThread() bool {2629 func (wv *WebView) isOnGTKThread() bool {
M internal/infrastructure/cef/webview_handlers.go +7 -1
@@ -72,7 +72,7 @@ func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h
7272 func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
7373 func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
7474 func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
75-func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }
75+func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return h }
7676 func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
7777 func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }
7878 func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
@@ -531,6 +531,8 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
531531 // Post-commit fallback for navigation paths that bypass OnBeforeBrowse
532532 // (same-page, error, and helper-driven commits). Main frame only.
533533 if h.wv != nil {
534+ // A new document replaces the page that asked for any open JS dialog.
535+ h.wv.cancelJSDialogs()
534536 h.wv.invalidateScrollMotion()
535537 h.wv.mu.Lock()
536538 h.wv.documentSeq++
@@ -1298,6 +1300,10 @@ const maxConsecutiveCrashes = 3
12981300
12991301 // OnRenderProcessTerminated fires the OnWebProcessTerminated callback with a mapped reason.
13001302 func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef.TerminationStatus, _ int32, _ string) {
1303+ // CEF never resets dialog state on a renderer crash; don't leave the UI
1304+ // (and the dead page's callback) hanging.
1305+ h.wv.cancelJSDialogs()
1306+
13011307 if h.wv.crashCount.Add(1) > maxConsecutiveCrashes {
13021308 return // suppress to break the loop
13031309 }
@@ -72,7 +72,7 @@ func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h
72 func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }72 func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
73 func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }73 func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
74 func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }74 func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
75-func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }75+func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return h }
76 func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }76 func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
77 func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }77 func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }
78 func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }78 func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
@@ -531,6 +531,8 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
531 // Post-commit fallback for navigation paths that bypass OnBeforeBrowse531 // Post-commit fallback for navigation paths that bypass OnBeforeBrowse
532 // (same-page, error, and helper-driven commits). Main frame only.532 // (same-page, error, and helper-driven commits). Main frame only.
533 if h.wv != nil {533 if h.wv != nil {
534+ // A new document replaces the page that asked for any open JS dialog.
535+ h.wv.cancelJSDialogs()
534 h.wv.invalidateScrollMotion()536 h.wv.invalidateScrollMotion()
535 h.wv.mu.Lock()537 h.wv.mu.Lock()
536 h.wv.documentSeq++538 h.wv.documentSeq++
@@ -1298,6 +1300,10 @@ const maxConsecutiveCrashes = 3
1298 1300
1299 // OnRenderProcessTerminated fires the OnWebProcessTerminated callback with a mapped reason.1301 // OnRenderProcessTerminated fires the OnWebProcessTerminated callback with a mapped reason.
1300 func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef.TerminationStatus, _ int32, _ string) {1302 func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef.TerminationStatus, _ int32, _ string) {
1303+ // CEF never resets dialog state on a renderer crash; don't leave the UI
1304+ // (and the dead page's callback) hanging.
1305+ h.wv.cancelJSDialogs()
1306+
1301 if h.wv.crashCount.Add(1) > maxConsecutiveCrashes {1307 if h.wv.crashCount.Add(1) > maxConsecutiveCrashes {
1302 return // suppress to break the loop1308 return // suppress to break the loop
1303 }1309 }
M internal/ui/app.go +21 -7
@@ -3424,17 +3424,18 @@ func (a *App) initCoordinators(ctx context.Context) {
34243424 a.handlePageEditableFocusChanged(ctx, paneID, editable)
34253425 })
34263426
3427+ // JS dialogs are shown in the owning pane's overlay, wherever that pane lives.
3428+ a.contentCoord.SetPaneViewResolver(a.paneViewForPane)
3429+ a.contentCoord.SetUIScaleProvider(func() float64 {
3430+ return a.runtimeConfigSnapshot().UI.DefaultUIScale
3431+ })
3432+
34273433 // Hide loading skeleton once the WebView paints
34283434 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
34293435 // The WebView can be shown while its pane is in a background tab, so scan
34303436 // all WorkspaceViews rather than only the active one.
3431- for _, wsView := range a.workspaceViews {
3432- if wsView == nil {
3433- continue
3434- }
3435- if pv := wsView.GetPaneView(paneID); pv != nil {
3436- pv.HideLoadingSkeleton()
3437- }
3437+ if pv := a.paneViewForPane(paneID); pv != nil {
3438+ pv.HideLoadingSkeleton()
34383439 }
34393440 if a.deps != nil && a.deps.OnFirstWebViewShown != nil {
34403441 a.firstWebViewShownOnce.Do(func() {
@@ -3589,6 +3590,19 @@ func (a *App) wireKeyboardActions() {
35893590 })
35903591 }
35913592
3593+// paneViewForPane finds a pane's view in any tab (including background tabs).
3594+func (a *App) paneViewForPane(paneID entity.PaneID) *component.PaneView {
3595+ for _, wsView := range a.workspaceViews {
3596+ if wsView == nil {
3597+ continue
3598+ }
3599+ if pv := wsView.GetPaneView(paneID); pv != nil {
3600+ return pv
3601+ }
3602+ }
3603+ return nil
3604+}
3605+
35923606 func (a *App) wireWebRTCPermissionIndicator() {
35933607 if a.contentCoord == nil {
35943608 return
@@ -3424,17 +3424,18 @@ func (a *App) initCoordinators(ctx context.Context) {
3424 a.handlePageEditableFocusChanged(ctx, paneID, editable)3424 a.handlePageEditableFocusChanged(ctx, paneID, editable)
3425 })3425 })
3426 3426
3427+ // JS dialogs are shown in the owning pane's overlay, wherever that pane lives.
3428+ a.contentCoord.SetPaneViewResolver(a.paneViewForPane)
3429+ a.contentCoord.SetUIScaleProvider(func() float64 {
3430+ return a.runtimeConfigSnapshot().UI.DefaultUIScale
3431+ })
3432+
3427 // Hide loading skeleton once the WebView paints3433 // Hide loading skeleton once the WebView paints
3428 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {3434 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
3429 // The WebView can be shown while its pane is in a background tab, so scan3435 // The WebView can be shown while its pane is in a background tab, so scan
3430 // all WorkspaceViews rather than only the active one.3436 // all WorkspaceViews rather than only the active one.
3431- for _, wsView := range a.workspaceViews {3437+ if pv := a.paneViewForPane(paneID); pv != nil {
3432- if wsView == nil {3438+ pv.HideLoadingSkeleton()
3433- continue
3434- }
3435- if pv := wsView.GetPaneView(paneID); pv != nil {
3436- pv.HideLoadingSkeleton()
3437- }
3438 }3439 }
3439 if a.deps != nil && a.deps.OnFirstWebViewShown != nil {3440 if a.deps != nil && a.deps.OnFirstWebViewShown != nil {
3440 a.firstWebViewShownOnce.Do(func() {3441 a.firstWebViewShownOnce.Do(func() {
@@ -3589,6 +3590,19 @@ func (a *App) wireKeyboardActions() {
3589 })3590 })
3590 }3591 }
3591 3592
3593+// paneViewForPane finds a pane's view in any tab (including background tabs).
3594+func (a *App) paneViewForPane(paneID entity.PaneID) *component.PaneView {
3595+ for _, wsView := range a.workspaceViews {
3596+ if wsView == nil {
3597+ continue
3598+ }
3599+ if pv := wsView.GetPaneView(paneID); pv != nil {
3600+ return pv
3601+ }
3602+ }
3603+ return nil
3604+}
3605+
3592 func (a *App) wireWebRTCPermissionIndicator() {3606 func (a *App) wireWebRTCPermissionIndicator() {
3593 if a.contentCoord == nil {3607 if a.contentCoord == nil {
3594 return3608 return
A internal/ui/component/jsdialog_popup.go +493 -0
@@ -0,0 +1,493 @@
1+package component
2+
3+import (
4+ "net/url"
5+ "strings"
6+ "sync"
7+ "sync/atomic"
8+
9+ "github.com/bnema/puregotk/v4/gdk"
10+ "github.com/bnema/puregotk/v4/gtk"
11+ "github.com/bnema/puregotk/v4/pango"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/ui/layout"
15+)
16+
17+const (
18+ jsDialogMaxBodyHeight = 240
19+ jsDialogMaxWidthChars = 60
20+ jsDialogMaxHostChars = 80
21+ // jsDialogMaxTextRunes caps page-controlled text (message, prompt default)
22+ // so a hostile page can't make GTK lay out megabytes of text.
23+ jsDialogMaxTextRunes = 3000
24+ // jsDialogBeforeUnloadText is Dumber's own beforeunload text: CEF always
25+ // passes a fixed string, and browsers no longer show page text here.
26+ jsDialogBeforeUnloadText = "Changes you made may not be saved."
27+)
28+
29+// JSDialogSizeDefaults sizes the dialog box relative to its pane.
30+var JSDialogSizeDefaults = ModalSizeConfig{
31+ WidthPct: 0.9,
32+ MaxWidth: 420,
33+ TopMarginPct: 0.3,
34+ FallbackWidth: 420,
35+ FallbackHeight: 300,
36+}
37+
38+// jsDialogContent is the plain-text content of a JS dialog.
39+type jsDialogContent struct {
40+ Heading string
41+ Body string
42+ OKLabel string
43+ CancelLabel string // empty: no cancel button (alert)
44+ ShowInput bool
45+}
46+
47+// jsDialogOriginLabel returns a short, user-facing origin ("example.com") for
48+// the requesting URL. The origin is shown separately from the page-controlled
49+// message so a page cannot impersonate browser UI.
50+func jsDialogOriginLabel(origin string) string {
51+ origin = strings.TrimSpace(origin)
52+ if origin == "" {
53+ return "This page"
54+ }
55+ u, err := url.Parse(origin)
56+ // blob:/filesystem: URLs carry the real origin in the opaque part.
57+ if err == nil && u.Host == "" && (u.Scheme == "blob" || u.Scheme == "filesystem") && u.Opaque != "" {
58+ if inner, innerErr := url.Parse(u.Opaque); innerErr == nil && inner.Host != "" {
59+ u = inner
60+ }
61+ }
62+ if err != nil || u.Host == "" {
63+ if err == nil && u.Scheme != "" {
64+ return truncateRunesLeft(u.Scheme+":", jsDialogMaxHostChars)
65+ }
66+ return "This page"
67+ }
68+ // Keep the end of the host: the registrable domain is what identifies it.
69+ return truncateRunesLeft(u.Host, jsDialogMaxHostChars)
70+}
71+
72+// truncateRunesLeft keeps the last n runes, prefixing "…" when it cut.
73+func truncateRunesLeft(s string, n int) string {
74+ r := []rune(s)
75+ if len(r) <= n {
76+ return s
77+ }
78+ return "…" + string(r[len(r)-n:])
79+}
80+
81+func truncateRunes(s string, n int) string {
82+ r := []rune(s)
83+ if len(r) <= n {
84+ return s
85+ }
86+ return string(r[:n]) + "…"
87+}
88+
89+// buildJSDialogContent maps a request to the dialog texts and buttons.
90+// All strings are plain text (never Pango markup).
91+func buildJSDialogContent(req port.JSDialogRequest) jsDialogContent {
92+ says := jsDialogOriginLabel(req.Origin) + " says:"
93+ msg := truncateRunes(req.Message, jsDialogMaxTextRunes)
94+ switch req.Type {
95+ case port.JSDialogConfirm:
96+ return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK", CancelLabel: "Cancel"}
97+ case port.JSDialogPrompt:
98+ return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK", CancelLabel: "Cancel", ShowInput: true}
99+ case port.JSDialogBeforeUnload:
100+ body := jsDialogBeforeUnloadText
101+ heading := "Leave page?"
102+ if req.IsReload {
103+ heading = "Reload page?"
104+ }
105+ return jsDialogContent{
106+ Heading: heading,
107+ Body: jsDialogOriginLabel(req.Origin) + "\n" + body,
108+ OKLabel: "Leave",
109+ CancelLabel: "Stay",
110+ }
111+ default:
112+ return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK"}
113+ }
114+}
115+
116+// JSDialogPopup is a per-pane overlay that renders alert/confirm/prompt and
117+// beforeunload dialogs. It is owned by a PaneView, so a dialog belongs to its
118+// pane's WebView and never blocks other panes. It replaces Adwaita AlertDialog
119+// for the same reason as PermissionPopup (purego ConnectResponse bug).
120+type JSDialogPopup struct {
121+ outerBox *gtk.Box
122+ mainBox *gtk.Box
123+
124+ headingLabel *gtk.Label
125+ bodyLabel *gtk.Label
126+ input *gtk.Entry
127+ btnOK *gtk.Button
128+ btnCancel *gtk.Button
129+
130+ parent layout.OverlayWidget
131+ controller *gtk.EventControllerKey
132+ focusCtrl *gtk.EventControllerFocus
133+ hasFocus atomic.Bool // keyboard focus is inside the popup (enter/leave)
134+ signals []popupSignal
135+
136+ mu sync.Mutex
137+ visible bool
138+ wantFocus bool // this dialog should own keyboard focus (pane is active)
139+ destroyed bool
140+ respond func(ok bool, input string)
141+ onHidden func(hadFocus bool)
142+
143+ retainedCallbacks []any
144+}
145+
146+// popupSignal remembers a connected handler so Destroy can disconnect it.
147+type popupSignal struct {
148+ obj interface{ DisconnectSignal(uint) }
149+ id uint
150+}
151+
152+// NewJSDialogPopup creates the popup (hidden). Returns nil if widget creation fails.
153+// parent is the pane overlay used to size the dialog.
154+func NewJSDialogPopup(parent layout.OverlayWidget) *JSDialogPopup {
155+ p := &JSDialogPopup{parent: parent}
156+ if err := p.createWidgets(); err != nil {
157+ return nil
158+ }
159+ p.attachKeyController()
160+ return p
161+}
162+
163+// Widget returns the outer GTK widget for overlay registration.
164+func (p *JSDialogPopup) Widget() *gtk.Widget {
165+ if p == nil || p.outerBox == nil {
166+ return nil
167+ }
168+ return &p.outerBox.Widget
169+}
170+
171+// SetOnHidden sets (or with nil clears) a callback run after the popup closes. hadFocus reports
172+// whether keyboard focus was inside the dialog when it closed, so the owner
173+// only restores focus when the dialog had taken it.
174+func (p *JSDialogPopup) SetOnHidden(fn func(hadFocus bool)) {
175+ p.mu.Lock()
176+ p.onHidden = fn
177+ p.mu.Unlock()
178+}
179+
180+// Show displays the dialog. respond is invoked once with the user's answer.
181+// A dialog already visible is dismissed as canceled first. Keyboard focus is
182+// only taken when focus is true (the pane is active); otherwise RequestFocus
183+// grabs it when the pane becomes active.
184+func (p *JSDialogPopup) Show(req port.JSDialogRequest, respond func(ok bool, input string), focus bool, uiScale float64) {
185+ p.Dismiss(false)
186+
187+ content := buildJSDialogContent(req)
188+
189+ p.mu.Lock()
190+ if p.destroyed {
191+ p.mu.Unlock()
192+ if respond != nil {
193+ respond(false, "")
194+ }
195+ return
196+ }
197+ p.visible = true
198+ p.wantFocus = focus
199+ p.respond = respond
200+ p.mu.Unlock()
201+
202+ width, _ := CalculateModalDimensionsWithScale(p.parent, JSDialogSizeDefaults, uiScale)
203+ p.mainBox.SetSizeRequest(width, -1)
204+
205+ p.headingLabel.SetText(content.Heading)
206+ p.bodyLabel.SetText(content.Body)
207+ p.bodyLabel.SetVisible(content.Body != "")
208+ p.btnOK.SetLabel(content.OKLabel)
209+ p.btnCancel.SetVisible(content.CancelLabel != "")
210+ if content.CancelLabel != "" {
211+ p.btnCancel.SetLabel(content.CancelLabel)
212+ }
213+ p.input.SetVisible(content.ShowInput)
214+ if content.ShowInput {
215+ p.input.SetText(truncateRunes(req.DefaultPrompt, jsDialogMaxTextRunes))
216+ } else {
217+ p.input.SetText("")
218+ }
219+
220+ p.outerBox.SetVisible(true)
221+ if focus {
222+ p.grabDefaultFocus()
223+ }
224+}
225+
226+// Visible reports whether a dialog is currently shown.
227+func (p *JSDialogPopup) Visible() bool {
228+ p.mu.Lock()
229+ defer p.mu.Unlock()
230+ return p.visible
231+}
232+
233+// RequestFocus marks the dialog as the focus owner and grabs focus if shown.
234+// Called when the owning pane becomes active.
235+func (p *JSDialogPopup) RequestFocus() {
236+ p.mu.Lock()
237+ if !p.visible || p.destroyed {
238+ p.mu.Unlock()
239+ return
240+ }
241+ p.wantFocus = true
242+ p.mu.Unlock()
243+ p.grabDefaultFocus()
244+}
245+
246+// grabDefaultFocus focuses the entry (prompt) or the OK button.
247+func (p *JSDialogPopup) grabDefaultFocus() {
248+ if p.input.GetVisible() {
249+ p.input.GrabFocus()
250+ p.input.SelectRegion(0, -1)
251+ return
252+ }
253+ p.btnOK.GrabFocus()
254+}
255+
256+// Hide closes the popup without invoking the response callback. It is used when
257+// the engine canceled the dialog (navigation, WebView destruction).
258+func (p *JSDialogPopup) Hide() {
259+ if p == nil {
260+ return
261+ }
262+ p.close(nil)
263+}
264+
265+// Dismiss closes the popup and answers with ok. No-op when hidden.
266+func (p *JSDialogPopup) Dismiss(ok bool) {
267+ p.close(&ok)
268+}
269+
270+// close hides the popup. When answer is non-nil the response callback is run.
271+func (p *JSDialogPopup) close(answer *bool) {
272+ p.mu.Lock()
273+ if !p.visible {
274+ p.mu.Unlock()
275+ return
276+ }
277+ p.visible = false
278+ p.wantFocus = false
279+ respond := p.respond
280+ p.respond = nil
281+ hidden := p.onHidden
282+ p.mu.Unlock()
283+
284+ // Read before hiding: hiding a focused widget fires "leave".
285+ hadFocus := p.hasFocus.Load()
286+ text := ""
287+ if answer != nil && *answer && p.input.GetVisible() {
288+ text = p.input.GetText()
289+ }
290+ p.outerBox.SetVisible(false)
291+ if answer != nil && respond != nil {
292+ respond(*answer, text)
293+ }
294+ if hidden != nil {
295+ hidden(hadFocus)
296+ }
297+}
298+
299+// Destroy disconnects every handler and drops callbacks. The popup must not be
300+// used afterwards. Call after the dialog was dismissed.
301+func (p *JSDialogPopup) Destroy() {
302+ if p == nil {
303+ return
304+ }
305+ p.mu.Lock()
306+ if p.destroyed {
307+ p.mu.Unlock()
308+ return
309+ }
310+ p.destroyed = true
311+ p.visible = false
312+ p.respond = nil
313+ p.onHidden = nil
314+ p.mu.Unlock()
315+
316+ for _, s := range p.signals {
317+ s.obj.DisconnectSignal(s.id)
318+ }
319+ p.signals = nil
320+ if p.controller != nil && p.outerBox != nil {
321+ p.outerBox.RemoveController(&p.controller.EventController)
322+ p.controller = nil
323+ }
324+ if p.focusCtrl != nil && p.outerBox != nil {
325+ p.outerBox.RemoveController(&p.focusCtrl.EventController)
326+ p.focusCtrl = nil
327+ }
328+ p.retainedCallbacks = nil
329+}
330+
331+func (p *JSDialogPopup) createWidgets() error {
332+ if err := p.createContainers(); err != nil {
333+ return err
334+ }
335+ if err := p.createLabels(); err != nil {
336+ return err
337+ }
338+ scroll := gtk.NewScrolledWindow()
339+ if scroll == nil {
340+ return errNilWidget("jsDialogScroll")
341+ }
342+ scroll.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
343+ scroll.SetPropagateNaturalHeight(true)
344+ scroll.SetMaxContentHeight(jsDialogMaxBodyHeight)
345+ scroll.SetChild(&p.bodyLabel.Widget)
346+
347+ p.input = gtk.NewEntry()
348+ if p.input == nil {
349+ return errNilWidget("jsDialogEntry")
350+ }
351+ p.input.AddCssClass("jsdialog-entry")
352+ p.input.SetMaxLength(jsDialogMaxTextRunes)
353+ p.input.SetVisible(false)
354+ // Enter in the entry = OK. Focused buttons activate themselves, so Enter on
355+ // Cancel/Stay still cancels.
356+ activateCb := func(gtk.Entry) { p.Dismiss(true) }
357+ p.retainedCallbacks = append(p.retainedCallbacks, activateCb)
358+ p.signals = append(p.signals, popupSignal{p.input, p.input.ConnectActivate(&activateCb)})
359+
360+ btnRow, err := p.createButtonRow()
361+ if err != nil {
362+ return err
363+ }
364+
365+ p.mainBox.Append(&p.headingLabel.Widget)
366+ p.mainBox.Append(&scroll.Widget)
367+ p.mainBox.Append(&p.input.Widget)
368+ p.mainBox.Append(&btnRow.Widget)
369+ p.outerBox.Append(&p.mainBox.Widget)
370+ return nil
371+}
372+
373+func (p *JSDialogPopup) createContainers() error {
374+ p.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
375+ if p.outerBox == nil {
376+ return errNilWidget("jsDialogOuterBox")
377+ }
378+ // Fill the pane so the scrim swallows pointer input meant for the page.
379+ p.outerBox.AddCssClass("jsdialog-scrim")
380+ p.outerBox.SetHexpand(true)
381+ p.outerBox.SetVexpand(true)
382+ p.outerBox.SetHalign(gtk.AlignFillValue)
383+ p.outerBox.SetValign(gtk.AlignFillValue)
384+ p.outerBox.SetVisible(false)
385+
386+ p.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
387+ if p.mainBox == nil {
388+ return errNilWidget("jsDialogMainBox")
389+ }
390+ p.mainBox.AddCssClass("permission-popup-container")
391+ p.mainBox.SetHalign(gtk.AlignCenterValue)
392+ p.mainBox.SetValign(gtk.AlignCenterValue)
393+ return nil
394+}
395+
396+func (p *JSDialogPopup) createLabels() error {
397+ empty := ""
398+ p.headingLabel = gtk.NewLabel(&empty)
399+ p.bodyLabel = gtk.NewLabel(&empty)
400+ if p.headingLabel == nil || p.bodyLabel == nil {
401+ return errNilWidget("jsDialogLabels")
402+ }
403+ // Labels use SetText only: page text is never parsed as Pango markup.
404+ for _, l := range []*gtk.Label{p.headingLabel, p.bodyLabel} {
405+ l.SetUseMarkup(false)
406+ l.SetHalign(gtk.AlignStartValue)
407+ l.SetXalign(0)
408+ l.SetWrap(true)
409+ l.SetWrapMode(pango.WrapWordCharValue)
410+ l.SetMaxWidthChars(jsDialogMaxWidthChars)
411+ }
412+ p.headingLabel.AddCssClass("permission-popup-heading")
413+ p.bodyLabel.AddCssClass("permission-popup-body")
414+ return nil
415+}
416+
417+func (p *JSDialogPopup) createButtonRow() (*gtk.Box, error) {
418+ btnRow := gtk.NewBox(gtk.OrientationHorizontalValue, buttonSpacing)
419+ if btnRow == nil {
420+ return nil, errNilWidget("jsDialogBtnRow")
421+ }
422+ btnRow.AddCssClass("permission-popup-btn-row")
423+ btnRow.SetHalign(gtk.AlignEndValue)
424+
425+ p.btnCancel = gtk.NewButtonWithLabel("Cancel")
426+ p.btnOK = gtk.NewButtonWithLabel("OK")
427+ if p.btnCancel == nil || p.btnOK == nil {
428+ return nil, errNilWidget("jsDialogButtons")
429+ }
430+ p.btnCancel.AddCssClass("permission-popup-btn")
431+ p.btnCancel.AddCssClass("permission-popup-btn-deny")
432+ p.btnOK.AddCssClass("permission-popup-btn")
433+ p.btnOK.AddCssClass("permission-popup-btn-allow")
434+
435+ cancelCb := func(_ gtk.Button) { p.Dismiss(false) }
436+ okCb := func(_ gtk.Button) { p.Dismiss(true) }
437+ p.retainedCallbacks = append(p.retainedCallbacks, cancelCb, okCb)
438+ p.signals = append(p.signals,
439+ popupSignal{p.btnCancel, p.btnCancel.ConnectClicked(&cancelCb)},
440+ popupSignal{p.btnOK, p.btnOK.ConnectClicked(&okCb)},
441+ )
442+
443+ btnRow.Append(&p.btnCancel.Widget)
444+ btnRow.Append(&p.btnOK.Widget)
445+ return btnRow, nil
446+}
447+
448+// attachKeyController maps Escape to Cancel (capture phase, so the page never
449+// sees it). Enter is not handled here: see the entry's activate handler.
450+func (p *JSDialogPopup) attachKeyController() {
451+ controller := gtk.NewEventControllerKey()
452+ if controller == nil {
453+ return
454+ }
455+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
456+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
457+ if keyval == uint(gdk.KEY_Escape) {
458+ p.Dismiss(false)
459+ return true
460+ }
461+ return false
462+ }
463+ p.retainedCallbacks = append(p.retainedCallbacks, keyPressedCb)
464+ p.signals = append(p.signals, popupSignal{controller, controller.ConnectKeyPressed(&keyPressedCb)})
465+ p.outerBox.AddController(&controller.EventController)
466+ p.controller = controller
467+
468+ // Track focus-within so close() knows whether the dialog owned focus
469+ // (GetFocusChild would leak a reference).
470+ if fc := gtk.NewEventControllerFocus(); fc != nil {
471+ enterCb := func(gtk.EventControllerFocus) { p.hasFocus.Store(true) }
472+ leaveCb := func(gtk.EventControllerFocus) { p.hasFocus.Store(false) }
473+ p.retainedCallbacks = append(p.retainedCallbacks, enterCb, leaveCb)
474+ p.signals = append(p.signals,
475+ popupSignal{fc, fc.ConnectEnter(&enterCb)},
476+ popupSignal{fc, fc.ConnectLeave(&leaveCb)},
477+ )
478+ p.outerBox.AddController(&fc.EventController)
479+ p.focusCtrl = fc
480+ }
481+
482+ // A dialog shown while its tab was in the background gets focus once mapped.
483+ mapCb := func(gtk.Widget) {
484+ p.mu.Lock()
485+ want := p.visible && p.wantFocus
486+ p.mu.Unlock()
487+ if want {
488+ p.grabDefaultFocus()
489+ }
490+ }
491+ p.retainedCallbacks = append(p.retainedCallbacks, mapCb)
492+ p.signals = append(p.signals, popupSignal{&p.outerBox.Widget, p.outerBox.ConnectMap(&mapCb)})
493+}
@@ -0,0 +1,493 @@
1+package component
2+
3+import (
4+ "net/url"
5+ "strings"
6+ "sync"
7+ "sync/atomic"
8+
9+ "github.com/bnema/puregotk/v4/gdk"
10+ "github.com/bnema/puregotk/v4/gtk"
11+ "github.com/bnema/puregotk/v4/pango"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/ui/layout"
15+)
16+
17+const (
18+ jsDialogMaxBodyHeight = 240
19+ jsDialogMaxWidthChars = 60
20+ jsDialogMaxHostChars = 80
21+ // jsDialogMaxTextRunes caps page-controlled text (message, prompt default)
22+ // so a hostile page can't make GTK lay out megabytes of text.
23+ jsDialogMaxTextRunes = 3000
24+ // jsDialogBeforeUnloadText is Dumber's own beforeunload text: CEF always
25+ // passes a fixed string, and browsers no longer show page text here.
26+ jsDialogBeforeUnloadText = "Changes you made may not be saved."
27+)
28+
29+// JSDialogSizeDefaults sizes the dialog box relative to its pane.
30+var JSDialogSizeDefaults = ModalSizeConfig{
31+ WidthPct: 0.9,
32+ MaxWidth: 420,
33+ TopMarginPct: 0.3,
34+ FallbackWidth: 420,
35+ FallbackHeight: 300,
36+}
37+
38+// jsDialogContent is the plain-text content of a JS dialog.
39+type jsDialogContent struct {
40+ Heading string
41+ Body string
42+ OKLabel string
43+ CancelLabel string // empty: no cancel button (alert)
44+ ShowInput bool
45+}
46+
47+// jsDialogOriginLabel returns a short, user-facing origin ("example.com") for
48+// the requesting URL. The origin is shown separately from the page-controlled
49+// message so a page cannot impersonate browser UI.
50+func jsDialogOriginLabel(origin string) string {
51+ origin = strings.TrimSpace(origin)
52+ if origin == "" {
53+ return "This page"
54+ }
55+ u, err := url.Parse(origin)
56+ // blob:/filesystem: URLs carry the real origin in the opaque part.
57+ if err == nil && u.Host == "" && (u.Scheme == "blob" || u.Scheme == "filesystem") && u.Opaque != "" {
58+ if inner, innerErr := url.Parse(u.Opaque); innerErr == nil && inner.Host != "" {
59+ u = inner
60+ }
61+ }
62+ if err != nil || u.Host == "" {
63+ if err == nil && u.Scheme != "" {
64+ return truncateRunesLeft(u.Scheme+":", jsDialogMaxHostChars)
65+ }
66+ return "This page"
67+ }
68+ // Keep the end of the host: the registrable domain is what identifies it.
69+ return truncateRunesLeft(u.Host, jsDialogMaxHostChars)
70+}
71+
72+// truncateRunesLeft keeps the last n runes, prefixing "…" when it cut.
73+func truncateRunesLeft(s string, n int) string {
74+ r := []rune(s)
75+ if len(r) <= n {
76+ return s
77+ }
78+ return "…" + string(r[len(r)-n:])
79+}
80+
81+func truncateRunes(s string, n int) string {
82+ r := []rune(s)
83+ if len(r) <= n {
84+ return s
85+ }
86+ return string(r[:n]) + "…"
87+}
88+
89+// buildJSDialogContent maps a request to the dialog texts and buttons.
90+// All strings are plain text (never Pango markup).
91+func buildJSDialogContent(req port.JSDialogRequest) jsDialogContent {
92+ says := jsDialogOriginLabel(req.Origin) + " says:"
93+ msg := truncateRunes(req.Message, jsDialogMaxTextRunes)
94+ switch req.Type {
95+ case port.JSDialogConfirm:
96+ return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK", CancelLabel: "Cancel"}
97+ case port.JSDialogPrompt:
98+ return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK", CancelLabel: "Cancel", ShowInput: true}
99+ case port.JSDialogBeforeUnload:
100+ body := jsDialogBeforeUnloadText
101+ heading := "Leave page?"
102+ if req.IsReload {
103+ heading = "Reload page?"
104+ }
105+ return jsDialogContent{
106+ Heading: heading,
107+ Body: jsDialogOriginLabel(req.Origin) + "\n" + body,
108+ OKLabel: "Leave",
109+ CancelLabel: "Stay",
110+ }
111+ default:
112+ return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK"}
113+ }
114+}
115+
116+// JSDialogPopup is a per-pane overlay that renders alert/confirm/prompt and
117+// beforeunload dialogs. It is owned by a PaneView, so a dialog belongs to its
118+// pane's WebView and never blocks other panes. It replaces Adwaita AlertDialog
119+// for the same reason as PermissionPopup (purego ConnectResponse bug).
120+type JSDialogPopup struct {
121+ outerBox *gtk.Box
122+ mainBox *gtk.Box
123+
124+ headingLabel *gtk.Label
125+ bodyLabel *gtk.Label
126+ input *gtk.Entry
127+ btnOK *gtk.Button
128+ btnCancel *gtk.Button
129+
130+ parent layout.OverlayWidget
131+ controller *gtk.EventControllerKey
132+ focusCtrl *gtk.EventControllerFocus
133+ hasFocus atomic.Bool // keyboard focus is inside the popup (enter/leave)
134+ signals []popupSignal
135+
136+ mu sync.Mutex
137+ visible bool
138+ wantFocus bool // this dialog should own keyboard focus (pane is active)
139+ destroyed bool
140+ respond func(ok bool, input string)
141+ onHidden func(hadFocus bool)
142+
143+ retainedCallbacks []any
144+}
145+
146+// popupSignal remembers a connected handler so Destroy can disconnect it.
147+type popupSignal struct {
148+ obj interface{ DisconnectSignal(uint) }
149+ id uint
150+}
151+
152+// NewJSDialogPopup creates the popup (hidden). Returns nil if widget creation fails.
153+// parent is the pane overlay used to size the dialog.
154+func NewJSDialogPopup(parent layout.OverlayWidget) *JSDialogPopup {
155+ p := &JSDialogPopup{parent: parent}
156+ if err := p.createWidgets(); err != nil {
157+ return nil
158+ }
159+ p.attachKeyController()
160+ return p
161+}
162+
163+// Widget returns the outer GTK widget for overlay registration.
164+func (p *JSDialogPopup) Widget() *gtk.Widget {
165+ if p == nil || p.outerBox == nil {
166+ return nil
167+ }
168+ return &p.outerBox.Widget
169+}
170+
171+// SetOnHidden sets (or with nil clears) a callback run after the popup closes. hadFocus reports
172+// whether keyboard focus was inside the dialog when it closed, so the owner
173+// only restores focus when the dialog had taken it.
174+func (p *JSDialogPopup) SetOnHidden(fn func(hadFocus bool)) {
175+ p.mu.Lock()
176+ p.onHidden = fn
177+ p.mu.Unlock()
178+}
179+
180+// Show displays the dialog. respond is invoked once with the user's answer.
181+// A dialog already visible is dismissed as canceled first. Keyboard focus is
182+// only taken when focus is true (the pane is active); otherwise RequestFocus
183+// grabs it when the pane becomes active.
184+func (p *JSDialogPopup) Show(req port.JSDialogRequest, respond func(ok bool, input string), focus bool, uiScale float64) {
185+ p.Dismiss(false)
186+
187+ content := buildJSDialogContent(req)
188+
189+ p.mu.Lock()
190+ if p.destroyed {
191+ p.mu.Unlock()
192+ if respond != nil {
193+ respond(false, "")
194+ }
195+ return
196+ }
197+ p.visible = true
198+ p.wantFocus = focus
199+ p.respond = respond
200+ p.mu.Unlock()
201+
202+ width, _ := CalculateModalDimensionsWithScale(p.parent, JSDialogSizeDefaults, uiScale)
203+ p.mainBox.SetSizeRequest(width, -1)
204+
205+ p.headingLabel.SetText(content.Heading)
206+ p.bodyLabel.SetText(content.Body)
207+ p.bodyLabel.SetVisible(content.Body != "")
208+ p.btnOK.SetLabel(content.OKLabel)
209+ p.btnCancel.SetVisible(content.CancelLabel != "")
210+ if content.CancelLabel != "" {
211+ p.btnCancel.SetLabel(content.CancelLabel)
212+ }
213+ p.input.SetVisible(content.ShowInput)
214+ if content.ShowInput {
215+ p.input.SetText(truncateRunes(req.DefaultPrompt, jsDialogMaxTextRunes))
216+ } else {
217+ p.input.SetText("")
218+ }
219+
220+ p.outerBox.SetVisible(true)
221+ if focus {
222+ p.grabDefaultFocus()
223+ }
224+}
225+
226+// Visible reports whether a dialog is currently shown.
227+func (p *JSDialogPopup) Visible() bool {
228+ p.mu.Lock()
229+ defer p.mu.Unlock()
230+ return p.visible
231+}
232+
233+// RequestFocus marks the dialog as the focus owner and grabs focus if shown.
234+// Called when the owning pane becomes active.
235+func (p *JSDialogPopup) RequestFocus() {
236+ p.mu.Lock()
237+ if !p.visible || p.destroyed {
238+ p.mu.Unlock()
239+ return
240+ }
241+ p.wantFocus = true
242+ p.mu.Unlock()
243+ p.grabDefaultFocus()
244+}
245+
246+// grabDefaultFocus focuses the entry (prompt) or the OK button.
247+func (p *JSDialogPopup) grabDefaultFocus() {
248+ if p.input.GetVisible() {
249+ p.input.GrabFocus()
250+ p.input.SelectRegion(0, -1)
251+ return
252+ }
253+ p.btnOK.GrabFocus()
254+}
255+
256+// Hide closes the popup without invoking the response callback. It is used when
257+// the engine canceled the dialog (navigation, WebView destruction).
258+func (p *JSDialogPopup) Hide() {
259+ if p == nil {
260+ return
261+ }
262+ p.close(nil)
263+}
264+
265+// Dismiss closes the popup and answers with ok. No-op when hidden.
266+func (p *JSDialogPopup) Dismiss(ok bool) {
267+ p.close(&ok)
268+}
269+
270+// close hides the popup. When answer is non-nil the response callback is run.
271+func (p *JSDialogPopup) close(answer *bool) {
272+ p.mu.Lock()
273+ if !p.visible {
274+ p.mu.Unlock()
275+ return
276+ }
277+ p.visible = false
278+ p.wantFocus = false
279+ respond := p.respond
280+ p.respond = nil
281+ hidden := p.onHidden
282+ p.mu.Unlock()
283+
284+ // Read before hiding: hiding a focused widget fires "leave".
285+ hadFocus := p.hasFocus.Load()
286+ text := ""
287+ if answer != nil && *answer && p.input.GetVisible() {
288+ text = p.input.GetText()
289+ }
290+ p.outerBox.SetVisible(false)
291+ if answer != nil && respond != nil {
292+ respond(*answer, text)
293+ }
294+ if hidden != nil {
295+ hidden(hadFocus)
296+ }
297+}
298+
299+// Destroy disconnects every handler and drops callbacks. The popup must not be
300+// used afterwards. Call after the dialog was dismissed.
301+func (p *JSDialogPopup) Destroy() {
302+ if p == nil {
303+ return
304+ }
305+ p.mu.Lock()
306+ if p.destroyed {
307+ p.mu.Unlock()
308+ return
309+ }
310+ p.destroyed = true
311+ p.visible = false
312+ p.respond = nil
313+ p.onHidden = nil
314+ p.mu.Unlock()
315+
316+ for _, s := range p.signals {
317+ s.obj.DisconnectSignal(s.id)
318+ }
319+ p.signals = nil
320+ if p.controller != nil && p.outerBox != nil {
321+ p.outerBox.RemoveController(&p.controller.EventController)
322+ p.controller = nil
323+ }
324+ if p.focusCtrl != nil && p.outerBox != nil {
325+ p.outerBox.RemoveController(&p.focusCtrl.EventController)
326+ p.focusCtrl = nil
327+ }
328+ p.retainedCallbacks = nil
329+}
330+
331+func (p *JSDialogPopup) createWidgets() error {
332+ if err := p.createContainers(); err != nil {
333+ return err
334+ }
335+ if err := p.createLabels(); err != nil {
336+ return err
337+ }
338+ scroll := gtk.NewScrolledWindow()
339+ if scroll == nil {
340+ return errNilWidget("jsDialogScroll")
341+ }
342+ scroll.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
343+ scroll.SetPropagateNaturalHeight(true)
344+ scroll.SetMaxContentHeight(jsDialogMaxBodyHeight)
345+ scroll.SetChild(&p.bodyLabel.Widget)
346+
347+ p.input = gtk.NewEntry()
348+ if p.input == nil {
349+ return errNilWidget("jsDialogEntry")
350+ }
351+ p.input.AddCssClass("jsdialog-entry")
352+ p.input.SetMaxLength(jsDialogMaxTextRunes)
353+ p.input.SetVisible(false)
354+ // Enter in the entry = OK. Focused buttons activate themselves, so Enter on
355+ // Cancel/Stay still cancels.
356+ activateCb := func(gtk.Entry) { p.Dismiss(true) }
357+ p.retainedCallbacks = append(p.retainedCallbacks, activateCb)
358+ p.signals = append(p.signals, popupSignal{p.input, p.input.ConnectActivate(&activateCb)})
359+
360+ btnRow, err := p.createButtonRow()
361+ if err != nil {
362+ return err
363+ }
364+
365+ p.mainBox.Append(&p.headingLabel.Widget)
366+ p.mainBox.Append(&scroll.Widget)
367+ p.mainBox.Append(&p.input.Widget)
368+ p.mainBox.Append(&btnRow.Widget)
369+ p.outerBox.Append(&p.mainBox.Widget)
370+ return nil
371+}
372+
373+func (p *JSDialogPopup) createContainers() error {
374+ p.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
375+ if p.outerBox == nil {
376+ return errNilWidget("jsDialogOuterBox")
377+ }
378+ // Fill the pane so the scrim swallows pointer input meant for the page.
379+ p.outerBox.AddCssClass("jsdialog-scrim")
380+ p.outerBox.SetHexpand(true)
381+ p.outerBox.SetVexpand(true)
382+ p.outerBox.SetHalign(gtk.AlignFillValue)
383+ p.outerBox.SetValign(gtk.AlignFillValue)
384+ p.outerBox.SetVisible(false)
385+
386+ p.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
387+ if p.mainBox == nil {
388+ return errNilWidget("jsDialogMainBox")
389+ }
390+ p.mainBox.AddCssClass("permission-popup-container")
391+ p.mainBox.SetHalign(gtk.AlignCenterValue)
392+ p.mainBox.SetValign(gtk.AlignCenterValue)
393+ return nil
394+}
395+
396+func (p *JSDialogPopup) createLabels() error {
397+ empty := ""
398+ p.headingLabel = gtk.NewLabel(&empty)
399+ p.bodyLabel = gtk.NewLabel(&empty)
400+ if p.headingLabel == nil || p.bodyLabel == nil {
401+ return errNilWidget("jsDialogLabels")
402+ }
403+ // Labels use SetText only: page text is never parsed as Pango markup.
404+ for _, l := range []*gtk.Label{p.headingLabel, p.bodyLabel} {
405+ l.SetUseMarkup(false)
406+ l.SetHalign(gtk.AlignStartValue)
407+ l.SetXalign(0)
408+ l.SetWrap(true)
409+ l.SetWrapMode(pango.WrapWordCharValue)
410+ l.SetMaxWidthChars(jsDialogMaxWidthChars)
411+ }
412+ p.headingLabel.AddCssClass("permission-popup-heading")
413+ p.bodyLabel.AddCssClass("permission-popup-body")
414+ return nil
415+}
416+
417+func (p *JSDialogPopup) createButtonRow() (*gtk.Box, error) {
418+ btnRow := gtk.NewBox(gtk.OrientationHorizontalValue, buttonSpacing)
419+ if btnRow == nil {
420+ return nil, errNilWidget("jsDialogBtnRow")
421+ }
422+ btnRow.AddCssClass("permission-popup-btn-row")
423+ btnRow.SetHalign(gtk.AlignEndValue)
424+
425+ p.btnCancel = gtk.NewButtonWithLabel("Cancel")
426+ p.btnOK = gtk.NewButtonWithLabel("OK")
427+ if p.btnCancel == nil || p.btnOK == nil {
428+ return nil, errNilWidget("jsDialogButtons")
429+ }
430+ p.btnCancel.AddCssClass("permission-popup-btn")
431+ p.btnCancel.AddCssClass("permission-popup-btn-deny")
432+ p.btnOK.AddCssClass("permission-popup-btn")
433+ p.btnOK.AddCssClass("permission-popup-btn-allow")
434+
435+ cancelCb := func(_ gtk.Button) { p.Dismiss(false) }
436+ okCb := func(_ gtk.Button) { p.Dismiss(true) }
437+ p.retainedCallbacks = append(p.retainedCallbacks, cancelCb, okCb)
438+ p.signals = append(p.signals,
439+ popupSignal{p.btnCancel, p.btnCancel.ConnectClicked(&cancelCb)},
440+ popupSignal{p.btnOK, p.btnOK.ConnectClicked(&okCb)},
441+ )
442+
443+ btnRow.Append(&p.btnCancel.Widget)
444+ btnRow.Append(&p.btnOK.Widget)
445+ return btnRow, nil
446+}
447+
448+// attachKeyController maps Escape to Cancel (capture phase, so the page never
449+// sees it). Enter is not handled here: see the entry's activate handler.
450+func (p *JSDialogPopup) attachKeyController() {
451+ controller := gtk.NewEventControllerKey()
452+ if controller == nil {
453+ return
454+ }
455+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
456+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
457+ if keyval == uint(gdk.KEY_Escape) {
458+ p.Dismiss(false)
459+ return true
460+ }
461+ return false
462+ }
463+ p.retainedCallbacks = append(p.retainedCallbacks, keyPressedCb)
464+ p.signals = append(p.signals, popupSignal{controller, controller.ConnectKeyPressed(&keyPressedCb)})
465+ p.outerBox.AddController(&controller.EventController)
466+ p.controller = controller
467+
468+ // Track focus-within so close() knows whether the dialog owned focus
469+ // (GetFocusChild would leak a reference).
470+ if fc := gtk.NewEventControllerFocus(); fc != nil {
471+ enterCb := func(gtk.EventControllerFocus) { p.hasFocus.Store(true) }
472+ leaveCb := func(gtk.EventControllerFocus) { p.hasFocus.Store(false) }
473+ p.retainedCallbacks = append(p.retainedCallbacks, enterCb, leaveCb)
474+ p.signals = append(p.signals,
475+ popupSignal{fc, fc.ConnectEnter(&enterCb)},
476+ popupSignal{fc, fc.ConnectLeave(&leaveCb)},
477+ )
478+ p.outerBox.AddController(&fc.EventController)
479+ p.focusCtrl = fc
480+ }
481+
482+ // A dialog shown while its tab was in the background gets focus once mapped.
483+ mapCb := func(gtk.Widget) {
484+ p.mu.Lock()
485+ want := p.visible && p.wantFocus
486+ p.mu.Unlock()
487+ if want {
488+ p.grabDefaultFocus()
489+ }
490+ }
491+ p.retainedCallbacks = append(p.retainedCallbacks, mapCb)
492+ p.signals = append(p.signals, popupSignal{&p.outerBox.Widget, p.outerBox.ConnectMap(&mapCb)})
493+}
A internal/ui/component/jsdialog_popup_test.go +82 -0
@@ -0,0 +1,82 @@
1+package component
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+func TestJSDialogOriginLabel(t *testing.T) {
13+ assert.Equal(t, "example.com", jsDialogOriginLabel("https://example.com/a?b=c"))
14+ assert.Equal(t, "example.com:8080", jsDialogOriginLabel("http://example.com:8080/"))
15+ assert.Equal(t, "This page", jsDialogOriginLabel(""))
16+ assert.Equal(t, "This page", jsDialogOriginLabel("not a url"))
17+ assert.Equal(t, "file:", jsDialogOriginLabel("file:///tmp/x.html"))
18+ long := jsDialogOriginLabel("https://" + strings.Repeat("a", 500) + ".example.com/")
19+ assert.LessOrEqual(t, len([]rune(long)), jsDialogMaxHostChars+1)
20+}
21+
22+func TestJSDialogOriginLabelOpaqueSchemes(t *testing.T) {
23+ cases := map[string]string{
24+ "blob:https://example.com/3f1c-uuid": "example.com",
25+ "blob:http://sub.example.com:8080/uuid": "sub.example.com:8080",
26+ "filesystem:https://example.com/temporary/file.txt": "example.com",
27+ "blob:null/uuid": "blob:",
28+ "blob:": "blob:",
29+ "data:text/html,<b>x</b>": "data:",
30+ "javascript:alert(1)": "javascript:",
31+ }
32+ for in, want := range cases {
33+ assert.Equal(t, want, jsDialogOriginLabel(in), in)
34+ }
35+}
36+
37+func TestJSDialogOriginLabelKeepsRegistrableDomain(t *testing.T) {
38+ // A long attacker-controlled prefix must not push the real domain out.
39+ label := jsDialogOriginLabel("https://" + strings.Repeat("paypal.com.", 20) + "evil.test/")
40+ assert.True(t, strings.HasPrefix(label, "…"))
41+ assert.True(t, strings.HasSuffix(label, "evil.test"))
42+}
43+
44+func TestBuildJSDialogContentCapsPageText(t *testing.T) {
45+ huge := strings.Repeat("x", jsDialogMaxTextRunes*3)
46+ c := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogAlert, Message: huge})
47+ assert.LessOrEqual(t, len([]rune(c.Body)), jsDialogMaxTextRunes+1)
48+}
49+
50+func TestBuildJSDialogContentBeforeUnloadUsesFixedText(t *testing.T) {
51+ c := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogBeforeUnload, Origin: "https://example.com", Message: "page text"})
52+ assert.Contains(t, c.Body, jsDialogBeforeUnloadText)
53+ assert.NotContains(t, c.Body, "page text")
54+}
55+
56+func TestBuildJSDialogContent(t *testing.T) {
57+ origin := "https://example.com/x"
58+
59+ alert := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogAlert, Origin: origin, Message: "<b>hi</b>"})
60+ assert.Equal(t, "example.com says:", alert.Heading)
61+ assert.Equal(t, "<b>hi</b>", alert.Body, "message must stay plain text")
62+ assert.Equal(t, "OK", alert.OKLabel)
63+ assert.Empty(t, alert.CancelLabel)
64+ assert.False(t, alert.ShowInput)
65+
66+ confirm := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogConfirm, Origin: origin})
67+ assert.Equal(t, "Cancel", confirm.CancelLabel)
68+ assert.False(t, confirm.ShowInput)
69+
70+ prompt := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogPrompt, Origin: origin})
71+ assert.True(t, prompt.ShowInput)
72+ assert.Equal(t, "Cancel", prompt.CancelLabel)
73+
74+ leave := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogBeforeUnload, Origin: origin})
75+ assert.Equal(t, "Leave page?", leave.Heading)
76+ assert.Equal(t, "Leave", leave.OKLabel)
77+ assert.Equal(t, "Stay", leave.CancelLabel)
78+ assert.Contains(t, leave.Body, "example.com")
79+
80+ reload := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogBeforeUnload, Origin: origin, IsReload: true, Message: "x"})
81+ assert.Equal(t, "Reload page?", reload.Heading)
82+}
@@ -0,0 +1,82 @@
1+package component
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+func TestJSDialogOriginLabel(t *testing.T) {
13+ assert.Equal(t, "example.com", jsDialogOriginLabel("https://example.com/a?b=c"))
14+ assert.Equal(t, "example.com:8080", jsDialogOriginLabel("http://example.com:8080/"))
15+ assert.Equal(t, "This page", jsDialogOriginLabel(""))
16+ assert.Equal(t, "This page", jsDialogOriginLabel("not a url"))
17+ assert.Equal(t, "file:", jsDialogOriginLabel("file:///tmp/x.html"))
18+ long := jsDialogOriginLabel("https://" + strings.Repeat("a", 500) + ".example.com/")
19+ assert.LessOrEqual(t, len([]rune(long)), jsDialogMaxHostChars+1)
20+}
21+
22+func TestJSDialogOriginLabelOpaqueSchemes(t *testing.T) {
23+ cases := map[string]string{
24+ "blob:https://example.com/3f1c-uuid": "example.com",
25+ "blob:http://sub.example.com:8080/uuid": "sub.example.com:8080",
26+ "filesystem:https://example.com/temporary/file.txt": "example.com",
27+ "blob:null/uuid": "blob:",
28+ "blob:": "blob:",
29+ "data:text/html,<b>x</b>": "data:",
30+ "javascript:alert(1)": "javascript:",
31+ }
32+ for in, want := range cases {
33+ assert.Equal(t, want, jsDialogOriginLabel(in), in)
34+ }
35+}
36+
37+func TestJSDialogOriginLabelKeepsRegistrableDomain(t *testing.T) {
38+ // A long attacker-controlled prefix must not push the real domain out.
39+ label := jsDialogOriginLabel("https://" + strings.Repeat("paypal.com.", 20) + "evil.test/")
40+ assert.True(t, strings.HasPrefix(label, "…"))
41+ assert.True(t, strings.HasSuffix(label, "evil.test"))
42+}
43+
44+func TestBuildJSDialogContentCapsPageText(t *testing.T) {
45+ huge := strings.Repeat("x", jsDialogMaxTextRunes*3)
46+ c := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogAlert, Message: huge})
47+ assert.LessOrEqual(t, len([]rune(c.Body)), jsDialogMaxTextRunes+1)
48+}
49+
50+func TestBuildJSDialogContentBeforeUnloadUsesFixedText(t *testing.T) {
51+ c := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogBeforeUnload, Origin: "https://example.com", Message: "page text"})
52+ assert.Contains(t, c.Body, jsDialogBeforeUnloadText)
53+ assert.NotContains(t, c.Body, "page text")
54+}
55+
56+func TestBuildJSDialogContent(t *testing.T) {
57+ origin := "https://example.com/x"
58+
59+ alert := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogAlert, Origin: origin, Message: "<b>hi</b>"})
60+ assert.Equal(t, "example.com says:", alert.Heading)
61+ assert.Equal(t, "<b>hi</b>", alert.Body, "message must stay plain text")
62+ assert.Equal(t, "OK", alert.OKLabel)
63+ assert.Empty(t, alert.CancelLabel)
64+ assert.False(t, alert.ShowInput)
65+
66+ confirm := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogConfirm, Origin: origin})
67+ assert.Equal(t, "Cancel", confirm.CancelLabel)
68+ assert.False(t, confirm.ShowInput)
69+
70+ prompt := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogPrompt, Origin: origin})
71+ assert.True(t, prompt.ShowInput)
72+ assert.Equal(t, "Cancel", prompt.CancelLabel)
73+
74+ leave := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogBeforeUnload, Origin: origin})
75+ assert.Equal(t, "Leave page?", leave.Heading)
76+ assert.Equal(t, "Leave", leave.OKLabel)
77+ assert.Equal(t, "Stay", leave.CancelLabel)
78+ assert.Contains(t, leave.Body, "example.com")
79+
80+ reload := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogBeforeUnload, Origin: origin, IsReload: true, Message: "x"})
81+ assert.Equal(t, "Reload page?", reload.Heading)
82+}
M internal/ui/component/pane_view.go +149 -14
@@ -5,10 +5,12 @@ import (
55 "context"
66 "sync"
77
8+ "github.com/bnema/dumber/internal/application/port"
89 "github.com/bnema/dumber/internal/domain/entity"
910 "github.com/bnema/dumber/internal/logging"
1011 "github.com/bnema/dumber/internal/ui/input"
1112 "github.com/bnema/dumber/internal/ui/layout"
13+ "github.com/bnema/puregotk/v4/gtk"
1214 )
1315
1416 // CSS class applied to the active pane's border overlay.
@@ -17,17 +19,21 @@ const activePaneClass = "pane-active"
1719 // PaneView is a container for a single WebView with active state indication.
1820 // It uses an overlay to display a border around the active pane.
1921 type PaneView struct {
20- ctx context.Context
21- factory layout.WidgetFactory
22- overlay layout.OverlayWidget
23- webViewWidget layout.Widget // The actual WebView widget
24- borderBox layout.BoxWidget // Border overlay for active indication
25- progressBar *ProgressBar // Loading progress indicator
26- toaster *Toaster // Toast notification overlay
27- linkStatus *LinkStatusOverlay // Link hover URL overlay
28- loading *LoadingSkeleton // Placeholder shown until WebView paints
29- paneID entity.PaneID
30- isActive bool
22+ ctx context.Context
23+ factory layout.WidgetFactory
24+ overlay layout.OverlayWidget
25+ webViewWidget layout.Widget // The actual WebView widget
26+ borderBox layout.BoxWidget // Border overlay for active indication
27+ progressBar *ProgressBar // Loading progress indicator
28+ toaster *Toaster // Toast notification overlay
29+ linkStatus *LinkStatusOverlay // Link hover URL overlay
30+ loading *LoadingSkeleton // Placeholder shown until WebView paints
31+ jsDialog jsDialogView // JS alert/confirm/prompt/beforeunload overlay
32+ jsDialogWidget layout.Widget // overlay wrapper of jsDialog, for removal
33+ newJSDialog func(layout.OverlayWidget) jsDialogView
34+ cleanedUp bool // Cleanup ran: no new JS dialog may be created
35+ paneID entity.PaneID
36+ isActive bool
3137
3238 // Vim Mode input ownership; its border and pulse belong to modeFrame.
3339 vimMode bool
@@ -101,9 +107,8 @@ func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entit
101107 // Active panes display a visual border indicator.
102108 func (pv *PaneView) SetActive(active bool) {
103109 pv.mu.Lock()
104- defer pv.mu.Unlock()
105-
106110 if pv.isActive == active {
111+ pv.mu.Unlock()
107112 return
108113 }
109114
@@ -114,6 +119,13 @@ func (pv *PaneView) SetActive(active bool) {
114119 } else {
115120 pv.borderBox.RemoveCssClass(activePaneClass)
116121 }
122+ popup := pv.jsDialog
123+ pv.mu.Unlock()
124+
125+ // A dialog raised while this pane was inactive takes focus now.
126+ if active && popup != nil {
127+ popup.RequestFocus()
128+ }
117129 }
118130
119131 // IsActive returns whether this pane is currently active.
@@ -208,11 +220,21 @@ func (pv *PaneView) AttachWebViewWidget(widget layout.Widget, revealed bool) {
208220
209221 // GrabFocus attempts to focus the WebView.
210222 // Returns true if focus was successfully grabbed.
223+//
224+// While a JS dialog is open it owns keyboard focus: activation paths call
225+// SetActive(true) (which requests dialog focus) and then GrabFocus, which must
226+// not steal focus back to the page.
211227 func (pv *PaneView) GrabFocus() bool {
212228 pv.mu.RLock()
213229 wv := pv.webViewWidget
230+ popup := pv.jsDialog
214231 pv.mu.RUnlock()
215232
233+ if popup != nil && popup.Visible() {
234+ popup.RequestFocus()
235+ return true
236+ }
237+
216238 if wv == nil {
217239 return false
218240 }
@@ -545,10 +567,36 @@ func (pv *PaneView) IsVimMode() bool {
545567 // Cleanup removes the WebView widget from the overlay and clears references.
546568 // This must be called before destroying the WebView to ensure proper GTK cleanup.
547569 // After calling Cleanup, the PaneView should not be reused.
570+//
571+// An open JS dialog is answered with cancel ("Stay" for beforeunload). That is
572+// an accepted trade-off: WorkspaceView.Rebuild (split/close/consume/reattach)
573+// cleans up views whose WebView survives, so such a dialog is canceled rather
574+// than carried over to the rebuilt view. Canceling is the safe default.
548575 func (pv *PaneView) Cleanup() {
549576 pv.mu.Lock()
550- defer pv.mu.Unlock()
577+ pv.cleanedUp = true
578+ // The WebView may outlive this view (workspace rebuild), so an open JS
579+ // dialog must be answered, not dropped. The hidden callback re-locks
580+ // pv.mu, so detach it here and dismiss after unlocking.
581+ popup, popupWidget := pv.jsDialog, pv.jsDialogWidget
582+ if popup != nil {
583+ popup.SetOnHidden(nil)
584+ }
585+ pv.jsDialog, pv.jsDialogWidget = nil, nil
586+ pv.cleanupLocked()
587+ pv.mu.Unlock()
551588
589+ if popup != nil {
590+ popup.Dismiss(false)
591+ popup.Destroy()
592+ if popupWidget != nil {
593+ pv.overlay.RemoveOverlay(popupWidget)
594+ }
595+ }
596+}
597+
598+// cleanupLocked releases everything except the JS dialog. Caller holds pv.mu.
599+func (pv *PaneView) cleanupLocked() {
552600 // Clear callbacks to prevent use-after-free
553601 pv.onFocusIn = nil
554602 pv.onFocusOut = nil
@@ -586,3 +634,90 @@ func (pv *PaneView) Cleanup() {
586634 pv.linkStatus = nil
587635 }
588636 }
637+
638+// jsDialogView is the JS dialog popup as seen by PaneView (a seam for tests).
639+type jsDialogView interface {
640+ Widget() *gtk.Widget
641+ Show(req port.JSDialogRequest, respond func(ok bool, input string), focus bool, uiScale float64)
642+ Hide()
643+ Dismiss(ok bool)
644+ RequestFocus()
645+ Visible() bool
646+ SetOnHidden(fn func(hadFocus bool))
647+ Destroy()
648+}
649+
650+func newJSDialogPopupView(parent layout.OverlayWidget) jsDialogView {
651+ if p := NewJSDialogPopup(parent); p != nil {
652+ return p
653+ }
654+ return nil
655+}
656+
657+// ensureJSDialog creates the JS dialog popup lazily on first use.
658+// Must be called with write lock held.
659+func (pv *PaneView) ensureJSDialog() jsDialogView {
660+ if pv.jsDialog != nil {
661+ return pv.jsDialog
662+ }
663+ create := pv.newJSDialog
664+ if create == nil {
665+ create = newJSDialogPopupView
666+ }
667+ popup := create(pv.overlay)
668+ if popup == nil {
669+ return nil
670+ }
671+ w := pv.factory.WrapWidget(popup.Widget())
672+ pv.overlay.AddOverlay(w)
673+ // Clip so the scrim stays inside the pane; it must not affect layout.
674+ pv.overlay.SetClipOverlay(w, true)
675+ pv.overlay.SetMeasureOverlay(w, false)
676+ popup.SetOnHidden(func(hadFocus bool) {
677+ // Give focus back to the page only if the dialog had taken it.
678+ if !hadFocus {
679+ return
680+ }
681+ pv.mu.RLock()
682+ wvw := pv.webViewWidget
683+ pv.mu.RUnlock()
684+ if wvw != nil {
685+ wvw.GrabFocus()
686+ }
687+ })
688+ pv.jsDialog = popup
689+ pv.jsDialogWidget = w
690+ return popup
691+}
692+
693+// ShowJSDialog displays a JavaScript dialog scoped to this pane. The dialog
694+// lives in the pane's overlay, so it never blocks other panes; if the pane is
695+// in a background tab it becomes visible when that tab is shown, and it only
696+// takes keyboard focus while the pane is active (otherwise when it becomes
697+// active). respond is invoked once with the user's answer. Returns false if the
698+// popup is unavailable.
699+func (pv *PaneView) ShowJSDialog(req port.JSDialogRequest, respond func(ok bool, input string), uiScale float64) bool {
700+ pv.mu.Lock()
701+ if pv.cleanedUp {
702+ pv.mu.Unlock()
703+ return false
704+ }
705+ popup := pv.ensureJSDialog()
706+ focus := pv.isActive
707+ pv.mu.Unlock()
708+ if popup == nil {
709+ return false
710+ }
711+ popup.Show(req, respond, focus, uiScale)
712+ return true
713+}
714+
715+// HideJSDialog closes any JS dialog without answering it (engine-canceled).
716+func (pv *PaneView) HideJSDialog() {
717+ pv.mu.RLock()
718+ popup := pv.jsDialog
719+ pv.mu.RUnlock()
720+ if popup != nil {
721+ popup.Hide()
722+ }
723+}
@@ -5,10 +5,12 @@ import (
5 "context"5 "context"
6 "sync"6 "sync"
7 7
8+ "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
10 "github.com/bnema/dumber/internal/ui/input"11 "github.com/bnema/dumber/internal/ui/input"
11 "github.com/bnema/dumber/internal/ui/layout"12 "github.com/bnema/dumber/internal/ui/layout"
13+ "github.com/bnema/puregotk/v4/gtk"
12 )14 )
13 15
14 // CSS class applied to the active pane's border overlay.16 // CSS class applied to the active pane's border overlay.
@@ -17,17 +19,21 @@ const activePaneClass = "pane-active"
17 // PaneView is a container for a single WebView with active state indication.19 // PaneView is a container for a single WebView with active state indication.
18 // It uses an overlay to display a border around the active pane.20 // It uses an overlay to display a border around the active pane.
19 type PaneView struct {21 type PaneView struct {
20- ctx context.Context22+ ctx context.Context
21- factory layout.WidgetFactory23+ factory layout.WidgetFactory
22- overlay layout.OverlayWidget24+ overlay layout.OverlayWidget
23- webViewWidget layout.Widget // The actual WebView widget25+ webViewWidget layout.Widget // The actual WebView widget
24- borderBox layout.BoxWidget // Border overlay for active indication26+ borderBox layout.BoxWidget // Border overlay for active indication
25- progressBar *ProgressBar // Loading progress indicator27+ progressBar *ProgressBar // Loading progress indicator
26- toaster *Toaster // Toast notification overlay28+ toaster *Toaster // Toast notification overlay
27- linkStatus *LinkStatusOverlay // Link hover URL overlay29+ linkStatus *LinkStatusOverlay // Link hover URL overlay
28- loading *LoadingSkeleton // Placeholder shown until WebView paints30+ loading *LoadingSkeleton // Placeholder shown until WebView paints
29- paneID entity.PaneID31+ jsDialog jsDialogView // JS alert/confirm/prompt/beforeunload overlay
30- isActive bool32+ jsDialogWidget layout.Widget // overlay wrapper of jsDialog, for removal
33+ newJSDialog func(layout.OverlayWidget) jsDialogView
34+ cleanedUp bool // Cleanup ran: no new JS dialog may be created
35+ paneID entity.PaneID
36+ isActive bool
31 37
32 // Vim Mode input ownership; its border and pulse belong to modeFrame.38 // Vim Mode input ownership; its border and pulse belong to modeFrame.
33 vimMode bool39 vimMode bool
@@ -101,9 +107,8 @@ func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entit
101 // Active panes display a visual border indicator.107 // Active panes display a visual border indicator.
102 func (pv *PaneView) SetActive(active bool) {108 func (pv *PaneView) SetActive(active bool) {
103 pv.mu.Lock()109 pv.mu.Lock()
104- defer pv.mu.Unlock()
105-
106 if pv.isActive == active {110 if pv.isActive == active {
111+ pv.mu.Unlock()
107 return112 return
108 }113 }
109 114
@@ -114,6 +119,13 @@ func (pv *PaneView) SetActive(active bool) {
114 } else {119 } else {
115 pv.borderBox.RemoveCssClass(activePaneClass)120 pv.borderBox.RemoveCssClass(activePaneClass)
116 }121 }
122+ popup := pv.jsDialog
123+ pv.mu.Unlock()
124+
125+ // A dialog raised while this pane was inactive takes focus now.
126+ if active && popup != nil {
127+ popup.RequestFocus()
128+ }
117 }129 }
118 130
119 // IsActive returns whether this pane is currently active.131 // IsActive returns whether this pane is currently active.
@@ -208,11 +220,21 @@ func (pv *PaneView) AttachWebViewWidget(widget layout.Widget, revealed bool) {
208 220
209 // GrabFocus attempts to focus the WebView.221 // GrabFocus attempts to focus the WebView.
210 // Returns true if focus was successfully grabbed.222 // Returns true if focus was successfully grabbed.
223+//
224+// While a JS dialog is open it owns keyboard focus: activation paths call
225+// SetActive(true) (which requests dialog focus) and then GrabFocus, which must
226+// not steal focus back to the page.
211 func (pv *PaneView) GrabFocus() bool {227 func (pv *PaneView) GrabFocus() bool {
212 pv.mu.RLock()228 pv.mu.RLock()
213 wv := pv.webViewWidget229 wv := pv.webViewWidget
230+ popup := pv.jsDialog
214 pv.mu.RUnlock()231 pv.mu.RUnlock()
215 232
233+ if popup != nil && popup.Visible() {
234+ popup.RequestFocus()
235+ return true
236+ }
237+
216 if wv == nil {238 if wv == nil {
217 return false239 return false
218 }240 }
@@ -545,10 +567,36 @@ func (pv *PaneView) IsVimMode() bool {
545 // Cleanup removes the WebView widget from the overlay and clears references.567 // Cleanup removes the WebView widget from the overlay and clears references.
546 // This must be called before destroying the WebView to ensure proper GTK cleanup.568 // This must be called before destroying the WebView to ensure proper GTK cleanup.
547 // After calling Cleanup, the PaneView should not be reused.569 // After calling Cleanup, the PaneView should not be reused.
570+//
571+// An open JS dialog is answered with cancel ("Stay" for beforeunload). That is
572+// an accepted trade-off: WorkspaceView.Rebuild (split/close/consume/reattach)
573+// cleans up views whose WebView survives, so such a dialog is canceled rather
574+// than carried over to the rebuilt view. Canceling is the safe default.
548 func (pv *PaneView) Cleanup() {575 func (pv *PaneView) Cleanup() {
549 pv.mu.Lock()576 pv.mu.Lock()
550- defer pv.mu.Unlock()577+ pv.cleanedUp = true
578+ // The WebView may outlive this view (workspace rebuild), so an open JS
579+ // dialog must be answered, not dropped. The hidden callback re-locks
580+ // pv.mu, so detach it here and dismiss after unlocking.
581+ popup, popupWidget := pv.jsDialog, pv.jsDialogWidget
582+ if popup != nil {
583+ popup.SetOnHidden(nil)
584+ }
585+ pv.jsDialog, pv.jsDialogWidget = nil, nil
586+ pv.cleanupLocked()
587+ pv.mu.Unlock()
551 588
589+ if popup != nil {
590+ popup.Dismiss(false)
591+ popup.Destroy()
592+ if popupWidget != nil {
593+ pv.overlay.RemoveOverlay(popupWidget)
594+ }
595+ }
596+}
597+
598+// cleanupLocked releases everything except the JS dialog. Caller holds pv.mu.
599+func (pv *PaneView) cleanupLocked() {
552 // Clear callbacks to prevent use-after-free600 // Clear callbacks to prevent use-after-free
553 pv.onFocusIn = nil601 pv.onFocusIn = nil
554 pv.onFocusOut = nil602 pv.onFocusOut = nil
@@ -586,3 +634,90 @@ func (pv *PaneView) Cleanup() {
586 pv.linkStatus = nil634 pv.linkStatus = nil
587 }635 }
588 }636 }
637+
638+// jsDialogView is the JS dialog popup as seen by PaneView (a seam for tests).
639+type jsDialogView interface {
640+ Widget() *gtk.Widget
641+ Show(req port.JSDialogRequest, respond func(ok bool, input string), focus bool, uiScale float64)
642+ Hide()
643+ Dismiss(ok bool)
644+ RequestFocus()
645+ Visible() bool
646+ SetOnHidden(fn func(hadFocus bool))
647+ Destroy()
648+}
649+
650+func newJSDialogPopupView(parent layout.OverlayWidget) jsDialogView {
651+ if p := NewJSDialogPopup(parent); p != nil {
652+ return p
653+ }
654+ return nil
655+}
656+
657+// ensureJSDialog creates the JS dialog popup lazily on first use.
658+// Must be called with write lock held.
659+func (pv *PaneView) ensureJSDialog() jsDialogView {
660+ if pv.jsDialog != nil {
661+ return pv.jsDialog
662+ }
663+ create := pv.newJSDialog
664+ if create == nil {
665+ create = newJSDialogPopupView
666+ }
667+ popup := create(pv.overlay)
668+ if popup == nil {
669+ return nil
670+ }
671+ w := pv.factory.WrapWidget(popup.Widget())
672+ pv.overlay.AddOverlay(w)
673+ // Clip so the scrim stays inside the pane; it must not affect layout.
674+ pv.overlay.SetClipOverlay(w, true)
675+ pv.overlay.SetMeasureOverlay(w, false)
676+ popup.SetOnHidden(func(hadFocus bool) {
677+ // Give focus back to the page only if the dialog had taken it.
678+ if !hadFocus {
679+ return
680+ }
681+ pv.mu.RLock()
682+ wvw := pv.webViewWidget
683+ pv.mu.RUnlock()
684+ if wvw != nil {
685+ wvw.GrabFocus()
686+ }
687+ })
688+ pv.jsDialog = popup
689+ pv.jsDialogWidget = w
690+ return popup
691+}
692+
693+// ShowJSDialog displays a JavaScript dialog scoped to this pane. The dialog
694+// lives in the pane's overlay, so it never blocks other panes; if the pane is
695+// in a background tab it becomes visible when that tab is shown, and it only
696+// takes keyboard focus while the pane is active (otherwise when it becomes
697+// active). respond is invoked once with the user's answer. Returns false if the
698+// popup is unavailable.
699+func (pv *PaneView) ShowJSDialog(req port.JSDialogRequest, respond func(ok bool, input string), uiScale float64) bool {
700+ pv.mu.Lock()
701+ if pv.cleanedUp {
702+ pv.mu.Unlock()
703+ return false
704+ }
705+ popup := pv.ensureJSDialog()
706+ focus := pv.isActive
707+ pv.mu.Unlock()
708+ if popup == nil {
709+ return false
710+ }
711+ popup.Show(req, respond, focus, uiScale)
712+ return true
713+}
714+
715+// HideJSDialog closes any JS dialog without answering it (engine-canceled).
716+func (pv *PaneView) HideJSDialog() {
717+ pv.mu.RLock()
718+ popup := pv.jsDialog
719+ pv.mu.RUnlock()
720+ if popup != nil {
721+ popup.Hide()
722+ }
723+}
A internal/ui/component/pane_view_jsdialog_test.go +148 -0
@@ -0,0 +1,148 @@
1+package component
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/puregotk/v4/gtk"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/ui/layout"
13+ "github.com/bnema/dumber/internal/ui/layout/mocks"
14+)
15+
16+// fakeJSDialogView records calls made by PaneView.
17+type fakeJSDialogView struct {
18+ respond func(bool, string)
19+ focus bool
20+ shown int
21+ focusRequests int
22+ destroyed bool
23+ hiddenCleared bool
24+ dismissedWith []bool
25+}
26+
27+func (f *fakeJSDialogView) Widget() *gtk.Widget { return nil }
28+func (f *fakeJSDialogView) Show(_ port.JSDialogRequest, respond func(bool, string), focus bool, _ float64) {
29+ f.shown++
30+ f.respond, f.focus = respond, focus
31+}
32+func (f *fakeJSDialogView) Visible() bool { return f.respond != nil }
33+func (f *fakeJSDialogView) Hide() {}
34+func (f *fakeJSDialogView) RequestFocus() { f.focusRequests++ }
35+func (f *fakeJSDialogView) SetOnHidden(fn func(bool)) {
36+ if fn == nil {
37+ f.hiddenCleared = true
38+ }
39+}
40+func (f *fakeJSDialogView) Dismiss(ok bool) {
41+ f.dismissedWith = append(f.dismissedWith, ok)
42+ if f.respond != nil {
43+ r := f.respond
44+ f.respond = nil
45+ r(ok, "")
46+ }
47+}
48+func (f *fakeJSDialogView) Destroy() { f.destroyed = true }
49+
50+func newJSDialogPaneView(t *testing.T, fake *fakeJSDialogView) *PaneView {
51+ t.Helper()
52+ overlay := mocks.NewMockOverlayWidget(t)
53+ factory := mocks.NewMockWidgetFactory(t)
54+ wrapped := mocks.NewMockWidget(t)
55+ factory.EXPECT().WrapWidget(mock.Anything).Return(wrapped).Maybe()
56+ overlay.EXPECT().AddOverlay(wrapped).Maybe()
57+ overlay.EXPECT().SetClipOverlay(wrapped, true).Maybe()
58+ overlay.EXPECT().SetMeasureOverlay(wrapped, false).Maybe()
59+ overlay.EXPECT().RemoveOverlay(wrapped).Maybe()
60+ pv := &PaneView{
61+ overlay: overlay,
62+ factory: factory,
63+ newJSDialog: func(layout.OverlayWidget) jsDialogView {
64+ return fake
65+ },
66+ }
67+ return pv
68+}
69+
70+func TestPaneViewCleanupAnswersOpenJSDialog(t *testing.T) {
71+ fake := &fakeJSDialogView{}
72+ pv := newJSDialogPaneView(t, fake)
73+
74+ var answers []bool
75+ require.True(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(ok bool, _ string) { answers = append(answers, ok) }, 1))
76+
77+ pv.Cleanup()
78+
79+ assert.Equal(t, []bool{false}, answers, "page must be answered, not left blocked")
80+ assert.True(t, fake.hiddenCleared, "hidden callback detached before dismissing")
81+ assert.True(t, fake.destroyed)
82+ assert.Nil(t, pv.jsDialog)
83+
84+ // Cleanup again is a no-op.
85+ pv.Cleanup()
86+ assert.Equal(t, []bool{false}, answers)
87+}
88+
89+func TestPaneViewShowJSDialogFocusFollowsActive(t *testing.T) {
90+ fake := &fakeJSDialogView{}
91+ pv := newJSDialogPaneView(t, fake)
92+
93+ require.True(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(bool, string) {}, 1))
94+ assert.False(t, fake.focus, "inactive pane's dialog must not steal focus")
95+
96+ pv.isActive = true
97+ require.True(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(bool, string) {}, 1))
98+ assert.True(t, fake.focus)
99+}
100+
101+func TestPaneViewSetActiveFocusesPendingJSDialog(t *testing.T) {
102+ fake := &fakeJSDialogView{}
103+ pv := newJSDialogPaneView(t, fake)
104+ border := mocks.NewMockBoxWidget(t)
105+ border.EXPECT().AddCssClass(activePaneClass).Once()
106+ pv.borderBox = border
107+ require.True(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(bool, string) {}, 1))
108+
109+ pv.SetActive(true)
110+
111+ assert.Equal(t, 1, fake.focusRequests)
112+}
113+
114+func TestPaneViewGrabFocusDefersToVisibleJSDialog(t *testing.T) {
115+ fake := &fakeJSDialogView{}
116+ pv := newJSDialogPaneView(t, fake)
117+ webview := mocks.NewMockWidget(t) // GrabFocus on it would fail the test (no expectation)
118+ pv.webViewWidget = webview
119+ border := mocks.NewMockBoxWidget(t)
120+ border.EXPECT().AddCssClass(activePaneClass).Once()
121+ pv.borderBox = border
122+ require.True(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(bool, string) {}, 1))
123+
124+ pv.SetActive(true)
125+ require.Equal(t, 1, fake.focusRequests)
126+ assert.True(t, pv.GrabFocus())
127+
128+ assert.Equal(t, 2, fake.focusRequests, "dialog focus requested again, WebView not focused")
129+}
130+
131+func TestPaneViewGrabFocusFocusesWebViewWithoutDialog(t *testing.T) {
132+ fake := &fakeJSDialogView{}
133+ pv := newJSDialogPaneView(t, fake)
134+ webview := mocks.NewMockWidget(t)
135+ webview.EXPECT().GrabFocus().Return(true).Once()
136+ pv.webViewWidget = webview
137+
138+ assert.True(t, pv.GrabFocus())
139+}
140+
141+func TestPaneViewShowJSDialogAfterCleanupFails(t *testing.T) {
142+ fake := &fakeJSDialogView{}
143+ pv := newJSDialogPaneView(t, fake)
144+ pv.Cleanup()
145+
146+ assert.False(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(bool, string) {}, 1))
147+ assert.Zero(t, fake.shown)
148+}
@@ -0,0 +1,148 @@
1+package component
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/puregotk/v4/gtk"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/ui/layout"
13+ "github.com/bnema/dumber/internal/ui/layout/mocks"
14+)
15+
16+// fakeJSDialogView records calls made by PaneView.
17+type fakeJSDialogView struct {
18+ respond func(bool, string)
19+ focus bool
20+ shown int
21+ focusRequests int
22+ destroyed bool
23+ hiddenCleared bool
24+ dismissedWith []bool
25+}
26+
27+func (f *fakeJSDialogView) Widget() *gtk.Widget { return nil }
28+func (f *fakeJSDialogView) Show(_ port.JSDialogRequest, respond func(bool, string), focus bool, _ float64) {
29+ f.shown++
30+ f.respond, f.focus = respond, focus
31+}
32+func (f *fakeJSDialogView) Visible() bool { return f.respond != nil }
33+func (f *fakeJSDialogView) Hide() {}
34+func (f *fakeJSDialogView) RequestFocus() { f.focusRequests++ }
35+func (f *fakeJSDialogView) SetOnHidden(fn func(bool)) {
36+ if fn == nil {
37+ f.hiddenCleared = true
38+ }
39+}
40+func (f *fakeJSDialogView) Dismiss(ok bool) {
41+ f.dismissedWith = append(f.dismissedWith, ok)
42+ if f.respond != nil {
43+ r := f.respond
44+ f.respond = nil
45+ r(ok, "")
46+ }
47+}
48+func (f *fakeJSDialogView) Destroy() { f.destroyed = true }
49+
50+func newJSDialogPaneView(t *testing.T, fake *fakeJSDialogView) *PaneView {
51+ t.Helper()
52+ overlay := mocks.NewMockOverlayWidget(t)
53+ factory := mocks.NewMockWidgetFactory(t)
54+ wrapped := mocks.NewMockWidget(t)
55+ factory.EXPECT().WrapWidget(mock.Anything).Return(wrapped).Maybe()
56+ overlay.EXPECT().AddOverlay(wrapped).Maybe()
57+ overlay.EXPECT().SetClipOverlay(wrapped, true).Maybe()
58+ overlay.EXPECT().SetMeasureOverlay(wrapped, false).Maybe()
59+ overlay.EXPECT().RemoveOverlay(wrapped).Maybe()
60+ pv := &PaneView{
61+ overlay: overlay,
62+ factory: factory,
63+ newJSDialog: func(layout.OverlayWidget) jsDialogView {
64+ return fake
65+ },
66+ }
67+ return pv
68+}
69+
70+func TestPaneViewCleanupAnswersOpenJSDialog(t *testing.T) {
71+ fake := &fakeJSDialogView{}
72+ pv := newJSDialogPaneView(t, fake)
73+
74+ var answers []bool
75+ require.True(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(ok bool, _ string) { answers = append(answers, ok) }, 1))
76+
77+ pv.Cleanup()
78+
79+ assert.Equal(t, []bool{false}, answers, "page must be answered, not left blocked")
80+ assert.True(t, fake.hiddenCleared, "hidden callback detached before dismissing")
81+ assert.True(t, fake.destroyed)
82+ assert.Nil(t, pv.jsDialog)
83+
84+ // Cleanup again is a no-op.
85+ pv.Cleanup()
86+ assert.Equal(t, []bool{false}, answers)
87+}
88+
89+func TestPaneViewShowJSDialogFocusFollowsActive(t *testing.T) {
90+ fake := &fakeJSDialogView{}
91+ pv := newJSDialogPaneView(t, fake)
92+
93+ require.True(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(bool, string) {}, 1))
94+ assert.False(t, fake.focus, "inactive pane's dialog must not steal focus")
95+
96+ pv.isActive = true
97+ require.True(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(bool, string) {}, 1))
98+ assert.True(t, fake.focus)
99+}
100+
101+func TestPaneViewSetActiveFocusesPendingJSDialog(t *testing.T) {
102+ fake := &fakeJSDialogView{}
103+ pv := newJSDialogPaneView(t, fake)
104+ border := mocks.NewMockBoxWidget(t)
105+ border.EXPECT().AddCssClass(activePaneClass).Once()
106+ pv.borderBox = border
107+ require.True(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(bool, string) {}, 1))
108+
109+ pv.SetActive(true)
110+
111+ assert.Equal(t, 1, fake.focusRequests)
112+}
113+
114+func TestPaneViewGrabFocusDefersToVisibleJSDialog(t *testing.T) {
115+ fake := &fakeJSDialogView{}
116+ pv := newJSDialogPaneView(t, fake)
117+ webview := mocks.NewMockWidget(t) // GrabFocus on it would fail the test (no expectation)
118+ pv.webViewWidget = webview
119+ border := mocks.NewMockBoxWidget(t)
120+ border.EXPECT().AddCssClass(activePaneClass).Once()
121+ pv.borderBox = border
122+ require.True(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(bool, string) {}, 1))
123+
124+ pv.SetActive(true)
125+ require.Equal(t, 1, fake.focusRequests)
126+ assert.True(t, pv.GrabFocus())
127+
128+ assert.Equal(t, 2, fake.focusRequests, "dialog focus requested again, WebView not focused")
129+}
130+
131+func TestPaneViewGrabFocusFocusesWebViewWithoutDialog(t *testing.T) {
132+ fake := &fakeJSDialogView{}
133+ pv := newJSDialogPaneView(t, fake)
134+ webview := mocks.NewMockWidget(t)
135+ webview.EXPECT().GrabFocus().Return(true).Once()
136+ pv.webViewWidget = webview
137+
138+ assert.True(t, pv.GrabFocus())
139+}
140+
141+func TestPaneViewShowJSDialogAfterCleanupFails(t *testing.T) {
142+ fake := &fakeJSDialogView{}
143+ pv := newJSDialogPaneView(t, fake)
144+ pv.Cleanup()
145+
146+ assert.False(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(bool, string) {}, 1))
147+ assert.Zero(t, fake.shown)
148+}
M internal/ui/coordinator/content/callbacks.go +36 -0
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/dumber/internal/application/usecase"
1010 "github.com/bnema/dumber/internal/domain/entity"
1111 "github.com/bnema/dumber/internal/logging"
12+ "github.com/bnema/dumber/internal/ui/component"
1213 )
1314
1415 // internalSchemePath is the host used in dumb:// crash-page URIs.
@@ -131,6 +132,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
131132 },
132133 }
133134
135+ c.setupJSDialogCallbacks(paneID, callbacks)
134136 c.setupFaviconCallbacks(ctx, paneID, wv, callbacks)
135137
136138 // Middle-click link handler
@@ -286,3 +288,37 @@ func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.Permiss
286288 }
287289 return filtered
288290 }
291+
292+// setupJSDialogCallbacks routes JavaScript dialogs to the owning pane's overlay.
293+func (c *Coordinator) setupJSDialogCallbacks(paneID entity.PaneID, callbacks *port.WebViewCallbacks) {
294+ callbacks.OnJSDialog = func(req port.JSDialogRequest, respond func(ok bool, input string)) bool {
295+ pv := c.paneViewForJSDialog(paneID)
296+ if pv == nil {
297+ return false
298+ }
299+ return pv.ShowJSDialog(req, respond, c.jsDialogUIScale())
300+ }
301+ callbacks.OnJSDialogReset = func() {
302+ if pv := c.paneViewForJSDialog(paneID); pv != nil {
303+ pv.HideJSDialog()
304+ }
305+ }
306+}
307+
308+// jsDialogUIScale returns the current UI scale (1 when no provider is set).
309+func (c *Coordinator) jsDialogUIScale() float64 {
310+ if c.uiScaleProvider == nil {
311+ return 1
312+ }
313+ return c.uiScaleProvider()
314+}
315+
316+// paneViewForJSDialog resolves the PaneView that owns paneID's WebView through
317+// the app-provided resolver (any tab/window). The dialog is shown inside that
318+// pane's overlay even if the pane is in a background tab and never blocks others.
319+func (c *Coordinator) paneViewForJSDialog(paneID entity.PaneID) *component.PaneView {
320+ if c.paneViewResolver == nil {
321+ return nil
322+ }
323+ return c.paneViewResolver(paneID)
324+}
@@ -9,6 +9,7 @@ import (
9 "github.com/bnema/dumber/internal/application/usecase"9 "github.com/bnema/dumber/internal/application/usecase"
10 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
11 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
12+ "github.com/bnema/dumber/internal/ui/component"
12 )13 )
13 14
14 // internalSchemePath is the host used in dumb:// crash-page URIs.15 // internalSchemePath is the host used in dumb:// crash-page URIs.
@@ -131,6 +132,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
131 },132 },
132 }133 }
133 134
135+ c.setupJSDialogCallbacks(paneID, callbacks)
134 c.setupFaviconCallbacks(ctx, paneID, wv, callbacks)136 c.setupFaviconCallbacks(ctx, paneID, wv, callbacks)
135 137
136 // Middle-click link handler138 // Middle-click link handler
@@ -286,3 +288,37 @@ func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.Permiss
286 }288 }
287 return filtered289 return filtered
288 }290 }
291+
292+// setupJSDialogCallbacks routes JavaScript dialogs to the owning pane's overlay.
293+func (c *Coordinator) setupJSDialogCallbacks(paneID entity.PaneID, callbacks *port.WebViewCallbacks) {
294+ callbacks.OnJSDialog = func(req port.JSDialogRequest, respond func(ok bool, input string)) bool {
295+ pv := c.paneViewForJSDialog(paneID)
296+ if pv == nil {
297+ return false
298+ }
299+ return pv.ShowJSDialog(req, respond, c.jsDialogUIScale())
300+ }
301+ callbacks.OnJSDialogReset = func() {
302+ if pv := c.paneViewForJSDialog(paneID); pv != nil {
303+ pv.HideJSDialog()
304+ }
305+ }
306+}
307+
308+// jsDialogUIScale returns the current UI scale (1 when no provider is set).
309+func (c *Coordinator) jsDialogUIScale() float64 {
310+ if c.uiScaleProvider == nil {
311+ return 1
312+ }
313+ return c.uiScaleProvider()
314+}
315+
316+// paneViewForJSDialog resolves the PaneView that owns paneID's WebView through
317+// the app-provided resolver (any tab/window). The dialog is shown inside that
318+// pane's overlay even if the pane is in a background tab and never blocks others.
319+func (c *Coordinator) paneViewForJSDialog(paneID entity.PaneID) *component.PaneView {
320+ if c.paneViewResolver == nil {
321+ return nil
322+ }
323+ return c.paneViewResolver(paneID)
324+}
M internal/ui/coordinator/content/coordinator.go +16 -0
@@ -46,6 +46,12 @@ type Coordinator struct {
4646 // Callback to get active workspace state (avoids circular dependency)
4747 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
4848
49+ // Resolves a pane's view across all tabs/windows (JS dialogs can come from
50+ // background panes). JS dialogs are not shown while it is nil.
51+ paneViewResolver func(paneID entity.PaneID) *component.PaneView
52+ // Current UI scale, used to size JS dialogs.
53+ uiScaleProvider func() float64
54+
4955 // Callback when title changes (for history persistence)
5056 onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
5157
@@ -246,6 +252,16 @@ func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv port.
246252 c.onWebViewFocused = fn
247253 }
248254
255+// SetPaneViewResolver sets a resolver that finds a pane's view in any tab/window.
256+func (c *Coordinator) SetPaneViewResolver(fn func(paneID entity.PaneID) *component.PaneView) {
257+ c.paneViewResolver = fn
258+}
259+
260+// SetUIScaleProvider sets the source of the current UI scale.
261+func (c *Coordinator) SetUIScaleProvider(fn func() float64) {
262+ c.uiScaleProvider = fn
263+}
264+
249265 // SetOnEditableFocusChanged sets the callback for page editable focus changes.
250266 func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) {
251267 c.onEditableFocusChanged = fn
@@ -46,6 +46,12 @@ type Coordinator struct {
46 // Callback to get active workspace state (avoids circular dependency)46 // Callback to get active workspace state (avoids circular dependency)
47 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)47 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
48 48
49+ // Resolves a pane's view across all tabs/windows (JS dialogs can come from
50+ // background panes). JS dialogs are not shown while it is nil.
51+ paneViewResolver func(paneID entity.PaneID) *component.PaneView
52+ // Current UI scale, used to size JS dialogs.
53+ uiScaleProvider func() float64
54+
49 // Callback when title changes (for history persistence)55 // Callback when title changes (for history persistence)
50 onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)56 onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
51 57
@@ -246,6 +252,16 @@ func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv port.
246 c.onWebViewFocused = fn252 c.onWebViewFocused = fn
247 }253 }
248 254
255+// SetPaneViewResolver sets a resolver that finds a pane's view in any tab/window.
256+func (c *Coordinator) SetPaneViewResolver(fn func(paneID entity.PaneID) *component.PaneView) {
257+ c.paneViewResolver = fn
258+}
259+
260+// SetUIScaleProvider sets the source of the current UI scale.
261+func (c *Coordinator) SetUIScaleProvider(fn func() float64) {
262+ c.uiScaleProvider = fn
263+}
264+
249 // SetOnEditableFocusChanged sets the callback for page editable focus changes.265 // SetOnEditableFocusChanged sets the callback for page editable focus changes.
250 func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) {266 func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) {
251 c.onEditableFocusChanged = fn267 c.onEditableFocusChanged = fn
M internal/ui/theme/permission_popup_css.go +10 -0
@@ -37,6 +37,16 @@ func generatePermissionPopupCSS(p Palette) string {
3737 padding: 0.5em 0.75em;
3838 }
3939
40+/* JS dialog (alert/confirm/prompt/beforeunload): per-pane scrim + prompt entry */
41+.jsdialog-scrim {
42+ background-color: alpha(black, 0.35);
43+}
44+
45+.jsdialog-entry {
46+ margin: 0 1em 0.75em 1em;
47+ font-size: 0.8125em;
48+}
49+
4050 /* Shared button base */
4151 .permission-popup-btn {
4252 background-image: none;
@@ -37,6 +37,16 @@ func generatePermissionPopupCSS(p Palette) string {
37 padding: 0.5em 0.75em;37 padding: 0.5em 0.75em;
38 }38 }
39 39
40+/* JS dialog (alert/confirm/prompt/beforeunload): per-pane scrim + prompt entry */
41+.jsdialog-scrim {
42+ background-color: alpha(black, 0.35);
43+}
44+
45+.jsdialog-entry {
46+ margin: 0 1em 0.75em 1em;
47+ font-size: 0.8125em;
48+}
49+
40 /* Shared button base */50 /* Shared button base */
41 .permission-popup-btn {51 .permission-popup-btn {
42 background-image: none;52 background-image: none;

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close