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