v0.30.0 eb3068f

engine_init.go RawHistoryFolder

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

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