v0.30.0 eb3068f

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

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