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