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