1package component
2
3import (
4 "net/url"
5 "strings"
6 "sync"
7 "sync/atomic"
8
9 "github.com/bnema/puregotk/v4/gdk"
10 "github.com/bnema/puregotk/v4/gtk"
11 "github.com/bnema/puregotk/v4/pango"
12
13 "github.com/bnema/dumber/internal/application/port"
14 "github.com/bnema/dumber/internal/ui/layout"
15)
16
17const (
18 jsDialogMaxBodyHeight = 240
19 jsDialogMaxWidthChars = 60
20 jsDialogMaxHostChars = 80
21 // jsDialogMaxTextRunes caps page-controlled text (message, prompt default)
22 // so a hostile page can't make GTK lay out megabytes of text.
23 jsDialogMaxTextRunes = 3000
24 // jsDialogBeforeUnloadText is Dumber's own beforeunload text: CEF always
25 // passes a fixed string, and browsers no longer show page text here.
26 jsDialogBeforeUnloadText = "Changes you made may not be saved."
27)
28
29// JSDialogSizeDefaults sizes the dialog box relative to its pane.
30var JSDialogSizeDefaults = ModalSizeConfig{
31 WidthPct: 0.9,
32 MaxWidth: 420,
33 TopMarginPct: 0.3,
34 FallbackWidth: 420,
35 FallbackHeight: 300,
36}
37
38// jsDialogContent is the plain-text content of a JS dialog.
39type jsDialogContent struct {
40 Heading string
41 Body string
42 OKLabel string
43 CancelLabel string // empty: no cancel button (alert)
44 ShowInput bool
45}
46
47// jsDialogOriginLabel returns a short, user-facing origin ("example.com") for
48// the requesting URL. The origin is shown separately from the page-controlled
49// message so a page cannot impersonate browser UI.
50func jsDialogOriginLabel(origin string) string {
51 origin = strings.TrimSpace(origin)
52 if origin == "" {
53 return "This page"
54 }
55 u, err := url.Parse(origin)
56 // blob:/filesystem: URLs carry the real origin in the opaque part.
57 if err == nil && u.Host == "" && (u.Scheme == "blob" || u.Scheme == "filesystem") && u.Opaque != "" {
58 if inner, innerErr := url.Parse(u.Opaque); innerErr == nil && inner.Host != "" {
59 u = inner
60 }
61 }
62 if err != nil || u.Host == "" {
63 if err == nil && u.Scheme != "" {
64 return truncateRunesLeft(u.Scheme+":", jsDialogMaxHostChars)
65 }
66 return "This page"
67 }
68 // Keep the end of the host: the registrable domain is what identifies it.
69 return truncateRunesLeft(u.Host, jsDialogMaxHostChars)
70}
71
72// truncateRunesLeft keeps the last n runes, prefixing "…" when it cut.
73func truncateRunesLeft(s string, n int) string {
74 r := []rune(s)
75 if len(r) <= n {
76 return s
77 }
78 return "…" + string(r[len(r)-n:])
79}
80
81func truncateRunes(s string, n int) string {
82 r := []rune(s)
83 if len(r) <= n {
84 return s
85 }
86 return string(r[:n]) + "…"
87}
88
89// buildJSDialogContent maps a request to the dialog texts and buttons.
90// All strings are plain text (never Pango markup).
91func buildJSDialogContent(req port.JSDialogRequest) jsDialogContent {
92 says := jsDialogOriginLabel(req.Origin) + " says:"
93 msg := truncateRunes(req.Message, jsDialogMaxTextRunes)
94 switch req.Type {
95 case port.JSDialogConfirm:
96 return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK", CancelLabel: "Cancel"}
97 case port.JSDialogPrompt:
98 return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK", CancelLabel: "Cancel", ShowInput: true}
99 case port.JSDialogBeforeUnload:
100 body := jsDialogBeforeUnloadText
101 heading := "Leave page?"
102 if req.IsReload {
103 heading = "Reload page?"
104 }
105 return jsDialogContent{
106 Heading: heading,
107 Body: jsDialogOriginLabel(req.Origin) + "\n" + body,
108 OKLabel: "Leave",
109 CancelLabel: "Stay",
110 }
111 default:
112 return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK"}
113 }
114}
115
116// JSDialogPopup is a per-pane overlay that renders alert/confirm/prompt and
117// beforeunload dialogs. It is owned by a PaneView, so a dialog belongs to its
118// pane's WebView and never blocks other panes. It replaces Adwaita AlertDialog
119// for the same reason as PermissionPopup (purego ConnectResponse bug).
120type JSDialogPopup struct {
121 outerBox *gtk.Box
122 mainBox *gtk.Box
123
124 headingLabel *gtk.Label
125 bodyLabel *gtk.Label
126 input *gtk.Entry
127 btnOK *gtk.Button
128 btnCancel *gtk.Button
129
130 parent layout.OverlayWidget
131 controller *gtk.EventControllerKey
132 focusCtrl *gtk.EventControllerFocus
133 hasFocus atomic.Bool // keyboard focus is inside the popup (enter/leave)
134 signals []popupSignal
135
136 mu sync.Mutex
137 visible bool
138 wantFocus bool // this dialog should own keyboard focus (pane is active)
139 destroyed bool
140 respond func(ok bool, input string)
141 onHidden func(hadFocus bool)
142
143 retainedCallbacks []any
144}
145
146// popupSignal remembers a connected handler so Destroy can disconnect it.
147type popupSignal struct {
148 obj interface{ DisconnectSignal(uint) }
149 id uint
150}
151
152// NewJSDialogPopup creates the popup (hidden). Returns nil if widget creation fails.
153// parent is the pane overlay used to size the dialog.
154func NewJSDialogPopup(parent layout.OverlayWidget) *JSDialogPopup {
155 p := &JSDialogPopup{parent: parent}
156 if err := p.createWidgets(); err != nil {
157 return nil
158 }
159 p.attachKeyController()
160 return p
161}
162
163// Widget returns the outer GTK widget for overlay registration.
164func (p *JSDialogPopup) Widget() *gtk.Widget {
165 if p == nil || p.outerBox == nil {
166 return nil
167 }
168 return &p.outerBox.Widget
169}
170
171// SetOnHidden sets (or with nil clears) a callback run after the popup closes. hadFocus reports
172// whether keyboard focus was inside the dialog when it closed, so the owner
173// only restores focus when the dialog had taken it.
174func (p *JSDialogPopup) SetOnHidden(fn func(hadFocus bool)) {
175 p.mu.Lock()
176 p.onHidden = fn
177 p.mu.Unlock()
178}
179
180// Show displays the dialog. respond is invoked once with the user's answer.
181// A dialog already visible is dismissed as canceled first. Keyboard focus is
182// only taken when focus is true (the pane is active); otherwise RequestFocus
183// grabs it when the pane becomes active.
184func (p *JSDialogPopup) Show(req port.JSDialogRequest, respond func(ok bool, input string), focus bool, uiScale float64) {
185 p.Dismiss(false)
186
187 content := buildJSDialogContent(req)
188
189 p.mu.Lock()
190 if p.destroyed {
191 p.mu.Unlock()
192 if respond != nil {
193 respond(false, "")
194 }
195 return
196 }
197 p.visible = true
198 p.wantFocus = focus
199 p.respond = respond
200 p.mu.Unlock()
201
202 width, _ := CalculateModalDimensionsWithScale(p.parent, JSDialogSizeDefaults, uiScale)
203 p.mainBox.SetSizeRequest(width, -1)
204
205 p.headingLabel.SetText(content.Heading)
206 p.bodyLabel.SetText(content.Body)
207 p.bodyLabel.SetVisible(content.Body != "")
208 p.btnOK.SetLabel(content.OKLabel)
209 p.btnCancel.SetVisible(content.CancelLabel != "")
210 if content.CancelLabel != "" {
211 p.btnCancel.SetLabel(content.CancelLabel)
212 }
213 p.input.SetVisible(content.ShowInput)
214 if content.ShowInput {
215 p.input.SetText(truncateRunes(req.DefaultPrompt, jsDialogMaxTextRunes))
216 } else {
217 p.input.SetText("")
218 }
219
220 p.outerBox.SetVisible(true)
221 if focus {
222 p.grabDefaultFocus()
223 }
224}
225
226// Visible reports whether a dialog is currently shown.
227func (p *JSDialogPopup) Visible() bool {
228 p.mu.Lock()
229 defer p.mu.Unlock()
230 return p.visible
231}
232
233// RequestFocus marks the dialog as the focus owner and grabs focus if shown.
234// Called when the owning pane becomes active.
235func (p *JSDialogPopup) RequestFocus() {
236 p.mu.Lock()
237 if !p.visible || p.destroyed {
238 p.mu.Unlock()
239 return
240 }
241 p.wantFocus = true
242 p.mu.Unlock()
243 p.grabDefaultFocus()
244}
245
246// grabDefaultFocus focuses the entry (prompt) or the OK button.
247func (p *JSDialogPopup) grabDefaultFocus() {
248 if p.input.GetVisible() {
249 p.input.GrabFocus()
250 p.input.SelectRegion(0, -1)
251 return
252 }
253 p.btnOK.GrabFocus()
254}
255
256// Hide closes the popup without invoking the response callback. It is used when
257// the engine canceled the dialog (navigation, WebView destruction).
258func (p *JSDialogPopup) Hide() {
259 if p == nil {
260 return
261 }
262 p.close(nil)
263}
264
265// Dismiss closes the popup and answers with ok. No-op when hidden.
266func (p *JSDialogPopup) Dismiss(ok bool) {
267 p.close(&ok)
268}
269
270// close hides the popup. When answer is non-nil the response callback is run.
271func (p *JSDialogPopup) close(answer *bool) {
272 p.mu.Lock()
273 if !p.visible {
274 p.mu.Unlock()
275 return
276 }
277 p.visible = false
278 p.wantFocus = false
279 respond := p.respond
280 p.respond = nil
281 hidden := p.onHidden
282 p.mu.Unlock()
283
284 // Read before hiding: hiding a focused widget fires "leave".
285 hadFocus := p.hasFocus.Load()
286 text := ""
287 if answer != nil && *answer && p.input.GetVisible() {
288 text = p.input.GetText()
289 }
290 p.outerBox.SetVisible(false)
291 if answer != nil && respond != nil {
292 respond(*answer, text)
293 }
294 if hidden != nil {
295 hidden(hadFocus)
296 }
297}
298
299// Destroy disconnects every handler and drops callbacks. The popup must not be
300// used afterwards. Call after the dialog was dismissed.
301func (p *JSDialogPopup) Destroy() {
302 if p == nil {
303 return
304 }
305 p.mu.Lock()
306 if p.destroyed {
307 p.mu.Unlock()
308 return
309 }
310 p.destroyed = true
311 p.visible = false
312 p.respond = nil
313 p.onHidden = nil
314 p.mu.Unlock()
315
316 for _, s := range p.signals {
317 s.obj.DisconnectSignal(s.id)
318 }
319 p.signals = nil
320 if p.controller != nil && p.outerBox != nil {
321 p.outerBox.RemoveController(&p.controller.EventController)
322 p.controller = nil
323 }
324 if p.focusCtrl != nil && p.outerBox != nil {
325 p.outerBox.RemoveController(&p.focusCtrl.EventController)
326 p.focusCtrl = nil
327 }
328 p.retainedCallbacks = nil
329}
330
331func (p *JSDialogPopup) createWidgets() error {
332 if err := p.createContainers(); err != nil {
333 return err
334 }
335 if err := p.createLabels(); err != nil {
336 return err
337 }
338 scroll := gtk.NewScrolledWindow()
339 if scroll == nil {
340 return errNilWidget("jsDialogScroll")
341 }
342 scroll.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
343 scroll.SetPropagateNaturalHeight(true)
344 scroll.SetMaxContentHeight(jsDialogMaxBodyHeight)
345 scroll.SetChild(&p.bodyLabel.Widget)
346
347 p.input = gtk.NewEntry()
348 if p.input == nil {
349 return errNilWidget("jsDialogEntry")
350 }
351 p.input.AddCssClass("jsdialog-entry")
352 p.input.SetMaxLength(jsDialogMaxTextRunes)
353 p.input.SetVisible(false)
354 // Enter in the entry = OK. Focused buttons activate themselves, so Enter on
355 // Cancel/Stay still cancels.
356 activateCb := func(gtk.Entry) { p.Dismiss(true) }
357 p.retainedCallbacks = append(p.retainedCallbacks, activateCb)
358 p.signals = append(p.signals, popupSignal{p.input, p.input.ConnectActivate(&activateCb)})
359
360 btnRow, err := p.createButtonRow()
361 if err != nil {
362 return err
363 }
364
365 p.mainBox.Append(&p.headingLabel.Widget)
366 p.mainBox.Append(&scroll.Widget)
367 p.mainBox.Append(&p.input.Widget)
368 p.mainBox.Append(&btnRow.Widget)
369 p.outerBox.Append(&p.mainBox.Widget)
370 return nil
371}
372
373func (p *JSDialogPopup) createContainers() error {
374 p.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
375 if p.outerBox == nil {
376 return errNilWidget("jsDialogOuterBox")
377 }
378 // Fill the pane so the scrim swallows pointer input meant for the page.
379 p.outerBox.AddCssClass("jsdialog-scrim")
380 p.outerBox.SetHexpand(true)
381 p.outerBox.SetVexpand(true)
382 p.outerBox.SetHalign(gtk.AlignFillValue)
383 p.outerBox.SetValign(gtk.AlignFillValue)
384 p.outerBox.SetVisible(false)
385
386 p.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
387 if p.mainBox == nil {
388 return errNilWidget("jsDialogMainBox")
389 }
390 p.mainBox.AddCssClass("permission-popup-container")
391 p.mainBox.SetHalign(gtk.AlignCenterValue)
392 p.mainBox.SetValign(gtk.AlignCenterValue)
393 return nil
394}
395
396func (p *JSDialogPopup) createLabels() error {
397 empty := ""
398 p.headingLabel = gtk.NewLabel(&empty)
399 p.bodyLabel = gtk.NewLabel(&empty)
400 if p.headingLabel == nil || p.bodyLabel == nil {
401 return errNilWidget("jsDialogLabels")
402 }
403 // Labels use SetText only: page text is never parsed as Pango markup.
404 for _, l := range []*gtk.Label{p.headingLabel, p.bodyLabel} {
405 l.SetUseMarkup(false)
406 l.SetHalign(gtk.AlignStartValue)
407 l.SetXalign(0)
408 l.SetWrap(true)
409 l.SetWrapMode(pango.WrapWordCharValue)
410 l.SetMaxWidthChars(jsDialogMaxWidthChars)
411 }
412 p.headingLabel.AddCssClass("permission-popup-heading")
413 p.bodyLabel.AddCssClass("permission-popup-body")
414 return nil
415}
416
417func (p *JSDialogPopup) createButtonRow() (*gtk.Box, error) {
418 btnRow := gtk.NewBox(gtk.OrientationHorizontalValue, buttonSpacing)
419 if btnRow == nil {
420 return nil, errNilWidget("jsDialogBtnRow")
421 }
422 btnRow.AddCssClass("permission-popup-btn-row")
423 btnRow.SetHalign(gtk.AlignEndValue)
424
425 p.btnCancel = gtk.NewButtonWithLabel("Cancel")
426 p.btnOK = gtk.NewButtonWithLabel("OK")
427 if p.btnCancel == nil || p.btnOK == nil {
428 return nil, errNilWidget("jsDialogButtons")
429 }
430 p.btnCancel.AddCssClass("permission-popup-btn")
431 p.btnCancel.AddCssClass("permission-popup-btn-deny")
432 p.btnOK.AddCssClass("permission-popup-btn")
433 p.btnOK.AddCssClass("permission-popup-btn-allow")
434
435 cancelCb := func(_ gtk.Button) { p.Dismiss(false) }
436 okCb := func(_ gtk.Button) { p.Dismiss(true) }
437 p.retainedCallbacks = append(p.retainedCallbacks, cancelCb, okCb)
438 p.signals = append(p.signals,
439 popupSignal{p.btnCancel, p.btnCancel.ConnectClicked(&cancelCb)},
440 popupSignal{p.btnOK, p.btnOK.ConnectClicked(&okCb)},
441 )
442
443 btnRow.Append(&p.btnCancel.Widget)
444 btnRow.Append(&p.btnOK.Widget)
445 return btnRow, nil
446}
447
448// attachKeyController maps Escape to Cancel (capture phase, so the page never
449// sees it). Enter is not handled here: see the entry's activate handler.
450func (p *JSDialogPopup) attachKeyController() {
451 controller := gtk.NewEventControllerKey()
452 if controller == nil {
453 return
454 }
455 controller.SetPropagationPhase(gtk.PhaseCaptureValue)
456 keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
457 if keyval == uint(gdk.KEY_Escape) {
458 p.Dismiss(false)
459 return true
460 }
461 return false
462 }
463 p.retainedCallbacks = append(p.retainedCallbacks, keyPressedCb)
464 p.signals = append(p.signals, popupSignal{controller, controller.ConnectKeyPressed(&keyPressedCb)})
465 p.outerBox.AddController(&controller.EventController)
466 p.controller = controller
467
468 // Track focus-within so close() knows whether the dialog owned focus
469 // (GetFocusChild would leak a reference).
470 if fc := gtk.NewEventControllerFocus(); fc != nil {
471 enterCb := func(gtk.EventControllerFocus) { p.hasFocus.Store(true) }
472 leaveCb := func(gtk.EventControllerFocus) { p.hasFocus.Store(false) }
473 p.retainedCallbacks = append(p.retainedCallbacks, enterCb, leaveCb)
474 p.signals = append(p.signals,
475 popupSignal{fc, fc.ConnectEnter(&enterCb)},
476 popupSignal{fc, fc.ConnectLeave(&leaveCb)},
477 )
478 p.outerBox.AddController(&fc.EventController)
479 p.focusCtrl = fc
480 }
481
482 // A dialog shown while its tab was in the background gets focus once mapped.
483 mapCb := func(gtk.Widget) {
484 p.mu.Lock()
485 want := p.visible && p.wantFocus
486 p.mu.Unlock()
487 if want {
488 p.grabDefaultFocus()
489 }
490 }
491 p.retainedCallbacks = append(p.retainedCallbacks, mapCb)
492 p.signals = append(p.signals, popupSignal{&p.outerBox.Widget, p.outerBox.ConnectMap(&mapCb)})
493}