Skip to content

Commit 3ec8ff3

Browse files
committed
Add bidirectional clipboard sync for Remote Desktop
A new CLIPBOARD message type carries a JSON envelope so viewers and the host can swap clipboards explicitly: {"kind": "text", "text": "..."} {"kind": "image", "format": "png", "data_b64": "..."} Existing utils/clipboard/clipboard.py is extended with get_clipboard_image / set_clipboard_image. Windows uses CF_DIB via ctypes (Pillow rasterises PNG -> BMP -> DIB); Linux shells out to 'xclip -t image/png'; macOS get works via Pillow ImageGrab and set raises a clear NotImplementedError pending a PyObjC backend. Host: broadcast_clipboard_text / broadcast_clipboard_image push to every authenticated viewer; incoming CLIPBOARD messages from a viewer are decoded and applied to the host's local clipboard via the helpers above. Viewer: send_clipboard_text / send_clipboard_image push to the host; incoming CLIPBOARD messages fire an on_clipboard(kind, data) callback so the GUI / library user controls when (and whether) to set the local clipboard. Sync is explicit per-call — no auto-polling that could create paste loops between the two sides. Tests cover the JSON serialisation contract (text + image, malformed input, unknown kinds, missing fields) and end-to-end host<->viewer flow with a recording host that captures apply calls instead of touching the OS clipboard.
1 parent dcb8828 commit 3ec8ff3

7 files changed

Lines changed: 494 additions & 4 deletions

File tree

je_auto_control/utils/clipboard/clipboard.py

Lines changed: 154 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
"""Headless cross-platform text clipboard.
1+
"""Headless cross-platform text + image clipboard.
22
3-
Windows uses Win32 clipboard API via ctypes.
4-
macOS shells out to pbcopy / pbpaste.
5-
Linux shells out to xclip or xsel (whichever is available).
3+
Windows uses Win32 clipboard API via ctypes (CF_UNICODETEXT for text,
4+
CF_DIB for image).
5+
macOS shells out to pbcopy / pbpaste for text; image support requires
6+
PyObjC and is best effort.
7+
Linux shells out to xclip / xsel for text and ``xclip -t image/png`` for
8+
images.
69
710
All functions raise ``RuntimeError`` if the platform backend is missing so
811
callers can degrade gracefully.
912
"""
1013
import shutil
1114
import subprocess # nosec B404 # reason: required for pbcopy/pbpaste/xclip/xsel
1215
import sys
16+
from io import BytesIO
1317
from typing import Optional
1418

1519

@@ -35,6 +39,30 @@ def set_clipboard(text: str) -> None:
3539
_linux_set(text)
3640

3741

42+
def get_clipboard_image() -> Optional[bytes]:
43+
"""Return the clipboard's image as PNG bytes, or ``None`` if no image."""
44+
if sys.platform.startswith("win"):
45+
return _win_get_image()
46+
if sys.platform == "darwin":
47+
return _mac_get_image()
48+
return _linux_get_image()
49+
50+
51+
def set_clipboard_image(png_bytes: bytes) -> None:
52+
"""Place a PNG image (as bytes) onto the clipboard."""
53+
if not isinstance(png_bytes, (bytes, bytearray)):
54+
raise TypeError("set_clipboard_image expects bytes")
55+
if not png_bytes:
56+
raise ValueError("png_bytes is empty")
57+
if sys.platform.startswith("win"):
58+
_win_set_image(bytes(png_bytes))
59+
return
60+
if sys.platform == "darwin":
61+
_mac_set_image(bytes(png_bytes))
62+
return
63+
_linux_set_image(bytes(png_bytes))
64+
65+
3866
# === Windows backend =========================================================
3967

4068
def _win_get() -> str:
@@ -160,3 +188,125 @@ def _linux_set(text: str) -> None:
160188
write_cmd, input=text.encode("utf-8"),
161189
check=True, timeout=5,
162190
)
191+
192+
193+
# === Image clipboard backends ===============================================
194+
195+
196+
def _win_get_image() -> Optional[bytes]:
197+
"""Return the Windows clipboard image as PNG bytes, or None."""
198+
try:
199+
from PIL import ImageGrab # noqa: PLC0415 lazy import
200+
except ImportError as error:
201+
raise RuntimeError(
202+
"Pillow is required for clipboard image support"
203+
) from error
204+
image = ImageGrab.grabclipboard()
205+
if image is None or isinstance(image, list):
206+
return None
207+
buffer = BytesIO()
208+
if image.mode != "RGB":
209+
image = image.convert("RGB")
210+
image.save(buffer, format="PNG")
211+
return buffer.getvalue()
212+
213+
214+
def _win_set_image(png_bytes: bytes) -> None:
215+
"""Set the Windows clipboard image from PNG bytes (CF_DIB)."""
216+
try:
217+
from PIL import Image # noqa: PLC0415 lazy import
218+
except ImportError as error:
219+
raise RuntimeError(
220+
"Pillow is required for clipboard image support"
221+
) from error
222+
image = Image.open(BytesIO(png_bytes))
223+
if image.mode != "RGB":
224+
image = image.convert("RGB")
225+
bmp_buf = BytesIO()
226+
image.save(bmp_buf, format="BMP")
227+
# CF_DIB excludes the 14-byte BITMAPFILEHEADER prefix that BMP files use.
228+
dib = bmp_buf.getvalue()[14:]
229+
230+
import ctypes # noqa: PLC0415
231+
from ctypes import wintypes # noqa: PLC0415
232+
233+
user32 = ctypes.WinDLL("user32", use_last_error=True)
234+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
235+
cf_dib = 8
236+
gmem_moveable = 0x0002
237+
238+
user32.OpenClipboard.argtypes = [wintypes.HWND]
239+
user32.OpenClipboard.restype = wintypes.BOOL
240+
user32.EmptyClipboard.restype = wintypes.BOOL
241+
user32.SetClipboardData.argtypes = [wintypes.UINT, wintypes.HANDLE]
242+
user32.SetClipboardData.restype = wintypes.HANDLE
243+
user32.CloseClipboard.restype = wintypes.BOOL
244+
kernel32.GlobalAlloc.argtypes = [wintypes.UINT, ctypes.c_size_t]
245+
kernel32.GlobalAlloc.restype = wintypes.HGLOBAL
246+
kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL]
247+
kernel32.GlobalLock.restype = ctypes.c_void_p
248+
kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL]
249+
250+
handle = kernel32.GlobalAlloc(gmem_moveable, len(dib))
251+
if not handle:
252+
raise RuntimeError("GlobalAlloc failed")
253+
pointer = kernel32.GlobalLock(handle)
254+
if not pointer:
255+
raise RuntimeError("GlobalLock failed")
256+
ctypes.memmove(pointer, dib, len(dib))
257+
kernel32.GlobalUnlock(handle)
258+
if not user32.OpenClipboard(None):
259+
raise RuntimeError("OpenClipboard failed")
260+
try:
261+
user32.EmptyClipboard()
262+
if not user32.SetClipboardData(cf_dib, handle):
263+
raise RuntimeError("SetClipboardData(CF_DIB) failed")
264+
finally:
265+
user32.CloseClipboard()
266+
267+
268+
def _mac_get_image() -> Optional[bytes]:
269+
"""Read clipboard image via Pillow's ImageGrab; raises if PIL missing."""
270+
try:
271+
from PIL import ImageGrab # noqa: PLC0415
272+
except ImportError as error:
273+
raise RuntimeError(
274+
"Pillow is required for clipboard image support on macOS"
275+
) from error
276+
image = ImageGrab.grabclipboard()
277+
if image is None or isinstance(image, list):
278+
return None
279+
buffer = BytesIO()
280+
if image.mode != "RGB":
281+
image = image.convert("RGB")
282+
image.save(buffer, format="PNG")
283+
return buffer.getvalue()
284+
285+
286+
def _mac_set_image(_png_bytes: bytes) -> None:
287+
raise RuntimeError(
288+
"Setting clipboard images on macOS requires PyObjC; not yet supported"
289+
)
290+
291+
292+
def _linux_get_image() -> Optional[bytes]:
293+
if not shutil.which("xclip"):
294+
raise RuntimeError("Install xclip for Linux clipboard image support")
295+
# nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit.dangerous-subprocess-use-audit
296+
result = subprocess.run( # nosec B603 B607 # reason: hard-coded argv to xclip
297+
["xclip", "-selection", "clipboard", "-t", "image/png", "-o"],
298+
capture_output=True, check=False, timeout=5,
299+
)
300+
if result.returncode != 0 or not result.stdout:
301+
return None
302+
return result.stdout
303+
304+
305+
def _linux_set_image(png_bytes: bytes) -> None:
306+
if not shutil.which("xclip"):
307+
raise RuntimeError("Install xclip for Linux clipboard image support")
308+
# nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit.dangerous-subprocess-use-audit
309+
subprocess.run( # nosec B603 B607 # reason: hard-coded argv to xclip
310+
["xclip", "-selection", "clipboard", "-t", "image/png", "-i"],
311+
input=png_bytes, check=True, timeout=5,
312+
)

je_auto_control/utils/remote_desktop/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
AudioBackendError, AudioCapture, AudioPlayer,
1414
is_audio_backend_available,
1515
)
16+
from je_auto_control.utils.remote_desktop.clipboard_sync import (
17+
ClipboardSyncError,
18+
)
1619
from je_auto_control.utils.remote_desktop.host import RemoteDesktopHost
1720
from je_auto_control.utils.remote_desktop.host_id import (
1821
HostIdError, format_host_id, generate_host_id, load_or_create_host_id,
@@ -42,4 +45,5 @@
4245
"load_or_create_host_id", "parse_host_id", "validate_host_id",
4346
"AudioBackendError", "AudioCapture", "AudioPlayer",
4447
"is_audio_backend_available",
48+
"ClipboardSyncError",
4549
]
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Serialization helpers for CLIPBOARD messages.
2+
3+
The wire format is a JSON envelope so adding new payload kinds (rich
4+
text, file lists, ...) doesn't require touching the framing layer:
5+
6+
* ``{"kind": "text", "text": "..."}``
7+
* ``{"kind": "image", "format": "png", "data_b64": "..."}``
8+
"""
9+
import base64
10+
import json
11+
from typing import Any, Dict, Tuple
12+
13+
14+
class ClipboardSyncError(ValueError):
15+
"""Raised when a CLIPBOARD payload is malformed or unsupported."""
16+
17+
18+
def encode_text(text: str) -> bytes:
19+
"""Encode a text-clipboard payload."""
20+
if not isinstance(text, str):
21+
raise TypeError("text must be a string")
22+
return json.dumps(
23+
{"kind": "text", "text": text}, ensure_ascii=False,
24+
).encode("utf-8")
25+
26+
27+
def encode_image(png_bytes: bytes) -> bytes:
28+
"""Encode a PNG image as a clipboard payload."""
29+
if not isinstance(png_bytes, (bytes, bytearray)):
30+
raise TypeError("png_bytes must be bytes")
31+
if not png_bytes:
32+
raise ValueError("png_bytes is empty")
33+
return json.dumps({
34+
"kind": "image",
35+
"format": "png",
36+
"data_b64": base64.b64encode(bytes(png_bytes)).decode("ascii"),
37+
}, ensure_ascii=False).encode("utf-8")
38+
39+
40+
def decode(payload: bytes) -> Tuple[str, Any]:
41+
"""Parse a CLIPBOARD payload; return ``(kind, data)``.
42+
43+
For ``"text"`` ``data`` is a ``str``; for ``"image"`` it is the raw
44+
PNG bytes (already base64-decoded).
45+
"""
46+
try:
47+
envelope: Dict[str, Any] = json.loads(payload.decode("utf-8"))
48+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
49+
raise ClipboardSyncError(f"invalid CLIPBOARD JSON: {error}") from error
50+
if not isinstance(envelope, dict):
51+
raise ClipboardSyncError("CLIPBOARD payload must be a JSON object")
52+
kind = envelope.get("kind")
53+
if kind == "text":
54+
text = envelope.get("text")
55+
if not isinstance(text, str):
56+
raise ClipboardSyncError("text payload missing 'text' string")
57+
return ("text", text)
58+
if kind == "image":
59+
if envelope.get("format") != "png":
60+
raise ClipboardSyncError(
61+
f"image format {envelope.get('format')!r} not supported"
62+
)
63+
encoded = envelope.get("data_b64", "")
64+
if not isinstance(encoded, str):
65+
raise ClipboardSyncError("image payload missing 'data_b64'")
66+
try:
67+
return ("image", base64.b64decode(encoded))
68+
except (ValueError, TypeError) as error:
69+
raise ClipboardSyncError(
70+
f"invalid base64 image payload: {error}"
71+
) from error
72+
raise ClipboardSyncError(f"unknown clipboard kind: {kind!r}")

je_auto_control/utils/remote_desktop/host.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
from je_auto_control.utils.remote_desktop.auth import (
1818
NONCE_BYTES, make_nonce, verify_response,
1919
)
20+
from je_auto_control.utils.remote_desktop.clipboard_sync import (
21+
ClipboardSyncError, decode as decode_clipboard, encode_image, encode_text,
22+
)
2023
from je_auto_control.utils.remote_desktop.host_id import (
2124
load_or_create_host_id, validate_host_id,
2225
)
@@ -204,11 +207,31 @@ def _recv_loop(self) -> None:
204207
if msg_type is MessageType.INPUT:
205208
self._handle_input_payload(payload)
206209
continue
210+
if msg_type is MessageType.CLIPBOARD:
211+
self._handle_clipboard_payload(payload)
212+
continue
207213
autocontrol_logger.info(
208214
"remote_desktop unexpected msg %s from %s",
209215
msg_type.name, self._address,
210216
)
211217

218+
def _handle_clipboard_payload(self, payload: bytes) -> None:
219+
try:
220+
kind, data = decode_clipboard(payload)
221+
except ClipboardSyncError as error:
222+
autocontrol_logger.info(
223+
"remote_desktop bad CLIPBOARD from %s: %r",
224+
self._address, error,
225+
)
226+
return
227+
try:
228+
self._host._apply_clipboard(kind, data)
229+
except (OSError, RuntimeError, TypeError, ValueError) as error:
230+
autocontrol_logger.warning(
231+
"remote_desktop clipboard apply failed for %s: %r",
232+
self._address, error,
233+
)
234+
212235
def _handle_input_payload(self, payload: bytes) -> None:
213236
try:
214237
message = json.loads(payload.decode("utf-8"))
@@ -428,6 +451,47 @@ def _broadcast_audio(self, chunk: bytes) -> None:
428451
for client in clients:
429452
client.push_audio(chunk)
430453

454+
def broadcast_clipboard_text(self, text: str) -> int:
455+
"""Send a text-clipboard message to every authenticated viewer."""
456+
return self._broadcast_clipboard_payload(encode_text(text))
457+
458+
def broadcast_clipboard_image(self, png_bytes: bytes) -> int:
459+
"""Send a PNG image to every authenticated viewer's clipboard."""
460+
return self._broadcast_clipboard_payload(encode_image(png_bytes))
461+
462+
def _broadcast_clipboard_payload(self, payload: bytes) -> int:
463+
with self._clients_lock:
464+
clients = [c for c in self._clients
465+
if c.authenticated and not c._shutdown.is_set()]
466+
sent = 0
467+
for client in clients:
468+
try:
469+
client._channel.send_typed(MessageType.CLIPBOARD, payload)
470+
sent += 1
471+
except (OSError, ConnectionError) as error:
472+
autocontrol_logger.info(
473+
"remote_desktop clipboard send to %s failed: %r",
474+
client.address, error,
475+
)
476+
client.stop()
477+
return sent
478+
479+
def _apply_clipboard(self, kind: str, data: Any) -> None:
480+
"""Set this host's local clipboard from a decoded CLIPBOARD payload.
481+
482+
Subclasses or tests may override; the default routes to the
483+
utils.clipboard helpers and accepts ``"text"`` / ``"image"`` kinds.
484+
"""
485+
from je_auto_control.utils.clipboard.clipboard import (
486+
set_clipboard, set_clipboard_image,
487+
)
488+
if kind == "text":
489+
set_clipboard(data)
490+
elif kind == "image":
491+
set_clipboard_image(data)
492+
else:
493+
raise ValueError(f"unsupported clipboard kind: {kind!r}")
494+
431495
# internals -----------------------------------------------------------
432496

433497
def _accept_loop(self) -> None:

je_auto_control/utils/remote_desktop/protocol.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class MessageType(enum.IntEnum):
3333
FRAME = 0x10 # host -> viewer: JPEG frame
3434
AUDIO = 0x11 # host -> viewer: PCM audio chunk
3535
INPUT = 0x20 # viewer -> host: JSON input message
36+
CLIPBOARD = 0x21 # either way: clipboard payload (text or image)
3637
PING = 0x30 # either way: liveness
3738

3839

0 commit comments

Comments
 (0)