v0.30.0 eb3068f

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

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