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