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.
- commit
- d5a684168c16f215aa7f819d7227bbc9d918785c
- author
- brice · · 2026-10-03 06:19 UTC
- committer
- brice · · 2026-10-03 07:50 UTC
- parent
- e8e7279
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +1056 -1
A internal/application/port/jsdialog.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 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). | |
| 24 | + | Message string | |
| 25 | + | // DefaultPrompt is the prefilled input for JSDialogPrompt. | |
| 26 | + | DefaultPrompt string | |
| 27 | + | // IsReload is true for beforeunload triggered by a reload. | |
| 28 | + | IsReload bool | |
| 29 | + | } | |
| @@ -0,0 +1,29 @@ | |||||
| 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). | |||
| 24 | + | Message string | |||
| 25 | + | // DefaultPrompt is the prefilled input for JSDialogPrompt. | |||
| 26 | + | DefaultPrompt string | |||
| 27 | + | // IsReload is true for beforeunload triggered by a reload. | |||
| 28 | + | IsReload bool | |||
| 29 | + | } | |||
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 +235 -0
| @@ -0,0 +1,235 @@ | |||
| 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 | + | // continueCEFJSDialog resolves a CEF dialog callback. It is a seam for tests. | |
| 25 | + | var continueCEFJSDialog = continueCEFJSDialogOnUIThread | |
| 26 | + | ||
| 27 | + | var _ purecef.JsdialogHandler = (*handlerSet)(nil) | |
| 28 | + | ||
| 29 | + | // jsDialogCall is one in-flight dialog. done is guarded by jsDialogState.mu. | |
| 30 | + | type jsDialogCall struct { | |
| 31 | + | callback purecef.JsdialogCallback | |
| 32 | + | done bool | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // jsDialogState tracks the single pending dialog of a WebView. | |
| 36 | + | type jsDialogState struct { | |
| 37 | + | mu sync.Mutex | |
| 38 | + | current *jsDialogCall | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // begin registers a new pending dialog; false if one is already open. | |
| 42 | + | func (s *jsDialogState) begin(cb purecef.JsdialogCallback) (*jsDialogCall, bool) { | |
| 43 | + | s.mu.Lock() | |
| 44 | + | defer s.mu.Unlock() | |
| 45 | + | if s.current != nil { | |
| 46 | + | return nil, false | |
| 47 | + | } | |
| 48 | + | call := &jsDialogCall{callback: cb} | |
| 49 | + | s.current = call | |
| 50 | + | return call, true | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (s *jsDialogState) isDone(call *jsDialogCall) bool { | |
| 54 | + | s.mu.Lock() | |
| 55 | + | defer s.mu.Unlock() | |
| 56 | + | return call.done | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // resolve answers the CEF callback exactly once; later calls are no-ops. | |
| 60 | + | func (s *jsDialogState) resolve(call *jsDialogCall, ok bool, input string) bool { | |
| 61 | + | s.mu.Lock() | |
| 62 | + | if call == nil || call.done { | |
| 63 | + | s.mu.Unlock() | |
| 64 | + | return false | |
| 65 | + | } | |
| 66 | + | call.done = true | |
| 67 | + | if s.current == call { | |
| 68 | + | s.current = nil | |
| 69 | + | } | |
| 70 | + | s.mu.Unlock() | |
| 71 | + | continueCEFJSDialog(call.callback, ok, input) | |
| 72 | + | return true | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // cancelPending resolves the pending dialog (if any) with a cancel. | |
| 76 | + | func (s *jsDialogState) cancelPending() bool { | |
| 77 | + | s.mu.Lock() | |
| 78 | + | call := s.current | |
| 79 | + | s.mu.Unlock() | |
| 80 | + | if call == nil { | |
| 81 | + | return false | |
| 82 | + | } | |
| 83 | + | return s.resolve(call, false, "") | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (wv *WebView) jsDialogUICallbacks() *port.WebViewCallbacks { | |
| 87 | + | wv.mu.RLock() | |
| 88 | + | defer wv.mu.RUnlock() | |
| 89 | + | return wv.callbacks | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // cancelJSDialogs resolves any pending dialog and tells the UI to hide it. | |
| 93 | + | func (wv *WebView) cancelJSDialogs() { | |
| 94 | + | if wv == nil { | |
| 95 | + | return | |
| 96 | + | } | |
| 97 | + | if !wv.jsDialogs.cancelPending() { | |
| 98 | + | return | |
| 99 | + | } | |
| 100 | + | if cb := wv.jsDialogUICallbacks(); cb != nil && cb.OnJSDialogReset != nil { | |
| 101 | + | wv.runOnGTK(cb.OnJSDialogReset) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func jsDialogTypeFromCEF(t purecef.JsdialogType) (port.JSDialogType, bool) { | |
| 106 | + | switch t { | |
| 107 | + | case purecef.JsdialogTypeJsdialogtypeAlert: | |
| 108 | + | return port.JSDialogAlert, true | |
| 109 | + | case purecef.JsdialogTypeJsdialogtypeConfirm: | |
| 110 | + | return port.JSDialogConfirm, true | |
| 111 | + | case purecef.JsdialogTypeJsdialogtypePrompt: | |
| 112 | + | return port.JSDialogPrompt, true | |
| 113 | + | default: | |
| 114 | + | return 0, false | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // present shows the dialog on the GTK thread and wires the user's answer back. | |
| 119 | + | func (wv *WebView) presentJSDialog(call *jsDialogCall, req port.JSDialogRequest, ui *port.WebViewCallbacks) { | |
| 120 | + | wv.runOnGTK(func() { | |
| 121 | + | if wv.jsDialogs.isDone(call) { | |
| 122 | + | return // canceled before the UI got to run | |
| 123 | + | } | |
| 124 | + | respond := func(ok bool, input string) { | |
| 125 | + | if req.Type != port.JSDialogPrompt { | |
| 126 | + | input = "" | |
| 127 | + | } | |
| 128 | + | wv.jsDialogs.resolve(call, ok, input) | |
| 129 | + | } | |
| 130 | + | if !ui.OnJSDialog(req, respond) { | |
| 131 | + | respond(false, "") | |
| 132 | + | } | |
| 133 | + | }) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // OnJsdialog implements purecef.JsdialogHandler. | |
| 137 | + | func (h *handlerSet) OnJsdialog( | |
| 138 | + | _ purecef.Browser, | |
| 139 | + | originURL string, | |
| 140 | + | dialogType purecef.JsdialogType, | |
| 141 | + | messageText string, | |
| 142 | + | defaultPromptText string, | |
| 143 | + | callback purecef.JsdialogCallback, | |
| 144 | + | suppressMessage *int32, | |
| 145 | + | ) int32 { | |
| 146 | + | if h == nil || h.wv == nil || callback == nil { | |
| 147 | + | return 0 | |
| 148 | + | } | |
| 149 | + | typ, ok := jsDialogTypeFromCEF(dialogType) | |
| 150 | + | if !ok { | |
| 151 | + | return 0 | |
| 152 | + | } | |
| 153 | + | ui := h.wv.jsDialogUICallbacks() | |
| 154 | + | if ui == nil || ui.OnJSDialog == nil { | |
| 155 | + | return 0 // no UI: let CEF apply its default behavior | |
| 156 | + | } | |
| 157 | + | call, ok := h.wv.jsDialogs.begin(callback) | |
| 158 | + | if !ok { | |
| 159 | + | // A dialog is already open: suppress rather than stack/spam. | |
| 160 | + | if suppressMessage != nil { | |
| 161 | + | *suppressMessage = 1 | |
| 162 | + | } | |
| 163 | + | return 0 | |
| 164 | + | } | |
| 165 | + | logging.FromContext(h.wv.ctx).Debug().Int32("type", dialogType).Msg("cef: js dialog requested") | |
| 166 | + | h.wv.presentJSDialog(call, port.JSDialogRequest{ | |
| 167 | + | Type: typ, | |
| 168 | + | Origin: originURL, | |
| 169 | + | Message: messageText, | |
| 170 | + | DefaultPrompt: defaultPromptText, | |
| 171 | + | }, ui) | |
| 172 | + | return 1 | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // OnBeforeUnloadDialog implements purecef.JsdialogHandler. | |
| 176 | + | func (h *handlerSet) OnBeforeUnloadDialog( | |
| 177 | + | _ purecef.Browser, | |
| 178 | + | messageText string, | |
| 179 | + | isReload int32, | |
| 180 | + | callback purecef.JsdialogCallback, | |
| 181 | + | ) bool { | |
| 182 | + | if h == nil || h.wv == nil || callback == nil { | |
| 183 | + | return false | |
| 184 | + | } | |
| 185 | + | ui := h.wv.jsDialogUICallbacks() | |
| 186 | + | if ui == nil || ui.OnJSDialog == nil { | |
| 187 | + | return false | |
| 188 | + | } | |
| 189 | + | call, ok := h.wv.jsDialogs.begin(callback) | |
| 190 | + | if !ok { | |
| 191 | + | // Another dialog is open: stay on the page rather than stacking. | |
| 192 | + | continueCEFJSDialog(callback, false, "") | |
| 193 | + | return true | |
| 194 | + | } | |
| 195 | + | h.wv.presentJSDialog(call, port.JSDialogRequest{ | |
| 196 | + | Type: port.JSDialogBeforeUnload, | |
| 197 | + | Origin: h.wv.URI(), | |
| 198 | + | Message: messageText, | |
| 199 | + | IsReload: isReload != 0, | |
| 200 | + | }, ui) | |
| 201 | + | return true | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // OnResetDialogState implements purecef.JsdialogHandler: CEF canceled pending | |
| 205 | + | // dialogs (navigation, close). Close the UI and resolve the callback. | |
| 206 | + | func (h *handlerSet) OnResetDialogState(_ purecef.Browser) { | |
| 207 | + | if h == nil || h.wv == nil { | |
| 208 | + | return | |
| 209 | + | } | |
| 210 | + | h.wv.cancelJSDialogs() | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // OnDialogClosed implements purecef.JsdialogHandler. It intentionally does | |
| 214 | + | // nothing: resolve already cleared the pending state, and a late notification | |
| 215 | + | // must not clear a newer dialog. | |
| 216 | + | func (h *handlerSet) OnDialogClosed(_ purecef.Browser) {} | |
| 217 | + | ||
| 218 | + | // continueCEFJSDialogOnUIThread answers the callback on the CEF UI thread. | |
| 219 | + | func continueCEFJSDialogOnUIThread(callback purecef.JsdialogCallback, ok bool, input string) { | |
| 220 | + | if callback == nil { | |
| 221 | + | return | |
| 222 | + | } | |
| 223 | + | success := int32(0) | |
| 224 | + | if ok { | |
| 225 | + | success = 1 | |
| 226 | + | } | |
| 227 | + | if purecef.CurrentlyOn(purecef.ThreadIDTidUi) == 1 { | |
| 228 | + | callback.Cont(success, input) | |
| 229 | + | return | |
| 230 | + | } | |
| 231 | + | task := cefNewTask(cefTaskFunc(func() { callback.Cont(success, input) })) | |
| 232 | + | if cefPostTask(purecef.ThreadIDTidUi, task) != 1 { | |
| 233 | + | callback.Cont(success, input) | |
| 234 | + | } | |
| 235 | + | } | |
| @@ -0,0 +1,235 @@ | |||||
| 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 | + | // continueCEFJSDialog resolves a CEF dialog callback. It is a seam for tests. | |||
| 25 | + | var continueCEFJSDialog = continueCEFJSDialogOnUIThread | |||
| 26 | + | ||||
| 27 | + | var _ purecef.JsdialogHandler = (*handlerSet)(nil) | |||
| 28 | + | ||||
| 29 | + | // jsDialogCall is one in-flight dialog. done is guarded by jsDialogState.mu. | |||
| 30 | + | type jsDialogCall struct { | |||
| 31 | + | callback purecef.JsdialogCallback | |||
| 32 | + | done bool | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // jsDialogState tracks the single pending dialog of a WebView. | |||
| 36 | + | type jsDialogState struct { | |||
| 37 | + | mu sync.Mutex | |||
| 38 | + | current *jsDialogCall | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // begin registers a new pending dialog; false if one is already open. | |||
| 42 | + | func (s *jsDialogState) begin(cb purecef.JsdialogCallback) (*jsDialogCall, bool) { | |||
| 43 | + | s.mu.Lock() | |||
| 44 | + | defer s.mu.Unlock() | |||
| 45 | + | if s.current != nil { | |||
| 46 | + | return nil, false | |||
| 47 | + | } | |||
| 48 | + | call := &jsDialogCall{callback: cb} | |||
| 49 | + | s.current = call | |||
| 50 | + | return call, true | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func (s *jsDialogState) isDone(call *jsDialogCall) bool { | |||
| 54 | + | s.mu.Lock() | |||
| 55 | + | defer s.mu.Unlock() | |||
| 56 | + | return call.done | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // resolve answers the CEF callback exactly once; later calls are no-ops. | |||
| 60 | + | func (s *jsDialogState) resolve(call *jsDialogCall, ok bool, input string) bool { | |||
| 61 | + | s.mu.Lock() | |||
| 62 | + | if call == nil || call.done { | |||
| 63 | + | s.mu.Unlock() | |||
| 64 | + | return false | |||
| 65 | + | } | |||
| 66 | + | call.done = true | |||
| 67 | + | if s.current == call { | |||
| 68 | + | s.current = nil | |||
| 69 | + | } | |||
| 70 | + | s.mu.Unlock() | |||
| 71 | + | continueCEFJSDialog(call.callback, ok, input) | |||
| 72 | + | return true | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // cancelPending resolves the pending dialog (if any) with a cancel. | |||
| 76 | + | func (s *jsDialogState) cancelPending() bool { | |||
| 77 | + | s.mu.Lock() | |||
| 78 | + | call := s.current | |||
| 79 | + | s.mu.Unlock() | |||
| 80 | + | if call == nil { | |||
| 81 | + | return false | |||
| 82 | + | } | |||
| 83 | + | return s.resolve(call, false, "") | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (wv *WebView) jsDialogUICallbacks() *port.WebViewCallbacks { | |||
| 87 | + | wv.mu.RLock() | |||
| 88 | + | defer wv.mu.RUnlock() | |||
| 89 | + | return wv.callbacks | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // cancelJSDialogs resolves any pending dialog and tells the UI to hide it. | |||
| 93 | + | func (wv *WebView) cancelJSDialogs() { | |||
| 94 | + | if wv == nil { | |||
| 95 | + | return | |||
| 96 | + | } | |||
| 97 | + | if !wv.jsDialogs.cancelPending() { | |||
| 98 | + | return | |||
| 99 | + | } | |||
| 100 | + | if cb := wv.jsDialogUICallbacks(); cb != nil && cb.OnJSDialogReset != nil { | |||
| 101 | + | wv.runOnGTK(cb.OnJSDialogReset) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func jsDialogTypeFromCEF(t purecef.JsdialogType) (port.JSDialogType, bool) { | |||
| 106 | + | switch t { | |||
| 107 | + | case purecef.JsdialogTypeJsdialogtypeAlert: | |||
| 108 | + | return port.JSDialogAlert, true | |||
| 109 | + | case purecef.JsdialogTypeJsdialogtypeConfirm: | |||
| 110 | + | return port.JSDialogConfirm, true | |||
| 111 | + | case purecef.JsdialogTypeJsdialogtypePrompt: | |||
| 112 | + | return port.JSDialogPrompt, true | |||
| 113 | + | default: | |||
| 114 | + | return 0, false | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // present shows the dialog on the GTK thread and wires the user's answer back. | |||
| 119 | + | func (wv *WebView) presentJSDialog(call *jsDialogCall, req port.JSDialogRequest, ui *port.WebViewCallbacks) { | |||
| 120 | + | wv.runOnGTK(func() { | |||
| 121 | + | if wv.jsDialogs.isDone(call) { | |||
| 122 | + | return // canceled before the UI got to run | |||
| 123 | + | } | |||
| 124 | + | respond := func(ok bool, input string) { | |||
| 125 | + | if req.Type != port.JSDialogPrompt { | |||
| 126 | + | input = "" | |||
| 127 | + | } | |||
| 128 | + | wv.jsDialogs.resolve(call, ok, input) | |||
| 129 | + | } | |||
| 130 | + | if !ui.OnJSDialog(req, respond) { | |||
| 131 | + | respond(false, "") | |||
| 132 | + | } | |||
| 133 | + | }) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // OnJsdialog implements purecef.JsdialogHandler. | |||
| 137 | + | func (h *handlerSet) OnJsdialog( | |||
| 138 | + | _ purecef.Browser, | |||
| 139 | + | originURL string, | |||
| 140 | + | dialogType purecef.JsdialogType, | |||
| 141 | + | messageText string, | |||
| 142 | + | defaultPromptText string, | |||
| 143 | + | callback purecef.JsdialogCallback, | |||
| 144 | + | suppressMessage *int32, | |||
| 145 | + | ) int32 { | |||
| 146 | + | if h == nil || h.wv == nil || callback == nil { | |||
| 147 | + | return 0 | |||
| 148 | + | } | |||
| 149 | + | typ, ok := jsDialogTypeFromCEF(dialogType) | |||
| 150 | + | if !ok { | |||
| 151 | + | return 0 | |||
| 152 | + | } | |||
| 153 | + | ui := h.wv.jsDialogUICallbacks() | |||
| 154 | + | if ui == nil || ui.OnJSDialog == nil { | |||
| 155 | + | return 0 // no UI: let CEF apply its default behavior | |||
| 156 | + | } | |||
| 157 | + | call, ok := h.wv.jsDialogs.begin(callback) | |||
| 158 | + | if !ok { | |||
| 159 | + | // A dialog is already open: suppress rather than stack/spam. | |||
| 160 | + | if suppressMessage != nil { | |||
| 161 | + | *suppressMessage = 1 | |||
| 162 | + | } | |||
| 163 | + | return 0 | |||
| 164 | + | } | |||
| 165 | + | logging.FromContext(h.wv.ctx).Debug().Int32("type", dialogType).Msg("cef: js dialog requested") | |||
| 166 | + | h.wv.presentJSDialog(call, port.JSDialogRequest{ | |||
| 167 | + | Type: typ, | |||
| 168 | + | Origin: originURL, | |||
| 169 | + | Message: messageText, | |||
| 170 | + | DefaultPrompt: defaultPromptText, | |||
| 171 | + | }, ui) | |||
| 172 | + | return 1 | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // OnBeforeUnloadDialog implements purecef.JsdialogHandler. | |||
| 176 | + | func (h *handlerSet) OnBeforeUnloadDialog( | |||
| 177 | + | _ purecef.Browser, | |||
| 178 | + | messageText string, | |||
| 179 | + | isReload int32, | |||
| 180 | + | callback purecef.JsdialogCallback, | |||
| 181 | + | ) bool { | |||
| 182 | + | if h == nil || h.wv == nil || callback == nil { | |||
| 183 | + | return false | |||
| 184 | + | } | |||
| 185 | + | ui := h.wv.jsDialogUICallbacks() | |||
| 186 | + | if ui == nil || ui.OnJSDialog == nil { | |||
| 187 | + | return false | |||
| 188 | + | } | |||
| 189 | + | call, ok := h.wv.jsDialogs.begin(callback) | |||
| 190 | + | if !ok { | |||
| 191 | + | // Another dialog is open: stay on the page rather than stacking. | |||
| 192 | + | continueCEFJSDialog(callback, false, "") | |||
| 193 | + | return true | |||
| 194 | + | } | |||
| 195 | + | h.wv.presentJSDialog(call, port.JSDialogRequest{ | |||
| 196 | + | Type: port.JSDialogBeforeUnload, | |||
| 197 | + | Origin: h.wv.URI(), | |||
| 198 | + | Message: messageText, | |||
| 199 | + | IsReload: isReload != 0, | |||
| 200 | + | }, ui) | |||
| 201 | + | return true | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // OnResetDialogState implements purecef.JsdialogHandler: CEF canceled pending | |||
| 205 | + | // dialogs (navigation, close). Close the UI and resolve the callback. | |||
| 206 | + | func (h *handlerSet) OnResetDialogState(_ purecef.Browser) { | |||
| 207 | + | if h == nil || h.wv == nil { | |||
| 208 | + | return | |||
| 209 | + | } | |||
| 210 | + | h.wv.cancelJSDialogs() | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // OnDialogClosed implements purecef.JsdialogHandler. It intentionally does | |||
| 214 | + | // nothing: resolve already cleared the pending state, and a late notification | |||
| 215 | + | // must not clear a newer dialog. | |||
| 216 | + | func (h *handlerSet) OnDialogClosed(_ purecef.Browser) {} | |||
| 217 | + | ||||
| 218 | + | // continueCEFJSDialogOnUIThread answers the callback on the CEF UI thread. | |||
| 219 | + | func continueCEFJSDialogOnUIThread(callback purecef.JsdialogCallback, ok bool, input string) { | |||
| 220 | + | if callback == nil { | |||
| 221 | + | return | |||
| 222 | + | } | |||
| 223 | + | success := int32(0) | |||
| 224 | + | if ok { | |||
| 225 | + | success = 1 | |||
| 226 | + | } | |||
| 227 | + | if purecef.CurrentlyOn(purecef.ThreadIDTidUi) == 1 { | |||
| 228 | + | callback.Cont(success, input) | |||
| 229 | + | return | |||
| 230 | + | } | |||
| 231 | + | task := cefNewTask(cefTaskFunc(func() { callback.Cont(success, input) })) | |||
| 232 | + | if cefPostTask(purecef.ThreadIDTidUi, task) != 1 { | |||
| 233 | + | callback.Cont(success, input) | |||
| 234 | + | } | |||
| 235 | + | } | |||
A internal/infrastructure/cef/jsdialog_handler_test.go +242 -0
| @@ -0,0 +1,242 @@ | |||
| 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 | + | } | |
| 16 | + | ||
| 17 | + | type jsDialogAnswer struct { | |
| 18 | + | ok bool | |
| 19 | + | input string | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (s *stubJSDialogCallback) Cont(success int32, userInput string) { | |
| 23 | + | s.calls = append(s.calls, jsDialogAnswer{ok: success != 0, input: userInput}) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // useDirectJSDialogContinue answers stub callbacks inline (no CEF UI thread). | |
| 27 | + | func useDirectJSDialogContinue(t *testing.T) { | |
| 28 | + | t.Helper() | |
| 29 | + | prev := continueCEFJSDialog | |
| 30 | + | continueCEFJSDialog = func(cb purecef.JsdialogCallback, ok bool, input string) { | |
| 31 | + | success := int32(0) | |
| 32 | + | if ok { | |
| 33 | + | success = 1 | |
| 34 | + | } | |
| 35 | + | cb.Cont(success, input) | |
| 36 | + | } | |
| 37 | + | t.Cleanup(func() { continueCEFJSDialog = prev }) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | type jsDialogUIRecorder struct { | |
| 41 | + | reqs []port.JSDialogRequest | |
| 42 | + | respond []func(bool, string) | |
| 43 | + | resets int | |
| 44 | + | handled bool | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func newJSDialogWebView(ui *jsDialogUIRecorder) *WebView { | |
| 48 | + | return &WebView{ | |
| 49 | + | ctx: context.Background(), | |
| 50 | + | callbacks: &port.WebViewCallbacks{ | |
| 51 | + | OnJSDialog: func(req port.JSDialogRequest, respond func(bool, string)) bool { | |
| 52 | + | ui.reqs = append(ui.reqs, req) | |
| 53 | + | ui.respond = append(ui.respond, respond) | |
| 54 | + | return ui.handled | |
| 55 | + | }, | |
| 56 | + | OnJSDialogReset: func() { ui.resets++ }, | |
| 57 | + | }, | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestGetJsdialogHandlerEnabled(t *testing.T) { | |
| 62 | + | h := &handlerSet{} | |
| 63 | + | require.Same(t, h, h.GetJsdialogHandler()) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestJSDialogTypeMapping(t *testing.T) { | |
| 67 | + | cases := map[purecef.JsdialogType]port.JSDialogType{ | |
| 68 | + | purecef.JsdialogTypeJsdialogtypeAlert: port.JSDialogAlert, | |
| 69 | + | purecef.JsdialogTypeJsdialogtypeConfirm: port.JSDialogConfirm, | |
| 70 | + | purecef.JsdialogTypeJsdialogtypePrompt: port.JSDialogPrompt, | |
| 71 | + | } | |
| 72 | + | for in, want := range cases { | |
| 73 | + | got, ok := jsDialogTypeFromCEF(in) | |
| 74 | + | require.True(t, ok) | |
| 75 | + | require.Equal(t, want, got) | |
| 76 | + | } | |
| 77 | + | _, ok := jsDialogTypeFromCEF(purecef.JsdialogTypeJsdialogtypeNumValues) | |
| 78 | + | require.False(t, ok) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestOnJsdialogForwardsRequestAndRespondsOnce(t *testing.T) { | |
| 82 | + | useDirectJSDialogContinue(t) | |
| 83 | + | ui := &jsDialogUIRecorder{handled: true} | |
| 84 | + | h := &handlerSet{wv: newJSDialogWebView(ui)} | |
| 85 | + | cb := &stubJSDialogCallback{} | |
| 86 | + | var suppress int32 | |
| 87 | + | ||
| 88 | + | handled := h.OnJsdialog(nil, "https://example.com/p", purecef.JsdialogTypeJsdialogtypePrompt, "Name?", "bob", cb, &suppress) | |
| 89 | + | ||
| 90 | + | require.Equal(t, int32(1), handled) | |
| 91 | + | require.Zero(t, suppress) | |
| 92 | + | require.Equal(t, []port.JSDialogRequest{{ | |
| 93 | + | Type: port.JSDialogPrompt, Origin: "https://example.com/p", Message: "Name?", DefaultPrompt: "bob", | |
| 94 | + | }}, ui.reqs) | |
| 95 | + | require.Empty(t, cb.calls) | |
| 96 | + | ||
| 97 | + | ui.respond[0](true, "alice") | |
| 98 | + | ui.respond[0](false, "late") // ignored: exactly once | |
| 99 | + | require.Equal(t, []jsDialogAnswer{{ok: true, input: "alice"}}, cb.calls) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestOnJsdialogDropsInputForNonPrompt(t *testing.T) { | |
| 103 | + | useDirectJSDialogContinue(t) | |
| 104 | + | ui := &jsDialogUIRecorder{handled: true} | |
| 105 | + | h := &handlerSet{wv: newJSDialogWebView(ui)} | |
| 106 | + | cb := &stubJSDialogCallback{} | |
| 107 | + | ||
| 108 | + | h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeConfirm, "ok?", "", cb, new(int32)) | |
| 109 | + | ui.respond[0](true, "ignored") | |
| 110 | + | ||
| 111 | + | require.Equal(t, []jsDialogAnswer{{ok: true}}, cb.calls) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestOnJsdialogWithoutUICallbackUsesDefault(t *testing.T) { | |
| 115 | + | cb := &stubJSDialogCallback{} | |
| 116 | + | var suppress int32 | |
| 117 | + | ||
| 118 | + | for _, h := range []*handlerSet{ | |
| 119 | + | nil, | |
| 120 | + | {}, | |
| 121 | + | {wv: &WebView{ctx: context.Background()}}, | |
| 122 | + | {wv: &WebView{ctx: context.Background(), callbacks: &port.WebViewCallbacks{}}}, | |
| 123 | + | } { | |
| 124 | + | require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, &suppress)) | |
| 125 | + | require.False(t, h.OnBeforeUnloadDialog(nil, "m", 0, cb)) | |
| 126 | + | } | |
| 127 | + | require.Zero(t, suppress) | |
| 128 | + | require.Empty(t, cb.calls) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func TestOnJsdialogRejectsNilCallbackAndUnknownType(t *testing.T) { | |
| 132 | + | ui := &jsDialogUIRecorder{handled: true} | |
| 133 | + | h := &handlerSet{wv: newJSDialogWebView(ui)} | |
| 134 | + | ||
| 135 | + | require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", nil, new(int32))) | |
| 136 | + | require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeNumValues, "m", "", &stubJSDialogCallback{}, new(int32))) | |
| 137 | + | require.Empty(t, ui.reqs) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestOnJsdialogUINotHandledCancels(t *testing.T) { | |
| 141 | + | useDirectJSDialogContinue(t) | |
| 142 | + | ui := &jsDialogUIRecorder{handled: false} | |
| 143 | + | h := &handlerSet{wv: newJSDialogWebView(ui)} | |
| 144 | + | cb := &stubJSDialogCallback{} | |
| 145 | + | ||
| 146 | + | require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeConfirm, "m", "", cb, new(int32))) | |
| 147 | + | require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestOnJsdialogSuppressesWhileDialogOpen(t *testing.T) { | |
| 151 | + | useDirectJSDialogContinue(t) | |
| 152 | + | ui := &jsDialogUIRecorder{handled: true} | |
| 153 | + | h := &handlerSet{wv: newJSDialogWebView(ui)} | |
| 154 | + | first := &stubJSDialogCallback{} | |
| 155 | + | second := &stubJSDialogCallback{} | |
| 156 | + | var suppress int32 | |
| 157 | + | ||
| 158 | + | require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))) | |
| 159 | + | require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", second, &suppress)) | |
| 160 | + | require.Equal(t, int32(1), suppress) | |
| 161 | + | require.Len(t, ui.reqs, 1) | |
| 162 | + | require.Empty(t, second.calls) | |
| 163 | + | ||
| 164 | + | // Once answered, new dialogs are allowed again. | |
| 165 | + | ui.respond[0](true, "") | |
| 166 | + | require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "3", "", second, new(int32))) | |
| 167 | + | require.Len(t, ui.reqs, 2) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestOnBeforeUnloadDialog(t *testing.T) { | |
| 171 | + | useDirectJSDialogContinue(t) | |
| 172 | + | ui := &jsDialogUIRecorder{handled: true} | |
| 173 | + | wv := newJSDialogWebView(ui) | |
| 174 | + | wv.uri = "https://example.com/edit" | |
| 175 | + | h := &handlerSet{wv: wv} | |
| 176 | + | cb := &stubJSDialogCallback{} | |
| 177 | + | ||
| 178 | + | require.True(t, h.OnBeforeUnloadDialog(nil, "unsaved", 1, cb)) | |
| 179 | + | require.Equal(t, []port.JSDialogRequest{{ | |
| 180 | + | Type: port.JSDialogBeforeUnload, Origin: "https://example.com/edit", Message: "unsaved", IsReload: true, | |
| 181 | + | }}, ui.reqs) | |
| 182 | + | ||
| 183 | + | // A second beforeunload while open is answered "stay" immediately. | |
| 184 | + | other := &stubJSDialogCallback{} | |
| 185 | + | require.True(t, h.OnBeforeUnloadDialog(nil, "again", 0, other)) | |
| 186 | + | require.Equal(t, []jsDialogAnswer{{ok: false}}, other.calls) | |
| 187 | + | require.Len(t, ui.reqs, 1) | |
| 188 | + | ||
| 189 | + | ui.respond[0](true, "") | |
| 190 | + | require.Equal(t, []jsDialogAnswer{{ok: true}}, cb.calls) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func TestOnResetDialogStateResolvesPendingAndClosesUI(t *testing.T) { | |
| 194 | + | useDirectJSDialogContinue(t) | |
| 195 | + | ui := &jsDialogUIRecorder{handled: true} | |
| 196 | + | h := &handlerSet{wv: newJSDialogWebView(ui)} | |
| 197 | + | cb := &stubJSDialogCallback{} | |
| 198 | + | h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypePrompt, "m", "d", cb, new(int32)) | |
| 199 | + | ||
| 200 | + | h.OnResetDialogState(nil) | |
| 201 | + | ||
| 202 | + | require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls) | |
| 203 | + | require.Equal(t, 1, ui.resets) | |
| 204 | + | ||
| 205 | + | // A late UI answer after reset must not resolve twice. | |
| 206 | + | ui.respond[0](true, "late") | |
| 207 | + | require.Len(t, cb.calls, 1) | |
| 208 | + | ||
| 209 | + | // Reset with nothing pending is a no-op and does not touch the UI. | |
| 210 | + | h.OnResetDialogState(nil) | |
| 211 | + | require.Equal(t, 1, ui.resets) | |
| 212 | + | h.OnDialogClosed(nil) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func TestDestroyCancelsPendingJSDialog(t *testing.T) { | |
| 216 | + | useDirectJSDialogContinue(t) | |
| 217 | + | ui := &jsDialogUIRecorder{handled: true} | |
| 218 | + | wv := newJSDialogWebView(ui) | |
| 219 | + | h := &handlerSet{wv: wv} | |
| 220 | + | cb := &stubJSDialogCallback{} | |
| 221 | + | h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, new(int32)) | |
| 222 | + | ||
| 223 | + | wv.cancelJSDialogs() | |
| 224 | + | ||
| 225 | + | require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls) | |
| 226 | + | require.Equal(t, 1, ui.resets) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func TestJSDialogCancelledBeforeUIRunsIsNotShown(t *testing.T) { | |
| 230 | + | useDirectJSDialogContinue(t) | |
| 231 | + | ui := &jsDialogUIRecorder{handled: true} | |
| 232 | + | wv := newJSDialogWebView(ui) | |
| 233 | + | cb := &stubJSDialogCallback{} | |
| 234 | + | call, ok := wv.jsDialogs.begin(cb) | |
| 235 | + | require.True(t, ok) | |
| 236 | + | wv.jsDialogs.resolve(call, false, "") | |
| 237 | + | ||
| 238 | + | wv.presentJSDialog(call, port.JSDialogRequest{}, wv.callbacks) | |
| 239 | + | ||
| 240 | + | require.Empty(t, ui.reqs) | |
| 241 | + | require.Len(t, cb.calls, 1) | |
| 242 | + | } | |
| @@ -0,0 +1,242 @@ | |||||
| 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 | + | } | |||
| 16 | + | ||||
| 17 | + | type jsDialogAnswer struct { | |||
| 18 | + | ok bool | |||
| 19 | + | input string | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (s *stubJSDialogCallback) Cont(success int32, userInput string) { | |||
| 23 | + | s.calls = append(s.calls, jsDialogAnswer{ok: success != 0, input: userInput}) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // useDirectJSDialogContinue answers stub callbacks inline (no CEF UI thread). | |||
| 27 | + | func useDirectJSDialogContinue(t *testing.T) { | |||
| 28 | + | t.Helper() | |||
| 29 | + | prev := continueCEFJSDialog | |||
| 30 | + | continueCEFJSDialog = func(cb purecef.JsdialogCallback, ok bool, input string) { | |||
| 31 | + | success := int32(0) | |||
| 32 | + | if ok { | |||
| 33 | + | success = 1 | |||
| 34 | + | } | |||
| 35 | + | cb.Cont(success, input) | |||
| 36 | + | } | |||
| 37 | + | t.Cleanup(func() { continueCEFJSDialog = prev }) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | type jsDialogUIRecorder struct { | |||
| 41 | + | reqs []port.JSDialogRequest | |||
| 42 | + | respond []func(bool, string) | |||
| 43 | + | resets int | |||
| 44 | + | handled bool | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func newJSDialogWebView(ui *jsDialogUIRecorder) *WebView { | |||
| 48 | + | return &WebView{ | |||
| 49 | + | ctx: context.Background(), | |||
| 50 | + | callbacks: &port.WebViewCallbacks{ | |||
| 51 | + | OnJSDialog: func(req port.JSDialogRequest, respond func(bool, string)) bool { | |||
| 52 | + | ui.reqs = append(ui.reqs, req) | |||
| 53 | + | ui.respond = append(ui.respond, respond) | |||
| 54 | + | return ui.handled | |||
| 55 | + | }, | |||
| 56 | + | OnJSDialogReset: func() { ui.resets++ }, | |||
| 57 | + | }, | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestGetJsdialogHandlerEnabled(t *testing.T) { | |||
| 62 | + | h := &handlerSet{} | |||
| 63 | + | require.Same(t, h, h.GetJsdialogHandler()) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestJSDialogTypeMapping(t *testing.T) { | |||
| 67 | + | cases := map[purecef.JsdialogType]port.JSDialogType{ | |||
| 68 | + | purecef.JsdialogTypeJsdialogtypeAlert: port.JSDialogAlert, | |||
| 69 | + | purecef.JsdialogTypeJsdialogtypeConfirm: port.JSDialogConfirm, | |||
| 70 | + | purecef.JsdialogTypeJsdialogtypePrompt: port.JSDialogPrompt, | |||
| 71 | + | } | |||
| 72 | + | for in, want := range cases { | |||
| 73 | + | got, ok := jsDialogTypeFromCEF(in) | |||
| 74 | + | require.True(t, ok) | |||
| 75 | + | require.Equal(t, want, got) | |||
| 76 | + | } | |||
| 77 | + | _, ok := jsDialogTypeFromCEF(purecef.JsdialogTypeJsdialogtypeNumValues) | |||
| 78 | + | require.False(t, ok) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestOnJsdialogForwardsRequestAndRespondsOnce(t *testing.T) { | |||
| 82 | + | useDirectJSDialogContinue(t) | |||
| 83 | + | ui := &jsDialogUIRecorder{handled: true} | |||
| 84 | + | h := &handlerSet{wv: newJSDialogWebView(ui)} | |||
| 85 | + | cb := &stubJSDialogCallback{} | |||
| 86 | + | var suppress int32 | |||
| 87 | + | ||||
| 88 | + | handled := h.OnJsdialog(nil, "https://example.com/p", purecef.JsdialogTypeJsdialogtypePrompt, "Name?", "bob", cb, &suppress) | |||
| 89 | + | ||||
| 90 | + | require.Equal(t, int32(1), handled) | |||
| 91 | + | require.Zero(t, suppress) | |||
| 92 | + | require.Equal(t, []port.JSDialogRequest{{ | |||
| 93 | + | Type: port.JSDialogPrompt, Origin: "https://example.com/p", Message: "Name?", DefaultPrompt: "bob", | |||
| 94 | + | }}, ui.reqs) | |||
| 95 | + | require.Empty(t, cb.calls) | |||
| 96 | + | ||||
| 97 | + | ui.respond[0](true, "alice") | |||
| 98 | + | ui.respond[0](false, "late") // ignored: exactly once | |||
| 99 | + | require.Equal(t, []jsDialogAnswer{{ok: true, input: "alice"}}, cb.calls) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func TestOnJsdialogDropsInputForNonPrompt(t *testing.T) { | |||
| 103 | + | useDirectJSDialogContinue(t) | |||
| 104 | + | ui := &jsDialogUIRecorder{handled: true} | |||
| 105 | + | h := &handlerSet{wv: newJSDialogWebView(ui)} | |||
| 106 | + | cb := &stubJSDialogCallback{} | |||
| 107 | + | ||||
| 108 | + | h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeConfirm, "ok?", "", cb, new(int32)) | |||
| 109 | + | ui.respond[0](true, "ignored") | |||
| 110 | + | ||||
| 111 | + | require.Equal(t, []jsDialogAnswer{{ok: true}}, cb.calls) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestOnJsdialogWithoutUICallbackUsesDefault(t *testing.T) { | |||
| 115 | + | cb := &stubJSDialogCallback{} | |||
| 116 | + | var suppress int32 | |||
| 117 | + | ||||
| 118 | + | for _, h := range []*handlerSet{ | |||
| 119 | + | nil, | |||
| 120 | + | {}, | |||
| 121 | + | {wv: &WebView{ctx: context.Background()}}, | |||
| 122 | + | {wv: &WebView{ctx: context.Background(), callbacks: &port.WebViewCallbacks{}}}, | |||
| 123 | + | } { | |||
| 124 | + | require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, &suppress)) | |||
| 125 | + | require.False(t, h.OnBeforeUnloadDialog(nil, "m", 0, cb)) | |||
| 126 | + | } | |||
| 127 | + | require.Zero(t, suppress) | |||
| 128 | + | require.Empty(t, cb.calls) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func TestOnJsdialogRejectsNilCallbackAndUnknownType(t *testing.T) { | |||
| 132 | + | ui := &jsDialogUIRecorder{handled: true} | |||
| 133 | + | h := &handlerSet{wv: newJSDialogWebView(ui)} | |||
| 134 | + | ||||
| 135 | + | require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", nil, new(int32))) | |||
| 136 | + | require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeNumValues, "m", "", &stubJSDialogCallback{}, new(int32))) | |||
| 137 | + | require.Empty(t, ui.reqs) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func TestOnJsdialogUINotHandledCancels(t *testing.T) { | |||
| 141 | + | useDirectJSDialogContinue(t) | |||
| 142 | + | ui := &jsDialogUIRecorder{handled: false} | |||
| 143 | + | h := &handlerSet{wv: newJSDialogWebView(ui)} | |||
| 144 | + | cb := &stubJSDialogCallback{} | |||
| 145 | + | ||||
| 146 | + | require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeConfirm, "m", "", cb, new(int32))) | |||
| 147 | + | require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls) | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func TestOnJsdialogSuppressesWhileDialogOpen(t *testing.T) { | |||
| 151 | + | useDirectJSDialogContinue(t) | |||
| 152 | + | ui := &jsDialogUIRecorder{handled: true} | |||
| 153 | + | h := &handlerSet{wv: newJSDialogWebView(ui)} | |||
| 154 | + | first := &stubJSDialogCallback{} | |||
| 155 | + | second := &stubJSDialogCallback{} | |||
| 156 | + | var suppress int32 | |||
| 157 | + | ||||
| 158 | + | require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))) | |||
| 159 | + | require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", second, &suppress)) | |||
| 160 | + | require.Equal(t, int32(1), suppress) | |||
| 161 | + | require.Len(t, ui.reqs, 1) | |||
| 162 | + | require.Empty(t, second.calls) | |||
| 163 | + | ||||
| 164 | + | // Once answered, new dialogs are allowed again. | |||
| 165 | + | ui.respond[0](true, "") | |||
| 166 | + | require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "3", "", second, new(int32))) | |||
| 167 | + | require.Len(t, ui.reqs, 2) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func TestOnBeforeUnloadDialog(t *testing.T) { | |||
| 171 | + | useDirectJSDialogContinue(t) | |||
| 172 | + | ui := &jsDialogUIRecorder{handled: true} | |||
| 173 | + | wv := newJSDialogWebView(ui) | |||
| 174 | + | wv.uri = "https://example.com/edit" | |||
| 175 | + | h := &handlerSet{wv: wv} | |||
| 176 | + | cb := &stubJSDialogCallback{} | |||
| 177 | + | ||||
| 178 | + | require.True(t, h.OnBeforeUnloadDialog(nil, "unsaved", 1, cb)) | |||
| 179 | + | require.Equal(t, []port.JSDialogRequest{{ | |||
| 180 | + | Type: port.JSDialogBeforeUnload, Origin: "https://example.com/edit", Message: "unsaved", IsReload: true, | |||
| 181 | + | }}, ui.reqs) | |||
| 182 | + | ||||
| 183 | + | // A second beforeunload while open is answered "stay" immediately. | |||
| 184 | + | other := &stubJSDialogCallback{} | |||
| 185 | + | require.True(t, h.OnBeforeUnloadDialog(nil, "again", 0, other)) | |||
| 186 | + | require.Equal(t, []jsDialogAnswer{{ok: false}}, other.calls) | |||
| 187 | + | require.Len(t, ui.reqs, 1) | |||
| 188 | + | ||||
| 189 | + | ui.respond[0](true, "") | |||
| 190 | + | require.Equal(t, []jsDialogAnswer{{ok: true}}, cb.calls) | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func TestOnResetDialogStateResolvesPendingAndClosesUI(t *testing.T) { | |||
| 194 | + | useDirectJSDialogContinue(t) | |||
| 195 | + | ui := &jsDialogUIRecorder{handled: true} | |||
| 196 | + | h := &handlerSet{wv: newJSDialogWebView(ui)} | |||
| 197 | + | cb := &stubJSDialogCallback{} | |||
| 198 | + | h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypePrompt, "m", "d", cb, new(int32)) | |||
| 199 | + | ||||
| 200 | + | h.OnResetDialogState(nil) | |||
| 201 | + | ||||
| 202 | + | require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls) | |||
| 203 | + | require.Equal(t, 1, ui.resets) | |||
| 204 | + | ||||
| 205 | + | // A late UI answer after reset must not resolve twice. | |||
| 206 | + | ui.respond[0](true, "late") | |||
| 207 | + | require.Len(t, cb.calls, 1) | |||
| 208 | + | ||||
| 209 | + | // Reset with nothing pending is a no-op and does not touch the UI. | |||
| 210 | + | h.OnResetDialogState(nil) | |||
| 211 | + | require.Equal(t, 1, ui.resets) | |||
| 212 | + | h.OnDialogClosed(nil) | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func TestDestroyCancelsPendingJSDialog(t *testing.T) { | |||
| 216 | + | useDirectJSDialogContinue(t) | |||
| 217 | + | ui := &jsDialogUIRecorder{handled: true} | |||
| 218 | + | wv := newJSDialogWebView(ui) | |||
| 219 | + | h := &handlerSet{wv: wv} | |||
| 220 | + | cb := &stubJSDialogCallback{} | |||
| 221 | + | h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, new(int32)) | |||
| 222 | + | ||||
| 223 | + | wv.cancelJSDialogs() | |||
| 224 | + | ||||
| 225 | + | require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls) | |||
| 226 | + | require.Equal(t, 1, ui.resets) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | func TestJSDialogCancelledBeforeUIRunsIsNotShown(t *testing.T) { | |||
| 230 | + | useDirectJSDialogContinue(t) | |||
| 231 | + | ui := &jsDialogUIRecorder{handled: true} | |||
| 232 | + | wv := newJSDialogWebView(ui) | |||
| 233 | + | cb := &stubJSDialogCallback{} | |||
| 234 | + | call, ok := wv.jsDialogs.begin(cb) | |||
| 235 | + | require.True(t, ok) | |||
| 236 | + | wv.jsDialogs.resolve(call, false, "") | |||
| 237 | + | ||||
| 238 | + | wv.presentJSDialog(call, port.JSDialogRequest{}, wv.callbacks) | |||
| 239 | + | ||||
| 240 | + | require.Empty(t, ui.reqs) | |||
| 241 | + | require.Len(t, cb.calls, 1) | |||
| 242 | + | } | |||
M internal/infrastructure/cef/webview.go +2 -0
| @@ -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.cancelJSDialogs() | |
| 1335 | 1337 | wv.shutdownAccessibilityCapture() | |
| 1336 | 1338 | wv.resetPageScrollQueue() | |
| 1337 | 1339 | wv.syntheticPopupMu.Lock() | |
| @@ -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.cancelJSDialogs() | |||
| 1335 | wv.shutdownAccessibilityCapture() | 1337 | wv.shutdownAccessibilityCapture() | ||
| 1336 | wv.resetPageScrollQueue() | 1338 | wv.resetPageScrollQueue() | ||
| 1337 | wv.syntheticPopupMu.Lock() | 1339 | wv.syntheticPopupMu.Lock() | ||
M internal/infrastructure/cef/webview_handlers.go +1 -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 } | |
| @@ -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 } | ||
M internal/ui/app.go +16 -0
| @@ -3424,6 +3424,9 @@ 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 | + | ||
| 3427 | 3430 | // Hide loading skeleton once the WebView paints | |
| 3428 | 3431 | a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) { | |
| 3429 | 3432 | // The WebView can be shown while its pane is in a background tab, so scan | |
| @@ -3589,6 +3592,19 @@ func (a *App) wireKeyboardActions() { | |||
| 3589 | 3592 | }) | |
| 3590 | 3593 | } | |
| 3591 | 3594 | ||
| 3595 | + | // paneViewForPane finds a pane's view in any tab (including background tabs). | |
| 3596 | + | func (a *App) paneViewForPane(paneID entity.PaneID) *component.PaneView { | |
| 3597 | + | for _, wsView := range a.workspaceViews { | |
| 3598 | + | if wsView == nil { | |
| 3599 | + | continue | |
| 3600 | + | } | |
| 3601 | + | if pv := wsView.GetPaneView(paneID); pv != nil { | |
| 3602 | + | return pv | |
| 3603 | + | } | |
| 3604 | + | } | |
| 3605 | + | return nil | |
| 3606 | + | } | |
| 3607 | + | ||
| 3592 | 3608 | func (a *App) wireWebRTCPermissionIndicator() { | |
| 3593 | 3609 | if a.contentCoord == nil { | |
| 3594 | 3610 | return | |
| @@ -3424,6 +3424,9 @@ 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 | + | ||||
| 3427 | // Hide loading skeleton once the WebView paints | 3430 | // Hide loading skeleton once the WebView paints | ||
| 3428 | a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) { | 3431 | a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) { | ||
| 3429 | // The WebView can be shown while its pane is in a background tab, so scan | 3432 | // The WebView can be shown while its pane is in a background tab, so scan | ||
| @@ -3589,6 +3592,19 @@ func (a *App) wireKeyboardActions() { | |||||
| 3589 | }) | 3592 | }) | ||
| 3590 | } | 3593 | } | ||
| 3591 | 3594 | ||||
| 3595 | + | // paneViewForPane finds a pane's view in any tab (including background tabs). | |||
| 3596 | + | func (a *App) paneViewForPane(paneID entity.PaneID) *component.PaneView { | |||
| 3597 | + | for _, wsView := range a.workspaceViews { | |||
| 3598 | + | if wsView == nil { | |||
| 3599 | + | continue | |||
| 3600 | + | } | |||
| 3601 | + | if pv := wsView.GetPaneView(paneID); pv != nil { | |||
| 3602 | + | return pv | |||
| 3603 | + | } | |||
| 3604 | + | } | |||
| 3605 | + | return nil | |||
| 3606 | + | } | |||
| 3607 | + | ||||
| 3592 | func (a *App) wireWebRTCPermissionIndicator() { | 3608 | func (a *App) wireWebRTCPermissionIndicator() { | ||
| 3593 | if a.contentCoord == nil { | 3609 | if a.contentCoord == nil { | ||
| 3594 | return | 3610 | return | ||
A internal/ui/component/jsdialog_popup.go +354 -0
| @@ -0,0 +1,354 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net/url" | |
| 5 | + | "strings" | |
| 6 | + | "sync" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 9 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 10 | + | "github.com/bnema/puregotk/v4/pango" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | jsDialogMaxBodyHeight = 240 | |
| 17 | + | jsDialogMaxWidthChars = 60 | |
| 18 | + | jsDialogMaxHostChars = 80 | |
| 19 | + | jsDialogDialogWidth = 420 | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // jsDialogContent is the plain-text content of a JS dialog. | |
| 23 | + | type jsDialogContent struct { | |
| 24 | + | Heading string | |
| 25 | + | Body string | |
| 26 | + | OKLabel string | |
| 27 | + | CancelLabel string // empty: no cancel button (alert) | |
| 28 | + | ShowInput bool | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // jsDialogOriginLabel returns a short, user-facing origin ("example.com") for | |
| 32 | + | // the requesting URL. The origin is shown separately from the page-controlled | |
| 33 | + | // message so a page cannot impersonate browser UI. | |
| 34 | + | func jsDialogOriginLabel(origin string) string { | |
| 35 | + | origin = strings.TrimSpace(origin) | |
| 36 | + | if origin == "" { | |
| 37 | + | return "This page" | |
| 38 | + | } | |
| 39 | + | u, err := url.Parse(origin) | |
| 40 | + | if err != nil || u.Host == "" { | |
| 41 | + | if err == nil && u.Scheme != "" { | |
| 42 | + | return truncateRunes(u.Scheme+":", jsDialogMaxHostChars) | |
| 43 | + | } | |
| 44 | + | return "This page" | |
| 45 | + | } | |
| 46 | + | return truncateRunes(u.Host, jsDialogMaxHostChars) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func truncateRunes(s string, n int) string { | |
| 50 | + | r := []rune(s) | |
| 51 | + | if len(r) <= n { | |
| 52 | + | return s | |
| 53 | + | } | |
| 54 | + | return string(r[:n]) + "…" | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // buildJSDialogContent maps a request to the dialog texts and buttons. | |
| 58 | + | // All strings are plain text (never Pango markup). | |
| 59 | + | func buildJSDialogContent(req port.JSDialogRequest) jsDialogContent { | |
| 60 | + | says := jsDialogOriginLabel(req.Origin) + " says:" | |
| 61 | + | switch req.Type { | |
| 62 | + | case port.JSDialogConfirm: | |
| 63 | + | return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK", CancelLabel: "Cancel"} | |
| 64 | + | case port.JSDialogPrompt: | |
| 65 | + | return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK", CancelLabel: "Cancel", ShowInput: true} | |
| 66 | + | case port.JSDialogBeforeUnload: | |
| 67 | + | body := strings.TrimSpace(req.Message) | |
| 68 | + | if body == "" { | |
| 69 | + | body = "Changes you made may not be saved." | |
| 70 | + | } | |
| 71 | + | heading := "Leave page?" | |
| 72 | + | if req.IsReload { | |
| 73 | + | heading = "Reload page?" | |
| 74 | + | } | |
| 75 | + | return jsDialogContent{ | |
| 76 | + | Heading: heading, | |
| 77 | + | Body: jsDialogOriginLabel(req.Origin) + "\n" + body, | |
| 78 | + | OKLabel: "Leave", | |
| 79 | + | CancelLabel: "Stay", | |
| 80 | + | } | |
| 81 | + | default: | |
| 82 | + | return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK"} | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // JSDialogPopup is a per-pane overlay that renders alert/confirm/prompt and | |
| 87 | + | // beforeunload dialogs. It is owned by a PaneView, so a dialog belongs to its | |
| 88 | + | // pane's WebView and never blocks other panes. It replaces Adwaita AlertDialog | |
| 89 | + | // for the same reason as PermissionPopup (purego ConnectResponse bug). | |
| 90 | + | type JSDialogPopup struct { | |
| 91 | + | outerBox *gtk.Box | |
| 92 | + | mainBox *gtk.Box | |
| 93 | + | ||
| 94 | + | headingLabel *gtk.Label | |
| 95 | + | bodyLabel *gtk.Label | |
| 96 | + | input *gtk.Entry | |
| 97 | + | btnOK *gtk.Button | |
| 98 | + | btnCancel *gtk.Button | |
| 99 | + | ||
| 100 | + | mu sync.Mutex | |
| 101 | + | visible bool | |
| 102 | + | respond func(ok bool, input string) | |
| 103 | + | onHidden func() | |
| 104 | + | ||
| 105 | + | retainedCallbacks []any | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // NewJSDialogPopup creates the popup (hidden). Returns nil if widget creation fails. | |
| 109 | + | func NewJSDialogPopup() *JSDialogPopup { | |
| 110 | + | p := &JSDialogPopup{} | |
| 111 | + | if err := p.createWidgets(); err != nil { | |
| 112 | + | return nil | |
| 113 | + | } | |
| 114 | + | p.attachKeyController() | |
| 115 | + | return p | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // Widget returns the outer GTK widget for overlay registration. | |
| 119 | + | func (p *JSDialogPopup) Widget() *gtk.Widget { | |
| 120 | + | if p == nil || p.outerBox == nil { | |
| 121 | + | return nil | |
| 122 | + | } | |
| 123 | + | return &p.outerBox.Widget | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // SetOnHidden sets a callback run after the popup closes (e.g. to restore focus). | |
| 127 | + | func (p *JSDialogPopup) SetOnHidden(fn func()) { | |
| 128 | + | p.mu.Lock() | |
| 129 | + | p.onHidden = fn | |
| 130 | + | p.mu.Unlock() | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // Show displays the dialog. respond is invoked once with the user's answer. | |
| 134 | + | // A dialog already visible is dismissed as canceled first. | |
| 135 | + | func (p *JSDialogPopup) Show(req port.JSDialogRequest, respond func(ok bool, input string)) { | |
| 136 | + | p.dismiss(false) | |
| 137 | + | ||
| 138 | + | content := buildJSDialogContent(req) | |
| 139 | + | ||
| 140 | + | p.mu.Lock() | |
| 141 | + | p.visible = true | |
| 142 | + | p.respond = respond | |
| 143 | + | p.mu.Unlock() | |
| 144 | + | ||
| 145 | + | p.headingLabel.SetText(content.Heading) | |
| 146 | + | p.bodyLabel.SetText(content.Body) | |
| 147 | + | p.bodyLabel.SetVisible(content.Body != "") | |
| 148 | + | p.btnOK.SetLabel(content.OKLabel) | |
| 149 | + | p.btnCancel.SetVisible(content.CancelLabel != "") | |
| 150 | + | if content.CancelLabel != "" { | |
| 151 | + | p.btnCancel.SetLabel(content.CancelLabel) | |
| 152 | + | } | |
| 153 | + | p.input.SetVisible(content.ShowInput) | |
| 154 | + | if content.ShowInput { | |
| 155 | + | p.input.SetText(req.DefaultPrompt) | |
| 156 | + | } else { | |
| 157 | + | p.input.SetText("") | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | p.outerBox.SetVisible(true) | |
| 161 | + | if content.ShowInput { | |
| 162 | + | p.input.GrabFocus() | |
| 163 | + | p.input.SelectRegion(0, -1) | |
| 164 | + | } else { | |
| 165 | + | p.btnOK.GrabFocus() | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // Hide closes the popup without invoking the response callback. It is used when | |
| 170 | + | // the engine canceled the dialog (navigation, WebView destruction). | |
| 171 | + | func (p *JSDialogPopup) Hide() { | |
| 172 | + | if p == nil { | |
| 173 | + | return | |
| 174 | + | } | |
| 175 | + | p.mu.Lock() | |
| 176 | + | wasVisible := p.visible | |
| 177 | + | p.visible = false | |
| 178 | + | p.respond = nil | |
| 179 | + | hidden := p.onHidden | |
| 180 | + | p.mu.Unlock() | |
| 181 | + | if !wasVisible { | |
| 182 | + | return | |
| 183 | + | } | |
| 184 | + | p.outerBox.SetVisible(false) | |
| 185 | + | if hidden != nil { | |
| 186 | + | hidden() | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // IsVisible reports whether a dialog is displayed. | |
| 191 | + | func (p *JSDialogPopup) IsVisible() bool { | |
| 192 | + | p.mu.Lock() | |
| 193 | + | defer p.mu.Unlock() | |
| 194 | + | return p.visible | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // dismiss closes the popup and answers. Safe to call when hidden (no-op). | |
| 198 | + | func (p *JSDialogPopup) dismiss(ok bool) { | |
| 199 | + | p.mu.Lock() | |
| 200 | + | if !p.visible { | |
| 201 | + | p.mu.Unlock() | |
| 202 | + | return | |
| 203 | + | } | |
| 204 | + | p.visible = false | |
| 205 | + | respond := p.respond | |
| 206 | + | p.respond = nil | |
| 207 | + | hidden := p.onHidden | |
| 208 | + | p.mu.Unlock() | |
| 209 | + | ||
| 210 | + | text := "" | |
| 211 | + | if ok && p.input.GetVisible() { | |
| 212 | + | text = p.input.GetText() | |
| 213 | + | } | |
| 214 | + | p.outerBox.SetVisible(false) | |
| 215 | + | if respond != nil { | |
| 216 | + | respond(ok, text) | |
| 217 | + | } | |
| 218 | + | if hidden != nil { | |
| 219 | + | hidden() | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func (p *JSDialogPopup) createWidgets() error { | |
| 224 | + | if err := p.createContainers(); err != nil { | |
| 225 | + | return err | |
| 226 | + | } | |
| 227 | + | if err := p.createLabels(); err != nil { | |
| 228 | + | return err | |
| 229 | + | } | |
| 230 | + | scroll := gtk.NewScrolledWindow() | |
| 231 | + | if scroll == nil { | |
| 232 | + | return errNilWidget("jsDialogScroll") | |
| 233 | + | } | |
| 234 | + | scroll.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | |
| 235 | + | scroll.SetPropagateNaturalHeight(true) | |
| 236 | + | scroll.SetMaxContentHeight(jsDialogMaxBodyHeight) | |
| 237 | + | scroll.SetChild(&p.bodyLabel.Widget) | |
| 238 | + | ||
| 239 | + | p.input = gtk.NewEntry() | |
| 240 | + | if p.input == nil { | |
| 241 | + | return errNilWidget("jsDialogEntry") | |
| 242 | + | } | |
| 243 | + | p.input.AddCssClass("jsdialog-entry") | |
| 244 | + | p.input.SetVisible(false) | |
| 245 | + | ||
| 246 | + | btnRow, err := p.createButtonRow() | |
| 247 | + | if err != nil { | |
| 248 | + | return err | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | p.mainBox.Append(&p.headingLabel.Widget) | |
| 252 | + | p.mainBox.Append(&scroll.Widget) | |
| 253 | + | p.mainBox.Append(&p.input.Widget) | |
| 254 | + | p.mainBox.Append(&btnRow.Widget) | |
| 255 | + | p.outerBox.Append(&p.mainBox.Widget) | |
| 256 | + | return nil | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func (p *JSDialogPopup) createContainers() error { | |
| 260 | + | p.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 261 | + | if p.outerBox == nil { | |
| 262 | + | return errNilWidget("jsDialogOuterBox") | |
| 263 | + | } | |
| 264 | + | // Fill the pane so the scrim swallows pointer input meant for the page. | |
| 265 | + | p.outerBox.AddCssClass("jsdialog-scrim") | |
| 266 | + | p.outerBox.SetHexpand(true) | |
| 267 | + | p.outerBox.SetVexpand(true) | |
| 268 | + | p.outerBox.SetHalign(gtk.AlignFillValue) | |
| 269 | + | p.outerBox.SetValign(gtk.AlignFillValue) | |
| 270 | + | p.outerBox.SetVisible(false) | |
| 271 | + | ||
| 272 | + | p.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 273 | + | if p.mainBox == nil { | |
| 274 | + | return errNilWidget("jsDialogMainBox") | |
| 275 | + | } | |
| 276 | + | p.mainBox.AddCssClass("permission-popup-container") | |
| 277 | + | p.mainBox.SetHalign(gtk.AlignCenterValue) | |
| 278 | + | p.mainBox.SetValign(gtk.AlignCenterValue) | |
| 279 | + | p.mainBox.SetSizeRequest(jsDialogDialogWidth, -1) | |
| 280 | + | return nil | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func (p *JSDialogPopup) createLabels() error { | |
| 284 | + | empty := "" | |
| 285 | + | p.headingLabel = gtk.NewLabel(&empty) | |
| 286 | + | p.bodyLabel = gtk.NewLabel(&empty) | |
| 287 | + | if p.headingLabel == nil || p.bodyLabel == nil { | |
| 288 | + | return errNilWidget("jsDialogLabels") | |
| 289 | + | } | |
| 290 | + | // Labels use SetText only: page text is never parsed as Pango markup. | |
| 291 | + | for _, l := range []*gtk.Label{p.headingLabel, p.bodyLabel} { | |
| 292 | + | l.SetUseMarkup(false) | |
| 293 | + | l.SetHalign(gtk.AlignStartValue) | |
| 294 | + | l.SetXalign(0) | |
| 295 | + | l.SetWrap(true) | |
| 296 | + | l.SetWrapMode(pango.WrapWordCharValue) | |
| 297 | + | l.SetMaxWidthChars(jsDialogMaxWidthChars) | |
| 298 | + | } | |
| 299 | + | p.headingLabel.AddCssClass("permission-popup-heading") | |
| 300 | + | p.bodyLabel.AddCssClass("permission-popup-body") | |
| 301 | + | return nil | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func (p *JSDialogPopup) createButtonRow() (*gtk.Box, error) { | |
| 305 | + | btnRow := gtk.NewBox(gtk.OrientationHorizontalValue, buttonSpacing) | |
| 306 | + | if btnRow == nil { | |
| 307 | + | return nil, errNilWidget("jsDialogBtnRow") | |
| 308 | + | } | |
| 309 | + | btnRow.AddCssClass("permission-popup-btn-row") | |
| 310 | + | btnRow.SetHalign(gtk.AlignEndValue) | |
| 311 | + | ||
| 312 | + | p.btnCancel = gtk.NewButtonWithLabel("Cancel") | |
| 313 | + | p.btnOK = gtk.NewButtonWithLabel("OK") | |
| 314 | + | if p.btnCancel == nil || p.btnOK == nil { | |
| 315 | + | return nil, errNilWidget("jsDialogButtons") | |
| 316 | + | } | |
| 317 | + | p.btnCancel.AddCssClass("permission-popup-btn") | |
| 318 | + | p.btnCancel.AddCssClass("permission-popup-btn-deny") | |
| 319 | + | p.btnOK.AddCssClass("permission-popup-btn") | |
| 320 | + | p.btnOK.AddCssClass("permission-popup-btn-allow") | |
| 321 | + | ||
| 322 | + | cancelCb := func(_ gtk.Button) { p.dismiss(false) } | |
| 323 | + | okCb := func(_ gtk.Button) { p.dismiss(true) } | |
| 324 | + | p.retainedCallbacks = append(p.retainedCallbacks, cancelCb, okCb) | |
| 325 | + | p.btnCancel.ConnectClicked(&cancelCb) | |
| 326 | + | p.btnOK.ConnectClicked(&okCb) | |
| 327 | + | ||
| 328 | + | btnRow.Append(&p.btnCancel.Widget) | |
| 329 | + | btnRow.Append(&p.btnOK.Widget) | |
| 330 | + | return btnRow, nil | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | // attachKeyController maps Enter to OK and Escape to Cancel. | |
| 334 | + | func (p *JSDialogPopup) attachKeyController() { | |
| 335 | + | controller := gtk.NewEventControllerKey() | |
| 336 | + | if controller == nil { | |
| 337 | + | return | |
| 338 | + | } | |
| 339 | + | controller.SetPropagationPhase(gtk.PhaseCaptureValue) | |
| 340 | + | keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool { | |
| 341 | + | switch keyval { | |
| 342 | + | case uint(gdk.KEY_Escape): | |
| 343 | + | p.dismiss(false) | |
| 344 | + | return true | |
| 345 | + | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | |
| 346 | + | p.dismiss(true) | |
| 347 | + | return true | |
| 348 | + | } | |
| 349 | + | return false | |
| 350 | + | } | |
| 351 | + | p.retainedCallbacks = append(p.retainedCallbacks, keyPressedCb) | |
| 352 | + | controller.ConnectKeyPressed(&keyPressedCb) | |
| 353 | + | p.outerBox.AddController(&controller.EventController) | |
| 354 | + | } | |
| @@ -0,0 +1,354 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/url" | |||
| 5 | + | "strings" | |||
| 6 | + | "sync" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/puregotk/v4/gdk" | |||
| 9 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 10 | + | "github.com/bnema/puregotk/v4/pango" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | jsDialogMaxBodyHeight = 240 | |||
| 17 | + | jsDialogMaxWidthChars = 60 | |||
| 18 | + | jsDialogMaxHostChars = 80 | |||
| 19 | + | jsDialogDialogWidth = 420 | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // jsDialogContent is the plain-text content of a JS dialog. | |||
| 23 | + | type jsDialogContent struct { | |||
| 24 | + | Heading string | |||
| 25 | + | Body string | |||
| 26 | + | OKLabel string | |||
| 27 | + | CancelLabel string // empty: no cancel button (alert) | |||
| 28 | + | ShowInput bool | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // jsDialogOriginLabel returns a short, user-facing origin ("example.com") for | |||
| 32 | + | // the requesting URL. The origin is shown separately from the page-controlled | |||
| 33 | + | // message so a page cannot impersonate browser UI. | |||
| 34 | + | func jsDialogOriginLabel(origin string) string { | |||
| 35 | + | origin = strings.TrimSpace(origin) | |||
| 36 | + | if origin == "" { | |||
| 37 | + | return "This page" | |||
| 38 | + | } | |||
| 39 | + | u, err := url.Parse(origin) | |||
| 40 | + | if err != nil || u.Host == "" { | |||
| 41 | + | if err == nil && u.Scheme != "" { | |||
| 42 | + | return truncateRunes(u.Scheme+":", jsDialogMaxHostChars) | |||
| 43 | + | } | |||
| 44 | + | return "This page" | |||
| 45 | + | } | |||
| 46 | + | return truncateRunes(u.Host, jsDialogMaxHostChars) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func truncateRunes(s string, n int) string { | |||
| 50 | + | r := []rune(s) | |||
| 51 | + | if len(r) <= n { | |||
| 52 | + | return s | |||
| 53 | + | } | |||
| 54 | + | return string(r[:n]) + "…" | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // buildJSDialogContent maps a request to the dialog texts and buttons. | |||
| 58 | + | // All strings are plain text (never Pango markup). | |||
| 59 | + | func buildJSDialogContent(req port.JSDialogRequest) jsDialogContent { | |||
| 60 | + | says := jsDialogOriginLabel(req.Origin) + " says:" | |||
| 61 | + | switch req.Type { | |||
| 62 | + | case port.JSDialogConfirm: | |||
| 63 | + | return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK", CancelLabel: "Cancel"} | |||
| 64 | + | case port.JSDialogPrompt: | |||
| 65 | + | return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK", CancelLabel: "Cancel", ShowInput: true} | |||
| 66 | + | case port.JSDialogBeforeUnload: | |||
| 67 | + | body := strings.TrimSpace(req.Message) | |||
| 68 | + | if body == "" { | |||
| 69 | + | body = "Changes you made may not be saved." | |||
| 70 | + | } | |||
| 71 | + | heading := "Leave page?" | |||
| 72 | + | if req.IsReload { | |||
| 73 | + | heading = "Reload page?" | |||
| 74 | + | } | |||
| 75 | + | return jsDialogContent{ | |||
| 76 | + | Heading: heading, | |||
| 77 | + | Body: jsDialogOriginLabel(req.Origin) + "\n" + body, | |||
| 78 | + | OKLabel: "Leave", | |||
| 79 | + | CancelLabel: "Stay", | |||
| 80 | + | } | |||
| 81 | + | default: | |||
| 82 | + | return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK"} | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // JSDialogPopup is a per-pane overlay that renders alert/confirm/prompt and | |||
| 87 | + | // beforeunload dialogs. It is owned by a PaneView, so a dialog belongs to its | |||
| 88 | + | // pane's WebView and never blocks other panes. It replaces Adwaita AlertDialog | |||
| 89 | + | // for the same reason as PermissionPopup (purego ConnectResponse bug). | |||
| 90 | + | type JSDialogPopup struct { | |||
| 91 | + | outerBox *gtk.Box | |||
| 92 | + | mainBox *gtk.Box | |||
| 93 | + | ||||
| 94 | + | headingLabel *gtk.Label | |||
| 95 | + | bodyLabel *gtk.Label | |||
| 96 | + | input *gtk.Entry | |||
| 97 | + | btnOK *gtk.Button | |||
| 98 | + | btnCancel *gtk.Button | |||
| 99 | + | ||||
| 100 | + | mu sync.Mutex | |||
| 101 | + | visible bool | |||
| 102 | + | respond func(ok bool, input string) | |||
| 103 | + | onHidden func() | |||
| 104 | + | ||||
| 105 | + | retainedCallbacks []any | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // NewJSDialogPopup creates the popup (hidden). Returns nil if widget creation fails. | |||
| 109 | + | func NewJSDialogPopup() *JSDialogPopup { | |||
| 110 | + | p := &JSDialogPopup{} | |||
| 111 | + | if err := p.createWidgets(); err != nil { | |||
| 112 | + | return nil | |||
| 113 | + | } | |||
| 114 | + | p.attachKeyController() | |||
| 115 | + | return p | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // Widget returns the outer GTK widget for overlay registration. | |||
| 119 | + | func (p *JSDialogPopup) Widget() *gtk.Widget { | |||
| 120 | + | if p == nil || p.outerBox == nil { | |||
| 121 | + | return nil | |||
| 122 | + | } | |||
| 123 | + | return &p.outerBox.Widget | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // SetOnHidden sets a callback run after the popup closes (e.g. to restore focus). | |||
| 127 | + | func (p *JSDialogPopup) SetOnHidden(fn func()) { | |||
| 128 | + | p.mu.Lock() | |||
| 129 | + | p.onHidden = fn | |||
| 130 | + | p.mu.Unlock() | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // Show displays the dialog. respond is invoked once with the user's answer. | |||
| 134 | + | // A dialog already visible is dismissed as canceled first. | |||
| 135 | + | func (p *JSDialogPopup) Show(req port.JSDialogRequest, respond func(ok bool, input string)) { | |||
| 136 | + | p.dismiss(false) | |||
| 137 | + | ||||
| 138 | + | content := buildJSDialogContent(req) | |||
| 139 | + | ||||
| 140 | + | p.mu.Lock() | |||
| 141 | + | p.visible = true | |||
| 142 | + | p.respond = respond | |||
| 143 | + | p.mu.Unlock() | |||
| 144 | + | ||||
| 145 | + | p.headingLabel.SetText(content.Heading) | |||
| 146 | + | p.bodyLabel.SetText(content.Body) | |||
| 147 | + | p.bodyLabel.SetVisible(content.Body != "") | |||
| 148 | + | p.btnOK.SetLabel(content.OKLabel) | |||
| 149 | + | p.btnCancel.SetVisible(content.CancelLabel != "") | |||
| 150 | + | if content.CancelLabel != "" { | |||
| 151 | + | p.btnCancel.SetLabel(content.CancelLabel) | |||
| 152 | + | } | |||
| 153 | + | p.input.SetVisible(content.ShowInput) | |||
| 154 | + | if content.ShowInput { | |||
| 155 | + | p.input.SetText(req.DefaultPrompt) | |||
| 156 | + | } else { | |||
| 157 | + | p.input.SetText("") | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | p.outerBox.SetVisible(true) | |||
| 161 | + | if content.ShowInput { | |||
| 162 | + | p.input.GrabFocus() | |||
| 163 | + | p.input.SelectRegion(0, -1) | |||
| 164 | + | } else { | |||
| 165 | + | p.btnOK.GrabFocus() | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // Hide closes the popup without invoking the response callback. It is used when | |||
| 170 | + | // the engine canceled the dialog (navigation, WebView destruction). | |||
| 171 | + | func (p *JSDialogPopup) Hide() { | |||
| 172 | + | if p == nil { | |||
| 173 | + | return | |||
| 174 | + | } | |||
| 175 | + | p.mu.Lock() | |||
| 176 | + | wasVisible := p.visible | |||
| 177 | + | p.visible = false | |||
| 178 | + | p.respond = nil | |||
| 179 | + | hidden := p.onHidden | |||
| 180 | + | p.mu.Unlock() | |||
| 181 | + | if !wasVisible { | |||
| 182 | + | return | |||
| 183 | + | } | |||
| 184 | + | p.outerBox.SetVisible(false) | |||
| 185 | + | if hidden != nil { | |||
| 186 | + | hidden() | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // IsVisible reports whether a dialog is displayed. | |||
| 191 | + | func (p *JSDialogPopup) IsVisible() bool { | |||
| 192 | + | p.mu.Lock() | |||
| 193 | + | defer p.mu.Unlock() | |||
| 194 | + | return p.visible | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // dismiss closes the popup and answers. Safe to call when hidden (no-op). | |||
| 198 | + | func (p *JSDialogPopup) dismiss(ok bool) { | |||
| 199 | + | p.mu.Lock() | |||
| 200 | + | if !p.visible { | |||
| 201 | + | p.mu.Unlock() | |||
| 202 | + | return | |||
| 203 | + | } | |||
| 204 | + | p.visible = false | |||
| 205 | + | respond := p.respond | |||
| 206 | + | p.respond = nil | |||
| 207 | + | hidden := p.onHidden | |||
| 208 | + | p.mu.Unlock() | |||
| 209 | + | ||||
| 210 | + | text := "" | |||
| 211 | + | if ok && p.input.GetVisible() { | |||
| 212 | + | text = p.input.GetText() | |||
| 213 | + | } | |||
| 214 | + | p.outerBox.SetVisible(false) | |||
| 215 | + | if respond != nil { | |||
| 216 | + | respond(ok, text) | |||
| 217 | + | } | |||
| 218 | + | if hidden != nil { | |||
| 219 | + | hidden() | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | func (p *JSDialogPopup) createWidgets() error { | |||
| 224 | + | if err := p.createContainers(); err != nil { | |||
| 225 | + | return err | |||
| 226 | + | } | |||
| 227 | + | if err := p.createLabels(); err != nil { | |||
| 228 | + | return err | |||
| 229 | + | } | |||
| 230 | + | scroll := gtk.NewScrolledWindow() | |||
| 231 | + | if scroll == nil { | |||
| 232 | + | return errNilWidget("jsDialogScroll") | |||
| 233 | + | } | |||
| 234 | + | scroll.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | |||
| 235 | + | scroll.SetPropagateNaturalHeight(true) | |||
| 236 | + | scroll.SetMaxContentHeight(jsDialogMaxBodyHeight) | |||
| 237 | + | scroll.SetChild(&p.bodyLabel.Widget) | |||
| 238 | + | ||||
| 239 | + | p.input = gtk.NewEntry() | |||
| 240 | + | if p.input == nil { | |||
| 241 | + | return errNilWidget("jsDialogEntry") | |||
| 242 | + | } | |||
| 243 | + | p.input.AddCssClass("jsdialog-entry") | |||
| 244 | + | p.input.SetVisible(false) | |||
| 245 | + | ||||
| 246 | + | btnRow, err := p.createButtonRow() | |||
| 247 | + | if err != nil { | |||
| 248 | + | return err | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | p.mainBox.Append(&p.headingLabel.Widget) | |||
| 252 | + | p.mainBox.Append(&scroll.Widget) | |||
| 253 | + | p.mainBox.Append(&p.input.Widget) | |||
| 254 | + | p.mainBox.Append(&btnRow.Widget) | |||
| 255 | + | p.outerBox.Append(&p.mainBox.Widget) | |||
| 256 | + | return nil | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | func (p *JSDialogPopup) createContainers() error { | |||
| 260 | + | p.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 261 | + | if p.outerBox == nil { | |||
| 262 | + | return errNilWidget("jsDialogOuterBox") | |||
| 263 | + | } | |||
| 264 | + | // Fill the pane so the scrim swallows pointer input meant for the page. | |||
| 265 | + | p.outerBox.AddCssClass("jsdialog-scrim") | |||
| 266 | + | p.outerBox.SetHexpand(true) | |||
| 267 | + | p.outerBox.SetVexpand(true) | |||
| 268 | + | p.outerBox.SetHalign(gtk.AlignFillValue) | |||
| 269 | + | p.outerBox.SetValign(gtk.AlignFillValue) | |||
| 270 | + | p.outerBox.SetVisible(false) | |||
| 271 | + | ||||
| 272 | + | p.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 273 | + | if p.mainBox == nil { | |||
| 274 | + | return errNilWidget("jsDialogMainBox") | |||
| 275 | + | } | |||
| 276 | + | p.mainBox.AddCssClass("permission-popup-container") | |||
| 277 | + | p.mainBox.SetHalign(gtk.AlignCenterValue) | |||
| 278 | + | p.mainBox.SetValign(gtk.AlignCenterValue) | |||
| 279 | + | p.mainBox.SetSizeRequest(jsDialogDialogWidth, -1) | |||
| 280 | + | return nil | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | func (p *JSDialogPopup) createLabels() error { | |||
| 284 | + | empty := "" | |||
| 285 | + | p.headingLabel = gtk.NewLabel(&empty) | |||
| 286 | + | p.bodyLabel = gtk.NewLabel(&empty) | |||
| 287 | + | if p.headingLabel == nil || p.bodyLabel == nil { | |||
| 288 | + | return errNilWidget("jsDialogLabels") | |||
| 289 | + | } | |||
| 290 | + | // Labels use SetText only: page text is never parsed as Pango markup. | |||
| 291 | + | for _, l := range []*gtk.Label{p.headingLabel, p.bodyLabel} { | |||
| 292 | + | l.SetUseMarkup(false) | |||
| 293 | + | l.SetHalign(gtk.AlignStartValue) | |||
| 294 | + | l.SetXalign(0) | |||
| 295 | + | l.SetWrap(true) | |||
| 296 | + | l.SetWrapMode(pango.WrapWordCharValue) | |||
| 297 | + | l.SetMaxWidthChars(jsDialogMaxWidthChars) | |||
| 298 | + | } | |||
| 299 | + | p.headingLabel.AddCssClass("permission-popup-heading") | |||
| 300 | + | p.bodyLabel.AddCssClass("permission-popup-body") | |||
| 301 | + | return nil | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | func (p *JSDialogPopup) createButtonRow() (*gtk.Box, error) { | |||
| 305 | + | btnRow := gtk.NewBox(gtk.OrientationHorizontalValue, buttonSpacing) | |||
| 306 | + | if btnRow == nil { | |||
| 307 | + | return nil, errNilWidget("jsDialogBtnRow") | |||
| 308 | + | } | |||
| 309 | + | btnRow.AddCssClass("permission-popup-btn-row") | |||
| 310 | + | btnRow.SetHalign(gtk.AlignEndValue) | |||
| 311 | + | ||||
| 312 | + | p.btnCancel = gtk.NewButtonWithLabel("Cancel") | |||
| 313 | + | p.btnOK = gtk.NewButtonWithLabel("OK") | |||
| 314 | + | if p.btnCancel == nil || p.btnOK == nil { | |||
| 315 | + | return nil, errNilWidget("jsDialogButtons") | |||
| 316 | + | } | |||
| 317 | + | p.btnCancel.AddCssClass("permission-popup-btn") | |||
| 318 | + | p.btnCancel.AddCssClass("permission-popup-btn-deny") | |||
| 319 | + | p.btnOK.AddCssClass("permission-popup-btn") | |||
| 320 | + | p.btnOK.AddCssClass("permission-popup-btn-allow") | |||
| 321 | + | ||||
| 322 | + | cancelCb := func(_ gtk.Button) { p.dismiss(false) } | |||
| 323 | + | okCb := func(_ gtk.Button) { p.dismiss(true) } | |||
| 324 | + | p.retainedCallbacks = append(p.retainedCallbacks, cancelCb, okCb) | |||
| 325 | + | p.btnCancel.ConnectClicked(&cancelCb) | |||
| 326 | + | p.btnOK.ConnectClicked(&okCb) | |||
| 327 | + | ||||
| 328 | + | btnRow.Append(&p.btnCancel.Widget) | |||
| 329 | + | btnRow.Append(&p.btnOK.Widget) | |||
| 330 | + | return btnRow, nil | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | // attachKeyController maps Enter to OK and Escape to Cancel. | |||
| 334 | + | func (p *JSDialogPopup) attachKeyController() { | |||
| 335 | + | controller := gtk.NewEventControllerKey() | |||
| 336 | + | if controller == nil { | |||
| 337 | + | return | |||
| 338 | + | } | |||
| 339 | + | controller.SetPropagationPhase(gtk.PhaseCaptureValue) | |||
| 340 | + | keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool { | |||
| 341 | + | switch keyval { | |||
| 342 | + | case uint(gdk.KEY_Escape): | |||
| 343 | + | p.dismiss(false) | |||
| 344 | + | return true | |||
| 345 | + | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | |||
| 346 | + | p.dismiss(true) | |||
| 347 | + | return true | |||
| 348 | + | } | |||
| 349 | + | return false | |||
| 350 | + | } | |||
| 351 | + | p.retainedCallbacks = append(p.retainedCallbacks, keyPressedCb) | |||
| 352 | + | controller.ConnectKeyPressed(&keyPressedCb) | |||
| 353 | + | p.outerBox.AddController(&controller.EventController) | |||
| 354 | + | } | |||
A internal/ui/component/jsdialog_popup_test.go +48 -0
| @@ -0,0 +1,48 @@ | |||
| 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) + ".com/") | |
| 19 | + | assert.LessOrEqual(t, len([]rune(long)), jsDialogMaxHostChars+1) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestBuildJSDialogContent(t *testing.T) { | |
| 23 | + | origin := "https://example.com/x" | |
| 24 | + | ||
| 25 | + | alert := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogAlert, Origin: origin, Message: "<b>hi</b>"}) | |
| 26 | + | assert.Equal(t, "example.com says:", alert.Heading) | |
| 27 | + | assert.Equal(t, "<b>hi</b>", alert.Body, "message must stay plain text") | |
| 28 | + | assert.Equal(t, "OK", alert.OKLabel) | |
| 29 | + | assert.Empty(t, alert.CancelLabel) | |
| 30 | + | assert.False(t, alert.ShowInput) | |
| 31 | + | ||
| 32 | + | confirm := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogConfirm, Origin: origin}) | |
| 33 | + | assert.Equal(t, "Cancel", confirm.CancelLabel) | |
| 34 | + | assert.False(t, confirm.ShowInput) | |
| 35 | + | ||
| 36 | + | prompt := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogPrompt, Origin: origin}) | |
| 37 | + | assert.True(t, prompt.ShowInput) | |
| 38 | + | assert.Equal(t, "Cancel", prompt.CancelLabel) | |
| 39 | + | ||
| 40 | + | leave := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogBeforeUnload, Origin: origin}) | |
| 41 | + | assert.Equal(t, "Leave page?", leave.Heading) | |
| 42 | + | assert.Equal(t, "Leave", leave.OKLabel) | |
| 43 | + | assert.Equal(t, "Stay", leave.CancelLabel) | |
| 44 | + | assert.Contains(t, leave.Body, "example.com") | |
| 45 | + | ||
| 46 | + | reload := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogBeforeUnload, Origin: origin, IsReload: true, Message: "x"}) | |
| 47 | + | assert.Equal(t, "Reload page?", reload.Heading) | |
| 48 | + | } | |
| @@ -0,0 +1,48 @@ | |||||
| 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) + ".com/") | |||
| 19 | + | assert.LessOrEqual(t, len([]rune(long)), jsDialogMaxHostChars+1) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestBuildJSDialogContent(t *testing.T) { | |||
| 23 | + | origin := "https://example.com/x" | |||
| 24 | + | ||||
| 25 | + | alert := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogAlert, Origin: origin, Message: "<b>hi</b>"}) | |||
| 26 | + | assert.Equal(t, "example.com says:", alert.Heading) | |||
| 27 | + | assert.Equal(t, "<b>hi</b>", alert.Body, "message must stay plain text") | |||
| 28 | + | assert.Equal(t, "OK", alert.OKLabel) | |||
| 29 | + | assert.Empty(t, alert.CancelLabel) | |||
| 30 | + | assert.False(t, alert.ShowInput) | |||
| 31 | + | ||||
| 32 | + | confirm := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogConfirm, Origin: origin}) | |||
| 33 | + | assert.Equal(t, "Cancel", confirm.CancelLabel) | |||
| 34 | + | assert.False(t, confirm.ShowInput) | |||
| 35 | + | ||||
| 36 | + | prompt := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogPrompt, Origin: origin}) | |||
| 37 | + | assert.True(t, prompt.ShowInput) | |||
| 38 | + | assert.Equal(t, "Cancel", prompt.CancelLabel) | |||
| 39 | + | ||||
| 40 | + | leave := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogBeforeUnload, Origin: origin}) | |||
| 41 | + | assert.Equal(t, "Leave page?", leave.Heading) | |||
| 42 | + | assert.Equal(t, "Leave", leave.OKLabel) | |||
| 43 | + | assert.Equal(t, "Stay", leave.CancelLabel) | |||
| 44 | + | assert.Contains(t, leave.Body, "example.com") | |||
| 45 | + | ||||
| 46 | + | reload := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogBeforeUnload, Origin: origin, IsReload: true, Message: "x"}) | |||
| 47 | + | assert.Equal(t, "Reload page?", reload.Heading) | |||
| 48 | + | } | |||
M internal/ui/component/pane_view.go +61 -0
| @@ -5,6 +5,7 @@ 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" | |
| @@ -26,6 +27,8 @@ type PaneView struct { | |||
| 26 | 27 | toaster *Toaster // Toast notification overlay | |
| 27 | 28 | linkStatus *LinkStatusOverlay // Link hover URL overlay | |
| 28 | 29 | loading *LoadingSkeleton // Placeholder shown until WebView paints | |
| 30 | + | jsDialog *JSDialogPopup // JS alert/confirm/prompt/beforeunload overlay | |
| 31 | + | jsDialogWidth layout.Widget // overlay wrapper of jsDialog, for removal | |
| 29 | 32 | paneID entity.PaneID | |
| 30 | 33 | isActive bool | |
| 31 | 34 | ||
| @@ -585,4 +588,62 @@ func (pv *PaneView) Cleanup() { | |||
| 585 | 588 | pv.overlay.RemoveOverlay(pv.linkStatus.Widget()) | |
| 586 | 589 | pv.linkStatus = nil | |
| 587 | 590 | } | |
| 591 | + | if pv.jsDialogWidth != nil { | |
| 592 | + | pv.overlay.RemoveOverlay(pv.jsDialogWidth) | |
| 593 | + | pv.jsDialogWidth = nil | |
| 594 | + | pv.jsDialog = nil | |
| 595 | + | } | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | // ensureJSDialog creates the JS dialog popup lazily on first use. | |
| 599 | + | // Must be called with write lock held. | |
| 600 | + | func (pv *PaneView) ensureJSDialog() *JSDialogPopup { | |
| 601 | + | if pv.jsDialog != nil { | |
| 602 | + | return pv.jsDialog | |
| 603 | + | } | |
| 604 | + | popup := NewJSDialogPopup() | |
| 605 | + | if popup == nil { | |
| 606 | + | return nil | |
| 607 | + | } | |
| 608 | + | w := pv.factory.WrapWidget(popup.Widget()) | |
| 609 | + | pv.overlay.AddOverlay(w) | |
| 610 | + | pv.overlay.SetClipOverlay(w, false) | |
| 611 | + | pv.overlay.SetMeasureOverlay(w, false) | |
| 612 | + | popup.SetOnHidden(func() { | |
| 613 | + | // Return keyboard focus to the page once the dialog is gone. | |
| 614 | + | pv.mu.RLock() | |
| 615 | + | wvw := pv.webViewWidget | |
| 616 | + | pv.mu.RUnlock() | |
| 617 | + | if wvw != nil { | |
| 618 | + | wvw.GrabFocus() | |
| 619 | + | } | |
| 620 | + | }) | |
| 621 | + | pv.jsDialog = popup | |
| 622 | + | pv.jsDialogWidth = w | |
| 623 | + | return popup | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | // ShowJSDialog displays a JavaScript dialog scoped to this pane. The dialog | |
| 627 | + | // lives in the pane's overlay, so it never blocks other panes; if the pane is | |
| 628 | + | // in a background tab it becomes visible when that tab is shown. respond is | |
| 629 | + | // invoked once with the user's answer. Returns false if the popup is unavailable. | |
| 630 | + | func (pv *PaneView) ShowJSDialog(req port.JSDialogRequest, respond func(ok bool, input string)) bool { | |
| 631 | + | pv.mu.Lock() | |
| 632 | + | popup := pv.ensureJSDialog() | |
| 633 | + | pv.mu.Unlock() | |
| 634 | + | if popup == nil { | |
| 635 | + | return false | |
| 636 | + | } | |
| 637 | + | popup.Show(req, respond) | |
| 638 | + | return true | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | // HideJSDialog closes any JS dialog without answering it (engine-canceled). | |
| 642 | + | func (pv *PaneView) HideJSDialog() { | |
| 643 | + | pv.mu.RLock() | |
| 644 | + | popup := pv.jsDialog | |
| 645 | + | pv.mu.RUnlock() | |
| 646 | + | if popup != nil { | |
| 647 | + | popup.Hide() | |
| 648 | + | } | |
| 588 | 649 | } | |
| @@ -5,6 +5,7 @@ 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" | ||
| @@ -26,6 +27,8 @@ type PaneView struct { | |||||
| 26 | toaster *Toaster // Toast notification overlay | 27 | toaster *Toaster // Toast notification overlay | ||
| 27 | linkStatus *LinkStatusOverlay // Link hover URL overlay | 28 | linkStatus *LinkStatusOverlay // Link hover URL overlay | ||
| 28 | loading *LoadingSkeleton // Placeholder shown until WebView paints | 29 | loading *LoadingSkeleton // Placeholder shown until WebView paints | ||
| 30 | + | jsDialog *JSDialogPopup // JS alert/confirm/prompt/beforeunload overlay | |||
| 31 | + | jsDialogWidth layout.Widget // overlay wrapper of jsDialog, for removal | |||
| 29 | paneID entity.PaneID | 32 | paneID entity.PaneID | ||
| 30 | isActive bool | 33 | isActive bool | ||
| 31 | 34 | ||||
| @@ -585,4 +588,62 @@ func (pv *PaneView) Cleanup() { | |||||
| 585 | pv.overlay.RemoveOverlay(pv.linkStatus.Widget()) | 588 | pv.overlay.RemoveOverlay(pv.linkStatus.Widget()) | ||
| 586 | pv.linkStatus = nil | 589 | pv.linkStatus = nil | ||
| 587 | } | 590 | } | ||
| 591 | + | if pv.jsDialogWidth != nil { | |||
| 592 | + | pv.overlay.RemoveOverlay(pv.jsDialogWidth) | |||
| 593 | + | pv.jsDialogWidth = nil | |||
| 594 | + | pv.jsDialog = nil | |||
| 595 | + | } | |||
| 596 | + | } | |||
| 597 | + | ||||
| 598 | + | // ensureJSDialog creates the JS dialog popup lazily on first use. | |||
| 599 | + | // Must be called with write lock held. | |||
| 600 | + | func (pv *PaneView) ensureJSDialog() *JSDialogPopup { | |||
| 601 | + | if pv.jsDialog != nil { | |||
| 602 | + | return pv.jsDialog | |||
| 603 | + | } | |||
| 604 | + | popup := NewJSDialogPopup() | |||
| 605 | + | if popup == nil { | |||
| 606 | + | return nil | |||
| 607 | + | } | |||
| 608 | + | w := pv.factory.WrapWidget(popup.Widget()) | |||
| 609 | + | pv.overlay.AddOverlay(w) | |||
| 610 | + | pv.overlay.SetClipOverlay(w, false) | |||
| 611 | + | pv.overlay.SetMeasureOverlay(w, false) | |||
| 612 | + | popup.SetOnHidden(func() { | |||
| 613 | + | // Return keyboard focus to the page once the dialog is gone. | |||
| 614 | + | pv.mu.RLock() | |||
| 615 | + | wvw := pv.webViewWidget | |||
| 616 | + | pv.mu.RUnlock() | |||
| 617 | + | if wvw != nil { | |||
| 618 | + | wvw.GrabFocus() | |||
| 619 | + | } | |||
| 620 | + | }) | |||
| 621 | + | pv.jsDialog = popup | |||
| 622 | + | pv.jsDialogWidth = w | |||
| 623 | + | return popup | |||
| 624 | + | } | |||
| 625 | + | ||||
| 626 | + | // ShowJSDialog displays a JavaScript dialog scoped to this pane. The dialog | |||
| 627 | + | // lives in the pane's overlay, so it never blocks other panes; if the pane is | |||
| 628 | + | // in a background tab it becomes visible when that tab is shown. respond is | |||
| 629 | + | // invoked once with the user's answer. Returns false if the popup is unavailable. | |||
| 630 | + | func (pv *PaneView) ShowJSDialog(req port.JSDialogRequest, respond func(ok bool, input string)) bool { | |||
| 631 | + | pv.mu.Lock() | |||
| 632 | + | popup := pv.ensureJSDialog() | |||
| 633 | + | pv.mu.Unlock() | |||
| 634 | + | if popup == nil { | |||
| 635 | + | return false | |||
| 636 | + | } | |||
| 637 | + | popup.Show(req, respond) | |||
| 638 | + | return true | |||
| 639 | + | } | |||
| 640 | + | ||||
| 641 | + | // HideJSDialog closes any JS dialog without answering it (engine-canceled). | |||
| 642 | + | func (pv *PaneView) HideJSDialog() { | |||
| 643 | + | pv.mu.RLock() | |||
| 644 | + | popup := pv.jsDialog | |||
| 645 | + | pv.mu.RUnlock() | |||
| 646 | + | if popup != nil { | |||
| 647 | + | popup.Hide() | |||
| 648 | + | } | |||
| 588 | } | 649 | } | ||
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) | |
| 300 | + | } | |
| 301 | + | callbacks.OnJSDialogReset = func() { | |
| 302 | + | if pv := c.paneViewForJSDialog(paneID); pv != nil { | |
| 303 | + | pv.HideJSDialog() | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // paneViewForJSDialog resolves the PaneView that owns paneID's WebView. The | |
| 309 | + | // dialog is shown inside that pane's overlay even if the pane is in a | |
| 310 | + | // background tab (it appears when the tab is shown) and never blocks others. | |
| 311 | + | func (c *Coordinator) paneViewForJSDialog(paneID entity.PaneID) *component.PaneView { | |
| 312 | + | if c.paneViewResolver != nil { | |
| 313 | + | if pv := c.paneViewResolver(paneID); pv != nil { | |
| 314 | + | return pv | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | if c.getActiveWS == nil { | |
| 318 | + | return nil | |
| 319 | + | } | |
| 320 | + | if _, wsView := c.getActiveWS(); wsView != nil { | |
| 321 | + | return wsView.GetPaneView(paneID) | |
| 322 | + | } | |
| 323 | + | return nil | |
| 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) | |||
| 300 | + | } | |||
| 301 | + | callbacks.OnJSDialogReset = func() { | |||
| 302 | + | if pv := c.paneViewForJSDialog(paneID); pv != nil { | |||
| 303 | + | pv.HideJSDialog() | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | // paneViewForJSDialog resolves the PaneView that owns paneID's WebView. The | |||
| 309 | + | // dialog is shown inside that pane's overlay even if the pane is in a | |||
| 310 | + | // background tab (it appears when the tab is shown) and never blocks others. | |||
| 311 | + | func (c *Coordinator) paneViewForJSDialog(paneID entity.PaneID) *component.PaneView { | |||
| 312 | + | if c.paneViewResolver != nil { | |||
| 313 | + | if pv := c.paneViewResolver(paneID); pv != nil { | |||
| 314 | + | return pv | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | if c.getActiveWS == nil { | |||
| 318 | + | return nil | |||
| 319 | + | } | |||
| 320 | + | if _, wsView := c.getActiveWS(); wsView != nil { | |||
| 321 | + | return wsView.GetPaneView(paneID) | |||
| 322 | + | } | |||
| 323 | + | return nil | |||
| 324 | + | } | |||
M internal/ui/coordinator/content/coordinator.go +9 -0
| @@ -46,6 +46,10 @@ 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). Falls back to the active workspace view when nil. | |
| 51 | + | paneViewResolver func(paneID entity.PaneID) *component.PaneView | |
| 52 | + | ||
| 49 | 53 | // Callback when title changes (for history persistence) | |
| 50 | 54 | onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string) | |
| 51 | 55 | ||
| @@ -246,6 +250,11 @@ func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv port. | |||
| 246 | 250 | c.onWebViewFocused = fn | |
| 247 | 251 | } | |
| 248 | 252 | ||
| 253 | + | // SetPaneViewResolver sets a resolver that finds a pane's view in any tab/window. | |
| 254 | + | func (c *Coordinator) SetPaneViewResolver(fn func(paneID entity.PaneID) *component.PaneView) { | |
| 255 | + | c.paneViewResolver = fn | |
| 256 | + | } | |
| 257 | + | ||
| 249 | 258 | // SetOnEditableFocusChanged sets the callback for page editable focus changes. | |
| 250 | 259 | func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) { | |
| 251 | 260 | c.onEditableFocusChanged = fn | |
| @@ -46,6 +46,10 @@ 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). Falls back to the active workspace view when nil. | |||
| 51 | + | paneViewResolver func(paneID entity.PaneID) *component.PaneView | |||
| 52 | + | ||||
| 49 | // Callback when title changes (for history persistence) | 53 | // Callback when title changes (for history persistence) | ||
| 50 | onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string) | 54 | onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string) | ||
| 51 | 55 | ||||
| @@ -246,6 +250,11 @@ func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv port. | |||||
| 246 | c.onWebViewFocused = fn | 250 | c.onWebViewFocused = fn | ||
| 247 | } | 251 | } | ||
| 248 | 252 | ||||
| 253 | + | // SetPaneViewResolver sets a resolver that finds a pane's view in any tab/window. | |||
| 254 | + | func (c *Coordinator) SetPaneViewResolver(fn func(paneID entity.PaneID) *component.PaneView) { | |||
| 255 | + | c.paneViewResolver = fn | |||
| 256 | + | } | |||
| 257 | + | ||||
| 249 | // SetOnEditableFocusChanged sets the callback for page editable focus changes. | 258 | // SetOnEditableFocusChanged sets the callback for page editable focus changes. | ||
| 250 | func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) { | 259 | func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) { | ||
| 251 | c.onEditableFocusChanged = fn | 260 | 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; | ||