v0.31.0 bf0756b

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 := normalizedWindowlessFrameRate(cfg.WindowlessFrameRate)
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	}
88
89	logger.Info().
90		Int32("windowless_frame_rate", windowlessFrameRate).
91		Str("render_stack", string(renderStackPlan.Stack)).
92		Str("render_backend", renderStackPlan.Backend.String()).
93		Str("angle_backend", renderStackPlan.ANGLEBackend).
94		Str("gsk_renderer", renderStackPlan.GSKRenderer).
95		Bool("external_begin_frame", externalBeginFrameEnabled()).
96		Bool("trace_handlers", cfg.TraceHandlers).
97		Bool("enable_audio_handler", cfg.EnableAudioHandler).
98		Float64("application_scale", eng.currentApplicationScale()).
99		Msg("cef: configured engine")
100
101	if err := initializeCEF(eng, settings, logger); err != nil {
102		os.Args = savedArgs
103		return nil, err
104	}
105	os.Args = savedArgs
106
107	return wireEngine(
108		ctx,
109		eng,
110		webViewFactoryOptions{
111			adaptiveWindowlessFrameRate: cfg.AdaptiveWindowlessFrameRate,
112			windowlessFrameRate:         windowlessFrameRate,
113			windowlessFrameRateMax:      cfg.WindowlessFrameRateMax,
114			inputConfig:                 cfg.Input,
115			audioOutputFactory:          audioFactory,
116		},
117		logger,
118		deps.CurrentConfigPayload,
119		deps.DefaultConfigPayload,
120	)
121}
122
123func normalizedApplicationScale(scale float64) float64 {
124	if math.IsNaN(scale) || math.IsInf(scale, 0) || scale <= 0 {
125		return 1
126	}
127	return scale
128}
129
130func normalizedWindowlessFrameRate(frameRate int32) int32 {
131	if frameRate > 0 {
132		return frameRate
133	}
134	return defaultCEFWindowlessFrameRate
135}
136
137func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string {
138	if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {
139		return root
140	}
141	if opts.CacheDir != "" {
142		return opts.CacheDir
143	}
144	if opts.DataDir != "" {
145		return opts.DataDir
146	}
147	return defaultStateRoot
148}
149
150// configureCEFSubprocessRuntime chooses the CEF runtime used by both the
151// browser and CEF helper processes. CEF re-execs this binary for helpers before
152// Dumber can load configuration, so the selected configured runtime must be in
153// its inherited environment before InitWithApp launches any child. A caller's
154// explicit CEF_DIR remains the override for both process types.
155func configureCEFSubprocessRuntime(configured string) string {
156	if explicit := os.Getenv("CEF_DIR"); explicit != "" {
157		return explicit
158	}
159	if configured == "" {
160		return ""
161	}
162	if err := os.Setenv("CEF_DIR", configured); err != nil {
163		return configured
164	}
165	return configured
166}
167
168// prepareCEFSettings builds purecef.Settings from the engine config.
169func prepareCEFSettings(
170	opts port.EngineOptions,
171	paths RuntimePaths,
172	cfg RuntimeConfig,
173	logger *zerolog.Logger,
174) (purecef.Settings, error) {
175	switch opts.CookiePolicy {
176	case "", port.CookiePolicyAlways:
177	case port.CookiePolicyNoThirdParty:
178		logger.Warn().
179			Str("cookie_policy", string(opts.CookiePolicy)).
180			Msg("cef: no_third_party cookie policy is currently treated as always")
181	case port.CookiePolicyNever:
182		return purecef.Settings{}, fmt.Errorf("%w: %s", ErrCookiePolicyUnsupported, opts.CookiePolicy)
183	default:
184		return purecef.Settings{}, fmt.Errorf("%w: %s", ErrCookiePolicyUnsupported, opts.CookiePolicy)
185	}
186
187	settings := purecef.DefaultSettings()
188	settings.MultiThreadedMessageLoop = true
189	settings.ExternalMessagePump = false
190	settings.RootCachePath = resolvedStateRoot(paths.StateRoot, opts)
191	settings.CEFDir = configureCEFSubprocessRuntime(cfg.CEFDir)
192	if runtimeLogFile, err := prepareCEFLogFile(paths.LogFile, cfg.LogFile); err != nil {
193		logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file")
194	} else {
195		settings.LogFile = runtimeLogFile
196	}
197	if puregoCEFInitTraceEnabled() {
198		warn := logger.Warn().Str("env_var", puregoCEFInitTraceEnvVar)
199		if bootstrapLogFile := resolveCEFInitTraceFile(paths.LogFile, cfg.LogFile); bootstrapLogFile != "" {
200			warn = warn.Str("bootstrap_log_file", bootstrapLogFile)
201		}
202		warn.Msg("cef: init bootstrap diagnostics requested, but current purego-cef no longer consumes this trace file")
203	}
204	if cfg.LogSeverity != 0 {
205		settings.LogSeverity = cfg.LogSeverity
206	}
207	return settings, nil
208}
209
210// initializeCEF calls cef_initialize with the App to register custom schemes
211// and browser-process callbacks (OnBeforeCommandLineProcessing, etc.).
212func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error {
213	app := newDumberApp(eng)
214	logger.Info().
215		Str("settings_cef_dir", settings.CEFDir).
216		Str("env_cef_dir", os.Getenv("CEF_DIR")).
217		Msg("cef: runtime selection")
218	logger.Debug().Msg("cef: calling InitWithApp")
219	// InitWithApp encapsulates loading libcef, so only the begin boundary is
220	// observable here; do not invent a separate library-load completion event.
221	activeStartupTrace().Mark("cef_library_load_begin")
222	if err := purecef.InitWithApp(settings, app); err != nil {
223		return fmt.Errorf("cef.InitWithApp: %w", err)
224	}
225	activeStartupTrace().Mark("cef_initialized")
226	if libcefPath := loadedLibCEFPath(); libcefPath != "" {
227		logger.Info().Str("libcef_path", libcefPath).Msg("cef: runtime library loaded")
228	} else {
229		logger.Warn().Msg("cef: runtime library loaded but libcef path was not found in /proc/self/maps")
230	}
231	logger.Debug().Msg("cef: InitWithApp returned OK")
232	return nil
233}
234
235func loadedLibCEFPath() string {
236	data, err := os.ReadFile("/proc/self/maps")
237	if err != nil {
238		return ""
239	}
240	return parseLoadedLibCEFPath(string(data))
241}
242
243func parseLoadedLibCEFPath(maps string) string {
244	for line := range strings.SplitSeq(maps, "\n") {
245		if !strings.Contains(line, "libcef.so") {
246			continue
247		}
248		pathStart := strings.Index(line, "/")
249		if pathStart < 0 {
250			continue
251		}
252		return strings.TrimSpace(line[pathStart:])
253	}
254	return ""
255}
256
257// wireEngine creates factory, pool, and scheme handler after CEF init.
258func wireEngine(
259	ctx context.Context, eng *Engine,
260	factoryOptions webViewFactoryOptions, logger *zerolog.Logger,
261	currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),
262) (*Engine, error) {
263	eng.factory = newWebViewFactory(eng, factoryOptions)
264	eng.pool = newWebViewPool(eng.factory)
265	eng.contentInj = newContentInjector(eng, nil)
266
267	messageRouter, schemeHandler, err := newEngineSchemeHandler(
268		ctx,
269		eng,
270		logger,
271		currentConfigPayload,
272		defaultConfigPayload,
273	)
274	if err != nil {
275		purecef.Shutdown()
276		return nil, err
277	}
278	eng.messageRouter = messageRouter
279	eng.schemeHandler = schemeHandler
280	eng.startCEFHeartbeat()
281
282	registerEngineSchemeFactories(logger, purecef.NewSchemeHandlerFactory(schemeHandler))
283	return eng, nil
284}
285
286func newEngineSchemeHandler(
287	ctx context.Context,
288	eng *Engine,
289	logger *zerolog.Logger,
290	currentConfigPayload func() ([]byte, error),
291	defaultConfigPayload func() ([]byte, error),
292) (*MessageRouter, *dumbSchemeHandler, error) {
293	messageRouter := NewMessageRouter(ctx)
294	schemeHandler, err := newDumbSchemeHandler(
295		ctx,
296		messageRouter,
297		currentConfigPayload,
298		defaultConfigPayload,
299	)
300	if err != nil {
301		return nil, nil, err
302	}
303	schemeHandler.setAssets(assets.WebUIAssets)
304	schemeHandler.onClipboardSet = makeGTKClipboardSetter(logger)
305	schemeHandler.onEditableFocus = eng.handleEditableFocusBridge
306	schemeHandler.onPopupOpen = eng.handlePopupBridgeOpen
307	schemeHandler.onPopupNavigate = eng.handlePopupBridgeNavigate
308	schemeHandler.onPopupClose = eng.handlePopupBridgeClose
309	schemeHandler.onPopupOpenerNavigate = eng.handlePopupOpenerNavigate
310	schemeHandler.onPopupOpenerPostMessage = eng.handlePopupOpenerPostMessage
311	schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest
312	return messageRouter, schemeHandler, nil
313}
314
315func makeGTKClipboardSetter(logger *zerolog.Logger) func(string) {
316	// Bridge clipboard writes from page JS → GDK system clipboard.
317	// The callback is invoked on the CEF IO thread, so schedule the actual GDK
318	// write on the GTK main loop.
319	return func(text string) {
320		fn := glib.SourceOnceFunc(func(_ uintptr) {
321			if display := gdk.DisplayGetDefault(); display != nil {
322				if cb := display.GetClipboard(); cb != nil {
323					cb.SetText(text)
324					logger.Debug().Msg("cef: clipboard set via GDK")
325				} else {
326					logger.Debug().Msg("cef: clipboard set failed — no GDK clipboard")
327				}
328			} else {
329				logger.Debug().Msg("cef: clipboard set failed — no GDK display")
330			}
331		})
332		glib.IdleAddOnce(&fn, 0)
333	}
334}
335
336func registerEngineSchemeFactories(logger *zerolog.Logger, schemeFactory purecef.SchemeHandlerFactory) {
337	registerEngineSchemeFactory(logger, "dumb", "", schemeFactory)
338	registerEngineSchemeFactory(logger, actualInternalScheme, actualInternalHost, schemeFactory)
339}
340
341func registerEngineSchemeFactory(
342	logger *zerolog.Logger,
343	scheme, host string,
344	schemeFactory purecef.SchemeHandlerFactory,
345) {
346	result := purecef.RegisterSchemeHandlerFactory(scheme, host, schemeFactory)
347	if scheme == "dumb" && host == "" {
348		if result != 1 {
349			logger.Error().Int32("result", result).Msg("cef: failed to register dumb:// scheme handler factory")
350			return
351		}
352		logger.Info().Msg("cef: registered dumb:// scheme handler factory")
353		return
354	}
355	if result != 1 {
356		logger.Error().
357			Int32("result", result).
358			Str("origin", actualInternalOrigin).
359			Msg("cef: failed to register internal https handler factory")
360		return
361	}
362	logger.Info().
363		Str("origin", actualInternalOrigin).
364		Msg("cef: registered internal https handler factory")
365}
366
367// appendIfMissing appends flag to args if it's not already present.
368func appendIfMissing(args []string, flag string) []string {
369	if slices.Contains(args, flag) {
370		return args
371	}
372	return append(args, flag)
373}
374
375// cleanStaleSingletonLocks removes CEF's SingletonLock/Socket/Cookie files
376// if the owning process is no longer running. CEF leaves these behind on
377// unclean shutdown (SIGKILL, SIGSEGV) and the next instance crashes trying
378// to connect to the dead process.
379func cleanStaleSingletonLocks(logger *zerolog.Logger, dir string) {
380	if dir == "" {
381		return
382	}
383
384	lockPath := filepath.Join(dir, "SingletonLock")
385	target, err := os.Readlink(lockPath)
386	if err != nil {
387		return // No lock file or not a symlink — nothing to clean.
388	}
389
390	// SingletonLock is a symlink to "hostname-pid". Split from the right
391	// because hostnames may contain hyphens.
392	lastDash := strings.LastIndex(target, "-")
393	if lastDash < 0 || lastDash == len(target)-1 {
394		return
395	}
396	pid, err := strconv.Atoi(target[lastDash+1:])
397	if err != nil || pid <= 0 {
398		return
399	}
400
401	// Check if the process is still alive.
402	proc, err := os.FindProcess(pid)
403	if err == nil {
404		if err := proc.Signal(syscall.Signal(0)); err == nil {
405			// Process is alive — don't touch the lock.
406			return
407		}
408	}
409
410	// Stale lock — remove singleton files.
411	for _, name := range []string{"SingletonLock", "SingletonSocket", "SingletonCookie"} {
412		p := filepath.Join(dir, name)
413		if err := os.Remove(p); err == nil {
414			logger.Info().Str("path", p).Msg("cef: removed stale singleton file")
415		}
416	}
417}
418
419const (
420	dirPerm  = 0o700
421	filePerm = 0o600
422)
423
424func prepareCEFLogFile(defaultLogFile, logFile string) (string, error) {
425	runtimeLogFile := resolveLogFile(defaultLogFile, logFile)
426	if runtimeLogFile == "" {
427		return "", nil
428	}
429	if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil {
430		return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)
431	}
432
433	file, err := openRegularLogFile(runtimeLogFile)
434	if err != nil {
435		return "", err
436	}
437	defer func() { _ = file.Close() }()
438
439	info, err := file.Stat()
440	if err != nil {
441		return "", fmt.Errorf("stat %s: %w", runtimeLogFile, err)
442	}
443	if !info.Mode().IsRegular() {
444		return "", fmt.Errorf("log path %s is not a regular file", runtimeLogFile)
445	}
446	if err := file.Chmod(filePerm); err != nil {
447		return "", fmt.Errorf("chmod %s: %w", runtimeLogFile, err)
448	}
449	return runtimeLogFile, nil
450}
451
452func openRegularLogFile(path string) (*os.File, error) {
453	info, err := os.Lstat(path)
454	if err != nil {
455		if !errors.Is(err, os.ErrNotExist) {
456			return nil, fmt.Errorf("lstat %s: %w", path, err)
457		}
458		file, createErr := openFileNoFollow(path, syscall.O_CREAT|syscall.O_EXCL|syscall.O_WRONLY, filePerm)
459		if createErr != nil {
460			return nil, fmt.Errorf("create %s: %w", path, createErr)
461		}
462		return file, nil
463	}
464	if info.Mode()&os.ModeSymlink != 0 {
465		return nil, fmt.Errorf("log path %s must not be a symlink", path)
466	}
467	if !info.Mode().IsRegular() {
468		return nil, fmt.Errorf("log path %s is not a regular file", path)
469	}
470	file, openErr := openFileNoFollow(path, syscall.O_WRONLY, 0)
471	if openErr != nil {
472		return nil, fmt.Errorf("open %s: %w", path, openErr)
473	}
474	return file, nil
475}
476
477func openFileNoFollow(path string, flags int, perm os.FileMode) (*os.File, error) {
478	fd, err := syscall.Open(path, flags|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, uint32(perm))
479	if err != nil {
480		return nil, err
481	}
482	return os.NewFile(uintptr(fd), path), nil
483}
484
485func resolveCEFInitTraceFile(defaultLogFile, logFile string) string {
486	runtimeLogFile := resolveLogFile(defaultLogFile, logFile)
487	if runtimeLogFile == "" {
488		return ""
489	}
490	return strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log"
491}
492
493func resolveLogFile(defaultLogFile, logFile string) string {
494	if strings.TrimSpace(logFile) != "" {
495		return filepath.Clean(logFile)
496	}
497	if strings.TrimSpace(defaultLogFile) != "" {
498		return filepath.Clean(defaultLogFile)
499	}
500	return ""
501}
502
503func puregoCEFInitTraceEnabled() bool {
504	return envBoolEnabled(puregoCEFInitTraceEnvVar)
505}

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