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