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