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