v0.1.0 d9218d6

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	"sync"
12	"time"
13
14	"github.com/bnema/neferwl/internal/adapters/config"
15	"github.com/bnema/neferwl/internal/adapters/drm"
16	"github.com/bnema/neferwl/internal/adapters/headlessinput"
17	"github.com/bnema/neferwl/internal/adapters/launcher"
18	"github.com/bnema/neferwl/internal/adapters/libinput"
19	"github.com/bnema/neferwl/internal/adapters/statefile"
20	"github.com/bnema/neferwl/internal/adapters/vulkan"
21	"github.com/bnema/neferwl/internal/adapters/wayland"
22	"github.com/bnema/neferwl/internal/adapters/xkb"
23	"github.com/bnema/neferwl/internal/adapters/xwayland"
24	"github.com/bnema/neferwl/internal/core"
25	"github.com/bnema/neferwl/internal/logging"
26	"github.com/bnema/neferwl/internal/ports"
27	"github.com/bnema/zerowrap"
28	"golang.org/x/sys/unix"
29)
30
31type Options struct {
32	Backend    string
33	Config     ports.Config
34	ConfigPath string
35	Timeout    time.Duration
36	NoTerminal bool
37	// NoXwayland skips the X11 display even when the config enables it.
38	NoXwayland bool
39	// Session exports the displays to D-Bus and systemd user services
40	// before any client starts, and withdraws them on exit.
41	Session       bool
42	ScreenshotDir string
43	// Sizes are the headless outputs (width, height), left to right; empty
44	// means one 1920x1080 output. With several outputs, screenshots go to
45	// one subdirectory per output (HEADLESS-1, HEADLESS-2, ...).
46	Sizes [][2]int
47	// Run closes Script on shutdown; the reader goroutine exits after Close.
48	Script     io.ReadCloser
49	testScenes chan<- []ports.Scene
50}
51
52func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
53
54func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error {
55	log := logging.For(ctx, "app")
56	log.Info().Str("backend", opts.Backend).Msg("starting neferwl")
57	var cancel context.CancelFunc
58	if opts.Timeout > 0 {
59		ctx, cancel = context.WithTimeoutCause(ctx, opts.Timeout, errTimeout)
60	} else {
61		ctx, cancel = context.WithCancel(ctx)
62	}
63	defer cancel()
64	sizes := opts.Sizes
65	if len(sizes) == 0 {
66		sizes = [][2]int{{1920, 1080}}
67	}
68	var hw *drmBackend
69	if opts.Backend == "drm" {
70		var err error
71		if hw, err = openDRM(ctx, opts.Config); err != nil {
72			return err
73		}
74		defer hw.close()
75	}
76	client := make(chan ports.ClientEvent, 32)
77	input := make(chan ports.InputEvent, 32)
78	output := make(chan ports.OutputEvent, 32)
79	configChanges := make(chan ports.ConfigChanged, 8)
80	commands := make(chan ports.ClientCommand, 32)
81	spawn := make(chan ports.SpawnRequest, 32)
82	scenes := make(chan []ports.Scene, 1)
83	layouts := make(chan ports.Layout, 1)
84	constraints := make(chan ports.PointerConstraint, 1)
85	states := make(chan ports.State, 1)
86	configErrors := make(chan error, 8)
87	renderScenes := make(chan []ports.Scene, 1)
88	contents := make(chan ports.SurfaceContent, 64)
89	cursorChanges := make(chan ports.CursorChange, 1)
90	presented := make(chan ports.OutputPresented, 64)
91	outputFormats := make(chan ports.OutputFormats, 8)
92	ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
93	c, err := core.New(opts.Config, ch)
94	if err != nil {
95		return err
96	}
97	km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options})
98	if err != nil {
99		return err
100	}
101	keymap := km.String()
102	runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
103	// Every output renders on the same GPU: its formats are the clients'.
104	dmabuf := vulkan.Probe()
105	log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
106	server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges, Presented: presented, OutputFormats: outputFormats}, logging.For(ctx, "wayland"))
107	if err != nil {
108		km.Close()
109		return err
110	}
111	log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening")
112	childEnv := launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir, cursorSize())
113	statePath, err := statefile.Path(runtimeDir, server.SocketName())
114	if err != nil {
115		log.Warn().Err(err).Msg("state file disabled")
116	} else {
117		childEnv = append(childEnv, statefile.Env+"="+statePath)
118	}
119	xdisplay := openXwayland(ctx, opts, childEnv, logging.For(ctx, "xwayland"))
120	if xdisplay != nil {
121		defer xdisplay.Close()
122		childEnv = append(childEnv, "DISPLAY="+xdisplay.Name())
123	}
124	if opts.Session {
125		launcher.ExportSession(ctx, childEnv, logging.For(ctx, "launcher"))
126		defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher"))
127	}
128	child := launcher.New(childEnv, logging.For(ctx, "launcher"))
129	if inject != nil {
130		inject(input)
131	}
132	var workers sync.WaitGroup
133	workers.Add(7)
134	done := make(chan error, 8)
135	path := opts.ConfigPath
136	if path == "" {
137		path = config.DefaultPath()
138	}
139	watched := make(chan ports.ConfigChanged, 8)
140	keymaps := make(chan *xkb.Keymap, 1)
141	go func() {
142		defer workers.Done()
143		done <- config.Watch(ctx, path, watched, logging.For(ctx, "config"))
144	}()
145	workers.Add(1)
146	go func() {
147		defer workers.Done()
148		relayConfig(ctx, opts.Config, watched, configChanges, keymaps, commands, logging.For(ctx, "config"))
149	}()
150	script := make(chan string)
151	curs := newCursors()
152	go func() {
153		defer workers.Done()
154		if hw != nil {
155			// Core sends the first layout once DRM reports the outputs.
156			var layout ports.Layout
157			select {
158			case layout = <-layouts:
159			case <-ctx.Done():
160				km.Close()
161				return
162			}
163			done <- safe("input", func() error {
164				return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion")}, input)
165			})
166			return
167		}
168		_ = headlessinput.Run(ctx, km, keymaps, script, input, layouts, curs.move, logging.For(ctx, "input"))
169	}()
170	if opts.Script != nil {
171		go func() { <-ctx.Done(); _ = opts.Script.Close() }()
172		go func() {
173			defer close(script)
174			scanner := bufio.NewScanner(opts.Script)
175			for scanner.Scan() {
176				select {
177				case <-ctx.Done():
178					return
179				case script <- scanner.Text():
180				}
181			}
182			if err := scanner.Err(); err != nil {
183				log.Warn().Err(err).Msg("reading input script")
184			}
185		}()
186	} else {
187		close(script)
188	}
189	go func() { defer workers.Done(); done <- server.Run(ctx) }()
190	go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }()
191	go func() { defer workers.Done(); done <- c.Run(ctx) }()
192	if xdisplay != nil {
193		workers.Add(1)
194		// X11 failing leaves the Wayland session running; Run logs why.
195		go func() { defer workers.Done(); _ = xdisplay.Run(ctx) }()
196	}
197	if statePath != "" {
198		workers.Add(1)
199		go func() {
200			defer workers.Done()
201			if err := statefile.Run(ctx, statePath, states, logging.For(ctx, "statefile")); err != nil {
202				// Scripts lose their state; the session goes on.
203				log.Warn().Err(err).Msg("state file disabled")
204			}
205		}()
206	}
207	go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes, renderScenes) }()
208
209	go func() {
210		defer workers.Done()
211		newRenderer := func(w, h int) (ports.Renderer, error) {
212			r, err := vulkan.New(w, h)
213			if err != nil {
214				return nil, err
215			}
216			return r, nil
217		}
218		if hw != nil {
219			done <- safe("output", func() error {
220				want := func() drm.Want {
221					w := wantFromConfig(opts.Config)
222					w.Sampled = dmabuf.Formats
223					return w
224				}
225				return hw.runOutputs(ctx, want, output, renderScenes, contents, cursorChanges, presented, outputFormats, curs, newRenderer, logging.For(ctx, "drm"))
226			})
227			return
228		}
229		done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, presented, curs, newRenderer, logging.For(ctx, "render"))
230	}()
231
232	if hw != nil {
233		workers.Add(1)
234		go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()
235	}
236	var result error
237	select {
238	case <-ctx.Done():
239		if errors.Is(context.Cause(ctx), errTimeout) {
240			log.Info().Str("reason", "timeout").Dur("after", opts.Timeout).Msg("stopping")
241		}
242	case result = <-done:
243		log.Info().AnErr("cause", result).Msg("stopping")
244	}
245	cancel()
246	workers.Wait()
247	// A keymap the input goroutine never took is still ours to free.
248	select {
249	case km := <-keymaps:
250		km.Close()
251	default:
252	}
253	for len(done) > 0 {
254		err := <-done
255		if result == nil && err != nil {
256			result = err
257		}
258	}
259	if errors.Is(result, core.ErrQuit) || errors.Is(result, libinput.ErrEmergencyQuit) {
260		return nil
261	}
262	return result
263}
264
265// relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
266// to the input goroutine and sends it to clients; a repeat-only change just updates
267// clients. A keymap that fails to build keeps the previous layout.
268func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, commands chan<- ports.ClientCommand, log zerowrap.Logger) {
269	kb := cur.Keyboard
270	for {
271		var ev ports.ConfigChanged
272		select {
273		case <-ctx.Done():
274			return
275		case ev = <-in:
276		}
277		next := ev.Config.Keyboard
278		layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options
279		repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay
280		cmd := ports.SetKeymap{RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}
281		if layoutChanged {
282			km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options})
283			if err != nil {
284				log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous layout")
285				ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options
286				layoutChanged = false
287			} else {
288				cmd.Keymap = km.String()
289				// Drop a keymap the input goroutine has not taken yet; the newest wins.
290				select {
291				case old := <-keymaps:
292					old.Close()
293				default:
294				}
295				select {
296				case keymaps <- km:
297				case <-ctx.Done():
298					km.Close()
299					return
300				}
301			}
302		}
303		if layoutChanged || repeatChanged {
304			select {
305			case commands <- cmd:
306			case <-ctx.Done():
307				return
308			}
309			if layoutChanged {
310				kb.Layout, kb.Variant, kb.Options = next.Layout, next.Variant, next.Options
311			}
312			kb.RepeatRate, kb.RepeatDelay = next.RepeatRate, next.RepeatDelay
313			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")
314		}
315		select {
316		case out <- ev:
317		case <-ctx.Done():
318			return
319		}
320	}
321}
322
323func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
324	log := logging.For(ctx, "core")
325	configLog := logging.For(ctx, "config")
326	for {
327		select {
328		case <-ctx.Done():
329			return
330		case set := <-scenes:
331			if tap != nil {
332				select {
333				case tap <- set:
334				default:
335				}
336			}
337			select {
338			case renderScenes <- set:
339			default:
340				select {
341				case <-renderScenes:
342				default:
343				}
344				select {
345				case renderScenes <- set:
346				default:
347				}
348			}
349			for _, s := range set {
350				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)
351				rects := make([]string, 0, len(s.Windows))
352				for _, w := range s.Windows {
353					rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
354				}
355				ev.Strs("windows", rects).Msg("scene")
356			}
357		case err := <-configErrors:
358			configLog.Warn().Err(err).Msg("config rejected")
359		}
360	}
361}
362
363// openXwayland reserves an X11 display for xwayland-satellite, or returns
364// nil when X11 is off or unavailable: the session runs without it.
365func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
366	bin := opts.Config.Xwayland
367	if bin == "" || opts.NoXwayland {
368		return nil
369	}
370	if !xwayland.Supported(ctx, bin) {
371		log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
372		return nil
373	}
374	d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
375	if err != nil {
376		log.Warn().Err(err).Msg("X11 display disabled")
377		return nil
378	}
379	log.Info().Str("DISPLAY", d.Name()).Msg("listening on X11 display")
380	return d
381}
382
383// renderNode is the /dev/dri path of a render node dev_t, "" when none.
384func renderNode(dev uint64) string {
385	if dev == 0 {
386		return ""
387	}
388	return fmt.Sprintf("/dev/dri/renderD%d", unix.Minor(dev))
389}

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 23

Tags 10

↑↓ moveenter openesc close