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