1// Package app coordinates the application lifecycle.
2package app
3
4import (
5 "bufio"
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "sync"
12 "time"
13
14 "github.com/bnema/neferwl/internal/adapters/config"
15 "github.com/bnema/neferwl/internal/adapters/drm"
16 "github.com/bnema/neferwl/internal/adapters/headlessinput"
17 "github.com/bnema/neferwl/internal/adapters/launcher"
18 "github.com/bnema/neferwl/internal/adapters/libinput"
19 "github.com/bnema/neferwl/internal/adapters/statefile"
20 "github.com/bnema/neferwl/internal/adapters/vulkan"
21 "github.com/bnema/neferwl/internal/adapters/wayland"
22 "github.com/bnema/neferwl/internal/adapters/xkb"
23 "github.com/bnema/neferwl/internal/adapters/xwayland"
24 "github.com/bnema/neferwl/internal/core"
25 "github.com/bnema/neferwl/internal/logging"
26 "github.com/bnema/neferwl/internal/ports"
27 "github.com/bnema/zerowrap"
28 "golang.org/x/sys/unix"
29)
30
31type Options struct {
32 Backend string
33 Config ports.Config
34 ConfigPath string
35 Timeout time.Duration
36 NoTerminal bool
37 // NoXwayland skips the X11 display even when the config enables it.
38 NoXwayland bool
39 // Session exports the displays to D-Bus and systemd user services
40 // before any client starts, and withdraws them on exit.
41 Session bool
42 ScreenshotDir string
43 // Sizes are the headless outputs (width, height), left to right; empty
44 // means one 1920x1080 output. With several outputs, screenshots go to
45 // one subdirectory per output (HEADLESS-1, HEADLESS-2, ...).
46 Sizes [][2]int
47 // Run closes Script on shutdown; the reader goroutine exits after Close.
48 Script io.ReadCloser
49 testScenes chan<- []ports.Scene
50}
51
52func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
53
54func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error {
55 log := logging.For(ctx, "app")
56 log.Info().Str("backend", opts.Backend).Msg("starting neferwl")
57 var cancel context.CancelFunc
58 if opts.Timeout > 0 {
59 ctx, cancel = context.WithTimeoutCause(ctx, opts.Timeout, errTimeout)
60 } else {
61 ctx, cancel = context.WithCancel(ctx)
62 }
63 defer cancel()
64 sizes := opts.Sizes
65 if len(sizes) == 0 {
66 sizes = [][2]int{{1920, 1080}}
67 }
68 var hw *drmBackend
69 if opts.Backend == "drm" {
70 var err error
71 if hw, err = openDRM(ctx, opts.Config); err != nil {
72 return err
73 }
74 defer hw.close()
75 }
76 client := make(chan ports.ClientEvent, 32)
77 input := make(chan ports.InputEvent, 32)
78 output := make(chan ports.OutputEvent, 32)
79 configChanges := make(chan ports.ConfigChanged, 8)
80 commands := make(chan ports.ClientCommand, 32)
81 spawn := make(chan ports.SpawnRequest, 32)
82 scenes := make(chan []ports.Scene, 1)
83 layouts := make(chan ports.Layout, 1)
84 constraints := make(chan ports.PointerConstraint, 1)
85 states := make(chan ports.State, 1)
86 configErrors := make(chan error, 8)
87 renderScenes := make(chan []ports.Scene, 1)
88 contents := make(chan ports.SurfaceContent, 64)
89 cursorChanges := make(chan ports.CursorChange, 1)
90 presented := make(chan ports.OutputPresented, 64)
91 outputFormats := make(chan ports.OutputFormats, 8)
92 ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
93 c, err := core.New(opts.Config, ch)
94 if err != nil {
95 return err
96 }
97 km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options})
98 if err != nil {
99 return err
100 }
101 keymap := km.String()
102 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
103 // Every output renders on the same GPU: its formats are the clients'.
104 dmabuf := vulkan.Probe()
105 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
106 server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges, Presented: presented, OutputFormats: outputFormats}, logging.For(ctx, "wayland"))
107 if err != nil {
108 km.Close()
109 return err
110 }
111 log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening")
112 childEnv := launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir, cursorSize())
113 statePath, err := statefile.Path(runtimeDir, server.SocketName())
114 if err != nil {
115 log.Warn().Err(err).Msg("state file disabled")
116 } else {
117 childEnv = append(childEnv, statefile.Env+"="+statePath)
118 }
119 xdisplay := openXwayland(ctx, opts, childEnv, logging.For(ctx, "xwayland"))
120 if xdisplay != nil {
121 defer xdisplay.Close()
122 childEnv = append(childEnv, "DISPLAY="+xdisplay.Name())
123 }
124 if opts.Session {
125 launcher.ExportSession(ctx, childEnv, logging.For(ctx, "launcher"))
126 defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher"))
127 }
128 child := launcher.New(childEnv, logging.For(ctx, "launcher"))
129 if inject != nil {
130 inject(input)
131 }
132 var workers sync.WaitGroup
133 workers.Add(7)
134 done := make(chan error, 8)
135 path := opts.ConfigPath
136 if path == "" {
137 path = config.DefaultPath()
138 }
139 watched := make(chan ports.ConfigChanged, 8)
140 keymaps := make(chan *xkb.Keymap, 1)
141 go func() {
142 defer workers.Done()
143 done <- config.Watch(ctx, path, watched, logging.For(ctx, "config"))
144 }()
145 workers.Add(1)
146 go func() {
147 defer workers.Done()
148 relayConfig(ctx, opts.Config, watched, configChanges, keymaps, commands, logging.For(ctx, "config"))
149 }()
150 script := make(chan string)
151 curs := newCursors()
152 go func() {
153 defer workers.Done()
154 if hw != nil {
155 // Core sends the first layout once DRM reports the outputs.
156 var layout ports.Layout
157 select {
158 case layout = <-layouts:
159 case <-ctx.Done():
160 km.Close()
161 return
162 }
163 done <- safe("input", func() error {
164 return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion")}, input)
165 })
166 return
167 }
168 _ = headlessinput.Run(ctx, km, keymaps, script, input, layouts, curs.move, logging.For(ctx, "input"))
169 }()
170 if opts.Script != nil {
171 go func() { <-ctx.Done(); _ = opts.Script.Close() }()
172 go func() {
173 defer close(script)
174 scanner := bufio.NewScanner(opts.Script)
175 for scanner.Scan() {
176 select {
177 case <-ctx.Done():
178 return
179 case script <- scanner.Text():
180 }
181 }
182 if err := scanner.Err(); err != nil {
183 log.Warn().Err(err).Msg("reading input script")
184 }
185 }()
186 } else {
187 close(script)
188 }
189 go func() { defer workers.Done(); done <- server.Run(ctx) }()
190 go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }()
191 go func() { defer workers.Done(); done <- c.Run(ctx) }()
192 if xdisplay != nil {
193 workers.Add(1)
194 // X11 failing leaves the Wayland session running; Run logs why.
195 go func() { defer workers.Done(); _ = xdisplay.Run(ctx) }()
196 }
197 if statePath != "" {
198 workers.Add(1)
199 go func() {
200 defer workers.Done()
201 if err := statefile.Run(ctx, statePath, states, logging.For(ctx, "statefile")); err != nil {
202 // Scripts lose their state; the session goes on.
203 log.Warn().Err(err).Msg("state file disabled")
204 }
205 }()
206 }
207 go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes, renderScenes) }()
208
209 go func() {
210 defer workers.Done()
211 newRenderer := func(w, h int) (ports.Renderer, error) {
212 r, err := vulkan.New(w, h)
213 if err != nil {
214 return nil, err
215 }
216 return r, nil
217 }
218 if hw != nil {
219 done <- safe("output", func() error {
220 want := func() drm.Want {
221 w := wantFromConfig(opts.Config)
222 w.Sampled = dmabuf.Formats
223 return w
224 }
225 return hw.runOutputs(ctx, want, output, renderScenes, contents, cursorChanges, presented, outputFormats, curs, newRenderer, logging.For(ctx, "drm"))
226 })
227 return
228 }
229 done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, presented, curs, newRenderer, logging.For(ctx, "render"))
230 }()
231
232 if hw != nil {
233 workers.Add(1)
234 go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()
235 }
236 var result error
237 select {
238 case <-ctx.Done():
239 if errors.Is(context.Cause(ctx), errTimeout) {
240 log.Info().Str("reason", "timeout").Dur("after", opts.Timeout).Msg("stopping")
241 }
242 case result = <-done:
243 log.Info().AnErr("cause", result).Msg("stopping")
244 }
245 cancel()
246 workers.Wait()
247 // A keymap the input goroutine never took is still ours to free.
248 select {
249 case km := <-keymaps:
250 km.Close()
251 default:
252 }
253 for len(done) > 0 {
254 err := <-done
255 if result == nil && err != nil {
256 result = err
257 }
258 }
259 if errors.Is(result, core.ErrQuit) || errors.Is(result, libinput.ErrEmergencyQuit) {
260 return nil
261 }
262 return result
263}
264
265// relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
266// to the input goroutine and sends it to clients; a repeat-only change just updates
267// clients. A keymap that fails to build keeps the previous layout.
268func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, commands chan<- ports.ClientCommand, log zerowrap.Logger) {
269 kb := cur.Keyboard
270 for {
271 var ev ports.ConfigChanged
272 select {
273 case <-ctx.Done():
274 return
275 case ev = <-in:
276 }
277 next := ev.Config.Keyboard
278 layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options
279 repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay
280 cmd := ports.SetKeymap{RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}
281 if layoutChanged {
282 km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options})
283 if err != nil {
284 log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous layout")
285 ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options
286 layoutChanged = false
287 } else {
288 cmd.Keymap = km.String()
289 // Drop a keymap the input goroutine has not taken yet; the newest wins.
290 select {
291 case old := <-keymaps:
292 old.Close()
293 default:
294 }
295 select {
296 case keymaps <- km:
297 case <-ctx.Done():
298 km.Close()
299 return
300 }
301 }
302 }
303 if layoutChanged || repeatChanged {
304 select {
305 case commands <- cmd:
306 case <-ctx.Done():
307 return
308 }
309 if layoutChanged {
310 kb.Layout, kb.Variant, kb.Options = next.Layout, next.Variant, next.Options
311 }
312 kb.RepeatRate, kb.RepeatDelay = next.RepeatRate, next.RepeatDelay
313 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")
314 }
315 select {
316 case out <- ev:
317 case <-ctx.Done():
318 return
319 }
320 }
321}
322
323func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
324 log := logging.For(ctx, "core")
325 configLog := logging.For(ctx, "config")
326 for {
327 select {
328 case <-ctx.Done():
329 return
330 case set := <-scenes:
331 if tap != nil {
332 select {
333 case tap <- set:
334 default:
335 }
336 }
337 select {
338 case renderScenes <- set:
339 default:
340 select {
341 case <-renderScenes:
342 default:
343 }
344 select {
345 case renderScenes <- set:
346 default:
347 }
348 }
349 for _, s := range set {
350 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)
351 rects := make([]string, 0, len(s.Windows))
352 for _, w := range s.Windows {
353 rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
354 }
355 ev.Strs("windows", rects).Msg("scene")
356 }
357 case err := <-configErrors:
358 configLog.Warn().Err(err).Msg("config rejected")
359 }
360 }
361}
362
363// openXwayland reserves an X11 display for xwayland-satellite, or returns
364// nil when X11 is off or unavailable: the session runs without it.
365func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
366 bin := opts.Config.Xwayland
367 if bin == "" || opts.NoXwayland {
368 return nil
369 }
370 if !xwayland.Supported(ctx, bin) {
371 log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
372 return nil
373 }
374 d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
375 if err != nil {
376 log.Warn().Err(err).Msg("X11 display disabled")
377 return nil
378 }
379 log.Info().Str("DISPLAY", d.Name()).Msg("listening on X11 display")
380 return d
381}
382
383// renderNode is the /dev/dri path of a render node dev_t, "" when none.
384func renderNode(dev uint64) string {
385 if dev == 0 {
386 return ""
387 }
388 return fmt.Sprintf("/dev/dri/renderD%d", unix.Minor(dev))
389}