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}