factory.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close