dot

vizie's NixOS desktop: Hyprland, CachyOS kernel and gaming tweaks

111 lines3896 bytesexecutable
1#!/usr/bin/env python3
2"""Bridge the X11 CLIPBOARD to the Wayland clipboard.
3
4Hyprland only forwards XWayland clipboard changes while an X11 window has
5keyboard focus. Wine/Proton sets the clipboard asynchronously from a hidden
6window, so copies from games often never reach Wayland. This watches CLIPBOARD
7ownership via XFixes and pushes any new text to wl-copy.
8"""
9import os
10import subprocess
11import sys
12import time
13
14from Xlib import X, Xatom, display
15from Xlib.ext import xfixes
16
17TIMEOUT = 2.0
18
19
20def 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
42def 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
60def 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
68def 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
110if __name__ == "__main__":
111 main()