detached def433b

test(app): e2e clipboard bridge, XSETTINGS and root cursor on the built-in XWM

commit
def433b8958eba9fe4e029d5d3f9449a7560f510
author
brice · · 2026-10-22 16:36 UTC
committer
brice · · 2026-10-22 17:55 UTC
parent
3e19081
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +560 -1
M internal/app/testdata/x11probe/main.go +358 -0
@@ -15,9 +15,19 @@
1515 // leave it that long after (prints "fullscreen off WxH"). -keyboard-grab
1616 // calls XGrabKeyboard on the window once it is mapped (prints "grab").
1717 // -at X,Y creates the window at that root position.
18+//
19+// Four more modes run without a window and exit when done.
20+// -copy TEXT|@file owns -copy-sel (CLIPBOARD or PRIMARY) for
21+// -copy-for, serving TARGETS and UTF8_STRING/STRING/TEXT, with INCR above
22+// 64 KiB, and prints "copy owned". -paste SEL requests UTF8_STRING (INCR
23+// aware) and prints `paste "<text>"`, `paste len=N sha256=H` above 4 KiB or
24+// `paste none`. -xsettings prints the XSETTINGS owner's serial, Xft/DPI,
25+// Gtk/CursorThemeName and Gtk/CursorThemeSize, or "xsettings none".
26+// -root-cursor prints "cursor WxH" of the XFixes cursor image.
1827 package main
1928
2029 import (
30+ "crypto/sha256"
2131 "encoding/binary"
2232 "flag"
2333 "fmt"
@@ -30,6 +40,7 @@ import (
3040
3141 "bnema.dev/xturbo"
3242 "bnema.dev/xturbo/shape"
43+ "bnema.dev/xturbo/xfixes"
3344 "bnema.dev/xturbo/xproto"
3445 )
3546
@@ -62,7 +73,16 @@ func run() error {
6273 fullscreenOff := flag.Duration("fullscreen-off", 0, "leave fullscreen this long after the request (0: never)")
6374 keyboardGrab := flag.Bool("keyboard-grab", false, "XGrabKeyboard on the window once it is mapped")
6475 at := flag.String("at", "0,0", "window position X,Y in root coordinates")
76+ copyText := flag.String("copy", "", "own a selection with TEXT (or @file) and serve it, no window")
77+ copySel := flag.String("copy-sel", "CLIPBOARD", "selection for -copy: CLIPBOARD or PRIMARY")
78+ copyFor := flag.Duration("copy-for", 5*time.Second, "how long -copy serves the selection")
79+ paste := flag.String("paste", "", "print the text of selection CLIPBOARD or PRIMARY, no window")
80+ xsettings := flag.Bool("xsettings", false, "print the XSETTINGS values, no window")
81+ rootCursor := flag.Bool("root-cursor", false, "print the size of the cursor shown over the root, no window")
6582 flag.Parse()
83+ if *copyText != "" || *paste != "" || *xsettings || *rootCursor {
84+ return runTool(*copyText, *copySel, *copyFor, *paste, *xsettings, *rootCursor)
85+ }
6686 atX, atY, okAt := strings.Cut(*at, ",")
6787 if !okAt {
6888 return fmt.Errorf("bad -at %q", *at)
@@ -316,3 +336,341 @@ func atoi(s string) int {
316336 n, _ := strconv.Atoi(s)
317337 return n
318338 }
339+
340+// tool is the connection of the windowless modes. Events read while waiting
341+// for a reply are queued, so the code reads linearly.
342+type tool struct {
343+ c *xturbo.Conn
344+ b *xturbo.Buf
345+ win xproto.Window
346+ q []xturbo.Event
347+ reply []byte
348+ h xturbo.Handler
349+ atoms map[string]xproto.Atom
350+}
351+
352+const chunk = 65536
353+
354+func newTool() (*tool, error) {
355+ c, err := xturbo.Dial("")
356+ if err != nil {
357+ return nil, err
358+ }
359+ t := &tool{c: c, b: c.Buf(), atoms: map[string]xproto.Atom{}}
360+ t.h = xturbo.Handler{
361+ OnEvent: func(ev *xturbo.Event) { t.q = append(t.q, *ev) },
362+ OnReply: func(r *xturbo.Reply) { t.reply = append(t.reply[:0], r.Raw...) },
363+ OnError: func(e *xturbo.XError) { fmt.Fprintln(os.Stderr, "x11probe:", e) },
364+ }
365+ names := []string{"CLIPBOARD", "PRIMARY", "TARGETS", "UTF8_STRING", "STRING", "TEXT", "INCR", "_PROBE_SEL",
366+ "_XSETTINGS_S0", "_XSETTINGS_SETTINGS"}
367+ ids := make([]uint32, len(names))
368+ if err := c.InternAtoms(names, false, ids); err != nil {
369+ return nil, err
370+ }
371+ for i, n := range names {
372+ t.atoms[n] = xproto.Atom(ids[i])
373+ }
374+ xid, err := c.NewXID()
375+ if err != nil {
376+ return nil, err
377+ }
378+ t.win = xproto.Window(xid)
379+ var v xproto.CreateWindowValues
380+ v.SetEventMask(xproto.EventMaskPropertyChange)
381+ xproto.CreateWindow(t.b, 0, t.win, xproto.Window(c.Root()), -10, -10, 1, 1, 0, xproto.WindowClassInputOnly, 0, &v)
382+ return t, t.c.Sync(&t.h)
383+}
384+
385+// rpc sends the queued requests and returns the reply of seq.
386+func (t *tool) rpc(seq xturbo.Seq) ([]byte, error) {
387+ t.reply = t.reply[:0]
388+ if err := t.c.Expect(seq, 16, 0, 0, 0, 0); err != nil {
389+ return nil, err
390+ }
391+ if err := t.c.Sync(&t.h); err != nil {
392+ return nil, err
393+ }
394+ return t.reply, nil
395+}
396+
397+// next returns the next event, or false at the deadline.
398+func (t *tool) next(deadline time.Time) (xturbo.Event, bool, error) {
399+ for len(t.q) == 0 {
400+ events := int16(unix.POLLIN)
401+ if err := t.c.Flush(); err == xturbo.ErrWouldBlock {
402+ events |= unix.POLLOUT
403+ } else if err != nil {
404+ return xturbo.Event{}, false, err
405+ }
406+ ms := int(time.Until(deadline).Milliseconds()) + 1
407+ if ms <= 0 {
408+ return xturbo.Event{}, false, nil
409+ }
410+ fds := []unix.PollFd{{Fd: int32(t.c.Fd()), Events: events}}
411+ if _, err := unix.Poll(fds, ms); err != nil && err != unix.EINTR {
412+ return xturbo.Event{}, false, err
413+ }
414+ if err := t.c.Dispatch(&t.h); err != nil {
415+ return xturbo.Event{}, false, err
416+ }
417+ }
418+ ev := t.q[0]
419+ t.q = t.q[1:]
420+ return ev, true, nil
421+}
422+
423+func (t *tool) selAtom(name string) (xproto.Atom, error) {
424+ switch name {
425+ case "CLIPBOARD", "PRIMARY":
426+ return t.atoms[name], nil
427+ }
428+ return 0, fmt.Errorf("bad selection %q", name)
429+}
430+
431+func runTool(copyText, copySel string, copyFor time.Duration, paste string, xsettings, rootCursor bool) error {
432+ t, err := newTool()
433+ if err != nil {
434+ return err
435+ }
436+ defer t.c.Close()
437+ switch {
438+ case copyText != "":
439+ return t.copyMode(copyText, copySel, copyFor)
440+ case paste != "":
441+ return t.pasteMode(paste)
442+ case xsettings:
443+ return t.xsettingsMode()
444+ }
445+ return t.cursorMode()
446+}
447+
448+func (t *tool) copyMode(text, selName string, dur time.Duration) error {
449+ data := []byte(text)
450+ if f, ok := strings.CutPrefix(text, "@"); ok {
451+ var err error
452+ if data, err = os.ReadFile(f); err != nil {
453+ return err
454+ }
455+ }
456+ sel, err := t.selAtom(selName)
457+ if err != nil {
458+ return err
459+ }
460+ xproto.SetSelectionOwner(t.b, t.win, sel, 0)
461+ raw, err := t.rpc(xproto.GetSelectionOwner(t.b, sel))
462+ if err != nil {
463+ return err
464+ }
465+ if r, ok := xproto.ParseGetSelectionOwnerReply(raw); !ok || r.Owner() != t.win {
466+ return fmt.Errorf("selection %s not owned", selName)
467+ }
468+ fmt.Println("copy owned")
469+ var (
470+ incrReq xproto.Window // requestor of the running INCR transfer
471+ incrProp, incrTgt xproto.Atom
472+ incrOff int
473+ incrLive bool
474+ notify = func(req xproto.Window, selection, target, prop xproto.Atom, time xproto.Timestamp) {
475+ var ev [32]byte
476+ d := xproto.SelectionNotifyEventData{Time: time, Requestor: req, Selection: selection, Target: target, Property: prop}
477+ d.Put(&ev)
478+ xproto.SendEvent(t.b, false, req, 0, &ev)
479+ }
480+ )
481+ deadline := time.Now().Add(dur)
482+ for {
483+ ev, ok, err := t.next(deadline)
484+ if err != nil || !ok {
485+ return err
486+ }
487+ switch ev.Kind {
488+ case xproto.KindSelectionClear:
489+ fmt.Println("copy lost")
490+ return nil
491+ case xproto.KindSelectionRequest:
492+ e := xproto.SelectionRequestOf(&ev)
493+ req, prop, tgt := e.Requestor(), e.Property(), e.Target()
494+ if prop == 0 {
495+ prop = tgt
496+ }
497+ switch tgt {
498+ case t.atoms["TARGETS"]:
499+ var l [16]byte
500+ for i, n := range []string{"TARGETS", "UTF8_STRING", "STRING", "TEXT"} {
501+ binary.LittleEndian.PutUint32(l[i*4:], uint32(t.atoms[n]))
502+ }
503+ xproto.ChangeProperty(t.b, xproto.PropModeReplace, req, prop, xproto.AtomAtom, 32, 4, l[:])
504+ case t.atoms["UTF8_STRING"], t.atoms["STRING"], t.atoms["TEXT"]:
505+ if len(data) <= chunk {
506+ xproto.ChangeProperty(t.b, xproto.PropModeReplace, req, prop, tgt, 8, uint32(len(data)), data)
507+ break
508+ }
509+ var cv xproto.ChangeWindowAttributesValues
510+ cv.SetEventMask(xproto.EventMaskPropertyChange)
511+ xproto.ChangeWindowAttributes(t.b, req, &cv)
512+ var n [4]byte
513+ binary.LittleEndian.PutUint32(n[:], uint32(len(data)))
514+ xproto.ChangeProperty(t.b, xproto.PropModeReplace, req, prop, t.atoms["INCR"], 32, 1, n[:])
515+ incrReq, incrProp, incrTgt, incrOff, incrLive = req, prop, tgt, 0, true
516+ default:
517+ prop = 0
518+ }
519+ notify(req, e.Selection(), tgt, prop, e.Time())
520+ case xproto.KindPropertyNotify:
521+ e := xproto.PropertyNotifyOf(&ev)
522+ if !incrLive || e.Window() != incrReq || e.Atom() != incrProp || e.State() != xproto.PropertyDelete {
523+ break
524+ }
525+ end := min(incrOff+chunk, len(data))
526+ xproto.ChangeProperty(t.b, xproto.PropModeReplace, incrReq, incrProp, incrTgt, 8, uint32(end-incrOff), data[incrOff:end])
527+ if end == incrOff {
528+ incrLive = false
529+ }
530+ incrOff = end
531+ }
532+ }
533+}
534+
535+func (t *tool) pasteMode(selName string) error {
536+ sel, err := t.selAtom(selName)
537+ if err != nil {
538+ return err
539+ }
540+ prop := t.atoms["_PROBE_SEL"]
541+ xproto.ConvertSelection(t.b, t.win, sel, t.atoms["UTF8_STRING"], prop, 0)
542+ deadline := time.Now().Add(10 * time.Second)
543+ var got []byte
544+ incr := false
545+ get := func() (value []byte, typ xproto.Atom, err error) {
546+ raw, err := t.rpc(xproto.GetProperty(t.b, true, t.win, prop, xproto.AtomAny, 0, 1<<22))
547+ if err != nil {
548+ return nil, 0, err
549+ }
550+ r, ok := xproto.ParseGetPropertyReply(raw)
551+ if !ok {
552+ return nil, 0, fmt.Errorf("bad GetProperty reply")
553+ }
554+ return append([]byte(nil), r.Value()...), r.Type(), nil
555+ }
556+ for {
557+ ev, ok, err := t.next(deadline)
558+ if err != nil {
559+ return err
560+ }
561+ if !ok {
562+ return fmt.Errorf("paste timed out")
563+ }
564+ switch ev.Kind {
565+ case xproto.KindSelectionNotify:
566+ if xproto.SelectionNotifyOf(&ev).Property() == 0 {
567+ fmt.Println("paste none")
568+ return nil
569+ }
570+ v, typ, err := get()
571+ if err != nil {
572+ return err
573+ }
574+ if typ != t.atoms["INCR"] {
575+ return printPaste(v)
576+ }
577+ incr = true // reading and deleting the INCR property starts the transfer
578+ case xproto.KindPropertyNotify:
579+ e := xproto.PropertyNotifyOf(&ev)
580+ if !incr || e.Window() != t.win || e.Atom() != prop || e.State() != xproto.PropertyNewValue {
581+ break
582+ }
583+ v, _, err := get()
584+ if err != nil {
585+ return err
586+ }
587+ if len(v) == 0 {
588+ return printPaste(got)
589+ }
590+ got = append(got, v...)
591+ }
592+ deadline = time.Now().Add(10 * time.Second)
593+ }
594+}
595+
596+func printPaste(v []byte) error {
597+ if len(v) <= 4096 {
598+ fmt.Printf("paste %q\n", v)
599+ } else {
600+ fmt.Printf("paste len=%d sha256=%x\n", len(v), sha256.Sum256(v))
601+ }
602+ return nil
603+}
604+
605+func (t *tool) xsettingsMode() error {
606+ raw, err := t.rpc(xproto.GetSelectionOwner(t.b, t.atoms["_XSETTINGS_S0"]))
607+ if err != nil {
608+ return err
609+ }
610+ r, ok := xproto.ParseGetSelectionOwnerReply(raw)
611+ if !ok || r.Owner() == 0 {
612+ fmt.Println("xsettings none")
613+ return nil
614+ }
615+ raw, err = t.rpc(xproto.GetProperty(t.b, false, r.Owner(), t.atoms["_XSETTINGS_SETTINGS"], xproto.AtomAny, 0, 1<<16))
616+ if err != nil {
617+ return err
618+ }
619+ pr, ok := xproto.ParseGetPropertyReply(raw)
620+ if !ok || len(pr.Value()) < 12 {
621+ fmt.Println("xsettings none")
622+ return nil
623+ }
624+ v := pr.Value()
625+ if v[0] != 0 {
626+ return fmt.Errorf("xsettings: big-endian data")
627+ }
628+ le := binary.LittleEndian
629+ serial, n := le.Uint32(v[4:]), int(le.Uint32(v[8:]))
630+ vals := map[string]string{}
631+ p := v[12:]
632+ pad := func(n int) int { return (n + 3) &^ 3 }
633+ for i := 0; i < n; i++ {
634+ if len(p) < 4 {
635+ return fmt.Errorf("xsettings: truncated")
636+ }
637+ typ, nl := p[0], int(le.Uint16(p[2:]))
638+ if len(p) < 4+pad(nl)+4 {
639+ return fmt.Errorf("xsettings: truncated")
640+ }
641+ name := string(p[4 : 4+nl])
642+ p = p[4+pad(nl)+4:] // name, last-change-serial
643+ switch typ {
644+ case 0:
645+ vals[name] = strconv.Itoa(int(int32(le.Uint32(p))))
646+ p = p[4:]
647+ case 1:
648+ sl := int(le.Uint32(p))
649+ vals[name] = string(p[4 : 4+sl])
650+ p = p[4+pad(sl):]
651+ case 2:
652+ p = p[8:]
653+ default:
654+ return fmt.Errorf("xsettings: type %d", typ)
655+ }
656+ }
657+ fmt.Printf("xsettings serial=%d Xft/DPI=%s Gtk/CursorThemeName=%s Gtk/CursorThemeSize=%s\n",
658+ serial, vals["Xft/DPI"], vals["Gtk/CursorThemeName"], vals["Gtk/CursorThemeSize"])
659+ return nil
660+}
661+
662+func (t *tool) cursorMode() error {
663+ if _, err := t.rpc(xfixes.QueryVersion(t.b, 4, 0)); err != nil {
664+ return err
665+ }
666+ raw, err := t.rpc(xfixes.GetCursorImage(t.b))
667+ if err != nil {
668+ return err
669+ }
670+ r, ok := xfixes.ParseGetCursorImageReply(raw)
671+ if !ok {
672+ return fmt.Errorf("bad GetCursorImage reply")
673+ }
674+ fmt.Printf("cursor %dx%d\n", r.Width(), r.Height())
675+ return nil
676+}
@@ -15,9 +15,19 @@
15 // leave it that long after (prints "fullscreen off WxH"). -keyboard-grab15 // leave it that long after (prints "fullscreen off WxH"). -keyboard-grab
16 // calls XGrabKeyboard on the window once it is mapped (prints "grab").16 // calls XGrabKeyboard on the window once it is mapped (prints "grab").
17 // -at X,Y creates the window at that root position.17 // -at X,Y creates the window at that root position.
18+//
19+// Four more modes run without a window and exit when done.
20+// -copy TEXT|@file owns -copy-sel (CLIPBOARD or PRIMARY) for
21+// -copy-for, serving TARGETS and UTF8_STRING/STRING/TEXT, with INCR above
22+// 64 KiB, and prints "copy owned". -paste SEL requests UTF8_STRING (INCR
23+// aware) and prints `paste "<text>"`, `paste len=N sha256=H` above 4 KiB or
24+// `paste none`. -xsettings prints the XSETTINGS owner's serial, Xft/DPI,
25+// Gtk/CursorThemeName and Gtk/CursorThemeSize, or "xsettings none".
26+// -root-cursor prints "cursor WxH" of the XFixes cursor image.
18 package main27 package main
19 28
20 import (29 import (
30+ "crypto/sha256"
21 "encoding/binary"31 "encoding/binary"
22 "flag"32 "flag"
23 "fmt"33 "fmt"
@@ -30,6 +40,7 @@ import (
30 40
31 "bnema.dev/xturbo"41 "bnema.dev/xturbo"
32 "bnema.dev/xturbo/shape"42 "bnema.dev/xturbo/shape"
43+ "bnema.dev/xturbo/xfixes"
33 "bnema.dev/xturbo/xproto"44 "bnema.dev/xturbo/xproto"
34 )45 )
35 46
@@ -62,7 +73,16 @@ func run() error {
62 fullscreenOff := flag.Duration("fullscreen-off", 0, "leave fullscreen this long after the request (0: never)")73 fullscreenOff := flag.Duration("fullscreen-off", 0, "leave fullscreen this long after the request (0: never)")
63 keyboardGrab := flag.Bool("keyboard-grab", false, "XGrabKeyboard on the window once it is mapped")74 keyboardGrab := flag.Bool("keyboard-grab", false, "XGrabKeyboard on the window once it is mapped")
64 at := flag.String("at", "0,0", "window position X,Y in root coordinates")75 at := flag.String("at", "0,0", "window position X,Y in root coordinates")
76+ copyText := flag.String("copy", "", "own a selection with TEXT (or @file) and serve it, no window")
77+ copySel := flag.String("copy-sel", "CLIPBOARD", "selection for -copy: CLIPBOARD or PRIMARY")
78+ copyFor := flag.Duration("copy-for", 5*time.Second, "how long -copy serves the selection")
79+ paste := flag.String("paste", "", "print the text of selection CLIPBOARD or PRIMARY, no window")
80+ xsettings := flag.Bool("xsettings", false, "print the XSETTINGS values, no window")
81+ rootCursor := flag.Bool("root-cursor", false, "print the size of the cursor shown over the root, no window")
65 flag.Parse()82 flag.Parse()
83+ if *copyText != "" || *paste != "" || *xsettings || *rootCursor {
84+ return runTool(*copyText, *copySel, *copyFor, *paste, *xsettings, *rootCursor)
85+ }
66 atX, atY, okAt := strings.Cut(*at, ",")86 atX, atY, okAt := strings.Cut(*at, ",")
67 if !okAt {87 if !okAt {
68 return fmt.Errorf("bad -at %q", *at)88 return fmt.Errorf("bad -at %q", *at)
@@ -316,3 +336,341 @@ func atoi(s string) int {
316 n, _ := strconv.Atoi(s)336 n, _ := strconv.Atoi(s)
317 return n337 return n
318 }338 }
339+
340+// tool is the connection of the windowless modes. Events read while waiting
341+// for a reply are queued, so the code reads linearly.
342+type tool struct {
343+ c *xturbo.Conn
344+ b *xturbo.Buf
345+ win xproto.Window
346+ q []xturbo.Event
347+ reply []byte
348+ h xturbo.Handler
349+ atoms map[string]xproto.Atom
350+}
351+
352+const chunk = 65536
353+
354+func newTool() (*tool, error) {
355+ c, err := xturbo.Dial("")
356+ if err != nil {
357+ return nil, err
358+ }
359+ t := &tool{c: c, b: c.Buf(), atoms: map[string]xproto.Atom{}}
360+ t.h = xturbo.Handler{
361+ OnEvent: func(ev *xturbo.Event) { t.q = append(t.q, *ev) },
362+ OnReply: func(r *xturbo.Reply) { t.reply = append(t.reply[:0], r.Raw...) },
363+ OnError: func(e *xturbo.XError) { fmt.Fprintln(os.Stderr, "x11probe:", e) },
364+ }
365+ names := []string{"CLIPBOARD", "PRIMARY", "TARGETS", "UTF8_STRING", "STRING", "TEXT", "INCR", "_PROBE_SEL",
366+ "_XSETTINGS_S0", "_XSETTINGS_SETTINGS"}
367+ ids := make([]uint32, len(names))
368+ if err := c.InternAtoms(names, false, ids); err != nil {
369+ return nil, err
370+ }
371+ for i, n := range names {
372+ t.atoms[n] = xproto.Atom(ids[i])
373+ }
374+ xid, err := c.NewXID()
375+ if err != nil {
376+ return nil, err
377+ }
378+ t.win = xproto.Window(xid)
379+ var v xproto.CreateWindowValues
380+ v.SetEventMask(xproto.EventMaskPropertyChange)
381+ xproto.CreateWindow(t.b, 0, t.win, xproto.Window(c.Root()), -10, -10, 1, 1, 0, xproto.WindowClassInputOnly, 0, &v)
382+ return t, t.c.Sync(&t.h)
383+}
384+
385+// rpc sends the queued requests and returns the reply of seq.
386+func (t *tool) rpc(seq xturbo.Seq) ([]byte, error) {
387+ t.reply = t.reply[:0]
388+ if err := t.c.Expect(seq, 16, 0, 0, 0, 0); err != nil {
389+ return nil, err
390+ }
391+ if err := t.c.Sync(&t.h); err != nil {
392+ return nil, err
393+ }
394+ return t.reply, nil
395+}
396+
397+// next returns the next event, or false at the deadline.
398+func (t *tool) next(deadline time.Time) (xturbo.Event, bool, error) {
399+ for len(t.q) == 0 {
400+ events := int16(unix.POLLIN)
401+ if err := t.c.Flush(); err == xturbo.ErrWouldBlock {
402+ events |= unix.POLLOUT
403+ } else if err != nil {
404+ return xturbo.Event{}, false, err
405+ }
406+ ms := int(time.Until(deadline).Milliseconds()) + 1
407+ if ms <= 0 {
408+ return xturbo.Event{}, false, nil
409+ }
410+ fds := []unix.PollFd{{Fd: int32(t.c.Fd()), Events: events}}
411+ if _, err := unix.Poll(fds, ms); err != nil && err != unix.EINTR {
412+ return xturbo.Event{}, false, err
413+ }
414+ if err := t.c.Dispatch(&t.h); err != nil {
415+ return xturbo.Event{}, false, err
416+ }
417+ }
418+ ev := t.q[0]
419+ t.q = t.q[1:]
420+ return ev, true, nil
421+}
422+
423+func (t *tool) selAtom(name string) (xproto.Atom, error) {
424+ switch name {
425+ case "CLIPBOARD", "PRIMARY":
426+ return t.atoms[name], nil
427+ }
428+ return 0, fmt.Errorf("bad selection %q", name)
429+}
430+
431+func runTool(copyText, copySel string, copyFor time.Duration, paste string, xsettings, rootCursor bool) error {
432+ t, err := newTool()
433+ if err != nil {
434+ return err
435+ }
436+ defer t.c.Close()
437+ switch {
438+ case copyText != "":
439+ return t.copyMode(copyText, copySel, copyFor)
440+ case paste != "":
441+ return t.pasteMode(paste)
442+ case xsettings:
443+ return t.xsettingsMode()
444+ }
445+ return t.cursorMode()
446+}
447+
448+func (t *tool) copyMode(text, selName string, dur time.Duration) error {
449+ data := []byte(text)
450+ if f, ok := strings.CutPrefix(text, "@"); ok {
451+ var err error
452+ if data, err = os.ReadFile(f); err != nil {
453+ return err
454+ }
455+ }
456+ sel, err := t.selAtom(selName)
457+ if err != nil {
458+ return err
459+ }
460+ xproto.SetSelectionOwner(t.b, t.win, sel, 0)
461+ raw, err := t.rpc(xproto.GetSelectionOwner(t.b, sel))
462+ if err != nil {
463+ return err
464+ }
465+ if r, ok := xproto.ParseGetSelectionOwnerReply(raw); !ok || r.Owner() != t.win {
466+ return fmt.Errorf("selection %s not owned", selName)
467+ }
468+ fmt.Println("copy owned")
469+ var (
470+ incrReq xproto.Window // requestor of the running INCR transfer
471+ incrProp, incrTgt xproto.Atom
472+ incrOff int
473+ incrLive bool
474+ notify = func(req xproto.Window, selection, target, prop xproto.Atom, time xproto.Timestamp) {
475+ var ev [32]byte
476+ d := xproto.SelectionNotifyEventData{Time: time, Requestor: req, Selection: selection, Target: target, Property: prop}
477+ d.Put(&ev)
478+ xproto.SendEvent(t.b, false, req, 0, &ev)
479+ }
480+ )
481+ deadline := time.Now().Add(dur)
482+ for {
483+ ev, ok, err := t.next(deadline)
484+ if err != nil || !ok {
485+ return err
486+ }
487+ switch ev.Kind {
488+ case xproto.KindSelectionClear:
489+ fmt.Println("copy lost")
490+ return nil
491+ case xproto.KindSelectionRequest:
492+ e := xproto.SelectionRequestOf(&ev)
493+ req, prop, tgt := e.Requestor(), e.Property(), e.Target()
494+ if prop == 0 {
495+ prop = tgt
496+ }
497+ switch tgt {
498+ case t.atoms["TARGETS"]:
499+ var l [16]byte
500+ for i, n := range []string{"TARGETS", "UTF8_STRING", "STRING", "TEXT"} {
501+ binary.LittleEndian.PutUint32(l[i*4:], uint32(t.atoms[n]))
502+ }
503+ xproto.ChangeProperty(t.b, xproto.PropModeReplace, req, prop, xproto.AtomAtom, 32, 4, l[:])
504+ case t.atoms["UTF8_STRING"], t.atoms["STRING"], t.atoms["TEXT"]:
505+ if len(data) <= chunk {
506+ xproto.ChangeProperty(t.b, xproto.PropModeReplace, req, prop, tgt, 8, uint32(len(data)), data)
507+ break
508+ }
509+ var cv xproto.ChangeWindowAttributesValues
510+ cv.SetEventMask(xproto.EventMaskPropertyChange)
511+ xproto.ChangeWindowAttributes(t.b, req, &cv)
512+ var n [4]byte
513+ binary.LittleEndian.PutUint32(n[:], uint32(len(data)))
514+ xproto.ChangeProperty(t.b, xproto.PropModeReplace, req, prop, t.atoms["INCR"], 32, 1, n[:])
515+ incrReq, incrProp, incrTgt, incrOff, incrLive = req, prop, tgt, 0, true
516+ default:
517+ prop = 0
518+ }
519+ notify(req, e.Selection(), tgt, prop, e.Time())
520+ case xproto.KindPropertyNotify:
521+ e := xproto.PropertyNotifyOf(&ev)
522+ if !incrLive || e.Window() != incrReq || e.Atom() != incrProp || e.State() != xproto.PropertyDelete {
523+ break
524+ }
525+ end := min(incrOff+chunk, len(data))
526+ xproto.ChangeProperty(t.b, xproto.PropModeReplace, incrReq, incrProp, incrTgt, 8, uint32(end-incrOff), data[incrOff:end])
527+ if end == incrOff {
528+ incrLive = false
529+ }
530+ incrOff = end
531+ }
532+ }
533+}
534+
535+func (t *tool) pasteMode(selName string) error {
536+ sel, err := t.selAtom(selName)
537+ if err != nil {
538+ return err
539+ }
540+ prop := t.atoms["_PROBE_SEL"]
541+ xproto.ConvertSelection(t.b, t.win, sel, t.atoms["UTF8_STRING"], prop, 0)
542+ deadline := time.Now().Add(10 * time.Second)
543+ var got []byte
544+ incr := false
545+ get := func() (value []byte, typ xproto.Atom, err error) {
546+ raw, err := t.rpc(xproto.GetProperty(t.b, true, t.win, prop, xproto.AtomAny, 0, 1<<22))
547+ if err != nil {
548+ return nil, 0, err
549+ }
550+ r, ok := xproto.ParseGetPropertyReply(raw)
551+ if !ok {
552+ return nil, 0, fmt.Errorf("bad GetProperty reply")
553+ }
554+ return append([]byte(nil), r.Value()...), r.Type(), nil
555+ }
556+ for {
557+ ev, ok, err := t.next(deadline)
558+ if err != nil {
559+ return err
560+ }
561+ if !ok {
562+ return fmt.Errorf("paste timed out")
563+ }
564+ switch ev.Kind {
565+ case xproto.KindSelectionNotify:
566+ if xproto.SelectionNotifyOf(&ev).Property() == 0 {
567+ fmt.Println("paste none")
568+ return nil
569+ }
570+ v, typ, err := get()
571+ if err != nil {
572+ return err
573+ }
574+ if typ != t.atoms["INCR"] {
575+ return printPaste(v)
576+ }
577+ incr = true // reading and deleting the INCR property starts the transfer
578+ case xproto.KindPropertyNotify:
579+ e := xproto.PropertyNotifyOf(&ev)
580+ if !incr || e.Window() != t.win || e.Atom() != prop || e.State() != xproto.PropertyNewValue {
581+ break
582+ }
583+ v, _, err := get()
584+ if err != nil {
585+ return err
586+ }
587+ if len(v) == 0 {
588+ return printPaste(got)
589+ }
590+ got = append(got, v...)
591+ }
592+ deadline = time.Now().Add(10 * time.Second)
593+ }
594+}
595+
596+func printPaste(v []byte) error {
597+ if len(v) <= 4096 {
598+ fmt.Printf("paste %q\n", v)
599+ } else {
600+ fmt.Printf("paste len=%d sha256=%x\n", len(v), sha256.Sum256(v))
601+ }
602+ return nil
603+}
604+
605+func (t *tool) xsettingsMode() error {
606+ raw, err := t.rpc(xproto.GetSelectionOwner(t.b, t.atoms["_XSETTINGS_S0"]))
607+ if err != nil {
608+ return err
609+ }
610+ r, ok := xproto.ParseGetSelectionOwnerReply(raw)
611+ if !ok || r.Owner() == 0 {
612+ fmt.Println("xsettings none")
613+ return nil
614+ }
615+ raw, err = t.rpc(xproto.GetProperty(t.b, false, r.Owner(), t.atoms["_XSETTINGS_SETTINGS"], xproto.AtomAny, 0, 1<<16))
616+ if err != nil {
617+ return err
618+ }
619+ pr, ok := xproto.ParseGetPropertyReply(raw)
620+ if !ok || len(pr.Value()) < 12 {
621+ fmt.Println("xsettings none")
622+ return nil
623+ }
624+ v := pr.Value()
625+ if v[0] != 0 {
626+ return fmt.Errorf("xsettings: big-endian data")
627+ }
628+ le := binary.LittleEndian
629+ serial, n := le.Uint32(v[4:]), int(le.Uint32(v[8:]))
630+ vals := map[string]string{}
631+ p := v[12:]
632+ pad := func(n int) int { return (n + 3) &^ 3 }
633+ for i := 0; i < n; i++ {
634+ if len(p) < 4 {
635+ return fmt.Errorf("xsettings: truncated")
636+ }
637+ typ, nl := p[0], int(le.Uint16(p[2:]))
638+ if len(p) < 4+pad(nl)+4 {
639+ return fmt.Errorf("xsettings: truncated")
640+ }
641+ name := string(p[4 : 4+nl])
642+ p = p[4+pad(nl)+4:] // name, last-change-serial
643+ switch typ {
644+ case 0:
645+ vals[name] = strconv.Itoa(int(int32(le.Uint32(p))))
646+ p = p[4:]
647+ case 1:
648+ sl := int(le.Uint32(p))
649+ vals[name] = string(p[4 : 4+sl])
650+ p = p[4+pad(sl):]
651+ case 2:
652+ p = p[8:]
653+ default:
654+ return fmt.Errorf("xsettings: type %d", typ)
655+ }
656+ }
657+ fmt.Printf("xsettings serial=%d Xft/DPI=%s Gtk/CursorThemeName=%s Gtk/CursorThemeSize=%s\n",
658+ serial, vals["Xft/DPI"], vals["Gtk/CursorThemeName"], vals["Gtk/CursorThemeSize"])
659+ return nil
660+}
661+
662+func (t *tool) cursorMode() error {
663+ if _, err := t.rpc(xfixes.QueryVersion(t.b, 4, 0)); err != nil {
664+ return err
665+ }
666+ raw, err := t.rpc(xfixes.GetCursorImage(t.b))
667+ if err != nil {
668+ return err
669+ }
670+ r, ok := xfixes.ParseGetCursorImageReply(raw)
671+ if !ok {
672+ return fmt.Errorf("bad GetCursorImage reply")
673+ }
674+ fmt.Printf("cursor %dx%d\n", r.Width(), r.Height())
675+ return nil
676+}
M internal/app/xwayland_test.go +202 -1
@@ -8,6 +8,8 @@ package app
88
99 import (
1010 "context"
11+ "crypto/sha256"
12+ "fmt"
1113 "image"
1214 "image/png"
1315 "io"
@@ -15,6 +17,7 @@ import (
1517 "os/exec"
1618 "path/filepath"
1719 "regexp"
20+ "strconv"
1821 "strings"
1922 "sync"
2023 "testing"
@@ -79,7 +82,7 @@ func startXwaylandWith(t *testing.T, sizes [][2]int, mutate func(*ports.Config))
7982 logs := &lockedBuffer{}
8083 ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: logs})))
8184 cfg := config.Defaults()
82- cfg.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland"}
85+ cfg.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland", XSettings: true}
8386 if mutate != nil {
8487 mutate(&cfg)
8588 }
@@ -575,3 +578,201 @@ func TestXwaylandClassRule(t *testing.T) {
575578 return false
576579 })
577580 }
581+
582+// waylandEnv is the environment of a Wayland client of the headless
583+// compositor: its socket, not the user's session.
584+func waylandEnv() []string {
585+ return append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "WAYLAND_DISPLAY=wayland-1")
586+}
587+
588+// runProbe runs a one-shot x11probe mode and returns its stdout.
589+func runProbe(t *testing.T, e *xwaylandEnv, args ...string) string {
590+ t.Helper()
591+ ctx, cancel := context.WithTimeout(context.Background(), xwaylandTimeout)
592+ defer cancel()
593+ cmd := exec.CommandContext(ctx, e.bin, args...)
594+ cmd.Env = append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "DISPLAY="+e.display)
595+ out, err := cmd.Output()
596+ if err != nil {
597+ t.Fatalf("x11probe %v: %v (stdout %q)", args, err, out)
598+ }
599+ return strings.TrimSpace(string(out))
600+}
601+
602+// needWlClipboard skips the test without wl-copy and wl-paste.
603+func needWlClipboard(t *testing.T) {
604+ t.Helper()
605+ for _, c := range []string{"wl-copy", "wl-paste"} {
606+ if _, err := exec.LookPath(c); err != nil {
607+ t.Skipf("%s not on PATH (wl-clipboard)", c)
608+ }
609+ }
610+}
611+
612+// wlCopy starts wl-copy --foreground serving data on the headless
613+// compositor's seat and stops it when the test ends.
614+func wlCopy(t *testing.T, data string, args ...string) {
615+ t.Helper()
616+ cmd := exec.Command("wl-copy", append([]string{"--foreground", "--type", "text/plain;charset=utf-8"}, args...)...)
617+ cmd.Env = waylandEnv()
618+ cmd.Stdin = strings.NewReader(data)
619+ stderr := &lockedBuffer{}
620+ cmd.Stderr = stderr
621+ if err := cmd.Start(); err != nil {
622+ t.Fatal(err)
623+ }
624+ waited := make(chan struct{})
625+ go func() { _ = cmd.Wait(); close(waited) }()
626+ t.Cleanup(func() {
627+ _ = cmd.Process.Kill()
628+ <-waited
629+ if s := stderr.String(); s != "" {
630+ t.Logf("wl-copy stderr: %s", s)
631+ }
632+ })
633+}
634+
635+// wlPaste returns what wl-paste prints, and whether it succeeded.
636+func wlPaste(args ...string) (string, bool) {
637+ ctx, cancel := context.WithTimeout(context.Background(), xwaylandTimeout)
638+ defer cancel()
639+ cmd := exec.CommandContext(ctx, "wl-paste", append([]string{"--no-newline", "--type", "text/plain;charset=utf-8"}, args...)...)
640+ cmd.Env = waylandEnv()
641+ out, err := cmd.Output()
642+ return string(out), err == nil
643+}
644+
645+// payload is n bytes of printable text that differ along the way, so a
646+// misplaced chunk is caught.
647+func payload(n int) string {
648+ var b strings.Builder
649+ for i := 0; b.Len() < n; i++ {
650+ fmt.Fprintf(&b, "line %d é\n", i)
651+ }
652+ return b.String()[:n]
653+}
654+
655+// pasteLine is what x11probe -paste prints for want.
656+func pasteLine(want string) string {
657+ if len(want) <= 4096 {
658+ return fmt.Sprintf("paste %q", want)
659+ }
660+ return fmt.Sprintf("paste len=%d sha256=%x", len(want), sha256.Sum256([]byte(want)))
661+}
662+
663+// TestXwaylandClipboard: the clipboard and the primary selection cross the
664+// X11/Wayland boundary in both directions, text only, small and INCR sized.
665+func TestXwaylandClipboard(t *testing.T) {
666+ needWlClipboard(t)
667+ e := startXwayland(t)
668+ startProbe(t, e.bin, e.display, "-title", "focus")
669+ until(t, "probe focused", func() bool { st := readState(e.statePath); return st.Window != nil })
670+
671+ for _, sel := range []struct{ x, w string }{{"CLIPBOARD", ""}, {"PRIMARY", "--primary"}} {
672+ for _, size := range []int{4 << 10, 1 << 20} {
673+ name := fmt.Sprintf("%s/%dKiB", sel.x, size>>10)
674+ text := payload(size)
675+ t.Run("W2X/"+name, func(t *testing.T) {
676+ var wargs []string
677+ if sel.w != "" {
678+ wargs = append(wargs, sel.w)
679+ }
680+ wlCopy(t, text, wargs...)
681+ until(t, "x11probe pastes the Wayland copy", func() bool {
682+ return runProbe(t, e, "-paste", sel.x) == pasteLine(text)
683+ })
684+ })
685+ t.Run("X2W/"+name, func(t *testing.T) {
686+ file := filepath.Join(t.TempDir(), "text")
687+ if err := os.WriteFile(file, []byte(text), 0o600); err != nil {
688+ t.Fatal(err)
689+ }
690+ p := startProbe(t, e.bin, e.display, "-copy", "@"+file, "-copy-sel", sel.x, "-copy-for", "30s")
691+ until(t, "copy owned", func() bool { return strings.Contains(p.out.String(), "copy owned") })
692+ var wargs []string
693+ if sel.w != "" {
694+ wargs = append(wargs, sel.w)
695+ }
696+ var got string
697+ until(t, "wl-paste returns the X copy", func() bool {
698+ var ok bool
699+ got, ok = wlPaste(wargs...)
700+ return ok && got == text
701+ })
702+ if got != text {
703+ t.Errorf("wl-paste differs: %d bytes", len(got))
704+ }
705+ })
706+ }
707+ }
708+
709+ t.Run("clear", func(t *testing.T) {
710+ wlCopy(t, "to be cleared")
711+ until(t, "paste before clear", func() bool { return runProbe(t, e, "-paste", "CLIPBOARD") == `paste "to be cleared"` })
712+ cmd := exec.Command("wl-copy", "--clear")
713+ cmd.Env = waylandEnv()
714+ if out, err := cmd.CombinedOutput(); err != nil {
715+ t.Fatalf("wl-copy --clear: %v: %s", err, out)
716+ }
717+ until(t, "paste none after clear", func() bool { return runProbe(t, e, "-paste", "CLIPBOARD") == "paste none" })
718+ })
719+}
720+
721+// cursorTheme installs a one-arrow Xcursor theme of the given size and
722+// selects it through the XCURSOR_* environment.
723+func cursorTheme(t *testing.T, size int) {
724+ t.Helper()
725+ dir := t.TempDir()
726+ themeCursor(t, dir, "left_ptr", size, 1)
727+ t.Setenv("XCURSOR_PATH", dir)
728+ t.Setenv("XCURSOR_THEME", "theme")
729+ t.Setenv("XCURSOR_SIZE", strconv.Itoa(size))
730+}
731+
732+// TestXwaylandXSettings: the XSETTINGS manager publishes the cursor theme,
733+// the cursor size and the DPI of the first output.
734+func TestXwaylandXSettings(t *testing.T) {
735+ cursorTheme(t, 32)
736+ e := startXwayland(t)
737+ want := "xsettings serial=1 Xft/DPI=98304 Gtk/CursorThemeName=theme Gtk/CursorThemeSize=32"
738+ var got string
739+ until(t, "XSETTINGS owner", func() bool { got = runProbe(t, e, "-xsettings"); return got != "xsettings none" })
740+ if got != want {
741+ t.Errorf("-xsettings = %q, want %q", got, want)
742+ }
743+}
744+
745+// TestXwaylandXSettingsScale2: the DPI follows the output scale.
746+func TestXwaylandXSettingsScale2(t *testing.T) {
747+ cursorTheme(t, 24)
748+ e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) {
749+ c.Outputs = append(c.Outputs, ports.OutputConfig{Name: "HEADLESS-1", Scale: 2, ScaleOnly: true})
750+ })
751+ var got string
752+ until(t, "Xft/DPI=196608", func() bool { got = runProbe(t, e, "-xsettings"); return strings.Contains(got, "Xft/DPI=196608") })
753+ if !strings.Contains(got, "Gtk/CursorThemeSize=24") {
754+ t.Errorf("-xsettings = %q, want Gtk/CursorThemeSize=24", got)
755+ }
756+}
757+
758+// TestXwaylandXSettingsOff: xwayland.xsettings = off leaves no manager.
759+func TestXwaylandXSettingsOff(t *testing.T) {
760+ e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Xwayland.XSettings = false })
761+ // A mapped window proves the XWM is up before the absence is checked.
762+ p := startProbe(t, e.bin, e.display)
763+ until(t, "probe mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") })
764+ if got := runProbe(t, e, "-xsettings"); got != "xsettings none" {
765+ t.Errorf("-xsettings = %q, want none", got)
766+ }
767+}
768+
769+// TestXwaylandRootCursor: the X background shows the themed arrow, at the
770+// theme size.
771+func TestXwaylandRootCursor(t *testing.T) {
772+ cursorTheme(t, 32)
773+ e := startXwayland(t)
774+ until(t, "XWM up", func() bool { return runProbe(t, e, "-xsettings") != "xsettings none" })
775+ if got := runProbe(t, e, "-root-cursor"); got != "cursor 32x32" {
776+ t.Errorf("-root-cursor = %q, want cursor 32x32", got)
777+ }
778+}
@@ -8,6 +8,8 @@ package app
8 8
9 import (9 import (
10 "context"10 "context"
11+ "crypto/sha256"
12+ "fmt"
11 "image"13 "image"
12 "image/png"14 "image/png"
13 "io"15 "io"
@@ -15,6 +17,7 @@ import (
15 "os/exec"17 "os/exec"
16 "path/filepath"18 "path/filepath"
17 "regexp"19 "regexp"
20+ "strconv"
18 "strings"21 "strings"
19 "sync"22 "sync"
20 "testing"23 "testing"
@@ -79,7 +82,7 @@ func startXwaylandWith(t *testing.T, sizes [][2]int, mutate func(*ports.Config))
79 logs := &lockedBuffer{}82 logs := &lockedBuffer{}
80 ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: logs})))83 ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: logs})))
81 cfg := config.Defaults()84 cfg := config.Defaults()
82- cfg.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland"}85+ cfg.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland", XSettings: true}
83 if mutate != nil {86 if mutate != nil {
84 mutate(&cfg)87 mutate(&cfg)
85 }88 }
@@ -575,3 +578,201 @@ func TestXwaylandClassRule(t *testing.T) {
575 return false578 return false
576 })579 })
577 }580 }
581+
582+// waylandEnv is the environment of a Wayland client of the headless
583+// compositor: its socket, not the user's session.
584+func waylandEnv() []string {
585+ return append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "WAYLAND_DISPLAY=wayland-1")
586+}
587+
588+// runProbe runs a one-shot x11probe mode and returns its stdout.
589+func runProbe(t *testing.T, e *xwaylandEnv, args ...string) string {
590+ t.Helper()
591+ ctx, cancel := context.WithTimeout(context.Background(), xwaylandTimeout)
592+ defer cancel()
593+ cmd := exec.CommandContext(ctx, e.bin, args...)
594+ cmd.Env = append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "DISPLAY="+e.display)
595+ out, err := cmd.Output()
596+ if err != nil {
597+ t.Fatalf("x11probe %v: %v (stdout %q)", args, err, out)
598+ }
599+ return strings.TrimSpace(string(out))
600+}
601+
602+// needWlClipboard skips the test without wl-copy and wl-paste.
603+func needWlClipboard(t *testing.T) {
604+ t.Helper()
605+ for _, c := range []string{"wl-copy", "wl-paste"} {
606+ if _, err := exec.LookPath(c); err != nil {
607+ t.Skipf("%s not on PATH (wl-clipboard)", c)
608+ }
609+ }
610+}
611+
612+// wlCopy starts wl-copy --foreground serving data on the headless
613+// compositor's seat and stops it when the test ends.
614+func wlCopy(t *testing.T, data string, args ...string) {
615+ t.Helper()
616+ cmd := exec.Command("wl-copy", append([]string{"--foreground", "--type", "text/plain;charset=utf-8"}, args...)...)
617+ cmd.Env = waylandEnv()
618+ cmd.Stdin = strings.NewReader(data)
619+ stderr := &lockedBuffer{}
620+ cmd.Stderr = stderr
621+ if err := cmd.Start(); err != nil {
622+ t.Fatal(err)
623+ }
624+ waited := make(chan struct{})
625+ go func() { _ = cmd.Wait(); close(waited) }()
626+ t.Cleanup(func() {
627+ _ = cmd.Process.Kill()
628+ <-waited
629+ if s := stderr.String(); s != "" {
630+ t.Logf("wl-copy stderr: %s", s)
631+ }
632+ })
633+}
634+
635+// wlPaste returns what wl-paste prints, and whether it succeeded.
636+func wlPaste(args ...string) (string, bool) {
637+ ctx, cancel := context.WithTimeout(context.Background(), xwaylandTimeout)
638+ defer cancel()
639+ cmd := exec.CommandContext(ctx, "wl-paste", append([]string{"--no-newline", "--type", "text/plain;charset=utf-8"}, args...)...)
640+ cmd.Env = waylandEnv()
641+ out, err := cmd.Output()
642+ return string(out), err == nil
643+}
644+
645+// payload is n bytes of printable text that differ along the way, so a
646+// misplaced chunk is caught.
647+func payload(n int) string {
648+ var b strings.Builder
649+ for i := 0; b.Len() < n; i++ {
650+ fmt.Fprintf(&b, "line %d é\n", i)
651+ }
652+ return b.String()[:n]
653+}
654+
655+// pasteLine is what x11probe -paste prints for want.
656+func pasteLine(want string) string {
657+ if len(want) <= 4096 {
658+ return fmt.Sprintf("paste %q", want)
659+ }
660+ return fmt.Sprintf("paste len=%d sha256=%x", len(want), sha256.Sum256([]byte(want)))
661+}
662+
663+// TestXwaylandClipboard: the clipboard and the primary selection cross the
664+// X11/Wayland boundary in both directions, text only, small and INCR sized.
665+func TestXwaylandClipboard(t *testing.T) {
666+ needWlClipboard(t)
667+ e := startXwayland(t)
668+ startProbe(t, e.bin, e.display, "-title", "focus")
669+ until(t, "probe focused", func() bool { st := readState(e.statePath); return st.Window != nil })
670+
671+ for _, sel := range []struct{ x, w string }{{"CLIPBOARD", ""}, {"PRIMARY", "--primary"}} {
672+ for _, size := range []int{4 << 10, 1 << 20} {
673+ name := fmt.Sprintf("%s/%dKiB", sel.x, size>>10)
674+ text := payload(size)
675+ t.Run("W2X/"+name, func(t *testing.T) {
676+ var wargs []string
677+ if sel.w != "" {
678+ wargs = append(wargs, sel.w)
679+ }
680+ wlCopy(t, text, wargs...)
681+ until(t, "x11probe pastes the Wayland copy", func() bool {
682+ return runProbe(t, e, "-paste", sel.x) == pasteLine(text)
683+ })
684+ })
685+ t.Run("X2W/"+name, func(t *testing.T) {
686+ file := filepath.Join(t.TempDir(), "text")
687+ if err := os.WriteFile(file, []byte(text), 0o600); err != nil {
688+ t.Fatal(err)
689+ }
690+ p := startProbe(t, e.bin, e.display, "-copy", "@"+file, "-copy-sel", sel.x, "-copy-for", "30s")
691+ until(t, "copy owned", func() bool { return strings.Contains(p.out.String(), "copy owned") })
692+ var wargs []string
693+ if sel.w != "" {
694+ wargs = append(wargs, sel.w)
695+ }
696+ var got string
697+ until(t, "wl-paste returns the X copy", func() bool {
698+ var ok bool
699+ got, ok = wlPaste(wargs...)
700+ return ok && got == text
701+ })
702+ if got != text {
703+ t.Errorf("wl-paste differs: %d bytes", len(got))
704+ }
705+ })
706+ }
707+ }
708+
709+ t.Run("clear", func(t *testing.T) {
710+ wlCopy(t, "to be cleared")
711+ until(t, "paste before clear", func() bool { return runProbe(t, e, "-paste", "CLIPBOARD") == `paste "to be cleared"` })
712+ cmd := exec.Command("wl-copy", "--clear")
713+ cmd.Env = waylandEnv()
714+ if out, err := cmd.CombinedOutput(); err != nil {
715+ t.Fatalf("wl-copy --clear: %v: %s", err, out)
716+ }
717+ until(t, "paste none after clear", func() bool { return runProbe(t, e, "-paste", "CLIPBOARD") == "paste none" })
718+ })
719+}
720+
721+// cursorTheme installs a one-arrow Xcursor theme of the given size and
722+// selects it through the XCURSOR_* environment.
723+func cursorTheme(t *testing.T, size int) {
724+ t.Helper()
725+ dir := t.TempDir()
726+ themeCursor(t, dir, "left_ptr", size, 1)
727+ t.Setenv("XCURSOR_PATH", dir)
728+ t.Setenv("XCURSOR_THEME", "theme")
729+ t.Setenv("XCURSOR_SIZE", strconv.Itoa(size))
730+}
731+
732+// TestXwaylandXSettings: the XSETTINGS manager publishes the cursor theme,
733+// the cursor size and the DPI of the first output.
734+func TestXwaylandXSettings(t *testing.T) {
735+ cursorTheme(t, 32)
736+ e := startXwayland(t)
737+ want := "xsettings serial=1 Xft/DPI=98304 Gtk/CursorThemeName=theme Gtk/CursorThemeSize=32"
738+ var got string
739+ until(t, "XSETTINGS owner", func() bool { got = runProbe(t, e, "-xsettings"); return got != "xsettings none" })
740+ if got != want {
741+ t.Errorf("-xsettings = %q, want %q", got, want)
742+ }
743+}
744+
745+// TestXwaylandXSettingsScale2: the DPI follows the output scale.
746+func TestXwaylandXSettingsScale2(t *testing.T) {
747+ cursorTheme(t, 24)
748+ e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) {
749+ c.Outputs = append(c.Outputs, ports.OutputConfig{Name: "HEADLESS-1", Scale: 2, ScaleOnly: true})
750+ })
751+ var got string
752+ until(t, "Xft/DPI=196608", func() bool { got = runProbe(t, e, "-xsettings"); return strings.Contains(got, "Xft/DPI=196608") })
753+ if !strings.Contains(got, "Gtk/CursorThemeSize=24") {
754+ t.Errorf("-xsettings = %q, want Gtk/CursorThemeSize=24", got)
755+ }
756+}
757+
758+// TestXwaylandXSettingsOff: xwayland.xsettings = off leaves no manager.
759+func TestXwaylandXSettingsOff(t *testing.T) {
760+ e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Xwayland.XSettings = false })
761+ // A mapped window proves the XWM is up before the absence is checked.
762+ p := startProbe(t, e.bin, e.display)
763+ until(t, "probe mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") })
764+ if got := runProbe(t, e, "-xsettings"); got != "xsettings none" {
765+ t.Errorf("-xsettings = %q, want none", got)
766+ }
767+}
768+
769+// TestXwaylandRootCursor: the X background shows the themed arrow, at the
770+// theme size.
771+func TestXwaylandRootCursor(t *testing.T) {
772+ cursorTheme(t, 32)
773+ e := startXwayland(t)
774+ until(t, "XWM up", func() bool { return runProbe(t, e, "-xsettings") != "xsettings none" })
775+ if got := runProbe(t, e, "-root-cursor"); got != "cursor 32x32" {
776+ t.Errorf("-root-cursor = %q, want cursor 32x32", got)
777+ }
778+}

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