detached 24f47eb

feat(wayland): integrate the built-in XWM with the server

Boot runs on the starter goroutine with a context that ends when the Xwayland
process dies, and installXWM/stop run on the display goroutine. hostImpl maps
XWM windows to neferwl windows (x11 role, Positioned map events), the
xwayland_shell_v1 global is visible to the Xwayland client only, and
configure/place/focus/close are forwarded to the XWM.
commit
24f47eb83d49568d4a4d3eaab22c7ae000da3dd0
author
brice · · 2026-10-22 13:23 UTC
committer
brice · · 2026-10-22 17:54 UTC
parent
e1de5a5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
21 files · +1186 -75
M internal/adapters/wayland/dmabuf.go +3 -2
@@ -223,10 +223,11 @@ func (g *dmabufGlobal) fullscreenOutput(surf *surface) (string, bool) {
223223 return "", false
224224 }
225225 root := toplevelRoot(surf)
226- if root.xdg == nil || root.xdg.window == nil || !root.xdg.window.hasLast || !root.xdg.window.last.Fullscreen || g.server.invisible(surf) {
226+ w := root.toplevelWindow()
227+ if w == nil || !w.hasLast || !w.last.Fullscreen || g.server.invisible(surf) {
227228 return "", false
228229 }
229- return root.xdg.window.last.Output, true
230+ return w.last.Output, true
230231 }
231232
232233 // scanoutFor is the scanout offer for a surface: its window is fullscreen
@@ -223,10 +223,11 @@ func (g *dmabufGlobal) fullscreenOutput(surf *surface) (string, bool) {
223 return "", false223 return "", false
224 }224 }
225 root := toplevelRoot(surf)225 root := toplevelRoot(surf)
226- if root.xdg == nil || root.xdg.window == nil || !root.xdg.window.hasLast || !root.xdg.window.last.Fullscreen || g.server.invisible(surf) {226+ w := root.toplevelWindow()
227+ if w == nil || !w.hasLast || !w.last.Fullscreen || g.server.invisible(surf) {
227 return "", false228 return "", false
228 }229 }
229- return root.xdg.window.last.Output, true230+ return w.last.Output, true
230 }231 }
231 232
232 // scanoutFor is the scanout offer for a surface: its window is fullscreen233 // scanoutFor is the scanout offer for a surface: its window is fullscreen
M internal/adapters/wayland/frames.go +7 -5
@@ -193,7 +193,8 @@ func toplevelRoot(surf *surface) *surface {
193193 // for a capture session is invisible.
194194 func (s *Server) invisible(surf *surface) bool {
195195 root := toplevelRoot(surf)
196- return root.xdg != nil && root.xdg.window != nil && root.xdg.window.hasLast && !root.xdg.window.last.Visible
196+ w := root.toplevelWindow()
197+ return w != nil && w.hasLast && !w.last.Visible
197198 }
198199
199200 // suspended reports that nothing draws the toplevel tree, not even a capture
@@ -201,10 +202,11 @@ func (s *Server) invisible(surf *surface) bool {
201202 // frame callbacks of its output so the capture keeps moving.
202203 func (s *Server) suspended(surf *surface) bool {
203204 root := toplevelRoot(surf)
204- if root.xdg == nil || root.xdg.window == nil || !root.xdg.window.hasLast {
205+ w := root.toplevelWindow()
206+ if w == nil || !w.hasLast {
205207 return false
206208 }
207- return !root.xdg.window.last.Visible && !root.xdg.window.last.Captured
209+ return !w.last.Visible && !w.last.Captured
208210 }
209211
210212 // frameOutput is the pacing class for a surface; "" is the 60 Hz
@@ -212,11 +214,11 @@ func (s *Server) suspended(surf *surface) bool {
212214 func (s *Server) frameOutput(surf *surface) string {
213215 surf = toplevelRoot(surf)
214216 switch {
215- case surf.xdg != nil && surf.xdg.window != nil:
217+ case surf.toplevelWindow() != nil:
216218 if s.suspended(surf) {
217219 return suspendedFrameQueue
218220 }
219- return surf.xdg.window.last.Output
221+ return surf.toplevelWindow().last.Output
220222 case surf.layer != nil && surf.layer.output != nil:
221223 return surf.layer.output.name()
222224 case surf.lock != nil && surf.lock.output != nil && !surf.lock.closed:
@@ -193,7 +193,8 @@ func toplevelRoot(surf *surface) *surface {
193 // for a capture session is invisible.193 // for a capture session is invisible.
194 func (s *Server) invisible(surf *surface) bool {194 func (s *Server) invisible(surf *surface) bool {
195 root := toplevelRoot(surf)195 root := toplevelRoot(surf)
196- return root.xdg != nil && root.xdg.window != nil && root.xdg.window.hasLast && !root.xdg.window.last.Visible196+ w := root.toplevelWindow()
197+ return w != nil && w.hasLast && !w.last.Visible
197 }198 }
198 199
199 // suspended reports that nothing draws the toplevel tree, not even a capture200 // suspended reports that nothing draws the toplevel tree, not even a capture
@@ -201,10 +202,11 @@ func (s *Server) invisible(surf *surface) bool {
201 // frame callbacks of its output so the capture keeps moving.202 // frame callbacks of its output so the capture keeps moving.
202 func (s *Server) suspended(surf *surface) bool {203 func (s *Server) suspended(surf *surface) bool {
203 root := toplevelRoot(surf)204 root := toplevelRoot(surf)
204- if root.xdg == nil || root.xdg.window == nil || !root.xdg.window.hasLast {205+ w := root.toplevelWindow()
206+ if w == nil || !w.hasLast {
205 return false207 return false
206 }208 }
207- return !root.xdg.window.last.Visible && !root.xdg.window.last.Captured209+ return !w.last.Visible && !w.last.Captured
208 }210 }
209 211
210 // frameOutput is the pacing class for a surface; "" is the 60 Hz212 // frameOutput is the pacing class for a surface; "" is the 60 Hz
@@ -212,11 +214,11 @@ func (s *Server) suspended(surf *surface) bool {
212 func (s *Server) frameOutput(surf *surface) string {214 func (s *Server) frameOutput(surf *surface) string {
213 surf = toplevelRoot(surf)215 surf = toplevelRoot(surf)
214 switch {216 switch {
215- case surf.xdg != nil && surf.xdg.window != nil:217+ case surf.toplevelWindow() != nil:
216 if s.suspended(surf) {218 if s.suspended(surf) {
217 return suspendedFrameQueue219 return suspendedFrameQueue
218 }220 }
219- return surf.xdg.window.last.Output221+ return surf.toplevelWindow().last.Output
220 case surf.layer != nil && surf.layer.output != nil:222 case surf.layer != nil && surf.layer.output != nil:
221 return surf.layer.output.name()223 return surf.layer.output.name()
222 case surf.lock != nil && surf.lock.output != nil && !surf.lock.closed:224 case surf.lock != nil && surf.lock.output != nil && !surf.lock.closed:
M internal/adapters/wayland/globals.go +4 -0
@@ -13,6 +13,7 @@ import (
1313 func registerGlobals(d *server.Display, o Options, s *Server) error {
1414 for _, register := range []func() error{
1515 func() error { return registerXDG(d, s) },
16+ func() error { return registerXwaylandShell(d, s) },
1617 func() error { return registerLayer(d, s) },
1718 func() error {
1819 // Advertise only with the production owner gate and authoritative
@@ -137,6 +138,9 @@ func (c compositor) CreateSurface(r *wayland.Compositor, id uint32) {
137138 delete(c.server.surfaces, w.Resource)
138139 delete(c.server.surfaceByIdentity, state.identity)
139140 state.Destroy(w)
141+ if state.kind == roleX11 && c.server.xwm != nil {
142+ c.server.xwm.SurfaceGone(state.identity)
143+ }
140144 }
141145 }
142146 }
@@ -13,6 +13,7 @@ import (
13 func registerGlobals(d *server.Display, o Options, s *Server) error {13 func registerGlobals(d *server.Display, o Options, s *Server) error {
14 for _, register := range []func() error{14 for _, register := range []func() error{
15 func() error { return registerXDG(d, s) },15 func() error { return registerXDG(d, s) },
16+ func() error { return registerXwaylandShell(d, s) },
16 func() error { return registerLayer(d, s) },17 func() error { return registerLayer(d, s) },
17 func() error {18 func() error {
18 // Advertise only with the production owner gate and authoritative19 // Advertise only with the production owner gate and authoritative
@@ -137,6 +138,9 @@ func (c compositor) CreateSurface(r *wayland.Compositor, id uint32) {
137 delete(c.server.surfaces, w.Resource)138 delete(c.server.surfaces, w.Resource)
138 delete(c.server.surfaceByIdentity, state.identity)139 delete(c.server.surfaceByIdentity, state.identity)
139 state.Destroy(w)140 state.Destroy(w)
141+ if state.kind == roleX11 && c.server.xwm != nil {
142+ c.server.xwm.SurfaceGone(state.identity)
143+ }
140 }144 }
141 }145 }
142 }146 }
M internal/adapters/wayland/scale.go +2 -2
@@ -25,8 +25,8 @@ func (s *Server) outputOfSurface(surf *surface) *output {
2525 }
2626 name := ""
2727 switch {
28- case surf.xdg != nil && surf.xdg.window != nil:
29- name = surf.xdg.window.last.Output
28+ case surf.toplevelWindow() != nil:
29+ name = surf.toplevelWindow().last.Output
3030 case surf.layer != nil && surf.layer.output != nil:
3131 return surf.layer.output
3232 case surf.lock != nil:
@@ -25,8 +25,8 @@ func (s *Server) outputOfSurface(surf *surface) *output {
25 }25 }
26 name := ""26 name := ""
27 switch {27 switch {
28- case surf.xdg != nil && surf.xdg.window != nil:28+ case surf.toplevelWindow() != nil:
29- name = surf.xdg.window.last.Output29+ name = surf.toplevelWindow().last.Output
30 case surf.layer != nil && surf.layer.output != nil:30 case surf.layer != nil && surf.layer.output != nil:
31 return surf.layer.output31 return surf.layer.output
32 case surf.lock != nil:32 case surf.lock != nil:
M internal/adapters/wayland/seat.go +1 -0
@@ -455,6 +455,7 @@ func (s *Server) changeFocus(id ports.WindowID) {
455455 }
456456 }
457457 s.textInputFocus()
458+ s.x11Focus()
458459 s.log.Debug().Uint64("id", uint64(s.seat.focused)).Msg("keyboard focus")
459460 s.updateConstraint()
460461 }
@@ -455,6 +455,7 @@ func (s *Server) changeFocus(id ports.WindowID) {
455 }455 }
456 }456 }
457 s.textInputFocus()457 s.textInputFocus()
458+ s.x11Focus()
458 s.log.Debug().Uint64("id", uint64(s.seat.focused)).Msg("keyboard focus")459 s.log.Debug().Uint64("id", uint64(s.seat.focused)).Msg("keyboard focus")
459 s.updateConstraint()460 s.updateConstraint()
460 }461 }
M internal/adapters/wayland/server.go +21 -9
@@ -16,6 +16,7 @@ import (
1616 "github.com/bnema/neferwl/internal/adapters/captureallow"
1717 "github.com/bnema/neferwl/internal/adapters/clock"
1818 "github.com/bnema/neferwl/internal/adapters/wayland/sessionlock"
19+ "github.com/bnema/neferwl/internal/adapters/wayland/xwm"
1920 "github.com/bnema/neferwl/internal/adapters/workspaceid"
2021 "github.com/bnema/neferwl/internal/ports"
2122 "github.com/bnema/purego-libwayland/server"
@@ -160,14 +161,18 @@ type Server struct {
160161 securityStop int
161162 securityWG sync.WaitGroup
162163 securityLive atomic.Int32
163- xwayland xwaylandClient // display owner; Phase 6 takes the XWM fd from here.
164- display *server.Display
165- env procEnv
166- name string
167- cleanup func()
168- log zerowrap.Logger
169- logDamage bool
170- channels Channels
164+ xwayland xwaylandClient // display owner
165+ // xwm manages the X11 windows of the Xwayland client; x11Slots maps its
166+ // slots to their windows. Display goroutine only.
167+ xwm *xwm.XWM
168+ x11Slots map[xwm.Slot]*window
169+ display *server.Display
170+ env procEnv
171+ name string
172+ cleanup func()
173+ log zerowrap.Logger
174+ logDamage bool
175+ channels Channels
171176 // awaiting holds frame callbacks by output name, due at its next
172177 // frame (frameDue, or its page flip).
173178 awaiting map[string][]*wayland.Callback
@@ -373,6 +378,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
373378 surfaceByIdentity: make(map[uint64]*surface),
374379 buffers: make(map[*server.Resource]clientBuffer),
375380 windows: make(map[ports.WindowID]*window),
381+ x11Slots: make(map[xwm.Slot]*window),
376382 layers: make(map[ports.WindowID]*layerSurface),
377383 nextWindow: 1,
378384 eventReady: make(chan struct{}, 1),
@@ -503,6 +509,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
503509 if err = s.openPaceTimer(); err != nil {
504510 return abort(err)
505511 }
512+ d.SetGlobalFilter(s.globalVisible)
506513 if err = registerGlobals(d, opts, s); err != nil {
507514 return abort(err)
508515 }
@@ -590,6 +597,11 @@ func (s *Server) Run(ctx context.Context) error {
590597 }
591598 s.castInventoryChanged()
592599 err := s.display.Run(ctx)
600+ // The display is gone: only the X connection is left to close.
601+ if s.xwm != nil {
602+ s.xwm.CloseConn()
603+ s.xwm = nil
604+ }
593605 s.forgetXwayland()
594606 s.stopSecurityContexts()
595607 wg.Wait()
@@ -781,7 +793,7 @@ func (s *Server) apply(cmd ports.ClientCommand) {
781793 w.popup.dismiss()
782794 }
783795 case ports.PlaceWindow:
784- // Consumed by the X11 host.
796+ s.x11Place(c)
785797 case ports.ConfigureWindow:
786798 w := s.windows[c.ID]
787799 if w == nil || !w.toplevelAlive() || !w.alive() {
@@ -16,6 +16,7 @@ import (
16 "github.com/bnema/neferwl/internal/adapters/captureallow"16 "github.com/bnema/neferwl/internal/adapters/captureallow"
17 "github.com/bnema/neferwl/internal/adapters/clock"17 "github.com/bnema/neferwl/internal/adapters/clock"
18 "github.com/bnema/neferwl/internal/adapters/wayland/sessionlock"18 "github.com/bnema/neferwl/internal/adapters/wayland/sessionlock"
19+ "github.com/bnema/neferwl/internal/adapters/wayland/xwm"
19 "github.com/bnema/neferwl/internal/adapters/workspaceid"20 "github.com/bnema/neferwl/internal/adapters/workspaceid"
20 "github.com/bnema/neferwl/internal/ports"21 "github.com/bnema/neferwl/internal/ports"
21 "github.com/bnema/purego-libwayland/server"22 "github.com/bnema/purego-libwayland/server"
@@ -160,14 +161,18 @@ type Server struct {
160 securityStop int161 securityStop int
161 securityWG sync.WaitGroup162 securityWG sync.WaitGroup
162 securityLive atomic.Int32163 securityLive atomic.Int32
163- xwayland xwaylandClient // display owner; Phase 6 takes the XWM fd from here.164+ xwayland xwaylandClient // display owner
164- display *server.Display165+ // xwm manages the X11 windows of the Xwayland client; x11Slots maps its
165- env procEnv166+ // slots to their windows. Display goroutine only.
166- name string167+ xwm *xwm.XWM
167- cleanup func()168+ x11Slots map[xwm.Slot]*window
168- log zerowrap.Logger169+ display *server.Display
169- logDamage bool170+ env procEnv
170- channels Channels171+ name string
172+ cleanup func()
173+ log zerowrap.Logger
174+ logDamage bool
175+ channels Channels
171 // awaiting holds frame callbacks by output name, due at its next176 // awaiting holds frame callbacks by output name, due at its next
172 // frame (frameDue, or its page flip).177 // frame (frameDue, or its page flip).
173 awaiting map[string][]*wayland.Callback178 awaiting map[string][]*wayland.Callback
@@ -373,6 +378,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
373 surfaceByIdentity: make(map[uint64]*surface),378 surfaceByIdentity: make(map[uint64]*surface),
374 buffers: make(map[*server.Resource]clientBuffer),379 buffers: make(map[*server.Resource]clientBuffer),
375 windows: make(map[ports.WindowID]*window),380 windows: make(map[ports.WindowID]*window),
381+ x11Slots: make(map[xwm.Slot]*window),
376 layers: make(map[ports.WindowID]*layerSurface),382 layers: make(map[ports.WindowID]*layerSurface),
377 nextWindow: 1,383 nextWindow: 1,
378 eventReady: make(chan struct{}, 1),384 eventReady: make(chan struct{}, 1),
@@ -503,6 +509,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
503 if err = s.openPaceTimer(); err != nil {509 if err = s.openPaceTimer(); err != nil {
504 return abort(err)510 return abort(err)
505 }511 }
512+ d.SetGlobalFilter(s.globalVisible)
506 if err = registerGlobals(d, opts, s); err != nil {513 if err = registerGlobals(d, opts, s); err != nil {
507 return abort(err)514 return abort(err)
508 }515 }
@@ -590,6 +597,11 @@ func (s *Server) Run(ctx context.Context) error {
590 }597 }
591 s.castInventoryChanged()598 s.castInventoryChanged()
592 err := s.display.Run(ctx)599 err := s.display.Run(ctx)
600+ // The display is gone: only the X connection is left to close.
601+ if s.xwm != nil {
602+ s.xwm.CloseConn()
603+ s.xwm = nil
604+ }
593 s.forgetXwayland()605 s.forgetXwayland()
594 s.stopSecurityContexts()606 s.stopSecurityContexts()
595 wg.Wait()607 wg.Wait()
@@ -781,7 +793,7 @@ func (s *Server) apply(cmd ports.ClientCommand) {
781 w.popup.dismiss()793 w.popup.dismiss()
782 }794 }
783 case ports.PlaceWindow:795 case ports.PlaceWindow:
784- // Consumed by the X11 host.796+ s.x11Place(c)
785 case ports.ConfigureWindow:797 case ports.ConfigureWindow:
786 w := s.windows[c.ID]798 w := s.windows[c.ID]
787 if w == nil || !w.toplevelAlive() || !w.alive() {799 if w == nil || !w.toplevelAlive() || !w.alive() {
M internal/adapters/wayland/surface.go +12 -3
@@ -30,7 +30,6 @@ const (
3030 roleDragIcon
3131 // roleX11 is the surface of an X11 window paired through
3232 // xwayland_shell_v1.
33- //lint:ignore U1000 used by the X11 window role (xwm)
3433 roleX11
3534 )
3635
@@ -51,8 +50,12 @@ type surface struct {
5150 formatOpaque bool
5251 kind roleKind
5352 xdg *xdgSurface
54- layer *layerSurface
55- lock *lockSurface
53+ // x11win is the X11 window the surface is associated with; x11Serial is
54+ // the serial of xwayland_surface_v1.set_serial, 0 until set.
55+ x11win *window
56+ x11Serial uint64
57+ layer *layerSurface
58+ lock *lockSurface
5659 // bufferCommitted is permanent, including commits later discarded by FIFO.
5760 bufferCommitted bool
5861 server *Server
@@ -196,6 +199,9 @@ func (s *surface) windowID() ports.WindowID {
196199 if s.xdg != nil && s.xdg.window != nil && s.xdg.window.mapped {
197200 return s.xdg.window.id
198201 }
202+ if s.x11win != nil && s.x11win.mapped {
203+ return s.x11win.id
204+ }
199205 if s.layer != nil && s.layer.mapped {
200206 return s.layer.id
201207 }
@@ -570,6 +576,9 @@ func (s *surface) applyCommit(u *update) {
570576 if s.xdg != nil && s.xdg.window != nil {
571577 s.xdg.window.afterCommit()
572578 }
579+ if s.x11win != nil {
580+ s.x11win.afterCommit()
581+ }
573582 if s.lock != nil {
574583 // Role callbacks precede content construction; publish lock placement
575584 // only once validated current dimensions are available.
@@ -30,7 +30,6 @@ const (
30 roleDragIcon30 roleDragIcon
31 // roleX11 is the surface of an X11 window paired through31 // roleX11 is the surface of an X11 window paired through
32 // xwayland_shell_v1.32 // xwayland_shell_v1.
33- //lint:ignore U1000 used by the X11 window role (xwm)
34 roleX1133 roleX11
35 )34 )
36 35
@@ -51,8 +50,12 @@ type surface struct {
51 formatOpaque bool50 formatOpaque bool
52 kind roleKind51 kind roleKind
53 xdg *xdgSurface52 xdg *xdgSurface
54- layer *layerSurface53+ // x11win is the X11 window the surface is associated with; x11Serial is
55- lock *lockSurface54+ // the serial of xwayland_surface_v1.set_serial, 0 until set.
55+ x11win *window
56+ x11Serial uint64
57+ layer *layerSurface
58+ lock *lockSurface
56 // bufferCommitted is permanent, including commits later discarded by FIFO.59 // bufferCommitted is permanent, including commits later discarded by FIFO.
57 bufferCommitted bool60 bufferCommitted bool
58 server *Server61 server *Server
@@ -196,6 +199,9 @@ func (s *surface) windowID() ports.WindowID {
196 if s.xdg != nil && s.xdg.window != nil && s.xdg.window.mapped {199 if s.xdg != nil && s.xdg.window != nil && s.xdg.window.mapped {
197 return s.xdg.window.id200 return s.xdg.window.id
198 }201 }
202+ if s.x11win != nil && s.x11win.mapped {
203+ return s.x11win.id
204+ }
199 if s.layer != nil && s.layer.mapped {205 if s.layer != nil && s.layer.mapped {
200 return s.layer.id206 return s.layer.id
201 }207 }
@@ -570,6 +576,9 @@ func (s *surface) applyCommit(u *update) {
570 if s.xdg != nil && s.xdg.window != nil {576 if s.xdg != nil && s.xdg.window != nil {
571 s.xdg.window.afterCommit()577 s.xdg.window.afterCommit()
572 }578 }
579+ if s.x11win != nil {
580+ s.x11win.afterCommit()
581+ }
573 if s.lock != nil {582 if s.lock != nil {
574 // Role callbacks precede content construction; publish lock placement583 // Role callbacks precede content construction; publish lock placement
575 // only once validated current dimensions are available.584 // only once validated current dimensions are available.
M internal/adapters/wayland/window.go +49 -8
@@ -10,26 +10,46 @@ import (
1010 // them instead of dereferencing w.xdg, which is nil for windows of another
1111 // role.
1212
13-// isToplevel reports whether w is a toplevel (not a popup).
14-func (w *window) isToplevel() bool { return w.toplevel != nil }
13+// isToplevel reports whether w is a toplevel (xdg or X11), not a popup.
14+func (w *window) isToplevel() bool { return w.toplevel != nil || w.x11 != nil }
15+
16+// srv is the server the window belongs to.
17+func (w *window) srv() *Server {
18+ if w.xdg != nil {
19+ return w.xdg.server
20+ }
21+ return w.x11.server
22+}
1523
1624 // surf is the window's surface, nil when it has none.
1725 func (w *window) surf() *surface {
18- if w.xdg != nil {
26+ switch {
27+ case w.xdg != nil:
1928 return w.xdg.surface
29+ case w.x11 != nil:
30+ return w.x11.surface
2031 }
2132 return nil
2233 }
2334
2435 // alive reports whether the window's role object is alive.
2536 func (w *window) alive() bool {
26- return w.xdg != nil && w.xdg.resource.Resource.Alive()
37+ switch {
38+ case w.xdg != nil:
39+ return w.xdg.resource.Resource.Alive()
40+ case w.x11 != nil:
41+ return w.x11.surface != nil && w.x11.surface.wl.Resource.Alive()
42+ }
43+ return false
2744 }
2845
2946 // client is the window's client; the zero Client when it has no role object.
3047 func (w *window) client() server.Client {
31- if w.xdg != nil {
48+ switch {
49+ case w.xdg != nil:
3250 return w.xdg.resource.Client()
51+ case w.x11 != nil && w.x11.surface != nil:
52+ return w.x11.surface.wl.Client()
3353 }
3454 return server.Client{}
3555 }
@@ -44,28 +64,49 @@ func (w *window) geometry() ports.Rect {
4464
4565 // sendConfigure sends w.last to the client.
4666 func (w *window) sendConfigure() {
47- if w.xdg != nil {
67+ switch {
68+ case w.xdg != nil:
4869 w.sendXDGConfigure()
70+ case w.x11 != nil:
71+ w.x11.server.x11Configure(w)
4972 }
5073 }
5174
5275 // sendClose asks the client to close the window.
5376 func (w *window) sendClose() {
54- if w.toplevel != nil {
77+ switch {
78+ case w.toplevel != nil:
5579 w.toplevel.SendClose()
80+ case w.x11 != nil:
81+ w.x11.server.x11Close(w)
5682 }
5783 }
5884
5985 // toplevelAlive reports whether the window is a toplevel whose protocol
6086 // object is alive.
6187 func (w *window) toplevelAlive() bool {
88+ if w.x11 != nil {
89+ return w.alive()
90+ }
6291 return w.toplevel != nil && w.toplevel.Resource.Alive()
6392 }
6493
6594 // surfaceResource is the window's wl_surface, nil when it has none or it died.
6695 func (w *window) surfaceResource() *wayland.Surface {
67- if w.xdg != nil {
96+ switch {
97+ case w.xdg != nil:
6898 return w.xdg.surfaceResource()
99+ case w.x11 != nil && w.alive():
100+ return w.x11.surface.wl
69101 }
70102 return nil
71103 }
104+
105+// toplevelWindow is the toplevel window (xdg or X11) the surface is the role
106+// surface of, nil for other roles and popups' parents are not followed.
107+func (s *surface) toplevelWindow() *window {
108+ if s.xdg != nil && s.xdg.window != nil {
109+ return s.xdg.window
110+ }
111+ return s.x11win
112+}
@@ -10,26 +10,46 @@ import (
10 // them instead of dereferencing w.xdg, which is nil for windows of another10 // them instead of dereferencing w.xdg, which is nil for windows of another
11 // role.11 // role.
12 12
13-// isToplevel reports whether w is a toplevel (not a popup).13+// isToplevel reports whether w is a toplevel (xdg or X11), not a popup.
14-func (w *window) isToplevel() bool { return w.toplevel != nil }14+func (w *window) isToplevel() bool { return w.toplevel != nil || w.x11 != nil }
15+
16+// srv is the server the window belongs to.
17+func (w *window) srv() *Server {
18+ if w.xdg != nil {
19+ return w.xdg.server
20+ }
21+ return w.x11.server
22+}
15 23
16 // surf is the window's surface, nil when it has none.24 // surf is the window's surface, nil when it has none.
17 func (w *window) surf() *surface {25 func (w *window) surf() *surface {
18- if w.xdg != nil {26+ switch {
27+ case w.xdg != nil:
19 return w.xdg.surface28 return w.xdg.surface
29+ case w.x11 != nil:
30+ return w.x11.surface
20 }31 }
21 return nil32 return nil
22 }33 }
23 34
24 // alive reports whether the window's role object is alive.35 // alive reports whether the window's role object is alive.
25 func (w *window) alive() bool {36 func (w *window) alive() bool {
26- return w.xdg != nil && w.xdg.resource.Resource.Alive()37+ switch {
38+ case w.xdg != nil:
39+ return w.xdg.resource.Resource.Alive()
40+ case w.x11 != nil:
41+ return w.x11.surface != nil && w.x11.surface.wl.Resource.Alive()
42+ }
43+ return false
27 }44 }
28 45
29 // client is the window's client; the zero Client when it has no role object.46 // client is the window's client; the zero Client when it has no role object.
30 func (w *window) client() server.Client {47 func (w *window) client() server.Client {
31- if w.xdg != nil {48+ switch {
49+ case w.xdg != nil:
32 return w.xdg.resource.Client()50 return w.xdg.resource.Client()
51+ case w.x11 != nil && w.x11.surface != nil:
52+ return w.x11.surface.wl.Client()
33 }53 }
34 return server.Client{}54 return server.Client{}
35 }55 }
@@ -44,28 +64,49 @@ func (w *window) geometry() ports.Rect {
44 64
45 // sendConfigure sends w.last to the client.65 // sendConfigure sends w.last to the client.
46 func (w *window) sendConfigure() {66 func (w *window) sendConfigure() {
47- if w.xdg != nil {67+ switch {
68+ case w.xdg != nil:
48 w.sendXDGConfigure()69 w.sendXDGConfigure()
70+ case w.x11 != nil:
71+ w.x11.server.x11Configure(w)
49 }72 }
50 }73 }
51 74
52 // sendClose asks the client to close the window.75 // sendClose asks the client to close the window.
53 func (w *window) sendClose() {76 func (w *window) sendClose() {
54- if w.toplevel != nil {77+ switch {
78+ case w.toplevel != nil:
55 w.toplevel.SendClose()79 w.toplevel.SendClose()
80+ case w.x11 != nil:
81+ w.x11.server.x11Close(w)
56 }82 }
57 }83 }
58 84
59 // toplevelAlive reports whether the window is a toplevel whose protocol85 // toplevelAlive reports whether the window is a toplevel whose protocol
60 // object is alive.86 // object is alive.
61 func (w *window) toplevelAlive() bool {87 func (w *window) toplevelAlive() bool {
88+ if w.x11 != nil {
89+ return w.alive()
90+ }
62 return w.toplevel != nil && w.toplevel.Resource.Alive()91 return w.toplevel != nil && w.toplevel.Resource.Alive()
63 }92 }
64 93
65 // surfaceResource is the window's wl_surface, nil when it has none or it died.94 // surfaceResource is the window's wl_surface, nil when it has none or it died.
66 func (w *window) surfaceResource() *wayland.Surface {95 func (w *window) surfaceResource() *wayland.Surface {
67- if w.xdg != nil {96+ switch {
97+ case w.xdg != nil:
68 return w.xdg.surfaceResource()98 return w.xdg.surfaceResource()
99+ case w.x11 != nil && w.alive():
100+ return w.x11.surface.wl
69 }101 }
70 return nil102 return nil
71 }103 }
104+
105+// toplevelWindow is the toplevel window (xdg or X11) the surface is the role
106+// surface of, nil for other roles and popups' parents are not followed.
107+func (s *surface) toplevelWindow() *window {
108+ if s.xdg != nil && s.xdg.window != nil {
109+ return s.xdg.window
110+ }
111+ return s.x11win
112+}
M internal/adapters/wayland/wire_client_test.go +3 -0
@@ -55,6 +55,7 @@ import (
5555 wire_xdgforeign "github.com/bnema/go-wayland-bindings/server/xdgforeign"
5656 wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput"
5757 wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"
58+ wire_xwaylandshell "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
5859 wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
5960 "github.com/bnema/purego-libwayland/server"
6061 "github.com/bnema/wlturbo"
@@ -253,6 +254,8 @@ var wireSchemas = map[string]wireSchema{
253254 "zwp_text_input_v3": {wire_textinput.ZwpTextInputV3Interface, &client_textinput.TextInputV3{}, map[uint16]bool{0: true}, nil},
254255 "zwp_virtual_keyboard_manager_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardManagerV1Interface, &client_virtualkeyboard.VirtualKeyboardManager{}, nil, nil},
255256 "zwp_virtual_keyboard_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardV1Interface, &client_virtualkeyboard.VirtualKeyboard{}, map[uint16]bool{3: true}, nil},
257+ "xwayland_shell_v1": {wire_xwaylandshell.XwaylandShellV1Interface, nil, map[uint16]bool{0: true}, nil},
258+ "xwayland_surface_v1": {wire_xwaylandshell.XwaylandSurfaceV1Interface, nil, map[uint16]bool{1: true}, nil},
256259 "zxdg_output_manager_v1": {wire_xdgoutput.ZxdgOutputManagerV1Interface, &client_xdgoutput.ZxdgOutputManager{}, map[uint16]bool{0: true}, nil},
257260 "zxdg_output_v1": {wire_xdgoutput.ZxdgOutputV1Interface, &client_xdgoutput.ZxdgOutput{}, map[uint16]bool{0: true}, nil},
258261 }
@@ -55,6 +55,7 @@ import (
55 wire_xdgforeign "github.com/bnema/go-wayland-bindings/server/xdgforeign"55 wire_xdgforeign "github.com/bnema/go-wayland-bindings/server/xdgforeign"
56 wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput"56 wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput"
57 wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"57 wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"
58+ wire_xwaylandshell "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
58 wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"59 wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
59 "github.com/bnema/purego-libwayland/server"60 "github.com/bnema/purego-libwayland/server"
60 "github.com/bnema/wlturbo"61 "github.com/bnema/wlturbo"
@@ -253,6 +254,8 @@ var wireSchemas = map[string]wireSchema{
253 "zwp_text_input_v3": {wire_textinput.ZwpTextInputV3Interface, &client_textinput.TextInputV3{}, map[uint16]bool{0: true}, nil},254 "zwp_text_input_v3": {wire_textinput.ZwpTextInputV3Interface, &client_textinput.TextInputV3{}, map[uint16]bool{0: true}, nil},
254 "zwp_virtual_keyboard_manager_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardManagerV1Interface, &client_virtualkeyboard.VirtualKeyboardManager{}, nil, nil},255 "zwp_virtual_keyboard_manager_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardManagerV1Interface, &client_virtualkeyboard.VirtualKeyboardManager{}, nil, nil},
255 "zwp_virtual_keyboard_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardV1Interface, &client_virtualkeyboard.VirtualKeyboard{}, map[uint16]bool{3: true}, nil},256 "zwp_virtual_keyboard_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardV1Interface, &client_virtualkeyboard.VirtualKeyboard{}, map[uint16]bool{3: true}, nil},
257+ "xwayland_shell_v1": {wire_xwaylandshell.XwaylandShellV1Interface, nil, map[uint16]bool{0: true}, nil},
258+ "xwayland_surface_v1": {wire_xwaylandshell.XwaylandSurfaceV1Interface, nil, map[uint16]bool{1: true}, nil},
256 "zxdg_output_manager_v1": {wire_xdgoutput.ZxdgOutputManagerV1Interface, &client_xdgoutput.ZxdgOutputManager{}, map[uint16]bool{0: true}, nil},259 "zxdg_output_manager_v1": {wire_xdgoutput.ZxdgOutputManagerV1Interface, &client_xdgoutput.ZxdgOutputManager{}, map[uint16]bool{0: true}, nil},
257 "zxdg_output_v1": {wire_xdgoutput.ZxdgOutputV1Interface, &client_xdgoutput.ZxdgOutput{}, map[uint16]bool{0: true}, nil},260 "zxdg_output_v1": {wire_xdgoutput.ZxdgOutputV1Interface, &client_xdgoutput.ZxdgOutput{}, map[uint16]bool{0: true}, nil},
258 }261 }
A internal/adapters/wayland/x11peer_test.go +298 -0
@@ -0,0 +1,298 @@
1+package wayland
2+
3+import (
4+ "encoding/binary"
5+ "io"
6+ "os"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "golang.org/x/sys/unix"
12+)
13+
14+// x11Peer is a scripted X server speaking the real wire protocol over a
15+// socketpair: setup, extensions, atoms, properties, geometry. It records every
16+// request. It is a protocol peer, not a double of a project interface.
17+type x11Peer struct {
18+ t *testing.T
19+ f *os.File
20+ mu sync.Mutex
21+
22+ seq uint16
23+ reqs []x11Req
24+ atoms map[string]uint32
25+ nextAtom uint32
26+ props map[uint32]map[uint32][]byte // window -> atom -> typed data (type, format, data)
27+ propMeta map[uint32]map[uint32][2]uint32
28+ done chan struct{}
29+}
30+
31+type x11Req struct {
32+ major, minor uint8
33+ raw []byte
34+}
35+
36+func (r x11Req) u32(off int) uint32 { return binary.LittleEndian.Uint32(r.raw[off:]) }
37+
38+const (
39+ x11PeerRoot = 0x100
40+ x11Win = 0x500001
41+ // Opcodes the tests look for.
42+ x11OpConfigureWindow = 12
43+ x11OpSetInputFocus = 42
44+ x11OpKillClient = 113
45+)
46+
47+func startX11Peer(t *testing.T) (int, *x11Peer) {
48+ t.Helper()
49+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+ p := &x11Peer{
54+ t: t, f: os.NewFile(uintptr(fds[1]), "x11peer"), atoms: map[string]uint32{}, nextAtom: 1000,
55+ props: map[uint32]map[uint32][]byte{}, propMeta: map[uint32]map[uint32][2]uint32{}, done: make(chan struct{}),
56+ }
57+ t.Cleanup(func() { p.f.Close(); <-p.done })
58+ go p.serve()
59+ return fds[0], p
60+}
61+
62+func (p *x11Peer) setupReply() []byte {
63+ vendor := []byte("x11pr\x00\x00\x00")
64+ b := make([]byte, 40+len(vendor)+40)
65+ b[0], b[2] = 1, 11
66+ binary.LittleEndian.PutUint16(b[6:], uint16((len(b)-8)/4))
67+ binary.LittleEndian.PutUint32(b[12:], 0x200000)
68+ binary.LittleEndian.PutUint32(b[16:], 0x1fffff)
69+ binary.LittleEndian.PutUint16(b[24:], 5)
70+ b[28] = 1
71+ copy(b[40:], vendor)
72+ scr := b[40+len(vendor):]
73+ binary.LittleEndian.PutUint32(scr, x11PeerRoot)
74+ binary.LittleEndian.PutUint32(scr[32:], 0x22)
75+ scr[38] = 24
76+ return b
77+}
78+
79+func (p *x11Peer) serve() {
80+ defer close(p.done)
81+ var hdr [12]byte
82+ if _, err := io.ReadFull(p.f, hdr[:]); err != nil {
83+ return
84+ }
85+ if _, err := p.f.Write(p.setupReply()); err != nil {
86+ return
87+ }
88+ for {
89+ var h [4]byte
90+ if _, err := io.ReadFull(p.f, h[:]); err != nil {
91+ return
92+ }
93+ raw := h[:]
94+ n := int(binary.LittleEndian.Uint16(h[2:])) * 4
95+ if n == 0 {
96+ var l [4]byte
97+ if _, err := io.ReadFull(p.f, l[:]); err != nil {
98+ return
99+ }
100+ raw = append(raw, l[:]...)
101+ n = int(binary.LittleEndian.Uint32(l[:])) * 4
102+ }
103+ rest := make([]byte, n-len(raw))
104+ if _, err := io.ReadFull(p.f, rest); err != nil {
105+ return
106+ }
107+ raw = append(append([]byte(nil), raw...), rest...)
108+ p.mu.Lock()
109+ p.seq++
110+ r := x11Req{h[0], h[1], raw}
111+ p.reqs = append(p.reqs, r)
112+ p.answer(r, raw[len(raw)-len(rest):])
113+ p.mu.Unlock()
114+ }
115+}
116+
117+func (p *x11Peer) reply(data uint8, body []byte) {
118+ words := (max(len(body), 24) - 24 + 3) / 4
119+ b := make([]byte, 32+4*words)
120+ b[0], b[1] = 1, data
121+ binary.LittleEndian.PutUint16(b[2:], p.seq)
122+ binary.LittleEndian.PutUint32(b[4:], uint32(words))
123+ copy(b[8:], body)
124+ _, _ = p.f.Write(b)
125+}
126+
127+func x11le32(v ...uint32) []byte {
128+ b := make([]byte, 0, 4*len(v))
129+ for _, x := range v {
130+ b = binary.LittleEndian.AppendUint32(b, x)
131+ }
132+ return b
133+}
134+
135+var x11Exts = map[string]uint8{"BIG-REQUESTS": 133, "XC-MISC": 136, "Composite": 142}
136+
137+func (p *x11Peer) answer(r x11Req, body []byte) {
138+ switch {
139+ case r.major == 98: // QueryExtension
140+ major, ok := x11Exts[string(body[4:4+binary.LittleEndian.Uint16(body)])]
141+ present := uint8(0)
142+ if ok {
143+ present = 1
144+ }
145+ p.reply(0, []byte{present, major, 0, 0})
146+ case r.major == 16: // InternAtom
147+ name := string(body[4 : 4+binary.LittleEndian.Uint16(body)])
148+ a, ok := p.atoms[name]
149+ if !ok {
150+ p.nextAtom++
151+ a = p.nextAtom
152+ p.atoms[name] = a
153+ }
154+ p.reply(0, x11le32(a))
155+ case r.major == 43: // GetInputFocus (Sync)
156+ p.reply(1, x11le32(0))
157+ case r.major == 15: // QueryTree
158+ b := make([]byte, 24)
159+ binary.LittleEndian.PutUint32(b, x11PeerRoot)
160+ p.reply(0, b)
161+ case r.major == 14: // GetGeometry
162+ b := make([]byte, 24)
163+ binary.LittleEndian.PutUint32(b, x11PeerRoot)
164+ binary.LittleEndian.PutUint16(b[8:], 300)
165+ binary.LittleEndian.PutUint16(b[10:], 200)
166+ p.reply(24, b)
167+ case r.major == 18: // ChangeProperty
168+ win, prop := binary.LittleEndian.Uint32(body), binary.LittleEndian.Uint32(body[4:])
169+ format, n := body[12], int(binary.LittleEndian.Uint32(body[16:]))
170+ p.store(win, prop, binary.LittleEndian.Uint32(body[8:]), uint32(format), body[20:20+n*int(format)/8])
171+ case r.major == 20: // GetProperty
172+ win, prop := binary.LittleEndian.Uint32(body), binary.LittleEndian.Uint32(body[4:])
173+ data, ok := p.props[win][prop]
174+ b := make([]byte, 24+(len(data)+3)&^3)
175+ var format uint8
176+ if ok {
177+ meta := p.propMeta[win][prop]
178+ format = uint8(meta[1])
179+ binary.LittleEndian.PutUint32(b[0:], meta[0])
180+ binary.LittleEndian.PutUint32(b[8:], uint32(len(data)/(int(format)/8)))
181+ copy(b[24:], data)
182+ }
183+ p.reply(format, b)
184+ case r.major >= 128:
185+ switch r.major {
186+ case 133:
187+ p.reply(0, x11le32(0x100000))
188+ case 136:
189+ if r.minor == 1 {
190+ p.reply(0, x11le32(0, 1))
191+ }
192+ case 142:
193+ if r.minor == 0 {
194+ p.reply(0, x11le32(0, 4))
195+ }
196+ }
197+ }
198+}
199+
200+func (p *x11Peer) store(win, prop, typ, format uint32, data []byte) {
201+ if p.props[win] == nil {
202+ p.props[win], p.propMeta[win] = map[uint32][]byte{}, map[uint32][2]uint32{}
203+ }
204+ p.props[win][prop] = append([]byte(nil), data...)
205+ p.propMeta[win][prop] = [2]uint32{typ, format}
206+}
207+
208+// atom interns name the way the XWM would have.
209+func (p *x11Peer) atom(name string) uint32 {
210+ p.mu.Lock()
211+ defer p.mu.Unlock()
212+ a, ok := p.atoms[name]
213+ if !ok {
214+ p.nextAtom++
215+ a = p.nextAtom
216+ p.atoms[name] = a
217+ }
218+ return a
219+}
220+
221+// setProp scripts a window property (typ is an atom number, format 8 or 32).
222+func (p *x11Peer) setProp(win, prop, typ, format uint32, data []byte) {
223+ p.mu.Lock()
224+ defer p.mu.Unlock()
225+ p.store(win, prop, typ, format, data)
226+}
227+
228+func (p *x11Peer) event(code uint8, fill func(b *[32]byte)) {
229+ p.mu.Lock()
230+ defer p.mu.Unlock()
231+ var b [32]byte
232+ b[0] = code
233+ binary.LittleEndian.PutUint16(b[2:], p.seq)
234+ fill(&b)
235+ _, _ = p.f.Write(b[:])
236+}
237+
238+func (p *x11Peer) createNotify(win uint32) {
239+ p.event(16, func(b *[32]byte) {
240+ binary.LittleEndian.PutUint32(b[4:], x11PeerRoot)
241+ binary.LittleEndian.PutUint32(b[8:], win)
242+ binary.LittleEndian.PutUint16(b[16:], 100)
243+ binary.LittleEndian.PutUint16(b[18:], 50)
244+ })
245+}
246+
247+func (p *x11Peer) windowEvent(code uint8, win uint32) {
248+ p.event(code, func(b *[32]byte) {
249+ binary.LittleEndian.PutUint32(b[4:], x11PeerRoot)
250+ binary.LittleEndian.PutUint32(b[8:], win)
251+ })
252+}
253+
254+func (p *x11Peer) mapRequest(win uint32) { p.windowEvent(20, win) }
255+func (p *x11Peer) unmapNotify(win uint32) { p.windowEvent(18, win) }
256+func (p *x11Peer) destroyNotify(win uint32) { p.windowEvent(17, win) }
257+
258+func (p *x11Peer) propertyNotify(win, atom uint32) {
259+ p.event(28, func(b *[32]byte) {
260+ binary.LittleEndian.PutUint32(b[4:], win)
261+ binary.LittleEndian.PutUint32(b[8:], atom)
262+ })
263+}
264+
265+func (p *x11Peer) clientMessage(win uint32, typ string, data ...uint32) {
266+ t := p.atom(typ)
267+ p.event(33, func(b *[32]byte) {
268+ b[1] = 32
269+ binary.LittleEndian.PutUint32(b[4:], win)
270+ binary.LittleEndian.PutUint32(b[8:], t)
271+ for i, d := range data {
272+ binary.LittleEndian.PutUint32(b[12+4*i:], d)
273+ }
274+ })
275+}
276+
277+func (p *x11Peer) requests() []x11Req {
278+ p.mu.Lock()
279+ defer p.mu.Unlock()
280+ return append([]x11Req(nil), p.reqs...)
281+}
282+
283+// waitReq waits for a request after the first skip ones that satisfies match.
284+func (p *x11Peer) waitReq(skip int, match func(x11Req) bool) (x11Req, int) {
285+ p.t.Helper()
286+ deadline := time.Now().Add(5 * time.Second)
287+ for time.Now().Before(deadline) {
288+ reqs := p.requests()
289+ for i := skip; i < len(reqs); i++ {
290+ if match(reqs[i]) {
291+ return reqs[i], i + 1
292+ }
293+ }
294+ time.Sleep(time.Millisecond)
295+ }
296+ p.t.Fatal("x11 peer: expected request never arrived")
297+ return x11Req{}, 0
298+}
@@ -0,0 +1,298 @@
1+package wayland
2+
3+import (
4+ "encoding/binary"
5+ "io"
6+ "os"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "golang.org/x/sys/unix"
12+)
13+
14+// x11Peer is a scripted X server speaking the real wire protocol over a
15+// socketpair: setup, extensions, atoms, properties, geometry. It records every
16+// request. It is a protocol peer, not a double of a project interface.
17+type x11Peer struct {
18+ t *testing.T
19+ f *os.File
20+ mu sync.Mutex
21+
22+ seq uint16
23+ reqs []x11Req
24+ atoms map[string]uint32
25+ nextAtom uint32
26+ props map[uint32]map[uint32][]byte // window -> atom -> typed data (type, format, data)
27+ propMeta map[uint32]map[uint32][2]uint32
28+ done chan struct{}
29+}
30+
31+type x11Req struct {
32+ major, minor uint8
33+ raw []byte
34+}
35+
36+func (r x11Req) u32(off int) uint32 { return binary.LittleEndian.Uint32(r.raw[off:]) }
37+
38+const (
39+ x11PeerRoot = 0x100
40+ x11Win = 0x500001
41+ // Opcodes the tests look for.
42+ x11OpConfigureWindow = 12
43+ x11OpSetInputFocus = 42
44+ x11OpKillClient = 113
45+)
46+
47+func startX11Peer(t *testing.T) (int, *x11Peer) {
48+ t.Helper()
49+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+ p := &x11Peer{
54+ t: t, f: os.NewFile(uintptr(fds[1]), "x11peer"), atoms: map[string]uint32{}, nextAtom: 1000,
55+ props: map[uint32]map[uint32][]byte{}, propMeta: map[uint32]map[uint32][2]uint32{}, done: make(chan struct{}),
56+ }
57+ t.Cleanup(func() { p.f.Close(); <-p.done })
58+ go p.serve()
59+ return fds[0], p
60+}
61+
62+func (p *x11Peer) setupReply() []byte {
63+ vendor := []byte("x11pr\x00\x00\x00")
64+ b := make([]byte, 40+len(vendor)+40)
65+ b[0], b[2] = 1, 11
66+ binary.LittleEndian.PutUint16(b[6:], uint16((len(b)-8)/4))
67+ binary.LittleEndian.PutUint32(b[12:], 0x200000)
68+ binary.LittleEndian.PutUint32(b[16:], 0x1fffff)
69+ binary.LittleEndian.PutUint16(b[24:], 5)
70+ b[28] = 1
71+ copy(b[40:], vendor)
72+ scr := b[40+len(vendor):]
73+ binary.LittleEndian.PutUint32(scr, x11PeerRoot)
74+ binary.LittleEndian.PutUint32(scr[32:], 0x22)
75+ scr[38] = 24
76+ return b
77+}
78+
79+func (p *x11Peer) serve() {
80+ defer close(p.done)
81+ var hdr [12]byte
82+ if _, err := io.ReadFull(p.f, hdr[:]); err != nil {
83+ return
84+ }
85+ if _, err := p.f.Write(p.setupReply()); err != nil {
86+ return
87+ }
88+ for {
89+ var h [4]byte
90+ if _, err := io.ReadFull(p.f, h[:]); err != nil {
91+ return
92+ }
93+ raw := h[:]
94+ n := int(binary.LittleEndian.Uint16(h[2:])) * 4
95+ if n == 0 {
96+ var l [4]byte
97+ if _, err := io.ReadFull(p.f, l[:]); err != nil {
98+ return
99+ }
100+ raw = append(raw, l[:]...)
101+ n = int(binary.LittleEndian.Uint32(l[:])) * 4
102+ }
103+ rest := make([]byte, n-len(raw))
104+ if _, err := io.ReadFull(p.f, rest); err != nil {
105+ return
106+ }
107+ raw = append(append([]byte(nil), raw...), rest...)
108+ p.mu.Lock()
109+ p.seq++
110+ r := x11Req{h[0], h[1], raw}
111+ p.reqs = append(p.reqs, r)
112+ p.answer(r, raw[len(raw)-len(rest):])
113+ p.mu.Unlock()
114+ }
115+}
116+
117+func (p *x11Peer) reply(data uint8, body []byte) {
118+ words := (max(len(body), 24) - 24 + 3) / 4
119+ b := make([]byte, 32+4*words)
120+ b[0], b[1] = 1, data
121+ binary.LittleEndian.PutUint16(b[2:], p.seq)
122+ binary.LittleEndian.PutUint32(b[4:], uint32(words))
123+ copy(b[8:], body)
124+ _, _ = p.f.Write(b)
125+}
126+
127+func x11le32(v ...uint32) []byte {
128+ b := make([]byte, 0, 4*len(v))
129+ for _, x := range v {
130+ b = binary.LittleEndian.AppendUint32(b, x)
131+ }
132+ return b
133+}
134+
135+var x11Exts = map[string]uint8{"BIG-REQUESTS": 133, "XC-MISC": 136, "Composite": 142}
136+
137+func (p *x11Peer) answer(r x11Req, body []byte) {
138+ switch {
139+ case r.major == 98: // QueryExtension
140+ major, ok := x11Exts[string(body[4:4+binary.LittleEndian.Uint16(body)])]
141+ present := uint8(0)
142+ if ok {
143+ present = 1
144+ }
145+ p.reply(0, []byte{present, major, 0, 0})
146+ case r.major == 16: // InternAtom
147+ name := string(body[4 : 4+binary.LittleEndian.Uint16(body)])
148+ a, ok := p.atoms[name]
149+ if !ok {
150+ p.nextAtom++
151+ a = p.nextAtom
152+ p.atoms[name] = a
153+ }
154+ p.reply(0, x11le32(a))
155+ case r.major == 43: // GetInputFocus (Sync)
156+ p.reply(1, x11le32(0))
157+ case r.major == 15: // QueryTree
158+ b := make([]byte, 24)
159+ binary.LittleEndian.PutUint32(b, x11PeerRoot)
160+ p.reply(0, b)
161+ case r.major == 14: // GetGeometry
162+ b := make([]byte, 24)
163+ binary.LittleEndian.PutUint32(b, x11PeerRoot)
164+ binary.LittleEndian.PutUint16(b[8:], 300)
165+ binary.LittleEndian.PutUint16(b[10:], 200)
166+ p.reply(24, b)
167+ case r.major == 18: // ChangeProperty
168+ win, prop := binary.LittleEndian.Uint32(body), binary.LittleEndian.Uint32(body[4:])
169+ format, n := body[12], int(binary.LittleEndian.Uint32(body[16:]))
170+ p.store(win, prop, binary.LittleEndian.Uint32(body[8:]), uint32(format), body[20:20+n*int(format)/8])
171+ case r.major == 20: // GetProperty
172+ win, prop := binary.LittleEndian.Uint32(body), binary.LittleEndian.Uint32(body[4:])
173+ data, ok := p.props[win][prop]
174+ b := make([]byte, 24+(len(data)+3)&^3)
175+ var format uint8
176+ if ok {
177+ meta := p.propMeta[win][prop]
178+ format = uint8(meta[1])
179+ binary.LittleEndian.PutUint32(b[0:], meta[0])
180+ binary.LittleEndian.PutUint32(b[8:], uint32(len(data)/(int(format)/8)))
181+ copy(b[24:], data)
182+ }
183+ p.reply(format, b)
184+ case r.major >= 128:
185+ switch r.major {
186+ case 133:
187+ p.reply(0, x11le32(0x100000))
188+ case 136:
189+ if r.minor == 1 {
190+ p.reply(0, x11le32(0, 1))
191+ }
192+ case 142:
193+ if r.minor == 0 {
194+ p.reply(0, x11le32(0, 4))
195+ }
196+ }
197+ }
198+}
199+
200+func (p *x11Peer) store(win, prop, typ, format uint32, data []byte) {
201+ if p.props[win] == nil {
202+ p.props[win], p.propMeta[win] = map[uint32][]byte{}, map[uint32][2]uint32{}
203+ }
204+ p.props[win][prop] = append([]byte(nil), data...)
205+ p.propMeta[win][prop] = [2]uint32{typ, format}
206+}
207+
208+// atom interns name the way the XWM would have.
209+func (p *x11Peer) atom(name string) uint32 {
210+ p.mu.Lock()
211+ defer p.mu.Unlock()
212+ a, ok := p.atoms[name]
213+ if !ok {
214+ p.nextAtom++
215+ a = p.nextAtom
216+ p.atoms[name] = a
217+ }
218+ return a
219+}
220+
221+// setProp scripts a window property (typ is an atom number, format 8 or 32).
222+func (p *x11Peer) setProp(win, prop, typ, format uint32, data []byte) {
223+ p.mu.Lock()
224+ defer p.mu.Unlock()
225+ p.store(win, prop, typ, format, data)
226+}
227+
228+func (p *x11Peer) event(code uint8, fill func(b *[32]byte)) {
229+ p.mu.Lock()
230+ defer p.mu.Unlock()
231+ var b [32]byte
232+ b[0] = code
233+ binary.LittleEndian.PutUint16(b[2:], p.seq)
234+ fill(&b)
235+ _, _ = p.f.Write(b[:])
236+}
237+
238+func (p *x11Peer) createNotify(win uint32) {
239+ p.event(16, func(b *[32]byte) {
240+ binary.LittleEndian.PutUint32(b[4:], x11PeerRoot)
241+ binary.LittleEndian.PutUint32(b[8:], win)
242+ binary.LittleEndian.PutUint16(b[16:], 100)
243+ binary.LittleEndian.PutUint16(b[18:], 50)
244+ })
245+}
246+
247+func (p *x11Peer) windowEvent(code uint8, win uint32) {
248+ p.event(code, func(b *[32]byte) {
249+ binary.LittleEndian.PutUint32(b[4:], x11PeerRoot)
250+ binary.LittleEndian.PutUint32(b[8:], win)
251+ })
252+}
253+
254+func (p *x11Peer) mapRequest(win uint32) { p.windowEvent(20, win) }
255+func (p *x11Peer) unmapNotify(win uint32) { p.windowEvent(18, win) }
256+func (p *x11Peer) destroyNotify(win uint32) { p.windowEvent(17, win) }
257+
258+func (p *x11Peer) propertyNotify(win, atom uint32) {
259+ p.event(28, func(b *[32]byte) {
260+ binary.LittleEndian.PutUint32(b[4:], win)
261+ binary.LittleEndian.PutUint32(b[8:], atom)
262+ })
263+}
264+
265+func (p *x11Peer) clientMessage(win uint32, typ string, data ...uint32) {
266+ t := p.atom(typ)
267+ p.event(33, func(b *[32]byte) {
268+ b[1] = 32
269+ binary.LittleEndian.PutUint32(b[4:], win)
270+ binary.LittleEndian.PutUint32(b[8:], t)
271+ for i, d := range data {
272+ binary.LittleEndian.PutUint32(b[12+4*i:], d)
273+ }
274+ })
275+}
276+
277+func (p *x11Peer) requests() []x11Req {
278+ p.mu.Lock()
279+ defer p.mu.Unlock()
280+ return append([]x11Req(nil), p.reqs...)
281+}
282+
283+// waitReq waits for a request after the first skip ones that satisfies match.
284+func (p *x11Peer) waitReq(skip int, match func(x11Req) bool) (x11Req, int) {
285+ p.t.Helper()
286+ deadline := time.Now().Add(5 * time.Second)
287+ for time.Now().Before(deadline) {
288+ reqs := p.requests()
289+ for i := skip; i < len(reqs); i++ {
290+ if match(reqs[i]) {
291+ return reqs[i], i + 1
292+ }
293+ }
294+ time.Sleep(time.Millisecond)
295+ }
296+ p.t.Fatal("x11 peer: expected request never arrived")
297+ return x11Req{}, 0
298+}
A internal/adapters/wayland/x11window.go +298 -0
@@ -0,0 +1,298 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/go-wayland-bindings/server/wayland"
5+ "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
6+ "github.com/bnema/neferwl/internal/adapters/wayland/xwm"
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/purego-libwayland/server"
9+)
10+
11+// x11Window is the X11 role of a window: the XWM slot, the window's X id and
12+// the wl_surface Xwayland paired with it. Display goroutine only.
13+type x11Window struct {
14+ server *Server
15+ slot xwm.Slot
16+ xid uint32
17+ surface *surface
18+ info xwm.WindowInfo
19+ // ready is set once the XWM read the window's properties (Ready).
20+ ready bool
21+}
22+
23+// xwaylandRestricted are the globals only the Xwayland client may see.
24+func xwaylandRestricted(iface string) bool {
25+ return iface == "xwayland_shell_v1" || iface == "zwp_xwayland_keyboard_grab_manager_v1"
26+}
27+
28+// globalVisible is the display's global filter: the Xwayland-only globals
29+// are visible to, and bindable by, the Xwayland client alone.
30+func (s *Server) globalVisible(c server.Client, g *server.Global) bool {
31+ if xwaylandRestricted(g.Interface()) {
32+ return s.xwayland.client != (server.Client{}) && c == s.xwayland.client
33+ }
34+ return true
35+}
36+
37+func registerXwaylandShell(d *server.Display, s *Server) error {
38+ return xwaylandshell.NewXwaylandShellV1Global(d, 1, func(c server.Client, v, id uint32) {
39+ _, _ = xwaylandshell.NewXwaylandShellV1(c, int32(v), id, xwaylandShell{s})
40+ })
41+}
42+
43+type xwaylandShell struct{ s *Server }
44+
45+func (xwaylandShell) Destroy(*xwaylandshell.XwaylandShellV1) {}
46+
47+func (h xwaylandShell) GetXwaylandSurface(r *xwaylandshell.XwaylandShellV1, id uint32, w *wayland.Surface) {
48+ surf := h.s.surfaceOf(w)
49+ if surf == nil {
50+ return
51+ }
52+ if surf.kind != roleNone {
53+ r.PostError(uint32(xwaylandshell.XwaylandShellV1ErrorRole), "surface already has a role")
54+ return
55+ }
56+ if _, err := xwaylandshell.NewXwaylandSurfaceV1(r.Client(), r.Version(), id, xwaylandSurface{h.s, surf}); err != nil {
57+ return
58+ }
59+ surf.kind = roleX11
60+}
61+
62+type xwaylandSurface struct {
63+ s *Server
64+ surf *surface
65+}
66+
67+func (x xwaylandSurface) SetSerial(r *xwaylandshell.XwaylandSurfaceV1, lo, hi uint32) {
68+ serial := uint64(hi)<<32 | uint64(lo)
69+ switch {
70+ case x.surf.x11Serial != 0:
71+ r.PostError(uint32(xwaylandshell.XwaylandSurfaceV1ErrorAlreadyAssociated), "serial already set")
72+ case serial == 0:
73+ r.PostError(uint32(xwaylandshell.XwaylandSurfaceV1ErrorInvalidSerial), "serial is zero")
74+ default:
75+ x.surf.x11Serial = serial
76+ if x.s.xwm != nil {
77+ x.s.xwm.SetSerial(x.surf.identity, serial)
78+ }
79+ }
80+}
81+
82+func (xwaylandSurface) Destroy(*xwaylandshell.XwaylandSurfaceV1) {}
83+
84+// installXWM makes x the server's window manager. Display goroutine only.
85+func (s *Server) installXWM(x *xwm.XWM) {
86+ s.teardownXWM()
87+ s.xwm = x
88+ if err := x.Attach(s.display, hostImpl{s}); err != nil {
89+ s.log.Warn().Err(err).Msg("X11 window management disabled")
90+ s.xwm = nil
91+ x.CloseConn()
92+ return
93+ }
94+ // Serials sent before the XWM was attached pair now.
95+ for _, surf := range s.surfaces {
96+ if surf.kind == roleX11 && surf.x11Serial != 0 && surf.x11win == nil {
97+ x.SetSerial(surf.identity, surf.x11Serial)
98+ }
99+ }
100+}
101+
102+// teardownXWM closes the window manager, dropping its windows. Idempotent.
103+func (s *Server) teardownXWM() {
104+ if x := s.xwm; x != nil {
105+ s.xwm = nil
106+ x.Close()
107+ }
108+}
109+
110+// x11Configure forwards the latest core configure of an X11 window.
111+func (s *Server) x11Configure(w *window) {
112+ if s.xwm != nil {
113+ s.xwm.Configure(w.x11.slot, w.last)
114+ }
115+}
116+
117+// x11Close asks an X11 window to close.
118+func (s *Server) x11Close(w *window) {
119+ if s.xwm != nil {
120+ s.xwm.CloseWindow(w.x11.slot)
121+ }
122+}
123+
124+// x11Place forwards core's position of an X11 window's client rect.
125+func (s *Server) x11Place(c ports.PlaceWindow) {
126+ if w := s.windows[c.ID]; w != nil && w.x11 != nil && s.xwm != nil {
127+ s.xwm.Place(w.x11.slot, c.X, c.Y)
128+ }
129+}
130+
131+// x11Focus tells the XWM which X window holds the keyboard focus, none when a
132+// native window does.
133+func (s *Server) x11Focus() {
134+ if s.xwm == nil {
135+ return
136+ }
137+ var slot xwm.Slot
138+ if w := s.windows[s.seat.focused]; w != nil && w.x11 != nil {
139+ slot = w.x11.slot
140+ }
141+ s.xwm.Focus(slot)
142+}
143+
144+// hostImpl is the server as the XWM sees it.
145+type hostImpl struct{ s *Server }
146+
147+func (h hostImpl) window(slot xwm.Slot) *window { return h.s.x11Slots[slot] }
148+
149+func (h hostImpl) Created(slot xwm.Slot, xid uint32) ports.WindowID {
150+ s := h.s
151+ w := &window{id: s.nextWindow}
152+ w.x11 = &x11Window{server: s, slot: slot, xid: xid}
153+ s.nextWindow++
154+ s.windows[w.id] = w
155+ s.x11Slots[slot] = w
156+ return w.id
157+}
158+
159+func (h hostImpl) Ready(slot xwm.Slot, info xwm.WindowInfo) {
160+ w := h.window(slot)
161+ if w == nil {
162+ return
163+ }
164+ w.x11.info, w.x11.ready = info, true
165+ w.title, w.appID, w.modal = info.Title, info.AppID, info.Modal
166+ w.parent = h.window(info.Parent)
167+ // A surface associated and committed earlier maps now.
168+ w.x11.tryMap()
169+}
170+
171+func (h hostImpl) Associate(slot xwm.Slot, surfaceID uint64) {
172+ w := h.window(slot)
173+ surf := h.s.surfaceByIdentity[surfaceID]
174+ if w == nil || surf == nil || surf.kind != roleX11 {
175+ return
176+ }
177+ x := w.x11
178+ x.drop()
179+ x.surface, surf.x11win = surf, w
180+ surf.role = func(buffer bool) {
181+ switch {
182+ case surf.destroyed:
183+ w.unmap()
184+ if x.surface == surf {
185+ x.surface = nil
186+ }
187+ surf.x11win = nil
188+ case buffer:
189+ x.tryMap()
190+ case w.mapped:
191+ w.unmap()
192+ }
193+ }
194+ // The buffer may have been committed before the pairing.
195+ x.tryMap()
196+}
197+
198+func (h hostImpl) Dissociate(slot xwm.Slot, destroyed bool) {
199+ w := h.window(slot)
200+ if w == nil {
201+ return
202+ }
203+ w.unmap()
204+ w.x11.drop()
205+ w.x11.ready = false
206+ if destroyed {
207+ delete(h.s.windows, w.id)
208+ delete(h.s.x11Slots, slot)
209+ h.s.foreignToplevelGone(w)
210+ }
211+}
212+
213+func (h hostImpl) Title(slot xwm.Slot, title string) {
214+ if w := h.window(slot); w != nil && w.title != title {
215+ w.title = title
216+ h.s.toplevelChanged(w)
217+ if w.mapped {
218+ h.s.castInventoryChanged()
219+ }
220+ }
221+}
222+
223+func (h hostImpl) AppID(slot xwm.Slot, appID string) {
224+ w := h.window(slot)
225+ if w == nil || w.appID == appID {
226+ return
227+ }
228+ w.appID = appID
229+ if w.mapped {
230+ h.s.emit(ports.WindowAppID{ID: w.id, AppID: appID})
231+ h.s.toplevelChanged(w)
232+ h.s.castInventoryChanged()
233+ }
234+}
235+
236+func (h hostImpl) Parent(slot xwm.Slot, parent xwm.Slot) {
237+ if w := h.window(slot); w != nil {
238+ w.setParent(h.window(parent))
239+ }
240+}
241+
242+func (h hostImpl) Fullscreen(slot xwm.Slot, on bool) {
243+ w := h.window(slot)
244+ if w == nil {
245+ return
246+ }
247+ w.x11.info.Fullscreen = on
248+ if w.mapped {
249+ h.s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: on})
250+ }
251+}
252+
253+// drop unlinks the window from its surface.
254+func (x *x11Window) drop() {
255+ if surf := x.surface; surf != nil {
256+ surf.x11win, surf.role = nil, nil
257+ x.surface = nil
258+ }
259+}
260+
261+// tryMap maps the window once the XWM knows it and its surface shows a buffer.
262+func (x *x11Window) tryMap() {
263+ surf := x.surface
264+ if surf == nil {
265+ return
266+ }
267+ w := surf.x11win
268+ if w == nil || w.mapped || !x.ready || surf.destroyed || surf.current == nil {
269+ return
270+ }
271+ s := x.server
272+ w.mapped = true
273+ w.gen++
274+ w.floating = x.info.Floating
275+ w.floatW, w.floatH = 0, 0
276+ if w.floating {
277+ w.floatW, w.floatH = x.info.Width, x.info.Height
278+ }
279+ var parent ports.WindowID
280+ if w.parent != nil && w.parent.mapped {
281+ parent = w.parent.id
282+ }
283+ s.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, PID: x.info.PID, Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Positioned: true, Modal: w.modal, Remap: w.gen > 1})
284+ if x.info.Fullscreen {
285+ s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: true})
286+ }
287+ s.syncInhibitors()
288+ s.toplevelChanged(w)
289+ s.castInventoryChanged()
290+ s.log.Info().Uint64("id", uint64(w.id)).Uint32("xid", x.xid).Str("app_id", w.appID).Bool("floating", w.floating).Msg("x11 window mapped")
291+ // Called from a commit, the content follows right after; called from the
292+ // pairing, the surface already shows a buffer and is published now.
293+ if surf.has {
294+ surf.resetInputEmission()
295+ surf.redraw()
296+ surf.emitInput()
297+ }
298+}
@@ -0,0 +1,298 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/go-wayland-bindings/server/wayland"
5+ "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
6+ "github.com/bnema/neferwl/internal/adapters/wayland/xwm"
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/purego-libwayland/server"
9+)
10+
11+// x11Window is the X11 role of a window: the XWM slot, the window's X id and
12+// the wl_surface Xwayland paired with it. Display goroutine only.
13+type x11Window struct {
14+ server *Server
15+ slot xwm.Slot
16+ xid uint32
17+ surface *surface
18+ info xwm.WindowInfo
19+ // ready is set once the XWM read the window's properties (Ready).
20+ ready bool
21+}
22+
23+// xwaylandRestricted are the globals only the Xwayland client may see.
24+func xwaylandRestricted(iface string) bool {
25+ return iface == "xwayland_shell_v1" || iface == "zwp_xwayland_keyboard_grab_manager_v1"
26+}
27+
28+// globalVisible is the display's global filter: the Xwayland-only globals
29+// are visible to, and bindable by, the Xwayland client alone.
30+func (s *Server) globalVisible(c server.Client, g *server.Global) bool {
31+ if xwaylandRestricted(g.Interface()) {
32+ return s.xwayland.client != (server.Client{}) && c == s.xwayland.client
33+ }
34+ return true
35+}
36+
37+func registerXwaylandShell(d *server.Display, s *Server) error {
38+ return xwaylandshell.NewXwaylandShellV1Global(d, 1, func(c server.Client, v, id uint32) {
39+ _, _ = xwaylandshell.NewXwaylandShellV1(c, int32(v), id, xwaylandShell{s})
40+ })
41+}
42+
43+type xwaylandShell struct{ s *Server }
44+
45+func (xwaylandShell) Destroy(*xwaylandshell.XwaylandShellV1) {}
46+
47+func (h xwaylandShell) GetXwaylandSurface(r *xwaylandshell.XwaylandShellV1, id uint32, w *wayland.Surface) {
48+ surf := h.s.surfaceOf(w)
49+ if surf == nil {
50+ return
51+ }
52+ if surf.kind != roleNone {
53+ r.PostError(uint32(xwaylandshell.XwaylandShellV1ErrorRole), "surface already has a role")
54+ return
55+ }
56+ if _, err := xwaylandshell.NewXwaylandSurfaceV1(r.Client(), r.Version(), id, xwaylandSurface{h.s, surf}); err != nil {
57+ return
58+ }
59+ surf.kind = roleX11
60+}
61+
62+type xwaylandSurface struct {
63+ s *Server
64+ surf *surface
65+}
66+
67+func (x xwaylandSurface) SetSerial(r *xwaylandshell.XwaylandSurfaceV1, lo, hi uint32) {
68+ serial := uint64(hi)<<32 | uint64(lo)
69+ switch {
70+ case x.surf.x11Serial != 0:
71+ r.PostError(uint32(xwaylandshell.XwaylandSurfaceV1ErrorAlreadyAssociated), "serial already set")
72+ case serial == 0:
73+ r.PostError(uint32(xwaylandshell.XwaylandSurfaceV1ErrorInvalidSerial), "serial is zero")
74+ default:
75+ x.surf.x11Serial = serial
76+ if x.s.xwm != nil {
77+ x.s.xwm.SetSerial(x.surf.identity, serial)
78+ }
79+ }
80+}
81+
82+func (xwaylandSurface) Destroy(*xwaylandshell.XwaylandSurfaceV1) {}
83+
84+// installXWM makes x the server's window manager. Display goroutine only.
85+func (s *Server) installXWM(x *xwm.XWM) {
86+ s.teardownXWM()
87+ s.xwm = x
88+ if err := x.Attach(s.display, hostImpl{s}); err != nil {
89+ s.log.Warn().Err(err).Msg("X11 window management disabled")
90+ s.xwm = nil
91+ x.CloseConn()
92+ return
93+ }
94+ // Serials sent before the XWM was attached pair now.
95+ for _, surf := range s.surfaces {
96+ if surf.kind == roleX11 && surf.x11Serial != 0 && surf.x11win == nil {
97+ x.SetSerial(surf.identity, surf.x11Serial)
98+ }
99+ }
100+}
101+
102+// teardownXWM closes the window manager, dropping its windows. Idempotent.
103+func (s *Server) teardownXWM() {
104+ if x := s.xwm; x != nil {
105+ s.xwm = nil
106+ x.Close()
107+ }
108+}
109+
110+// x11Configure forwards the latest core configure of an X11 window.
111+func (s *Server) x11Configure(w *window) {
112+ if s.xwm != nil {
113+ s.xwm.Configure(w.x11.slot, w.last)
114+ }
115+}
116+
117+// x11Close asks an X11 window to close.
118+func (s *Server) x11Close(w *window) {
119+ if s.xwm != nil {
120+ s.xwm.CloseWindow(w.x11.slot)
121+ }
122+}
123+
124+// x11Place forwards core's position of an X11 window's client rect.
125+func (s *Server) x11Place(c ports.PlaceWindow) {
126+ if w := s.windows[c.ID]; w != nil && w.x11 != nil && s.xwm != nil {
127+ s.xwm.Place(w.x11.slot, c.X, c.Y)
128+ }
129+}
130+
131+// x11Focus tells the XWM which X window holds the keyboard focus, none when a
132+// native window does.
133+func (s *Server) x11Focus() {
134+ if s.xwm == nil {
135+ return
136+ }
137+ var slot xwm.Slot
138+ if w := s.windows[s.seat.focused]; w != nil && w.x11 != nil {
139+ slot = w.x11.slot
140+ }
141+ s.xwm.Focus(slot)
142+}
143+
144+// hostImpl is the server as the XWM sees it.
145+type hostImpl struct{ s *Server }
146+
147+func (h hostImpl) window(slot xwm.Slot) *window { return h.s.x11Slots[slot] }
148+
149+func (h hostImpl) Created(slot xwm.Slot, xid uint32) ports.WindowID {
150+ s := h.s
151+ w := &window{id: s.nextWindow}
152+ w.x11 = &x11Window{server: s, slot: slot, xid: xid}
153+ s.nextWindow++
154+ s.windows[w.id] = w
155+ s.x11Slots[slot] = w
156+ return w.id
157+}
158+
159+func (h hostImpl) Ready(slot xwm.Slot, info xwm.WindowInfo) {
160+ w := h.window(slot)
161+ if w == nil {
162+ return
163+ }
164+ w.x11.info, w.x11.ready = info, true
165+ w.title, w.appID, w.modal = info.Title, info.AppID, info.Modal
166+ w.parent = h.window(info.Parent)
167+ // A surface associated and committed earlier maps now.
168+ w.x11.tryMap()
169+}
170+
171+func (h hostImpl) Associate(slot xwm.Slot, surfaceID uint64) {
172+ w := h.window(slot)
173+ surf := h.s.surfaceByIdentity[surfaceID]
174+ if w == nil || surf == nil || surf.kind != roleX11 {
175+ return
176+ }
177+ x := w.x11
178+ x.drop()
179+ x.surface, surf.x11win = surf, w
180+ surf.role = func(buffer bool) {
181+ switch {
182+ case surf.destroyed:
183+ w.unmap()
184+ if x.surface == surf {
185+ x.surface = nil
186+ }
187+ surf.x11win = nil
188+ case buffer:
189+ x.tryMap()
190+ case w.mapped:
191+ w.unmap()
192+ }
193+ }
194+ // The buffer may have been committed before the pairing.
195+ x.tryMap()
196+}
197+
198+func (h hostImpl) Dissociate(slot xwm.Slot, destroyed bool) {
199+ w := h.window(slot)
200+ if w == nil {
201+ return
202+ }
203+ w.unmap()
204+ w.x11.drop()
205+ w.x11.ready = false
206+ if destroyed {
207+ delete(h.s.windows, w.id)
208+ delete(h.s.x11Slots, slot)
209+ h.s.foreignToplevelGone(w)
210+ }
211+}
212+
213+func (h hostImpl) Title(slot xwm.Slot, title string) {
214+ if w := h.window(slot); w != nil && w.title != title {
215+ w.title = title
216+ h.s.toplevelChanged(w)
217+ if w.mapped {
218+ h.s.castInventoryChanged()
219+ }
220+ }
221+}
222+
223+func (h hostImpl) AppID(slot xwm.Slot, appID string) {
224+ w := h.window(slot)
225+ if w == nil || w.appID == appID {
226+ return
227+ }
228+ w.appID = appID
229+ if w.mapped {
230+ h.s.emit(ports.WindowAppID{ID: w.id, AppID: appID})
231+ h.s.toplevelChanged(w)
232+ h.s.castInventoryChanged()
233+ }
234+}
235+
236+func (h hostImpl) Parent(slot xwm.Slot, parent xwm.Slot) {
237+ if w := h.window(slot); w != nil {
238+ w.setParent(h.window(parent))
239+ }
240+}
241+
242+func (h hostImpl) Fullscreen(slot xwm.Slot, on bool) {
243+ w := h.window(slot)
244+ if w == nil {
245+ return
246+ }
247+ w.x11.info.Fullscreen = on
248+ if w.mapped {
249+ h.s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: on})
250+ }
251+}
252+
253+// drop unlinks the window from its surface.
254+func (x *x11Window) drop() {
255+ if surf := x.surface; surf != nil {
256+ surf.x11win, surf.role = nil, nil
257+ x.surface = nil
258+ }
259+}
260+
261+// tryMap maps the window once the XWM knows it and its surface shows a buffer.
262+func (x *x11Window) tryMap() {
263+ surf := x.surface
264+ if surf == nil {
265+ return
266+ }
267+ w := surf.x11win
268+ if w == nil || w.mapped || !x.ready || surf.destroyed || surf.current == nil {
269+ return
270+ }
271+ s := x.server
272+ w.mapped = true
273+ w.gen++
274+ w.floating = x.info.Floating
275+ w.floatW, w.floatH = 0, 0
276+ if w.floating {
277+ w.floatW, w.floatH = x.info.Width, x.info.Height
278+ }
279+ var parent ports.WindowID
280+ if w.parent != nil && w.parent.mapped {
281+ parent = w.parent.id
282+ }
283+ s.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, PID: x.info.PID, Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Positioned: true, Modal: w.modal, Remap: w.gen > 1})
284+ if x.info.Fullscreen {
285+ s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: true})
286+ }
287+ s.syncInhibitors()
288+ s.toplevelChanged(w)
289+ s.castInventoryChanged()
290+ s.log.Info().Uint64("id", uint64(w.id)).Uint32("xid", x.xid).Str("app_id", w.appID).Bool("floating", w.floating).Msg("x11 window mapped")
291+ // Called from a commit, the content follows right after; called from the
292+ // pairing, the surface already shows a buffer and is published now.
293+ if surf.has {
294+ surf.resetInputEmission()
295+ surf.redraw()
296+ surf.emitInput()
297+ }
298+}
A internal/adapters/wayland/x11window_test.go +324 -0
@@ -0,0 +1,324 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "net"
7+ "os"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/go-wayland-bindings/server/wayland"
12+ "github.com/bnema/go-wayland-bindings/server/xdgshell"
13+ "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
14+ "github.com/bnema/neferwl/internal/ports"
15+ "github.com/bnema/wlturbo"
16+ "golang.org/x/sys/unix"
17+)
18+
19+var x11Serial = uint64(0x1234_0000_0042)
20+
21+// x11Rig is a server with an Xwayland client (a real wlturbo client on the
22+// adopted descriptor), a scripted X peer on the WM descriptor and the XWM
23+// between them.
24+type x11Rig struct {
25+ t *testing.T
26+ s *Server
27+ events chan ports.ClientEvent
28+ commands chan ports.ClientCommand
29+ dir string
30+ xc *wlturbo.Display
31+ peer *x11Peer
32+ stop func()
33+ shell uint32
34+ comp uint32
35+}
36+
37+func newX11Rig(t *testing.T) *x11Rig {
38+ t.Helper()
39+ s, events, commands, dir := lifecycleServer(t)
40+ r := &x11Rig{t: t, s: s, events: events, commands: commands, dir: dir}
41+ wl := xwaylandPair(t)
42+ s.AttachXwayland(wl[0])
43+ f := os.NewFile(uintptr(wl[1]), "xwayland client")
44+ wl[1] = -1
45+ conn, err := net.FileConn(f)
46+ f.Close()
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+ if r.xc, err = wlturbo.ConnectFromConn(conn); err != nil {
51+ t.Fatal(err)
52+ }
53+ t.Cleanup(func() { _ = r.xc.Close(); forgetWireClient(r.xc) })
54+ if err := r.xc.Roundtrip(); err != nil {
55+ t.Fatal(err)
56+ }
57+ wm, peer := startX11Peer(t)
58+ r.peer = peer
59+ r.stop = s.AttachXWM(context.Background(), wm)
60+ t.Cleanup(func() { r.stop() })
61+ r.shell = bindProtocol(t, r.xc, "xwayland_shell_v1")
62+ r.comp = bindProtocol(t, r.xc, "wl_compositor")
63+ return r
64+}
65+
66+// x11Window announces and maps X window win with the properties of an
67+// ordinary client, and waits for the XWM to be ready for it.
68+func (r *x11Rig) x11Window(win uint32) {
69+ r.t.Helper()
70+ p := r.peer
71+ str := uint32(31) // XA_STRING
72+ p.setProp(win, 67, str, 8, []byte("inst\x00Class\x00")) // WM_CLASS
73+ p.setProp(win, p.atom("_NET_WM_NAME"), p.atom("UTF8_STRING"), 8, []byte("net title"))
74+ p.setProp(win, p.atom("_NET_WM_PID"), 6, 32, x11le32(4242))
75+ p.createNotify(win)
76+ skip := len(p.requests())
77+ p.mapRequest(win)
78+ p.waitReq(skip, func(q x11Req) bool { return q.major == 8 && q.u32(4) == win }) // MapWindow
79+}
80+
81+// pair sends the serial from both sides and commits a buffer on the surface.
82+// It returns the wl_surface id of the Xwayland client.
83+func (r *x11Rig) pair(win uint32, serial uint64) uint32 {
84+ r.t.Helper()
85+ r.peer.clientMessage(win, "WL_SURFACE_SERIAL", uint32(serial), uint32(serial>>32))
86+ surf, xs := r.xc.AllocateID(), r.xc.AllocateID()
87+ requestProtocol(r.t, r.xc, r.comp, wayland.CompositorRequestCreateSurface, surf)
88+ registerProtocol(r.t, r.xc, surf)
89+ requestProtocol(r.t, r.xc, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
90+ registerProtocol(r.t, r.xc, xs)
91+ requestProtocol(r.t, r.xc, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(serial), uint32(serial>>32))
92+ requestProtocol(r.t, r.xc, surf, wayland.SurfaceRequestAttach, shmBuffer(r.t, r.xc), int32(0), int32(0))
93+ requestProtocol(r.t, r.xc, surf, wayland.SurfaceRequestCommit)
94+ if err := r.xc.Roundtrip(); err != nil {
95+ r.t.Fatal(err)
96+ }
97+ return surf
98+}
99+
100+func awaitEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientEvent) T {
101+ t.Helper()
102+ for deadline := time.After(3 * time.Second); ; {
103+ select {
104+ case ev := <-events:
105+ if v, ok := ev.(T); ok {
106+ return v
107+ }
108+ case <-deadline:
109+ var zero T
110+ t.Fatalf("no %T event", zero)
111+ }
112+ }
113+}
114+
115+func TestXwaylandGlobalFilter(t *testing.T) {
116+ s, _, _, dir := lifecycleServer(t)
117+ other := protocolClient(t, s, dir)
118+ for _, iface := range []string{"xwayland_shell_v1", "zwp_xwayland_keyboard_grab_manager_v1"} {
119+ if _, ok := other.Registry().FindGlobal(iface); ok {
120+ t.Errorf("ordinary client sees %s", iface)
121+ }
122+ }
123+ if _, ok := other.Registry().FindGlobal("xdg_wm_base"); !ok {
124+ t.Error("ordinary globals filtered out")
125+ }
126+ r := newX11Rig(t)
127+ if _, ok := r.xc.Registry().FindGlobal("xwayland_shell_v1"); !ok {
128+ t.Error("Xwayland client misses xwayland_shell_v1")
129+ }
130+ // The ordinary client of the same server still does not see it.
131+ late := protocolClient(t, r.s, r.dir)
132+ if _, ok := late.Registry().FindGlobal("xwayland_shell_v1"); ok {
133+ t.Error("ordinary client sees xwayland_shell_v1 while Xwayland is attached")
134+ }
135+}
136+
137+func TestXwaylandShellRoleErrors(t *testing.T) {
138+ for _, scenario := range []string{"xdg role first", "serial twice", "zero serial"} {
139+ t.Run(scenario, func(t *testing.T) {
140+ r := newX11Rig(t)
141+ c := r.xc
142+ wmBase := bindProtocol(t, c, "xdg_wm_base")
143+ surf := c.AllocateID()
144+ requestProtocol(t, c, r.comp, wayland.CompositorRequestCreateSurface, surf)
145+ registerProtocol(t, c, surf)
146+ xs := c.AllocateID()
147+ switch scenario {
148+ case "xdg role first":
149+ xdg := xs
150+ requestProtocol(t, c, wmBase, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
151+ requestProtocol(t, c, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, c.AllocateID(), surf)
152+ expectProtocolError(t, c, r.shell, uint32(xwaylandshell.XwaylandShellV1ErrorRole))
153+ case "serial twice":
154+ requestProtocol(t, c, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
155+ registerProtocol(t, c, xs)
156+ requestProtocol(t, c, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(1), uint32(0))
157+ requestProtocol(t, c, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(2), uint32(0))
158+ expectProtocolError(t, c, xs, uint32(xwaylandshell.XwaylandSurfaceV1ErrorAlreadyAssociated))
159+ case "zero serial":
160+ requestProtocol(t, c, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
161+ registerProtocol(t, c, xs)
162+ requestProtocol(t, c, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(0), uint32(0))
163+ expectProtocolError(t, c, xs, uint32(xwaylandshell.XwaylandSurfaceV1ErrorInvalidSerial))
164+ }
165+ })
166+ }
167+}
168+
169+// A window announced by the X server and a surface carrying the same serial
170+// map as one neferwl window with the X11 info; core commands reach the X side.
171+func TestX11WindowLifecycle(t *testing.T) {
172+ r := newX11Rig(t)
173+ win := uint32(x11Win)
174+ r.x11Window(win)
175+ surf := r.pair(win, x11Serial)
176+
177+ m := awaitEvent[ports.WindowMapped](t, r.events)
178+ if m.AppID != "Class" || m.PID != 4242 || !m.Positioned || m.Floating || m.Slot != "" {
179+ t.Fatalf("mapped %+v", m)
180+ }
181+
182+ // Title and app id changes.
183+ p := r.peer
184+ p.setProp(win, p.atom("_NET_WM_NAME"), p.atom("UTF8_STRING"), 8, []byte("renamed"))
185+ p.propertyNotify(win, p.atom("_NET_WM_NAME"))
186+ p.setProp(win, 67, 31, 8, []byte("inst\x00Other\x00"))
187+ p.propertyNotify(win, 67)
188+ if a := awaitEvent[ports.WindowAppID](t, r.events); a.ID != m.ID || a.AppID != "Other" {
189+ t.Fatalf("app id %+v", a)
190+ }
191+ var title string
192+ waitTrue(t, "title", func() bool {
193+ r.s.display.Do(func() {
194+ if w := r.s.windows[m.ID]; w != nil {
195+ title = w.title
196+ }
197+ })
198+ return title == "renamed"
199+ })
200+
201+ // Fullscreen request from the client.
202+ p.clientMessage(win, "_NET_WM_STATE", 1, p.atom("_NET_WM_STATE_FULLSCREEN"))
203+ if f := awaitEvent[ports.WindowFullscreenRequest](t, r.events); f.ID != m.ID || !f.Fullscreen || f.External {
204+ t.Fatalf("fullscreen %+v", f)
205+ }
206+
207+ // Configure and Place reach the X server as ConfigureWindow.
208+ skip := len(p.requests())
209+ r.commands <- ports.ConfigureWindow{ID: m.ID, Width: 640, Height: 360, Visible: true}
210+ cfg, skip := p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpConfigureWindow && q.u32(4) == win })
211+ if w := binary.LittleEndian.Uint32(cfg.raw[20:]); w != 640 {
212+ t.Fatalf("configured width %d, want 640 (%x)", w, cfg.raw)
213+ }
214+ r.commands <- ports.PlaceWindow{ID: m.ID, X: 10, Y: 20}
215+ cfg, _ = p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpConfigureWindow && q.u32(4) == win })
216+ if x, y := int32(binary.LittleEndian.Uint32(cfg.raw[12:])), int32(binary.LittleEndian.Uint32(cfg.raw[16:])); x != 10 || y != 20 {
217+ t.Fatalf("placed at %d,%d, want 10,20", x, y)
218+ }
219+
220+ // Focus.
221+ skip = len(p.requests())
222+ r.commands <- ports.FocusWindow{ID: m.ID}
223+ if f, _ := p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpSetInputFocus }); f.u32(4) != win {
224+ t.Fatalf("focus on %#x, want %#x", f.u32(4), win)
225+ }
226+
227+ // Close: without WM_DELETE_WINDOW the client is killed.
228+ skip = len(p.requests())
229+ r.commands <- ports.CloseWindow{ID: m.ID}
230+ if k, _ := p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpKillClient }); k.u32(4) != win {
231+ t.Fatalf("killed %#x, want %#x", k.u32(4), win)
232+ }
233+
234+ // Destroying the surface unmaps the window.
235+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestDestroy)
236+ if err := r.xc.Roundtrip(); err != nil {
237+ t.Fatal(err)
238+ }
239+ if u := awaitEvent[ports.WindowUnmapped](t, r.events); u.ID != m.ID {
240+ t.Fatalf("unmapped %+v", u)
241+ }
242+}
243+
244+// The serial may arrive on the Wayland side before the X side knows it, and
245+// an UnmapNotify unmaps; a remap pairs again.
246+func TestX11WindowSurfaceFirstAndRemap(t *testing.T) {
247+ r := newX11Rig(t)
248+ win := uint32(x11Win)
249+ p := r.peer
250+ surf, xs := r.xc.AllocateID(), r.xc.AllocateID()
251+ requestProtocol(t, r.xc, r.comp, wayland.CompositorRequestCreateSurface, surf)
252+ registerProtocol(t, r.xc, surf)
253+ requestProtocol(t, r.xc, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
254+ registerProtocol(t, r.xc, xs)
255+ requestProtocol(t, r.xc, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(x11Serial), uint32(x11Serial>>32))
256+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestAttach, shmBuffer(t, r.xc), int32(0), int32(0))
257+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestCommit)
258+ if err := r.xc.Roundtrip(); err != nil {
259+ t.Fatal(err)
260+ }
261+ r.x11Window(win)
262+ p.clientMessage(win, "WL_SURFACE_SERIAL", uint32(x11Serial), uint32(x11Serial>>32))
263+ m := awaitEvent[ports.WindowMapped](t, r.events)
264+ if m.Remap {
265+ t.Fatalf("first map flagged as remap: %+v", m)
266+ }
267+ p.unmapNotify(win)
268+ if u := awaitEvent[ports.WindowUnmapped](t, r.events); u.ID != m.ID {
269+ t.Fatalf("unmapped %+v", u)
270+ }
271+ p.destroyNotify(win)
272+ waitTrue(t, "window forgotten", func() bool {
273+ gone := false
274+ r.s.display.Do(func() { gone = r.s.windows[m.ID] == nil })
275+ return gone
276+ })
277+}
278+
279+// stop closes the XWM: its windows unmap, and the server keeps serving.
280+func TestX11StopUnmapsWindows(t *testing.T) {
281+ r := newX11Rig(t)
282+ win := uint32(x11Win)
283+ r.x11Window(win)
284+ r.pair(win, x11Serial)
285+ m := awaitEvent[ports.WindowMapped](t, r.events)
286+ r.stop()
287+ if u := awaitEvent[ports.WindowUnmapped](t, r.events); u.ID != m.ID {
288+ t.Fatalf("unmapped %+v", u)
289+ }
290+ r.s.display.Do(func() {
291+ if r.s.xwm != nil || r.s.windows[m.ID] != nil || len(r.s.x11Slots) != 0 {
292+ t.Error("XWM state left behind")
293+ }
294+ })
295+ r.stop() // idempotent
296+}
297+
298+// Boot gives up as soon as its context ends, even against a silent server,
299+// and leaves the server serving.
300+func TestAttachXWMBootAbortsWithContext(t *testing.T) {
301+ s, _, _, dir := lifecycleServer(t)
302+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
303+ if err != nil {
304+ t.Fatal(err)
305+ }
306+ defer unix.Close(fds[1]) // the silent server end
307+ ctx, cancel := context.WithCancel(context.Background())
308+ done := make(chan func(), 1)
309+ go func() { done <- s.AttachXWM(ctx, fds[0]) }()
310+ select {
311+ case <-done:
312+ t.Fatal("AttachXWM returned before the context ended")
313+ case <-time.After(150 * time.Millisecond):
314+ }
315+ cancel()
316+ select {
317+ case stop := <-done:
318+ stop()
319+ case <-time.After(2 * time.Second):
320+ t.Fatal("AttachXWM did not abort with its context")
321+ }
322+ assertFDClosed(t, fds[0])
323+ protocolClient(t, s, dir) // still serving
324+}
@@ -0,0 +1,324 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "net"
7+ "os"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/go-wayland-bindings/server/wayland"
12+ "github.com/bnema/go-wayland-bindings/server/xdgshell"
13+ "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
14+ "github.com/bnema/neferwl/internal/ports"
15+ "github.com/bnema/wlturbo"
16+ "golang.org/x/sys/unix"
17+)
18+
19+var x11Serial = uint64(0x1234_0000_0042)
20+
21+// x11Rig is a server with an Xwayland client (a real wlturbo client on the
22+// adopted descriptor), a scripted X peer on the WM descriptor and the XWM
23+// between them.
24+type x11Rig struct {
25+ t *testing.T
26+ s *Server
27+ events chan ports.ClientEvent
28+ commands chan ports.ClientCommand
29+ dir string
30+ xc *wlturbo.Display
31+ peer *x11Peer
32+ stop func()
33+ shell uint32
34+ comp uint32
35+}
36+
37+func newX11Rig(t *testing.T) *x11Rig {
38+ t.Helper()
39+ s, events, commands, dir := lifecycleServer(t)
40+ r := &x11Rig{t: t, s: s, events: events, commands: commands, dir: dir}
41+ wl := xwaylandPair(t)
42+ s.AttachXwayland(wl[0])
43+ f := os.NewFile(uintptr(wl[1]), "xwayland client")
44+ wl[1] = -1
45+ conn, err := net.FileConn(f)
46+ f.Close()
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+ if r.xc, err = wlturbo.ConnectFromConn(conn); err != nil {
51+ t.Fatal(err)
52+ }
53+ t.Cleanup(func() { _ = r.xc.Close(); forgetWireClient(r.xc) })
54+ if err := r.xc.Roundtrip(); err != nil {
55+ t.Fatal(err)
56+ }
57+ wm, peer := startX11Peer(t)
58+ r.peer = peer
59+ r.stop = s.AttachXWM(context.Background(), wm)
60+ t.Cleanup(func() { r.stop() })
61+ r.shell = bindProtocol(t, r.xc, "xwayland_shell_v1")
62+ r.comp = bindProtocol(t, r.xc, "wl_compositor")
63+ return r
64+}
65+
66+// x11Window announces and maps X window win with the properties of an
67+// ordinary client, and waits for the XWM to be ready for it.
68+func (r *x11Rig) x11Window(win uint32) {
69+ r.t.Helper()
70+ p := r.peer
71+ str := uint32(31) // XA_STRING
72+ p.setProp(win, 67, str, 8, []byte("inst\x00Class\x00")) // WM_CLASS
73+ p.setProp(win, p.atom("_NET_WM_NAME"), p.atom("UTF8_STRING"), 8, []byte("net title"))
74+ p.setProp(win, p.atom("_NET_WM_PID"), 6, 32, x11le32(4242))
75+ p.createNotify(win)
76+ skip := len(p.requests())
77+ p.mapRequest(win)
78+ p.waitReq(skip, func(q x11Req) bool { return q.major == 8 && q.u32(4) == win }) // MapWindow
79+}
80+
81+// pair sends the serial from both sides and commits a buffer on the surface.
82+// It returns the wl_surface id of the Xwayland client.
83+func (r *x11Rig) pair(win uint32, serial uint64) uint32 {
84+ r.t.Helper()
85+ r.peer.clientMessage(win, "WL_SURFACE_SERIAL", uint32(serial), uint32(serial>>32))
86+ surf, xs := r.xc.AllocateID(), r.xc.AllocateID()
87+ requestProtocol(r.t, r.xc, r.comp, wayland.CompositorRequestCreateSurface, surf)
88+ registerProtocol(r.t, r.xc, surf)
89+ requestProtocol(r.t, r.xc, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
90+ registerProtocol(r.t, r.xc, xs)
91+ requestProtocol(r.t, r.xc, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(serial), uint32(serial>>32))
92+ requestProtocol(r.t, r.xc, surf, wayland.SurfaceRequestAttach, shmBuffer(r.t, r.xc), int32(0), int32(0))
93+ requestProtocol(r.t, r.xc, surf, wayland.SurfaceRequestCommit)
94+ if err := r.xc.Roundtrip(); err != nil {
95+ r.t.Fatal(err)
96+ }
97+ return surf
98+}
99+
100+func awaitEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientEvent) T {
101+ t.Helper()
102+ for deadline := time.After(3 * time.Second); ; {
103+ select {
104+ case ev := <-events:
105+ if v, ok := ev.(T); ok {
106+ return v
107+ }
108+ case <-deadline:
109+ var zero T
110+ t.Fatalf("no %T event", zero)
111+ }
112+ }
113+}
114+
115+func TestXwaylandGlobalFilter(t *testing.T) {
116+ s, _, _, dir := lifecycleServer(t)
117+ other := protocolClient(t, s, dir)
118+ for _, iface := range []string{"xwayland_shell_v1", "zwp_xwayland_keyboard_grab_manager_v1"} {
119+ if _, ok := other.Registry().FindGlobal(iface); ok {
120+ t.Errorf("ordinary client sees %s", iface)
121+ }
122+ }
123+ if _, ok := other.Registry().FindGlobal("xdg_wm_base"); !ok {
124+ t.Error("ordinary globals filtered out")
125+ }
126+ r := newX11Rig(t)
127+ if _, ok := r.xc.Registry().FindGlobal("xwayland_shell_v1"); !ok {
128+ t.Error("Xwayland client misses xwayland_shell_v1")
129+ }
130+ // The ordinary client of the same server still does not see it.
131+ late := protocolClient(t, r.s, r.dir)
132+ if _, ok := late.Registry().FindGlobal("xwayland_shell_v1"); ok {
133+ t.Error("ordinary client sees xwayland_shell_v1 while Xwayland is attached")
134+ }
135+}
136+
137+func TestXwaylandShellRoleErrors(t *testing.T) {
138+ for _, scenario := range []string{"xdg role first", "serial twice", "zero serial"} {
139+ t.Run(scenario, func(t *testing.T) {
140+ r := newX11Rig(t)
141+ c := r.xc
142+ wmBase := bindProtocol(t, c, "xdg_wm_base")
143+ surf := c.AllocateID()
144+ requestProtocol(t, c, r.comp, wayland.CompositorRequestCreateSurface, surf)
145+ registerProtocol(t, c, surf)
146+ xs := c.AllocateID()
147+ switch scenario {
148+ case "xdg role first":
149+ xdg := xs
150+ requestProtocol(t, c, wmBase, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
151+ requestProtocol(t, c, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, c.AllocateID(), surf)
152+ expectProtocolError(t, c, r.shell, uint32(xwaylandshell.XwaylandShellV1ErrorRole))
153+ case "serial twice":
154+ requestProtocol(t, c, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
155+ registerProtocol(t, c, xs)
156+ requestProtocol(t, c, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(1), uint32(0))
157+ requestProtocol(t, c, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(2), uint32(0))
158+ expectProtocolError(t, c, xs, uint32(xwaylandshell.XwaylandSurfaceV1ErrorAlreadyAssociated))
159+ case "zero serial":
160+ requestProtocol(t, c, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
161+ registerProtocol(t, c, xs)
162+ requestProtocol(t, c, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(0), uint32(0))
163+ expectProtocolError(t, c, xs, uint32(xwaylandshell.XwaylandSurfaceV1ErrorInvalidSerial))
164+ }
165+ })
166+ }
167+}
168+
169+// A window announced by the X server and a surface carrying the same serial
170+// map as one neferwl window with the X11 info; core commands reach the X side.
171+func TestX11WindowLifecycle(t *testing.T) {
172+ r := newX11Rig(t)
173+ win := uint32(x11Win)
174+ r.x11Window(win)
175+ surf := r.pair(win, x11Serial)
176+
177+ m := awaitEvent[ports.WindowMapped](t, r.events)
178+ if m.AppID != "Class" || m.PID != 4242 || !m.Positioned || m.Floating || m.Slot != "" {
179+ t.Fatalf("mapped %+v", m)
180+ }
181+
182+ // Title and app id changes.
183+ p := r.peer
184+ p.setProp(win, p.atom("_NET_WM_NAME"), p.atom("UTF8_STRING"), 8, []byte("renamed"))
185+ p.propertyNotify(win, p.atom("_NET_WM_NAME"))
186+ p.setProp(win, 67, 31, 8, []byte("inst\x00Other\x00"))
187+ p.propertyNotify(win, 67)
188+ if a := awaitEvent[ports.WindowAppID](t, r.events); a.ID != m.ID || a.AppID != "Other" {
189+ t.Fatalf("app id %+v", a)
190+ }
191+ var title string
192+ waitTrue(t, "title", func() bool {
193+ r.s.display.Do(func() {
194+ if w := r.s.windows[m.ID]; w != nil {
195+ title = w.title
196+ }
197+ })
198+ return title == "renamed"
199+ })
200+
201+ // Fullscreen request from the client.
202+ p.clientMessage(win, "_NET_WM_STATE", 1, p.atom("_NET_WM_STATE_FULLSCREEN"))
203+ if f := awaitEvent[ports.WindowFullscreenRequest](t, r.events); f.ID != m.ID || !f.Fullscreen || f.External {
204+ t.Fatalf("fullscreen %+v", f)
205+ }
206+
207+ // Configure and Place reach the X server as ConfigureWindow.
208+ skip := len(p.requests())
209+ r.commands <- ports.ConfigureWindow{ID: m.ID, Width: 640, Height: 360, Visible: true}
210+ cfg, skip := p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpConfigureWindow && q.u32(4) == win })
211+ if w := binary.LittleEndian.Uint32(cfg.raw[20:]); w != 640 {
212+ t.Fatalf("configured width %d, want 640 (%x)", w, cfg.raw)
213+ }
214+ r.commands <- ports.PlaceWindow{ID: m.ID, X: 10, Y: 20}
215+ cfg, _ = p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpConfigureWindow && q.u32(4) == win })
216+ if x, y := int32(binary.LittleEndian.Uint32(cfg.raw[12:])), int32(binary.LittleEndian.Uint32(cfg.raw[16:])); x != 10 || y != 20 {
217+ t.Fatalf("placed at %d,%d, want 10,20", x, y)
218+ }
219+
220+ // Focus.
221+ skip = len(p.requests())
222+ r.commands <- ports.FocusWindow{ID: m.ID}
223+ if f, _ := p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpSetInputFocus }); f.u32(4) != win {
224+ t.Fatalf("focus on %#x, want %#x", f.u32(4), win)
225+ }
226+
227+ // Close: without WM_DELETE_WINDOW the client is killed.
228+ skip = len(p.requests())
229+ r.commands <- ports.CloseWindow{ID: m.ID}
230+ if k, _ := p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpKillClient }); k.u32(4) != win {
231+ t.Fatalf("killed %#x, want %#x", k.u32(4), win)
232+ }
233+
234+ // Destroying the surface unmaps the window.
235+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestDestroy)
236+ if err := r.xc.Roundtrip(); err != nil {
237+ t.Fatal(err)
238+ }
239+ if u := awaitEvent[ports.WindowUnmapped](t, r.events); u.ID != m.ID {
240+ t.Fatalf("unmapped %+v", u)
241+ }
242+}
243+
244+// The serial may arrive on the Wayland side before the X side knows it, and
245+// an UnmapNotify unmaps; a remap pairs again.
246+func TestX11WindowSurfaceFirstAndRemap(t *testing.T) {
247+ r := newX11Rig(t)
248+ win := uint32(x11Win)
249+ p := r.peer
250+ surf, xs := r.xc.AllocateID(), r.xc.AllocateID()
251+ requestProtocol(t, r.xc, r.comp, wayland.CompositorRequestCreateSurface, surf)
252+ registerProtocol(t, r.xc, surf)
253+ requestProtocol(t, r.xc, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
254+ registerProtocol(t, r.xc, xs)
255+ requestProtocol(t, r.xc, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(x11Serial), uint32(x11Serial>>32))
256+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestAttach, shmBuffer(t, r.xc), int32(0), int32(0))
257+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestCommit)
258+ if err := r.xc.Roundtrip(); err != nil {
259+ t.Fatal(err)
260+ }
261+ r.x11Window(win)
262+ p.clientMessage(win, "WL_SURFACE_SERIAL", uint32(x11Serial), uint32(x11Serial>>32))
263+ m := awaitEvent[ports.WindowMapped](t, r.events)
264+ if m.Remap {
265+ t.Fatalf("first map flagged as remap: %+v", m)
266+ }
267+ p.unmapNotify(win)
268+ if u := awaitEvent[ports.WindowUnmapped](t, r.events); u.ID != m.ID {
269+ t.Fatalf("unmapped %+v", u)
270+ }
271+ p.destroyNotify(win)
272+ waitTrue(t, "window forgotten", func() bool {
273+ gone := false
274+ r.s.display.Do(func() { gone = r.s.windows[m.ID] == nil })
275+ return gone
276+ })
277+}
278+
279+// stop closes the XWM: its windows unmap, and the server keeps serving.
280+func TestX11StopUnmapsWindows(t *testing.T) {
281+ r := newX11Rig(t)
282+ win := uint32(x11Win)
283+ r.x11Window(win)
284+ r.pair(win, x11Serial)
285+ m := awaitEvent[ports.WindowMapped](t, r.events)
286+ r.stop()
287+ if u := awaitEvent[ports.WindowUnmapped](t, r.events); u.ID != m.ID {
288+ t.Fatalf("unmapped %+v", u)
289+ }
290+ r.s.display.Do(func() {
291+ if r.s.xwm != nil || r.s.windows[m.ID] != nil || len(r.s.x11Slots) != 0 {
292+ t.Error("XWM state left behind")
293+ }
294+ })
295+ r.stop() // idempotent
296+}
297+
298+// Boot gives up as soon as its context ends, even against a silent server,
299+// and leaves the server serving.
300+func TestAttachXWMBootAbortsWithContext(t *testing.T) {
301+ s, _, _, dir := lifecycleServer(t)
302+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
303+ if err != nil {
304+ t.Fatal(err)
305+ }
306+ defer unix.Close(fds[1]) // the silent server end
307+ ctx, cancel := context.WithCancel(context.Background())
308+ done := make(chan func(), 1)
309+ go func() { done <- s.AttachXWM(ctx, fds[0]) }()
310+ select {
311+ case <-done:
312+ t.Fatal("AttachXWM returned before the context ended")
313+ case <-time.After(150 * time.Millisecond):
314+ }
315+ cancel()
316+ select {
317+ case stop := <-done:
318+ stop()
319+ case <-time.After(2 * time.Second):
320+ t.Fatal("AttachXWM did not abort with its context")
321+ }
322+ assertFDClosed(t, fds[0])
323+ protocolClient(t, s, dir) // still serving
324+}
M internal/adapters/wayland/xdg.go +30 -19
@@ -128,10 +128,12 @@ func (x *xdgSurface) Destroy(r *xdgshell.Surface) {
128128
129129 // window is an xdg_toplevel, or an xdg_popup when popup is set.
130130 type window struct {
131- id ports.WindowID
132- toplevel *xdgshell.Toplevel
133- popup *popup
134- xdg *xdgSurface
131+ id ports.WindowID
132+ toplevel *xdgshell.Toplevel
133+ popup *popup
134+ xdg *xdgSurface
135+ // x11 is set for the window of an X11 client (x11window.go).
136+ x11 *x11Window
135137 appID, title string
136138 mapped bool
137139 // gen counts the mappings: an unmap and a remap is a new toplevel to
@@ -192,24 +194,29 @@ func toplevelStates(c ports.ConfigureWindow, version int32) []byte {
192194 }
193195
194196 func (w *window) unmap() {
195- if w.xdg.server.seat.pointerFocus == w.id {
196- w.xdg.server.changePointerFocus(0, 0, 0)
197+ s := w.srv()
198+ if s.seat.pointerFocus == w.id {
199+ s.changePointerFocus(0, 0, 0)
197200 }
198- if w.xdg.server.seat.focused == w.id {
199- w.xdg.server.changeFocus(0)
201+ if s.seat.focused == w.id {
202+ s.changeFocus(0)
200203 }
201204 if !w.mapped {
202205 return
203206 }
204207 w.mapped = false
205- w.xdg.server.toplevelClosed(w.id)
206- w.xdg.surface.resetInputEmission()
207- w.xdg.configured = false
208- w.xdg.acked = false
209- w.xdg.serials = nil
210- w.xdg.server.emit(ports.WindowUnmapped{ID: w.id})
211- w.xdg.server.syncInhibitors()
212- w.xdg.server.emitContent(ports.SurfaceContent{ID: w.id}, damage{full: true})
208+ s.toplevelClosed(w.id)
209+ if surf := w.surf(); surf != nil {
210+ surf.resetInputEmission()
211+ }
212+ if x := w.xdg; x != nil {
213+ x.configured = false
214+ x.acked = false
215+ x.serials = nil
216+ }
217+ s.emit(ports.WindowUnmapped{ID: w.id})
218+ s.syncInhibitors()
219+ s.emitContent(ports.SurfaceContent{ID: w.id}, damage{full: true})
213220 }
214221 func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
215222 if x.window != nil {
@@ -337,7 +344,7 @@ func (w *window) setParent(p *window) {
337344 if p != nil && p.mapped {
338345 id = p.id
339346 }
340- w.xdg.server.emit(ports.WindowParent{ID: w.id, Parent: id})
347+ w.srv().emit(ports.WindowParent{ID: w.id, Parent: id})
341348 }
342349 func (t top) SetTitle(_ *xdgshell.Toplevel, title string) {
343350 if title == t.w.title {
@@ -417,7 +424,7 @@ func (w *window) afterCommit() {
417424 }
418425 if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) {
419426 w.floatW, w.floatH = fw, fh
420- w.xdg.server.emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh})
427+ w.srv().emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh})
421428 }
422429 }
423430
@@ -431,6 +438,10 @@ func (w *window) floats() bool {
431438
432439 // size is the window geometry size, else the surface size, in logical pixels.
433440 func (w *window) size() (int, int) {
441+ if w.xdg == nil {
442+ c := w.surf().content
443+ return c.LogicalW, c.LogicalH
444+ }
434445 if g := w.xdg.geometry; g.W > 0 && g.H > 0 {
435446 return g.W, g.H
436447 }
@@ -441,7 +452,7 @@ func (w *window) size() (int, int) {
441452 // surfacePoint turns window coordinates (core's, from the geometry origin)
442453 // into surface coordinates.
443454 func (w *window) surfacePoint(x, y float64) (float64, float64) {
444- g := w.xdg.geometry
455+ g := w.geometry()
445456 return x + float64(g.X), y + float64(g.Y)
446457 }
447458
@@ -128,10 +128,12 @@ func (x *xdgSurface) Destroy(r *xdgshell.Surface) {
128 128
129 // window is an xdg_toplevel, or an xdg_popup when popup is set.129 // window is an xdg_toplevel, or an xdg_popup when popup is set.
130 type window struct {130 type window struct {
131- id ports.WindowID131+ id ports.WindowID
132- toplevel *xdgshell.Toplevel132+ toplevel *xdgshell.Toplevel
133- popup *popup133+ popup *popup
134- xdg *xdgSurface134+ xdg *xdgSurface
135+ // x11 is set for the window of an X11 client (x11window.go).
136+ x11 *x11Window
135 appID, title string137 appID, title string
136 mapped bool138 mapped bool
137 // gen counts the mappings: an unmap and a remap is a new toplevel to139 // gen counts the mappings: an unmap and a remap is a new toplevel to
@@ -192,24 +194,29 @@ func toplevelStates(c ports.ConfigureWindow, version int32) []byte {
192 }194 }
193 195
194 func (w *window) unmap() {196 func (w *window) unmap() {
195- if w.xdg.server.seat.pointerFocus == w.id {197+ s := w.srv()
196- w.xdg.server.changePointerFocus(0, 0, 0)198+ if s.seat.pointerFocus == w.id {
199+ s.changePointerFocus(0, 0, 0)
197 }200 }
198- if w.xdg.server.seat.focused == w.id {201+ if s.seat.focused == w.id {
199- w.xdg.server.changeFocus(0)202+ s.changeFocus(0)
200 }203 }
201 if !w.mapped {204 if !w.mapped {
202 return205 return
203 }206 }
204 w.mapped = false207 w.mapped = false
205- w.xdg.server.toplevelClosed(w.id)208+ s.toplevelClosed(w.id)
206- w.xdg.surface.resetInputEmission()209+ if surf := w.surf(); surf != nil {
207- w.xdg.configured = false210+ surf.resetInputEmission()
208- w.xdg.acked = false211+ }
209- w.xdg.serials = nil212+ if x := w.xdg; x != nil {
210- w.xdg.server.emit(ports.WindowUnmapped{ID: w.id})213+ x.configured = false
211- w.xdg.server.syncInhibitors()214+ x.acked = false
212- w.xdg.server.emitContent(ports.SurfaceContent{ID: w.id}, damage{full: true})215+ x.serials = nil
216+ }
217+ s.emit(ports.WindowUnmapped{ID: w.id})
218+ s.syncInhibitors()
219+ s.emitContent(ports.SurfaceContent{ID: w.id}, damage{full: true})
213 }220 }
214 func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {221 func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
215 if x.window != nil {222 if x.window != nil {
@@ -337,7 +344,7 @@ func (w *window) setParent(p *window) {
337 if p != nil && p.mapped {344 if p != nil && p.mapped {
338 id = p.id345 id = p.id
339 }346 }
340- w.xdg.server.emit(ports.WindowParent{ID: w.id, Parent: id})347+ w.srv().emit(ports.WindowParent{ID: w.id, Parent: id})
341 }348 }
342 func (t top) SetTitle(_ *xdgshell.Toplevel, title string) {349 func (t top) SetTitle(_ *xdgshell.Toplevel, title string) {
343 if title == t.w.title {350 if title == t.w.title {
@@ -417,7 +424,7 @@ func (w *window) afterCommit() {
417 }424 }
418 if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) {425 if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) {
419 w.floatW, w.floatH = fw, fh426 w.floatW, w.floatH = fw, fh
420- w.xdg.server.emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh})427+ w.srv().emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh})
421 }428 }
422 }429 }
423 430
@@ -431,6 +438,10 @@ func (w *window) floats() bool {
431 438
432 // size is the window geometry size, else the surface size, in logical pixels.439 // size is the window geometry size, else the surface size, in logical pixels.
433 func (w *window) size() (int, int) {440 func (w *window) size() (int, int) {
441+ if w.xdg == nil {
442+ c := w.surf().content
443+ return c.LogicalW, c.LogicalH
444+ }
434 if g := w.xdg.geometry; g.W > 0 && g.H > 0 {445 if g := w.xdg.geometry; g.W > 0 && g.H > 0 {
435 return g.W, g.H446 return g.W, g.H
436 }447 }
@@ -441,7 +452,7 @@ func (w *window) size() (int, int) {
441 // surfacePoint turns window coordinates (core's, from the geometry origin)452 // surfacePoint turns window coordinates (core's, from the geometry origin)
442 // into surface coordinates.453 // into surface coordinates.
443 func (w *window) surfacePoint(x, y float64) (float64, float64) {454 func (w *window) surfacePoint(x, y float64) (float64, float64) {
444- g := w.xdg.geometry455+ g := w.geometry()
445 return x + float64(g.X), y + float64(g.Y)456 return x + float64(g.X), y + float64(g.Y)
446 }457 }
447 458
M internal/adapters/wayland/xwayland.go +31 -7
@@ -1,6 +1,10 @@
11 package wayland
22
33 import (
4+ "context"
5+
6+ "github.com/bnema/neferwl/internal/adapters/logging"
7+ "github.com/bnema/neferwl/internal/adapters/wayland/xwm"
48 "github.com/bnema/purego-libwayland/server"
59 "golang.org/x/sys/unix"
610 )
@@ -24,6 +28,7 @@ func (s *Server) AttachXwayland(wlFD int) {
2428 s.xwayland = xwaylandClient{client: c}
2529 c.OnDestroy(func() {
2630 if s.xwayland.client == c {
31+ s.teardownXWM()
2732 s.forgetXwayland()
2833 }
2934 })
@@ -41,11 +46,30 @@ func (s *Server) forgetXwayland() {
4146 }
4247 }
4348
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() {}
49+// AttachXWM bootstraps the X11 window manager on the window manager
50+// descriptor, which it owns. It runs on the starter goroutine once Xwayland is
51+// ready and blocks until the XWM is installed, ctx ends or Boot fails. stop
52+// closes the XWM on the display goroutine and waits for it.
53+func (s *Server) AttachXWM(ctx context.Context, wmFD int) (stop func()) {
54+ log := logging.For(ctx, "xwm")
55+ x, err := xwm.Boot(ctx, wmFD, log)
56+ if err != nil {
57+ if ctx.Err() != nil {
58+ log.Info().Err(err).Msg("X11 window management not started: Xwayland is going away")
59+ } else {
60+ log.Warn().Err(err).Msg("X11 window management disabled")
61+ }
62+ return func() {}
63+ }
64+ if !s.display.Do(func() { s.installXWM(x) }) {
65+ x.CloseConn()
66+ return func() {}
67+ }
68+ return func() {
69+ s.display.Do(func() {
70+ if s.xwm == x {
71+ s.teardownXWM()
72+ }
73+ })
74+ }
5175 }
@@ -1,6 +1,10 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4+ "context"
5+
6+ "github.com/bnema/neferwl/internal/adapters/logging"
7+ "github.com/bnema/neferwl/internal/adapters/wayland/xwm"
4 "github.com/bnema/purego-libwayland/server"8 "github.com/bnema/purego-libwayland/server"
5 "golang.org/x/sys/unix"9 "golang.org/x/sys/unix"
6 )10 )
@@ -24,6 +28,7 @@ func (s *Server) AttachXwayland(wlFD int) {
24 s.xwayland = xwaylandClient{client: c}28 s.xwayland = xwaylandClient{client: c}
25 c.OnDestroy(func() {29 c.OnDestroy(func() {
26 if s.xwayland.client == c {30 if s.xwayland.client == c {
31+ s.teardownXWM()
27 s.forgetXwayland()32 s.forgetXwayland()
28 }33 }
29 })34 })
@@ -41,11 +46,30 @@ func (s *Server) forgetXwayland() {
41 }46 }
42 }47 }
43 48
44-// AttachXWM takes ownership of the window manager descriptor. It runs on the49+// AttachXWM bootstraps the X11 window manager on the window manager
45-// starter goroutine once Xwayland is ready; the XWM is not implemented yet,50+// descriptor, which it owns. It runs on the starter goroutine once Xwayland is
46-// so the descriptor is closed and stop does nothing.51+// ready and blocks until the XWM is installed, ctx ends or Boot fails. stop
47-func (s *Server) AttachXWM(wmFD int) (stop func()) {52+// closes the XWM on the display goroutine and waits for it.
48- s.log.Info().Msg("X11 window management not implemented: closing XWM descriptor")53+func (s *Server) AttachXWM(ctx context.Context, wmFD int) (stop func()) {
49- _ = unix.Close(wmFD)54+ log := logging.For(ctx, "xwm")
50- return func() {}55+ x, err := xwm.Boot(ctx, wmFD, log)
56+ if err != nil {
57+ if ctx.Err() != nil {
58+ log.Info().Err(err).Msg("X11 window management not started: Xwayland is going away")
59+ } else {
60+ log.Warn().Err(err).Msg("X11 window management disabled")
61+ }
62+ return func() {}
63+ }
64+ if !s.display.Do(func() { s.installXWM(x) }) {
65+ x.CloseConn()
66+ return func() {}
67+ }
68+ return func() {
69+ s.display.Do(func() {
70+ if s.xwm == x {
71+ s.teardownXWM()
72+ }
73+ })
74+ }
51 }75 }
M internal/adapters/wayland/xwm/mapflow_test.go +2 -1
@@ -1,6 +1,7 @@
11 package xwm
22
33 import (
4+ "context"
45 "encoding/binary"
56 "testing"
67
@@ -262,7 +263,7 @@ func TestMapFlowNoXResAsksNothing(t *testing.T) {
262263 exts := fullPeerExts()
263264 delete(exts, "X-Resource")
264265 fd, p := startXPeer(t, exts)
265- x, err := Boot(fd, testLog())
266+ x, err := Boot(context.Background(), fd, testLog())
266267 require.NoError(t, err)
267268 t.Cleanup(x.CloseConn)
268269 h := newMockhost(t)
@@ -1,6 +1,7 @@
1 package xwm1 package xwm
2 2
3 import (3 import (
4+ "context"
4 "encoding/binary"5 "encoding/binary"
5 "testing"6 "testing"
6 7
@@ -262,7 +263,7 @@ func TestMapFlowNoXResAsksNothing(t *testing.T) {
262 exts := fullPeerExts()263 exts := fullPeerExts()
263 delete(exts, "X-Resource")264 delete(exts, "X-Resource")
264 fd, p := startXPeer(t, exts)265 fd, p := startXPeer(t, exts)
265- x, err := Boot(fd, testLog())266+ x, err := Boot(context.Background(), fd, testLog())
266 require.NoError(t, err)267 require.NoError(t, err)
267 t.Cleanup(x.CloseConn)268 t.Cleanup(x.CloseConn)
268 h := newMockhost(t)269 h := newMockhost(t)
M internal/adapters/wayland/xwm/xwm.go +42 -2
@@ -8,10 +8,12 @@
88 package xwm
99
1010 import (
11+ "context"
1112 "encoding/binary"
1213 "errors"
1314 "fmt"
1415 "io"
16+ "os"
1517
1618 "bnema.dev/xturbo"
1719 "bnema.dev/xturbo/composite"
@@ -20,6 +22,7 @@ import (
2022 "bnema.dev/xturbo/xproto"
2123 "github.com/bnema/purego-libwayland/server"
2224 "github.com/bnema/zerowrap"
25+ "golang.org/x/sys/unix"
2326 )
2427
2528 var (
@@ -144,8 +147,45 @@ func (x *XWM) setProp32(win uint32, prop int, typ xproto.Atom, vals []uint32) {
144147 // blocks (about 25 s at worst) and must not run on the display goroutine: the
145148 // handshake needs Xwayland, which needs the display goroutine to serve its
146149 // Wayland requests. It takes ownership of wmFD and closes it on failure.
147-// log is scoped by the caller with logging.For(ctx, "xwm").
148-func Boot(wmFD int, log zerowrap.Logger) (*XWM, error) {
150+// Boot gives up promptly when ctx ends (the Xwayland process died or the run
151+// was cancelled). log is scoped by the caller with logging.For(ctx, "xwm").
152+func Boot(ctx context.Context, wmFD int, log zerowrap.Logger) (*XWM, error) {
153+ // The watcher shuts the socket down when ctx ends, which wakes any blocked
154+ // handshake, Sync or InternAtoms. It holds its own duplicate so a shutdown
155+ // can never reach a descriptor number reused after Boot closed wmFD.
156+ var watch *os.File
157+ if dup, err := unix.FcntlInt(uintptr(wmFD), unix.F_DUPFD_CLOEXEC, 0); err == nil {
158+ watch = os.NewFile(uintptr(dup), "xwm abort")
159+ }
160+ stopWatch := func() {}
161+ if watch != nil {
162+ quit, joined := make(chan struct{}), make(chan struct{})
163+ go func() {
164+ defer close(joined)
165+ select {
166+ case <-ctx.Done():
167+ if rc, err := watch.SyscallConn(); err == nil {
168+ _ = rc.Control(func(fd uintptr) { _ = unix.Shutdown(int(fd), unix.SHUT_RDWR) })
169+ }
170+ case <-quit:
171+ }
172+ _ = watch.Close()
173+ }()
174+ stopWatch = func() { close(quit); <-joined }
175+ }
176+ x, err := boot(wmFD, log)
177+ stopWatch()
178+ if err == nil && ctx.Err() != nil {
179+ x.CloseConn()
180+ x, err = nil, ctx.Err()
181+ }
182+ if err != nil && ctx.Err() != nil {
183+ err = fmt.Errorf("%w (%w)", err, ctx.Err())
184+ }
185+ return x, err
186+}
187+
188+func boot(wmFD int, log zerowrap.Logger) (*XWM, error) {
149189 conn, err := xturbo.NewConn(wmFD)
150190 if err != nil {
151191 return nil, fmt.Errorf("xwm: connect: %w", err)
@@ -8,10 +8,12 @@
8 package xwm8 package xwm
9 9
10 import (10 import (
11+ "context"
11 "encoding/binary"12 "encoding/binary"
12 "errors"13 "errors"
13 "fmt"14 "fmt"
14 "io"15 "io"
16+ "os"
15 17
16 "bnema.dev/xturbo"18 "bnema.dev/xturbo"
17 "bnema.dev/xturbo/composite"19 "bnema.dev/xturbo/composite"
@@ -20,6 +22,7 @@ import (
20 "bnema.dev/xturbo/xproto"22 "bnema.dev/xturbo/xproto"
21 "github.com/bnema/purego-libwayland/server"23 "github.com/bnema/purego-libwayland/server"
22 "github.com/bnema/zerowrap"24 "github.com/bnema/zerowrap"
25+ "golang.org/x/sys/unix"
23 )26 )
24 27
25 var (28 var (
@@ -144,8 +147,45 @@ func (x *XWM) setProp32(win uint32, prop int, typ xproto.Atom, vals []uint32) {
144 // blocks (about 25 s at worst) and must not run on the display goroutine: the147 // blocks (about 25 s at worst) and must not run on the display goroutine: the
145 // handshake needs Xwayland, which needs the display goroutine to serve its148 // handshake needs Xwayland, which needs the display goroutine to serve its
146 // Wayland requests. It takes ownership of wmFD and closes it on failure.149 // Wayland requests. It takes ownership of wmFD and closes it on failure.
147-// log is scoped by the caller with logging.For(ctx, "xwm").150+// Boot gives up promptly when ctx ends (the Xwayland process died or the run
148-func Boot(wmFD int, log zerowrap.Logger) (*XWM, error) {151+// was cancelled). log is scoped by the caller with logging.For(ctx, "xwm").
152+func Boot(ctx context.Context, wmFD int, log zerowrap.Logger) (*XWM, error) {
153+ // The watcher shuts the socket down when ctx ends, which wakes any blocked
154+ // handshake, Sync or InternAtoms. It holds its own duplicate so a shutdown
155+ // can never reach a descriptor number reused after Boot closed wmFD.
156+ var watch *os.File
157+ if dup, err := unix.FcntlInt(uintptr(wmFD), unix.F_DUPFD_CLOEXEC, 0); err == nil {
158+ watch = os.NewFile(uintptr(dup), "xwm abort")
159+ }
160+ stopWatch := func() {}
161+ if watch != nil {
162+ quit, joined := make(chan struct{}), make(chan struct{})
163+ go func() {
164+ defer close(joined)
165+ select {
166+ case <-ctx.Done():
167+ if rc, err := watch.SyscallConn(); err == nil {
168+ _ = rc.Control(func(fd uintptr) { _ = unix.Shutdown(int(fd), unix.SHUT_RDWR) })
169+ }
170+ case <-quit:
171+ }
172+ _ = watch.Close()
173+ }()
174+ stopWatch = func() { close(quit); <-joined }
175+ }
176+ x, err := boot(wmFD, log)
177+ stopWatch()
178+ if err == nil && ctx.Err() != nil {
179+ x.CloseConn()
180+ x, err = nil, ctx.Err()
181+ }
182+ if err != nil && ctx.Err() != nil {
183+ err = fmt.Errorf("%w (%w)", err, ctx.Err())
184+ }
185+ return x, err
186+}
187+
188+func boot(wmFD int, log zerowrap.Logger) (*XWM, error) {
149 conn, err := xturbo.NewConn(wmFD)189 conn, err := xturbo.NewConn(wmFD)
150 if err != nil {190 if err != nil {
151 return nil, fmt.Errorf("xwm: connect: %w", err)191 return nil, fmt.Errorf("xwm: connect: %w", err)
M internal/adapters/wayland/xwm/xwm_test.go +6 -5
@@ -1,6 +1,7 @@
11 package xwm
22
33 import (
4+ "context"
45 "encoding/binary"
56 "os"
67 "strings"
@@ -23,7 +24,7 @@ func bootPeer(t *testing.T, mutate func(p *xpeer)) (*XWM, *xpeer) {
2324 if mutate != nil {
2425 p.set(mutate)
2526 }
26- x, err := Boot(fd, testLog())
27+ x, err := Boot(context.Background(), fd, testLog())
2728 require.NoError(t, err)
2829 t.Cleanup(x.CloseConn)
2930 return x, p
@@ -134,7 +135,7 @@ func TestBootRequests(t *testing.T) {
134135 func TestBootWMExists(t *testing.T) {
135136 fd, p := startXPeer(t, fullPeerExts())
136137 p.set(func(p *xpeer) { p.denyRoot = true })
137- x, err := Boot(fd, testLog())
138+ x, err := Boot(context.Background(), fd, testLog())
138139 require.ErrorIs(t, err, ErrWMExists)
139140 require.Nil(t, x)
140141 // Nothing the running window manager owns was touched.
@@ -150,7 +151,7 @@ func TestBootNoComposite(t *testing.T) {
150151 exts := fullPeerExts()
151152 delete(exts, "Composite")
152153 fd, _ := startXPeer(t, exts)
153- _, err := Boot(fd, testLog())
154+ _, err := Boot(context.Background(), fd, testLog())
154155 require.ErrorIs(t, err, ErrNoComposite)
155156 }
156157
@@ -159,7 +160,7 @@ func TestBootOptionalExtensions(t *testing.T) {
159160 delete(exts, "XFIXES")
160161 delete(exts, "X-Resource")
161162 fd, _ := startXPeer(t, exts)
162- x, err := Boot(fd, testLog())
163+ x, err := Boot(context.Background(), fd, testLog())
163164 require.NoError(t, err)
164165 x.CloseConn()
165166 }
@@ -167,7 +168,7 @@ func TestBootOptionalExtensions(t *testing.T) {
167168 func TestBootConnectFailure(t *testing.T) {
168169 fd, p := startXPeer(t, fullPeerExts())
169170 p.hangup()
170- _, err := Boot(fd, testLog())
171+ _, err := Boot(context.Background(), fd, testLog())
171172 require.Error(t, err)
172173 }
173174
@@ -1,6 +1,7 @@
1 package xwm1 package xwm
2 2
3 import (3 import (
4+ "context"
4 "encoding/binary"5 "encoding/binary"
5 "os"6 "os"
6 "strings"7 "strings"
@@ -23,7 +24,7 @@ func bootPeer(t *testing.T, mutate func(p *xpeer)) (*XWM, *xpeer) {
23 if mutate != nil {24 if mutate != nil {
24 p.set(mutate)25 p.set(mutate)
25 }26 }
26- x, err := Boot(fd, testLog())27+ x, err := Boot(context.Background(), fd, testLog())
27 require.NoError(t, err)28 require.NoError(t, err)
28 t.Cleanup(x.CloseConn)29 t.Cleanup(x.CloseConn)
29 return x, p30 return x, p
@@ -134,7 +135,7 @@ func TestBootRequests(t *testing.T) {
134 func TestBootWMExists(t *testing.T) {135 func TestBootWMExists(t *testing.T) {
135 fd, p := startXPeer(t, fullPeerExts())136 fd, p := startXPeer(t, fullPeerExts())
136 p.set(func(p *xpeer) { p.denyRoot = true })137 p.set(func(p *xpeer) { p.denyRoot = true })
137- x, err := Boot(fd, testLog())138+ x, err := Boot(context.Background(), fd, testLog())
138 require.ErrorIs(t, err, ErrWMExists)139 require.ErrorIs(t, err, ErrWMExists)
139 require.Nil(t, x)140 require.Nil(t, x)
140 // Nothing the running window manager owns was touched.141 // Nothing the running window manager owns was touched.
@@ -150,7 +151,7 @@ func TestBootNoComposite(t *testing.T) {
150 exts := fullPeerExts()151 exts := fullPeerExts()
151 delete(exts, "Composite")152 delete(exts, "Composite")
152 fd, _ := startXPeer(t, exts)153 fd, _ := startXPeer(t, exts)
153- _, err := Boot(fd, testLog())154+ _, err := Boot(context.Background(), fd, testLog())
154 require.ErrorIs(t, err, ErrNoComposite)155 require.ErrorIs(t, err, ErrNoComposite)
155 }156 }
156 157
@@ -159,7 +160,7 @@ func TestBootOptionalExtensions(t *testing.T) {
159 delete(exts, "XFIXES")160 delete(exts, "XFIXES")
160 delete(exts, "X-Resource")161 delete(exts, "X-Resource")
161 fd, _ := startXPeer(t, exts)162 fd, _ := startXPeer(t, exts)
162- x, err := Boot(fd, testLog())163+ x, err := Boot(context.Background(), fd, testLog())
163 require.NoError(t, err)164 require.NoError(t, err)
164 x.CloseConn()165 x.CloseConn()
165 }166 }
@@ -167,7 +168,7 @@ func TestBootOptionalExtensions(t *testing.T) {
167 func TestBootConnectFailure(t *testing.T) {168 func TestBootConnectFailure(t *testing.T) {
168 fd, p := startXPeer(t, fullPeerExts())169 fd, p := startXPeer(t, fullPeerExts())
169 p.hangup()170 p.hangup()
170- _, err := Boot(fd, testLog())171+ _, err := Boot(context.Background(), fd, testLog())
171 require.Error(t, err)172 require.Error(t, err)
172 }173 }
173 174
M internal/adapters/xwayland/builtin.go +17 -2
@@ -88,7 +88,8 @@ func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter)
8888 }
8989 files[wl].Close()
9090 done := make(chan error, 1)
91- go func() { done <- cmd.Wait() }()
91+ procGone := make(chan struct{})
92+ go func() { done <- cmd.Wait(); close(procGone) }()
9293 if err != nil {
9394 stopGroup(cmd, done)
9495 return err
@@ -115,7 +116,21 @@ func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter)
115116 stopGroup(cmd, done)
116117 return err
117118 }
118- stop := d.opts.AttachWM(wmFD)
119+ // The context ends with the process or the caller's context, so a
120+ // bootstrap against a dead or silent Xwayland aborts at once.
121+ wmCtx, cancel := context.WithCancel(ctx)
122+ watched := make(chan struct{})
123+ go func() {
124+ defer close(watched)
125+ select {
126+ case <-procGone:
127+ cancel()
128+ case <-wmCtx.Done():
129+ }
130+ }()
131+ stop := d.opts.AttachWM(wmCtx, wmFD)
132+ cancel()
133+ <-watched
119134 if stop != nil {
120135 // Runs before start returns so the restart loop never overlaps two XWMs.
121136 defer stop()
@@ -88,7 +88,8 @@ func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter)
88 }88 }
89 files[wl].Close()89 files[wl].Close()
90 done := make(chan error, 1)90 done := make(chan error, 1)
91- go func() { done <- cmd.Wait() }()91+ procGone := make(chan struct{})
92+ go func() { done <- cmd.Wait(); close(procGone) }()
92 if err != nil {93 if err != nil {
93 stopGroup(cmd, done)94 stopGroup(cmd, done)
94 return err95 return err
@@ -115,7 +116,21 @@ func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter)
115 stopGroup(cmd, done)116 stopGroup(cmd, done)
116 return err117 return err
117 }118 }
118- stop := d.opts.AttachWM(wmFD)119+ // The context ends with the process or the caller's context, so a
120+ // bootstrap against a dead or silent Xwayland aborts at once.
121+ wmCtx, cancel := context.WithCancel(ctx)
122+ watched := make(chan struct{})
123+ go func() {
124+ defer close(watched)
125+ select {
126+ case <-procGone:
127+ cancel()
128+ case <-wmCtx.Done():
129+ }
130+ }()
131+ stop := d.opts.AttachWM(wmCtx, wmFD)
132+ cancel()
133+ <-watched
119 if stop != nil {134 if stop != nil {
120 // Runs before start returns so the restart loop never overlaps two XWMs.135 // Runs before start returns so the restart loop never overlaps two XWMs.
121 defer stop()136 defer stop()
M internal/adapters/xwayland/xwayland.go +5 -4
@@ -28,10 +28,11 @@ type Options struct {
2828 // starts.
2929 Attach func(wlFD int)
3030 // 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())
31+ // backend is ready. It may block to bootstrap the X11 connection; ctx ends
32+ // when the backend exits or the run is cancelled, and the bootstrap must
33+ // then give up. The returned stop (nil for none) is called before the run
34+ // ends, on backend exit or context cancellation.
35+ AttachWM func(ctx context.Context, wmFD int) (stop func())
3536 // Binary is the selected backend executable.
3637 Binary string
3738 // Env is the backend environment; satellite requires WAYLAND_DISPLAY.
@@ -28,10 +28,11 @@ type Options struct {
28 // starts.28 // starts.
29 Attach func(wlFD int)29 Attach func(wlFD int)
30 // AttachWM takes ownership of the window manager descriptor once the30 // AttachWM takes ownership of the window manager descriptor once the
31- // backend is ready. It may block to bootstrap the X11 connection. The31+ // backend is ready. It may block to bootstrap the X11 connection; ctx ends
32- // returned stop (nil for none) is called before the run ends, on backend32+ // when the backend exits or the run is cancelled, and the bootstrap must
33- // exit or context cancellation.33+ // then give up. The returned stop (nil for none) is called before the run
34- AttachWM func(wmFD int) (stop func())34+ // ends, on backend exit or context cancellation.
35+ AttachWM func(ctx context.Context, wmFD int) (stop func())
35 // Binary is the selected backend executable.36 // Binary is the selected backend executable.
36 Binary string37 Binary string
37 // Env is the backend environment; satellite requires WAYLAND_DISPLAY.38 // Env is the backend environment; satellite requires WAYLAND_DISPLAY.
M internal/adapters/xwayland/xwayland_test.go +30 -5
@@ -388,7 +388,7 @@ func readyScript(t *testing.T, dir string, ready bool, tail string) (bin, marker
388388 return bin, marker
389389 }
390390
391-func runBuiltin(t *testing.T, ctx context.Context, ready bool, tail string, attachWM func(wmFD int)) (events []string) {
391+func runBuiltin(t *testing.T, ctx context.Context, ready bool, tail string, attachWM func(ctx context.Context, wmFD int)) (events []string) {
392392 t.Helper()
393393 opts := testOptions(t, "")
394394 opts.Backend = ports.XwaylandBuiltin
@@ -402,12 +402,12 @@ func runBuiltin(t *testing.T, ctx context.Context, ready bool, tail string, atta
402402 events = append(events, "attach")
403403 unix.Close(wlFD)
404404 }
405- opts.AttachWM = func(wmFD int) func() {
405+ opts.AttachWM = func(ctx context.Context, wmFD int) func() {
406406 if !exists() {
407407 t.Error("AttachWM ran before readiness")
408408 }
409409 events = append(events, "attachWM")
410- attachWM(wmFD)
410+ attachWM(ctx, wmFD)
411411 return func() { events = append(events, "stop") }
412412 }
413413 d, err := Open(opts)
@@ -429,7 +429,7 @@ func TestBuiltinAttachWMAfterReadiness(t *testing.T) {
429429 t.Run(name, func(t *testing.T) {
430430 ctx, cancel := context.WithCancel(t.Context())
431431 defer cancel()
432- events := runBuiltin(t, ctx, true, tc.tail, func(wmFD int) {
432+ events := runBuiltin(t, ctx, true, tc.tail, func(_ context.Context, wmFD int) {
433433 if _, err := unix.FcntlInt(uintptr(wmFD), unix.F_GETFD, 0); err != nil {
434434 t.Errorf("wmFD not open: %v", err)
435435 }
@@ -447,8 +447,33 @@ func TestBuiltinAttachWMAfterReadiness(t *testing.T) {
447447
448448 // A process that exits before reporting readiness never reaches AttachWM.
449449 func TestBuiltinNoAttachWMWithoutReadiness(t *testing.T) {
450- events := runBuiltin(t, t.Context(), false, "exit 1", func(int) {})
450+ events := runBuiltin(t, t.Context(), false, "exit 1", func(context.Context, int) {})
451451 if got := strings.Join(events, ","); got != "attach,returned" {
452452 t.Fatalf("events %s", got)
453453 }
454454 }
455+
456+// The AttachWM context ends when the process dies, so a bootstrap against a
457+// dead Xwayland gives up at once instead of waiting out its handshake.
458+func TestBuiltinAttachWMContextEndsWithProcess(t *testing.T) {
459+ ended := make(chan error, 1)
460+ start := time.Now()
461+ events := runBuiltin(t, t.Context(), true, "sleep 0.3", func(ctx context.Context, wmFD int) {
462+ defer unix.Close(wmFD)
463+ select {
464+ case <-ctx.Done():
465+ ended <- ctx.Err()
466+ case <-time.After(5 * time.Second):
467+ ended <- nil
468+ }
469+ })
470+ if err := <-ended; err == nil {
471+ t.Fatal("AttachWM context did not end with the process")
472+ }
473+ if time.Since(start) > 3*time.Second {
474+ t.Fatalf("took %v", time.Since(start))
475+ }
476+ if got := strings.Join(events, ","); got != "attach,attachWM,stop,returned" {
477+ t.Fatalf("events %s", got)
478+ }
479+}
@@ -388,7 +388,7 @@ func readyScript(t *testing.T, dir string, ready bool, tail string) (bin, marker
388 return bin, marker388 return bin, marker
389 }389 }
390 390
391-func runBuiltin(t *testing.T, ctx context.Context, ready bool, tail string, attachWM func(wmFD int)) (events []string) {391+func runBuiltin(t *testing.T, ctx context.Context, ready bool, tail string, attachWM func(ctx context.Context, wmFD int)) (events []string) {
392 t.Helper()392 t.Helper()
393 opts := testOptions(t, "")393 opts := testOptions(t, "")
394 opts.Backend = ports.XwaylandBuiltin394 opts.Backend = ports.XwaylandBuiltin
@@ -402,12 +402,12 @@ func runBuiltin(t *testing.T, ctx context.Context, ready bool, tail string, atta
402 events = append(events, "attach")402 events = append(events, "attach")
403 unix.Close(wlFD)403 unix.Close(wlFD)
404 }404 }
405- opts.AttachWM = func(wmFD int) func() {405+ opts.AttachWM = func(ctx context.Context, wmFD int) func() {
406 if !exists() {406 if !exists() {
407 t.Error("AttachWM ran before readiness")407 t.Error("AttachWM ran before readiness")
408 }408 }
409 events = append(events, "attachWM")409 events = append(events, "attachWM")
410- attachWM(wmFD)410+ attachWM(ctx, wmFD)
411 return func() { events = append(events, "stop") }411 return func() { events = append(events, "stop") }
412 }412 }
413 d, err := Open(opts)413 d, err := Open(opts)
@@ -429,7 +429,7 @@ func TestBuiltinAttachWMAfterReadiness(t *testing.T) {
429 t.Run(name, func(t *testing.T) {429 t.Run(name, func(t *testing.T) {
430 ctx, cancel := context.WithCancel(t.Context())430 ctx, cancel := context.WithCancel(t.Context())
431 defer cancel()431 defer cancel()
432- events := runBuiltin(t, ctx, true, tc.tail, func(wmFD int) {432+ events := runBuiltin(t, ctx, true, tc.tail, func(_ context.Context, wmFD int) {
433 if _, err := unix.FcntlInt(uintptr(wmFD), unix.F_GETFD, 0); err != nil {433 if _, err := unix.FcntlInt(uintptr(wmFD), unix.F_GETFD, 0); err != nil {
434 t.Errorf("wmFD not open: %v", err)434 t.Errorf("wmFD not open: %v", err)
435 }435 }
@@ -447,8 +447,33 @@ func TestBuiltinAttachWMAfterReadiness(t *testing.T) {
447 447
448 // A process that exits before reporting readiness never reaches AttachWM.448 // A process that exits before reporting readiness never reaches AttachWM.
449 func TestBuiltinNoAttachWMWithoutReadiness(t *testing.T) {449 func TestBuiltinNoAttachWMWithoutReadiness(t *testing.T) {
450- events := runBuiltin(t, t.Context(), false, "exit 1", func(int) {})450+ events := runBuiltin(t, t.Context(), false, "exit 1", func(context.Context, int) {})
451 if got := strings.Join(events, ","); got != "attach,returned" {451 if got := strings.Join(events, ","); got != "attach,returned" {
452 t.Fatalf("events %s", got)452 t.Fatalf("events %s", got)
453 }453 }
454 }454 }
455+
456+// The AttachWM context ends when the process dies, so a bootstrap against a
457+// dead Xwayland gives up at once instead of waiting out its handshake.
458+func TestBuiltinAttachWMContextEndsWithProcess(t *testing.T) {
459+ ended := make(chan error, 1)
460+ start := time.Now()
461+ events := runBuiltin(t, t.Context(), true, "sleep 0.3", func(ctx context.Context, wmFD int) {
462+ defer unix.Close(wmFD)
463+ select {
464+ case <-ctx.Done():
465+ ended <- ctx.Err()
466+ case <-time.After(5 * time.Second):
467+ ended <- nil
468+ }
469+ })
470+ if err := <-ended; err == nil {
471+ t.Fatal("AttachWM context did not end with the process")
472+ }
473+ if time.Since(start) > 3*time.Second {
474+ t.Fatalf("took %v", time.Since(start))
475+ }
476+ if got := strings.Join(events, ","); got != "attach,attachWM,stop,returned" {
477+ t.Fatalf("events %s", got)
478+ }
479+}
M internal/app/app.go +1 -1
@@ -191,7 +191,7 @@ func logScenes(log zerowrap.Logger, set []ports.Scene) {
191191
192192 // openXwayland reserves an X11 display for the selected backend, or returns
193193 // nil when X11 is off or unavailable: the session runs without it.
194-func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger, attach func(int), attachWM func(int) func()) *xwayland.Display {
194+func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger, attach func(int), attachWM func(context.Context, int) func()) *xwayland.Display {
195195 cfg := opts.Config.Xwayland
196196 bin := cfg.Binary
197197 if cfg.Backend == ports.XwaylandOff || opts.NoXwayland {
@@ -191,7 +191,7 @@ func logScenes(log zerowrap.Logger, set []ports.Scene) {
191 191
192 // openXwayland reserves an X11 display for the selected backend, or returns192 // openXwayland reserves an X11 display for the selected backend, or returns
193 // 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.
194-func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger, attach func(int), attachWM func(int) func()) *xwayland.Display {194+func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger, attach func(int), attachWM func(context.Context, int) func()) *xwayland.Display {
195 cfg := opts.Config.Xwayland195 cfg := opts.Config.Xwayland
196 bin := cfg.Binary196 bin := cfg.Binary
197 if cfg.Backend == ports.XwaylandOff || opts.NoXwayland {197 if cfg.Backend == ports.XwaylandOff || opts.NoXwayland {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close