feat/data-dnd e05cb15

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	for {
88		var ev ports.ConfigChanged
89		select {
90		case <-ctx.Done():
91			return
92		case ev = <-in:
93		}
94		next := ev.Config.Keyboard
95		layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options
96		repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay
97		cmd := ports.SetKeymap{RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}
98		if layoutChanged {
99			km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options})
100			if err != nil {
101				log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous layout")
102				ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options
103				layoutChanged = false
104			} else {
105				cmd.Keymap = km.String()
106				// Drop a keymap the input goroutine has not taken yet; the newest wins.
107				select {
108				case old := <-keymaps:
109					old.Close()
110				default:
111				}
112				select {
113				case keymaps <- km:
114				case <-ctx.Done():
115					km.Close()
116					return
117				}
118			}
119		}
120		curs.setHideAfter(ev.Config.Cursor.HideAfter)
121		if ev.Config.InputDevicesConfig != devCfg {
122			devCfg = ev.Config.InputDevicesConfig
123			latest(deviceConfigs, devCfg) // this goroutine is the only sender
124		}
125		if layoutChanged || repeatChanged {
126			select {
127			case commands <- cmd:
128			case <-ctx.Done():
129				return
130			}
131			if layoutChanged {
132				kb.Layout, kb.Variant, kb.Options = next.Layout, next.Variant, next.Options
133			}
134			kb.RepeatRate, kb.RepeatDelay = next.RepeatRate, next.RepeatDelay
135			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")
136		}
137		select {
138		case out <- ev:
139		case <-ctx.Done():
140			return
141		}
142	}
143}
144
145func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
146	log := logging.For(ctx, "core")
147	configLog := logging.For(ctx, "config")
148	for {
149		select {
150		case <-ctx.Done():
151			return
152		case set := <-scenes:
153			if tap != nil {
154				select {
155				case tap <- set:
156				default:
157				}
158			}
159			latest(renderScenes, set) // this goroutine is the only sender
160			logScenes(log, set)
161		case err := <-configErrors:
162			configLog.Warn().Err(err).Msg("config rejected")
163		}
164	}
165}
166
167// logScenes logs each scene at debug level. The rect strings are built only
168// when debug is on: at the default level a frame costs no allocation here.
169func logScenes(log zerowrap.Logger, set []ports.Scene) {
170	for _, s := range set {
171		ev := log.Debug()
172		if !ev.Enabled() {
173			continue
174		}
175		ev = ev.Str("output", s.Output).Uint64("seq", s.Seq).Int("out_w", s.OutputWidth).Int("out_h", s.OutputHeight).Float64("scale", s.Scale)
176		rects := make([]string, 0, len(s.Windows))
177		for _, w := range s.Windows {
178			rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
179			if w.FocusEffect > 0 {
180				// The focus indicator frame by frame: window and effect.
181				ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
182			}
183		}
184		ev.Strs("windows", rects).Msg("scene")
185	}
186}
187
188// openXwayland reserves an X11 display for xwayland-satellite, or returns
189// nil when X11 is off or unavailable: the session runs without it.
190func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
191	bin := opts.Config.Xwayland
192	if bin == "" || opts.NoXwayland {
193		return nil
194	}
195	if !xwayland.Supported(ctx, bin) {
196		log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
197		return nil
198	}
199	d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
200	if err != nil {
201		log.Warn().Err(err).Msg("X11 display disabled")
202		return nil
203	}
204	log.Info().Str("DISPLAY", d.Name()).Msg("listening on X11 display")
205	return d
206}
207
208// renderNode is the /dev/dri path of a render node dev_t, "" when none.
209func renderNode(dev uint64) string {
210	if dev == 0 {
211		return ""
212	}
213	return fmt.Sprintf("/dev/dri/renderD%d", unix.Minor(dev))
214}
215
216// latest sends v without blocking and replaces a value the consumer has not
217// read yet: the newest wins. Only one goroutine may send on ch; consumers only
218// receive, so the retry after draining cannot find the buffer full again.
219func latest[T any](ch chan T, v T) {
220	select {
221	case ch <- v:
222	default:
223		select {
224		case <-ch:
225		default:
226		}
227		select {
228		case ch <- v:
229		default:
230		}
231	}
232}

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