v0.33.0 62a09f5

engine.go RawHistoryFolder

1package cef
2
3import (
4	"context"
5	"crypto/subtle"
6	"fmt"
7	"os"
8	"sync"
9	"sync/atomic"
10	"time"
11	"unicode/utf8"
12
13	purecef "github.com/bnema/purego-cef/cef"
14	cef2gtk "github.com/bnema/purego-cef2gtk"
15
16	"github.com/bnema/dumber/internal/application/dto"
17	"github.com/bnema/dumber/internal/application/port"
18	"github.com/bnema/dumber/internal/domain/entity"
19	"github.com/bnema/dumber/internal/logging"
20)
21
22// Compile-time interface check.
23var _ port.Engine = (*Engine)(nil)
24var _ port.RuntimeActivityProvider = (*Engine)(nil)
25var _ port.AlreadyRunningAppRelaunchHandlerSetter = (*Engine)(nil)
26
27// Engine implements port.Engine for the CEF browser backend.
28// It manages the CEF lifecycle and provides access to all engine subsystems.
29type Engine struct {
30	ctx                context.Context
31	factory            *WebViewFactory
32	pool               *WebViewPool
33	profileLogDir      string
34	runtimeCEFDir      string
35	stateRoot          string
36	renderStackPlan    cef2gtk.RenderStackPlan
37	applicationScaleMu sync.RWMutex
38	applicationScale   float64
39
40	messageRouter *MessageRouter
41	schemeHandler *dumbSchemeHandler
42	contentInj    *contentInjector
43
44	registerHandlers                 HandlerRegistrar
45	registerAccentHandlers           AccentHandlerRegistrar
46	currentConfigPayload             func() ([]byte, error)
47	defaultConfigPayload             func() ([]byte, error)
48	ctxMenuBuilder                   port.ContextMenuBuilder
49	ctxMenuExecutorFactory           port.ContextMenuActionExecutorFactory
50	ctxMenuRenderer                  ContextMenuRenderer
51	clipboard                        port.Clipboard
52	clipboardTextOrchestrator        port.ClipboardTextOrchestrator
53	onClipboardCopied                func(textLen int)
54	resolver                         port.ImageDataResolver
55	downloadMu                       sync.RWMutex
56	downloadHandler                  *downloadHandler
57	alreadyRunningAppRelaunchMu      sync.RWMutex
58	alreadyRunningAppRelaunchHandler func(string)
59	gpuRelaunchCount                 atomic.Int32
60
61	// activeWebViews tracks all live webviews for CSS broadcast.
62	activeWebViews  sync.Map // map[port.WebViewID]*WebView
63	browserWebViews sync.Map // map[int32]*WebView
64	activeCount     atomic.Int32
65
66	// activity tracks outstanding native runtime work for the residency
67	// quiescence boundary. Initialized by NewEngine; nil in unit-test
68	// literals, where tracker methods are safe no-ops.
69	activity *RuntimeActivityTracker
70
71	// shutdownNotify is signaled when the active webview count reaches 0
72	// during shutdown, replacing the busy-wait poll in closeActiveWebViews.
73	shutdownNotify chan struct{}
74
75	// Cross-layer render diagnostics.
76	cefHeartbeatStop chan struct{}
77	cefHeartbeatDone chan struct{}
78	cefUIHeartbeat   cefThreadHeartbeatState
79	cefIOHeartbeat   cefThreadHeartbeatState
80
81	// Diagnostic counters.
82	contextInitializedCount atomic.Uint64
83	childLaunchRenderer     atomic.Uint64
84	childLaunchGPU          atomic.Uint64
85	childLaunchOther        atomic.Uint64
86
87	browserCreateRequests   atomic.Uint64
88	browserAfterCreated     atomic.Uint64
89	browserCreateLastAtMs   atomic.Int64
90	browserCreateLastResult atomic.Int32
91	browserCreateLastWidth  atomic.Int32
92	browserCreateLastHeight atomic.Int32
93	browserCreateComplete   atomic.Bool
94}
95
96// RuntimeActivity exposes the engine's outstanding-work tracker through
97// the application port. UI subscribes at startup and unsubscribes at
98// shutdown; the initial snapshot is race-safe and sequenced.
99func (e *Engine) RuntimeActivity() port.RuntimeActivity {
100	if e == nil || e.activity == nil {
101		return NewRuntimeActivityTracker()
102	}
103	return e.activity
104}
105
106func (e *Engine) Factory() port.WebViewFactory {
107	return &webViewFactoryAdapter{factory: e.factory}
108}
109
110func (e *Engine) contextMenuRenderer() ContextMenuRenderer {
111	if e == nil {
112		return nil
113	}
114	return e.ctxMenuRenderer
115}
116
117func (e *Engine) Pool() port.WebViewPool {
118	return &webViewPoolAdapter{pool: e.pool, ctx: e.ctx}
119}
120
121func (e *Engine) ContentInjector() port.ContentInjector {
122	if e.contentInj != nil {
123		return e.contentInj
124	}
125	return &noopContentInjector{}
126}
127
128// SettingsApplier returns a no-op applier (Phase 1 stub).
129func (e *Engine) SettingsApplier() port.SettingsApplier {
130	return &noopSettingsApplier{}
131}
132
133// FilterApplier returns nil (content filtering not yet supported for CEF).
134func (e *Engine) FilterApplier() port.FilterApplier {
135	return nil
136}
137
138// FaviconDatabase returns a no-op database (Phase 1 stub).
139func (e *Engine) FaviconDatabase() port.FaviconDatabase {
140	return &noopFaviconDatabase{}
141}
142
143// SetColorResolver sets the color scheme resolver on the content injector.
144// This allows dark mode detection for internal pages. Safe to call after
145// engine creation (e.g., from bootstrap wiring).
146func (e *Engine) SetColorResolver(resolver port.ColorSchemeResolver) {
147	if e.contentInj != nil {
148		e.contentInj.setColorResolver(resolver)
149	}
150}
151
152// SetAlreadyRunningAppRelaunchHandler configures the callback invoked when CEF
153// relaunches an already-running app instance.
154func (e *Engine) SetAlreadyRunningAppRelaunchHandler(handler func(string)) {
155	e.alreadyRunningAppRelaunchMu.Lock()
156	defer e.alreadyRunningAppRelaunchMu.Unlock()
157	e.alreadyRunningAppRelaunchHandler = handler
158}
159
160func (e *Engine) alreadyRunningAppRelaunchCallback() func(string) {
161	e.alreadyRunningAppRelaunchMu.RLock()
162	defer e.alreadyRunningAppRelaunchMu.RUnlock()
163	return e.alreadyRunningAppRelaunchHandler
164}
165
166// recordGPURelaunch asks the next engine start to use Chromium's conservative
167// defaults after two GPU-process relaunch attempts in one running instance.
168func (e *Engine) recordGPURelaunch() {
169	if e == nil || e.gpuRelaunchCount.Add(1) != 2 {
170		return
171	}
172	if err := writeNextStartSafetyMarker(e.stateRoot); err != nil {
173		logging.FromContext(e.ctx).Warn().Err(err).Msg("cef: failed to mark next start safe after repeated GPU relaunch")
174	}
175}
176
177// registerWebView adds a webview to the active tracking map.
178func (e *Engine) registerWebView(wv *WebView) {
179	e.activeWebViews.Store(wv.id, wv)
180	e.activeCount.Add(1)
181	e.activity.NoteViewRegistered()
182}
183
184func (e *Engine) lookupWebView(id port.WebViewID) *WebView {
185	if e == nil {
186		return nil
187	}
188	current, ok := e.activeWebViews.Load(id)
189	if !ok {
190		return nil
191	}
192	wv, _ := current.(*WebView)
193	return wv
194}
195
196func (e *Engine) bindBrowserWebView(browser purecef.Browser, wv *WebView) {
197	if e == nil || browser == nil || wv == nil {
198		return
199	}
200	browserID := browser.GetIdentifier()
201	if browserID <= 0 {
202		logging.FromContext(e.ctx).Debug().Int32("browser_id", browserID).Msg("cef: skipped browser/webview binding for invalid browser id")
203		return
204	}
205	e.browserWebViews.Store(browserID, wv)
206}
207
208func (e *Engine) unbindBrowserWebView(browserID int32, wv *WebView) {
209	if e == nil || browserID <= 0 {
210		return
211	}
212	if current, ok := e.browserWebViews.Load(browserID); ok {
213		if existing, ok := current.(*WebView); ok && existing == wv {
214			e.browserWebViews.Delete(browserID)
215		}
216	}
217}
218
219// unregisterWebView removes a webview from the active tracking map.
220// If the count reaches 0 and a shutdown is in progress, it signals shutdownNotify.
221func (e *Engine) unregisterWebView(wv *WebView, browserID int32) {
222	e.activeWebViews.Delete(wv.id)
223	e.unbindBrowserWebView(browserID, wv)
224	e.activity.DropBrowser(browserID)
225	if e.activeCount.Add(-1) == 0 && e.shutdownNotify != nil {
226		select {
227		case e.shutdownNotify <- struct{}{}:
228		default:
229		}
230	}
231}
232
233// internalSchemePath is the URI scheme used for internal app resources.
234const internalSchemePath = "dumb://"
235
236// InternalSchemePath returns the URI scheme used for internal app resources.
237func (e *Engine) InternalSchemePath() string {
238	return internalSchemePath
239}
240
241// Close releases all resources held by the engine.
242func (e *Engine) Close() error {
243	log := logging.FromContext(e.ctx)
244	activeBefore := e.activeWebViewCount()
245	log.Debug().
246		Int("active_webviews", activeBefore).
247		Msg("cef: engine close started")
248
249	e.closeActiveWebViews()
250	e.pool.Close()
251	e.stopCEFHeartbeat()
252
253	if activeAfter := e.activeWebViewCount(); activeAfter > 0 {
254		log.Warn().
255			Int("remaining_active_webviews", activeAfter).
256			Msg("cef: shutting down with active webviews still registered")
257	}
258
259	purecef.Shutdown()
260	log.Debug().Msg("cef: engine close completed")
261	return nil
262}
263
264// cefShutdownWaitTimeout gives CEF browser destruction enough time to flush
265// GTK/DMABUF texture teardown and child-process shutdown on busy media pages.
266// It is intentionally longer than a UI-frame timeout; Close still logs if the
267// wait expires so slow shutdown remains visible during diagnostics.
268const cefShutdownWaitTimeout = 10 * time.Second
269
270func (e *Engine) activeWebViewCount() int {
271	return int(e.activeCount.Load())
272}
273
274func (e *Engine) closeActiveWebViews() {
275	if e == nil {
276		return
277	}
278
279	log := logging.FromContext(e.ctx)
280	e.shutdownNotify = make(chan struct{}, 1)
281	webViews := make([]*WebView, 0, e.activeWebViewCount())
282	e.activeWebViews.Range(func(_, value any) bool {
283		wv, ok := value.(*WebView)
284		if ok && wv != nil {
285			webViews = append(webViews, wv)
286		}
287		return true
288	})
289
290	if len(webViews) == 0 {
291		return
292	}
293
294	log.Debug().
295		Int("count", len(webViews)).
296		Msg("cef: closing active webviews before shutdown")
297
298	for _, wv := range webViews {
299		wv.Destroy()
300	}
301
302	// Check immediately in case all webviews closed synchronously.
303	if e.activeWebViewCount() == 0 {
304		log.Debug().Msg("cef: all active webviews closed before shutdown")
305		e.destroyClosedWebViewBridges(webViews)
306		return
307	}
308
309	// Wait for unregisterWebView to signal that all webviews are gone, or timeout.
310	select {
311	case <-e.shutdownNotify:
312		log.Debug().Msg("cef: all active webviews closed before shutdown")
313	case <-time.After(cefShutdownWaitTimeout):
314		log.Warn().
315			Int("remaining", e.activeWebViewCount()).
316			Str("timeout", cefShutdownWaitTimeout.String()).
317			Msg("cef: timed out waiting for OnBeforeClose before shutdown")
318	}
319	e.destroyClosedWebViewBridges(webViews)
320}
321
322func (e *Engine) destroyClosedWebViewBridges(webViews []*WebView) {
323	for _, wv := range webViews {
324		if wv != nil && wv.destroyed.Load() {
325			// Completion accounting lands in destroyViewBridgeOnGTKThread.
326			wv.destroyViewBridgeOnGTKSync()
327		}
328	}
329}
330
331// RegisterHandlers registers all WebUI message bridge handlers with the message router.
332// Handler registration follows the same pattern as WebKit's handlers/register.go:
333// each handler is registered by message type with the router, which dispatches
334// incoming /api/message POSTs to the correct handler based on Message.Type.
335func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependencies) error {
336	e.clipboardTextOrchestrator = deps.ClipboardTextOrchestrator
337	e.onClipboardCopied = deps.OnClipboardCopied
338	if e.messageRouter == nil || e.registerHandlers == nil {
339		return nil
340	}
341	return e.registerHandlers(ctx, e.messageRouter, deps)
342}
343
344func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.AccentKeyHandler) error {
345	if e.messageRouter == nil || handler == nil || e.registerAccentHandlers == nil {
346		return nil
347	}
348	return e.registerAccentHandlers(ctx, e.messageRouter, handler)
349}
350
351// ConfigureDownloads sets up download handling for all CEF webviews.
352func (e *Engine) ConfigureDownloads(
353	_ context.Context,
354	downloadPath string,
355	eventHandler port.DownloadEventHandler,
356	preparer port.DownloadPreparer,
357) error {
358	if preparer == nil {
359		return fmt.Errorf("cef: download preparer is required")
360	}
361	e.downloadMu.Lock()
362	e.downloadHandler = newDownloadHandler(downloadPath, eventHandler, preparer, e.activity)
363	e.downloadMu.Unlock()
364	return nil
365}
366
367// OnToolkitReady is called after the UI toolkit has initialized.
368// With multi-threaded message loop enabled, CEF drives its own pump
369// and no manual pump is needed.
370func (e *Engine) OnToolkitReady(_ context.Context) error {
371	log := logging.FromContext(e.ctx)
372	log.Debug().Msg("cef: OnToolkitReady called")
373	return nil
374}
375
376// UpdateAppearance updates the default background color for new WebViews.
377func (e *Engine) UpdateAppearance(_ context.Context, r, g, b, alpha float64) error {
378	if e.factory != nil {
379		e.factory.setDefaultBackgroundColor(r, g, b, alpha)
380	}
381	return nil
382}
383
384// UpdateSettings applies runtime config changes to engine internals.
385//
386// CEF view scaling is fixed when each cef2gtk view is constructed. Changes to
387// default_ui_scale therefore apply to newly-created CEF views in this engine;
388// existing views continue using their construction-time scale until reload under
389// a new engine lifetime. Browser zoom remains independently adjustable at
390// runtime through SetZoomLevel.
391func (e *Engine) UpdateSettings(ctx context.Context, update entity.EngineSettingsUpdate) error {
392	newScale := normalizedApplicationScale(update.Settings.DefaultUIScale)
393	e.applicationScaleMu.Lock()
394	oldScale := e.applicationScale
395	if oldScale != newScale {
396		e.applicationScale = newScale
397	}
398	e.applicationScaleMu.Unlock()
399
400	if oldScale != newScale {
401		logging.FromContext(ctx).Info().
402			Float64("old_application_scale", oldScale).
403			Float64("new_application_scale", newScale).
404			Msg("cef: application scale update will apply to newly-created CEF views")
405	}
406	return nil
407}
408
409func (e *Engine) currentApplicationScale() float64 {
410	if e == nil {
411		return 1
412	}
413	e.applicationScaleMu.RLock()
414	defer e.applicationScaleMu.RUnlock()
415	return normalizedApplicationScale(e.applicationScale)
416}
417
418func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action, text string) {
419	if e == nil || text == "" {
420		return
421	}
422	if e.clipboardTextOrchestrator == nil {
423		logging.FromContext(e.currentContext()).Debug().
424			Str("action", action).
425			Msg("cef: clipboard orchestration skipped — orchestrator nil")
426		return
427	}
428	if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), dto.ExplicitClipboardInput{
429		Text:         text,
430		Action:       action,
431		SourceEngine: dto.ClipboardSourceCEF,
432		ViewID:       uint64(viewID),
433	}); err != nil {
434		logging.FromContext(e.currentContext()).Debug().
435			Err(err).
436			Str("action", action).
437			Int("text_len", utf8.RuneCountInString(text)).
438			Msg("cef: clipboard explicit copy handling failed")
439	}
440}
441
442func (e *Engine) handleClipboardSelectionUpdate(viewID port.WebViewID, text string) {
443	if e == nil || e.clipboardTextOrchestrator == nil {
444		return
445	}
446	if err := e.clipboardTextOrchestrator.HandleSelectionUpdate(e.currentContext(), dto.SelectionClipboardInput{
447		Text:         text,
448		SourceEngine: dto.ClipboardSourceCEF,
449		ViewID:       uint64(viewID),
450	}); err != nil {
451		logging.FromContext(e.currentContext()).Debug().
452			Err(err).
453			Int("text_len", utf8.RuneCountInString(text)).
454			Msg("cef: clipboard selection handling failed")
455	}
456}
457
458func (e *Engine) notifyClipboardCopied(text string) {
459	if e == nil || e.onClipboardCopied == nil || text == "" {
460		return
461	}
462	e.onClipboardCopied(utf8.RuneCountInString(text))
463}
464
465// SetHandlerContext sets the base context for message handler dispatch.
466func (e *Engine) SetHandlerContext(ctx context.Context) {
467	e.ctx = ctx
468	logger := logging.FromContext(ctx)
469	logger.Info().
470		Str("settings_cef_dir", e.runtimeCEFDir).
471		Str("env_cef_dir", os.Getenv("CEF_DIR")).
472		Msg("cef: runtime selection")
473	if libcefPath := loadedLibCEFPath(); libcefPath != "" {
474		logger.Info().Str("libcef_path", libcefPath).Msg("cef: runtime library loaded")
475	} else {
476		logger.Warn().Msg("cef: runtime library loaded but libcef path was not found in /proc/self/maps")
477	}
478	if e.messageRouter != nil {
479		e.messageRouter.SetBaseContext(ctx)
480	}
481}
482
483func (e *Engine) recordContextInitialized() {
484	count := e.contextInitializedCount.Add(1)
485	logging.FromContext(e.ctx).Debug().
486		Uint64("count", count).
487		Msg("cef: OnContextInitialized")
488}
489
490func (e *Engine) recordChildProcessLaunch(processType, useAngle, ozonePlatform, renderNodeOverride, commandLine string) {
491	switch processType {
492	case "renderer":
493		e.childLaunchRenderer.Add(1)
494	case "gpu-process":
495		e.childLaunchGPU.Add(1)
496	default:
497		e.childLaunchOther.Add(1)
498	}
499
500	logging.FromContext(e.ctx).Debug().
501		Str("process_type", processType).
502		Str("use_angle", useAngle).
503		Str("ozone_platform", ozonePlatform).
504		Str("render_node_override", renderNodeOverride).
505		Strs("command_line_flags", safeChromiumCmdlineFlags(commandLine)).
506		Uint64("renderer_launches", e.childLaunchRenderer.Load()).
507		Uint64("gpu_launches", e.childLaunchGPU.Load()).
508		Uint64("other_launches", e.childLaunchOther.Load()).
509		Msg("cef: OnBeforeChildProcessLaunch")
510}
511
512func (e *Engine) recordBrowserCreateRequest(width, height, result int32) {
513	count := e.browserCreateRequests.Add(1)
514	e.browserCreateLastAtMs.Store(time.Now().UnixMilli())
515	e.browserCreateLastResult.Store(result)
516	e.browserCreateLastWidth.Store(width)
517	e.browserCreateLastHeight.Store(height)
518
519	logging.FromContext(e.ctx).Debug().
520		Uint64("request_count", count).
521		Int32("width", width).
522		Int32("height", height).
523		Int32("result", result).
524		Msg("cef: BrowserHostCreateBrowser returned")
525	if result == 1 {
526		e.activity.NoteCreationAccepted()
527		return
528	}
529	logging.FromContext(e.ctx).Warn().
530		Uint64("request_count", count).
531		Int32("width", width).
532		Int32("height", height).
533		Int32("result", result).
534		Msg("cef: BrowserHostCreateBrowser returned non-success")
535}
536
537func (e *Engine) recordBrowserAfterCreated(browser purecef.Browser) {
538	count := e.browserAfterCreated.Add(1)
539	e.activity.NoteCreationResolved()
540	if count >= e.browserCreateRequests.Load() {
541		e.browserCreateComplete.Store(true)
542	}
543	browserID := int32(0)
544	if browser != nil {
545		browserID = browser.GetIdentifier()
546	}
547	logging.FromContext(e.ctx).Debug().
548		Uint64("after_created_count", count).
549		Uint64("create_requests", e.browserCreateRequests.Load()).
550		Int32("browser_id", browserID).
551		Msg("cef: browser created")
552}
553
554func (e *Engine) currentContext() context.Context {
555	if e == nil || e.ctx == nil {
556		return context.Background()
557	}
558	return e.ctx
559}
560
561func (e *Engine) validateBridgeRequest(browser purecef.Browser, bridgeNonce string) bool {
562	if e == nil || browser == nil || bridgeNonce == "" {
563		return false
564	}
565
566	wv := e.webViewForBrowser(browser)
567	if wv == nil {
568		return false
569	}
570
571	wv.mu.RLock()
572	wvBridgeNonce := wv.bridgeNonce
573	wv.mu.RUnlock()
574	if wvBridgeNonce == "" {
575		return false
576	}
577	return subtle.ConstantTimeCompare([]byte(wvBridgeNonce), []byte(bridgeNonce)) == 1
578}
579
580func (e *Engine) currentDownloadHandler() *downloadHandler {
581	if e == nil {
582		return nil
583	}
584	e.downloadMu.RLock()
585	defer e.downloadMu.RUnlock()
586	return e.downloadHandler
587}

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