v0.29.0 21b3150

engine_init.go RawHistoryFolder

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

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