dot
vizie's NixOS desktop: Hyprland, CachyOS kernel and gaming tweaks
| 1 | #!/usr/bin/env python3 |
| 2 | """Bridge the X11 CLIPBOARD to the Wayland clipboard. |
| 3 | |
| 4 | Hyprland only forwards XWayland clipboard changes while an X11 window has |
| 5 | keyboard focus. Wine/Proton sets the clipboard asynchronously from a hidden |
| 6 | window, so copies from games often never reach Wayland. This watches CLIPBOARD |
| 7 | ownership via XFixes and pushes any new text to wl-copy. |
| 8 | """ |
| 9 | import os |
| 10 | import subprocess |
| 11 | import sys |
| 12 | import time |
| 13 | |
| 14 | from Xlib import X, Xatom, display |
| 15 | from Xlib.ext import xfixes |
| 16 | |
| 17 | TIMEOUT = 2.0 |
| 18 | |
| 19 | |
| 20 | def read_selection(d, win, sel, target, prop): |
| 21 | win.convert_selection(sel, target, prop, X.CurrentTime) |
| 22 | d.flush() |
| 23 | deadline = time.monotonic() + TIMEOUT |
| 24 | while time.monotonic() < deadline: |
| 25 | while d.pending_events(): |
| 26 | e = d.next_event() |
| 27 | if e.type != X.SelectionNotify: |
| 28 | continue |
| 29 | if e.property == X.NONE: |
| 30 | return None |
| 31 | p = win.get_full_property(prop, X.AnyPropertyType, sizehint=1 << 20) |
| 32 | win.delete_property(prop) |
| 33 | if p is None: |
| 34 | return None |
| 35 | if p.property_type == d.intern_atom("INCR"): |
| 36 | return read_incr(d, win, prop) |
| 37 | return bytes(p.value) |
| 38 | time.sleep(0.01) |
| 39 | return None |
| 40 | |
| 41 | |
| 42 | def read_incr(d, win, prop): |
| 43 | chunks = [] |
| 44 | d.flush() |
| 45 | deadline = time.monotonic() + TIMEOUT * 5 |
| 46 | while time.monotonic() < deadline: |
| 47 | while d.pending_events(): |
| 48 | e = d.next_event() |
| 49 | if e.type == X.PropertyNotify and e.atom == prop and e.state == X.PropertyNewValue: |
| 50 | p = win.get_full_property(prop, X.AnyPropertyType, sizehint=1 << 20) |
| 51 | win.delete_property(prop) |
| 52 | d.flush() |
| 53 | if p is None or not p.value: |
| 54 | return b"".join(chunks) |
| 55 | chunks.append(bytes(p.value)) |
| 56 | time.sleep(0.005) |
| 57 | return None |
| 58 | |
| 59 | |
| 60 | def wayland_text(): |
| 61 | try: |
| 62 | r = subprocess.run(["wl-paste", "-n", "-t", "text"], capture_output=True, timeout=TIMEOUT) |
| 63 | return r.stdout if r.returncode == 0 else None |
| 64 | except subprocess.TimeoutExpired: |
| 65 | return None |
| 66 | |
| 67 | |
| 68 | def main(): |
| 69 | # Xwayland may not be up yet when launched from the compositor's autostart. |
| 70 | for _ in range(60): |
| 71 | try: |
| 72 | d = display.Display(os.environ.get("DISPLAY", ":0")) |
| 73 | break |
| 74 | except Exception: |
| 75 | time.sleep(1) |
| 76 | else: |
| 77 | sys.exit("xwl-clipbridge: could not connect to X display") |
| 78 | root = d.screen().root |
| 79 | win = root.create_window(0, 0, 1, 1, 0, d.screen().root_depth, |
| 80 | event_mask=X.PropertyChangeMask) |
| 81 | clip = d.intern_atom("CLIPBOARD") |
| 82 | utf8 = d.intern_atom("UTF8_STRING") |
| 83 | prop = d.intern_atom("XWL_CLIPBRIDGE") |
| 84 | d.xfixes_query_version() |
| 85 | d.xfixes_select_selection_input(root, clip, xfixes.XFixesSetSelectionOwnerNotifyMask) |
| 86 | d.flush() |
| 87 | owner_notify = d.extension_event.SetSelectionOwnerNotify # (type, subcode) |
| 88 | print("xwl-clipbridge: watching X11 CLIPBOARD", file=sys.stderr, flush=True) |
| 89 | |
| 90 | while True: |
| 91 | e = d.next_event() |
| 92 | if (e.type, getattr(e, "sub_code", None)) != owner_notify: |
| 93 | continue |
| 94 | if e.owner in (X.NONE, win.id): |
| 95 | continue |
| 96 | # Let the owner settle and give Hyprland's own bridge a chance first. |
| 97 | time.sleep(0.15) |
| 98 | text = read_selection(d, win, clip, utf8, prop) |
| 99 | if text is None: |
| 100 | text = read_selection(d, win, clip, Xatom.STRING, prop) |
| 101 | if not text: |
| 102 | continue |
| 103 | # Skip if Wayland already has it (Hyprland bridged it, or it came from Wayland). |
| 104 | if wayland_text() == text: |
| 105 | continue |
| 106 | subprocess.run(["wl-copy", "--type", "text/plain;charset=utf-8"], input=text) |
| 107 | print(f"xwl-clipbridge: forwarded {len(text)} bytes", file=sys.stderr, flush=True) |
| 108 | |
| 109 | |
| 110 | if __name__ == "__main__": |
| 111 | main() |