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// screensaverRetry is the first delay before the screensaver service tries
69// the session bus again: without one, only surface inhibitors count, and
70// the session goes on either way.
71const screensaverRetry = 2 * time.Second
72
73// relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
74// to the input goroutine and sends it to clients; a repeat-only change just updates
75// clients. A keymap that fails to build keeps the previous layout. A touchpad
76// or mouse change goes to the input goroutine as one InputDevicesConfig; only
77// the newest one waits there. The cursor idle delay goes straight to the
78// cursor router.
79func 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) {
80 kb := cur.Keyboard
81 devCfg := cur.InputDevicesConfig
82 for {
83 var ev ports.ConfigChanged
84 select {
85 case <-ctx.Done():
86 return
87 case ev = <-in:
88 }
89 next := ev.Config.Keyboard
90 layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options
91 repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay
92 cmd := ports.SetKeymap{RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}
93 if layoutChanged {
94 km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options})
95 if err != nil {
96 log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous layout")
97 ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options
98 layoutChanged = false
99 } else {
100 cmd.Keymap = km.String()
101 // Drop a keymap the input goroutine has not taken yet; the newest wins.
102 select {
103 case old := <-keymaps:
104 old.Close()
105 default:
106 }
107 select {
108 case keymaps <- km:
109 case <-ctx.Done():
110 km.Close()
111 return
112 }
113 }
114 }
115 curs.setHideAfter(ev.Config.Cursor.HideAfter)
116 if ev.Config.InputDevicesConfig != devCfg {
117 devCfg = ev.Config.InputDevicesConfig
118 latest(deviceConfigs, devCfg) // this goroutine is the only sender
119 }
120 if layoutChanged || repeatChanged {
121 select {
122 case commands <- cmd:
123 case <-ctx.Done():
124 return
125 }
126 if layoutChanged {
127 kb.Layout, kb.Variant, kb.Options = next.Layout, next.Variant, next.Options
128 }
129 kb.RepeatRate, kb.RepeatDelay = next.RepeatRate, next.RepeatDelay
130 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")
131 }
132 select {
133 case out <- ev:
134 case <-ctx.Done():
135 return
136 }
137 }
138}
139
140// relayPresented passes output reports to wayland in order. Each flip is
141// also a frame for core; a frame core has not read yet is enough, so
142// frames never wait.
143func relayPresented(ctx context.Context, in <-chan ports.OutputPresented, out chan<- ports.OutputPresented, frames chan<- ports.OutputFrame) {
144 for {
145 var p ports.OutputPresented
146 select {
147 case <-ctx.Done():
148 return
149 case p = <-in:
150 }
151 if p.Flip != nil {
152 select {
153 case frames <- ports.OutputFrame{Output: p.Output}:
154 default:
155 }
156 }
157 select {
158 case out <- p:
159 case <-ctx.Done():
160 return
161 }
162 }
163}
164
165func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
166 log := logging.For(ctx, "core")
167 configLog := logging.For(ctx, "config")
168 for {
169 select {
170 case <-ctx.Done():
171 return
172 case set := <-scenes:
173 if tap != nil {
174 select {
175 case tap <- set:
176 default:
177 }
178 }
179 latest(renderScenes, set) // this goroutine is the only sender
180 for _, s := range set {
181 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)
182 rects := make([]string, 0, len(s.Windows))
183 for _, w := range s.Windows {
184 rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
185 if w.FocusEffect > 0 {
186 // The focus indicator frame by frame: window and effect.
187 ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
188 }
189 }
190 ev.Strs("windows", rects).Msg("scene")
191 }
192 case err := <-configErrors:
193 configLog.Warn().Err(err).Msg("config rejected")
194 }
195 }
196}
197
198// openXwayland reserves an X11 display for xwayland-satellite, or returns
199// nil when X11 is off or unavailable: the session runs without it.
200func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
201 bin := opts.Config.Xwayland
202 if bin == "" || opts.NoXwayland {
203 return nil
204 }
205 if !xwayland.Supported(ctx, bin) {
206 log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
207 return nil
208 }
209 d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
210 if err != nil {
211 log.Warn().Err(err).Msg("X11 display disabled")
212 return nil
213 }
214 log.Info().Str("DISPLAY", d.Name()).Msg("listening on X11 display")
215 return d
216}
217
218// renderNode is the /dev/dri path of a render node dev_t, "" when none.
219func renderNode(dev uint64) string {
220 if dev == 0 {
221 return ""
222 }
223 return fmt.Sprintf("/dev/dri/renderD%d", unix.Minor(dev))
224}
225
226// latest sends v without blocking and replaces a value the consumer has not
227// read yet: the newest wins. Only one goroutine may send on ch; consumers only
228// receive, so the retry after draining cannot find the buffer full again.
229func latest[T any](ch chan T, v T) {
230 select {
231 case ch <- v:
232 default:
233 select {
234 case <-ch:
235 default:
236 }
237 select {
238 case ch <- v:
239 default:
240 }
241 }
242}