detached 1bd45a1

Merge 1912da36439ef2fcf477d4e7176bb721d5169813 into 48800da9adb0579d631006563138d4866e2dff4e

commit
1bd45a170d12983784647cf1177d220dd308b0f0
author
ermite · · 2026-10-22 06:23 UTC
merge of
48800da + 1912da3 · diff against the first parent
signature
unsigned
15 files · +649 -40
M .mockery.yml +8 -0
@@ -172,3 +172,11 @@ packages:
172172 dir: internal/adapters/screencast
173173 pkgname: screencast
174174 filename: pipewire_opener_mock_test.go
175+
176+ github.com/bnema/neferwl/internal/adapters/xwayland:
177+ interfaces:
178+ starter:
179+ config:
180+ dir: internal/adapters/xwayland
181+ pkgname: xwayland
182+ filename: starter_mock_test.go
@@ -172,3 +172,11 @@ packages:
172 dir: internal/adapters/screencast172 dir: internal/adapters/screencast
173 pkgname: screencast173 pkgname: screencast
174 filename: pipewire_opener_mock_test.go174 filename: pipewire_opener_mock_test.go
175+
176+ github.com/bnema/neferwl/internal/adapters/xwayland:
177+ interfaces:
178+ starter:
179+ config:
180+ dir: internal/adapters/xwayland
181+ pkgname: xwayland
182+ filename: starter_mock_test.go
M docs/config.md +3 -1
@@ -27,7 +27,7 @@ See [examples/config](../examples/config) for every key with its default.
2727 | `terminal` | `$TERMINAL` or `foot` | Terminal command |
2828 | `terminal.auto-open` | `first` | Open a terminal on the first workspace (`first`), on every empty one (`all`), or never (`off`) |
2929 | `startup` | none | Commands run at session start, separated by `;` |
30-| `xwayland` | `xwayland-satellite` | X11 support; `off` disables it |
30+| `xwayland` | `satellite` | `off`, `satellite`, `builtin` or a satellite binary path |
3131 | `background` | `#111111` | Background color |
3232 | `border.radius` | `0` | Corner radius in logical pixels, `0` to `64`; floats always, tiles only with gaps |
3333 | `border.width` | `2` | Border width in pixels; `0` hides borders |
@@ -103,6 +103,8 @@ destination output, even if that output has no windows. Same-screen workspace
103103 switches and mouse focus do not trigger a flash.
104104 It runs when `animations = on`.
105105
106+`xwayland = satellite` uses `xwayland-satellite` from `PATH`; a custom path also selects satellite. `off` disables X11. `builtin` (experimental): neferwl starts `Xwayland` itself. It does not manage X11 windows yet; use `satellite` for X11 apps.
107+
106108 ## Automatic terminal
107109
108110 The command comes from `terminal`, then `$TERMINAL`, then `foot`.
@@ -27,7 +27,7 @@ See [examples/config](../examples/config) for every key with its default.
27 | `terminal` | `$TERMINAL` or `foot` | Terminal command |27 | `terminal` | `$TERMINAL` or `foot` | Terminal command |
28 | `terminal.auto-open` | `first` | Open a terminal on the first workspace (`first`), on every empty one (`all`), or never (`off`) |28 | `terminal.auto-open` | `first` | Open a terminal on the first workspace (`first`), on every empty one (`all`), or never (`off`) |
29 | `startup` | none | Commands run at session start, separated by `;` |29 | `startup` | none | Commands run at session start, separated by `;` |
30-| `xwayland` | `xwayland-satellite` | X11 support; `off` disables it |30+| `xwayland` | `satellite` | `off`, `satellite`, `builtin` or a satellite binary path |
31 | `background` | `#111111` | Background color |31 | `background` | `#111111` | Background color |
32 | `border.radius` | `0` | Corner radius in logical pixels, `0` to `64`; floats always, tiles only with gaps |32 | `border.radius` | `0` | Corner radius in logical pixels, `0` to `64`; floats always, tiles only with gaps |
33 | `border.width` | `2` | Border width in pixels; `0` hides borders |33 | `border.width` | `2` | Border width in pixels; `0` hides borders |
@@ -103,6 +103,8 @@ destination output, even if that output has no windows. Same-screen workspace
103 switches and mouse focus do not trigger a flash.103 switches and mouse focus do not trigger a flash.
104 It runs when `animations = on`.104 It runs when `animations = on`.
105 105
106+`xwayland = satellite` uses `xwayland-satellite` from `PATH`; a custom path also selects satellite. `off` disables X11. `builtin` (experimental): neferwl starts `Xwayland` itself. It does not manage X11 windows yet; use `satellite` for X11 apps.
107+
106 ## Automatic terminal108 ## Automatic terminal
107 109
108 The command comes from `terminal`, then `$TERMINAL`, then `foot`.110 The command comes from `terminal`, then `$TERMINAL`, then `foot`.
M docs/desktop.md +3 -1
@@ -52,12 +52,14 @@ Candidate popups are not drawn yet.
5252
5353 ## X11 apps
5454
55-X11 apps such as Steam run through [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7+, found in `PATH`. NeferWL starts it when the first X11 app connects; do not start it yourself.
55+By default, X11 apps such as Steam run through [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7+, found in `PATH`. NeferWL starts it when the first X11 app connects; do not start it yourself.
5656
5757 ```text
5858 xwayland = off # no X11 support
5959 ```
6060
61+With `xwayland = builtin` (experimental), neferwl starts `Xwayland` itself. It does not manage X11 windows yet; use `satellite` for X11 apps.
62+
6163 ## Session locking
6264
6365 NeferWL supports `ext-session-lock-v1` with an external locker such as swaylock or hyprlock. The locker checks the password; NeferWL hides the desktop and blocks input.
@@ -52,12 +52,14 @@ Candidate popups are not drawn yet.
52 52
53 ## X11 apps53 ## X11 apps
54 54
55-X11 apps such as Steam run through [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7+, found in `PATH`. NeferWL starts it when the first X11 app connects; do not start it yourself.55+By default, X11 apps such as Steam run through [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7+, found in `PATH`. NeferWL starts it when the first X11 app connects; do not start it yourself.
56 56
57 ```text57 ```text
58 xwayland = off # no X11 support58 xwayland = off # no X11 support
59 ```59 ```
60 60
61+With `xwayland = builtin` (experimental), neferwl starts `Xwayland` itself. It does not manage X11 windows yet; use `satellite` for X11 apps.
62+
61 ## Session locking63 ## Session locking
62 64
63 NeferWL supports `ext-session-lock-v1` with an external locker such as swaylock or hyprlock. The locker checks the password; NeferWL hides the desktop and blocks input.65 NeferWL supports `ext-session-lock-v1` with an external locker such as swaylock or hyprlock. The locker checks the password; NeferWL hides the desktop and blocks input.
M internal/adapters/config/config.go +8 -4
@@ -167,7 +167,7 @@ func Defaults() ports.Config {
167167 // Hint mode is experimental: off unless the config turns it on.
168168 c.Hint.Enable = false
169169 c.Hint.TextClick = ports.ModCtrl | ports.ModShift
170- c.Xwayland = "xwayland-satellite"
170+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite"}
171171 c.Background.Color = "#111111"
172172 c.Floating.Dim = 0.3
173173 c.Stash.Width = 80
@@ -726,11 +726,15 @@ func set(c *ports.Config, key, v string) error {
726726 case "xwayland":
727727 switch {
728728 case v == "off":
729- c.Xwayland = ""
729+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandOff}
730+ case v == "satellite":
731+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite"}
732+ case v == "builtin":
733+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland"}
730734 case v == "" || strings.ContainsAny(v, " \t"):
731- return fmt.Errorf("must be off or the xwayland-satellite program")
735+ return fmt.Errorf("must be off, satellite, builtin or a satellite program")
732736 default:
733- c.Xwayland = v
737+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: v}
734738 }
735739 case "background":
736740 if !color.MatchString(v) {
@@ -167,7 +167,7 @@ func Defaults() ports.Config {
167 // Hint mode is experimental: off unless the config turns it on.167 // Hint mode is experimental: off unless the config turns it on.
168 c.Hint.Enable = false168 c.Hint.Enable = false
169 c.Hint.TextClick = ports.ModCtrl | ports.ModShift169 c.Hint.TextClick = ports.ModCtrl | ports.ModShift
170- c.Xwayland = "xwayland-satellite"170+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite"}
171 c.Background.Color = "#111111"171 c.Background.Color = "#111111"
172 c.Floating.Dim = 0.3172 c.Floating.Dim = 0.3
173 c.Stash.Width = 80173 c.Stash.Width = 80
@@ -726,11 +726,15 @@ func set(c *ports.Config, key, v string) error {
726 case "xwayland":726 case "xwayland":
727 switch {727 switch {
728 case v == "off":728 case v == "off":
729- c.Xwayland = ""729+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandOff}
730+ case v == "satellite":
731+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite"}
732+ case v == "builtin":
733+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland"}
730 case v == "" || strings.ContainsAny(v, " \t"):734 case v == "" || strings.ContainsAny(v, " \t"):
731- return fmt.Errorf("must be off or the xwayland-satellite program")735+ return fmt.Errorf("must be off, satellite, builtin or a satellite program")
732 default:736 default:
733- c.Xwayland = v737+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: v}
734 }738 }
735 case "background":739 case "background":
736 if !color.MatchString(v) {740 if !color.MatchString(v) {
M internal/adapters/config/config_test.go +16 -6
@@ -340,14 +340,24 @@ func TestWorkspacePrevNextAction(t *testing.T) {
340340 }
341341
342342 func TestXwayland(t *testing.T) {
343- if Defaults().Xwayland != "xwayland-satellite" {
343+ if Defaults().Xwayland != (ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite"}) {
344344 t.Fatal(Defaults().Xwayland)
345345 }
346- if c, w := parseString(t, "xwayland = off"); c.Xwayland != "" || len(w) != 0 {
347- t.Fatal(c.Xwayland, w)
348- }
349- if c, w := parseString(t, "xwayland = /opt/xwls"); c.Xwayland != "/opt/xwls" || len(w) != 0 {
350- t.Fatal(c.Xwayland, w)
346+ for _, tt := range []struct {
347+ value string
348+ want ports.XwaylandConfig
349+ }{
350+ {"off", ports.XwaylandConfig{Backend: ports.XwaylandOff}},
351+ {"satellite", ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite"}},
352+ {"builtin", ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland"}},
353+ {"/opt/xwls", ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "/opt/xwls"}},
354+ } {
355+ t.Run(tt.value, func(t *testing.T) {
356+ c, w := parseString(t, "xwayland = "+tt.value)
357+ if c.Xwayland != tt.want || len(w) != 0 {
358+ t.Fatal(c.Xwayland, w)
359+ }
360+ })
351361 }
352362 }
353363
@@ -340,14 +340,24 @@ func TestWorkspacePrevNextAction(t *testing.T) {
340 }340 }
341 341
342 func TestXwayland(t *testing.T) {342 func TestXwayland(t *testing.T) {
343- if Defaults().Xwayland != "xwayland-satellite" {343+ if Defaults().Xwayland != (ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite"}) {
344 t.Fatal(Defaults().Xwayland)344 t.Fatal(Defaults().Xwayland)
345 }345 }
346- if c, w := parseString(t, "xwayland = off"); c.Xwayland != "" || len(w) != 0 {346+ for _, tt := range []struct {
347- t.Fatal(c.Xwayland, w)347+ value string
348- }348+ want ports.XwaylandConfig
349- if c, w := parseString(t, "xwayland = /opt/xwls"); c.Xwayland != "/opt/xwls" || len(w) != 0 {349+ }{
350- t.Fatal(c.Xwayland, w)350+ {"off", ports.XwaylandConfig{Backend: ports.XwaylandOff}},
351+ {"satellite", ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite"}},
352+ {"builtin", ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland"}},
353+ {"/opt/xwls", ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "/opt/xwls"}},
354+ } {
355+ t.Run(tt.value, func(t *testing.T) {
356+ c, w := parseString(t, "xwayland = "+tt.value)
357+ if c.Xwayland != tt.want || len(w) != 0 {
358+ t.Fatal(c.Xwayland, w)
359+ }
360+ })
351 }361 }
352 }362 }
353 363
M internal/adapters/wayland/server.go +2 -0
@@ -160,6 +160,7 @@ type Server struct {
160160 securityStop int
161161 securityWG sync.WaitGroup
162162 securityLive atomic.Int32
163+ xwayland xwaylandClient // display owner; Phase 6 takes the XWM fd from here.
163164 display *server.Display
164165 env procEnv
165166 name string
@@ -585,6 +586,7 @@ func (s *Server) Run(ctx context.Context) error {
585586 }
586587 s.castInventoryChanged()
587588 err := s.display.Run(ctx)
589+ s.forgetXwayland()
588590 s.stopSecurityContexts()
589591 wg.Wait()
590592 return err
@@ -160,6 +160,7 @@ type Server struct {
160 securityStop int160 securityStop int
161 securityWG sync.WaitGroup161 securityWG sync.WaitGroup
162 securityLive atomic.Int32162 securityLive atomic.Int32
163+ xwayland xwaylandClient // display owner; Phase 6 takes the XWM fd from here.
163 display *server.Display164 display *server.Display
164 env procEnv165 env procEnv
165 name string166 name string
@@ -585,6 +586,7 @@ func (s *Server) Run(ctx context.Context) error {
585 }586 }
586 s.castInventoryChanged()587 s.castInventoryChanged()
587 err := s.display.Run(ctx)588 err := s.display.Run(ctx)
589+ s.forgetXwayland()
588 s.stopSecurityContexts()590 s.stopSecurityContexts()
589 wg.Wait()591 wg.Wait()
590 return err592 return err
A internal/adapters/wayland/xwayland.go +47 -0
@@ -0,0 +1,47 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/purego-libwayland/server"
5+ "golang.org/x/sys/unix"
6+)
7+
8+// xwaylandClient belongs to the display goroutine. Phase 6 takes wmFD here
9+// to run the XWM; until then it is held open without dispatching X11 traffic.
10+type xwaylandClient struct {
11+ client server.Client
12+ wmFD int
13+}
14+
15+// AttachXwayland transfers ownership of both descriptors to the display.
16+// It may be called from any goroutine; Phase 6 takes the XWM fd from here.
17+func (s *Server) AttachXwayland(wlFD, wmFD int) {
18+ ran := s.display.Do(func() {
19+ c, err := s.display.CreateClient(wlFD)
20+ if err != nil {
21+ s.log.Warn().Err(err).Msg("Xwayland connection not adopted")
22+ _ = unix.Close(wlFD) // CreateClient only adopts on success.
23+ _ = unix.Close(wmFD)
24+ return
25+ }
26+ s.forgetXwayland()
27+ s.xwayland = xwaylandClient{client: c, wmFD: wmFD}
28+ c.OnDestroy(func() {
29+ if s.xwayland.client == c {
30+ s.forgetXwayland()
31+ }
32+ })
33+ s.log.Info().Msg("Xwayland attached")
34+ })
35+ if !ran {
36+ _ = unix.Close(wlFD)
37+ _ = unix.Close(wmFD)
38+ }
39+}
40+
41+// forgetXwayland runs on the display owner, or after Run has joined it.
42+func (s *Server) forgetXwayland() {
43+ if s.xwayland.client != (server.Client{}) {
44+ _ = unix.Close(s.xwayland.wmFD)
45+ s.xwayland = xwaylandClient{}
46+ }
47+}
@@ -0,0 +1,47 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/purego-libwayland/server"
5+ "golang.org/x/sys/unix"
6+)
7+
8+// xwaylandClient belongs to the display goroutine. Phase 6 takes wmFD here
9+// to run the XWM; until then it is held open without dispatching X11 traffic.
10+type xwaylandClient struct {
11+ client server.Client
12+ wmFD int
13+}
14+
15+// AttachXwayland transfers ownership of both descriptors to the display.
16+// It may be called from any goroutine; Phase 6 takes the XWM fd from here.
17+func (s *Server) AttachXwayland(wlFD, wmFD int) {
18+ ran := s.display.Do(func() {
19+ c, err := s.display.CreateClient(wlFD)
20+ if err != nil {
21+ s.log.Warn().Err(err).Msg("Xwayland connection not adopted")
22+ _ = unix.Close(wlFD) // CreateClient only adopts on success.
23+ _ = unix.Close(wmFD)
24+ return
25+ }
26+ s.forgetXwayland()
27+ s.xwayland = xwaylandClient{client: c, wmFD: wmFD}
28+ c.OnDestroy(func() {
29+ if s.xwayland.client == c {
30+ s.forgetXwayland()
31+ }
32+ })
33+ s.log.Info().Msg("Xwayland attached")
34+ })
35+ if !ran {
36+ _ = unix.Close(wlFD)
37+ _ = unix.Close(wmFD)
38+ }
39+}
40+
41+// forgetXwayland runs on the display owner, or after Run has joined it.
42+func (s *Server) forgetXwayland() {
43+ if s.xwayland.client != (server.Client{}) {
44+ _ = unix.Close(s.xwayland.wmFD)
45+ s.xwayland = xwaylandClient{}
46+ }
47+}
A internal/adapters/wayland/xwayland_test.go +89 -0
@@ -0,0 +1,89 @@
1+package wayland
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/purego-libwayland/server"
8+ "golang.org/x/sys/unix"
9+)
10+
11+func xwaylandPair(t *testing.T) *[2]int {
12+ t.Helper()
13+ p, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ t.Cleanup(func() {
18+ if p[1] >= 0 {
19+ unix.Close(p[1])
20+ }
21+ })
22+ return &p
23+}
24+
25+func assertFDClosed(t *testing.T, fd int) {
26+ t.Helper()
27+ if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); !errors.Is(err, unix.EBADF) {
28+ t.Fatalf("fd %d still open: %v", fd, err)
29+ }
30+}
31+
32+func TestAttachXwaylandLifecycle(t *testing.T) {
33+ h := newAdmissionHarness(t, 1)
34+ wl, wm := xwaylandPair(t), xwaylandPair(t)
35+ h.s.AttachXwayland(wl[0], wm[0])
36+ var first server.Client
37+ h.s.display.Do(func() { first = h.s.xwayland.client })
38+ if first == (server.Client{}) {
39+ t.Fatal("client not recorded")
40+ }
41+ wl2, wm2 := xwaylandPair(t), xwaylandPair(t)
42+ h.s.AttachXwayland(wl2[0], wm2[0])
43+ assertFDClosed(t, wm[0])
44+ unix.Close(wl[1])
45+ wl[1] = -1 // Old client's destroy must not clear the new record.
46+ waitTrue(t, "old client destroyed", func() bool {
47+ _, err := unix.FcntlInt(uintptr(wl[0]), unix.F_GETFD, 0)
48+ return errors.Is(err, unix.EBADF)
49+ })
50+ h.s.display.Do(func() {
51+ if h.s.xwayland.client == (server.Client{}) || h.s.xwayland.client == first {
52+ t.Error("replacement lost")
53+ }
54+ })
55+ unix.Close(wl2[1])
56+ wl2[1] = -1
57+ waitTrue(t, "Xwayland disconnected", func() bool {
58+ clear := false
59+ h.s.display.Do(func() { clear = h.s.xwayland.client == (server.Client{}) })
60+ return clear
61+ })
62+ assertFDClosed(t, wm2[0])
63+}
64+
65+func TestAttachXwaylandFailureClosesFDs(t *testing.T) {
66+ h := newAdmissionHarness(t, 1)
67+ var p [2]int
68+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
69+ t.Fatal(err)
70+ }
71+ defer unix.Close(p[1])
72+ wm := xwaylandPair(t)
73+ h.s.AttachXwayland(p[0], wm[0])
74+ assertFDClosed(t, p[0])
75+ assertFDClosed(t, wm[0])
76+}
77+
78+func TestAttachXwaylandStopped(t *testing.T) {
79+ d, err := server.NewDisplay()
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ d.Close()
84+ s := &Server{display: d}
85+ wl, wm := xwaylandPair(t), xwaylandPair(t)
86+ s.AttachXwayland(wl[0], wm[0])
87+ assertFDClosed(t, wl[0])
88+ assertFDClosed(t, wm[0])
89+}
@@ -0,0 +1,89 @@
1+package wayland
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/purego-libwayland/server"
8+ "golang.org/x/sys/unix"
9+)
10+
11+func xwaylandPair(t *testing.T) *[2]int {
12+ t.Helper()
13+ p, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ t.Cleanup(func() {
18+ if p[1] >= 0 {
19+ unix.Close(p[1])
20+ }
21+ })
22+ return &p
23+}
24+
25+func assertFDClosed(t *testing.T, fd int) {
26+ t.Helper()
27+ if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); !errors.Is(err, unix.EBADF) {
28+ t.Fatalf("fd %d still open: %v", fd, err)
29+ }
30+}
31+
32+func TestAttachXwaylandLifecycle(t *testing.T) {
33+ h := newAdmissionHarness(t, 1)
34+ wl, wm := xwaylandPair(t), xwaylandPair(t)
35+ h.s.AttachXwayland(wl[0], wm[0])
36+ var first server.Client
37+ h.s.display.Do(func() { first = h.s.xwayland.client })
38+ if first == (server.Client{}) {
39+ t.Fatal("client not recorded")
40+ }
41+ wl2, wm2 := xwaylandPair(t), xwaylandPair(t)
42+ h.s.AttachXwayland(wl2[0], wm2[0])
43+ assertFDClosed(t, wm[0])
44+ unix.Close(wl[1])
45+ wl[1] = -1 // Old client's destroy must not clear the new record.
46+ waitTrue(t, "old client destroyed", func() bool {
47+ _, err := unix.FcntlInt(uintptr(wl[0]), unix.F_GETFD, 0)
48+ return errors.Is(err, unix.EBADF)
49+ })
50+ h.s.display.Do(func() {
51+ if h.s.xwayland.client == (server.Client{}) || h.s.xwayland.client == first {
52+ t.Error("replacement lost")
53+ }
54+ })
55+ unix.Close(wl2[1])
56+ wl2[1] = -1
57+ waitTrue(t, "Xwayland disconnected", func() bool {
58+ clear := false
59+ h.s.display.Do(func() { clear = h.s.xwayland.client == (server.Client{}) })
60+ return clear
61+ })
62+ assertFDClosed(t, wm2[0])
63+}
64+
65+func TestAttachXwaylandFailureClosesFDs(t *testing.T) {
66+ h := newAdmissionHarness(t, 1)
67+ var p [2]int
68+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
69+ t.Fatal(err)
70+ }
71+ defer unix.Close(p[1])
72+ wm := xwaylandPair(t)
73+ h.s.AttachXwayland(p[0], wm[0])
74+ assertFDClosed(t, p[0])
75+ assertFDClosed(t, wm[0])
76+}
77+
78+func TestAttachXwaylandStopped(t *testing.T) {
79+ d, err := server.NewDisplay()
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ d.Close()
84+ s := &Server{display: d}
85+ wl, wm := xwaylandPair(t), xwaylandPair(t)
86+ s.AttachXwayland(wl[0], wm[0])
87+ assertFDClosed(t, wl[0])
88+ assertFDClosed(t, wm[0])
89+}
A internal/adapters/xwayland/builtin.go +194 -0
@@ -0,0 +1,194 @@
1+package xwayland
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+ "strconv"
10+ "strings"
11+ "syscall"
12+ "time"
13+
14+ "golang.org/x/sys/unix"
15+)
16+
17+type builtinStarter struct{}
18+
19+func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter) error {
20+ // Files own all setup descriptors until Start succeeds and Attach takes
21+ // the two parent sockets. This also unwinds partial setup failures.
22+ var files []*os.File
23+ defer func() {
24+ for _, f := range files {
25+ if f != nil {
26+ f.Close()
27+ }
28+ }
29+ }()
30+ pair := func(name string) (int, error) {
31+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
32+ if err != nil {
33+ return 0, err
34+ }
35+ i := len(files)
36+ files = append(files, os.NewFile(uintptr(fds[0]), name+" parent"), os.NewFile(uintptr(fds[1]), name+" child"))
37+ return i, nil
38+ }
39+ wl, err := pair("wayland")
40+ if err != nil {
41+ return err
42+ }
43+ wm, err := pair("wm")
44+ if err != nil {
45+ return err
46+ }
47+ var pipe [2]int
48+ if err := unix.Pipe2(pipe[:], unix.O_CLOEXEC); err != nil {
49+ return err
50+ }
51+ rd := os.NewFile(uintptr(pipe[0]), "displayfd read")
52+ wr := os.NewFile(uintptr(pipe[1]), "displayfd write")
53+ files = append(files, rd, wr)
54+ args := []string{d.Name(), "-rootless"}
55+ for i := range d.sockets {
56+ args = append(args, "-listenfd", strconv.Itoa(3+i))
57+ }
58+ wlChild := 3 + len(d.sockets)
59+ args = append(args, "-wm", strconv.Itoa(wlChild+1), "-displayfd", strconv.Itoa(wlChild+2))
60+ cmd := exec.Command(d.opts.Binary, args...)
61+ for _, e := range d.opts.Env {
62+ if !strings.HasPrefix(e, "WAYLAND_DISPLAY=") && !strings.HasPrefix(e, "WAYLAND_SOCKET=") {
63+ cmd.Env = append(cmd.Env, e)
64+ }
65+ }
66+ cmd.Env = append(cmd.Env, "WAYLAND_SOCKET="+strconv.Itoa(wlChild))
67+ cmd.ExtraFiles = append(append([]*os.File(nil), d.sockets...), files[wl+1], files[wm+1], wr)
68+ cmd.Stderr = stderr
69+ cmd.WaitDelay = 2 * time.Second
70+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
71+ if err := cmd.Start(); err != nil {
72+ return err
73+ }
74+ files[wl+1].Close()
75+ files[wm+1].Close()
76+ wr.Close()
77+ d.opts.Log.Info().Str("display", d.Name()).Int("pid", cmd.Process.Pid).Msg("Xwayland started")
78+ // Detach the raw descriptors from os.File ownership before handing them
79+ // to the display (including its finalizers).
80+ wlFD, wmFD := int(files[wl].Fd()), int(files[wm].Fd())
81+ if d.opts.Attach != nil {
82+ // Dup transfers independent descriptors; closing the File wrappers below
83+ // cannot race with the display's ownership of the transferred descriptors.
84+ wlFD, err = unix.FcntlInt(uintptr(wlFD), unix.F_DUPFD_CLOEXEC, 0)
85+ if err == nil {
86+ wmFD, err = unix.FcntlInt(uintptr(wmFD), unix.F_DUPFD_CLOEXEC, 0)
87+ if err != nil {
88+ unix.Close(wlFD)
89+ }
90+ }
91+ if err == nil {
92+ d.opts.Attach(wlFD, wmFD)
93+ }
94+ }
95+ files[wl].Close()
96+ files[wm].Close()
97+ done := make(chan error, 1)
98+ go func() { done <- cmd.Wait() }()
99+ if err != nil {
100+ stopGroup(cmd, done)
101+ return err
102+ }
103+ exited, readyErr := waitReady(ctx, pipe[0], done)
104+ if readyErr != nil {
105+ err := readyErr
106+ if errors.Is(err, errReadinessTimeout) {
107+ d.opts.Log.Warn().Str("display", d.Name()).Msg("Xwayland readiness timed out")
108+ }
109+ // Join Wait unless readiness already observed process exit.
110+ if !exited {
111+ stopGroup(cmd, done)
112+ }
113+ return err
114+ }
115+ rd.Close()
116+ d.opts.Log.Info().Str("display", d.Name()).Msg("Xwayland ready")
117+ select {
118+ case err := <-done:
119+ return err
120+ case <-ctx.Done():
121+ stopGroup(cmd, done)
122+ return ctx.Err()
123+ }
124+}
125+
126+var errReadinessTimeout = errors.New("xwayland readiness timed out")
127+
128+// waitReady uses bounded poll waits rather than a pipe reader goroutine.
129+// The result reports whether cmd.Wait was already joined.
130+func waitReady(ctx context.Context, fd int, done <-chan error) (bool, error) {
131+ deadline := time.Now().Add(10 * time.Second)
132+ var buf [32]byte
133+ n := 0
134+ for {
135+ select {
136+ case err := <-done:
137+ if err == nil {
138+ err = errors.New("xwayland exited before readiness")
139+ }
140+ return true, err
141+ default:
142+ }
143+ if ctx.Err() != nil {
144+ return false, ctx.Err()
145+ }
146+ if time.Now().After(deadline) {
147+ return false, errReadinessTimeout
148+ }
149+ // A closed pipe also covers process exit, including exec's stderr delay.
150+ p := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
151+ _, err := unix.Poll(p, 50)
152+ if errors.Is(err, unix.EINTR) {
153+ continue
154+ }
155+ if err != nil {
156+ return false, err
157+ }
158+ if p[0].Revents == 0 {
159+ continue
160+ }
161+ if p[0].Revents&unix.POLLIN != 0 {
162+ count, err := unix.Read(fd, buf[n:])
163+ if errors.Is(err, unix.EINTR) {
164+ continue
165+ }
166+ if err != nil {
167+ return false, err
168+ }
169+ n += count
170+ if n > 0 && buf[n-1] == '\n' {
171+ if _, err := strconv.Atoi(strings.TrimSpace(string(buf[:n]))); err != nil {
172+ return false, fmt.Errorf("xwayland displayfd: %w", err)
173+ }
174+ return false, nil
175+ }
176+ if n == len(buf) {
177+ return false, errors.New("invalid Xwayland displayfd response")
178+ }
179+ }
180+ if p[0].Revents&(unix.POLLHUP|unix.POLLERR|unix.POLLNVAL) != 0 {
181+ return false, errors.New("xwayland exited before readiness")
182+ }
183+ }
184+}
185+
186+func stopGroup(cmd *exec.Cmd, done <-chan error) {
187+ _ = unix.Kill(-cmd.Process.Pid, unix.SIGTERM)
188+ select {
189+ case <-done:
190+ case <-time.After(2 * time.Second):
191+ _ = unix.Kill(-cmd.Process.Pid, unix.SIGKILL)
192+ <-done
193+ }
194+}
@@ -0,0 +1,194 @@
1+package xwayland
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+ "strconv"
10+ "strings"
11+ "syscall"
12+ "time"
13+
14+ "golang.org/x/sys/unix"
15+)
16+
17+type builtinStarter struct{}
18+
19+func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter) error {
20+ // Files own all setup descriptors until Start succeeds and Attach takes
21+ // the two parent sockets. This also unwinds partial setup failures.
22+ var files []*os.File
23+ defer func() {
24+ for _, f := range files {
25+ if f != nil {
26+ f.Close()
27+ }
28+ }
29+ }()
30+ pair := func(name string) (int, error) {
31+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
32+ if err != nil {
33+ return 0, err
34+ }
35+ i := len(files)
36+ files = append(files, os.NewFile(uintptr(fds[0]), name+" parent"), os.NewFile(uintptr(fds[1]), name+" child"))
37+ return i, nil
38+ }
39+ wl, err := pair("wayland")
40+ if err != nil {
41+ return err
42+ }
43+ wm, err := pair("wm")
44+ if err != nil {
45+ return err
46+ }
47+ var pipe [2]int
48+ if err := unix.Pipe2(pipe[:], unix.O_CLOEXEC); err != nil {
49+ return err
50+ }
51+ rd := os.NewFile(uintptr(pipe[0]), "displayfd read")
52+ wr := os.NewFile(uintptr(pipe[1]), "displayfd write")
53+ files = append(files, rd, wr)
54+ args := []string{d.Name(), "-rootless"}
55+ for i := range d.sockets {
56+ args = append(args, "-listenfd", strconv.Itoa(3+i))
57+ }
58+ wlChild := 3 + len(d.sockets)
59+ args = append(args, "-wm", strconv.Itoa(wlChild+1), "-displayfd", strconv.Itoa(wlChild+2))
60+ cmd := exec.Command(d.opts.Binary, args...)
61+ for _, e := range d.opts.Env {
62+ if !strings.HasPrefix(e, "WAYLAND_DISPLAY=") && !strings.HasPrefix(e, "WAYLAND_SOCKET=") {
63+ cmd.Env = append(cmd.Env, e)
64+ }
65+ }
66+ cmd.Env = append(cmd.Env, "WAYLAND_SOCKET="+strconv.Itoa(wlChild))
67+ cmd.ExtraFiles = append(append([]*os.File(nil), d.sockets...), files[wl+1], files[wm+1], wr)
68+ cmd.Stderr = stderr
69+ cmd.WaitDelay = 2 * time.Second
70+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
71+ if err := cmd.Start(); err != nil {
72+ return err
73+ }
74+ files[wl+1].Close()
75+ files[wm+1].Close()
76+ wr.Close()
77+ d.opts.Log.Info().Str("display", d.Name()).Int("pid", cmd.Process.Pid).Msg("Xwayland started")
78+ // Detach the raw descriptors from os.File ownership before handing them
79+ // to the display (including its finalizers).
80+ wlFD, wmFD := int(files[wl].Fd()), int(files[wm].Fd())
81+ if d.opts.Attach != nil {
82+ // Dup transfers independent descriptors; closing the File wrappers below
83+ // cannot race with the display's ownership of the transferred descriptors.
84+ wlFD, err = unix.FcntlInt(uintptr(wlFD), unix.F_DUPFD_CLOEXEC, 0)
85+ if err == nil {
86+ wmFD, err = unix.FcntlInt(uintptr(wmFD), unix.F_DUPFD_CLOEXEC, 0)
87+ if err != nil {
88+ unix.Close(wlFD)
89+ }
90+ }
91+ if err == nil {
92+ d.opts.Attach(wlFD, wmFD)
93+ }
94+ }
95+ files[wl].Close()
96+ files[wm].Close()
97+ done := make(chan error, 1)
98+ go func() { done <- cmd.Wait() }()
99+ if err != nil {
100+ stopGroup(cmd, done)
101+ return err
102+ }
103+ exited, readyErr := waitReady(ctx, pipe[0], done)
104+ if readyErr != nil {
105+ err := readyErr
106+ if errors.Is(err, errReadinessTimeout) {
107+ d.opts.Log.Warn().Str("display", d.Name()).Msg("Xwayland readiness timed out")
108+ }
109+ // Join Wait unless readiness already observed process exit.
110+ if !exited {
111+ stopGroup(cmd, done)
112+ }
113+ return err
114+ }
115+ rd.Close()
116+ d.opts.Log.Info().Str("display", d.Name()).Msg("Xwayland ready")
117+ select {
118+ case err := <-done:
119+ return err
120+ case <-ctx.Done():
121+ stopGroup(cmd, done)
122+ return ctx.Err()
123+ }
124+}
125+
126+var errReadinessTimeout = errors.New("xwayland readiness timed out")
127+
128+// waitReady uses bounded poll waits rather than a pipe reader goroutine.
129+// The result reports whether cmd.Wait was already joined.
130+func waitReady(ctx context.Context, fd int, done <-chan error) (bool, error) {
131+ deadline := time.Now().Add(10 * time.Second)
132+ var buf [32]byte
133+ n := 0
134+ for {
135+ select {
136+ case err := <-done:
137+ if err == nil {
138+ err = errors.New("xwayland exited before readiness")
139+ }
140+ return true, err
141+ default:
142+ }
143+ if ctx.Err() != nil {
144+ return false, ctx.Err()
145+ }
146+ if time.Now().After(deadline) {
147+ return false, errReadinessTimeout
148+ }
149+ // A closed pipe also covers process exit, including exec's stderr delay.
150+ p := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
151+ _, err := unix.Poll(p, 50)
152+ if errors.Is(err, unix.EINTR) {
153+ continue
154+ }
155+ if err != nil {
156+ return false, err
157+ }
158+ if p[0].Revents == 0 {
159+ continue
160+ }
161+ if p[0].Revents&unix.POLLIN != 0 {
162+ count, err := unix.Read(fd, buf[n:])
163+ if errors.Is(err, unix.EINTR) {
164+ continue
165+ }
166+ if err != nil {
167+ return false, err
168+ }
169+ n += count
170+ if n > 0 && buf[n-1] == '\n' {
171+ if _, err := strconv.Atoi(strings.TrimSpace(string(buf[:n]))); err != nil {
172+ return false, fmt.Errorf("xwayland displayfd: %w", err)
173+ }
174+ return false, nil
175+ }
176+ if n == len(buf) {
177+ return false, errors.New("invalid Xwayland displayfd response")
178+ }
179+ }
180+ if p[0].Revents&(unix.POLLHUP|unix.POLLERR|unix.POLLNVAL) != 0 {
181+ return false, errors.New("xwayland exited before readiness")
182+ }
183+ }
184+}
185+
186+func stopGroup(cmd *exec.Cmd, done <-chan error) {
187+ _ = unix.Kill(-cmd.Process.Pid, unix.SIGTERM)
188+ select {
189+ case <-done:
190+ case <-time.After(2 * time.Second):
191+ _ = unix.Kill(-cmd.Process.Pid, unix.SIGKILL)
192+ <-done
193+ }
194+}
A internal/adapters/xwayland/starter_mock_test.go +110 -0
@@ -0,0 +1,110 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package xwayland
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMockstarter creates a new instance of mockstarter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMockstarter(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mockstarter {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mockstarter{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mockstarter is an autogenerated mock type for the starter type
37+type mockstarter struct {
38+ mock.Mock
39+}
40+
41+type mockstarter_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mockstarter) EXPECT() *mockstarter_Expecter {
46+ return &mockstarter_Expecter{mock: &_m.Mock}
47+}
48+
49+// start provides a mock function for the type mockstarter
50+func (_mock *mockstarter) start(ctx context.Context, d *Display, stderr *logWriter) error {
51+ ret := _mock.Called(ctx, d, stderr)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for start")
55+ }
56+
57+ var r0 error
58+ if returnFunc, ok := ret.Get(0).(func(context.Context, *Display, *logWriter) error); ok {
59+ r0 = returnFunc(ctx, d, stderr)
60+ } else {
61+ r0 = ret.Error(0)
62+ }
63+ return r0
64+}
65+
66+// mockstarter_start_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'start'
67+type mockstarter_start_Call struct {
68+ *mock.Call
69+}
70+
71+// start is a helper method to define mock.On call
72+// - ctx context.Context
73+// - d *Display
74+// - stderr *logWriter
75+func (_e *mockstarter_Expecter) start(ctx any, d any, stderr any) *mockstarter_start_Call {
76+ return &mockstarter_start_Call{Call: _e.mock.On("start", ctx, d, stderr)}
77+}
78+
79+func (_c *mockstarter_start_Call) Run(run func(ctx context.Context, d *Display, stderr *logWriter)) *mockstarter_start_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 context.Context
82+ if args[0] != nil {
83+ arg0 = args[0].(context.Context)
84+ }
85+ var arg1 *Display
86+ if args[1] != nil {
87+ arg1 = args[1].(*Display)
88+ }
89+ var arg2 *logWriter
90+ if args[2] != nil {
91+ arg2 = args[2].(*logWriter)
92+ }
93+ run(
94+ arg0,
95+ arg1,
96+ arg2,
97+ )
98+ })
99+ return _c
100+}
101+
102+func (_c *mockstarter_start_Call) Return(err error) *mockstarter_start_Call {
103+ _c.Call.Return(err)
104+ return _c
105+}
106+
107+func (_c *mockstarter_start_Call) RunAndReturn(run func(ctx context.Context, d *Display, stderr *logWriter) error) *mockstarter_start_Call {
108+ _c.Call.Return(run)
109+ return _c
110+}
@@ -0,0 +1,110 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package xwayland
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMockstarter creates a new instance of mockstarter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMockstarter(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mockstarter {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mockstarter{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mockstarter is an autogenerated mock type for the starter type
37+type mockstarter struct {
38+ mock.Mock
39+}
40+
41+type mockstarter_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mockstarter) EXPECT() *mockstarter_Expecter {
46+ return &mockstarter_Expecter{mock: &_m.Mock}
47+}
48+
49+// start provides a mock function for the type mockstarter
50+func (_mock *mockstarter) start(ctx context.Context, d *Display, stderr *logWriter) error {
51+ ret := _mock.Called(ctx, d, stderr)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for start")
55+ }
56+
57+ var r0 error
58+ if returnFunc, ok := ret.Get(0).(func(context.Context, *Display, *logWriter) error); ok {
59+ r0 = returnFunc(ctx, d, stderr)
60+ } else {
61+ r0 = ret.Error(0)
62+ }
63+ return r0
64+}
65+
66+// mockstarter_start_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'start'
67+type mockstarter_start_Call struct {
68+ *mock.Call
69+}
70+
71+// start is a helper method to define mock.On call
72+// - ctx context.Context
73+// - d *Display
74+// - stderr *logWriter
75+func (_e *mockstarter_Expecter) start(ctx any, d any, stderr any) *mockstarter_start_Call {
76+ return &mockstarter_start_Call{Call: _e.mock.On("start", ctx, d, stderr)}
77+}
78+
79+func (_c *mockstarter_start_Call) Run(run func(ctx context.Context, d *Display, stderr *logWriter)) *mockstarter_start_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 context.Context
82+ if args[0] != nil {
83+ arg0 = args[0].(context.Context)
84+ }
85+ var arg1 *Display
86+ if args[1] != nil {
87+ arg1 = args[1].(*Display)
88+ }
89+ var arg2 *logWriter
90+ if args[2] != nil {
91+ arg2 = args[2].(*logWriter)
92+ }
93+ run(
94+ arg0,
95+ arg1,
96+ arg2,
97+ )
98+ })
99+ return _c
100+}
101+
102+func (_c *mockstarter_start_Call) Return(err error) *mockstarter_start_Call {
103+ _c.Call.Return(err)
104+ return _c
105+}
106+
107+func (_c *mockstarter_start_Call) RunAndReturn(run func(ctx context.Context, d *Display, stderr *logWriter) error) *mockstarter_start_Call {
108+ _c.Call.Return(run)
109+ return _c
110+}
M internal/adapters/xwayland/xwayland.go +39 -18
@@ -1,6 +1,5 @@
1-// Package xwayland runs X11 clients through xwayland-satellite, started on
2-// demand: neferwl owns the X11 display sockets and starts the satellite
3-// when the first X11 client connects, and again after it exits.
1+// Package xwayland starts an X11 backend on demand. NeferWL owns the display
2+// sockets and starts the selected backend when the first client connects.
43 package xwayland
54
65 import (
@@ -17,15 +16,19 @@ import (
1716 "syscall"
1817 "time"
1918
19+ "github.com/bnema/neferwl/internal/ports"
2020 "github.com/bnema/zerowrap"
2121 "golang.org/x/sys/unix"
2222 )
2323
2424 // Options configures the X11 display.
2525 type Options struct {
26- // Binary is the xwayland-satellite executable.
26+ Backend ports.XwaylandBackend
27+ // Attach takes ownership of both descriptors when called.
28+ Attach func(wlFD, wmFD int)
29+ // Binary is the selected backend executable.
2730 Binary string
28- // Env is the satellite's environment; it must hold WAYLAND_DISPLAY.
31+ // Env is the backend environment; satellite requires WAYLAND_DISPLAY.
2932 Env []string
3033 // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix
3134 // and /tmp outside tests.
@@ -35,8 +38,9 @@ type Options struct {
3538 Log zerowrap.Logger
3639 }
3740
38-// Display is an X11 display served by xwayland-satellite.
41+// Display is an on-demand X11 display.
3942 type Display struct {
43+ starter starter
4044 opts Options
4145 n int
4246 sockets []*os.File // unix socket, then the abstract one
@@ -81,7 +85,10 @@ func Open(opts Options) (*Display, error) {
8185 _ = os.Remove(lock)
8286 return nil, fmt.Errorf("write X11 lock: %w", err)
8387 }
84- d := &Display{opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}
88+ d := &Display{starter: satelliteStarter{}, opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}
89+ if opts.Backend == ports.XwaylandBuiltin {
90+ d.starter = builtinStarter{}
91+ }
8592 if err := d.listen(); err != nil {
8693 last = err
8794 d.Close()
@@ -212,15 +219,15 @@ func (d *Display) Run(ctx context.Context) error {
212219 return err
213220 }
214221 start := time.Now()
215- stderr := &logWriter{log: d.opts.Log}
216- err := d.serve(ctx, stderr)
222+ stderr := &logWriter{log: d.opts.Log, backend: d.backendName()}
223+ err := d.starter.start(ctx, d, stderr)
217224 if ctx.Err() != nil {
218225 return nil
219226 }
220227 if err != nil {
221- d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg("xwayland-satellite exited")
228+ d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg(d.backendName() + " exited")
222229 } else {
223- d.opts.Log.Info().Str("display", d.Name()).Msg("xwayland-satellite exited")
230+ d.opts.Log.Info().Str("display", d.Name()).Msg(d.backendName() + " exited")
224231 }
225232 // Clients the satellite never accepted would wake us at once: a
226233 // satellite that cannot start must not respawn in a loop.
@@ -261,8 +268,14 @@ func (d *Display) waitClient(ctx context.Context, wake int) error {
261268 return ctx.Err()
262269 }
263270
264-// serve runs the satellite on the sockets until it exits or ctx ends.
265-func (d *Display) serve(ctx context.Context, stderr *logWriter) error {
271+// starter runs a backend on the reserved sockets until it exits or ctx ends.
272+type starter interface {
273+ start(ctx context.Context, d *Display, stderr *logWriter) error
274+}
275+
276+type satelliteStarter struct{}
277+
278+func (satelliteStarter) start(ctx context.Context, d *Display, stderr *logWriter) error {
266279 args := []string{d.Name()}
267280 for i := range d.sockets {
268281 args = append(args, "-listenfd", strconv.Itoa(3+i))
@@ -318,10 +331,11 @@ func (d *Display) dropPending() {
318331 // logWriter logs the satellite's stderr line by line and keeps the last
319332 // lines for the exit log; exec copies it from a pipe on its own goroutine.
320333 type logWriter struct {
321- mu sync.Mutex
322- log zerowrap.Logger
323- line []byte
324- last []string
334+ backend string
335+ mu sync.Mutex
336+ log zerowrap.Logger
337+ line []byte
338+ last []string
325339 }
326340
327341 // tail is the last lines written.
@@ -342,7 +356,7 @@ func (w *logWriter) Write(p []byte) (int, error) {
342356 }
343357 if i > 0 {
344358 line := string(w.line[:i])
345- w.log.Debug().Str("line", line).Msg("xwayland-satellite")
359+ w.log.Debug().Str("line", line).Msg(w.backend)
346360 if w.last = append(w.last, line); len(w.last) > 5 {
347361 w.last = w.last[1:]
348362 }
@@ -354,3 +368,10 @@ func (w *logWriter) Write(p []byte) (int, error) {
354368 }
355369 return len(p), nil
356370 }
371+
372+func (d *Display) backendName() string {
373+ if d.opts.Backend == ports.XwaylandBuiltin {
374+ return "Xwayland"
375+ }
376+ return "xwayland-satellite"
377+}
@@ -1,6 +1,5 @@
1-// Package xwayland runs X11 clients through xwayland-satellite, started on1+// Package xwayland starts an X11 backend on demand. NeferWL owns the display
2-// demand: neferwl owns the X11 display sockets and starts the satellite2+// sockets and starts the selected backend when the first client connects.
3-// when the first X11 client connects, and again after it exits.
4 package xwayland3 package xwayland
5 4
6 import (5 import (
@@ -17,15 +16,19 @@ import (
17 "syscall"16 "syscall"
18 "time"17 "time"
19 18
19+ "github.com/bnema/neferwl/internal/ports"
20 "github.com/bnema/zerowrap"20 "github.com/bnema/zerowrap"
21 "golang.org/x/sys/unix"21 "golang.org/x/sys/unix"
22 )22 )
23 23
24 // Options configures the X11 display.24 // Options configures the X11 display.
25 type Options struct {25 type Options struct {
26- // Binary is the xwayland-satellite executable.26+ Backend ports.XwaylandBackend
27+ // Attach takes ownership of both descriptors when called.
28+ Attach func(wlFD, wmFD int)
29+ // Binary is the selected backend executable.
27 Binary string30 Binary string
28- // Env is the satellite's environment; it must hold WAYLAND_DISPLAY.31+ // Env is the backend environment; satellite requires WAYLAND_DISPLAY.
29 Env []string32 Env []string
30 // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix33 // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix
31 // and /tmp outside tests.34 // and /tmp outside tests.
@@ -35,8 +38,9 @@ type Options struct {
35 Log zerowrap.Logger38 Log zerowrap.Logger
36 }39 }
37 40
38-// Display is an X11 display served by xwayland-satellite.41+// Display is an on-demand X11 display.
39 type Display struct {42 type Display struct {
43+ starter starter
40 opts Options44 opts Options
41 n int45 n int
42 sockets []*os.File // unix socket, then the abstract one46 sockets []*os.File // unix socket, then the abstract one
@@ -81,7 +85,10 @@ func Open(opts Options) (*Display, error) {
81 _ = os.Remove(lock)85 _ = os.Remove(lock)
82 return nil, fmt.Errorf("write X11 lock: %w", err)86 return nil, fmt.Errorf("write X11 lock: %w", err)
83 }87 }
84- d := &Display{opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}88+ d := &Display{starter: satelliteStarter{}, opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}
89+ if opts.Backend == ports.XwaylandBuiltin {
90+ d.starter = builtinStarter{}
91+ }
85 if err := d.listen(); err != nil {92 if err := d.listen(); err != nil {
86 last = err93 last = err
87 d.Close()94 d.Close()
@@ -212,15 +219,15 @@ func (d *Display) Run(ctx context.Context) error {
212 return err219 return err
213 }220 }
214 start := time.Now()221 start := time.Now()
215- stderr := &logWriter{log: d.opts.Log}222+ stderr := &logWriter{log: d.opts.Log, backend: d.backendName()}
216- err := d.serve(ctx, stderr)223+ err := d.starter.start(ctx, d, stderr)
217 if ctx.Err() != nil {224 if ctx.Err() != nil {
218 return nil225 return nil
219 }226 }
220 if err != nil {227 if err != nil {
221- d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg("xwayland-satellite exited")228+ d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg(d.backendName() + " exited")
222 } else {229 } else {
223- d.opts.Log.Info().Str("display", d.Name()).Msg("xwayland-satellite exited")230+ d.opts.Log.Info().Str("display", d.Name()).Msg(d.backendName() + " exited")
224 }231 }
225 // Clients the satellite never accepted would wake us at once: a232 // Clients the satellite never accepted would wake us at once: a
226 // satellite that cannot start must not respawn in a loop.233 // satellite that cannot start must not respawn in a loop.
@@ -261,8 +268,14 @@ func (d *Display) waitClient(ctx context.Context, wake int) error {
261 return ctx.Err()268 return ctx.Err()
262 }269 }
263 270
264-// serve runs the satellite on the sockets until it exits or ctx ends.271+// starter runs a backend on the reserved sockets until it exits or ctx ends.
265-func (d *Display) serve(ctx context.Context, stderr *logWriter) error {272+type starter interface {
273+ start(ctx context.Context, d *Display, stderr *logWriter) error
274+}
275+
276+type satelliteStarter struct{}
277+
278+func (satelliteStarter) start(ctx context.Context, d *Display, stderr *logWriter) error {
266 args := []string{d.Name()}279 args := []string{d.Name()}
267 for i := range d.sockets {280 for i := range d.sockets {
268 args = append(args, "-listenfd", strconv.Itoa(3+i))281 args = append(args, "-listenfd", strconv.Itoa(3+i))
@@ -318,10 +331,11 @@ func (d *Display) dropPending() {
318 // logWriter logs the satellite's stderr line by line and keeps the last331 // logWriter logs the satellite's stderr line by line and keeps the last
319 // lines for the exit log; exec copies it from a pipe on its own goroutine.332 // lines for the exit log; exec copies it from a pipe on its own goroutine.
320 type logWriter struct {333 type logWriter struct {
321- mu sync.Mutex334+ backend string
322- log zerowrap.Logger335+ mu sync.Mutex
323- line []byte336+ log zerowrap.Logger
324- last []string337+ line []byte
338+ last []string
325 }339 }
326 340
327 // tail is the last lines written.341 // tail is the last lines written.
@@ -342,7 +356,7 @@ func (w *logWriter) Write(p []byte) (int, error) {
342 }356 }
343 if i > 0 {357 if i > 0 {
344 line := string(w.line[:i])358 line := string(w.line[:i])
345- w.log.Debug().Str("line", line).Msg("xwayland-satellite")359+ w.log.Debug().Str("line", line).Msg(w.backend)
346 if w.last = append(w.last, line); len(w.last) > 5 {360 if w.last = append(w.last, line); len(w.last) > 5 {
347 w.last = w.last[1:]361 w.last = w.last[1:]
348 }362 }
@@ -354,3 +368,10 @@ func (w *logWriter) Write(p []byte) (int, error) {
354 }368 }
355 return len(p), nil369 return len(p), nil
356 }370 }
371+
372+func (d *Display) backendName() string {
373+ if d.opts.Backend == ports.XwaylandBuiltin {
374+ return "Xwayland"
375+ }
376+ return "xwayland-satellite"
377+}
M internal/adapters/xwayland/xwayland_test.go +99 -0
@@ -11,6 +11,8 @@ import (
1111 "time"
1212
1313 "github.com/bnema/neferwl/internal/adapters/logging"
14+ "github.com/bnema/neferwl/internal/ports"
15+ "github.com/stretchr/testify/mock"
1416 "golang.org/x/sys/unix"
1517 )
1618
@@ -270,3 +272,100 @@ func TestRunKillsSatelliteGroup(t *testing.T) {
270272 }
271273 }
272274 }
275+
276+func TestStarterSelection(t *testing.T) {
277+ for _, backend := range []ports.XwaylandBackend{ports.XwaylandSatellite, ports.XwaylandBuiltin} {
278+ opts := testOptions(t, "true")
279+ opts.Backend = backend
280+ d, err := Open(opts)
281+ if err != nil {
282+ t.Fatal(err)
283+ }
284+ defer d.Close()
285+ switch d.starter.(type) {
286+ case satelliteStarter:
287+ if backend != ports.XwaylandSatellite {
288+ t.Fatal("satellite selected")
289+ }
290+ case builtinStarter:
291+ if backend != ports.XwaylandBuiltin {
292+ t.Fatal("builtin selected")
293+ }
294+ default:
295+ t.Fatalf("unexpected starter %T", d.starter)
296+ }
297+ }
298+}
299+
300+func TestRunCallsStarter(t *testing.T) {
301+ d, err := Open(testOptions(t, "unused"))
302+ if err != nil {
303+ t.Fatal(err)
304+ }
305+ defer d.Close()
306+ ctx, cancel := context.WithCancel(t.Context())
307+ defer cancel()
308+ m := newMockstarter(t)
309+ d.starter = m
310+ m.EXPECT().start(ctx, d, mock.Anything).RunAndReturn(func(_ context.Context, got *Display, stderr *logWriter) error {
311+ if got != d || stderr == nil {
312+ t.Error("starter arguments")
313+ }
314+ cancel()
315+ return ctx.Err()
316+ }).Once()
317+ done := make(chan error, 1)
318+ go func() { done <- d.Run(ctx) }()
319+ unix.Close(dial(t, d.socket))
320+ select {
321+ case err := <-done:
322+ if err != nil {
323+ t.Fatal(err)
324+ }
325+ case <-time.After(3 * time.Second):
326+ t.Fatal("Run did not stop")
327+ }
328+}
329+
330+func TestWaitReady(t *testing.T) {
331+ for _, tt := range []struct {
332+ name, data string
333+ wantErr bool
334+ }{
335+ {"ready", "12\n", false}, {"invalid", "bad\n", true}, {"closed", "", true},
336+ } {
337+ t.Run(tt.name, func(t *testing.T) {
338+ var p [2]int
339+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
340+ t.Fatal(err)
341+ }
342+ defer unix.Close(p[0])
343+ if tt.data != "" {
344+ if _, err := unix.Write(p[1], []byte(tt.data)); err != nil {
345+ t.Fatal(err)
346+ }
347+ }
348+ unix.Close(p[1])
349+ joined, err := waitReady(t.Context(), p[0], make(chan error))
350+ if joined || (err != nil) != tt.wantErr {
351+ t.Fatalf("joined=%v err=%v", joined, err)
352+ }
353+ })
354+ }
355+ t.Run("exit", func(t *testing.T) {
356+ done := make(chan error, 1)
357+ done <- errors.New("exit")
358+ joined, err := waitReady(t.Context(), -1, done)
359+ if !joined || err == nil {
360+ t.Fatal(joined, err)
361+ }
362+ })
363+ t.Run("cancel", func(t *testing.T) {
364+ ctx, cancel := context.WithCancel(t.Context())
365+ cancel()
366+ joined, err := waitReady(ctx, -1, make(chan error))
367+ if joined || !errors.Is(err, context.Canceled) {
368+ t.Fatal(joined, err)
369+ }
370+ })
371+}
@@ -11,6 +11,8 @@ import (
11 "time"11 "time"
12 12
13 "github.com/bnema/neferwl/internal/adapters/logging"13 "github.com/bnema/neferwl/internal/adapters/logging"
14+ "github.com/bnema/neferwl/internal/ports"
15+ "github.com/stretchr/testify/mock"
14 "golang.org/x/sys/unix"16 "golang.org/x/sys/unix"
15 )17 )
16 18
@@ -270,3 +272,100 @@ func TestRunKillsSatelliteGroup(t *testing.T) {
270 }272 }
271 }273 }
272 }274 }
275+
276+func TestStarterSelection(t *testing.T) {
277+ for _, backend := range []ports.XwaylandBackend{ports.XwaylandSatellite, ports.XwaylandBuiltin} {
278+ opts := testOptions(t, "true")
279+ opts.Backend = backend
280+ d, err := Open(opts)
281+ if err != nil {
282+ t.Fatal(err)
283+ }
284+ defer d.Close()
285+ switch d.starter.(type) {
286+ case satelliteStarter:
287+ if backend != ports.XwaylandSatellite {
288+ t.Fatal("satellite selected")
289+ }
290+ case builtinStarter:
291+ if backend != ports.XwaylandBuiltin {
292+ t.Fatal("builtin selected")
293+ }
294+ default:
295+ t.Fatalf("unexpected starter %T", d.starter)
296+ }
297+ }
298+}
299+
300+func TestRunCallsStarter(t *testing.T) {
301+ d, err := Open(testOptions(t, "unused"))
302+ if err != nil {
303+ t.Fatal(err)
304+ }
305+ defer d.Close()
306+ ctx, cancel := context.WithCancel(t.Context())
307+ defer cancel()
308+ m := newMockstarter(t)
309+ d.starter = m
310+ m.EXPECT().start(ctx, d, mock.Anything).RunAndReturn(func(_ context.Context, got *Display, stderr *logWriter) error {
311+ if got != d || stderr == nil {
312+ t.Error("starter arguments")
313+ }
314+ cancel()
315+ return ctx.Err()
316+ }).Once()
317+ done := make(chan error, 1)
318+ go func() { done <- d.Run(ctx) }()
319+ unix.Close(dial(t, d.socket))
320+ select {
321+ case err := <-done:
322+ if err != nil {
323+ t.Fatal(err)
324+ }
325+ case <-time.After(3 * time.Second):
326+ t.Fatal("Run did not stop")
327+ }
328+}
329+
330+func TestWaitReady(t *testing.T) {
331+ for _, tt := range []struct {
332+ name, data string
333+ wantErr bool
334+ }{
335+ {"ready", "12\n", false}, {"invalid", "bad\n", true}, {"closed", "", true},
336+ } {
337+ t.Run(tt.name, func(t *testing.T) {
338+ var p [2]int
339+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
340+ t.Fatal(err)
341+ }
342+ defer unix.Close(p[0])
343+ if tt.data != "" {
344+ if _, err := unix.Write(p[1], []byte(tt.data)); err != nil {
345+ t.Fatal(err)
346+ }
347+ }
348+ unix.Close(p[1])
349+ joined, err := waitReady(t.Context(), p[0], make(chan error))
350+ if joined || (err != nil) != tt.wantErr {
351+ t.Fatalf("joined=%v err=%v", joined, err)
352+ }
353+ })
354+ }
355+ t.Run("exit", func(t *testing.T) {
356+ done := make(chan error, 1)
357+ done <- errors.New("exit")
358+ joined, err := waitReady(t.Context(), -1, done)
359+ if !joined || err == nil {
360+ t.Fatal(joined, err)
361+ }
362+ })
363+ t.Run("cancel", func(t *testing.T) {
364+ ctx, cancel := context.WithCancel(t.Context())
365+ cancel()
366+ joined, err := waitReady(ctx, -1, make(chan error))
367+ if joined || !errors.Is(err, context.Canceled) {
368+ t.Fatal(joined, err)
369+ }
370+ })
371+}
M internal/app/app.go +13 -6
@@ -5,6 +5,7 @@ import (
55 "context"
66 "fmt"
77 "io"
8+ "os/exec"
89 "time"
910
1011 "github.com/bnema/neferwl/internal/adapters/logging"
@@ -188,18 +189,24 @@ func logScenes(log zerowrap.Logger, set []ports.Scene) {
188189 }
189190 }
190191
191-// openXwayland reserves an X11 display for xwayland-satellite, or returns
192+// openXwayland reserves an X11 display for the selected backend, or returns
192193 // nil when X11 is off or unavailable: the session runs without it.
193-func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
194- bin := opts.Config.Xwayland
195- if bin == "" || opts.NoXwayland {
194+func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger, attach func(int, int)) *xwayland.Display {
195+ cfg := opts.Config.Xwayland
196+ bin := cfg.Binary
197+ if cfg.Backend == ports.XwaylandOff || opts.NoXwayland {
196198 return nil
197199 }
198- if !xwayland.Supported(ctx, bin) {
200+ if cfg.Backend == ports.XwaylandBuiltin {
201+ if _, err := exec.LookPath(bin); err != nil {
202+ log.Warn().Err(err).Str("binary", bin).Msg("Xwayland not found: X11 apps disabled")
203+ return nil
204+ }
205+ } else if !xwayland.Supported(ctx, bin) {
199206 log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
200207 return nil
201208 }
202- d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
209+ d, err := xwayland.Open(xwayland.Options{Backend: cfg.Backend, Attach: attach, Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
203210 if err != nil {
204211 log.Warn().Err(err).Msg("X11 display disabled")
205212 return nil
@@ -5,6 +5,7 @@ import (
5 "context"5 "context"
6 "fmt"6 "fmt"
7 "io"7 "io"
8+ "os/exec"
8 "time"9 "time"
9 10
10 "github.com/bnema/neferwl/internal/adapters/logging"11 "github.com/bnema/neferwl/internal/adapters/logging"
@@ -188,18 +189,24 @@ func logScenes(log zerowrap.Logger, set []ports.Scene) {
188 }189 }
189 }190 }
190 191
191-// openXwayland reserves an X11 display for xwayland-satellite, or returns192+// openXwayland reserves an X11 display for the selected backend, or returns
192 // nil when X11 is off or unavailable: the session runs without it.193 // nil when X11 is off or unavailable: the session runs without it.
193-func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {194+func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger, attach func(int, int)) *xwayland.Display {
194- bin := opts.Config.Xwayland195+ cfg := opts.Config.Xwayland
195- if bin == "" || opts.NoXwayland {196+ bin := cfg.Binary
197+ if cfg.Backend == ports.XwaylandOff || opts.NoXwayland {
196 return nil198 return nil
197 }199 }
198- if !xwayland.Supported(ctx, bin) {200+ if cfg.Backend == ports.XwaylandBuiltin {
201+ if _, err := exec.LookPath(bin); err != nil {
202+ log.Warn().Err(err).Str("binary", bin).Msg("Xwayland not found: X11 apps disabled")
203+ return nil
204+ }
205+ } else if !xwayland.Supported(ctx, bin) {
199 log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")206 log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
200 return nil207 return nil
201 }208 }
202- d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})209+ d, err := xwayland.Open(xwayland.Options{Backend: cfg.Backend, Attach: attach, Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
203 if err != nil {210 if err != nil {
204 log.Warn().Err(err).Msg("X11 display disabled")211 log.Warn().Err(err).Msg("X11 display disabled")
205 return nil212 return nil
M internal/app/session.go +1 -1
@@ -152,7 +152,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
152152 s.statePath = statePath
153153 s.childEnv = append(s.childEnv, statefile.Env+"="+statePath)
154154 }
155- if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland")); s.xdisplay != nil {
155+ if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland"), s.server.AttachXwayland); s.xdisplay != nil {
156156 s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name())
157157 }
158158 s.child = launcher.New(s.childEnv, s.security, logging.For(ctx, "launcher"))
@@ -152,7 +152,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
152 s.statePath = statePath152 s.statePath = statePath
153 s.childEnv = append(s.childEnv, statefile.Env+"="+statePath)153 s.childEnv = append(s.childEnv, statefile.Env+"="+statePath)
154 }154 }
155- if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland")); s.xdisplay != nil {155+ if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland"), s.server.AttachXwayland); s.xdisplay != nil {
156 s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name())156 s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name())
157 }157 }
158 s.child = launcher.New(s.childEnv, s.security, logging.For(ctx, "launcher"))158 s.child = launcher.New(s.childEnv, s.security, logging.For(ctx, "launcher"))
M internal/ports/config.go +17 -3
@@ -101,9 +101,8 @@ type Config struct {
101101 Command []string
102102 AutoOpen string
103103 }
104- // Xwayland is the xwayland-satellite binary serving X11 clients; empty
105- // disables X11.
106- Xwayland string
104+ // Xwayland selects the X11 launcher.
105+ Xwayland XwaylandConfig
107106 // Startup are commands run once when the session starts, in order.
108107 // One startup line may hold several, split by SplitCommands.
109108 Startup [][]string
@@ -298,3 +297,18 @@ type WindowRule struct {
298297 // (1 to 16; 0 leaves it automatic).
299298 Cells int
300299 }
300+
301+// XwaylandBackend selects how X11 clients are served.
302+type XwaylandBackend uint8
303+
304+const (
305+ XwaylandOff XwaylandBackend = iota
306+ XwaylandSatellite
307+ XwaylandBuiltin
308+)
309+
310+// XwaylandConfig selects the launcher and its executable.
311+type XwaylandConfig struct {
312+ Backend XwaylandBackend
313+ Binary string
314+}
@@ -101,9 +101,8 @@ type Config struct {
101 Command []string101 Command []string
102 AutoOpen string102 AutoOpen string
103 }103 }
104- // Xwayland is the xwayland-satellite binary serving X11 clients; empty104+ // Xwayland selects the X11 launcher.
105- // disables X11.105+ Xwayland XwaylandConfig
106- Xwayland string
107 // Startup are commands run once when the session starts, in order.106 // Startup are commands run once when the session starts, in order.
108 // One startup line may hold several, split by SplitCommands.107 // One startup line may hold several, split by SplitCommands.
109 Startup [][]string108 Startup [][]string
@@ -298,3 +297,18 @@ type WindowRule struct {
298 // (1 to 16; 0 leaves it automatic).297 // (1 to 16; 0 leaves it automatic).
299 Cells int298 Cells int
300 }299 }
300+
301+// XwaylandBackend selects how X11 clients are served.
302+type XwaylandBackend uint8
303+
304+const (
305+ XwaylandOff XwaylandBackend = iota
306+ XwaylandSatellite
307+ XwaylandBuiltin
308+)
309+
310+// XwaylandConfig selects the launcher and its executable.
311+type XwaylandConfig struct {
312+ Backend XwaylandBackend
313+ Binary string
314+}

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 25

Tags 10

↑↓ moveenter openesc close