v0.31.0 bf0756b

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		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}

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