feat(xwayland): on-demand xwayland-satellite for X11 apps
- commit
- 481852298d3a04a1165f83726463609b6ab0d10a
- author
- brice · · 2026-09-25 12:55 UTC
- parent
- 5ebaa37
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 61 | 61 | ||
| 62 | 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 | 66 | ## Clipboard | |
| 67 | 67 | ||
| @@ -81,6 +81,17 @@ cliphist list | fuzzel --dmenu | cliphist decode | wl-copy | |||
| 81 | 81 | ||
| 82 | 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 | 95 | ## State for scripts | |
| 85 | 96 | ||
| 86 | 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. | |
| @@ -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 | ## Clipboard | 66 | ## 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 scripts | 95 | ## 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 { | |||
| 112 | 112 | size := flags.String("size", "1920x1080", "headless output sizes WxH, comma-separated for several outputs") | |
| 113 | 113 | inputPath := flags.String("input", "", "headless input script path (- for stdin)") | |
| 114 | 114 | noTerminal := flags.Bool("no-terminal", false, "skip initial terminal") | |
| 115 | + | noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps") | |
| 115 | 116 | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | |
| 116 | 117 | debugFlag := flags.String("debug", "", "debug components (comma-separated or all)") | |
| 117 | 118 | configFlag := flags.String("config", "", "config path (empty uses XDG default)") | |
| @@ -220,7 +221,7 @@ func run() error { | |||
| 220 | 221 | for _, w := range warnings { | |
| 221 | 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 | 225 | // SIGINT and SIGTERM cancel the context and are clean exits. | |
| 225 | 226 | if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) { | |
| 226 | 227 | 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 = nil | 227 | err = nil | ||
M internal/adapters/config/config.go +10 -0
| @@ -78,6 +78,7 @@ func Defaults() ports.Config { | |||
| 78 | 78 | c.Keyboard.RepeatDelay = 600 | |
| 79 | 79 | c.Keyboard.CmdKey = "super" | |
| 80 | 80 | c.Terminal.Command = []string{"foot"} | |
| 81 | + | c.Xwayland = "xwayland-satellite" | |
| 81 | 82 | c.Background.Color = "#111111" | |
| 82 | 83 | c.Border.Width = 2 | |
| 83 | 84 | c.Border.Active = "#b4befe" | |
| @@ -388,6 +389,15 @@ func set(c *ports.Config, key, v string) error { | |||
| 388 | 389 | return fmt.Errorf("must not be empty") | |
| 389 | 390 | } | |
| 390 | 391 | 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 | 401 | case "background": | |
| 392 | 402 | if !color.MatchString(v) { | |
| 393 | 403 | return fmt.Errorf("must be #rrggbb") | |
| @@ -78,6 +78,7 @@ func Defaults() ports.Config { | |||||
| 78 | c.Keyboard.RepeatDelay = 600 | 78 | 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 = 2 | 83 | 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 = argv | 391 | 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) { | |||
| 91 | 91 | {"keyboard.repeat-rate = 0", "keyboard.repeat-rate"}, | |
| 92 | 92 | {"keyboard.cmd = meta", "keyboard.cmd"}, | |
| 93 | 93 | {"terminal =", "terminal"}, | |
| 94 | + | {"xwayland = a b", "xwayland"}, | |
| 94 | 95 | {"background = red", "background"}, | |
| 95 | 96 | {"layout.gaps = 201", "layout.gaps"}, | |
| 96 | 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 | 140 | func TestDuplicateKeyLastWins(t *testing.T) { | |
| 128 | 141 | c, w := parseString(t, "background = #000000\nbackground = #111111\n") | |
| 129 | 142 | 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 ( | |||
| 20 | 20 | "github.com/bnema/nefertty/internal/adapters/vulkan" | |
| 21 | 21 | "github.com/bnema/nefertty/internal/adapters/wayland" | |
| 22 | 22 | "github.com/bnema/nefertty/internal/adapters/xkb" | |
| 23 | + | "github.com/bnema/nefertty/internal/adapters/xwayland" | |
| 23 | 24 | "github.com/bnema/nefertty/internal/core" | |
| 24 | 25 | "github.com/bnema/nefertty/internal/logging" | |
| 25 | 26 | "github.com/bnema/nefertty/internal/ports" | |
| @@ -27,11 +28,13 @@ import ( | |||
| 27 | 28 | ) | |
| 28 | 29 | ||
| 29 | 30 | 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 | |
| 35 | 38 | ScreenshotDir string | |
| 36 | 39 | // Sizes are the headless outputs (width, height), left to right; empty | |
| 37 | 40 | // 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 | 110 | } else { | |
| 108 | 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 | 118 | child := launcher.New(childEnv, logging.For(ctx, "launcher")) | |
| 111 | 119 | if inject != nil { | |
| 112 | 120 | inject(input) | |
| @@ -171,6 +179,10 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 171 | 179 | go func() { defer workers.Done(); done <- server.Run(ctx) }() | |
| 172 | 180 | go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }() | |
| 173 | 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 | 186 | if statePath != "" { | |
| 175 | 187 | workers.Add(1) | |
| 176 | 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 | + | } | |
| @@ -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 string | 31 | + | Backend string |
| 31 | - | Config ports.Config | 32 | + | Config ports.Config |
| 32 | - | ConfigPath string | 33 | + | ConfigPath string |
| 33 | - | Timeout time.Duration | 34 | + | Timeout time.Duration |
| 34 | - | NoTerminal bool | 35 | + | NoTerminal bool |
| 36 | + | // NoXwayland skips the X11 display even when the config enables it. | |||
| 37 | + | NoXwayland bool | |||
| 35 | ScreenshotDir string | 38 | ScreenshotDir string | ||
| 36 | // Sizes are the headless outputs (width, height), left to right; empty | 39 | // Sizes are the headless outputs (width, height), left to right; empty | ||
| 37 | // means one 1920x1080 output. With several outputs, screenshots go to | 40 | // 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) { | |||
| 33 | 33 | ctx, cancel := context.WithCancel(context.Background()) | |
| 34 | 34 | done := make(chan error, 1) | |
| 35 | 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 | 38 | defer func() { | |
| 39 | 39 | cancel() | |
| @@ -86,7 +86,7 @@ func TestHeadlessConfigReload(t *testing.T) { | |||
| 86 | 86 | func TestQuitJoinsWorkers(t *testing.T) { | |
| 87 | 87 | done := make(chan error, 1) | |
| 88 | 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 | 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 | 114 | inputReady := make(chan chan<- ports.InputEvent, 1) | |
| 115 | 115 | done := make(chan error, 1) | |
| 116 | 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 | 119 | var input chan<- ports.InputEvent | |
| 120 | 120 | select { | |
| @@ -171,7 +171,7 @@ func TestHeadlessTyping(t *testing.T) { | |||
| 171 | 171 | cfg := config.Defaults() | |
| 172 | 172 | cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"} | |
| 173 | 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 | 175 | if err != nil { | |
| 176 | 176 | if strings.Contains(err.Error(), "Vulkan") || strings.Contains(err.Error(), "vulkan") { | |
| 177 | 177 | t.Skipf("Vulkan unavailable: %v", err) | |
| @@ -223,7 +223,7 @@ func TestHeadlessPointerClickFocus(t *testing.T) { | |||
| 223 | 223 | cfg := config.Defaults() | |
| 224 | 224 | cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"} | |
| 225 | 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 | 227 | if err != nil { | |
| 228 | 228 | if strings.Contains(strings.ToLower(err.Error()), "vulkan") { | |
| 229 | 229 | t.Skipf("Vulkan unavailable: %v", err) | |
| @@ -253,7 +253,7 @@ func TestBlockedScriptShutdown(t *testing.T) { | |||
| 253 | 253 | defer w.Close() | |
| 254 | 254 | done := make(chan error, 1) | |
| 255 | 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 | 258 | select { | |
| 259 | 259 | case <-done: | |
| @@ -316,7 +316,7 @@ func TestHeadlessTwoOutputs(t *testing.T) { | |||
| 316 | 316 | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |
| 317 | 317 | scenes := make(chan []ports.Scene, 64) | |
| 318 | 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 | 320 | if err != nil { | |
| 321 | 321 | if strings.Contains(strings.ToLower(err.Error()), "vulkan") { | |
| 322 | 322 | 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.InputEvent | 119 | 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 { | |||
| 28 | 28 | CmdKey string | |
| 29 | 29 | } | |
| 30 | 30 | Terminal struct{ Command []string } | |
| 31 | + | // Xwayland is the xwayland-satellite binary serving X11 clients; empty | |
| 32 | + | // disables X11. | |
| 33 | + | Xwayland string | |
| 31 | 34 | // Startup are commands run once when the session starts, in order. | |
| 32 | 35 | Startup [][]string | |
| 33 | 36 | Background struct{ Color string } | |
| @@ -28,6 +28,9 @@ type Config struct { | |||||
| 28 | CmdKey string | 28 | 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 [][]string | 35 | Startup [][]string | ||
| 33 | Background struct{ Color string } | 36 | Background struct{ Color string } | ||