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 audioOutputFactory: f.audioOutputFactory,
131 adaptiveWindowlessFrameRate: f.adaptiveWindowlessFrameRate,
132 windowlessFrameRate: f.windowlessFrameRate,
133 windowlessFrameRateMax: f.windowlessFrameRateMax,
134 inputConfig: f.inputConfig,
135 backgroundColor: f.bgColor.Load(),
136 }
137 result := wv.runOnGTKSyncLabel("cef.new_webview.init_widgets", func() {
138 nativeWidget := viewBridge.Widget()
139 popupSurface := newPopupBridgeSurface(ctx, nativeWidget, f.engine.renderStackPlan, applicationScale)
140 wv.nativeWidget = nativeWidget
141 if popupSurface != nil {
142 wv.popupSurface = popupSurface
143 if popupRoot := popupSurface.RootWidget(); popupRoot != nil {
144 wv.nativeWidget = popupRoot
145 }
146 }
147 // Guard the render widget with the theme background until the first frame.
148 wv.applyBackgroundFlashGuard(nativeWidget)
149 })
150 if !result.Completed() {
151 wv.destroyViewBridgeOnGTKAsync()
152 return nil, errGTKSyncDispatchIncomplete("initialize CEF GTK widgets", result)
153 }
154
155 handlers := &handlerSet{wv: wv}
156 wv.handlers = handlers
157 wv.findCtrl = newFindController()
158
159 // Capture worker must exist before NewClient so the lazily wrapped render
160 // handler can install a real sink when DUMBER_A11Y_CAPTURE=1.
161 if err := wv.enableAccessibilityCaptureIfRequested(); err != nil {
162 logging.FromContext(ctx).Warn().Err(err).
163 Uint64("webview_id", uint64(id)).
164 Msg("cef: accessibility capture setup failed")
165 }
166
167 // Build a CEF client backed by our handlerSet.
168 // Store on WebView to prevent GC collection before CEF AddRef's it.
169 wv.client = purecef.NewClient(wv.handlers)
170 if opts := f.engine.cef2gtkProfileOptions(wv); opts.Enabled {
171 if err := viewBridge.ConfigureProfiling(opts); err != nil {
172 logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef2gtk: failed to enable profiling")
173 }
174 }
175 if err := wv.installNewWebViewViewportHooksAfterCapture(); err != nil {
176 return nil, err
177 }
178 wv.startRenderStallWatchdog()
179 return wv, nil
180}
181
182func (f *WebViewFactory) configureInitialBrowserCreation(
183 ctx context.Context, wv *WebView, client purecef.RawClient,
184 windowInfo *purecef.WindowInfo, settings *purecef.BrowserSettings,
185 onFirstResize func(w, h int32),
186) error {
187 if wv == nil {
188 return errors.New("configure initial browser creation: webview is nil")
189 }
190 wv.pendingCreate = &pendingBrowserCreate{
191 windowInfo: windowInfo,
192 client: client,
193 settings: settings,
194 }
195 if onFirstResize == nil {
196 onFirstResize = func(w, h int32) {
197 logging.FromContext(ctx).Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation")
198 f.postPendingBrowserCreate(ctx, wv, w, h)
199 }
200 }
201 if wv.viewBridge == nil {
202 return nil
203 }
204 result := wv.runOnGTKSyncLabel("cef.configure_initial_browser_creation", func() {
205 wv.removeSizeObserver = wv.viewBridge.AddSizeObserver(func(w, h int32) {
206 // Size observers run on the GTK thread. Prepare the GtkGLArea only for
207 // the one-shot initial resize path; once that path has been consumed,
208 // later size events must go through normal viewport sync even if popup
209 // attachment keeps pendingCreate alive a little longer.
210 if f.handleInitialBrowserCreateSizeObserver(ctx, wv, onFirstResize, wv.viewBridge.PrepareOnGTKThread, w, h) {
211 return
212 }
213
214 synced := wv.syncResizeViewportOnGTK(ctx, "gtk-size-observer")
215 logging.FromContext(ctx).Debug().
216 Uint64("webview_id", uint64(wv.id)).
217 Int32("resize_width", w).
218 Int32("resize_height", h).
219 Bool("browser_ready", synced).
220 Msg("cef: viewport sync handled after GTK size change")
221 })
222 })
223 if !result.Completed() {
224 wv.pendingCreate = nil
225 return errGTKSyncDispatchIncomplete("configure initial browser creation", result)
226 }
227 return nil
228}
229
230func (f *WebViewFactory) handleInitialBrowserCreateSizeObserver(
231 ctx context.Context,
232 wv *WebView,
233 onFirstResize func(w, h int32),
234 prepareOnGTK func() error,
235 w, h int32,
236) bool {
237 if wv == nil || !wv.shouldStartBrowserCreateFromSizeObserver() {
238 return false
239 }
240 if !initialBrowserCreateSizeReadyFromObserver(w, h) {
241 if wv.awaitsNativePopupAttachment() && onFirstResize != nil {
242 onFirstResize(w, h)
243 }
244 logging.FromContext(ctx).Debug().
245 Uint64("webview_id", uint64(wv.id)).
246 Int32("resize_width", w).
247 Int32("resize_height", h).
248 Msg("cef: ignoring bootstrap size observer event before real view size is ready")
249 return true
250 }
251 if prepareOnGTK != nil {
252 if err := prepareOnGTK(); err != nil {
253 logging.FromContext(ctx).Warn().
254 Err(err).
255 Uint64("webview_id", uint64(wv.id)).
256 Int32("resize_width", w).
257 Int32("resize_height", h).
258 Msg("cef: cef2gtk view not ready after resize, will retry")
259 return true
260 }
261 }
262 wv.markInitialBrowserCreateResizeHandled()
263 if onFirstResize != nil {
264 onFirstResize(w, h)
265 }
266 return true
267}
268
269func initialBrowserCreateSizeReadyFromObserver(width, height int32) bool {
270 return observedViewportSizeReady(width, height, width, height)
271}
272
273func (f *WebViewFactory) postPendingBrowserCreate(ctx context.Context, wv *WebView, w, h int32) {
274 log := logging.FromContext(ctx)
275 pc := wv.takePendingCreate()
276 if pc == nil {
277 return
278 }
279
280 task := cefNewTask(cefTaskFunc(func() {
281 if wv.destroyed.Load() {
282 log.Debug().Msg("cef: skipping browser creation for destroyed webview")
283 return
284 }
285 pendingURL := wv.pendingNavigationURI()
286 // Always bootstrap OSR browsers on about:blank first. Starting directly on
287 // the target URL can race host visibility/focus setup and strand the
288 // renderer without an initial paint. We replay pending navigation from
289 // OnAfterCreated once the host is fully wired.
290 initialURL := "about:blank"
291 // This is the request boundary, not the later OnAfterCreated callback.
292 activeStartupTrace().Mark("browser_create_requested")
293 result := cefBrowserHostCreateBrowser(
294 pc.windowInfo,
295 pc.client,
296 initialURL,
297 pc.settings,
298 pc.extraInfo,
299 nil, // requestContext
300 )
301 log.Debug().
302 Str("initial_url", initialURL).
303 Str("pending_url", pendingURL).
304 Msg("cef: BrowserHostCreateBrowser initial URL")
305 if f.engine != nil {
306 f.engine.recordBrowserCreateRequest(w, h, result)
307 }
308 log.Debug().
309 Int32("result", result).
310 Int32("windowless", pc.windowInfo.WindowlessRenderingEnabled).
311 Int32("windowless_frame_rate", pc.settings.WindowlessFrameRate).
312 Int32("shared_texture", pc.windowInfo.SharedTextureEnabled).
313 Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled).
314 Bool("client_nil", pc.client == nil).
315 Msg("cef: BrowserHostCreateBrowser call completed on CEF UI thread")
316 }))
317
318 postResult := cefPostTask(purecef.ThreadIDTidUi, task)
319 if postResult != 1 {
320 pc.postTaskRetries++
321 wv.mu.Lock()
322 wv.pendingCreate = pc
323 wv.mu.Unlock()
324 log.Error().
325 Int32("result", postResult).
326 Int("retry", pc.postTaskRetries).
327 Msg("cef: failed to post initial browser creation to CEF UI thread")
328 f.schedulePendingBrowserCreateRetry(ctx, wv, w, h, pc)
329 }
330}
331
332func (f *WebViewFactory) schedulePendingBrowserCreateRetry(
333 ctx context.Context,
334 wv *WebView,
335 w, h int32,
336 pc *pendingBrowserCreate,
337) {
338 if f == nil || wv == nil || pc == nil || wv.destroyed.Load() {
339 return
340 }
341 if pc.postTaskRetries > pendingBrowserCreateMaxPostRetries {
342 logging.FromContext(ctx).Warn().
343 Uint64("webview_id", uint64(wv.id)).
344 Int("retries", pc.postTaskRetries).
345 Int("max_retries", pendingBrowserCreateMaxPostRetries).
346 Msg("cef: browser creation retries exhausted")
347 return
348 }
349 cefScheduleAfter(pendingBrowserCreateRetryDelay, func() {
350 if wv.destroyed.Load() {
351 return
352 }
353 f.postPendingBrowserCreate(ctx, wv, w, h)
354 })
355}
356
357func notifyBrowserResize(host resizeNotifiableBrowserHost) {
358 if host == nil {
359 return
360 }
361 host.WasResized()
362 host.Invalidate(purecef.PaintElementTypePetView)
363}
364
365// CreateRelated creates a popup shell for a native CEF popup. The popup
366// browser itself is created by CEF from OnBeforePopup and later bound to this
367// shell in OnAfterCreated.
368func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebViewID) (port.WebView, error) {
369 if f == nil || f.engine == nil {
370 return nil, ErrRelatedWebViewUnsupported
371 }
372
373 parent := f.engine.lookupWebView(parentID)
374 if parent == nil {
375 return nil, fmt.Errorf("parent webview %d not found", parentID)
376 }
377 if parent.destroyed.Load() {
378 return nil, fmt.Errorf("parent webview %d is destroyed", parentID)
379 }
380
381 popupWV, err := f.newWebView(ctx)
382 if err != nil {
383 return nil, err
384 }
385 popupWV.markNativePopupCandidate(parent)
386
387 windowInfo := purecef.NewWindowInfo()
388 cef2gtk.ConfigureWindowInfo(&windowInfo, cef2gtk.WindowInfoOptions{})
389 if externalBeginFrameEnabled() {
390 windowInfo.ExternalBeginFrameEnabled = 1
391 }
392
393 settings := purecef.NewBrowserSettings()
394 cef2gtk.ConfigureBrowserSettings(&settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: f.windowlessFrameRate})
395 settings.LocalStorage = 1 // CEF_STATE_ENABLED
396 // Always opaque white: the theme background is applied as a presentation-layer
397 // flash guard, not baked into Chromium's canvas.
398 settings.BackgroundColor = opaqueWhiteBackground
399
400 if err := f.configureInitialBrowserCreation(ctx, popupWV, popupWV.client, &windowInfo, &settings, func(w, h int32) {
401 f.handlePopupShellInitialResize(ctx, popupWV, func(w, h int32) {
402 f.postPendingBrowserCreate(ctx, popupWV, w, h)
403 }, w, h)
404 }); err != nil {
405 popupWV.Destroy()
406 return nil, err
407 }
408 return popupWV, nil
409}
410
411func (f *WebViewFactory) handlePopupShellInitialResize(
412 ctx context.Context,
413 popupWV *WebView,
414 postPendingCreate func(w, h int32),
415 w, h int32,
416) {
417 if popupWV == nil {
418 return
419 }
420 log := logging.FromContext(ctx)
421 if popupWV.awaitsNativePopupAttachment() {
422 log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell initial size observed, awaiting native popup attach")
423 f.schedulePopupShellNativeFallback(ctx, popupWV)
424 return
425 }
426 if popupWV.awaitsBrowserCreateFromNativePopupFallback() {
427 log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell size ready after fallback, creating browser directly")
428 if postPendingCreate != nil {
429 postPendingCreate(w, h)
430 }
431 return
432 }
433 if !popupWV.preparePopupShellDirectBrowserCreation() {
434 return
435 }
436 log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, creating browser directly")
437 if postPendingCreate != nil {
438 postPendingCreate(w, h)
439 }
440}
441
442func (f *WebViewFactory) schedulePopupShellNativeFallback(ctx context.Context, popupWV *WebView) {
443 if popupWV == nil {
444 return
445 }
446 popupWV.scheduleNativePopupFallback(nativePopupAttachFallbackDelay, func() {
447 popupWV.runOnGTK(func() {
448 if !popupWV.startNativePopupFallback() {
449 return
450 }
451 logging.FromContext(ctx).Warn().
452 Uint64("webview_id", uint64(popupWV.id)).
453 Msg("cef: native popup attach timed out, starting direct popup fallback")
454 synced := popupWV.syncViewportNowOnGTK(ctx, "native-popup-fallback-timeout")
455 logging.FromContext(ctx).Debug().
456 Uint64("webview_id", uint64(popupWV.id)).
457 Bool("browser_ready", synced).
458 Msg("cef: popup fallback viewport sync requested")
459 })
460 })
461}