engine_init.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close