v0.31.0 bf0756b

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

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