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 Unverified
B5690EEEBB952194The 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 { | |||
| 147 | 147 | // OnEditableFocusChanged is called when the page focus enters or leaves an | |
| 148 | 148 | // editable target such as input, textarea, or contenteditable content. | |
| 149 | 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 | 165 | // FindOptions configures search behavior. | |
| @@ -147,6 +147,19 @@ type WebViewCallbacks struct { | |||||
| 147 | // OnEditableFocusChanged is called when the page focus enters or leaves an | 147 | // 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 { | |||
| 221 | 221 | // Callbacks and browsing-context state set by the UI layer. | |
| 222 | 222 | mu sync.RWMutex | |
| 223 | 223 | callbacks *port.WebViewCallbacks | |
| 224 | + | jsDialogs jsDialogState | |
| 224 | 225 | browsingContextDecision dto.HostDecision | |
| 225 | 226 | hasBrowsingContextDecision bool | |
| 226 | 227 | nativePopupHostAbort func() | |
| @@ -1332,6 +1333,7 @@ func (wv *WebView) Destroy() { | |||
| 1332 | 1333 | // calling thread; GTK-only cleanup follows through the owning | |
| 1333 | 1334 | // dispatcher without waiting for the deferred native browser close. | |
| 1334 | 1335 | wv.invalidateScrollMotion() | |
| 1336 | + | wv.cancelJSDialogsClosing(true) | |
| 1335 | 1337 | wv.shutdownAccessibilityCapture() | |
| 1336 | 1338 | wv.resetPageScrollQueue() | |
| 1337 | 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 | 2617 | func (wv *WebView) runOnGTK(fn func()) { | |
| 2606 | 2618 | if fn == nil { | |
| 2607 | 2619 | return | |
| @@ -2611,13 +2623,7 @@ func (wv *WebView) runOnGTK(fn func()) { | |||
| 2611 | 2623 | fn() | |
| 2612 | 2624 | return | |
| 2613 | 2625 | } | |
| 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) | |
| 2621 | 2627 | } | |
| 2622 | 2628 | ||
| 2623 | 2629 | 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.RWMutex | 222 | mu sync.RWMutex | ||
| 223 | callbacks *port.WebViewCallbacks | 223 | callbacks *port.WebViewCallbacks | ||
| 224 | + | jsDialogs jsDialogState | |||
| 224 | browsingContextDecision dto.HostDecision | 225 | browsingContextDecision dto.HostDecision | ||
| 225 | hasBrowsingContextDecision bool | 226 | 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 owning | 1333 | // 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 | return | 2619 | return | ||
| @@ -2611,13 +2623,7 @@ func (wv *WebView) runOnGTK(fn func()) { | |||||
| 2611 | fn() | 2623 | fn() | ||
| 2612 | return | 2624 | 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 | |||
| 72 | 72 | func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil } | |
| 73 | 73 | func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil } | |
| 74 | 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 | 76 | func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil } | |
| 77 | 77 | func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h } | |
| 78 | 78 | func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h } | |
| @@ -531,6 +531,8 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec | |||
| 531 | 531 | // Post-commit fallback for navigation paths that bypass OnBeforeBrowse | |
| 532 | 532 | // (same-page, error, and helper-driven commits). Main frame only. | |
| 533 | 533 | if h.wv != nil { | |
| 534 | + | // A new document replaces the page that asked for any open JS dialog. | |
| 535 | + | h.wv.cancelJSDialogs() | |
| 534 | 536 | h.wv.invalidateScrollMotion() | |
| 535 | 537 | h.wv.mu.Lock() | |
| 536 | 538 | h.wv.documentSeq++ | |
| @@ -1298,6 +1300,10 @@ const maxConsecutiveCrashes = 3 | |||
| 1298 | 1300 | ||
| 1299 | 1301 | // OnRenderProcessTerminated fires the OnWebProcessTerminated callback with a mapped reason. | |
| 1300 | 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 | 1307 | if h.wv.crashCount.Add(1) > maxConsecutiveCrashes { | |
| 1302 | 1308 | return // suppress to break the loop | |
| 1303 | 1309 | } | |
| @@ -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 OnBeforeBrowse | 531 | // 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 loop | 1308 | 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) { | |||
| 3424 | 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 | 3433 | // Hide loading skeleton once the WebView paints | |
| 3428 | 3434 | a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) { | |
| 3429 | 3435 | // The WebView can be shown while its pane is in a background tab, so scan | |
| 3430 | 3436 | // 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() | |
| 3438 | 3439 | } | |
| 3439 | 3440 | if a.deps != nil && a.deps.OnFirstWebViewShown != nil { | |
| 3440 | 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 | 3606 | func (a *App) wireWebRTCPermissionIndicator() { | |
| 3593 | 3607 | if a.contentCoord == nil { | |
| 3594 | 3608 | 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 paints | 3433 | // 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 scan | 3435 | // 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 | return | 3608 | 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 ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "sync" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 11 | "github.com/bnema/dumber/internal/ui/input" | |
| 11 | 12 | "github.com/bnema/dumber/internal/ui/layout" | |
| 13 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 12 | 14 | ) | |
| 13 | 15 | ||
| 14 | 16 | // CSS class applied to the active pane's border overlay. | |
| @@ -17,17 +19,21 @@ const activePaneClass = "pane-active" | |||
| 17 | 19 | // PaneView is a container for a single WebView with active state indication. | |
| 18 | 20 | // It uses an overlay to display a border around the active pane. | |
| 19 | 21 | 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 | |
| 31 | 37 | ||
| 32 | 38 | // Vim Mode input ownership; its border and pulse belong to modeFrame. | |
| 33 | 39 | vimMode bool | |
| @@ -101,9 +107,8 @@ func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entit | |||
| 101 | 107 | // Active panes display a visual border indicator. | |
| 102 | 108 | func (pv *PaneView) SetActive(active bool) { | |
| 103 | 109 | pv.mu.Lock() | |
| 104 | - | defer pv.mu.Unlock() | |
| 105 | - | ||
| 106 | 110 | if pv.isActive == active { | |
| 111 | + | pv.mu.Unlock() | |
| 107 | 112 | return | |
| 108 | 113 | } | |
| 109 | 114 | ||
| @@ -114,6 +119,13 @@ func (pv *PaneView) SetActive(active bool) { | |||
| 114 | 119 | } else { | |
| 115 | 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 | 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 | 221 | // GrabFocus attempts to focus the WebView. | |
| 210 | 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 | 227 | func (pv *PaneView) GrabFocus() bool { | |
| 212 | 228 | pv.mu.RLock() | |
| 213 | 229 | wv := pv.webViewWidget | |
| 230 | + | popup := pv.jsDialog | |
| 214 | 231 | pv.mu.RUnlock() | |
| 215 | 232 | ||
| 233 | + | if popup != nil && popup.Visible() { | |
| 234 | + | popup.RequestFocus() | |
| 235 | + | return true | |
| 236 | + | } | |
| 237 | + | ||
| 216 | 238 | if wv == nil { | |
| 217 | 239 | return false | |
| 218 | 240 | } | |
| @@ -545,10 +567,36 @@ func (pv *PaneView) IsVimMode() bool { | |||
| 545 | 567 | // Cleanup removes the WebView widget from the overlay and clears references. | |
| 546 | 568 | // This must be called before destroying the WebView to ensure proper GTK cleanup. | |
| 547 | 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 | 575 | func (pv *PaneView) Cleanup() { | |
| 549 | 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 | 600 | // Clear callbacks to prevent use-after-free | |
| 553 | 601 | pv.onFocusIn = nil | |
| 554 | 602 | pv.onFocusOut = nil | |
| @@ -586,3 +634,90 @@ func (pv *PaneView) Cleanup() { | |||
| 586 | 634 | 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 | + | } | |
| @@ -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.Context | 22 | + | ctx context.Context |
| 21 | - | factory layout.WidgetFactory | 23 | + | factory layout.WidgetFactory |
| 22 | - | overlay layout.OverlayWidget | 24 | + | overlay layout.OverlayWidget |
| 23 | - | webViewWidget layout.Widget // The actual WebView widget | 25 | + | webViewWidget layout.Widget // The actual WebView widget |
| 24 | - | borderBox layout.BoxWidget // Border overlay for active indication | 26 | + | borderBox layout.BoxWidget // Border overlay for active indication |
| 25 | - | progressBar *ProgressBar // Loading progress indicator | 27 | + | progressBar *ProgressBar // Loading progress indicator |
| 26 | - | toaster *Toaster // Toast notification overlay | 28 | + | toaster *Toaster // Toast notification overlay |
| 27 | - | linkStatus *LinkStatusOverlay // Link hover URL overlay | 29 | + | linkStatus *LinkStatusOverlay // Link hover URL overlay |
| 28 | - | loading *LoadingSkeleton // Placeholder shown until WebView paints | 30 | + | loading *LoadingSkeleton // Placeholder shown until WebView paints |
| 29 | - | paneID entity.PaneID | 31 | + | jsDialog jsDialogView // JS alert/confirm/prompt/beforeunload overlay |
| 30 | - | isActive bool | 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 | |||
| 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 bool | 39 | 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 | return | 112 | 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.webViewWidget | 229 | 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 false | 239 | 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-free | 600 | // Clear callbacks to prevent use-after-free | ||
| 553 | pv.onFocusIn = nil | 601 | pv.onFocusIn = nil | ||
| 554 | pv.onFocusOut = nil | 602 | pv.onFocusOut = nil | ||
| @@ -586,3 +634,90 @@ func (pv *PaneView) Cleanup() { | |||||
| 586 | pv.linkStatus = nil | 634 | 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 ( | |||
| 9 | 9 | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 12 | + | "github.com/bnema/dumber/internal/ui/component" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 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 | 136 | c.setupFaviconCallbacks(ctx, paneID, wv, callbacks) | |
| 135 | 137 | ||
| 136 | 138 | // Middle-click link handler | |
| @@ -286,3 +288,37 @@ func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.Permiss | |||
| 286 | 288 | } | |
| 287 | 289 | 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 | + | } | |
| @@ -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 handler | 138 | // Middle-click link handler | ||
| @@ -286,3 +288,37 @@ func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.Permiss | |||||
| 286 | } | 288 | } | ||
| 287 | return filtered | 289 | 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 { | |||
| 46 | 46 | // Callback to get active workspace state (avoids circular dependency) | |
| 47 | 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 | 55 | // Callback when title changes (for history persistence) | |
| 50 | 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 | 252 | 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 | 265 | // SetOnEditableFocusChanged sets the callback for page editable focus changes. | |
| 250 | 266 | func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) { | |
| 251 | 267 | 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 = fn | 252 | 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 = fn | 267 | c.onEditableFocusChanged = fn | ||
M internal/ui/theme/permission_popup_css.go +10 -0
| @@ -37,6 +37,16 @@ func generatePermissionPopupCSS(p Palette) string { | |||
| 37 | 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 | 50 | /* Shared button base */ | |
| 41 | 51 | .permission-popup-btn { | |
| 42 | 52 | 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; | ||