1// Package app coordinates the application lifecycle.
2package app
3
4import (
5 "context"
6 "fmt"
7 "io"
8 "time"
9
10 "github.com/bnema/neferwl/internal/adapters/logging"
11 "github.com/bnema/neferwl/internal/adapters/xkb"
12 "github.com/bnema/neferwl/internal/adapters/xwayland"
13 "github.com/bnema/neferwl/internal/ports"
14 "github.com/bnema/zerowrap"
15 "golang.org/x/sys/unix"
16)
17
18type Options struct {
19 Backend string
20 Config ports.Config
21 ConfigPath string
22 Timeout time.Duration
23 NoTerminal bool
24 // NoXwayland skips the X11 display even when the config enables it.
25 NoXwayland bool
26 // HeadlessHDR enables a virtual HDR output for protocol testing only.
27 HeadlessHDR bool
28 // Session exports the displays to D-Bus and systemd user services
29 // before any client starts, and withdraws them on exit.
30 Session bool
31 ScreenshotDir string
32 // ScreenshotRaw also writes latest-pq.png, the raw PQ codes of a
33 // virtual HDR output (test-only; needs ScreenshotDir and HeadlessHDR).
34 ScreenshotRaw bool
35 // Sizes are the headless outputs (width, height), left to right; empty
36 // means one 1920x1080 output. With several outputs, screenshots go to
37 // one subdirectory per output (HEADLESS-1, HEADLESS-2, ...).
38 Sizes [][2]int
39 // Run closes Script on shutdown; the reader goroutine exits after Close.
40 Script io.ReadCloser
41 testScenes chan<- []ports.Scene
42 // captureAllowPath replaces the fixed allowlist path (tests).
43 captureAllowPath string
44 // captureAllowOwner is the uid that must own that file (tests use their
45 // own; root, 0, otherwise).
46 captureAllowOwner uint32
47}
48
49func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
50
51func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error {
52 log := logging.For(ctx, "app")
53 log.Info().Str("backend", opts.Backend).Msg("starting neferwl")
54 var cancel context.CancelFunc
55 if opts.Timeout > 0 {
56 ctx, cancel = context.WithTimeoutCause(ctx, opts.Timeout, errTimeout)
57 } else {
58 ctx, cancel = context.WithCancel(ctx)
59 }
60 defer cancel()
61 s, err := assembleSession(ctx, opts)
62 if err != nil {
63 return err
64 }
65 return s.run(ctx, inject)
66}
67
68// busRetry is the first delay before a bus service (the screensaver, the
69// logind key lock) tries its bus again: without it, only surface inhibitors
70// count and the power keys keep logind's behaviour, and the session goes on
71// either way.
72const busRetry = 2 * time.Second
73
74// relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
75// to the input goroutine and sends it to clients; a repeat-only change just updates
76// clients. A keymap that fails to build keeps the previous layout. A touchpad
77// or mouse change goes to the input goroutine as one InputDevicesConfig; only
78// the newest one waits there. The cursor idle delay goes straight to the
79// cursor router.
80func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, deviceConfigs chan ports.InputDevicesConfig, curs *cursors, commands chan<- ports.ClientCommand, log zerowrap.Logger) {
81 kb := cur.Keyboard
82 devCfg := cur.InputDevicesConfig
83 for {
84 var ev ports.ConfigChanged
85 select {
86 case <-ctx.Done():
87 return
88 case ev = <-in:
89 }
90 next := ev.Config.Keyboard
91 layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options
92 repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay
93 cmd := ports.SetKeymap{RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}
94 if layoutChanged {
95 km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options})
96 if err != nil {
97 log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous layout")
98 ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options
99 layoutChanged = false
100 } else {
101 cmd.Keymap = km.String()
102 // Drop a keymap the input goroutine has not taken yet; the newest wins.
103 select {
104 case old := <-keymaps:
105 old.Close()
106 default:
107 }
108 select {
109 case keymaps <- km:
110 case <-ctx.Done():
111 km.Close()
112 return
113 }
114 }
115 }
116 curs.setHideAfter(ev.Config.Cursor.HideAfter)
117 if ev.Config.InputDevicesConfig != devCfg {
118 devCfg = ev.Config.InputDevicesConfig
119 latest(deviceConfigs, devCfg) // this goroutine is the only sender
120 }
121 if layoutChanged || repeatChanged {
122 select {
123 case commands <- cmd:
124 case <-ctx.Done():
125 return
126 }
127 if layoutChanged {
128 kb.Layout, kb.Variant, kb.Options = next.Layout, next.Variant, next.Options
129 }
130 kb.RepeatRate, kb.RepeatDelay = next.RepeatRate, next.RepeatDelay
131 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")
132 }
133 select {
134 case out <- ev:
135 case <-ctx.Done():
136 return
137 }
138 }
139}
140
141// relayPresented passes output reports to wayland in order. Each flip is
142// also a frame for core; a frame core has not read yet is enough, so
143// frames never wait.
144func relayPresented(ctx context.Context, in <-chan ports.OutputPresented, out chan<- ports.OutputPresented, frames chan<- ports.OutputFrame) {
145 for {
146 var p ports.OutputPresented
147 select {
148 case <-ctx.Done():
149 return
150 case p = <-in:
151 }
152 if p.Flip != nil {
153 select {
154 case frames <- ports.OutputFrame{Output: p.Output}:
155 default:
156 }
157 }
158 select {
159 case out <- p:
160 case <-ctx.Done():
161 return
162 }
163 }
164}
165
166func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
167 log := logging.For(ctx, "core")
168 configLog := logging.For(ctx, "config")
169 for {
170 select {
171 case <-ctx.Done():
172 return
173 case set := <-scenes:
174 if tap != nil {
175 select {
176 case tap <- set:
177 default:
178 }
179 }
180 latest(renderScenes, set) // this goroutine is the only sender
181 for _, s := range set {
182 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)
183 rects := make([]string, 0, len(s.Windows))
184 for _, w := range s.Windows {
185 rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
186 if w.FocusEffect > 0 {
187 // The focus indicator frame by frame: window and effect.
188 ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
189 }
190 }
191 ev.Strs("windows", rects).Msg("scene")
192 }
193 case err := <-configErrors:
194 configLog.Warn().Err(err).Msg("config rejected")
195 }
196 }
197}
198
199// openXwayland reserves an X11 display for xwayland-satellite, or returns
200// nil when X11 is off or unavailable: the session runs without it.
201func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
202 bin := opts.Config.Xwayland
203 if bin == "" || opts.NoXwayland {
204 return nil
205 }
206 if !xwayland.Supported(ctx, bin) {
207 log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
208 return nil
209 }
210 d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
211 if err != nil {
212 log.Warn().Err(err).Msg("X11 display disabled")
213 return nil
214 }
215 log.Info().Str("DISPLAY", d.Name()).Msg("listening on X11 display")
216 return d
217}
218
219// renderNode is the /dev/dri path of a render node dev_t, "" when none.
220func renderNode(dev uint64) string {
221 if dev == 0 {
222 return ""
223 }
224 return fmt.Sprintf("/dev/dri/renderD%d", unix.Minor(dev))
225}
226
227// latest sends v without blocking and replaces a value the consumer has not
228// read yet: the newest wins. Only one goroutine may send on ch; consumers only
229// receive, so the retry after draining cannot find the buffer full again.
230func latest[T any](ch chan T, v T) {
231 select {
232 case ch <- v:
233 default:
234 select {
235 case <-ch:
236 default:
237 }
238 select {
239 case ch <- v:
240 default:
241 }
242 }
243}