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}