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