refactor/kvconf 3462c76

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
141// relayPresented passes output reports to wayland in order. Each flip is
142// also a frame for core; a frame core has not read yet is enough, so
143// frames never wait.
144func relayPresented(ctx context.Context, in <-chan ports.OutputPresented, out chan<- ports.OutputPresented, frames chan<- ports.OutputFrame) {
145	for {
146		var p ports.OutputPresented
147		select {
148		case <-ctx.Done():
149			return
150		case p = <-in:
151		}
152		if p.Flip != nil {
153			select {
154			case frames <- ports.OutputFrame{Output: p.Output}:
155			default:
156			}
157		}
158		select {
159		case out <- p:
160		case <-ctx.Done():
161			return
162		}
163	}
164}
165
166func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
167	log := logging.For(ctx, "core")
168	configLog := logging.For(ctx, "config")
169	for {
170		select {
171		case <-ctx.Done():
172			return
173		case set := <-scenes:
174			if tap != nil {
175				select {
176				case tap <- set:
177				default:
178				}
179			}
180			latest(renderScenes, set) // this goroutine is the only sender
181			for _, s := range set {
182				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)
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					if w.FocusEffect > 0 {
187						// The focus indicator frame by frame: window and effect.
188						ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
189					}
190				}
191				ev.Strs("windows", rects).Msg("scene")
192			}
193		case err := <-configErrors:
194			configLog.Warn().Err(err).Msg("config rejected")
195		}
196	}
197}
198
199// openXwayland reserves an X11 display for xwayland-satellite, or returns
200// nil when X11 is off or unavailable: the session runs without it.
201func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
202	bin := opts.Config.Xwayland
203	if bin == "" || opts.NoXwayland {
204		return nil
205	}
206	if !xwayland.Supported(ctx, bin) {
207		log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
208		return nil
209	}
210	d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
211	if err != nil {
212		log.Warn().Err(err).Msg("X11 display disabled")
213		return nil
214	}
215	log.Info().Str("DISPLAY", d.Name()).Msg("listening on X11 display")
216	return d
217}
218
219// renderNode is the /dev/dri path of a render node dev_t, "" when none.
220func renderNode(dev uint64) string {
221	if dev == 0 {
222		return ""
223	}
224	return fmt.Sprintf("/dev/dri/renderD%d", unix.Minor(dev))
225}
226
227// latest sends v without blocking and replaces a value the consumer has not
228// read yet: the newest wins. Only one goroutine may send on ch; consumers only
229// receive, so the retry after draining cannot find the buffer full again.
230func latest[T any](ch chan T, v T) {
231	select {
232	case ch <- v:
233	default:
234		select {
235		case <-ch:
236		default:
237		}
238		select {
239		case ch <- v:
240		default:
241		}
242	}
243}

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