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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +560 -1
M internal/app/testdata/x11probe/main.go +358 -0
| @@ -15,9 +15,19 @@ | |||
| 15 | 15 | // leave it that long after (prints "fullscreen off WxH"). -keyboard-grab | |
| 16 | 16 | // calls XGrabKeyboard on the window once it is mapped (prints "grab"). | |
| 17 | 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 | 27 | package main | |
| 19 | 28 | ||
| 20 | 29 | import ( | |
| 30 | + | "crypto/sha256" | |
| 21 | 31 | "encoding/binary" | |
| 22 | 32 | "flag" | |
| 23 | 33 | "fmt" | |
| @@ -30,6 +40,7 @@ import ( | |||
| 30 | 40 | ||
| 31 | 41 | "bnema.dev/xturbo" | |
| 32 | 42 | "bnema.dev/xturbo/shape" | |
| 43 | + | "bnema.dev/xturbo/xfixes" | |
| 33 | 44 | "bnema.dev/xturbo/xproto" | |
| 34 | 45 | ) | |
| 35 | 46 | ||
| @@ -62,7 +73,16 @@ func run() error { | |||
| 62 | 73 | fullscreenOff := flag.Duration("fullscreen-off", 0, "leave fullscreen this long after the request (0: never)") | |
| 63 | 74 | keyboardGrab := flag.Bool("keyboard-grab", false, "XGrabKeyboard on the window once it is mapped") | |
| 64 | 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 | 82 | flag.Parse() | |
| 83 | + | if *copyText != "" || *paste != "" || *xsettings || *rootCursor { | |
| 84 | + | return runTool(*copyText, *copySel, *copyFor, *paste, *xsettings, *rootCursor) | |
| 85 | + | } | |
| 66 | 86 | atX, atY, okAt := strings.Cut(*at, ",") | |
| 67 | 87 | if !okAt { | |
| 68 | 88 | return fmt.Errorf("bad -at %q", *at) | |
| @@ -316,3 +336,341 @@ func atoi(s string) int { | |||
| 316 | 336 | n, _ := strconv.Atoi(s) | |
| 317 | 337 | 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 | + | } | |
| @@ -15,9 +15,19 @@ | |||||
| 15 | // leave it that long after (prints "fullscreen off WxH"). -keyboard-grab | 15 | // 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 main | 27 | 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 n | 337 | 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 | |||
| 8 | 8 | ||
| 9 | 9 | import ( | |
| 10 | 10 | "context" | |
| 11 | + | "crypto/sha256" | |
| 12 | + | "fmt" | |
| 11 | 13 | "image" | |
| 12 | 14 | "image/png" | |
| 13 | 15 | "io" | |
| @@ -15,6 +17,7 @@ import ( | |||
| 15 | 17 | "os/exec" | |
| 16 | 18 | "path/filepath" | |
| 17 | 19 | "regexp" | |
| 20 | + | "strconv" | |
| 18 | 21 | "strings" | |
| 19 | 22 | "sync" | |
| 20 | 23 | "testing" | |
| @@ -79,7 +82,7 @@ func startXwaylandWith(t *testing.T, sizes [][2]int, mutate func(*ports.Config)) | |||
| 79 | 82 | logs := &lockedBuffer{} | |
| 80 | 83 | ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: logs}))) | |
| 81 | 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 | 86 | if mutate != nil { | |
| 84 | 87 | mutate(&cfg) | |
| 85 | 88 | } | |
| @@ -575,3 +578,201 @@ func TestXwaylandClassRule(t *testing.T) { | |||
| 575 | 578 | 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 | + | } | |
| @@ -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 false | 578 | 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 | + | } | |||