1package app
2
3import (
4 "bufio"
5 "context"
6 "errors"
7 "os"
8 "runtime"
9 "sync"
10 "time"
11
12 "github.com/bnema/neferwl/internal/adapters/captureallow"
13 "github.com/bnema/neferwl/internal/adapters/clock"
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/logging"
20 "github.com/bnema/neferwl/internal/adapters/powerkey"
21 "github.com/bnema/neferwl/internal/adapters/sched"
22 "github.com/bnema/neferwl/internal/adapters/screensaver"
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/watchdog"
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/ports"
33 "github.com/bnema/zerowrap"
34)
35
36// session holds the dependencies assembleSession acquired and wired; run
37// starts them. The session owns hw, curs and xdisplay (released by close)
38// until run ends; the keymap and the wayland server own themselves.
39type session struct {
40 opts Options
41 log zerowrap.Logger
42
43 clock ports.Clock
44 security *sessionsecurity.Gate
45 p *pipes
46 sizes [][2]int
47 path string
48
49 hw *drmBackend
50 km *xkb.Keymap
51 core *core.Core
52 dmabuf ports.DMABufSupport
53 wsIDs *workspaceid.IDs
54 captureAllow *captureallow.Store
55 server *wayland.Server
56 childEnv []string
57 statePath string
58 xdisplay *xwayland.Display
59 child *launcher.Launcher
60 curs *cursors
61
62 newRenderer func(w, h int) (ports.Renderer, error)
63}
64
65// assembleSession acquires and wires every dependency in startup order and
66// starts no long-lived worker.
67//
68// Invariant: wayland.New is the last fallible step. *wayland.Server has no
69// Close: its sockets are released only by Server.Run. Every later step
70// (statefile.Path, openXwayland, launcher.New, newCursors) degrades with a
71// warning or nil instead of failing. A failure therefore releases exactly
72// the DRM backend and the keymap (abort); nothing else is owned yet.
73func assembleSession(ctx context.Context, opts Options) (s *session, err error) {
74 s = &session{opts: opts, log: logging.For(ctx, "app"), clock: clock.System{}, security: &sessionsecurity.Gate{}}
75 defer func() {
76 if err != nil {
77 s.abort()
78 s = nil
79 }
80 }()
81 log := s.log
82 s.sizes = opts.Sizes
83 if len(s.sizes) == 0 {
84 s.sizes = [][2]int{{1920, 1080}}
85 }
86 if opts.Backend == "drm" {
87 if s.hw, err = openDRM(ctx, opts.Config); err != nil {
88 return s, err
89 }
90 }
91 s.p = newPipes(s.hw != nil)
92 // core() carries channels only; the gate, clock and terminal flag are
93 // explicit core.Options.
94 if s.core, err = core.New(opts.Config, s.p.core(), core.Options{Security: s.security, Clock: s.clock, Terminal: !opts.NoTerminal}); err != nil {
95 return s, err
96 }
97 if s.km, err = xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options}); err != nil {
98 return s, err
99 }
100 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
101 // Every output renders on the same GPU: its formats are the clients'.
102 s.dmabuf = vulkan.Probe()
103 log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf")
104 // One launch prefix for ext-workspace ids and the state file.
105 s.wsIDs = workspaceid.New()
106 // The path is fixed: a config key would let a program of the same user
107 // widen who may capture.
108 // Production: the fixed path, a root-owned file. Only tests set a path,
109 // and an owner for it.
110 captureAllowLog := logging.For(ctx, "captureallow")
111 if opts.captureAllowPath != "" {
112 s.captureAllow = captureallow.NewStoreOwnedBy(opts.captureAllowPath, opts.captureAllowOwner, captureAllowLog)
113 } else {
114 s.captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog)
115 }
116 if s.server, err = wayland.New(wayland.Options{
117 CaptureAllow: s.captureAllow,
118 WorkspaceIDs: s.wsIDs,
119 Security: s.security,
120 RuntimeDir: runtimeDir,
121 DMABuf: s.dmabuf,
122 SyncobjNode: renderNode(s.dmabuf.Device),
123 Keymap: s.km.String(),
124 RepeatRate: opts.Config.Keyboard.RepeatRate,
125 RepeatDelay: opts.Config.Keyboard.RepeatDelay,
126 LogDamage: logging.Enabled(ctx, "wayland-damage"),
127 }, s.p.wayland(), logging.For(ctx, "wayland")); err != nil {
128 return s, err
129 }
130 log.Info().Str("WAYLAND_DISPLAY", s.server.SocketName()).Msg("listening")
131 s.childEnv = launcher.ChildEnv(os.Environ(), s.server.SocketName(), runtimeDir, cursorSize())
132 statePath, serr := statefile.Path(runtimeDir, s.server.SocketName())
133 if serr != nil {
134 log.Warn().Err(serr).Msg("state file disabled")
135 } else {
136 s.statePath = statePath
137 s.childEnv = append(s.childEnv, statefile.Env+"="+statePath)
138 }
139 if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland")); s.xdisplay != nil {
140 s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name())
141 }
142 s.child = launcher.New(s.childEnv, s.security, logging.For(ctx, "launcher"))
143 s.curs = newCursors(s.clock, opts.Config.Cursor.HideAfter)
144 s.path = opts.ConfigPath
145 if s.path == "" {
146 s.path = config.DefaultPath()
147 }
148 renderLog := logging.For(ctx, "render")
149 traceDamage := logging.Enabled(ctx, "render-damage")
150 if s.hw != nil {
151 s.newRenderer = newVulkanRenderer(renderLog, false, false, traceDamage)
152 } else {
153 s.newRenderer = newVulkanRenderer(renderLog, true, opts.ScreenshotRaw, traceDamage)
154 }
155 return s, nil
156}
157
158// abort releases what assembly acquired before ownership passed on: the DRM
159// backend and the keymap. It runs only when assembly fails, before
160// anything else is owned.
161func (s *session) abort() {
162 if s.hw != nil {
163 s.hw.close()
164 }
165 if s.km != nil {
166 s.km.Close()
167 }
168}
169
170// close releases what the session owns after its workers have joined. The
171// keymap and the wayland server are not here: the input goroutine and
172// Server.Run own them.
173func (s *session) close() {
174 s.curs.stop()
175 if s.xdisplay != nil {
176 s.xdisplay.Close()
177 }
178 if s.hw != nil {
179 s.hw.close()
180 }
181}
182
183// newVulkanRenderer returns the concrete Vulkan renderer factory. Virtual
184// outputs have no display to list modifiers, so a headless renderer is told
185// it drives one: without it a real GPU refuses the HDR targets and the
186// output falls back to SDR. readback makes the HDR targets readable
187// (HDRPixels). traceDamage logs every frame's redrawn area. These settings
188// are Vulkan-only and not on ports.Renderer.
189func newVulkanRenderer(log zerowrap.Logger, virtual, readback, traceDamage bool) func(w, h int) (ports.Renderer, error) {
190 return func(w, h int) (ports.Renderer, error) {
191 r, err := vulkan.New(w, h)
192 if err != nil {
193 return nil, err
194 }
195 r.SetLogger(log)
196 r.SetTraceDamage(traceDamage)
197 log.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue")
198 r.SetVirtualOutput(virtual)
199 if readback {
200 r.SetHDRReadback(true)
201 }
202 return r, nil
203 }
204}
205
206// run starts the workers and waits for the session to end; it releases the
207// session's resources after every worker has joined.
208func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent)) error {
209 defer s.close()
210 ctx, cancel := context.WithCancel(ctx)
211 defer cancel()
212 opts, log, p, hw, km, security := s.opts, s.log, s.p, s.hw, s.km, s.security
213 childEnv, curs, xdisplay, statePath := s.childEnv, s.curs, s.xdisplay, s.statePath
214 server, captureAllow, c, child, wsIDs, dmabuf, sizes, path := s.server, s.captureAllow, s.core, s.child, s.wsIDs, s.dmabuf, s.sizes, s.path
215 if opts.Session {
216 launcher.ExportSession(ctx, childEnv, logging.For(ctx, "launcher"))
217 launcher.NotifyReady(os.Getenv("NOTIFY_SOCKET"), logging.For(ctx, "launcher"))
218 defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher"))
219 }
220 if inject != nil {
221 inject(p.input)
222 }
223 var workers sync.WaitGroup
224 workers.Add(8)
225 done := make(chan error, 8)
226 go func() {
227 defer workers.Done()
228 done <- config.Watch(ctx, path, p.watched, logging.For(ctx, "config"))
229 }()
230 if p.scales != nil {
231 workers.Add(1)
232 go func() {
233 defer workers.Done()
234 core.PersistScales(ctx, p.scales, config.ScaleStore{Path: path}, launcher.NewNotifier(ctx, childEnv, logging.For(ctx, "launcher")), s.clock, time.Second)
235 }()
236 }
237 workers.Add(1)
238 go func() {
239 defer workers.Done()
240 relayConfig(ctx, opts.Config, p.watched, p.filtered, p.keymaps, p.deviceConfigs, curs, p.commands, logging.For(ctx, "config"))
241 }()
242 script := make(chan string)
243 go func() {
244 defer workers.Done()
245 if hw != nil {
246 // Core sends the first layout once DRM reports the outputs.
247 var layout ports.Layout
248 select {
249 case layout = <-p.layouts:
250 case <-ctx.Done():
251 km.Close()
252 return
253 }
254 done <- safe("input", func() error {
255 if opts.Config.Performance.Realtime {
256 runtime.LockOSThread()
257 // Let this dedicated thread exit with its scheduling policy.
258 schedLog := logging.For(ctx, "sched")
259 if err := sched.Realtime(schedLog); err != nil {
260 schedLog.Warn().Err(err).Msg("input scheduling")
261 }
262 }
263 return libinput.Run(ctx, libinput.Options{
264 Security: security,
265 Seat: hw.seat,
266 SeatName: hw.seat.Name(),
267 Keymap: km,
268 Keymaps: p.keymaps,
269 Layout: layout,
270 Layouts: p.layouts,
271 Constraints: p.constraints,
272 DeviceConfig: opts.Config.InputDevicesConfig,
273 DeviceConfigs: p.deviceConfigs,
274 Active: hw.seat.Subscribe(),
275 MoveCursor: curs.move,
276 Log: logging.For(ctx, "input"),
277 LogMotion: logging.Enabled(ctx, "input-motion"),
278 LogKeys: logging.Enabled(ctx, "input-keys"),
279 }, p.input)
280 })
281 return
282 }
283 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) {
284 select {
285 case done <- err:
286 case <-ctx.Done():
287 }
288 }
289 }()
290 if opts.Script != nil {
291 go func() { <-ctx.Done(); _ = opts.Script.Close() }()
292 go func() {
293 defer close(script)
294 scanner := bufio.NewScanner(opts.Script)
295 for scanner.Scan() {
296 select {
297 case <-ctx.Done():
298 return
299 case script <- scanner.Text():
300 }
301 }
302 if err := scanner.Err(); err != nil {
303 log.Warn().Err(err).Msg("reading input script")
304 }
305 }()
306 } else {
307 close(script)
308 }
309 go func() { defer workers.Done(); done <- server.Run(ctx) }()
310 if dir := logging.Dir(ctx); dir != "" {
311 workers.Add(1)
312 go func() {
313 defer workers.Done()
314 watchdog.Run(ctx, func() []watchdog.Probe {
315 probes := []watchdog.Probe{
316 {Name: "wayland", Ping: func() { server.Ping(ctx) }},
317 {Name: "core", Ping: func() { c.Ping(ctx) }},
318 }
319 if hw != nil {
320 probes = append(probes, hw.watchdogProbes()...)
321 }
322 return probes
323 }, watchdog.Options{Dir: dir, Every: time.Second, Timeout: 3 * time.Second, Log: logging.For(ctx, "app")})
324 }()
325 }
326 go func() {
327 defer workers.Done()
328 // Without a watch the allowlist stays as loaded: restart to apply.
329 if err := captureAllow.Run(ctx); err != nil {
330 log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes")
331 }
332 }()
333 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()
334 go func() { defer workers.Done(); done <- c.Run(ctx) }()
335 if xdisplay != nil {
336 workers.Add(1)
337 // X11 failing leaves the Wayland session running; Run logs why.
338 go func() { defer workers.Done(); _ = xdisplay.Run(ctx) }()
339 }
340 if statePath != "" {
341 workers.Add(1)
342 go func() {
343 defer workers.Done()
344 if err := statefile.Run(ctx, statePath, p.states, wsIDs, logging.For(ctx, "statefile")); err != nil {
345 // Scripts lose their state; the session goes on.
346 log.Warn().Err(err).Msg("state file disabled")
347 }
348 }()
349 }
350 go func() {
351 defer workers.Done()
352 consumeScenes(ctx, p.scenes, p.configErrors, opts.testScenes, p.renderScenes)
353 }()
354
355 go func() {
356 defer workers.Done()
357 outputIO := p.outputs(security, server.Presentation(), server)
358 apply := newOutputApply(core.NewOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
359 if hw != nil {
360 done <- safe("output", func() error {
361 want := func(cfg ports.Config) drm.Want {
362 w := wantFromConfig(cfg)
363 w.Sampled = dmabuf.Formats
364 w.TraceFlips = logging.Enabled(ctx, "drm-flip")
365 return w
366 }
367 return hw.runOutputs(ctx, want, opts.Config, apply, outputIO, curs, s.newRenderer, logging.For(ctx, "drm"))
368 })
369 return
370 }
371 done <- runHeadless(ctx, headlessOptions{sizes: sizes, shots: opts.ScreenshotDir, hdr: opts.HeadlessHDR, raw: opts.ScreenshotRaw}, apply, outputIO, curs, s.newRenderer, logging.For(ctx, "render"))
372 }()
373
374 if hw != nil {
375 workers.Add(1)
376 go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()
377 // Only a real session serves the session bus: headless runs would take
378 // the name from the desktop they run in.
379 workers.Add(1)
380 go func() {
381 defer workers.Done()
382 reports := screensaver.Reports{Held: p.idleInhibited, Activity: p.idleActivity}
383 screensaver.Serve(ctx, "", reports, busRetry, logging.For(ctx, "screensaver"))
384 }()
385 // The same goes for the system bus: logind's locks keep the power
386 // keys from acting before a bind sees them.
387 workers.Add(1)
388 go func() {
389 defer workers.Done()
390 powerkey.Serve(ctx, "", busRetry, logging.For(ctx, "powerkey"))
391 }()
392 }
393 var result error
394 select {
395 case <-ctx.Done():
396 if errors.Is(context.Cause(ctx), errTimeout) {
397 log.Info().Str("reason", "timeout").Dur("after", opts.Timeout).Msg("stopping")
398 }
399 case result = <-done:
400 log.Info().AnErr("cause", result).Msg("stopping")
401 }
402 cancel()
403 workers.Wait()
404 // The wayland server has stopped: no request can be queued any more.
405 drainCaptures(ctx, p.captures, nil)
406 // A keymap the input goroutine never took is still ours to free.
407 select {
408 case km := <-p.keymaps:
409 km.Close()
410 default:
411 }
412 for len(done) > 0 {
413 err := <-done
414 if result == nil && err != nil {
415 result = err
416 }
417 }
418 if errors.Is(result, core.ErrQuit) || errors.Is(result, libinput.ErrEmergencyQuit) {
419 return nil
420 }
421 return result
422}