detached 4818522

feat(xwayland): on-demand xwayland-satellite for X11 apps

commit
481852298d3a04a1165f83726463609b6ab0d10a
author
brice · · 2026-09-25 12:55 UTC
parent
5ebaa37
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +431 -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`.
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`.
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 +243 -0
@@ -0,0 +1,243 @@
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+ "strconv"
14+ "syscall"
15+ "time"
16+
17+ "github.com/bnema/zerowrap"
18+ "golang.org/x/sys/unix"
19+)
20+
21+// Options configures the X11 display.
22+type Options struct {
23+ // Binary is the xwayland-satellite executable.
24+ Binary string
25+ // Env is the satellite's environment; it must hold WAYLAND_DISPLAY.
26+ Env []string
27+ // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix
28+ // and /tmp outside tests.
29+ Dir, TmpDir string
30+ // Abstract also listens on the abstract socket, as X servers do.
31+ Abstract bool
32+ Log zerowrap.Logger
33+}
34+
35+// Display is an X11 display served by xwayland-satellite.
36+type Display struct {
37+ opts Options
38+ n int
39+ sockets []*os.File // unix socket, then the abstract one
40+ socket string
41+ lock string
42+}
43+
44+// Supported reports whether the binary can take over listening sockets
45+// (xwayland-satellite 0.7 and later).
46+func Supported(binary string) bool {
47+ cmd := exec.Command(binary, ":0", "--test-listenfd-support")
48+ cmd.Env = []string{}
49+ return cmd.Run() == nil
50+}
51+
52+// Open reserves the first free display number and listens on its sockets.
53+func Open(opts Options) (*Display, error) {
54+ if err := ensureDir(opts.Dir, opts.TmpDir); err != nil {
55+ return nil, err
56+ }
57+ for n := 0; n < 50; n++ {
58+ lock := filepath.Join(opts.TmpDir, fmt.Sprintf(".X%d-lock", n))
59+ fd, err := unix.Open(lock, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC, 0o444)
60+ if err != nil {
61+ continue
62+ }
63+ _, err = unix.Write(fd, fmt.Appendf(nil, "%10d\n", os.Getpid()))
64+ unix.Close(fd)
65+ if err != nil {
66+ _ = os.Remove(lock)
67+ return nil, fmt.Errorf("write X11 lock: %w", err)
68+ }
69+ d := &Display{opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}
70+ if err := d.listen(); err != nil {
71+ d.Close()
72+ continue
73+ }
74+ return d, nil
75+ }
76+ return nil, errors.New("no free X11 display")
77+}
78+
79+// ensureDir creates the X11 socket directory, or checks an existing one is
80+// shared and sticky like /tmp.
81+func ensureDir(dir, tmp string) error {
82+ err := unix.Mkdir(dir, 0o1777)
83+ if err == nil {
84+ // The umask may have dropped bits.
85+ return unix.Chmod(dir, 0o1777)
86+ }
87+ if !errors.Is(err, unix.EEXIST) {
88+ return fmt.Errorf("create %s: %w", dir, err)
89+ }
90+ var st, tst unix.Stat_t
91+ if err := unix.Lstat(dir, &st); err != nil {
92+ return err
93+ }
94+ if err := unix.Lstat(tmp, &tst); err != nil {
95+ return err
96+ }
97+ if st.Uid != tst.Uid && st.Uid != uint32(os.Getuid()) {
98+ return fmt.Errorf("%s has the wrong owner", dir)
99+ }
100+ if st.Mode&0o022 != 0o022 || st.Mode&0o1000 == 0 {
101+ return fmt.Errorf("%s must be world-writable and sticky", dir)
102+ }
103+ return nil
104+}
105+
106+func (d *Display) listen() error {
107+ names := []string{d.socket}
108+ if d.opts.Abstract {
109+ names = append(names, "@"+d.socket)
110+ }
111+ for _, name := range names {
112+ if name == d.socket {
113+ _ = os.Remove(name) // a stale socket without a lock
114+ }
115+ fd, err := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
116+ if err != nil {
117+ return err
118+ }
119+ f := os.NewFile(uintptr(fd), name)
120+ addr := name
121+ if addr[0] == '@' {
122+ addr = "\x00" + addr[1:]
123+ }
124+ if err := unix.Bind(fd, &unix.SockaddrUnix{Name: addr}); err != nil {
125+ f.Close()
126+ return fmt.Errorf("bind %s: %w", name, err)
127+ }
128+ d.sockets = append(d.sockets, f)
129+ if err := unix.Listen(fd, 128); err != nil {
130+ return fmt.Errorf("listen %s: %w", name, err)
131+ }
132+ }
133+ return nil
134+}
135+
136+// Name is the DISPLAY value, such as ":1".
137+func (d *Display) Name() string { return ":" + strconv.Itoa(d.n) }
138+
139+// Close stops listening and removes the socket and lock files.
140+func (d *Display) Close() {
141+ for _, f := range d.sockets {
142+ f.Close()
143+ }
144+ if len(d.sockets) > 0 {
145+ _ = os.Remove(d.socket)
146+ }
147+ d.sockets = nil
148+ _ = os.Remove(d.lock)
149+}
150+
151+// Run starts the satellite whenever an X11 client connects while it is not
152+// running, until ctx ends. The satellite takes over the sockets and accepts
153+// the waiting clients itself.
154+func (d *Display) Run(ctx context.Context) error {
155+ for {
156+ if err := d.waitClient(ctx); err != nil {
157+ return nil
158+ }
159+ start := time.Now()
160+ err := d.serve(ctx)
161+ if ctx.Err() != nil {
162+ return nil
163+ }
164+ d.opts.Log.Info().Err(err).Str("display", d.Name()).Msg("xwayland-satellite exited")
165+ // Clients the satellite never accepted would wake us at once: a
166+ // satellite that cannot start must not respawn in a loop.
167+ d.dropPending()
168+ if time.Since(start) < time.Second {
169+ select {
170+ case <-ctx.Done():
171+ return nil
172+ case <-time.After(time.Second):
173+ }
174+ }
175+ }
176+}
177+
178+// waitClient blocks until a socket has a pending connection.
179+func (d *Display) waitClient(ctx context.Context) error {
180+ fds := make([]unix.PollFd, len(d.sockets))
181+ for i, f := range d.sockets {
182+ fds[i] = unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN}
183+ }
184+ for ctx.Err() == nil {
185+ n, err := unix.Poll(fds, 200)
186+ if err != nil && !errors.Is(err, unix.EINTR) {
187+ return err
188+ }
189+ if n > 0 {
190+ return nil
191+ }
192+ }
193+ return ctx.Err()
194+}
195+
196+// serve runs the satellite on the sockets until it exits or ctx ends.
197+func (d *Display) serve(ctx context.Context) error {
198+ args := []string{d.Name()}
199+ for i := range d.sockets {
200+ args = append(args, "-listenfd", strconv.Itoa(3+i))
201+ }
202+ cmd := exec.Command(d.opts.Binary, args...)
203+ cmd.Env = d.opts.Env
204+ cmd.ExtraFiles = d.sockets
205+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
206+ if err := cmd.Start(); err != nil {
207+ return err
208+ }
209+ d.opts.Log.Info().Str("display", d.Name()).Int("pid", cmd.Process.Pid).Msg("xwayland-satellite started")
210+ done := make(chan error, 1)
211+ go func() { done <- cmd.Wait() }()
212+ select {
213+ case err := <-done:
214+ return err
215+ case <-ctx.Done():
216+ _ = cmd.Process.Signal(syscall.SIGTERM)
217+ select {
218+ case <-done:
219+ case <-time.After(2 * time.Second):
220+ _ = cmd.Process.Kill()
221+ <-done
222+ }
223+ return ctx.Err()
224+ }
225+}
226+
227+// dropPending accepts and closes the connections waiting on the sockets.
228+func (d *Display) dropPending() {
229+ for _, f := range d.sockets {
230+ fd := int(f.Fd())
231+ if unix.SetNonblock(fd, true) != nil {
232+ continue
233+ }
234+ for {
235+ nfd, _, err := unix.Accept4(fd, unix.SOCK_CLOEXEC)
236+ if err != nil {
237+ break
238+ }
239+ unix.Close(nfd)
240+ }
241+ _ = unix.SetNonblock(fd, false)
242+ }
243+}
@@ -0,0 +1,243 @@
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+ "strconv"
14+ "syscall"
15+ "time"
16+
17+ "github.com/bnema/zerowrap"
18+ "golang.org/x/sys/unix"
19+)
20+
21+// Options configures the X11 display.
22+type Options struct {
23+ // Binary is the xwayland-satellite executable.
24+ Binary string
25+ // Env is the satellite's environment; it must hold WAYLAND_DISPLAY.
26+ Env []string
27+ // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix
28+ // and /tmp outside tests.
29+ Dir, TmpDir string
30+ // Abstract also listens on the abstract socket, as X servers do.
31+ Abstract bool
32+ Log zerowrap.Logger
33+}
34+
35+// Display is an X11 display served by xwayland-satellite.
36+type Display struct {
37+ opts Options
38+ n int
39+ sockets []*os.File // unix socket, then the abstract one
40+ socket string
41+ lock string
42+}
43+
44+// Supported reports whether the binary can take over listening sockets
45+// (xwayland-satellite 0.7 and later).
46+func Supported(binary string) bool {
47+ cmd := exec.Command(binary, ":0", "--test-listenfd-support")
48+ cmd.Env = []string{}
49+ return cmd.Run() == nil
50+}
51+
52+// Open reserves the first free display number and listens on its sockets.
53+func Open(opts Options) (*Display, error) {
54+ if err := ensureDir(opts.Dir, opts.TmpDir); err != nil {
55+ return nil, err
56+ }
57+ for n := 0; n < 50; n++ {
58+ lock := filepath.Join(opts.TmpDir, fmt.Sprintf(".X%d-lock", n))
59+ fd, err := unix.Open(lock, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC, 0o444)
60+ if err != nil {
61+ continue
62+ }
63+ _, err = unix.Write(fd, fmt.Appendf(nil, "%10d\n", os.Getpid()))
64+ unix.Close(fd)
65+ if err != nil {
66+ _ = os.Remove(lock)
67+ return nil, fmt.Errorf("write X11 lock: %w", err)
68+ }
69+ d := &Display{opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}
70+ if err := d.listen(); err != nil {
71+ d.Close()
72+ continue
73+ }
74+ return d, nil
75+ }
76+ return nil, errors.New("no free X11 display")
77+}
78+
79+// ensureDir creates the X11 socket directory, or checks an existing one is
80+// shared and sticky like /tmp.
81+func ensureDir(dir, tmp string) error {
82+ err := unix.Mkdir(dir, 0o1777)
83+ if err == nil {
84+ // The umask may have dropped bits.
85+ return unix.Chmod(dir, 0o1777)
86+ }
87+ if !errors.Is(err, unix.EEXIST) {
88+ return fmt.Errorf("create %s: %w", dir, err)
89+ }
90+ var st, tst unix.Stat_t
91+ if err := unix.Lstat(dir, &st); err != nil {
92+ return err
93+ }
94+ if err := unix.Lstat(tmp, &tst); err != nil {
95+ return err
96+ }
97+ if st.Uid != tst.Uid && st.Uid != uint32(os.Getuid()) {
98+ return fmt.Errorf("%s has the wrong owner", dir)
99+ }
100+ if st.Mode&0o022 != 0o022 || st.Mode&0o1000 == 0 {
101+ return fmt.Errorf("%s must be world-writable and sticky", dir)
102+ }
103+ return nil
104+}
105+
106+func (d *Display) listen() error {
107+ names := []string{d.socket}
108+ if d.opts.Abstract {
109+ names = append(names, "@"+d.socket)
110+ }
111+ for _, name := range names {
112+ if name == d.socket {
113+ _ = os.Remove(name) // a stale socket without a lock
114+ }
115+ fd, err := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
116+ if err != nil {
117+ return err
118+ }
119+ f := os.NewFile(uintptr(fd), name)
120+ addr := name
121+ if addr[0] == '@' {
122+ addr = "\x00" + addr[1:]
123+ }
124+ if err := unix.Bind(fd, &unix.SockaddrUnix{Name: addr}); err != nil {
125+ f.Close()
126+ return fmt.Errorf("bind %s: %w", name, err)
127+ }
128+ d.sockets = append(d.sockets, f)
129+ if err := unix.Listen(fd, 128); err != nil {
130+ return fmt.Errorf("listen %s: %w", name, err)
131+ }
132+ }
133+ return nil
134+}
135+
136+// Name is the DISPLAY value, such as ":1".
137+func (d *Display) Name() string { return ":" + strconv.Itoa(d.n) }
138+
139+// Close stops listening and removes the socket and lock files.
140+func (d *Display) Close() {
141+ for _, f := range d.sockets {
142+ f.Close()
143+ }
144+ if len(d.sockets) > 0 {
145+ _ = os.Remove(d.socket)
146+ }
147+ d.sockets = nil
148+ _ = os.Remove(d.lock)
149+}
150+
151+// Run starts the satellite whenever an X11 client connects while it is not
152+// running, until ctx ends. The satellite takes over the sockets and accepts
153+// the waiting clients itself.
154+func (d *Display) Run(ctx context.Context) error {
155+ for {
156+ if err := d.waitClient(ctx); err != nil {
157+ return nil
158+ }
159+ start := time.Now()
160+ err := d.serve(ctx)
161+ if ctx.Err() != nil {
162+ return nil
163+ }
164+ d.opts.Log.Info().Err(err).Str("display", d.Name()).Msg("xwayland-satellite exited")
165+ // Clients the satellite never accepted would wake us at once: a
166+ // satellite that cannot start must not respawn in a loop.
167+ d.dropPending()
168+ if time.Since(start) < time.Second {
169+ select {
170+ case <-ctx.Done():
171+ return nil
172+ case <-time.After(time.Second):
173+ }
174+ }
175+ }
176+}
177+
178+// waitClient blocks until a socket has a pending connection.
179+func (d *Display) waitClient(ctx context.Context) error {
180+ fds := make([]unix.PollFd, len(d.sockets))
181+ for i, f := range d.sockets {
182+ fds[i] = unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN}
183+ }
184+ for ctx.Err() == nil {
185+ n, err := unix.Poll(fds, 200)
186+ if err != nil && !errors.Is(err, unix.EINTR) {
187+ return err
188+ }
189+ if n > 0 {
190+ return nil
191+ }
192+ }
193+ return ctx.Err()
194+}
195+
196+// serve runs the satellite on the sockets until it exits or ctx ends.
197+func (d *Display) serve(ctx context.Context) error {
198+ args := []string{d.Name()}
199+ for i := range d.sockets {
200+ args = append(args, "-listenfd", strconv.Itoa(3+i))
201+ }
202+ cmd := exec.Command(d.opts.Binary, args...)
203+ cmd.Env = d.opts.Env
204+ cmd.ExtraFiles = d.sockets
205+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
206+ if err := cmd.Start(); err != nil {
207+ return err
208+ }
209+ d.opts.Log.Info().Str("display", d.Name()).Int("pid", cmd.Process.Pid).Msg("xwayland-satellite started")
210+ done := make(chan error, 1)
211+ go func() { done <- cmd.Wait() }()
212+ select {
213+ case err := <-done:
214+ return err
215+ case <-ctx.Done():
216+ _ = cmd.Process.Signal(syscall.SIGTERM)
217+ select {
218+ case <-done:
219+ case <-time.After(2 * time.Second):
220+ _ = cmd.Process.Kill()
221+ <-done
222+ }
223+ return ctx.Err()
224+ }
225+}
226+
227+// dropPending accepts and closes the connections waiting on the sockets.
228+func (d *Display) dropPending() {
229+ for _, f := range d.sockets {
230+ fd := int(f.Fd())
231+ if unix.SetNonblock(fd, true) != nil {
232+ continue
233+ }
234+ for {
235+ nfd, _, err := unix.Accept4(fd, unix.SOCK_CLOEXEC)
236+ if err != nil {
237+ break
238+ }
239+ unix.Close(nfd)
240+ }
241+ _ = unix.SetNonblock(fd, false)
242+ }
243+}
A internal/adapters/xwayland/xwayland_test.go +104 -0
@@ -0,0 +1,104 @@
1+package xwayland
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strconv"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/nefertty/internal/logging"
13+ "golang.org/x/sys/unix"
14+)
15+
16+func testOptions(t *testing.T, binary string) Options {
17+ t.Helper()
18+ tmp := t.TempDir()
19+ if err := os.Chmod(tmp, 0o1777); err != nil {
20+ t.Fatal(err)
21+ }
22+ return Options{Binary: binary, Dir: filepath.Join(tmp, ".X11-unix"), TmpDir: tmp, Log: logging.For(context.Background(), "xwayland")}
23+}
24+
25+func dial(t *testing.T, path string) int {
26+ t.Helper()
27+ fd, err := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ if err := unix.Connect(fd, &unix.SockaddrUnix{Name: path}); err != nil {
32+ t.Fatal(err)
33+ }
34+ return fd
35+}
36+
37+func TestOpenPicksFreeDisplay(t *testing.T) {
38+ opts := testOptions(t, "true")
39+ if err := os.WriteFile(filepath.Join(opts.TmpDir, ".X0-lock"), nil, 0o444); err != nil {
40+ t.Fatal(err)
41+ }
42+ d, err := Open(opts)
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ if d.Name() != ":1" {
47+ t.Fatalf("display %s", d.Name())
48+ }
49+ lock, err := os.ReadFile(filepath.Join(opts.TmpDir, ".X1-lock"))
50+ if err != nil || strings.TrimSpace(string(lock)) != strconv.Itoa(os.Getpid()) {
51+ t.Fatalf("lock %q %v", lock, err)
52+ }
53+ st, err := os.Stat(opts.Dir)
54+ if err != nil || st.Mode()&os.ModeSticky == 0 || st.Mode().Perm() != 0o777 {
55+ t.Fatalf("dir %v %v", st.Mode(), err)
56+ }
57+ d.Close()
58+ for _, p := range []string{filepath.Join(opts.Dir, "X1"), filepath.Join(opts.TmpDir, ".X1-lock")} {
59+ if _, err := os.Stat(p); !os.IsNotExist(err) {
60+ t.Fatalf("%s left behind: %v", p, err)
61+ }
62+ }
63+}
64+
65+func TestInsecureDirRejected(t *testing.T) {
66+ opts := testOptions(t, "true")
67+ if err := os.Mkdir(opts.Dir, 0o755); err != nil {
68+ t.Fatal(err)
69+ }
70+ if _, err := Open(opts); err == nil {
71+ t.Fatal("opened a non-sticky X11 directory")
72+ }
73+}
74+
75+// A satellite that exits without accepting drops the waiting client and is
76+// not respawned until another client connects.
77+func TestRunDropsClientsOfFailedSatellite(t *testing.T) {
78+ d, err := Open(testOptions(t, "false"))
79+ if err != nil {
80+ t.Fatal(err)
81+ }
82+ defer d.Close()
83+ ctx, cancel := context.WithCancel(context.Background())
84+ done := make(chan error, 1)
85+ go func() { done <- d.Run(ctx) }()
86+ fd := dial(t, d.socket)
87+ defer unix.Close(fd)
88+ // The dropped connection reads EOF.
89+ if err := unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &unix.Timeval{Sec: 3}); err != nil {
90+ t.Fatal(err)
91+ }
92+ if n, err := unix.Read(fd, make([]byte, 1)); n != 0 || err != nil {
93+ t.Fatalf("read %d %v, want EOF", n, err)
94+ }
95+ cancel()
96+ select {
97+ case err := <-done:
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ case <-time.After(3 * time.Second):
102+ t.Fatal("Run did not stop")
103+ }
104+}
@@ -0,0 +1,104 @@
1+package xwayland
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strconv"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/nefertty/internal/logging"
13+ "golang.org/x/sys/unix"
14+)
15+
16+func testOptions(t *testing.T, binary string) Options {
17+ t.Helper()
18+ tmp := t.TempDir()
19+ if err := os.Chmod(tmp, 0o1777); err != nil {
20+ t.Fatal(err)
21+ }
22+ return Options{Binary: binary, Dir: filepath.Join(tmp, ".X11-unix"), TmpDir: tmp, Log: logging.For(context.Background(), "xwayland")}
23+}
24+
25+func dial(t *testing.T, path string) int {
26+ t.Helper()
27+ fd, err := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ if err := unix.Connect(fd, &unix.SockaddrUnix{Name: path}); err != nil {
32+ t.Fatal(err)
33+ }
34+ return fd
35+}
36+
37+func TestOpenPicksFreeDisplay(t *testing.T) {
38+ opts := testOptions(t, "true")
39+ if err := os.WriteFile(filepath.Join(opts.TmpDir, ".X0-lock"), nil, 0o444); err != nil {
40+ t.Fatal(err)
41+ }
42+ d, err := Open(opts)
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ if d.Name() != ":1" {
47+ t.Fatalf("display %s", d.Name())
48+ }
49+ lock, err := os.ReadFile(filepath.Join(opts.TmpDir, ".X1-lock"))
50+ if err != nil || strings.TrimSpace(string(lock)) != strconv.Itoa(os.Getpid()) {
51+ t.Fatalf("lock %q %v", lock, err)
52+ }
53+ st, err := os.Stat(opts.Dir)
54+ if err != nil || st.Mode()&os.ModeSticky == 0 || st.Mode().Perm() != 0o777 {
55+ t.Fatalf("dir %v %v", st.Mode(), err)
56+ }
57+ d.Close()
58+ for _, p := range []string{filepath.Join(opts.Dir, "X1"), filepath.Join(opts.TmpDir, ".X1-lock")} {
59+ if _, err := os.Stat(p); !os.IsNotExist(err) {
60+ t.Fatalf("%s left behind: %v", p, err)
61+ }
62+ }
63+}
64+
65+func TestInsecureDirRejected(t *testing.T) {
66+ opts := testOptions(t, "true")
67+ if err := os.Mkdir(opts.Dir, 0o755); err != nil {
68+ t.Fatal(err)
69+ }
70+ if _, err := Open(opts); err == nil {
71+ t.Fatal("opened a non-sticky X11 directory")
72+ }
73+}
74+
75+// A satellite that exits without accepting drops the waiting client and is
76+// not respawned until another client connects.
77+func TestRunDropsClientsOfFailedSatellite(t *testing.T) {
78+ d, err := Open(testOptions(t, "false"))
79+ if err != nil {
80+ t.Fatal(err)
81+ }
82+ defer d.Close()
83+ ctx, cancel := context.WithCancel(context.Background())
84+ done := make(chan error, 1)
85+ go func() { done <- d.Run(ctx) }()
86+ fd := dial(t, d.socket)
87+ defer unix.Close(fd)
88+ // The dropped connection reads EOF.
89+ if err := unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &unix.Timeval{Sec: 3}); err != nil {
90+ t.Fatal(err)
91+ }
92+ if n, err := unix.Read(fd, make([]byte, 1)); n != 0 || err != nil {
93+ t.Fatalf("read %d %v, want EOF", n, err)
94+ }
95+ cancel()
96+ select {
97+ case err := <-done:
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ case <-time.After(3 * time.Second):
102+ t.Fatal("Run did not stop")
103+ }
104+}
M internal/app/app.go +37 -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(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,10 @@ 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+ go func() { defer workers.Done(); _ = xdisplay.Run(ctx) }()
185+ }
174186 if statePath != "" {
175187 workers.Add(1)
176188 go func() {
@@ -332,3 +344,23 @@ func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configError
332344 }
333345 }
334346 }
347+
348+// openXwayland reserves an X11 display for xwayland-satellite, or returns
349+// nil when X11 is off or unavailable: the session runs without it.
350+func openXwayland(opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
351+ bin := opts.Config.Xwayland
352+ if bin == "" || opts.NoXwayland {
353+ return nil
354+ }
355+ if !xwayland.Supported(bin) {
356+ log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
357+ return nil
358+ }
359+ d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
360+ if err != nil {
361+ log.Warn().Err(err).Msg("X11 display disabled")
362+ return nil
363+ }
364+ log.Info().Str("DISPLAY", d.Name()).Msg("listening on X11 display")
365+ return d
366+}
@@ -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(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,10 @@ 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+ go func() { defer workers.Done(); _ = xdisplay.Run(ctx) }()
185+ }
174 if statePath != "" {186 if statePath != "" {
175 workers.Add(1)187 workers.Add(1)
176 go func() {188 go func() {
@@ -332,3 +344,23 @@ func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configError
332 }344 }
333 }345 }
334 }346 }
347+
348+// openXwayland reserves an X11 display for xwayland-satellite, or returns
349+// nil when X11 is off or unavailable: the session runs without it.
350+func openXwayland(opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
351+ bin := opts.Config.Xwayland
352+ if bin == "" || opts.NoXwayland {
353+ return nil
354+ }
355+ if !xwayland.Supported(bin) {
356+ log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
357+ return nil
358+ }
359+ d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
360+ if err != nil {
361+ log.Warn().Err(err).Msg("X11 display disabled")
362+ return nil
363+ }
364+ log.Info().Str("DISPLAY", d.Name()).Msg("listening on X11 display")
365+ return d
366+}
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 23

Tags 10

↑↓ moveenter openesc close