detached d919d3c

Merge a0ea919d490e9ca747a3700440f06f60af0517a9 into 3c13b3d3cf1deae3f997dd5bb3e9e0a55049380f

commit
d919d3cda18afee33348ab02cca151ef8b3bc60a
author
ermite · · 2026-10-22 10:56 UTC
merge of
3c13b3d + a0ea919 · diff against the first parent
signature
unsigned
37 files · +902 -92
M .mockery.yml +8 -0
@@ -182,3 +182,11 @@ packages:
182182 dir: internal/adapters/screencast
183183 pkgname: screencast
184184 filename: pipewire_opener_mock_test.go
185+
186+ github.com/bnema/neferwl/internal/adapters/xwayland:
187+ interfaces:
188+ starter:
189+ config:
190+ dir: internal/adapters/xwayland
191+ pkgname: xwayland
192+ filename: starter_mock_test.go
@@ -182,3 +182,11 @@ packages:
182 dir: internal/adapters/screencast182 dir: internal/adapters/screencast
183 pkgname: screencast183 pkgname: screencast
184 filename: pipewire_opener_mock_test.go184 filename: pipewire_opener_mock_test.go
185+
186+ github.com/bnema/neferwl/internal/adapters/xwayland:
187+ interfaces:
188+ starter:
189+ config:
190+ dir: internal/adapters/xwayland
191+ pkgname: xwayland
192+ 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 go.mod +2 -2
@@ -5,8 +5,8 @@ go 1.27
55 require (
66 github.com/bnema/go-wayland-bindings v0.1.0
77 github.com/bnema/kvconf v0.1.2
8- github.com/bnema/purego v0.13.0-bnema.1
9- github.com/bnema/purego-libwayland v0.11.0
8+ github.com/bnema/purego v0.13.0-bnema.2
9+ github.com/bnema/purego-libwayland v0.12.0
1010 github.com/bnema/purego-pipewire v0.2.0
1111 github.com/bnema/purego-vulkan v0.6.0
1212 github.com/bnema/wlturbo v0.5.0
@@ -5,8 +5,8 @@ go 1.27
5 require (5 require (
6 github.com/bnema/go-wayland-bindings v0.1.06 github.com/bnema/go-wayland-bindings v0.1.0
7 github.com/bnema/kvconf v0.1.27 github.com/bnema/kvconf v0.1.2
8- github.com/bnema/purego v0.13.0-bnema.18+ github.com/bnema/purego v0.13.0-bnema.2
9- github.com/bnema/purego-libwayland v0.11.09+ github.com/bnema/purego-libwayland v0.12.0
10 github.com/bnema/purego-pipewire v0.2.010 github.com/bnema/purego-pipewire v0.2.0
11 github.com/bnema/purego-vulkan v0.6.011 github.com/bnema/purego-vulkan v0.6.0
12 github.com/bnema/wlturbo v0.5.012 github.com/bnema/wlturbo v0.5.0
M go.sum +4 -4
@@ -2,10 +2,10 @@ github.com/bnema/go-wayland-bindings v0.1.0 h1:mi2vNa8BB5ZCo/hq/9d6Af01yhqY6QXAZ
22 github.com/bnema/go-wayland-bindings v0.1.0/go.mod h1:eWOgjp9vzZEqxm059q1G9F5NXplmv9UAmrDJiivxVjA=
33 github.com/bnema/kvconf v0.1.2 h1:1hDKL9jkQSBdR9dSKbwTONhUUBfJgAdh5vu+yFHOb70=
44 github.com/bnema/kvconf v0.1.2/go.mod h1:+P2X9I/sLx+Lfir45s9a7WA0GnC22WgKbZWE9LK6lcU=
5-github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8=
6-github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM=
7-github.com/bnema/purego-libwayland v0.11.0 h1:9DHAgg1M9+blG5rAC9EWlJt2GFRnA/pxuXE0S0S2Cl8=
8-github.com/bnema/purego-libwayland v0.11.0/go.mod h1:ZGc8Hz68KwJEHztCBU68o0pRFmDZgGFe3dJ+U1MHgwA=
5+github.com/bnema/purego v0.13.0-bnema.2 h1:FgGAFNswiShd+A4lFuP5broqjOzW4Lf8f6OZKjgXzAM=
6+github.com/bnema/purego v0.13.0-bnema.2/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM=
7+github.com/bnema/purego-libwayland v0.12.0 h1:TTISNNN3zfEwD0G46ZDzI7MMzXRmduLq+NMqBPhB+9g=
8+github.com/bnema/purego-libwayland v0.12.0/go.mod h1:HHDHathJMypu+/pDKWuyD8xcDneU5GoUt3nRen6SKU4=
99 github.com/bnema/purego-pipewire v0.2.0 h1:drIE0NjxynoCn9gCHTQFKkQVYEh/qsBNt2L2qZFYwro=
1010 github.com/bnema/purego-pipewire v0.2.0/go.mod h1:FxEYkYIWIP2g8Zo25jlnQSoP9yjsmb5vIuZwnqPg9uQ=
1111 github.com/bnema/purego-vulkan v0.6.0 h1:SJgu0smuDC2vtBv0dJ6dmRKewJW5EUXQwTXyb/p9JSE=
@@ -2,10 +2,10 @@ github.com/bnema/go-wayland-bindings v0.1.0 h1:mi2vNa8BB5ZCo/hq/9d6Af01yhqY6QXAZ
2 github.com/bnema/go-wayland-bindings v0.1.0/go.mod h1:eWOgjp9vzZEqxm059q1G9F5NXplmv9UAmrDJiivxVjA=2 github.com/bnema/go-wayland-bindings v0.1.0/go.mod h1:eWOgjp9vzZEqxm059q1G9F5NXplmv9UAmrDJiivxVjA=
3 github.com/bnema/kvconf v0.1.2 h1:1hDKL9jkQSBdR9dSKbwTONhUUBfJgAdh5vu+yFHOb70=3 github.com/bnema/kvconf v0.1.2 h1:1hDKL9jkQSBdR9dSKbwTONhUUBfJgAdh5vu+yFHOb70=
4 github.com/bnema/kvconf v0.1.2/go.mod h1:+P2X9I/sLx+Lfir45s9a7WA0GnC22WgKbZWE9LK6lcU=4 github.com/bnema/kvconf v0.1.2/go.mod h1:+P2X9I/sLx+Lfir45s9a7WA0GnC22WgKbZWE9LK6lcU=
5-github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8=5+github.com/bnema/purego v0.13.0-bnema.2 h1:FgGAFNswiShd+A4lFuP5broqjOzW4Lf8f6OZKjgXzAM=
6-github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM=6+github.com/bnema/purego v0.13.0-bnema.2/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM=
7-github.com/bnema/purego-libwayland v0.11.0 h1:9DHAgg1M9+blG5rAC9EWlJt2GFRnA/pxuXE0S0S2Cl8=7+github.com/bnema/purego-libwayland v0.12.0 h1:TTISNNN3zfEwD0G46ZDzI7MMzXRmduLq+NMqBPhB+9g=
8-github.com/bnema/purego-libwayland v0.11.0/go.mod h1:ZGc8Hz68KwJEHztCBU68o0pRFmDZgGFe3dJ+U1MHgwA=8+github.com/bnema/purego-libwayland v0.12.0/go.mod h1:HHDHathJMypu+/pDKWuyD8xcDneU5GoUt3nRen6SKU4=
9 github.com/bnema/purego-pipewire v0.2.0 h1:drIE0NjxynoCn9gCHTQFKkQVYEh/qsBNt2L2qZFYwro=9 github.com/bnema/purego-pipewire v0.2.0 h1:drIE0NjxynoCn9gCHTQFKkQVYEh/qsBNt2L2qZFYwro=
10 github.com/bnema/purego-pipewire v0.2.0/go.mod h1:FxEYkYIWIP2g8Zo25jlnQSoP9yjsmb5vIuZwnqPg9uQ=10 github.com/bnema/purego-pipewire v0.2.0/go.mod h1:FxEYkYIWIP2g8Zo25jlnQSoP9yjsmb5vIuZwnqPg9uQ=
11 github.com/bnema/purego-vulkan v0.6.0 h1:SJgu0smuDC2vtBv0dJ6dmRKewJW5EUXQwTXyb/p9JSE=11 github.com/bnema/purego-vulkan v0.6.0 h1:SJgu0smuDC2vtBv0dJ6dmRKewJW5EUXQwTXyb/p9JSE=
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/vulkan/capture_test.go +3 -0
@@ -358,6 +358,9 @@ func openFDs(t *testing.T) int {
358358 // slot keeps its sync file for every lease. No mock stands in the measured
359359 // closure.
360360 func TestCaptureCycleAllocations(t *testing.T) {
361+ if raceEnabled {
362+ t.Skip("allocation budgets are not stable under the race runtime")
363+ }
361364 for _, tc := range []struct {
362365 name string
363366 new func(*testing.T) *Renderer
@@ -358,6 +358,9 @@ func openFDs(t *testing.T) int {
358 // slot keeps its sync file for every lease. No mock stands in the measured358 // slot keeps its sync file for every lease. No mock stands in the measured
359 // closure.359 // closure.
360 func TestCaptureCycleAllocations(t *testing.T) {360 func TestCaptureCycleAllocations(t *testing.T) {
361+ if raceEnabled {
362+ t.Skip("allocation budgets are not stable under the race runtime")
363+ }
361 for _, tc := range []struct {364 for _, tc := range []struct {
362 name string365 name string
363 new func(*testing.T) *Renderer366 new func(*testing.T) *Renderer
A internal/adapters/vulkan/norace_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package vulkan
4+
5+const raceEnabled = false
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package vulkan
4+
5+const raceEnabled = false
A internal/adapters/vulkan/race_test.go +8 -0
@@ -0,0 +1,8 @@
1+//go:build race
2+
3+package vulkan
4+
5+// raceEnabled: sync.Pool drops items at random under the race runtime, so the
6+// allocation budgets of calls that go through purego cannot hold. perf-check
7+// runs them without it.
8+const raceEnabled = true
@@ -0,0 +1,8 @@
1+//go:build race
2+
3+package vulkan
4+
5+// raceEnabled: sync.Pool drops items at random under the race runtime, so the
6+// allocation budgets of calls that go through purego cannot hold. perf-check
7+// runs them without it.
8+const raceEnabled = true
M internal/adapters/vulkan/scene_perf_test.go +6 -0
@@ -16,6 +16,9 @@ import (
1616 // A new scene Seq with one of two windows moved is a partial redraw seeded by
1717 // sceneDelta and allocates nothing either.
1818 func TestRenderSteadyStateAllocations(t *testing.T) {
19+ if raceEnabled {
20+ t.Skip("allocation budgets are not stable under the race runtime")
21+ }
1922 for _, tc := range []struct {
2023 name string
2124 transform ports.BufferTransform
@@ -145,6 +148,9 @@ func TestRenderSteadyStateAllocations(t *testing.T) {
145148 // keeps one alloc of headroom for drivers whose import path differs.
146149 // Skipped without a GPU or /dev/udmabuf.
147150 func TestRenderSteadyStateDMABufAllocations(t *testing.T) {
151+ if raceEnabled {
152+ t.Skip("allocation budgets are not stable under the race runtime")
153+ }
148154 r, err := New(80, 32)
149155 if err != nil {
150156 t.Skipf("Vulkan unavailable: %v", err)
@@ -16,6 +16,9 @@ import (
16 // A new scene Seq with one of two windows moved is a partial redraw seeded by16 // A new scene Seq with one of two windows moved is a partial redraw seeded by
17 // sceneDelta and allocates nothing either.17 // sceneDelta and allocates nothing either.
18 func TestRenderSteadyStateAllocations(t *testing.T) {18 func TestRenderSteadyStateAllocations(t *testing.T) {
19+ if raceEnabled {
20+ t.Skip("allocation budgets are not stable under the race runtime")
21+ }
19 for _, tc := range []struct {22 for _, tc := range []struct {
20 name string23 name string
21 transform ports.BufferTransform24 transform ports.BufferTransform
@@ -145,6 +148,9 @@ func TestRenderSteadyStateAllocations(t *testing.T) {
145 // keeps one alloc of headroom for drivers whose import path differs.148 // keeps one alloc of headroom for drivers whose import path differs.
146 // Skipped without a GPU or /dev/udmabuf.149 // Skipped without a GPU or /dev/udmabuf.
147 func TestRenderSteadyStateDMABufAllocations(t *testing.T) {150 func TestRenderSteadyStateDMABufAllocations(t *testing.T) {
151+ if raceEnabled {
152+ t.Skip("allocation budgets are not stable under the race runtime")
153+ }
148 r, err := New(80, 32)154 r, err := New(80, 32)
149 if err != nil {155 if err != nil {
150 t.Skipf("Vulkan unavailable: %v", err)156 t.Skipf("Vulkan unavailable: %v", err)
M internal/adapters/wayland/activation.go +3 -3
@@ -66,7 +66,7 @@ func (a activation) Activate(_ *xdgactivation.ActivationV1, token string, surf *
6666 }
6767 var w *window
6868 if reason == "" {
69- if w = s.windows[state.root().windowID()]; w == nil || w.toplevel == nil || !w.mapped {
69+ if w = s.windows[state.root().windowID()]; w == nil || !w.isToplevel() || !w.mapped {
7070 reason = "not_toplevel"
7171 }
7272 }
@@ -116,8 +116,8 @@ func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) {
116116
117117 // focusClient is the client with the keyboard focus.
118118 func (s *Server) focusClient() server.Client {
119- if w := s.windows[s.seat.focused]; w != nil && w.xdg.resource.Resource.Alive() {
120- return w.xdg.resource.Client()
119+ if w := s.windows[s.seat.focused]; w != nil && w.alive() {
120+ return w.client()
121121 }
122122 if l := s.layers[s.seat.focused]; l != nil && l.resource.Resource.Alive() {
123123 return l.resource.Client()
@@ -66,7 +66,7 @@ func (a activation) Activate(_ *xdgactivation.ActivationV1, token string, surf *
66 }66 }
67 var w *window67 var w *window
68 if reason == "" {68 if reason == "" {
69- if w = s.windows[state.root().windowID()]; w == nil || w.toplevel == nil || !w.mapped {69+ if w = s.windows[state.root().windowID()]; w == nil || !w.isToplevel() || !w.mapped {
70 reason = "not_toplevel"70 reason = "not_toplevel"
71 }71 }
72 }72 }
@@ -116,8 +116,8 @@ func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) {
116 116
117 // focusClient is the client with the keyboard focus.117 // focusClient is the client with the keyboard focus.
118 func (s *Server) focusClient() server.Client {118 func (s *Server) focusClient() server.Client {
119- if w := s.windows[s.seat.focused]; w != nil && w.xdg.resource.Resource.Alive() {119+ if w := s.windows[s.seat.focused]; w != nil && w.alive() {
120- return w.xdg.resource.Client()120+ return w.client()
121 }121 }
122 if l := s.layers[s.seat.focused]; l != nil && l.resource.Resource.Alive() {122 if l := s.layers[s.seat.focused]; l != nil && l.resource.Resource.Alive() {
123 return l.resource.Client()123 return l.resource.Client()
M internal/adapters/wayland/constraints.go +8 -8
@@ -61,11 +61,11 @@ func (h pointerWarp) WarpPointer(r *pointerwarp.WpPointerWarpV1, surf *wayland.S
6161 return
6262 }
6363 w := s.windows[s.seat.pointerFocus]
64- if w == nil || !w.mapped || w.xdg.resource.Client() != r.Client() || s.surfaces[surf.Resource] != w.xdg.surface {
64+ if w == nil || !w.mapped || w.client() != r.Client() || s.surfaces[surf.Resource] != w.surf() {
6565 return
6666 }
6767 // Surface-local to window-local (core's, from the geometry origin).
68- g := w.xdg.geometry
68+ g := w.geometry()
6969 s.emit(ports.PointerWarp{ID: w.id, X: x.Float() - float64(g.X), Y: y.Float() - float64(g.Y)})
7070 }
7171
@@ -80,12 +80,12 @@ func (s *Server) relativeMotion(w *window, c ports.PointerMotionTo) bool {
8080 if s.protected() || c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 {
8181 return false
8282 }
83- if !w.mapped || !w.xdg.resource.Resource.Alive() {
83+ if !w.mapped || !w.alive() {
8484 return false
8585 }
8686 sent := false
8787 usec := uint64(c.Time / time.Microsecond)
88- for _, rel := range s.relatives[w.xdg.resource.Client()] {
88+ for _, rel := range s.relatives[w.client()] {
8989 if rel.Resource.Alive() {
9090 rel.SendRelativeMotion(uint32(usec>>32), uint32(usec), server.FixedFromFloat(c.DX), server.FixedFromFloat(c.DY), server.FixedFromFloat(c.UnaccelDX), server.FixedFromFloat(c.UnaccelDY))
9191 sent = true
@@ -272,7 +272,7 @@ func (s *Server) updateConstraint() {
272272 }
273273 var want *constraint
274274 if w := s.windows[s.seat.pointerFocus]; w != nil && w.mapped && s.seat.pointerFocus == s.seat.focused {
275- if c := s.constraints[w.xdg.surface]; c != nil && !c.defunct && (c.active || c.contains(w)) {
275+ if c := s.constraints[w.surf()]; c != nil && !c.defunct && (c.active || c.contains(w)) {
276276 want = c
277277 }
278278 }
@@ -340,10 +340,10 @@ func (s *Server) emitConstraint(c *constraint) {
340340 // activates only there, so it never warps the pointer.
341341 func (c *constraint) contains(w *window) bool {
342342 r := c.region
343- x, y := w.surfacePoint(w.xdg.server.seat.pointerX, w.xdg.server.seat.pointerY)
343+ x, y := w.surfacePoint(c.surface.server.seat.pointerX, c.surface.server.seat.pointerY)
344344 all, rects := c.surface.effectiveInput()
345345 if !all {
346- localX, localY := x-float64(w.xdg.geometry.X), y-float64(w.xdg.geometry.Y)
346+ localX, localY := x-float64(w.geometry().X), y-float64(w.geometry().Y)
347347 inside := false
348348 for _, input := range rects {
349349 if localX >= float64(input.X) && localX < float64(input.X+input.W) && localY >= float64(input.Y) && localY < float64(input.Y+input.H) {
@@ -363,5 +363,5 @@ func (c *constraint) contains(w *window) bool {
363363
364364 // locked reports whether the window's pointer is locked.
365365 func (s *Server) locked(w *window) bool {
366- return !s.protected() && s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.xdg.surface
366+ return !s.protected() && s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.surf()
367367 }
@@ -61,11 +61,11 @@ func (h pointerWarp) WarpPointer(r *pointerwarp.WpPointerWarpV1, surf *wayland.S
61 return61 return
62 }62 }
63 w := s.windows[s.seat.pointerFocus]63 w := s.windows[s.seat.pointerFocus]
64- if w == nil || !w.mapped || w.xdg.resource.Client() != r.Client() || s.surfaces[surf.Resource] != w.xdg.surface {64+ if w == nil || !w.mapped || w.client() != r.Client() || s.surfaces[surf.Resource] != w.surf() {
65 return65 return
66 }66 }
67 // Surface-local to window-local (core's, from the geometry origin).67 // Surface-local to window-local (core's, from the geometry origin).
68- g := w.xdg.geometry68+ g := w.geometry()
69 s.emit(ports.PointerWarp{ID: w.id, X: x.Float() - float64(g.X), Y: y.Float() - float64(g.Y)})69 s.emit(ports.PointerWarp{ID: w.id, X: x.Float() - float64(g.X), Y: y.Float() - float64(g.Y)})
70 }70 }
71 71
@@ -80,12 +80,12 @@ func (s *Server) relativeMotion(w *window, c ports.PointerMotionTo) bool {
80 if s.protected() || c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 {80 if s.protected() || c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 {
81 return false81 return false
82 }82 }
83- if !w.mapped || !w.xdg.resource.Resource.Alive() {83+ if !w.mapped || !w.alive() {
84 return false84 return false
85 }85 }
86 sent := false86 sent := false
87 usec := uint64(c.Time / time.Microsecond)87 usec := uint64(c.Time / time.Microsecond)
88- for _, rel := range s.relatives[w.xdg.resource.Client()] {88+ for _, rel := range s.relatives[w.client()] {
89 if rel.Resource.Alive() {89 if rel.Resource.Alive() {
90 rel.SendRelativeMotion(uint32(usec>>32), uint32(usec), server.FixedFromFloat(c.DX), server.FixedFromFloat(c.DY), server.FixedFromFloat(c.UnaccelDX), server.FixedFromFloat(c.UnaccelDY))90 rel.SendRelativeMotion(uint32(usec>>32), uint32(usec), server.FixedFromFloat(c.DX), server.FixedFromFloat(c.DY), server.FixedFromFloat(c.UnaccelDX), server.FixedFromFloat(c.UnaccelDY))
91 sent = true91 sent = true
@@ -272,7 +272,7 @@ func (s *Server) updateConstraint() {
272 }272 }
273 var want *constraint273 var want *constraint
274 if w := s.windows[s.seat.pointerFocus]; w != nil && w.mapped && s.seat.pointerFocus == s.seat.focused {274 if w := s.windows[s.seat.pointerFocus]; w != nil && w.mapped && s.seat.pointerFocus == s.seat.focused {
275- if c := s.constraints[w.xdg.surface]; c != nil && !c.defunct && (c.active || c.contains(w)) {275+ if c := s.constraints[w.surf()]; c != nil && !c.defunct && (c.active || c.contains(w)) {
276 want = c276 want = c
277 }277 }
278 }278 }
@@ -340,10 +340,10 @@ func (s *Server) emitConstraint(c *constraint) {
340 // activates only there, so it never warps the pointer.340 // activates only there, so it never warps the pointer.
341 func (c *constraint) contains(w *window) bool {341 func (c *constraint) contains(w *window) bool {
342 r := c.region342 r := c.region
343- x, y := w.surfacePoint(w.xdg.server.seat.pointerX, w.xdg.server.seat.pointerY)343+ x, y := w.surfacePoint(c.surface.server.seat.pointerX, c.surface.server.seat.pointerY)
344 all, rects := c.surface.effectiveInput()344 all, rects := c.surface.effectiveInput()
345 if !all {345 if !all {
346- localX, localY := x-float64(w.xdg.geometry.X), y-float64(w.xdg.geometry.Y)346+ localX, localY := x-float64(w.geometry().X), y-float64(w.geometry().Y)
347 inside := false347 inside := false
348 for _, input := range rects {348 for _, input := range rects {
349 if localX >= float64(input.X) && localX < float64(input.X+input.W) && localY >= float64(input.Y) && localY < float64(input.Y+input.H) {349 if localX >= float64(input.X) && localX < float64(input.X+input.W) && localY >= float64(input.Y) && localY < float64(input.Y+input.H) {
@@ -363,5 +363,5 @@ func (c *constraint) contains(w *window) bool {
363 363
364 // locked reports whether the window's pointer is locked.364 // locked reports whether the window's pointer is locked.
365 func (s *Server) locked(w *window) bool {365 func (s *Server) locked(w *window) bool {
366- return !s.protected() && s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.xdg.surface366+ return !s.protected() && s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.surf()
367 }367 }
M internal/adapters/wayland/decoration.go +1 -1
@@ -80,7 +80,7 @@ func (d *decoration) UnsetMode(r *xdgdecoration.ZxdgToplevelDecorationV1)
8080 func (d *decoration) send(r *xdgdecoration.ZxdgToplevelDecorationV1) {
8181 r.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide))
8282 for _, w := range d.s.windows {
83- if w.toplevel == d.toplevel && w.hasLast && w.toplevel.Resource.Alive() && w.xdg.resource.Resource.Alive() {
83+ if w.toplevel == d.toplevel && w.hasLast && w.toplevelAlive() && w.alive() {
8484 w.sendConfigure()
8585 return
8686 }
@@ -80,7 +80,7 @@ func (d *decoration) UnsetMode(r *xdgdecoration.ZxdgToplevelDecorationV1)
80 func (d *decoration) send(r *xdgdecoration.ZxdgToplevelDecorationV1) {80 func (d *decoration) send(r *xdgdecoration.ZxdgToplevelDecorationV1) {
81 r.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide))81 r.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide))
82 for _, w := range d.s.windows {82 for _, w := range d.s.windows {
83- if w.toplevel == d.toplevel && w.hasLast && w.toplevel.Resource.Alive() && w.xdg.resource.Resource.Alive() {83+ if w.toplevel == d.toplevel && w.hasLast && w.toplevelAlive() && w.alive() {
84 w.sendConfigure()84 w.sendConfigure()
85 return85 return
86 }86 }
M internal/adapters/wayland/dialog.go +1 -1
@@ -76,7 +76,7 @@ func (w *window) setModal(modal bool) {
7676 return
7777 }
7878 w.modal = modal
79- if w.mapped {
79+ if w.mapped && w.xdg != nil {
8080 w.xdg.server.emit(ports.WindowModal{ID: w.id, Modal: modal})
8181 }
8282 }
@@ -76,7 +76,7 @@ func (w *window) setModal(modal bool) {
76 return76 return
77 }77 }
78 w.modal = modal78 w.modal = modal
79- if w.mapped {79+ if w.mapped && w.xdg != nil {
80 w.xdg.server.emit(ports.WindowModal{ID: w.id, Modal: modal})80 w.xdg.server.emit(ports.WindowModal{ID: w.id, Modal: modal})
81 }81 }
82 }82 }
M internal/adapters/wayland/extforeigntoplevel.go +1 -1
@@ -72,7 +72,7 @@ func (*extToplevelList) Destroy(*ext.ExtForeignToplevelListV1) {}
7272 func (s *Server) mappedToplevels() []ports.WindowID {
7373 ids := make([]ports.WindowID, 0, len(s.windows))
7474 for id, w := range s.windows {
75- if w.toplevel != nil && w.mapped {
75+ if w.isToplevel() && w.mapped {
7676 ids = append(ids, id)
7777 }
7878 }
@@ -72,7 +72,7 @@ func (*extToplevelList) Destroy(*ext.ExtForeignToplevelListV1) {}
72 func (s *Server) mappedToplevels() []ports.WindowID {72 func (s *Server) mappedToplevels() []ports.WindowID {
73 ids := make([]ports.WindowID, 0, len(s.windows))73 ids := make([]ports.WindowID, 0, len(s.windows))
74 for id, w := range s.windows {74 for id, w := range s.windows {
75- if w.toplevel != nil && w.mapped {75+ if w.isToplevel() && w.mapped {
76 ids = append(ids, id)76 ids = append(ids, id)
77 }77 }
78 }78 }
M internal/adapters/wayland/foreigntoplevel.go +6 -6
@@ -44,7 +44,7 @@ func registerForeignToplevel(d *server.Display, s *Server) error {
4444 r.OnDestroy = func() { s.toplevelManagers = removeItem(s.toplevelManagers, m) }
4545 ids := make([]ports.WindowID, 0, len(s.windows))
4646 for id, w := range s.windows {
47- if w.toplevel != nil && w.mapped {
47+ if w.isToplevel() && w.mapped {
4848 ids = append(ids, id)
4949 }
5050 }
@@ -186,7 +186,7 @@ func toplevelState(w *window, version int32) []byte {
186186
187187 // toplevelChanged updates every manager after w mapped or changed.
188188 func (s *Server) toplevelChanged(w *window) {
189- if w.toplevel == nil || !w.mapped {
189+ if !w.isToplevel() || !w.mapped {
190190 return
191191 }
192192 for _, m := range s.toplevelManagers {
@@ -220,7 +220,7 @@ func (s *Server) refreshToplevels() {
220220 for _, l := range s.extToplevelLists {
221221 for id, h := range l.handles {
222222 w := s.windows[id]
223- if h.closed || w == nil || w.toplevel == nil || !w.mapped {
223+ if h.closed || w == nil || !w.isToplevel() || !w.mapped {
224224 l.closed(id)
225225 }
226226 }
@@ -231,7 +231,7 @@ func (s *Server) refreshToplevels() {
231231 for _, m := range s.toplevelManagers {
232232 for id, h := range m.handles {
233233 w := s.windows[id]
234- if h.req.closed || w == nil || w.toplevel == nil || !w.mapped {
234+ if h.req.closed || w == nil || !w.isToplevel() || !w.mapped {
235235 m.closed(id)
236236 }
237237 }
@@ -253,7 +253,7 @@ func (t *toplevelRequests) window() *window {
253253 if t.s.protected() || t.closed {
254254 return nil
255255 }
256- if w := t.s.windows[t.id]; w != nil && w.toplevel != nil && w.mapped && w.toplevel.Resource.Alive() {
256+ if w := t.s.windows[t.id]; w != nil && w.toplevelAlive() && w.mapped {
257257 return w
258258 }
259259 return nil
@@ -274,7 +274,7 @@ func (t *toplevelRequests) Activate(*wlr.ZwlrForeignToplevelHandleV1, *wayland.S
274274 }
275275 func (t *toplevelRequests) Close(*wlr.ZwlrForeignToplevelHandleV1) {
276276 if w := t.window(); w != nil {
277- w.toplevel.SendClose()
277+ w.sendClose()
278278 }
279279 }
280280 func (t *toplevelRequests) SetRectangle(r *wlr.ZwlrForeignToplevelHandleV1, _ *wayland.Surface, _, _, w, h int32) {
@@ -44,7 +44,7 @@ func registerForeignToplevel(d *server.Display, s *Server) error {
44 r.OnDestroy = func() { s.toplevelManagers = removeItem(s.toplevelManagers, m) }44 r.OnDestroy = func() { s.toplevelManagers = removeItem(s.toplevelManagers, m) }
45 ids := make([]ports.WindowID, 0, len(s.windows))45 ids := make([]ports.WindowID, 0, len(s.windows))
46 for id, w := range s.windows {46 for id, w := range s.windows {
47- if w.toplevel != nil && w.mapped {47+ if w.isToplevel() && w.mapped {
48 ids = append(ids, id)48 ids = append(ids, id)
49 }49 }
50 }50 }
@@ -186,7 +186,7 @@ func toplevelState(w *window, version int32) []byte {
186 186
187 // toplevelChanged updates every manager after w mapped or changed.187 // toplevelChanged updates every manager after w mapped or changed.
188 func (s *Server) toplevelChanged(w *window) {188 func (s *Server) toplevelChanged(w *window) {
189- if w.toplevel == nil || !w.mapped {189+ if !w.isToplevel() || !w.mapped {
190 return190 return
191 }191 }
192 for _, m := range s.toplevelManagers {192 for _, m := range s.toplevelManagers {
@@ -220,7 +220,7 @@ func (s *Server) refreshToplevels() {
220 for _, l := range s.extToplevelLists {220 for _, l := range s.extToplevelLists {
221 for id, h := range l.handles {221 for id, h := range l.handles {
222 w := s.windows[id]222 w := s.windows[id]
223- if h.closed || w == nil || w.toplevel == nil || !w.mapped {223+ if h.closed || w == nil || !w.isToplevel() || !w.mapped {
224 l.closed(id)224 l.closed(id)
225 }225 }
226 }226 }
@@ -231,7 +231,7 @@ func (s *Server) refreshToplevels() {
231 for _, m := range s.toplevelManagers {231 for _, m := range s.toplevelManagers {
232 for id, h := range m.handles {232 for id, h := range m.handles {
233 w := s.windows[id]233 w := s.windows[id]
234- if h.req.closed || w == nil || w.toplevel == nil || !w.mapped {234+ if h.req.closed || w == nil || !w.isToplevel() || !w.mapped {
235 m.closed(id)235 m.closed(id)
236 }236 }
237 }237 }
@@ -253,7 +253,7 @@ func (t *toplevelRequests) window() *window {
253 if t.s.protected() || t.closed {253 if t.s.protected() || t.closed {
254 return nil254 return nil
255 }255 }
256- if w := t.s.windows[t.id]; w != nil && w.toplevel != nil && w.mapped && w.toplevel.Resource.Alive() {256+ if w := t.s.windows[t.id]; w != nil && w.toplevelAlive() && w.mapped {
257 return w257 return w
258 }258 }
259 return nil259 return nil
@@ -274,7 +274,7 @@ func (t *toplevelRequests) Activate(*wlr.ZwlrForeignToplevelHandleV1, *wayland.S
274 }274 }
275 func (t *toplevelRequests) Close(*wlr.ZwlrForeignToplevelHandleV1) {275 func (t *toplevelRequests) Close(*wlr.ZwlrForeignToplevelHandleV1) {
276 if w := t.window(); w != nil {276 if w := t.window(); w != nil {
277- w.toplevel.SendClose()277+ w.sendClose()
278 }278 }
279 }279 }
280 func (t *toplevelRequests) SetRectangle(r *wlr.ZwlrForeignToplevelHandleV1, _ *wayland.Surface, _, _, w, h int32) {280 func (t *toplevelRequests) SetRectangle(r *wlr.ZwlrForeignToplevelHandleV1, _ *wayland.Surface, _, _, w, h int32) {
M internal/adapters/wayland/frames.go +1 -1
@@ -183,7 +183,7 @@ func (s *Server) pacePass(flipped map[string]bool, reports []ports.OutputPresent
183183 func toplevelRoot(surf *surface) *surface {
184184 surf = surf.root()
185185 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {
186- surf = surf.xdg.window.popup.parent.xdg.surface.root()
186+ surf = surf.xdg.window.popup.parent.surf().root()
187187 }
188188 return surf
189189 }
@@ -183,7 +183,7 @@ func (s *Server) pacePass(flipped map[string]bool, reports []ports.OutputPresent
183 func toplevelRoot(surf *surface) *surface {183 func toplevelRoot(surf *surface) *surface {
184 surf = surf.root()184 surf = surf.root()
185 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {185 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {
186- surf = surf.xdg.window.popup.parent.xdg.surface.root()186+ surf = surf.xdg.window.popup.parent.surf().root()
187 }187 }
188 return surf188 return surf
189 }189 }
M internal/adapters/wayland/globals.go +6 -1
@@ -131,8 +131,13 @@ func (c compositor) CreateSurface(r *wayland.Compositor, id uint32) {
131131 state.identity = c.server.nextSurface
132132 state.wl = w
133133 c.server.surfaces[w.Resource] = state
134+ c.server.surfaceByIdentity[state.identity] = state
134135 state.sendScale()
135- w.OnDestroy = func() { delete(c.server.surfaces, w.Resource); state.Destroy(w) }
136+ w.OnDestroy = func() {
137+ delete(c.server.surfaces, w.Resource)
138+ delete(c.server.surfaceByIdentity, state.identity)
139+ state.Destroy(w)
140+ }
136141 }
137142 }
138143 func (c compositor) CreateRegion(r *wayland.Compositor, id uint32) {
@@ -131,8 +131,13 @@ func (c compositor) CreateSurface(r *wayland.Compositor, id uint32) {
131 state.identity = c.server.nextSurface131 state.identity = c.server.nextSurface
132 state.wl = w132 state.wl = w
133 c.server.surfaces[w.Resource] = state133 c.server.surfaces[w.Resource] = state
134+ c.server.surfaceByIdentity[state.identity] = state
134 state.sendScale()135 state.sendScale()
135- w.OnDestroy = func() { delete(c.server.surfaces, w.Resource); state.Destroy(w) }136+ w.OnDestroy = func() {
137+ delete(c.server.surfaces, w.Resource)
138+ delete(c.server.surfaceByIdentity, state.identity)
139+ state.Destroy(w)
140+ }
136 }141 }
137 }142 }
138 func (c compositor) CreateRegion(r *wayland.Compositor, id uint32) {143 func (c compositor) CreateRegion(r *wayland.Compositor, id uint32) {
M internal/adapters/wayland/imagecopy.go +1 -1
@@ -351,7 +351,7 @@ func (c *captureTarget) geometry() (o *output, rect image.Rectangle, hidden, ok
351351 case srcWindow:
352352 // Core's state gives the size; before it, the last configure.
353353 w := c.s.windows[src.window]
354- if w == nil || !w.mapped || w.toplevel == nil || w.gen != src.gen {
354+ if w == nil || !w.mapped || !w.isToplevel() || w.gen != src.gen {
355355 return nil, image.Rectangle{}, false, false
356356 }
357357 name, size := w.last.Output, ports.Rect{W: w.last.Width, H: w.last.Height}
@@ -351,7 +351,7 @@ func (c *captureTarget) geometry() (o *output, rect image.Rectangle, hidden, ok
351 case srcWindow:351 case srcWindow:
352 // Core's state gives the size; before it, the last configure.352 // Core's state gives the size; before it, the last configure.
353 w := c.s.windows[src.window]353 w := c.s.windows[src.window]
354- if w == nil || !w.mapped || w.toplevel == nil || w.gen != src.gen {354+ if w == nil || !w.mapped || !w.isToplevel() || w.gen != src.gen {
355 return nil, image.Rectangle{}, false, false355 return nil, image.Rectangle{}, false, false
356 }356 }
357 name, size := w.last.Output, ports.Rect{W: w.last.Width, H: w.last.Height}357 name, size := w.last.Output, ports.Rect{W: w.last.Width, H: w.last.Height}
M internal/adapters/wayland/popup.go +2 -2
@@ -31,7 +31,7 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX
3131 var parent *window
3232 if parentXDG != nil {
3333 for _, w := range s.windows {
34- if w.xdg.resource != nil && w.xdg.resource.Resource == parentXDG.Resource {
34+ if w.xdg != nil && w.xdg.resource != nil && w.xdg.resource.Resource == parentXDG.Resource {
3535 parent = w
3636 }
3737 }
@@ -166,7 +166,7 @@ func (p *popup) configure(c ports.ConfigurePopup) {
166166 func (s *Server) topGrab(client server.Client, parent *window) bool {
167167 var top *window
168168 for _, w := range s.windows {
169- if w.popup != nil && w.popup.grab && !w.popup.done && w.xdg.resource.Client() == client && (top == nil || w.id > top.id) {
169+ if w.popup != nil && w.popup.grab && !w.popup.done && w.client() == client && (top == nil || w.id > top.id) {
170170 top = w
171171 }
172172 }
@@ -31,7 +31,7 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX
31 var parent *window31 var parent *window
32 if parentXDG != nil {32 if parentXDG != nil {
33 for _, w := range s.windows {33 for _, w := range s.windows {
34- if w.xdg.resource != nil && w.xdg.resource.Resource == parentXDG.Resource {34+ if w.xdg != nil && w.xdg.resource != nil && w.xdg.resource.Resource == parentXDG.Resource {
35 parent = w35 parent = w
36 }36 }
37 }37 }
@@ -166,7 +166,7 @@ func (p *popup) configure(c ports.ConfigurePopup) {
166 func (s *Server) topGrab(client server.Client, parent *window) bool {166 func (s *Server) topGrab(client server.Client, parent *window) bool {
167 var top *window167 var top *window
168 for _, w := range s.windows {168 for _, w := range s.windows {
169- if w.popup != nil && w.popup.grab && !w.popup.done && w.xdg.resource.Client() == client && (top == nil || w.id > top.id) {169+ if w.popup != nil && w.popup.grab && !w.popup.done && w.client() == client && (top == nil || w.id > top.id) {
170 top = w170 top = w
171 }171 }
172 }172 }
M internal/adapters/wayland/scale.go +1 -1
@@ -21,7 +21,7 @@ func (s *Server) outputOfSurface(surf *surface) *output {
2121 surf = surf.root()
2222 // Popups are on their parent's output.
2323 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {
24- surf = surf.xdg.window.popup.parent.xdg.surface.root()
24+ surf = surf.xdg.window.popup.parent.surf().root()
2525 }
2626 name := ""
2727 switch {
@@ -21,7 +21,7 @@ func (s *Server) outputOfSurface(surf *surface) *output {
21 surf = surf.root()21 surf = surf.root()
22 // Popups are on their parent's output.22 // Popups are on their parent's output.
23 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {23 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {
24- surf = surf.xdg.window.popup.parent.xdg.surface.root()24+ surf = surf.xdg.window.popup.parent.surf().root()
25 }25 }
26 name := ""26 name := ""
27 switch {27 switch {
M internal/adapters/wayland/screencast.go +1 -1
@@ -146,7 +146,7 @@ func (s *Server) openCast(cmd ports.CastCommand) {
146146 gone := ports.CaptureReasonOutputGone
147147 if cmd.Window != 0 {
148148 gone = ports.CaptureReasonWindowGone
149- if w := s.windows[cmd.Window]; w != nil && w.mapped && w.toplevel != nil {
149+ if w := s.windows[cmd.Window]; w != nil && w.mapped && w.isToplevel() {
150150 src = &captureSource{kind: srcWindow, window: cmd.Window, gen: w.gen}
151151 open.Window = cmd.Window
152152 }
@@ -146,7 +146,7 @@ func (s *Server) openCast(cmd ports.CastCommand) {
146 gone := ports.CaptureReasonOutputGone146 gone := ports.CaptureReasonOutputGone
147 if cmd.Window != 0 {147 if cmd.Window != 0 {
148 gone = ports.CaptureReasonWindowGone148 gone = ports.CaptureReasonWindowGone
149- if w := s.windows[cmd.Window]; w != nil && w.mapped && w.toplevel != nil {149+ if w := s.windows[cmd.Window]; w != nil && w.mapped && w.isToplevel() {
150 src = &captureSource{kind: srcWindow, window: cmd.Window, gen: w.gen}150 src = &captureSource{kind: srcWindow, window: cmd.Window, gen: w.gen}
151 open.Window = cmd.Window151 open.Window = cmd.Window
152 }152 }
M internal/adapters/wayland/seat.go +8 -8
@@ -276,10 +276,10 @@ func (s *Server) setModifiers(c ports.SetModifiers) {
276276 }
277277
278278 func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {
279- if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {
279+ if w == nil || !w.mapped || !w.alive() {
280280 return nil
281281 }
282- return s.clientKeyboards(w.xdg.resource.Client())
282+ return s.clientKeyboards(w.client())
283283 }
284284
285285 // clientKeyboards returns c's wl_keyboards without allocating. The list holds
@@ -301,7 +301,7 @@ func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Ke
301301 return l.surface.wl, s.clientKeyboards(l.surface.wl.Client())
302302 }
303303 if w := s.windows[id]; w != nil && w.mapped {
304- if surf := w.xdg.surfaceResource(); surf != nil {
304+ if surf := w.surfaceResource(); surf != nil {
305305 return surf, s.windowKeyboards(w)
306306 }
307307 }
@@ -319,8 +319,8 @@ func (s *Server) pointerTarget(id ports.WindowID) (server.Client, []*wayland.Poi
319319 if l := s.lockSurfaces[id]; l != nil && s.lockInputTarget(id) {
320320 return l.resource.Client(), s.clientPointers(l.resource.Client()), true
321321 }
322- if w := s.windows[id]; w != nil && w.mapped && w.xdg.resource.Resource.Alive() {
323- return w.xdg.resource.Client(), s.windowPointers(w), true
322+ if w := s.windows[id]; w != nil && w.mapped && w.alive() {
323+ return w.client(), s.windowPointers(w), true
324324 }
325325 if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() {
326326 return l.resource.Client(), s.layerPointers(l), true
@@ -335,7 +335,7 @@ func (s *Server) pointerSurface(id ports.WindowID, x, y float64) (*wayland.Surfa
335335 return l.surface.wl, s.clientPointers(l.surface.wl.Client()), x, y
336336 }
337337 if w := s.windows[id]; w != nil {
338- if surf := w.xdg.surfaceResource(); surf != nil && surf.Resource.Alive() {
338+ if surf := w.surfaceResource(); surf != nil && surf.Resource.Alive() {
339339 x, y := w.surfacePoint(x, y)
340340 return surf, s.windowPointers(w), x, y
341341 }
@@ -543,10 +543,10 @@ func pointerFrame(p *wayland.Pointer) {
543543 }
544544 }
545545 func (s *Server) windowPointers(w *window) []*wayland.Pointer {
546- if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {
546+ if w == nil || !w.mapped || !w.alive() {
547547 return nil
548548 }
549- return s.clientPointers(w.xdg.resource.Client())
549+ return s.clientPointers(w.client())
550550 }
551551
552552 func (s *Server) layerPointers(l *layerSurface) []*wayland.Pointer {
@@ -276,10 +276,10 @@ func (s *Server) setModifiers(c ports.SetModifiers) {
276 }276 }
277 277
278 func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {278 func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {
279- if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {279+ if w == nil || !w.mapped || !w.alive() {
280 return nil280 return nil
281 }281 }
282- return s.clientKeyboards(w.xdg.resource.Client())282+ return s.clientKeyboards(w.client())
283 }283 }
284 284
285 // clientKeyboards returns c's wl_keyboards without allocating. The list holds285 // clientKeyboards returns c's wl_keyboards without allocating. The list holds
@@ -301,7 +301,7 @@ func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Ke
301 return l.surface.wl, s.clientKeyboards(l.surface.wl.Client())301 return l.surface.wl, s.clientKeyboards(l.surface.wl.Client())
302 }302 }
303 if w := s.windows[id]; w != nil && w.mapped {303 if w := s.windows[id]; w != nil && w.mapped {
304- if surf := w.xdg.surfaceResource(); surf != nil {304+ if surf := w.surfaceResource(); surf != nil {
305 return surf, s.windowKeyboards(w)305 return surf, s.windowKeyboards(w)
306 }306 }
307 }307 }
@@ -319,8 +319,8 @@ func (s *Server) pointerTarget(id ports.WindowID) (server.Client, []*wayland.Poi
319 if l := s.lockSurfaces[id]; l != nil && s.lockInputTarget(id) {319 if l := s.lockSurfaces[id]; l != nil && s.lockInputTarget(id) {
320 return l.resource.Client(), s.clientPointers(l.resource.Client()), true320 return l.resource.Client(), s.clientPointers(l.resource.Client()), true
321 }321 }
322- if w := s.windows[id]; w != nil && w.mapped && w.xdg.resource.Resource.Alive() {322+ if w := s.windows[id]; w != nil && w.mapped && w.alive() {
323- return w.xdg.resource.Client(), s.windowPointers(w), true323+ return w.client(), s.windowPointers(w), true
324 }324 }
325 if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() {325 if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() {
326 return l.resource.Client(), s.layerPointers(l), true326 return l.resource.Client(), s.layerPointers(l), true
@@ -335,7 +335,7 @@ func (s *Server) pointerSurface(id ports.WindowID, x, y float64) (*wayland.Surfa
335 return l.surface.wl, s.clientPointers(l.surface.wl.Client()), x, y335 return l.surface.wl, s.clientPointers(l.surface.wl.Client()), x, y
336 }336 }
337 if w := s.windows[id]; w != nil {337 if w := s.windows[id]; w != nil {
338- if surf := w.xdg.surfaceResource(); surf != nil && surf.Resource.Alive() {338+ if surf := w.surfaceResource(); surf != nil && surf.Resource.Alive() {
339 x, y := w.surfacePoint(x, y)339 x, y := w.surfacePoint(x, y)
340 return surf, s.windowPointers(w), x, y340 return surf, s.windowPointers(w), x, y
341 }341 }
@@ -543,10 +543,10 @@ func pointerFrame(p *wayland.Pointer) {
543 }543 }
544 }544 }
545 func (s *Server) windowPointers(w *window) []*wayland.Pointer {545 func (s *Server) windowPointers(w *window) []*wayland.Pointer {
546- if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {546+ if w == nil || !w.mapped || !w.alive() {
547 return nil547 return nil
548 }548 }
549- return s.clientPointers(w.xdg.resource.Client())549+ return s.clientPointers(w.client())
550 }550 }
551 551
552 func (s *Server) layerPointers(l *layerSurface) []*wayland.Pointer {552 func (s *Server) layerPointers(l *layerSurface) []*wayland.Pointer {
M internal/adapters/wayland/server.go +15 -9
@@ -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
@@ -214,8 +215,11 @@ type Server struct {
214215 // foreignExports are the live xdg-foreign handles (foreign.go).
215216 foreignExports map[string]*foreignExport
216217 surfaces map[*server.Resource]*surface
217- buffers map[*server.Resource]clientBuffer
218- dmabuf *dmabufGlobal
218+ // surfaceByIdentity finds a surface by its identity (xwayland_shell_v1
219+ // pairing); kept with surfaces.
220+ surfaceByIdentity map[uint64]*surface
221+ buffers map[*server.Resource]clientBuffer
222+ dmabuf *dmabufGlobal
219223 // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.
220224 hdrOutputs map[string]ports.OutputHDR
221225 colorID uint64
@@ -366,6 +370,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
366370 logDamage: opts.LogDamage,
367371 channels: ch,
368372 surfaces: make(map[*server.Resource]*surface),
373+ surfaceByIdentity: make(map[uint64]*surface),
369374 buffers: make(map[*server.Resource]clientBuffer),
370375 windows: make(map[ports.WindowID]*window),
371376 layers: make(map[ports.WindowID]*layerSurface),
@@ -585,6 +590,7 @@ func (s *Server) Run(ctx context.Context) error {
585590 }
586591 s.castInventoryChanged()
587592 err := s.display.Run(ctx)
593+ s.forgetXwayland()
588594 s.stopSecurityContexts()
589595 wg.Wait()
590596 return err
@@ -776,7 +782,7 @@ func (s *Server) apply(cmd ports.ClientCommand) {
776782 }
777783 case ports.ConfigureWindow:
778784 w := s.windows[c.ID]
779- if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() || !w.xdg.resource.Resource.Alive() {
785+ if w == nil || !w.toplevelAlive() || !w.alive() {
780786 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window")
781787 return
782788 }
@@ -787,16 +793,16 @@ func (s *Server) apply(cmd ports.ClientCommand) {
787793 scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output || w.last.Visible != c.Visible
788794 w.last, w.hasLast = c, true
789795 if visibilityChanged {
790- s.relocateCallbacks(w.xdg.surface)
796+ s.relocateCallbacks(w.surf())
791797 s.dropFeedbacks(time.Now())
792798 s.wakePacer() // reconsider FIFO barriers at the new period
793799 }
794800 w.sendConfigure()
795801 s.toplevelChanged(w)
796- if scanoutChanged && w.xdg.surface != nil {
797- s.dmabuf.resendSurface(w.xdg.surface)
802+ if scanoutChanged && w.surf() != nil {
803+ s.dmabuf.resendSurface(w.surf())
798804 }
799- if surf := w.xdg.surface; surf != nil && c.Output != "" {
805+ if surf := w.surf(); surf != nil && c.Output != "" {
800806 surf.sendTreeScale()
801807 }
802808 if scanoutChanged {
@@ -817,10 +823,10 @@ func (s *Server) apply(cmd ports.ClientCommand) {
817823 s.setShortcutsInhibit(c)
818824 case ports.CloseWindow:
819825 w := s.windows[c.ID]
820- if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() {
826+ if w == nil || !w.toplevelAlive() {
821827 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window")
822828 return
823829 }
824- w.toplevel.SendClose()
830+ w.sendClose()
825831 }
826832 }
@@ -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
@@ -214,8 +215,11 @@ type Server struct {
214 // foreignExports are the live xdg-foreign handles (foreign.go).215 // foreignExports are the live xdg-foreign handles (foreign.go).
215 foreignExports map[string]*foreignExport216 foreignExports map[string]*foreignExport
216 surfaces map[*server.Resource]*surface217 surfaces map[*server.Resource]*surface
217- buffers map[*server.Resource]clientBuffer218+ // surfaceByIdentity finds a surface by its identity (xwayland_shell_v1
218- dmabuf *dmabufGlobal219+ // pairing); kept with surfaces.
220+ surfaceByIdentity map[uint64]*surface
221+ buffers map[*server.Resource]clientBuffer
222+ dmabuf *dmabufGlobal
219 // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.223 // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.
220 hdrOutputs map[string]ports.OutputHDR224 hdrOutputs map[string]ports.OutputHDR
221 colorID uint64225 colorID uint64
@@ -366,6 +370,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
366 logDamage: opts.LogDamage,370 logDamage: opts.LogDamage,
367 channels: ch,371 channels: ch,
368 surfaces: make(map[*server.Resource]*surface),372 surfaces: make(map[*server.Resource]*surface),
373+ surfaceByIdentity: make(map[uint64]*surface),
369 buffers: make(map[*server.Resource]clientBuffer),374 buffers: make(map[*server.Resource]clientBuffer),
370 windows: make(map[ports.WindowID]*window),375 windows: make(map[ports.WindowID]*window),
371 layers: make(map[ports.WindowID]*layerSurface),376 layers: make(map[ports.WindowID]*layerSurface),
@@ -585,6 +590,7 @@ func (s *Server) Run(ctx context.Context) error {
585 }590 }
586 s.castInventoryChanged()591 s.castInventoryChanged()
587 err := s.display.Run(ctx)592 err := s.display.Run(ctx)
593+ s.forgetXwayland()
588 s.stopSecurityContexts()594 s.stopSecurityContexts()
589 wg.Wait()595 wg.Wait()
590 return err596 return err
@@ -776,7 +782,7 @@ func (s *Server) apply(cmd ports.ClientCommand) {
776 }782 }
777 case ports.ConfigureWindow:783 case ports.ConfigureWindow:
778 w := s.windows[c.ID]784 w := s.windows[c.ID]
779- if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() || !w.xdg.resource.Resource.Alive() {785+ if w == nil || !w.toplevelAlive() || !w.alive() {
780 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window")786 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window")
781 return787 return
782 }788 }
@@ -787,16 +793,16 @@ func (s *Server) apply(cmd ports.ClientCommand) {
787 scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output || w.last.Visible != c.Visible793 scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output || w.last.Visible != c.Visible
788 w.last, w.hasLast = c, true794 w.last, w.hasLast = c, true
789 if visibilityChanged {795 if visibilityChanged {
790- s.relocateCallbacks(w.xdg.surface)796+ s.relocateCallbacks(w.surf())
791 s.dropFeedbacks(time.Now())797 s.dropFeedbacks(time.Now())
792 s.wakePacer() // reconsider FIFO barriers at the new period798 s.wakePacer() // reconsider FIFO barriers at the new period
793 }799 }
794 w.sendConfigure()800 w.sendConfigure()
795 s.toplevelChanged(w)801 s.toplevelChanged(w)
796- if scanoutChanged && w.xdg.surface != nil {802+ if scanoutChanged && w.surf() != nil {
797- s.dmabuf.resendSurface(w.xdg.surface)803+ s.dmabuf.resendSurface(w.surf())
798 }804 }
799- if surf := w.xdg.surface; surf != nil && c.Output != "" {805+ if surf := w.surf(); surf != nil && c.Output != "" {
800 surf.sendTreeScale()806 surf.sendTreeScale()
801 }807 }
802 if scanoutChanged {808 if scanoutChanged {
@@ -817,10 +823,10 @@ func (s *Server) apply(cmd ports.ClientCommand) {
817 s.setShortcutsInhibit(c)823 s.setShortcutsInhibit(c)
818 case ports.CloseWindow:824 case ports.CloseWindow:
819 w := s.windows[c.ID]825 w := s.windows[c.ID]
820- if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() {826+ if w == nil || !w.toplevelAlive() {
821 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window")827 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window")
822 return828 return
823 }829 }
824- w.toplevel.SendClose()830+ w.sendClose()
825 }831 }
826 }832 }
M internal/adapters/wayland/surface.go +4 -0
@@ -28,6 +28,10 @@ const (
2828 roleSessionLock
2929 // roleDragIcon is the icon of a data drag (dnd.go).
3030 roleDragIcon
31+ // roleX11 is the surface of an X11 window paired through
32+ // xwayland_shell_v1.
33+ //lint:ignore U1000 used by the X11 window role (xwm)
34+ roleX11
3135 )
3236
3337 type surface struct {
@@ -28,6 +28,10 @@ const (
28 roleSessionLock28 roleSessionLock
29 // roleDragIcon is the icon of a data drag (dnd.go).29 // roleDragIcon is the icon of a data drag (dnd.go).
30 roleDragIcon30 roleDragIcon
31+ // roleX11 is the surface of an X11 window paired through
32+ // xwayland_shell_v1.
33+ //lint:ignore U1000 used by the X11 window role (xwm)
34+ roleX11
31 )35 )
32 36
33 type surface struct {37 type surface struct {
A internal/adapters/wayland/window.go +71 -0
@@ -0,0 +1,71 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/go-wayland-bindings/server/wayland"
5+ "github.com/bnema/neferwl/internal/ports"
6+ "github.com/bnema/purego-libwayland/server"
7+)
8+
9+// Role-independent accessors of a window. Code outside xdg.go goes through
10+// them instead of dereferencing w.xdg, which is nil for windows of another
11+// role.
12+
13+// isToplevel reports whether w is a toplevel (not a popup).
14+func (w *window) isToplevel() bool { return w.toplevel != nil }
15+
16+// surf is the window's surface, nil when it has none.
17+func (w *window) surf() *surface {
18+ if w.xdg != nil {
19+ return w.xdg.surface
20+ }
21+ return nil
22+}
23+
24+// alive reports whether the window's role object is alive.
25+func (w *window) alive() bool {
26+ return w.xdg != nil && w.xdg.resource.Resource.Alive()
27+}
28+
29+// client is the window's client; the zero Client when it has no role object.
30+func (w *window) client() server.Client {
31+ if w.xdg != nil {
32+ return w.xdg.resource.Client()
33+ }
34+ return server.Client{}
35+}
36+
37+// geometry is the committed window geometry; empty means the whole surface.
38+func (w *window) geometry() ports.Rect {
39+ if w.xdg != nil {
40+ return w.xdg.geometry
41+ }
42+ return ports.Rect{}
43+}
44+
45+// sendConfigure sends w.last to the client.
46+func (w *window) sendConfigure() {
47+ if w.xdg != nil {
48+ w.sendXDGConfigure()
49+ }
50+}
51+
52+// sendClose asks the client to close the window.
53+func (w *window) sendClose() {
54+ if w.toplevel != nil {
55+ w.toplevel.SendClose()
56+ }
57+}
58+
59+// toplevelAlive reports whether the window is a toplevel whose protocol
60+// object is alive.
61+func (w *window) toplevelAlive() bool {
62+ return w.toplevel != nil && w.toplevel.Resource.Alive()
63+}
64+
65+// surfaceResource is the window's wl_surface, nil when it has none or it died.
66+func (w *window) surfaceResource() *wayland.Surface {
67+ if w.xdg != nil {
68+ return w.xdg.surfaceResource()
69+ }
70+ return nil
71+}
@@ -0,0 +1,71 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/go-wayland-bindings/server/wayland"
5+ "github.com/bnema/neferwl/internal/ports"
6+ "github.com/bnema/purego-libwayland/server"
7+)
8+
9+// Role-independent accessors of a window. Code outside xdg.go goes through
10+// them instead of dereferencing w.xdg, which is nil for windows of another
11+// role.
12+
13+// isToplevel reports whether w is a toplevel (not a popup).
14+func (w *window) isToplevel() bool { return w.toplevel != nil }
15+
16+// surf is the window's surface, nil when it has none.
17+func (w *window) surf() *surface {
18+ if w.xdg != nil {
19+ return w.xdg.surface
20+ }
21+ return nil
22+}
23+
24+// alive reports whether the window's role object is alive.
25+func (w *window) alive() bool {
26+ return w.xdg != nil && w.xdg.resource.Resource.Alive()
27+}
28+
29+// client is the window's client; the zero Client when it has no role object.
30+func (w *window) client() server.Client {
31+ if w.xdg != nil {
32+ return w.xdg.resource.Client()
33+ }
34+ return server.Client{}
35+}
36+
37+// geometry is the committed window geometry; empty means the whole surface.
38+func (w *window) geometry() ports.Rect {
39+ if w.xdg != nil {
40+ return w.xdg.geometry
41+ }
42+ return ports.Rect{}
43+}
44+
45+// sendConfigure sends w.last to the client.
46+func (w *window) sendConfigure() {
47+ if w.xdg != nil {
48+ w.sendXDGConfigure()
49+ }
50+}
51+
52+// sendClose asks the client to close the window.
53+func (w *window) sendClose() {
54+ if w.toplevel != nil {
55+ w.toplevel.SendClose()
56+ }
57+}
58+
59+// toplevelAlive reports whether the window is a toplevel whose protocol
60+// object is alive.
61+func (w *window) toplevelAlive() bool {
62+ return w.toplevel != nil && w.toplevel.Resource.Alive()
63+}
64+
65+// surfaceResource is the window's wl_surface, nil when it has none or it died.
66+func (w *window) surfaceResource() *wayland.Surface {
67+ if w.xdg != nil {
68+ return w.xdg.surfaceResource()
69+ }
70+ return nil
71+}
M internal/adapters/wayland/xdg.go +2 -2
@@ -151,8 +151,8 @@ type window struct {
151151 hasLast bool
152152 }
153153
154-// sendConfigure sends w.last as xdg_toplevel + xdg_surface configure.
155-func (w *window) sendConfigure() {
154+// sendXDGConfigure sends w.last as xdg_toplevel + xdg_surface configure.
155+func (w *window) sendXDGConfigure() {
156156 c := w.last
157157 w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), toplevelStates(c, w.toplevel.Version()))
158158 s := w.xdg.server
@@ -151,8 +151,8 @@ type window struct {
151 hasLast bool151 hasLast bool
152 }152 }
153 153
154-// sendConfigure sends w.last as xdg_toplevel + xdg_surface configure.154+// sendXDGConfigure sends w.last as xdg_toplevel + xdg_surface configure.
155-func (w *window) sendConfigure() {155+func (w *window) sendXDGConfigure() {
156 c := w.last156 c := w.last
157 w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), toplevelStates(c, w.toplevel.Version()))157 w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), toplevelStates(c, w.toplevel.Version()))
158 s := w.xdg.server158 s := w.xdg.server
A internal/adapters/wayland/xwayland.go +51 -0
@@ -0,0 +1,51 @@
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.
9+type xwaylandClient struct {
10+ client server.Client
11+}
12+
13+// AttachXwayland transfers ownership of the Wayland descriptor to the display.
14+// It may be called from any goroutine.
15+func (s *Server) AttachXwayland(wlFD int) {
16+ ran := s.display.Do(func() {
17+ c, err := s.display.CreateClient(wlFD)
18+ if err != nil {
19+ s.log.Warn().Err(err).Msg("Xwayland connection not adopted")
20+ _ = unix.Close(wlFD) // CreateClient only adopts on success.
21+ return
22+ }
23+ s.forgetXwayland()
24+ s.xwayland = xwaylandClient{client: c}
25+ c.OnDestroy(func() {
26+ if s.xwayland.client == c {
27+ s.forgetXwayland()
28+ }
29+ })
30+ s.log.Info().Msg("Xwayland attached")
31+ })
32+ if !ran {
33+ _ = unix.Close(wlFD)
34+ }
35+}
36+
37+// forgetXwayland runs on the display owner, or after Run has joined it.
38+func (s *Server) forgetXwayland() {
39+ if s.xwayland.client != (server.Client{}) {
40+ s.xwayland = xwaylandClient{}
41+ }
42+}
43+
44+// AttachXWM takes ownership of the window manager descriptor. It runs on the
45+// starter goroutine once Xwayland is ready; the XWM is not implemented yet,
46+// so the descriptor is closed and stop does nothing.
47+func (s *Server) AttachXWM(wmFD int) (stop func()) {
48+ s.log.Info().Msg("X11 window management not implemented: closing XWM descriptor")
49+ _ = unix.Close(wmFD)
50+ return func() {}
51+}
@@ -0,0 +1,51 @@
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.
9+type xwaylandClient struct {
10+ client server.Client
11+}
12+
13+// AttachXwayland transfers ownership of the Wayland descriptor to the display.
14+// It may be called from any goroutine.
15+func (s *Server) AttachXwayland(wlFD int) {
16+ ran := s.display.Do(func() {
17+ c, err := s.display.CreateClient(wlFD)
18+ if err != nil {
19+ s.log.Warn().Err(err).Msg("Xwayland connection not adopted")
20+ _ = unix.Close(wlFD) // CreateClient only adopts on success.
21+ return
22+ }
23+ s.forgetXwayland()
24+ s.xwayland = xwaylandClient{client: c}
25+ c.OnDestroy(func() {
26+ if s.xwayland.client == c {
27+ s.forgetXwayland()
28+ }
29+ })
30+ s.log.Info().Msg("Xwayland attached")
31+ })
32+ if !ran {
33+ _ = unix.Close(wlFD)
34+ }
35+}
36+
37+// forgetXwayland runs on the display owner, or after Run has joined it.
38+func (s *Server) forgetXwayland() {
39+ if s.xwayland.client != (server.Client{}) {
40+ s.xwayland = xwaylandClient{}
41+ }
42+}
43+
44+// AttachXWM takes ownership of the window manager descriptor. It runs on the
45+// starter goroutine once Xwayland is ready; the XWM is not implemented yet,
46+// so the descriptor is closed and stop does nothing.
47+func (s *Server) AttachXWM(wmFD int) (stop func()) {
48+ s.log.Info().Msg("X11 window management not implemented: closing XWM descriptor")
49+ _ = unix.Close(wmFD)
50+ return func() {}
51+}
A internal/adapters/wayland/xwayland_test.go +84 -0
@@ -0,0 +1,84 @@
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 := xwaylandPair(t)
35+ h.s.AttachXwayland(wl[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 := xwaylandPair(t)
42+ h.s.AttachXwayland(wl2[0])
43+ unix.Close(wl[1])
44+ wl[1] = -1 // Old client's destroy must not clear the new record.
45+ waitTrue(t, "old client destroyed", func() bool {
46+ _, err := unix.FcntlInt(uintptr(wl[0]), unix.F_GETFD, 0)
47+ return errors.Is(err, unix.EBADF)
48+ })
49+ h.s.display.Do(func() {
50+ if h.s.xwayland.client == (server.Client{}) || h.s.xwayland.client == first {
51+ t.Error("replacement lost")
52+ }
53+ })
54+ unix.Close(wl2[1])
55+ wl2[1] = -1
56+ waitTrue(t, "Xwayland disconnected", func() bool {
57+ clear := false
58+ h.s.display.Do(func() { clear = h.s.xwayland.client == (server.Client{}) })
59+ return clear
60+ })
61+}
62+
63+func TestAttachXwaylandFailureClosesFDs(t *testing.T) {
64+ h := newAdmissionHarness(t, 1)
65+ var p [2]int
66+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
67+ t.Fatal(err)
68+ }
69+ defer unix.Close(p[1])
70+ h.s.AttachXwayland(p[0])
71+ assertFDClosed(t, p[0])
72+}
73+
74+func TestAttachXwaylandStopped(t *testing.T) {
75+ d, err := server.NewDisplay()
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ d.Close()
80+ s := &Server{display: d}
81+ wl := xwaylandPair(t)
82+ s.AttachXwayland(wl[0])
83+ assertFDClosed(t, wl[0])
84+}
@@ -0,0 +1,84 @@
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 := xwaylandPair(t)
35+ h.s.AttachXwayland(wl[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 := xwaylandPair(t)
42+ h.s.AttachXwayland(wl2[0])
43+ unix.Close(wl[1])
44+ wl[1] = -1 // Old client's destroy must not clear the new record.
45+ waitTrue(t, "old client destroyed", func() bool {
46+ _, err := unix.FcntlInt(uintptr(wl[0]), unix.F_GETFD, 0)
47+ return errors.Is(err, unix.EBADF)
48+ })
49+ h.s.display.Do(func() {
50+ if h.s.xwayland.client == (server.Client{}) || h.s.xwayland.client == first {
51+ t.Error("replacement lost")
52+ }
53+ })
54+ unix.Close(wl2[1])
55+ wl2[1] = -1
56+ waitTrue(t, "Xwayland disconnected", func() bool {
57+ clear := false
58+ h.s.display.Do(func() { clear = h.s.xwayland.client == (server.Client{}) })
59+ return clear
60+ })
61+}
62+
63+func TestAttachXwaylandFailureClosesFDs(t *testing.T) {
64+ h := newAdmissionHarness(t, 1)
65+ var p [2]int
66+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
67+ t.Fatal(err)
68+ }
69+ defer unix.Close(p[1])
70+ h.s.AttachXwayland(p[0])
71+ assertFDClosed(t, p[0])
72+}
73+
74+func TestAttachXwaylandStopped(t *testing.T) {
75+ d, err := server.NewDisplay()
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ d.Close()
80+ s := &Server{display: d}
81+ wl := xwaylandPair(t)
82+ s.AttachXwayland(wl[0])
83+ assertFDClosed(t, wl[0])
84+}
A internal/adapters/xwayland/builtin.go +201 -0
@@ -0,0 +1,201 @@
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 Wayland socket. 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 Wayland descriptor from os.File ownership before handing it
79+ // to the display (including its finalizers).
80+ if d.opts.Attach != nil {
81+ // Dup transfers an independent descriptor; closing the File wrapper below
82+ // cannot race with the display's ownership of the transferred descriptor.
83+ var wlFD int
84+ wlFD, err = unix.FcntlInt(uintptr(files[wl].Fd()), unix.F_DUPFD_CLOEXEC, 0)
85+ if err == nil {
86+ d.opts.Attach(wlFD)
87+ }
88+ }
89+ files[wl].Close()
90+ done := make(chan error, 1)
91+ go func() { done <- cmd.Wait() }()
92+ if err != nil {
93+ stopGroup(cmd, done)
94+ return err
95+ }
96+ exited, readyErr := waitReady(ctx, pipe[0], done)
97+ if readyErr != nil {
98+ err := readyErr
99+ if errors.Is(err, errReadinessTimeout) {
100+ d.opts.Log.Warn().Str("display", d.Name()).Msg("Xwayland readiness timed out")
101+ }
102+ // Join Wait unless readiness already observed process exit.
103+ if !exited {
104+ stopGroup(cmd, done)
105+ }
106+ return err
107+ }
108+ rd.Close()
109+ d.opts.Log.Info().Str("display", d.Name()).Msg("Xwayland ready")
110+ if d.opts.AttachWM != nil {
111+ // The WM socket is bootstrapped only now: Xwayland serves it after its
112+ // Wayland round trips, which the display goroutine must be free to answer.
113+ wmFD, err := unix.FcntlInt(uintptr(files[wm].Fd()), unix.F_DUPFD_CLOEXEC, 0)
114+ if err != nil {
115+ stopGroup(cmd, done)
116+ return err
117+ }
118+ stop := d.opts.AttachWM(wmFD)
119+ if stop != nil {
120+ // Runs before start returns so the restart loop never overlaps two XWMs.
121+ defer stop()
122+ }
123+ }
124+ select {
125+ case err := <-done:
126+ return err
127+ case <-ctx.Done():
128+ stopGroup(cmd, done)
129+ return ctx.Err()
130+ }
131+}
132+
133+var errReadinessTimeout = errors.New("xwayland readiness timed out")
134+
135+// waitReady uses bounded poll waits rather than a pipe reader goroutine.
136+// The result reports whether cmd.Wait was already joined.
137+func waitReady(ctx context.Context, fd int, done <-chan error) (bool, error) {
138+ deadline := time.Now().Add(10 * time.Second)
139+ var buf [32]byte
140+ n := 0
141+ for {
142+ select {
143+ case err := <-done:
144+ if err == nil {
145+ err = errors.New("xwayland exited before readiness")
146+ }
147+ return true, err
148+ default:
149+ }
150+ if ctx.Err() != nil {
151+ return false, ctx.Err()
152+ }
153+ if time.Now().After(deadline) {
154+ return false, errReadinessTimeout
155+ }
156+ // A closed pipe also covers process exit, including exec's stderr delay.
157+ p := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
158+ _, err := unix.Poll(p, 50)
159+ if errors.Is(err, unix.EINTR) {
160+ continue
161+ }
162+ if err != nil {
163+ return false, err
164+ }
165+ if p[0].Revents == 0 {
166+ continue
167+ }
168+ if p[0].Revents&unix.POLLIN != 0 {
169+ count, err := unix.Read(fd, buf[n:])
170+ if errors.Is(err, unix.EINTR) {
171+ continue
172+ }
173+ if err != nil {
174+ return false, err
175+ }
176+ n += count
177+ if n > 0 && buf[n-1] == '\n' {
178+ if _, err := strconv.Atoi(strings.TrimSpace(string(buf[:n]))); err != nil {
179+ return false, fmt.Errorf("xwayland displayfd: %w", err)
180+ }
181+ return false, nil
182+ }
183+ if n == len(buf) {
184+ return false, errors.New("invalid Xwayland displayfd response")
185+ }
186+ }
187+ if p[0].Revents&(unix.POLLHUP|unix.POLLERR|unix.POLLNVAL) != 0 {
188+ return false, errors.New("xwayland exited before readiness")
189+ }
190+ }
191+}
192+
193+func stopGroup(cmd *exec.Cmd, done <-chan error) {
194+ _ = unix.Kill(-cmd.Process.Pid, unix.SIGTERM)
195+ select {
196+ case <-done:
197+ case <-time.After(2 * time.Second):
198+ _ = unix.Kill(-cmd.Process.Pid, unix.SIGKILL)
199+ <-done
200+ }
201+}
@@ -0,0 +1,201 @@
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 Wayland socket. 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 Wayland descriptor from os.File ownership before handing it
79+ // to the display (including its finalizers).
80+ if d.opts.Attach != nil {
81+ // Dup transfers an independent descriptor; closing the File wrapper below
82+ // cannot race with the display's ownership of the transferred descriptor.
83+ var wlFD int
84+ wlFD, err = unix.FcntlInt(uintptr(files[wl].Fd()), unix.F_DUPFD_CLOEXEC, 0)
85+ if err == nil {
86+ d.opts.Attach(wlFD)
87+ }
88+ }
89+ files[wl].Close()
90+ done := make(chan error, 1)
91+ go func() { done <- cmd.Wait() }()
92+ if err != nil {
93+ stopGroup(cmd, done)
94+ return err
95+ }
96+ exited, readyErr := waitReady(ctx, pipe[0], done)
97+ if readyErr != nil {
98+ err := readyErr
99+ if errors.Is(err, errReadinessTimeout) {
100+ d.opts.Log.Warn().Str("display", d.Name()).Msg("Xwayland readiness timed out")
101+ }
102+ // Join Wait unless readiness already observed process exit.
103+ if !exited {
104+ stopGroup(cmd, done)
105+ }
106+ return err
107+ }
108+ rd.Close()
109+ d.opts.Log.Info().Str("display", d.Name()).Msg("Xwayland ready")
110+ if d.opts.AttachWM != nil {
111+ // The WM socket is bootstrapped only now: Xwayland serves it after its
112+ // Wayland round trips, which the display goroutine must be free to answer.
113+ wmFD, err := unix.FcntlInt(uintptr(files[wm].Fd()), unix.F_DUPFD_CLOEXEC, 0)
114+ if err != nil {
115+ stopGroup(cmd, done)
116+ return err
117+ }
118+ stop := d.opts.AttachWM(wmFD)
119+ if stop != nil {
120+ // Runs before start returns so the restart loop never overlaps two XWMs.
121+ defer stop()
122+ }
123+ }
124+ select {
125+ case err := <-done:
126+ return err
127+ case <-ctx.Done():
128+ stopGroup(cmd, done)
129+ return ctx.Err()
130+ }
131+}
132+
133+var errReadinessTimeout = errors.New("xwayland readiness timed out")
134+
135+// waitReady uses bounded poll waits rather than a pipe reader goroutine.
136+// The result reports whether cmd.Wait was already joined.
137+func waitReady(ctx context.Context, fd int, done <-chan error) (bool, error) {
138+ deadline := time.Now().Add(10 * time.Second)
139+ var buf [32]byte
140+ n := 0
141+ for {
142+ select {
143+ case err := <-done:
144+ if err == nil {
145+ err = errors.New("xwayland exited before readiness")
146+ }
147+ return true, err
148+ default:
149+ }
150+ if ctx.Err() != nil {
151+ return false, ctx.Err()
152+ }
153+ if time.Now().After(deadline) {
154+ return false, errReadinessTimeout
155+ }
156+ // A closed pipe also covers process exit, including exec's stderr delay.
157+ p := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
158+ _, err := unix.Poll(p, 50)
159+ if errors.Is(err, unix.EINTR) {
160+ continue
161+ }
162+ if err != nil {
163+ return false, err
164+ }
165+ if p[0].Revents == 0 {
166+ continue
167+ }
168+ if p[0].Revents&unix.POLLIN != 0 {
169+ count, err := unix.Read(fd, buf[n:])
170+ if errors.Is(err, unix.EINTR) {
171+ continue
172+ }
173+ if err != nil {
174+ return false, err
175+ }
176+ n += count
177+ if n > 0 && buf[n-1] == '\n' {
178+ if _, err := strconv.Atoi(strings.TrimSpace(string(buf[:n]))); err != nil {
179+ return false, fmt.Errorf("xwayland displayfd: %w", err)
180+ }
181+ return false, nil
182+ }
183+ if n == len(buf) {
184+ return false, errors.New("invalid Xwayland displayfd response")
185+ }
186+ }
187+ if p[0].Revents&(unix.POLLHUP|unix.POLLERR|unix.POLLNVAL) != 0 {
188+ return false, errors.New("xwayland exited before readiness")
189+ }
190+ }
191+}
192+
193+func stopGroup(cmd *exec.Cmd, done <-chan error) {
194+ _ = unix.Kill(-cmd.Process.Pid, unix.SIGTERM)
195+ select {
196+ case <-done:
197+ case <-time.After(2 * time.Second):
198+ _ = unix.Kill(-cmd.Process.Pid, unix.SIGKILL)
199+ <-done
200+ }
201+}
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 +45 -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,25 @@ 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 the Wayland descriptor right after the backend
28+ // starts.
29+ Attach func(wlFD int)
30+ // AttachWM takes ownership of the window manager descriptor once the
31+ // backend is ready. It may block to bootstrap the X11 connection. The
32+ // returned stop (nil for none) is called before the run ends, on backend
33+ // exit or context cancellation.
34+ AttachWM func(wmFD int) (stop func())
35+ // Binary is the selected backend executable.
2736 Binary string
28- // Env is the satellite's environment; it must hold WAYLAND_DISPLAY.
37+ // Env is the backend environment; satellite requires WAYLAND_DISPLAY.
2938 Env []string
3039 // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix
3140 // and /tmp outside tests.
@@ -35,8 +44,9 @@ type Options struct {
3544 Log zerowrap.Logger
3645 }
3746
38-// Display is an X11 display served by xwayland-satellite.
47+// Display is an on-demand X11 display.
3948 type Display struct {
49+ starter starter
4050 opts Options
4151 n int
4252 sockets []*os.File // unix socket, then the abstract one
@@ -81,7 +91,10 @@ func Open(opts Options) (*Display, error) {
8191 _ = os.Remove(lock)
8292 return nil, fmt.Errorf("write X11 lock: %w", err)
8393 }
84- d := &Display{opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}
94+ d := &Display{starter: satelliteStarter{}, opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}
95+ if opts.Backend == ports.XwaylandBuiltin {
96+ d.starter = builtinStarter{}
97+ }
8598 if err := d.listen(); err != nil {
8699 last = err
87100 d.Close()
@@ -212,15 +225,15 @@ func (d *Display) Run(ctx context.Context) error {
212225 return err
213226 }
214227 start := time.Now()
215- stderr := &logWriter{log: d.opts.Log}
216- err := d.serve(ctx, stderr)
228+ stderr := &logWriter{log: d.opts.Log, backend: d.backendName()}
229+ err := d.starter.start(ctx, d, stderr)
217230 if ctx.Err() != nil {
218231 return nil
219232 }
220233 if err != nil {
221- d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg("xwayland-satellite exited")
234+ d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg(d.backendName() + " exited")
222235 } else {
223- d.opts.Log.Info().Str("display", d.Name()).Msg("xwayland-satellite exited")
236+ d.opts.Log.Info().Str("display", d.Name()).Msg(d.backendName() + " exited")
224237 }
225238 // Clients the satellite never accepted would wake us at once: a
226239 // satellite that cannot start must not respawn in a loop.
@@ -261,8 +274,14 @@ func (d *Display) waitClient(ctx context.Context, wake int) error {
261274 return ctx.Err()
262275 }
263276
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 {
277+// starter runs a backend on the reserved sockets until it exits or ctx ends.
278+type starter interface {
279+ start(ctx context.Context, d *Display, stderr *logWriter) error
280+}
281+
282+type satelliteStarter struct{}
283+
284+func (satelliteStarter) start(ctx context.Context, d *Display, stderr *logWriter) error {
266285 args := []string{d.Name()}
267286 for i := range d.sockets {
268287 args = append(args, "-listenfd", strconv.Itoa(3+i))
@@ -318,10 +337,11 @@ func (d *Display) dropPending() {
318337 // logWriter logs the satellite's stderr line by line and keeps the last
319338 // lines for the exit log; exec copies it from a pipe on its own goroutine.
320339 type logWriter struct {
321- mu sync.Mutex
322- log zerowrap.Logger
323- line []byte
324- last []string
340+ backend string
341+ mu sync.Mutex
342+ log zerowrap.Logger
343+ line []byte
344+ last []string
325345 }
326346
327347 // tail is the last lines written.
@@ -342,7 +362,7 @@ func (w *logWriter) Write(p []byte) (int, error) {
342362 }
343363 if i > 0 {
344364 line := string(w.line[:i])
345- w.log.Debug().Str("line", line).Msg("xwayland-satellite")
365+ w.log.Debug().Str("line", line).Msg(w.backend)
346366 if w.last = append(w.last, line); len(w.last) > 5 {
347367 w.last = w.last[1:]
348368 }
@@ -354,3 +374,10 @@ func (w *logWriter) Write(p []byte) (int, error) {
354374 }
355375 return len(p), nil
356376 }
377+
378+func (d *Display) backendName() string {
379+ if d.opts.Backend == ports.XwaylandBuiltin {
380+ return "Xwayland"
381+ }
382+ return "xwayland-satellite"
383+}
@@ -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,25 @@ 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 the Wayland descriptor right after the backend
28+ // starts.
29+ Attach func(wlFD int)
30+ // AttachWM takes ownership of the window manager descriptor once the
31+ // backend is ready. It may block to bootstrap the X11 connection. The
32+ // returned stop (nil for none) is called before the run ends, on backend
33+ // exit or context cancellation.
34+ AttachWM func(wmFD int) (stop func())
35+ // Binary is the selected backend executable.
27 Binary string36 Binary string
28- // Env is the satellite's environment; it must hold WAYLAND_DISPLAY.37+ // Env is the backend environment; satellite requires WAYLAND_DISPLAY.
29 Env []string38 Env []string
30 // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix39 // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix
31 // and /tmp outside tests.40 // and /tmp outside tests.
@@ -35,8 +44,9 @@ type Options struct {
35 Log zerowrap.Logger44 Log zerowrap.Logger
36 }45 }
37 46
38-// Display is an X11 display served by xwayland-satellite.47+// Display is an on-demand X11 display.
39 type Display struct {48 type Display struct {
49+ starter starter
40 opts Options50 opts Options
41 n int51 n int
42 sockets []*os.File // unix socket, then the abstract one52 sockets []*os.File // unix socket, then the abstract one
@@ -81,7 +91,10 @@ func Open(opts Options) (*Display, error) {
81 _ = os.Remove(lock)91 _ = os.Remove(lock)
82 return nil, fmt.Errorf("write X11 lock: %w", err)92 return nil, fmt.Errorf("write X11 lock: %w", err)
83 }93 }
84- d := &Display{opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}94+ d := &Display{starter: satelliteStarter{}, opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}
95+ if opts.Backend == ports.XwaylandBuiltin {
96+ d.starter = builtinStarter{}
97+ }
85 if err := d.listen(); err != nil {98 if err := d.listen(); err != nil {
86 last = err99 last = err
87 d.Close()100 d.Close()
@@ -212,15 +225,15 @@ func (d *Display) Run(ctx context.Context) error {
212 return err225 return err
213 }226 }
214 start := time.Now()227 start := time.Now()
215- stderr := &logWriter{log: d.opts.Log}228+ stderr := &logWriter{log: d.opts.Log, backend: d.backendName()}
216- err := d.serve(ctx, stderr)229+ err := d.starter.start(ctx, d, stderr)
217 if ctx.Err() != nil {230 if ctx.Err() != nil {
218 return nil231 return nil
219 }232 }
220 if err != nil {233 if err != nil {
221- d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg("xwayland-satellite exited")234+ d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg(d.backendName() + " exited")
222 } else {235 } else {
223- d.opts.Log.Info().Str("display", d.Name()).Msg("xwayland-satellite exited")236+ d.opts.Log.Info().Str("display", d.Name()).Msg(d.backendName() + " exited")
224 }237 }
225 // Clients the satellite never accepted would wake us at once: a238 // Clients the satellite never accepted would wake us at once: a
226 // satellite that cannot start must not respawn in a loop.239 // satellite that cannot start must not respawn in a loop.
@@ -261,8 +274,14 @@ func (d *Display) waitClient(ctx context.Context, wake int) error {
261 return ctx.Err()274 return ctx.Err()
262 }275 }
263 276
264-// serve runs the satellite on the sockets until it exits or ctx ends.277+// 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 {278+type starter interface {
279+ start(ctx context.Context, d *Display, stderr *logWriter) error
280+}
281+
282+type satelliteStarter struct{}
283+
284+func (satelliteStarter) start(ctx context.Context, d *Display, stderr *logWriter) error {
266 args := []string{d.Name()}285 args := []string{d.Name()}
267 for i := range d.sockets {286 for i := range d.sockets {
268 args = append(args, "-listenfd", strconv.Itoa(3+i))287 args = append(args, "-listenfd", strconv.Itoa(3+i))
@@ -318,10 +337,11 @@ func (d *Display) dropPending() {
318 // logWriter logs the satellite's stderr line by line and keeps the last337 // 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.338 // lines for the exit log; exec copies it from a pipe on its own goroutine.
320 type logWriter struct {339 type logWriter struct {
321- mu sync.Mutex340+ backend string
322- log zerowrap.Logger341+ mu sync.Mutex
323- line []byte342+ log zerowrap.Logger
324- last []string343+ line []byte
344+ last []string
325 }345 }
326 346
327 // tail is the last lines written.347 // tail is the last lines written.
@@ -342,7 +362,7 @@ func (w *logWriter) Write(p []byte) (int, error) {
342 }362 }
343 if i > 0 {363 if i > 0 {
344 line := string(w.line[:i])364 line := string(w.line[:i])
345- w.log.Debug().Str("line", line).Msg("xwayland-satellite")365+ w.log.Debug().Str("line", line).Msg(w.backend)
346 if w.last = append(w.last, line); len(w.last) > 5 {366 if w.last = append(w.last, line); len(w.last) > 5 {
347 w.last = w.last[1:]367 w.last = w.last[1:]
348 }368 }
@@ -354,3 +374,10 @@ func (w *logWriter) Write(p []byte) (int, error) {
354 }374 }
355 return len(p), nil375 return len(p), nil
356 }376 }
377+
378+func (d *Display) backendName() string {
379+ if d.opts.Backend == ports.XwaylandBuiltin {
380+ return "Xwayland"
381+ }
382+ return "xwayland-satellite"
383+}
M internal/adapters/xwayland/xwayland_test.go +182 -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,183 @@ 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+}
372+
373+// readyScript stands in for Xwayland: after a short delay it creates a marker
374+// file and reports readiness on the -displayfd descriptor when ready is true,
375+// then runs tail.
376+func readyScript(t *testing.T, dir string, ready bool, tail string) (bin, marker string) {
377+ t.Helper()
378+ bin, marker = filepath.Join(dir, "xwayland"), filepath.Join(dir, "ready")
379+ script := "#!/bin/sh\nwhile [ $# -gt 0 ]; do [ \"$1\" = -displayfd ] && fd=$2; shift; done\n" +
380+ "sleep 0.3\n"
381+ if ready {
382+ script += ": > " + marker + "\neval \"echo 1 >&$fd\"\n"
383+ }
384+ script += tail + "\n"
385+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
386+ t.Fatal(err)
387+ }
388+ return bin, marker
389+}
390+
391+func runBuiltin(t *testing.T, ctx context.Context, ready bool, tail string, attachWM func(wmFD int)) (events []string) {
392+ t.Helper()
393+ opts := testOptions(t, "")
394+ opts.Backend = ports.XwaylandBuiltin
395+ var marker string
396+ opts.Binary, marker = readyScript(t, opts.TmpDir, ready, tail)
397+ exists := func() bool { _, err := os.Stat(marker); return err == nil }
398+ opts.Attach = func(wlFD int) {
399+ if exists() {
400+ t.Error("Attach ran after readiness")
401+ }
402+ events = append(events, "attach")
403+ unix.Close(wlFD)
404+ }
405+ opts.AttachWM = func(wmFD int) func() {
406+ if !exists() {
407+ t.Error("AttachWM ran before readiness")
408+ }
409+ events = append(events, "attachWM")
410+ attachWM(wmFD)
411+ return func() { events = append(events, "stop") }
412+ }
413+ d, err := Open(opts)
414+ if err != nil {
415+ t.Fatal(err)
416+ }
417+ defer d.Close()
418+ _ = d.starter.start(ctx, d, &logWriter{log: opts.Log, backend: d.backendName()})
419+ return append(events, "returned")
420+}
421+
422+// AttachWM runs only after -displayfd readiness; stop runs before start
423+// returns, whether the process exits or the context ends.
424+func TestBuiltinAttachWMAfterReadiness(t *testing.T) {
425+ for name, tc := range map[string]struct {
426+ tail string
427+ cancel bool
428+ }{"process exit": {tail: "exit 0"}, "context end": {tail: "exec sleep 30", cancel: true}} {
429+ t.Run(name, func(t *testing.T) {
430+ ctx, cancel := context.WithCancel(t.Context())
431+ defer cancel()
432+ events := runBuiltin(t, ctx, true, tc.tail, func(wmFD int) {
433+ if _, err := unix.FcntlInt(uintptr(wmFD), unix.F_GETFD, 0); err != nil {
434+ t.Errorf("wmFD not open: %v", err)
435+ }
436+ unix.Close(wmFD)
437+ if tc.cancel {
438+ go func() { time.Sleep(200 * time.Millisecond); cancel() }()
439+ }
440+ })
441+ if got := strings.Join(events, ","); got != "attach,attachWM,stop,returned" {
442+ t.Fatalf("events %s", got)
443+ }
444+ })
445+ }
446+}
447+
448+// A process that exits before reporting readiness never reaches AttachWM.
449+func TestBuiltinNoAttachWMWithoutReadiness(t *testing.T) {
450+ events := runBuiltin(t, t.Context(), false, "exit 1", func(int) {})
451+ if got := strings.Join(events, ","); got != "attach,returned" {
452+ t.Fatalf("events %s", got)
453+ }
454+}
@@ -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,183 @@ 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+}
372+
373+// readyScript stands in for Xwayland: after a short delay it creates a marker
374+// file and reports readiness on the -displayfd descriptor when ready is true,
375+// then runs tail.
376+func readyScript(t *testing.T, dir string, ready bool, tail string) (bin, marker string) {
377+ t.Helper()
378+ bin, marker = filepath.Join(dir, "xwayland"), filepath.Join(dir, "ready")
379+ script := "#!/bin/sh\nwhile [ $# -gt 0 ]; do [ \"$1\" = -displayfd ] && fd=$2; shift; done\n" +
380+ "sleep 0.3\n"
381+ if ready {
382+ script += ": > " + marker + "\neval \"echo 1 >&$fd\"\n"
383+ }
384+ script += tail + "\n"
385+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
386+ t.Fatal(err)
387+ }
388+ return bin, marker
389+}
390+
391+func runBuiltin(t *testing.T, ctx context.Context, ready bool, tail string, attachWM func(wmFD int)) (events []string) {
392+ t.Helper()
393+ opts := testOptions(t, "")
394+ opts.Backend = ports.XwaylandBuiltin
395+ var marker string
396+ opts.Binary, marker = readyScript(t, opts.TmpDir, ready, tail)
397+ exists := func() bool { _, err := os.Stat(marker); return err == nil }
398+ opts.Attach = func(wlFD int) {
399+ if exists() {
400+ t.Error("Attach ran after readiness")
401+ }
402+ events = append(events, "attach")
403+ unix.Close(wlFD)
404+ }
405+ opts.AttachWM = func(wmFD int) func() {
406+ if !exists() {
407+ t.Error("AttachWM ran before readiness")
408+ }
409+ events = append(events, "attachWM")
410+ attachWM(wmFD)
411+ return func() { events = append(events, "stop") }
412+ }
413+ d, err := Open(opts)
414+ if err != nil {
415+ t.Fatal(err)
416+ }
417+ defer d.Close()
418+ _ = d.starter.start(ctx, d, &logWriter{log: opts.Log, backend: d.backendName()})
419+ return append(events, "returned")
420+}
421+
422+// AttachWM runs only after -displayfd readiness; stop runs before start
423+// returns, whether the process exits or the context ends.
424+func TestBuiltinAttachWMAfterReadiness(t *testing.T) {
425+ for name, tc := range map[string]struct {
426+ tail string
427+ cancel bool
428+ }{"process exit": {tail: "exit 0"}, "context end": {tail: "exec sleep 30", cancel: true}} {
429+ t.Run(name, func(t *testing.T) {
430+ ctx, cancel := context.WithCancel(t.Context())
431+ defer cancel()
432+ events := runBuiltin(t, ctx, true, tc.tail, func(wmFD int) {
433+ if _, err := unix.FcntlInt(uintptr(wmFD), unix.F_GETFD, 0); err != nil {
434+ t.Errorf("wmFD not open: %v", err)
435+ }
436+ unix.Close(wmFD)
437+ if tc.cancel {
438+ go func() { time.Sleep(200 * time.Millisecond); cancel() }()
439+ }
440+ })
441+ if got := strings.Join(events, ","); got != "attach,attachWM,stop,returned" {
442+ t.Fatalf("events %s", got)
443+ }
444+ })
445+ }
446+}
447+
448+// A process that exits before reporting readiness never reaches AttachWM.
449+func TestBuiltinNoAttachWMWithoutReadiness(t *testing.T) {
450+ events := runBuiltin(t, t.Context(), false, "exit 1", func(int) {})
451+ if got := strings.Join(events, ","); got != "attach,returned" {
452+ t.Fatalf("events %s", got)
453+ }
454+}
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), attachWM func(int) func()) *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, AttachWM: attachWM, 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), attachWM func(int) func()) *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, AttachWM: attachWM, 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.server.AttachXWM); 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.server.AttachXWM); 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