Merge a0ea919d490e9ca747a3700440f06f60af0517a9 into 3c13b3d3cf1deae3f997dd5bb3e9e0a55049380f
37 files · +902 -92
M .mockery.yml +8 -0
| @@ -182,3 +182,11 @@ packages: | |||
| 182 | 182 | dir: internal/adapters/screencast | |
| 183 | 183 | pkgname: screencast | |
| 184 | 184 | 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/screencast | 182 | dir: internal/adapters/screencast | ||
| 183 | pkgname: screencast | 183 | pkgname: screencast | ||
| 184 | filename: pipewire_opener_mock_test.go | 184 | 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. | |||
| 27 | 27 | | `terminal` | `$TERMINAL` or `foot` | Terminal command | | |
| 28 | 28 | | `terminal.auto-open` | `first` | Open a terminal on the first workspace (`first`), on every empty one (`all`), or never (`off`) | | |
| 29 | 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 | 31 | | `background` | `#111111` | Background color | | |
| 32 | 32 | | `border.radius` | `0` | Corner radius in logical pixels, `0` to `64`; floats always, tiles only with gaps | | |
| 33 | 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 | 103 | switches and mouse focus do not trigger a flash. | |
| 104 | 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 | 108 | ## Automatic terminal | |
| 107 | 109 | ||
| 108 | 110 | 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 terminal | 108 | ## 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. | |||
| 52 | 52 | ||
| 53 | 53 | ## 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 | 57 | ```text | |
| 58 | 58 | 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 | 63 | ## Session locking | |
| 62 | 64 | ||
| 63 | 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. | |
| @@ -52,12 +52,14 @@ Candidate popups are not drawn yet. | |||||
| 52 | 52 | ||||
| 53 | ## X11 apps | 53 | ## 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 | ```text | 57 | ```text | ||
| 58 | xwayland = off # no X11 support | 58 | 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 locking | 63 | ## 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 | |||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/bnema/go-wayland-bindings v0.1.0 | |
| 7 | 7 | 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 | |
| 10 | 10 | github.com/bnema/purego-pipewire v0.2.0 | |
| 11 | 11 | github.com/bnema/purego-vulkan v0.6.0 | |
| 12 | 12 | 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.0 | 6 | github.com/bnema/go-wayland-bindings v0.1.0 | ||
| 7 | github.com/bnema/kvconf v0.1.2 | 7 | github.com/bnema/kvconf v0.1.2 | ||
| 8 | - | github.com/bnema/purego v0.13.0-bnema.1 | 8 | + | github.com/bnema/purego v0.13.0-bnema.2 |
| 9 | - | github.com/bnema/purego-libwayland v0.11.0 | 9 | + | github.com/bnema/purego-libwayland v0.12.0 |
| 10 | github.com/bnema/purego-pipewire v0.2.0 | 10 | github.com/bnema/purego-pipewire v0.2.0 | ||
| 11 | github.com/bnema/purego-vulkan v0.6.0 | 11 | github.com/bnema/purego-vulkan v0.6.0 | ||
| 12 | github.com/bnema/wlturbo v0.5.0 | 12 | github.com/bnema/wlturbo v0.5.0 | ||
M go.sum +4 -4 generated file: collapsed
M internal/adapters/config/config.go +8 -4
| @@ -167,7 +167,7 @@ func Defaults() ports.Config { | |||
| 167 | 167 | // Hint mode is experimental: off unless the config turns it on. | |
| 168 | 168 | c.Hint.Enable = false | |
| 169 | 169 | c.Hint.TextClick = ports.ModCtrl | ports.ModShift | |
| 170 | - | c.Xwayland = "xwayland-satellite" | |
| 170 | + | c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite"} | |
| 171 | 171 | c.Background.Color = "#111111" | |
| 172 | 172 | c.Floating.Dim = 0.3 | |
| 173 | 173 | c.Stash.Width = 80 | |
| @@ -726,11 +726,15 @@ func set(c *ports.Config, key, v string) error { | |||
| 726 | 726 | case "xwayland": | |
| 727 | 727 | switch { | |
| 728 | 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 | 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 | 736 | default: | |
| 733 | - | c.Xwayland = v | |
| 737 | + | c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: v} | |
| 734 | 738 | } | |
| 735 | 739 | case "background": | |
| 736 | 740 | 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 = false | 168 | c.Hint.Enable = false | ||
| 169 | c.Hint.TextClick = ports.ModCtrl | ports.ModShift | 169 | 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.3 | 172 | c.Floating.Dim = 0.3 | ||
| 173 | c.Stash.Width = 80 | 173 | 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 = v | 737 | + | 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) { | |||
| 340 | 340 | } | |
| 341 | 341 | ||
| 342 | 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 | 344 | t.Fatal(Defaults().Xwayland) | |
| 345 | 345 | } | |
| 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 | + | }) | |
| 351 | 361 | } | |
| 352 | 362 | } | |
| 353 | 363 | ||
| @@ -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 { | |||
| 358 | 358 | // slot keeps its sync file for every lease. No mock stands in the measured | |
| 359 | 359 | // closure. | |
| 360 | 360 | func TestCaptureCycleAllocations(t *testing.T) { | |
| 361 | + | if raceEnabled { | |
| 362 | + | t.Skip("allocation budgets are not stable under the race runtime") | |
| 363 | + | } | |
| 361 | 364 | for _, tc := range []struct { | |
| 362 | 365 | name string | |
| 363 | 366 | 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 measured | 358 | // 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 string | 365 | name string | ||
| 363 | new func(*testing.T) *Renderer | 366 | 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 ( | |||
| 16 | 16 | // A new scene Seq with one of two windows moved is a partial redraw seeded by | |
| 17 | 17 | // sceneDelta and allocates nothing either. | |
| 18 | 18 | func TestRenderSteadyStateAllocations(t *testing.T) { | |
| 19 | + | if raceEnabled { | |
| 20 | + | t.Skip("allocation budgets are not stable under the race runtime") | |
| 21 | + | } | |
| 19 | 22 | for _, tc := range []struct { | |
| 20 | 23 | name string | |
| 21 | 24 | transform ports.BufferTransform | |
| @@ -145,6 +148,9 @@ func TestRenderSteadyStateAllocations(t *testing.T) { | |||
| 145 | 148 | // keeps one alloc of headroom for drivers whose import path differs. | |
| 146 | 149 | // Skipped without a GPU or /dev/udmabuf. | |
| 147 | 150 | func TestRenderSteadyStateDMABufAllocations(t *testing.T) { | |
| 151 | + | if raceEnabled { | |
| 152 | + | t.Skip("allocation budgets are not stable under the race runtime") | |
| 153 | + | } | |
| 148 | 154 | r, err := New(80, 32) | |
| 149 | 155 | if err != nil { | |
| 150 | 156 | 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 by | 16 | // 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 string | 23 | name string | ||
| 21 | transform ports.BufferTransform | 24 | 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 * | |||
| 66 | 66 | } | |
| 67 | 67 | var w *window | |
| 68 | 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 | 70 | reason = "not_toplevel" | |
| 71 | 71 | } | |
| 72 | 72 | } | |
| @@ -116,8 +116,8 @@ func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) { | |||
| 116 | 116 | ||
| 117 | 117 | // focusClient is the client with the keyboard focus. | |
| 118 | 118 | 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() | |
| 121 | 121 | } | |
| 122 | 122 | if l := s.layers[s.seat.focused]; l != nil && l.resource.Resource.Alive() { | |
| 123 | 123 | return l.resource.Client() | |
| @@ -66,7 +66,7 @@ func (a activation) Activate(_ *xdgactivation.ActivationV1, token string, surf * | |||||
| 66 | } | 66 | } | ||
| 67 | var w *window | 67 | 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 | |||
| 61 | 61 | return | |
| 62 | 62 | } | |
| 63 | 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 | 65 | return | |
| 66 | 66 | } | |
| 67 | 67 | // Surface-local to window-local (core's, from the geometry origin). | |
| 68 | - | g := w.xdg.geometry | |
| 68 | + | g := w.geometry() | |
| 69 | 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 | 80 | if s.protected() || c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 { | |
| 81 | 81 | return false | |
| 82 | 82 | } | |
| 83 | - | if !w.mapped || !w.xdg.resource.Resource.Alive() { | |
| 83 | + | if !w.mapped || !w.alive() { | |
| 84 | 84 | return false | |
| 85 | 85 | } | |
| 86 | 86 | sent := false | |
| 87 | 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 | 89 | if rel.Resource.Alive() { | |
| 90 | 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 | 91 | sent = true | |
| @@ -272,7 +272,7 @@ func (s *Server) updateConstraint() { | |||
| 272 | 272 | } | |
| 273 | 273 | var want *constraint | |
| 274 | 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 | 276 | want = c | |
| 277 | 277 | } | |
| 278 | 278 | } | |
| @@ -340,10 +340,10 @@ func (s *Server) emitConstraint(c *constraint) { | |||
| 340 | 340 | // activates only there, so it never warps the pointer. | |
| 341 | 341 | func (c *constraint) contains(w *window) bool { | |
| 342 | 342 | 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 | 344 | all, rects := c.surface.effectiveInput() | |
| 345 | 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 | 347 | inside := false | |
| 348 | 348 | for _, input := range rects { | |
| 349 | 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 | 364 | // locked reports whether the window's pointer is locked. | |
| 365 | 365 | 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() | |
| 367 | 367 | } | |
| @@ -61,11 +61,11 @@ func (h pointerWarp) WarpPointer(r *pointerwarp.WpPointerWarpV1, surf *wayland.S | |||||
| 61 | return | 61 | 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 | return | 65 | 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.geometry | 68 | + | 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 false | 81 | return false | ||
| 82 | } | 82 | } | ||
| 83 | - | if !w.mapped || !w.xdg.resource.Resource.Alive() { | 83 | + | if !w.mapped || !w.alive() { |
| 84 | return false | 84 | return false | ||
| 85 | } | 85 | } | ||
| 86 | sent := false | 86 | 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 = true | 91 | sent = true | ||
| @@ -272,7 +272,7 @@ func (s *Server) updateConstraint() { | |||||
| 272 | } | 272 | } | ||
| 273 | var want *constraint | 273 | 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 = c | 276 | 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.region | 342 | 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 := false | 347 | 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.surface | 366 | + | 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) | |||
| 80 | 80 | func (d *decoration) send(r *xdgdecoration.ZxdgToplevelDecorationV1) { | |
| 81 | 81 | r.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide)) | |
| 82 | 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 | 84 | w.sendConfigure() | |
| 85 | 85 | return | |
| 86 | 86 | } | |
| @@ -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 | return | 85 | return | ||
| 86 | } | 86 | } | ||
M internal/adapters/wayland/dialog.go +1 -1
| @@ -76,7 +76,7 @@ func (w *window) setModal(modal bool) { | |||
| 76 | 76 | return | |
| 77 | 77 | } | |
| 78 | 78 | w.modal = modal | |
| 79 | - | if w.mapped { | |
| 79 | + | if w.mapped && w.xdg != nil { | |
| 80 | 80 | w.xdg.server.emit(ports.WindowModal{ID: w.id, Modal: modal}) | |
| 81 | 81 | } | |
| 82 | 82 | } | |
| @@ -76,7 +76,7 @@ func (w *window) setModal(modal bool) { | |||||
| 76 | return | 76 | return | ||
| 77 | } | 77 | } | ||
| 78 | w.modal = modal | 78 | 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) {} | |||
| 72 | 72 | func (s *Server) mappedToplevels() []ports.WindowID { | |
| 73 | 73 | ids := make([]ports.WindowID, 0, len(s.windows)) | |
| 74 | 74 | for id, w := range s.windows { | |
| 75 | - | if w.toplevel != nil && w.mapped { | |
| 75 | + | if w.isToplevel() && w.mapped { | |
| 76 | 76 | ids = append(ids, id) | |
| 77 | 77 | } | |
| 78 | 78 | } | |
| @@ -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 { | |||
| 44 | 44 | r.OnDestroy = func() { s.toplevelManagers = removeItem(s.toplevelManagers, m) } | |
| 45 | 45 | ids := make([]ports.WindowID, 0, len(s.windows)) | |
| 46 | 46 | for id, w := range s.windows { | |
| 47 | - | if w.toplevel != nil && w.mapped { | |
| 47 | + | if w.isToplevel() && w.mapped { | |
| 48 | 48 | ids = append(ids, id) | |
| 49 | 49 | } | |
| 50 | 50 | } | |
| @@ -186,7 +186,7 @@ func toplevelState(w *window, version int32) []byte { | |||
| 186 | 186 | ||
| 187 | 187 | // toplevelChanged updates every manager after w mapped or changed. | |
| 188 | 188 | func (s *Server) toplevelChanged(w *window) { | |
| 189 | - | if w.toplevel == nil || !w.mapped { | |
| 189 | + | if !w.isToplevel() || !w.mapped { | |
| 190 | 190 | return | |
| 191 | 191 | } | |
| 192 | 192 | for _, m := range s.toplevelManagers { | |
| @@ -220,7 +220,7 @@ func (s *Server) refreshToplevels() { | |||
| 220 | 220 | for _, l := range s.extToplevelLists { | |
| 221 | 221 | for id, h := range l.handles { | |
| 222 | 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 | 224 | l.closed(id) | |
| 225 | 225 | } | |
| 226 | 226 | } | |
| @@ -231,7 +231,7 @@ func (s *Server) refreshToplevels() { | |||
| 231 | 231 | for _, m := range s.toplevelManagers { | |
| 232 | 232 | for id, h := range m.handles { | |
| 233 | 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 | 235 | m.closed(id) | |
| 236 | 236 | } | |
| 237 | 237 | } | |
| @@ -253,7 +253,7 @@ func (t *toplevelRequests) window() *window { | |||
| 253 | 253 | if t.s.protected() || t.closed { | |
| 254 | 254 | 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 | 257 | return w | |
| 258 | 258 | } | |
| 259 | 259 | return nil | |
| @@ -274,7 +274,7 @@ func (t *toplevelRequests) Activate(*wlr.ZwlrForeignToplevelHandleV1, *wayland.S | |||
| 274 | 274 | } | |
| 275 | 275 | func (t *toplevelRequests) Close(*wlr.ZwlrForeignToplevelHandleV1) { | |
| 276 | 276 | if w := t.window(); w != nil { | |
| 277 | - | w.toplevel.SendClose() | |
| 277 | + | w.sendClose() | |
| 278 | 278 | } | |
| 279 | 279 | } | |
| 280 | 280 | 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 | return | 190 | 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 nil | 254 | 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 w | 257 | return w | ||
| 258 | } | 258 | } | ||
| 259 | return nil | 259 | 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 | |||
| 183 | 183 | func toplevelRoot(surf *surface) *surface { | |
| 184 | 184 | surf = surf.root() | |
| 185 | 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 | 188 | return surf | |
| 189 | 189 | } | |
| @@ -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 surf | 188 | 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) { | |||
| 131 | 131 | state.identity = c.server.nextSurface | |
| 132 | 132 | state.wl = w | |
| 133 | 133 | c.server.surfaces[w.Resource] = state | |
| 134 | + | c.server.surfaceByIdentity[state.identity] = state | |
| 134 | 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 | 143 | 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.nextSurface | 131 | state.identity = c.server.nextSurface | ||
| 132 | state.wl = w | 132 | state.wl = w | ||
| 133 | c.server.surfaces[w.Resource] = state | 133 | 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 | |||
| 351 | 351 | case srcWindow: | |
| 352 | 352 | // Core's state gives the size; before it, the last configure. | |
| 353 | 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 | 355 | return nil, image.Rectangle{}, false, false | |
| 356 | 356 | } | |
| 357 | 357 | 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, false | 355 | 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 | |||
| 31 | 31 | var parent *window | |
| 32 | 32 | if parentXDG != nil { | |
| 33 | 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 | 35 | parent = w | |
| 36 | 36 | } | |
| 37 | 37 | } | |
| @@ -166,7 +166,7 @@ func (p *popup) configure(c ports.ConfigurePopup) { | |||
| 166 | 166 | func (s *Server) topGrab(client server.Client, parent *window) bool { | |
| 167 | 167 | var top *window | |
| 168 | 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 | 170 | top = w | |
| 171 | 171 | } | |
| 172 | 172 | } | |
| @@ -31,7 +31,7 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX | |||||
| 31 | var parent *window | 31 | 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 = w | 35 | 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 *window | 167 | 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 = w | 170 | 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 { | |||
| 21 | 21 | surf = surf.root() | |
| 22 | 22 | // Popups are on their parent's output. | |
| 23 | 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 | 26 | name := "" | |
| 27 | 27 | 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) { | |||
| 146 | 146 | gone := ports.CaptureReasonOutputGone | |
| 147 | 147 | if cmd.Window != 0 { | |
| 148 | 148 | 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 | 150 | src = &captureSource{kind: srcWindow, window: cmd.Window, gen: w.gen} | |
| 151 | 151 | open.Window = cmd.Window | |
| 152 | 152 | } | |
| @@ -146,7 +146,7 @@ func (s *Server) openCast(cmd ports.CastCommand) { | |||||
| 146 | gone := ports.CaptureReasonOutputGone | 146 | gone := ports.CaptureReasonOutputGone | ||
| 147 | if cmd.Window != 0 { | 147 | if cmd.Window != 0 { | ||
| 148 | gone = ports.CaptureReasonWindowGone | 148 | 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.Window | 151 | 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) { | |||
| 276 | 276 | } | |
| 277 | 277 | ||
| 278 | 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 | 280 | return nil | |
| 281 | 281 | } | |
| 282 | - | return s.clientKeyboards(w.xdg.resource.Client()) | |
| 282 | + | return s.clientKeyboards(w.client()) | |
| 283 | 283 | } | |
| 284 | 284 | ||
| 285 | 285 | // 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 | 301 | return l.surface.wl, s.clientKeyboards(l.surface.wl.Client()) | |
| 302 | 302 | } | |
| 303 | 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 | 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 | 319 | if l := s.lockSurfaces[id]; l != nil && s.lockInputTarget(id) { | |
| 320 | 320 | 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() { | |
| 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 | |
| 324 | 324 | } | |
| 325 | 325 | if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() { | |
| 326 | 326 | 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 | 335 | return l.surface.wl, s.clientPointers(l.surface.wl.Client()), x, y | |
| 336 | 336 | } | |
| 337 | 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 | 339 | x, y := w.surfacePoint(x, y) | |
| 340 | 340 | return surf, s.windowPointers(w), x, y | |
| 341 | 341 | } | |
| @@ -543,10 +543,10 @@ func pointerFrame(p *wayland.Pointer) { | |||
| 543 | 543 | } | |
| 544 | 544 | } | |
| 545 | 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 | 547 | return nil | |
| 548 | 548 | } | |
| 549 | - | return s.clientPointers(w.xdg.resource.Client()) | |
| 549 | + | return s.clientPointers(w.client()) | |
| 550 | 550 | } | |
| 551 | 551 | ||
| 552 | 552 | 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 nil | 280 | 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 holds | 285 | // 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()), true | 320 | 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), true | 323 | + | 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), true | 326 | 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, y | 335 | 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, y | 340 | 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 nil | 547 | 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 { | |||
| 160 | 160 | securityStop int | |
| 161 | 161 | securityWG sync.WaitGroup | |
| 162 | 162 | securityLive atomic.Int32 | |
| 163 | + | xwayland xwaylandClient // display owner; Phase 6 takes the XWM fd from here. | |
| 163 | 164 | display *server.Display | |
| 164 | 165 | env procEnv | |
| 165 | 166 | name string | |
| @@ -214,8 +215,11 @@ type Server struct { | |||
| 214 | 215 | // foreignExports are the live xdg-foreign handles (foreign.go). | |
| 215 | 216 | foreignExports map[string]*foreignExport | |
| 216 | 217 | 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 | |
| 219 | 223 | // hdrOutputs records confirmed DRM modesets; absent outputs are SDR. | |
| 220 | 224 | hdrOutputs map[string]ports.OutputHDR | |
| 221 | 225 | colorID uint64 | |
| @@ -366,6 +370,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 366 | 370 | logDamage: opts.LogDamage, | |
| 367 | 371 | channels: ch, | |
| 368 | 372 | surfaces: make(map[*server.Resource]*surface), | |
| 373 | + | surfaceByIdentity: make(map[uint64]*surface), | |
| 369 | 374 | buffers: make(map[*server.Resource]clientBuffer), | |
| 370 | 375 | windows: make(map[ports.WindowID]*window), | |
| 371 | 376 | layers: make(map[ports.WindowID]*layerSurface), | |
| @@ -585,6 +590,7 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 585 | 590 | } | |
| 586 | 591 | s.castInventoryChanged() | |
| 587 | 592 | err := s.display.Run(ctx) | |
| 593 | + | s.forgetXwayland() | |
| 588 | 594 | s.stopSecurityContexts() | |
| 589 | 595 | wg.Wait() | |
| 590 | 596 | return err | |
| @@ -776,7 +782,7 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 776 | 782 | } | |
| 777 | 783 | case ports.ConfigureWindow: | |
| 778 | 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 | 786 | s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window") | |
| 781 | 787 | return | |
| 782 | 788 | } | |
| @@ -787,16 +793,16 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 787 | 793 | scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output || w.last.Visible != c.Visible | |
| 788 | 794 | w.last, w.hasLast = c, true | |
| 789 | 795 | if visibilityChanged { | |
| 790 | - | s.relocateCallbacks(w.xdg.surface) | |
| 796 | + | s.relocateCallbacks(w.surf()) | |
| 791 | 797 | s.dropFeedbacks(time.Now()) | |
| 792 | 798 | s.wakePacer() // reconsider FIFO barriers at the new period | |
| 793 | 799 | } | |
| 794 | 800 | w.sendConfigure() | |
| 795 | 801 | 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()) | |
| 798 | 804 | } | |
| 799 | - | if surf := w.xdg.surface; surf != nil && c.Output != "" { | |
| 805 | + | if surf := w.surf(); surf != nil && c.Output != "" { | |
| 800 | 806 | surf.sendTreeScale() | |
| 801 | 807 | } | |
| 802 | 808 | if scanoutChanged { | |
| @@ -817,10 +823,10 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 817 | 823 | s.setShortcutsInhibit(c) | |
| 818 | 824 | case ports.CloseWindow: | |
| 819 | 825 | w := s.windows[c.ID] | |
| 820 | - | if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() { | |
| 826 | + | if w == nil || !w.toplevelAlive() { | |
| 821 | 827 | s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window") | |
| 822 | 828 | return | |
| 823 | 829 | } | |
| 824 | - | w.toplevel.SendClose() | |
| 830 | + | w.sendClose() | |
| 825 | 831 | } | |
| 826 | 832 | } | |
| @@ -160,6 +160,7 @@ type Server struct { | |||||
| 160 | securityStop int | 160 | securityStop int | ||
| 161 | securityWG sync.WaitGroup | 161 | securityWG sync.WaitGroup | ||
| 162 | securityLive atomic.Int32 | 162 | securityLive atomic.Int32 | ||
| 163 | + | xwayland xwaylandClient // display owner; Phase 6 takes the XWM fd from here. | |||
| 163 | display *server.Display | 164 | display *server.Display | ||
| 164 | env procEnv | 165 | env procEnv | ||
| 165 | name string | 166 | 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]*foreignExport | 216 | foreignExports map[string]*foreignExport | ||
| 216 | surfaces map[*server.Resource]*surface | 217 | surfaces map[*server.Resource]*surface | ||
| 217 | - | buffers map[*server.Resource]clientBuffer | 218 | + | // surfaceByIdentity finds a surface by its identity (xwayland_shell_v1 |
| 218 | - | dmabuf *dmabufGlobal | 219 | + | // 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.OutputHDR | 224 | hdrOutputs map[string]ports.OutputHDR | ||
| 221 | colorID uint64 | 225 | 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 err | 596 | 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 | return | 787 | 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.Visible | 793 | scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output || w.last.Visible != c.Visible | ||
| 788 | w.last, w.hasLast = c, true | 794 | 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 period | 798 | 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 | return | 828 | 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 ( | |||
| 28 | 28 | roleSessionLock | |
| 29 | 29 | // roleDragIcon is the icon of a data drag (dnd.go). | |
| 30 | 30 | 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 | 37 | type surface struct { | |
| @@ -28,6 +28,10 @@ const ( | |||||
| 28 | roleSessionLock | 28 | roleSessionLock | ||
| 29 | // roleDragIcon is the icon of a data drag (dnd.go). | 29 | // roleDragIcon is the icon of a data drag (dnd.go). | ||
| 30 | roleDragIcon | 30 | 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 { | |||
| 151 | 151 | hasLast bool | |
| 152 | 152 | } | |
| 153 | 153 | ||
| 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() { | |
| 156 | 156 | c := w.last | |
| 157 | 157 | w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), toplevelStates(c, w.toplevel.Version())) | |
| 158 | 158 | s := w.xdg.server | |
| @@ -151,8 +151,8 @@ type window struct { | |||||
| 151 | hasLast bool | 151 | 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.last | 156 | 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.server | 158 | 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. | |
| 4 | 3 | package xwayland | |
| 5 | 4 | ||
| 6 | 5 | import ( | |
| @@ -17,15 +16,25 @@ import ( | |||
| 17 | 16 | "syscall" | |
| 18 | 17 | "time" | |
| 19 | 18 | ||
| 19 | + | "github.com/bnema/neferwl/internal/ports" | |
| 20 | 20 | "github.com/bnema/zerowrap" | |
| 21 | 21 | "golang.org/x/sys/unix" | |
| 22 | 22 | ) | |
| 23 | 23 | ||
| 24 | 24 | // Options configures the X11 display. | |
| 25 | 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 | 36 | 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 | 38 | Env []string | |
| 30 | 39 | // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix | |
| 31 | 40 | // and /tmp outside tests. | |
| @@ -35,8 +44,9 @@ type Options struct { | |||
| 35 | 44 | 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 | 48 | type Display struct { | |
| 49 | + | starter starter | |
| 40 | 50 | opts Options | |
| 41 | 51 | n int | |
| 42 | 52 | sockets []*os.File // unix socket, then the abstract one | |
| @@ -81,7 +91,10 @@ func Open(opts Options) (*Display, error) { | |||
| 81 | 91 | _ = os.Remove(lock) | |
| 82 | 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 | 98 | if err := d.listen(); err != nil { | |
| 86 | 99 | last = err | |
| 87 | 100 | d.Close() | |
| @@ -212,15 +225,15 @@ func (d *Display) Run(ctx context.Context) error { | |||
| 212 | 225 | return err | |
| 213 | 226 | } | |
| 214 | 227 | 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) | |
| 217 | 230 | if ctx.Err() != nil { | |
| 218 | 231 | return nil | |
| 219 | 232 | } | |
| 220 | 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 | 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 | 238 | // Clients the satellite never accepted would wake us at once: a | |
| 226 | 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 | 274 | return ctx.Err() | |
| 262 | 275 | } | |
| 263 | 276 | ||
| 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 { | |
| 266 | 285 | args := []string{d.Name()} | |
| 267 | 286 | for i := range d.sockets { | |
| 268 | 287 | args = append(args, "-listenfd", strconv.Itoa(3+i)) | |
| @@ -318,10 +337,11 @@ func (d *Display) dropPending() { | |||
| 318 | 337 | // logWriter logs the satellite's stderr line by line and keeps the last | |
| 319 | 338 | // lines for the exit log; exec copies it from a pipe on its own goroutine. | |
| 320 | 339 | 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 | |
| 325 | 345 | } | |
| 326 | 346 | ||
| 327 | 347 | // tail is the last lines written. | |
| @@ -342,7 +362,7 @@ func (w *logWriter) Write(p []byte) (int, error) { | |||
| 342 | 362 | } | |
| 343 | 363 | if i > 0 { | |
| 344 | 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 | 366 | if w.last = append(w.last, line); len(w.last) > 5 { | |
| 347 | 367 | w.last = w.last[1:] | |
| 348 | 368 | } | |
| @@ -354,3 +374,10 @@ func (w *logWriter) Write(p []byte) (int, error) { | |||
| 354 | 374 | } | |
| 355 | 375 | 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 | + | } | |
| @@ -1,6 +1,5 @@ | |||||
| 1 | - | // Package xwayland runs X11 clients through xwayland-satellite, started on | 1 | + | // Package xwayland starts an X11 backend on demand. NeferWL owns the display |
| 2 | - | // demand: neferwl owns the X11 display sockets and starts the satellite | 2 | + | // 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 xwayland | 3 | 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 string | 36 | 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 []string | 38 | Env []string | ||
| 30 | // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix | 39 | // 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.Logger | 44 | 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 Options | 50 | opts Options | ||
| 41 | n int | 51 | n int | ||
| 42 | sockets []*os.File // unix socket, then the abstract one | 52 | 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 = err | 99 | last = err | ||
| 87 | d.Close() | 100 | d.Close() | ||
| @@ -212,15 +225,15 @@ func (d *Display) Run(ctx context.Context) error { | |||||
| 212 | return err | 225 | 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 nil | 231 | 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: a | 238 | // 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 last | 337 | // 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.Mutex | 340 | + | backend string |
| 322 | - | log zerowrap.Logger | 341 | + | mu sync.Mutex |
| 323 | - | line []byte | 342 | + | log zerowrap.Logger |
| 324 | - | last []string | 343 | + | 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), nil | 375 | 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 ( | |||
| 11 | 11 | "time" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 14 | + | "github.com/bnema/neferwl/internal/ports" | |
| 15 | + | "github.com/stretchr/testify/mock" | |
| 14 | 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 | + | } | |
| @@ -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 ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "io" | |
| 8 | + | "os/exec" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| 10 | 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 returns | |
| 192 | + | // openXwayland reserves an X11 display for the selected backend, or returns | |
| 192 | 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 | - | 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 { | |
| 196 | 198 | 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 | 206 | log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled") | |
| 200 | 207 | 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 | 210 | if err != nil { | |
| 204 | 211 | log.Warn().Err(err).Msg("X11 display disabled") | |
| 205 | 212 | 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 returns | 192 | + | // 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.Xwayland | 195 | + | cfg := opts.Config.Xwayland |
| 195 | - | if bin == "" || opts.NoXwayland { | 196 | + | bin := cfg.Binary |
| 197 | + | if cfg.Backend == ports.XwaylandOff || opts.NoXwayland { | |||
| 196 | return nil | 198 | 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 nil | 207 | 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 nil | 212 | return nil | ||
M internal/app/session.go +1 -1
| @@ -152,7 +152,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error) | |||
| 152 | 152 | s.statePath = statePath | |
| 153 | 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 | 156 | s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name()) | |
| 157 | 157 | } | |
| 158 | 158 | 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 = statePath | 152 | 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 { | |||
| 101 | 101 | Command []string | |
| 102 | 102 | AutoOpen string | |
| 103 | 103 | } | |
| 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 | |
| 107 | 106 | // Startup are commands run once when the session starts, in order. | |
| 108 | 107 | // One startup line may hold several, split by SplitCommands. | |
| 109 | 108 | Startup [][]string | |
| @@ -298,3 +297,18 @@ type WindowRule struct { | |||
| 298 | 297 | // (1 to 16; 0 leaves it automatic). | |
| 299 | 298 | 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 | + | } | |
| @@ -101,9 +101,8 @@ type Config struct { | |||||
| 101 | Command []string | 101 | Command []string | ||
| 102 | AutoOpen string | 102 | AutoOpen string | ||
| 103 | } | 103 | } | ||
| 104 | - | // Xwayland is the xwayland-satellite binary serving X11 clients; empty | 104 | + | // 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 [][]string | 108 | 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 int | 298 | 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 | + | } | |||