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