1package cef
2
3import (
4 "context"
5 "strings"
6 "sync"
7
8 purecef "github.com/bnema/purego-cef/cef"
9
10 "github.com/bnema/dumber/internal/application/dto"
11 "github.com/bnema/dumber/internal/application/port"
12 downloadutil "github.com/bnema/dumber/internal/domain/download"
13 "github.com/bnema/dumber/internal/logging"
14)
15
16// Console message markers used by injected JavaScript for log filtering.
17const (
18 consoleMarkerVideoDiag = "[VIDEO-DIAG]"
19 consoleMarkerAutoCopy = "[AUTO-COPY]"
20 consoleMarkerScaleProbe = "[SCALE-PROBE]"
21)
22
23// handlerSet implements all CEF handler interfaces and dispatches events to the
24// owning WebView. A single struct is used so that the Client's Get*Handler
25// methods can return the same receiver, avoiding extra allocations.
26type handlerSet struct {
27 wv *WebView
28 fileDialogPresenter fileDialogPresenter
29 renderHandlerOnce sync.Once
30 renderHandler purecef.RenderHandler
31}
32
33// Compile-time interface checks.
34var (
35 _ purecef.Client = (*handlerSet)(nil)
36 _ purecef.DisplayHandler = (*handlerSet)(nil)
37 _ purecef.LoadHandler = (*handlerSet)(nil)
38 _ purecef.LifeSpanHandler = (*handlerSet)(nil)
39 _ purecef.RequestHandler = (*handlerSet)(nil)
40 _ purecef.AudioHandler = (*handlerSet)(nil)
41 _ purecef.ContextMenuHandler = (*handlerSet)(nil)
42 _ purecef.DialogHandler = (*handlerSet)(nil)
43 _ purecef.DownloadHandler = (*handlerSet)(nil)
44 _ purecef.FindHandler = (*handlerSet)(nil)
45)
46
47// ===========================================================================
48// Client
49// ===========================================================================
50
51func (h *handlerSet) GetAudioHandler() purecef.AudioHandler {
52 // Always return the audio handler — without it, CEF has no audio output
53 // path and refuses to start media decoding, breaking all video playback.
54 // The handler accepts audio but discards packets (no-op) until a real
55 // audio backend (PipeWire/PulseAudio) is wired up.
56 return h
57}
58func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }
59func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h }
60func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return h }
61func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }
62func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler {
63 if h.downloadHandler() != nil {
64 return h
65 }
66 return nil
67}
68func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil }
69func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }
70func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
71func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
72func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
73func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }
74func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
75func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }
76func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
77func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil }
78func (h *handlerSet) GetRenderHandler() purecef.RenderHandler {
79 if h == nil {
80 return nil
81 }
82 h.renderHandlerOnce.Do(func() {
83 h.renderHandler = newDumberRenderHandler(h.wv)
84 })
85 return h.renderHandler
86}
87func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
88
89func (h *handlerSet) OnProcessMessageReceived(
90 browser purecef.Browser,
91 _ purecef.Frame,
92 _ purecef.ProcessID,
93 message purecef.ProcessMessage,
94) int32 {
95 if h == nil || h.wv == nil || h.wv.engine == nil {
96 return 0
97 }
98 action, payload, ok := decodeRendererBridgeProcessMessage(message)
99 if !ok {
100 return 0
101 }
102
103 log := logging.FromContext(h.wv.ctx)
104 log.Debug().
105 Str("action", action).
106 Int("payload_len", len(payload)).
107 Msg("cef: renderer bridge message received")
108
109 switch action {
110 case rendererBridgeActionExplicitTextCopy:
111 req, err := decodeRendererBridgeExplicitTextCopyPayload([]byte(payload))
112 if err != nil {
113 log.Debug().Str("action", action).Msg("cef: invalid explicit copy payload")
114 return 1
115 }
116 h.wv.engine.handleExplicitClipboardBridgeText(h.wv.id, req.Action, req.Text)
117 case rendererBridgeActionEditableFocusChanged:
118 h.wv.setEditableFocus(payload == "1" || strings.EqualFold(payload, "true"))
119 case rendererBridgeActionFocusSync:
120 h.wv.engine.handleEditableFocusBridge(browser)
121 case rendererBridgeActionPopupOpen:
122 req, err := decodeRendererBridgePopupOpenPayload([]byte(payload))
123 if err != nil {
124 log.Debug().Str("action", action).Msg("cef: invalid popup_open payload")
125 return 1
126 }
127 h.wv.handleSyntheticPopupOpen(req.URL, req.FrameName, req.ProxyID, req.UserGesture, req.NoJavaScriptAccess)
128 case rendererBridgeActionPopupNavigate:
129 req, err := decodeRendererBridgePopupNavigatePayload([]byte(payload))
130 if err != nil {
131 log.Debug().Str("action", action).Msg("cef: invalid popup_navigate payload")
132 return 1
133 }
134 h.wv.handleSyntheticPopupNavigate(req.ProxyID, req.URL)
135 case rendererBridgeActionPopupClose:
136 req, err := decodeRendererBridgePopupClosePayload([]byte(payload))
137 if err != nil {
138 log.Debug().Str("action", action).Msg("cef: invalid popup_close payload")
139 return 1
140 }
141 h.wv.handleSyntheticPopupClose(req.ProxyID)
142 case rendererBridgeActionReady:
143 log.Debug().
144 Str("frame_url", logging.TruncateURL(payload, logging.PermissionLogURLMaxLen)).
145 Msg("cef: renderer bridge ready")
146 default:
147 log.Debug().Str("action", action).Msg("cef: unknown renderer bridge action")
148 }
149 return 1
150}
151
152// ===========================================================================
153// RenderHandler methods live in render_handler_adapter.go. handlerSet keeps
154// Dumber's non-render CEF handlers and returns a delegating render handler from
155// GetRenderHandler.
156// ===========================================================================
157
158// ===========================================================================
159// DisplayHandler (13 methods)
160// ===========================================================================
161
162// OnAddressChange updates the cached URI when the main frame navigates.
163func (h *handlerSet) OnAddressChange(_ purecef.Browser, frame purecef.Frame, url string) {
164 if frame != nil && frame.IsMain() {
165 if h.wv != nil && h.wv.ctx != nil {
166 logging.FromContext(h.wv.ctx).Debug().
167 Str("url", logging.TruncateURL(url, logging.PermissionLogURLMaxLen)).
168 Msg("cef: OnAddressChange")
169 }
170 h.wv.updateURI(url)
171 }
172}
173
174// OnTitleChange updates the cached title.
175func (h *handlerSet) OnTitleChange(_ purecef.Browser, title string) {
176 h.wv.updateTitle(title)
177}
178
179func (h *handlerSet) OnFaviconUrlchange(_ purecef.Browser, _ purecef.StringList) {}
180
181// OnFullscreenModeChange toggles the fullscreen atomic and fires callbacks.
182func (h *handlerSet) OnFullscreenModeChange(_ purecef.Browser, fullscreen int32) {
183 entering := fullscreen != 0
184 h.wv.fullscreen.Store(entering)
185
186 h.wv.mu.RLock()
187 cb := h.wv.callbacks
188 h.wv.mu.RUnlock()
189 if cb == nil {
190 return
191 }
192 if entering && cb.OnEnterFullscreen != nil {
193 h.wv.runOnGTK(func() {
194 cb.OnEnterFullscreen()
195 })
196 }
197 if !entering && cb.OnLeaveFullscreen != nil {
198 h.wv.runOnGTK(func() {
199 cb.OnLeaveFullscreen()
200 })
201 }
202}
203
204func (h *handlerSet) OnTooltip(_ purecef.Browser, _ uintptr) int32 { return 0 }
205
206// OnStatusMessage fires the OnLinkHover callback with the status text
207// and caches the hover URI for middle-click interception.
208func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) {
209 h.wv.updateHoverURI(value)
210
211 h.wv.mu.RLock()
212 cb := h.wv.callbacks
213 h.wv.mu.RUnlock()
214 if cb != nil && cb.OnLinkHover != nil {
215 h.wv.runOnGTK(func() {
216 cb.OnLinkHover(value)
217 })
218 }
219}
220
221func (h *handlerSet) OnConsoleMessage(
222 _ purecef.Browser,
223 level purecef.LogSeverity,
224 message, source string,
225 line int32,
226) int32 {
227 if h.wv != nil && h.wv.ctx != nil &&
228 (strings.Contains(message, consoleMarkerVideoDiag) ||
229 strings.Contains(message, consoleMarkerAutoCopy) ||
230 strings.Contains(message, consoleMarkerScaleProbe)) {
231 log := logging.FromContext(h.wv.ctx).With().
232 Str("component", "cef-console").
233 Str("source", source).
234 Int32("line", line).
235 Logger()
236 switch level {
237 case purecef.LogSeverityLogseverityError, purecef.LogSeverityLogseverityFatal:
238 log.Error().Str("message", message).Msg("cef: console message")
239 case purecef.LogSeverityLogseverityWarning:
240 log.Warn().Str("message", message).Msg("cef: console message")
241 default:
242 log.Info().Str("message", message).Msg("cef: console message")
243 }
244 }
245 return 0
246}
247
248func (h *handlerSet) OnAutoResize(_ purecef.Browser, _ *purecef.Size) int32 { return 0 }
249
250// OnLoadingProgressChange updates the cached progress value.
251func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64) {
252 if h.wv != nil && h.wv.ctx != nil {
253 logging.FromContext(h.wv.ctx).Trace().
254 Float64("progress", progress).
255 Msg("cef: OnLoadingProgressChange")
256 }
257 h.wv.updateProgress(progress)
258}
259
260func (h *handlerSet) OnCursorChange(
261 _ purecef.Browser,
262 _ uintptr,
263 cursorType purecef.CursorType,
264 _ *purecef.CursorInfo,
265) int32 {
266 if h == nil || h.wv == nil {
267 return 0
268 }
269 name := cefCursorToGDKName(cursorType)
270 h.wv.runOnGTK(func() {
271 if h.wv.viewBridge != nil {
272 h.wv.viewBridge.SetCursorFromName(name)
273 }
274 })
275 return 1 // handled
276}
277
278func (h *handlerSet) OnMediaAccessChange(_ purecef.Browser, _, _ int32) {}
279
280func (h *handlerSet) OnContentsBoundsChange(_ purecef.Browser, _ *purecef.Rect) int32 { return 0 }
281
282func (h *handlerSet) GetRootWindowScreenRect(_ purecef.Browser, _ *purecef.Rect) int32 { return 0 }
283
284// ===========================================================================
285// LoadHandler (4 methods)
286// ===========================================================================
287
288// OnLoadingStateChange updates the loading/navigation state cache and fires callbacks.
289func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangoback, cangoforward int32) {
290 loading := isloading != 0
291 h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0)
292 if h.wv != nil && h.wv.ctx != nil {
293 h.wv.mu.RLock()
294 uri := h.wv.uri
295 progress := h.wv.progress
296 pendingURI := h.wv.pendingURI
297 h.wv.mu.RUnlock()
298 logging.FromContext(h.wv.ctx).Debug().
299 Bool("loading", loading).
300 Bool("can_go_back", cangoback != 0).
301 Bool("can_go_forward", cangoforward != 0).
302 Float64("progress", progress).
303 Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
304 Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
305 Msg("cef: OnLoadingStateChange")
306 }
307
308 if !loading && h.wv.viewBridge != nil {
309 h.wv.runOnGTK(func() {
310 if h.wv.viewBridge != nil && h.wv.viewBridge.HasFocus() {
311 h.wv.mu.RLock()
312 host := h.wv.host
313 h.wv.mu.RUnlock()
314 if host != nil {
315 syncWindowlessBrowserFocus(host)
316 }
317 }
318 })
319 }
320
321 h.wv.mu.RLock()
322 cb := h.wv.callbacks
323 h.wv.mu.RUnlock()
324 if cb != nil && cb.OnLoadChanged != nil {
325 if loading {
326 h.wv.runOnGTK(func() {
327 cb.OnLoadChanged(port.LoadStarted)
328 })
329 } else {
330 h.wv.runOnGTK(func() {
331 cb.OnLoadChanged(port.LoadFinished)
332 })
333 }
334 }
335}
336
337// OnLoadStart fires LoadCommitted for main frame navigations.
338// In CEF this callback runs after navigation commit, so it is the closest
339// equivalent to WebKit's LoadCommitted event.
340func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purecef.TransitionType) {
341 if frame == nil || !frame.IsMain() {
342 return
343 }
344 bridgeNonce := h.wv.ensureBridgeNonce()
345 if openerBridgeScript := h.wv.popupOpenerBridgeScript(bridgeNonce); openerBridgeScript != "" {
346 if h.wv != nil && h.wv.ctx != nil {
347 parentURI, active, blocked := h.wv.popupOpenerBridgeState()
348 logging.FromContext(h.wv.ctx).Debug().
349 Uint64("webview_id", uint64(h.wv.id)).
350 Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).
351 Str("parent_uri", logging.TruncateURL(parentURI, logging.PermissionLogURLMaxLen)).
352 Bool("bridge_active", active).
353 Bool("bridge_blocked", blocked).
354 Msg("cef: injecting popup opener bridge")
355 }
356 frame.ExecuteJavaScript(openerBridgeScript, frame.GetURL(), 0)
357 }
358 if h.wv != nil && h.wv.ctx != nil {
359 logging.FromContext(h.wv.ctx).Debug().
360 Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).
361 Msg("cef: OnLoadStart")
362 }
363 h.wv.updateURI(frame.GetURL())
364 h.wv.mu.RLock()
365 cb := h.wv.callbacks
366 h.wv.mu.RUnlock()
367 if cb != nil && cb.OnLoadChanged != nil {
368 h.wv.runOnGTK(func() {
369 cb.OnLoadChanged(port.LoadCommitted)
370 })
371 }
372}
373
374// OnLoadEnd resets crash count and injects content scripts for the main frame.
375func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatusCode int32) {
376 log := logging.FromContext(h.wv.ctx)
377 log.Debug().
378 Bool("frame_nil", frame == nil).
379 Bool("is_main", frame != nil && frame.IsMain()).
380 Int32("http_status", httpStatusCode).
381 Msg("cef: OnLoadEnd")
382 if frame == nil || !frame.IsMain() {
383 return
384 }
385 // Successful load — reset the consecutive crash counter.
386 h.wv.crashCount.Store(0)
387
388 // If a queued startup navigation is still pending after about:blank finished,
389 // replay it now that the initial main-frame load completed.
390 frameURL := frame.GetURL()
391 if pendingURI := h.wv.pendingNavigationURI(); pendingURI != "" && !pendingURIEquivalent(frameURL, pendingURI) {
392 if strings.EqualFold(strings.TrimSpace(frameURL), "about:blank") {
393 log.Debug().
394 Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
395 Msg("cef: replaying pending navigation after about:blank load end")
396 h.wv.schedulePendingNavigationReplay(0)
397 }
398 }
399
400 // Inject scripts and styles after page load.
401 // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread,
402 // and JavaScript injection requires the main thread.
403 if cefScaleProbeEnabled() && httpStatusCode >= 0 && !strings.EqualFold(strings.TrimSpace(frameURL), "about:blank") {
404 // Do not retain/use CEF callback-scoped frame wrappers across the GTK idle hop.
405 // Re-read the current main frame through WebView.RunJavaScript instead.
406 h.wv.runOnGTK(func() {
407 logging.FromContext(h.wv.ctx).Debug().Msg("cef: injecting scale probe")
408 h.wv.RunJavaScript(context.Background(), cefScaleProbeScript)
409 })
410 }
411 if h.wv.engine != nil && h.wv.engine.contentInj != nil {
412 h.wv.runOnGTK(func() {
413 h.wv.engine.contentInj.onLoadEnd(h.wv)
414 })
415 }
416
417 // NOTE: We intentionally do NOT dispatch LoadFinished or ProgressChanged(1.0)
418 // here. OnLoadEnd is a per-frame event, and during cross-site navigations
419 // (process swap), CEF fires OnLoadEnd for the OLD main frame before the new
420 // page finishes loading. Dispatching LoadFinished here caused the progress
421 // bar to hide prematurely while the new page was still loading.
422 //
423 // OnLoadingStateChange(isLoading=false) is the correct browser-level signal
424 // for load completion — CEF guarantees it fires AFTER all OnLoadEnd calls.
425 // CEF's OnLoadingProgressChange also provides progress=1.0 at completion.
426}
427
428// OnLoadError is a no-op.
429func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.Errorcode, _, _ string) {
430}
431
432func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition {
433 switch disposition {
434 case purecef.WindowOpenDispositionWodCurrentTab:
435 return dto.WindowDispositionCurrentTab
436 case purecef.WindowOpenDispositionWodNewPopup:
437 return dto.WindowDispositionNewPopup
438 case purecef.WindowOpenDispositionWodNewWindow:
439 return dto.WindowDispositionNewWindow
440 case purecef.WindowOpenDispositionWodNewForegroundTab,
441 purecef.WindowOpenDispositionWodNewBackgroundTab,
442 purecef.WindowOpenDispositionWodSingletonTab,
443 purecef.WindowOpenDispositionWodSwitchToTab:
444 return dto.WindowDispositionNewTab
445 default:
446 return ""
447 }
448}
449
450// ===========================================================================
451// LifeSpanHandler (6 methods)
452// ===========================================================================
453
454// OnBeforePopup intercepts popup requests (target="_blank", window.open).
455// When the coordinator returns a related CEF popup shell we hand that shell's
456// client back to CEF and allow native popup creation so opener semantics are
457// preserved. Otherwise we keep blocking and let the coordinator's fallback
458// pane handle the navigation.
459func (h *handlerSet) OnBeforePopup(
460 browser purecef.Browser, frame purecef.Frame, popupID int32, targetURL, targetFrameName string,
461 targetDisposition purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures,
462 windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings,
463 _ *purecef.DictionaryValue, noJavaScriptAccess *bool,
464) bool {
465 if targetURL == "" {
466 return true
467 }
468
469 h.wv.mu.RLock()
470 cb := h.wv.callbacks
471 h.wv.mu.RUnlock()
472 if cb == nil || cb.OnCreate == nil {
473 return true
474 }
475
476 requestNoJavaScriptAccess := false
477 if noJavaScriptAccess != nil {
478 requestNoJavaScriptAccess = *noJavaScriptAccess
479 }
480 var popup port.WebView
481 sourceBrowserID := int32(0)
482 if browser != nil {
483 sourceBrowserID = browser.GetIdentifier()
484 }
485 sourceFrameID := ""
486 sourceFrameURL := ""
487 if frame != nil {
488 sourceFrameID = frame.GetIdentifier()
489 sourceFrameURL = frame.GetURL()
490 }
491
492 h.wv.runOnGTKSync(func() {
493 popup = cb.OnCreate(port.PopupRequest{
494 TargetURI: targetURL,
495 FrameName: targetFrameName,
496 IsUserGesture: userGesture != 0,
497 NoJavaScriptAccess: requestNoJavaScriptAccess,
498 ParentViewID: h.wv.id,
499 SourceBrowserID: sourceBrowserID,
500 SourceFrameID: sourceFrameID,
501 SourceFrameURL: sourceFrameURL,
502 TargetDisposition: mapCEFWindowDisposition(targetDisposition),
503 })
504 })
505
506 cefPopup, ok := popup.(*WebView)
507 if !ok || cefPopup == nil {
508 return true
509 }
510
511 decision := dto.HostDecision{}
512 if carrier, ok := popup.(port.BrowsingContextHostDecisionCapable); ok {
513 if got, hasDecision := carrier.BrowsingContextHostDecision(); hasDecision {
514 decision = got
515 }
516 }
517
518 cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess)
519 switch decision.Kind {
520 case dto.HostDecisionCreateNativeWin:
521 if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {
522 logging.FromContext(h.currentContext()).Debug().
523 Int32("popup_id", popupID).
524 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
525 Msg("cef: native popup armed")
526 return false
527 }
528 if aborter, ok := popup.(port.NativePopupHostAbortCapable); ok {
529 aborter.AbortNativePopupHost()
530 }
531 cefPopup.discardNativePopupCandidate()
532 logging.FromContext(h.currentContext()).Warn().
533 Int32("popup_id", popupID).
534 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
535 Msg("cef: native popup arming failed, denying without fallback")
536 return true
537 case dto.HostDecisionCreatePane, dto.HostDecisionReuseNamedPane:
538 logging.FromContext(h.currentContext()).Debug().
539 Int32("popup_id", popupID).
540 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
541 Str("decision", string(decision.Kind)).
542 Msg("cef: pane-hosted browsing context, blocking native popup and using related shell browser creation")
543 return true
544 default:
545 if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {
546 return false
547 }
548
549 cefPopup.discardNativePopupCandidate()
550 logging.FromContext(h.currentContext()).Debug().
551 Int32("popup_id", popupID).
552 Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
553 Msg("cef: native popup bridge unavailable, blocking CEF popup and using popup shell browser creation")
554 return true
555 }
556}
557
558func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, popupID int32) {
559 if popup := h.wv.takePendingNativePopup(popupID); popup != nil {
560 popup.handleNativePopupAborted()
561 }
562}
563
564func (h *handlerSet) OnBeforeDevToolsPopup(
565 _ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot,
566 _ *purecef.BrowserSettings, _ *purecef.DictionaryValue, _ *bool,
567) {
568}
569
570// OnAfterCreated stores the browser and host references and enables input.
571func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
572 log := logging.FromContext(h.wv.ctx)
573 if browser == nil {
574 log.Warn().Msg("cef: OnAfterCreated called with nil browser")
575 return
576 }
577 browserID := browser.GetIdentifier()
578 log.Debug().Int32("browser_id", browserID).Msg("cef: OnAfterCreated")
579
580 host := browser.GetHost()
581 if host == nil {
582 log.Warn().Msg("cef: OnAfterCreated returned nil host")
583 return
584 }
585
586 state := h.attachAfterCreatedBrowser(browser, host, browserID)
587 if state.closeDuplicate {
588 log.Warn().
589 Int32("browser_id", browserID).
590 Int32("existing_browser_id", state.duplicateBrowserID).
591 Msg("cef: duplicate popup browser attached after shell already had a browser; closing duplicate")
592 host.CloseBrowser(1)
593 return
594 }
595
596 h.finishAfterCreated(browser, host, state)
597}
598
599func (h *handlerSet) attachAfterCreatedBrowser(
600 browser purecef.Browser,
601 host purecef.BrowserHost,
602 browserID int32,
603) afterCreatedState {
604 state := afterCreatedState{}
605 h.wv.mu.Lock()
606
607 if existing := h.wv.browser; existing != nil {
608 existingID := existing.GetIdentifier()
609 if existingID != 0 && existingID != browserID {
610 state.closeDuplicate = true
611 state.duplicateBrowserID = existingID
612 h.wv.mu.Unlock()
613 return state
614 }
615 }
616
617 h.wv.browser = browser
618 h.wv.host = host
619 h.wv.lastAppliedOSRBackingScaleBits.Store(0)
620 h.wv.pendingCreate = nil
621 bridge := h.wv.viewBridge
622 h.wv.inputAttached = bridge == nil
623 state.inputAttached = h.wv.inputAttached
624 if h.wv.findCtrl != nil {
625 h.wv.findCtrl.setHost(host)
626 }
627 state.nativePopupParent = h.wv.nativePopupParent
628 state.nativePopupID = h.wv.nativePopupID
629 h.wv.nativePopupParent = nil
630 h.wv.nativePopupID = 0
631 h.wv.nativePopupFallbackStarted = false
632 state.hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
633 h.wv.mu.Unlock()
634
635 if bridge != nil {
636 wv := h.wv
637 wv.runOnGTK(func() {
638 if wv.destroyed.Load() || wv.viewBridge == nil {
639 wv.handleInputAttachFailure(ErrAdapterDestroyed, host)
640 return
641 }
642 if err := wv.viewBridge.AttachInputToWidget(host, wv.nativeWidget, wv.bridgeInputOptions()); err != nil {
643 wv.handleInputAttachFailure(err, host)
644 return
645 }
646 if wv.viewBridge != nil && wv.viewBridge.HasFocus() {
647 syncWindowlessBrowserFocus(host)
648 } else {
649 host.Invalidate(purecef.PaintElementTypePetView)
650 }
651 wv.markInputAttached()
652 })
653 }
654
655 // Mark browser as visible — CEF OSR starts in hidden state and suppresses
656 // painting/caret updates until explicitly told the browser is shown.
657 host.WasHidden(0)
658 if h.wv != nil {
659 if h.wv.ctx != nil {
660 logging.FromContext(h.wv.ctx).Info().
661 Int32("windowless_frame_rate_configured", host.GetWindowlessFrameRate()).
662 Msg("cef: browser initial/configured windowless frame rate")
663 }
664 h.wv.SyncViewport(h.wv.ctx, "after-created")
665 }
666 return state
667}
668
669func (wv *WebView) handleInputAttachFailure(err error, host purecef.BrowserHost) {
670 if wv == nil || err == nil {
671 return
672 }
673 if wv.ctx != nil {
674 logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: failed to attach input to cef2gtk bridge")
675 }
676 if host != nil && !wv.destroyed.Load() {
677 host.CloseBrowser(1)
678 return
679 }
680 wv.runCloseCallbacks()
681}
682
683func (h *handlerSet) finishAfterCreated(
684 browser purecef.Browser,
685 host purecef.BrowserHost,
686 state afterCreatedState,
687) {
688 if h.wv.engine != nil {
689 h.wv.engine.recordBrowserAfterCreated(browser)
690 h.wv.engine.registerWebView(h.wv)
691 h.wv.engine.bindBrowserWebView(browser, h.wv)
692 }
693 h.wv.stopNativePopupFallbackTimer()
694 if state.inputAttached {
695 // Request an initial OSR frame even before the first real navigation
696 // commits. This restores the about:blank warm-up paint that the stable
697 // startup path relied on and prevents the rendering bridge from staying idle.
698 host.Invalidate(purecef.PaintElementTypePetView)
699 }
700 if state.hasPendingNavigation {
701 h.wv.schedulePendingNavigationReplay(0)
702 }
703 if state.nativePopupParent != nil && state.nativePopupID != 0 {
704 state.nativePopupParent.clearPendingNativePopup(state.nativePopupID, h.wv)
705 }
706 h.wv.scheduleStartAdaptiveFrameRatePolling()
707 h.wv.scheduleStartBeginFrameLoop()
708 h.wv.fireReadyToShow()
709}
710
711type afterCreatedState struct {
712 inputAttached bool
713 hasPendingNavigation bool
714 nativePopupParent *WebView
715 nativePopupID int32
716 duplicateBrowserID int32
717 closeDuplicate bool
718}
719
720// DoClose returns false to allow the default close behavior.
721func (h *handlerSet) DoClose(_ purecef.Browser) bool {
722 return false
723}
724
725// OnBeforeClose fires the OnClose callback.
726func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
727 if h.wv.engine != nil {
728 browserID := int32(0)
729 if browser != nil {
730 browserID = browser.GetIdentifier()
731 }
732 h.wv.engine.unregisterWebView(h.wv, browserID)
733 }
734 h.wv.mu.Lock()
735 h.wv.browser = nil
736 h.wv.host = nil
737 h.wv.lastAppliedOSRBackingScaleBits.Store(0)
738 h.wv.inputAttached = false
739 if h.wv.findCtrl != nil {
740 h.wv.findCtrl.setHost(nil)
741 }
742 h.wv.mu.Unlock()
743 if h.wv.destroyed.Load() {
744 h.wv.destroyViewBridgeOnGTKAsync()
745 } else if h.wv.viewBridge != nil {
746 wv := h.wv
747 wv.runOnGTK(func() {
748 if wv.viewBridge == nil {
749 return
750 }
751 if err := wv.viewBridge.SetInputHost(nil); err != nil && wv.ctx != nil {
752 logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: failed to clear cef2gtk input host")
753 }
754 })
755 }
756 h.wv.scheduleStopAdaptiveFrameRatePolling()
757 h.wv.scheduleStopBeginFrameLoop()
758
759 h.wv.mu.RLock()
760 cb := h.wv.callbacks
761 h.wv.mu.RUnlock()
762 if cb != nil && cb.OnClose != nil {
763 h.wv.runOnGTK(func() {
764 cb.OnClose()
765 })
766 }
767 h.wv.runCloseCallbacks()
768}
769
770// ===========================================================================
771// RequestHandler (11 methods)
772// ===========================================================================
773
774func (h *handlerSet) OnBeforeBrowse(browser purecef.Browser, frame purecef.Frame, request purecef.Request, _, _ int32) bool {
775 if frame == nil || !frame.IsMain() || request == nil {
776 return false
777 }
778
779 handler := h.downloadHandler()
780 if handler == nil || !strings.EqualFold(request.GetMethod(), "GET") {
781 return false
782 }
783
784 url := request.GetURL()
785 if !downloadutil.ShouldForceDownloadForURI(url) || browser == nil {
786 return false
787 }
788
789 host := browser.GetHost()
790 if host == nil {
791 return false
792 }
793
794 logging.FromContext(h.currentContext()).Debug().
795 Str("url", logging.TruncateURL(url, maxSchemeTruncatedURLLength)).
796 Msg("cef: forcing download for navigation")
797
798 host.StartDownload(url)
799 return true
800}
801
802func (h *handlerSet) CanDownload(browser purecef.Browser, url, requestMethod string) bool {
803 handler := h.downloadHandler()
804 if handler == nil {
805 return false
806 }
807 return handler.canDownload(browser, url, requestMethod)
808}
809
810func (h *handlerSet) OnBeforeDownload(
811 browser purecef.Browser,
812 downloadItem purecef.DownloadItem,
813 suggestedName string,
814 callback purecef.BeforeDownloadCallback,
815) bool {
816 handler := h.downloadHandler()
817 if handler == nil {
818 return false
819 }
820 return handler.onBeforeDownload(h.currentContext(), browser, downloadItem, suggestedName, callback)
821}
822
823func (h *handlerSet) OnDownloadUpdated(
824 _ purecef.Browser,
825 downloadItem purecef.DownloadItem,
826 callback purecef.DownloadItemCallback,
827) {
828 handler := h.downloadHandler()
829 if handler == nil {
830 return
831 }
832 handler.onDownloadUpdated(h.currentContext(), downloadItem, callback)
833}
834
835func (h *handlerSet) downloadHandler() *downloadHandler {
836 if h == nil || h.wv == nil || h.wv.engine == nil {
837 return nil
838 }
839 return h.wv.engine.currentDownloadHandler()
840}
841
842func (h *handlerSet) currentContext() context.Context {
843 if h == nil || h.wv == nil || h.wv.ctx == nil {
844 return context.Background()
845 }
846 return h.wv.ctx
847}
848
849func (h *handlerSet) OnOpenUrlfromTab(
850 _ purecef.Browser,
851 _ purecef.Frame,
852 _ string,
853 _ purecef.WindowOpenDisposition,
854 _ int32,
855) int32 {
856 return 0
857}
858
859func (h *handlerSet) GetResourceRequestHandler(
860 _ purecef.Browser, _ purecef.Frame, _ purecef.Request,
861 _, _ int32, _ string, _ *int32,
862) purecef.ResourceRequestHandler {
863 return nil
864}
865
866func (h *handlerSet) GetAuthCredentials(
867 _ purecef.Browser, _ string, _ int32, _ string, _ int32,
868 _, _ string, _ purecef.AuthCallback,
869) int32 {
870 return 0
871}
872
873func (h *handlerSet) OnCertificateError(
874 _ purecef.Browser,
875 _ purecef.Errorcode,
876 _ string,
877 _ purecef.Sslinfo,
878 _ purecef.Callback,
879) int32 {
880 return 0
881}
882
883func (h *handlerSet) OnSelectClientCertificate(
884 _ purecef.Browser, _ int32, _ string, _ int32,
885 _ []purecef.X509Certificate, _ purecef.SelectClientCertificateCallback,
886) int32 {
887 return 0
888}
889
890func (h *handlerSet) OnRenderViewReady(_ purecef.Browser) {}
891
892func (h *handlerSet) OnRenderProcessUnresponsive(_ purecef.Browser, _ purecef.UnresponsiveProcessCallback) int32 {
893 return 0
894}
895
896func (h *handlerSet) OnRenderProcessResponsive(_ purecef.Browser) {}
897
898// maxConsecutiveCrashes caps how many times OnRenderProcessTerminated will
899// forward the event before suppressing further notifications. This prevents
900// infinite crash → redirect → crash loops.
901const maxConsecutiveCrashes = 3
902
903// OnRenderProcessTerminated fires the OnWebProcessTerminated callback with a mapped reason.
904func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef.TerminationStatus, _ int32, _ string) {
905 if h.wv.crashCount.Add(1) > maxConsecutiveCrashes {
906 return // suppress to break the loop
907 }
908
909 var reason port.WebProcessTerminationReason
910 var label string
911 switch status {
912 case purecef.TerminationStatusTsAbnormalTermination:
913 reason = port.WebProcessTerminationCrashed
914 label = "abnormal termination"
915 case purecef.TerminationStatusTsProcessWasKilled:
916 reason = port.WebProcessTerminationByAPI
917 label = "killed"
918 case purecef.TerminationStatusTsProcessCrashed:
919 reason = port.WebProcessTerminationCrashed
920 label = "crashed"
921 case purecef.TerminationStatusTsProcessOom:
922 reason = port.WebProcessTerminationExceededMemory
923 label = "out of memory"
924 default:
925 reason = port.WebProcessTerminationUnknown
926 label = "unknown"
927 }
928
929 h.wv.mu.RLock()
930 cb := h.wv.callbacks
931 uri := h.wv.uri
932 h.wv.mu.RUnlock()
933 if cb != nil && cb.OnWebProcessTerminated != nil {
934 h.wv.runOnGTK(func() {
935 cb.OnWebProcessTerminated(reason, label, uri)
936 })
937 }
938}
939
940func (h *handlerSet) OnDocumentAvailableInMainFrame(_ purecef.Browser) {}
941
942// ===========================================================================
943// AudioHandler (5 methods)
944// ===========================================================================
945
946func (h *handlerSet) GetAudioParameters(_ purecef.Browser, _ *purecef.AudioParameters) int32 {
947 return 1 // proceed with defaults
948}
949
950// OnAudioStreamStarted handles the start of an audio stream.
951// The third callback argument is the channel count (not frames-per-buffer);
952// frames-per-buffer comes from params.FramesPerBuffer.
953func (h *handlerSet) OnAudioStreamStarted(_ purecef.Browser, params *purecef.AudioParameters, channels int32) {
954 // Validate params and factory first
955 if params == nil || h.wv.audioOutputFactory == nil {
956 return
957 }
958
959 // Build format from CEF parameters
960 format := h.buildAudioStreamFormat(params, channels)
961
962 if h.wv.ctx != nil {
963 logging.FromContext(h.wv.ctx).Info().
964 Int("sample_rate", format.SampleRate).
965 Int("channels", format.ChannelCount).
966 Int("frames_per_buffer", format.FramesPerBuffer).
967 Int32("channel_layout", params.ChannelLayout).
968 Msg("cef: OnAudioStreamStarted")
969 }
970
971 // Reset packet counters for the new stream
972 h.wv.audioPacketCount.Store(0)
973 h.wv.audioWriteCount.Store(0)
974
975 // Close any existing stream first
976 h.wv.closeAudioStream()
977
978 // Create new stream
979 ctx := h.wv.ctx
980 if ctx == nil {
981 ctx = context.Background()
982 }
983
984 stream, err := h.wv.audioOutputFactory.NewStream(ctx, format)
985 if err != nil {
986 // Log error but don't panic - audio is non-critical.
987 // Do NOT set audioPlaying — the stream was never created.
988 if h.wv.ctx != nil {
989 logging.FromContext(h.wv.ctx).Warn().
990 Err(err).
991 Int("sample_rate", format.SampleRate).
992 Int("channels", format.ChannelCount).
993 Msg("cef: failed to create audio output stream")
994 }
995 return
996 }
997
998 // Set playing state only after stream creation succeeds
999 h.wv.setAudioPlaying(true)
1000
1001 if h.wv.ctx != nil {
1002 logging.FromContext(h.wv.ctx).Info().
1003 Int("sample_rate", format.SampleRate).
1004 Int("channels", format.ChannelCount).
1005 Int("frames_per_buffer", format.FramesPerBuffer).
1006 Msg("cef: audio output stream created successfully")
1007 }
1008
1009 // Store the new stream
1010 h.wv.audioStreamMu.Lock()
1011 h.wv.activeAudioStream = stream
1012 h.wv.audioStreamMu.Unlock()
1013}
1014
1015// buildAudioStreamFormat converts CEF AudioParameters to port.AudioStreamFormat.
1016// channels is the authoritative channel count from the callback; params.FramesPerBuffer
1017// supplies the buffer size.
1018func (h *handlerSet) buildAudioStreamFormat(params *purecef.AudioParameters, channels int32) port.AudioStreamFormat {
1019 return port.AudioStreamFormat{
1020 SampleRate: int(params.SampleRate),
1021 ChannelCount: int(channels),
1022 FramesPerBuffer: int(params.FramesPerBuffer),
1023 }
1024}
1025
1026// OnAudioStreamPacket receives audio packets and forwards them to the output stream.
1027// The mutex is held across both the stream snapshot and Write to prevent
1028// closeAudioStream from closing the stream mid-write.
1029func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, data [][]float32, frames int32, pts int64) {
1030 if len(data) == 0 || frames <= 0 {
1031 return
1032 }
1033
1034 // Copy the data before acquiring the stream lock because CEF can reuse the
1035 // buffer as soon as this callback returns.
1036 copiedData := make([][]float32, len(data))
1037 for i, channel := range data {
1038 if len(channel) < int(frames) {
1039 continue
1040 }
1041 copiedData[i] = make([]float32, frames)
1042 copy(copiedData[i], channel[:frames])
1043 }
1044
1045 // Hold the lock across the stream read and Write so closeAudioStream
1046 // cannot close the stream between snapshot and write.
1047 h.wv.audioStreamMu.Lock()
1048 stream := h.wv.activeAudioStream
1049 if stream == nil {
1050 h.wv.audioStreamMu.Unlock()
1051 return
1052 }
1053
1054 pktNum := h.wv.audioPacketCount.Add(1)
1055 if pktNum == 1 && h.wv.ctx != nil {
1056 logging.FromContext(h.wv.ctx).Info().
1057 Int("channels", len(data)).
1058 Int32("frames", frames).
1059 Int64("pts", pts).
1060 Msg("cef: first audio packet received")
1061 }
1062
1063 err := stream.Write(copiedData)
1064 h.wv.audioStreamMu.Unlock()
1065
1066 if err != nil {
1067 if h.wv.ctx != nil {
1068 logging.FromContext(h.wv.ctx).Debug().
1069 Err(err).
1070 Uint64("packets_received", pktNum).
1071 Msg("cef: audio stream write failed")
1072 }
1073 return
1074 }
1075 h.wv.audioWriteCount.Add(1)
1076}
1077
1078// OnAudioStreamStopped handles stream stop.
1079func (h *handlerSet) OnAudioStreamStopped(_ purecef.Browser) {
1080 if h.wv.ctx != nil {
1081 logging.FromContext(h.wv.ctx).Info().
1082 Uint64("packets_received", h.wv.audioPacketCount.Load()).
1083 Uint64("writes_succeeded", h.wv.audioWriteCount.Load()).
1084 Msg("cef: OnAudioStreamStopped")
1085 }
1086 h.wv.setAudioPlaying(false)
1087 h.wv.closeAudioStream()
1088}
1089
1090// OnAudioStreamError handles stream errors.
1091func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, message string) {
1092 if h.wv.ctx != nil {
1093 event := logging.FromContext(h.wv.ctx).Warn()
1094 logMsg := "cef: audio stream error"
1095 if h.wv.destroyed.Load() && message == "Socket closed unexpectedly" {
1096 event = logging.FromContext(h.wv.ctx).Debug()
1097 logMsg = "cef: audio stream closed during shutdown"
1098 }
1099 event.
1100 Str("error", message).
1101 Uint64("packets_received", h.wv.audioPacketCount.Load()).
1102 Uint64("writes_succeeded", h.wv.audioWriteCount.Load()).
1103 Msg(logMsg)
1104 }
1105 h.wv.setAudioPlaying(false)
1106 h.wv.closeAudioStream()
1107}
1108
1109// ===========================================================================
1110// FindHandler (1 method)
1111// ===========================================================================
1112
1113// OnFindResult dispatches CEF find results to the WebView's FindController.
1114func (h *handlerSet) OnFindResult(
1115 _ purecef.Browser,
1116 identifier, count int32,
1117 _ *purecef.Rect,
1118 activematchordinal, finalupdate int32,
1119) {
1120 if h.wv.findCtrl != nil {
1121 h.wv.findCtrl.handleFindResult(identifier, count, activematchordinal, finalupdate)
1122 }
1123}
1124
1125// cefCursorNames maps CEF cursor types to GDK/CSS cursor names.
1126var cefCursorNames = map[purecef.CursorType]string{
1127 purecef.CursorTypeCtPointer: "default",
1128 purecef.CursorTypeCtCross: "crosshair",
1129 purecef.CursorTypeCtHand: "pointer",
1130 purecef.CursorTypeCtIbeam: "text",
1131 purecef.CursorTypeCtWait: "wait",
1132 purecef.CursorTypeCtHelp: "help",
1133 purecef.CursorTypeCtEastresize: "e-resize",
1134 purecef.CursorTypeCtNorthresize: "n-resize",
1135 purecef.CursorTypeCtNortheastresize: "ne-resize",
1136 purecef.CursorTypeCtNorthwestresize: "nw-resize",
1137 purecef.CursorTypeCtSouthresize: "s-resize",
1138 purecef.CursorTypeCtSoutheastresize: "se-resize",
1139 purecef.CursorTypeCtSouthwestresize: "sw-resize",
1140 purecef.CursorTypeCtWestresize: "w-resize",
1141 purecef.CursorTypeCtNorthsouthresize: "ns-resize",
1142 purecef.CursorTypeCtEastwestresize: "ew-resize",
1143 purecef.CursorTypeCtNortheastsouthwestresize: "nesw-resize",
1144 purecef.CursorTypeCtNorthwestsoutheastresize: "nwse-resize",
1145 purecef.CursorTypeCtColumnresize: "col-resize",
1146 purecef.CursorTypeCtRowresize: "row-resize",
1147 purecef.CursorTypeCtMove: "move",
1148 purecef.CursorTypeCtProgress: "progress",
1149 purecef.CursorTypeCtNodrop: "no-drop",
1150 purecef.CursorTypeCtNotallowed: "not-allowed",
1151 purecef.CursorTypeCtGrab: "grab",
1152 purecef.CursorTypeCtGrabbing: "grabbing",
1153 purecef.CursorTypeCtZoomin: "zoom-in",
1154 purecef.CursorTypeCtZoomout: "zoom-out",
1155}
1156
1157// cefCursorToGDKName maps a CEF cursor type to a GDK/CSS cursor name.
1158func cefCursorToGDKName(ct purecef.CursorType) string {
1159 if name, ok := cefCursorNames[ct]; ok {
1160 return name
1161 }
1162 return "default"
1163}