From ae0f6772c3094ee48e5221e39c63f235e6759fc4 Mon Sep 17 00:00:00 2001 From: akadeepesh Date: Wed, 7 Oct 2026 21:22:24 +0530 Subject: [PATCH] feat(clipboard): add pure-Python X11 clipboard owner for Linux Owns the CLIPBOARD selection through the raw X protocol (stdlib only), so pasting a file works without xclip or any other system package. Serves text/uri-list (read by browsers on paste), x-special/gnome-copied-files and application/x-kde-cutselection, answers TARGETS, and exits when another app takes the clipboard. Reads MIT-MAGIC-COOKIE from XAUTHORITY / ~/.Xauthority. Signals 'ready' once it owns the selection so the launcher can verify the copy. --- contextzip/_x11_clip.py | 338 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 contextzip/_x11_clip.py diff --git a/contextzip/_x11_clip.py b/contextzip/_x11_clip.py new file mode 100644 index 0000000..0fbb298 --- /dev/null +++ b/contextzip/_x11_clip.py @@ -0,0 +1,338 @@ +""" +_x11_clip.py — a tiny X11 CLIPBOARD owner written against the raw X protocol. + +Why this exists: on Linux, "a file on the clipboard" means owning the X +CLIPBOARD selection and answering requests for ``text/uri-list`` (what +browsers read when you paste) with a ``file:///…`` URL. Normally that needs +``xclip`` — a system package that ``pip install`` can't provide. The protocol +subset needed is small, so contextzip speaks it itself: standard library only, +no dependencies. + +It offers the same targets a file manager offers when you press Ctrl+C on a +file, so it works for browsers (text/uri-list), GNOME-family file managers +(x-special/gnome-copied-files) and KDE (application/x-kde-cutselection). + +Runs two ways: + * imported: ``read_selection()`` reads the clipboard back (verification) + * as a script ``python _x11_clip.py serve `` — the background + process that keeps owning the selection until another app copies + something (SelectionClear) or the X connection closes. It is launched by + path, so this file must stay importable without the contextzip package. +""" + +from __future__ import annotations + +import os +import socket +import struct +import sys +from pathlib import Path + +URI_LIST = "text/uri-list" +GNOME_COPIED = "x-special/gnome-copied-files" +KDE_CUT = "application/x-kde-cutselection" + +_ATOM = 4 # predefined atom: ATOM +_SELECTION_CLEAR = 29 +_SELECTION_REQUEST = 30 +_SELECTION_NOTIFY = 31 + + +class X11Error(Exception): + pass + + +def _pad(n: int) -> int: + return (4 - n % 4) % 4 + + +def payloads_for(path: str) -> dict[str, bytes]: + """Clipboard targets (and their data) for one file, as a file manager sets them.""" + uri = Path(path).as_uri() + return { + URI_LIST: (uri + "\r\n").encode(), + GNOME_COPIED: ("copy\n" + uri).encode(), + KDE_CUT: b"0", + } + + +# --------------------------------------------------------------------------- +# Connection +# --------------------------------------------------------------------------- + + +def _parse_display(display: str) -> tuple[str, int]: + host, sep, rest = display.rpartition(":") + if not sep or not rest: + raise X11Error(f"unusable DISPLAY {display!r}") + try: + return host, int(rest.split(".")[0]) + except ValueError: + raise X11Error(f"unusable DISPLAY {display!r}") from None + + +def _open_socket(host: str, num: int, timeout: float | None) -> socket.socket: + if host in ("", "unix"): + # Xorg/XWayland listen on a filesystem socket and (on Linux) an abstract one. + for addr in (f"/tmp/.X11-unix/X{num}", f"\0/tmp/.X11-unix/X{num}"): + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(timeout) + try: + sock.connect(addr) + return sock + except OSError: + sock.close() + raise X11Error(f"cannot connect to X display :{num}") + try: + return socket.create_connection((host, 6000 + num), timeout) + except OSError as exc: + raise X11Error(f"cannot connect to X display {host}:{num}") from exc + + +def _read_xauthority() -> list[tuple[int, bytes, bytes, bytes, bytes]]: + path = os.environ.get("XAUTHORITY") or os.path.join(os.path.expanduser("~"), ".Xauthority") + try: + data = Path(path).read_bytes() + except OSError: + return [] + entries, i = [], 0 + try: + while i + 2 <= len(data): + (family,) = struct.unpack(">H", data[i : i + 2]) + i += 2 + fields = [] + for _ in range(4): # address, display number, auth name, auth data + (n,) = struct.unpack(">H", data[i : i + 2]) + i += 2 + fields.append(data[i : i + n]) + i += n + entries.append((family, *fields)) + except struct.error: + pass + return entries + + +def _auth_for(host: str, num: int) -> tuple[bytes, bytes]: + """MIT-MAGIC-COOKIE-1 for this display from ~/.Xauthority ($XAUTHORITY), if any.""" + cookies = [ + e + for e in _read_xauthority() + if e[3] == b"MIT-MAGIC-COOKIE-1" and e[2] in (b"", str(num).encode()) + ] + local = host in ("", "unix") + me = socket.gethostname().encode() + for family, addr, _number, name, value in cookies: + if local and (family == 65535 or (family == 256 and addr == me)): + return name, value + if cookies: # hostname changed (containers, renamed machines): number match is enough + return cookies[0][3], cookies[0][4] + return b"", b"" # open display (xhost +local:, Xvfb -ac) + + +class Connection: + """Just enough X11 client for selection handling. Little-endian, core protocol only.""" + + def __init__(self, display: str | None = None, timeout: float | None = 5.0): + display = display if display is not None else os.environ.get("DISPLAY", "") + host, num = _parse_display(display) + self.sock = _open_socket(host, num, timeout) + self.sock.settimeout(timeout) + self.seq = 0 + self.events: list[tuple[int, bytes]] = [] + self._handshake(*_auth_for(host, num)) + self._next_id = 0 + + # -- wire helpers ------------------------------------------------------- + + def _recv(self, n: int) -> bytes: + buf = b"" + while len(buf) < n: + chunk = self.sock.recv(n - len(buf)) + if not chunk: + raise X11Error("X connection closed") + buf += chunk + return buf + + def _handshake(self, auth_name: bytes, auth_data: bytes) -> None: + req = struct.pack(" None: + assert len(body) % 4 == 0 + self.sock.sendall(struct.pack(" tuple[int, bytes]: + head = self._recv(32) + code = head[0] & 0x7F + if code == 1: # reply: may carry extra data + (extra,) = struct.unpack(" bytes: + while True: + code, pkt = self._read_packet() + if code == 0: + raise X11Error(f"X error {pkt[1]} (request {struct.unpack(' tuple[int, bytes]: + """Next event; asynchronous errors are ignored.""" + while True: + if self.events: + return self.events.pop(0) + code, pkt = self._read_packet() + if code not in (0, 1): + return code, pkt + + # -- requests ----------------------------------------------------------- + + def intern(self, name: str) -> int: + raw = name.encode() + self._request(16, 0, struct.pack(" int: + """A 1x1 InputOnly window: selections need a window to be owned from.""" + step = self.id_mask & -self.id_mask + wid = self.id_base | (step * (self._next_id + 1)) + self._next_id += 1 + # wid, parent, x, y, w, h, border, class=InputOnly, visual=CopyFromParent, mask=0 + self._request(1, 0, struct.pack(" None: + self._request(22, 0, struct.pack(" int: + self._request(23, 0, struct.pack(" None: + units = len(data) // (fmt // 8) + body = struct.pack(" None: + event = struct.pack(" None: + self._request(24, 0, struct.pack(" tuple[int, bytes]: + self._request(20, 1, struct.pack(" None: + try: + self.sock.close() + except OSError: + pass + + +# --------------------------------------------------------------------------- +# Reading (used to verify the copy, and by the tests) +# --------------------------------------------------------------------------- + + +def read_selection( + target: str = URI_LIST, *, display: str | None = None, timeout: float = 2.0 +) -> bytes | None: + """Ask the current CLIPBOARD owner for *target*. None if nobody offers it.""" + conn = Connection(display, timeout=timeout) + try: + win = conn.create_window() + clipboard, dest = conn.intern("CLIPBOARD"), conn.intern("CZ_CLIPBOARD_READ") + conn.convert_selection(win, clipboard, conn.intern(target), dest) + while True: + code, pkt = conn.next_event() + if code == _SELECTION_NOTIFY: + (prop,) = struct.unpack(" None: + """ + Own the CLIPBOARD selection for *path* until something else takes it. + *on_ready* is called once ownership is confirmed (the launcher waits for it). + """ + conn = Connection(display, timeout=5.0) + win = conn.create_window() + clipboard, targets = conn.intern("CLIPBOARD"), conn.intern("TARGETS") + offered = {conn.intern(name): data for name, data in payloads_for(path).items()} + + conn.set_selection_owner(win, clipboard) + if conn.get_selection_owner(clipboard) != win: + raise X11Error("could not become CLIPBOARD owner") + conn.sock.settimeout(None) # from here on: block until the server says something + if on_ready: + on_ready() + + while True: + code, pkt = conn.next_event() + if code == _SELECTION_CLEAR: + return # someone else copied something — our job is done + if code != _SELECTION_REQUEST: + continue + time, _owner, requestor, selection, target, prop = struct.unpack(" int: + if len(argv) == 3 and argv[1] == "serve": + def ready() -> None: + sys.stdout.write("ready\n") + sys.stdout.flush() + + try: + serve(argv[2], on_ready=ready) + except (OSError, X11Error): + return 1 + return 0 + sys.stderr.write("usage: _x11_clip.py serve \n") + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv))