v0.29.0 21b3150

factory.go RawHistoryFolder

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}

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