v0.32.0 0617278

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

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