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
141func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
142 log := logging.For(ctx, "core")
143 configLog := logging.For(ctx, "config")
144 for {
145 select {
146 case <-ctx.Done():
147 return
148 case set := <-scenes:
149 if tap != nil {
150 select {
151 case tap <- set:
152 default:
153 }
154 }
155 latest(renderScenes, set) // this goroutine is the only sender
156 for _, s := range set {
157 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)
158 rects := make([]string, 0, len(s.Windows))
159 for _, w := range s.Windows {
160 rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
161 if w.FocusEffect > 0 {
162 // The focus indicator frame by frame: window and effect.
163 ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
164 }
165 }
166 ev.Strs("windows", rects).Msg("scene")
167 }
168 case err := <-configErrors:
169 configLog.Warn().Err(err).Msg("config rejected")
170 }
171 }
172}
173
174// openXwayland reserves an X11 display for xwayland-satellite, or returns
175// nil when X11 is off or unavailable: the session runs without it.
176func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
177 bin := opts.Config.Xwayland
178 if bin == "" || opts.NoXwayland {
179 return nil
180 }
181 if !xwayland.Supported(ctx, bin) {
182 log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
183 return nil
184 }
185 d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
186 if err != nil {
187 log.Warn().Err(err).Msg("X11 display disabled")
188 return nil
189 }
190 log.Info().Str("DISPLAY", d.Name()).Msg("listening on X11 display")
191 return d
192}
193
194// renderNode is the /dev/dri path of a render node dev_t, "" when none.
195func renderNode(dev uint64) string {
196 if dev == 0 {
197 return ""
198 }
199 return fmt.Sprintf("/dev/dri/renderD%d", unix.Minor(dev))
200}
201
202// latest sends v without blocking and replaces a value the consumer has not
203// read yet: the newest wins. Only one goroutine may send on ch; consumers only
204// receive, so the retry after draining cannot find the buffer full again.
205func latest[T any](ch chan T, v T) {
206 select {
207 case ch <- v:
208 default:
209 select {
210 case <-ch:
211 default:
212 }
213 select {
214 case ch <- v:
215 default:
216 }
217 }
218}