1package cef
2
3import (
4 "context"
5 "strings"
6 "sync"
7 "sync/atomic"
8 "time"
9
10 purecef "github.com/bnema/purego-cef/cef"
11
12 "github.com/bnema/dumber/internal/application/dto"
13 "github.com/bnema/dumber/internal/application/port"
14 downloadutil "github.com/bnema/dumber/internal/domain/download"
15 "github.com/bnema/dumber/internal/logging"
16)
17
18// Console message markers used by injected JavaScript for log filtering.
19const (
20 consoleMarkerVideoDiag = "[VIDEO-DIAG]"
21 consoleMarkerAutoCopy = "[AUTO-COPY]"
22 consoleMarkerScaleProbe = "[SCALE-PROBE]"
23)
24
25// handlerSet implements all CEF handler interfaces and dispatches events to the
26// owning WebView. A single struct is used so that the Client's Get*Handler
27// methods can return the same receiver, avoiding extra allocations.
28type handlerSet struct {
29 wv *WebView
30 fileDialogPresenter fileDialogPresenter
31 renderHandlerOnce sync.Once
32 renderHandler purecef.RenderHandler
33 permissions permissionTracker
34}
35
36// Compile-time interface checks.
37var (
38 _ purecef.Client = (*handlerSet)(nil)
39 _ purecef.DisplayHandler = (*handlerSet)(nil)
40 _ purecef.LoadHandler = (*handlerSet)(nil)
41 _ purecef.LifeSpanHandler = (*handlerSet)(nil)
42 _ purecef.RequestHandler = (*handlerSet)(nil)
43 _ purecef.AudioHandler = (*handlerSet)(nil)
44 _ purecef.ContextMenuHandler = (*handlerSet)(nil)
45 _ purecef.DialogHandler = (*handlerSet)(nil)
46 _ purecef.DownloadHandler = (*handlerSet)(nil)
47 _ purecef.FindHandler = (*handlerSet)(nil)
48 _ purecef.PermissionHandler = (*handlerSet)(nil)
49)
50
51// ===========================================================================
52// Client
53// ===========================================================================
54
55func (h *handlerSet) GetAudioHandler() purecef.AudioHandler {
56 // Always return the audio handler — without it, CEF has no audio output
57 // path and refuses to start media decoding, breaking all video playback.
58 // The handler accepts audio but discards packets (no-op) until a real
59 // audio backend (PipeWire/PulseAudio) is wired up.
60 return h
61}
62func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }
63func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h }
64func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return h }
65func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }
66func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler {
67 if h.downloadHandler() != nil {
68 return h
69 }
70 return nil
71}
72func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil }
73func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }
74func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
75func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
76func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return h }
77func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return h }
78func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
79func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }
80func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
81func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil }
82func (h *handlerSet) GetRenderHandler() purecef.RenderHandler {
83 if h == nil {
84 return nil
85 }
86 h.renderHandlerOnce.Do(func() {
87 h.renderHandler = newDumberRenderHandler(h.wv)
88 })
89 return h.renderHandler
90}
91func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
92
93func (h *handlerSet) OnProcessMessageReceived(
94 browser purecef.Browser,
95 _ purecef.Frame,
96 _ purecef.ProcessID,
97 message purecef.ProcessMessage,
98) int32 {
99 if h == nil || h.wv == nil || h.wv.engine == nil {
100 return 0
101 }
102 action, payload, ok := decodeRendererBridgeProcessMessage(message)
103 if !ok {
104 return 0
105 }
106
107 log := logging.FromContext(h.wv.ctx)
108 log.Debug().
109 Str("action", action).
110 Int("payload_len", len(payload)).
111 Msg("cef: renderer bridge message received")
112
113 switch action {
114 case rendererBridgeActionExplicitTextCopy:
115 req, err := decodeRendererBridgeExplicitTextCopyPayload([]byte(payload))
116 if err != nil {
117 log.Debug().Str("action", action).Msg("cef: invalid explicit copy payload")
118 return 1
119 }
120 h.wv.engine.handleExplicitClipboardBridgeText(h.wv.id, req.Action, req.Text)
121 case rendererBridgeActionEditableFocusChanged:
122 h.wv.setEditableFocus(payload == "1" || strings.EqualFold(payload, "true"))
123 case rendererBridgeActionFocusSync:
124 h.wv.engine.handleEditableFocusBridge(browser)
125 case rendererBridgeActionPopupOpen:
126 req, err := decodeRendererBridgePopupOpenPayload([]byte(payload))
127 if err != nil {
128 log.Debug().Str("action", action).Msg("cef: invalid popup_open payload")
129 return 1
130 }
131 h.wv.handleSyntheticPopupOpen(req.URL, req.FrameName, req.ProxyID, req.UserGesture, req.NoJavaScriptAccess)
132 case rendererBridgeActionPopupNavigate:
133 req, err := decodeRendererBridgePopupNavigatePayload([]byte(payload))
134 if err != nil {
135 log.Debug().Str("action", action).Msg("cef: invalid popup_navigate payload")
136 return 1
137 }
138 h.wv.handleSyntheticPopupNavigate(req.ProxyID, req.URL)
139 case rendererBridgeActionPopupClose:
140 req, err := decodeRendererBridgePopupClosePayload([]byte(payload))
141 if err != nil {
142 log.Debug().Str("action", action).Msg("cef: invalid popup_close payload")
143 return 1
144 }
145 h.wv.handleSyntheticPopupClose(req.ProxyID)
146 case rendererBridgeActionReady:
147 log.Debug().
148 Str("frame_url", logging.TruncateURL(payload, logging.PermissionLogURLMaxLen)).
149 Msg("cef: renderer bridge ready")
150 default:
151 log.Debug().Str("action", action).Msg("cef: unknown renderer bridge action")
152 }
153 return 1
154}
155
156// ===========================================================================
157// RenderHandler methods live in render_handler_adapter.go. handlerSet keeps
158// Dumber's non-render CEF handlers and returns a delegating render handler from
159// GetRenderHandler.
160// ===========================================================================
161
162// ===========================================================================
163// DisplayHandler (13 methods)
164// ===========================================================================
165
166// OnAddressChange updates the cached URI when the main frame navigates.
167func (h *handlerSet) OnAddressChange(_ purecef.Browser, frame purecef.Frame, url string) {
168 if frame != nil && frame.IsMain() {
169 if h.wv != nil && h.wv.ctx != nil {
170 logging.FromContext(h.wv.ctx).Debug().
171 Str("url", logging.TruncateURL(url, logging.PermissionLogURLMaxLen)).
172 Msg("cef: OnAddressChange")
173 }
174 h.wv.updateURI(url)
175 }
176}
177
178// OnTitleChange updates the cached title.
179func (h *handlerSet) OnTitleChange(_ purecef.Browser, title string) {
180 h.wv.updateTitle(title)
181}
182
183func (h *handlerSet) OnFaviconUrlchange(browser purecef.Browser, iconURLs purecef.StringList) {
184 if h == nil || h.wv == nil {
185 return
186 }
187 h.wv.mu.RLock()
188 pageURL, committedURL, cb, currentBrowser, documentSeq := h.wv.uri, h.wv.committedURL, h.wv.callbacks, h.wv.browser, h.wv.documentSeq
189 h.wv.mu.RUnlock()
190 if cb == nil || cb.OnFaviconURLChanged == nil || browser == nil || currentBrowser == nil ||
191 browser.GetIdentifier() != currentBrowser.GetIdentifier() {
192 return
193 }
194 if committedURL == "" {
195 committedURL = pageURL
196 }
197 candidates := resolveFaviconCandidates(committedURL, decodeCEFStringList(iconURLs))
198 if len(candidates) == 0 {
199 return
200 }
201 h.wv.mu.Lock()
202 if h.wv.documentSeq != documentSeq || h.wv.uri != pageURL {
203 h.wv.mu.Unlock()
204 return
205 }
206 h.wv.faviconEngineDocumentSeq = documentSeq
207 h.wv.mu.Unlock()
208 h.wv.runOnGTK(func() {
209 if !h.faviconCallbackCurrent(cb, browser, documentSeq, pageURL) {
210 return
211 }
212 cb.OnFaviconURLChanged(pageURL, candidates)
213 })
214}
215
216// faviconCallbackCurrent reports whether a queued favicon callback still
217// belongs to the current committed document, callback owner, and browser. The
218// GTK hop outlives the state it was queued for, so callbacks revalidate before
219// handing candidates to the UI.
220func (h *handlerSet) faviconCallbackCurrent(cb *port.WebViewCallbacks, browser purecef.Browser, documentSeq uint64, pageURL string) bool {
221 if h == nil || h.wv == nil || cb == nil {
222 return false
223 }
224 h.wv.mu.RLock()
225 currentBrowser := h.wv.browser
226 current := h.wv.callbacks == cb && h.wv.documentSeq == documentSeq && h.wv.uri == pageURL
227 h.wv.mu.RUnlock()
228 if !current {
229 return false
230 }
231 if browser == nil {
232 // No captured browser identity to compare; the document checks above
233 // still bound the callback.
234 return true
235 }
236 return currentBrowser != nil && currentBrowser.GetIdentifier() == browser.GetIdentifier()
237}
238
239// OnFullscreenModeChange toggles the fullscreen atomic and fires callbacks.
240func (h *handlerSet) OnFullscreenModeChange(_ purecef.Browser, fullscreen int32) {
241 entering := fullscreen != 0
242 h.wv.fullscreen.Store(entering)
243
244 h.wv.mu.RLock()
245 cb := h.wv.callbacks
246 h.wv.mu.RUnlock()
247 if cb == nil {
248 return
249 }
250 if entering && cb.OnEnterFullscreen != nil {
251 h.wv.runOnGTK(func() {
252 cb.OnEnterFullscreen()
253 })
254 }
255 if !entering && cb.OnLeaveFullscreen != nil {
256 h.wv.runOnGTK(func() {
257 cb.OnLeaveFullscreen()
258 })
259 }
260}
261
262func (h *handlerSet) OnTooltip(_ purecef.Browser, _ uintptr) int32 { return 0 }
263
264// OnStatusMessage fires the OnLinkHover callback with the status text
265// and caches the hover URI for middle-click interception.
266func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) {
267 h.wv.updateHoverURI(value)
268
269 h.wv.mu.RLock()
270 cb := h.wv.callbacks
271 h.wv.mu.RUnlock()
272 if cb != nil && cb.OnLinkHover != nil {
273 h.wv.runOnGTK(func() {
274 cb.OnLinkHover(value)
275 })
276 }
277}
278
279func (h *handlerSet) OnConsoleMessage(
280 _ purecef.Browser,
281 level purecef.LogSeverity,
282 message, source string,
283 line int32,
284) int32 {
285 if h.wv != nil && h.wv.ctx != nil &&
286 (strings.Contains(message, consoleMarkerVideoDiag) ||
287 strings.Contains(message, consoleMarkerAutoCopy) ||
288 strings.Contains(message, consoleMarkerScaleProbe)) {
289 log := logging.FromContext(h.wv.ctx).With().
290 Str("component", "cef-console").
291 Str("source", source).
292 Int32("line", line).
293 Logger()
294 switch level {
295 case purecef.LogSeverityLogseverityError, purecef.LogSeverityLogseverityFatal:
296 log.Error().Str("message", message).Msg("cef: console message")
297 case purecef.LogSeverityLogseverityWarning:
298 log.Warn().Str("message", message).Msg("cef: console message")
299 default:
300 log.Info().Str("message", message).Msg("cef: console message")
301 }
302 }
303 return 0
304}
305
306func (h *handlerSet) OnAutoResize(_ purecef.Browser, _ *purecef.Size) int32 { return 0 }
307
308// OnLoadingProgressChange updates the cached progress value.
309func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64) {
310 if h.wv != nil && h.wv.ctx != nil {
311 logging.FromContext(h.wv.ctx).Trace().
312 Float64("progress", progress).
313 Msg("cef: OnLoadingProgressChange")
314 }
315 h.wv.updateProgress(progress)
316}
317
318func (h *handlerSet) OnCursorChange(
319 _ purecef.Browser,
320 _ uintptr,
321 cursorType purecef.CursorType,
322 _ *purecef.CursorInfo,
323) int32 {
324 if h == nil || h.wv == nil {
325 return 0
326 }
327 name := cefCursorToGDKName(cursorType)
328 h.wv.runOnGTK(func() {
329 if h.wv.viewBridge != nil {
330 h.wv.viewBridge.SetCursorFromName(name)
331 }
332 })
333 return 1 // handled
334}
335
336func (h *handlerSet) OnMediaAccessChange(_ purecef.Browser, _, _ int32) {}
337
338func (h *handlerSet) OnContentsBoundsChange(_ purecef.Browser, _ *purecef.Rect) int32 { return 0 }
339
340func (h *handlerSet) GetRootWindowScreenRect(_ purecef.Browser, _ *purecef.Rect) int32 { return 0 }
341
342// ===========================================================================
343// LoadHandler (4 methods)
344// ===========================================================================
345
346type faviconSourceVisitorRetention struct {
347 id uint64
348 visitor purecef.StringVisitor
349}
350
351type faviconSourceVisitor struct {
352 visit func(string)
353}
354
355func (v faviconSourceVisitor) Visit(source string) {
356 if v.visit != nil {
357 v.visit(source)
358 }
359}
360
361// OnLoadingStateChange updates the loading/navigation state cache and fires callbacks.
362func (h *handlerSet) OnLoadingStateChange(browser purecef.Browser, isloading, cangoback, cangoforward int32) {
363 loading := isloading != 0
364 h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0)
365 if h.wv != nil && h.wv.ctx != nil {
366 h.wv.mu.RLock()
367 uri := h.wv.uri
368 progress := h.wv.progress
369 pendingURI := h.wv.pendingURI
370 h.wv.mu.RUnlock()
371 logging.FromContext(h.wv.ctx).Debug().
372 Bool("loading", loading).
373 Bool("can_go_back", cangoback != 0).
374 Bool("can_go_forward", cangoforward != 0).
375 Float64("progress", progress).
376 Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
377 Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
378 Msg("cef: OnLoadingStateChange")
379 }
380
381 if !loading && h.wv.viewBridge != nil {
382 h.wv.runOnGTK(func() {
383 if h.wv.viewBridge != nil && h.wv.viewBridge.HasFocus() {
384 h.wv.mu.RLock()
385 host := h.wv.host
386 h.wv.mu.RUnlock()
387 if host != nil {
388 syncWindowlessBrowserFocus(host)
389 }
390 }
391 })
392 }
393
394 h.wv.mu.RLock()
395 cb := h.wv.callbacks
396 pageURL := h.wv.uri
397 committedURL := h.wv.committedURL
398 h.wv.mu.RUnlock()
399 if committedURL == "" {
400 committedURL = pageURL
401 }
402 if !loading && cb != nil && cb.OnFaviconURLChanged != nil {
403 h.emitDiscoveredFaviconCandidates(browser, pageURL, committedURL, cb)
404 }
405 if cb != nil && cb.OnLoadChanged != nil {
406 if loading {
407 h.wv.runOnGTK(func() {
408 cb.OnLoadChanged(port.LoadStarted)
409 })
410 } else {
411 h.wv.runOnGTK(func() {
412 cb.OnLoadChanged(port.LoadFinished)
413 })
414 }
415 }
416}
417
418var faviconSourceVisitorRetentionID atomic.Uint64
419
420func (wv *WebView) retainFaviconSourceVisitor(visitor purecef.StringVisitor) func() {
421 if wv == nil || visitor == nil {
422 return func() {}
423 }
424 id := faviconSourceVisitorRetentionID.Add(1)
425 wv.faviconSourceVisitorsMu.Lock()
426 wv.faviconSourceVisitors = append(wv.faviconSourceVisitors, faviconSourceVisitorRetention{id: id, visitor: visitor})
427 wv.faviconSourceVisitorsMu.Unlock()
428 return func() {
429 wv.faviconSourceVisitorsMu.Lock()
430 defer wv.faviconSourceVisitorsMu.Unlock()
431 for i, candidate := range wv.faviconSourceVisitors {
432 if candidate.id != id {
433 continue
434 }
435 last := len(wv.faviconSourceVisitors) - 1
436 copy(wv.faviconSourceVisitors[i:], wv.faviconSourceVisitors[i+1:])
437 wv.faviconSourceVisitors[last] = faviconSourceVisitorRetention{}
438 wv.faviconSourceVisitors = wv.faviconSourceVisitors[:last]
439 return
440 }
441 }
442}
443
444func (h *handlerSet) emitDiscoveredFaviconCandidates(browser purecef.Browser, pageURL, committedURL string, cb *port.WebViewCallbacks) {
445 if h == nil || h.wv == nil || cb == nil || cb.OnFaviconURLChanged == nil {
446 return
447 }
448 h.wv.mu.Lock()
449 documentSeq := h.wv.documentSeq
450 if h.wv.faviconEngineDocumentSeq == documentSeq || h.wv.faviconSourceCompletedDocumentSeq == documentSeq ||
451 h.wv.faviconSourcePendingToken != 0 || documentSeq == 0 {
452 h.wv.mu.Unlock()
453 return
454 }
455 h.wv.faviconSourceRequestSerial++
456 token := h.wv.faviconSourceRequestSerial
457 h.wv.faviconSourcePendingToken = token
458 h.wv.mu.Unlock()
459 finish := func() {
460 h.wv.mu.Lock()
461 if h.wv.documentSeq == documentSeq && h.wv.faviconSourcePendingToken == token {
462 h.wv.faviconSourcePendingToken = 0
463 h.wv.faviconSourceCompletedDocumentSeq = documentSeq
464 }
465 h.wv.mu.Unlock()
466 }
467 fallback := fallbackFaviconCandidates(committedURL)
468 emit := func(candidates []string) {
469 if len(candidates) == 0 {
470 return
471 }
472 h.wv.runOnGTK(func() {
473 if !h.faviconCallbackCurrent(cb, browser, documentSeq, pageURL) {
474 return
475 }
476 cb.OnFaviconURLChanged(pageURL, candidates)
477 })
478 }
479 if browser == nil {
480 finish()
481 emit(fallback)
482 return
483 }
484 frame := browser.GetMainFrame()
485 if frame == nil {
486 finish()
487 emit(fallback)
488 return
489 }
490 var releaseOnce sync.Once
491 var release func()
492 visitor := cefNewStringVisitor(faviconSourceVisitor{visit: func(source string) {
493 releaseOnce.Do(func() {
494 if release != nil {
495 release()
496 }
497 })
498 finish()
499 if !h.faviconCallbackCurrent(cb, browser, documentSeq, pageURL) {
500 return
501 }
502 h.wv.mu.RLock()
503 engineWon := h.wv.faviconEngineDocumentSeq == documentSeq
504 h.wv.mu.RUnlock()
505 if engineWon {
506 return
507 }
508 candidates := DiscoverFaviconCandidates(committedURL, source)
509 if len(candidates) == 0 {
510 candidates = fallback
511 }
512 emit(candidates)
513 }})
514 release = h.wv.retainFaviconSourceVisitor(visitor)
515 time.AfterFunc(5*time.Second, func() {
516 releaseOnce.Do(func() {
517 if release != nil {
518 release()
519 }
520 })
521 finish()
522 })
523 frame.GetSource(visitor)
524}
525
526// OnLoadStart fires LoadCommitted for main frame navigations.
527// In CEF this callback runs after navigation commit, so it is the closest
528// equivalent to WebKit's LoadCommitted event.
529func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purecef.TransitionType) {
530 if frame == nil || !frame.IsMain() {
531 return
532 }
533 // Post-commit fallback for navigation paths that bypass OnBeforeBrowse
534 // (same-page, error, and helper-driven commits). Main frame only.
535 if h.wv != nil {
536 // A new document replaces the page that asked for any open JS dialog.
537 h.wv.cancelJSDialogs()
538 h.wv.invalidateScrollMotion()
539 h.wv.endVimPageInteractionOnNavigation()
540 h.wv.mu.Lock()
541 h.wv.documentSeq++
542 h.wv.faviconSourcePendingToken = 0
543 h.wv.faviconSourceCompletedDocumentSeq = 0
544 h.wv.mu.Unlock()
545 }
546 bridgeNonce := h.wv.ensureBridgeNonce()
547 if openerBridgeScript := h.wv.popupOpenerBridgeScript(bridgeNonce); openerBridgeScript != "" {
548 if h.wv != nil && h.wv.ctx != nil {
549 parentURI, active, blocked := h.wv.popupOpenerBridgeState()
550 logging.FromContext(h.wv.ctx).Debug().
551 Uint64("webview_id", uint64(h.wv.id)).
552 Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).
553 Str("parent_uri", logging.TruncateURL(parentURI, logging.PermissionLogURLMaxLen)).
554 Bool("bridge_active", active).
555 Bool("bridge_blocked", blocked).
556 Msg("cef: injecting popup opener bridge")
557 }
558 frame.ExecuteJavaScript(openerBridgeScript, frame.GetURL(), 0)
559 }
560 if h.wv != nil && h.wv.ctx != nil {
561 logging.FromContext(h.wv.ctx).Debug().
562 Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).
563 Msg("cef: OnLoadStart")
564 }
565 h.wv.setEditableFocus(false)
566 h.wv.updateURI(frame.GetURL())
567 h.wv.mu.RLock()
568 cb := h.wv.callbacks
569 h.wv.mu.RUnlock()
570 if cb != nil && cb.OnLoadChanged != nil {
571 h.wv.runOnGTK(func() {
572 cb.OnLoadChanged(port.LoadCommitted)
573 })
574 }
575}
576
577// OnLoadEnd resets crash count and injects content scripts for the main frame.
578func (h *handlerSet) OnLoadEnd(browser purecef.Browser, frame purecef.Frame, httpStatusCode int32) {
579 log := logging.FromContext(h.wv.ctx)
580 log.Debug().
581 Bool("frame_nil", frame == nil).
582 Bool("is_main", frame != nil && frame.IsMain()).
583 Int32("http_status", httpStatusCode).
584 Msg("cef: OnLoadEnd")
585 if frame == nil || !frame.IsMain() {
586 return
587 }
588 // Successful load — reset the consecutive crash counter.
589 h.wv.crashCount.Store(0)
590
591 // If a queued startup navigation is still pending after about:blank finished,
592 // replay it now that the initial main-frame load completed.
593 frameURL := frame.GetURL()
594 pendingURI, pendingIntentID := h.wv.pendingNavigationSnapshot()
595 if pendingURI != "" && !pendingURIEquivalent(frameURL, pendingURI) {
596 if strings.EqualFold(strings.TrimSpace(frameURL), "about:blank") {
597 rearmed := h.wv.rearmPendingNavigationAfterBlankLoadEnd(pendingIntentID)
598 log.Info().
599 Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
600 Bool("rearmed", rearmed).
601 Msg("cef: replaying pending navigation after about:blank load end")
602 h.wv.schedulePendingNavigationReplayForIntent(0, pendingIntentID)
603 }
604 }
605
606 // Inject scripts and styles after page load.
607 // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread,
608 // and JavaScript injection requires the main thread.
609 if cefScaleProbeEnabled() && shouldRunCEFScaleProbe(frameURL, httpStatusCode) {
610 // Do not retain/use CEF callback-scoped frame wrappers across the GTK idle hop.
611 // Re-read the current main frame through WebView.RunJavaScript instead.
612 h.wv.runOnGTK(func() {
613 snapshot := cefScaleProbeSnapshot(h.wv)
614 event := logging.FromContext(h.wv.ctx).Debug()
615 for key, value := range snapshot.logFields() {
616 event = event.Interface(key, value)
617 }
618 event.Msg("cef: injecting scale probe")
619 h.wv.RunJavaScript(context.Background(), cefScaleProbeScript)
620 })
621 }
622 if h.wv.engine != nil && h.wv.engine.contentInj != nil {
623 // Capture navigation identity for the GTK hop from the callback's
624 // browser: a process swap or a newer navigation can stale this
625 // event before it runs.
626 event := h.wv.captureInjectionEvent(browser, frameURL)
627 h.wv.runOnGTK(func() {
628 h.wv.engine.contentInj.onLoadEndForEvent(h.wv, event)
629 })
630 }
631
632 // NOTE: We intentionally do NOT dispatch LoadFinished or ProgressChanged(1.0)
633 // here. OnLoadEnd is a per-frame event, and during cross-site navigations
634 // (process swap), CEF fires OnLoadEnd for the OLD main frame before the new
635 // page finishes loading. Dispatching LoadFinished here caused the progress
636 // bar to hide prematurely while the new page was still loading.
637 //
638 // OnLoadingStateChange(isLoading=false) is the correct browser-level signal
639 // for load completion — CEF guarantees it fires AFTER all OnLoadEnd calls.
640 // CEF's OnLoadingProgressChange also provides progress=1.0 at completion.
641}
642
643const cefErrAborted purecef.Errorcode = -3
644
645// OnLoadError handles failed main-frame navigations that never reach
646// OnLoadStart. CEF still sends OnLoadingStateChange(false) after load errors,
647// so this callback only synthesizes the missing committed event needed to reveal
648// the pane; the normal browser-level loading-state callback remains responsible
649// for the terminal LoadFinished state.
650func (h *handlerSet) OnLoadError(_ purecef.Browser, frame purecef.Frame, errorCode purecef.Errorcode, errorText, failedURL string) {
651 if h == nil || h.wv == nil || frame == nil || !frame.IsMain() {
652 return
653 }
654 if errorCode == cefErrAborted {
655 return
656 }
657 if failedURL == "" {
658 failedURL = frame.GetURL()
659 }
660
661 h.wv.mu.RLock()
662 currentURI := h.wv.uri
663 cb := h.wv.callbacks
664 h.wv.mu.RUnlock()
665
666 if h.wv.ctx != nil {
667 logging.FromContext(h.wv.ctx).Debug().
668 Int32("error_code", errorCode).
669 Str("error_text", errorText).
670 Str("failed_url", logging.TruncateURL(failedURL, logging.PermissionLogURLMaxLen)).
671 Msg("cef: OnLoadError")
672 }
673
674 if failedURL != "" {
675 h.wv.updateURI(failedURL)
676 }
677
678 if cb == nil || cb.OnLoadChanged == nil {
679 return
680 }
681 if failedURL != "" && !pendingURIEquivalent(currentURI, failedURL) {
682 h.wv.runOnGTK(func() {
683 cb.OnLoadChanged(port.LoadCommitted)
684 })
685 }
686}
687
688func mapCEFPopupFeatures(in *purecef.PopupFeatures) dto.PopupFeatures {
689 if in == nil {
690 return dto.PopupFeatures{State: dto.PopupFeaturesNone}
691 }
692
693 state := dto.PopupFeaturesNone
694 if in.Xset != 0 || in.Yset != 0 || in.Widthset != 0 || in.Heightset != 0 || in.Ispopup != 0 {
695 state = dto.PopupFeaturesSpecified
696 }
697 return dto.PopupFeatures{
698 State: state,
699 X: int(in.X),
700 Y: int(in.Y),
701 Width: int(in.Width),
702 Height: int(in.Height),
703 XSet: in.Xset != 0,
704 YSet: in.Yset != 0,
705 WidthSet: in.Widthset != 0,
706 HeightSet: in.Heightset != 0,
707 IsPopup: in.Ispopup != 0,
708 IsPopupSet: true,
709 }
710}
711
712func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition {
713 switch disposition {
714 case purecef.WindowOpenDispositionWodCurrentTab:
715 return dto.WindowDispositionCurrentTab
716 case purecef.WindowOpenDispositionWodNewPopup:
717 return dto.WindowDispositionNewPopup
718 case purecef.WindowOpenDispositionWodNewWindow:
719 return dto.WindowDispositionNewWindow
720 case purecef.WindowOpenDispositionWodNewForegroundTab,
721 purecef.WindowOpenDispositionWodNewBackgroundTab,
722 purecef.WindowOpenDispositionWodSingletonTab,
723 purecef.WindowOpenDispositionWodSwitchToTab:
724 return dto.WindowDispositionNewTab
725 default:
726 return ""
727 }
728}
729
730// ===========================================================================
731// LifeSpanHandler (6 methods)
732// ===========================================================================
733
734// OnBeforePopup intercepts popup requests (target="_blank", window.open).
735// When the coordinator returns a related CEF popup shell we hand that shell's
736// client back to CEF and allow native popup creation so opener semantics are
737// preserved. Otherwise we keep blocking and let the coordinator's fallback
738// pane handle the navigation.
739func (h *handlerSet) OnBeforePopup(
740 browser purecef.Browser, frame purecef.Frame, popupID int32, targetURL, targetFrameName string,
741 targetDisposition purecef.WindowOpenDisposition, userGesture int32, popupFeatures *purecef.PopupFeatures,
742 windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings,
743 _ *purecef.DictionaryValue, noJavaScriptAccess *bool,
744) bool {
745 if targetURL == "" {
746 return true
747 }
748
749 h.wv.mu.RLock()
750 cb := h.wv.callbacks
751 h.wv.mu.RUnlock()
752 if cb == nil || cb.OnCreate == nil {
753 return true
754 }
755
756 requestNoJavaScriptAccess := false
757 if noJavaScriptAccess != nil {
758 requestNoJavaScriptAccess = *noJavaScriptAccess
759 }
760 var popup port.WebView
761 sourceBrowserID := int32(0)
762 if browser != nil {
763 sourceBrowserID = browser.GetIdentifier()
764 }
765 sourceFrameID := ""
766 sourceFrameURL := ""
767 if frame != nil {
768 sourceFrameID = frame.GetIdentifier()
769 sourceFrameURL = frame.GetURL()
770 }
771
772 dispatchResult := h.wv.runOnGTKSyncLabel("cef.on_before_popup", func() {
773 popup = cb.OnCreate(port.PopupRequest{
774 Engine: dto.BrowserEngineCEF,
775 PopupFeatures: mapCEFPopupFeatures(popupFeatures),
776 TargetURI: targetURL,
777 FrameName: targetFrameName,
778 IsUserGesture: userGesture != 0,
779 NoJavaScriptAccess: requestNoJavaScriptAccess,
780 ParentViewID: h.wv.id,
781 SourceBrowserID: sourceBrowserID,
782 SourceFrameID: sourceFrameID,
783 SourceFrameURL: sourceFrameURL,
784 TargetDisposition: mapCEFWindowDisposition(targetDisposition),
785 })
786 })
787 if !dispatchResult.Completed() {
788 logging.FromContext(h.currentContext()).Warn().
789 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
790 Str("target_frame", targetFrameName).
791 Dur("elapsed", dispatchResult.Elapsed).
792 Str("dispatch_status", string(dispatchResult.Status)).
793 Msg("cef: blocked popup after GTK dispatch did not complete")
794 return true
795 }
796
797 cefPopup, ok := popup.(*WebView)
798 if !ok || cefPopup == nil {
799 return true
800 }
801
802 decision := dto.HostDecision{}
803 if carrier, ok := popup.(port.BrowsingContextHostDecisionCapable); ok {
804 if got, hasDecision := carrier.BrowsingContextHostDecision(); hasDecision {
805 decision = got
806 }
807 }
808
809 return h.handlePopupHostDecision(
810 cefPopup,
811 decision,
812 requestNoJavaScriptAccess,
813 popupID,
814 targetURL,
815 targetDisposition,
816 windowInfo,
817 clientSlot,
818 settings,
819 )
820}
821
822func (h *handlerSet) handlePopupHostDecision(
823 cefPopup *WebView,
824 decision dto.HostDecision,
825 requestNoJavaScriptAccess bool,
826 popupID int32,
827 targetURL string,
828 targetDisposition purecef.WindowOpenDisposition,
829 windowInfo *purecef.WindowInfo,
830 clientSlot *purecef.RawClientWriteSlot,
831 settings *purecef.BrowserSettings,
832) bool {
833 cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess)
834 switch decision.Kind {
835 case dto.HostDecisionCreateNativePopup:
836 if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {
837 logging.FromContext(h.currentContext()).Debug().
838 Int32("popup_id", popupID).
839 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
840 Msg("cef: native popup armed")
841 return false
842 }
843
844 fallbackEligible := !decision.RequiresNativeOpener && decision.ReasonCode != dto.HostDecisionReasonAuthNativePopup
845 if fallbackEligible {
846 // The Phase 3 abort callback may synchronously detach and adopt this
847 // shell. Arm direct creation first so the adopted shell can create a
848 // browser and retain the synthetic opener bridge when allowed.
849 cefPopup.preparePopupShellDirectBrowserCreation()
850 }
851 abortInvoked := cefPopup.abortNativePopupHost()
852 if !abortInvoked {
853 cefPopup.Destroy()
854 }
855 logging.FromContext(h.currentContext()).Warn().
856 Str("engine", string(dto.BrowserEngineCEF)).
857 Str("source_host", string(decision.SourceHost)).
858 Str("decision", string(decision.Kind)).
859 Str("target_disposition", string(mapCEFWindowDisposition(targetDisposition))).
860 Str("reason_code", string(dto.BrowsingContextFailureNativeArm)).
861 Int32("popup_id", popupID).
862 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
863 Bool("fallback_eligible", fallbackEligible).
864 Bool("host_abort_invoked", abortInvoked).
865 Msg("cef: native popup arming failed")
866 return true
867 case dto.HostDecisionCreateBrowserWindow,
868 dto.HostDecisionCreatePane,
869 dto.HostDecisionReuseNamedPane:
870 // CEF's native popup is blocked. A related shell adopted by a Dumber
871 // host must create its browser directly; noopener controls whether the
872 // synthetic opener/postMessage bridge is installed.
873 directCreationPrepared := cefPopup.preparePopupShellDirectBrowserCreation()
874 if !directCreationPrepared && cefPopup.isNativePopupCandidate() {
875 // Clear only a still-unprepared candidate; an already-started fallback
876 // owns its opener state and must remain intact.
877 cefPopup.discardNativePopupCandidate()
878 }
879 logging.FromContext(h.currentContext()).Debug().
880 Int32("popup_id", popupID).
881 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
882 Str("decision", string(decision.Kind)).
883 Bool("direct_creation_prepared", directCreationPrepared).
884 Msg("cef: blocking native popup for related shell browser creation")
885 return true
886 case dto.HostDecisionAwaitPopupFeatures, dto.HostDecisionDeny:
887 cefPopup.Destroy()
888 logging.FromContext(h.currentContext()).Warn().
889 Int32("popup_id", popupID).
890 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
891 Str("decision", string(decision.Kind)).
892 Msg("cef: denied popup host decision")
893 return true
894 default:
895 if !cefPopup.preparePopupShellDirectBrowserCreation() {
896 cefPopup.discardNativePopupCandidate()
897 }
898 logging.FromContext(h.currentContext()).Debug().
899 Int32("popup_id", popupID).
900 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
901 Msg("cef: missing host decision, blocking native popup and using popup shell browser creation")
902 return true
903 }
904}
905
906func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, popupID int32) {
907 if popup := h.wv.takePendingNativePopup(popupID); popup != nil {
908 popup.handleNativePopupAborted()
909 }
910}
911
912func (h *handlerSet) OnBeforeDevToolsPopup(
913 _ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot,
914 _ *purecef.BrowserSettings, _ *purecef.DictionaryValue, _ *bool,
915) {
916}
917
918// OnAfterCreated stores the browser and host references and enables input.
919func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
920 log := logging.FromContext(h.wv.ctx)
921 if browser == nil {
922 log.Warn().Msg("cef: OnAfterCreated called with nil browser")
923 if h.wv.engine != nil {
924 // No browser will ever complete this creation: resolve the
925 // pending count so quiescence cannot strand on it.
926 h.wv.engine.activity.NoteCreationResolved()
927 }
928 return
929 }
930 browserID := browser.GetIdentifier()
931 log.Debug().Int32("browser_id", browserID).Msg("cef: OnAfterCreated")
932
933 host := browser.GetHost()
934 if host == nil {
935 log.Warn().Msg("cef: OnAfterCreated returned nil host")
936 if h.wv.engine != nil {
937 // No view will ever attach to this browser: resolve the
938 // pending count so quiescence cannot strand on it.
939 h.wv.engine.activity.NoteCreationResolved()
940 }
941 return
942 }
943
944 state := h.attachAfterCreatedBrowser(browser, host, browserID)
945 if state.closeDuplicate {
946 log.Warn().
947 Int32("browser_id", browserID).
948 Int32("existing_browser_id", state.duplicateBrowserID).
949 Msg("cef: duplicate popup browser attached after shell already had a browser; closing duplicate")
950 if h.wv.engine != nil {
951 h.wv.engine.activity.NoteCreationResolved()
952 }
953 host.CloseBrowser(1)
954 return
955 }
956
957 h.finishAfterCreated(browser, host, state)
958}
959
960func (h *handlerSet) attachAfterCreatedBrowser(
961 browser purecef.Browser,
962 host purecef.BrowserHost,
963 browserID int32,
964) afterCreatedState {
965 state := afterCreatedState{}
966 h.wv.mu.Lock()
967
968 if existing := h.wv.browser; existing != nil {
969 existingID := existing.GetIdentifier()
970 if existingID != 0 && existingID != browserID {
971 state.closeDuplicate = true
972 state.duplicateBrowserID = existingID
973 h.wv.mu.Unlock()
974 return state
975 }
976 }
977
978 h.wv.browser = browser
979 h.wv.host = host
980 h.wv.clearAppliedZoomCompensation()
981 h.wv.pendingCreate = nil
982 bridge := h.wv.viewBridge
983 h.wv.inputAttached = bridge == nil
984 state.inputAttached = h.wv.inputAttached
985 if h.wv.findCtrl != nil {
986 h.wv.findCtrl.setHost(host)
987 }
988 state.nativePopupParent = h.wv.nativePopupParent
989 state.nativePopupID = h.wv.nativePopupID
990 h.wv.nativePopupParent = nil
991 h.wv.nativePopupID = 0
992 h.wv.nativePopupFallbackStarted = false
993 state.hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
994 h.wv.mu.Unlock()
995
996 if bridge != nil {
997 wv := h.wv
998 log := logging.FromContext(wv.ctx)
999 log.Debug().
1000 Uint64("webview_id", uint64(wv.id)).
1001 Int32("browser_id", browserID).
1002 Bool("destroyed", wv.destroyed.Load()).
1003 Bool("bridge_present", true).
1004 Msg("cef: scheduling input bridge attachment")
1005 wv.runOnGTK(func() {
1006 currentBridge := wv.viewBridge
1007 log.Debug().
1008 Uint64("webview_id", uint64(wv.id)).
1009 Int32("browser_id", browserID).
1010 Bool("destroyed", wv.destroyed.Load()).
1011 Bool("bridge_present", currentBridge != nil).
1012 Bool("bridge_stale", currentBridge != bridge).
1013 Msg("cef: starting input bridge attachment")
1014 if wv.destroyed.Load() || currentBridge == nil || currentBridge != bridge || bridge.IsDestroyed() {
1015 wv.handleInputAttachFailure(ErrAdapterDestroyed, host)
1016 return
1017 }
1018 if err := bridge.AttachInputToWidget(host, wv.nativeWidget, wv.bridgeInputOptions()); err != nil {
1019 wv.handleInputAttachFailure(err, host)
1020 return
1021 }
1022 bridgeFocused := bridge.HasFocus()
1023 if bridgeFocused {
1024 syncWindowlessBrowserFocus(host)
1025 } else {
1026 host.Invalidate(purecef.PaintElementTypePetView)
1027 }
1028 wv.markInputAttached()
1029 log.Debug().
1030 Uint64("webview_id", uint64(wv.id)).
1031 Int32("browser_id", browserID).
1032 Bool("destroyed", wv.destroyed.Load()).
1033 Bool("bridge_present", wv.viewBridge != nil).
1034 Bool("bridge_focused", bridgeFocused).
1035 Msg("cef: input bridge attachment succeeded")
1036 })
1037 }
1038
1039 // CEF OSR starts hidden. SyncViewport reads GTK's mapped, ancestor-aware
1040 // effective state on the main context and reports that truthful initial state.
1041 if h.wv != nil {
1042 if h.wv.ctx != nil {
1043 logging.FromContext(h.wv.ctx).Info().
1044 Int32("windowless_frame_rate_configured", host.GetWindowlessFrameRate()).
1045 Msg("cef: browser initial/configured windowless frame rate")
1046 }
1047 h.wv.SyncViewport(h.wv.ctx, "after-created")
1048 }
1049 return state
1050}
1051
1052func (wv *WebView) handleInputAttachFailure(err error, host purecef.BrowserHost) {
1053 if wv == nil || err == nil {
1054 return
1055 }
1056 if wv.ctx != nil {
1057 browserID := int32(0)
1058 if host != nil {
1059 if browser := host.GetBrowser(); browser != nil {
1060 browserID = browser.GetIdentifier()
1061 }
1062 }
1063 logging.FromContext(wv.ctx).Warn().Err(err).
1064 Uint64("webview_id", uint64(wv.id)).
1065 Int32("browser_id", browserID).
1066 Bool("destroyed", wv.destroyed.Load()).
1067 Bool("bridge_present", wv.viewBridge != nil).
1068 Msg("cef: input bridge attachment failed")
1069 }
1070 if host != nil && !wv.destroyed.Load() {
1071 host.CloseBrowser(1)
1072 return
1073 }
1074 wv.runCloseCallbacks()
1075}
1076
1077func (h *handlerSet) finishAfterCreated(
1078 browser purecef.Browser,
1079 host purecef.BrowserHost,
1080 state afterCreatedState,
1081) {
1082 if h.wv.engine != nil {
1083 h.wv.engine.recordBrowserAfterCreated(browser)
1084 h.wv.engine.registerWebView(h.wv)
1085 h.wv.engine.bindBrowserWebView(browser, h.wv)
1086 }
1087 h.wv.stopNativePopupFallbackTimer()
1088 if state.inputAttached {
1089 // Request an initial OSR frame even before the first real navigation
1090 // commits. This restores the about:blank warm-up paint that the stable
1091 // startup path relied on and prevents the rendering bridge from staying idle.
1092 host.Invalidate(purecef.PaintElementTypePetView)
1093 }
1094 if state.hasPendingNavigation {
1095 h.wv.schedulePendingNavigationReplay()
1096 }
1097 if state.nativePopupParent != nil && state.nativePopupID != 0 {
1098 state.nativePopupParent.clearPendingNativePopup(state.nativePopupID, h.wv)
1099 }
1100 h.wv.scheduleStartAdaptiveFrameRatePolling()
1101 h.wv.scheduleStartBeginFrameLoop()
1102 h.wv.applyAccessibilityIfReady()
1103 h.wv.fireReadyToShow()
1104}
1105
1106type afterCreatedState struct {
1107 inputAttached bool
1108 hasPendingNavigation bool
1109 nativePopupParent *WebView
1110 nativePopupID int32
1111 duplicateBrowserID int32
1112 closeDuplicate bool
1113}
1114
1115// DoClose returns false to allow the default close behavior.
1116func (h *handlerSet) DoClose(_ purecef.Browser) bool {
1117 return false
1118}
1119
1120// OnBeforeClose fires the OnClose callback.
1121func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
1122 h.denyPendingPermissions()
1123 if h.wv.engine != nil {
1124 browserID := int32(0)
1125 if browser != nil {
1126 browserID = browser.GetIdentifier()
1127 }
1128 h.wv.engine.unregisterWebView(h.wv, browserID)
1129 }
1130 h.wv.mu.Lock()
1131 h.wv.browser = nil
1132 h.wv.host = nil
1133 h.wv.clearAppliedZoomCompensation()
1134 h.wv.inputAttached = false
1135 if h.wv.findCtrl != nil {
1136 h.wv.findCtrl.setHost(nil)
1137 }
1138 h.wv.mu.Unlock()
1139 if h.wv.destroyed.Load() {
1140 h.wv.destroyViewBridgeOnGTKAsync()
1141 } else if h.wv.viewBridge != nil {
1142 wv := h.wv
1143 wv.runOnGTK(func() {
1144 if wv.viewBridge == nil {
1145 return
1146 }
1147 if err := wv.viewBridge.SetInputHost(nil); err != nil && wv.ctx != nil {
1148 logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: failed to clear cef2gtk input host")
1149 }
1150 })
1151 }
1152 h.wv.scheduleStopAdaptiveFrameRatePolling()
1153 h.wv.scheduleStopBeginFrameLoop()
1154
1155 h.wv.mu.RLock()
1156 cb := h.wv.callbacks
1157 h.wv.mu.RUnlock()
1158 if cb != nil && cb.OnClose != nil {
1159 h.wv.runOnGTK(func() {
1160 cb.OnClose()
1161 })
1162 }
1163 h.wv.runCloseCallbacks()
1164}
1165
1166// ===========================================================================
1167// RequestHandler (11 methods)
1168// ===========================================================================
1169
1170func (h *handlerSet) OnBeforeBrowse(browser purecef.Browser, frame purecef.Frame, request purecef.Request, _, _ int32) bool {
1171 if frame == nil || !frame.IsMain() || request == nil {
1172 return false
1173 }
1174 // Pre-commit invalidation for every main-frame navigation. The allow /
1175 // cancel decision below is unchanged; subframe navigations never reach
1176 // this point and do not cancel the main page's gesture.
1177 if h.wv != nil {
1178 h.wv.invalidateScrollMotion()
1179 }
1180
1181 handler := h.downloadHandler()
1182 if handler == nil || !strings.EqualFold(request.GetMethod(), "GET") {
1183 return false
1184 }
1185
1186 url := request.GetURL()
1187 if !downloadutil.ShouldForceDownloadForURI(url) || browser == nil {
1188 return false
1189 }
1190
1191 host := browser.GetHost()
1192 if host == nil {
1193 return false
1194 }
1195
1196 logging.FromContext(h.currentContext()).Debug().
1197 Str("url", logging.TruncateURL(url, maxSchemeTruncatedURLLength)).
1198 Msg("cef: forcing download for navigation")
1199
1200 host.StartDownload(url)
1201 return true
1202}
1203
1204func (h *handlerSet) CanDownload(browser purecef.Browser, url, requestMethod string) bool {
1205 handler := h.downloadHandler()
1206 if handler == nil {
1207 return false
1208 }
1209 return handler.canDownload(browser, url, requestMethod)
1210}
1211
1212func (h *handlerSet) OnBeforeDownload(
1213 browser purecef.Browser,
1214 downloadItem purecef.DownloadItem,
1215 suggestedName string,
1216 callback purecef.BeforeDownloadCallback,
1217) bool {
1218 handler := h.downloadHandler()
1219 if handler == nil {
1220 return false
1221 }
1222 return handler.onBeforeDownload(h.currentContext(), browser, downloadItem, suggestedName, callback)
1223}
1224
1225func (h *handlerSet) OnDownloadUpdated(
1226 browser purecef.Browser,
1227 downloadItem purecef.DownloadItem,
1228 callback purecef.DownloadItemCallback,
1229) {
1230 handler := h.downloadHandler()
1231 if handler == nil {
1232 return
1233 }
1234 handler.onDownloadUpdated(h.currentContext(), browser, downloadItem, callback)
1235}
1236
1237func (h *handlerSet) downloadHandler() *downloadHandler {
1238 if h == nil || h.wv == nil || h.wv.engine == nil {
1239 return nil
1240 }
1241 return h.wv.engine.currentDownloadHandler()
1242}
1243
1244func (h *handlerSet) currentContext() context.Context {
1245 if h == nil || h.wv == nil || h.wv.ctx == nil {
1246 return context.Background()
1247 }
1248 return h.wv.ctx
1249}
1250
1251func (h *handlerSet) OnOpenUrlfromTab(
1252 _ purecef.Browser,
1253 _ purecef.Frame,
1254 _ string,
1255 _ purecef.WindowOpenDisposition,
1256 _ int32,
1257) int32 {
1258 return 0
1259}
1260
1261func (h *handlerSet) GetResourceRequestHandler(
1262 _ purecef.Browser, _ purecef.Frame, _ purecef.Request,
1263 _, _ int32, _ string, _ *int32,
1264) purecef.ResourceRequestHandler {
1265 return nil
1266}
1267
1268func (h *handlerSet) GetAuthCredentials(
1269 _ purecef.Browser, _ string, _ int32, _ string, _ int32,
1270 _, _ string, _ purecef.AuthCallback,
1271) int32 {
1272 return 0
1273}
1274
1275func (h *handlerSet) OnCertificateError(
1276 _ purecef.Browser,
1277 _ purecef.Errorcode,
1278 _ string,
1279 _ purecef.Sslinfo,
1280 _ purecef.Callback,
1281) int32 {
1282 return 0
1283}
1284
1285func (h *handlerSet) OnSelectClientCertificate(
1286 _ purecef.Browser, _ int32, _ string, _ int32,
1287 _ []purecef.X509Certificate, _ purecef.SelectClientCertificateCallback,
1288) int32 {
1289 return 0
1290}
1291
1292func (h *handlerSet) OnRenderViewReady(_ purecef.Browser) {}
1293
1294func (h *handlerSet) OnRenderProcessUnresponsive(_ purecef.Browser, _ purecef.UnresponsiveProcessCallback) int32 {
1295 return 0
1296}
1297
1298func (h *handlerSet) OnRenderProcessResponsive(_ purecef.Browser) {}
1299
1300// maxConsecutiveCrashes caps how many times OnRenderProcessTerminated will
1301// forward the event before suppressing further notifications. This prevents
1302// infinite crash → redirect → crash loops.
1303const maxConsecutiveCrashes = 3
1304
1305// OnRenderProcessTerminated fires the OnWebProcessTerminated callback with a mapped reason.
1306func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef.TerminationStatus, _ int32, _ string) {
1307 // CEF never resets dialog state on a renderer crash; don't leave the UI
1308 // (and the dead page's callback) hanging.
1309 h.wv.cancelJSDialogs()
1310
1311 // The page runtime died with the renderer and can no longer report its end.
1312 h.wv.endVimPageInteractionOnNavigation()
1313 if h.wv.crashCount.Add(1) > maxConsecutiveCrashes {
1314 return // suppress to break the loop
1315 }
1316
1317 var reason port.WebProcessTerminationReason
1318 var label string
1319 switch status {
1320 case purecef.TerminationStatusTsAbnormalTermination:
1321 reason = port.WebProcessTerminationCrashed
1322 label = "abnormal termination"
1323 case purecef.TerminationStatusTsProcessWasKilled:
1324 reason = port.WebProcessTerminationByAPI
1325 label = "killed"
1326 case purecef.TerminationStatusTsProcessCrashed:
1327 reason = port.WebProcessTerminationCrashed
1328 label = "crashed"
1329 case purecef.TerminationStatusTsProcessOom:
1330 reason = port.WebProcessTerminationExceededMemory
1331 label = "out of memory"
1332 default:
1333 reason = port.WebProcessTerminationUnknown
1334 label = "unknown"
1335 }
1336
1337 h.wv.mu.RLock()
1338 cb := h.wv.callbacks
1339 uri := h.wv.uri
1340 h.wv.mu.RUnlock()
1341 if cb != nil && cb.OnWebProcessTerminated != nil {
1342 h.wv.runOnGTK(func() {
1343 cb.OnWebProcessTerminated(reason, label, uri)
1344 })
1345 }
1346}
1347
1348func (h *handlerSet) OnDocumentAvailableInMainFrame(_ purecef.Browser) {}
1349
1350// ===========================================================================
1351// AudioHandler (5 methods)
1352// ===========================================================================
1353
1354func (h *handlerSet) GetAudioParameters(_ purecef.Browser, _ *purecef.AudioParameters) int32 {
1355 return 1 // proceed with defaults
1356}
1357
1358// OnAudioStreamStarted handles the start of an audio stream.
1359// The third callback argument is the channel count (not frames-per-buffer);
1360// frames-per-buffer comes from params.FramesPerBuffer.
1361func (h *handlerSet) OnAudioStreamStarted(_ purecef.Browser, params *purecef.AudioParameters, channels int32) {
1362 // Validate params and factory first
1363 if params == nil || h.wv.audioOutputFactory == nil {
1364 return
1365 }
1366
1367 // Build format from CEF parameters
1368 format := h.buildAudioStreamFormat(params, channels)
1369
1370 if h.wv.ctx != nil {
1371 logging.FromContext(h.wv.ctx).Info().
1372 Int("sample_rate", format.SampleRate).
1373 Int("channels", format.ChannelCount).
1374 Int("frames_per_buffer", format.FramesPerBuffer).
1375 Int32("channel_layout", params.ChannelLayout).
1376 Msg("cef: OnAudioStreamStarted")
1377 }
1378
1379 // Starting a stream invalidates both a previous publication and any older
1380 // creation already in flight. Close detached streams outside the mutex.
1381 generation, previous, accepted := h.wv.beginAudioStreamStart()
1382 if !accepted {
1383 return
1384 }
1385 h.wv.closeDetachedAudioStream(previous)
1386 h.wv.audioPacketCount.Store(0)
1387 h.wv.audioWriteCount.Store(0)
1388
1389 ctx := h.wv.ctx
1390 if ctx == nil {
1391 ctx = context.Background()
1392 }
1393 stream, err := h.wv.audioOutputFactory.NewStream(ctx, format)
1394 if err != nil {
1395 if h.wv.ctx != nil {
1396 logging.FromContext(h.wv.ctx).Warn().Err(err).
1397 Int("sample_rate", format.SampleRate).Int("channels", format.ChannelCount).
1398 Msg("cef: failed to create audio output stream")
1399 }
1400 return
1401 }
1402
1403 replaced, published := h.wv.publishAudioStream(generation, stream)
1404 if !published {
1405 // Stop, Destroy, or a newer Start won the race. This callback owns the
1406 // unpublishable stream and closes it exactly once.
1407 h.wv.closeDetachedAudioStream(&audioStreamHandle{stream: stream, generation: generation})
1408 return
1409 }
1410 h.wv.closeDetachedAudioStream(replaced)
1411 if h.wv.ctx != nil {
1412 logging.FromContext(h.wv.ctx).Info().
1413 Int("sample_rate", format.SampleRate).Int("channels", format.ChannelCount).
1414 Int("frames_per_buffer", format.FramesPerBuffer).
1415 Msg("cef: audio output stream created successfully")
1416 }
1417}
1418
1419// buildAudioStreamFormat converts CEF AudioParameters to port.AudioStreamFormat.
1420// channels is the authoritative channel count from the callback; params.FramesPerBuffer
1421// supplies the buffer size.
1422func (h *handlerSet) buildAudioStreamFormat(params *purecef.AudioParameters, channels int32) port.AudioStreamFormat {
1423 return port.AudioStreamFormat{
1424 SampleRate: int(params.SampleRate),
1425 ChannelCount: int(channels),
1426 FramesPerBuffer: int(params.FramesPerBuffer),
1427 }
1428}
1429
1430// OnAudioStreamPacket is a real-time callback: it atomically snapshots an
1431// immutable handle and performs only allocation-free atomic diagnostics. It
1432// never takes a lifecycle mutex or emits synchronous logs.
1433func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, data [][]float32, frames int32, _ int64) {
1434 if len(data) == 0 || frames <= 0 {
1435 return
1436 }
1437 handle := h.wv.audioStream.Load()
1438 if handle == nil || handle.stream == nil {
1439 return
1440 }
1441 h.wv.audioPacketCount.Add(1)
1442 if handle.stream.Write(data) == nil {
1443 h.wv.audioWriteCount.Add(1)
1444 }
1445}
1446
1447// OnAudioStreamStopped handles stream stop.
1448func (h *handlerSet) OnAudioStreamStopped(_ purecef.Browser) {
1449 if h.wv.ctx != nil {
1450 logging.FromContext(h.wv.ctx).Info().
1451 Uint64("packets_received", h.wv.audioPacketCount.Load()).
1452 Uint64("writes_succeeded", h.wv.audioWriteCount.Load()).
1453 Msg("cef: OnAudioStreamStopped")
1454 }
1455 h.wv.setAudioPlaying(false)
1456 h.wv.closeAudioStream()
1457}
1458
1459// OnAudioStreamError handles stream errors.
1460func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, message string) {
1461 if h.wv.ctx != nil {
1462 event := logging.FromContext(h.wv.ctx).Warn()
1463 logMsg := "cef: audio stream error"
1464 if h.wv.destroyed.Load() && message == "Socket closed unexpectedly" {
1465 event = logging.FromContext(h.wv.ctx).Debug()
1466 logMsg = "cef: audio stream closed during shutdown"
1467 }
1468 event.
1469 Str("error", message).
1470 Uint64("packets_received", h.wv.audioPacketCount.Load()).
1471 Uint64("writes_succeeded", h.wv.audioWriteCount.Load()).
1472 Msg(logMsg)
1473 }
1474 h.wv.setAudioPlaying(false)
1475 h.wv.closeAudioStream()
1476}
1477
1478// ===========================================================================
1479// FindHandler (1 method)
1480// ===========================================================================
1481
1482// OnFindResult dispatches CEF find results to the WebView's FindController.
1483func (h *handlerSet) OnFindResult(
1484 _ purecef.Browser,
1485 identifier, count int32,
1486 _ *purecef.Rect,
1487 activematchordinal, finalupdate int32,
1488) {
1489 if h.wv.findCtrl != nil {
1490 h.wv.findCtrl.handleFindResult(identifier, count, activematchordinal, finalupdate)
1491 }
1492}
1493
1494// cefCursorNames maps CEF cursor types to GDK/CSS cursor names.
1495var cefCursorNames = map[purecef.CursorType]string{
1496 purecef.CursorTypeCtPointer: "default",
1497 purecef.CursorTypeCtCross: "crosshair",
1498 purecef.CursorTypeCtHand: "pointer",
1499 purecef.CursorTypeCtIbeam: "text",
1500 purecef.CursorTypeCtWait: "wait",
1501 purecef.CursorTypeCtHelp: "help",
1502 purecef.CursorTypeCtEastresize: "e-resize",
1503 purecef.CursorTypeCtNorthresize: "n-resize",
1504 purecef.CursorTypeCtNortheastresize: "ne-resize",
1505 purecef.CursorTypeCtNorthwestresize: "nw-resize",
1506 purecef.CursorTypeCtSouthresize: "s-resize",
1507 purecef.CursorTypeCtSoutheastresize: "se-resize",
1508 purecef.CursorTypeCtSouthwestresize: "sw-resize",
1509 purecef.CursorTypeCtWestresize: "w-resize",
1510 purecef.CursorTypeCtNorthsouthresize: "ns-resize",
1511 purecef.CursorTypeCtEastwestresize: "ew-resize",
1512 purecef.CursorTypeCtNortheastsouthwestresize: "nesw-resize",
1513 purecef.CursorTypeCtNorthwestsoutheastresize: "nwse-resize",
1514 purecef.CursorTypeCtColumnresize: "col-resize",
1515 purecef.CursorTypeCtRowresize: "row-resize",
1516 purecef.CursorTypeCtMove: "move",
1517 purecef.CursorTypeCtProgress: "progress",
1518 purecef.CursorTypeCtNodrop: "no-drop",
1519 purecef.CursorTypeCtNotallowed: "not-allowed",
1520 purecef.CursorTypeCtGrab: "grab",
1521 purecef.CursorTypeCtGrabbing: "grabbing",
1522 purecef.CursorTypeCtZoomin: "zoom-in",
1523 purecef.CursorTypeCtZoomout: "zoom-out",
1524 purecef.CursorTypeCtNone: "none",
1525}
1526
1527// cefCursorToGDKName maps a CEF cursor type to a GDK/CSS cursor name.
1528func cefCursorToGDKName(ct purecef.CursorType) string {
1529 if name, ok := cefCursorNames[ct]; ok {
1530 return name
1531 }
1532 return "default"
1533}