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	ScreenshotDir string
32	// ScreenshotRaw also writes latest-pq.png, the raw PQ codes of a
33	// virtual HDR output (test-only; needs ScreenshotDir and HeadlessHDR).
34	ScreenshotRaw bool
35	// Sizes are the headless outputs (width, height), left to right; empty
36	// means one 1920x1080 output. With several outputs, screenshots go to
37	// one subdirectory per output (HEADLESS-1, HEADLESS-2, ...).
38	Sizes [][2]int
39	// Run closes Script on shutdown; the reader goroutine exits after Close.
40	Script     io.ReadCloser
41	testScenes chan<- []ports.Scene
42	// captureAllowPath replaces the fixed allowlist path (tests).
43	captureAllowPath string
44	// captureAllowOwner is the uid that must own that file (tests use their
45	// own; root, 0, otherwise).
46	captureAllowOwner uint32
47}
48
49func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
50
51func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error {
52	log := logging.For(ctx, "app")
53	log.Info().Str("backend", opts.Backend).Msg("starting neferwl")
54	var cancel context.CancelFunc
55	if opts.Timeout > 0 {
56		ctx, cancel = context.WithTimeoutCause(ctx, opts.Timeout, errTimeout)
57	} else {
58		ctx, cancel = context.WithCancel(ctx)
59	}
60	defer cancel()
61	s, err := assembleSession(ctx, opts)
62	if err != nil {
63		return err
64	}
65	return s.run(ctx, inject)
66}
67
68// busRetry is the first delay before a bus service (the screensaver, the
69// logind key lock) tries its bus again: without it, only surface inhibitors
70// count and the power keys keep logind's behaviour, and the session goes on
71// either way.
72const busRetry = 2 * time.Second
73
74// relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
75// to the input goroutine and sends it to clients; a repeat-only change just updates
76// clients. A keymap that fails to build keeps the previous layout. A touchpad
77// or mouse change goes to the input goroutine as one InputDevicesConfig; only
78// the newest one waits there. The cursor idle delay goes straight to the
79// cursor router.
80func 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) {
81	kb := cur.Keyboard
82	devCfg := cur.InputDevicesConfig
83	for {
84		var ev ports.ConfigChanged
85		select {
86		case <-ctx.Done():
87			return
88		case ev = <-in:
89		}
90		next := ev.Config.Keyboard
91		layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options
92		repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay
93		cmd := ports.SetKeymap{RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}
94		if layoutChanged {
95			km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options})
96			if err != nil {
97				log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous layout")
98				ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options
99				layoutChanged = false
100			} else {
101				cmd.Keymap = km.String()
102				// Drop a keymap the input goroutine has not taken yet; the newest wins.
103				select {
104				case old := <-keymaps:
105					old.Close()
106				default:
107				}
108				select {
109				case keymaps <- km:
110				case <-ctx.Done():
111					km.Close()
112					return
113				}
114			}
115		}
116		curs.setHideAfter(ev.Config.Cursor.HideAfter)
117		if ev.Config.InputDevicesConfig != devCfg {
118			devCfg = ev.Config.InputDevicesConfig
119			latest(deviceConfigs, devCfg) // this goroutine is the only sender
120		}
121		if layoutChanged || repeatChanged {
122			select {
123			case commands <- cmd:
124			case <-ctx.Done():
125				return
126			}
127			if layoutChanged {
128				kb.Layout, kb.Variant, kb.Options = next.Layout, next.Variant, next.Options
129			}
130			kb.RepeatRate, kb.RepeatDelay = next.RepeatRate, next.RepeatDelay
131			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")
132		}
133		select {
134		case out <- ev:
135		case <-ctx.Done():
136			return
137		}
138	}
139}
140
141func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
142	log := logging.For(ctx, "core")
143	configLog := logging.For(ctx, "config")
144	for {
145		select {
146		case <-ctx.Done():
147			return
148		case set := <-scenes:
149			if tap != nil {
150				select {
151				case tap <- set:
152				default:
153				}
154			}
155			latest(renderScenes, set) // this goroutine is the only sender
156			for _, s := range set {
157				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)
158				rects := make([]string, 0, len(s.Windows))
159				for _, w := range s.Windows {
160					rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
161					if w.FocusEffect > 0 {
162						// The focus indicator frame by frame: window and effect.
163						ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
164					}
165				}
166				ev.Strs("windows", rects).Msg("scene")
167			}
168		case err := <-configErrors:
169			configLog.Warn().Err(err).Msg("config rejected")
170		}
171	}
172}
173
174// openXwayland reserves an X11 display for xwayland-satellite, or returns
175// nil when X11 is off or unavailable: the session runs without it.
176func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
177	bin := opts.Config.Xwayland
178	if bin == "" || opts.NoXwayland {
179		return nil
180	}
181	if !xwayland.Supported(ctx, bin) {
182		log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
183		return nil
184	}
185	d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
186	if err != nil {
187		log.Warn().Err(err).Msg("X11 display disabled")
188		return nil
189	}
190	log.Info().Str("DISPLAY", d.Name()).Msg("listening on X11 display")
191	return d
192}
193
194// renderNode is the /dev/dri path of a render node dev_t, "" when none.
195func renderNode(dev uint64) string {
196	if dev == 0 {
197		return ""
198	}
199	return fmt.Sprintf("/dev/dri/renderD%d", unix.Minor(dev))
200}
201
202// latest sends v without blocking and replaces a value the consumer has not
203// read yet: the newest wins. Only one goroutine may send on ch; consumers only
204// receive, so the retry after draining cannot find the buffer full again.
205func latest[T any](ch chan T, v T) {
206	select {
207	case ch <- v:
208	default:
209		select {
210		case <-ch:
211		default:
212		}
213		select {
214		case ch <- v:
215		default:
216		}
217	}
218}

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