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.onVimPage = eng.handleVimPageBridge
327	schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest
328	return messageRouter, schemeHandler, nil
329}
330
331func makeGTKClipboardSetter(logger *zerolog.Logger) func(string) {
332	// Bridge clipboard writes from page JS → GDK system clipboard.
333	// The callback is invoked on the CEF IO thread, so schedule the actual GDK
334	// write on the GTK main loop.
335	return func(text string) {
336		fn := glib.SourceOnceFunc(func(_ uintptr) {
337			if display := gdk.DisplayGetDefault(); display != nil {
338				if cb := display.GetClipboard(); cb != nil {
339					cb.SetText(text)
340					logger.Debug().Msg("cef: clipboard set via GDK")
341				} else {
342					logger.Debug().Msg("cef: clipboard set failed — no GDK clipboard")
343				}
344			} else {
345				logger.Debug().Msg("cef: clipboard set failed — no GDK display")
346			}
347		})
348		glib.IdleAddOnce(&fn, 0)
349	}
350}
351
352func registerEngineSchemeFactories(logger *zerolog.Logger, schemeFactory purecef.SchemeHandlerFactory) {
353	registerEngineSchemeFactory(logger, "dumb", "", schemeFactory)
354	registerEngineSchemeFactory(logger, actualInternalScheme, actualInternalHost, schemeFactory)
355}
356
357func registerEngineSchemeFactory(
358	logger *zerolog.Logger,
359	scheme, host string,
360	schemeFactory purecef.SchemeHandlerFactory,
361) {
362	result := purecef.RegisterSchemeHandlerFactory(scheme, host, schemeFactory)
363	if scheme == "dumb" && host == "" {
364		if result != 1 {
365			logger.Error().Int32("result", result).Msg("cef: failed to register dumb:// scheme handler factory")
366			return
367		}
368		logger.Info().Msg("cef: registered dumb:// scheme handler factory")
369		return
370	}
371	if result != 1 {
372		logger.Error().
373			Int32("result", result).
374			Str("origin", actualInternalOrigin).
375			Msg("cef: failed to register internal https handler factory")
376		return
377	}
378	logger.Info().
379		Str("origin", actualInternalOrigin).
380		Msg("cef: registered internal https handler factory")
381}
382
383// appendIfMissing appends flag to args if it's not already present.
384func appendIfMissing(args []string, flag string) []string {
385	if slices.Contains(args, flag) {
386		return args
387	}
388	return append(args, flag)
389}
390
391// cleanStaleSingletonLocks removes CEF's SingletonLock/Socket/Cookie files
392// if the owning process is no longer running. CEF leaves these behind on
393// unclean shutdown (SIGKILL, SIGSEGV) and the next instance crashes trying
394// to connect to the dead process.
395func cleanStaleSingletonLocks(logger *zerolog.Logger, dir string) {
396	if dir == "" {
397		return
398	}
399
400	lockPath := filepath.Join(dir, "SingletonLock")
401	target, err := os.Readlink(lockPath)
402	if err != nil {
403		return // No lock file or not a symlink — nothing to clean.
404	}
405
406	// SingletonLock is a symlink to "hostname-pid". Split from the right
407	// because hostnames may contain hyphens.
408	lastDash := strings.LastIndex(target, "-")
409	if lastDash < 0 || lastDash == len(target)-1 {
410		return
411	}
412	pid, err := strconv.Atoi(target[lastDash+1:])
413	if err != nil || pid <= 0 {
414		return
415	}
416
417	// Check if the process is still alive.
418	proc, err := os.FindProcess(pid)
419	if err == nil {
420		if err := proc.Signal(syscall.Signal(0)); err == nil {
421			// Process is alive — don't touch the lock.
422			return
423		}
424	}
425
426	// Stale lock — remove singleton files.
427	for _, name := range []string{"SingletonLock", "SingletonSocket", "SingletonCookie"} {
428		p := filepath.Join(dir, name)
429		if err := os.Remove(p); err == nil {
430			logger.Info().Str("path", p).Msg("cef: removed stale singleton file")
431		}
432	}
433}
434
435const (
436	dirPerm  = 0o700
437	filePerm = 0o600
438)
439
440func prepareCEFLogFile(defaultLogFile, logFile string) (string, error) {
441	runtimeLogFile := resolveLogFile(defaultLogFile, logFile)
442	if runtimeLogFile == "" {
443		return "", nil
444	}
445	if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil {
446		return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)
447	}
448
449	file, err := openRegularLogFile(runtimeLogFile)
450	if err != nil {
451		return "", err
452	}
453	defer func() { _ = file.Close() }()
454
455	info, err := file.Stat()
456	if err != nil {
457		return "", fmt.Errorf("stat %s: %w", runtimeLogFile, err)
458	}
459	if !info.Mode().IsRegular() {
460		return "", fmt.Errorf("log path %s is not a regular file", runtimeLogFile)
461	}
462	if err := file.Chmod(filePerm); err != nil {
463		return "", fmt.Errorf("chmod %s: %w", runtimeLogFile, err)
464	}
465	return runtimeLogFile, nil
466}
467
468func openRegularLogFile(path string) (*os.File, error) {
469	info, err := os.Lstat(path)
470	if err != nil {
471		if !errors.Is(err, os.ErrNotExist) {
472			return nil, fmt.Errorf("lstat %s: %w", path, err)
473		}
474		file, createErr := openFileNoFollow(path, syscall.O_CREAT|syscall.O_EXCL|syscall.O_WRONLY, filePerm)
475		if createErr != nil {
476			return nil, fmt.Errorf("create %s: %w", path, createErr)
477		}
478		return file, nil
479	}
480	if info.Mode()&os.ModeSymlink != 0 {
481		return nil, fmt.Errorf("log path %s must not be a symlink", path)
482	}
483	if !info.Mode().IsRegular() {
484		return nil, fmt.Errorf("log path %s is not a regular file", path)
485	}
486	file, openErr := openFileNoFollow(path, syscall.O_WRONLY, 0)
487	if openErr != nil {
488		return nil, fmt.Errorf("open %s: %w", path, openErr)
489	}
490	return file, nil
491}
492
493func openFileNoFollow(path string, flags int, perm os.FileMode) (*os.File, error) {
494	fd, err := syscall.Open(path, flags|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, uint32(perm))
495	if err != nil {
496		return nil, err
497	}
498	return os.NewFile(uintptr(fd), path), nil
499}
500
501func resolveCEFInitTraceFile(defaultLogFile, logFile string) string {
502	runtimeLogFile := resolveLogFile(defaultLogFile, logFile)
503	if runtimeLogFile == "" {
504		return ""
505	}
506	return strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log"
507}
508
509func resolveLogFile(defaultLogFile, logFile string) string {
510	if strings.TrimSpace(logFile) != "" {
511		return filepath.Clean(logFile)
512	}
513	if strings.TrimSpace(defaultLogFile) != "" {
514		return filepath.Clean(defaultLogFile)
515	}
516	return ""
517}
518
519func puregoCEFInitTraceEnabled() bool {
520	return envBoolEnabled(puregoCEFInitTraceEnvVar)
521}

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