1package cef
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "math"
8 "os"
9 "path/filepath"
10 "slices"
11 "strconv"
12 "strings"
13 "syscall"
14
15 purecef "github.com/bnema/purego-cef/cef"
16 cef2gtk "github.com/bnema/purego-cef2gtk"
17 "github.com/bnema/puregotk/v4/gdk"
18 "github.com/bnema/puregotk/v4/glib"
19 "github.com/rs/zerolog"
20
21 "github.com/bnema/dumber/assets"
22 "github.com/bnema/dumber/internal/application/port"
23 "github.com/bnema/dumber/internal/logging"
24)
25
26const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"
27const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH"
28const defaultCEFWindowlessFrameRate = 60
29
30// RuntimePaths contains the concrete filesystem paths the CEF adapter needs.
31type RuntimePaths struct {
32 StateRoot string
33 LogFile string
34 ProfileLogDir string
35}
36
37// NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
38func NewEngine(
39 ctx context.Context,
40 opts port.EngineOptions,
41 paths RuntimePaths,
42 cfg RuntimeConfig,
43 audioFactory port.AudioOutputFactory,
44 deps EngineDependencies,
45) (*Engine, error) {
46 logger := logging.FromContext(ctx)
47 stateRoot := resolvedStateRoot(paths.StateRoot, opts)
48 if consumeNextStartSafetyMarker(stateRoot) {
49 applyNextStartSafetyEnvironment()
50 logger.Warn().Str("state_root", stateRoot).Msg("cef: applying next-start GPU safety recovery")
51 }
52 cleanStaleSingletonLocks(logger, stateRoot)
53 windowlessFrameRate := normalizedWindowlessFrameRate(cfg.WindowlessFrameRate)
54 renderStackPlan, err := resolveCEFRenderStackPlan(cfg.RenderStack)
55 if err != nil {
56 return nil, err
57 }
58 cef2gtk.ConfigureRenderStackEnvironment(renderStackPlan)
59 activeStartupTrace().SetBackend(renderStackPlan.Backend.String())
60
61 settings, err := prepareCEFSettings(opts, paths, cfg, logger)
62 if err != nil {
63 return nil, err
64 }
65
66 // Inject --no-zygote temporarily for cef_initialize, then restore.
67 // Safe: runs during single-threaded startup before concurrent goroutines.
68 savedArgs := os.Args
69 os.Args = appendIfMissing(os.Args, "--no-zygote")
70
71 eng := &Engine{
72 ctx: ctx,
73 profileLogDir: paths.ProfileLogDir,
74 runtimeCEFDir: settings.CEFDir,
75 stateRoot: stateRoot,
76 renderStackPlan: renderStackPlan,
77 applicationScale: normalizedApplicationScale(cfg.ApplicationScale),
78 registerHandlers: deps.RegisterHandlers,
79 registerAccentHandlers: deps.RegisterAccentHandlers,
80 currentConfigPayload: deps.CurrentConfigPayload,
81 defaultConfigPayload: deps.DefaultConfigPayload,
82 ctxMenuBuilder: deps.ContextMenuBuilder,
83 ctxMenuExecutorFactory: deps.ContextMenuExecutorFactory,
84 ctxMenuRenderer: deps.ContextMenuRenderer,
85 clipboard: deps.Clipboard,
86 resolver: deps.ImageDataResolver,
87 }
88
89 logger.Info().
90 Int32("windowless_frame_rate", windowlessFrameRate).
91 Str("render_stack", string(renderStackPlan.Stack)).
92 Str("render_backend", renderStackPlan.Backend.String()).
93 Str("angle_backend", renderStackPlan.ANGLEBackend).
94 Str("gsk_renderer", renderStackPlan.GSKRenderer).
95 Bool("external_begin_frame", externalBeginFrameEnabled()).
96 Bool("trace_handlers", cfg.TraceHandlers).
97 Bool("enable_audio_handler", cfg.EnableAudioHandler).
98 Float64("application_scale", eng.currentApplicationScale()).
99 Msg("cef: configured engine")
100
101 if err := initializeCEF(eng, settings, logger); err != nil {
102 os.Args = savedArgs
103 return nil, err
104 }
105 os.Args = savedArgs
106
107 return wireEngine(
108 ctx,
109 eng,
110 webViewFactoryOptions{
111 adaptiveWindowlessFrameRate: cfg.AdaptiveWindowlessFrameRate,
112 windowlessFrameRate: windowlessFrameRate,
113 windowlessFrameRateMax: cfg.WindowlessFrameRateMax,
114 inputConfig: cfg.Input,
115 audioOutputFactory: audioFactory,
116 },
117 logger,
118 deps.CurrentConfigPayload,
119 deps.DefaultConfigPayload,
120 )
121}
122
123func normalizedApplicationScale(scale float64) float64 {
124 if math.IsNaN(scale) || math.IsInf(scale, 0) || scale <= 0 {
125 return 1
126 }
127 return scale
128}
129
130func normalizedWindowlessFrameRate(frameRate int32) int32 {
131 if frameRate > 0 {
132 return frameRate
133 }
134 return defaultCEFWindowlessFrameRate
135}
136
137func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string {
138 if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {
139 return root
140 }
141 if opts.CacheDir != "" {
142 return opts.CacheDir
143 }
144 if opts.DataDir != "" {
145 return opts.DataDir
146 }
147 return defaultStateRoot
148}
149
150// configureCEFSubprocessRuntime chooses the CEF runtime used by both the
151// browser and CEF helper processes. CEF re-execs this binary for helpers before
152// Dumber can load configuration, so the selected configured runtime must be in
153// its inherited environment before InitWithApp launches any child. A caller's
154// explicit CEF_DIR remains the override for both process types.
155func configureCEFSubprocessRuntime(configured string) string {
156 if explicit := os.Getenv("CEF_DIR"); explicit != "" {
157 return explicit
158 }
159 if configured == "" {
160 return ""
161 }
162 if err := os.Setenv("CEF_DIR", configured); err != nil {
163 return configured
164 }
165 return configured
166}
167
168// prepareCEFSettings builds purecef.Settings from the engine config.
169func prepareCEFSettings(
170 opts port.EngineOptions,
171 paths RuntimePaths,
172 cfg RuntimeConfig,
173 logger *zerolog.Logger,
174) (purecef.Settings, error) {
175 switch opts.CookiePolicy {
176 case "", port.CookiePolicyAlways:
177 case port.CookiePolicyNoThirdParty:
178 logger.Warn().
179 Str("cookie_policy", string(opts.CookiePolicy)).
180 Msg("cef: no_third_party cookie policy is currently treated as always")
181 case port.CookiePolicyNever:
182 return purecef.Settings{}, fmt.Errorf("%w: %s", ErrCookiePolicyUnsupported, opts.CookiePolicy)
183 default:
184 return purecef.Settings{}, fmt.Errorf("%w: %s", ErrCookiePolicyUnsupported, opts.CookiePolicy)
185 }
186
187 settings := purecef.DefaultSettings()
188 settings.MultiThreadedMessageLoop = true
189 settings.ExternalMessagePump = false
190 settings.RootCachePath = resolvedStateRoot(paths.StateRoot, opts)
191 settings.CEFDir = configureCEFSubprocessRuntime(cfg.CEFDir)
192 if runtimeLogFile, err := prepareCEFLogFile(paths.LogFile, cfg.LogFile); err != nil {
193 logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file")
194 } else {
195 settings.LogFile = runtimeLogFile
196 }
197 if puregoCEFInitTraceEnabled() {
198 warn := logger.Warn().Str("env_var", puregoCEFInitTraceEnvVar)
199 if bootstrapLogFile := resolveCEFInitTraceFile(paths.LogFile, cfg.LogFile); bootstrapLogFile != "" {
200 warn = warn.Str("bootstrap_log_file", bootstrapLogFile)
201 }
202 warn.Msg("cef: init bootstrap diagnostics requested, but current purego-cef no longer consumes this trace file")
203 }
204 if cfg.LogSeverity != 0 {
205 settings.LogSeverity = cfg.LogSeverity
206 }
207 return settings, nil
208}
209
210// initializeCEF calls cef_initialize with the App to register custom schemes
211// and browser-process callbacks (OnBeforeCommandLineProcessing, etc.).
212func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error {
213 app := newDumberApp(eng)
214 logger.Info().
215 Str("settings_cef_dir", settings.CEFDir).
216 Str("env_cef_dir", os.Getenv("CEF_DIR")).
217 Msg("cef: runtime selection")
218 logger.Debug().Msg("cef: calling InitWithApp")
219 // InitWithApp encapsulates loading libcef, so only the begin boundary is
220 // observable here; do not invent a separate library-load completion event.
221 activeStartupTrace().Mark("cef_library_load_begin")
222 if err := purecef.InitWithApp(settings, app); err != nil {
223 return fmt.Errorf("cef.InitWithApp: %w", err)
224 }
225 activeStartupTrace().Mark("cef_initialized")
226 if libcefPath := loadedLibCEFPath(); libcefPath != "" {
227 logger.Info().Str("libcef_path", libcefPath).Msg("cef: runtime library loaded")
228 } else {
229 logger.Warn().Msg("cef: runtime library loaded but libcef path was not found in /proc/self/maps")
230 }
231 logger.Debug().Msg("cef: InitWithApp returned OK")
232 return nil
233}
234
235func loadedLibCEFPath() string {
236 data, err := os.ReadFile("/proc/self/maps")
237 if err != nil {
238 return ""
239 }
240 return parseLoadedLibCEFPath(string(data))
241}
242
243func parseLoadedLibCEFPath(maps string) string {
244 for line := range strings.SplitSeq(maps, "\n") {
245 if !strings.Contains(line, "libcef.so") {
246 continue
247 }
248 pathStart := strings.Index(line, "/")
249 if pathStart < 0 {
250 continue
251 }
252 return strings.TrimSpace(line[pathStart:])
253 }
254 return ""
255}
256
257// wireEngine creates factory, pool, and scheme handler after CEF init.
258func wireEngine(
259 ctx context.Context, eng *Engine,
260 factoryOptions webViewFactoryOptions, logger *zerolog.Logger,
261 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),
262) (*Engine, error) {
263 eng.factory = newWebViewFactory(eng, factoryOptions)
264 eng.pool = newWebViewPool(eng.factory)
265 eng.contentInj = newContentInjector(eng, nil)
266
267 messageRouter, schemeHandler, err := newEngineSchemeHandler(
268 ctx,
269 eng,
270 logger,
271 currentConfigPayload,
272 defaultConfigPayload,
273 )
274 if err != nil {
275 purecef.Shutdown()
276 return nil, err
277 }
278 eng.messageRouter = messageRouter
279 eng.schemeHandler = schemeHandler
280 eng.startCEFHeartbeat()
281
282 registerEngineSchemeFactories(logger, purecef.NewSchemeHandlerFactory(schemeHandler))
283 return eng, nil
284}
285
286func newEngineSchemeHandler(
287 ctx context.Context,
288 eng *Engine,
289 logger *zerolog.Logger,
290 currentConfigPayload func() ([]byte, error),
291 defaultConfigPayload func() ([]byte, error),
292) (*MessageRouter, *dumbSchemeHandler, error) {
293 messageRouter := NewMessageRouter(ctx)
294 schemeHandler, err := newDumbSchemeHandler(
295 ctx,
296 messageRouter,
297 currentConfigPayload,
298 defaultConfigPayload,
299 )
300 if err != nil {
301 return nil, nil, err
302 }
303 schemeHandler.setAssets(assets.WebUIAssets)
304 schemeHandler.onClipboardSet = makeGTKClipboardSetter(logger)
305 schemeHandler.onEditableFocus = eng.handleEditableFocusBridge
306 schemeHandler.onPopupOpen = eng.handlePopupBridgeOpen
307 schemeHandler.onPopupNavigate = eng.handlePopupBridgeNavigate
308 schemeHandler.onPopupClose = eng.handlePopupBridgeClose
309 schemeHandler.onPopupOpenerNavigate = eng.handlePopupOpenerNavigate
310 schemeHandler.onPopupOpenerPostMessage = eng.handlePopupOpenerPostMessage
311 schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest
312 return messageRouter, schemeHandler, nil
313}
314
315func makeGTKClipboardSetter(logger *zerolog.Logger) func(string) {
316 // Bridge clipboard writes from page JS → GDK system clipboard.
317 // The callback is invoked on the CEF IO thread, so schedule the actual GDK
318 // write on the GTK main loop.
319 return func(text string) {
320 fn := glib.SourceOnceFunc(func(_ uintptr) {
321 if display := gdk.DisplayGetDefault(); display != nil {
322 if cb := display.GetClipboard(); cb != nil {
323 cb.SetText(text)
324 logger.Debug().Msg("cef: clipboard set via GDK")
325 } else {
326 logger.Debug().Msg("cef: clipboard set failed — no GDK clipboard")
327 }
328 } else {
329 logger.Debug().Msg("cef: clipboard set failed — no GDK display")
330 }
331 })
332 glib.IdleAddOnce(&fn, 0)
333 }
334}
335
336func registerEngineSchemeFactories(logger *zerolog.Logger, schemeFactory purecef.SchemeHandlerFactory) {
337 registerEngineSchemeFactory(logger, "dumb", "", schemeFactory)
338 registerEngineSchemeFactory(logger, actualInternalScheme, actualInternalHost, schemeFactory)
339}
340
341func registerEngineSchemeFactory(
342 logger *zerolog.Logger,
343 scheme, host string,
344 schemeFactory purecef.SchemeHandlerFactory,
345) {
346 result := purecef.RegisterSchemeHandlerFactory(scheme, host, schemeFactory)
347 if scheme == "dumb" && host == "" {
348 if result != 1 {
349 logger.Error().Int32("result", result).Msg("cef: failed to register dumb:// scheme handler factory")
350 return
351 }
352 logger.Info().Msg("cef: registered dumb:// scheme handler factory")
353 return
354 }
355 if result != 1 {
356 logger.Error().
357 Int32("result", result).
358 Str("origin", actualInternalOrigin).
359 Msg("cef: failed to register internal https handler factory")
360 return
361 }
362 logger.Info().
363 Str("origin", actualInternalOrigin).
364 Msg("cef: registered internal https handler factory")
365}
366
367// appendIfMissing appends flag to args if it's not already present.
368func appendIfMissing(args []string, flag string) []string {
369 if slices.Contains(args, flag) {
370 return args
371 }
372 return append(args, flag)
373}
374
375// cleanStaleSingletonLocks removes CEF's SingletonLock/Socket/Cookie files
376// if the owning process is no longer running. CEF leaves these behind on
377// unclean shutdown (SIGKILL, SIGSEGV) and the next instance crashes trying
378// to connect to the dead process.
379func cleanStaleSingletonLocks(logger *zerolog.Logger, dir string) {
380 if dir == "" {
381 return
382 }
383
384 lockPath := filepath.Join(dir, "SingletonLock")
385 target, err := os.Readlink(lockPath)
386 if err != nil {
387 return // No lock file or not a symlink — nothing to clean.
388 }
389
390 // SingletonLock is a symlink to "hostname-pid". Split from the right
391 // because hostnames may contain hyphens.
392 lastDash := strings.LastIndex(target, "-")
393 if lastDash < 0 || lastDash == len(target)-1 {
394 return
395 }
396 pid, err := strconv.Atoi(target[lastDash+1:])
397 if err != nil || pid <= 0 {
398 return
399 }
400
401 // Check if the process is still alive.
402 proc, err := os.FindProcess(pid)
403 if err == nil {
404 if err := proc.Signal(syscall.Signal(0)); err == nil {
405 // Process is alive — don't touch the lock.
406 return
407 }
408 }
409
410 // Stale lock — remove singleton files.
411 for _, name := range []string{"SingletonLock", "SingletonSocket", "SingletonCookie"} {
412 p := filepath.Join(dir, name)
413 if err := os.Remove(p); err == nil {
414 logger.Info().Str("path", p).Msg("cef: removed stale singleton file")
415 }
416 }
417}
418
419const (
420 dirPerm = 0o700
421 filePerm = 0o600
422)
423
424func prepareCEFLogFile(defaultLogFile, logFile string) (string, error) {
425 runtimeLogFile := resolveLogFile(defaultLogFile, logFile)
426 if runtimeLogFile == "" {
427 return "", nil
428 }
429 if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil {
430 return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)
431 }
432
433 file, err := openRegularLogFile(runtimeLogFile)
434 if err != nil {
435 return "", err
436 }
437 defer func() { _ = file.Close() }()
438
439 info, err := file.Stat()
440 if err != nil {
441 return "", fmt.Errorf("stat %s: %w", runtimeLogFile, err)
442 }
443 if !info.Mode().IsRegular() {
444 return "", fmt.Errorf("log path %s is not a regular file", runtimeLogFile)
445 }
446 if err := file.Chmod(filePerm); err != nil {
447 return "", fmt.Errorf("chmod %s: %w", runtimeLogFile, err)
448 }
449 return runtimeLogFile, nil
450}
451
452func openRegularLogFile(path string) (*os.File, error) {
453 info, err := os.Lstat(path)
454 if err != nil {
455 if !errors.Is(err, os.ErrNotExist) {
456 return nil, fmt.Errorf("lstat %s: %w", path, err)
457 }
458 file, createErr := openFileNoFollow(path, syscall.O_CREAT|syscall.O_EXCL|syscall.O_WRONLY, filePerm)
459 if createErr != nil {
460 return nil, fmt.Errorf("create %s: %w", path, createErr)
461 }
462 return file, nil
463 }
464 if info.Mode()&os.ModeSymlink != 0 {
465 return nil, fmt.Errorf("log path %s must not be a symlink", path)
466 }
467 if !info.Mode().IsRegular() {
468 return nil, fmt.Errorf("log path %s is not a regular file", path)
469 }
470 file, openErr := openFileNoFollow(path, syscall.O_WRONLY, 0)
471 if openErr != nil {
472 return nil, fmt.Errorf("open %s: %w", path, openErr)
473 }
474 return file, nil
475}
476
477func openFileNoFollow(path string, flags int, perm os.FileMode) (*os.File, error) {
478 fd, err := syscall.Open(path, flags|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, uint32(perm))
479 if err != nil {
480 return nil, err
481 }
482 return os.NewFile(uintptr(fd), path), nil
483}
484
485func resolveCEFInitTraceFile(defaultLogFile, logFile string) string {
486 runtimeLogFile := resolveLogFile(defaultLogFile, logFile)
487 if runtimeLogFile == "" {
488 return ""
489 }
490 return strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log"
491}
492
493func resolveLogFile(defaultLogFile, logFile string) string {
494 if strings.TrimSpace(logFile) != "" {
495 return filepath.Clean(logFile)
496 }
497 if strings.TrimSpace(defaultLogFile) != "" {
498 return filepath.Clean(defaultLogFile)
499 }
500 return ""
501}
502
503func puregoCEFInitTraceEnabled() bool {
504 return envBoolEnabled(puregoCEFInitTraceEnvVar)
505}