1// Package app coordinates the application lifecycle.
2package app
3
4import (
5 "bufio"
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "runtime"
12 "sync"
13 "time"
14
15 "github.com/bnema/neferwl/internal/adapters/captureallow"
16 "github.com/bnema/neferwl/internal/adapters/clock"
17 "github.com/bnema/neferwl/internal/adapters/config"
18 "github.com/bnema/neferwl/internal/adapters/drm"
19 "github.com/bnema/neferwl/internal/adapters/headlessinput"
20 "github.com/bnema/neferwl/internal/adapters/launcher"
21 "github.com/bnema/neferwl/internal/adapters/libinput"
22 "github.com/bnema/neferwl/internal/adapters/logging"
23 "github.com/bnema/neferwl/internal/adapters/sched"
24 "github.com/bnema/neferwl/internal/adapters/screensaver"
25 "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
26 "github.com/bnema/neferwl/internal/adapters/statefile"
27 "github.com/bnema/neferwl/internal/adapters/vulkan"
28 "github.com/bnema/neferwl/internal/adapters/wayland"
29 "github.com/bnema/neferwl/internal/adapters/workspaceid"
30 "github.com/bnema/neferwl/internal/adapters/xkb"
31 "github.com/bnema/neferwl/internal/adapters/xwayland"
32 "github.com/bnema/neferwl/internal/core"
33 "github.com/bnema/neferwl/internal/ports"
34 "github.com/bnema/zerowrap"
35 "golang.org/x/sys/unix"
36)
37
38type Options struct {
39 Backend string
40 Config ports.Config
41 ConfigPath string
42 Timeout time.Duration
43 NoTerminal bool
44 // NoXwayland skips the X11 display even when the config enables it.
45 NoXwayland bool
46 // HeadlessHDR enables a virtual HDR output for protocol testing only.
47 HeadlessHDR bool
48 // Session exports the displays to D-Bus and systemd user services
49 // before any client starts, and withdraws them on exit.
50 Session bool
51 ScreenshotDir string
52 // ScreenshotRaw also writes latest-pq.png, the raw PQ codes of a
53 // virtual HDR output (test-only; needs ScreenshotDir and HeadlessHDR).
54 ScreenshotRaw bool
55 // Sizes are the headless outputs (width, height), left to right; empty
56 // means one 1920x1080 output. With several outputs, screenshots go to
57 // one subdirectory per output (HEADLESS-1, HEADLESS-2, ...).
58 Sizes [][2]int
59 // Run closes Script on shutdown; the reader goroutine exits after Close.
60 Script io.ReadCloser
61 testScenes chan<- []ports.Scene
62 // captureAllowPath replaces the fixed allowlist path (tests).
63 captureAllowPath string
64 // captureAllowOwner is the uid that must own that file (tests use their
65 // own; root, 0, otherwise).
66 captureAllowOwner uint32
67}
68
69func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
70
71func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error {
72 log := logging.For(ctx, "app")
73 log.Info().Str("backend", opts.Backend).Msg("starting neferwl")
74 var cancel context.CancelFunc
75 if opts.Timeout > 0 {
76 ctx, cancel = context.WithTimeoutCause(ctx, opts.Timeout, errTimeout)
77 } else {
78 ctx, cancel = context.WithCancel(ctx)
79 }
80 defer cancel()
81 sizes := opts.Sizes
82 if len(sizes) == 0 {
83 sizes = [][2]int{{1920, 1080}}
84 }
85 var hw *drmBackend
86 if opts.Backend == "drm" {
87 var err error
88 if hw, err = openDRM(ctx, opts.Config); err != nil {
89 return err
90 }
91 defer hw.close()
92 }
93 security := &sessionsecurity.Gate{}
94 p := newPipes(hw != nil)
95 ch := p.core()
96 ch.Security = security
97 ch.Terminal = !opts.NoTerminal
98 ch.Clock = clock.System{}
99 c, err := core.New(opts.Config, ch)
100 if err != nil {
101 return err
102 }
103 km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options})
104 if err != nil {
105 return err
106 }
107 keymap := km.String()
108 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
109 // Every output renders on the same GPU: its formats are the clients'.
110 dmabuf := vulkan.Probe()
111 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
112 // One launch prefix for ext-workspace ids and the state file.
113 wsIDs := workspaceid.New()
114 // The path is fixed: a config key would let a program of the same user
115 // widen who may capture.
116 // Production: the fixed path, a root-owned file. Only tests set a path,
117 // and an owner for it.
118 captureAllowLog := logging.For(ctx, "captureallow")
119 var captureAllow *captureallow.Store
120 if opts.captureAllowPath != "" {
121 captureAllow = captureallow.NewStoreOwnedBy(opts.captureAllowPath, opts.captureAllowOwner, captureAllowLog)
122 } else {
123 captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog)
124 }
125 server, err := wayland.New(wayland.Options{
126 CaptureAllow: captureAllow,
127 WorkspaceIDs: wsIDs,
128 Security: security,
129 RuntimeDir: runtimeDir,
130 DMABuf: dmabuf,
131 SyncobjNode: renderNode(dmabuf.Device),
132 Keymap: keymap,
133 RepeatRate: opts.Config.Keyboard.RepeatRate,
134 RepeatDelay: opts.Config.Keyboard.RepeatDelay,
135 }, p.wayland(), logging.For(ctx, "wayland"))
136 if err != nil {
137 km.Close()
138 return err
139 }
140 log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening")
141 childEnv := launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir, cursorSize())
142 statePath, err := statefile.Path(runtimeDir, server.SocketName())
143 if err != nil {
144 log.Warn().Err(err).Msg("state file disabled")
145 } else {
146 childEnv = append(childEnv, statefile.Env+"="+statePath)
147 }
148 xdisplay := openXwayland(ctx, opts, childEnv, logging.For(ctx, "xwayland"))
149 if xdisplay != nil {
150 defer xdisplay.Close()
151 childEnv = append(childEnv, "DISPLAY="+xdisplay.Name())
152 }
153 if opts.Session {
154 launcher.ExportSession(ctx, childEnv, logging.For(ctx, "launcher"))
155 launcher.NotifyReady(os.Getenv("NOTIFY_SOCKET"), logging.For(ctx, "launcher"))
156 defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher"))
157 }
158 child := launcher.New(childEnv, logging.For(ctx, "launcher"))
159 child.Security = security
160 if inject != nil {
161 inject(p.input)
162 }
163 var workers sync.WaitGroup
164 workers.Add(8)
165 done := make(chan error, 8)
166 path := opts.ConfigPath
167 if path == "" {
168 path = config.DefaultPath()
169 }
170 go func() {
171 defer workers.Done()
172 done <- config.Watch(ctx, path, p.watched, logging.For(ctx, "config"))
173 }()
174 if p.scales != nil {
175 workers.Add(1)
176 go func() {
177 defer workers.Done()
178 core.PersistScales(ctx, p.scales, config.ScaleStore{Path: path}, launcher.NewNotifier(ctx, childEnv, logging.For(ctx, "launcher")), clock.System{}, time.Second)
179 }()
180 }
181 curs := newCursors(clock.System{}, opts.Config.Cursor.HideAfter)
182 defer curs.stop()
183 workers.Add(1)
184 go func() {
185 defer workers.Done()
186 relayConfig(ctx, opts.Config, p.watched, p.filtered, p.keymaps, p.deviceConfigs, curs, p.commands, logging.For(ctx, "config"))
187 }()
188 script := make(chan string)
189 go func() {
190 defer workers.Done()
191 if hw != nil {
192 // Core sends the first layout once DRM reports the outputs.
193 var layout ports.Layout
194 select {
195 case layout = <-p.layouts:
196 case <-ctx.Done():
197 km.Close()
198 return
199 }
200 done <- safe("input", func() error {
201 if opts.Config.Performance.Realtime {
202 runtime.LockOSThread()
203 // Let this dedicated thread exit with its scheduling policy.
204 schedLog := logging.For(ctx, "sched")
205 if err := sched.Realtime(schedLog); err != nil {
206 schedLog.Warn().Err(err).Msg("input scheduling")
207 }
208 }
209 return libinput.Run(ctx, libinput.Options{
210 Security: security,
211 Seat: hw.seat,
212 SeatName: hw.seat.Name(),
213 Keymap: km,
214 Keymaps: p.keymaps,
215 Layout: layout,
216 Layouts: p.layouts,
217 Constraints: p.constraints,
218 DeviceConfig: opts.Config.InputDevicesConfig,
219 DeviceConfigs: p.deviceConfigs,
220 Active: hw.seat.Subscribe(),
221 MoveCursor: curs.move,
222 Log: logging.For(ctx, "input"),
223 LogMotion: logging.Enabled(ctx, "input-motion"),
224 LogKeys: logging.Enabled(ctx, "input-keys"),
225 }, p.input)
226 })
227 return
228 }
229 if err := headlessinput.RunSecure(ctx, km, p.keymaps, script, p.input, p.layouts, func(o string, x, y float64) { curs.move(o, x, y, true) }, logging.For(ctx, "input"), security); err != nil && !errors.Is(err, context.Canceled) {
230 select {
231 case done <- err:
232 case <-ctx.Done():
233 }
234 }
235 }()
236 if opts.Script != nil {
237 go func() { <-ctx.Done(); _ = opts.Script.Close() }()
238 go func() {
239 defer close(script)
240 scanner := bufio.NewScanner(opts.Script)
241 for scanner.Scan() {
242 select {
243 case <-ctx.Done():
244 return
245 case script <- scanner.Text():
246 }
247 }
248 if err := scanner.Err(); err != nil {
249 log.Warn().Err(err).Msg("reading input script")
250 }
251 }()
252 } else {
253 close(script)
254 }
255 go func() { defer workers.Done(); done <- server.Run(ctx) }()
256 go func() {
257 defer workers.Done()
258 // Without a watch the allowlist stays as loaded: restart to apply.
259 if err := captureAllow.Run(ctx); err != nil {
260 log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes")
261 }
262 }()
263 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()
264 go func() { defer workers.Done(); done <- c.Run(ctx) }()
265 if xdisplay != nil {
266 workers.Add(1)
267 // X11 failing leaves the Wayland session running; Run logs why.
268 go func() { defer workers.Done(); _ = xdisplay.Run(ctx) }()
269 }
270 if statePath != "" {
271 workers.Add(1)
272 go func() {
273 defer workers.Done()
274 if err := statefile.Run(ctx, statePath, p.states, wsIDs, logging.For(ctx, "statefile")); err != nil {
275 // Scripts lose their state; the session goes on.
276 log.Warn().Err(err).Msg("state file disabled")
277 }
278 }()
279 }
280 go func() {
281 defer workers.Done()
282 consumeScenes(ctx, p.scenes, p.configErrors, opts.testScenes, p.renderScenes)
283 }()
284 workers.Add(1)
285 go func() { defer workers.Done(); relayPresented(ctx, p.flips, p.presented, p.frames) }()
286
287 go func() {
288 defer workers.Done()
289 outputIO := p.outputs(security)
290 apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
291 renderLog := logging.For(ctx, "render")
292 newRenderer := func(w, h int) (ports.Renderer, error) {
293 r, err := vulkan.New(w, h)
294 if err != nil {
295 return nil, err
296 }
297 r.SetLogger(renderLog)
298 renderLog.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue")
299 return r, nil
300 }
301 if hw != nil {
302 done <- safe("output", func() error {
303 want := func(cfg ports.Config) drm.Want {
304 w := wantFromConfig(cfg)
305 w.Sampled = dmabuf.Formats
306 w.TraceFlips = logging.Enabled(ctx, "drm-flip")
307 return w
308 }
309 return hw.runOutputs(ctx, want, opts.Config, apply, outputIO, curs, newRenderer, logging.For(ctx, "drm"))
310 })
311 return
312 }
313 done <- runHeadless(ctx, headlessOptions{sizes: sizes, shots: opts.ScreenshotDir, hdr: opts.HeadlessHDR, raw: opts.ScreenshotRaw}, apply, outputIO, curs, newRenderer, logging.For(ctx, "render"))
314 }()
315
316 if hw != nil {
317 workers.Add(1)
318 go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()
319 // Only a real session serves the session bus: headless runs would take
320 // the name from the desktop they run in.
321 workers.Add(1)
322 go func() {
323 defer workers.Done()
324 reports := screensaver.Reports{Held: p.idleInhibited, Activity: p.idleActivity}
325 screensaver.Serve(ctx, "", reports, screensaverRetry, logging.For(ctx, "screensaver"))
326 }()
327 }
328 var result error
329 select {
330 case <-ctx.Done():
331 if errors.Is(context.Cause(ctx), errTimeout) {
332 log.Info().Str("reason", "timeout").Dur("after", opts.Timeout).Msg("stopping")
333 }
334 case result = <-done:
335 log.Info().AnErr("cause", result).Msg("stopping")
336 }
337 cancel()
338 workers.Wait()
339 // The wayland server has stopped: no request can be queued any more.
340 drainCaptures(ctx, p.captures, nil)
341 // A keymap the input goroutine never took is still ours to free.
342 select {
343 case km := <-p.keymaps:
344 km.Close()
345 default:
346 }
347 for len(done) > 0 {
348 err := <-done
349 if result == nil && err != nil {
350 result = err
351 }
352 }
353 if errors.Is(result, core.ErrQuit) || errors.Is(result, libinput.ErrEmergencyQuit) {
354 return nil
355 }
356 return result
357}
358
359// screensaverRetry is the first delay before the screensaver service tries
360// the session bus again: without one, only surface inhibitors count, and
361// the session goes on either way.
362const screensaverRetry = 2 * time.Second
363
364// relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
365// to the input goroutine and sends it to clients; a repeat-only change just updates
366// clients. A keymap that fails to build keeps the previous layout. A touchpad
367// or mouse change goes to the input goroutine as one InputDevicesConfig; only
368// the newest one waits there. The cursor idle delay goes straight to the
369// cursor router.
370func 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) {
371 kb := cur.Keyboard
372 devCfg := cur.InputDevicesConfig
373 for {
374 var ev ports.ConfigChanged
375 select {
376 case <-ctx.Done():
377 return
378 case ev = <-in:
379 }
380 next := ev.Config.Keyboard
381 layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options
382 repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay
383 cmd := ports.SetKeymap{RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}
384 if layoutChanged {
385 km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options})
386 if err != nil {
387 log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous layout")
388 ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options
389 layoutChanged = false
390 } else {
391 cmd.Keymap = km.String()
392 // Drop a keymap the input goroutine has not taken yet; the newest wins.
393 select {
394 case old := <-keymaps:
395 old.Close()
396 default:
397 }
398 select {
399 case keymaps <- km:
400 case <-ctx.Done():
401 km.Close()
402 return
403 }
404 }
405 }
406 curs.setHideAfter(ev.Config.Cursor.HideAfter)
407 if ev.Config.InputDevicesConfig != devCfg {
408 devCfg = ev.Config.InputDevicesConfig
409 latest(deviceConfigs, devCfg) // this goroutine is the only sender
410 }
411 if layoutChanged || repeatChanged {
412 select {
413 case commands <- cmd:
414 case <-ctx.Done():
415 return
416 }
417 if layoutChanged {
418 kb.Layout, kb.Variant, kb.Options = next.Layout, next.Variant, next.Options
419 }
420 kb.RepeatRate, kb.RepeatDelay = next.RepeatRate, next.RepeatDelay
421 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")
422 }
423 select {
424 case out <- ev:
425 case <-ctx.Done():
426 return
427 }
428 }
429}
430
431// relayPresented passes output reports to wayland in order. Each flip is
432// also a frame for core; a frame core has not read yet is enough, so
433// frames never wait.
434func relayPresented(ctx context.Context, in <-chan ports.OutputPresented, out chan<- ports.OutputPresented, frames chan<- ports.OutputFrame) {
435 for {
436 var p ports.OutputPresented
437 select {
438 case <-ctx.Done():
439 return
440 case p = <-in:
441 }
442 if p.Flip != nil {
443 select {
444 case frames <- ports.OutputFrame{Output: p.Output}:
445 default:
446 }
447 }
448 select {
449 case out <- p:
450 case <-ctx.Done():
451 return
452 }
453 }
454}
455
456func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
457 log := logging.For(ctx, "core")
458 configLog := logging.For(ctx, "config")
459 for {
460 select {
461 case <-ctx.Done():
462 return
463 case set := <-scenes:
464 if tap != nil {
465 select {
466 case tap <- set:
467 default:
468 }
469 }
470 latest(renderScenes, set) // this goroutine is the only sender
471 for _, s := range set {
472 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)
473 rects := make([]string, 0, len(s.Windows))
474 for _, w := range s.Windows {
475 rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
476 if w.FocusEffect > 0 {
477 // The focus indicator frame by frame: window and effect.
478 ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
479 }
480 }
481 ev.Strs("windows", rects).Msg("scene")
482 }
483 case err := <-configErrors:
484 configLog.Warn().Err(err).Msg("config rejected")
485 }
486 }
487}
488
489// openXwayland reserves an X11 display for xwayland-satellite, or returns
490// nil when X11 is off or unavailable: the session runs without it.
491func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
492 bin := opts.Config.Xwayland
493 if bin == "" || opts.NoXwayland {
494 return nil
495 }
496 if !xwayland.Supported(ctx, bin) {
497 log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
498 return nil
499 }
500 d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
501 if err != nil {
502 log.Warn().Err(err).Msg("X11 display disabled")
503 return nil
504 }
505 log.Info().Str("DISPLAY", d.Name()).Msg("listening on X11 display")
506 return d
507}
508
509// renderNode is the /dev/dri path of a render node dev_t, "" when none.
510func renderNode(dev uint64) string {
511 if dev == 0 {
512 return ""
513 }
514 return fmt.Sprintf("/dev/dri/renderD%d", unix.Minor(dev))
515}
516
517// latest sends v without blocking and replaces a value the consumer has not
518// read yet: the newest wins. Only one goroutine may send on ch; consumers only
519// receive, so the retry after draining cannot find the buffer full again.
520func latest[T any](ch chan T, v T) {
521 select {
522 case ch <- v:
523 default:
524 select {
525 case <-ch:
526 default:
527 }
528 select {
529 case ch <- v:
530 default:
531 }
532 }
533}