v0.29.0 21b3150

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

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