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