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}