detached dadad45

merge: xwayland (on-demand xwayland-satellite)

commit
dadad4562a9f77fb02cf6c2c8b0f5a7e59c61aa9
author
brice · · 2026-09-25 13:02 UTC
merge of
5ebaa37 + 8540222 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +708 -14
M README.md +12 -1
@@ -61,7 +61,7 @@ bind.cmd+shift+s = spawn screenshot-area
6161
6262 Make it executable (`chmod +x`) and put it in a directory of your `PATH`, such as `~/.local/bin`. The script can use any shell, including fish (`#!/usr/bin/env fish`).
6363
64-Programs started by NeferTTY get `WAYLAND_DISPLAY` and `NEFERTTY_STATE` (see below). `startup` commands run once when the session starts; editing them takes effect at the next start.
64+Programs started by NeferTTY get `WAYLAND_DISPLAY`, `DISPLAY` (see [X11 apps](#x11-apps)) and `NEFERTTY_STATE` (see below). `startup` commands run once when the session starts; editing them takes effect at the next start.
6565
6666 ## Clipboard
6767
@@ -81,6 +81,17 @@ cliphist list | fuzzel --dmenu | cliphist decode | wl-copy
8181
8282 Drag and drop is not supported yet.
8383
84+## X11 apps
85+
86+X11 apps such as Steam and Wine run through [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7 or later, found in `PATH`. NeferTTY opens an X11 display, sets `DISPLAY` for the programs it starts, and runs xwayland-satellite when the first X11 app connects. If xwayland-satellite exits, the next X11 app starts it again.
87+
88+```text
89+xwayland = xwayland-satellite # the default; a path also works
90+xwayland = off # no X11 display
91+```
92+
93+Do not start xwayland-satellite yourself with `startup`. Changing `xwayland` takes effect at the next start.
94+
8495 ## State for scripts
8596
8697 While it runs, NeferTTY keeps its state in `$XDG_RUNTIME_DIR/nefertty/<wayland socket>.json` and passes that path to the programs it starts as `NEFERTTY_STATE`. The file lists every output (active numbered workspace, workspace count, name of the workspace on screen), the focused output and window, and every window with its app ID, PID, output and workspace.
@@ -61,7 +61,7 @@ bind.cmd+shift+s = spawn screenshot-area
61 61
62 Make it executable (`chmod +x`) and put it in a directory of your `PATH`, such as `~/.local/bin`. The script can use any shell, including fish (`#!/usr/bin/env fish`).62 Make it executable (`chmod +x`) and put it in a directory of your `PATH`, such as `~/.local/bin`. The script can use any shell, including fish (`#!/usr/bin/env fish`).
63 63
64-Programs started by NeferTTY get `WAYLAND_DISPLAY` and `NEFERTTY_STATE` (see below). `startup` commands run once when the session starts; editing them takes effect at the next start.64+Programs started by NeferTTY get `WAYLAND_DISPLAY`, `DISPLAY` (see [X11 apps](#x11-apps)) and `NEFERTTY_STATE` (see below). `startup` commands run once when the session starts; editing them takes effect at the next start.
65 65
66 ## Clipboard66 ## Clipboard
67 67
@@ -81,6 +81,17 @@ cliphist list | fuzzel --dmenu | cliphist decode | wl-copy
81 81
82 Drag and drop is not supported yet.82 Drag and drop is not supported yet.
83 83
84+## X11 apps
85+
86+X11 apps such as Steam and Wine run through [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7 or later, found in `PATH`. NeferTTY opens an X11 display, sets `DISPLAY` for the programs it starts, and runs xwayland-satellite when the first X11 app connects. If xwayland-satellite exits, the next X11 app starts it again.
87+
88+```text
89+xwayland = xwayland-satellite # the default; a path also works
90+xwayland = off # no X11 display
91+```
92+
93+Do not start xwayland-satellite yourself with `startup`. Changing `xwayland` takes effect at the next start.
94+
84 ## State for scripts95 ## State for scripts
85 96
86 While it runs, NeferTTY keeps its state in `$XDG_RUNTIME_DIR/nefertty/<wayland socket>.json` and passes that path to the programs it starts as `NEFERTTY_STATE`. The file lists every output (active numbered workspace, workspace count, name of the workspace on screen), the focused output and window, and every window with its app ID, PID, output and workspace.97 While it runs, NeferTTY keeps its state in `$XDG_RUNTIME_DIR/nefertty/<wayland socket>.json` and passes that path to the programs it starts as `NEFERTTY_STATE`. The file lists every output (active numbered workspace, workspace count, name of the workspace on screen), the focused output and window, and every window with its app ID, PID, output and workspace.
M cmd/nefertty/main.go +2 -1
@@ -112,6 +112,7 @@ func run() error {
112112 size := flags.String("size", "1920x1080", "headless output sizes WxH, comma-separated for several outputs")
113113 inputPath := flags.String("input", "", "headless input script path (- for stdin)")
114114 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
115+ noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps")
115116 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
116117 debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")
117118 configFlag := flags.String("config", "", "config path (empty uses XDG default)")
@@ -220,7 +221,7 @@ func run() error {
220221 for _, w := range warnings {
221222 configLog.Warn().Int("line", w.Line).Msg(w.Msg)
222223 }
223- err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Sizes: sizes, Script: script})
224+ err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, ScreenshotDir: *screenshot, Sizes: sizes, Script: script})
224225 // SIGINT and SIGTERM cancel the context and are clean exits.
225226 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
226227 err = nil
@@ -112,6 +112,7 @@ func run() error {
112 size := flags.String("size", "1920x1080", "headless output sizes WxH, comma-separated for several outputs")112 size := flags.String("size", "1920x1080", "headless output sizes WxH, comma-separated for several outputs")
113 inputPath := flags.String("input", "", "headless input script path (- for stdin)")113 inputPath := flags.String("input", "", "headless input script path (- for stdin)")
114 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")114 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
115+ noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps")
115 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")116 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
116 debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")117 debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")
117 configFlag := flags.String("config", "", "config path (empty uses XDG default)")118 configFlag := flags.String("config", "", "config path (empty uses XDG default)")
@@ -220,7 +221,7 @@ func run() error {
220 for _, w := range warnings {221 for _, w := range warnings {
221 configLog.Warn().Int("line", w.Line).Msg(w.Msg)222 configLog.Warn().Int("line", w.Line).Msg(w.Msg)
222 }223 }
223- err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Sizes: sizes, Script: script})224+ err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, ScreenshotDir: *screenshot, Sizes: sizes, Script: script})
224 // SIGINT and SIGTERM cancel the context and are clean exits.225 // SIGINT and SIGTERM cancel the context and are clean exits.
225 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {226 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
226 err = nil227 err = nil
M internal/adapters/config/config.go +10 -0
@@ -78,6 +78,7 @@ func Defaults() ports.Config {
7878 c.Keyboard.RepeatDelay = 600
7979 c.Keyboard.CmdKey = "super"
8080 c.Terminal.Command = []string{"foot"}
81+ c.Xwayland = "xwayland-satellite"
8182 c.Background.Color = "#111111"
8283 c.Border.Width = 2
8384 c.Border.Active = "#b4befe"
@@ -388,6 +389,15 @@ func set(c *ports.Config, key, v string) error {
388389 return fmt.Errorf("must not be empty")
389390 }
390391 c.Terminal.Command = argv
392+ case "xwayland":
393+ switch {
394+ case v == "off":
395+ c.Xwayland = ""
396+ case v == "" || strings.ContainsAny(v, " \t"):
397+ return fmt.Errorf("must be off or the xwayland-satellite program")
398+ default:
399+ c.Xwayland = v
400+ }
391401 case "background":
392402 if !color.MatchString(v) {
393403 return fmt.Errorf("must be #rrggbb")
@@ -78,6 +78,7 @@ func Defaults() ports.Config {
78 c.Keyboard.RepeatDelay = 60078 c.Keyboard.RepeatDelay = 600
79 c.Keyboard.CmdKey = "super"79 c.Keyboard.CmdKey = "super"
80 c.Terminal.Command = []string{"foot"}80 c.Terminal.Command = []string{"foot"}
81+ c.Xwayland = "xwayland-satellite"
81 c.Background.Color = "#111111"82 c.Background.Color = "#111111"
82 c.Border.Width = 283 c.Border.Width = 2
83 c.Border.Active = "#b4befe"84 c.Border.Active = "#b4befe"
@@ -388,6 +389,15 @@ func set(c *ports.Config, key, v string) error {
388 return fmt.Errorf("must not be empty")389 return fmt.Errorf("must not be empty")
389 }390 }
390 c.Terminal.Command = argv391 c.Terminal.Command = argv
392+ case "xwayland":
393+ switch {
394+ case v == "off":
395+ c.Xwayland = ""
396+ case v == "" || strings.ContainsAny(v, " \t"):
397+ return fmt.Errorf("must be off or the xwayland-satellite program")
398+ default:
399+ c.Xwayland = v
400+ }
391 case "background":401 case "background":
392 if !color.MatchString(v) {402 if !color.MatchString(v) {
393 return fmt.Errorf("must be #rrggbb")403 return fmt.Errorf("must be #rrggbb")
M internal/adapters/config/config_test.go +13 -0
@@ -91,6 +91,7 @@ func TestWarningsKeepDefaults(t *testing.T) {
9191 {"keyboard.repeat-rate = 0", "keyboard.repeat-rate"},
9292 {"keyboard.cmd = meta", "keyboard.cmd"},
9393 {"terminal =", "terminal"},
94+ {"xwayland = a b", "xwayland"},
9495 {"background = red", "background"},
9596 {"layout.gaps = 201", "layout.gaps"},
9697 {"layout.max-columns = 0", "layout.max-columns"},
@@ -124,6 +125,18 @@ func TestWarningsKeepDefaults(t *testing.T) {
124125 }
125126 }
126127
128+func TestXwayland(t *testing.T) {
129+ if Defaults().Xwayland != "xwayland-satellite" {
130+ t.Fatal(Defaults().Xwayland)
131+ }
132+ if c, w := parseString(t, "xwayland = off"); c.Xwayland != "" || len(w) != 0 {
133+ t.Fatal(c.Xwayland, w)
134+ }
135+ if c, w := parseString(t, "xwayland = /opt/xwls"); c.Xwayland != "/opt/xwls" || len(w) != 0 {
136+ t.Fatal(c.Xwayland, w)
137+ }
138+}
139+
127140 func TestDuplicateKeyLastWins(t *testing.T) {
128141 c, w := parseString(t, "background = #000000\nbackground = #111111\n")
129142 if c.Background.Color != "#111111" || len(w) != 1 || w[0].Line != 2 {
@@ -91,6 +91,7 @@ func TestWarningsKeepDefaults(t *testing.T) {
91 {"keyboard.repeat-rate = 0", "keyboard.repeat-rate"},91 {"keyboard.repeat-rate = 0", "keyboard.repeat-rate"},
92 {"keyboard.cmd = meta", "keyboard.cmd"},92 {"keyboard.cmd = meta", "keyboard.cmd"},
93 {"terminal =", "terminal"},93 {"terminal =", "terminal"},
94+ {"xwayland = a b", "xwayland"},
94 {"background = red", "background"},95 {"background = red", "background"},
95 {"layout.gaps = 201", "layout.gaps"},96 {"layout.gaps = 201", "layout.gaps"},
96 {"layout.max-columns = 0", "layout.max-columns"},97 {"layout.max-columns = 0", "layout.max-columns"},
@@ -124,6 +125,18 @@ func TestWarningsKeepDefaults(t *testing.T) {
124 }125 }
125 }126 }
126 127
128+func TestXwayland(t *testing.T) {
129+ if Defaults().Xwayland != "xwayland-satellite" {
130+ t.Fatal(Defaults().Xwayland)
131+ }
132+ if c, w := parseString(t, "xwayland = off"); c.Xwayland != "" || len(w) != 0 {
133+ t.Fatal(c.Xwayland, w)
134+ }
135+ if c, w := parseString(t, "xwayland = /opt/xwls"); c.Xwayland != "/opt/xwls" || len(w) != 0 {
136+ t.Fatal(c.Xwayland, w)
137+ }
138+}
139+
127 func TestDuplicateKeyLastWins(t *testing.T) {140 func TestDuplicateKeyLastWins(t *testing.T) {
128 c, w := parseString(t, "background = #000000\nbackground = #111111\n")141 c, w := parseString(t, "background = #000000\nbackground = #111111\n")
129 if c.Background.Color != "#111111" || len(w) != 1 || w[0].Line != 2 {142 if c.Background.Color != "#111111" || len(w) != 1 || w[0].Line != 2 {
A internal/adapters/xwayland/xwayland.go +356 -0
@@ -0,0 +1,356 @@
1+// Package xwayland runs X11 clients through xwayland-satellite, started on
2+// demand: nefertty owns the X11 display sockets and starts the satellite
3+// when the first X11 client connects, and again after it exits.
4+package xwayland
5+
6+import (
7+ "context"
8+ "errors"
9+ "fmt"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "slices"
14+ "strconv"
15+ "strings"
16+ "sync"
17+ "syscall"
18+ "time"
19+
20+ "github.com/bnema/zerowrap"
21+ "golang.org/x/sys/unix"
22+)
23+
24+// Options configures the X11 display.
25+type Options struct {
26+ // Binary is the xwayland-satellite executable.
27+ Binary string
28+ // Env is the satellite's environment; it must hold WAYLAND_DISPLAY.
29+ Env []string
30+ // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix
31+ // and /tmp outside tests.
32+ Dir, TmpDir string
33+ // Abstract also listens on the abstract socket, as X servers do.
34+ Abstract bool
35+ Log zerowrap.Logger
36+}
37+
38+// Display is an X11 display served by xwayland-satellite.
39+type Display struct {
40+ opts Options
41+ n int
42+ sockets []*os.File // unix socket, then the abstract one
43+ socket string
44+ lock string
45+}
46+
47+// Supported reports whether the binary can take over listening sockets
48+// (xwayland-satellite 0.7 and later). A binary that hangs is unsupported.
49+func Supported(ctx context.Context, binary string) bool {
50+ ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
51+ defer cancel()
52+ cmd := exec.CommandContext(ctx, binary, ":0", "--test-listenfd-support")
53+ cmd.Env = []string{}
54+ return cmd.Run() == nil
55+}
56+
57+// Open reserves the first free display number and listens on its sockets.
58+func Open(opts Options) (*Display, error) {
59+ if err := ensureDir(opts.Dir, opts.TmpDir); err != nil {
60+ return nil, err
61+ }
62+ var last error
63+ for n := 0; n < 50; n++ {
64+ lock := filepath.Join(opts.TmpDir, fmt.Sprintf(".X%d-lock", n))
65+ fd, err := unix.Open(lock, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC, 0o444)
66+ if errors.Is(err, unix.EEXIST) && staleLock(lock) {
67+ // A crashed session left it: take the display over.
68+ opts.Log.Info().Str("lock", lock).Msg("removing stale X11 lock")
69+ _ = os.Remove(lock)
70+ fd, err = unix.Open(lock, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC, 0o444)
71+ }
72+ if err != nil {
73+ if !errors.Is(err, unix.EEXIST) {
74+ last = fmt.Errorf("create %s: %w", lock, err)
75+ }
76+ continue
77+ }
78+ _, err = unix.Write(fd, fmt.Appendf(nil, "%10d\n", os.Getpid()))
79+ unix.Close(fd)
80+ if err != nil {
81+ _ = os.Remove(lock)
82+ return nil, fmt.Errorf("write X11 lock: %w", err)
83+ }
84+ d := &Display{opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}
85+ if err := d.listen(); err != nil {
86+ last = err
87+ d.Close()
88+ continue
89+ }
90+ return d, nil
91+ }
92+ if last != nil {
93+ return nil, fmt.Errorf("no free X11 display: %w", last)
94+ }
95+ return nil, errors.New("no free X11 display")
96+}
97+
98+// staleLock reports whether a lock file names a process that is gone.
99+func staleLock(path string) bool {
100+ b, err := os.ReadFile(path)
101+ if err != nil {
102+ return false
103+ }
104+ pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
105+ if err != nil || pid <= 0 {
106+ return false
107+ }
108+ return errors.Is(unix.Kill(pid, 0), unix.ESRCH)
109+}
110+
111+// ensureDir creates the X11 socket directory, or checks an existing one is
112+// shared and sticky like /tmp.
113+func ensureDir(dir, tmp string) error {
114+ err := unix.Mkdir(dir, 0o1777)
115+ if err == nil {
116+ // The umask may have dropped bits.
117+ return unix.Chmod(dir, 0o1777)
118+ }
119+ if !errors.Is(err, unix.EEXIST) {
120+ return fmt.Errorf("create %s: %w", dir, err)
121+ }
122+ var st, tst unix.Stat_t
123+ if err := unix.Lstat(dir, &st); err != nil {
124+ return err
125+ }
126+ if err := unix.Lstat(tmp, &tst); err != nil {
127+ return err
128+ }
129+ if st.Mode&unix.S_IFMT != unix.S_IFDIR {
130+ return fmt.Errorf("%s is not a directory", dir)
131+ }
132+ if st.Uid != tst.Uid && st.Uid != uint32(os.Getuid()) {
133+ return fmt.Errorf("%s has the wrong owner", dir)
134+ }
135+ if st.Mode&0o022 != 0o022 || st.Mode&0o1000 == 0 {
136+ return fmt.Errorf("%s must be world-writable and sticky", dir)
137+ }
138+ return nil
139+}
140+
141+func (d *Display) listen() error {
142+ names := []string{d.socket}
143+ if d.opts.Abstract {
144+ names = append(names, "@"+d.socket)
145+ }
146+ for _, name := range names {
147+ if name == d.socket {
148+ _ = os.Remove(name) // a stale socket without a lock
149+ }
150+ fd, err := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
151+ if err != nil {
152+ return err
153+ }
154+ f := os.NewFile(uintptr(fd), name)
155+ addr := name
156+ if addr[0] == '@' {
157+ addr = "\x00" + addr[1:]
158+ }
159+ if err := unix.Bind(fd, &unix.SockaddrUnix{Name: addr}); err != nil {
160+ f.Close()
161+ return fmt.Errorf("bind %s: %w", name, err)
162+ }
163+ d.sockets = append(d.sockets, f)
164+ if err := unix.Listen(fd, 128); err != nil {
165+ return fmt.Errorf("listen %s: %w", name, err)
166+ }
167+ }
168+ return nil
169+}
170+
171+// Name is the DISPLAY value, such as ":1".
172+func (d *Display) Name() string { return ":" + strconv.Itoa(d.n) }
173+
174+// Close stops listening and removes the socket and lock files.
175+func (d *Display) Close() {
176+ for _, f := range d.sockets {
177+ f.Close()
178+ }
179+ if len(d.sockets) > 0 {
180+ _ = os.Remove(d.socket)
181+ }
182+ d.sockets = nil
183+ _ = os.Remove(d.lock)
184+}
185+
186+// Run starts the satellite whenever an X11 client connects while it is not
187+// running, until ctx ends. The satellite takes over the sockets and accepts
188+// the waiting clients itself.
189+func (d *Display) Run(ctx context.Context) error {
190+ // The eventfd wakes waitClient when ctx ends; the waker is joined
191+ // before the fd closes.
192+ wake, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
193+ if err != nil {
194+ return err
195+ }
196+ stopWaker := make(chan struct{})
197+ var waker sync.WaitGroup
198+ waker.Go(func() {
199+ select {
200+ case <-ctx.Done():
201+ _, _ = unix.Write(wake, []byte{1, 0, 0, 0, 0, 0, 0, 0})
202+ case <-stopWaker:
203+ }
204+ })
205+ defer func() { close(stopWaker); waker.Wait(); unix.Close(wake) }()
206+ for {
207+ if err := d.waitClient(ctx, wake); err != nil {
208+ if ctx.Err() != nil {
209+ return nil
210+ }
211+ d.opts.Log.Warn().Err(err).Str("display", d.Name()).Msg("X11 display stopped")
212+ return err
213+ }
214+ start := time.Now()
215+ stderr := &logWriter{log: d.opts.Log}
216+ err := d.serve(ctx, stderr)
217+ if ctx.Err() != nil {
218+ return nil
219+ }
220+ if err != nil {
221+ d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg("xwayland-satellite exited")
222+ } else {
223+ d.opts.Log.Info().Str("display", d.Name()).Msg("xwayland-satellite exited")
224+ }
225+ // Clients the satellite never accepted would wake us at once: a
226+ // satellite that cannot start must not respawn in a loop.
227+ d.dropPending()
228+ if time.Since(start) < time.Second {
229+ select {
230+ case <-ctx.Done():
231+ return nil
232+ case <-time.After(time.Second):
233+ }
234+ }
235+ }
236+}
237+
238+// waitClient blocks until a socket has a pending connection or ctx ends.
239+func (d *Display) waitClient(ctx context.Context, wake int) error {
240+ fds := []unix.PollFd{{Fd: int32(wake), Events: unix.POLLIN}}
241+ for _, f := range d.sockets {
242+ fds = append(fds, unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN})
243+ }
244+ for ctx.Err() == nil {
245+ _, err := unix.Poll(fds, -1)
246+ if errors.Is(err, unix.EINTR) {
247+ continue
248+ }
249+ if err != nil {
250+ return fmt.Errorf("poll X11 sockets: %w", err)
251+ }
252+ for _, p := range fds[1:] {
253+ if p.Revents&(unix.POLLERR|unix.POLLNVAL|unix.POLLHUP) != 0 {
254+ return fmt.Errorf("X11 socket error (revents %#x)", p.Revents)
255+ }
256+ if p.Revents&unix.POLLIN != 0 {
257+ return nil
258+ }
259+ }
260+ }
261+ return ctx.Err()
262+}
263+
264+// serve runs the satellite on the sockets until it exits or ctx ends.
265+func (d *Display) serve(ctx context.Context, stderr *logWriter) error {
266+ args := []string{d.Name()}
267+ for i := range d.sockets {
268+ args = append(args, "-listenfd", strconv.Itoa(3+i))
269+ }
270+ cmd := exec.Command(d.opts.Binary, args...)
271+ cmd.Env = d.opts.Env
272+ cmd.ExtraFiles = d.sockets
273+ cmd.Stderr = stderr
274+ // Xwayland inherits the stderr pipe: Wait must not wait for it.
275+ cmd.WaitDelay = 2 * time.Second
276+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
277+ if err := cmd.Start(); err != nil {
278+ return err
279+ }
280+ d.opts.Log.Info().Str("display", d.Name()).Int("pid", cmd.Process.Pid).Msg("xwayland-satellite started")
281+ done := make(chan error, 1)
282+ go func() { done <- cmd.Wait() }()
283+ select {
284+ case err := <-done:
285+ return err
286+ case <-ctx.Done():
287+ // The satellite leads its session's process group, Xwayland included.
288+ pgid := -cmd.Process.Pid
289+ _ = unix.Kill(pgid, unix.SIGTERM)
290+ select {
291+ case <-done:
292+ case <-time.After(2 * time.Second):
293+ _ = unix.Kill(pgid, unix.SIGKILL)
294+ <-done
295+ }
296+ return ctx.Err()
297+ }
298+}
299+
300+// dropPending accepts and closes the connections waiting on the sockets.
301+func (d *Display) dropPending() {
302+ for _, f := range d.sockets {
303+ fd := int(f.Fd())
304+ if unix.SetNonblock(fd, true) != nil {
305+ continue
306+ }
307+ for {
308+ nfd, _, err := unix.Accept4(fd, unix.SOCK_CLOEXEC)
309+ if err != nil {
310+ break
311+ }
312+ unix.Close(nfd)
313+ }
314+ _ = unix.SetNonblock(fd, false)
315+ }
316+}
317+
318+// logWriter logs the satellite's stderr line by line and keeps the last
319+// lines for the exit log; exec copies it from a pipe on its own goroutine.
320+type logWriter struct {
321+ mu sync.Mutex
322+ log zerowrap.Logger
323+ line []byte
324+ last []string
325+}
326+
327+// tail is the last lines written.
328+func (w *logWriter) tail() []string {
329+ w.mu.Lock()
330+ defer w.mu.Unlock()
331+ return slices.Clone(w.last)
332+}
333+
334+func (w *logWriter) Write(p []byte) (int, error) {
335+ w.mu.Lock()
336+ defer w.mu.Unlock()
337+ w.line = append(w.line, p...)
338+ for {
339+ i := strings.IndexByte(string(w.line), '\n')
340+ if i < 0 {
341+ break
342+ }
343+ if i > 0 {
344+ line := string(w.line[:i])
345+ w.log.Debug().Str("line", line).Msg("xwayland-satellite")
346+ if w.last = append(w.last, line); len(w.last) > 5 {
347+ w.last = w.last[1:]
348+ }
349+ }
350+ w.line = w.line[i+1:]
351+ }
352+ if len(w.line) > 4096 {
353+ w.line = w.line[:0]
354+ }
355+ return len(p), nil
356+}
@@ -0,0 +1,356 @@
1+// Package xwayland runs X11 clients through xwayland-satellite, started on
2+// demand: nefertty owns the X11 display sockets and starts the satellite
3+// when the first X11 client connects, and again after it exits.
4+package xwayland
5+
6+import (
7+ "context"
8+ "errors"
9+ "fmt"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "slices"
14+ "strconv"
15+ "strings"
16+ "sync"
17+ "syscall"
18+ "time"
19+
20+ "github.com/bnema/zerowrap"
21+ "golang.org/x/sys/unix"
22+)
23+
24+// Options configures the X11 display.
25+type Options struct {
26+ // Binary is the xwayland-satellite executable.
27+ Binary string
28+ // Env is the satellite's environment; it must hold WAYLAND_DISPLAY.
29+ Env []string
30+ // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix
31+ // and /tmp outside tests.
32+ Dir, TmpDir string
33+ // Abstract also listens on the abstract socket, as X servers do.
34+ Abstract bool
35+ Log zerowrap.Logger
36+}
37+
38+// Display is an X11 display served by xwayland-satellite.
39+type Display struct {
40+ opts Options
41+ n int
42+ sockets []*os.File // unix socket, then the abstract one
43+ socket string
44+ lock string
45+}
46+
47+// Supported reports whether the binary can take over listening sockets
48+// (xwayland-satellite 0.7 and later). A binary that hangs is unsupported.
49+func Supported(ctx context.Context, binary string) bool {
50+ ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
51+ defer cancel()
52+ cmd := exec.CommandContext(ctx, binary, ":0", "--test-listenfd-support")
53+ cmd.Env = []string{}
54+ return cmd.Run() == nil
55+}
56+
57+// Open reserves the first free display number and listens on its sockets.
58+func Open(opts Options) (*Display, error) {
59+ if err := ensureDir(opts.Dir, opts.TmpDir); err != nil {
60+ return nil, err
61+ }
62+ var last error
63+ for n := 0; n < 50; n++ {
64+ lock := filepath.Join(opts.TmpDir, fmt.Sprintf(".X%d-lock", n))
65+ fd, err := unix.Open(lock, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC, 0o444)
66+ if errors.Is(err, unix.EEXIST) && staleLock(lock) {
67+ // A crashed session left it: take the display over.
68+ opts.Log.Info().Str("lock", lock).Msg("removing stale X11 lock")
69+ _ = os.Remove(lock)
70+ fd, err = unix.Open(lock, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC, 0o444)
71+ }
72+ if err != nil {
73+ if !errors.Is(err, unix.EEXIST) {
74+ last = fmt.Errorf("create %s: %w", lock, err)
75+ }
76+ continue
77+ }
78+ _, err = unix.Write(fd, fmt.Appendf(nil, "%10d\n", os.Getpid()))
79+ unix.Close(fd)
80+ if err != nil {
81+ _ = os.Remove(lock)
82+ return nil, fmt.Errorf("write X11 lock: %w", err)
83+ }
84+ d := &Display{opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}
85+ if err := d.listen(); err != nil {
86+ last = err
87+ d.Close()
88+ continue
89+ }
90+ return d, nil
91+ }
92+ if last != nil {
93+ return nil, fmt.Errorf("no free X11 display: %w", last)
94+ }
95+ return nil, errors.New("no free X11 display")
96+}
97+
98+// staleLock reports whether a lock file names a process that is gone.
99+func staleLock(path string) bool {
100+ b, err := os.ReadFile(path)
101+ if err != nil {
102+ return false
103+ }
104+ pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
105+ if err != nil || pid <= 0 {
106+ return false
107+ }
108+ return errors.Is(unix.Kill(pid, 0), unix.ESRCH)
109+}
110+
111+// ensureDir creates the X11 socket directory, or checks an existing one is
112+// shared and sticky like /tmp.
113+func ensureDir(dir, tmp string) error {
114+ err := unix.Mkdir(dir, 0o1777)
115+ if err == nil {
116+ // The umask may have dropped bits.
117+ return unix.Chmod(dir, 0o1777)
118+ }
119+ if !errors.Is(err, unix.EEXIST) {
120+ return fmt.Errorf("create %s: %w", dir, err)
121+ }
122+ var st, tst unix.Stat_t
123+ if err := unix.Lstat(dir, &st); err != nil {
124+ return err
125+ }
126+ if err := unix.Lstat(tmp, &tst); err != nil {
127+ return err
128+ }
129+ if st.Mode&unix.S_IFMT != unix.S_IFDIR {
130+ return fmt.Errorf("%s is not a directory", dir)
131+ }
132+ if st.Uid != tst.Uid && st.Uid != uint32(os.Getuid()) {
133+ return fmt.Errorf("%s has the wrong owner", dir)
134+ }
135+ if st.Mode&0o022 != 0o022 || st.Mode&0o1000 == 0 {
136+ return fmt.Errorf("%s must be world-writable and sticky", dir)
137+ }
138+ return nil
139+}
140+
141+func (d *Display) listen() error {
142+ names := []string{d.socket}
143+ if d.opts.Abstract {
144+ names = append(names, "@"+d.socket)
145+ }
146+ for _, name := range names {
147+ if name == d.socket {
148+ _ = os.Remove(name) // a stale socket without a lock
149+ }
150+ fd, err := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
151+ if err != nil {
152+ return err
153+ }
154+ f := os.NewFile(uintptr(fd), name)
155+ addr := name
156+ if addr[0] == '@' {
157+ addr = "\x00" + addr[1:]
158+ }
159+ if err := unix.Bind(fd, &unix.SockaddrUnix{Name: addr}); err != nil {
160+ f.Close()
161+ return fmt.Errorf("bind %s: %w", name, err)
162+ }
163+ d.sockets = append(d.sockets, f)
164+ if err := unix.Listen(fd, 128); err != nil {
165+ return fmt.Errorf("listen %s: %w", name, err)
166+ }
167+ }
168+ return nil
169+}
170+
171+// Name is the DISPLAY value, such as ":1".
172+func (d *Display) Name() string { return ":" + strconv.Itoa(d.n) }
173+
174+// Close stops listening and removes the socket and lock files.
175+func (d *Display) Close() {
176+ for _, f := range d.sockets {
177+ f.Close()
178+ }
179+ if len(d.sockets) > 0 {
180+ _ = os.Remove(d.socket)
181+ }
182+ d.sockets = nil
183+ _ = os.Remove(d.lock)
184+}
185+
186+// Run starts the satellite whenever an X11 client connects while it is not
187+// running, until ctx ends. The satellite takes over the sockets and accepts
188+// the waiting clients itself.
189+func (d *Display) Run(ctx context.Context) error {
190+ // The eventfd wakes waitClient when ctx ends; the waker is joined
191+ // before the fd closes.
192+ wake, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
193+ if err != nil {
194+ return err
195+ }
196+ stopWaker := make(chan struct{})
197+ var waker sync.WaitGroup
198+ waker.Go(func() {
199+ select {
200+ case <-ctx.Done():
201+ _, _ = unix.Write(wake, []byte{1, 0, 0, 0, 0, 0, 0, 0})
202+ case <-stopWaker:
203+ }
204+ })
205+ defer func() { close(stopWaker); waker.Wait(); unix.Close(wake) }()
206+ for {
207+ if err := d.waitClient(ctx, wake); err != nil {
208+ if ctx.Err() != nil {
209+ return nil
210+ }
211+ d.opts.Log.Warn().Err(err).Str("display", d.Name()).Msg("X11 display stopped")
212+ return err
213+ }
214+ start := time.Now()
215+ stderr := &logWriter{log: d.opts.Log}
216+ err := d.serve(ctx, stderr)
217+ if ctx.Err() != nil {
218+ return nil
219+ }
220+ if err != nil {
221+ d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg("xwayland-satellite exited")
222+ } else {
223+ d.opts.Log.Info().Str("display", d.Name()).Msg("xwayland-satellite exited")
224+ }
225+ // Clients the satellite never accepted would wake us at once: a
226+ // satellite that cannot start must not respawn in a loop.
227+ d.dropPending()
228+ if time.Since(start) < time.Second {
229+ select {
230+ case <-ctx.Done():
231+ return nil
232+ case <-time.After(time.Second):
233+ }
234+ }
235+ }
236+}
237+
238+// waitClient blocks until a socket has a pending connection or ctx ends.
239+func (d *Display) waitClient(ctx context.Context, wake int) error {
240+ fds := []unix.PollFd{{Fd: int32(wake), Events: unix.POLLIN}}
241+ for _, f := range d.sockets {
242+ fds = append(fds, unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN})
243+ }
244+ for ctx.Err() == nil {
245+ _, err := unix.Poll(fds, -1)
246+ if errors.Is(err, unix.EINTR) {
247+ continue
248+ }
249+ if err != nil {
250+ return fmt.Errorf("poll X11 sockets: %w", err)
251+ }
252+ for _, p := range fds[1:] {
253+ if p.Revents&(unix.POLLERR|unix.POLLNVAL|unix.POLLHUP) != 0 {
254+ return fmt.Errorf("X11 socket error (revents %#x)", p.Revents)
255+ }
256+ if p.Revents&unix.POLLIN != 0 {
257+ return nil
258+ }
259+ }
260+ }
261+ return ctx.Err()
262+}
263+
264+// serve runs the satellite on the sockets until it exits or ctx ends.
265+func (d *Display) serve(ctx context.Context, stderr *logWriter) error {
266+ args := []string{d.Name()}
267+ for i := range d.sockets {
268+ args = append(args, "-listenfd", strconv.Itoa(3+i))
269+ }
270+ cmd := exec.Command(d.opts.Binary, args...)
271+ cmd.Env = d.opts.Env
272+ cmd.ExtraFiles = d.sockets
273+ cmd.Stderr = stderr
274+ // Xwayland inherits the stderr pipe: Wait must not wait for it.
275+ cmd.WaitDelay = 2 * time.Second
276+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
277+ if err := cmd.Start(); err != nil {
278+ return err
279+ }
280+ d.opts.Log.Info().Str("display", d.Name()).Int("pid", cmd.Process.Pid).Msg("xwayland-satellite started")
281+ done := make(chan error, 1)
282+ go func() { done <- cmd.Wait() }()
283+ select {
284+ case err := <-done:
285+ return err
286+ case <-ctx.Done():
287+ // The satellite leads its session's process group, Xwayland included.
288+ pgid := -cmd.Process.Pid
289+ _ = unix.Kill(pgid, unix.SIGTERM)
290+ select {
291+ case <-done:
292+ case <-time.After(2 * time.Second):
293+ _ = unix.Kill(pgid, unix.SIGKILL)
294+ <-done
295+ }
296+ return ctx.Err()
297+ }
298+}
299+
300+// dropPending accepts and closes the connections waiting on the sockets.
301+func (d *Display) dropPending() {
302+ for _, f := range d.sockets {
303+ fd := int(f.Fd())
304+ if unix.SetNonblock(fd, true) != nil {
305+ continue
306+ }
307+ for {
308+ nfd, _, err := unix.Accept4(fd, unix.SOCK_CLOEXEC)
309+ if err != nil {
310+ break
311+ }
312+ unix.Close(nfd)
313+ }
314+ _ = unix.SetNonblock(fd, false)
315+ }
316+}
317+
318+// logWriter logs the satellite's stderr line by line and keeps the last
319+// lines for the exit log; exec copies it from a pipe on its own goroutine.
320+type logWriter struct {
321+ mu sync.Mutex
322+ log zerowrap.Logger
323+ line []byte
324+ last []string
325+}
326+
327+// tail is the last lines written.
328+func (w *logWriter) tail() []string {
329+ w.mu.Lock()
330+ defer w.mu.Unlock()
331+ return slices.Clone(w.last)
332+}
333+
334+func (w *logWriter) Write(p []byte) (int, error) {
335+ w.mu.Lock()
336+ defer w.mu.Unlock()
337+ w.line = append(w.line, p...)
338+ for {
339+ i := strings.IndexByte(string(w.line), '\n')
340+ if i < 0 {
341+ break
342+ }
343+ if i > 0 {
344+ line := string(w.line[:i])
345+ w.log.Debug().Str("line", line).Msg("xwayland-satellite")
346+ if w.last = append(w.last, line); len(w.last) > 5 {
347+ w.last = w.last[1:]
348+ }
349+ }
350+ w.line = w.line[i+1:]
351+ }
352+ if len(w.line) > 4096 {
353+ w.line = w.line[:0]
354+ }
355+ return len(p), nil
356+}
A internal/adapters/xwayland/xwayland_test.go +267 -0
@@ -0,0 +1,267 @@
1+package xwayland
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "strconv"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/nefertty/internal/logging"
14+ "golang.org/x/sys/unix"
15+)
16+
17+func testOptions(t *testing.T, binary string) Options {
18+ t.Helper()
19+ tmp := t.TempDir()
20+ if err := os.Chmod(tmp, 0o1777); err != nil {
21+ t.Fatal(err)
22+ }
23+ return Options{Binary: binary, Dir: filepath.Join(tmp, ".X11-unix"), TmpDir: tmp, Log: logging.For(context.Background(), "xwayland")}
24+}
25+
26+func dial(t *testing.T, path string) int {
27+ t.Helper()
28+ fd, err := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ if err := unix.Connect(fd, &unix.SockaddrUnix{Name: path}); err != nil {
33+ t.Fatal(err)
34+ }
35+ return fd
36+}
37+
38+func TestOpenPicksFreeDisplay(t *testing.T) {
39+ opts := testOptions(t, "true")
40+ if err := os.WriteFile(filepath.Join(opts.TmpDir, ".X0-lock"), nil, 0o444); err != nil {
41+ t.Fatal(err)
42+ }
43+ d, err := Open(opts)
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ if d.Name() != ":1" {
48+ t.Fatalf("display %s", d.Name())
49+ }
50+ lock, err := os.ReadFile(filepath.Join(opts.TmpDir, ".X1-lock"))
51+ if err != nil || strings.TrimSpace(string(lock)) != strconv.Itoa(os.Getpid()) {
52+ t.Fatalf("lock %q %v", lock, err)
53+ }
54+ st, err := os.Stat(opts.Dir)
55+ if err != nil || st.Mode()&os.ModeSticky == 0 || st.Mode().Perm() != 0o777 {
56+ t.Fatalf("dir %v %v", st.Mode(), err)
57+ }
58+ d.Close()
59+ for _, p := range []string{filepath.Join(opts.Dir, "X1"), filepath.Join(opts.TmpDir, ".X1-lock")} {
60+ if _, err := os.Stat(p); !os.IsNotExist(err) {
61+ t.Fatalf("%s left behind: %v", p, err)
62+ }
63+ }
64+}
65+
66+// A satellite that exits without accepting drops the waiting client and is
67+// not respawned until another client connects.
68+func TestRunDropsClientsOfFailedSatellite(t *testing.T) {
69+ d, err := Open(testOptions(t, "false"))
70+ if err != nil {
71+ t.Fatal(err)
72+ }
73+ defer d.Close()
74+ ctx, cancel := context.WithCancel(context.Background())
75+ done := make(chan error, 1)
76+ go func() { done <- d.Run(ctx) }()
77+ fd := dial(t, d.socket)
78+ defer unix.Close(fd)
79+ // The dropped connection reads EOF.
80+ if err := unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &unix.Timeval{Sec: 3}); err != nil {
81+ t.Fatal(err)
82+ }
83+ if n, err := unix.Read(fd, make([]byte, 1)); n != 0 || err != nil {
84+ t.Fatalf("read %d %v, want EOF", n, err)
85+ }
86+ cancel()
87+ select {
88+ case err := <-done:
89+ if err != nil {
90+ t.Fatal(err)
91+ }
92+ case <-time.After(3 * time.Second):
93+ t.Fatal("Run did not stop")
94+ }
95+}
96+
97+// satelliteScript writes a real script that records each run and whether it
98+// received listening sockets on fds 3 and 4, then exits.
99+func satelliteScript(t *testing.T, dir string) (string, string) {
100+ t.Helper()
101+ runs := filepath.Join(dir, "runs")
102+ bin := filepath.Join(dir, "satellite")
103+ script := "#!/bin/sh\n[ -S /proc/self/fd/3 ] && [ -S /proc/self/fd/4 ] && echo \"$*\" >> " + runs + "\n"
104+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
105+ t.Fatal(err)
106+ }
107+ return bin, runs
108+}
109+
110+func readRuns(t *testing.T, path string, want int) []string {
111+ t.Helper()
112+ deadline := time.Now().Add(3 * time.Second)
113+ for {
114+ b, _ := os.ReadFile(path)
115+ lines := strings.Fields(strings.ReplaceAll(string(b), " ", "_"))
116+ if len(lines) >= want || time.Now().After(deadline) {
117+ return lines
118+ }
119+ time.Sleep(20 * time.Millisecond)
120+ }
121+}
122+
123+// Each connection starts the satellite once, with both sockets handed over.
124+func TestRunStartsSatelliteOnConnect(t *testing.T) {
125+ opts := testOptions(t, "")
126+ var runs string
127+ opts.Binary, runs = satelliteScript(t, opts.TmpDir)
128+ opts.Abstract = true
129+ d, err := Open(opts)
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+ defer d.Close()
134+ ctx, cancel := context.WithCancel(context.Background())
135+ done := make(chan error, 1)
136+ go func() { done <- d.Run(ctx) }()
137+ time.Sleep(100 * time.Millisecond)
138+ if got := readRuns(t, runs, 0); len(got) != 0 {
139+ t.Fatalf("started without a client: %v", got)
140+ }
141+ unix.Close(dial(t, d.socket))
142+ want := d.Name() + "_-listenfd_3_-listenfd_4"
143+ if got := readRuns(t, runs, 1); len(got) != 1 || got[0] != want {
144+ t.Fatalf("runs %v, want [%s]", got, want)
145+ }
146+ // The next client, after the backoff, starts it again.
147+ time.Sleep(1200 * time.Millisecond)
148+ if got := readRuns(t, runs, 1); len(got) != 1 {
149+ t.Fatalf("respawned without a client: %v", got)
150+ }
151+ unix.Close(dial(t, "\x00"+d.socket))
152+ if got := readRuns(t, runs, 2); len(got) != 2 {
153+ t.Fatalf("runs %v after the second client", got)
154+ }
155+ cancel()
156+ if err := <-done; err != nil {
157+ t.Fatal(err)
158+ }
159+}
160+
161+func TestStaleLockReclaimed(t *testing.T) {
162+ opts := testOptions(t, "true")
163+ // PID 0x7ffffff0 does not exist.
164+ if err := os.WriteFile(filepath.Join(opts.TmpDir, ".X0-lock"), []byte("2147483632\n"), 0o444); err != nil {
165+ t.Fatal(err)
166+ }
167+ // A live process keeps its display.
168+ if err := os.WriteFile(filepath.Join(opts.TmpDir, ".X1-lock"), []byte(strconv.Itoa(os.Getpid())), 0o444); err != nil {
169+ t.Fatal(err)
170+ }
171+ d, err := Open(opts)
172+ if err != nil {
173+ t.Fatal(err)
174+ }
175+ defer d.Close()
176+ if d.Name() != ":0" {
177+ t.Fatalf("display %s", d.Name())
178+ }
179+ if b, _ := os.ReadFile(d.lock); strings.TrimSpace(string(b)) != strconv.Itoa(os.Getpid()) {
180+ t.Fatalf("lock holds %q", b)
181+ }
182+}
183+
184+func TestDirChecks(t *testing.T) {
185+ for name, setup := range map[string]func(dir string) error{
186+ "not sticky": func(dir string) error { return os.Mkdir(dir, 0o777) },
187+ "symlink": func(dir string) error {
188+ target := dir + "-target"
189+ if err := os.Mkdir(target, 0o1777); err != nil {
190+ return err
191+ }
192+ return os.Symlink(target, dir)
193+ },
194+ "file": func(dir string) error { return os.WriteFile(dir, nil, 0o777) },
195+ } {
196+ t.Run(name, func(t *testing.T) {
197+ opts := testOptions(t, "true")
198+ if err := setup(opts.Dir); err != nil {
199+ t.Fatal(err)
200+ }
201+ _ = os.Chmod(opts.Dir, 0o777|os.ModeSticky)
202+ if name == "not sticky" {
203+ _ = os.Chmod(opts.Dir, 0o777)
204+ }
205+ if _, err := Open(opts); err == nil {
206+ t.Fatal("opened an unsafe X11 directory")
207+ }
208+ })
209+ }
210+}
211+
212+func TestSupported(t *testing.T) {
213+ if !Supported(context.Background(), "true") || Supported(context.Background(), "false") {
214+ t.Fatal("exit status not honoured")
215+ }
216+ hang := filepath.Join(t.TempDir(), "hang")
217+ if err := os.WriteFile(hang, []byte("#!/bin/sh\nexec sleep 10\n"), 0o755); err != nil {
218+ t.Fatal(err)
219+ }
220+ start := time.Now()
221+ if Supported(context.Background(), hang) || time.Since(start) > 3*time.Second {
222+ t.Fatalf("hanging binary: %v", time.Since(start))
223+ }
224+}
225+
226+// Stopping kills the satellite's whole process group, even when a child
227+// keeps its stderr open.
228+func TestRunKillsSatelliteGroup(t *testing.T) {
229+ opts := testOptions(t, "")
230+ pidfile := filepath.Join(opts.TmpDir, "pid")
231+ opts.Binary = filepath.Join(opts.TmpDir, "satellite")
232+ script := "#!/bin/sh\ntrap '' TERM\nsleep 30 &\necho $! > " + pidfile + "\nwait\n"
233+ if err := os.WriteFile(opts.Binary, []byte(script), 0o755); err != nil {
234+ t.Fatal(err)
235+ }
236+ d, err := Open(opts)
237+ if err != nil {
238+ t.Fatal(err)
239+ }
240+ defer d.Close()
241+ ctx, cancel := context.WithCancel(context.Background())
242+ done := make(chan error, 1)
243+ go func() { done <- d.Run(ctx) }()
244+ unix.Close(dial(t, d.socket))
245+ var pid int
246+ for deadline := time.Now().Add(3 * time.Second); pid == 0 && time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
247+ b, _ := os.ReadFile(pidfile)
248+ pid, _ = strconv.Atoi(strings.TrimSpace(string(b)))
249+ }
250+ if pid == 0 {
251+ t.Fatal("satellite did not start")
252+ }
253+ cancel()
254+ select {
255+ case <-done:
256+ case <-time.After(5 * time.Second):
257+ t.Fatal("Run did not stop")
258+ }
259+ for deadline := time.Now().Add(2 * time.Second); ; time.Sleep(20 * time.Millisecond) {
260+ if errors.Is(unix.Kill(pid, 0), unix.ESRCH) {
261+ break
262+ }
263+ if time.Now().After(deadline) {
264+ t.Fatalf("child %d survived", pid)
265+ }
266+ }
267+}
@@ -0,0 +1,267 @@
1+package xwayland
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "strconv"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/nefertty/internal/logging"
14+ "golang.org/x/sys/unix"
15+)
16+
17+func testOptions(t *testing.T, binary string) Options {
18+ t.Helper()
19+ tmp := t.TempDir()
20+ if err := os.Chmod(tmp, 0o1777); err != nil {
21+ t.Fatal(err)
22+ }
23+ return Options{Binary: binary, Dir: filepath.Join(tmp, ".X11-unix"), TmpDir: tmp, Log: logging.For(context.Background(), "xwayland")}
24+}
25+
26+func dial(t *testing.T, path string) int {
27+ t.Helper()
28+ fd, err := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ if err := unix.Connect(fd, &unix.SockaddrUnix{Name: path}); err != nil {
33+ t.Fatal(err)
34+ }
35+ return fd
36+}
37+
38+func TestOpenPicksFreeDisplay(t *testing.T) {
39+ opts := testOptions(t, "true")
40+ if err := os.WriteFile(filepath.Join(opts.TmpDir, ".X0-lock"), nil, 0o444); err != nil {
41+ t.Fatal(err)
42+ }
43+ d, err := Open(opts)
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ if d.Name() != ":1" {
48+ t.Fatalf("display %s", d.Name())
49+ }
50+ lock, err := os.ReadFile(filepath.Join(opts.TmpDir, ".X1-lock"))
51+ if err != nil || strings.TrimSpace(string(lock)) != strconv.Itoa(os.Getpid()) {
52+ t.Fatalf("lock %q %v", lock, err)
53+ }
54+ st, err := os.Stat(opts.Dir)
55+ if err != nil || st.Mode()&os.ModeSticky == 0 || st.Mode().Perm() != 0o777 {
56+ t.Fatalf("dir %v %v", st.Mode(), err)
57+ }
58+ d.Close()
59+ for _, p := range []string{filepath.Join(opts.Dir, "X1"), filepath.Join(opts.TmpDir, ".X1-lock")} {
60+ if _, err := os.Stat(p); !os.IsNotExist(err) {
61+ t.Fatalf("%s left behind: %v", p, err)
62+ }
63+ }
64+}
65+
66+// A satellite that exits without accepting drops the waiting client and is
67+// not respawned until another client connects.
68+func TestRunDropsClientsOfFailedSatellite(t *testing.T) {
69+ d, err := Open(testOptions(t, "false"))
70+ if err != nil {
71+ t.Fatal(err)
72+ }
73+ defer d.Close()
74+ ctx, cancel := context.WithCancel(context.Background())
75+ done := make(chan error, 1)
76+ go func() { done <- d.Run(ctx) }()
77+ fd := dial(t, d.socket)
78+ defer unix.Close(fd)
79+ // The dropped connection reads EOF.
80+ if err := unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &unix.Timeval{Sec: 3}); err != nil {
81+ t.Fatal(err)
82+ }
83+ if n, err := unix.Read(fd, make([]byte, 1)); n != 0 || err != nil {
84+ t.Fatalf("read %d %v, want EOF", n, err)
85+ }
86+ cancel()
87+ select {
88+ case err := <-done:
89+ if err != nil {
90+ t.Fatal(err)
91+ }
92+ case <-time.After(3 * time.Second):
93+ t.Fatal("Run did not stop")
94+ }
95+}
96+
97+// satelliteScript writes a real script that records each run and whether it
98+// received listening sockets on fds 3 and 4, then exits.
99+func satelliteScript(t *testing.T, dir string) (string, string) {
100+ t.Helper()
101+ runs := filepath.Join(dir, "runs")
102+ bin := filepath.Join(dir, "satellite")
103+ script := "#!/bin/sh\n[ -S /proc/self/fd/3 ] && [ -S /proc/self/fd/4 ] && echo \"$*\" >> " + runs + "\n"
104+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
105+ t.Fatal(err)
106+ }
107+ return bin, runs
108+}
109+
110+func readRuns(t *testing.T, path string, want int) []string {
111+ t.Helper()
112+ deadline := time.Now().Add(3 * time.Second)
113+ for {
114+ b, _ := os.ReadFile(path)
115+ lines := strings.Fields(strings.ReplaceAll(string(b), " ", "_"))
116+ if len(lines) >= want || time.Now().After(deadline) {
117+ return lines
118+ }
119+ time.Sleep(20 * time.Millisecond)
120+ }
121+}
122+
123+// Each connection starts the satellite once, with both sockets handed over.
124+func TestRunStartsSatelliteOnConnect(t *testing.T) {
125+ opts := testOptions(t, "")
126+ var runs string
127+ opts.Binary, runs = satelliteScript(t, opts.TmpDir)
128+ opts.Abstract = true
129+ d, err := Open(opts)
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+ defer d.Close()
134+ ctx, cancel := context.WithCancel(context.Background())
135+ done := make(chan error, 1)
136+ go func() { done <- d.Run(ctx) }()
137+ time.Sleep(100 * time.Millisecond)
138+ if got := readRuns(t, runs, 0); len(got) != 0 {
139+ t.Fatalf("started without a client: %v", got)
140+ }
141+ unix.Close(dial(t, d.socket))
142+ want := d.Name() + "_-listenfd_3_-listenfd_4"
143+ if got := readRuns(t, runs, 1); len(got) != 1 || got[0] != want {
144+ t.Fatalf("runs %v, want [%s]", got, want)
145+ }
146+ // The next client, after the backoff, starts it again.
147+ time.Sleep(1200 * time.Millisecond)
148+ if got := readRuns(t, runs, 1); len(got) != 1 {
149+ t.Fatalf("respawned without a client: %v", got)
150+ }
151+ unix.Close(dial(t, "\x00"+d.socket))
152+ if got := readRuns(t, runs, 2); len(got) != 2 {
153+ t.Fatalf("runs %v after the second client", got)
154+ }
155+ cancel()
156+ if err := <-done; err != nil {
157+ t.Fatal(err)
158+ }
159+}
160+
161+func TestStaleLockReclaimed(t *testing.T) {
162+ opts := testOptions(t, "true")
163+ // PID 0x7ffffff0 does not exist.
164+ if err := os.WriteFile(filepath.Join(opts.TmpDir, ".X0-lock"), []byte("2147483632\n"), 0o444); err != nil {
165+ t.Fatal(err)
166+ }
167+ // A live process keeps its display.
168+ if err := os.WriteFile(filepath.Join(opts.TmpDir, ".X1-lock"), []byte(strconv.Itoa(os.Getpid())), 0o444); err != nil {
169+ t.Fatal(err)
170+ }
171+ d, err := Open(opts)
172+ if err != nil {
173+ t.Fatal(err)
174+ }
175+ defer d.Close()
176+ if d.Name() != ":0" {
177+ t.Fatalf("display %s", d.Name())
178+ }
179+ if b, _ := os.ReadFile(d.lock); strings.TrimSpace(string(b)) != strconv.Itoa(os.Getpid()) {
180+ t.Fatalf("lock holds %q", b)
181+ }
182+}
183+
184+func TestDirChecks(t *testing.T) {
185+ for name, setup := range map[string]func(dir string) error{
186+ "not sticky": func(dir string) error { return os.Mkdir(dir, 0o777) },
187+ "symlink": func(dir string) error {
188+ target := dir + "-target"
189+ if err := os.Mkdir(target, 0o1777); err != nil {
190+ return err
191+ }
192+ return os.Symlink(target, dir)
193+ },
194+ "file": func(dir string) error { return os.WriteFile(dir, nil, 0o777) },
195+ } {
196+ t.Run(name, func(t *testing.T) {
197+ opts := testOptions(t, "true")
198+ if err := setup(opts.Dir); err != nil {
199+ t.Fatal(err)
200+ }
201+ _ = os.Chmod(opts.Dir, 0o777|os.ModeSticky)
202+ if name == "not sticky" {
203+ _ = os.Chmod(opts.Dir, 0o777)
204+ }
205+ if _, err := Open(opts); err == nil {
206+ t.Fatal("opened an unsafe X11 directory")
207+ }
208+ })
209+ }
210+}
211+
212+func TestSupported(t *testing.T) {
213+ if !Supported(context.Background(), "true") || Supported(context.Background(), "false") {
214+ t.Fatal("exit status not honoured")
215+ }
216+ hang := filepath.Join(t.TempDir(), "hang")
217+ if err := os.WriteFile(hang, []byte("#!/bin/sh\nexec sleep 10\n"), 0o755); err != nil {
218+ t.Fatal(err)
219+ }
220+ start := time.Now()
221+ if Supported(context.Background(), hang) || time.Since(start) > 3*time.Second {
222+ t.Fatalf("hanging binary: %v", time.Since(start))
223+ }
224+}
225+
226+// Stopping kills the satellite's whole process group, even when a child
227+// keeps its stderr open.
228+func TestRunKillsSatelliteGroup(t *testing.T) {
229+ opts := testOptions(t, "")
230+ pidfile := filepath.Join(opts.TmpDir, "pid")
231+ opts.Binary = filepath.Join(opts.TmpDir, "satellite")
232+ script := "#!/bin/sh\ntrap '' TERM\nsleep 30 &\necho $! > " + pidfile + "\nwait\n"
233+ if err := os.WriteFile(opts.Binary, []byte(script), 0o755); err != nil {
234+ t.Fatal(err)
235+ }
236+ d, err := Open(opts)
237+ if err != nil {
238+ t.Fatal(err)
239+ }
240+ defer d.Close()
241+ ctx, cancel := context.WithCancel(context.Background())
242+ done := make(chan error, 1)
243+ go func() { done <- d.Run(ctx) }()
244+ unix.Close(dial(t, d.socket))
245+ var pid int
246+ for deadline := time.Now().Add(3 * time.Second); pid == 0 && time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
247+ b, _ := os.ReadFile(pidfile)
248+ pid, _ = strconv.Atoi(strings.TrimSpace(string(b)))
249+ }
250+ if pid == 0 {
251+ t.Fatal("satellite did not start")
252+ }
253+ cancel()
254+ select {
255+ case <-done:
256+ case <-time.After(5 * time.Second):
257+ t.Fatal("Run did not stop")
258+ }
259+ for deadline := time.Now().Add(2 * time.Second); ; time.Sleep(20 * time.Millisecond) {
260+ if errors.Is(unix.Kill(pid, 0), unix.ESRCH) {
261+ break
262+ }
263+ if time.Now().After(deadline) {
264+ t.Fatalf("child %d survived", pid)
265+ }
266+ }
267+}
M internal/app/app.go +38 -5
@@ -20,6 +20,7 @@ import (
2020 "github.com/bnema/nefertty/internal/adapters/vulkan"
2121 "github.com/bnema/nefertty/internal/adapters/wayland"
2222 "github.com/bnema/nefertty/internal/adapters/xkb"
23+ "github.com/bnema/nefertty/internal/adapters/xwayland"
2324 "github.com/bnema/nefertty/internal/core"
2425 "github.com/bnema/nefertty/internal/logging"
2526 "github.com/bnema/nefertty/internal/ports"
@@ -27,11 +28,13 @@ import (
2728 )
2829
2930 type Options struct {
30- Backend string
31- Config ports.Config
32- ConfigPath string
33- Timeout time.Duration
34- NoTerminal bool
31+ Backend string
32+ Config ports.Config
33+ ConfigPath string
34+ Timeout time.Duration
35+ NoTerminal bool
36+ // NoXwayland skips the X11 display even when the config enables it.
37+ NoXwayland bool
3538 ScreenshotDir string
3639 // Sizes are the headless outputs (width, height), left to right; empty
3740 // means one 1920x1080 output. With several outputs, screenshots go to
@@ -107,6 +110,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
107110 } else {
108111 childEnv = append(childEnv, statefile.Env+"="+statePath)
109112 }
113+ xdisplay := openXwayland(ctx, opts, childEnv, logging.For(ctx, "xwayland"))
114+ if xdisplay != nil {
115+ defer xdisplay.Close()
116+ childEnv = append(childEnv, "DISPLAY="+xdisplay.Name())
117+ }
110118 child := launcher.New(childEnv, logging.For(ctx, "launcher"))
111119 if inject != nil {
112120 inject(input)
@@ -171,6 +179,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
171179 go func() { defer workers.Done(); done <- server.Run(ctx) }()
172180 go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }()
173181 go func() { defer workers.Done(); done <- c.Run(ctx) }()
182+ if xdisplay != nil {
183+ workers.Add(1)
184+ // X11 failing leaves the Wayland session running; Run logs why.
185+ go func() { defer workers.Done(); _ = xdisplay.Run(ctx) }()
186+ }
174187 if statePath != "" {
175188 workers.Add(1)
176189 go func() {
@@ -332,3 +345,23 @@ func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configError
332345 }
333346 }
334347 }
348+
349+// openXwayland reserves an X11 display for xwayland-satellite, or returns
350+// nil when X11 is off or unavailable: the session runs without it.
351+func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
352+ bin := opts.Config.Xwayland
353+ if bin == "" || opts.NoXwayland {
354+ return nil
355+ }
356+ if !xwayland.Supported(ctx, bin) {
357+ log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
358+ return nil
359+ }
360+ d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
361+ if err != nil {
362+ log.Warn().Err(err).Msg("X11 display disabled")
363+ return nil
364+ }
365+ log.Info().Str("DISPLAY", d.Name()).Msg("listening on X11 display")
366+ return d
367+}
@@ -20,6 +20,7 @@ import (
20 "github.com/bnema/nefertty/internal/adapters/vulkan"20 "github.com/bnema/nefertty/internal/adapters/vulkan"
21 "github.com/bnema/nefertty/internal/adapters/wayland"21 "github.com/bnema/nefertty/internal/adapters/wayland"
22 "github.com/bnema/nefertty/internal/adapters/xkb"22 "github.com/bnema/nefertty/internal/adapters/xkb"
23+ "github.com/bnema/nefertty/internal/adapters/xwayland"
23 "github.com/bnema/nefertty/internal/core"24 "github.com/bnema/nefertty/internal/core"
24 "github.com/bnema/nefertty/internal/logging"25 "github.com/bnema/nefertty/internal/logging"
25 "github.com/bnema/nefertty/internal/ports"26 "github.com/bnema/nefertty/internal/ports"
@@ -27,11 +28,13 @@ import (
27 )28 )
28 29
29 type Options struct {30 type Options struct {
30- Backend string31+ Backend string
31- Config ports.Config32+ Config ports.Config
32- ConfigPath string33+ ConfigPath string
33- Timeout time.Duration34+ Timeout time.Duration
34- NoTerminal bool35+ NoTerminal bool
36+ // NoXwayland skips the X11 display even when the config enables it.
37+ NoXwayland bool
35 ScreenshotDir string38 ScreenshotDir string
36 // Sizes are the headless outputs (width, height), left to right; empty39 // Sizes are the headless outputs (width, height), left to right; empty
37 // means one 1920x1080 output. With several outputs, screenshots go to40 // means one 1920x1080 output. With several outputs, screenshots go to
@@ -107,6 +110,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
107 } else {110 } else {
108 childEnv = append(childEnv, statefile.Env+"="+statePath)111 childEnv = append(childEnv, statefile.Env+"="+statePath)
109 }112 }
113+ xdisplay := openXwayland(ctx, opts, childEnv, logging.For(ctx, "xwayland"))
114+ if xdisplay != nil {
115+ defer xdisplay.Close()
116+ childEnv = append(childEnv, "DISPLAY="+xdisplay.Name())
117+ }
110 child := launcher.New(childEnv, logging.For(ctx, "launcher"))118 child := launcher.New(childEnv, logging.For(ctx, "launcher"))
111 if inject != nil {119 if inject != nil {
112 inject(input)120 inject(input)
@@ -171,6 +179,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
171 go func() { defer workers.Done(); done <- server.Run(ctx) }()179 go func() { defer workers.Done(); done <- server.Run(ctx) }()
172 go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }()180 go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }()
173 go func() { defer workers.Done(); done <- c.Run(ctx) }()181 go func() { defer workers.Done(); done <- c.Run(ctx) }()
182+ if xdisplay != nil {
183+ workers.Add(1)
184+ // X11 failing leaves the Wayland session running; Run logs why.
185+ go func() { defer workers.Done(); _ = xdisplay.Run(ctx) }()
186+ }
174 if statePath != "" {187 if statePath != "" {
175 workers.Add(1)188 workers.Add(1)
176 go func() {189 go func() {
@@ -332,3 +345,23 @@ func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configError
332 }345 }
333 }346 }
334 }347 }
348+
349+// openXwayland reserves an X11 display for xwayland-satellite, or returns
350+// nil when X11 is off or unavailable: the session runs without it.
351+func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
352+ bin := opts.Config.Xwayland
353+ if bin == "" || opts.NoXwayland {
354+ return nil
355+ }
356+ if !xwayland.Supported(ctx, bin) {
357+ log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
358+ return nil
359+ }
360+ d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
361+ if err != nil {
362+ log.Warn().Err(err).Msg("X11 display disabled")
363+ return nil
364+ }
365+ log.Info().Str("DISPLAY", d.Name()).Msg("listening on X11 display")
366+ return d
367+}
M internal/app/app_test.go +7 -7
@@ -33,7 +33,7 @@ func TestHeadlessConfigReload(t *testing.T) {
3333 ctx, cancel := context.WithCancel(context.Background())
3434 done := make(chan error, 1)
3535 go func() {
36- done <- Run(ctx, Options{Backend: "headless", Config: cfg, ConfigPath: path, NoTerminal: true, ScreenshotDir: shots})
36+ done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, Config: cfg, ConfigPath: path, NoTerminal: true, ScreenshotDir: shots})
3737 }()
3838 defer func() {
3939 cancel()
@@ -86,7 +86,7 @@ func TestHeadlessConfigReload(t *testing.T) {
8686 func TestQuitJoinsWorkers(t *testing.T) {
8787 done := make(chan error, 1)
8888 go func() {
89- done <- run(context.Background(), Options{Backend: "headless", Config: config.Defaults(), NoTerminal: true}, func(input chan<- ports.InputEvent) {
89+ done <- run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true}, func(input chan<- ports.InputEvent) {
9090 input <- ports.KeyEvent{Keysym: "BackSpace", Mods: ports.ModCtrl | ports.ModAlt, Pressed: true}
9191 })
9292 }()
@@ -114,7 +114,7 @@ func TestHeadlessSpawnClose(t *testing.T) {
114114 inputReady := make(chan chan<- ports.InputEvent, 1)
115115 done := make(chan error, 1)
116116 go func() {
117- done <- run(ctx, Options{Backend: "headless", NoTerminal: true, Config: cfg, testScenes: scenes}, func(input chan<- ports.InputEvent) { inputReady <- input })
117+ done <- run(ctx, Options{Backend: "headless", NoXwayland: true, NoTerminal: true, Config: cfg, testScenes: scenes}, func(input chan<- ports.InputEvent) { inputReady <- input })
118118 }()
119119 var input chan<- ports.InputEvent
120120 select {
@@ -171,7 +171,7 @@ func TestHeadlessTyping(t *testing.T) {
171171 cfg := config.Defaults()
172172 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
173173 dir := t.TempDir()
174- err := Run(context.Background(), Options{Backend: "headless", Config: cfg, ScreenshotDir: dir, Script: io.NopCloser(strings.NewReader("sleep 1.5s\ntype echo nefertty-ok\nkey Return\nsleep 1s\n")), Timeout: 6 * time.Second})
174+ err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: cfg, ScreenshotDir: dir, Script: io.NopCloser(strings.NewReader("sleep 1.5s\ntype echo nefertty-ok\nkey Return\nsleep 1s\n")), Timeout: 6 * time.Second})
175175 if err != nil {
176176 if strings.Contains(err.Error(), "Vulkan") || strings.Contains(err.Error(), "vulkan") {
177177 t.Skipf("Vulkan unavailable: %v", err)
@@ -223,7 +223,7 @@ func TestHeadlessPointerClickFocus(t *testing.T) {
223223 cfg := config.Defaults()
224224 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
225225 scenes := make(chan []ports.Scene, 128)
226- err := Run(context.Background(), Options{Backend: "headless", Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n")), Timeout: 5 * time.Second, testScenes: scenes})
226+ err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n")), Timeout: 5 * time.Second, testScenes: scenes})
227227 if err != nil {
228228 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {
229229 t.Skipf("Vulkan unavailable: %v", err)
@@ -253,7 +253,7 @@ func TestBlockedScriptShutdown(t *testing.T) {
253253 defer w.Close()
254254 done := make(chan error, 1)
255255 go func() {
256- done <- Run(context.Background(), Options{Backend: "headless", Config: config.Defaults(), NoTerminal: true, Script: r, Timeout: 300 * time.Millisecond})
256+ done <- Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true, Script: r, Timeout: 300 * time.Millisecond})
257257 }()
258258 select {
259259 case <-done:
@@ -316,7 +316,7 @@ func TestHeadlessTwoOutputs(t *testing.T) {
316316 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
317317 scenes := make(chan []ports.Scene, 64)
318318 shots := t.TempDir()
319- err := Run(context.Background(), Options{Backend: "headless", Config: config.Defaults(), NoTerminal: true, ScreenshotDir: shots, Sizes: [][2]int{{640, 480}, {320, 240}}, Timeout: 2 * time.Second, testScenes: scenes})
319+ err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true, ScreenshotDir: shots, Sizes: [][2]int{{640, 480}, {320, 240}}, Timeout: 2 * time.Second, testScenes: scenes})
320320 if err != nil {
321321 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {
322322 t.Skipf("Vulkan unavailable: %v", err)
@@ -33,7 +33,7 @@ func TestHeadlessConfigReload(t *testing.T) {
33 ctx, cancel := context.WithCancel(context.Background())33 ctx, cancel := context.WithCancel(context.Background())
34 done := make(chan error, 1)34 done := make(chan error, 1)
35 go func() {35 go func() {
36- done <- Run(ctx, Options{Backend: "headless", Config: cfg, ConfigPath: path, NoTerminal: true, ScreenshotDir: shots})36+ done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, Config: cfg, ConfigPath: path, NoTerminal: true, ScreenshotDir: shots})
37 }()37 }()
38 defer func() {38 defer func() {
39 cancel()39 cancel()
@@ -86,7 +86,7 @@ func TestHeadlessConfigReload(t *testing.T) {
86 func TestQuitJoinsWorkers(t *testing.T) {86 func TestQuitJoinsWorkers(t *testing.T) {
87 done := make(chan error, 1)87 done := make(chan error, 1)
88 go func() {88 go func() {
89- done <- run(context.Background(), Options{Backend: "headless", Config: config.Defaults(), NoTerminal: true}, func(input chan<- ports.InputEvent) {89+ done <- run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true}, func(input chan<- ports.InputEvent) {
90 input <- ports.KeyEvent{Keysym: "BackSpace", Mods: ports.ModCtrl | ports.ModAlt, Pressed: true}90 input <- ports.KeyEvent{Keysym: "BackSpace", Mods: ports.ModCtrl | ports.ModAlt, Pressed: true}
91 })91 })
92 }()92 }()
@@ -114,7 +114,7 @@ func TestHeadlessSpawnClose(t *testing.T) {
114 inputReady := make(chan chan<- ports.InputEvent, 1)114 inputReady := make(chan chan<- ports.InputEvent, 1)
115 done := make(chan error, 1)115 done := make(chan error, 1)
116 go func() {116 go func() {
117- done <- run(ctx, Options{Backend: "headless", NoTerminal: true, Config: cfg, testScenes: scenes}, func(input chan<- ports.InputEvent) { inputReady <- input })117+ done <- run(ctx, Options{Backend: "headless", NoXwayland: true, NoTerminal: true, Config: cfg, testScenes: scenes}, func(input chan<- ports.InputEvent) { inputReady <- input })
118 }()118 }()
119 var input chan<- ports.InputEvent119 var input chan<- ports.InputEvent
120 select {120 select {
@@ -171,7 +171,7 @@ func TestHeadlessTyping(t *testing.T) {
171 cfg := config.Defaults()171 cfg := config.Defaults()
172 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}172 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
173 dir := t.TempDir()173 dir := t.TempDir()
174- err := Run(context.Background(), Options{Backend: "headless", Config: cfg, ScreenshotDir: dir, Script: io.NopCloser(strings.NewReader("sleep 1.5s\ntype echo nefertty-ok\nkey Return\nsleep 1s\n")), Timeout: 6 * time.Second})174+ err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: cfg, ScreenshotDir: dir, Script: io.NopCloser(strings.NewReader("sleep 1.5s\ntype echo nefertty-ok\nkey Return\nsleep 1s\n")), Timeout: 6 * time.Second})
175 if err != nil {175 if err != nil {
176 if strings.Contains(err.Error(), "Vulkan") || strings.Contains(err.Error(), "vulkan") {176 if strings.Contains(err.Error(), "Vulkan") || strings.Contains(err.Error(), "vulkan") {
177 t.Skipf("Vulkan unavailable: %v", err)177 t.Skipf("Vulkan unavailable: %v", err)
@@ -223,7 +223,7 @@ func TestHeadlessPointerClickFocus(t *testing.T) {
223 cfg := config.Defaults()223 cfg := config.Defaults()
224 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}224 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
225 scenes := make(chan []ports.Scene, 128)225 scenes := make(chan []ports.Scene, 128)
226- err := Run(context.Background(), Options{Backend: "headless", Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n")), Timeout: 5 * time.Second, testScenes: scenes})226+ err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n")), Timeout: 5 * time.Second, testScenes: scenes})
227 if err != nil {227 if err != nil {
228 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {228 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {
229 t.Skipf("Vulkan unavailable: %v", err)229 t.Skipf("Vulkan unavailable: %v", err)
@@ -253,7 +253,7 @@ func TestBlockedScriptShutdown(t *testing.T) {
253 defer w.Close()253 defer w.Close()
254 done := make(chan error, 1)254 done := make(chan error, 1)
255 go func() {255 go func() {
256- done <- Run(context.Background(), Options{Backend: "headless", Config: config.Defaults(), NoTerminal: true, Script: r, Timeout: 300 * time.Millisecond})256+ done <- Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true, Script: r, Timeout: 300 * time.Millisecond})
257 }()257 }()
258 select {258 select {
259 case <-done:259 case <-done:
@@ -316,7 +316,7 @@ func TestHeadlessTwoOutputs(t *testing.T) {
316 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())316 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
317 scenes := make(chan []ports.Scene, 64)317 scenes := make(chan []ports.Scene, 64)
318 shots := t.TempDir()318 shots := t.TempDir()
319- err := Run(context.Background(), Options{Backend: "headless", Config: config.Defaults(), NoTerminal: true, ScreenshotDir: shots, Sizes: [][2]int{{640, 480}, {320, 240}}, Timeout: 2 * time.Second, testScenes: scenes})319+ err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true, ScreenshotDir: shots, Sizes: [][2]int{{640, 480}, {320, 240}}, Timeout: 2 * time.Second, testScenes: scenes})
320 if err != nil {320 if err != nil {
321 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {321 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {
322 t.Skipf("Vulkan unavailable: %v", err)322 t.Skipf("Vulkan unavailable: %v", err)
M internal/ports/config.go +3 -0
@@ -28,6 +28,9 @@ type Config struct {
2828 CmdKey string
2929 }
3030 Terminal struct{ Command []string }
31+ // Xwayland is the xwayland-satellite binary serving X11 clients; empty
32+ // disables X11.
33+ Xwayland string
3134 // Startup are commands run once when the session starts, in order.
3235 Startup [][]string
3336 Background struct{ Color string }
@@ -28,6 +28,9 @@ type Config struct {
28 CmdKey string28 CmdKey string
29 }29 }
30 Terminal struct{ Command []string }30 Terminal struct{ Command []string }
31+ // Xwayland is the xwayland-satellite binary serving X11 clients; empty
32+ // disables X11.
33+ Xwayland string
31 // Startup are commands run once when the session starts, in order.34 // Startup are commands run once when the session starts, in order.
32 Startup [][]string35 Startup [][]string
33 Background struct{ Color string }36 Background struct{ Color string }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 26

Tags 10

↑↓ moveenter openesc close