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