main f2d166b

app.go RawHistoryFolder

1// Package app coordinates the application lifecycle.
2package app
3
4import (
5	"context"
6	"fmt"
7	"io"
8	"time"
9
10	"github.com/bnema/neferwl/internal/adapters/logging"
11	"github.com/bnema/neferwl/internal/adapters/xkb"
12	"github.com/bnema/neferwl/internal/adapters/xwayland"
13	"github.com/bnema/neferwl/internal/ports"
14	"github.com/bnema/zerowrap"
15	"golang.org/x/sys/unix"
16)
17
18type Options struct {
19	Backend    string
20	Config     ports.Config
21	ConfigPath string
22	Timeout    time.Duration
23	NoTerminal bool
24	// NoXwayland skips the X11 display even when the config enables it.
25	NoXwayland bool
26	// HeadlessHDR enables a virtual HDR output for protocol testing only.
27	HeadlessHDR bool
28	// Session exports the displays to D-Bus and systemd user services
29	// before any client starts, and withdraws them on exit.
30	Session bool
31	// DBus serves the session D-Bus services (idle inhibition, screen
32	// casting and screenshots) of a headless run, which otherwise leaves the
33	// session bus alone. For tests under dbus-run-session.
34	DBus          bool
35	ScreenshotDir string
36	// ScreenshotRaw also writes latest-pq.png, the raw PQ codes of a
37	// virtual HDR output (test-only; needs ScreenshotDir and HeadlessHDR).
38	ScreenshotRaw bool
39	// Sizes are the headless outputs (width, height), left to right; empty
40	// means one 1920x1080 output. With several outputs, screenshots go to
41	// one subdirectory per output (HEADLESS-1, HEADLESS-2, ...).
42	Sizes [][2]int
43	// Run closes Script on shutdown; the reader goroutine exits after Close.
44	Script     io.ReadCloser
45	testScenes chan<- []ports.Scene
46	// captureAllowPath replaces the fixed allowlist path (tests).
47	captureAllowPath string
48	// captureAllowOwner is the uid that must own that file (tests use their
49	// own; root, 0, otherwise).
50	captureAllowOwner uint32
51}
52
53func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
54
55func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error {
56	log := logging.For(ctx, "app")
57	log.Info().Str("backend", opts.Backend).Msg("starting neferwl")
58	var cancel context.CancelFunc
59	if opts.Timeout > 0 {
60		ctx, cancel = context.WithTimeoutCause(ctx, opts.Timeout, errTimeout)
61	} else {
62		ctx, cancel = context.WithCancel(ctx)
63	}
64	defer cancel()
65	s, err := assembleSession(ctx, opts)
66	if err != nil {
67		return err
68	}
69	return s.run(ctx, inject)
70}
71
72// busRetry is the first delay before a bus service (the screensaver, the
73// logind key lock) tries its bus again: without it, only surface inhibitors
74// count and the power keys keep logind's behaviour, and the session goes on
75// either way.
76const busRetry = 2 * time.Second
77
78// relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
79// to the input goroutine and sends it to clients; a repeat-only change just updates
80// clients. A keymap that fails to build keeps the previous layout. A touchpad
81// or mouse change goes to the input goroutine as one InputDevicesConfig; only
82// the newest one waits there. The cursor idle delay goes straight to the
83// cursor router.
84func 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) {
85	kb := cur.Keyboard
86	devCfg := cur.InputDevicesConfig
87	hintOn := cur.Hint.Enable
88	for {
89		var ev ports.ConfigChanged
90		select {
91		case <-ctx.Done():
92			return
93		case ev = <-in:
94		}
95		// hint.enable is read at startup: nothing of hint mode starts or
96		// stops on a reload.
97		if ev.Config.Hint.Enable != hintOn {
98			log.Info().Bool("running", hintOn).Msg("hint.enable changes apply after restart")
99			ev.Config.Hint.Enable = hintOn
100		}
101		next := ev.Config.Keyboard
102		layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options
103		repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay
104		cmd := ports.SetKeymap{RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}
105		if layoutChanged {
106			km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options})
107			if err != nil {
108				log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous layout")
109				ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options
110				layoutChanged = false
111			} else {
112				cmd.Keymap = km.String()
113				// Drop a keymap the input goroutine has not taken yet; the newest wins.
114				select {
115				case old := <-keymaps:
116					old.Close()
117				default:
118				}
119				select {
120				case keymaps <- km:
121				case <-ctx.Done():
122					km.Close()
123					return
124				}
125			}
126		}
127		curs.setHideAfter(ev.Config.Cursor.HideAfter)
128		if ev.Config.InputDevicesConfig != devCfg {
129			devCfg = ev.Config.InputDevicesConfig
130			latest(deviceConfigs, devCfg) // this goroutine is the only sender
131		}
132		if layoutChanged || repeatChanged {
133			select {
134			case commands <- cmd:
135			case <-ctx.Done():
136				return
137			}
138			if layoutChanged {
139				kb.Layout, kb.Variant, kb.Options = next.Layout, next.Variant, next.Options
140			}
141			kb.RepeatRate, kb.RepeatDelay = next.RepeatRate, next.RepeatDelay
142			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")
143		}
144		select {
145		case out <- ev:
146		case <-ctx.Done():
147			return
148		}
149	}
150}
151
152func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
153	log := logging.For(ctx, "core")
154	configLog := logging.For(ctx, "config")
155	for {
156		select {
157		case <-ctx.Done():
158			return
159		case set := <-scenes:
160			if tap != nil {
161				select {
162				case tap <- set:
163				default:
164				}
165			}
166			latest(renderScenes, set) // this goroutine is the only sender
167			logScenes(log, set)
168		case err := <-configErrors:
169			configLog.Warn().Err(err).Msg("config rejected")
170		}
171	}
172}
173
174// logScenes logs each scene at debug level. The rect strings are built only
175// when debug is on: at the default level a frame costs no allocation here.
176func logScenes(log zerowrap.Logger, set []ports.Scene) {
177	for _, s := range set {
178		ev := log.Debug()
179		if !ev.Enabled() {
180			continue
181		}
182		ev = ev.Str("output", s.Output).Uint64("seq", s.Seq).Int("out_w", s.OutputWidth).Int("out_h", s.OutputHeight).Float64("scale", s.Scale)
183		rects := make([]string, 0, len(s.Windows))
184		for _, w := range s.Windows {
185			rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
186		}
187		ev.Strs("windows", rects).Msg("scene")
188	}
189}
190
191// openXwayland reserves an X11 display for xwayland-satellite, or returns
192// nil when X11 is off or unavailable: the session runs without it.
193func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
194	bin := opts.Config.Xwayland
195	if bin == "" || opts.NoXwayland {
196		return nil
197	}
198	if !xwayland.Supported(ctx, bin) {
199		log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
200		return nil
201	}
202	d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
203	if err != nil {
204		log.Warn().Err(err).Msg("X11 display disabled")
205		return nil
206	}
207	log.Info().Str("DISPLAY", d.Name()).Msg("listening on X11 display")
208	return d
209}
210
211// renderNode is the /dev/dri path of a render node dev_t, "" when none.
212func renderNode(dev uint64) string {
213	if dev == 0 {
214		return ""
215	}
216	return fmt.Sprintf("/dev/dri/renderD%d", unix.Minor(dev))
217}
218
219// latest sends v without blocking and replaces a value the consumer has not
220// read yet: the newest wins. It reports whether it dropped an unread value.
221// Only one goroutine may send on ch; consumers only receive, so the retry
222// after draining cannot find the buffer full again.
223func latest[T any](ch chan T, v T) (replaced bool) {
224	select {
225	case ch <- v:
226	default:
227		select {
228		case <-ch:
229			replaced = true
230		default:
231		}
232		select {
233		case ch <- v:
234		default:
235		}
236	}
237	return replaced
238}

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