app.go RawHistoryFolder

1// Package app coordinates the application lifecycle.
2package app
3
4import (
5	"bufio"
6	"context"
7	"errors"
8	"fmt"
9	"io"
10	"os"
11	"runtime"
12	"sync"
13	"time"
14
15	"github.com/bnema/neferwl/internal/adapters/captureallow"
16	"github.com/bnema/neferwl/internal/adapters/clock"
17	"github.com/bnema/neferwl/internal/adapters/config"
18	"github.com/bnema/neferwl/internal/adapters/drm"
19	"github.com/bnema/neferwl/internal/adapters/headlessinput"
20	"github.com/bnema/neferwl/internal/adapters/launcher"
21	"github.com/bnema/neferwl/internal/adapters/libinput"
22	"github.com/bnema/neferwl/internal/adapters/sched"
23	"github.com/bnema/neferwl/internal/adapters/screensaver"
24	"github.com/bnema/neferwl/internal/adapters/sessionsecurity"
25	"github.com/bnema/neferwl/internal/adapters/statefile"
26	"github.com/bnema/neferwl/internal/adapters/vulkan"
27	"github.com/bnema/neferwl/internal/adapters/wayland"
28	"github.com/bnema/neferwl/internal/adapters/workspaceid"
29	"github.com/bnema/neferwl/internal/adapters/xkb"
30	"github.com/bnema/neferwl/internal/adapters/xwayland"
31	"github.com/bnema/neferwl/internal/core"
32	"github.com/bnema/neferwl/internal/logging"
33	"github.com/bnema/neferwl/internal/ports"
34	"github.com/bnema/zerowrap"
35	"golang.org/x/sys/unix"
36)
37
38type Options struct {
39	Backend    string
40	Config     ports.Config
41	ConfigPath string
42	Timeout    time.Duration
43	NoTerminal bool
44	// NoXwayland skips the X11 display even when the config enables it.
45	NoXwayland bool
46	// HeadlessHDR enables a virtual HDR output for protocol testing only.
47	HeadlessHDR bool
48	// Session exports the displays to D-Bus and systemd user services
49	// before any client starts, and withdraws them on exit.
50	Session       bool
51	ScreenshotDir string
52	// Sizes are the headless outputs (width, height), left to right; empty
53	// means one 1920x1080 output. With several outputs, screenshots go to
54	// one subdirectory per output (HEADLESS-1, HEADLESS-2, ...).
55	Sizes [][2]int
56	// Run closes Script on shutdown; the reader goroutine exits after Close.
57	Script     io.ReadCloser
58	testScenes chan<- []ports.Scene
59	// captureAllowPath replaces the fixed allowlist path (tests).
60	captureAllowPath string
61	// captureAllowOwner is the uid that must own that file (tests use their
62	// own; root, 0, otherwise).
63	captureAllowOwner uint32
64}
65
66func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
67
68func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error {
69	log := logging.For(ctx, "app")
70	log.Info().Str("backend", opts.Backend).Msg("starting neferwl")
71	var cancel context.CancelFunc
72	if opts.Timeout > 0 {
73		ctx, cancel = context.WithTimeoutCause(ctx, opts.Timeout, errTimeout)
74	} else {
75		ctx, cancel = context.WithCancel(ctx)
76	}
77	defer cancel()
78	sizes := opts.Sizes
79	if len(sizes) == 0 {
80		sizes = [][2]int{{1920, 1080}}
81	}
82	var hw *drmBackend
83	if opts.Backend == "drm" {
84		var err error
85		if hw, err = openDRM(ctx, opts.Config); err != nil {
86			return err
87		}
88		defer hw.close()
89	}
90	security := &sessionsecurity.Gate{}
91	p := newPipes(hw != nil)
92	ch := p.core()
93	ch.Security = security
94	ch.Terminal = !opts.NoTerminal
95	ch.Clock = clock.System{}
96	c, err := core.New(opts.Config, ch)
97	if err != nil {
98		return err
99	}
100	km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options})
101	if err != nil {
102		return err
103	}
104	keymap := km.String()
105	runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
106	// Every output renders on the same GPU: its formats are the clients'.
107	dmabuf := vulkan.Probe()
108	log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
109	// One launch prefix for ext-workspace ids and the state file.
110	wsIDs := workspaceid.New()
111	// The path is fixed: a config key would let a program of the same user
112	// widen who may capture.
113	// Production: the fixed path, a root-owned file. Only tests set a path,
114	// and an owner for it.
115	captureAllowLog := logging.For(ctx, "captureallow")
116	var captureAllow *captureallow.Store
117	if opts.captureAllowPath != "" {
118		captureAllow = captureallow.NewStoreOwnedBy(opts.captureAllowPath, opts.captureAllowOwner, captureAllowLog)
119	} else {
120		captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog)
121	}
122	server, err := wayland.New(wayland.Options{
123		CaptureAllow: captureAllow,
124		WorkspaceIDs: wsIDs,
125		Security:     security,
126		RuntimeDir:   runtimeDir,
127		DMABuf:       dmabuf,
128		SyncobjNode:  renderNode(dmabuf.Device),
129		Keymap:       keymap,
130		RepeatRate:   opts.Config.Keyboard.RepeatRate,
131		RepeatDelay:  opts.Config.Keyboard.RepeatDelay,
132	}, p.wayland(), logging.For(ctx, "wayland"))
133	if err != nil {
134		km.Close()
135		return err
136	}
137	log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening")
138	childEnv := launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir, cursorSize())
139	statePath, err := statefile.Path(runtimeDir, server.SocketName())
140	if err != nil {
141		log.Warn().Err(err).Msg("state file disabled")
142	} else {
143		childEnv = append(childEnv, statefile.Env+"="+statePath)
144	}
145	xdisplay := openXwayland(ctx, opts, childEnv, logging.For(ctx, "xwayland"))
146	if xdisplay != nil {
147		defer xdisplay.Close()
148		childEnv = append(childEnv, "DISPLAY="+xdisplay.Name())
149	}
150	if opts.Session {
151		launcher.ExportSession(ctx, childEnv, logging.For(ctx, "launcher"))
152		launcher.NotifyReady(os.Getenv("NOTIFY_SOCKET"), logging.For(ctx, "launcher"))
153		defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher"))
154	}
155	child := launcher.New(childEnv, logging.For(ctx, "launcher"))
156	child.Security = security
157	if inject != nil {
158		inject(p.input)
159	}
160	var workers sync.WaitGroup
161	workers.Add(8)
162	done := make(chan error, 8)
163	path := opts.ConfigPath
164	if path == "" {
165		path = config.DefaultPath()
166	}
167	go func() {
168		defer workers.Done()
169		done <- config.Watch(ctx, path, p.watched, logging.For(ctx, "config"))
170	}()
171	if p.scales != nil {
172		workers.Add(1)
173		go func() {
174			defer workers.Done()
175			core.PersistScales(ctx, p.scales, config.ScaleStore{Path: path}, launcher.NewNotifier(ctx, childEnv, logging.For(ctx, "launcher")), clock.System{}, time.Second)
176		}()
177	}
178	curs := newCursors(clock.System{}, opts.Config.Cursor.HideAfter)
179	defer curs.stop()
180	workers.Add(1)
181	go func() {
182		defer workers.Done()
183		relayConfig(ctx, opts.Config, p.watched, p.filtered, p.keymaps, p.deviceConfigs, curs, p.commands, logging.For(ctx, "config"))
184	}()
185	script := make(chan string)
186	go func() {
187		defer workers.Done()
188		if hw != nil {
189			// Core sends the first layout once DRM reports the outputs.
190			var layout ports.Layout
191			select {
192			case layout = <-p.layouts:
193			case <-ctx.Done():
194				km.Close()
195				return
196			}
197			done <- safe("input", func() error {
198				if opts.Config.Performance.Realtime {
199					runtime.LockOSThread()
200					// Let this dedicated thread exit with its scheduling policy.
201					if err := sched.Realtime(logging.For(ctx, "sched")); err != nil {
202						log.Warn().Str("component", "sched").Err(err).Msg("input scheduling")
203					}
204				}
205				return libinput.Run(ctx, libinput.Options{
206					Security:      security,
207					Seat:          hw.seat,
208					SeatName:      hw.seat.Name(),
209					Keymap:        km,
210					Keymaps:       p.keymaps,
211					Layout:        layout,
212					Layouts:       p.layouts,
213					Constraints:   p.constraints,
214					DeviceConfig:  opts.Config.InputDevicesConfig,
215					DeviceConfigs: p.deviceConfigs,
216					Active:        hw.seat.Subscribe(),
217					MoveCursor:    curs.move,
218					Log:           logging.For(ctx, "input"),
219					LogMotion:     logging.Enabled(ctx, "input-motion"),
220					LogKeys:       logging.Enabled(ctx, "input-keys"),
221				}, p.input)
222			})
223			return
224		}
225		if err := headlessinput.RunSecure(ctx, km, p.keymaps, script, p.input, p.layouts, func(o string, x, y float64) { curs.move(o, x, y, true) }, logging.For(ctx, "input"), security); err != nil && !errors.Is(err, context.Canceled) {
226			select {
227			case done <- err:
228			case <-ctx.Done():
229			}
230		}
231	}()
232	if opts.Script != nil {
233		go func() { <-ctx.Done(); _ = opts.Script.Close() }()
234		go func() {
235			defer close(script)
236			scanner := bufio.NewScanner(opts.Script)
237			for scanner.Scan() {
238				select {
239				case <-ctx.Done():
240					return
241				case script <- scanner.Text():
242				}
243			}
244			if err := scanner.Err(); err != nil {
245				log.Warn().Err(err).Msg("reading input script")
246			}
247		}()
248	} else {
249		close(script)
250	}
251	go func() { defer workers.Done(); done <- server.Run(ctx) }()
252	go func() {
253		defer workers.Done()
254		// Without a watch the allowlist stays as loaded: restart to apply.
255		if err := captureAllow.Run(ctx); err != nil {
256			log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes")
257		}
258	}()
259	go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()
260	go func() { defer workers.Done(); done <- c.Run(ctx) }()
261	if xdisplay != nil {
262		workers.Add(1)
263		// X11 failing leaves the Wayland session running; Run logs why.
264		go func() { defer workers.Done(); _ = xdisplay.Run(ctx) }()
265	}
266	if statePath != "" {
267		workers.Add(1)
268		go func() {
269			defer workers.Done()
270			if err := statefile.Run(ctx, statePath, p.states, wsIDs, logging.For(ctx, "statefile")); err != nil {
271				// Scripts lose their state; the session goes on.
272				log.Warn().Err(err).Msg("state file disabled")
273			}
274		}()
275	}
276	go func() {
277		defer workers.Done()
278		consumeScenes(ctx, p.scenes, p.configErrors, opts.testScenes, p.renderScenes)
279	}()
280	workers.Add(1)
281	go func() { defer workers.Done(); relayPresented(ctx, p.flips, p.presented, p.frames) }()
282
283	go func() {
284		defer workers.Done()
285		outputIO := p.outputs(security)
286		apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
287		renderLog := logging.For(ctx, "render")
288		newRenderer := func(w, h int) (ports.Renderer, error) {
289			r, err := vulkan.New(w, h)
290			if err != nil {
291				return nil, err
292			}
293			renderLog.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue")
294			return r, nil
295		}
296		if hw != nil {
297			done <- safe("output", func() error {
298				want := func(cfg ports.Config) drm.Want {
299					w := wantFromConfig(cfg)
300					w.Sampled = dmabuf.Formats
301					w.TraceFlips = logging.Enabled(ctx, "drm-flip")
302					return w
303				}
304				return hw.runOutputs(ctx, want, opts.Config, apply, outputIO, curs, newRenderer, logging.For(ctx, "drm"))
305			})
306			return
307		}
308		done <- runHeadless(ctx, sizes, opts.ScreenshotDir, opts.HeadlessHDR, apply, outputIO, curs, newRenderer, logging.For(ctx, "render"))
309	}()
310
311	if hw != nil {
312		workers.Add(1)
313		go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()
314		// Only a real session serves the session bus: headless runs would take
315		// the name from the desktop they run in.
316		workers.Add(1)
317		go func() {
318			defer workers.Done()
319			reports := screensaver.Reports{Held: p.idleInhibited, Activity: p.idleActivity}
320			screensaver.Serve(ctx, "", reports, screensaverRetry, logging.For(ctx, "screensaver"))
321		}()
322	}
323	var result error
324	select {
325	case <-ctx.Done():
326		if errors.Is(context.Cause(ctx), errTimeout) {
327			log.Info().Str("reason", "timeout").Dur("after", opts.Timeout).Msg("stopping")
328		}
329	case result = <-done:
330		log.Info().AnErr("cause", result).Msg("stopping")
331	}
332	cancel()
333	workers.Wait()
334	// The wayland server has stopped: no request can be queued any more.
335	drainCaptures(ctx, p.captures, nil)
336	// A keymap the input goroutine never took is still ours to free.
337	select {
338	case km := <-p.keymaps:
339		km.Close()
340	default:
341	}
342	for len(done) > 0 {
343		err := <-done
344		if result == nil && err != nil {
345			result = err
346		}
347	}
348	if errors.Is(result, core.ErrQuit) || errors.Is(result, libinput.ErrEmergencyQuit) {
349		return nil
350	}
351	return result
352}
353
354// screensaverRetry is the first delay before the screensaver service tries
355// the session bus again: without one, only surface inhibitors count, and
356// the session goes on either way.
357const screensaverRetry = 2 * time.Second
358
359// relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
360// to the input goroutine and sends it to clients; a repeat-only change just updates
361// clients. A keymap that fails to build keeps the previous layout. A touchpad
362// or mouse change goes to the input goroutine as one InputDevicesConfig; only
363// the newest one waits there. The cursor idle delay goes straight to the
364// cursor router.
365func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, deviceConfigs chan ports.InputDevicesConfig, curs *cursors, commands chan<- ports.ClientCommand, log zerowrap.Logger) {
366	kb := cur.Keyboard
367	devCfg := cur.InputDevicesConfig
368	for {
369		var ev ports.ConfigChanged
370		select {
371		case <-ctx.Done():
372			return
373		case ev = <-in:
374		}
375		next := ev.Config.Keyboard
376		layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options
377		repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay
378		cmd := ports.SetKeymap{RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}
379		if layoutChanged {
380			km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options})
381			if err != nil {
382				log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous layout")
383				ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options
384				layoutChanged = false
385			} else {
386				cmd.Keymap = km.String()
387				// Drop a keymap the input goroutine has not taken yet; the newest wins.
388				select {
389				case old := <-keymaps:
390					old.Close()
391				default:
392				}
393				select {
394				case keymaps <- km:
395				case <-ctx.Done():
396					km.Close()
397					return
398				}
399			}
400		}
401		curs.setHideAfter(ev.Config.Cursor.HideAfter)
402		if ev.Config.InputDevicesConfig != devCfg {
403			devCfg = ev.Config.InputDevicesConfig
404			latest(deviceConfigs, devCfg) // this goroutine is the only sender
405		}
406		if layoutChanged || repeatChanged {
407			select {
408			case commands <- cmd:
409			case <-ctx.Done():
410				return
411			}
412			if layoutChanged {
413				kb.Layout, kb.Variant, kb.Options = next.Layout, next.Variant, next.Options
414			}
415			kb.RepeatRate, kb.RepeatDelay = next.RepeatRate, next.RepeatDelay
416			log.Info().Str("layout", kb.Layout).Str("variant", kb.Variant).Str("options", kb.Options).Int("rate", kb.RepeatRate).Int("delay", kb.RepeatDelay).Bool("keymap", layoutChanged).Msg("keyboard reloaded")
417		}
418		select {
419		case out <- ev:
420		case <-ctx.Done():
421			return
422		}
423	}
424}
425
426// relayPresented passes output reports to wayland in order. Each flip is
427// also a frame for core; a frame core has not read yet is enough, so
428// frames never wait.
429func relayPresented(ctx context.Context, in <-chan ports.OutputPresented, out chan<- ports.OutputPresented, frames chan<- ports.OutputFrame) {
430	for {
431		var p ports.OutputPresented
432		select {
433		case <-ctx.Done():
434			return
435		case p = <-in:
436		}
437		if p.Flip != nil {
438			select {
439			case frames <- ports.OutputFrame{Output: p.Output}:
440			default:
441			}
442		}
443		select {
444		case out <- p:
445		case <-ctx.Done():
446			return
447		}
448	}
449}
450
451func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
452	log := logging.For(ctx, "core")
453	configLog := logging.For(ctx, "config")
454	for {
455		select {
456		case <-ctx.Done():
457			return
458		case set := <-scenes:
459			if tap != nil {
460				select {
461				case tap <- set:
462				default:
463				}
464			}
465			latest(renderScenes, set) // this goroutine is the only sender
466			for _, s := range set {
467				ev := log.Debug().Str("output", s.Output).Uint64("seq", s.Seq).Int("out_w", s.OutputWidth).Int("out_h", s.OutputHeight).Float64("scale", s.Scale)
468				rects := make([]string, 0, len(s.Windows))
469				for _, w := range s.Windows {
470					rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
471					if w.FocusEffect > 0 {
472						// The focus indicator frame by frame: window and effect.
473						ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
474					}
475				}
476				ev.Strs("windows", rects).Msg("scene")
477			}
478		case err := <-configErrors:
479			configLog.Warn().Err(err).Msg("config rejected")
480		}
481	}
482}
483
484// openXwayland reserves an X11 display for xwayland-satellite, or returns
485// nil when X11 is off or unavailable: the session runs without it.
486func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
487	bin := opts.Config.Xwayland
488	if bin == "" || opts.NoXwayland {
489		return nil
490	}
491	if !xwayland.Supported(ctx, bin) {
492		log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
493		return nil
494	}
495	d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
496	if err != nil {
497		log.Warn().Err(err).Msg("X11 display disabled")
498		return nil
499	}
500	log.Info().Str("DISPLAY", d.Name()).Msg("listening on X11 display")
501	return d
502}
503
504// renderNode is the /dev/dri path of a render node dev_t, "" when none.
505func renderNode(dev uint64) string {
506	if dev == 0 {
507		return ""
508	}
509	return fmt.Sprintf("/dev/dri/renderD%d", unix.Minor(dev))
510}
511
512// latest sends v without blocking and replaces a value the consumer has not
513// read yet: the newest wins. Only one goroutine may send on ch; consumers only
514// receive, so the retry after draining cannot find the buffer full again.
515func latest[T any](ch chan T, v T) {
516	select {
517	case ch <- v:
518	default:
519		select {
520		case <-ch:
521		default:
522		}
523		select {
524		case ch <- v:
525		default:
526		}
527	}
528}

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 25

Tags 10

↑↓ moveenter openesc close