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