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