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/sessionsecurity"
24 "github.com/bnema/neferwl/internal/adapters/statefile"
25 "github.com/bnema/neferwl/internal/adapters/vulkan"
26 "github.com/bnema/neferwl/internal/adapters/wayland"
27 "github.com/bnema/neferwl/internal/adapters/workspaceid"
28 "github.com/bnema/neferwl/internal/adapters/xkb"
29 "github.com/bnema/neferwl/internal/adapters/xwayland"
30 "github.com/bnema/neferwl/internal/core"
31 "github.com/bnema/neferwl/internal/logging"
32 "github.com/bnema/neferwl/internal/ports"
33 "github.com/bnema/zerowrap"
34 "golang.org/x/sys/unix"
35)
36
37type Options struct {
38 Backend string
39 Config ports.Config
40 ConfigPath string
41 Timeout time.Duration
42 NoTerminal bool
43 // NoXwayland skips the X11 display even when the config enables it.
44 NoXwayland bool
45 // HeadlessHDR enables a virtual HDR output for protocol testing only.
46 HeadlessHDR bool
47 // Session exports the displays to D-Bus and systemd user services
48 // before any client starts, and withdraws them on exit.
49 Session bool
50 ScreenshotDir string
51 // Sizes are the headless outputs (width, height), left to right; empty
52 // means one 1920x1080 output. With several outputs, screenshots go to
53 // one subdirectory per output (HEADLESS-1, HEADLESS-2, ...).
54 Sizes [][2]int
55 // Run closes Script on shutdown; the reader goroutine exits after Close.
56 Script io.ReadCloser
57 testScenes chan<- []ports.Scene
58 // captureAllowPath replaces the fixed allowlist path (tests).
59 captureAllowPath string
60 // captureAllowOwner is the uid that must own that file (tests use their
61 // own; root, 0, otherwise).
62 captureAllowOwner uint32
63}
64
65func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
66
67func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error {
68 log := logging.For(ctx, "app")
69 log.Info().Str("backend", opts.Backend).Msg("starting neferwl")
70 var cancel context.CancelFunc
71 if opts.Timeout > 0 {
72 ctx, cancel = context.WithTimeoutCause(ctx, opts.Timeout, errTimeout)
73 } else {
74 ctx, cancel = context.WithCancel(ctx)
75 }
76 defer cancel()
77 sizes := opts.Sizes
78 if len(sizes) == 0 {
79 sizes = [][2]int{{1920, 1080}}
80 }
81 var hw *drmBackend
82 if opts.Backend == "drm" {
83 var err error
84 if hw, err = openDRM(ctx, opts.Config); err != nil {
85 return err
86 }
87 defer hw.close()
88 }
89 security := &sessionsecurity.Gate{}
90 securityChanges := make(chan ports.SecurityState, 8)
91 securityEvents := make(chan ports.SecurityBackendEvent, 64)
92 client := make(chan ports.ClientEvent, 32)
93 input := make(chan ports.InputEvent, 32)
94 output := make(chan ports.OutputEvent, 32)
95 configChanges := make(chan ports.ConfigChanged, 8)
96 commands := make(chan ports.ClientCommand, 32)
97 spawn := make(chan ports.SpawnRequest, 32)
98 scenes := make(chan []ports.Scene, 1)
99 layouts := make(chan ports.Layout, 1)
100 constraints := make(chan ports.PointerConstraint, 1)
101 states := make(chan ports.State, 1)
102 workspaces := make(chan ports.Workspaces, 1)
103 configErrors := make(chan error, 8)
104 renderScenes := make(chan []ports.Scene, 1)
105 contents := make(chan ports.SurfaceContent, 64)
106 cursorChanges := make(chan ports.CursorChange, 1)
107 presented := make(chan ports.OutputPresented, 64)
108 // Outputs report on flips; relayPresented passes them to wayland and
109 // tells core about each flip (frames) to move running slides.
110 flips := make(chan ports.OutputPresented, 64)
111 frames := make(chan ports.OutputFrame, 8)
112 captures := make(chan ports.CaptureRequest, ports.MaxCaptureInflight)
113 // Every admitted capture owns one reply slot until Wayland consumes it.
114 // Keep this capacity tied to admission so output routing never waits.
115 captured := make(chan ports.CaptureDone, ports.MaxCaptureInflight)
116 outputFormats := make(chan ports.OutputFormats, 8)
117 outputHeads := make(chan ports.OutputHeads, 8)
118 leaseRequests := make(chan ports.LeaseMessage, 32)
119 leaseEvents := make(chan ports.LeaseMessage, 32)
120 applyOutput := make(chan ports.OutputApply, 8)
121 appliedOutput := make(chan ports.OutputApplied, 8)
122 var scales chan ports.ScaleChanged
123 if hw != nil {
124 // Only real sessions save scales: headless runs never touch the config file.
125 scales = make(chan ports.ScaleChanged, 8)
126 }
127 ch := core.Channels{Security: security, Scales: scales, Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal, Clock: clock.System{}, Frames: frames}
128 c, err := core.New(opts.Config, ch)
129 if err != nil {
130 return err
131 }
132 km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options})
133 if err != nil {
134 return err
135 }
136 keymap := km.String()
137 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
138 // Every output renders on the same GPU: its formats are the clients'.
139 dmabuf := vulkan.Probe()
140 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
141 // One launch prefix for ext-workspace ids and the state file.
142 wsIDs := workspaceid.New()
143 // The path is fixed: a config key would let a program of the same user
144 // widen who may capture.
145 // Production: the fixed path, a root-owned file. Only tests set a path,
146 // and an owner for it.
147 captureAllowLog := logging.For(ctx, "captureallow")
148 var captureAllow *captureallow.Store
149 if opts.captureAllowPath != "" {
150 captureAllow = captureallow.NewStoreOwnedBy(opts.captureAllowPath, opts.captureAllowOwner, captureAllowLog)
151 } else {
152 captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog)
153 }
154 server, err := wayland.New(wayland.Options{CaptureAllow: captureAllow, WorkspaceIDs: wsIDs, Security: security, RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{SecurityChanges: securityChanges, SecurityEvents: securityEvents, Events: client, Commands: commands, Workspaces: workspaces, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, LeaseRequests: leaseRequests, LeaseEvents: leaseEvents, OutputApply: applyOutput, OutputApplied: appliedOutput}, logging.For(ctx, "wayland"))
155 if err != nil {
156 km.Close()
157 return err
158 }
159 log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening")
160 childEnv := launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir, cursorSize())
161 statePath, err := statefile.Path(runtimeDir, server.SocketName())
162 if err != nil {
163 log.Warn().Err(err).Msg("state file disabled")
164 } else {
165 childEnv = append(childEnv, statefile.Env+"="+statePath)
166 }
167 xdisplay := openXwayland(ctx, opts, childEnv, logging.For(ctx, "xwayland"))
168 if xdisplay != nil {
169 defer xdisplay.Close()
170 childEnv = append(childEnv, "DISPLAY="+xdisplay.Name())
171 }
172 if opts.Session {
173 launcher.ExportSession(ctx, childEnv, logging.For(ctx, "launcher"))
174 launcher.NotifyReady(os.Getenv("NOTIFY_SOCKET"), logging.For(ctx, "launcher"))
175 defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher"))
176 }
177 child := launcher.New(childEnv, logging.For(ctx, "launcher"))
178 child.Security = security
179 if inject != nil {
180 inject(input)
181 }
182 var workers sync.WaitGroup
183 workers.Add(8)
184 done := make(chan error, 8)
185 path := opts.ConfigPath
186 if path == "" {
187 path = config.DefaultPath()
188 }
189 watched := make(chan ports.ConfigChanged, 8)
190 keymaps := make(chan *xkb.Keymap, 1)
191 touchpads := make(chan ports.TouchpadConfig, 1)
192 go func() {
193 defer workers.Done()
194 done <- config.Watch(ctx, path, watched, logging.For(ctx, "config"))
195 }()
196 if scales != nil {
197 workers.Add(1)
198 go func() {
199 defer workers.Done()
200 core.PersistScales(ctx, scales, config.ScaleStore{Path: path}, launcher.NewNotifier(ctx, childEnv, logging.For(ctx, "launcher")), clock.System{}, time.Second)
201 }()
202 }
203 filtered := make(chan ports.ConfigChanged, 8)
204 workers.Add(1)
205 go func() {
206 defer workers.Done()
207 relayConfig(ctx, opts.Config, watched, filtered, keymaps, touchpads, commands, logging.For(ctx, "config"))
208 }()
209 script := make(chan string)
210 curs := newCursors()
211 go func() {
212 defer workers.Done()
213 if hw != nil {
214 // Core sends the first layout once DRM reports the outputs.
215 var layout ports.Layout
216 select {
217 case layout = <-layouts:
218 case <-ctx.Done():
219 km.Close()
220 return
221 }
222 done <- safe("input", func() error {
223 if opts.Config.Performance.Realtime {
224 runtime.LockOSThread()
225 // Let this dedicated thread exit with its scheduling policy.
226 if err := sched.Realtime(logging.For(ctx, "sched")); err != nil {
227 log.Warn().Str("component", "sched").Err(err).Msg("input scheduling")
228 }
229 }
230 return libinput.Run(ctx, libinput.Options{Security: security, Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Touchpad: opts.Config.Touchpad, Touchpads: touchpads, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion"), LogKeys: logging.Enabled(ctx, "input-keys")}, input)
231 })
232 return
233 }
234 if err := headlessinput.RunSecure(ctx, km, keymaps, script, input, layouts, curs.move, logging.For(ctx, "input"), security); err != nil && !errors.Is(err, context.Canceled) {
235 select {
236 case done <- err:
237 case <-ctx.Done():
238 }
239 }
240 }()
241 if opts.Script != nil {
242 go func() { <-ctx.Done(); _ = opts.Script.Close() }()
243 go func() {
244 defer close(script)
245 scanner := bufio.NewScanner(opts.Script)
246 for scanner.Scan() {
247 select {
248 case <-ctx.Done():
249 return
250 case script <- scanner.Text():
251 }
252 }
253 if err := scanner.Err(); err != nil {
254 log.Warn().Err(err).Msg("reading input script")
255 }
256 }()
257 } else {
258 close(script)
259 }
260 go func() { defer workers.Done(); done <- server.Run(ctx) }()
261 go func() {
262 defer workers.Done()
263 // Without a watch the allowlist stays as loaded: restart to apply.
264 if err := captureAllow.Run(ctx); err != nil {
265 log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes")
266 }
267 }()
268 go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }()
269 go func() { defer workers.Done(); done <- c.Run(ctx) }()
270 if xdisplay != nil {
271 workers.Add(1)
272 // X11 failing leaves the Wayland session running; Run logs why.
273 go func() { defer workers.Done(); _ = xdisplay.Run(ctx) }()
274 }
275 if statePath != "" {
276 workers.Add(1)
277 go func() {
278 defer workers.Done()
279 if err := statefile.Run(ctx, statePath, states, wsIDs, logging.For(ctx, "statefile")); err != nil {
280 // Scripts lose their state; the session goes on.
281 log.Warn().Err(err).Msg("state file disabled")
282 }
283 }()
284 }
285 go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes, renderScenes) }()
286 workers.Add(1)
287 go func() { defer workers.Done(); relayPresented(ctx, flips, presented, frames) }()
288
289 go func() {
290 defer workers.Done()
291 outputIO := outputChannels{security: security, securityChanges: securityChanges, securityEvents: securityEvents, events: output, scenes: renderScenes, contents: contents, cursorChanges: cursorChanges, presented: flips, captures: captures, captured: captured, formats: outputFormats, heads: outputHeads, leaseRequests: leaseRequests, leaseEvents: leaseEvents, reloads: filtered, configured: configChanges, requests: applyOutput, replies: appliedOutput}
292 apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
293 renderLog := logging.For(ctx, "render")
294 newRenderer := func(w, h int) (ports.Renderer, error) {
295 r, err := vulkan.New(w, h)
296 if err != nil {
297 return nil, err
298 }
299 renderLog.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue")
300 return r, nil
301 }
302 if hw != nil {
303 done <- safe("output", func() error {
304 want := func(cfg ports.Config) drm.Want {
305 w := wantFromConfig(cfg)
306 w.Sampled = dmabuf.Formats
307 w.TraceFlips = logging.Enabled(ctx, "drm-flip")
308 return w
309 }
310 return hw.runOutputs(ctx, want, opts.Config, apply, outputIO, curs, newRenderer, logging.For(ctx, "drm"))
311 })
312 return
313 }
314 done <- runHeadless(ctx, sizes, opts.ScreenshotDir, opts.HeadlessHDR, apply, outputIO, curs, newRenderer, logging.For(ctx, "render"))
315 }()
316
317 if hw != nil {
318 workers.Add(1)
319 go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()
320 }
321 var result error
322 select {
323 case <-ctx.Done():
324 if errors.Is(context.Cause(ctx), errTimeout) {
325 log.Info().Str("reason", "timeout").Dur("after", opts.Timeout).Msg("stopping")
326 }
327 case result = <-done:
328 log.Info().AnErr("cause", result).Msg("stopping")
329 }
330 cancel()
331 workers.Wait()
332 // The wayland server has stopped: no request can be queued any more.
333 drainCaptures(ctx, captures, nil)
334 // A keymap the input goroutine never took is still ours to free.
335 select {
336 case km := <-keymaps:
337 km.Close()
338 default:
339 }
340 for len(done) > 0 {
341 err := <-done
342 if result == nil && err != nil {
343 result = err
344 }
345 }
346 if errors.Is(result, core.ErrQuit) || errors.Is(result, libinput.ErrEmergencyQuit) {
347 return nil
348 }
349 return result
350}
351
352// relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
353// to the input goroutine and sends it to clients; a repeat-only change just updates
354// clients. A keymap that fails to build keeps the previous layout. A touchpad
355// change goes to the input goroutine; only the newest one waits there.
356func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, touchpads chan ports.TouchpadConfig, commands chan<- ports.ClientCommand, log zerowrap.Logger) {
357 kb := cur.Keyboard
358 tp := cur.Touchpad
359 for {
360 var ev ports.ConfigChanged
361 select {
362 case <-ctx.Done():
363 return
364 case ev = <-in:
365 }
366 next := ev.Config.Keyboard
367 layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options
368 repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay
369 cmd := ports.SetKeymap{RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}
370 if layoutChanged {
371 km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options})
372 if err != nil {
373 log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous layout")
374 ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options
375 layoutChanged = false
376 } else {
377 cmd.Keymap = km.String()
378 // Drop a keymap the input goroutine has not taken yet; the newest wins.
379 select {
380 case old := <-keymaps:
381 old.Close()
382 default:
383 }
384 select {
385 case keymaps <- km:
386 case <-ctx.Done():
387 km.Close()
388 return
389 }
390 }
391 }
392 if ev.Config.Touchpad != tp {
393 tp = ev.Config.Touchpad
394 select {
395 case <-touchpads:
396 default:
397 }
398 touchpads <- tp // buffered and just drained: this goroutine is the only sender
399 }
400 if layoutChanged || repeatChanged {
401 select {
402 case commands <- cmd:
403 case <-ctx.Done():
404 return
405 }
406 if layoutChanged {
407 kb.Layout, kb.Variant, kb.Options = next.Layout, next.Variant, next.Options
408 }
409 kb.RepeatRate, kb.RepeatDelay = next.RepeatRate, next.RepeatDelay
410 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")
411 }
412 select {
413 case out <- ev:
414 case <-ctx.Done():
415 return
416 }
417 }
418}
419
420// relayPresented passes output reports to wayland in order. Each flip is
421// also a frame for core; a frame core has not read yet is enough, so
422// frames never wait.
423func relayPresented(ctx context.Context, in <-chan ports.OutputPresented, out chan<- ports.OutputPresented, frames chan<- ports.OutputFrame) {
424 for {
425 var p ports.OutputPresented
426 select {
427 case <-ctx.Done():
428 return
429 case p = <-in:
430 }
431 if p.Flip != nil {
432 select {
433 case frames <- ports.OutputFrame{Output: p.Output}:
434 default:
435 }
436 }
437 select {
438 case out <- p:
439 case <-ctx.Done():
440 return
441 }
442 }
443}
444
445func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
446 log := logging.For(ctx, "core")
447 configLog := logging.For(ctx, "config")
448 for {
449 select {
450 case <-ctx.Done():
451 return
452 case set := <-scenes:
453 if tap != nil {
454 select {
455 case tap <- set:
456 default:
457 }
458 }
459 select {
460 case renderScenes <- set:
461 default:
462 select {
463 case <-renderScenes:
464 default:
465 }
466 select {
467 case renderScenes <- set:
468 default:
469 }
470 }
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}