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