v0.32.0 0617278

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	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}

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