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// screensaverRetry is the first delay before the screensaver service tries
69// the session bus again: without one, only surface inhibitors count, and
70// the session goes on either way.
71const screensaverRetry = 2 * time.Second
72
73// relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
74// to the input goroutine and sends it to clients; a repeat-only change just updates
75// clients. A keymap that fails to build keeps the previous layout. A touchpad
76// or mouse change goes to the input goroutine as one InputDevicesConfig; only
77// the newest one waits there. The cursor idle delay goes straight to the
78// cursor router.
79func 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) {
80	kb := cur.Keyboard
81	devCfg := cur.InputDevicesConfig
82	for {
83		var ev ports.ConfigChanged
84		select {
85		case <-ctx.Done():
86			return
87		case ev = <-in:
88		}
89		next := ev.Config.Keyboard
90		layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options
91		repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay
92		cmd := ports.SetKeymap{RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}
93		if layoutChanged {
94			km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options})
95			if err != nil {
96				log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous layout")
97				ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options
98				layoutChanged = false
99			} else {
100				cmd.Keymap = km.String()
101				// Drop a keymap the input goroutine has not taken yet; the newest wins.
102				select {
103				case old := <-keymaps:
104					old.Close()
105				default:
106				}
107				select {
108				case keymaps <- km:
109				case <-ctx.Done():
110					km.Close()
111					return
112				}
113			}
114		}
115		curs.setHideAfter(ev.Config.Cursor.HideAfter)
116		if ev.Config.InputDevicesConfig != devCfg {
117			devCfg = ev.Config.InputDevicesConfig
118			latest(deviceConfigs, devCfg) // this goroutine is the only sender
119		}
120		if layoutChanged || repeatChanged {
121			select {
122			case commands <- cmd:
123			case <-ctx.Done():
124				return
125			}
126			if layoutChanged {
127				kb.Layout, kb.Variant, kb.Options = next.Layout, next.Variant, next.Options
128			}
129			kb.RepeatRate, kb.RepeatDelay = next.RepeatRate, next.RepeatDelay
130			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")
131		}
132		select {
133		case out <- ev:
134		case <-ctx.Done():
135			return
136		}
137	}
138}
139
140// relayPresented passes output reports to wayland in order. Each flip is
141// also a frame for core; a frame core has not read yet is enough, so
142// frames never wait.
143func relayPresented(ctx context.Context, in <-chan ports.OutputPresented, out chan<- ports.OutputPresented, frames chan<- ports.OutputFrame) {
144	for {
145		var p ports.OutputPresented
146		select {
147		case <-ctx.Done():
148			return
149		case p = <-in:
150		}
151		if p.Flip != nil {
152			select {
153			case frames <- ports.OutputFrame{Output: p.Output}:
154			default:
155			}
156		}
157		select {
158		case out <- p:
159		case <-ctx.Done():
160			return
161		}
162	}
163}
164
165func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
166	log := logging.For(ctx, "core")
167	configLog := logging.For(ctx, "config")
168	for {
169		select {
170		case <-ctx.Done():
171			return
172		case set := <-scenes:
173			if tap != nil {
174				select {
175				case tap <- set:
176				default:
177				}
178			}
179			latest(renderScenes, set) // this goroutine is the only sender
180			for _, s := range set {
181				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)
182				rects := make([]string, 0, len(s.Windows))
183				for _, w := range s.Windows {
184					rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
185					if w.FocusEffect > 0 {
186						// The focus indicator frame by frame: window and effect.
187						ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
188					}
189				}
190				ev.Strs("windows", rects).Msg("scene")
191			}
192		case err := <-configErrors:
193			configLog.Warn().Err(err).Msg("config rejected")
194		}
195	}
196}
197
198// openXwayland reserves an X11 display for xwayland-satellite, or returns
199// nil when X11 is off or unavailable: the session runs without it.
200func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
201	bin := opts.Config.Xwayland
202	if bin == "" || opts.NoXwayland {
203		return nil
204	}
205	if !xwayland.Supported(ctx, bin) {
206		log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
207		return nil
208	}
209	d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
210	if err != nil {
211		log.Warn().Err(err).Msg("X11 display disabled")
212		return nil
213	}
214	log.Info().Str("DISPLAY", d.Name()).Msg("listening on X11 display")
215	return d
216}
217
218// renderNode is the /dev/dri path of a render node dev_t, "" when none.
219func renderNode(dev uint64) string {
220	if dev == 0 {
221		return ""
222	}
223	return fmt.Sprintf("/dev/dri/renderD%d", unix.Minor(dev))
224}
225
226// latest sends v without blocking and replaces a value the consumer has not
227// read yet: the newest wins. Only one goroutine may send on ch; consumers only
228// receive, so the retry after draining cannot find the buffer full again.
229func latest[T any](ch chan T, v T) {
230	select {
231	case ch <- v:
232	default:
233		select {
234		case <-ch:
235		default:
236		}
237		select {
238		case ch <- v:
239		default:
240		}
241	}
242}

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