v0.33.1 9577fee

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

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