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

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 26

Tags 10

↑↓ moveenter openesc close