1package cef
2
3import (
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.
41var 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
49var _ purecef.JsdialogHandler = (*handlerSet)(nil)
50
51// jsDialogCall is one in-flight dialog. done is guarded by jsDialogState.mu.
52type jsDialogCall struct {
53 callback purecef.JsdialogCallback
54 done bool
55}
56
57// jsDialogState tracks the single pending dialog of a WebView.
58type 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.
66type jsDialogBegin int
67
68const (
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.
75func (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.
82func (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.
91func (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.
109func (s *jsDialogState) hasPending() bool {
110 s.mu.Lock()
111 defer s.mu.Unlock()
112 return s.current != nil
113}
114
115func (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.
122func (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).
143func (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
156func (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.
163func (wv *WebView) cancelJSDialogs() { wv.cancelJSDialogsClosing(false) }
164
165// cancelJSDialogsClosing is cancelJSDialogs; with closing it also forbids any
166// further dialog (WebView destruction).
167func (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.
184func (wv *WebView) resetJSDialogUI(reset func()) {
185 if wv.jsDialogs.hasPending() {
186 return
187 }
188 reset()
189}
190
191func 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.
205func (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.
223func (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.
271func (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.
315func (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.
328func (h *handlerSet) OnDialogClosed(_ purecef.Browser) {}