1package cef
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "sync/atomic"
8 "time"
9
10 purecef "github.com/bnema/purego-cef/cef"
11 cef2gtk "github.com/bnema/purego-cef2gtk"
12
13 "github.com/bnema/dumber/internal/application/port"
14 "github.com/bnema/dumber/internal/logging"
15)
16
17// Compile-time interface check.
18var _ port.WebViewFactory = (*WebViewFactory)(nil)
19
20// WebViewFactory creates new CEF off-screen browser WebViews. Each WebView
21// gets a unique ID, its own cef2gtk bridge view, and an asynchronously-created
22// CEF browser (via BrowserHostCreateBrowser).
23type WebViewFactory struct {
24 engine *Engine
25 nextID atomic.Uint64
26 adaptiveWindowlessFrameRate bool
27 windowlessFrameRate int32
28 windowlessFrameRateMax int32
29 inputConfig RuntimeInputConfig
30 bgColor atomic.Uint32 // packed ARGB theme color for the presentation-layer flash guard
31 audioOutputFactory port.AudioOutputFactory
32}
33
34type webViewFactoryOptions struct {
35 adaptiveWindowlessFrameRate bool
36 windowlessFrameRate int32
37 windowlessFrameRateMax int32
38 inputConfig RuntimeInputConfig
39 audioOutputFactory port.AudioOutputFactory
40}
41
42type resizeNotifiableBrowserHost interface {
43 WasResized()
44 Invalidate(purecef.PaintElementType)
45}
46
47var cefBrowserHostCreateBrowser = purecef.BrowserHostCreateBrowser
48
49const (
50 pendingBrowserCreateRetryDelay = 10 * time.Millisecond
51 pendingBrowserCreateMaxPostRetries = 3
52)
53
54// newWebViewFactory returns a factory that will create WebViews.
55func newWebViewFactory(engine *Engine, opts webViewFactoryOptions) *WebViewFactory {
56 if opts.windowlessFrameRate < 1 {
57 opts.windowlessFrameRate = 60
58 }
59 if opts.windowlessFrameRateMax < 1 {
60 opts.windowlessFrameRateMax = 240
61 }
62 return &WebViewFactory{
63 engine: engine,
64 adaptiveWindowlessFrameRate: opts.adaptiveWindowlessFrameRate,
65 windowlessFrameRate: opts.windowlessFrameRate,
66 windowlessFrameRateMax: opts.windowlessFrameRateMax,
67 inputConfig: opts.inputConfig,
68 audioOutputFactory: opts.audioOutputFactory,
69 }
70}
71
72// setDefaultBackgroundColor stores the packed ARGB theme color used by the
73// presentation-layer flash guard on newly-created WebViews. It no longer feeds
74// BrowserSettings.BackgroundColor, which is always opaque white.
75func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) {
76 const s = colorScale
77 argb := uint32(a*s)<<24 | uint32(r*s)<<16 | uint32(g*s)<<8 | uint32(b*s)
78 f.bgColor.Store(argb)
79}
80
81// Create builds a new CEF off-screen browser WebView. The browser is created
82// asynchronously; the returned WebView is usable immediately but navigation
83// will fail with errNoBrowser until OnAfterCreated fires.
84func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
85 wv, err := f.newWebView(ctx)
86 if err != nil {
87 return nil, err
88 }
89
90 // Delegate windowless/shared-texture setup to the GTK bridge.
91 windowInfo := purecef.NewWindowInfo()
92 cef2gtk.ConfigureWindowInfo(&windowInfo, cef2gtk.WindowInfoOptions{})
93 if externalBeginFrameEnabled() {
94 windowInfo.ExternalBeginFrameEnabled = 1
95 }
96
97 // Configure BrowserSettings.
98 settings := purecef.NewBrowserSettings()
99 cef2gtk.ConfigureBrowserSettings(&settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: f.windowlessFrameRate})
100 settings.LocalStorage = 1 // CEF_STATE_ENABLED
101 // Always opaque white: Chromium's standard canvas fallback. The theme
102 // background is applied at the presentation layer as a flash guard instead.
103 settings.BackgroundColor = opaqueWhiteBackground
104
105 // Defer browser creation until the bridge view has a non-zero size.
106 // CEF requires GetViewRect to return a non-empty rect, but the GL area
107 // is not yet realized at this point.
108 if err := f.configureInitialBrowserCreation(ctx, wv, wv.client, &windowInfo, &settings, nil); err != nil {
109 wv.Destroy()
110 return nil, err
111 }
112
113 return wv, nil
114}
115
116func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
117 id := port.WebViewID(f.nextID.Add(1))
118 applicationScale := f.engine.currentApplicationScale()
119 viewBridge := NewCef2gtkAdapter(f.engine.renderStackPlan, applicationScale)
120 if viewBridge == nil {
121 return nil, fmt.Errorf("create cef2gtk view")
122 }
123
124 wv := &WebView{
125 id: id,
126 ctx: ctx,
127 engine: f.engine,
128 factory: f,
129 viewBridge: viewBridge,
130 zoomCompensation: viewBridge,
131 audioOutputFactory: f.audioOutputFactory,
132 adaptiveWindowlessFrameRate: f.adaptiveWindowlessFrameRate,
133 windowlessFrameRate: f.windowlessFrameRate,
134 windowlessFrameRateMax: f.windowlessFrameRateMax,
135 inputConfig: f.inputConfig,
136 backgroundColor: f.bgColor.Load(),
137 }
138 result := wv.runOnGTKSyncLabel("cef.new_webview.init_widgets", func() {
139 nativeWidget := viewBridge.Widget()
140 popupSurface := newPopupBridgeSurface(ctx, nativeWidget, f.engine.renderStackPlan, applicationScale)
141 wv.nativeWidget = nativeWidget
142 if popupSurface != nil {
143 wv.popupSurface = popupSurface
144 if popupRoot := popupSurface.RootWidget(); popupRoot != nil {
145 wv.nativeWidget = popupRoot
146 }
147 }
148 // Guard the render widget with the theme background until the first frame.
149 wv.applyBackgroundFlashGuard(nativeWidget)
150 })
151 if !result.Completed() {
152 wv.destroyViewBridgeOnGTKAsync()
153 return nil, errGTKSyncDispatchIncomplete("initialize CEF GTK widgets", result)
154 }
155
156 handlers := &handlerSet{wv: wv}
157 wv.handlers = handlers
158 wv.findCtrl = newFindController()
159
160 // Capture worker must exist before NewClient so the lazily wrapped render
161 // handler can install a real sink when DUMBER_A11Y_CAPTURE=1.
162 if err := wv.enableAccessibilityCaptureIfRequested(); err != nil {
163 logging.FromContext(ctx).Warn().Err(err).
164 Uint64("webview_id", uint64(id)).
165 Msg("cef: accessibility capture setup failed")
166 }
167
168 // Build a CEF client backed by our handlerSet.
169 // Store on WebView to prevent GC collection before CEF AddRef's it.
170 wv.client = purecef.NewClient(wv.handlers)
171 if opts := f.engine.cef2gtkProfileOptions(wv); opts.Enabled {
172 if err := viewBridge.ConfigureProfiling(opts); err != nil {
173 logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef2gtk: failed to enable profiling")
174 }
175 }
176 if err := wv.installNewWebViewViewportHooksAfterCapture(); err != nil {
177 return nil, err
178 }
179 wv.startRenderStallWatchdog()
180 return wv, nil
181}
182
183func (f *WebViewFactory) configureInitialBrowserCreation(
184 ctx context.Context, wv *WebView, client purecef.RawClient,
185 windowInfo *purecef.WindowInfo, settings *purecef.BrowserSettings,
186 onFirstResize func(w, h int32),
187) error {
188 if wv == nil {
189 return errors.New("configure initial browser creation: webview is nil")
190 }
191 wv.pendingCreate = &pendingBrowserCreate{
192 windowInfo: windowInfo,
193 client: client,
194 settings: settings,
195 }
196 if onFirstResize == nil {
197 onFirstResize = func(w, h int32) {
198 logging.FromContext(ctx).Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation")
199 f.postPendingBrowserCreate(ctx, wv, w, h)
200 }
201 }
202 if wv.viewBridge == nil {
203 return nil
204 }
205 result := wv.runOnGTKSyncLabel("cef.configure_initial_browser_creation", func() {
206 wv.removeSizeObserver = wv.viewBridge.AddSizeObserver(func(w, h int32) {
207 // Size observers run on the GTK thread. Prepare the GtkGLArea only for
208 // the one-shot initial resize path; once that path has been consumed,
209 // later size events must go through normal viewport sync even if popup
210 // attachment keeps pendingCreate alive a little longer.
211 if f.handleInitialBrowserCreateSizeObserver(ctx, wv, onFirstResize, wv.viewBridge.PrepareOnGTKThread, w, h) {
212 return
213 }
214
215 synced := wv.syncResizeViewportOnGTK(ctx, "gtk-size-observer")
216 logging.FromContext(ctx).Debug().
217 Uint64("webview_id", uint64(wv.id)).
218 Int32("resize_width", w).
219 Int32("resize_height", h).
220 Bool("browser_ready", synced).
221 Msg("cef: viewport sync handled after GTK size change")
222 })
223 })
224 if !result.Completed() {
225 wv.pendingCreate = nil
226 return errGTKSyncDispatchIncomplete("configure initial browser creation", result)
227 }
228 return nil
229}
230
231func (f *WebViewFactory) handleInitialBrowserCreateSizeObserver(
232 ctx context.Context,
233 wv *WebView,
234 onFirstResize func(w, h int32),
235 prepareOnGTK func() error,
236 w, h int32,
237) bool {
238 if wv == nil || !wv.shouldStartBrowserCreateFromSizeObserver() {
239 return false
240 }
241 if !initialBrowserCreateSizeReadyFromObserver(w, h) {
242 if wv.awaitsNativePopupAttachment() && onFirstResize != nil {
243 onFirstResize(w, h)
244 }
245 logging.FromContext(ctx).Debug().
246 Uint64("webview_id", uint64(wv.id)).
247 Int32("resize_width", w).
248 Int32("resize_height", h).
249 Msg("cef: ignoring bootstrap size observer event before real view size is ready")
250 return true
251 }
252 if prepareOnGTK != nil {
253 if err := prepareOnGTK(); err != nil {
254 logging.FromContext(ctx).Warn().
255 Err(err).
256 Uint64("webview_id", uint64(wv.id)).
257 Int32("resize_width", w).
258 Int32("resize_height", h).
259 Msg("cef: cef2gtk view not ready after resize, will retry")
260 return true
261 }
262 }
263 wv.markInitialBrowserCreateResizeHandled()
264 if onFirstResize != nil {
265 onFirstResize(w, h)
266 }
267 return true
268}
269
270func initialBrowserCreateSizeReadyFromObserver(width, height int32) bool {
271 return observedViewportSizeReady(width, height, width, height)
272}
273
274func (f *WebViewFactory) postPendingBrowserCreate(ctx context.Context, wv *WebView, w, h int32) {
275 log := logging.FromContext(ctx)
276 pc := wv.takePendingCreate()
277 if pc == nil {
278 return
279 }
280
281 task := cefNewTask(cefTaskFunc(func() {
282 if wv.destroyed.Load() {
283 log.Debug().Msg("cef: skipping browser creation for destroyed webview")
284 return
285 }
286 pendingURL := wv.pendingNavigationURI()
287 // Always bootstrap OSR browsers on about:blank first. Starting directly on
288 // the target URL can race host visibility/focus setup and strand the
289 // renderer without an initial paint. We replay pending navigation from
290 // OnAfterCreated once the host is fully wired.
291 initialURL := "about:blank"
292 // This is the request boundary, not the later OnAfterCreated callback.
293 activeStartupTrace().Mark("browser_create_requested")
294 result := cefBrowserHostCreateBrowser(
295 pc.windowInfo,
296 pc.client,
297 initialURL,
298 pc.settings,
299 pc.extraInfo,
300 nil, // requestContext
301 )
302 log.Debug().
303 Str("initial_url", initialURL).
304 Str("pending_url", pendingURL).
305 Msg("cef: BrowserHostCreateBrowser initial URL")
306 if f.engine != nil {
307 f.engine.recordBrowserCreateRequest(w, h, result)
308 }
309 log.Debug().
310 Int32("result", result).
311 Int32("windowless", pc.windowInfo.WindowlessRenderingEnabled).
312 Int32("windowless_frame_rate", pc.settings.WindowlessFrameRate).
313 Int32("shared_texture", pc.windowInfo.SharedTextureEnabled).
314 Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled).
315 Bool("client_nil", pc.client == nil).
316 Msg("cef: BrowserHostCreateBrowser call completed on CEF UI thread")
317 }))
318
319 postResult := cefPostTask(purecef.ThreadIDTidUi, task)
320 if postResult != 1 {
321 pc.postTaskRetries++
322 wv.mu.Lock()
323 wv.pendingCreate = pc
324 wv.mu.Unlock()
325 log.Error().
326 Int32("result", postResult).
327 Int("retry", pc.postTaskRetries).
328 Msg("cef: failed to post initial browser creation to CEF UI thread")
329 f.schedulePendingBrowserCreateRetry(ctx, wv, w, h, pc)
330 }
331}
332
333func (f *WebViewFactory) schedulePendingBrowserCreateRetry(
334 ctx context.Context,
335 wv *WebView,
336 w, h int32,
337 pc *pendingBrowserCreate,
338) {
339 if f == nil || wv == nil || pc == nil || wv.destroyed.Load() {
340 return
341 }
342 if pc.postTaskRetries > pendingBrowserCreateMaxPostRetries {
343 logging.FromContext(ctx).Warn().
344 Uint64("webview_id", uint64(wv.id)).
345 Int("retries", pc.postTaskRetries).
346 Int("max_retries", pendingBrowserCreateMaxPostRetries).
347 Msg("cef: browser creation retries exhausted")
348 return
349 }
350 cefScheduleAfter(pendingBrowserCreateRetryDelay, func() {
351 if wv.destroyed.Load() {
352 return
353 }
354 f.postPendingBrowserCreate(ctx, wv, w, h)
355 })
356}
357
358func notifyBrowserResize(host resizeNotifiableBrowserHost) {
359 if host == nil {
360 return
361 }
362 host.WasResized()
363 host.Invalidate(purecef.PaintElementTypePetView)
364}
365
366// CreateRelated creates a popup shell for a native CEF popup. The popup
367// browser itself is created by CEF from OnBeforePopup and later bound to this
368// shell in OnAfterCreated.
369func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebViewID) (port.WebView, error) {
370 if f == nil || f.engine == nil {
371 return nil, ErrRelatedWebViewUnsupported
372 }
373
374 parent := f.engine.lookupWebView(parentID)
375 if parent == nil {
376 return nil, fmt.Errorf("parent webview %d not found", parentID)
377 }
378 if parent.destroyed.Load() {
379 return nil, fmt.Errorf("parent webview %d is destroyed", parentID)
380 }
381
382 popupWV, err := f.newWebView(ctx)
383 if err != nil {
384 return nil, err
385 }
386 popupWV.markNativePopupCandidate(parent)
387
388 windowInfo := purecef.NewWindowInfo()
389 cef2gtk.ConfigureWindowInfo(&windowInfo, cef2gtk.WindowInfoOptions{})
390 if externalBeginFrameEnabled() {
391 windowInfo.ExternalBeginFrameEnabled = 1
392 }
393
394 settings := purecef.NewBrowserSettings()
395 cef2gtk.ConfigureBrowserSettings(&settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: f.windowlessFrameRate})
396 settings.LocalStorage = 1 // CEF_STATE_ENABLED
397 // Always opaque white: the theme background is applied as a presentation-layer
398 // flash guard, not baked into Chromium's canvas.
399 settings.BackgroundColor = opaqueWhiteBackground
400
401 if err := f.configureInitialBrowserCreation(ctx, popupWV, popupWV.client, &windowInfo, &settings, func(w, h int32) {
402 f.handlePopupShellInitialResize(ctx, popupWV, func(w, h int32) {
403 f.postPendingBrowserCreate(ctx, popupWV, w, h)
404 }, w, h)
405 }); err != nil {
406 popupWV.Destroy()
407 return nil, err
408 }
409 return popupWV, nil
410}
411
412func (f *WebViewFactory) handlePopupShellInitialResize(
413 ctx context.Context,
414 popupWV *WebView,
415 postPendingCreate func(w, h int32),
416 w, h int32,
417) {
418 if popupWV == nil {
419 return
420 }
421 log := logging.FromContext(ctx)
422 if popupWV.awaitsNativePopupAttachment() {
423 log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell initial size observed, awaiting native popup attach")
424 f.schedulePopupShellNativeFallback(ctx, popupWV)
425 return
426 }
427 if popupWV.awaitsBrowserCreateFromNativePopupFallback() {
428 log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell size ready after fallback, creating browser directly")
429 if postPendingCreate != nil {
430 postPendingCreate(w, h)
431 }
432 return
433 }
434 if !popupWV.preparePopupShellDirectBrowserCreation() {
435 return
436 }
437 log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, creating browser directly")
438 if postPendingCreate != nil {
439 postPendingCreate(w, h)
440 }
441}
442
443func (f *WebViewFactory) schedulePopupShellNativeFallback(ctx context.Context, popupWV *WebView) {
444 if popupWV == nil {
445 return
446 }
447 popupWV.scheduleNativePopupFallback(nativePopupAttachFallbackDelay, func() {
448 popupWV.runOnGTK(func() {
449 if !popupWV.startNativePopupFallback() {
450 return
451 }
452 logging.FromContext(ctx).Warn().
453 Uint64("webview_id", uint64(popupWV.id)).
454 Msg("cef: native popup attach timed out, starting direct popup fallback")
455 synced := popupWV.syncViewportNowOnGTK(ctx, "native-popup-fallback-timeout")
456 logging.FromContext(ctx).Debug().
457 Uint64("webview_id", uint64(popupWV.id)).
458 Bool("browser_ready", synced).
459 Msg("cef: popup fallback viewport sync requested")
460 })
461 })
462}