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