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