detached 1f67695

feat(wayland): Wayland to X text selection with INCR and X-owned clipboard sources

commit
1f67695ef79716a00323ce58c354ab2c36424994
author
brice · · 2026-10-22 16:25 UTC
committer
brice · · 2026-10-22 17:55 UTC
parent
8c66911
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +1171 -4
M .mockery.yml +5 -0
@@ -87,6 +87,11 @@ packages:
8787 dir: internal/adapters/wayland
8888 pkgname: wayland
8989 filename: acquire_poller_mock_test.go
90+ xwmSelection:
91+ config:
92+ dir: internal/adapters/wayland
93+ pkgname: wayland
94+ filename: xwm_selection_mock_test.go
9095 syncobjDevice:
9196 config:
9297 dir: internal/adapters/wayland
@@ -87,6 +87,11 @@ packages:
87 dir: internal/adapters/wayland87 dir: internal/adapters/wayland
88 pkgname: wayland88 pkgname: wayland
89 filename: acquire_poller_mock_test.go89 filename: acquire_poller_mock_test.go
90+ xwmSelection:
91+ config:
92+ dir: internal/adapters/wayland
93+ pkgname: wayland
94+ filename: xwm_selection_mock_test.go
90 syncobjDevice:95 syncobjDevice:
91 config:96 config:
92 dir: internal/adapters/wayland97 dir: internal/adapters/wayland
M Makefile +1 -1
@@ -70,7 +70,7 @@ adapter-imports:
7070 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
7171 # Fast, mandatory guards for tiled content publications and SHM copies.
7272 perf-check:
73- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast ./internal/adapters/wayland/xwm -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing|TestTableLookupAllocations|TestTableInsertRemoveAllocations|TestPropertyUnchangedAllocations|TestDispatchAllocations|TestConfigureFlushAllocations|TestFocusAllocations|TestSpaceAllocations|TestUnmanagedGeometryAllocations|TestFocusInUnmanagedAllocations|TestUnmanagedPublishAllocations|TestUnmanagedHitAllocations|TestOverrideMovedUnchangedAllocations|TestFullscreenConfigureAllocations|TestKeyboardGrabSyncAllocations|TestSelectionCopyAllocations|TestSelectionINCRChunkAllocations)$$' -count=1
73+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast ./internal/adapters/wayland/xwm -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing|TestTableLookupAllocations|TestTableInsertRemoveAllocations|TestPropertyUnchangedAllocations|TestDispatchAllocations|TestConfigureFlushAllocations|TestFocusAllocations|TestSpaceAllocations|TestUnmanagedGeometryAllocations|TestFocusInUnmanagedAllocations|TestUnmanagedPublishAllocations|TestUnmanagedHitAllocations|TestOverrideMovedUnchangedAllocations|TestFullscreenConfigureAllocations|TestKeyboardGrabSyncAllocations|TestSelectionCopyAllocations|TestSelectionCopyAllocationsWaylandToX|TestSelectionINCRChunkAllocations)$$' -count=1
7474 CGO_ENABLED=0 go test -tags xturbo_poison ./internal/adapters/wayland/xwm -count=1
7575 check: vet test arch fakes-check log-check perf-check
7676
@@ -70,7 +70,7 @@ adapter-imports:
70 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }70 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
71 # Fast, mandatory guards for tiled content publications and SHM copies.71 # Fast, mandatory guards for tiled content publications and SHM copies.
72 perf-check:72 perf-check:
73- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast ./internal/adapters/wayland/xwm -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing|TestTableLookupAllocations|TestTableInsertRemoveAllocations|TestPropertyUnchangedAllocations|TestDispatchAllocations|TestConfigureFlushAllocations|TestFocusAllocations|TestSpaceAllocations|TestUnmanagedGeometryAllocations|TestFocusInUnmanagedAllocations|TestUnmanagedPublishAllocations|TestUnmanagedHitAllocations|TestOverrideMovedUnchangedAllocations|TestFullscreenConfigureAllocations|TestKeyboardGrabSyncAllocations|TestSelectionCopyAllocations|TestSelectionINCRChunkAllocations)$$' -count=173+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast ./internal/adapters/wayland/xwm -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing|TestTableLookupAllocations|TestTableInsertRemoveAllocations|TestPropertyUnchangedAllocations|TestDispatchAllocations|TestConfigureFlushAllocations|TestFocusAllocations|TestSpaceAllocations|TestUnmanagedGeometryAllocations|TestFocusInUnmanagedAllocations|TestUnmanagedPublishAllocations|TestUnmanagedHitAllocations|TestOverrideMovedUnchangedAllocations|TestFullscreenConfigureAllocations|TestKeyboardGrabSyncAllocations|TestSelectionCopyAllocations|TestSelectionCopyAllocationsWaylandToX|TestSelectionINCRChunkAllocations)$$' -count=1
74 CGO_ENABLED=0 go test -tags xturbo_poison ./internal/adapters/wayland/xwm -count=174 CGO_ENABLED=0 go test -tags xturbo_poison ./internal/adapters/wayland/xwm -count=1
75 check: vet test arch fakes-check log-check perf-check75 check: vet test arch fakes-check log-check perf-check
76 76
M internal/adapters/wayland/clipboard.go +5 -0
@@ -37,6 +37,8 @@ type clipSource struct {
3737 // used: the source was set, rejected or dragged; it cannot be set
3838 // again and takes no more MIME types.
3939 used bool
40+ // x11: the offer is an X client's selection (xSource), not a Wayland one.
41+ x11 bool
4042 // res is the wl_data_source (nil for the other protocols); actions are
4143 // its set_actions, for drag and drop only (dnd.go).
4244 res *wayland.DataSource
@@ -105,6 +107,7 @@ func (s *Server) setSelection(kind int, src *clipSource) {
105107 if src != nil && src != s.selections[kind] && src.alive() {
106108 src.cancel()
107109 }
110+ s.exposeToX(kind, nil) // X clients must not read a protected selection
108111 return
109112 }
110113 old := s.selections[kind]
@@ -116,6 +119,7 @@ func (s *Server) setSelection(kind int, src *clipSource) {
116119 old.cancel()
117120 }
118121 s.announceAll(kind)
122+ s.exposeToX(kind, src)
119123 }
120124
121125 // announceAll sends the selection of kind to the focused client and to
@@ -187,6 +191,7 @@ func (s *Server) sourceGone(src *clipSource) {
187191 if s.selections[kind] == src {
188192 s.selections[kind] = nil
189193 s.announceAll(kind)
194+ s.exposeToX(kind, nil)
190195 }
191196 }
192197 }
@@ -37,6 +37,8 @@ type clipSource struct {
37 // used: the source was set, rejected or dragged; it cannot be set37 // used: the source was set, rejected or dragged; it cannot be set
38 // again and takes no more MIME types.38 // again and takes no more MIME types.
39 used bool39 used bool
40+ // x11: the offer is an X client's selection (xSource), not a Wayland one.
41+ x11 bool
40 // res is the wl_data_source (nil for the other protocols); actions are42 // res is the wl_data_source (nil for the other protocols); actions are
41 // its set_actions, for drag and drop only (dnd.go).43 // its set_actions, for drag and drop only (dnd.go).
42 res *wayland.DataSource44 res *wayland.DataSource
@@ -105,6 +107,7 @@ func (s *Server) setSelection(kind int, src *clipSource) {
105 if src != nil && src != s.selections[kind] && src.alive() {107 if src != nil && src != s.selections[kind] && src.alive() {
106 src.cancel()108 src.cancel()
107 }109 }
110+ s.exposeToX(kind, nil) // X clients must not read a protected selection
108 return111 return
109 }112 }
110 old := s.selections[kind]113 old := s.selections[kind]
@@ -116,6 +119,7 @@ func (s *Server) setSelection(kind int, src *clipSource) {
116 old.cancel()119 old.cancel()
117 }120 }
118 s.announceAll(kind)121 s.announceAll(kind)
122+ s.exposeToX(kind, src)
119 }123 }
120 124
121 // announceAll sends the selection of kind to the focused client and to125 // announceAll sends the selection of kind to the focused client and to
@@ -187,6 +191,7 @@ func (s *Server) sourceGone(src *clipSource) {
187 if s.selections[kind] == src {191 if s.selections[kind] == src {
188 s.selections[kind] = nil192 s.selections[kind] = nil
189 s.announceAll(kind)193 s.announceAll(kind)
194+ s.exposeToX(kind, nil)
190 }195 }
191 }196 }
192 }197 }
A internal/adapters/wayland/clipboard_xwm.go +93 -0
@@ -0,0 +1,93 @@
1+package wayland
2+
3+import (
4+ "slices"
5+
6+ "golang.org/x/sys/unix"
7+)
8+
9+// The text clipboard bridge between Wayland and the built-in XWM. An X
10+// client's selection becomes a clipSource like any other (xSource); a Wayland
11+// selection is exposed to X through exposeToX. The X protocol side lives in
12+// the xwm package; nothing of it reaches core or ports.
13+
14+// xwmSelection is the XWM as the clipboard sees it.
15+type xwmSelection interface {
16+ // WaylandSelection: a Wayland client owns kind with these MIME types;
17+ // nil mimes: none does (or it is protected).
18+ WaylandSelection(kind int, mimes []string)
19+ // SelectionReceive hands the XWM the write end of a reader's pipe (it
20+ // closes it) to fill with the X selection as mime.
21+ SelectionReceive(kind int, mime string, fd int)
22+}
23+
24+// xSource is the clipSource of an X-owned selection: no Wayland client, the
25+// data comes from the XWM. It is valid while it is the current X source.
26+func (s *Server) xSource(kind int, mimes []string) *clipSource {
27+ src := &clipSource{mimes: slices.Clone(mimes), used: true, x11: true}
28+ src.alive = func() bool { return s.xsel[kind] == src }
29+ src.cancel = func() {}
30+ src.send = func(mime string, fd int) {
31+ x := s.xwmSel
32+ if x == nil {
33+ return
34+ }
35+ // receive closes its fd after send: the XWM gets its own.
36+ dup, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0)
37+ if err != nil {
38+ return
39+ }
40+ x.SelectionReceive(kind, mime, dup)
41+ }
42+ return src
43+}
44+
45+// setXSelection is the XWM's report: an X client owns kind (mimes), or none
46+// does. A Wayland owner that already replaced the X one is never clobbered.
47+func (s *Server) setXSelection(kind int, mimes []string) {
48+ if len(mimes) == 0 {
49+ cur := s.xsel[kind]
50+ s.xsel[kind] = nil
51+ if cur != nil && s.selections[kind] == cur {
52+ s.setSelection(kind, nil)
53+ }
54+ return
55+ }
56+ src := s.xSource(kind, mimes)
57+ s.xsel[kind] = src
58+ s.setSelection(kind, src)
59+}
60+
61+// exposeToX tells the XWM what Wayland offers. An X source is not exposed
62+// back (it is X's own); nil withdraws.
63+func (s *Server) exposeToX(kind int, src *clipSource) {
64+ if s.xwmSel == nil {
65+ return
66+ }
67+ if src == nil || src.x11 {
68+ s.xwmSel.WaylandSelection(kind, nil)
69+ return
70+ }
71+ s.xwmSel.WaylandSelection(kind, src.mimes)
72+}
73+
74+// xSelectionRead returns a non-blocking read end of the Wayland selection
75+// kind as mime, or -1.
76+func (s *Server) xSelectionRead(kind int, mime string) int {
77+ src := s.selections[kind]
78+ if src == nil || src.x11 || s.protected() || !src.alive() || !slices.Contains(src.mimes, mime) {
79+ return -1
80+ }
81+ var p [2]int
82+ if unix.Pipe2(p[:], unix.O_CLOEXEC) != nil {
83+ return -1
84+ }
85+ if unix.SetNonblock(p[0], true) != nil {
86+ unix.Close(p[0])
87+ unix.Close(p[1])
88+ return -1
89+ }
90+ src.send(mime, p[1])
91+ unix.Close(p[1])
92+ return p[0]
93+}
@@ -0,0 +1,93 @@
1+package wayland
2+
3+import (
4+ "slices"
5+
6+ "golang.org/x/sys/unix"
7+)
8+
9+// The text clipboard bridge between Wayland and the built-in XWM. An X
10+// client's selection becomes a clipSource like any other (xSource); a Wayland
11+// selection is exposed to X through exposeToX. The X protocol side lives in
12+// the xwm package; nothing of it reaches core or ports.
13+
14+// xwmSelection is the XWM as the clipboard sees it.
15+type xwmSelection interface {
16+ // WaylandSelection: a Wayland client owns kind with these MIME types;
17+ // nil mimes: none does (or it is protected).
18+ WaylandSelection(kind int, mimes []string)
19+ // SelectionReceive hands the XWM the write end of a reader's pipe (it
20+ // closes it) to fill with the X selection as mime.
21+ SelectionReceive(kind int, mime string, fd int)
22+}
23+
24+// xSource is the clipSource of an X-owned selection: no Wayland client, the
25+// data comes from the XWM. It is valid while it is the current X source.
26+func (s *Server) xSource(kind int, mimes []string) *clipSource {
27+ src := &clipSource{mimes: slices.Clone(mimes), used: true, x11: true}
28+ src.alive = func() bool { return s.xsel[kind] == src }
29+ src.cancel = func() {}
30+ src.send = func(mime string, fd int) {
31+ x := s.xwmSel
32+ if x == nil {
33+ return
34+ }
35+ // receive closes its fd after send: the XWM gets its own.
36+ dup, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0)
37+ if err != nil {
38+ return
39+ }
40+ x.SelectionReceive(kind, mime, dup)
41+ }
42+ return src
43+}
44+
45+// setXSelection is the XWM's report: an X client owns kind (mimes), or none
46+// does. A Wayland owner that already replaced the X one is never clobbered.
47+func (s *Server) setXSelection(kind int, mimes []string) {
48+ if len(mimes) == 0 {
49+ cur := s.xsel[kind]
50+ s.xsel[kind] = nil
51+ if cur != nil && s.selections[kind] == cur {
52+ s.setSelection(kind, nil)
53+ }
54+ return
55+ }
56+ src := s.xSource(kind, mimes)
57+ s.xsel[kind] = src
58+ s.setSelection(kind, src)
59+}
60+
61+// exposeToX tells the XWM what Wayland offers. An X source is not exposed
62+// back (it is X's own); nil withdraws.
63+func (s *Server) exposeToX(kind int, src *clipSource) {
64+ if s.xwmSel == nil {
65+ return
66+ }
67+ if src == nil || src.x11 {
68+ s.xwmSel.WaylandSelection(kind, nil)
69+ return
70+ }
71+ s.xwmSel.WaylandSelection(kind, src.mimes)
72+}
73+
74+// xSelectionRead returns a non-blocking read end of the Wayland selection
75+// kind as mime, or -1.
76+func (s *Server) xSelectionRead(kind int, mime string) int {
77+ src := s.selections[kind]
78+ if src == nil || src.x11 || s.protected() || !src.alive() || !slices.Contains(src.mimes, mime) {
79+ return -1
80+ }
81+ var p [2]int
82+ if unix.Pipe2(p[:], unix.O_CLOEXEC) != nil {
83+ return -1
84+ }
85+ if unix.SetNonblock(p[0], true) != nil {
86+ unix.Close(p[0])
87+ unix.Close(p[1])
88+ return -1
89+ }
90+ src.send(mime, p[1])
91+ unix.Close(p[1])
92+ return p[0]
93+}
A internal/adapters/wayland/clipboard_xwm_test.go +147 -0
@@ -0,0 +1,147 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/go-wayland-bindings/server/wayland"
8+ "github.com/stretchr/testify/mock"
9+ "golang.org/x/sys/unix"
10+)
11+
12+func onDisplay(t *testing.T, s *Server, f func()) {
13+ t.Helper()
14+ if !s.display.Do(f) {
15+ t.Fatal("display stopped")
16+ }
17+}
18+
19+// receiveVia asks the offer for mime into a fresh pipe and returns its read end.
20+func receiveVia(t *testing.T, cc *clipClient, offer uint32, mime string) int {
21+ t.Helper()
22+ var fds [2]int
23+ if err := unix.Pipe2(fds[:], unix.O_CLOEXEC); err != nil {
24+ t.Fatal(err)
25+ }
26+ t.Cleanup(func() { unix.Close(fds[0]) })
27+ if err := wireRequest(cc.c, offer, uint16(wayland.DataOfferRequestReceive), []int{fds[1]}, mime); err != nil {
28+ t.Fatal(err)
29+ }
30+ _ = unix.Close(fds[1])
31+ if err := cc.c.Roundtrip(); err != nil {
32+ t.Fatal(err)
33+ }
34+ return fds[0]
35+}
36+
37+// An X selection reaches the focused client and not the others, is replaced
38+// when a Wayland client copies (and is then not read back from X), and is
39+// never read while the session is protected.
40+func TestXSelectionReachesWaylandClients(t *testing.T) {
41+ s, events, commands, dir := keyboardServer(t)
42+ state := installSecurity(t, s)
43+ xm := newMockxwmSelection(t)
44+ onDisplay(t, s, func() { s.xwmSel = xm })
45+ a := newClipClient(t, s, dir, events)
46+ b := newClipClient(t, s, dir, events)
47+ a.focus(t, commands)
48+
49+ // X owns CLIPBOARD: the focused client gets an offer, the other none.
50+ xm.EXPECT().WaylandSelection(selClipboard, []string(nil)).Maybe()
51+ onDisplay(t, s, func() { s.setXSelection(selClipboard, []string{"text/plain;charset=utf-8", "UTF8_STRING"}) })
52+ offer, ok := a.selection(t)
53+ if !ok || offer == 0 {
54+ t.Fatalf("focused client got no X offer: %d %v", offer, ok)
55+ }
56+ if id, ok := b.selection(t); ok {
57+ t.Fatalf("unfocused client got %d", id)
58+ }
59+
60+ // Reading reaches the XWM with a pipe it owns.
61+ got := make(chan string, 1)
62+ xm.EXPECT().SelectionReceive(selClipboard, "UTF8_STRING", mock.Anything).Run(func(_ int, mime string, fd int) {
63+ _, _ = unix.Write(fd, []byte("from x"))
64+ _ = unix.Close(fd)
65+ got <- mime
66+ }).Once()
67+ r := receiveVia(t, a, offer, "UTF8_STRING")
68+ select {
69+ case <-got:
70+ case <-time.After(2 * time.Second):
71+ t.Fatal("XWM never asked to receive")
72+ }
73+ buf := make([]byte, 16)
74+ n, err := unix.Read(r, buf)
75+ if err != nil || string(buf[:n]) != "from x" {
76+ t.Fatalf("pipe: %q %v", buf[:n], err)
77+ }
78+
79+ // Focus follows: b gets the X offer too (focus replay).
80+ if id := b.focus(t, commands); id == 0 {
81+ t.Fatal("no X offer on focus")
82+ }
83+
84+ // A Wayland copy replaces it and is exposed to X.
85+ xm.EXPECT().WaylandSelection(selClipboard, []string{"text/plain"}).Once()
86+ b.copy(t)
87+ if id, ok := b.selection(t); !ok || id == 0 {
88+ t.Fatal("copy gave no offer")
89+ }
90+ // X clearing afterwards never clobbers the Wayland owner.
91+ onDisplay(t, s, func() { s.setXSelection(selClipboard, nil) })
92+ if id, ok := b.selection(t); ok && id == 0 {
93+ t.Fatal("the Wayland selection was cleared by X")
94+ }
95+
96+ // Protection: the X offer is not readable, and X cannot read Wayland.
97+ onDisplay(t, s, func() { s.setXSelection(selClipboard, []string{"UTF8_STRING"}) })
98+ protect(t, s, state, 1, true)
99+ var fd int
100+ onDisplay(t, s, func() { fd = s.xSelectionRead(selClipboard, "text/plain") })
101+ if fd >= 0 {
102+ unix.Close(fd)
103+ t.Fatal("protected selection readable by X")
104+ }
105+ protect(t, s, state, 2, false)
106+}
107+
108+// A Wayland client's text selection is read for X through a pipe it fills.
109+func TestXSelectionReadFromWayland(t *testing.T) {
110+ s, events, commands, dir := keyboardServer(t)
111+ xm := newMockxwmSelection(t)
112+ onDisplay(t, s, func() { s.xwmSel = xm })
113+ a := newClipClient(t, s, dir, events)
114+ a.focus(t, commands)
115+ xm.EXPECT().WaylandSelection(selClipboard, []string(nil)).Maybe() // teardown destroys the source
116+ xm.EXPECT().WaylandSelection(selClipboard, []string{"text/plain"}).Once()
117+ src := a.copy(t)
118+ var fd int
119+ onDisplay(t, s, func() { fd = s.xSelectionRead(selClipboard, "text/plain") })
120+ if fd < 0 {
121+ t.Fatal("no read end")
122+ }
123+ defer unix.Close(fd)
124+ deadline := time.After(2 * time.Second)
125+ for len(src.sends) == 0 {
126+ if err := a.c.Roundtrip(); err != nil {
127+ t.Fatal(err)
128+ }
129+ select {
130+ case <-deadline:
131+ t.Fatal("source never asked to send")
132+ case <-time.After(5 * time.Millisecond):
133+ }
134+ }
135+ buf := make([]byte, 16)
136+ n, err := unix.Read(fd, buf)
137+ if err != nil || string(buf[:n]) != "pasted" {
138+ t.Fatalf("read %q %v", buf[:n], err)
139+ }
140+ onDisplay(t, s, func() {
141+ fd2 := s.xSelectionRead(selClipboard, "image/png")
142+ if fd2 >= 0 {
143+ unix.Close(fd2)
144+ t.Error("unoffered mime readable")
145+ }
146+ })
147+}
@@ -0,0 +1,147 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/go-wayland-bindings/server/wayland"
8+ "github.com/stretchr/testify/mock"
9+ "golang.org/x/sys/unix"
10+)
11+
12+func onDisplay(t *testing.T, s *Server, f func()) {
13+ t.Helper()
14+ if !s.display.Do(f) {
15+ t.Fatal("display stopped")
16+ }
17+}
18+
19+// receiveVia asks the offer for mime into a fresh pipe and returns its read end.
20+func receiveVia(t *testing.T, cc *clipClient, offer uint32, mime string) int {
21+ t.Helper()
22+ var fds [2]int
23+ if err := unix.Pipe2(fds[:], unix.O_CLOEXEC); err != nil {
24+ t.Fatal(err)
25+ }
26+ t.Cleanup(func() { unix.Close(fds[0]) })
27+ if err := wireRequest(cc.c, offer, uint16(wayland.DataOfferRequestReceive), []int{fds[1]}, mime); err != nil {
28+ t.Fatal(err)
29+ }
30+ _ = unix.Close(fds[1])
31+ if err := cc.c.Roundtrip(); err != nil {
32+ t.Fatal(err)
33+ }
34+ return fds[0]
35+}
36+
37+// An X selection reaches the focused client and not the others, is replaced
38+// when a Wayland client copies (and is then not read back from X), and is
39+// never read while the session is protected.
40+func TestXSelectionReachesWaylandClients(t *testing.T) {
41+ s, events, commands, dir := keyboardServer(t)
42+ state := installSecurity(t, s)
43+ xm := newMockxwmSelection(t)
44+ onDisplay(t, s, func() { s.xwmSel = xm })
45+ a := newClipClient(t, s, dir, events)
46+ b := newClipClient(t, s, dir, events)
47+ a.focus(t, commands)
48+
49+ // X owns CLIPBOARD: the focused client gets an offer, the other none.
50+ xm.EXPECT().WaylandSelection(selClipboard, []string(nil)).Maybe()
51+ onDisplay(t, s, func() { s.setXSelection(selClipboard, []string{"text/plain;charset=utf-8", "UTF8_STRING"}) })
52+ offer, ok := a.selection(t)
53+ if !ok || offer == 0 {
54+ t.Fatalf("focused client got no X offer: %d %v", offer, ok)
55+ }
56+ if id, ok := b.selection(t); ok {
57+ t.Fatalf("unfocused client got %d", id)
58+ }
59+
60+ // Reading reaches the XWM with a pipe it owns.
61+ got := make(chan string, 1)
62+ xm.EXPECT().SelectionReceive(selClipboard, "UTF8_STRING", mock.Anything).Run(func(_ int, mime string, fd int) {
63+ _, _ = unix.Write(fd, []byte("from x"))
64+ _ = unix.Close(fd)
65+ got <- mime
66+ }).Once()
67+ r := receiveVia(t, a, offer, "UTF8_STRING")
68+ select {
69+ case <-got:
70+ case <-time.After(2 * time.Second):
71+ t.Fatal("XWM never asked to receive")
72+ }
73+ buf := make([]byte, 16)
74+ n, err := unix.Read(r, buf)
75+ if err != nil || string(buf[:n]) != "from x" {
76+ t.Fatalf("pipe: %q %v", buf[:n], err)
77+ }
78+
79+ // Focus follows: b gets the X offer too (focus replay).
80+ if id := b.focus(t, commands); id == 0 {
81+ t.Fatal("no X offer on focus")
82+ }
83+
84+ // A Wayland copy replaces it and is exposed to X.
85+ xm.EXPECT().WaylandSelection(selClipboard, []string{"text/plain"}).Once()
86+ b.copy(t)
87+ if id, ok := b.selection(t); !ok || id == 0 {
88+ t.Fatal("copy gave no offer")
89+ }
90+ // X clearing afterwards never clobbers the Wayland owner.
91+ onDisplay(t, s, func() { s.setXSelection(selClipboard, nil) })
92+ if id, ok := b.selection(t); ok && id == 0 {
93+ t.Fatal("the Wayland selection was cleared by X")
94+ }
95+
96+ // Protection: the X offer is not readable, and X cannot read Wayland.
97+ onDisplay(t, s, func() { s.setXSelection(selClipboard, []string{"UTF8_STRING"}) })
98+ protect(t, s, state, 1, true)
99+ var fd int
100+ onDisplay(t, s, func() { fd = s.xSelectionRead(selClipboard, "text/plain") })
101+ if fd >= 0 {
102+ unix.Close(fd)
103+ t.Fatal("protected selection readable by X")
104+ }
105+ protect(t, s, state, 2, false)
106+}
107+
108+// A Wayland client's text selection is read for X through a pipe it fills.
109+func TestXSelectionReadFromWayland(t *testing.T) {
110+ s, events, commands, dir := keyboardServer(t)
111+ xm := newMockxwmSelection(t)
112+ onDisplay(t, s, func() { s.xwmSel = xm })
113+ a := newClipClient(t, s, dir, events)
114+ a.focus(t, commands)
115+ xm.EXPECT().WaylandSelection(selClipboard, []string(nil)).Maybe() // teardown destroys the source
116+ xm.EXPECT().WaylandSelection(selClipboard, []string{"text/plain"}).Once()
117+ src := a.copy(t)
118+ var fd int
119+ onDisplay(t, s, func() { fd = s.xSelectionRead(selClipboard, "text/plain") })
120+ if fd < 0 {
121+ t.Fatal("no read end")
122+ }
123+ defer unix.Close(fd)
124+ deadline := time.After(2 * time.Second)
125+ for len(src.sends) == 0 {
126+ if err := a.c.Roundtrip(); err != nil {
127+ t.Fatal(err)
128+ }
129+ select {
130+ case <-deadline:
131+ t.Fatal("source never asked to send")
132+ case <-time.After(5 * time.Millisecond):
133+ }
134+ }
135+ buf := make([]byte, 16)
136+ n, err := unix.Read(fd, buf)
137+ if err != nil || string(buf[:n]) != "pasted" {
138+ t.Fatalf("read %q %v", buf[:n], err)
139+ }
140+ onDisplay(t, s, func() {
141+ fd2 := s.xSelectionRead(selClipboard, "image/png")
142+ if fd2 >= 0 {
143+ unix.Close(fd2)
144+ t.Error("unoffered mime readable")
145+ }
146+ })
147+}
M internal/adapters/wayland/server.go +3 -0
@@ -313,6 +313,8 @@ type Server struct {
313313 cursorReady chan struct{}
314314 // Clipboard and primary selection (clipboard.go).
315315 selections [2]*clipSource
316+ xsel [2]*clipSource // the current X-owned source per kind, if any
317+ xwmSel xwmSelection // the XWM's clipboard side; nil without one
316318 // Drag and drop (dnd.go): the drag in progress, drops waiting for
317319 // their finish, and each client's data devices.
318320 dnd *dndState
@@ -607,6 +609,7 @@ func (s *Server) Run(ctx context.Context) error {
607609 if s.xwm != nil {
608610 s.xwm.CloseConn()
609611 s.xwm = nil
612+ s.xwmSel = nil
610613 }
611614 s.forgetXwayland()
612615 s.stopSecurityContexts()
@@ -313,6 +313,8 @@ type Server struct {
313 cursorReady chan struct{}313 cursorReady chan struct{}
314 // Clipboard and primary selection (clipboard.go).314 // Clipboard and primary selection (clipboard.go).
315 selections [2]*clipSource315 selections [2]*clipSource
316+ xsel [2]*clipSource // the current X-owned source per kind, if any
317+ xwmSel xwmSelection // the XWM's clipboard side; nil without one
316 // Drag and drop (dnd.go): the drag in progress, drops waiting for318 // Drag and drop (dnd.go): the drag in progress, drops waiting for
317 // their finish, and each client's data devices.319 // their finish, and each client's data devices.
318 dnd *dndState320 dnd *dndState
@@ -607,6 +609,7 @@ func (s *Server) Run(ctx context.Context) error {
607 if s.xwm != nil {609 if s.xwm != nil {
608 s.xwm.CloseConn()610 s.xwm.CloseConn()
609 s.xwm = nil611 s.xwm = nil
612+ s.xwmSel = nil
610 }613 }
611 s.forgetXwayland()614 s.forgetXwayland()
612 s.stopSecurityContexts()615 s.stopSecurityContexts()
M internal/adapters/wayland/x11window.go +13 -2
@@ -99,10 +99,11 @@ func (xwaylandSurface) Destroy(*xwaylandshell.XwaylandSurfaceV1) {}
9999 func (s *Server) installXWM(x *xwm.XWM) {
100100 s.teardownXWM()
101101 s.xwm = x
102+ s.xwmSel = x
102103 x.UseSpace(&s.xspace)
103104 if err := x.Attach(s.display, hostImpl{s}); err != nil {
104105 s.log.Warn().Err(err).Msg("X11 window management disabled")
105- s.xwm = nil
106+ s.xwm, s.xwmSel = nil, nil
106107 x.CloseConn()
107108 return
108109 }
@@ -112,13 +113,18 @@ func (s *Server) installXWM(x *xwm.XWM) {
112113 x.SetSerial(surf.identity, surf.x11Serial)
113114 }
114115 }
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+ }
115121 x.LayoutChanged(true)
116122 }
117123
118124 // teardownXWM closes the window manager, dropping its windows. Idempotent.
119125 func (s *Server) teardownXWM() {
120126 if x := s.xwm; x != nil {
121- s.xwm = nil
127+ s.xwm, s.xwmSel = nil, nil
122128 x.Close()
123129 }
124130 }
@@ -377,6 +383,11 @@ func (h hostImpl) OverrideMoved(slot xwm.Slot, x, y, w, h2 int) {
377383 // OverrideGone ends an override window: unmapped, or destroyed.
378384 func (h hostImpl) SelectionOwned(kind int, mimes []string) {
379385 h.s.log.Debug().Int("kind", kind).Int("mimes", len(mimes)).Msg("X selection owner changed")
386+ h.s.setXSelection(kind, mimes)
387+}
388+
389+func (h hostImpl) SelectionRead(kind int, mime string) int {
390+ return h.s.xSelectionRead(kind, mime)
380391 }
381392
382393 func (h hostImpl) After(d time.Duration, fn func()) func() {
@@ -99,10 +99,11 @@ func (xwaylandSurface) Destroy(*xwaylandshell.XwaylandSurfaceV1) {}
99 func (s *Server) installXWM(x *xwm.XWM) {99 func (s *Server) installXWM(x *xwm.XWM) {
100 s.teardownXWM()100 s.teardownXWM()
101 s.xwm = x101 s.xwm = x
102+ s.xwmSel = x
102 x.UseSpace(&s.xspace)103 x.UseSpace(&s.xspace)
103 if err := x.Attach(s.display, hostImpl{s}); err != nil {104 if err := x.Attach(s.display, hostImpl{s}); err != nil {
104 s.log.Warn().Err(err).Msg("X11 window management disabled")105 s.log.Warn().Err(err).Msg("X11 window management disabled")
105- s.xwm = nil106+ s.xwm, s.xwmSel = nil, nil
106 x.CloseConn()107 x.CloseConn()
107 return108 return
108 }109 }
@@ -112,13 +113,18 @@ func (s *Server) installXWM(x *xwm.XWM) {
112 x.SetSerial(surf.identity, surf.x11Serial)113 x.SetSerial(surf.identity, surf.x11Serial)
113 }114 }
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+ }
115 x.LayoutChanged(true)121 x.LayoutChanged(true)
116 }122 }
117 123
118 // teardownXWM closes the window manager, dropping its windows. Idempotent.124 // teardownXWM closes the window manager, dropping its windows. Idempotent.
119 func (s *Server) teardownXWM() {125 func (s *Server) teardownXWM() {
120 if x := s.xwm; x != nil {126 if x := s.xwm; x != nil {
121- s.xwm = nil127+ s.xwm, s.xwmSel = nil, nil
122 x.Close()128 x.Close()
123 }129 }
124 }130 }
@@ -377,6 +383,11 @@ func (h hostImpl) OverrideMoved(slot xwm.Slot, x, y, w, h2 int) {
377 // OverrideGone ends an override window: unmapped, or destroyed.383 // OverrideGone ends an override window: unmapped, or destroyed.
378 func (h hostImpl) SelectionOwned(kind int, mimes []string) {384 func (h hostImpl) SelectionOwned(kind int, mimes []string) {
379 h.s.log.Debug().Int("kind", kind).Int("mimes", len(mimes)).Msg("X selection owner changed")385 h.s.log.Debug().Int("kind", kind).Int("mimes", len(mimes)).Msg("X selection owner changed")
386+ h.s.setXSelection(kind, mimes)
387+}
388+
389+func (h hostImpl) SelectionRead(kind int, mime string) int {
390+ return h.s.xSelectionRead(kind, mime)
380 }391 }
381 392
382 func (h hostImpl) After(d time.Duration, fn func()) func() {393 func (h hostImpl) After(d time.Duration, fn func()) func() {
M internal/adapters/wayland/xwm/events.go +2 -1
@@ -23,6 +23,7 @@ const (
2323 kindOverrideType
2424 kindSelTargets
2525 kindSelData
26+ kindSelOut
2627 )
2728
2829 // onEvent dispatches an X event. Before Attach there is no host: only the
@@ -136,7 +137,7 @@ func (x *XWM) onError(e *xturbo.XError) {
136137 return
137138 }
138139 switch e.Kind {
139- case kindSelTargets, kindSelData:
140+ case kindSelTargets, kindSelData, kindSelOut:
140141 x.onSelError(e)
141142 case kindRootMask:
142143 if e.Code == xproto.BadAccess {
@@ -23,6 +23,7 @@ const (
23 kindOverrideType23 kindOverrideType
24 kindSelTargets24 kindSelTargets
25 kindSelData25 kindSelData
26+ kindSelOut
26 )27 )
27 28
28 // onEvent dispatches an X event. Before Attach there is no host: only the29 // onEvent dispatches an X event. Before Attach there is no host: only the
@@ -136,7 +137,7 @@ func (x *XWM) onError(e *xturbo.XError) {
136 return137 return
137 }138 }
138 switch e.Kind {139 switch e.Kind {
139- case kindSelTargets, kindSelData:140+ case kindSelTargets, kindSelData, kindSelOut:
140 x.onSelError(e)141 x.onSelError(e)
141 case kindRootMask:142 case kindRootMask:
142 if e.Code == xproto.BadAccess {143 if e.Code == xproto.BadAccess {
M internal/adapters/wayland/xwm/host.go +5 -0
@@ -63,6 +63,11 @@ type host interface {
6363 // scratch: the host copies it.
6464 SelectionOwned(kind int, mimes []string)
6565
66+ // SelectionRead asks for the data of the current Wayland selection kind
67+ // as mime: it returns a non-blocking read end the XWM closes, or -1 when
68+ // there is none, it is protected, or mime is not offered.
69+ SelectionRead(kind int, mime string) (fd int)
70+
6671 // After runs fn on the display goroutine once d has elapsed, unless the
6772 // returned cancel ran first. cancel is idempotent and display goroutine
6873 // only; fn may still run once after cancel when it was already queued,
@@ -63,6 +63,11 @@ type host interface {
63 // scratch: the host copies it.63 // scratch: the host copies it.
64 SelectionOwned(kind int, mimes []string)64 SelectionOwned(kind int, mimes []string)
65 65
66+ // SelectionRead asks for the data of the current Wayland selection kind
67+ // as mime: it returns a non-blocking read end the XWM closes, or -1 when
68+ // there is none, it is protected, or mime is not offered.
69+ SelectionRead(kind int, mime string) (fd int)
70+
66 // After runs fn on the display goroutine once d has elapsed, unless the71 // After runs fn on the display goroutine once d has elapsed, unless the
67 // returned cancel ran first. cancel is idempotent and display goroutine72 // returned cancel ran first. cancel is idempotent and display goroutine
68 // only; fn may still run once after cancel when it was already queued,73 // only; fn may still run once after cancel when it was already queued,
M internal/adapters/wayland/xwm/host_mock_test.go +57 -0
@@ -671,6 +671,63 @@ func (_c *mockhost_SelectionOwned_Call) RunAndReturn(run func(kind int, mimes []
671671 return _c
672672 }
673673
674+// SelectionRead provides a mock function for the type mockhost
675+func (_mock *mockhost) SelectionRead(kind int, mime string) int {
676+ ret := _mock.Called(kind, mime)
677+
678+ if len(ret) == 0 {
679+ panic("no return value specified for SelectionRead")
680+ }
681+
682+ var r0 int
683+ if returnFunc, ok := ret.Get(0).(func(int, string) int); ok {
684+ r0 = returnFunc(kind, mime)
685+ } else {
686+ r0 = ret.Get(0).(int)
687+ }
688+ return r0
689+}
690+
691+// mockhost_SelectionRead_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectionRead'
692+type mockhost_SelectionRead_Call struct {
693+ *mock.Call
694+}
695+
696+// SelectionRead is a helper method to define mock.On call
697+// - kind int
698+// - mime string
699+func (_e *mockhost_Expecter) SelectionRead(kind any, mime any) *mockhost_SelectionRead_Call {
700+ return &mockhost_SelectionRead_Call{Call: _e.mock.On("SelectionRead", kind, mime)}
701+}
702+
703+func (_c *mockhost_SelectionRead_Call) Run(run func(kind int, mime string)) *mockhost_SelectionRead_Call {
704+ _c.Call.Run(func(args mock.Arguments) {
705+ var arg0 int
706+ if args[0] != nil {
707+ arg0 = args[0].(int)
708+ }
709+ var arg1 string
710+ if args[1] != nil {
711+ arg1 = args[1].(string)
712+ }
713+ run(
714+ arg0,
715+ arg1,
716+ )
717+ })
718+ return _c
719+}
720+
721+func (_c *mockhost_SelectionRead_Call) Return(fd int) *mockhost_SelectionRead_Call {
722+ _c.Call.Return(fd)
723+ return _c
724+}
725+
726+func (_c *mockhost_SelectionRead_Call) RunAndReturn(run func(kind int, mime string) int) *mockhost_SelectionRead_Call {
727+ _c.Call.Return(run)
728+ return _c
729+}
730+
674731 // Title provides a mock function for the type mockhost
675732 func (_mock *mockhost) Title(slot Slot, title string) {
676733 _mock.Called(slot, title)
@@ -671,6 +671,63 @@ func (_c *mockhost_SelectionOwned_Call) RunAndReturn(run func(kind int, mimes []
671 return _c671 return _c
672 }672 }
673 673
674+// SelectionRead provides a mock function for the type mockhost
675+func (_mock *mockhost) SelectionRead(kind int, mime string) int {
676+ ret := _mock.Called(kind, mime)
677+
678+ if len(ret) == 0 {
679+ panic("no return value specified for SelectionRead")
680+ }
681+
682+ var r0 int
683+ if returnFunc, ok := ret.Get(0).(func(int, string) int); ok {
684+ r0 = returnFunc(kind, mime)
685+ } else {
686+ r0 = ret.Get(0).(int)
687+ }
688+ return r0
689+}
690+
691+// mockhost_SelectionRead_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectionRead'
692+type mockhost_SelectionRead_Call struct {
693+ *mock.Call
694+}
695+
696+// SelectionRead is a helper method to define mock.On call
697+// - kind int
698+// - mime string
699+func (_e *mockhost_Expecter) SelectionRead(kind any, mime any) *mockhost_SelectionRead_Call {
700+ return &mockhost_SelectionRead_Call{Call: _e.mock.On("SelectionRead", kind, mime)}
701+}
702+
703+func (_c *mockhost_SelectionRead_Call) Run(run func(kind int, mime string)) *mockhost_SelectionRead_Call {
704+ _c.Call.Run(func(args mock.Arguments) {
705+ var arg0 int
706+ if args[0] != nil {
707+ arg0 = args[0].(int)
708+ }
709+ var arg1 string
710+ if args[1] != nil {
711+ arg1 = args[1].(string)
712+ }
713+ run(
714+ arg0,
715+ arg1,
716+ )
717+ })
718+ return _c
719+}
720+
721+func (_c *mockhost_SelectionRead_Call) Return(fd int) *mockhost_SelectionRead_Call {
722+ _c.Call.Return(fd)
723+ return _c
724+}
725+
726+func (_c *mockhost_SelectionRead_Call) RunAndReturn(run func(kind int, mime string) int) *mockhost_SelectionRead_Call {
727+ _c.Call.Return(run)
728+ return _c
729+}
730+
674 // Title provides a mock function for the type mockhost731 // Title provides a mock function for the type mockhost
675 func (_mock *mockhost) Title(slot Slot, title string) {732 func (_mock *mockhost) Title(slot Slot, title string) {
676 _mock.Called(slot, title)733 _mock.Called(slot, title)
M internal/adapters/wayland/xwm/props.go +3 -0
@@ -117,6 +117,9 @@ func (x *XWM) onPropertyNotify(ev *xturbo.Event) {
117117 x.onSelProperty(ev)
118118 return
119119 }
120+ if x.onRequestorProperty(e) {
121+ return
122+ }
120123 s := x.tab.lookup(uint32(e.Window()))
121124 if s == 0 {
122125 return
@@ -117,6 +117,9 @@ func (x *XWM) onPropertyNotify(ev *xturbo.Event) {
117 x.onSelProperty(ev)117 x.onSelProperty(ev)
118 return118 return
119 }119 }
120+ if x.onRequestorProperty(e) {
121+ return
122+ }
120 s := x.tab.lookup(uint32(e.Window()))123 s := x.tab.lookup(uint32(e.Window()))
121 if s == 0 {124 if s == 0 {
122 return125 return
M internal/adapters/wayland/xwm/selection.go +433 -0
@@ -33,6 +33,10 @@ type selState struct {
3333 atom uint32
3434 gen uint32 // bumped on every ownership change; stales TARGETS probes
3535 owner uint32 // X owner window other than selWin, 0 none
36+ ours bool // selWin owns the selection (Wayland -> X)
37+ // wl is what the Wayland side offers while ours: raw text availability.
38+ wlUTF8, wlPlain bool
39+ wlMime [len(textMimes)]string // offered text MIMEs, in textMimes order, "" absent
3640 }
3741
3842 const (
@@ -109,6 +113,7 @@ type selBridge struct {
109113 mimes [len(textMimes)]string // scratch handed to SelectionOwned
110114 pool bufPool
111115 in [nXfer]xin
116+ out [nXfer]xout
112117 order uint64
113118 // active is the transfer that owns the conversion property, -1 none.
114119 active int
@@ -128,6 +133,11 @@ func (x *XWM) initSelection() {
128133 t.onR = func() { t.x.inAbort(t.idx, true) }
129134 t.onW = func() { t.x.inWritable(t.idx) }
130135 }
136+ for i := range b.out {
137+ o := &b.out[i]
138+ o.x, o.idx, o.buf, o.fd = x, i, -1, -1
139+ o.onR = func() { o.x.outReadable(o.idx) }
140+ }
131141 }
132142
133143 // bootSelection creates the selection window and asks for ownership events.
@@ -170,6 +180,10 @@ func (x *XWM) onSelectionEvent(ev *xturbo.Event) {
170180 x.onXFixesSelection(ev)
171181 case xproto.KindSelectionNotify:
172182 x.onSelectionNotify(ev)
183+ case xproto.KindSelectionRequest:
184+ x.onSelectionRequest(ev)
185+ case xproto.KindSelectionClear:
186+ x.onSelectionClear(ev)
173187 }
174188 }
175189
@@ -520,6 +534,7 @@ func (x *XWM) onSelProperty(ev *xturbo.Event) {
520534 if e.State() != xproto.PropertyNewValue || uint32(e.Atom()) != x.atoms[atomNeferwlData] {
521535 return
522536 }
537+ // (a Deleted state on a requestor is handled by onRequestorProperty)
523538 i := x.sel.active
524539 if i < 0 || !x.sel.in[i].incr {
525540 return
@@ -537,6 +552,12 @@ func (x *XWM) onSelProperty(ev *xturbo.Event) {
537552
538553 // onSelError: a selection request failed; the awaited reply never comes.
539554 func (x *XWM) onSelError(e *xturbo.XError) {
555+ if e.Kind == kindSelOut {
556+ if i := int(e.Args[0]); i >= 0 && i < nXfer && x.sel.out[i].gen == e.Args[1] && x.sel.out[i].state != outFree {
557+ x.outAbort(i)
558+ }
559+ return
560+ }
540561 if e.Kind == kindSelData {
541562 if i := int(e.Args[0]); i >= 0 && i < nXfer && x.sel.in[i].gen == e.Args[1] && x.sel.in[i].state == inReading {
542563 x.inAbort(i, false)
@@ -580,6 +601,23 @@ func (x *XWM) selTick() {
580601 }
581602 live = true
582603 }
604+ for i := range b.out {
605+ o := &b.out[i]
606+ if o.state == outFree {
607+ continue
608+ }
609+ o.idle++
610+ limit := rIdleMax
611+ if o.incr && o.n > 0 && !o.credit {
612+ limit = xIdleMax // the requestor does not delete the property
613+ }
614+ if o.idle >= limit {
615+ x.log.Warn().Int("kind", o.kind).Msg("selection: transfer timed out")
616+ x.outAbort(i)
617+ continue
618+ }
619+ live = true
620+ }
583621 if live {
584622 x.selEnsureTimer()
585623 }
@@ -587,6 +625,11 @@ func (x *XWM) selTick() {
587625
588626 // selClose aborts every transfer.
589627 func (x *XWM) selClose() {
628+ for i := range x.sel.out {
629+ if x.sel.out[i].state != outFree {
630+ x.outAbort(i)
631+ }
632+ }
590633 for i := range x.sel.in {
591634 if x.sel.in[i].state != inFree {
592635 x.inAbort(i, false)
@@ -597,3 +640,393 @@ func (x *XWM) selClose() {
597640 x.sel.cancel = nil
598641 }
599642 }
643+
644+// ---- Wayland -> X ----
645+
646+// Wayland -> X transfer states.
647+const (
648+ outFree uint8 = iota
649+ outActive
650+)
651+
652+// xout is one Wayland -> X transfer.
653+type xout struct {
654+ x *XWM
655+ idx int
656+ state uint8
657+ kind int
658+ requestor uint32
659+ property uint32
660+ target uint32
661+ time uint32
662+ fd int
663+ buf int
664+ n int
665+ eof bool
666+ incr bool // INCR announced
667+ credit bool // the requestor deleted the property: the next chunk may go
668+ notified bool // the requestor got its SelectionNotify
669+ idle int
670+ gen uint32
671+ src fdSource
672+ onR func()
673+}
674+
675+// WaylandSelection: a Wayland client owns kind with these MIME types, or none
676+// does (mimes nil). The XWM takes X ownership when text is offered.
677+func (x *XWM) WaylandSelection(kind int, mimes []string) {
678+ if x.selWin == 0 || x.closed || kind < 0 || kind >= selKinds || x.host == nil {
679+ return
680+ }
681+ st := &x.sel.st[kind]
682+ st.wlUTF8, st.wlPlain = false, false
683+ st.wlMime = [len(textMimes)]string{}
684+ for _, m := range mimes {
685+ for i, tm := range textMimes {
686+ if m != tm {
687+ continue
688+ }
689+ st.wlMime[i] = m
690+ if mimeTarget[i] == atomUTF8String {
691+ st.wlUTF8 = true
692+ } else {
693+ st.wlPlain = true
694+ }
695+ }
696+ }
697+ for i := range x.sel.out { // the data being served is stale
698+ if x.sel.out[i].state != outFree && x.sel.out[i].kind == kind {
699+ x.outAbort(i)
700+ }
701+ }
702+ b := x.conn.Buf()
703+ if !st.wlUTF8 && !st.wlPlain {
704+ if st.ours {
705+ st.ours = false
706+ xproto.SetSelectionOwner(b, 0, xproto.Atom(st.atom), 0)
707+ x.wake()
708+ }
709+ return
710+ }
711+ st.ours = true
712+ st.gen++
713+ st.owner = 0
714+ xproto.SetSelectionOwner(b, xproto.Window(x.selWin), xproto.Atom(st.atom), 0)
715+ x.log.Debug().Int("kind", kind).Msg("selection: Wayland owner")
716+ x.wake()
717+}
718+
719+// onSelectionClear: someone else took a selection from selWin.
720+func (x *XWM) onSelectionClear(ev *xturbo.Event) {
721+ e := xproto.SelectionClearOf(ev)
722+ k := x.selKind(uint32(e.Selection()))
723+ if k < 0 || uint32(e.Owner()) != x.selWin {
724+ return
725+ }
726+ x.sel.st[k].ours = false
727+ for i := range x.sel.out {
728+ if x.sel.out[i].state != outFree && x.sel.out[i].kind == k {
729+ x.outAbort(i)
730+ }
731+ }
732+}
733+
734+// notify sends a SelectionNotify to the requestor; property 0 refuses.
735+func (x *XWM) notify(requestor, selection, target, property, time uint32) {
736+ d := xproto.SelectionNotifyEventData{
737+ Time: xproto.Timestamp(time), Requestor: xproto.Window(requestor),
738+ Selection: xproto.Atom(selection), Target: xproto.Atom(target), Property: xproto.Atom(property),
739+ }
740+ var ev [32]byte
741+ d.Put(&ev)
742+ xproto.SendEvent(x.conn.Buf(), false, xproto.Window(requestor), 0, &ev)
743+ x.wake()
744+}
745+
746+// onSelectionRequest: an X client asks selWin for the Wayland selection.
747+func (x *XWM) onSelectionRequest(ev *xturbo.Event) {
748+ e := xproto.SelectionRequestOf(ev)
749+ if uint32(e.Owner()) != x.selWin {
750+ return
751+ }
752+ sel, req, target, prop, time := uint32(e.Selection()), uint32(e.Requestor()), uint32(e.Target()), uint32(e.Property()), uint32(e.Time())
753+ k := x.selKind(sel)
754+ if k < 0 || !x.sel.st[k].ours {
755+ x.notify(req, sel, target, 0, time)
756+ return
757+ }
758+ if prop == 0 { // obsolete client
759+ prop = target
760+ }
761+ st := &x.sel.st[k]
762+ switch target {
763+ case x.atoms[atomTargets]:
764+ var list [5]uint32
765+ n := 0
766+ list[n], n = x.atoms[atomTargets], n+1
767+ if st.wlUTF8 {
768+ list[n], n = x.atoms[atomUTF8String], n+1
769+ list[n], n = x.atoms[atomText], n+1
770+ }
771+ list[n], n = x.atoms[atomString], n+1
772+ xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(req), xproto.Atom(prop),
773+ xproto.AtomAtom, 32, uint32(n), x.encode32(list[:n]))
774+ x.notify(req, sel, target, prop, time)
775+ return
776+ case x.atoms[atomUTF8String], x.atoms[atomText], x.atoms[atomString]:
777+ default:
778+ x.notify(req, sel, target, 0, time)
779+ return
780+ }
781+ // Choose the Wayland MIME: UTF-8 targets prefer the UTF-8 types; STRING
782+ // prefers plain text and falls back to the UTF-8 bytes unchanged.
783+ mime := ""
784+ order := [...]int{0, 2, 4, 1, 3} // utf8 types, then plain
785+ if target == x.atoms[atomString] {
786+ order = [...]int{1, 3, 0, 2, 4}
787+ }
788+ for _, i := range order {
789+ if st.wlMime[i] != "" && (target == x.atoms[atomString] || mimeTarget[i] == atomUTF8String) {
790+ mime = st.wlMime[i]
791+ break
792+ }
793+ }
794+ if mime == "" {
795+ x.notify(req, sel, target, 0, time)
796+ return
797+ }
798+ for i := range x.sel.out { // one transfer per (requestor, property)
799+ o := &x.sel.out[i]
800+ if o.state != outFree && o.requestor == req && o.property == prop {
801+ x.notify(req, sel, target, 0, time)
802+ return
803+ }
804+ }
805+ slot := -1
806+ for i := range x.sel.out {
807+ if x.sel.out[i].state == outFree {
808+ slot = i
809+ break
810+ }
811+ }
812+ if slot < 0 || x.sel.pool.n == 0 {
813+ x.log.Warn().Msg("selection: too many transfers, request refused")
814+ x.notify(req, sel, target, 0, time)
815+ return
816+ }
817+ fd := x.host.SelectionRead(k, mime)
818+ if fd < 0 {
819+ x.notify(req, sel, target, 0, time)
820+ return
821+ }
822+ x.startOut(slot, k, req, prop, target, time, fd)
823+}
824+
825+// startOut begins serving the data on fd (a non-blocking read end it now
826+// owns) to requestor.
827+func (x *XWM) startOut(slot, k int, req, prop, target, time uint32, fd int) {
828+ o := &x.sel.out[slot]
829+ o.state, o.kind, o.requestor, o.property, o.target, o.time = outActive, k, req, prop, target, time
830+ o.fd, o.buf, o.n, o.eof, o.incr, o.credit, o.notified, o.idle, o.src = fd, x.sel.pool.get(), 0, false, false, false, false, 0, nil
831+ x.selEnsureTimer()
832+ x.outRead(slot) // data may already be waiting
833+}
834+
835+// encode32 packs vals into the shared scratch.
836+func (x *XWM) encode32(vals []uint32) []byte {
837+ n := 4 * len(vals)
838+ if cap(x.scratch) < n {
839+ x.scratch = make([]byte, 0, n)
840+ }
841+ p := x.scratch[:n]
842+ for i, v := range vals {
843+ binary.LittleEndian.PutUint32(p[4*i:], v)
844+ }
845+ return p
846+}
847+
848+// outWatch registers the pipe's read end with the display.
849+func (x *XWM) outWatch(i int) {
850+ o := &x.sel.out[i]
851+ if o.src != nil {
852+ return
853+ }
854+ if x.addFD == nil {
855+ x.outAbort(i)
856+ return
857+ }
858+ src, err := x.addFD(o.fd, o.onR, nil)
859+ if err != nil {
860+ x.outAbort(i)
861+ return
862+ }
863+ o.src = src
864+}
865+
866+// outUnwatch drops read interest: the buffer is full and nothing may be read
867+// until the requestor consumed the previous chunk.
868+func (x *XWM) outUnwatch(i int) {
869+ o := &x.sel.out[i]
870+ if o.src != nil {
871+ o.src.Remove()
872+ o.src = nil
873+ }
874+}
875+
876+// outReadable is the pipe's read callback.
877+func (x *XWM) outReadable(i int) {
878+ if x.sel.out[i].state == outFree {
879+ return
880+ }
881+ x.outRead(i)
882+}
883+
884+// outRead fills the buffer from the pipe and advances the transfer.
885+func (x *XWM) outRead(i int) {
886+ o := &x.sel.out[i]
887+ buf := &x.sel.pool.bufs[o.buf]
888+ for o.n < chunk && !o.eof {
889+ n, err := unix.Read(o.fd, buf[o.n:])
890+ switch {
891+ case err == nil && n == 0:
892+ o.eof = true
893+ case err == nil:
894+ o.n += n
895+ o.idle = 0
896+ case errors.Is(err, unix.EINTR):
897+ continue
898+ case errors.Is(err, unix.EAGAIN):
899+ x.outPump(i)
900+ return
901+ default:
902+ x.outAbort(i)
903+ return
904+ }
905+ }
906+ x.outPump(i)
907+}
908+
909+// outPump sends what the state allows.
910+func (x *XWM) outPump(i int) {
911+ o := &x.sel.out[i]
912+ if o.state == outFree {
913+ return
914+ }
915+ full := o.n == chunk
916+ if !o.incr {
917+ switch {
918+ case o.eof: // whole value
919+ x.outSetProp(i, o.n)
920+ x.notify(o.requestor, x.sel.st[o.kind].atom, o.target, o.property, o.time)
921+ x.outFinish(i)
922+ case full: // does not fit one request: INCR
923+ o.incr, o.notified = true, true
924+ var v xproto.ChangeWindowAttributesValues
925+ v.SetEventMask(xproto.EventMaskPropertyChange)
926+ xproto.ChangeWindowAttributes(x.conn.Buf(), xproto.Window(o.requestor), &v)
927+ xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(o.requestor), xproto.Atom(o.property),
928+ xproto.Atom(x.atoms[atomIncr]), 32, 1, x.encode32([]uint32{0}))
929+ x.notify(o.requestor, x.sel.st[o.kind].atom, o.target, o.property, o.time)
930+ x.outUnwatch(i)
931+ default:
932+ x.outWatch(i)
933+ }
934+ return
935+ }
936+ if o.credit && (full || o.eof) {
937+ o.credit = false
938+ o.idle = 0
939+ x.outSetProp(i, o.n)
940+ if o.n == 0 { // the empty chunk ends the transfer
941+ x.outFinish(i)
942+ return
943+ }
944+ o.n = 0
945+ }
946+ if o.n < chunk && !o.eof {
947+ x.outWatch(i)
948+ } else {
949+ x.outUnwatch(i)
950+ }
951+}
952+
953+// outSetProp writes the first n buffered bytes as the property value.
954+func (x *XWM) outSetProp(i, n int) {
955+ o := &x.sel.out[i]
956+ seq := xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(o.requestor), xproto.Atom(o.property),
957+ xproto.Atom(o.target), 8, uint32(n), x.sel.pool.bufs[o.buf][:n])
958+ if seq != 0 {
959+ if err := x.conn.Expect(seq, kindSelOut, uint32(i), o.gen, 0, 0); err != nil {
960+ x.stats.RingFull++
961+ }
962+ }
963+ x.wake()
964+}
965+
966+// outFinish ends a completed transfer.
967+func (x *XWM) outFinish(i int) {
968+ o := &x.sel.out[i]
969+ if o.incr && !x.closed {
970+ var v xproto.ChangeWindowAttributesValues
971+ v.SetEventMask(0)
972+ xproto.ChangeWindowAttributes(x.conn.Buf(), xproto.Window(o.requestor), &v)
973+ x.wake()
974+ }
975+ x.outRelease(i)
976+}
977+
978+// outRelease frees the transfer's resources.
979+func (x *XWM) outRelease(i int) {
980+ o := &x.sel.out[i]
981+ x.outUnwatch(i)
982+ if o.fd >= 0 {
983+ _ = unix.Close(o.fd)
984+ }
985+ if o.buf >= 0 {
986+ x.sel.pool.put(o.buf)
987+ }
988+ o.fd, o.buf = -1, -1
989+ o.gen++
990+ o.state = outFree
991+}
992+
993+// outAbort ends a transfer early: the requestor is refused (before its
994+// notification) or released (INCR: mask dropped, property deleted).
995+func (x *XWM) outAbort(i int) {
996+ o := &x.sel.out[i]
997+ if o.state == outFree {
998+ return
999+ }
1000+ if !x.closed {
1001+ switch {
1002+ case !o.notified:
1003+ x.notify(o.requestor, x.sel.st[o.kind].atom, o.target, 0, o.time)
1004+ case o.incr:
1005+ var v xproto.ChangeWindowAttributesValues
1006+ v.SetEventMask(0)
1007+ xproto.ChangeWindowAttributes(x.conn.Buf(), xproto.Window(o.requestor), &v)
1008+ xproto.DeleteProperty(x.conn.Buf(), xproto.Window(o.requestor), xproto.Atom(o.property))
1009+ x.wake()
1010+ }
1011+ }
1012+ x.outRelease(i)
1013+}
1014+
1015+// onRequestorProperty: PropertyNotify on a window we serve by INCR.
1016+func (x *XWM) onRequestorProperty(e xproto.PropertyNotifyEvent) bool {
1017+ if e.State() != xproto.PropertyDelete {
1018+ return false
1019+ }
1020+ hit := false
1021+ for i := range x.sel.out {
1022+ o := &x.sel.out[i]
1023+ if o.state == outFree || !o.incr || o.requestor != uint32(e.Window()) || o.property != uint32(e.Atom()) {
1024+ continue
1025+ }
1026+ hit = true
1027+ o.credit = true
1028+ o.idle = 0
1029+ x.outPump(i)
1030+ }
1031+ return hit
1032+}
@@ -33,6 +33,10 @@ type selState struct {
33 atom uint3233 atom uint32
34 gen uint32 // bumped on every ownership change; stales TARGETS probes34 gen uint32 // bumped on every ownership change; stales TARGETS probes
35 owner uint32 // X owner window other than selWin, 0 none35 owner uint32 // X owner window other than selWin, 0 none
36+ ours bool // selWin owns the selection (Wayland -> X)
37+ // wl is what the Wayland side offers while ours: raw text availability.
38+ wlUTF8, wlPlain bool
39+ wlMime [len(textMimes)]string // offered text MIMEs, in textMimes order, "" absent
36 }40 }
37 41
38 const (42 const (
@@ -109,6 +113,7 @@ type selBridge struct {
109 mimes [len(textMimes)]string // scratch handed to SelectionOwned113 mimes [len(textMimes)]string // scratch handed to SelectionOwned
110 pool bufPool114 pool bufPool
111 in [nXfer]xin115 in [nXfer]xin
116+ out [nXfer]xout
112 order uint64117 order uint64
113 // active is the transfer that owns the conversion property, -1 none.118 // active is the transfer that owns the conversion property, -1 none.
114 active int119 active int
@@ -128,6 +133,11 @@ func (x *XWM) initSelection() {
128 t.onR = func() { t.x.inAbort(t.idx, true) }133 t.onR = func() { t.x.inAbort(t.idx, true) }
129 t.onW = func() { t.x.inWritable(t.idx) }134 t.onW = func() { t.x.inWritable(t.idx) }
130 }135 }
136+ for i := range b.out {
137+ o := &b.out[i]
138+ o.x, o.idx, o.buf, o.fd = x, i, -1, -1
139+ o.onR = func() { o.x.outReadable(o.idx) }
140+ }
131 }141 }
132 142
133 // bootSelection creates the selection window and asks for ownership events.143 // bootSelection creates the selection window and asks for ownership events.
@@ -170,6 +180,10 @@ func (x *XWM) onSelectionEvent(ev *xturbo.Event) {
170 x.onXFixesSelection(ev)180 x.onXFixesSelection(ev)
171 case xproto.KindSelectionNotify:181 case xproto.KindSelectionNotify:
172 x.onSelectionNotify(ev)182 x.onSelectionNotify(ev)
183+ case xproto.KindSelectionRequest:
184+ x.onSelectionRequest(ev)
185+ case xproto.KindSelectionClear:
186+ x.onSelectionClear(ev)
173 }187 }
174 }188 }
175 189
@@ -520,6 +534,7 @@ func (x *XWM) onSelProperty(ev *xturbo.Event) {
520 if e.State() != xproto.PropertyNewValue || uint32(e.Atom()) != x.atoms[atomNeferwlData] {534 if e.State() != xproto.PropertyNewValue || uint32(e.Atom()) != x.atoms[atomNeferwlData] {
521 return535 return
522 }536 }
537+ // (a Deleted state on a requestor is handled by onRequestorProperty)
523 i := x.sel.active538 i := x.sel.active
524 if i < 0 || !x.sel.in[i].incr {539 if i < 0 || !x.sel.in[i].incr {
525 return540 return
@@ -537,6 +552,12 @@ func (x *XWM) onSelProperty(ev *xturbo.Event) {
537 552
538 // onSelError: a selection request failed; the awaited reply never comes.553 // onSelError: a selection request failed; the awaited reply never comes.
539 func (x *XWM) onSelError(e *xturbo.XError) {554 func (x *XWM) onSelError(e *xturbo.XError) {
555+ if e.Kind == kindSelOut {
556+ if i := int(e.Args[0]); i >= 0 && i < nXfer && x.sel.out[i].gen == e.Args[1] && x.sel.out[i].state != outFree {
557+ x.outAbort(i)
558+ }
559+ return
560+ }
540 if e.Kind == kindSelData {561 if e.Kind == kindSelData {
541 if i := int(e.Args[0]); i >= 0 && i < nXfer && x.sel.in[i].gen == e.Args[1] && x.sel.in[i].state == inReading {562 if i := int(e.Args[0]); i >= 0 && i < nXfer && x.sel.in[i].gen == e.Args[1] && x.sel.in[i].state == inReading {
542 x.inAbort(i, false)563 x.inAbort(i, false)
@@ -580,6 +601,23 @@ func (x *XWM) selTick() {
580 }601 }
581 live = true602 live = true
582 }603 }
604+ for i := range b.out {
605+ o := &b.out[i]
606+ if o.state == outFree {
607+ continue
608+ }
609+ o.idle++
610+ limit := rIdleMax
611+ if o.incr && o.n > 0 && !o.credit {
612+ limit = xIdleMax // the requestor does not delete the property
613+ }
614+ if o.idle >= limit {
615+ x.log.Warn().Int("kind", o.kind).Msg("selection: transfer timed out")
616+ x.outAbort(i)
617+ continue
618+ }
619+ live = true
620+ }
583 if live {621 if live {
584 x.selEnsureTimer()622 x.selEnsureTimer()
585 }623 }
@@ -587,6 +625,11 @@ func (x *XWM) selTick() {
587 625
588 // selClose aborts every transfer.626 // selClose aborts every transfer.
589 func (x *XWM) selClose() {627 func (x *XWM) selClose() {
628+ for i := range x.sel.out {
629+ if x.sel.out[i].state != outFree {
630+ x.outAbort(i)
631+ }
632+ }
590 for i := range x.sel.in {633 for i := range x.sel.in {
591 if x.sel.in[i].state != inFree {634 if x.sel.in[i].state != inFree {
592 x.inAbort(i, false)635 x.inAbort(i, false)
@@ -597,3 +640,393 @@ func (x *XWM) selClose() {
597 x.sel.cancel = nil640 x.sel.cancel = nil
598 }641 }
599 }642 }
643+
644+// ---- Wayland -> X ----
645+
646+// Wayland -> X transfer states.
647+const (
648+ outFree uint8 = iota
649+ outActive
650+)
651+
652+// xout is one Wayland -> X transfer.
653+type xout struct {
654+ x *XWM
655+ idx int
656+ state uint8
657+ kind int
658+ requestor uint32
659+ property uint32
660+ target uint32
661+ time uint32
662+ fd int
663+ buf int
664+ n int
665+ eof bool
666+ incr bool // INCR announced
667+ credit bool // the requestor deleted the property: the next chunk may go
668+ notified bool // the requestor got its SelectionNotify
669+ idle int
670+ gen uint32
671+ src fdSource
672+ onR func()
673+}
674+
675+// WaylandSelection: a Wayland client owns kind with these MIME types, or none
676+// does (mimes nil). The XWM takes X ownership when text is offered.
677+func (x *XWM) WaylandSelection(kind int, mimes []string) {
678+ if x.selWin == 0 || x.closed || kind < 0 || kind >= selKinds || x.host == nil {
679+ return
680+ }
681+ st := &x.sel.st[kind]
682+ st.wlUTF8, st.wlPlain = false, false
683+ st.wlMime = [len(textMimes)]string{}
684+ for _, m := range mimes {
685+ for i, tm := range textMimes {
686+ if m != tm {
687+ continue
688+ }
689+ st.wlMime[i] = m
690+ if mimeTarget[i] == atomUTF8String {
691+ st.wlUTF8 = true
692+ } else {
693+ st.wlPlain = true
694+ }
695+ }
696+ }
697+ for i := range x.sel.out { // the data being served is stale
698+ if x.sel.out[i].state != outFree && x.sel.out[i].kind == kind {
699+ x.outAbort(i)
700+ }
701+ }
702+ b := x.conn.Buf()
703+ if !st.wlUTF8 && !st.wlPlain {
704+ if st.ours {
705+ st.ours = false
706+ xproto.SetSelectionOwner(b, 0, xproto.Atom(st.atom), 0)
707+ x.wake()
708+ }
709+ return
710+ }
711+ st.ours = true
712+ st.gen++
713+ st.owner = 0
714+ xproto.SetSelectionOwner(b, xproto.Window(x.selWin), xproto.Atom(st.atom), 0)
715+ x.log.Debug().Int("kind", kind).Msg("selection: Wayland owner")
716+ x.wake()
717+}
718+
719+// onSelectionClear: someone else took a selection from selWin.
720+func (x *XWM) onSelectionClear(ev *xturbo.Event) {
721+ e := xproto.SelectionClearOf(ev)
722+ k := x.selKind(uint32(e.Selection()))
723+ if k < 0 || uint32(e.Owner()) != x.selWin {
724+ return
725+ }
726+ x.sel.st[k].ours = false
727+ for i := range x.sel.out {
728+ if x.sel.out[i].state != outFree && x.sel.out[i].kind == k {
729+ x.outAbort(i)
730+ }
731+ }
732+}
733+
734+// notify sends a SelectionNotify to the requestor; property 0 refuses.
735+func (x *XWM) notify(requestor, selection, target, property, time uint32) {
736+ d := xproto.SelectionNotifyEventData{
737+ Time: xproto.Timestamp(time), Requestor: xproto.Window(requestor),
738+ Selection: xproto.Atom(selection), Target: xproto.Atom(target), Property: xproto.Atom(property),
739+ }
740+ var ev [32]byte
741+ d.Put(&ev)
742+ xproto.SendEvent(x.conn.Buf(), false, xproto.Window(requestor), 0, &ev)
743+ x.wake()
744+}
745+
746+// onSelectionRequest: an X client asks selWin for the Wayland selection.
747+func (x *XWM) onSelectionRequest(ev *xturbo.Event) {
748+ e := xproto.SelectionRequestOf(ev)
749+ if uint32(e.Owner()) != x.selWin {
750+ return
751+ }
752+ sel, req, target, prop, time := uint32(e.Selection()), uint32(e.Requestor()), uint32(e.Target()), uint32(e.Property()), uint32(e.Time())
753+ k := x.selKind(sel)
754+ if k < 0 || !x.sel.st[k].ours {
755+ x.notify(req, sel, target, 0, time)
756+ return
757+ }
758+ if prop == 0 { // obsolete client
759+ prop = target
760+ }
761+ st := &x.sel.st[k]
762+ switch target {
763+ case x.atoms[atomTargets]:
764+ var list [5]uint32
765+ n := 0
766+ list[n], n = x.atoms[atomTargets], n+1
767+ if st.wlUTF8 {
768+ list[n], n = x.atoms[atomUTF8String], n+1
769+ list[n], n = x.atoms[atomText], n+1
770+ }
771+ list[n], n = x.atoms[atomString], n+1
772+ xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(req), xproto.Atom(prop),
773+ xproto.AtomAtom, 32, uint32(n), x.encode32(list[:n]))
774+ x.notify(req, sel, target, prop, time)
775+ return
776+ case x.atoms[atomUTF8String], x.atoms[atomText], x.atoms[atomString]:
777+ default:
778+ x.notify(req, sel, target, 0, time)
779+ return
780+ }
781+ // Choose the Wayland MIME: UTF-8 targets prefer the UTF-8 types; STRING
782+ // prefers plain text and falls back to the UTF-8 bytes unchanged.
783+ mime := ""
784+ order := [...]int{0, 2, 4, 1, 3} // utf8 types, then plain
785+ if target == x.atoms[atomString] {
786+ order = [...]int{1, 3, 0, 2, 4}
787+ }
788+ for _, i := range order {
789+ if st.wlMime[i] != "" && (target == x.atoms[atomString] || mimeTarget[i] == atomUTF8String) {
790+ mime = st.wlMime[i]
791+ break
792+ }
793+ }
794+ if mime == "" {
795+ x.notify(req, sel, target, 0, time)
796+ return
797+ }
798+ for i := range x.sel.out { // one transfer per (requestor, property)
799+ o := &x.sel.out[i]
800+ if o.state != outFree && o.requestor == req && o.property == prop {
801+ x.notify(req, sel, target, 0, time)
802+ return
803+ }
804+ }
805+ slot := -1
806+ for i := range x.sel.out {
807+ if x.sel.out[i].state == outFree {
808+ slot = i
809+ break
810+ }
811+ }
812+ if slot < 0 || x.sel.pool.n == 0 {
813+ x.log.Warn().Msg("selection: too many transfers, request refused")
814+ x.notify(req, sel, target, 0, time)
815+ return
816+ }
817+ fd := x.host.SelectionRead(k, mime)
818+ if fd < 0 {
819+ x.notify(req, sel, target, 0, time)
820+ return
821+ }
822+ x.startOut(slot, k, req, prop, target, time, fd)
823+}
824+
825+// startOut begins serving the data on fd (a non-blocking read end it now
826+// owns) to requestor.
827+func (x *XWM) startOut(slot, k int, req, prop, target, time uint32, fd int) {
828+ o := &x.sel.out[slot]
829+ o.state, o.kind, o.requestor, o.property, o.target, o.time = outActive, k, req, prop, target, time
830+ o.fd, o.buf, o.n, o.eof, o.incr, o.credit, o.notified, o.idle, o.src = fd, x.sel.pool.get(), 0, false, false, false, false, 0, nil
831+ x.selEnsureTimer()
832+ x.outRead(slot) // data may already be waiting
833+}
834+
835+// encode32 packs vals into the shared scratch.
836+func (x *XWM) encode32(vals []uint32) []byte {
837+ n := 4 * len(vals)
838+ if cap(x.scratch) < n {
839+ x.scratch = make([]byte, 0, n)
840+ }
841+ p := x.scratch[:n]
842+ for i, v := range vals {
843+ binary.LittleEndian.PutUint32(p[4*i:], v)
844+ }
845+ return p
846+}
847+
848+// outWatch registers the pipe's read end with the display.
849+func (x *XWM) outWatch(i int) {
850+ o := &x.sel.out[i]
851+ if o.src != nil {
852+ return
853+ }
854+ if x.addFD == nil {
855+ x.outAbort(i)
856+ return
857+ }
858+ src, err := x.addFD(o.fd, o.onR, nil)
859+ if err != nil {
860+ x.outAbort(i)
861+ return
862+ }
863+ o.src = src
864+}
865+
866+// outUnwatch drops read interest: the buffer is full and nothing may be read
867+// until the requestor consumed the previous chunk.
868+func (x *XWM) outUnwatch(i int) {
869+ o := &x.sel.out[i]
870+ if o.src != nil {
871+ o.src.Remove()
872+ o.src = nil
873+ }
874+}
875+
876+// outReadable is the pipe's read callback.
877+func (x *XWM) outReadable(i int) {
878+ if x.sel.out[i].state == outFree {
879+ return
880+ }
881+ x.outRead(i)
882+}
883+
884+// outRead fills the buffer from the pipe and advances the transfer.
885+func (x *XWM) outRead(i int) {
886+ o := &x.sel.out[i]
887+ buf := &x.sel.pool.bufs[o.buf]
888+ for o.n < chunk && !o.eof {
889+ n, err := unix.Read(o.fd, buf[o.n:])
890+ switch {
891+ case err == nil && n == 0:
892+ o.eof = true
893+ case err == nil:
894+ o.n += n
895+ o.idle = 0
896+ case errors.Is(err, unix.EINTR):
897+ continue
898+ case errors.Is(err, unix.EAGAIN):
899+ x.outPump(i)
900+ return
901+ default:
902+ x.outAbort(i)
903+ return
904+ }
905+ }
906+ x.outPump(i)
907+}
908+
909+// outPump sends what the state allows.
910+func (x *XWM) outPump(i int) {
911+ o := &x.sel.out[i]
912+ if o.state == outFree {
913+ return
914+ }
915+ full := o.n == chunk
916+ if !o.incr {
917+ switch {
918+ case o.eof: // whole value
919+ x.outSetProp(i, o.n)
920+ x.notify(o.requestor, x.sel.st[o.kind].atom, o.target, o.property, o.time)
921+ x.outFinish(i)
922+ case full: // does not fit one request: INCR
923+ o.incr, o.notified = true, true
924+ var v xproto.ChangeWindowAttributesValues
925+ v.SetEventMask(xproto.EventMaskPropertyChange)
926+ xproto.ChangeWindowAttributes(x.conn.Buf(), xproto.Window(o.requestor), &v)
927+ xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(o.requestor), xproto.Atom(o.property),
928+ xproto.Atom(x.atoms[atomIncr]), 32, 1, x.encode32([]uint32{0}))
929+ x.notify(o.requestor, x.sel.st[o.kind].atom, o.target, o.property, o.time)
930+ x.outUnwatch(i)
931+ default:
932+ x.outWatch(i)
933+ }
934+ return
935+ }
936+ if o.credit && (full || o.eof) {
937+ o.credit = false
938+ o.idle = 0
939+ x.outSetProp(i, o.n)
940+ if o.n == 0 { // the empty chunk ends the transfer
941+ x.outFinish(i)
942+ return
943+ }
944+ o.n = 0
945+ }
946+ if o.n < chunk && !o.eof {
947+ x.outWatch(i)
948+ } else {
949+ x.outUnwatch(i)
950+ }
951+}
952+
953+// outSetProp writes the first n buffered bytes as the property value.
954+func (x *XWM) outSetProp(i, n int) {
955+ o := &x.sel.out[i]
956+ seq := xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(o.requestor), xproto.Atom(o.property),
957+ xproto.Atom(o.target), 8, uint32(n), x.sel.pool.bufs[o.buf][:n])
958+ if seq != 0 {
959+ if err := x.conn.Expect(seq, kindSelOut, uint32(i), o.gen, 0, 0); err != nil {
960+ x.stats.RingFull++
961+ }
962+ }
963+ x.wake()
964+}
965+
966+// outFinish ends a completed transfer.
967+func (x *XWM) outFinish(i int) {
968+ o := &x.sel.out[i]
969+ if o.incr && !x.closed {
970+ var v xproto.ChangeWindowAttributesValues
971+ v.SetEventMask(0)
972+ xproto.ChangeWindowAttributes(x.conn.Buf(), xproto.Window(o.requestor), &v)
973+ x.wake()
974+ }
975+ x.outRelease(i)
976+}
977+
978+// outRelease frees the transfer's resources.
979+func (x *XWM) outRelease(i int) {
980+ o := &x.sel.out[i]
981+ x.outUnwatch(i)
982+ if o.fd >= 0 {
983+ _ = unix.Close(o.fd)
984+ }
985+ if o.buf >= 0 {
986+ x.sel.pool.put(o.buf)
987+ }
988+ o.fd, o.buf = -1, -1
989+ o.gen++
990+ o.state = outFree
991+}
992+
993+// outAbort ends a transfer early: the requestor is refused (before its
994+// notification) or released (INCR: mask dropped, property deleted).
995+func (x *XWM) outAbort(i int) {
996+ o := &x.sel.out[i]
997+ if o.state == outFree {
998+ return
999+ }
1000+ if !x.closed {
1001+ switch {
1002+ case !o.notified:
1003+ x.notify(o.requestor, x.sel.st[o.kind].atom, o.target, 0, o.time)
1004+ case o.incr:
1005+ var v xproto.ChangeWindowAttributesValues
1006+ v.SetEventMask(0)
1007+ xproto.ChangeWindowAttributes(x.conn.Buf(), xproto.Window(o.requestor), &v)
1008+ xproto.DeleteProperty(x.conn.Buf(), xproto.Window(o.requestor), xproto.Atom(o.property))
1009+ x.wake()
1010+ }
1011+ }
1012+ x.outRelease(i)
1013+}
1014+
1015+// onRequestorProperty: PropertyNotify on a window we serve by INCR.
1016+func (x *XWM) onRequestorProperty(e xproto.PropertyNotifyEvent) bool {
1017+ if e.State() != xproto.PropertyDelete {
1018+ return false
1019+ }
1020+ hit := false
1021+ for i := range x.sel.out {
1022+ o := &x.sel.out[i]
1023+ if o.state == outFree || !o.incr || o.requestor != uint32(e.Window()) || o.property != uint32(e.Atom()) {
1024+ continue
1025+ }
1026+ hit = true
1027+ o.credit = true
1028+ o.idle = 0
1029+ x.outPump(i)
1030+ }
1031+ return hit
1032+}
M internal/adapters/wayland/xwm/selection_test.go +261 -0
@@ -422,3 +422,264 @@ func TestSelectionINCRChunkAllocations(t *testing.T) {
422422 require.Zero(t, testing.AllocsPerRun(100, step))
423423 require.Equal(t, inWaitIncr, x.sel.in[0].state)
424424 }
425+
426+// ---- Wayland -> X ----
427+
428+const reqWin = 0x500020
429+
430+func (p *xpeer) selectionRequest(owner, requestor, selection, target, property uint32) {
431+ p.event(30, func(b *[32]byte) {
432+ binary.LittleEndian.PutUint32(b[8:], owner)
433+ binary.LittleEndian.PutUint32(b[12:], requestor)
434+ binary.LittleEndian.PutUint32(b[16:], selection)
435+ binary.LittleEndian.PutUint32(b[20:], target)
436+ binary.LittleEndian.PutUint32(b[24:], property)
437+ })
438+}
439+
440+func (p *xpeer) selectionClear(owner, selection uint32) {
441+ p.event(29, func(b *[32]byte) {
442+ binary.LittleEndian.PutUint32(b[8:], owner)
443+ binary.LittleEndian.PutUint32(b[12:], selection)
444+ })
445+}
446+
447+// sentNotify returns the property of the SelectionNotify SendEvent after skip.
448+func sentNotify(t *testing.T, r *selRig) (property uint32) {
449+ t.Helper()
450+ req, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == opSendEvent && q.raw[12] == 31 })
451+ r.skip = n
452+ require.EqualValues(t, reqWin, req.u32(4))
453+ require.Equal(t, r.clip, binary.LittleEndian.Uint32(req.raw[12+12:]))
454+ return binary.LittleEndian.Uint32(req.raw[12+20:])
455+}
456+
457+// ownedByWayland attaches an XWM and gives CLIPBOARD to a Wayland client offering mimes.
458+func ownedByWayland(t *testing.T, mimes ...string) *selRig {
459+ t.Helper()
460+ x, p, h, _, _ := attached(t)
461+ r := &selRig{x: x, p: p, h: h, clip: p.atom("CLIPBOARD"), utf8: p.atom("UTF8_STRING"), data: p.atom("_NEFERWL_DATA")}
462+ h.EXPECT().After(mock.Anything, mock.Anything).Run(func(_ time.Duration, fn func()) { r.timer = fn }).Return(func() {}).Maybe()
463+ x.WaylandSelection(SelClipboard, mimes)
464+ x.flush()
465+ req, n := pumpReq(t, x, p, 0, func(q xpeerReq) bool { return q.major == opSetSelectionOwner && q.u32(8) == r.clip })
466+ require.Equal(t, x.selWin, req.u32(4))
467+ r.skip = n
468+ return r
469+}
470+
471+// serve queues a SelectionRequest; SelectionRead hands out the read end of a
472+// pipe whose write end is returned.
473+func (r *selRig) serve(t *testing.T, target uint32) (w *os.File) {
474+ t.Helper()
475+ fds := make([]int, 2)
476+ require.NoError(t, unix.Pipe2(fds, unix.O_CLOEXEC|unix.O_NONBLOCK))
477+ r.h.EXPECT().SelectionRead(SelClipboard, mock.Anything).Return(fds[0]).Once()
478+ r.p.selectionRequest(r.x.selWin, reqWin, r.clip, target, r.data)
479+ w = os.NewFile(uintptr(fds[1]), "pipe-w")
480+ t.Cleanup(func() { w.Close() })
481+ return w
482+}
483+
484+func TestSelectionWaylandToXTargets(t *testing.T) {
485+ r := ownedByWayland(t, "text/plain")
486+ r.p.selectionRequest(r.x.selWin, reqWin, r.clip, r.p.atom("TARGETS"), r.data)
487+ require.Equal(t, r.data, sentNotify(t, r))
488+ v, ok := r.p.prop(reqWin, "_NEFERWL_DATA")
489+ require.True(t, ok)
490+ require.Equal(t, []uint32{r.p.atom("TARGETS"), r.p.atom("STRING")}, atomsOf(v), "no UTF8 target for text/plain only")
491+ r.p.selectionRequest(r.x.selWin, reqWin, r.clip, r.p.atom("image/png"), r.data)
492+ require.Zero(t, sentNotify(t, r), "unknown target refused")
493+ // UTF8_STRING is not offered by a text/plain-only source, but STRING reads it.
494+ r.p.selectionRequest(r.x.selWin, reqWin, r.clip, r.utf8, r.data)
495+ require.Zero(t, sentNotify(t, r), "no UTF-8 text offered")
496+}
497+
498+func TestSelectionWaylandToXSmall(t *testing.T) {
499+ r := ownedByWayland(t, "text/plain;charset=utf-8")
500+ w := r.serve(t, r.utf8)
501+ data := pattern(4096)
502+ _, err := w.Write(data)
503+ require.NoError(t, err)
504+ require.NoError(t, w.Close())
505+ require.Equal(t, r.data, sentNotify(t, r))
506+ v, ok := r.p.prop(reqWin, "_NEFERWL_DATA")
507+ require.True(t, ok)
508+ require.Equal(t, r.utf8, v.typ)
509+ require.True(t, bytes.Equal(data, v.data))
510+ require.Equal(t, nXfer, r.x.sel.pool.n)
511+}
512+
513+func TestSelectionWaylandToXINCR(t *testing.T) {
514+ r := ownedByWayland(t, "text/plain;charset=utf-8")
515+ src := newMockfdSource(t)
516+ var onR func()
517+ removes := 0
518+ r.x.addFD = func(_ int, rd, _ func()) (fdSource, error) { onR = rd; return src, nil }
519+ src.EXPECT().Remove().Run(func() { removes++ }).Return().Maybe()
520+ const total = 1<<20 + 123
521+ data := pattern(total)
522+ w := r.serve(t, r.utf8)
523+ require.NoError(t, unix.SetNonblock(int(w.Fd()), false))
524+ go func() { _, _ = w.Write(data); w.Close() }()
525+
526+ // The first fill is a whole chunk: INCR is announced.
527+ var got []byte
528+ deadline := time.Now().Add(5 * time.Second)
529+ for !r.x.sel.out[0].incr {
530+ require.True(t, time.Now().Before(deadline), "INCR never announced")
531+ r.x.onReadable()
532+ if onR != nil && r.x.sel.out[0].state == outActive {
533+ onR()
534+ }
535+ time.Sleep(time.Millisecond)
536+ }
537+ require.Equal(t, r.data, sentNotify(t, r))
538+ v, _ := r.p.prop(reqWin, "_NEFERWL_DATA")
539+ require.Equal(t, r.p.atom("INCR"), v.typ)
540+ mask, _ := pumpReq(t, r.x, r.p, 0, func(q xpeerReq) bool {
541+ return q.major == opChangeWindowAttributes && q.u32(4) == reqWin && q.u32(12) == uint32(xproto.EventMaskPropertyChange)
542+ })
543+ require.NotZero(t, mask.raw)
544+
545+ chunks := 0
546+ for {
547+ // The requestor deletes the property: the next chunk goes out.
548+ r.p.propertyNotify(reqWin, r.data, true)
549+ before := len(r.p.requests())
550+ if onR != nil {
551+ time.Sleep(time.Millisecond)
552+ }
553+ var cp xpeerProp
554+ readUntil(t, r.x, func() bool {
555+ if onR != nil && r.x.sel.out[0].state != outFree && r.x.sel.out[0].n < chunk && !r.x.sel.out[0].eof {
556+ onR()
557+ }
558+ r.x.flush()
559+ for _, q := range r.p.requests()[before:] {
560+ if q.major == opChangeProperty && q.u32(4) == reqWin {
561+ return true
562+ }
563+ }
564+ return false
565+ })
566+ cp, _ = r.p.prop(reqWin, "_NEFERWL_DATA")
567+ chunks++
568+ if len(cp.data) == 0 {
569+ break
570+ }
571+ require.Equal(t, r.utf8, cp.typ)
572+ got = append(got, cp.data...)
573+ }
574+ require.True(t, bytes.Equal(data, got), "byte-exact: %d of %d", len(got), total)
575+ require.Equal(t, total/chunk+2, chunks, "16 full chunks, the tail, then the empty terminator")
576+ readUntil(t, r.x, func() bool { return r.x.sel.out[0].state == outFree })
577+ // The mask is removed at the end.
578+ reqs := r.p.requests()
579+ last := reqs[len(reqs)-1]
580+ for i := len(reqs) - 1; i >= 0; i-- {
581+ if reqs[i].major == opChangeWindowAttributes && reqs[i].u32(4) == reqWin {
582+ last = reqs[i]
583+ break
584+ }
585+ }
586+ require.Zero(t, last.u32(12), "event mask dropped")
587+ require.Equal(t, nXfer, r.x.sel.pool.n)
588+}
589+
590+func TestSelectionWaylandToXTimeoutAndClear(t *testing.T) {
591+ r := ownedByWayland(t, "text/plain;charset=utf-8")
592+ src := newMockfdSource(t)
593+ src.EXPECT().Remove().Return().Maybe()
594+ r.x.addFD = func(_ int, _, _ func()) (fdSource, error) { return src, nil }
595+ w := r.serve(t, r.utf8)
596+ _, err := w.Write(pattern(chunk)) // a full chunk: INCR
597+ require.NoError(t, err)
598+ readUntil(t, r.x, func() bool { return r.x.sel.out[0].incr })
599+ require.Equal(t, r.data, sentNotify(t, r))
600+ require.NotNil(t, r.timer)
601+ for i := 0; i < xIdleMax; i++ {
602+ require.Equal(t, outActive, r.x.sel.out[0].state, i)
603+ r.timer()
604+ }
605+ require.Equal(t, outFree, r.x.sel.out[0].state, "no Deleted for 10 s aborts")
606+ pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == opDeleteProperty && q.u32(4) == reqWin })
607+
608+ // SelectionClear aborts a running transfer and refuses further requests.
609+ w2 := r.serve(t, r.utf8)
610+ _, err = w2.Write(pattern(chunk))
611+ require.NoError(t, err)
612+ readUntil(t, r.x, func() bool { return r.x.sel.out[0].state == outActive })
613+ r.p.selectionClear(r.x.selWin, r.clip)
614+ readUntil(t, r.x, func() bool { return r.x.sel.out[0].state == outFree })
615+ r.p.selectionRequest(r.x.selWin, reqWin, r.clip, r.utf8, r.data)
616+ require.Equal(t, r.data, sentNotify(t, r), "INCR announcement of the aborted transfer")
617+ require.Zero(t, sentNotify(t, r), "request after the clear is refused")
618+}
619+
620+// Replacing the Wayland selection mid-transfer aborts it; nil releases X ownership.
621+func TestSelectionClearAndRace(t *testing.T) {
622+ r := ownedByWayland(t, "text/plain;charset=utf-8")
623+ src := newMockfdSource(t)
624+ src.EXPECT().Remove().Return().Maybe()
625+ r.x.addFD = func(_ int, _, _ func()) (fdSource, error) { return src, nil }
626+ w := r.serve(t, r.utf8)
627+ _, err := w.Write(pattern(chunk))
628+ require.NoError(t, err)
629+ readUntil(t, r.x, func() bool { return r.x.sel.out[0].incr })
630+ r.x.WaylandSelection(SelClipboard, []string{"text/plain"})
631+ require.Equal(t, outFree, r.x.sel.out[0].state)
632+ r.x.WaylandSelection(SelClipboard, nil) // protected or cleared
633+ r.x.flush()
634+ req, _ := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == opSetSelectionOwner && q.u32(4) == 0 })
635+ require.Equal(t, r.clip, req.u32(8), "ownership released")
636+ require.False(t, r.x.sel.st[SelClipboard].ours)
637+}
638+
639+func TestSelectionCopyAllocationsWaylandToX(t *testing.T) {
640+ r := ownedByWayland(t, "text/plain;charset=utf-8")
641+ x := r.x
642+ sinkPeer(t, x, r.p)
643+ x.after = func(time.Duration, func()) func() { return func() {} }
644+ x.sel.timerOn = true
645+ data := pattern(4096)
646+ var ev xturbo.Event
647+ ev.Kind = xproto.KindSelectionRequest
648+ binary.LittleEndian.PutUint32(ev.Raw[8:], x.selWin)
649+ binary.LittleEndian.PutUint32(ev.Raw[12:], reqWin)
650+ binary.LittleEndian.PutUint32(ev.Raw[16:], r.clip)
651+ binary.LittleEndian.PutUint32(ev.Raw[20:], r.utf8)
652+ binary.LittleEndian.PutUint32(ev.Raw[24:], r.data)
653+ fds := make([]int, 2)
654+ round := func() {
655+ if unix.Pipe2(fds, unix.O_CLOEXEC|unix.O_NONBLOCK) != nil {
656+ t.Fatal("pipe")
657+ }
658+ if _, err := unix.Write(fds[1], data); err != nil {
659+ t.Fatal("write")
660+ }
661+ unix.Close(fds[1])
662+ x.host.(*mockhost).ExpectedCalls = nil
663+ x.onSelectionRequest(&ev)
664+ x.posted = false
665+ x.flush()
666+ }
667+ _ = round
668+ // The host mock's matching is not allocation free, so the host call is replaced.
669+ x.sel.st[SelClipboard].wlMime = [len(textMimes)]string{"text/plain;charset=utf-8"}
670+ allocs := testing.AllocsPerRun(100, func() {
671+ if unix.Pipe2(fds, unix.O_CLOEXEC|unix.O_NONBLOCK) != nil {
672+ t.Fatal("pipe")
673+ }
674+ if _, err := unix.Write(fds[1], data); err != nil {
675+ t.Fatal("write")
676+ }
677+ unix.Close(fds[1])
678+ x.startOut(0, SelClipboard, reqWin, r.data, r.utf8, 0, fds[0])
679+ x.posted = false
680+ x.flush()
681+ })
682+ // Pipe2 itself does not allocate; the measured path is read + ChangeProperty + SendEvent.
683+ require.Zero(t, allocs)
684+ require.Equal(t, outFree, x.sel.out[0].state)
685+}
@@ -422,3 +422,264 @@ func TestSelectionINCRChunkAllocations(t *testing.T) {
422 require.Zero(t, testing.AllocsPerRun(100, step))422 require.Zero(t, testing.AllocsPerRun(100, step))
423 require.Equal(t, inWaitIncr, x.sel.in[0].state)423 require.Equal(t, inWaitIncr, x.sel.in[0].state)
424 }424 }
425+
426+// ---- Wayland -> X ----
427+
428+const reqWin = 0x500020
429+
430+func (p *xpeer) selectionRequest(owner, requestor, selection, target, property uint32) {
431+ p.event(30, func(b *[32]byte) {
432+ binary.LittleEndian.PutUint32(b[8:], owner)
433+ binary.LittleEndian.PutUint32(b[12:], requestor)
434+ binary.LittleEndian.PutUint32(b[16:], selection)
435+ binary.LittleEndian.PutUint32(b[20:], target)
436+ binary.LittleEndian.PutUint32(b[24:], property)
437+ })
438+}
439+
440+func (p *xpeer) selectionClear(owner, selection uint32) {
441+ p.event(29, func(b *[32]byte) {
442+ binary.LittleEndian.PutUint32(b[8:], owner)
443+ binary.LittleEndian.PutUint32(b[12:], selection)
444+ })
445+}
446+
447+// sentNotify returns the property of the SelectionNotify SendEvent after skip.
448+func sentNotify(t *testing.T, r *selRig) (property uint32) {
449+ t.Helper()
450+ req, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == opSendEvent && q.raw[12] == 31 })
451+ r.skip = n
452+ require.EqualValues(t, reqWin, req.u32(4))
453+ require.Equal(t, r.clip, binary.LittleEndian.Uint32(req.raw[12+12:]))
454+ return binary.LittleEndian.Uint32(req.raw[12+20:])
455+}
456+
457+// ownedByWayland attaches an XWM and gives CLIPBOARD to a Wayland client offering mimes.
458+func ownedByWayland(t *testing.T, mimes ...string) *selRig {
459+ t.Helper()
460+ x, p, h, _, _ := attached(t)
461+ r := &selRig{x: x, p: p, h: h, clip: p.atom("CLIPBOARD"), utf8: p.atom("UTF8_STRING"), data: p.atom("_NEFERWL_DATA")}
462+ h.EXPECT().After(mock.Anything, mock.Anything).Run(func(_ time.Duration, fn func()) { r.timer = fn }).Return(func() {}).Maybe()
463+ x.WaylandSelection(SelClipboard, mimes)
464+ x.flush()
465+ req, n := pumpReq(t, x, p, 0, func(q xpeerReq) bool { return q.major == opSetSelectionOwner && q.u32(8) == r.clip })
466+ require.Equal(t, x.selWin, req.u32(4))
467+ r.skip = n
468+ return r
469+}
470+
471+// serve queues a SelectionRequest; SelectionRead hands out the read end of a
472+// pipe whose write end is returned.
473+func (r *selRig) serve(t *testing.T, target uint32) (w *os.File) {
474+ t.Helper()
475+ fds := make([]int, 2)
476+ require.NoError(t, unix.Pipe2(fds, unix.O_CLOEXEC|unix.O_NONBLOCK))
477+ r.h.EXPECT().SelectionRead(SelClipboard, mock.Anything).Return(fds[0]).Once()
478+ r.p.selectionRequest(r.x.selWin, reqWin, r.clip, target, r.data)
479+ w = os.NewFile(uintptr(fds[1]), "pipe-w")
480+ t.Cleanup(func() { w.Close() })
481+ return w
482+}
483+
484+func TestSelectionWaylandToXTargets(t *testing.T) {
485+ r := ownedByWayland(t, "text/plain")
486+ r.p.selectionRequest(r.x.selWin, reqWin, r.clip, r.p.atom("TARGETS"), r.data)
487+ require.Equal(t, r.data, sentNotify(t, r))
488+ v, ok := r.p.prop(reqWin, "_NEFERWL_DATA")
489+ require.True(t, ok)
490+ require.Equal(t, []uint32{r.p.atom("TARGETS"), r.p.atom("STRING")}, atomsOf(v), "no UTF8 target for text/plain only")
491+ r.p.selectionRequest(r.x.selWin, reqWin, r.clip, r.p.atom("image/png"), r.data)
492+ require.Zero(t, sentNotify(t, r), "unknown target refused")
493+ // UTF8_STRING is not offered by a text/plain-only source, but STRING reads it.
494+ r.p.selectionRequest(r.x.selWin, reqWin, r.clip, r.utf8, r.data)
495+ require.Zero(t, sentNotify(t, r), "no UTF-8 text offered")
496+}
497+
498+func TestSelectionWaylandToXSmall(t *testing.T) {
499+ r := ownedByWayland(t, "text/plain;charset=utf-8")
500+ w := r.serve(t, r.utf8)
501+ data := pattern(4096)
502+ _, err := w.Write(data)
503+ require.NoError(t, err)
504+ require.NoError(t, w.Close())
505+ require.Equal(t, r.data, sentNotify(t, r))
506+ v, ok := r.p.prop(reqWin, "_NEFERWL_DATA")
507+ require.True(t, ok)
508+ require.Equal(t, r.utf8, v.typ)
509+ require.True(t, bytes.Equal(data, v.data))
510+ require.Equal(t, nXfer, r.x.sel.pool.n)
511+}
512+
513+func TestSelectionWaylandToXINCR(t *testing.T) {
514+ r := ownedByWayland(t, "text/plain;charset=utf-8")
515+ src := newMockfdSource(t)
516+ var onR func()
517+ removes := 0
518+ r.x.addFD = func(_ int, rd, _ func()) (fdSource, error) { onR = rd; return src, nil }
519+ src.EXPECT().Remove().Run(func() { removes++ }).Return().Maybe()
520+ const total = 1<<20 + 123
521+ data := pattern(total)
522+ w := r.serve(t, r.utf8)
523+ require.NoError(t, unix.SetNonblock(int(w.Fd()), false))
524+ go func() { _, _ = w.Write(data); w.Close() }()
525+
526+ // The first fill is a whole chunk: INCR is announced.
527+ var got []byte
528+ deadline := time.Now().Add(5 * time.Second)
529+ for !r.x.sel.out[0].incr {
530+ require.True(t, time.Now().Before(deadline), "INCR never announced")
531+ r.x.onReadable()
532+ if onR != nil && r.x.sel.out[0].state == outActive {
533+ onR()
534+ }
535+ time.Sleep(time.Millisecond)
536+ }
537+ require.Equal(t, r.data, sentNotify(t, r))
538+ v, _ := r.p.prop(reqWin, "_NEFERWL_DATA")
539+ require.Equal(t, r.p.atom("INCR"), v.typ)
540+ mask, _ := pumpReq(t, r.x, r.p, 0, func(q xpeerReq) bool {
541+ return q.major == opChangeWindowAttributes && q.u32(4) == reqWin && q.u32(12) == uint32(xproto.EventMaskPropertyChange)
542+ })
543+ require.NotZero(t, mask.raw)
544+
545+ chunks := 0
546+ for {
547+ // The requestor deletes the property: the next chunk goes out.
548+ r.p.propertyNotify(reqWin, r.data, true)
549+ before := len(r.p.requests())
550+ if onR != nil {
551+ time.Sleep(time.Millisecond)
552+ }
553+ var cp xpeerProp
554+ readUntil(t, r.x, func() bool {
555+ if onR != nil && r.x.sel.out[0].state != outFree && r.x.sel.out[0].n < chunk && !r.x.sel.out[0].eof {
556+ onR()
557+ }
558+ r.x.flush()
559+ for _, q := range r.p.requests()[before:] {
560+ if q.major == opChangeProperty && q.u32(4) == reqWin {
561+ return true
562+ }
563+ }
564+ return false
565+ })
566+ cp, _ = r.p.prop(reqWin, "_NEFERWL_DATA")
567+ chunks++
568+ if len(cp.data) == 0 {
569+ break
570+ }
571+ require.Equal(t, r.utf8, cp.typ)
572+ got = append(got, cp.data...)
573+ }
574+ require.True(t, bytes.Equal(data, got), "byte-exact: %d of %d", len(got), total)
575+ require.Equal(t, total/chunk+2, chunks, "16 full chunks, the tail, then the empty terminator")
576+ readUntil(t, r.x, func() bool { return r.x.sel.out[0].state == outFree })
577+ // The mask is removed at the end.
578+ reqs := r.p.requests()
579+ last := reqs[len(reqs)-1]
580+ for i := len(reqs) - 1; i >= 0; i-- {
581+ if reqs[i].major == opChangeWindowAttributes && reqs[i].u32(4) == reqWin {
582+ last = reqs[i]
583+ break
584+ }
585+ }
586+ require.Zero(t, last.u32(12), "event mask dropped")
587+ require.Equal(t, nXfer, r.x.sel.pool.n)
588+}
589+
590+func TestSelectionWaylandToXTimeoutAndClear(t *testing.T) {
591+ r := ownedByWayland(t, "text/plain;charset=utf-8")
592+ src := newMockfdSource(t)
593+ src.EXPECT().Remove().Return().Maybe()
594+ r.x.addFD = func(_ int, _, _ func()) (fdSource, error) { return src, nil }
595+ w := r.serve(t, r.utf8)
596+ _, err := w.Write(pattern(chunk)) // a full chunk: INCR
597+ require.NoError(t, err)
598+ readUntil(t, r.x, func() bool { return r.x.sel.out[0].incr })
599+ require.Equal(t, r.data, sentNotify(t, r))
600+ require.NotNil(t, r.timer)
601+ for i := 0; i < xIdleMax; i++ {
602+ require.Equal(t, outActive, r.x.sel.out[0].state, i)
603+ r.timer()
604+ }
605+ require.Equal(t, outFree, r.x.sel.out[0].state, "no Deleted for 10 s aborts")
606+ pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == opDeleteProperty && q.u32(4) == reqWin })
607+
608+ // SelectionClear aborts a running transfer and refuses further requests.
609+ w2 := r.serve(t, r.utf8)
610+ _, err = w2.Write(pattern(chunk))
611+ require.NoError(t, err)
612+ readUntil(t, r.x, func() bool { return r.x.sel.out[0].state == outActive })
613+ r.p.selectionClear(r.x.selWin, r.clip)
614+ readUntil(t, r.x, func() bool { return r.x.sel.out[0].state == outFree })
615+ r.p.selectionRequest(r.x.selWin, reqWin, r.clip, r.utf8, r.data)
616+ require.Equal(t, r.data, sentNotify(t, r), "INCR announcement of the aborted transfer")
617+ require.Zero(t, sentNotify(t, r), "request after the clear is refused")
618+}
619+
620+// Replacing the Wayland selection mid-transfer aborts it; nil releases X ownership.
621+func TestSelectionClearAndRace(t *testing.T) {
622+ r := ownedByWayland(t, "text/plain;charset=utf-8")
623+ src := newMockfdSource(t)
624+ src.EXPECT().Remove().Return().Maybe()
625+ r.x.addFD = func(_ int, _, _ func()) (fdSource, error) { return src, nil }
626+ w := r.serve(t, r.utf8)
627+ _, err := w.Write(pattern(chunk))
628+ require.NoError(t, err)
629+ readUntil(t, r.x, func() bool { return r.x.sel.out[0].incr })
630+ r.x.WaylandSelection(SelClipboard, []string{"text/plain"})
631+ require.Equal(t, outFree, r.x.sel.out[0].state)
632+ r.x.WaylandSelection(SelClipboard, nil) // protected or cleared
633+ r.x.flush()
634+ req, _ := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == opSetSelectionOwner && q.u32(4) == 0 })
635+ require.Equal(t, r.clip, req.u32(8), "ownership released")
636+ require.False(t, r.x.sel.st[SelClipboard].ours)
637+}
638+
639+func TestSelectionCopyAllocationsWaylandToX(t *testing.T) {
640+ r := ownedByWayland(t, "text/plain;charset=utf-8")
641+ x := r.x
642+ sinkPeer(t, x, r.p)
643+ x.after = func(time.Duration, func()) func() { return func() {} }
644+ x.sel.timerOn = true
645+ data := pattern(4096)
646+ var ev xturbo.Event
647+ ev.Kind = xproto.KindSelectionRequest
648+ binary.LittleEndian.PutUint32(ev.Raw[8:], x.selWin)
649+ binary.LittleEndian.PutUint32(ev.Raw[12:], reqWin)
650+ binary.LittleEndian.PutUint32(ev.Raw[16:], r.clip)
651+ binary.LittleEndian.PutUint32(ev.Raw[20:], r.utf8)
652+ binary.LittleEndian.PutUint32(ev.Raw[24:], r.data)
653+ fds := make([]int, 2)
654+ round := func() {
655+ if unix.Pipe2(fds, unix.O_CLOEXEC|unix.O_NONBLOCK) != nil {
656+ t.Fatal("pipe")
657+ }
658+ if _, err := unix.Write(fds[1], data); err != nil {
659+ t.Fatal("write")
660+ }
661+ unix.Close(fds[1])
662+ x.host.(*mockhost).ExpectedCalls = nil
663+ x.onSelectionRequest(&ev)
664+ x.posted = false
665+ x.flush()
666+ }
667+ _ = round
668+ // The host mock's matching is not allocation free, so the host call is replaced.
669+ x.sel.st[SelClipboard].wlMime = [len(textMimes)]string{"text/plain;charset=utf-8"}
670+ allocs := testing.AllocsPerRun(100, func() {
671+ if unix.Pipe2(fds, unix.O_CLOEXEC|unix.O_NONBLOCK) != nil {
672+ t.Fatal("pipe")
673+ }
674+ if _, err := unix.Write(fds[1], data); err != nil {
675+ t.Fatal("write")
676+ }
677+ unix.Close(fds[1])
678+ x.startOut(0, SelClipboard, reqWin, r.data, r.utf8, 0, fds[0])
679+ x.posted = false
680+ x.flush()
681+ })
682+ // Pipe2 itself does not allocate; the measured path is read + ChangeProperty + SendEvent.
683+ require.Zero(t, allocs)
684+ require.Equal(t, outFree, x.sel.out[0].state)
685+}
A internal/adapters/wayland/xwm_selection_mock_test.go +143 -0
@@ -0,0 +1,143 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package wayland
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockxwmSelection creates a new instance of mockxwmSelection. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockxwmSelection(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockxwmSelection {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockxwmSelection{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// mockxwmSelection is an autogenerated mock type for the xwmSelection type
35+type mockxwmSelection struct {
36+ mock.Mock
37+}
38+
39+type mockxwmSelection_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockxwmSelection) EXPECT() *mockxwmSelection_Expecter {
44+ return &mockxwmSelection_Expecter{mock: &_m.Mock}
45+}
46+
47+// SelectionReceive provides a mock function for the type mockxwmSelection
48+func (_mock *mockxwmSelection) SelectionReceive(kind int, mime string, fd int) {
49+ _mock.Called(kind, mime, fd)
50+ return
51+}
52+
53+// mockxwmSelection_SelectionReceive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectionReceive'
54+type mockxwmSelection_SelectionReceive_Call struct {
55+ *mock.Call
56+}
57+
58+// SelectionReceive is a helper method to define mock.On call
59+// - kind int
60+// - mime string
61+// - fd int
62+func (_e *mockxwmSelection_Expecter) SelectionReceive(kind any, mime any, fd any) *mockxwmSelection_SelectionReceive_Call {
63+ return &mockxwmSelection_SelectionReceive_Call{Call: _e.mock.On("SelectionReceive", kind, mime, fd)}
64+}
65+
66+func (_c *mockxwmSelection_SelectionReceive_Call) Run(run func(kind int, mime string, fd int)) *mockxwmSelection_SelectionReceive_Call {
67+ _c.Call.Run(func(args mock.Arguments) {
68+ var arg0 int
69+ if args[0] != nil {
70+ arg0 = args[0].(int)
71+ }
72+ var arg1 string
73+ if args[1] != nil {
74+ arg1 = args[1].(string)
75+ }
76+ var arg2 int
77+ if args[2] != nil {
78+ arg2 = args[2].(int)
79+ }
80+ run(
81+ arg0,
82+ arg1,
83+ arg2,
84+ )
85+ })
86+ return _c
87+}
88+
89+func (_c *mockxwmSelection_SelectionReceive_Call) Return() *mockxwmSelection_SelectionReceive_Call {
90+ _c.Call.Return()
91+ return _c
92+}
93+
94+func (_c *mockxwmSelection_SelectionReceive_Call) RunAndReturn(run func(kind int, mime string, fd int)) *mockxwmSelection_SelectionReceive_Call {
95+ _c.Run(run)
96+ return _c
97+}
98+
99+// WaylandSelection provides a mock function for the type mockxwmSelection
100+func (_mock *mockxwmSelection) WaylandSelection(kind int, mimes []string) {
101+ _mock.Called(kind, mimes)
102+ return
103+}
104+
105+// mockxwmSelection_WaylandSelection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WaylandSelection'
106+type mockxwmSelection_WaylandSelection_Call struct {
107+ *mock.Call
108+}
109+
110+// WaylandSelection is a helper method to define mock.On call
111+// - kind int
112+// - mimes []string
113+func (_e *mockxwmSelection_Expecter) WaylandSelection(kind any, mimes any) *mockxwmSelection_WaylandSelection_Call {
114+ return &mockxwmSelection_WaylandSelection_Call{Call: _e.mock.On("WaylandSelection", kind, mimes)}
115+}
116+
117+func (_c *mockxwmSelection_WaylandSelection_Call) Run(run func(kind int, mimes []string)) *mockxwmSelection_WaylandSelection_Call {
118+ _c.Call.Run(func(args mock.Arguments) {
119+ var arg0 int
120+ if args[0] != nil {
121+ arg0 = args[0].(int)
122+ }
123+ var arg1 []string
124+ if args[1] != nil {
125+ arg1 = args[1].([]string)
126+ }
127+ run(
128+ arg0,
129+ arg1,
130+ )
131+ })
132+ return _c
133+}
134+
135+func (_c *mockxwmSelection_WaylandSelection_Call) Return() *mockxwmSelection_WaylandSelection_Call {
136+ _c.Call.Return()
137+ return _c
138+}
139+
140+func (_c *mockxwmSelection_WaylandSelection_Call) RunAndReturn(run func(kind int, mimes []string)) *mockxwmSelection_WaylandSelection_Call {
141+ _c.Run(run)
142+ return _c
143+}
@@ -0,0 +1,143 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package wayland
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockxwmSelection creates a new instance of mockxwmSelection. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockxwmSelection(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockxwmSelection {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockxwmSelection{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// mockxwmSelection is an autogenerated mock type for the xwmSelection type
35+type mockxwmSelection struct {
36+ mock.Mock
37+}
38+
39+type mockxwmSelection_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockxwmSelection) EXPECT() *mockxwmSelection_Expecter {
44+ return &mockxwmSelection_Expecter{mock: &_m.Mock}
45+}
46+
47+// SelectionReceive provides a mock function for the type mockxwmSelection
48+func (_mock *mockxwmSelection) SelectionReceive(kind int, mime string, fd int) {
49+ _mock.Called(kind, mime, fd)
50+ return
51+}
52+
53+// mockxwmSelection_SelectionReceive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectionReceive'
54+type mockxwmSelection_SelectionReceive_Call struct {
55+ *mock.Call
56+}
57+
58+// SelectionReceive is a helper method to define mock.On call
59+// - kind int
60+// - mime string
61+// - fd int
62+func (_e *mockxwmSelection_Expecter) SelectionReceive(kind any, mime any, fd any) *mockxwmSelection_SelectionReceive_Call {
63+ return &mockxwmSelection_SelectionReceive_Call{Call: _e.mock.On("SelectionReceive", kind, mime, fd)}
64+}
65+
66+func (_c *mockxwmSelection_SelectionReceive_Call) Run(run func(kind int, mime string, fd int)) *mockxwmSelection_SelectionReceive_Call {
67+ _c.Call.Run(func(args mock.Arguments) {
68+ var arg0 int
69+ if args[0] != nil {
70+ arg0 = args[0].(int)
71+ }
72+ var arg1 string
73+ if args[1] != nil {
74+ arg1 = args[1].(string)
75+ }
76+ var arg2 int
77+ if args[2] != nil {
78+ arg2 = args[2].(int)
79+ }
80+ run(
81+ arg0,
82+ arg1,
83+ arg2,
84+ )
85+ })
86+ return _c
87+}
88+
89+func (_c *mockxwmSelection_SelectionReceive_Call) Return() *mockxwmSelection_SelectionReceive_Call {
90+ _c.Call.Return()
91+ return _c
92+}
93+
94+func (_c *mockxwmSelection_SelectionReceive_Call) RunAndReturn(run func(kind int, mime string, fd int)) *mockxwmSelection_SelectionReceive_Call {
95+ _c.Run(run)
96+ return _c
97+}
98+
99+// WaylandSelection provides a mock function for the type mockxwmSelection
100+func (_mock *mockxwmSelection) WaylandSelection(kind int, mimes []string) {
101+ _mock.Called(kind, mimes)
102+ return
103+}
104+
105+// mockxwmSelection_WaylandSelection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WaylandSelection'
106+type mockxwmSelection_WaylandSelection_Call struct {
107+ *mock.Call
108+}
109+
110+// WaylandSelection is a helper method to define mock.On call
111+// - kind int
112+// - mimes []string
113+func (_e *mockxwmSelection_Expecter) WaylandSelection(kind any, mimes any) *mockxwmSelection_WaylandSelection_Call {
114+ return &mockxwmSelection_WaylandSelection_Call{Call: _e.mock.On("WaylandSelection", kind, mimes)}
115+}
116+
117+func (_c *mockxwmSelection_WaylandSelection_Call) Run(run func(kind int, mimes []string)) *mockxwmSelection_WaylandSelection_Call {
118+ _c.Call.Run(func(args mock.Arguments) {
119+ var arg0 int
120+ if args[0] != nil {
121+ arg0 = args[0].(int)
122+ }
123+ var arg1 []string
124+ if args[1] != nil {
125+ arg1 = args[1].([]string)
126+ }
127+ run(
128+ arg0,
129+ arg1,
130+ )
131+ })
132+ return _c
133+}
134+
135+func (_c *mockxwmSelection_WaylandSelection_Call) Return() *mockxwmSelection_WaylandSelection_Call {
136+ _c.Call.Return()
137+ return _c
138+}
139+
140+func (_c *mockxwmSelection_WaylandSelection_Call) RunAndReturn(run func(kind int, mimes []string)) *mockxwmSelection_WaylandSelection_Call {
141+ _c.Run(run)
142+ return _c
143+}

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close