#21 WIP: feat(xwayland): add built-in Xwayland launcher

state
draft
branch
feat/xwayland-builtin → main
opened
bnema ·
updated
CommitSubjectAuthorDateSignatureStatus
8545a93Merge branch 'fix/hdr-sdr-plane' into feat/xwayland-builtinbriceGPG Verified
8342bf4Merge branch 'fix/x11-fullscreen-restore' into feat/xwayland-builtinbriceGPG Verified
7f7b429fix(drm): compose SDR buffers on HDR outputs until the plane pipeline is verifiedbriceGPG Verified
61f9ab3fix(core): restore fullscreen when focus returns to a window that left itbriceGPG Verified
41da69cfix(core): honour the fullscreen state a window maps withbriceGPG Verified
5eb9885test(xwm): make sinkPeer deterministicbriceGPG Verified
13ff6acfix(xwm): announce scale 1 to X11 clients while Xwayland runs at logical sizebriceGPG Verified
f2f4074test(xwm): measure the Wayland-to-X alloc path through onSelectionRequestbriceGPG Verified
04b38e5feat(xwm): answer TIMESTAMP and list it in TARGETSbriceGPG Verified
6168668fix(xwm): harden selection transfers on close, late replies and theme truncationbriceGPG Verified
8fb84bfrefactor(xwm): replace addFD/after function seams with a mockery-generated loop interfacebriceGPG Verified
07dab5dfix(xwm): never adopt the XSETTINGS window as a client windowbriceGPG Verified
a50e31efix(xwm): drop stale TARGETS replies after Wayland takes ownershipbriceGPG Verified
4afca7adocs(xwayland): clipboard bridge, XSETTINGS and root cursorbriceGPG Verified
def433btest(app): e2e clipboard bridge, XSETTINGS and root cursor on the built-in XWMbriceGPG Verified
3e19081Merge branch 'feat/x10' into feat/xwayland-builtinbriceGPG Verified
d7ef3dbMerge branch 'feat/x9' into feat/xwayland-builtinbriceGPG Verified
9bbf554feat(xwm): XSETTINGS manager, xwayland.xsettings key and root cursorbriceGPG Verified
46fd2a1Merge branch 'feat/x8' into feat/xwayland-builtinbriceGPG Verified
1f67695feat(wayland): Wayland to X text selection with INCR and X-owned clipboard sourcesbriceGPG Verified
3df83c3docs(xwayland): describe fullscreen, keyboard grab, output hint and steam_app rulesbriceGPG Verified
8c66911feat(xwm): X to Wayland text selection transfers with INCR, buffer pool and timeoutsbriceGPG Verified
ad55fe8feat(xwm): host timer seam After for selection timeoutsbriceGPG Verified
41edcc9feat(xwm): selection window, XFixes ownership events and TARGETS probebriceGPG Verified
d71e4a4test(app): cover X11 fullscreen, keyboard grab, output hint and class rules end to endbriceGPG Verified
43a1ce7test(wayland): wait for the output change before counting surface entersbriceGPG Verified
1b0bfc7fix(core): keep rule workspaces ahead of the X11 output hint and read the keyboard grab once per syncbriceGPG Verified
56a0abdMerge branch 'feat/x8-pointer' into feat/x8briceGPG Verified
b77ab13Merge branch 'feat/x8-grab' into feat/x8briceGPG Verified
224cf30feat(xwm): place output-sized X11 windows on their output, read bypass hint, steam_app rulesbriceGPG Verified
c5b7312docs(xwayland): state that builtin shows menus, popups and tooltipsbriceGPG Verified
864c005test(wayland): cover X11 pointer lock, confine, relative motion and single-output enterbriceGPG Verified
65eceabfeat(wayland): serve zwp_xwayland_keyboard_grab as a fullscreen shortcuts inhibitorbriceGPG Verified
47739datest(app): cover override-redirect X11 windows end to endbriceGPG Verified
dc722bbrefactor(xwm): share one Space with the wayland server and drop dead codebriceGPG Verified
8ab1d07fix(core): clear pointer and grab when an unmanaged window unmapsbriceGPG Verified
b3f6b3afeat(wayland): host override-redirect X11 windows as unmanaged windowsbriceGPG Verified
27b8b9eMerge branch 'feat/x7-xdgoutput' into feat/x7briceGPG Verified
83719a6Merge branch 'feat/x7-core' into feat/x7briceGPG Verified
7ef926ffeat(xwm): convert Place through Space and track override-redirect windowsbriceGPG Verified
5a832c8feat(xwm): add Space mapping layout coordinates to X coordinatesbriceGPG Verified
305c2c8feat(wayland): private X-space xdg-output manager for XwaylandbriceGPG Verified
bf24250feat(core): place, draw and hit-test unmanaged X11 windowsbriceGPG Verified
58a7346refactor(xwm): log counters on close, drop the test-only Slot lookup and state atoms in present tensebriceGPG Verified
80258b6docs(xwayland): describe the built-in window manager and the xwayland e2e checkbriceGPG Verified
b64a207test(app): add headless Xwayland end-to-end test with an x11probe clientbriceGPG Verified
ace7dd1fix(wayland): forward X11 pointer constraints and modal changes, remap on surface replacement, clear dangling parentsbriceGPG Verified
24f47ebfeat(wayland): integrate the built-in XWM with the serverbriceGPG Verified
e1de5a5test(xwm): replace the sinkPeer sleep with a peer lock barrierbriceGPG Verified
9439437fix(xwm): stop _NET_WM_STATE feedback, honour pre-ready geometry, drop stale map repliesbriceGPG Verified
3ccf649Merge branch 'feat/x6-configure' into feat/x6-xwmbriceGPG Verified
ce78abcfeat(xwm): add map flow, property decoding, serial pairing and reparent handlingbriceGPG Verified
5021c6ctest(xwm): fix staticcheck findings in configure testsbriceGPG Verified
50a2e95docs(xwm): state that Boot takes a component-scoped loggerbriceGPG Verified
f1a2966feat(xwm): add configure, focus, close and stacking lists for X11 windowsbriceGPG Verified
01f9a3dfix(xwm): check root ownership before claiming WM state and never adopt the check windowbriceGPG Verified
69b4382feat(xwm): add window table, boot and fd flush skeleton for the built-in X11 window managerbriceGPG Verified
3ed03aabuild(deps): add bnema.dev/xturbo v0.1.0briceGPG Verified
7646086Merge branch 'feat/x6-place' into feat/xwayland-builtinbriceGPG Verified
aa24d35Merge branch 'feat/x6-window-role' into feat/xwayland-builtinbriceGPG Verified
4eada3btest(xwayland): assert AttachWM is skipped when Xwayland exits before readinessbriceGPG Verified
04fea8dtest(vulkan): skip purego allocation budgets under the race runtimebriceGPG Verified
cac0ab6feat(core): send PlaceWindow for positioned windows on position changebriceGPG Verified
957ec9crefactor(wayland): route window access through role-neutral accessorsbriceGPG Verified
57e2ff1refactor(xwayland): split attachment into Wayland fd at start and WM fd after readinessbriceGPG Verified
db0a693docs(xwayland): state that the built-in backend does not manage X11 windowsbriceGPG Verified
e519f1fbuild(deps): bump purego-libwayland to v0.12.0 and purego to v0.13.0-bnema.2briceGPG Verified
a97cce0docs(xwayland): describe the built-in backend and drop the launcher handoff notebriceGPG Verified
e82a03efeat(xwayland): add experimental built-in launcher and client attachmentbriceGPG Verified

Checks 1

note: a failing or unfinished check blocks the merge.

107 files · +18450 -160
1/1
M .mockery.yml +30 -0
M Makefile +9 -2
M README.md +1 -1
M docs/config.md +9 -3
M docs/desktop.md +12 -1
M docs/dev/headless.md +25 -0
M go.mod +4 -3
M go.sum +8 -6
M internal/adapters/config/config.go +18 -4
M internal/adapters/config/config_test.go +36 -4
M internal/adapters/drm/colorop_match.go +11 -0
A internal/adapters/drm/sdrplane_test.go +30 -0
M internal/adapters/vulkan/capture_test.go +3 -0
A internal/adapters/vulkan/norace_test.go +5 -0
A internal/adapters/vulkan/race_test.go +8 -0
M internal/adapters/vulkan/scene_perf_test.go +6 -0
M internal/adapters/wayland/activation.go +3 -3
M internal/adapters/wayland/alloc_hotpath_test.go +9 -0
M internal/adapters/wayland/clipboard.go +5 -0
A internal/adapters/wayland/clipboard_xwm.go +93 -0
A internal/adapters/wayland/clipboard_xwm_test.go +147 -0
M internal/adapters/wayland/constraints.go +14 -13
M internal/adapters/wayland/decoration.go +1 -1
M internal/adapters/wayland/dialog.go +1 -1
M internal/adapters/wayland/dmabuf.go +3 -2
M internal/adapters/wayland/extforeigntoplevel.go +1 -1
@@ -72,7 +72,7 @@ func (*extToplevelList) Destroy(*ext.ExtForeignToplevelListV1) {}
7272 func (s *Server) mappedToplevels() []ports.WindowID {
7373 ids := make([]ports.WindowID, 0, len(s.windows))
7474 for id, w := range s.windows {
75- if w.toplevel != nil && w.mapped {
75+ if w.isToplevel() && w.mapped {
7676 ids = append(ids, id)
7777 }
7878 }
@@ -72,7 +72,7 @@ func (*extToplevelList) Destroy(*ext.ExtForeignToplevelListV1) {}
72 func (s *Server) mappedToplevels() []ports.WindowID {72 func (s *Server) mappedToplevels() []ports.WindowID {
73 ids := make([]ports.WindowID, 0, len(s.windows))73 ids := make([]ports.WindowID, 0, len(s.windows))
74 for id, w := range s.windows {74 for id, w := range s.windows {
75- if w.toplevel != nil && w.mapped {75+ if w.isToplevel() && w.mapped {
76 ids = append(ids, id)76 ids = append(ids, id)
77 }77 }
78 }78 }
M internal/adapters/wayland/foreigntoplevel.go +6 -6
@@ -44,7 +44,7 @@ func registerForeignToplevel(d *server.Display, s *Server) error {
4444 r.OnDestroy = func() { s.toplevelManagers = removeItem(s.toplevelManagers, m) }
4545 ids := make([]ports.WindowID, 0, len(s.windows))
4646 for id, w := range s.windows {
47- if w.toplevel != nil && w.mapped {
47+ if w.isToplevel() && w.mapped {
4848 ids = append(ids, id)
4949 }
5050 }
@@ -186,7 +186,7 @@ func toplevelState(w *window, version int32) []byte {
186186
187187 // toplevelChanged updates every manager after w mapped or changed.
188188 func (s *Server) toplevelChanged(w *window) {
189- if w.toplevel == nil || !w.mapped {
189+ if !w.isToplevel() || !w.mapped {
190190 return
191191 }
192192 for _, m := range s.toplevelManagers {
@@ -220,7 +220,7 @@ func (s *Server) refreshToplevels() {
220220 for _, l := range s.extToplevelLists {
221221 for id, h := range l.handles {
222222 w := s.windows[id]
223- if h.closed || w == nil || w.toplevel == nil || !w.mapped {
223+ if h.closed || w == nil || !w.isToplevel() || !w.mapped {
224224 l.closed(id)
225225 }
226226 }
@@ -231,7 +231,7 @@ func (s *Server) refreshToplevels() {
231231 for _, m := range s.toplevelManagers {
232232 for id, h := range m.handles {
233233 w := s.windows[id]
234- if h.req.closed || w == nil || w.toplevel == nil || !w.mapped {
234+ if h.req.closed || w == nil || !w.isToplevel() || !w.mapped {
235235 m.closed(id)
236236 }
237237 }
@@ -253,7 +253,7 @@ func (t *toplevelRequests) window() *window {
253253 if t.s.protected() || t.closed {
254254 return nil
255255 }
256- if w := t.s.windows[t.id]; w != nil && w.toplevel != nil && w.mapped && w.toplevel.Resource.Alive() {
256+ if w := t.s.windows[t.id]; w != nil && w.toplevelAlive() && w.mapped {
257257 return w
258258 }
259259 return nil
@@ -274,7 +274,7 @@ func (t *toplevelRequests) Activate(*wlr.ZwlrForeignToplevelHandleV1, *wayland.S
274274 }
275275 func (t *toplevelRequests) Close(*wlr.ZwlrForeignToplevelHandleV1) {
276276 if w := t.window(); w != nil {
277- w.toplevel.SendClose()
277+ w.sendClose()
278278 }
279279 }
280280 func (t *toplevelRequests) SetRectangle(r *wlr.ZwlrForeignToplevelHandleV1, _ *wayland.Surface, _, _, w, h int32) {
@@ -44,7 +44,7 @@ func registerForeignToplevel(d *server.Display, s *Server) error {
44 r.OnDestroy = func() { s.toplevelManagers = removeItem(s.toplevelManagers, m) }44 r.OnDestroy = func() { s.toplevelManagers = removeItem(s.toplevelManagers, m) }
45 ids := make([]ports.WindowID, 0, len(s.windows))45 ids := make([]ports.WindowID, 0, len(s.windows))
46 for id, w := range s.windows {46 for id, w := range s.windows {
47- if w.toplevel != nil && w.mapped {47+ if w.isToplevel() && w.mapped {
48 ids = append(ids, id)48 ids = append(ids, id)
49 }49 }
50 }50 }
@@ -186,7 +186,7 @@ func toplevelState(w *window, version int32) []byte {
186 186
187 // toplevelChanged updates every manager after w mapped or changed.187 // toplevelChanged updates every manager after w mapped or changed.
188 func (s *Server) toplevelChanged(w *window) {188 func (s *Server) toplevelChanged(w *window) {
189- if w.toplevel == nil || !w.mapped {189+ if !w.isToplevel() || !w.mapped {
190 return190 return
191 }191 }
192 for _, m := range s.toplevelManagers {192 for _, m := range s.toplevelManagers {
@@ -220,7 +220,7 @@ func (s *Server) refreshToplevels() {
220 for _, l := range s.extToplevelLists {220 for _, l := range s.extToplevelLists {
221 for id, h := range l.handles {221 for id, h := range l.handles {
222 w := s.windows[id]222 w := s.windows[id]
223- if h.closed || w == nil || w.toplevel == nil || !w.mapped {223+ if h.closed || w == nil || !w.isToplevel() || !w.mapped {
224 l.closed(id)224 l.closed(id)
225 }225 }
226 }226 }
@@ -231,7 +231,7 @@ func (s *Server) refreshToplevels() {
231 for _, m := range s.toplevelManagers {231 for _, m := range s.toplevelManagers {
232 for id, h := range m.handles {232 for id, h := range m.handles {
233 w := s.windows[id]233 w := s.windows[id]
234- if h.req.closed || w == nil || w.toplevel == nil || !w.mapped {234+ if h.req.closed || w == nil || !w.isToplevel() || !w.mapped {
235 m.closed(id)235 m.closed(id)
236 }236 }
237 }237 }
@@ -253,7 +253,7 @@ func (t *toplevelRequests) window() *window {
253 if t.s.protected() || t.closed {253 if t.s.protected() || t.closed {
254 return nil254 return nil
255 }255 }
256- if w := t.s.windows[t.id]; w != nil && w.toplevel != nil && w.mapped && w.toplevel.Resource.Alive() {256+ if w := t.s.windows[t.id]; w != nil && w.toplevelAlive() && w.mapped {
257 return w257 return w
258 }258 }
259 return nil259 return nil
@@ -274,7 +274,7 @@ func (t *toplevelRequests) Activate(*wlr.ZwlrForeignToplevelHandleV1, *wayland.S
274 }274 }
275 func (t *toplevelRequests) Close(*wlr.ZwlrForeignToplevelHandleV1) {275 func (t *toplevelRequests) Close(*wlr.ZwlrForeignToplevelHandleV1) {
276 if w := t.window(); w != nil {276 if w := t.window(); w != nil {
277- w.toplevel.SendClose()277+ w.sendClose()
278 }278 }
279 }279 }
280 func (t *toplevelRequests) SetRectangle(r *wlr.ZwlrForeignToplevelHandleV1, _ *wayland.Surface, _, _, w, h int32) {280 func (t *toplevelRequests) SetRectangle(r *wlr.ZwlrForeignToplevelHandleV1, _ *wayland.Surface, _, _, w, h int32) {
M internal/adapters/wayland/frames.go +10 -6
@@ -183,7 +183,7 @@ func (s *Server) pacePass(flipped map[string]bool, reports []ports.OutputPresent
183183 func toplevelRoot(surf *surface) *surface {
184184 surf = surf.root()
185185 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {
186- surf = surf.xdg.window.popup.parent.xdg.surface.root()
186+ surf = surf.xdg.window.popup.parent.surf().root()
187187 }
188188 return surf
189189 }
@@ -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,13 @@ 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.x11win != nil && surf.x11win.isOverride():
218+ return s.overrideOutput(surf.x11win)
219+ case surf.toplevelWindow() != nil:
216220 if s.suspended(surf) {
217221 return suspendedFrameQueue
218222 }
219- return surf.xdg.window.last.Output
223+ return surf.toplevelWindow().last.Output
220224 case surf.layer != nil && surf.layer.output != nil:
221225 return surf.layer.output.name()
222226 case surf.lock != nil && surf.lock.output != nil && !surf.lock.closed:
@@ -183,7 +183,7 @@ func (s *Server) pacePass(flipped map[string]bool, reports []ports.OutputPresent
183 func toplevelRoot(surf *surface) *surface {183 func toplevelRoot(surf *surface) *surface {
184 surf = surf.root()184 surf = surf.root()
185 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {185 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {
186- surf = surf.xdg.window.popup.parent.xdg.surface.root()186+ surf = surf.xdg.window.popup.parent.surf().root()
187 }187 }
188 return surf188 return surf
189 }189 }
@@ -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,13 @@ 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.x11win != nil && surf.x11win.isOverride():
218+ return s.overrideOutput(surf.x11win)
219+ case surf.toplevelWindow() != nil:
216 if s.suspended(surf) {220 if s.suspended(surf) {
217 return suspendedFrameQueue221 return suspendedFrameQueue
218 }222 }
219- return surf.xdg.window.last.Output223+ return surf.toplevelWindow().last.Output
220 case surf.layer != nil && surf.layer.output != nil:224 case surf.layer != nil && surf.layer.output != nil:
221 return surf.layer.output.name()225 return surf.layer.output.name()
222 case surf.lock != nil && surf.lock.output != nil && !surf.lock.closed:226 case surf.lock != nil && surf.lock.output != nil && !surf.lock.closed:
M internal/adapters/wayland/globals.go +11 -1
@@ -13,6 +13,8 @@ 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) },
17+ func() error { return registerXwaylandGrab(d, s) },
1618 func() error { return registerLayer(d, s) },
1719 func() error {
1820 // Advertise only with the production owner gate and authoritative
@@ -131,8 +133,16 @@ func (c compositor) CreateSurface(r *wayland.Compositor, id uint32) {
131133 state.identity = c.server.nextSurface
132134 state.wl = w
133135 c.server.surfaces[w.Resource] = state
136+ c.server.surfaceByIdentity[state.identity] = state
134137 state.sendScale()
135- w.OnDestroy = func() { delete(c.server.surfaces, w.Resource); state.Destroy(w) }
138+ w.OnDestroy = func() {
139+ delete(c.server.surfaces, w.Resource)
140+ delete(c.server.surfaceByIdentity, state.identity)
141+ state.Destroy(w)
142+ if state.kind == roleX11 && c.server.xwm != nil {
143+ c.server.xwm.SurfaceGone(state.identity)
144+ }
145+ }
136146 }
137147 }
138148 func (c compositor) CreateRegion(r *wayland.Compositor, id uint32) {
@@ -13,6 +13,8 @@ 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) },
17+ func() error { return registerXwaylandGrab(d, s) },
16 func() error { return registerLayer(d, s) },18 func() error { return registerLayer(d, s) },
17 func() error {19 func() error {
18 // Advertise only with the production owner gate and authoritative20 // Advertise only with the production owner gate and authoritative
@@ -131,8 +133,16 @@ func (c compositor) CreateSurface(r *wayland.Compositor, id uint32) {
131 state.identity = c.server.nextSurface133 state.identity = c.server.nextSurface
132 state.wl = w134 state.wl = w
133 c.server.surfaces[w.Resource] = state135 c.server.surfaces[w.Resource] = state
136+ c.server.surfaceByIdentity[state.identity] = state
134 state.sendScale()137 state.sendScale()
135- w.OnDestroy = func() { delete(c.server.surfaces, w.Resource); state.Destroy(w) }138+ w.OnDestroy = func() {
139+ delete(c.server.surfaces, w.Resource)
140+ delete(c.server.surfaceByIdentity, state.identity)
141+ state.Destroy(w)
142+ if state.kind == roleX11 && c.server.xwm != nil {
143+ c.server.xwm.SurfaceGone(state.identity)
144+ }
145+ }
136 }146 }
137 }147 }
138 func (c compositor) CreateRegion(r *wayland.Compositor, id uint32) {148 func (c compositor) CreateRegion(r *wayland.Compositor, id uint32) {
M internal/adapters/wayland/imagecopy.go +1 -1
@@ -351,7 +351,7 @@ func (c *captureTarget) geometry() (o *output, rect image.Rectangle, hidden, ok
351351 case srcWindow:
352352 // Core's state gives the size; before it, the last configure.
353353 w := c.s.windows[src.window]
354- if w == nil || !w.mapped || w.toplevel == nil || w.gen != src.gen {
354+ if w == nil || !w.mapped || !w.isToplevel() || w.gen != src.gen {
355355 return nil, image.Rectangle{}, false, false
356356 }
357357 name, size := w.last.Output, ports.Rect{W: w.last.Width, H: w.last.Height}
@@ -351,7 +351,7 @@ func (c *captureTarget) geometry() (o *output, rect image.Rectangle, hidden, ok
351 case srcWindow:351 case srcWindow:
352 // Core's state gives the size; before it, the last configure.352 // Core's state gives the size; before it, the last configure.
353 w := c.s.windows[src.window]353 w := c.s.windows[src.window]
354- if w == nil || !w.mapped || w.toplevel == nil || w.gen != src.gen {354+ if w == nil || !w.mapped || !w.isToplevel() || w.gen != src.gen {
355 return nil, image.Rectangle{}, false, false355 return nil, image.Rectangle{}, false, false
356 }356 }
357 name, size := w.last.Output, ports.Rect{W: w.last.Width, H: w.last.Height}357 name, size := w.last.Output, ports.Rect{W: w.last.Width, H: w.last.Height}
M internal/adapters/wayland/inhibit.go +5 -0
@@ -121,6 +121,11 @@ func (s *Server) syncInhibitors() {
121121 idle[in.window] = true
122122 }
123123 }
124+ // syncKeyboardGrab compares against xgrabbed: it is only written here.
125+ s.xgrabbed = s.xgrabWindow()
126+ if s.xgrabbed != 0 {
127+ shortcuts[s.xgrabbed] = true
128+ }
124129 for id, on := range diffSets(s.shortcutWindows, shortcuts) {
125130 s.emit(ports.ShortcutsInhibit{Window: id, Active: on})
126131 }
@@ -121,6 +121,11 @@ func (s *Server) syncInhibitors() {
121 idle[in.window] = true121 idle[in.window] = true
122 }122 }
123 }123 }
124+ // syncKeyboardGrab compares against xgrabbed: it is only written here.
125+ s.xgrabbed = s.xgrabWindow()
126+ if s.xgrabbed != 0 {
127+ shortcuts[s.xgrabbed] = true
128+ }
124 for id, on := range diffSets(s.shortcutWindows, shortcuts) {129 for id, on := range diffSets(s.shortcutWindows, shortcuts) {
125 s.emit(ports.ShortcutsInhibit{Window: id, Active: on})130 s.emit(ports.ShortcutsInhibit{Window: id, Active: on})
126 }131 }
M internal/adapters/wayland/outputs.go +72 -1
@@ -42,6 +42,8 @@ type output struct {
4242 global *server.Global
4343 resources []*wayland.Output
4444 xdgOutputs []*xdgoutput.ZxdgOutputV1
45+ // xdgOutputsX are the private xdg-outputs of the Xwayland client (X space).
46+ xdgOutputsX []*xdgoutput.ZxdgOutputV1
4547 }
4648
4749 func (o *output) name() string { return o.place.Info.Name }
@@ -89,6 +91,36 @@ func (o *output) sendXDG(x *xdgoutput.ZxdgOutputV1, withName bool) {
8991 }
9092 }
9193
94+// sendXDGX sends the private xdg-output state: the layout position moved to
95+// X space and the logical size.
96+func (o *output) sendXDGX(s *Server, x *xdgoutput.ZxdgOutputV1, withName bool) {
97+ ox, oy := s.xspace.Origin()
98+ x.SendLogicalPosition(int32(o.place.X-ox), int32(o.place.Y-oy))
99+ x.SendLogicalSize(int32(o.place.Width), int32(o.place.Height))
100+ if withName && x.Version() >= 2 {
101+ x.SendName(o.name())
102+ x.SendDescription(o.description())
103+ }
104+}
105+
106+// republishXDGX resends every private xdg-output after a layout change, so
107+// Xwayland resizes the root and its RandR outputs.
108+func (s *Server) republishXDGX() {
109+ for _, o := range s.outputs {
110+ for _, x := range o.xdgOutputsX {
111+ o.sendXDGX(s, x, false)
112+ if x.Version() < 3 {
113+ x.SendDone()
114+ }
115+ }
116+ for _, r := range o.resources {
117+ if r.Client() == s.xwayland.client && r.Version() >= 2 {
118+ r.SendDone()
119+ }
120+ }
121+ }
122+}
123+
92124 // outputOf returns the output a wl_output resource belongs to, or nil for a
93125 // removed one.
94126 func (s *Server) outputOf(r *wayland.Output) *output {
@@ -162,6 +194,10 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
162194 changed := !slices.Equal(s.outputPlaces, c.Outputs)
163195 focusChanged := s.focusedOutput != c.Focused
164196 s.outputPlaces = append(ports.Layout(nil), c.Outputs...)
197+ xChanged := s.xspace.Set(s.outputPlaces)
198+ if s.xwm != nil {
199+ s.xwm.LayoutChanged(xChanged)
200+ }
165201 defer func() {
166202 if changed {
167203 s.refreshOutputManagers()
@@ -244,6 +280,9 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
244280 for _, surf := range s.surfaces {
245281 surf.sendScale()
246282 }
283+ if changed {
284+ s.republishXDGX()
285+ }
247286 s.notifyColorFeedback()
248287 // Layer surfaces sized from the output follow its new logical size.
249288 adopted := false
@@ -279,10 +318,42 @@ type outputHandler struct{}
279318
280319 func (outputHandler) Release(*wayland.Output) {}
281320
321+// registerXDGOutput registers the public zxdg_output_manager_v1 and a second,
322+// private one for Xwayland whose outputs are in X space (see globalVisible).
282323 func registerXDGOutput(d *server.Display, s *Server) error {
283- return xdgoutput.NewZxdgOutputManagerV1Global(d, 3, func(c server.Client, v, id uint32) {
324+ var err error
325+ s.xdgPublic, err = d.AddGlobal(xdgoutput.ZxdgOutputManagerV1Interface, 3, func(c server.Client, v, id uint32) {
284326 _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{s})
285327 })
328+ if err != nil {
329+ return err
330+ }
331+ s.xdgPrivate, err = d.AddGlobal(xdgoutput.ZxdgOutputManagerV1Interface, 3, func(c server.Client, v, id uint32) {
332+ _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManagerX{s})
333+ })
334+ return err
335+}
336+
337+type xdgOutputManagerX struct{ server *Server }
338+
339+func (xdgOutputManagerX) Destroy(*xdgoutput.ZxdgOutputManagerV1) {}
340+func (m xdgOutputManagerX) GetXdgOutput(r *xdgoutput.ZxdgOutputManagerV1, id uint32, wl *wayland.Output) {
341+ x, err := xdgoutput.NewZxdgOutputV1(r.Client(), r.Version(), id, xdgOutput{})
342+ if err != nil {
343+ return
344+ }
345+ o := m.server.outputOf(wl)
346+ if o == nil {
347+ return
348+ }
349+ o.xdgOutputsX = append(o.xdgOutputsX, x)
350+ x.OnDestroy = func() { o.xdgOutputsX = removeItem(o.xdgOutputsX, x) }
351+ o.sendXDGX(m.server, x, true)
352+ if r.Version() < 3 || wl.Version() < 2 {
353+ x.SendDone()
354+ } else {
355+ wl.SendDone()
356+ }
286357 }
287358
288359 type xdgOutputManager struct{ server *Server }
@@ -42,6 +42,8 @@ type output struct {
42 global *server.Global42 global *server.Global
43 resources []*wayland.Output43 resources []*wayland.Output
44 xdgOutputs []*xdgoutput.ZxdgOutputV144 xdgOutputs []*xdgoutput.ZxdgOutputV1
45+ // xdgOutputsX are the private xdg-outputs of the Xwayland client (X space).
46+ xdgOutputsX []*xdgoutput.ZxdgOutputV1
45 }47 }
46 48
47 func (o *output) name() string { return o.place.Info.Name }49 func (o *output) name() string { return o.place.Info.Name }
@@ -89,6 +91,36 @@ func (o *output) sendXDG(x *xdgoutput.ZxdgOutputV1, withName bool) {
89 }91 }
90 }92 }
91 93
94+// sendXDGX sends the private xdg-output state: the layout position moved to
95+// X space and the logical size.
96+func (o *output) sendXDGX(s *Server, x *xdgoutput.ZxdgOutputV1, withName bool) {
97+ ox, oy := s.xspace.Origin()
98+ x.SendLogicalPosition(int32(o.place.X-ox), int32(o.place.Y-oy))
99+ x.SendLogicalSize(int32(o.place.Width), int32(o.place.Height))
100+ if withName && x.Version() >= 2 {
101+ x.SendName(o.name())
102+ x.SendDescription(o.description())
103+ }
104+}
105+
106+// republishXDGX resends every private xdg-output after a layout change, so
107+// Xwayland resizes the root and its RandR outputs.
108+func (s *Server) republishXDGX() {
109+ for _, o := range s.outputs {
110+ for _, x := range o.xdgOutputsX {
111+ o.sendXDGX(s, x, false)
112+ if x.Version() < 3 {
113+ x.SendDone()
114+ }
115+ }
116+ for _, r := range o.resources {
117+ if r.Client() == s.xwayland.client && r.Version() >= 2 {
118+ r.SendDone()
119+ }
120+ }
121+ }
122+}
123+
92 // outputOf returns the output a wl_output resource belongs to, or nil for a124 // outputOf returns the output a wl_output resource belongs to, or nil for a
93 // removed one.125 // removed one.
94 func (s *Server) outputOf(r *wayland.Output) *output {126 func (s *Server) outputOf(r *wayland.Output) *output {
@@ -162,6 +194,10 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
162 changed := !slices.Equal(s.outputPlaces, c.Outputs)194 changed := !slices.Equal(s.outputPlaces, c.Outputs)
163 focusChanged := s.focusedOutput != c.Focused195 focusChanged := s.focusedOutput != c.Focused
164 s.outputPlaces = append(ports.Layout(nil), c.Outputs...)196 s.outputPlaces = append(ports.Layout(nil), c.Outputs...)
197+ xChanged := s.xspace.Set(s.outputPlaces)
198+ if s.xwm != nil {
199+ s.xwm.LayoutChanged(xChanged)
200+ }
165 defer func() {201 defer func() {
166 if changed {202 if changed {
167 s.refreshOutputManagers()203 s.refreshOutputManagers()
@@ -244,6 +280,9 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
244 for _, surf := range s.surfaces {280 for _, surf := range s.surfaces {
245 surf.sendScale()281 surf.sendScale()
246 }282 }
283+ if changed {
284+ s.republishXDGX()
285+ }
247 s.notifyColorFeedback()286 s.notifyColorFeedback()
248 // Layer surfaces sized from the output follow its new logical size.287 // Layer surfaces sized from the output follow its new logical size.
249 adopted := false288 adopted := false
@@ -279,10 +318,42 @@ type outputHandler struct{}
279 318
280 func (outputHandler) Release(*wayland.Output) {}319 func (outputHandler) Release(*wayland.Output) {}
281 320
321+// registerXDGOutput registers the public zxdg_output_manager_v1 and a second,
322+// private one for Xwayland whose outputs are in X space (see globalVisible).
282 func registerXDGOutput(d *server.Display, s *Server) error {323 func registerXDGOutput(d *server.Display, s *Server) error {
283- return xdgoutput.NewZxdgOutputManagerV1Global(d, 3, func(c server.Client, v, id uint32) {324+ var err error
325+ s.xdgPublic, err = d.AddGlobal(xdgoutput.ZxdgOutputManagerV1Interface, 3, func(c server.Client, v, id uint32) {
284 _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{s})326 _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{s})
285 })327 })
328+ if err != nil {
329+ return err
330+ }
331+ s.xdgPrivate, err = d.AddGlobal(xdgoutput.ZxdgOutputManagerV1Interface, 3, func(c server.Client, v, id uint32) {
332+ _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManagerX{s})
333+ })
334+ return err
335+}
336+
337+type xdgOutputManagerX struct{ server *Server }
338+
339+func (xdgOutputManagerX) Destroy(*xdgoutput.ZxdgOutputManagerV1) {}
340+func (m xdgOutputManagerX) GetXdgOutput(r *xdgoutput.ZxdgOutputManagerV1, id uint32, wl *wayland.Output) {
341+ x, err := xdgoutput.NewZxdgOutputV1(r.Client(), r.Version(), id, xdgOutput{})
342+ if err != nil {
343+ return
344+ }
345+ o := m.server.outputOf(wl)
346+ if o == nil {
347+ return
348+ }
349+ o.xdgOutputsX = append(o.xdgOutputsX, x)
350+ x.OnDestroy = func() { o.xdgOutputsX = removeItem(o.xdgOutputsX, x) }
351+ o.sendXDGX(m.server, x, true)
352+ if r.Version() < 3 || wl.Version() < 2 {
353+ x.SendDone()
354+ } else {
355+ wl.SendDone()
356+ }
286 }357 }
287 358
288 type xdgOutputManager struct{ server *Server }359 type xdgOutputManager struct{ server *Server }
M internal/adapters/wayland/popup.go +2 -2
@@ -31,7 +31,7 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX
3131 var parent *window
3232 if parentXDG != nil {
3333 for _, w := range s.windows {
34- if w.xdg.resource != nil && w.xdg.resource.Resource == parentXDG.Resource {
34+ if w.xdg != nil && w.xdg.resource != nil && w.xdg.resource.Resource == parentXDG.Resource {
3535 parent = w
3636 }
3737 }
@@ -166,7 +166,7 @@ func (p *popup) configure(c ports.ConfigurePopup) {
166166 func (s *Server) topGrab(client server.Client, parent *window) bool {
167167 var top *window
168168 for _, w := range s.windows {
169- if w.popup != nil && w.popup.grab && !w.popup.done && w.xdg.resource.Client() == client && (top == nil || w.id > top.id) {
169+ if w.popup != nil && w.popup.grab && !w.popup.done && w.client() == client && (top == nil || w.id > top.id) {
170170 top = w
171171 }
172172 }
@@ -31,7 +31,7 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX
31 var parent *window31 var parent *window
32 if parentXDG != nil {32 if parentXDG != nil {
33 for _, w := range s.windows {33 for _, w := range s.windows {
34- if w.xdg.resource != nil && w.xdg.resource.Resource == parentXDG.Resource {34+ if w.xdg != nil && w.xdg.resource != nil && w.xdg.resource.Resource == parentXDG.Resource {
35 parent = w35 parent = w
36 }36 }
37 }37 }
@@ -166,7 +166,7 @@ func (p *popup) configure(c ports.ConfigurePopup) {
166 func (s *Server) topGrab(client server.Client, parent *window) bool {166 func (s *Server) topGrab(client server.Client, parent *window) bool {
167 var top *window167 var top *window
168 for _, w := range s.windows {168 for _, w := range s.windows {
169- if w.popup != nil && w.popup.grab && !w.popup.done && w.xdg.resource.Client() == client && (top == nil || w.id > top.id) {169+ if w.popup != nil && w.popup.grab && !w.popup.done && w.client() == client && (top == nil || w.id > top.id) {
170 top = w170 top = w
171 }171 }
172 }172 }
M internal/adapters/wayland/scale.go +5 -3
@@ -21,12 +21,14 @@ func (s *Server) outputOfSurface(surf *surface) *output {
2121 surf = surf.root()
2222 // Popups are on their parent's output.
2323 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {
24- surf = surf.xdg.window.popup.parent.xdg.surface.root()
24+ surf = surf.xdg.window.popup.parent.surf().root()
2525 }
2626 name := ""
2727 switch {
28- case surf.xdg != nil && surf.xdg.window != nil:
29- name = surf.xdg.window.last.Output
28+ case surf.x11win != nil && surf.x11win.isOverride():
29+ name = s.overrideOutput(surf.x11win)
30+ case surf.toplevelWindow() != nil:
31+ name = surf.toplevelWindow().last.Output
3032 case surf.layer != nil && surf.layer.output != nil:
3133 return surf.layer.output
3234 case surf.lock != nil:
@@ -21,12 +21,14 @@ func (s *Server) outputOfSurface(surf *surface) *output {
21 surf = surf.root()21 surf = surf.root()
22 // Popups are on their parent's output.22 // Popups are on their parent's output.
23 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {23 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {
24- surf = surf.xdg.window.popup.parent.xdg.surface.root()24+ surf = surf.xdg.window.popup.parent.surf().root()
25 }25 }
26 name := ""26 name := ""
27 switch {27 switch {
28- case surf.xdg != nil && surf.xdg.window != nil:28+ case surf.x11win != nil && surf.x11win.isOverride():
29- name = surf.xdg.window.last.Output29+ name = s.overrideOutput(surf.x11win)
30+ case surf.toplevelWindow() != nil:
31+ name = surf.toplevelWindow().last.Output
30 case surf.layer != nil && surf.layer.output != nil:32 case surf.layer != nil && surf.layer.output != nil:
31 return surf.layer.output33 return surf.layer.output
32 case surf.lock != nil:34 case surf.lock != nil:
M internal/adapters/wayland/screencast.go +1 -1
@@ -146,7 +146,7 @@ func (s *Server) openCast(cmd ports.CastCommand) {
146146 gone := ports.CaptureReasonOutputGone
147147 if cmd.Window != 0 {
148148 gone = ports.CaptureReasonWindowGone
149- if w := s.windows[cmd.Window]; w != nil && w.mapped && w.toplevel != nil {
149+ if w := s.windows[cmd.Window]; w != nil && w.mapped && w.isToplevel() {
150150 src = &captureSource{kind: srcWindow, window: cmd.Window, gen: w.gen}
151151 open.Window = cmd.Window
152152 }
@@ -146,7 +146,7 @@ func (s *Server) openCast(cmd ports.CastCommand) {
146 gone := ports.CaptureReasonOutputGone146 gone := ports.CaptureReasonOutputGone
147 if cmd.Window != 0 {147 if cmd.Window != 0 {
148 gone = ports.CaptureReasonWindowGone148 gone = ports.CaptureReasonWindowGone
149- if w := s.windows[cmd.Window]; w != nil && w.mapped && w.toplevel != nil {149+ if w := s.windows[cmd.Window]; w != nil && w.mapped && w.isToplevel() {
150 src = &captureSource{kind: srcWindow, window: cmd.Window, gen: w.gen}150 src = &captureSource{kind: srcWindow, window: cmd.Window, gen: w.gen}
151 open.Window = cmd.Window151 open.Window = cmd.Window
152 }152 }
M internal/adapters/wayland/seat.go +11 -9
@@ -276,10 +276,10 @@ func (s *Server) setModifiers(c ports.SetModifiers) {
276276 }
277277
278278 func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {
279- if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {
279+ if w == nil || !w.mapped || !w.alive() {
280280 return nil
281281 }
282- return s.clientKeyboards(w.xdg.resource.Client())
282+ return s.clientKeyboards(w.client())
283283 }
284284
285285 // clientKeyboards returns c's wl_keyboards without allocating. The list holds
@@ -300,8 +300,8 @@ func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Ke
300300 // already dead. Admission is enforced before entering or delivering.
301301 return l.surface.wl, s.clientKeyboards(l.surface.wl.Client())
302302 }
303- if w := s.windows[id]; w != nil && w.mapped {
304- if surf := w.xdg.surfaceResource(); surf != nil {
303+ if w := s.windows[id]; w != nil && w.mapped && !w.isOverride() {
304+ if surf := w.surfaceResource(); surf != nil {
305305 return surf, s.windowKeyboards(w)
306306 }
307307 }
@@ -319,8 +319,8 @@ func (s *Server) pointerTarget(id ports.WindowID) (server.Client, []*wayland.Poi
319319 if l := s.lockSurfaces[id]; l != nil && s.lockInputTarget(id) {
320320 return l.resource.Client(), s.clientPointers(l.resource.Client()), true
321321 }
322- if w := s.windows[id]; w != nil && w.mapped && w.xdg.resource.Resource.Alive() {
323- return w.xdg.resource.Client(), s.windowPointers(w), true
322+ if w := s.windows[id]; w != nil && w.mapped && w.alive() {
323+ return w.client(), s.windowPointers(w), true
324324 }
325325 if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() {
326326 return l.resource.Client(), s.layerPointers(l), true
@@ -335,7 +335,7 @@ func (s *Server) pointerSurface(id ports.WindowID, x, y float64) (*wayland.Surfa
335335 return l.surface.wl, s.clientPointers(l.surface.wl.Client()), x, y
336336 }
337337 if w := s.windows[id]; w != nil {
338- if surf := w.xdg.surfaceResource(); surf != nil && surf.Resource.Alive() {
338+ if surf := w.surfaceResource(); surf != nil && surf.Resource.Alive() {
339339 x, y := w.surfacePoint(x, y)
340340 return surf, s.windowPointers(w), x, y
341341 }
@@ -455,6 +455,8 @@ func (s *Server) changeFocus(id ports.WindowID) {
455455 }
456456 }
457457 s.textInputFocus()
458+ s.x11Focus()
459+ s.syncKeyboardGrab()
458460 s.log.Debug().Uint64("id", uint64(s.seat.focused)).Msg("keyboard focus")
459461 s.updateConstraint()
460462 }
@@ -543,10 +545,10 @@ func pointerFrame(p *wayland.Pointer) {
543545 }
544546 }
545547 func (s *Server) windowPointers(w *window) []*wayland.Pointer {
546- if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {
548+ if w == nil || !w.mapped || !w.alive() {
547549 return nil
548550 }
549- return s.clientPointers(w.xdg.resource.Client())
551+ return s.clientPointers(w.client())
550552 }
551553
552554 func (s *Server) layerPointers(l *layerSurface) []*wayland.Pointer {
@@ -276,10 +276,10 @@ func (s *Server) setModifiers(c ports.SetModifiers) {
276 }276 }
277 277
278 func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {278 func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {
279- if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {279+ if w == nil || !w.mapped || !w.alive() {
280 return nil280 return nil
281 }281 }
282- return s.clientKeyboards(w.xdg.resource.Client())282+ return s.clientKeyboards(w.client())
283 }283 }
284 284
285 // clientKeyboards returns c's wl_keyboards without allocating. The list holds285 // clientKeyboards returns c's wl_keyboards without allocating. The list holds
@@ -300,8 +300,8 @@ func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Ke
300 // already dead. Admission is enforced before entering or delivering.300 // already dead. Admission is enforced before entering or delivering.
301 return l.surface.wl, s.clientKeyboards(l.surface.wl.Client())301 return l.surface.wl, s.clientKeyboards(l.surface.wl.Client())
302 }302 }
303- if w := s.windows[id]; w != nil && w.mapped {303+ if w := s.windows[id]; w != nil && w.mapped && !w.isOverride() {
304- if surf := w.xdg.surfaceResource(); surf != nil {304+ if surf := w.surfaceResource(); surf != nil {
305 return surf, s.windowKeyboards(w)305 return surf, s.windowKeyboards(w)
306 }306 }
307 }307 }
@@ -319,8 +319,8 @@ func (s *Server) pointerTarget(id ports.WindowID) (server.Client, []*wayland.Poi
319 if l := s.lockSurfaces[id]; l != nil && s.lockInputTarget(id) {319 if l := s.lockSurfaces[id]; l != nil && s.lockInputTarget(id) {
320 return l.resource.Client(), s.clientPointers(l.resource.Client()), true320 return l.resource.Client(), s.clientPointers(l.resource.Client()), true
321 }321 }
322- if w := s.windows[id]; w != nil && w.mapped && w.xdg.resource.Resource.Alive() {322+ if w := s.windows[id]; w != nil && w.mapped && w.alive() {
323- return w.xdg.resource.Client(), s.windowPointers(w), true323+ return w.client(), s.windowPointers(w), true
324 }324 }
325 if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() {325 if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() {
326 return l.resource.Client(), s.layerPointers(l), true326 return l.resource.Client(), s.layerPointers(l), true
@@ -335,7 +335,7 @@ func (s *Server) pointerSurface(id ports.WindowID, x, y float64) (*wayland.Surfa
335 return l.surface.wl, s.clientPointers(l.surface.wl.Client()), x, y335 return l.surface.wl, s.clientPointers(l.surface.wl.Client()), x, y
336 }336 }
337 if w := s.windows[id]; w != nil {337 if w := s.windows[id]; w != nil {
338- if surf := w.xdg.surfaceResource(); surf != nil && surf.Resource.Alive() {338+ if surf := w.surfaceResource(); surf != nil && surf.Resource.Alive() {
339 x, y := w.surfacePoint(x, y)339 x, y := w.surfacePoint(x, y)
340 return surf, s.windowPointers(w), x, y340 return surf, s.windowPointers(w), x, y
341 }341 }
@@ -455,6 +455,8 @@ func (s *Server) changeFocus(id ports.WindowID) {
455 }455 }
456 }456 }
457 s.textInputFocus()457 s.textInputFocus()
458+ s.x11Focus()
459+ s.syncKeyboardGrab()
458 s.log.Debug().Uint64("id", uint64(s.seat.focused)).Msg("keyboard focus")460 s.log.Debug().Uint64("id", uint64(s.seat.focused)).Msg("keyboard focus")
459 s.updateConstraint()461 s.updateConstraint()
460 }462 }
@@ -543,10 +545,10 @@ func pointerFrame(p *wayland.Pointer) {
543 }545 }
544 }546 }
545 func (s *Server) windowPointers(w *window) []*wayland.Pointer {547 func (s *Server) windowPointers(w *window) []*wayland.Pointer {
546- if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {548+ if w == nil || !w.mapped || !w.alive() {
547 return nil549 return nil
548 }550 }
549- return s.clientPointers(w.xdg.resource.Client())551+ return s.clientPointers(w.client())
550 }552 }
551 553
552 func (s *Server) layerPointers(l *layerSurface) []*wayland.Pointer {554 func (s *Server) layerPointers(l *layerSurface) []*wayland.Pointer {
M internal/adapters/wayland/server.go +51 -16
@@ -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"
@@ -51,6 +52,9 @@ type Options struct {
5152 // Casts receives the screencast events and inventory (screencast.go);
5253 // nil serves no casts (Channels.CastCommands is not read).
5354 Casts ports.CastSink
55+ // Xwayland is what the built-in XWM publishes to X11 clients
56+ // (XSETTINGS, root cursor); the zero value publishes nothing.
57+ Xwayland ports.XwaylandSettings
5458 // LogDamage logs the damage of every fresh client commit at debug level
5559 // (--debug=wayland-damage).
5660 LogDamage bool
@@ -160,13 +164,21 @@ type Server struct {
160164 securityStop int
161165 securityWG sync.WaitGroup
162166 securityLive atomic.Int32
163- display *server.Display
164- env procEnv
165- name string
166- cleanup func()
167- log zerowrap.Logger
168- logDamage bool
169- channels Channels
167+ xwayland xwaylandClient // display owner
168+ // xdgPublic and xdgPrivate are the two zxdg_output_manager_v1 globals:
169+ // layout space for clients, X space for Xwayland.
170+ xdgPublic, xdgPrivate *server.Global
171+ // xwm manages the X11 windows of the Xwayland client; x11Slots maps its
172+ // slots to their windows. Display goroutine only.
173+ xwm *xwm.XWM
174+ x11Slots map[xwm.Slot]*window
175+ display *server.Display
176+ env procEnv
177+ name string
178+ cleanup func()
179+ log zerowrap.Logger
180+ logDamage bool
181+ channels Channels
170182 // awaiting holds frame callbacks by output name, due at its next
171183 // frame (frameDue, or its page flip).
172184 awaiting map[string][]*wayland.Callback
@@ -214,8 +226,11 @@ type Server struct {
214226 // foreignExports are the live xdg-foreign handles (foreign.go).
215227 foreignExports map[string]*foreignExport
216228 surfaces map[*server.Resource]*surface
217- buffers map[*server.Resource]clientBuffer
218- dmabuf *dmabufGlobal
229+ // surfaceByIdentity finds a surface by its identity (xwayland_shell_v1
230+ // pairing); kept with surfaces.
231+ surfaceByIdentity map[uint64]*surface
232+ buffers map[*server.Resource]clientBuffer
233+ dmabuf *dmabufGlobal
219234 // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.
220235 hdrOutputs map[string]ports.OutputHDR
221236 colorID uint64
@@ -262,6 +277,8 @@ type Server struct {
262277 // inhibitors are the live inhibitor objects (inhibit.go);
263278 // shortcutWindows and idleWindows what core was told.
264279 inhibitors []*inhibitor
280+ xgrab *xwaylandGrab // Xwayland keyboard grab (xwaylandgrab.go)
281+ xgrabbed ports.WindowID // window the grab inhibits for
265282 shortcutWindows, idleWindows map[ports.WindowID]bool
266283 // busInhibited is the last IdleInhibited value (idle.go).
267284 busInhibited bool
@@ -281,6 +298,8 @@ type Server struct {
281298 workspaceIDs *workspaceid.IDs
282299 outputHeads ports.OutputHeads
283300 outputPlaces ports.Layout
301+ xsettings ports.XwaylandSettings
302+ xspace xwm.Space // the one layout <-> X mapping, shared with the XWM
284303 managementSerial uint32
285304 nextOutputApply uint64
286305 outputReplies map[uint64]*outputConfiguration
@@ -298,6 +317,8 @@ type Server struct {
298317 cursorReady chan struct{}
299318 // Clipboard and primary selection (clipboard.go).
300319 selections [2]*clipSource
320+ xsel [2]*clipSource // the current X-owned source per kind, if any
321+ xwmSel xwmSelection // the XWM's clipboard side; nil without one
301322 // Drag and drop (dnd.go): the drag in progress, drops waiting for
302323 // their finish, and each client's data devices.
303324 dnd *dndState
@@ -364,10 +385,13 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
364385 cleanup: cleanup,
365386 log: log,
366387 logDamage: opts.LogDamage,
388+ xsettings: opts.Xwayland,
367389 channels: ch,
368390 surfaces: make(map[*server.Resource]*surface),
391+ surfaceByIdentity: make(map[uint64]*surface),
369392 buffers: make(map[*server.Resource]clientBuffer),
370393 windows: make(map[ports.WindowID]*window),
394+ x11Slots: make(map[xwm.Slot]*window),
371395 layers: make(map[ports.WindowID]*layerSurface),
372396 nextWindow: 1,
373397 eventReady: make(chan struct{}, 1),
@@ -498,6 +522,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
498522 if err = s.openPaceTimer(); err != nil {
499523 return abort(err)
500524 }
525+ d.SetGlobalFilter(s.globalVisible)
501526 if err = registerGlobals(d, opts, s); err != nil {
502527 return abort(err)
503528 }
@@ -585,6 +610,13 @@ func (s *Server) Run(ctx context.Context) error {
585610 }
586611 s.castInventoryChanged()
587612 err := s.display.Run(ctx)
613+ // The display is gone: only the X connection is left to close.
614+ if s.xwm != nil {
615+ s.xwm.CloseConn()
616+ s.xwm = nil
617+ s.xwmSel = nil
618+ }
619+ s.forgetXwayland()
588620 s.stopSecurityContexts()
589621 wg.Wait()
590622 return err
@@ -774,9 +806,11 @@ func (s *Server) apply(cmd ports.ClientCommand) {
774806 if w := s.windows[c.ID]; w != nil && w.popup != nil {
775807 w.popup.dismiss()
776808 }
809+ case ports.PlaceWindow:
810+ s.x11Place(c)
777811 case ports.ConfigureWindow:
778812 w := s.windows[c.ID]
779- if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() || !w.xdg.resource.Resource.Alive() {
813+ if w == nil || !w.toplevelAlive() || !w.alive() {
780814 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window")
781815 return
782816 }
@@ -787,16 +821,17 @@ func (s *Server) apply(cmd ports.ClientCommand) {
787821 scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output || w.last.Visible != c.Visible
788822 w.last, w.hasLast = c, true
789823 if visibilityChanged {
790- s.relocateCallbacks(w.xdg.surface)
824+ s.relocateCallbacks(w.surf())
791825 s.dropFeedbacks(time.Now())
792826 s.wakePacer() // reconsider FIFO barriers at the new period
793827 }
794828 w.sendConfigure()
829+ s.syncKeyboardGrab()
795830 s.toplevelChanged(w)
796- if scanoutChanged && w.xdg.surface != nil {
797- s.dmabuf.resendSurface(w.xdg.surface)
831+ if scanoutChanged && w.surf() != nil {
832+ s.dmabuf.resendSurface(w.surf())
798833 }
799- if surf := w.xdg.surface; surf != nil && c.Output != "" {
834+ if surf := w.surf(); surf != nil && c.Output != "" {
800835 surf.sendTreeScale()
801836 }
802837 if scanoutChanged {
@@ -817,10 +852,10 @@ func (s *Server) apply(cmd ports.ClientCommand) {
817852 s.setShortcutsInhibit(c)
818853 case ports.CloseWindow:
819854 w := s.windows[c.ID]
820- if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() {
855+ if w == nil || !w.toplevelAlive() {
821856 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window")
822857 return
823858 }
824- w.toplevel.SendClose()
859+ w.sendClose()
825860 }
826861 }
@@ -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"
@@ -51,6 +52,9 @@ type Options struct {
51 // Casts receives the screencast events and inventory (screencast.go);52 // Casts receives the screencast events and inventory (screencast.go);
52 // nil serves no casts (Channels.CastCommands is not read).53 // nil serves no casts (Channels.CastCommands is not read).
53 Casts ports.CastSink54 Casts ports.CastSink
55+ // Xwayland is what the built-in XWM publishes to X11 clients
56+ // (XSETTINGS, root cursor); the zero value publishes nothing.
57+ Xwayland ports.XwaylandSettings
54 // LogDamage logs the damage of every fresh client commit at debug level58 // LogDamage logs the damage of every fresh client commit at debug level
55 // (--debug=wayland-damage).59 // (--debug=wayland-damage).
56 LogDamage bool60 LogDamage bool
@@ -160,13 +164,21 @@ type Server struct {
160 securityStop int164 securityStop int
161 securityWG sync.WaitGroup165 securityWG sync.WaitGroup
162 securityLive atomic.Int32166 securityLive atomic.Int32
163- display *server.Display167+ xwayland xwaylandClient // display owner
164- env procEnv168+ // xdgPublic and xdgPrivate are the two zxdg_output_manager_v1 globals:
165- name string169+ // layout space for clients, X space for Xwayland.
166- cleanup func()170+ xdgPublic, xdgPrivate *server.Global
167- log zerowrap.Logger171+ // xwm manages the X11 windows of the Xwayland client; x11Slots maps its
168- logDamage bool172+ // slots to their windows. Display goroutine only.
169- channels Channels173+ xwm *xwm.XWM
174+ x11Slots map[xwm.Slot]*window
175+ display *server.Display
176+ env procEnv
177+ name string
178+ cleanup func()
179+ log zerowrap.Logger
180+ logDamage bool
181+ channels Channels
170 // awaiting holds frame callbacks by output name, due at its next182 // awaiting holds frame callbacks by output name, due at its next
171 // frame (frameDue, or its page flip).183 // frame (frameDue, or its page flip).
172 awaiting map[string][]*wayland.Callback184 awaiting map[string][]*wayland.Callback
@@ -214,8 +226,11 @@ type Server struct {
214 // foreignExports are the live xdg-foreign handles (foreign.go).226 // foreignExports are the live xdg-foreign handles (foreign.go).
215 foreignExports map[string]*foreignExport227 foreignExports map[string]*foreignExport
216 surfaces map[*server.Resource]*surface228 surfaces map[*server.Resource]*surface
217- buffers map[*server.Resource]clientBuffer229+ // surfaceByIdentity finds a surface by its identity (xwayland_shell_v1
218- dmabuf *dmabufGlobal230+ // pairing); kept with surfaces.
231+ surfaceByIdentity map[uint64]*surface
232+ buffers map[*server.Resource]clientBuffer
233+ dmabuf *dmabufGlobal
219 // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.234 // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.
220 hdrOutputs map[string]ports.OutputHDR235 hdrOutputs map[string]ports.OutputHDR
221 colorID uint64236 colorID uint64
@@ -262,6 +277,8 @@ type Server struct {
262 // inhibitors are the live inhibitor objects (inhibit.go);277 // inhibitors are the live inhibitor objects (inhibit.go);
263 // shortcutWindows and idleWindows what core was told.278 // shortcutWindows and idleWindows what core was told.
264 inhibitors []*inhibitor279 inhibitors []*inhibitor
280+ xgrab *xwaylandGrab // Xwayland keyboard grab (xwaylandgrab.go)
281+ xgrabbed ports.WindowID // window the grab inhibits for
265 shortcutWindows, idleWindows map[ports.WindowID]bool282 shortcutWindows, idleWindows map[ports.WindowID]bool
266 // busInhibited is the last IdleInhibited value (idle.go).283 // busInhibited is the last IdleInhibited value (idle.go).
267 busInhibited bool284 busInhibited bool
@@ -281,6 +298,8 @@ type Server struct {
281 workspaceIDs *workspaceid.IDs298 workspaceIDs *workspaceid.IDs
282 outputHeads ports.OutputHeads299 outputHeads ports.OutputHeads
283 outputPlaces ports.Layout300 outputPlaces ports.Layout
301+ xsettings ports.XwaylandSettings
302+ xspace xwm.Space // the one layout <-> X mapping, shared with the XWM
284 managementSerial uint32303 managementSerial uint32
285 nextOutputApply uint64304 nextOutputApply uint64
286 outputReplies map[uint64]*outputConfiguration305 outputReplies map[uint64]*outputConfiguration
@@ -298,6 +317,8 @@ type Server struct {
298 cursorReady chan struct{}317 cursorReady chan struct{}
299 // Clipboard and primary selection (clipboard.go).318 // Clipboard and primary selection (clipboard.go).
300 selections [2]*clipSource319 selections [2]*clipSource
320+ xsel [2]*clipSource // the current X-owned source per kind, if any
321+ xwmSel xwmSelection // the XWM's clipboard side; nil without one
301 // Drag and drop (dnd.go): the drag in progress, drops waiting for322 // Drag and drop (dnd.go): the drag in progress, drops waiting for
302 // their finish, and each client's data devices.323 // their finish, and each client's data devices.
303 dnd *dndState324 dnd *dndState
@@ -364,10 +385,13 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
364 cleanup: cleanup,385 cleanup: cleanup,
365 log: log,386 log: log,
366 logDamage: opts.LogDamage,387 logDamage: opts.LogDamage,
388+ xsettings: opts.Xwayland,
367 channels: ch,389 channels: ch,
368 surfaces: make(map[*server.Resource]*surface),390 surfaces: make(map[*server.Resource]*surface),
391+ surfaceByIdentity: make(map[uint64]*surface),
369 buffers: make(map[*server.Resource]clientBuffer),392 buffers: make(map[*server.Resource]clientBuffer),
370 windows: make(map[ports.WindowID]*window),393 windows: make(map[ports.WindowID]*window),
394+ x11Slots: make(map[xwm.Slot]*window),
371 layers: make(map[ports.WindowID]*layerSurface),395 layers: make(map[ports.WindowID]*layerSurface),
372 nextWindow: 1,396 nextWindow: 1,
373 eventReady: make(chan struct{}, 1),397 eventReady: make(chan struct{}, 1),
@@ -498,6 +522,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
498 if err = s.openPaceTimer(); err != nil {522 if err = s.openPaceTimer(); err != nil {
499 return abort(err)523 return abort(err)
500 }524 }
525+ d.SetGlobalFilter(s.globalVisible)
501 if err = registerGlobals(d, opts, s); err != nil {526 if err = registerGlobals(d, opts, s); err != nil {
502 return abort(err)527 return abort(err)
503 }528 }
@@ -585,6 +610,13 @@ func (s *Server) Run(ctx context.Context) error {
585 }610 }
586 s.castInventoryChanged()611 s.castInventoryChanged()
587 err := s.display.Run(ctx)612 err := s.display.Run(ctx)
613+ // The display is gone: only the X connection is left to close.
614+ if s.xwm != nil {
615+ s.xwm.CloseConn()
616+ s.xwm = nil
617+ s.xwmSel = nil
618+ }
619+ s.forgetXwayland()
588 s.stopSecurityContexts()620 s.stopSecurityContexts()
589 wg.Wait()621 wg.Wait()
590 return err622 return err
@@ -774,9 +806,11 @@ func (s *Server) apply(cmd ports.ClientCommand) {
774 if w := s.windows[c.ID]; w != nil && w.popup != nil {806 if w := s.windows[c.ID]; w != nil && w.popup != nil {
775 w.popup.dismiss()807 w.popup.dismiss()
776 }808 }
809+ case ports.PlaceWindow:
810+ s.x11Place(c)
777 case ports.ConfigureWindow:811 case ports.ConfigureWindow:
778 w := s.windows[c.ID]812 w := s.windows[c.ID]
779- if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() || !w.xdg.resource.Resource.Alive() {813+ if w == nil || !w.toplevelAlive() || !w.alive() {
780 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window")814 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window")
781 return815 return
782 }816 }
@@ -787,16 +821,17 @@ func (s *Server) apply(cmd ports.ClientCommand) {
787 scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output || w.last.Visible != c.Visible821 scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output || w.last.Visible != c.Visible
788 w.last, w.hasLast = c, true822 w.last, w.hasLast = c, true
789 if visibilityChanged {823 if visibilityChanged {
790- s.relocateCallbacks(w.xdg.surface)824+ s.relocateCallbacks(w.surf())
791 s.dropFeedbacks(time.Now())825 s.dropFeedbacks(time.Now())
792 s.wakePacer() // reconsider FIFO barriers at the new period826 s.wakePacer() // reconsider FIFO barriers at the new period
793 }827 }
794 w.sendConfigure()828 w.sendConfigure()
829+ s.syncKeyboardGrab()
795 s.toplevelChanged(w)830 s.toplevelChanged(w)
796- if scanoutChanged && w.xdg.surface != nil {831+ if scanoutChanged && w.surf() != nil {
797- s.dmabuf.resendSurface(w.xdg.surface)832+ s.dmabuf.resendSurface(w.surf())
798 }833 }
799- if surf := w.xdg.surface; surf != nil && c.Output != "" {834+ if surf := w.surf(); surf != nil && c.Output != "" {
800 surf.sendTreeScale()835 surf.sendTreeScale()
801 }836 }
802 if scanoutChanged {837 if scanoutChanged {
@@ -817,10 +852,10 @@ func (s *Server) apply(cmd ports.ClientCommand) {
817 s.setShortcutsInhibit(c)852 s.setShortcutsInhibit(c)
818 case ports.CloseWindow:853 case ports.CloseWindow:
819 w := s.windows[c.ID]854 w := s.windows[c.ID]
820- if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() {855+ if w == nil || !w.toplevelAlive() {
821 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window")856 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window")
822 return857 return
823 }858 }
824- w.toplevel.SendClose()859+ w.sendClose()
825 }860 }
826 }861 }
M internal/adapters/wayland/surface.go +15 -2
@@ -28,6 +28,9 @@ const (
2828 roleSessionLock
2929 // roleDragIcon is the icon of a data drag (dnd.go).
3030 roleDragIcon
31+ // roleX11 is the surface of an X11 window paired through
32+ // xwayland_shell_v1.
33+ roleX11
3134 )
3235
3336 type surface struct {
@@ -47,8 +50,12 @@ type surface struct {
4750 formatOpaque bool
4851 kind roleKind
4952 xdg *xdgSurface
50- layer *layerSurface
51- 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
5259 // bufferCommitted is permanent, including commits later discarded by FIFO.
5360 bufferCommitted bool
5461 server *Server
@@ -192,6 +199,9 @@ func (s *surface) windowID() ports.WindowID {
192199 if s.xdg != nil && s.xdg.window != nil && s.xdg.window.mapped {
193200 return s.xdg.window.id
194201 }
202+ if s.x11win != nil && s.x11win.mapped {
203+ return s.x11win.id
204+ }
195205 if s.layer != nil && s.layer.mapped {
196206 return s.layer.id
197207 }
@@ -566,6 +576,9 @@ func (s *surface) applyCommit(u *update) {
566576 if s.xdg != nil && s.xdg.window != nil {
567577 s.xdg.window.afterCommit()
568578 }
579+ if s.x11win != nil {
580+ s.x11win.afterCommit()
581+ }
569582 if s.lock != nil {
570583 // Role callbacks precede content construction; publish lock placement
571584 // only once validated current dimensions are available.
@@ -28,6 +28,9 @@ const (
28 roleSessionLock28 roleSessionLock
29 // roleDragIcon is the icon of a data drag (dnd.go).29 // roleDragIcon is the icon of a data drag (dnd.go).
30 roleDragIcon30 roleDragIcon
31+ // roleX11 is the surface of an X11 window paired through
32+ // xwayland_shell_v1.
33+ roleX11
31 )34 )
32 35
33 type surface struct {36 type surface struct {
@@ -47,8 +50,12 @@ type surface struct {
47 formatOpaque bool50 formatOpaque bool
48 kind roleKind51 kind roleKind
49 xdg *xdgSurface52 xdg *xdgSurface
50- layer *layerSurface53+ // x11win is the X11 window the surface is associated with; x11Serial is
51- 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
52 // bufferCommitted is permanent, including commits later discarded by FIFO.59 // bufferCommitted is permanent, including commits later discarded by FIFO.
53 bufferCommitted bool60 bufferCommitted bool
54 server *Server61 server *Server
@@ -192,6 +199,9 @@ func (s *surface) windowID() ports.WindowID {
192 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 {
193 return s.xdg.window.id200 return s.xdg.window.id
194 }201 }
202+ if s.x11win != nil && s.x11win.mapped {
203+ return s.x11win.id
204+ }
195 if s.layer != nil && s.layer.mapped {205 if s.layer != nil && s.layer.mapped {
196 return s.layer.id206 return s.layer.id
197 }207 }
@@ -566,6 +576,9 @@ func (s *surface) applyCommit(u *update) {
566 if s.xdg != nil && s.xdg.window != nil {576 if s.xdg != nil && s.xdg.window != nil {
567 s.xdg.window.afterCommit()577 s.xdg.window.afterCommit()
568 }578 }
579+ if s.x11win != nil {
580+ s.x11win.afterCommit()
581+ }
569 if s.lock != nil {582 if s.lock != nil {
570 // Role callbacks precede content construction; publish lock placement583 // Role callbacks precede content construction; publish lock placement
571 // only once validated current dimensions are available.584 // only once validated current dimensions are available.
A internal/adapters/wayland/window.go +112 -0
@@ -0,0 +1,112 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/go-wayland-bindings/server/wayland"
5+ "github.com/bnema/neferwl/internal/ports"
6+ "github.com/bnema/purego-libwayland/server"
7+)
8+
9+// Role-independent accessors of a window. Code outside xdg.go goes through
10+// them instead of dereferencing w.xdg, which is nil for windows of another
11+// role.
12+
13+// isToplevel reports whether w is a toplevel (xdg or X11), not a popup.
14+func (w *window) isToplevel() bool { return w.toplevel != nil || (w.x11 != nil && !w.x11.override) }
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+}
23+
24+// surf is the window's surface, nil when it has none.
25+func (w *window) surf() *surface {
26+ switch {
27+ case w.xdg != nil:
28+ return w.xdg.surface
29+ case w.x11 != nil:
30+ return w.x11.surface
31+ }
32+ return nil
33+}
34+
35+// alive reports whether the window's role object is alive.
36+func (w *window) alive() bool {
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
44+}
45+
46+// client is the window's client; the zero Client when it has no role object.
47+func (w *window) client() server.Client {
48+ switch {
49+ case w.xdg != nil:
50+ return w.xdg.resource.Client()
51+ case w.x11 != nil && w.x11.surface != nil:
52+ return w.x11.surface.wl.Client()
53+ }
54+ return server.Client{}
55+}
56+
57+// geometry is the committed window geometry; empty means the whole surface.
58+func (w *window) geometry() ports.Rect {
59+ if w.xdg != nil {
60+ return w.xdg.geometry
61+ }
62+ return ports.Rect{}
63+}
64+
65+// sendConfigure sends w.last to the client.
66+func (w *window) sendConfigure() {
67+ switch {
68+ case w.xdg != nil:
69+ w.sendXDGConfigure()
70+ case w.x11 != nil:
71+ w.x11.server.x11Configure(w)
72+ }
73+}
74+
75+// sendClose asks the client to close the window.
76+func (w *window) sendClose() {
77+ switch {
78+ case w.toplevel != nil:
79+ w.toplevel.SendClose()
80+ case w.x11 != nil:
81+ w.x11.server.x11Close(w)
82+ }
83+}
84+
85+// toplevelAlive reports whether the window is a toplevel whose protocol
86+// object is alive.
87+func (w *window) toplevelAlive() bool {
88+ if w.x11 != nil {
89+ return w.alive()
90+ }
91+ return w.toplevel != nil && w.toplevel.Resource.Alive()
92+}
93+
94+// surfaceResource is the window's wl_surface, nil when it has none or it died.
95+func (w *window) surfaceResource() *wayland.Surface {
96+ switch {
97+ case w.xdg != nil:
98+ return w.xdg.surfaceResource()
99+ case w.x11 != nil && w.alive():
100+ return w.x11.surface.wl
101+ }
102+ return nil
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+}
@@ -0,0 +1,112 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/go-wayland-bindings/server/wayland"
5+ "github.com/bnema/neferwl/internal/ports"
6+ "github.com/bnema/purego-libwayland/server"
7+)
8+
9+// Role-independent accessors of a window. Code outside xdg.go goes through
10+// them instead of dereferencing w.xdg, which is nil for windows of another
11+// role.
12+
13+// isToplevel reports whether w is a toplevel (xdg or X11), not a popup.
14+func (w *window) isToplevel() bool { return w.toplevel != nil || (w.x11 != nil && !w.x11.override) }
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+}
23+
24+// surf is the window's surface, nil when it has none.
25+func (w *window) surf() *surface {
26+ switch {
27+ case w.xdg != nil:
28+ return w.xdg.surface
29+ case w.x11 != nil:
30+ return w.x11.surface
31+ }
32+ return nil
33+}
34+
35+// alive reports whether the window's role object is alive.
36+func (w *window) alive() bool {
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
44+}
45+
46+// client is the window's client; the zero Client when it has no role object.
47+func (w *window) client() server.Client {
48+ switch {
49+ case w.xdg != nil:
50+ return w.xdg.resource.Client()
51+ case w.x11 != nil && w.x11.surface != nil:
52+ return w.x11.surface.wl.Client()
53+ }
54+ return server.Client{}
55+}
56+
57+// geometry is the committed window geometry; empty means the whole surface.
58+func (w *window) geometry() ports.Rect {
59+ if w.xdg != nil {
60+ return w.xdg.geometry
61+ }
62+ return ports.Rect{}
63+}
64+
65+// sendConfigure sends w.last to the client.
66+func (w *window) sendConfigure() {
67+ switch {
68+ case w.xdg != nil:
69+ w.sendXDGConfigure()
70+ case w.x11 != nil:
71+ w.x11.server.x11Configure(w)
72+ }
73+}
74+
75+// sendClose asks the client to close the window.
76+func (w *window) sendClose() {
77+ switch {
78+ case w.toplevel != nil:
79+ w.toplevel.SendClose()
80+ case w.x11 != nil:
81+ w.x11.server.x11Close(w)
82+ }
83+}
84+
85+// toplevelAlive reports whether the window is a toplevel whose protocol
86+// object is alive.
87+func (w *window) toplevelAlive() bool {
88+ if w.x11 != nil {
89+ return w.alive()
90+ }
91+ return w.toplevel != nil && w.toplevel.Resource.Alive()
92+}
93+
94+// surfaceResource is the window's wl_surface, nil when it has none or it died.
95+func (w *window) surfaceResource() *wayland.Surface {
96+ switch {
97+ case w.xdg != nil:
98+ return w.xdg.surfaceResource()
99+ case w.x11 != nil && w.alive():
100+ return w.x11.surface.wl
101+ }
102+ return nil
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 +6 -0
@@ -55,6 +55,8 @@ 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_xwaylandkeyboardgrab "github.com/bnema/go-wayland-bindings/server/xwaylandkeyboardgrab"
59+ wire_xwaylandshell "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
5860 wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
5961 "github.com/bnema/purego-libwayland/server"
6062 "github.com/bnema/wlturbo"
@@ -253,6 +255,10 @@ var wireSchemas = map[string]wireSchema{
253255 "zwp_text_input_v3": {wire_textinput.ZwpTextInputV3Interface, &client_textinput.TextInputV3{}, map[uint16]bool{0: true}, nil},
254256 "zwp_virtual_keyboard_manager_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardManagerV1Interface, &client_virtualkeyboard.VirtualKeyboardManager{}, nil, nil},
255257 "zwp_virtual_keyboard_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardV1Interface, &client_virtualkeyboard.VirtualKeyboard{}, map[uint16]bool{3: true}, nil},
258+ "zwp_xwayland_keyboard_grab_manager_v1": {wire_xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabManagerV1Interface, nil, map[uint16]bool{0: true}, nil},
259+ "zwp_xwayland_keyboard_grab_v1": {wire_xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabV1Interface, nil, map[uint16]bool{0: true}, nil},
260+ "xwayland_shell_v1": {wire_xwaylandshell.XwaylandShellV1Interface, nil, map[uint16]bool{0: true}, nil},
261+ "xwayland_surface_v1": {wire_xwaylandshell.XwaylandSurfaceV1Interface, nil, map[uint16]bool{1: true}, nil},
256262 "zxdg_output_manager_v1": {wire_xdgoutput.ZxdgOutputManagerV1Interface, &client_xdgoutput.ZxdgOutputManager{}, map[uint16]bool{0: true}, nil},
257263 "zxdg_output_v1": {wire_xdgoutput.ZxdgOutputV1Interface, &client_xdgoutput.ZxdgOutput{}, map[uint16]bool{0: true}, nil},
258264 }
@@ -55,6 +55,8 @@ 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_xwaylandkeyboardgrab "github.com/bnema/go-wayland-bindings/server/xwaylandkeyboardgrab"
59+ wire_xwaylandshell "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
58 wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"60 wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
59 "github.com/bnema/purego-libwayland/server"61 "github.com/bnema/purego-libwayland/server"
60 "github.com/bnema/wlturbo"62 "github.com/bnema/wlturbo"
@@ -253,6 +255,10 @@ var wireSchemas = map[string]wireSchema{
253 "zwp_text_input_v3": {wire_textinput.ZwpTextInputV3Interface, &client_textinput.TextInputV3{}, map[uint16]bool{0: true}, nil},255 "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},256 "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},257 "zwp_virtual_keyboard_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardV1Interface, &client_virtualkeyboard.VirtualKeyboard{}, map[uint16]bool{3: true}, nil},
258+ "zwp_xwayland_keyboard_grab_manager_v1": {wire_xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabManagerV1Interface, nil, map[uint16]bool{0: true}, nil},
259+ "zwp_xwayland_keyboard_grab_v1": {wire_xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabV1Interface, nil, map[uint16]bool{0: true}, nil},
260+ "xwayland_shell_v1": {wire_xwaylandshell.XwaylandShellV1Interface, nil, map[uint16]bool{0: true}, nil},
261+ "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},262 "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},263 "zxdg_output_v1": {wire_xdgoutput.ZxdgOutputV1Interface, &client_xdgoutput.ZxdgOutput{}, map[uint16]bool{0: true}, nil},
258 }264 }
A internal/adapters/wayland/x11override_test.go +107 -0
@@ -0,0 +1,107 @@
1+package wayland
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/go-wayland-bindings/server/wayland"
7+ "github.com/bnema/neferwl/internal/adapters/wayland/xwm"
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+const ovWin = 0x500002
12+
13+// overrideWindow maps an override-redirect X window and pairs its surface.
14+func (r *x11Rig) overrideWindow(win uint32, x, y int16, serial uint64) uint32 {
15+ r.t.Helper()
16+ r.peer.createOverride(win, x, y)
17+ r.peer.mapNotify(win)
18+ return r.pair(win, serial)
19+}
20+
21+func (r *x11Rig) state(f func()) {
22+ r.t.Helper()
23+ r.s.display.Do(f)
24+}
25+
26+func TestOverrideMapsOnFirstCommitAndMoves(t *testing.T) {
27+ r := newX11Rig(t)
28+ r.overrideWindow(ovWin, 30, 40, x11Serial)
29+
30+ m := awaitEvent[ports.UnmanagedMapped](t, r.events)
31+ if m.X != 30 || m.Y != 40 || m.Width != 100 || m.Height != 50 || m.NoInput {
32+ t.Fatalf("mapped %+v", m)
33+ }
34+ r.state(func() {
35+ w := r.s.windows[m.ID]
36+ if w == nil || !w.isOverride() || w.isToplevel() || !w.mapped {
37+ t.Errorf("override window %+v", w)
38+ }
39+ if surf, _ := r.s.focusTarget(m.ID); surf != nil {
40+ t.Error("override window is a keyboard focus target")
41+ }
42+ })
43+
44+ r.peer.configureNotify(ovWin, 60, 70, 100, 50)
45+ if mv := awaitEvent[ports.UnmanagedMoved](t, r.events); mv.ID != m.ID || mv.X != 60 || mv.Y != 70 {
46+ t.Fatalf("moved %+v", mv)
47+ }
48+
49+ r.peer.unmapNotify(ovWin)
50+ if u := awaitEvent[ports.UnmanagedUnmapped](t, r.events); u.ID != m.ID {
51+ t.Fatalf("unmapped %+v", u)
52+ }
53+ r.state(func() {
54+ if r.s.windows[m.ID] != nil {
55+ t.Error("override window kept after unmap")
56+ }
57+ })
58+}
59+
60+func TestOverrideSurfaceDestroyUnmaps(t *testing.T) {
61+ r := newX11Rig(t)
62+ surf := r.overrideWindow(ovWin, 0, 0, x11Serial)
63+ m := awaitEvent[ports.UnmanagedMapped](t, r.events)
64+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestDestroy)
65+ if err := r.xc.Roundtrip(); err != nil {
66+ t.Fatal(err)
67+ }
68+ if u := awaitEvent[ports.UnmanagedUnmapped](t, r.events); u.ID != m.ID {
69+ t.Fatalf("unmapped %+v", u)
70+ }
71+}
72+
73+func TestOverrideTakesPointerAndOutput(t *testing.T) {
74+ r := newX11Rig(t)
75+ r.commands <- ports.SetOutputs{Outputs: ports.Layout{
76+ {Info: ports.OutputInfo{Name: "A"}, X: 0, Y: 0, Width: 1920, Height: 1080, Scale: 1},
77+ {Info: ports.OutputInfo{Name: "B"}, X: 1920, Y: 0, Width: 1920, Height: 1080, Scale: 1},
78+ }, Focused: "A"}
79+ r.overrideWindow(ovWin, 100, 10, x11Serial)
80+ m := awaitEvent[ports.UnmanagedMapped](t, r.events)
81+ r.state(func() {
82+ w := r.s.windows[m.ID]
83+ if got := r.s.frameOutput(w.x11.surface); got != "A" {
84+ t.Errorf("frame output %q, want A", got)
85+ }
86+ if surf, _, _, _ := r.s.pointerSurface(m.ID, 1, 1); surf == nil {
87+ t.Error("pointer cannot enter the override surface")
88+ }
89+ })
90+ r.peer.configureNotify(ovWin, 2000, 10, 100, 50)
91+ awaitEvent[ports.UnmanagedMoved](t, r.events)
92+ r.state(func() {
93+ if got := r.s.frameOutput(r.s.windows[m.ID].x11.surface); got != "B" {
94+ t.Errorf("frame output %q after move, want B", got)
95+ }
96+ })
97+}
98+
99+func TestOverrideMovedUnchangedAllocations(t *testing.T) {
100+ s := &Server{windows: map[ports.WindowID]*window{}, x11Slots: map[xwm.Slot]*window{}}
101+ w := &window{id: 1, x11: &x11Window{override: true, rect: ports.Rect{X: 1, Y: 2, W: 3, H: 4}}}
102+ s.x11Slots[1] = w
103+ h := hostImpl{s}
104+ if n := testing.AllocsPerRun(1000, func() { h.OverrideMoved(1, 1, 2, 3, 4) }); n != 0 {
105+ t.Fatalf("%v allocs", n)
106+ }
107+}
@@ -0,0 +1,107 @@
1+package wayland
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/go-wayland-bindings/server/wayland"
7+ "github.com/bnema/neferwl/internal/adapters/wayland/xwm"
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+const ovWin = 0x500002
12+
13+// overrideWindow maps an override-redirect X window and pairs its surface.
14+func (r *x11Rig) overrideWindow(win uint32, x, y int16, serial uint64) uint32 {
15+ r.t.Helper()
16+ r.peer.createOverride(win, x, y)
17+ r.peer.mapNotify(win)
18+ return r.pair(win, serial)
19+}
20+
21+func (r *x11Rig) state(f func()) {
22+ r.t.Helper()
23+ r.s.display.Do(f)
24+}
25+
26+func TestOverrideMapsOnFirstCommitAndMoves(t *testing.T) {
27+ r := newX11Rig(t)
28+ r.overrideWindow(ovWin, 30, 40, x11Serial)
29+
30+ m := awaitEvent[ports.UnmanagedMapped](t, r.events)
31+ if m.X != 30 || m.Y != 40 || m.Width != 100 || m.Height != 50 || m.NoInput {
32+ t.Fatalf("mapped %+v", m)
33+ }
34+ r.state(func() {
35+ w := r.s.windows[m.ID]
36+ if w == nil || !w.isOverride() || w.isToplevel() || !w.mapped {
37+ t.Errorf("override window %+v", w)
38+ }
39+ if surf, _ := r.s.focusTarget(m.ID); surf != nil {
40+ t.Error("override window is a keyboard focus target")
41+ }
42+ })
43+
44+ r.peer.configureNotify(ovWin, 60, 70, 100, 50)
45+ if mv := awaitEvent[ports.UnmanagedMoved](t, r.events); mv.ID != m.ID || mv.X != 60 || mv.Y != 70 {
46+ t.Fatalf("moved %+v", mv)
47+ }
48+
49+ r.peer.unmapNotify(ovWin)
50+ if u := awaitEvent[ports.UnmanagedUnmapped](t, r.events); u.ID != m.ID {
51+ t.Fatalf("unmapped %+v", u)
52+ }
53+ r.state(func() {
54+ if r.s.windows[m.ID] != nil {
55+ t.Error("override window kept after unmap")
56+ }
57+ })
58+}
59+
60+func TestOverrideSurfaceDestroyUnmaps(t *testing.T) {
61+ r := newX11Rig(t)
62+ surf := r.overrideWindow(ovWin, 0, 0, x11Serial)
63+ m := awaitEvent[ports.UnmanagedMapped](t, r.events)
64+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestDestroy)
65+ if err := r.xc.Roundtrip(); err != nil {
66+ t.Fatal(err)
67+ }
68+ if u := awaitEvent[ports.UnmanagedUnmapped](t, r.events); u.ID != m.ID {
69+ t.Fatalf("unmapped %+v", u)
70+ }
71+}
72+
73+func TestOverrideTakesPointerAndOutput(t *testing.T) {
74+ r := newX11Rig(t)
75+ r.commands <- ports.SetOutputs{Outputs: ports.Layout{
76+ {Info: ports.OutputInfo{Name: "A"}, X: 0, Y: 0, Width: 1920, Height: 1080, Scale: 1},
77+ {Info: ports.OutputInfo{Name: "B"}, X: 1920, Y: 0, Width: 1920, Height: 1080, Scale: 1},
78+ }, Focused: "A"}
79+ r.overrideWindow(ovWin, 100, 10, x11Serial)
80+ m := awaitEvent[ports.UnmanagedMapped](t, r.events)
81+ r.state(func() {
82+ w := r.s.windows[m.ID]
83+ if got := r.s.frameOutput(w.x11.surface); got != "A" {
84+ t.Errorf("frame output %q, want A", got)
85+ }
86+ if surf, _, _, _ := r.s.pointerSurface(m.ID, 1, 1); surf == nil {
87+ t.Error("pointer cannot enter the override surface")
88+ }
89+ })
90+ r.peer.configureNotify(ovWin, 2000, 10, 100, 50)
91+ awaitEvent[ports.UnmanagedMoved](t, r.events)
92+ r.state(func() {
93+ if got := r.s.frameOutput(r.s.windows[m.ID].x11.surface); got != "B" {
94+ t.Errorf("frame output %q after move, want B", got)
95+ }
96+ })
97+}
98+
99+func TestOverrideMovedUnchangedAllocations(t *testing.T) {
100+ s := &Server{windows: map[ports.WindowID]*window{}, x11Slots: map[xwm.Slot]*window{}}
101+ w := &window{id: 1, x11: &x11Window{override: true, rect: ports.Rect{X: 1, Y: 2, W: 3, H: 4}}}
102+ s.x11Slots[1] = w
103+ h := hostImpl{s}
104+ if n := testing.AllocsPerRun(1000, func() { h.OverrideMoved(1, 1, 2, 3, 4) }); n != 0 {
105+ t.Fatalf("%v allocs", n)
106+ }
107+}
A internal/adapters/wayland/x11peer_test.go +332 -0
@@ -0,0 +1,332 @@
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+// createOverride announces an override-redirect window of 100x50 at x,y.
248+func (p *x11Peer) createOverride(win uint32, x, y int16) {
249+ p.event(16, func(b *[32]byte) {
250+ binary.LittleEndian.PutUint32(b[4:], x11PeerRoot)
251+ binary.LittleEndian.PutUint32(b[8:], win)
252+ binary.LittleEndian.PutUint16(b[12:], uint16(x))
253+ binary.LittleEndian.PutUint16(b[14:], uint16(y))
254+ binary.LittleEndian.PutUint16(b[16:], 100)
255+ binary.LittleEndian.PutUint16(b[18:], 50)
256+ b[22] = 1
257+ })
258+}
259+
260+func (p *x11Peer) mapNotify(win uint32) {
261+ p.event(19, func(b *[32]byte) {
262+ binary.LittleEndian.PutUint32(b[4:], x11PeerRoot)
263+ binary.LittleEndian.PutUint32(b[8:], win)
264+ b[12] = 1
265+ })
266+}
267+
268+// configureNotify moves and resizes a window (root SubstructureNotify form).
269+func (p *x11Peer) configureNotify(win uint32, x, y int16, w, h uint16) {
270+ p.event(22, func(b *[32]byte) {
271+ binary.LittleEndian.PutUint32(b[4:], x11PeerRoot)
272+ binary.LittleEndian.PutUint32(b[8:], win)
273+ binary.LittleEndian.PutUint16(b[16:], uint16(x))
274+ binary.LittleEndian.PutUint16(b[18:], uint16(y))
275+ binary.LittleEndian.PutUint16(b[20:], w)
276+ binary.LittleEndian.PutUint16(b[22:], h)
277+ b[24] = 1
278+ })
279+}
280+
281+func (p *x11Peer) windowEvent(code uint8, win uint32) {
282+ p.event(code, func(b *[32]byte) {
283+ binary.LittleEndian.PutUint32(b[4:], x11PeerRoot)
284+ binary.LittleEndian.PutUint32(b[8:], win)
285+ })
286+}
287+
288+func (p *x11Peer) mapRequest(win uint32) { p.windowEvent(20, win) }
289+func (p *x11Peer) unmapNotify(win uint32) { p.windowEvent(18, win) }
290+func (p *x11Peer) destroyNotify(win uint32) { p.windowEvent(17, win) }
291+
292+func (p *x11Peer) propertyNotify(win, atom uint32) {
293+ p.event(28, func(b *[32]byte) {
294+ binary.LittleEndian.PutUint32(b[4:], win)
295+ binary.LittleEndian.PutUint32(b[8:], atom)
296+ })
297+}
298+
299+func (p *x11Peer) clientMessage(win uint32, typ string, data ...uint32) {
300+ t := p.atom(typ)
301+ p.event(33, func(b *[32]byte) {
302+ b[1] = 32
303+ binary.LittleEndian.PutUint32(b[4:], win)
304+ binary.LittleEndian.PutUint32(b[8:], t)
305+ for i, d := range data {
306+ binary.LittleEndian.PutUint32(b[12+4*i:], d)
307+ }
308+ })
309+}
310+
311+func (p *x11Peer) requests() []x11Req {
312+ p.mu.Lock()
313+ defer p.mu.Unlock()
314+ return append([]x11Req(nil), p.reqs...)
315+}
316+
317+// waitReq waits for a request after the first skip ones that satisfies match.
318+func (p *x11Peer) waitReq(skip int, match func(x11Req) bool) (x11Req, int) {
319+ p.t.Helper()
320+ deadline := time.Now().Add(5 * time.Second)
321+ for time.Now().Before(deadline) {
322+ reqs := p.requests()
323+ for i := skip; i < len(reqs); i++ {
324+ if match(reqs[i]) {
325+ return reqs[i], i + 1
326+ }
327+ }
328+ time.Sleep(time.Millisecond)
329+ }
330+ p.t.Fatal("x11 peer: expected request never arrived")
331+ return x11Req{}, 0
332+}
@@ -0,0 +1,332 @@
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+// createOverride announces an override-redirect window of 100x50 at x,y.
248+func (p *x11Peer) createOverride(win uint32, x, y int16) {
249+ p.event(16, func(b *[32]byte) {
250+ binary.LittleEndian.PutUint32(b[4:], x11PeerRoot)
251+ binary.LittleEndian.PutUint32(b[8:], win)
252+ binary.LittleEndian.PutUint16(b[12:], uint16(x))
253+ binary.LittleEndian.PutUint16(b[14:], uint16(y))
254+ binary.LittleEndian.PutUint16(b[16:], 100)
255+ binary.LittleEndian.PutUint16(b[18:], 50)
256+ b[22] = 1
257+ })
258+}
259+
260+func (p *x11Peer) mapNotify(win uint32) {
261+ p.event(19, func(b *[32]byte) {
262+ binary.LittleEndian.PutUint32(b[4:], x11PeerRoot)
263+ binary.LittleEndian.PutUint32(b[8:], win)
264+ b[12] = 1
265+ })
266+}
267+
268+// configureNotify moves and resizes a window (root SubstructureNotify form).
269+func (p *x11Peer) configureNotify(win uint32, x, y int16, w, h uint16) {
270+ p.event(22, func(b *[32]byte) {
271+ binary.LittleEndian.PutUint32(b[4:], x11PeerRoot)
272+ binary.LittleEndian.PutUint32(b[8:], win)
273+ binary.LittleEndian.PutUint16(b[16:], uint16(x))
274+ binary.LittleEndian.PutUint16(b[18:], uint16(y))
275+ binary.LittleEndian.PutUint16(b[20:], w)
276+ binary.LittleEndian.PutUint16(b[22:], h)
277+ b[24] = 1
278+ })
279+}
280+
281+func (p *x11Peer) windowEvent(code uint8, win uint32) {
282+ p.event(code, func(b *[32]byte) {
283+ binary.LittleEndian.PutUint32(b[4:], x11PeerRoot)
284+ binary.LittleEndian.PutUint32(b[8:], win)
285+ })
286+}
287+
288+func (p *x11Peer) mapRequest(win uint32) { p.windowEvent(20, win) }
289+func (p *x11Peer) unmapNotify(win uint32) { p.windowEvent(18, win) }
290+func (p *x11Peer) destroyNotify(win uint32) { p.windowEvent(17, win) }
291+
292+func (p *x11Peer) propertyNotify(win, atom uint32) {
293+ p.event(28, func(b *[32]byte) {
294+ binary.LittleEndian.PutUint32(b[4:], win)
295+ binary.LittleEndian.PutUint32(b[8:], atom)
296+ })
297+}
298+
299+func (p *x11Peer) clientMessage(win uint32, typ string, data ...uint32) {
300+ t := p.atom(typ)
301+ p.event(33, func(b *[32]byte) {
302+ b[1] = 32
303+ binary.LittleEndian.PutUint32(b[4:], win)
304+ binary.LittleEndian.PutUint32(b[8:], t)
305+ for i, d := range data {
306+ binary.LittleEndian.PutUint32(b[12+4*i:], d)
307+ }
308+ })
309+}
310+
311+func (p *x11Peer) requests() []x11Req {
312+ p.mu.Lock()
313+ defer p.mu.Unlock()
314+ return append([]x11Req(nil), p.reqs...)
315+}
316+
317+// waitReq waits for a request after the first skip ones that satisfies match.
318+func (p *x11Peer) waitReq(skip int, match func(x11Req) bool) (x11Req, int) {
319+ p.t.Helper()
320+ deadline := time.Now().Add(5 * time.Second)
321+ for time.Now().Before(deadline) {
322+ reqs := p.requests()
323+ for i := skip; i < len(reqs); i++ {
324+ if match(reqs[i]) {
325+ return reqs[i], i + 1
326+ }
327+ }
328+ time.Sleep(time.Millisecond)
329+ }
330+ p.t.Fatal("x11 peer: expected request never arrived")
331+ return x11Req{}, 0
332+}
A internal/adapters/wayland/x11window.go +458 -0
@@ -0,0 +1,458 @@
1+package wayland
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/go-wayland-bindings/server/wayland"
7+ "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
8+ "github.com/bnema/neferwl/internal/adapters/wayland/xwm"
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/bnema/purego-libwayland/server"
11+)
12+
13+// x11Window is the X11 role of a window: the XWM slot, the window's X id and
14+// the wl_surface Xwayland paired with it. Display goroutine only.
15+type x11Window struct {
16+ server *Server
17+ slot xwm.Slot
18+ xid uint32
19+ surface *surface
20+ info xwm.WindowInfo
21+ // ready is set once the XWM read the window's properties (Ready).
22+ ready bool
23+ // override marks an override-redirect window: tracked and drawn, never a
24+ // toplevel nor keyboard focus. rect is its layout rect, noInput keeps the
25+ // pointer off it.
26+ override bool
27+ noInput bool
28+ rect ports.Rect
29+}
30+
31+// xwaylandRestricted are the globals only the Xwayland client may see.
32+func xwaylandRestricted(iface string) bool {
33+ return iface == "xwayland_shell_v1" || iface == "zwp_xwayland_keyboard_grab_manager_v1"
34+}
35+
36+// globalVisible is the display's global filter: the Xwayland-only globals
37+// are visible to, and bindable by, the Xwayland client alone.
38+func (s *Server) globalVisible(c server.Client, g *server.Global) bool {
39+ if g == s.xdgPrivate {
40+ return s.xwayland.client != (server.Client{}) && c == s.xwayland.client
41+ }
42+ if g == s.xdgPublic {
43+ return c != s.xwayland.client
44+ }
45+ if xwaylandRestricted(g.Interface()) {
46+ return s.xwayland.client != (server.Client{}) && c == s.xwayland.client
47+ }
48+ return true
49+}
50+
51+func registerXwaylandShell(d *server.Display, s *Server) error {
52+ return xwaylandshell.NewXwaylandShellV1Global(d, 1, func(c server.Client, v, id uint32) {
53+ _, _ = xwaylandshell.NewXwaylandShellV1(c, int32(v), id, xwaylandShell{s})
54+ })
55+}
56+
57+type xwaylandShell struct{ s *Server }
58+
59+func (xwaylandShell) Destroy(*xwaylandshell.XwaylandShellV1) {}
60+
61+func (h xwaylandShell) GetXwaylandSurface(r *xwaylandshell.XwaylandShellV1, id uint32, w *wayland.Surface) {
62+ surf := h.s.surfaceOf(w)
63+ if surf == nil {
64+ return
65+ }
66+ if surf.kind != roleNone {
67+ r.PostError(uint32(xwaylandshell.XwaylandShellV1ErrorRole), "surface already has a role")
68+ return
69+ }
70+ if _, err := xwaylandshell.NewXwaylandSurfaceV1(r.Client(), r.Version(), id, xwaylandSurface{h.s, surf}); err != nil {
71+ return
72+ }
73+ surf.kind = roleX11
74+}
75+
76+type xwaylandSurface struct {
77+ s *Server
78+ surf *surface
79+}
80+
81+func (x xwaylandSurface) SetSerial(r *xwaylandshell.XwaylandSurfaceV1, lo, hi uint32) {
82+ serial := uint64(hi)<<32 | uint64(lo)
83+ switch {
84+ case x.surf.x11Serial != 0:
85+ r.PostError(uint32(xwaylandshell.XwaylandSurfaceV1ErrorAlreadyAssociated), "serial already set")
86+ case serial == 0:
87+ r.PostError(uint32(xwaylandshell.XwaylandSurfaceV1ErrorInvalidSerial), "serial is zero")
88+ default:
89+ x.surf.x11Serial = serial
90+ if x.s.xwm != nil {
91+ x.s.xwm.SetSerial(x.surf.identity, serial)
92+ }
93+ }
94+}
95+
96+func (xwaylandSurface) Destroy(*xwaylandshell.XwaylandSurfaceV1) {}
97+
98+// installXWM makes x the server's window manager. Display goroutine only.
99+func (s *Server) installXWM(x *xwm.XWM) {
100+ s.teardownXWM()
101+ s.xwm = x
102+ s.xwmSel = x
103+ x.UseSpace(&s.xspace)
104+ if err := x.Attach(s.display, hostImpl{s}); err != nil {
105+ s.log.Warn().Err(err).Msg("X11 window management disabled")
106+ s.xwm, s.xwmSel = nil, nil
107+ x.CloseConn()
108+ return
109+ }
110+ // Serials sent before the XWM was attached pair now.
111+ for _, surf := range s.surfaces {
112+ if surf.kind == roleX11 && surf.x11Serial != 0 && surf.x11win == nil {
113+ x.SetSerial(surf.identity, surf.x11Serial)
114+ }
115+ }
116+ for kind, src := range s.selections { // what Wayland offered before the XWM attached
117+ if src != nil && src.alive() {
118+ s.exposeToX(kind, src)
119+ }
120+ }
121+ x.LayoutChanged(true)
122+}
123+
124+// teardownXWM closes the window manager, dropping its windows. Idempotent.
125+func (s *Server) teardownXWM() {
126+ if x := s.xwm; x != nil {
127+ s.xwm, s.xwmSel = nil, nil
128+ x.Close()
129+ }
130+}
131+
132+// x11Configure forwards the latest core configure of an X11 window.
133+func (s *Server) x11Configure(w *window) {
134+ if s.xwm != nil {
135+ s.xwm.Configure(w.x11.slot, w.last)
136+ }
137+}
138+
139+// x11Close asks an X11 window to close.
140+func (s *Server) x11Close(w *window) {
141+ if s.xwm != nil {
142+ s.xwm.CloseWindow(w.x11.slot)
143+ }
144+}
145+
146+// x11Place forwards core's position of an X11 window's client rect.
147+func (s *Server) x11Place(c ports.PlaceWindow) {
148+ if w := s.windows[c.ID]; w != nil && w.x11 != nil && s.xwm != nil {
149+ s.xwm.Place(w.x11.slot, c.X, c.Y)
150+ }
151+}
152+
153+// x11Focus tells the XWM which X window holds the keyboard focus, none when a
154+// native window does.
155+func (s *Server) x11Focus() {
156+ if s.xwm == nil {
157+ return
158+ }
159+ var slot xwm.Slot
160+ if w := s.windows[s.seat.focused]; w != nil && w.x11 != nil {
161+ slot = w.x11.slot
162+ }
163+ s.xwm.Focus(slot)
164+}
165+
166+// hostImpl is the server as the XWM sees it.
167+type hostImpl struct{ s *Server }
168+
169+func (h hostImpl) window(slot xwm.Slot) *window { return h.s.x11Slots[slot] }
170+
171+func (h hostImpl) Created(slot xwm.Slot, xid uint32) ports.WindowID {
172+ s := h.s
173+ w := &window{id: s.nextWindow}
174+ w.x11 = &x11Window{server: s, slot: slot, xid: xid}
175+ s.nextWindow++
176+ s.windows[w.id] = w
177+ s.x11Slots[slot] = w
178+ return w.id
179+}
180+
181+func (h hostImpl) Ready(slot xwm.Slot, info xwm.WindowInfo) {
182+ w := h.window(slot)
183+ if w == nil {
184+ return
185+ }
186+ w.x11.info, w.x11.ready = info, true
187+ w.title, w.appID, w.modal = info.Title, info.AppID, info.Modal
188+ w.parent = h.window(info.Parent)
189+ // A surface associated and committed earlier maps now.
190+ w.x11.tryMap()
191+}
192+
193+func (h hostImpl) Associate(slot xwm.Slot, surfaceID uint64) {
194+ w := h.window(slot)
195+ surf := h.s.surfaceByIdentity[surfaceID]
196+ if w == nil || surf == nil || surf.kind != roleX11 {
197+ return
198+ }
199+ x := w.x11
200+ if x.surface != nil && x.surface != surf {
201+ // A new surface replaces the shown one: core sees a remap.
202+ w.unmap()
203+ }
204+ x.drop()
205+ x.surface, surf.x11win = surf, w
206+ surf.role = func(buffer bool) {
207+ switch {
208+ case surf.destroyed:
209+ w.unmap()
210+ if x.surface == surf {
211+ x.surface = nil
212+ }
213+ surf.x11win = nil
214+ case buffer:
215+ x.tryMap()
216+ case w.mapped:
217+ w.unmap()
218+ }
219+ }
220+ // The buffer may have been committed before the pairing.
221+ x.tryMap()
222+}
223+
224+func (h hostImpl) Dissociate(slot xwm.Slot, destroyed bool) {
225+ w := h.window(slot)
226+ if w == nil {
227+ return
228+ }
229+ w.unmap()
230+ w.x11.drop()
231+ w.x11.ready = false
232+ if destroyed {
233+ for _, c := range h.s.windows {
234+ if c.parent == w {
235+ c.parent = nil
236+ }
237+ }
238+ delete(h.s.windows, w.id)
239+ delete(h.s.x11Slots, slot)
240+ h.s.foreignToplevelGone(w)
241+ }
242+}
243+
244+func (h hostImpl) Title(slot xwm.Slot, title string) {
245+ if w := h.window(slot); w != nil && w.title != title {
246+ w.title = title
247+ h.s.toplevelChanged(w)
248+ if w.mapped {
249+ h.s.castInventoryChanged()
250+ }
251+ }
252+}
253+
254+func (h hostImpl) AppID(slot xwm.Slot, appID string) {
255+ w := h.window(slot)
256+ if w == nil || w.appID == appID {
257+ return
258+ }
259+ w.appID = appID
260+ if w.mapped {
261+ h.s.emit(ports.WindowAppID{ID: w.id, AppID: appID})
262+ h.s.toplevelChanged(w)
263+ h.s.castInventoryChanged()
264+ }
265+}
266+
267+func (h hostImpl) Parent(slot xwm.Slot, parent xwm.Slot) {
268+ if w := h.window(slot); w != nil {
269+ w.setParent(h.window(parent))
270+ }
271+}
272+
273+func (h hostImpl) Fullscreen(slot xwm.Slot, on bool) {
274+ w := h.window(slot)
275+ if w == nil {
276+ return
277+ }
278+ w.x11.info.Fullscreen = on
279+ if w.mapped {
280+ h.s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: on})
281+ }
282+}
283+
284+// drop unlinks the window from its surface.
285+func (x *x11Window) drop() {
286+ if surf := x.surface; surf != nil {
287+ surf.x11win, surf.role = nil, nil
288+ x.surface = nil
289+ }
290+}
291+
292+// tryMap maps the window once the XWM knows it and its surface shows a buffer.
293+func (x *x11Window) tryMap() {
294+ surf := x.surface
295+ if surf == nil {
296+ return
297+ }
298+ w := surf.x11win
299+ if w == nil || w.mapped || !x.ready || surf.destroyed || surf.current == nil {
300+ return
301+ }
302+ s := x.server
303+ w.mapped = true
304+ w.gen++
305+ w.floating = x.info.Floating
306+ w.floatW, w.floatH = 0, 0
307+ if w.floating {
308+ w.floatW, w.floatH = x.info.Width, x.info.Height
309+ }
310+ var parent ports.WindowID
311+ if w.parent != nil && w.parent.mapped {
312+ parent = w.parent.id
313+ }
314+ 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, HasAt: x.info.HasAt, AtX: x.info.AtX, AtY: x.info.AtY, Fullscreen: x.info.Fullscreen})
315+ s.syncInhibitors()
316+ s.toplevelChanged(w)
317+ s.castInventoryChanged()
318+ 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")
319+ // Called from a commit, the content follows right after; called from the
320+ // pairing, the surface already shows a buffer and is published now.
321+ if surf.has {
322+ surf.resetInputEmission()
323+ surf.redraw()
324+ surf.emitInput()
325+ }
326+}
327+
328+// OverrideMapped starts tracking an override-redirect window. It is announced
329+// to core on the paired surface's first buffer commit, so it is never drawn
330+// without content.
331+func (h hostImpl) OverrideMapped(slot xwm.Slot, xid uint32, surfaceID uint64, x, y, w, h2 int, noInput bool) {
332+ s := h.s
333+ surf := s.surfaceByIdentity[surfaceID]
334+ if surf == nil || surf.kind != roleX11 || surf.x11win != nil {
335+ return
336+ }
337+ if old := s.x11Slots[slot]; old != nil {
338+ h.OverrideGone(slot)
339+ }
340+ win := &window{id: s.nextWindow}
341+ win.x11 = &x11Window{server: s, slot: slot, xid: xid, override: true, noInput: noInput, rect: ports.Rect{X: x, Y: y, W: w, H: h2}}
342+ s.nextWindow++
343+ s.windows[win.id] = win
344+ s.x11Slots[slot] = win
345+ ox := win.x11
346+ ox.surface, surf.x11win = surf, win
347+ surf.role = func(buffer bool) {
348+ switch {
349+ case surf.destroyed:
350+ ox.hide()
351+ ox.surface = nil
352+ surf.x11win = nil
353+ case buffer:
354+ ox.show()
355+ case win.mapped:
356+ ox.hide()
357+ }
358+ }
359+ ox.show()
360+}
361+
362+// OverrideMoved records the new layout rect of an override window and, once
363+// shown, tells core. An unchanged rect costs nothing.
364+func (h hostImpl) OverrideMoved(slot xwm.Slot, x, y, w, h2 int) {
365+ win := h.s.x11Slots[slot]
366+ if win == nil || win.x11 == nil || !win.x11.override {
367+ return
368+ }
369+ r := ports.Rect{X: x, Y: y, W: w, H: h2}
370+ if r == win.x11.rect {
371+ return
372+ }
373+ win.x11.rect = r
374+ if win.mapped {
375+ h.s.emit(ports.UnmanagedMoved{ID: win.id, X: x, Y: y, Width: w, Height: h2})
376+ win.x11.surface.sendTreeScale()
377+ }
378+}
379+
380+// OverrideGone ends an override window: unmapped, or destroyed.
381+func (h hostImpl) SelectionOwned(kind int, mimes []string) {
382+ h.s.log.Debug().Int("kind", kind).Int("mimes", len(mimes)).Msg("X selection owner changed")
383+ h.s.setXSelection(kind, mimes)
384+}
385+
386+func (h hostImpl) SelectionRead(kind int, mime string) int {
387+ return h.s.xSelectionRead(kind, mime)
388+}
389+
390+func (h hostImpl) After(d time.Duration, fn func()) func() {
391+ t := h.s.clock.AfterFunc(d, func() { h.s.display.Post(fn) })
392+ return func() { t.Stop() }
393+}
394+
395+func (h hostImpl) OverrideGone(slot xwm.Slot) {
396+ s := h.s
397+ win := s.x11Slots[slot]
398+ if win == nil || win.x11 == nil || !win.x11.override {
399+ return
400+ }
401+ win.x11.hide()
402+ win.x11.drop()
403+ delete(s.windows, win.id)
404+ delete(s.x11Slots, slot)
405+}
406+
407+// show announces the window to core once its surface has content.
408+func (x *x11Window) show() {
409+ surf := x.surface
410+ if surf == nil || surf.x11win == nil || surf.destroyed || surf.current == nil {
411+ return
412+ }
413+ w := surf.x11win
414+ if w.mapped {
415+ return
416+ }
417+ s := x.server
418+ w.mapped = true
419+ r := x.rect
420+ s.emit(ports.UnmanagedMapped{ID: w.id, X: r.X, Y: r.Y, Width: r.W, Height: r.H, NoInput: x.noInput})
421+ surf.sendTreeScale()
422+ if surf.has {
423+ surf.resetInputEmission()
424+ surf.redraw()
425+ surf.emitInput()
426+ }
427+}
428+
429+// hide withdraws a shown override window from core and the seat.
430+func (x *x11Window) hide() {
431+ if x.surface == nil || x.surface.x11win == nil {
432+ return
433+ }
434+ w := x.surface.x11win
435+ s := x.server
436+ if s.seat.pointerFocus == w.id {
437+ s.changePointerFocus(0, 0, 0)
438+ }
439+ if !w.mapped {
440+ return
441+ }
442+ w.mapped = false
443+ x.surface.resetInputEmission()
444+ s.emit(ports.UnmanagedUnmapped{ID: w.id})
445+ s.emitContent(ports.SurfaceContent{ID: w.id}, damage{full: true})
446+}
447+
448+// isOverride reports whether w is an override-redirect X11 window.
449+func (w *window) isOverride() bool { return w.x11 != nil && w.x11.override }
450+
451+// overrideOutput is the name of the output under the origin of an override
452+// window's rect, "" when none.
453+func (s *Server) overrideOutput(w *window) string {
454+ if o, ok := s.outputPlaces.At(float64(w.x11.rect.X), float64(w.x11.rect.Y)); ok {
455+ return o.Info.Name
456+ }
457+ return ""
458+}
@@ -0,0 +1,458 @@
1+package wayland
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/go-wayland-bindings/server/wayland"
7+ "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
8+ "github.com/bnema/neferwl/internal/adapters/wayland/xwm"
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/bnema/purego-libwayland/server"
11+)
12+
13+// x11Window is the X11 role of a window: the XWM slot, the window's X id and
14+// the wl_surface Xwayland paired with it. Display goroutine only.
15+type x11Window struct {
16+ server *Server
17+ slot xwm.Slot
18+ xid uint32
19+ surface *surface
20+ info xwm.WindowInfo
21+ // ready is set once the XWM read the window's properties (Ready).
22+ ready bool
23+ // override marks an override-redirect window: tracked and drawn, never a
24+ // toplevel nor keyboard focus. rect is its layout rect, noInput keeps the
25+ // pointer off it.
26+ override bool
27+ noInput bool
28+ rect ports.Rect
29+}
30+
31+// xwaylandRestricted are the globals only the Xwayland client may see.
32+func xwaylandRestricted(iface string) bool {
33+ return iface == "xwayland_shell_v1" || iface == "zwp_xwayland_keyboard_grab_manager_v1"
34+}
35+
36+// globalVisible is the display's global filter: the Xwayland-only globals
37+// are visible to, and bindable by, the Xwayland client alone.
38+func (s *Server) globalVisible(c server.Client, g *server.Global) bool {
39+ if g == s.xdgPrivate {
40+ return s.xwayland.client != (server.Client{}) && c == s.xwayland.client
41+ }
42+ if g == s.xdgPublic {
43+ return c != s.xwayland.client
44+ }
45+ if xwaylandRestricted(g.Interface()) {
46+ return s.xwayland.client != (server.Client{}) && c == s.xwayland.client
47+ }
48+ return true
49+}
50+
51+func registerXwaylandShell(d *server.Display, s *Server) error {
52+ return xwaylandshell.NewXwaylandShellV1Global(d, 1, func(c server.Client, v, id uint32) {
53+ _, _ = xwaylandshell.NewXwaylandShellV1(c, int32(v), id, xwaylandShell{s})
54+ })
55+}
56+
57+type xwaylandShell struct{ s *Server }
58+
59+func (xwaylandShell) Destroy(*xwaylandshell.XwaylandShellV1) {}
60+
61+func (h xwaylandShell) GetXwaylandSurface(r *xwaylandshell.XwaylandShellV1, id uint32, w *wayland.Surface) {
62+ surf := h.s.surfaceOf(w)
63+ if surf == nil {
64+ return
65+ }
66+ if surf.kind != roleNone {
67+ r.PostError(uint32(xwaylandshell.XwaylandShellV1ErrorRole), "surface already has a role")
68+ return
69+ }
70+ if _, err := xwaylandshell.NewXwaylandSurfaceV1(r.Client(), r.Version(), id, xwaylandSurface{h.s, surf}); err != nil {
71+ return
72+ }
73+ surf.kind = roleX11
74+}
75+
76+type xwaylandSurface struct {
77+ s *Server
78+ surf *surface
79+}
80+
81+func (x xwaylandSurface) SetSerial(r *xwaylandshell.XwaylandSurfaceV1, lo, hi uint32) {
82+ serial := uint64(hi)<<32 | uint64(lo)
83+ switch {
84+ case x.surf.x11Serial != 0:
85+ r.PostError(uint32(xwaylandshell.XwaylandSurfaceV1ErrorAlreadyAssociated), "serial already set")
86+ case serial == 0:
87+ r.PostError(uint32(xwaylandshell.XwaylandSurfaceV1ErrorInvalidSerial), "serial is zero")
88+ default:
89+ x.surf.x11Serial = serial
90+ if x.s.xwm != nil {
91+ x.s.xwm.SetSerial(x.surf.identity, serial)
92+ }
93+ }
94+}
95+
96+func (xwaylandSurface) Destroy(*xwaylandshell.XwaylandSurfaceV1) {}
97+
98+// installXWM makes x the server's window manager. Display goroutine only.
99+func (s *Server) installXWM(x *xwm.XWM) {
100+ s.teardownXWM()
101+ s.xwm = x
102+ s.xwmSel = x
103+ x.UseSpace(&s.xspace)
104+ if err := x.Attach(s.display, hostImpl{s}); err != nil {
105+ s.log.Warn().Err(err).Msg("X11 window management disabled")
106+ s.xwm, s.xwmSel = nil, nil
107+ x.CloseConn()
108+ return
109+ }
110+ // Serials sent before the XWM was attached pair now.
111+ for _, surf := range s.surfaces {
112+ if surf.kind == roleX11 && surf.x11Serial != 0 && surf.x11win == nil {
113+ x.SetSerial(surf.identity, surf.x11Serial)
114+ }
115+ }
116+ for kind, src := range s.selections { // what Wayland offered before the XWM attached
117+ if src != nil && src.alive() {
118+ s.exposeToX(kind, src)
119+ }
120+ }
121+ x.LayoutChanged(true)
122+}
123+
124+// teardownXWM closes the window manager, dropping its windows. Idempotent.
125+func (s *Server) teardownXWM() {
126+ if x := s.xwm; x != nil {
127+ s.xwm, s.xwmSel = nil, nil
128+ x.Close()
129+ }
130+}
131+
132+// x11Configure forwards the latest core configure of an X11 window.
133+func (s *Server) x11Configure(w *window) {
134+ if s.xwm != nil {
135+ s.xwm.Configure(w.x11.slot, w.last)
136+ }
137+}
138+
139+// x11Close asks an X11 window to close.
140+func (s *Server) x11Close(w *window) {
141+ if s.xwm != nil {
142+ s.xwm.CloseWindow(w.x11.slot)
143+ }
144+}
145+
146+// x11Place forwards core's position of an X11 window's client rect.
147+func (s *Server) x11Place(c ports.PlaceWindow) {
148+ if w := s.windows[c.ID]; w != nil && w.x11 != nil && s.xwm != nil {
149+ s.xwm.Place(w.x11.slot, c.X, c.Y)
150+ }
151+}
152+
153+// x11Focus tells the XWM which X window holds the keyboard focus, none when a
154+// native window does.
155+func (s *Server) x11Focus() {
156+ if s.xwm == nil {
157+ return
158+ }
159+ var slot xwm.Slot
160+ if w := s.windows[s.seat.focused]; w != nil && w.x11 != nil {
161+ slot = w.x11.slot
162+ }
163+ s.xwm.Focus(slot)
164+}
165+
166+// hostImpl is the server as the XWM sees it.
167+type hostImpl struct{ s *Server }
168+
169+func (h hostImpl) window(slot xwm.Slot) *window { return h.s.x11Slots[slot] }
170+
171+func (h hostImpl) Created(slot xwm.Slot, xid uint32) ports.WindowID {
172+ s := h.s
173+ w := &window{id: s.nextWindow}
174+ w.x11 = &x11Window{server: s, slot: slot, xid: xid}
175+ s.nextWindow++
176+ s.windows[w.id] = w
177+ s.x11Slots[slot] = w
178+ return w.id
179+}
180+
181+func (h hostImpl) Ready(slot xwm.Slot, info xwm.WindowInfo) {
182+ w := h.window(slot)
183+ if w == nil {
184+ return
185+ }
186+ w.x11.info, w.x11.ready = info, true
187+ w.title, w.appID, w.modal = info.Title, info.AppID, info.Modal
188+ w.parent = h.window(info.Parent)
189+ // A surface associated and committed earlier maps now.
190+ w.x11.tryMap()
191+}
192+
193+func (h hostImpl) Associate(slot xwm.Slot, surfaceID uint64) {
194+ w := h.window(slot)
195+ surf := h.s.surfaceByIdentity[surfaceID]
196+ if w == nil || surf == nil || surf.kind != roleX11 {
197+ return
198+ }
199+ x := w.x11
200+ if x.surface != nil && x.surface != surf {
201+ // A new surface replaces the shown one: core sees a remap.
202+ w.unmap()
203+ }
204+ x.drop()
205+ x.surface, surf.x11win = surf, w
206+ surf.role = func(buffer bool) {
207+ switch {
208+ case surf.destroyed:
209+ w.unmap()
210+ if x.surface == surf {
211+ x.surface = nil
212+ }
213+ surf.x11win = nil
214+ case buffer:
215+ x.tryMap()
216+ case w.mapped:
217+ w.unmap()
218+ }
219+ }
220+ // The buffer may have been committed before the pairing.
221+ x.tryMap()
222+}
223+
224+func (h hostImpl) Dissociate(slot xwm.Slot, destroyed bool) {
225+ w := h.window(slot)
226+ if w == nil {
227+ return
228+ }
229+ w.unmap()
230+ w.x11.drop()
231+ w.x11.ready = false
232+ if destroyed {
233+ for _, c := range h.s.windows {
234+ if c.parent == w {
235+ c.parent = nil
236+ }
237+ }
238+ delete(h.s.windows, w.id)
239+ delete(h.s.x11Slots, slot)
240+ h.s.foreignToplevelGone(w)
241+ }
242+}
243+
244+func (h hostImpl) Title(slot xwm.Slot, title string) {
245+ if w := h.window(slot); w != nil && w.title != title {
246+ w.title = title
247+ h.s.toplevelChanged(w)
248+ if w.mapped {
249+ h.s.castInventoryChanged()
250+ }
251+ }
252+}
253+
254+func (h hostImpl) AppID(slot xwm.Slot, appID string) {
255+ w := h.window(slot)
256+ if w == nil || w.appID == appID {
257+ return
258+ }
259+ w.appID = appID
260+ if w.mapped {
261+ h.s.emit(ports.WindowAppID{ID: w.id, AppID: appID})
262+ h.s.toplevelChanged(w)
263+ h.s.castInventoryChanged()
264+ }
265+}
266+
267+func (h hostImpl) Parent(slot xwm.Slot, parent xwm.Slot) {
268+ if w := h.window(slot); w != nil {
269+ w.setParent(h.window(parent))
270+ }
271+}
272+
273+func (h hostImpl) Fullscreen(slot xwm.Slot, on bool) {
274+ w := h.window(slot)
275+ if w == nil {
276+ return
277+ }
278+ w.x11.info.Fullscreen = on
279+ if w.mapped {
280+ h.s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: on})
281+ }
282+}
283+
284+// drop unlinks the window from its surface.
285+func (x *x11Window) drop() {
286+ if surf := x.surface; surf != nil {
287+ surf.x11win, surf.role = nil, nil
288+ x.surface = nil
289+ }
290+}
291+
292+// tryMap maps the window once the XWM knows it and its surface shows a buffer.
293+func (x *x11Window) tryMap() {
294+ surf := x.surface
295+ if surf == nil {
296+ return
297+ }
298+ w := surf.x11win
299+ if w == nil || w.mapped || !x.ready || surf.destroyed || surf.current == nil {
300+ return
301+ }
302+ s := x.server
303+ w.mapped = true
304+ w.gen++
305+ w.floating = x.info.Floating
306+ w.floatW, w.floatH = 0, 0
307+ if w.floating {
308+ w.floatW, w.floatH = x.info.Width, x.info.Height
309+ }
310+ var parent ports.WindowID
311+ if w.parent != nil && w.parent.mapped {
312+ parent = w.parent.id
313+ }
314+ 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, HasAt: x.info.HasAt, AtX: x.info.AtX, AtY: x.info.AtY, Fullscreen: x.info.Fullscreen})
315+ s.syncInhibitors()
316+ s.toplevelChanged(w)
317+ s.castInventoryChanged()
318+ 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")
319+ // Called from a commit, the content follows right after; called from the
320+ // pairing, the surface already shows a buffer and is published now.
321+ if surf.has {
322+ surf.resetInputEmission()
323+ surf.redraw()
324+ surf.emitInput()
325+ }
326+}
327+
328+// OverrideMapped starts tracking an override-redirect window. It is announced
329+// to core on the paired surface's first buffer commit, so it is never drawn
330+// without content.
331+func (h hostImpl) OverrideMapped(slot xwm.Slot, xid uint32, surfaceID uint64, x, y, w, h2 int, noInput bool) {
332+ s := h.s
333+ surf := s.surfaceByIdentity[surfaceID]
334+ if surf == nil || surf.kind != roleX11 || surf.x11win != nil {
335+ return
336+ }
337+ if old := s.x11Slots[slot]; old != nil {
338+ h.OverrideGone(slot)
339+ }
340+ win := &window{id: s.nextWindow}
341+ win.x11 = &x11Window{server: s, slot: slot, xid: xid, override: true, noInput: noInput, rect: ports.Rect{X: x, Y: y, W: w, H: h2}}
342+ s.nextWindow++
343+ s.windows[win.id] = win
344+ s.x11Slots[slot] = win
345+ ox := win.x11
346+ ox.surface, surf.x11win = surf, win
347+ surf.role = func(buffer bool) {
348+ switch {
349+ case surf.destroyed:
350+ ox.hide()
351+ ox.surface = nil
352+ surf.x11win = nil
353+ case buffer:
354+ ox.show()
355+ case win.mapped:
356+ ox.hide()
357+ }
358+ }
359+ ox.show()
360+}
361+
362+// OverrideMoved records the new layout rect of an override window and, once
363+// shown, tells core. An unchanged rect costs nothing.
364+func (h hostImpl) OverrideMoved(slot xwm.Slot, x, y, w, h2 int) {
365+ win := h.s.x11Slots[slot]
366+ if win == nil || win.x11 == nil || !win.x11.override {
367+ return
368+ }
369+ r := ports.Rect{X: x, Y: y, W: w, H: h2}
370+ if r == win.x11.rect {
371+ return
372+ }
373+ win.x11.rect = r
374+ if win.mapped {
375+ h.s.emit(ports.UnmanagedMoved{ID: win.id, X: x, Y: y, Width: w, Height: h2})
376+ win.x11.surface.sendTreeScale()
377+ }
378+}
379+
380+// OverrideGone ends an override window: unmapped, or destroyed.
381+func (h hostImpl) SelectionOwned(kind int, mimes []string) {
382+ h.s.log.Debug().Int("kind", kind).Int("mimes", len(mimes)).Msg("X selection owner changed")
383+ h.s.setXSelection(kind, mimes)
384+}
385+
386+func (h hostImpl) SelectionRead(kind int, mime string) int {
387+ return h.s.xSelectionRead(kind, mime)
388+}
389+
390+func (h hostImpl) After(d time.Duration, fn func()) func() {
391+ t := h.s.clock.AfterFunc(d, func() { h.s.display.Post(fn) })
392+ return func() { t.Stop() }
393+}
394+
395+func (h hostImpl) OverrideGone(slot xwm.Slot) {
396+ s := h.s
397+ win := s.x11Slots[slot]
398+ if win == nil || win.x11 == nil || !win.x11.override {
399+ return
400+ }
401+ win.x11.hide()
402+ win.x11.drop()
403+ delete(s.windows, win.id)
404+ delete(s.x11Slots, slot)
405+}
406+
407+// show announces the window to core once its surface has content.
408+func (x *x11Window) show() {
409+ surf := x.surface
410+ if surf == nil || surf.x11win == nil || surf.destroyed || surf.current == nil {
411+ return
412+ }
413+ w := surf.x11win
414+ if w.mapped {
415+ return
416+ }
417+ s := x.server
418+ w.mapped = true
419+ r := x.rect
420+ s.emit(ports.UnmanagedMapped{ID: w.id, X: r.X, Y: r.Y, Width: r.W, Height: r.H, NoInput: x.noInput})
421+ surf.sendTreeScale()
422+ if surf.has {
423+ surf.resetInputEmission()
424+ surf.redraw()
425+ surf.emitInput()
426+ }
427+}
428+
429+// hide withdraws a shown override window from core and the seat.
430+func (x *x11Window) hide() {
431+ if x.surface == nil || x.surface.x11win == nil {
432+ return
433+ }
434+ w := x.surface.x11win
435+ s := x.server
436+ if s.seat.pointerFocus == w.id {
437+ s.changePointerFocus(0, 0, 0)
438+ }
439+ if !w.mapped {
440+ return
441+ }
442+ w.mapped = false
443+ x.surface.resetInputEmission()
444+ s.emit(ports.UnmanagedUnmapped{ID: w.id})
445+ s.emitContent(ports.SurfaceContent{ID: w.id}, damage{full: true})
446+}
447+
448+// isOverride reports whether w is an override-redirect X11 window.
449+func (w *window) isOverride() bool { return w.x11 != nil && w.x11.override }
450+
451+// overrideOutput is the name of the output under the origin of an override
452+// window's rect, "" when none.
453+func (s *Server) overrideOutput(w *window) string {
454+ if o, ok := s.outputPlaces.At(float64(w.x11.rect.X), float64(w.x11.rect.Y)); ok {
455+ return o.Info.Name
456+ }
457+ return ""
458+}
A internal/adapters/wayland/x11window_test.go +627 -0
@@ -0,0 +1,627 @@
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/pointerconstraints"
12+ "github.com/bnema/go-wayland-bindings/server/relativepointer"
13+ "github.com/bnema/go-wayland-bindings/server/wayland"
14+ "github.com/bnema/go-wayland-bindings/server/xdgshell"
15+ "github.com/bnema/go-wayland-bindings/server/xwaylandkeyboardgrab"
16+ "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
17+ "github.com/bnema/neferwl/internal/ports"
18+ "github.com/bnema/wlturbo"
19+ "golang.org/x/sys/unix"
20+)
21+
22+var x11Serial = uint64(0x1234_0000_0042)
23+
24+// x11Rig is a server with an Xwayland client (a real wlturbo client on the
25+// adopted descriptor), a scripted X peer on the WM descriptor and the XWM
26+// between them.
27+type x11Rig struct {
28+ t *testing.T
29+ s *Server
30+ events chan ports.ClientEvent
31+ commands chan ports.ClientCommand
32+ dir string
33+ xc *wlturbo.Display
34+ peer *x11Peer
35+ stop func()
36+ shell uint32
37+ comp uint32
38+ // surfaceProxy, when set, observes the next paired wl_surface.
39+ surfaceProxy func(id uint32) wlturbo.Proxy
40+}
41+
42+func newX11Rig(t *testing.T) *x11Rig {
43+ t.Helper()
44+ s, events, commands, dir := lifecycleServer(t)
45+ r := &x11Rig{t: t, s: s, events: events, commands: commands, dir: dir}
46+ wl := xwaylandPair(t)
47+ s.AttachXwayland(wl[0])
48+ f := os.NewFile(uintptr(wl[1]), "xwayland client")
49+ wl[1] = -1
50+ conn, err := net.FileConn(f)
51+ f.Close()
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ if r.xc, err = wlturbo.ConnectFromConn(conn); err != nil {
56+ t.Fatal(err)
57+ }
58+ t.Cleanup(func() { _ = r.xc.Close(); forgetWireClient(r.xc) })
59+ if err := r.xc.Roundtrip(); err != nil {
60+ t.Fatal(err)
61+ }
62+ wm, peer := startX11Peer(t)
63+ r.peer = peer
64+ r.stop = s.AttachXWM(context.Background(), wm)
65+ t.Cleanup(func() { r.stop() })
66+ r.shell = bindProtocol(t, r.xc, "xwayland_shell_v1")
67+ r.comp = bindProtocol(t, r.xc, "wl_compositor")
68+ return r
69+}
70+
71+// x11Window announces and maps X window win with the properties of an
72+// ordinary client, and waits for the XWM to be ready for it.
73+func (r *x11Rig) x11Window(win uint32) {
74+ r.t.Helper()
75+ p := r.peer
76+ str := uint32(31) // XA_STRING
77+ p.setProp(win, 67, str, 8, []byte("inst\x00Class\x00")) // WM_CLASS
78+ p.setProp(win, p.atom("_NET_WM_NAME"), p.atom("UTF8_STRING"), 8, []byte("net title"))
79+ p.setProp(win, p.atom("_NET_WM_PID"), 6, 32, x11le32(4242))
80+ p.createNotify(win)
81+ skip := len(p.requests())
82+ p.mapRequest(win)
83+ p.waitReq(skip, func(q x11Req) bool { return q.major == 8 && q.u32(4) == win }) // MapWindow
84+}
85+
86+// pair sends the serial from both sides and commits a buffer on the surface.
87+// It returns the wl_surface id of the Xwayland client.
88+func (r *x11Rig) pair(win uint32, serial uint64) uint32 {
89+ r.t.Helper()
90+ r.peer.clientMessage(win, "WL_SURFACE_SERIAL", uint32(serial), uint32(serial>>32))
91+ surf, xs := r.xc.AllocateID(), r.xc.AllocateID()
92+ requestProtocol(r.t, r.xc, r.comp, wayland.CompositorRequestCreateSurface, surf)
93+ if r.surfaceProxy != nil {
94+ registerWireProxy(r.xc, r.surfaceProxy(surf))
95+ } else {
96+ registerProtocol(r.t, r.xc, surf)
97+ }
98+ requestProtocol(r.t, r.xc, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
99+ registerProtocol(r.t, r.xc, xs)
100+ requestProtocol(r.t, r.xc, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(serial), uint32(serial>>32))
101+ requestProtocol(r.t, r.xc, surf, wayland.SurfaceRequestAttach, shmBuffer(r.t, r.xc), int32(0), int32(0))
102+ requestProtocol(r.t, r.xc, surf, wayland.SurfaceRequestCommit)
103+ if err := r.xc.Roundtrip(); err != nil {
104+ r.t.Fatal(err)
105+ }
106+ return surf
107+}
108+
109+func awaitEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientEvent) T {
110+ t.Helper()
111+ for deadline := time.After(3 * time.Second); ; {
112+ select {
113+ case ev := <-events:
114+ if v, ok := ev.(T); ok {
115+ return v
116+ }
117+ case <-deadline:
118+ var zero T
119+ t.Fatalf("no %T event", zero)
120+ }
121+ }
122+}
123+
124+func TestXwaylandGlobalFilter(t *testing.T) {
125+ s, _, _, dir := lifecycleServer(t)
126+ other := protocolClient(t, s, dir)
127+ for _, iface := range []string{"xwayland_shell_v1", "zwp_xwayland_keyboard_grab_manager_v1"} {
128+ if _, ok := other.Registry().FindGlobal(iface); ok {
129+ t.Errorf("ordinary client sees %s", iface)
130+ }
131+ }
132+ if _, ok := other.Registry().FindGlobal("xdg_wm_base"); !ok {
133+ t.Error("ordinary globals filtered out")
134+ }
135+ r := newX11Rig(t)
136+ if _, ok := r.xc.Registry().FindGlobal("xwayland_shell_v1"); !ok {
137+ t.Error("Xwayland client misses xwayland_shell_v1")
138+ }
139+ // The ordinary client of the same server still does not see it.
140+ late := protocolClient(t, r.s, r.dir)
141+ if _, ok := late.Registry().FindGlobal("xwayland_shell_v1"); ok {
142+ t.Error("ordinary client sees xwayland_shell_v1 while Xwayland is attached")
143+ }
144+}
145+
146+func TestXwaylandShellRoleErrors(t *testing.T) {
147+ for _, scenario := range []string{"xdg role first", "serial twice", "zero serial"} {
148+ t.Run(scenario, func(t *testing.T) {
149+ r := newX11Rig(t)
150+ c := r.xc
151+ wmBase := bindProtocol(t, c, "xdg_wm_base")
152+ surf := c.AllocateID()
153+ requestProtocol(t, c, r.comp, wayland.CompositorRequestCreateSurface, surf)
154+ registerProtocol(t, c, surf)
155+ xs := c.AllocateID()
156+ switch scenario {
157+ case "xdg role first":
158+ xdg := xs
159+ requestProtocol(t, c, wmBase, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
160+ requestProtocol(t, c, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, c.AllocateID(), surf)
161+ expectProtocolError(t, c, r.shell, uint32(xwaylandshell.XwaylandShellV1ErrorRole))
162+ case "serial twice":
163+ requestProtocol(t, c, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
164+ registerProtocol(t, c, xs)
165+ requestProtocol(t, c, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(1), uint32(0))
166+ requestProtocol(t, c, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(2), uint32(0))
167+ expectProtocolError(t, c, xs, uint32(xwaylandshell.XwaylandSurfaceV1ErrorAlreadyAssociated))
168+ case "zero serial":
169+ requestProtocol(t, c, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
170+ registerProtocol(t, c, xs)
171+ requestProtocol(t, c, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(0), uint32(0))
172+ expectProtocolError(t, c, xs, uint32(xwaylandshell.XwaylandSurfaceV1ErrorInvalidSerial))
173+ }
174+ })
175+ }
176+}
177+
178+// A window announced by the X server and a surface carrying the same serial
179+// map as one neferwl window with the X11 info; core commands reach the X side.
180+func TestX11WindowLifecycle(t *testing.T) {
181+ r := newX11Rig(t)
182+ win := uint32(x11Win)
183+ r.x11Window(win)
184+ surf := r.pair(win, x11Serial)
185+
186+ m := awaitEvent[ports.WindowMapped](t, r.events)
187+ if m.AppID != "Class" || m.PID != 4242 || !m.Positioned || m.Floating || m.Slot != "" {
188+ t.Fatalf("mapped %+v", m)
189+ }
190+
191+ // Title and app id changes.
192+ p := r.peer
193+ p.setProp(win, p.atom("_NET_WM_NAME"), p.atom("UTF8_STRING"), 8, []byte("renamed"))
194+ p.propertyNotify(win, p.atom("_NET_WM_NAME"))
195+ p.setProp(win, 67, 31, 8, []byte("inst\x00Other\x00"))
196+ p.propertyNotify(win, 67)
197+ if a := awaitEvent[ports.WindowAppID](t, r.events); a.ID != m.ID || a.AppID != "Other" {
198+ t.Fatalf("app id %+v", a)
199+ }
200+ var title string
201+ waitTrue(t, "title", func() bool {
202+ r.s.display.Do(func() {
203+ if w := r.s.windows[m.ID]; w != nil {
204+ title = w.title
205+ }
206+ })
207+ return title == "renamed"
208+ })
209+
210+ // Fullscreen request from the client.
211+ p.clientMessage(win, "_NET_WM_STATE", 1, p.atom("_NET_WM_STATE_FULLSCREEN"))
212+ if f := awaitEvent[ports.WindowFullscreenRequest](t, r.events); f.ID != m.ID || !f.Fullscreen || f.External {
213+ t.Fatalf("fullscreen %+v", f)
214+ }
215+
216+ // Configure and Place reach the X server as ConfigureWindow.
217+ skip := len(p.requests())
218+ r.commands <- ports.ConfigureWindow{ID: m.ID, Width: 640, Height: 360, Visible: true}
219+ cfg, skip := p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpConfigureWindow && q.u32(4) == win })
220+ if w := binary.LittleEndian.Uint32(cfg.raw[20:]); w != 640 {
221+ t.Fatalf("configured width %d, want 640 (%x)", w, cfg.raw)
222+ }
223+ r.commands <- ports.PlaceWindow{ID: m.ID, X: 10, Y: 20}
224+ cfg, _ = p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpConfigureWindow && q.u32(4) == win })
225+ if x, y := int32(binary.LittleEndian.Uint32(cfg.raw[12:])), int32(binary.LittleEndian.Uint32(cfg.raw[16:])); x != 10 || y != 20 {
226+ t.Fatalf("placed at %d,%d, want 10,20", x, y)
227+ }
228+
229+ // Focus.
230+ skip = len(p.requests())
231+ r.commands <- ports.FocusWindow{ID: m.ID}
232+ if f, _ := p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpSetInputFocus }); f.u32(4) != win {
233+ t.Fatalf("focus on %#x, want %#x", f.u32(4), win)
234+ }
235+
236+ // Close: without WM_DELETE_WINDOW the client is killed.
237+ skip = len(p.requests())
238+ r.commands <- ports.CloseWindow{ID: m.ID}
239+ if k, _ := p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpKillClient }); k.u32(4) != win {
240+ t.Fatalf("killed %#x, want %#x", k.u32(4), win)
241+ }
242+
243+ // Destroying the surface unmaps the window.
244+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestDestroy)
245+ if err := r.xc.Roundtrip(); err != nil {
246+ t.Fatal(err)
247+ }
248+ if u := awaitEvent[ports.WindowUnmapped](t, r.events); u.ID != m.ID {
249+ t.Fatalf("unmapped %+v", u)
250+ }
251+}
252+
253+// The serial may arrive on the Wayland side before the X side knows it, and
254+// an UnmapNotify unmaps; a remap pairs again.
255+func TestX11WindowSurfaceFirstAndRemap(t *testing.T) {
256+ r := newX11Rig(t)
257+ win := uint32(x11Win)
258+ p := r.peer
259+ surf, xs := r.xc.AllocateID(), r.xc.AllocateID()
260+ requestProtocol(t, r.xc, r.comp, wayland.CompositorRequestCreateSurface, surf)
261+ registerProtocol(t, r.xc, surf)
262+ requestProtocol(t, r.xc, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
263+ registerProtocol(t, r.xc, xs)
264+ requestProtocol(t, r.xc, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(x11Serial), uint32(x11Serial>>32))
265+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestAttach, shmBuffer(t, r.xc), int32(0), int32(0))
266+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestCommit)
267+ if err := r.xc.Roundtrip(); err != nil {
268+ t.Fatal(err)
269+ }
270+ r.x11Window(win)
271+ p.clientMessage(win, "WL_SURFACE_SERIAL", uint32(x11Serial), uint32(x11Serial>>32))
272+ m := awaitEvent[ports.WindowMapped](t, r.events)
273+ if m.Remap {
274+ t.Fatalf("first map flagged as remap: %+v", m)
275+ }
276+ p.unmapNotify(win)
277+ if u := awaitEvent[ports.WindowUnmapped](t, r.events); u.ID != m.ID {
278+ t.Fatalf("unmapped %+v", u)
279+ }
280+ p.destroyNotify(win)
281+ waitTrue(t, "window forgotten", func() bool {
282+ gone := false
283+ r.s.display.Do(func() { gone = r.s.windows[m.ID] == nil })
284+ return gone
285+ })
286+}
287+
288+// stop closes the XWM: its windows unmap, and the server keeps serving.
289+func TestX11StopUnmapsWindows(t *testing.T) {
290+ r := newX11Rig(t)
291+ win := uint32(x11Win)
292+ r.x11Window(win)
293+ r.pair(win, x11Serial)
294+ m := awaitEvent[ports.WindowMapped](t, r.events)
295+ r.stop()
296+ if u := awaitEvent[ports.WindowUnmapped](t, r.events); u.ID != m.ID {
297+ t.Fatalf("unmapped %+v", u)
298+ }
299+ r.s.display.Do(func() {
300+ if r.s.xwm != nil || r.s.windows[m.ID] != nil || len(r.s.x11Slots) != 0 {
301+ t.Error("XWM state left behind")
302+ }
303+ })
304+ r.stop() // idempotent
305+}
306+
307+// Boot gives up as soon as its context ends, even against a silent server,
308+// and leaves the server serving.
309+func TestAttachXWMBootAbortsWithContext(t *testing.T) {
310+ s, _, _, dir := lifecycleServer(t)
311+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
312+ if err != nil {
313+ t.Fatal(err)
314+ }
315+ defer unix.Close(fds[1]) // the silent server end
316+ ctx, cancel := context.WithCancel(context.Background())
317+ done := make(chan func(), 1)
318+ go func() { done <- s.AttachXWM(ctx, fds[0]) }()
319+ select {
320+ case <-done:
321+ t.Fatal("AttachXWM returned before the context ended")
322+ case <-time.After(150 * time.Millisecond):
323+ }
324+ cancel()
325+ select {
326+ case stop := <-done:
327+ stop()
328+ case <-time.After(2 * time.Second):
329+ t.Fatal("AttachXWM did not abort with its context")
330+ }
331+ assertFDClosed(t, fds[0])
332+ protocolClient(t, s, dir) // still serving
333+}
334+
335+// Xwayland locks the pointer for games: the constraint reaches core for the
336+// X11 window like for an xdg toplevel.
337+func TestX11WindowPointerLock(t *testing.T) {
338+ r := newX11Rig(t)
339+ win := uint32(x11Win)
340+ r.x11Window(win)
341+ surf := r.pair(win, x11Serial)
342+ m := awaitEvent[ports.WindowMapped](t, r.events)
343+
344+ seat := bindProtocol(t, r.xc, "wl_seat")
345+ registerProtocol(t, r.xc, seat)
346+ pointer := r.xc.AllocateID()
347+ requestProtocol(t, r.xc, seat, wayland.SeatRequestGetPointer, pointer)
348+ registerProtocol(t, r.xc, pointer)
349+ constraints := bindProtocol(t, r.xc, "zwp_pointer_constraints_v1")
350+ lock := r.xc.AllocateID()
351+ requestProtocol(t, r.xc, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, lock, surf, pointer, uint32(0), uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent))
352+ registerProtocol(t, r.xc, lock)
353+ if err := r.xc.Roundtrip(); err != nil {
354+ t.Fatal(err)
355+ }
356+ r.commands <- ports.PointerFocus{ID: m.ID}
357+ r.commands <- ports.FocusWindow{ID: m.ID}
358+ if got := constrained(t, r.events); got.ID != m.ID || got.Mode != ports.ConstraintLock {
359+ t.Fatalf("constrained %+v", got)
360+ }
361+}
362+
363+// grabRig is an X11 window with an Xwayland keyboard grab on its surface.
364+func grabRig(t *testing.T) (r *x11Rig, w ports.WindowID, surf uint32) {
365+ t.Helper()
366+ r = newX11Rig(t)
367+ r.x11Window(x11Win)
368+ surf = r.pair(x11Win, x11Serial)
369+ w = awaitEvent[ports.WindowMapped](t, r.events).ID
370+ seat := bindProtocol(t, r.xc, "wl_seat")
371+ registerProtocol(t, r.xc, seat)
372+ mgr := bindProtocol(t, r.xc, "zwp_xwayland_keyboard_grab_manager_v1")
373+ grab := r.xc.AllocateID()
374+ requestProtocol(t, r.xc, mgr, xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabManagerV1RequestGrabKeyboard, grab, surf, seat)
375+ registerProtocol(t, r.xc, grab)
376+ if err := r.xc.Roundtrip(); err != nil {
377+ t.Fatal(err)
378+ }
379+ return r, w, grab
380+}
381+
382+// noInhibit fails when a ShortcutsInhibit event arrives shortly.
383+func noInhibit(t *testing.T, r *x11Rig) {
384+ t.Helper()
385+ if err := r.xc.Roundtrip(); err != nil {
386+ t.Fatal(err)
387+ }
388+ for deadline := time.After(150 * time.Millisecond); ; {
389+ select {
390+ case ev := <-r.events:
391+ if v, ok := ev.(ports.ShortcutsInhibit); ok {
392+ t.Fatalf("unexpected %+v", v)
393+ }
394+ case <-deadline:
395+ return
396+ }
397+ }
398+}
399+
400+func TestXwaylandKeyboardGrabThreeStates(t *testing.T) {
401+ r, w, grab := grabRig(t)
402+ // Windowed and focused: accepted, no effect.
403+ r.commands <- ports.ConfigureWindow{ID: w, Width: 100, Height: 100, Visible: true}
404+ r.commands <- ports.FocusWindow{ID: w}
405+ noInhibit(t, r)
406+ // Focused and fullscreen: inhibits.
407+ r.commands <- ports.ConfigureWindow{ID: w, Width: 100, Height: 100, Visible: true, Fullscreen: true}
408+ if v := awaitEvent[ports.ShortcutsInhibit](t, r.events); v != (ports.ShortcutsInhibit{Window: w, Active: true}) {
409+ t.Fatal(v)
410+ }
411+ // Focus lost: released.
412+ r.commands <- ports.FocusWindow{}
413+ if v := awaitEvent[ports.ShortcutsInhibit](t, r.events); v != (ports.ShortcutsInhibit{Window: w}) {
414+ t.Fatal(v)
415+ }
416+ // Back, then the grab object goes away.
417+ r.commands <- ports.FocusWindow{ID: w}
418+ awaitEvent[ports.ShortcutsInhibit](t, r.events)
419+ requestProtocol(t, r.xc, grab, xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabV1RequestDestroy)
420+ if v := awaitEvent[ports.ShortcutsInhibit](t, r.events); v != (ports.ShortcutsInhibit{Window: w}) {
421+ t.Fatal(v)
422+ }
423+}
424+
425+// x11Pointer gives the Xwayland client a wl_pointer and a relative pointer,
426+// both with event logs.
427+func (r *x11Rig) x11Pointer() (pointer uint32, ptrEvents, relEvents chan []float64) {
428+ r.t.Helper()
429+ seat := bindProtocol(r.t, r.xc, "wl_seat")
430+ registerProtocol(r.t, r.xc, seat)
431+ pointer = r.xc.AllocateID()
432+ ptrEvents = logProxy(r.t, r.xc, pointer)
433+ requestProtocol(r.t, r.xc, seat, wayland.SeatRequestGetPointer, pointer)
434+ mgr := bindProtocol(r.t, r.xc, "zwp_relative_pointer_manager_v1")
435+ rel := r.xc.AllocateID()
436+ relEvents = logProxy(r.t, r.xc, rel)
437+ requestProtocol(r.t, r.xc, mgr, relativepointer.ZwpRelativePointerManagerV1RequestGetRelativePointer, rel, pointer)
438+ return pointer, ptrEvents, relEvents
439+}
440+
441+// A lock on an X11 surface activates with pointer and keyboard focus, sends
442+// relative motion to the Xwayland client and deactivates when a native
443+// window takes the focus; it comes back with the X11 focus.
444+func TestX11LockFollowsFocusAndRelativeMotion(t *testing.T) {
445+ r := newX11Rig(t)
446+ win := uint32(x11Win)
447+ r.x11Window(win)
448+ surf := r.pair(win, x11Serial)
449+ m := awaitEvent[ports.WindowMapped](t, r.events)
450+ pointer, _, relEvents := r.x11Pointer()
451+ constraints := bindProtocol(t, r.xc, "zwp_pointer_constraints_v1")
452+ lock := r.xc.AllocateID()
453+ lockEvents := logProxy(t, r.xc, lock)
454+ requestProtocol(t, r.xc, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, lock, surf, pointer, uint32(0), uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent))
455+ if err := r.xc.Roundtrip(); err != nil {
456+ t.Fatal(err)
457+ }
458+ if len(lockEvents) > 0 {
459+ t.Fatal("locked without focus")
460+ }
461+
462+ r.commands <- ports.PointerFocus{ID: m.ID}
463+ r.commands <- ports.FocusWindow{ID: m.ID}
464+ if ev := next(t, r.xc, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventLocked) {
465+ t.Fatalf("lock event %v", ev)
466+ }
467+ if got := constrained(t, r.events); got.ID != m.ID || got.Mode != ports.ConstraintLock || got.Rect != (ports.Rect{}) {
468+ t.Fatalf("constrained %+v", got)
469+ }
470+
471+ r.commands <- ports.PointerMotionTo{ID: m.ID, X: 5, Y: 5, DX: 2.5, DY: -1, UnaccelDX: 4, UnaccelDY: -2}
472+ if ev := next(t, r.xc, relEvents); len(ev) != 5 || ev[1] != 2.5 || ev[2] != -1 || ev[3] != 4 || ev[4] != -2 {
473+ t.Fatalf("relative motion %v", ev)
474+ }
475+
476+ // A native window takes the focus: the X11 lock ends and the XWM drops
477+ // the X focus.
478+ nc := protocolClient(t, r.s, r.dir)
479+ nm, _, _ := surfaceMapper(t, nc, r.events)()
480+ skip := len(r.peer.requests())
481+ r.commands <- ports.FocusWindow{ID: nm.ID}
482+ if ev := next(t, r.xc, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventUnlocked) {
483+ t.Fatalf("unlock event %v", ev)
484+ }
485+ if got := constrained(t, r.events); got.ID != 0 {
486+ t.Fatalf("constrained %+v after native focus", got)
487+ }
488+ if f, _ := r.peer.waitReq(skip, func(q x11Req) bool { return q.major == x11OpSetInputFocus }); f.u32(4) == win {
489+ t.Fatalf("X focus still on %#x", win)
490+ }
491+
492+ r.commands <- ports.FocusWindow{ID: m.ID}
493+ if ev := next(t, r.xc, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventLocked) {
494+ t.Fatalf("relock event %v", ev)
495+ }
496+}
497+
498+// A confine on an X11 surface reaches core with its region as given: an X11
499+// window has no geometry offset.
500+func TestX11ConfineRegionHasNoOffset(t *testing.T) {
501+ r := newX11Rig(t)
502+ win := uint32(x11Win)
503+ r.x11Window(win)
504+ surf := r.pair(win, x11Serial)
505+ m := awaitEvent[ports.WindowMapped](t, r.events)
506+ pointer, _, _ := r.x11Pointer()
507+ region := r.xc.AllocateID()
508+ registerProtocol(t, r.xc, region)
509+ requestProtocol(t, r.xc, r.comp, wayland.CompositorRequestCreateRegion, region)
510+ requestProtocol(t, r.xc, region, wayland.RegionRequestAdd, int32(5), int32(6), int32(20), int32(30))
511+ constraints := bindProtocol(t, r.xc, "zwp_pointer_constraints_v1")
512+ confine := r.xc.AllocateID()
513+ confineEvents := logProxy(t, r.xc, confine)
514+ requestProtocol(t, r.xc, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestConfinePointer, confine, surf, pointer, region, uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent))
515+ if err := r.xc.Roundtrip(); err != nil {
516+ t.Fatal(err)
517+ }
518+ r.state(func() {
519+ if g := r.s.windows[m.ID].geometry(); g != (ports.Rect{}) {
520+ t.Errorf("X11 geometry %+v, want zero", g)
521+ }
522+ })
523+
524+ r.commands <- ports.PointerFocus{ID: m.ID, X: 10, Y: 10}
525+ r.commands <- ports.FocusWindow{ID: m.ID}
526+ r.commands <- ports.PointerMotionTo{ID: m.ID, X: 10, Y: 10}
527+ if ev := next(t, r.xc, confineEvents); ev[0] != float64(pointerconstraints.ZwpConfinedPointerV1EventConfined) {
528+ t.Fatalf("confine event %v", ev)
529+ }
530+ want := ports.PointerConstrained{ID: m.ID, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 5, Y: 6, W: 20, H: 30}}}
531+ if got := constrained(t, r.events); got != want {
532+ t.Fatalf("constrained %+v, want %+v", got, want)
533+ }
534+}
535+
536+// waitOn waits until window id's surface is on output name: commands reach
537+// the display goroutine asynchronously.
538+func (r *x11Rig) waitOn(id ports.WindowID, name string) {
539+ r.t.Helper()
540+ waitTrue(r.t, "surface on "+name, func() bool {
541+ ok := false
542+ r.state(func() {
543+ if w := r.s.windows[id]; w != nil {
544+ on := w.surf().on
545+ ok = on != nil && on.name() == name
546+ }
547+ })
548+ return ok
549+ })
550+}
551+
552+// An X11 window is on one output only, even straddling two: a single
553+// wl_surface.enter, and a leave only when its output changes.
554+func TestX11WindowEntersOneOutput(t *testing.T) {
555+ r := newX11Rig(t)
556+ r.commands <- ports.SetOutputs{Outputs: ports.Layout{
557+ {Info: ports.OutputInfo{Name: "A"}, X: 0, Y: 0, Width: 1920, Height: 1080, Scale: 1},
558+ {Info: ports.OutputInfo{Name: "B"}, X: 1920, Y: 0, Width: 1920, Height: 1080, Scale: 1},
559+ }, Focused: "A"}
560+ r.state(func() {}) // the layout is applied
561+ if err := r.xc.Roundtrip(); err != nil {
562+ t.Fatal(err)
563+ }
564+ outputs := 0
565+ for _, g := range r.xc.Registry().GetGlobals() {
566+ if g.Interface != "wl_output" {
567+ continue
568+ }
569+ id, err := bindWireID(r.xc, g.Name, g.Interface, 1)
570+ if err != nil {
571+ t.Fatal(err)
572+ }
573+ registerProtocol(t, r.xc, id)
574+ outputs++
575+ }
576+ if outputs != 2 {
577+ t.Fatalf("%d wl_output globals, want 2", outputs)
578+ }
579+
580+ win := uint32(x11Win)
581+ r.x11Window(win)
582+ events := make(chan []float64, 64)
583+ r.surfaceProxy = func(id uint32) wlturbo.Proxy {
584+ p := &eventLog{events: events}
585+ p.SetID(id)
586+ return p
587+ }
588+ r.pair(win, x11Serial)
589+ m := awaitEvent[ports.WindowMapped](t, r.events)
590+
591+ // Straddling both outputs, configured on A.
592+ r.commands <- ports.PlaceWindow{ID: m.ID, X: 1900, Y: 0}
593+ r.commands <- ports.ConfigureWindow{ID: m.ID, Width: 200, Height: 100, Visible: true, Output: "A"}
594+ r.waitOn(m.ID, "A")
595+ if err := r.xc.Roundtrip(); err != nil {
596+ t.Fatal(err)
597+ }
598+ count := func() (enters, leaves int) {
599+ for len(events) > 0 {
600+ switch ev := <-events; ev[0] {
601+ case float64(wayland.SurfaceEventEnter):
602+ enters++
603+ case float64(wayland.SurfaceEventLeave):
604+ leaves++
605+ }
606+ }
607+ return
608+ }
609+ if e, l := count(); e != 1 || l != 0 {
610+ t.Fatalf("straddling window: %d enters, %d leaves, want 1, 0", e, l)
611+ }
612+ r.state(func() {
613+ if on := r.s.windows[m.ID].surf().on; on == nil || on.name() != "A" {
614+ t.Errorf("surface on %v, want A", on)
615+ }
616+ })
617+
618+ // Moving to B leaves A and enters B once.
619+ r.commands <- ports.ConfigureWindow{ID: m.ID, Width: 200, Height: 100, Visible: true, Output: "B"}
620+ r.waitOn(m.ID, "B")
621+ if err := r.xc.Roundtrip(); err != nil {
622+ t.Fatal(err)
623+ }
624+ if e, l := count(); e != 1 || l != 1 {
625+ t.Fatalf("move to B: %d enters, %d leaves, want 1, 1", e, l)
626+ }
627+}
@@ -0,0 +1,627 @@
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/pointerconstraints"
12+ "github.com/bnema/go-wayland-bindings/server/relativepointer"
13+ "github.com/bnema/go-wayland-bindings/server/wayland"
14+ "github.com/bnema/go-wayland-bindings/server/xdgshell"
15+ "github.com/bnema/go-wayland-bindings/server/xwaylandkeyboardgrab"
16+ "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
17+ "github.com/bnema/neferwl/internal/ports"
18+ "github.com/bnema/wlturbo"
19+ "golang.org/x/sys/unix"
20+)
21+
22+var x11Serial = uint64(0x1234_0000_0042)
23+
24+// x11Rig is a server with an Xwayland client (a real wlturbo client on the
25+// adopted descriptor), a scripted X peer on the WM descriptor and the XWM
26+// between them.
27+type x11Rig struct {
28+ t *testing.T
29+ s *Server
30+ events chan ports.ClientEvent
31+ commands chan ports.ClientCommand
32+ dir string
33+ xc *wlturbo.Display
34+ peer *x11Peer
35+ stop func()
36+ shell uint32
37+ comp uint32
38+ // surfaceProxy, when set, observes the next paired wl_surface.
39+ surfaceProxy func(id uint32) wlturbo.Proxy
40+}
41+
42+func newX11Rig(t *testing.T) *x11Rig {
43+ t.Helper()
44+ s, events, commands, dir := lifecycleServer(t)
45+ r := &x11Rig{t: t, s: s, events: events, commands: commands, dir: dir}
46+ wl := xwaylandPair(t)
47+ s.AttachXwayland(wl[0])
48+ f := os.NewFile(uintptr(wl[1]), "xwayland client")
49+ wl[1] = -1
50+ conn, err := net.FileConn(f)
51+ f.Close()
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ if r.xc, err = wlturbo.ConnectFromConn(conn); err != nil {
56+ t.Fatal(err)
57+ }
58+ t.Cleanup(func() { _ = r.xc.Close(); forgetWireClient(r.xc) })
59+ if err := r.xc.Roundtrip(); err != nil {
60+ t.Fatal(err)
61+ }
62+ wm, peer := startX11Peer(t)
63+ r.peer = peer
64+ r.stop = s.AttachXWM(context.Background(), wm)
65+ t.Cleanup(func() { r.stop() })
66+ r.shell = bindProtocol(t, r.xc, "xwayland_shell_v1")
67+ r.comp = bindProtocol(t, r.xc, "wl_compositor")
68+ return r
69+}
70+
71+// x11Window announces and maps X window win with the properties of an
72+// ordinary client, and waits for the XWM to be ready for it.
73+func (r *x11Rig) x11Window(win uint32) {
74+ r.t.Helper()
75+ p := r.peer
76+ str := uint32(31) // XA_STRING
77+ p.setProp(win, 67, str, 8, []byte("inst\x00Class\x00")) // WM_CLASS
78+ p.setProp(win, p.atom("_NET_WM_NAME"), p.atom("UTF8_STRING"), 8, []byte("net title"))
79+ p.setProp(win, p.atom("_NET_WM_PID"), 6, 32, x11le32(4242))
80+ p.createNotify(win)
81+ skip := len(p.requests())
82+ p.mapRequest(win)
83+ p.waitReq(skip, func(q x11Req) bool { return q.major == 8 && q.u32(4) == win }) // MapWindow
84+}
85+
86+// pair sends the serial from both sides and commits a buffer on the surface.
87+// It returns the wl_surface id of the Xwayland client.
88+func (r *x11Rig) pair(win uint32, serial uint64) uint32 {
89+ r.t.Helper()
90+ r.peer.clientMessage(win, "WL_SURFACE_SERIAL", uint32(serial), uint32(serial>>32))
91+ surf, xs := r.xc.AllocateID(), r.xc.AllocateID()
92+ requestProtocol(r.t, r.xc, r.comp, wayland.CompositorRequestCreateSurface, surf)
93+ if r.surfaceProxy != nil {
94+ registerWireProxy(r.xc, r.surfaceProxy(surf))
95+ } else {
96+ registerProtocol(r.t, r.xc, surf)
97+ }
98+ requestProtocol(r.t, r.xc, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
99+ registerProtocol(r.t, r.xc, xs)
100+ requestProtocol(r.t, r.xc, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(serial), uint32(serial>>32))
101+ requestProtocol(r.t, r.xc, surf, wayland.SurfaceRequestAttach, shmBuffer(r.t, r.xc), int32(0), int32(0))
102+ requestProtocol(r.t, r.xc, surf, wayland.SurfaceRequestCommit)
103+ if err := r.xc.Roundtrip(); err != nil {
104+ r.t.Fatal(err)
105+ }
106+ return surf
107+}
108+
109+func awaitEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientEvent) T {
110+ t.Helper()
111+ for deadline := time.After(3 * time.Second); ; {
112+ select {
113+ case ev := <-events:
114+ if v, ok := ev.(T); ok {
115+ return v
116+ }
117+ case <-deadline:
118+ var zero T
119+ t.Fatalf("no %T event", zero)
120+ }
121+ }
122+}
123+
124+func TestXwaylandGlobalFilter(t *testing.T) {
125+ s, _, _, dir := lifecycleServer(t)
126+ other := protocolClient(t, s, dir)
127+ for _, iface := range []string{"xwayland_shell_v1", "zwp_xwayland_keyboard_grab_manager_v1"} {
128+ if _, ok := other.Registry().FindGlobal(iface); ok {
129+ t.Errorf("ordinary client sees %s", iface)
130+ }
131+ }
132+ if _, ok := other.Registry().FindGlobal("xdg_wm_base"); !ok {
133+ t.Error("ordinary globals filtered out")
134+ }
135+ r := newX11Rig(t)
136+ if _, ok := r.xc.Registry().FindGlobal("xwayland_shell_v1"); !ok {
137+ t.Error("Xwayland client misses xwayland_shell_v1")
138+ }
139+ // The ordinary client of the same server still does not see it.
140+ late := protocolClient(t, r.s, r.dir)
141+ if _, ok := late.Registry().FindGlobal("xwayland_shell_v1"); ok {
142+ t.Error("ordinary client sees xwayland_shell_v1 while Xwayland is attached")
143+ }
144+}
145+
146+func TestXwaylandShellRoleErrors(t *testing.T) {
147+ for _, scenario := range []string{"xdg role first", "serial twice", "zero serial"} {
148+ t.Run(scenario, func(t *testing.T) {
149+ r := newX11Rig(t)
150+ c := r.xc
151+ wmBase := bindProtocol(t, c, "xdg_wm_base")
152+ surf := c.AllocateID()
153+ requestProtocol(t, c, r.comp, wayland.CompositorRequestCreateSurface, surf)
154+ registerProtocol(t, c, surf)
155+ xs := c.AllocateID()
156+ switch scenario {
157+ case "xdg role first":
158+ xdg := xs
159+ requestProtocol(t, c, wmBase, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
160+ requestProtocol(t, c, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, c.AllocateID(), surf)
161+ expectProtocolError(t, c, r.shell, uint32(xwaylandshell.XwaylandShellV1ErrorRole))
162+ case "serial twice":
163+ requestProtocol(t, c, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
164+ registerProtocol(t, c, xs)
165+ requestProtocol(t, c, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(1), uint32(0))
166+ requestProtocol(t, c, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(2), uint32(0))
167+ expectProtocolError(t, c, xs, uint32(xwaylandshell.XwaylandSurfaceV1ErrorAlreadyAssociated))
168+ case "zero serial":
169+ requestProtocol(t, c, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
170+ registerProtocol(t, c, xs)
171+ requestProtocol(t, c, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(0), uint32(0))
172+ expectProtocolError(t, c, xs, uint32(xwaylandshell.XwaylandSurfaceV1ErrorInvalidSerial))
173+ }
174+ })
175+ }
176+}
177+
178+// A window announced by the X server and a surface carrying the same serial
179+// map as one neferwl window with the X11 info; core commands reach the X side.
180+func TestX11WindowLifecycle(t *testing.T) {
181+ r := newX11Rig(t)
182+ win := uint32(x11Win)
183+ r.x11Window(win)
184+ surf := r.pair(win, x11Serial)
185+
186+ m := awaitEvent[ports.WindowMapped](t, r.events)
187+ if m.AppID != "Class" || m.PID != 4242 || !m.Positioned || m.Floating || m.Slot != "" {
188+ t.Fatalf("mapped %+v", m)
189+ }
190+
191+ // Title and app id changes.
192+ p := r.peer
193+ p.setProp(win, p.atom("_NET_WM_NAME"), p.atom("UTF8_STRING"), 8, []byte("renamed"))
194+ p.propertyNotify(win, p.atom("_NET_WM_NAME"))
195+ p.setProp(win, 67, 31, 8, []byte("inst\x00Other\x00"))
196+ p.propertyNotify(win, 67)
197+ if a := awaitEvent[ports.WindowAppID](t, r.events); a.ID != m.ID || a.AppID != "Other" {
198+ t.Fatalf("app id %+v", a)
199+ }
200+ var title string
201+ waitTrue(t, "title", func() bool {
202+ r.s.display.Do(func() {
203+ if w := r.s.windows[m.ID]; w != nil {
204+ title = w.title
205+ }
206+ })
207+ return title == "renamed"
208+ })
209+
210+ // Fullscreen request from the client.
211+ p.clientMessage(win, "_NET_WM_STATE", 1, p.atom("_NET_WM_STATE_FULLSCREEN"))
212+ if f := awaitEvent[ports.WindowFullscreenRequest](t, r.events); f.ID != m.ID || !f.Fullscreen || f.External {
213+ t.Fatalf("fullscreen %+v", f)
214+ }
215+
216+ // Configure and Place reach the X server as ConfigureWindow.
217+ skip := len(p.requests())
218+ r.commands <- ports.ConfigureWindow{ID: m.ID, Width: 640, Height: 360, Visible: true}
219+ cfg, skip := p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpConfigureWindow && q.u32(4) == win })
220+ if w := binary.LittleEndian.Uint32(cfg.raw[20:]); w != 640 {
221+ t.Fatalf("configured width %d, want 640 (%x)", w, cfg.raw)
222+ }
223+ r.commands <- ports.PlaceWindow{ID: m.ID, X: 10, Y: 20}
224+ cfg, _ = p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpConfigureWindow && q.u32(4) == win })
225+ if x, y := int32(binary.LittleEndian.Uint32(cfg.raw[12:])), int32(binary.LittleEndian.Uint32(cfg.raw[16:])); x != 10 || y != 20 {
226+ t.Fatalf("placed at %d,%d, want 10,20", x, y)
227+ }
228+
229+ // Focus.
230+ skip = len(p.requests())
231+ r.commands <- ports.FocusWindow{ID: m.ID}
232+ if f, _ := p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpSetInputFocus }); f.u32(4) != win {
233+ t.Fatalf("focus on %#x, want %#x", f.u32(4), win)
234+ }
235+
236+ // Close: without WM_DELETE_WINDOW the client is killed.
237+ skip = len(p.requests())
238+ r.commands <- ports.CloseWindow{ID: m.ID}
239+ if k, _ := p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpKillClient }); k.u32(4) != win {
240+ t.Fatalf("killed %#x, want %#x", k.u32(4), win)
241+ }
242+
243+ // Destroying the surface unmaps the window.
244+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestDestroy)
245+ if err := r.xc.Roundtrip(); err != nil {
246+ t.Fatal(err)
247+ }
248+ if u := awaitEvent[ports.WindowUnmapped](t, r.events); u.ID != m.ID {
249+ t.Fatalf("unmapped %+v", u)
250+ }
251+}
252+
253+// The serial may arrive on the Wayland side before the X side knows it, and
254+// an UnmapNotify unmaps; a remap pairs again.
255+func TestX11WindowSurfaceFirstAndRemap(t *testing.T) {
256+ r := newX11Rig(t)
257+ win := uint32(x11Win)
258+ p := r.peer
259+ surf, xs := r.xc.AllocateID(), r.xc.AllocateID()
260+ requestProtocol(t, r.xc, r.comp, wayland.CompositorRequestCreateSurface, surf)
261+ registerProtocol(t, r.xc, surf)
262+ requestProtocol(t, r.xc, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
263+ registerProtocol(t, r.xc, xs)
264+ requestProtocol(t, r.xc, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(x11Serial), uint32(x11Serial>>32))
265+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestAttach, shmBuffer(t, r.xc), int32(0), int32(0))
266+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestCommit)
267+ if err := r.xc.Roundtrip(); err != nil {
268+ t.Fatal(err)
269+ }
270+ r.x11Window(win)
271+ p.clientMessage(win, "WL_SURFACE_SERIAL", uint32(x11Serial), uint32(x11Serial>>32))
272+ m := awaitEvent[ports.WindowMapped](t, r.events)
273+ if m.Remap {
274+ t.Fatalf("first map flagged as remap: %+v", m)
275+ }
276+ p.unmapNotify(win)
277+ if u := awaitEvent[ports.WindowUnmapped](t, r.events); u.ID != m.ID {
278+ t.Fatalf("unmapped %+v", u)
279+ }
280+ p.destroyNotify(win)
281+ waitTrue(t, "window forgotten", func() bool {
282+ gone := false
283+ r.s.display.Do(func() { gone = r.s.windows[m.ID] == nil })
284+ return gone
285+ })
286+}
287+
288+// stop closes the XWM: its windows unmap, and the server keeps serving.
289+func TestX11StopUnmapsWindows(t *testing.T) {
290+ r := newX11Rig(t)
291+ win := uint32(x11Win)
292+ r.x11Window(win)
293+ r.pair(win, x11Serial)
294+ m := awaitEvent[ports.WindowMapped](t, r.events)
295+ r.stop()
296+ if u := awaitEvent[ports.WindowUnmapped](t, r.events); u.ID != m.ID {
297+ t.Fatalf("unmapped %+v", u)
298+ }
299+ r.s.display.Do(func() {
300+ if r.s.xwm != nil || r.s.windows[m.ID] != nil || len(r.s.x11Slots) != 0 {
301+ t.Error("XWM state left behind")
302+ }
303+ })
304+ r.stop() // idempotent
305+}
306+
307+// Boot gives up as soon as its context ends, even against a silent server,
308+// and leaves the server serving.
309+func TestAttachXWMBootAbortsWithContext(t *testing.T) {
310+ s, _, _, dir := lifecycleServer(t)
311+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
312+ if err != nil {
313+ t.Fatal(err)
314+ }
315+ defer unix.Close(fds[1]) // the silent server end
316+ ctx, cancel := context.WithCancel(context.Background())
317+ done := make(chan func(), 1)
318+ go func() { done <- s.AttachXWM(ctx, fds[0]) }()
319+ select {
320+ case <-done:
321+ t.Fatal("AttachXWM returned before the context ended")
322+ case <-time.After(150 * time.Millisecond):
323+ }
324+ cancel()
325+ select {
326+ case stop := <-done:
327+ stop()
328+ case <-time.After(2 * time.Second):
329+ t.Fatal("AttachXWM did not abort with its context")
330+ }
331+ assertFDClosed(t, fds[0])
332+ protocolClient(t, s, dir) // still serving
333+}
334+
335+// Xwayland locks the pointer for games: the constraint reaches core for the
336+// X11 window like for an xdg toplevel.
337+func TestX11WindowPointerLock(t *testing.T) {
338+ r := newX11Rig(t)
339+ win := uint32(x11Win)
340+ r.x11Window(win)
341+ surf := r.pair(win, x11Serial)
342+ m := awaitEvent[ports.WindowMapped](t, r.events)
343+
344+ seat := bindProtocol(t, r.xc, "wl_seat")
345+ registerProtocol(t, r.xc, seat)
346+ pointer := r.xc.AllocateID()
347+ requestProtocol(t, r.xc, seat, wayland.SeatRequestGetPointer, pointer)
348+ registerProtocol(t, r.xc, pointer)
349+ constraints := bindProtocol(t, r.xc, "zwp_pointer_constraints_v1")
350+ lock := r.xc.AllocateID()
351+ requestProtocol(t, r.xc, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, lock, surf, pointer, uint32(0), uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent))
352+ registerProtocol(t, r.xc, lock)
353+ if err := r.xc.Roundtrip(); err != nil {
354+ t.Fatal(err)
355+ }
356+ r.commands <- ports.PointerFocus{ID: m.ID}
357+ r.commands <- ports.FocusWindow{ID: m.ID}
358+ if got := constrained(t, r.events); got.ID != m.ID || got.Mode != ports.ConstraintLock {
359+ t.Fatalf("constrained %+v", got)
360+ }
361+}
362+
363+// grabRig is an X11 window with an Xwayland keyboard grab on its surface.
364+func grabRig(t *testing.T) (r *x11Rig, w ports.WindowID, surf uint32) {
365+ t.Helper()
366+ r = newX11Rig(t)
367+ r.x11Window(x11Win)
368+ surf = r.pair(x11Win, x11Serial)
369+ w = awaitEvent[ports.WindowMapped](t, r.events).ID
370+ seat := bindProtocol(t, r.xc, "wl_seat")
371+ registerProtocol(t, r.xc, seat)
372+ mgr := bindProtocol(t, r.xc, "zwp_xwayland_keyboard_grab_manager_v1")
373+ grab := r.xc.AllocateID()
374+ requestProtocol(t, r.xc, mgr, xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabManagerV1RequestGrabKeyboard, grab, surf, seat)
375+ registerProtocol(t, r.xc, grab)
376+ if err := r.xc.Roundtrip(); err != nil {
377+ t.Fatal(err)
378+ }
379+ return r, w, grab
380+}
381+
382+// noInhibit fails when a ShortcutsInhibit event arrives shortly.
383+func noInhibit(t *testing.T, r *x11Rig) {
384+ t.Helper()
385+ if err := r.xc.Roundtrip(); err != nil {
386+ t.Fatal(err)
387+ }
388+ for deadline := time.After(150 * time.Millisecond); ; {
389+ select {
390+ case ev := <-r.events:
391+ if v, ok := ev.(ports.ShortcutsInhibit); ok {
392+ t.Fatalf("unexpected %+v", v)
393+ }
394+ case <-deadline:
395+ return
396+ }
397+ }
398+}
399+
400+func TestXwaylandKeyboardGrabThreeStates(t *testing.T) {
401+ r, w, grab := grabRig(t)
402+ // Windowed and focused: accepted, no effect.
403+ r.commands <- ports.ConfigureWindow{ID: w, Width: 100, Height: 100, Visible: true}
404+ r.commands <- ports.FocusWindow{ID: w}
405+ noInhibit(t, r)
406+ // Focused and fullscreen: inhibits.
407+ r.commands <- ports.ConfigureWindow{ID: w, Width: 100, Height: 100, Visible: true, Fullscreen: true}
408+ if v := awaitEvent[ports.ShortcutsInhibit](t, r.events); v != (ports.ShortcutsInhibit{Window: w, Active: true}) {
409+ t.Fatal(v)
410+ }
411+ // Focus lost: released.
412+ r.commands <- ports.FocusWindow{}
413+ if v := awaitEvent[ports.ShortcutsInhibit](t, r.events); v != (ports.ShortcutsInhibit{Window: w}) {
414+ t.Fatal(v)
415+ }
416+ // Back, then the grab object goes away.
417+ r.commands <- ports.FocusWindow{ID: w}
418+ awaitEvent[ports.ShortcutsInhibit](t, r.events)
419+ requestProtocol(t, r.xc, grab, xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabV1RequestDestroy)
420+ if v := awaitEvent[ports.ShortcutsInhibit](t, r.events); v != (ports.ShortcutsInhibit{Window: w}) {
421+ t.Fatal(v)
422+ }
423+}
424+
425+// x11Pointer gives the Xwayland client a wl_pointer and a relative pointer,
426+// both with event logs.
427+func (r *x11Rig) x11Pointer() (pointer uint32, ptrEvents, relEvents chan []float64) {
428+ r.t.Helper()
429+ seat := bindProtocol(r.t, r.xc, "wl_seat")
430+ registerProtocol(r.t, r.xc, seat)
431+ pointer = r.xc.AllocateID()
432+ ptrEvents = logProxy(r.t, r.xc, pointer)
433+ requestProtocol(r.t, r.xc, seat, wayland.SeatRequestGetPointer, pointer)
434+ mgr := bindProtocol(r.t, r.xc, "zwp_relative_pointer_manager_v1")
435+ rel := r.xc.AllocateID()
436+ relEvents = logProxy(r.t, r.xc, rel)
437+ requestProtocol(r.t, r.xc, mgr, relativepointer.ZwpRelativePointerManagerV1RequestGetRelativePointer, rel, pointer)
438+ return pointer, ptrEvents, relEvents
439+}
440+
441+// A lock on an X11 surface activates with pointer and keyboard focus, sends
442+// relative motion to the Xwayland client and deactivates when a native
443+// window takes the focus; it comes back with the X11 focus.
444+func TestX11LockFollowsFocusAndRelativeMotion(t *testing.T) {
445+ r := newX11Rig(t)
446+ win := uint32(x11Win)
447+ r.x11Window(win)
448+ surf := r.pair(win, x11Serial)
449+ m := awaitEvent[ports.WindowMapped](t, r.events)
450+ pointer, _, relEvents := r.x11Pointer()
451+ constraints := bindProtocol(t, r.xc, "zwp_pointer_constraints_v1")
452+ lock := r.xc.AllocateID()
453+ lockEvents := logProxy(t, r.xc, lock)
454+ requestProtocol(t, r.xc, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, lock, surf, pointer, uint32(0), uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent))
455+ if err := r.xc.Roundtrip(); err != nil {
456+ t.Fatal(err)
457+ }
458+ if len(lockEvents) > 0 {
459+ t.Fatal("locked without focus")
460+ }
461+
462+ r.commands <- ports.PointerFocus{ID: m.ID}
463+ r.commands <- ports.FocusWindow{ID: m.ID}
464+ if ev := next(t, r.xc, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventLocked) {
465+ t.Fatalf("lock event %v", ev)
466+ }
467+ if got := constrained(t, r.events); got.ID != m.ID || got.Mode != ports.ConstraintLock || got.Rect != (ports.Rect{}) {
468+ t.Fatalf("constrained %+v", got)
469+ }
470+
471+ r.commands <- ports.PointerMotionTo{ID: m.ID, X: 5, Y: 5, DX: 2.5, DY: -1, UnaccelDX: 4, UnaccelDY: -2}
472+ if ev := next(t, r.xc, relEvents); len(ev) != 5 || ev[1] != 2.5 || ev[2] != -1 || ev[3] != 4 || ev[4] != -2 {
473+ t.Fatalf("relative motion %v", ev)
474+ }
475+
476+ // A native window takes the focus: the X11 lock ends and the XWM drops
477+ // the X focus.
478+ nc := protocolClient(t, r.s, r.dir)
479+ nm, _, _ := surfaceMapper(t, nc, r.events)()
480+ skip := len(r.peer.requests())
481+ r.commands <- ports.FocusWindow{ID: nm.ID}
482+ if ev := next(t, r.xc, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventUnlocked) {
483+ t.Fatalf("unlock event %v", ev)
484+ }
485+ if got := constrained(t, r.events); got.ID != 0 {
486+ t.Fatalf("constrained %+v after native focus", got)
487+ }
488+ if f, _ := r.peer.waitReq(skip, func(q x11Req) bool { return q.major == x11OpSetInputFocus }); f.u32(4) == win {
489+ t.Fatalf("X focus still on %#x", win)
490+ }
491+
492+ r.commands <- ports.FocusWindow{ID: m.ID}
493+ if ev := next(t, r.xc, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventLocked) {
494+ t.Fatalf("relock event %v", ev)
495+ }
496+}
497+
498+// A confine on an X11 surface reaches core with its region as given: an X11
499+// window has no geometry offset.
500+func TestX11ConfineRegionHasNoOffset(t *testing.T) {
501+ r := newX11Rig(t)
502+ win := uint32(x11Win)
503+ r.x11Window(win)
504+ surf := r.pair(win, x11Serial)
505+ m := awaitEvent[ports.WindowMapped](t, r.events)
506+ pointer, _, _ := r.x11Pointer()
507+ region := r.xc.AllocateID()
508+ registerProtocol(t, r.xc, region)
509+ requestProtocol(t, r.xc, r.comp, wayland.CompositorRequestCreateRegion, region)
510+ requestProtocol(t, r.xc, region, wayland.RegionRequestAdd, int32(5), int32(6), int32(20), int32(30))
511+ constraints := bindProtocol(t, r.xc, "zwp_pointer_constraints_v1")
512+ confine := r.xc.AllocateID()
513+ confineEvents := logProxy(t, r.xc, confine)
514+ requestProtocol(t, r.xc, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestConfinePointer, confine, surf, pointer, region, uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent))
515+ if err := r.xc.Roundtrip(); err != nil {
516+ t.Fatal(err)
517+ }
518+ r.state(func() {
519+ if g := r.s.windows[m.ID].geometry(); g != (ports.Rect{}) {
520+ t.Errorf("X11 geometry %+v, want zero", g)
521+ }
522+ })
523+
524+ r.commands <- ports.PointerFocus{ID: m.ID, X: 10, Y: 10}
525+ r.commands <- ports.FocusWindow{ID: m.ID}
526+ r.commands <- ports.PointerMotionTo{ID: m.ID, X: 10, Y: 10}
527+ if ev := next(t, r.xc, confineEvents); ev[0] != float64(pointerconstraints.ZwpConfinedPointerV1EventConfined) {
528+ t.Fatalf("confine event %v", ev)
529+ }
530+ want := ports.PointerConstrained{ID: m.ID, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 5, Y: 6, W: 20, H: 30}}}
531+ if got := constrained(t, r.events); got != want {
532+ t.Fatalf("constrained %+v, want %+v", got, want)
533+ }
534+}
535+
536+// waitOn waits until window id's surface is on output name: commands reach
537+// the display goroutine asynchronously.
538+func (r *x11Rig) waitOn(id ports.WindowID, name string) {
539+ r.t.Helper()
540+ waitTrue(r.t, "surface on "+name, func() bool {
541+ ok := false
542+ r.state(func() {
543+ if w := r.s.windows[id]; w != nil {
544+ on := w.surf().on
545+ ok = on != nil && on.name() == name
546+ }
547+ })
548+ return ok
549+ })
550+}
551+
552+// An X11 window is on one output only, even straddling two: a single
553+// wl_surface.enter, and a leave only when its output changes.
554+func TestX11WindowEntersOneOutput(t *testing.T) {
555+ r := newX11Rig(t)
556+ r.commands <- ports.SetOutputs{Outputs: ports.Layout{
557+ {Info: ports.OutputInfo{Name: "A"}, X: 0, Y: 0, Width: 1920, Height: 1080, Scale: 1},
558+ {Info: ports.OutputInfo{Name: "B"}, X: 1920, Y: 0, Width: 1920, Height: 1080, Scale: 1},
559+ }, Focused: "A"}
560+ r.state(func() {}) // the layout is applied
561+ if err := r.xc.Roundtrip(); err != nil {
562+ t.Fatal(err)
563+ }
564+ outputs := 0
565+ for _, g := range r.xc.Registry().GetGlobals() {
566+ if g.Interface != "wl_output" {
567+ continue
568+ }
569+ id, err := bindWireID(r.xc, g.Name, g.Interface, 1)
570+ if err != nil {
571+ t.Fatal(err)
572+ }
573+ registerProtocol(t, r.xc, id)
574+ outputs++
575+ }
576+ if outputs != 2 {
577+ t.Fatalf("%d wl_output globals, want 2", outputs)
578+ }
579+
580+ win := uint32(x11Win)
581+ r.x11Window(win)
582+ events := make(chan []float64, 64)
583+ r.surfaceProxy = func(id uint32) wlturbo.Proxy {
584+ p := &eventLog{events: events}
585+ p.SetID(id)
586+ return p
587+ }
588+ r.pair(win, x11Serial)
589+ m := awaitEvent[ports.WindowMapped](t, r.events)
590+
591+ // Straddling both outputs, configured on A.
592+ r.commands <- ports.PlaceWindow{ID: m.ID, X: 1900, Y: 0}
593+ r.commands <- ports.ConfigureWindow{ID: m.ID, Width: 200, Height: 100, Visible: true, Output: "A"}
594+ r.waitOn(m.ID, "A")
595+ if err := r.xc.Roundtrip(); err != nil {
596+ t.Fatal(err)
597+ }
598+ count := func() (enters, leaves int) {
599+ for len(events) > 0 {
600+ switch ev := <-events; ev[0] {
601+ case float64(wayland.SurfaceEventEnter):
602+ enters++
603+ case float64(wayland.SurfaceEventLeave):
604+ leaves++
605+ }
606+ }
607+ return
608+ }
609+ if e, l := count(); e != 1 || l != 0 {
610+ t.Fatalf("straddling window: %d enters, %d leaves, want 1, 0", e, l)
611+ }
612+ r.state(func() {
613+ if on := r.s.windows[m.ID].surf().on; on == nil || on.name() != "A" {
614+ t.Errorf("surface on %v, want A", on)
615+ }
616+ })
617+
618+ // Moving to B leaves A and enters B once.
619+ r.commands <- ports.ConfigureWindow{ID: m.ID, Width: 200, Height: 100, Visible: true, Output: "B"}
620+ r.waitOn(m.ID, "B")
621+ if err := r.xc.Roundtrip(); err != nil {
622+ t.Fatal(err)
623+ }
624+ if e, l := count(); e != 1 || l != 1 {
625+ t.Fatalf("move to B: %d enters, %d leaves, want 1, 1", e, l)
626+ }
627+}
M internal/adapters/wayland/xdg.go +32 -21
@@ -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
@@ -151,8 +153,8 @@ type window struct {
151153 hasLast bool
152154 }
153155
154-// sendConfigure sends w.last as xdg_toplevel + xdg_surface configure.
155-func (w *window) sendConfigure() {
156+// sendXDGConfigure sends w.last as xdg_toplevel + xdg_surface configure.
157+func (w *window) sendXDGConfigure() {
156158 c := w.last
157159 w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), toplevelStates(c, w.toplevel.Version()))
158160 s := w.xdg.server
@@ -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
@@ -151,8 +153,8 @@ type window struct {
151 hasLast bool153 hasLast bool
152 }154 }
153 155
154-// sendConfigure sends w.last as xdg_toplevel + xdg_surface configure.156+// sendXDGConfigure sends w.last as xdg_toplevel + xdg_surface configure.
155-func (w *window) sendConfigure() {157+func (w *window) sendXDGConfigure() {
156 c := w.last158 c := w.last
157 w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), toplevelStates(c, w.toplevel.Version()))159 w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), toplevelStates(c, w.toplevel.Version()))
158 s := w.xdg.server160 s := w.xdg.server
@@ -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
A internal/adapters/wayland/xdgoutputx_test.go +143 -0
@@ -0,0 +1,143 @@
1+package wayland
2+
3+import (
4+ "slices"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/purego-libwayland/server"
9+ "github.com/bnema/wlturbo"
10+)
11+
12+// xdgPosProxy records the logical_position events of one zxdg_output_v1.
13+type xdgPosProxy struct {
14+ wlturbo.BaseProxy
15+ pos [][2]int32
16+}
17+
18+func (p *xdgPosProxy) Dispatch(e *wlturbo.Event) {
19+ if e.Opcode == 0 {
20+ p.pos = append(p.pos, [2]int32{e.Int32(), e.Int32()})
21+ }
22+}
23+
24+// xdgOutputsOf binds the xdg-output manager and an xdg-output for every
25+// wl_output of c, and returns what each one reported.
26+func xdgOutputsOf(t *testing.T, c *wlturbo.Display) []*xdgPosProxy {
27+ t.Helper()
28+ if err := c.Roundtrip(); err != nil {
29+ t.Fatal(err)
30+ }
31+ var mgrs int
32+ var manager uint32
33+ for _, g := range c.Registry().GetGlobals() {
34+ if g.Interface == "zxdg_output_manager_v1" {
35+ mgrs++
36+ id, err := bindWireID(c, g.Name, g.Interface, 3)
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ registerProtocol(t, c, id)
41+ manager = id
42+ }
43+ }
44+ if mgrs != 1 {
45+ t.Fatalf("client sees %d zxdg_output_manager_v1 globals, want 1", mgrs)
46+ }
47+ var out []*xdgPosProxy
48+ for _, g := range c.Registry().GetGlobals() {
49+ if g.Interface != "wl_output" {
50+ continue
51+ }
52+ wo, err := bindWireID(c, g.Name, g.Interface, 4)
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ xp := &xdgPosProxy{}
57+ xp.SetID(c.AllocateID())
58+ registerWireProxy(c, xp)
59+ requestProtocol(t, c, manager, 1, xp.ID(), wo)
60+ out = append(out, xp)
61+ }
62+ if err := c.Roundtrip(); err != nil {
63+ t.Fatal(err)
64+ }
65+ return out
66+}
67+
68+func lastPositions(ps []*xdgPosProxy) [][2]int32 {
69+ var r [][2]int32
70+ for _, p := range ps {
71+ r = append(r, p.pos[len(p.pos)-1])
72+ }
73+ slices.SortFunc(r, func(a, b [2]int32) int { return int(a[0] - b[0]) })
74+ return r
75+}
76+
77+func twoOutputs(ax int) ports.SetOutputs {
78+ mk := func(name string, x int) ports.OutputPlacement {
79+ return ports.OutputPlacement{Info: ports.OutputInfo{Name: name, Width: 1920, Height: 1080}, X: x, Y: 0, Width: 1920, Height: 1080, Scale: 1}
80+ }
81+ return ports.SetOutputs{Outputs: ports.Layout{mk("A", ax), mk("B", 0)}}
82+}
83+
84+// Xwayland gets X-space positions (layout top-left is 0,0) and re-receives
85+// them on a layout change; every other client keeps the layout coordinates.
86+func TestXwaylandPrivateXDGOutput(t *testing.T) {
87+ r := newX11Rig(t)
88+ applyOutputs(t, r.s, r.commands, twoOutputs(-1920))
89+ other := protocolClient(t, r.s, r.dir)
90+ xo := xdgOutputsOf(t, r.xc)
91+ oo := xdgOutputsOf(t, other)
92+ if got, want := lastPositions(xo), [][2]int32{{0, 0}, {1920, 0}}; !slices.Equal(got, want) {
93+ t.Errorf("Xwayland positions %v, want %v", got, want)
94+ }
95+ if got, want := lastPositions(oo), [][2]int32{{-1920, 0}, {0, 0}}; !slices.Equal(got, want) {
96+ t.Errorf("ordinary positions %v, want %v", got, want)
97+ }
98+ applyOutputs(t, r.s, r.commands, twoOutputs(-3840))
99+ if err := r.xc.Roundtrip(); err != nil {
100+ t.Fatal(err)
101+ }
102+ if err := other.Roundtrip(); err != nil {
103+ t.Fatal(err)
104+ }
105+ if got, want := lastPositions(xo), [][2]int32{{0, 0}, {3840, 0}}; !slices.Equal(got, want) {
106+ t.Errorf("Xwayland positions after change %v, want %v", got, want)
107+ }
108+ if got, want := lastPositions(oo), [][2]int32{{-3840, 0}, {0, 0}}; !slices.Equal(got, want) {
109+ t.Errorf("ordinary positions after change %v, want %v", got, want)
110+ }
111+ // Hotplug: a third output on the right grows the extents.
112+ third := twoOutputs(-3840)
113+ third.Outputs = append(third.Outputs, ports.OutputPlacement{Info: ports.OutputInfo{Name: "C", Width: 1920, Height: 1080}, X: 1920, Width: 1920, Height: 1080, Scale: 1})
114+ applyOutputs(t, r.s, r.commands, third)
115+ if err := r.xc.Roundtrip(); err != nil {
116+ t.Fatal(err)
117+ }
118+ if got := lastPositions(xo); len(got) != 2 || got[1] != [2]int32{3840, 0} {
119+ t.Errorf("Xwayland positions after hotplug %v", got)
120+ }
121+}
122+
123+func TestGlobalFilterAllocations(t *testing.T) {
124+ if raceEnabled {
125+ t.Skip("the race detector allocates in the background")
126+ }
127+ s := &Server{xdgPublic: &server.Global{}, xdgPrivate: &server.Global{}}
128+ if allocs := testing.AllocsPerRun(100, func() {
129+ s.globalVisible(server.Client{}, s.xdgPrivate)
130+ s.globalVisible(server.Client{}, s.xdgPublic)
131+ }); allocs != 0 {
132+ t.Errorf("globalVisible: %.1f allocs, want 0", allocs)
133+ }
134+}
135+
136+// The X origin is derived by one Space, kept without a window manager.
137+func TestXSpaceFollowsLayoutWithoutXWM(t *testing.T) {
138+ r := newX11Rig(t)
139+ applyOutputs(t, r.s, r.commands, twoOutputs(-1920))
140+ if ox, oy := r.s.xspace.Origin(); ox != -1920 || oy != 0 {
141+ t.Fatalf("origin %d,%d, want -1920,0", ox, oy)
142+ }
143+}
@@ -0,0 +1,143 @@
1+package wayland
2+
3+import (
4+ "slices"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/purego-libwayland/server"
9+ "github.com/bnema/wlturbo"
10+)
11+
12+// xdgPosProxy records the logical_position events of one zxdg_output_v1.
13+type xdgPosProxy struct {
14+ wlturbo.BaseProxy
15+ pos [][2]int32
16+}
17+
18+func (p *xdgPosProxy) Dispatch(e *wlturbo.Event) {
19+ if e.Opcode == 0 {
20+ p.pos = append(p.pos, [2]int32{e.Int32(), e.Int32()})
21+ }
22+}
23+
24+// xdgOutputsOf binds the xdg-output manager and an xdg-output for every
25+// wl_output of c, and returns what each one reported.
26+func xdgOutputsOf(t *testing.T, c *wlturbo.Display) []*xdgPosProxy {
27+ t.Helper()
28+ if err := c.Roundtrip(); err != nil {
29+ t.Fatal(err)
30+ }
31+ var mgrs int
32+ var manager uint32
33+ for _, g := range c.Registry().GetGlobals() {
34+ if g.Interface == "zxdg_output_manager_v1" {
35+ mgrs++
36+ id, err := bindWireID(c, g.Name, g.Interface, 3)
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ registerProtocol(t, c, id)
41+ manager = id
42+ }
43+ }
44+ if mgrs != 1 {
45+ t.Fatalf("client sees %d zxdg_output_manager_v1 globals, want 1", mgrs)
46+ }
47+ var out []*xdgPosProxy
48+ for _, g := range c.Registry().GetGlobals() {
49+ if g.Interface != "wl_output" {
50+ continue
51+ }
52+ wo, err := bindWireID(c, g.Name, g.Interface, 4)
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ xp := &xdgPosProxy{}
57+ xp.SetID(c.AllocateID())
58+ registerWireProxy(c, xp)
59+ requestProtocol(t, c, manager, 1, xp.ID(), wo)
60+ out = append(out, xp)
61+ }
62+ if err := c.Roundtrip(); err != nil {
63+ t.Fatal(err)
64+ }
65+ return out
66+}
67+
68+func lastPositions(ps []*xdgPosProxy) [][2]int32 {
69+ var r [][2]int32
70+ for _, p := range ps {
71+ r = append(r, p.pos[len(p.pos)-1])
72+ }
73+ slices.SortFunc(r, func(a, b [2]int32) int { return int(a[0] - b[0]) })
74+ return r
75+}
76+
77+func twoOutputs(ax int) ports.SetOutputs {
78+ mk := func(name string, x int) ports.OutputPlacement {
79+ return ports.OutputPlacement{Info: ports.OutputInfo{Name: name, Width: 1920, Height: 1080}, X: x, Y: 0, Width: 1920, Height: 1080, Scale: 1}
80+ }
81+ return ports.SetOutputs{Outputs: ports.Layout{mk("A", ax), mk("B", 0)}}
82+}
83+
84+// Xwayland gets X-space positions (layout top-left is 0,0) and re-receives
85+// them on a layout change; every other client keeps the layout coordinates.
86+func TestXwaylandPrivateXDGOutput(t *testing.T) {
87+ r := newX11Rig(t)
88+ applyOutputs(t, r.s, r.commands, twoOutputs(-1920))
89+ other := protocolClient(t, r.s, r.dir)
90+ xo := xdgOutputsOf(t, r.xc)
91+ oo := xdgOutputsOf(t, other)
92+ if got, want := lastPositions(xo), [][2]int32{{0, 0}, {1920, 0}}; !slices.Equal(got, want) {
93+ t.Errorf("Xwayland positions %v, want %v", got, want)
94+ }
95+ if got, want := lastPositions(oo), [][2]int32{{-1920, 0}, {0, 0}}; !slices.Equal(got, want) {
96+ t.Errorf("ordinary positions %v, want %v", got, want)
97+ }
98+ applyOutputs(t, r.s, r.commands, twoOutputs(-3840))
99+ if err := r.xc.Roundtrip(); err != nil {
100+ t.Fatal(err)
101+ }
102+ if err := other.Roundtrip(); err != nil {
103+ t.Fatal(err)
104+ }
105+ if got, want := lastPositions(xo), [][2]int32{{0, 0}, {3840, 0}}; !slices.Equal(got, want) {
106+ t.Errorf("Xwayland positions after change %v, want %v", got, want)
107+ }
108+ if got, want := lastPositions(oo), [][2]int32{{-3840, 0}, {0, 0}}; !slices.Equal(got, want) {
109+ t.Errorf("ordinary positions after change %v, want %v", got, want)
110+ }
111+ // Hotplug: a third output on the right grows the extents.
112+ third := twoOutputs(-3840)
113+ third.Outputs = append(third.Outputs, ports.OutputPlacement{Info: ports.OutputInfo{Name: "C", Width: 1920, Height: 1080}, X: 1920, Width: 1920, Height: 1080, Scale: 1})
114+ applyOutputs(t, r.s, r.commands, third)
115+ if err := r.xc.Roundtrip(); err != nil {
116+ t.Fatal(err)
117+ }
118+ if got := lastPositions(xo); len(got) != 2 || got[1] != [2]int32{3840, 0} {
119+ t.Errorf("Xwayland positions after hotplug %v", got)
120+ }
121+}
122+
123+func TestGlobalFilterAllocations(t *testing.T) {
124+ if raceEnabled {
125+ t.Skip("the race detector allocates in the background")
126+ }
127+ s := &Server{xdgPublic: &server.Global{}, xdgPrivate: &server.Global{}}
128+ if allocs := testing.AllocsPerRun(100, func() {
129+ s.globalVisible(server.Client{}, s.xdgPrivate)
130+ s.globalVisible(server.Client{}, s.xdgPublic)
131+ }); allocs != 0 {
132+ t.Errorf("globalVisible: %.1f allocs, want 0", allocs)
133+ }
134+}
135+
136+// The X origin is derived by one Space, kept without a window manager.
137+func TestXSpaceFollowsLayoutWithoutXWM(t *testing.T) {
138+ r := newX11Rig(t)
139+ applyOutputs(t, r.s, r.commands, twoOutputs(-1920))
140+ if ox, oy := r.s.xspace.Origin(); ox != -1920 || oy != 0 {
141+ t.Fatalf("origin %d,%d, want -1920,0", ox, oy)
142+ }
143+}
A internal/adapters/wayland/xwayland.go +75 -0
@@ -0,0 +1,75 @@
1+package wayland
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/neferwl/internal/adapters/logging"
7+ "github.com/bnema/neferwl/internal/adapters/wayland/xwm"
8+ "github.com/bnema/purego-libwayland/server"
9+ "golang.org/x/sys/unix"
10+)
11+
12+// xwaylandClient belongs to the display goroutine.
13+type xwaylandClient struct {
14+ client server.Client
15+}
16+
17+// AttachXwayland transfers ownership of the Wayland descriptor to the display.
18+// It may be called from any goroutine.
19+func (s *Server) AttachXwayland(wlFD int) {
20+ ran := s.display.Do(func() {
21+ c, err := s.display.CreateClient(wlFD)
22+ if err != nil {
23+ s.log.Warn().Err(err).Msg("Xwayland connection not adopted")
24+ _ = unix.Close(wlFD) // CreateClient only adopts on success.
25+ return
26+ }
27+ s.forgetXwayland()
28+ s.xwayland = xwaylandClient{client: c}
29+ c.OnDestroy(func() {
30+ if s.xwayland.client == c {
31+ s.teardownXWM()
32+ s.forgetXwayland()
33+ }
34+ })
35+ s.log.Info().Msg("Xwayland attached")
36+ })
37+ if !ran {
38+ _ = unix.Close(wlFD)
39+ }
40+}
41+
42+// forgetXwayland runs on the display owner, or after Run has joined it.
43+func (s *Server) forgetXwayland() {
44+ if s.xwayland.client != (server.Client{}) {
45+ s.xwayland = xwaylandClient{}
46+ }
47+}
48+
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.BootSettings(ctx, wmFD, log, s.xsettings)
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+ }
75+}
@@ -0,0 +1,75 @@
1+package wayland
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/neferwl/internal/adapters/logging"
7+ "github.com/bnema/neferwl/internal/adapters/wayland/xwm"
8+ "github.com/bnema/purego-libwayland/server"
9+ "golang.org/x/sys/unix"
10+)
11+
12+// xwaylandClient belongs to the display goroutine.
13+type xwaylandClient struct {
14+ client server.Client
15+}
16+
17+// AttachXwayland transfers ownership of the Wayland descriptor to the display.
18+// It may be called from any goroutine.
19+func (s *Server) AttachXwayland(wlFD int) {
20+ ran := s.display.Do(func() {
21+ c, err := s.display.CreateClient(wlFD)
22+ if err != nil {
23+ s.log.Warn().Err(err).Msg("Xwayland connection not adopted")
24+ _ = unix.Close(wlFD) // CreateClient only adopts on success.
25+ return
26+ }
27+ s.forgetXwayland()
28+ s.xwayland = xwaylandClient{client: c}
29+ c.OnDestroy(func() {
30+ if s.xwayland.client == c {
31+ s.teardownXWM()
32+ s.forgetXwayland()
33+ }
34+ })
35+ s.log.Info().Msg("Xwayland attached")
36+ })
37+ if !ran {
38+ _ = unix.Close(wlFD)
39+ }
40+}
41+
42+// forgetXwayland runs on the display owner, or after Run has joined it.
43+func (s *Server) forgetXwayland() {
44+ if s.xwayland.client != (server.Client{}) {
45+ s.xwayland = xwaylandClient{}
46+ }
47+}
48+
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.BootSettings(ctx, wmFD, log, s.xsettings)
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+ }
75+}
A internal/adapters/wayland/xwayland_test.go +84 -0
@@ -0,0 +1,84 @@
1+package wayland
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/purego-libwayland/server"
8+ "golang.org/x/sys/unix"
9+)
10+
11+func xwaylandPair(t *testing.T) *[2]int {
12+ t.Helper()
13+ p, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ t.Cleanup(func() {
18+ if p[1] >= 0 {
19+ unix.Close(p[1])
20+ }
21+ })
22+ return &p
23+}
24+
25+func assertFDClosed(t *testing.T, fd int) {
26+ t.Helper()
27+ if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); !errors.Is(err, unix.EBADF) {
28+ t.Fatalf("fd %d still open: %v", fd, err)
29+ }
30+}
31+
32+func TestAttachXwaylandLifecycle(t *testing.T) {
33+ h := newAdmissionHarness(t, 1)
34+ wl := xwaylandPair(t)
35+ h.s.AttachXwayland(wl[0])
36+ var first server.Client
37+ h.s.display.Do(func() { first = h.s.xwayland.client })
38+ if first == (server.Client{}) {
39+ t.Fatal("client not recorded")
40+ }
41+ wl2 := xwaylandPair(t)
42+ h.s.AttachXwayland(wl2[0])
43+ unix.Close(wl[1])
44+ wl[1] = -1 // Old client's destroy must not clear the new record.
45+ waitTrue(t, "old client destroyed", func() bool {
46+ _, err := unix.FcntlInt(uintptr(wl[0]), unix.F_GETFD, 0)
47+ return errors.Is(err, unix.EBADF)
48+ })
49+ h.s.display.Do(func() {
50+ if h.s.xwayland.client == (server.Client{}) || h.s.xwayland.client == first {
51+ t.Error("replacement lost")
52+ }
53+ })
54+ unix.Close(wl2[1])
55+ wl2[1] = -1
56+ waitTrue(t, "Xwayland disconnected", func() bool {
57+ clear := false
58+ h.s.display.Do(func() { clear = h.s.xwayland.client == (server.Client{}) })
59+ return clear
60+ })
61+}
62+
63+func TestAttachXwaylandFailureClosesFDs(t *testing.T) {
64+ h := newAdmissionHarness(t, 1)
65+ var p [2]int
66+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
67+ t.Fatal(err)
68+ }
69+ defer unix.Close(p[1])
70+ h.s.AttachXwayland(p[0])
71+ assertFDClosed(t, p[0])
72+}
73+
74+func TestAttachXwaylandStopped(t *testing.T) {
75+ d, err := server.NewDisplay()
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ d.Close()
80+ s := &Server{display: d}
81+ wl := xwaylandPair(t)
82+ s.AttachXwayland(wl[0])
83+ assertFDClosed(t, wl[0])
84+}
@@ -0,0 +1,84 @@
1+package wayland
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/purego-libwayland/server"
8+ "golang.org/x/sys/unix"
9+)
10+
11+func xwaylandPair(t *testing.T) *[2]int {
12+ t.Helper()
13+ p, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ t.Cleanup(func() {
18+ if p[1] >= 0 {
19+ unix.Close(p[1])
20+ }
21+ })
22+ return &p
23+}
24+
25+func assertFDClosed(t *testing.T, fd int) {
26+ t.Helper()
27+ if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); !errors.Is(err, unix.EBADF) {
28+ t.Fatalf("fd %d still open: %v", fd, err)
29+ }
30+}
31+
32+func TestAttachXwaylandLifecycle(t *testing.T) {
33+ h := newAdmissionHarness(t, 1)
34+ wl := xwaylandPair(t)
35+ h.s.AttachXwayland(wl[0])
36+ var first server.Client
37+ h.s.display.Do(func() { first = h.s.xwayland.client })
38+ if first == (server.Client{}) {
39+ t.Fatal("client not recorded")
40+ }
41+ wl2 := xwaylandPair(t)
42+ h.s.AttachXwayland(wl2[0])
43+ unix.Close(wl[1])
44+ wl[1] = -1 // Old client's destroy must not clear the new record.
45+ waitTrue(t, "old client destroyed", func() bool {
46+ _, err := unix.FcntlInt(uintptr(wl[0]), unix.F_GETFD, 0)
47+ return errors.Is(err, unix.EBADF)
48+ })
49+ h.s.display.Do(func() {
50+ if h.s.xwayland.client == (server.Client{}) || h.s.xwayland.client == first {
51+ t.Error("replacement lost")
52+ }
53+ })
54+ unix.Close(wl2[1])
55+ wl2[1] = -1
56+ waitTrue(t, "Xwayland disconnected", func() bool {
57+ clear := false
58+ h.s.display.Do(func() { clear = h.s.xwayland.client == (server.Client{}) })
59+ return clear
60+ })
61+}
62+
63+func TestAttachXwaylandFailureClosesFDs(t *testing.T) {
64+ h := newAdmissionHarness(t, 1)
65+ var p [2]int
66+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
67+ t.Fatal(err)
68+ }
69+ defer unix.Close(p[1])
70+ h.s.AttachXwayland(p[0])
71+ assertFDClosed(t, p[0])
72+}
73+
74+func TestAttachXwaylandStopped(t *testing.T) {
75+ d, err := server.NewDisplay()
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ d.Close()
80+ s := &Server{display: d}
81+ wl := xwaylandPair(t)
82+ s.AttachXwayland(wl[0])
83+ assertFDClosed(t, wl[0])
84+}
A internal/adapters/wayland/xwaylandgrab.go +90 -0
@@ -0,0 +1,90 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/go-wayland-bindings/server/wayland"
5+ "github.com/bnema/go-wayland-bindings/server/xwaylandkeyboardgrab"
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/bnema/purego-libwayland/server"
8+)
9+
10+// zwp_xwayland_keyboard_grab_v1. Xwayland asks for it when an X client
11+// takes an active keyboard grab. It is a shortcuts inhibitor, but only
12+// while the grabbing surface's window has the keyboard focus and is
13+// fullscreen: a windowed app must not lock the compositor binds out.
14+// Otherwise the request is accepted and has no effect. Never honoured
15+// while the session is protected. The global is visible to Xwayland only
16+// (xwaylandRestricted).
17+
18+// xwaylandGrab is the seat's single grab.
19+type xwaylandGrab struct {
20+ res *xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabV1
21+ surf *surface
22+}
23+
24+func registerXwaylandGrab(d *server.Display, s *Server) error {
25+ return xwaylandkeyboardgrab.NewZwpXwaylandKeyboardGrabManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
26+ _, _ = xwaylandkeyboardgrab.NewZwpXwaylandKeyboardGrabManagerV1(c, int32(v), id, xwaylandGrabManager{s})
27+ })
28+}
29+
30+type xwaylandGrabManager struct{ s *Server }
31+
32+func (xwaylandGrabManager) Destroy(*xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabManagerV1) {}
33+
34+// GrabKeyboard replaces the seat's grab with a new one.
35+func (m xwaylandGrabManager) GrabKeyboard(r *xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabManagerV1, id uint32, surf *wayland.Surface, _ *wayland.Seat) {
36+ state := m.s.surfaceOf(surf)
37+ if state == nil {
38+ return
39+ }
40+ g := &xwaylandGrab{surf: state}
41+ res, err := xwaylandkeyboardgrab.NewZwpXwaylandKeyboardGrabV1(r.Client(), r.Version(), id, xwaylandGrabHandler{})
42+ if err != nil {
43+ return
44+ }
45+ g.res = res
46+ old := m.s.xgrab
47+ m.s.xgrab = g
48+ res.OnDestroy = func() {
49+ if m.s.xgrab == g {
50+ m.s.xgrab = nil
51+ m.s.syncKeyboardGrab()
52+ }
53+ }
54+ if old != nil && old.res != nil && old.res.Resource.Alive() {
55+ old.res.Resource.Destroy()
56+ }
57+ m.s.syncKeyboardGrab()
58+}
59+
60+type xwaylandGrabHandler struct{}
61+
62+func (xwaylandGrabHandler) Destroy(*xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabV1) {}
63+
64+// xgrabWindow is the window whose grab is in effect (0: none).
65+func (s *Server) xgrabWindow() ports.WindowID {
66+ g := s.xgrab
67+ if g == nil || g.surf == nil || g.surf.destroyed || !g.res.Resource.Alive() {
68+ return 0
69+ }
70+ if s.security != nil && s.security.Snapshot().Protected {
71+ return 0
72+ }
73+ id := g.surf.root().windowID()
74+ if id == 0 || id != s.seat.focused {
75+ return 0
76+ }
77+ if w := s.windows[id]; w == nil || !w.hasLast || !w.last.Fullscreen {
78+ return 0
79+ }
80+ return id
81+}
82+
83+// syncKeyboardGrab re-evaluates the grab after a focus or fullscreen
84+// change, and reports to core only when its effect changed.
85+func (s *Server) syncKeyboardGrab() {
86+ if id := s.xgrabWindow(); id != s.xgrabbed {
87+ s.xgrabbed = id
88+ s.syncInhibitors()
89+ }
90+}
@@ -0,0 +1,90 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/go-wayland-bindings/server/wayland"
5+ "github.com/bnema/go-wayland-bindings/server/xwaylandkeyboardgrab"
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/bnema/purego-libwayland/server"
8+)
9+
10+// zwp_xwayland_keyboard_grab_v1. Xwayland asks for it when an X client
11+// takes an active keyboard grab. It is a shortcuts inhibitor, but only
12+// while the grabbing surface's window has the keyboard focus and is
13+// fullscreen: a windowed app must not lock the compositor binds out.
14+// Otherwise the request is accepted and has no effect. Never honoured
15+// while the session is protected. The global is visible to Xwayland only
16+// (xwaylandRestricted).
17+
18+// xwaylandGrab is the seat's single grab.
19+type xwaylandGrab struct {
20+ res *xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabV1
21+ surf *surface
22+}
23+
24+func registerXwaylandGrab(d *server.Display, s *Server) error {
25+ return xwaylandkeyboardgrab.NewZwpXwaylandKeyboardGrabManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
26+ _, _ = xwaylandkeyboardgrab.NewZwpXwaylandKeyboardGrabManagerV1(c, int32(v), id, xwaylandGrabManager{s})
27+ })
28+}
29+
30+type xwaylandGrabManager struct{ s *Server }
31+
32+func (xwaylandGrabManager) Destroy(*xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabManagerV1) {}
33+
34+// GrabKeyboard replaces the seat's grab with a new one.
35+func (m xwaylandGrabManager) GrabKeyboard(r *xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabManagerV1, id uint32, surf *wayland.Surface, _ *wayland.Seat) {
36+ state := m.s.surfaceOf(surf)
37+ if state == nil {
38+ return
39+ }
40+ g := &xwaylandGrab{surf: state}
41+ res, err := xwaylandkeyboardgrab.NewZwpXwaylandKeyboardGrabV1(r.Client(), r.Version(), id, xwaylandGrabHandler{})
42+ if err != nil {
43+ return
44+ }
45+ g.res = res
46+ old := m.s.xgrab
47+ m.s.xgrab = g
48+ res.OnDestroy = func() {
49+ if m.s.xgrab == g {
50+ m.s.xgrab = nil
51+ m.s.syncKeyboardGrab()
52+ }
53+ }
54+ if old != nil && old.res != nil && old.res.Resource.Alive() {
55+ old.res.Resource.Destroy()
56+ }
57+ m.s.syncKeyboardGrab()
58+}
59+
60+type xwaylandGrabHandler struct{}
61+
62+func (xwaylandGrabHandler) Destroy(*xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabV1) {}
63+
64+// xgrabWindow is the window whose grab is in effect (0: none).
65+func (s *Server) xgrabWindow() ports.WindowID {
66+ g := s.xgrab
67+ if g == nil || g.surf == nil || g.surf.destroyed || !g.res.Resource.Alive() {
68+ return 0
69+ }
70+ if s.security != nil && s.security.Snapshot().Protected {
71+ return 0
72+ }
73+ id := g.surf.root().windowID()
74+ if id == 0 || id != s.seat.focused {
75+ return 0
76+ }
77+ if w := s.windows[id]; w == nil || !w.hasLast || !w.last.Fullscreen {
78+ return 0
79+ }
80+ return id
81+}
82+
83+// syncKeyboardGrab re-evaluates the grab after a focus or fullscreen
84+// change, and reports to core only when its effect changed.
85+func (s *Server) syncKeyboardGrab() {
86+ if id := s.xgrabWindow(); id != s.xgrabbed {
87+ s.xgrabbed = id
88+ s.syncInhibitors()
89+ }
90+}
A internal/adapters/wayland/xwm/atoms.go +118 -0
@@ -0,0 +1,118 @@
1+package xwm
2+
3+// Atoms interned at boot in one round trip, in this fixed order. The table
4+// includes the clipboard and selection atoms.
5+const (
6+ atomWMProtocols = iota
7+ atomWMDeleteWindow
8+ atomWMTakeFocus
9+ atomWMState
10+ atomWMChangeState
11+ atomWMS0
12+ atomUTF8String
13+ atomNetWMCMS0
14+ atomNetSupported
15+ atomNetSupportingWMCheck
16+ atomNetWMName
17+ atomNetWMPID
18+ atomNetWMWindowType
19+ atomNetWMWindowTypeNormal
20+ atomNetWMWindowTypeDialog
21+ atomNetWMWindowTypeUtility
22+ atomNetWMWindowTypeSplash
23+ atomNetWMWindowTypeToolbar
24+ atomNetWMWindowTypeMenu
25+ atomNetWMWindowTypeDropdownMenu
26+ atomNetWMWindowTypePopupMenu
27+ atomNetWMWindowTypeTooltip
28+ atomNetWMWindowTypeNotification
29+ atomNetWMWindowTypeCombo
30+ atomNetWMWindowTypeDND
31+ atomNetWMState
32+ atomNetWMStateModal
33+ atomNetWMStateFullscreen
34+ atomNetWMStateFocused
35+ atomNetWMStateMaximizedVert
36+ atomNetWMStateMaximizedHorz
37+ atomNetActiveWindow
38+ atomNetClientList
39+ atomNetClientListStacking
40+ atomNetWMMoveresize
41+ atomNetWMBypassCompositor
42+ atomWLSurfaceID
43+ atomWLSurfaceSerial
44+ atomNetWMIcon
45+ atomMotifWMHints
46+ atomClipboard
47+ atomPrimary
48+ atomTargets
49+ atomString
50+ atomText
51+ atomTextPlain
52+ atomTextPlainUTF8
53+ atomIncr
54+ atomTimestamp
55+ atomNeferwlData
56+ atomNeferwlTargets
57+ atomManager
58+ atomXSettingsS0
59+ atomXSettingsSettings
60+ atomCount
61+)
62+
63+var atomNames = [atomCount]string{
64+ atomWMProtocols: "WM_PROTOCOLS",
65+ atomWMDeleteWindow: "WM_DELETE_WINDOW",
66+ atomWMTakeFocus: "WM_TAKE_FOCUS",
67+ atomWMState: "WM_STATE",
68+ atomWMChangeState: "WM_CHANGE_STATE",
69+ atomWMS0: "WM_S0",
70+ atomUTF8String: "UTF8_STRING",
71+ atomNetWMCMS0: "_NET_WM_CM_S0",
72+ atomNetSupported: "_NET_SUPPORTED",
73+ atomNetSupportingWMCheck: "_NET_SUPPORTING_WM_CHECK",
74+ atomNetWMName: "_NET_WM_NAME",
75+ atomNetWMPID: "_NET_WM_PID",
76+ atomNetWMWindowType: "_NET_WM_WINDOW_TYPE",
77+ atomNetWMWindowTypeNormal: "_NET_WM_WINDOW_TYPE_NORMAL",
78+ atomNetWMWindowTypeDialog: "_NET_WM_WINDOW_TYPE_DIALOG",
79+ atomNetWMWindowTypeUtility: "_NET_WM_WINDOW_TYPE_UTILITY",
80+ atomNetWMWindowTypeSplash: "_NET_WM_WINDOW_TYPE_SPLASH",
81+ atomNetWMWindowTypeToolbar: "_NET_WM_WINDOW_TYPE_TOOLBAR",
82+ atomNetWMWindowTypeMenu: "_NET_WM_WINDOW_TYPE_MENU",
83+ atomNetWMWindowTypeDropdownMenu: "_NET_WM_WINDOW_TYPE_DROPDOWN_MENU",
84+ atomNetWMWindowTypePopupMenu: "_NET_WM_WINDOW_TYPE_POPUP_MENU",
85+ atomNetWMWindowTypeTooltip: "_NET_WM_WINDOW_TYPE_TOOLTIP",
86+ atomNetWMWindowTypeNotification: "_NET_WM_WINDOW_TYPE_NOTIFICATION",
87+ atomNetWMWindowTypeCombo: "_NET_WM_WINDOW_TYPE_COMBO",
88+ atomNetWMWindowTypeDND: "_NET_WM_WINDOW_TYPE_DND",
89+ atomNetWMState: "_NET_WM_STATE",
90+ atomNetWMStateModal: "_NET_WM_STATE_MODAL",
91+ atomNetWMStateFullscreen: "_NET_WM_STATE_FULLSCREEN",
92+ atomNetWMStateFocused: "_NET_WM_STATE_FOCUSED",
93+ atomNetWMStateMaximizedVert: "_NET_WM_STATE_MAXIMIZED_VERT",
94+ atomNetWMStateMaximizedHorz: "_NET_WM_STATE_MAXIMIZED_HORZ",
95+ atomNetActiveWindow: "_NET_ACTIVE_WINDOW",
96+ atomNetClientList: "_NET_CLIENT_LIST",
97+ atomNetClientListStacking: "_NET_CLIENT_LIST_STACKING",
98+ atomNetWMMoveresize: "_NET_WM_MOVERESIZE",
99+ atomNetWMBypassCompositor: "_NET_WM_BYPASS_COMPOSITOR",
100+ atomWLSurfaceID: "WL_SURFACE_ID",
101+ atomWLSurfaceSerial: "WL_SURFACE_SERIAL",
102+ atomNetWMIcon: "_NET_WM_ICON",
103+ atomMotifWMHints: "_MOTIF_WM_HINTS",
104+ atomClipboard: "CLIPBOARD",
105+ atomPrimary: "PRIMARY",
106+ atomTargets: "TARGETS",
107+ atomString: "STRING",
108+ atomText: "TEXT",
109+ atomTextPlain: "text/plain",
110+ atomTextPlainUTF8: "text/plain;charset=utf-8",
111+ atomIncr: "INCR",
112+ atomTimestamp: "TIMESTAMP",
113+ atomNeferwlData: "_NEFERWL_DATA",
114+ atomNeferwlTargets: "_NEFERWL_TARGETS",
115+ atomManager: "MANAGER",
116+ atomXSettingsS0: "_XSETTINGS_S0",
117+ atomXSettingsSettings: "_XSETTINGS_SETTINGS",
118+}
@@ -0,0 +1,118 @@
1+package xwm
2+
3+// Atoms interned at boot in one round trip, in this fixed order. The table
4+// includes the clipboard and selection atoms.
5+const (
6+ atomWMProtocols = iota
7+ atomWMDeleteWindow
8+ atomWMTakeFocus
9+ atomWMState
10+ atomWMChangeState
11+ atomWMS0
12+ atomUTF8String
13+ atomNetWMCMS0
14+ atomNetSupported
15+ atomNetSupportingWMCheck
16+ atomNetWMName
17+ atomNetWMPID
18+ atomNetWMWindowType
19+ atomNetWMWindowTypeNormal
20+ atomNetWMWindowTypeDialog
21+ atomNetWMWindowTypeUtility
22+ atomNetWMWindowTypeSplash
23+ atomNetWMWindowTypeToolbar
24+ atomNetWMWindowTypeMenu
25+ atomNetWMWindowTypeDropdownMenu
26+ atomNetWMWindowTypePopupMenu
27+ atomNetWMWindowTypeTooltip
28+ atomNetWMWindowTypeNotification
29+ atomNetWMWindowTypeCombo
30+ atomNetWMWindowTypeDND
31+ atomNetWMState
32+ atomNetWMStateModal
33+ atomNetWMStateFullscreen
34+ atomNetWMStateFocused
35+ atomNetWMStateMaximizedVert
36+ atomNetWMStateMaximizedHorz
37+ atomNetActiveWindow
38+ atomNetClientList
39+ atomNetClientListStacking
40+ atomNetWMMoveresize
41+ atomNetWMBypassCompositor
42+ atomWLSurfaceID
43+ atomWLSurfaceSerial
44+ atomNetWMIcon
45+ atomMotifWMHints
46+ atomClipboard
47+ atomPrimary
48+ atomTargets
49+ atomString
50+ atomText
51+ atomTextPlain
52+ atomTextPlainUTF8
53+ atomIncr
54+ atomTimestamp
55+ atomNeferwlData
56+ atomNeferwlTargets
57+ atomManager
58+ atomXSettingsS0
59+ atomXSettingsSettings
60+ atomCount
61+)
62+
63+var atomNames = [atomCount]string{
64+ atomWMProtocols: "WM_PROTOCOLS",
65+ atomWMDeleteWindow: "WM_DELETE_WINDOW",
66+ atomWMTakeFocus: "WM_TAKE_FOCUS",
67+ atomWMState: "WM_STATE",
68+ atomWMChangeState: "WM_CHANGE_STATE",
69+ atomWMS0: "WM_S0",
70+ atomUTF8String: "UTF8_STRING",
71+ atomNetWMCMS0: "_NET_WM_CM_S0",
72+ atomNetSupported: "_NET_SUPPORTED",
73+ atomNetSupportingWMCheck: "_NET_SUPPORTING_WM_CHECK",
74+ atomNetWMName: "_NET_WM_NAME",
75+ atomNetWMPID: "_NET_WM_PID",
76+ atomNetWMWindowType: "_NET_WM_WINDOW_TYPE",
77+ atomNetWMWindowTypeNormal: "_NET_WM_WINDOW_TYPE_NORMAL",
78+ atomNetWMWindowTypeDialog: "_NET_WM_WINDOW_TYPE_DIALOG",
79+ atomNetWMWindowTypeUtility: "_NET_WM_WINDOW_TYPE_UTILITY",
80+ atomNetWMWindowTypeSplash: "_NET_WM_WINDOW_TYPE_SPLASH",
81+ atomNetWMWindowTypeToolbar: "_NET_WM_WINDOW_TYPE_TOOLBAR",
82+ atomNetWMWindowTypeMenu: "_NET_WM_WINDOW_TYPE_MENU",
83+ atomNetWMWindowTypeDropdownMenu: "_NET_WM_WINDOW_TYPE_DROPDOWN_MENU",
84+ atomNetWMWindowTypePopupMenu: "_NET_WM_WINDOW_TYPE_POPUP_MENU",
85+ atomNetWMWindowTypeTooltip: "_NET_WM_WINDOW_TYPE_TOOLTIP",
86+ atomNetWMWindowTypeNotification: "_NET_WM_WINDOW_TYPE_NOTIFICATION",
87+ atomNetWMWindowTypeCombo: "_NET_WM_WINDOW_TYPE_COMBO",
88+ atomNetWMWindowTypeDND: "_NET_WM_WINDOW_TYPE_DND",
89+ atomNetWMState: "_NET_WM_STATE",
90+ atomNetWMStateModal: "_NET_WM_STATE_MODAL",
91+ atomNetWMStateFullscreen: "_NET_WM_STATE_FULLSCREEN",
92+ atomNetWMStateFocused: "_NET_WM_STATE_FOCUSED",
93+ atomNetWMStateMaximizedVert: "_NET_WM_STATE_MAXIMIZED_VERT",
94+ atomNetWMStateMaximizedHorz: "_NET_WM_STATE_MAXIMIZED_HORZ",
95+ atomNetActiveWindow: "_NET_ACTIVE_WINDOW",
96+ atomNetClientList: "_NET_CLIENT_LIST",
97+ atomNetClientListStacking: "_NET_CLIENT_LIST_STACKING",
98+ atomNetWMMoveresize: "_NET_WM_MOVERESIZE",
99+ atomNetWMBypassCompositor: "_NET_WM_BYPASS_COMPOSITOR",
100+ atomWLSurfaceID: "WL_SURFACE_ID",
101+ atomWLSurfaceSerial: "WL_SURFACE_SERIAL",
102+ atomNetWMIcon: "_NET_WM_ICON",
103+ atomMotifWMHints: "_MOTIF_WM_HINTS",
104+ atomClipboard: "CLIPBOARD",
105+ atomPrimary: "PRIMARY",
106+ atomTargets: "TARGETS",
107+ atomString: "STRING",
108+ atomText: "TEXT",
109+ atomTextPlain: "text/plain",
110+ atomTextPlainUTF8: "text/plain;charset=utf-8",
111+ atomIncr: "INCR",
112+ atomTimestamp: "TIMESTAMP",
113+ atomNeferwlData: "_NEFERWL_DATA",
114+ atomNeferwlTargets: "_NEFERWL_TARGETS",
115+ atomManager: "MANAGER",
116+ atomXSettingsS0: "_XSETTINGS_S0",
117+ atomXSettingsSettings: "_XSETTINGS_SETTINGS",
118+}
A internal/adapters/wayland/xwm/configure.go +317 -0
A internal/adapters/wayland/xwm/configure_test.go +591 -0
A internal/adapters/wayland/xwm/cursor.go +109 -0
A internal/adapters/wayland/xwm/cursor_test.go +86 -0
A internal/adapters/wayland/xwm/events.go +303 -0
A internal/adapters/wayland/xwm/fdsource_mock_test.go +129 -0
A internal/adapters/wayland/xwm/fixes_test.go +230 -0
A internal/adapters/wayland/xwm/focus.go +218 -0
A internal/adapters/wayland/xwm/host.go +82 -0
A internal/adapters/wayland/xwm/host_mock_test.go +775 -0
A internal/adapters/wayland/xwm/loop_mock_test.go +180 -0
A internal/adapters/wayland/xwm/mapflow.go +359 -0
A internal/adapters/wayland/xwm/mapflow_test.go +806 -0
A internal/adapters/wayland/xwm/props.go +376 -0
A internal/adapters/wayland/xwm/selection.go +1050 -0
A internal/adapters/wayland/xwm/selection_test.go +801 -0
A internal/adapters/wayland/xwm/selection_xsettings.go +198 -0
A internal/adapters/wayland/xwm/space.go +158 -0
A internal/adapters/wayland/xwm/space_test.go +110 -0
A internal/adapters/wayland/xwm/streams_test.go +197 -0
A internal/adapters/wayland/xwm/table.go +242 -0
A internal/adapters/wayland/xwm/table_test.go +138 -0
A internal/adapters/wayland/xwm/testdata/streams/property_unchanged.hex +950 -0
A internal/adapters/wayland/xwm/testdata/streams/steady.hex +2150 -0
A internal/adapters/wayland/xwm/unmanaged.go +109 -0
A internal/adapters/wayland/xwm/unmanaged_test.go +247 -0
A internal/adapters/wayland/xwm/xpeer_test.go +655 -0
A internal/adapters/wayland/xwm/xsettings_test.go +244 -0
A internal/adapters/wayland/xwm/xwm.go +509 -0
A internal/adapters/wayland/xwm/xwm_test.go +387 -0
A internal/adapters/wayland/xwm_selection_mock_test.go +143 -0
A internal/adapters/xwayland/builtin.go +216 -0
A internal/adapters/xwayland/starter_mock_test.go +110 -0
M internal/adapters/xwayland/xwayland.go +46 -18
M internal/adapters/xwayland/xwayland_test.go +207 -0
M internal/app/app.go +13 -6
M internal/app/cursor.go +18 -0
M internal/app/session.go +2 -1
A internal/app/testdata/x11probe/main.go +692 -0
A internal/app/xwayland_test.go +808 -0
M internal/core/client.go +8 -0
M internal/core/core.go +16 -5
M internal/core/core_test.go +52 -3
M internal/core/datadrag.go +1 -1
M internal/core/fullscreen_left_core_test.go +1 -6
M internal/core/fullscreen_left_test.go +29 -0
M internal/core/layout.go +7 -1
A internal/core/place_test.go +80 -0
M internal/core/rules_test.go +44 -0
M internal/core/scene.go +12 -1
M internal/core/spawn_placement.go +9 -1
M internal/core/transition.go +20 -0
A internal/core/unmanaged.go +117 -0
A internal/core/unmanaged_test.go +145 -0
M internal/ports/client_commands.go +10 -0
M internal/ports/client_events.go +37 -0
M internal/ports/config.go +35 -3

Comments0 open / 0

no comments yet.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close