From df63ddb347fb70630918f1cf2c40347d8edce2bd Mon Sep 17 00:00:00 2001 From: tranquil-tr0 Date: Sat, 21 Feb 2026 23:11:54 -0800 Subject: [PATCH 1/5] midpoint implement proper wayland protocol support --- .../src/clipboard/clipboard_manager.py | 13 +- .../src/clipboard/clipboard_monitor_linux.py | 304 +++++++++++++++++- ClipCascade_Desktop/src/core/constants.py | 36 ++- .../src/requirements_linux.txt | 1 + .../src/requirements_linux_cli.txt | 1 + 5 files changed, 337 insertions(+), 18 deletions(-) diff --git a/ClipCascade_Desktop/src/clipboard/clipboard_manager.py b/ClipCascade_Desktop/src/clipboard/clipboard_manager.py index 8a7767b3..1bc02db6 100644 --- a/ClipCascade_Desktop/src/clipboard/clipboard_manager.py +++ b/ClipCascade_Desktop/src/clipboard/clipboard_manager.py @@ -218,6 +218,11 @@ def paste(self, payload: any, payload_type: str = "text"): if PLATFORM == WINDOWS or PLATFORM == MACOS: pyperclip.copy(payload) elif PLATFORM.startswith(LINUX): + if EXT_DATA_CONTROL_SUPPORT: + if clipboard_monitor.wayland_set_clipboard( + payload.encode("utf-8"), "text/plain" + ): + return if XMODE and self.is_x_clipboard_owner: ClipboardManager.execute_command( "xclip", @@ -253,12 +258,16 @@ def paste(self, payload: any, payload_type: str = "text"): pb_image = pasteboard.Pasteboard() pb_image.set_contents(tiff_data, pasteboard.TIFF) elif PLATFORM.startswith(LINUX): - # Save the image to a binary buffer in PNG format with io.BytesIO() as output: payload.convert("RGB").save(output, format="PNG") png_data = output.getvalue() - clipboard_monitor.enable_block_image_once() # Block image copy to prevent deadlock + clipboard_monitor.enable_block_image_once() + if EXT_DATA_CONTROL_SUPPORT: + if clipboard_monitor.wayland_set_clipboard( + png_data, "image/png" + ): + return if XMODE and self.is_x_clipboard_owner: ClipboardManager.execute_command( "xclip", diff --git a/ClipCascade_Desktop/src/clipboard/clipboard_monitor_linux.py b/ClipCascade_Desktop/src/clipboard/clipboard_monitor_linux.py index 60350e49..303a610b 100644 --- a/ClipCascade_Desktop/src/clipboard/clipboard_monitor_linux.py +++ b/ClipCascade_Desktop/src/clipboard/clipboard_monitor_linux.py @@ -1,4 +1,5 @@ import logging +import os import re import subprocess import threading @@ -15,6 +16,196 @@ _run_poll = threading.Event() +class _WaylandClipboardMonitor: + def __init__( + self, + enable_image_monitoring=False, + enable_file_monitoring=False, + ): + self.enable_image_monitoring = enable_image_monitoring + self.enable_file_monitoring = enable_file_monitoring + self.display = None + self.data_control_manager = None + self.data_device = None + self.seat = None + self.current_offer = None + self.current_offer_mime_types = [] + self.running = False + self.previous_clipboard = None + + def _handle_data_offer(self, offer): + self.current_offer_mime_types = [] + + @offer.event("offer") + def on_offer(offer_obj, mime_type): + self.current_offer_mime_types.append(mime_type) + + def _handle_selection(self, offer): + global _block_image_once + + if self.current_offer is not None: + try: + self.current_offer.destroy() + except Exception: + pass + + self.current_offer = offer + + if offer is None: + return + + mime_types = self.current_offer_mime_types.copy() + type_ = convert_mime_to_generic_type(mime_types) + + if type_ == "text": + data = self._receive_data(offer, "text/plain;charset=utf-8") + if data is None: + data = self._receive_data(offer, "text/plain") + if data: + text = data.decode("utf-8") + if text and text != self.previous_clipboard: + self.previous_clipboard = text + if _callback_update: + _callback_update("text", text) + + elif type_ == "image" and self.enable_image_monitoring: + png_mime = next((m for m in mime_types if m.startswith("image/png")), None) + if png_mime: + data = self._receive_data(offer, png_mime) + else: + data = self._receive_data(offer, mime_types[0]) + + if data and data != self.previous_clipboard: + self.previous_clipboard = data + if _callback_update and not _block_image_once: + _callback_update("image", data) + else: + _block_image_once = False + + elif type_ == "files" and self.enable_file_monitoring: + data = self._receive_data(offer, "text/uri-list") + if data: + files = data.decode("utf-8") + files = files.replace("\r\n", "\n").replace("\r", "\n").split("\n") + files = [f.strip() for f in files if f.strip()] + if files and files != self.previous_clipboard: + self.previous_clipboard = files + if _callback_update: + _callback_update("files", files) + + def _receive_data(self, offer, mime_type): + read_fd, write_fd = os.pipe() + try: + offer.receive(mime_type, write_fd) + os.close(write_fd) + self.display.roundtrip() + + data = b"" + while True: + chunk = os.read(read_fd, 4096) + if not chunk: + break + data += chunk + return data if data else None + except Exception as e: + logging.error(f"Failed to receive clipboard data: {e}") + return None + finally: + try: + os.close(read_fd) + except Exception: + pass + + def start(self): + try: + from pywayland.client import Display + + self.display = Display() + self.display.connect() + + registry = self.display.get_registry() + + def on_global(reg, name, interface, version): + if interface == "ext_data_control_manager_v1": + self.data_control_manager = reg.bind(name, interface, version) + elif interface == "wl_seat": + self.seat = reg.bind(name, interface, version) + + registry.dispatcher["global"] = on_global + + self.display.roundtrip() + + if self.data_control_manager is None: + logging.error( + "ext_data_control_v1 protocol not available. " + "Falling back to polling mode." + ) + self.stop() + return False + + if self.seat is None: + logging.error("No wl_seat available. Falling back to polling mode.") + self.stop() + return False + + self.data_device = self.data_control_manager.get_data_device(self.seat) + + def on_data_offer(device, offer_id): + self._handle_data_offer(offer_id) + + def on_selection(device, offer): + self._handle_selection(offer) + + def on_primary_selection(device, offer): + pass + + self.data_device.dispatcher["data_offer"] = on_data_offer + self.data_device.dispatcher["selection"] = on_selection + self.data_device.dispatcher["primary_selection"] = on_primary_selection + + self.running = True + self.display.roundtrip() + + while self.running and _run_poll.is_set(): + self.display.dispatch(block=True) + + return True + except Exception as e: + logging.error(f"Failed to start Wayland clipboard monitor: {e}") + self.stop() + return False + + def stop(self): + self.running = False + if self.current_offer is not None: + try: + self.current_offer.destroy() + except Exception: + pass + self.current_offer = None + if self.data_device is not None: + try: + self.data_device.destroy() + except Exception: + pass + self.data_device = None + if self.data_control_manager is not None: + try: + self.data_control_manager.destroy() + except Exception: + pass + self.data_control_manager = None + if self.display is not None: + try: + self.display.disconnect() + except Exception: + pass + self.display = None + + +_wayland_monitor = None + + def _on_clipboard_changed( clipboard, event=None, enable_image_monitoring=False, enable_file_monitoring=False ): @@ -58,7 +249,7 @@ def _monitor_x_wl_clipboard( ): global _block_image_once last_error = None - previous_clipboard: str | bytes | None = None + previous_clipboard: str | bytes | list | None = None ignore_patterns = [ r"target .+ not available", # xclip pattern r"no suitable type of content copied", # wl-clipboard pattern @@ -250,16 +441,29 @@ def _start_clipboard_polling(enable_image_monitoring, enable_file_monitoring): def _runner(enable_image_monitoring=False, enable_file_monitoring=False): - global _is_gdk_running, _run_poll + global _is_gdk_running, _run_poll, _wayland_monitor + _run_poll.set() + + if EXT_DATA_CONTROL_SUPPORT: + _wayland_monitor = _WaylandClipboardMonitor( + enable_image_monitoring=enable_image_monitoring, + enable_file_monitoring=enable_file_monitoring, + ) + if _wayland_monitor.start(): + return + _wayland_monitor = None + logging.warning( + "ext_data_control_v1 unavailable. Falling back to polling mode." + ) + try: - _run_poll.set() import gi gi.require_version("Gtk", "3.0") gi.require_version("Gdk", "3.0") - from gi.repository import Gtk, Gdk + from gi.repository import Gdk, Gtk - if "x11" in str(type(Gdk.Display.get_default())).lower(): # X11 + if "x11" in str(type(Gdk.Display.get_default())).lower(): clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) clipboard.connect( "owner-change", @@ -292,10 +496,94 @@ def _start(enable_image_monitoring=False, enable_file_monitoring=False): _clipboard_thread.start() +def wayland_set_clipboard(data: bytes, mime_type: str = "text/plain") -> bool: + global _wayland_monitor + + if not EXT_DATA_CONTROL_SUPPORT: + return False + + try: + from pywayland.client import Display + + display = Display() + display.connect() + + registry = display.get_registry() + + data_control_manager = None + seat = None + + def registry_handler(reg, event): + nonlocal data_control_manager, seat + if event.name == "ext_data_control_manager_v1": + data_control_manager = reg.bind( + event.name, event.interface, event.version + ) + elif event.interface == "wl_seat": + seat = reg.bind(event.name, event.interface, event.version) + + registry.dispatcher["global"] = registry_handler + display.roundtrip() + + if data_control_manager is None or seat is None: + display.disconnect() + return False + + data_device = data_control_manager.get_data_device(seat) + data_source = data_control_manager.create_data_source() + + source_data = data + source_mime = mime_type + + @data_source.event("send") + def on_send(source, mime, fd): + try: + os.write(fd, source_data) + finally: + os.close(fd) + + @data_source.event("cancelled") + def on_cancelled(source): + try: + source.destroy() + except Exception: + pass + + data_source.offer(mime_type) + if mime_type == "text/plain": + data_source.offer("text/plain;charset=utf-8") + data_source.offer("UTF8_STRING") + + data_device.set_selection(data_source) + display.roundtrip() + + time.sleep(0.05) + display.disconnect() + + if _wayland_monitor is not None: + _wayland_monitor.previous_clipboard = ( + data.decode("utf-8") if mime_type.startswith("text") else data + ) + + return True + except Exception as e: + logging.error(f"Failed to set clipboard via ext_data_control_v1: {e}") + return False + + def stop(): - global _clipboard_thread, _callback_update, _block_image_once, _run_poll, _is_gdk_running + global \ + _clipboard_thread, \ + _callback_update, \ + _block_image_once, \ + _run_poll, \ + _is_gdk_running, \ + _wayland_monitor if _clipboard_thread: - if _is_gdk_running: + if _wayland_monitor is not None: + _wayland_monitor.stop() + _wayland_monitor = None + elif _is_gdk_running: import gi gi.require_version("Gtk", "3.0") @@ -304,7 +592,7 @@ def stop(): Gtk.main_quit() _is_gdk_running = False _run_poll.clear() - _clipboard_thread.join() # Wait for the thread to finish + _clipboard_thread.join() _clipboard_thread = None _callback_update = None _block_image_once = False diff --git a/ClipCascade_Desktop/src/core/constants.py b/ClipCascade_Desktop/src/core/constants.py index 5551f5e7..ec70610b 100644 --- a/ClipCascade_Desktop/src/core/constants.py +++ b/ClipCascade_Desktop/src/core/constants.py @@ -38,8 +38,6 @@ def detect_linux_display_server(): wayland_display = os.environ.get("WAYLAND_DISPLAY") x_display = os.environ.get("DISPLAY") - # Priority detection order: X11 > XWayland > Hyprland > Wayland > Unknown - if session_type == "x11": return "X11" @@ -55,17 +53,39 @@ def detect_linux_display_server(): return "Unknown" +def check_ext_data_control_support(): + if os.environ.get("XDG_SESSION_TYPE", "").lower().strip() != "wayland": + return False + + try: + from pywayland.client import Display + + display = Display() + if display: + display.disconnect() + return True + except Exception: + pass + return False + + PLATFORM = get_os_and_display_server() +XMODE = False +EXT_DATA_CONTROL_SUPPORT = False + if PLATFORM.startswith(LINUX): - if ( - detect_linux_display_server() == "X11" - or detect_linux_display_server() == "XWayland" - or detect_linux_display_server() == "Unknown" - ): + display_server = detect_linux_display_server() + if display_server == "X11": XMODE = True - else: + elif display_server == "XWayland": + XMODE = True + EXT_DATA_CONTROL_SUPPORT = check_ext_data_control_support() + elif display_server in ("Wayland", "Hyprland"): XMODE = False + EXT_DATA_CONTROL_SUPPORT = check_ext_data_control_support() + else: + XMODE = True # App version diff --git a/ClipCascade_Desktop/src/requirements_linux.txt b/ClipCascade_Desktop/src/requirements_linux.txt index b613c300..95b64860 100644 --- a/ClipCascade_Desktop/src/requirements_linux.txt +++ b/ClipCascade_Desktop/src/requirements_linux.txt @@ -8,3 +8,4 @@ xxhash==3.5.0 pyfiglet==1.0.2 beautifulsoup4==4.12.3 aiortc==1.10.0 +pywayland>=0.4.0 diff --git a/ClipCascade_Desktop/src/requirements_linux_cli.txt b/ClipCascade_Desktop/src/requirements_linux_cli.txt index dc63be30..82dbc189 100644 --- a/ClipCascade_Desktop/src/requirements_linux_cli.txt +++ b/ClipCascade_Desktop/src/requirements_linux_cli.txt @@ -5,3 +5,4 @@ websocket_client==1.8.0 xxhash==3.5.0 beautifulsoup4==4.12.3 aiortc==1.10.0 +pywayland>=0.4.0 From 9403c3b2e475b833b5b2a96d26892c77f3880485 Mon Sep 17 00:00:00 2001 From: tranquil-tr0 Date: Sat, 21 Feb 2026 23:44:10 -0800 Subject: [PATCH 2/5] Implement proper wayland protocol support --- .../src/clipboard/clipboard_monitor_linux.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/ClipCascade_Desktop/src/clipboard/clipboard_monitor_linux.py b/ClipCascade_Desktop/src/clipboard/clipboard_monitor_linux.py index 303a610b..63174d9c 100644 --- a/ClipCascade_Desktop/src/clipboard/clipboard_monitor_linux.py +++ b/ClipCascade_Desktop/src/clipboard/clipboard_monitor_linux.py @@ -6,6 +6,10 @@ import time from core.constants import * +from pywayland.protocol.ext_data_control_v1.ext_data_control_manager_v1 import ( + ExtDataControlManagerV1, +) +from pywayland.protocol.wayland.wl_seat import WlSeat _callback_update = None _clipboard_thread = None @@ -127,9 +131,11 @@ def start(self): def on_global(reg, name, interface, version): if interface == "ext_data_control_manager_v1": - self.data_control_manager = reg.bind(name, interface, version) + self.data_control_manager = reg.bind( + name, ExtDataControlManagerV1, version + ) elif interface == "wl_seat": - self.seat = reg.bind(name, interface, version) + self.seat = reg.bind(name, WlSeat, version) registry.dispatcher["global"] = on_global @@ -513,14 +519,12 @@ def wayland_set_clipboard(data: bytes, mime_type: str = "text/plain") -> bool: data_control_manager = None seat = None - def registry_handler(reg, event): + def registry_handler(reg, name, interface, version): nonlocal data_control_manager, seat - if event.name == "ext_data_control_manager_v1": - data_control_manager = reg.bind( - event.name, event.interface, event.version - ) - elif event.interface == "wl_seat": - seat = reg.bind(event.name, event.interface, event.version) + if interface == "ext_data_control_manager_v1": + data_control_manager = reg.bind(name, interface, version) + elif interface == "wl_seat": + seat = reg.bind(name, interface, version) registry.dispatcher["global"] = registry_handler display.roundtrip() From 6686960dcc7e9a69eb73da60b42f8f924114efa8 Mon Sep 17 00:00:00 2001 From: tranquil-tr0 Date: Sun, 22 Feb 2026 00:03:44 -0800 Subject: [PATCH 3/5] fix race condition - implement ICE candidate buffering --- ClipCascade_Desktop/src/p2p/p2p_manager.py | 68 +++++++++++++++++----- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/ClipCascade_Desktop/src/p2p/p2p_manager.py b/ClipCascade_Desktop/src/p2p/p2p_manager.py index 2a3abe96..ef25ac01 100644 --- a/ClipCascade_Desktop/src/p2p/p2p_manager.py +++ b/ClipCascade_Desktop/src/p2p/p2p_manager.py @@ -50,22 +50,23 @@ def __init__(self, config: Config, is_login_phase=True): # Fragment variables self.sending_fragment_id = "" # The id of the fragment currently being sent - self.receiving_fragments: dict = ( - {} - ) # Mapping: fragmentid:str -> fragment:list[str] + self.receiving_fragments: dict = {} # Mapping: fragmentid:str -> fragment:list[str] self.sending_fragment_stats: str = None self.receiving_fragment_stats: str = None # p2p variables self.my_peer_id: str = None # Own peer id assigned by the server self.peers: set[str] = set() # All peers in this 'room' - self.peer_connections: dict[str, RTCPeerConnection] = ( - {} - ) # Mapping: peer_id -> RTCPeerConnection - self.data_channels: dict[str, RTCDataChannel] = ( - {} - ) # Mapping: peer_id -> DataChannel + self.peer_connections: dict[ + str, RTCPeerConnection + ] = {} # Mapping: peer_id -> RTCPeerConnection + self.data_channels: dict[ + str, RTCDataChannel + ] = {} # Mapping: peer_id -> DataChannel self.live_connections: int = 0 # Number of active connections + self.pending_ice_candidates: dict[ + str, list + ] = {} # Mapping: peer_id -> list of pending ICE candidates # Event loop for asyncio self.loop = asyncio.new_event_loop() @@ -291,6 +292,7 @@ async def _cleanup_peer_connections(self): self.peer_connections.clear() self.data_channels.clear() self.live_connections = 0 + self.pending_ice_candidates.clear() async def _handle_peer_list(self, peer_list): """ @@ -443,6 +445,9 @@ async def _handle_offer(self, from_peer_id: str, offer: dict): } ) + # Process any buffered ICE candidates for this peer + await self._process_pending_ice_candidates(from_peer_id) + async def _handle_answer(self, from_peer_id: str, answer: dict): """ Handle an incoming ANSWER to our previously sent OFFER. @@ -456,9 +461,13 @@ async def _handle_answer(self, from_peer_id: str, answer: dict): desc = RTCSessionDescription(sdp=answer["sdp"], type=answer["type"]) await pc.setRemoteDescription(desc) + # Process any buffered ICE candidates for this peer + await self._process_pending_ice_candidates(from_peer_id) + async def _handle_ice_candidate(self, from_peer_id: str, candidate_data: dict): """ Handle an incoming ICE candidate from the remote peer. + Buffers candidates if remote description is not yet set. """ pc = self.peer_connections.get(from_peer_id) if not pc: @@ -467,6 +476,20 @@ async def _handle_ice_candidate(self, from_peer_id: str, candidate_data: dict): ) return + # Check if remote description is set + if pc.remoteDescription is None: + # Buffer the candidate for later processing + if from_peer_id not in self.pending_ice_candidates: + self.pending_ice_candidates[from_peer_id] = [] + self.pending_ice_candidates[from_peer_id].append(candidate_data) + return + + await self._add_ice_candidate(pc, candidate_data) + + async def _add_ice_candidate(self, pc: RTCPeerConnection, candidate_data: dict): + """ + Parse and add an ICE candidate to the peer connection. + """ parsed_candidate = P2PManager.parse_ice_candidate_line( candidate_data.get("candidate") ) @@ -486,6 +509,21 @@ async def _handle_ice_candidate(self, from_peer_id: str, candidate_data: dict): ) await pc.addIceCandidate(ice_candidate) + async def _process_pending_ice_candidates(self, peer_id: str): + """ + Process any buffered ICE candidates for a peer after remote description is set. + """ + pending = self.pending_ice_candidates.pop(peer_id, []) + if not pending: + return + + pc = self.peer_connections.get(peer_id) + if not pc: + return + + for candidate_data in pending: + await self._add_ice_candidate(pc, candidate_data) + def _setup_data_channel(self, remote_peer_id: str, channel: RTCDataChannel): """ Set up handlers for an RTCDataChannel (open, message, close, error). @@ -600,9 +638,9 @@ def _receive(self, frame: any) -> str: f"{metadata['index'] + 1}/{metadata['totalFragments']}" ) if metadata["id"] in self.receiving_fragments: - self.receiving_fragments[metadata["id"]][ - metadata["index"] - ] = payload + self.receiving_fragments[metadata["id"]][metadata["index"]] = ( + payload + ) if metadata["index"] == metadata["totalFragments"] - 1: if all( s != "" for s in self.receiving_fragments[metadata["id"]] @@ -621,9 +659,9 @@ def _receive(self, frame: any) -> str: self.receiving_fragments[metadata["id"]] = [""] * metadata[ "totalFragments" ] - self.receiving_fragments[metadata["id"]][ - metadata["index"] - ] = payload + self.receiving_fragments[metadata["id"]][metadata["index"]] = ( + payload + ) return if self.config.data["cipher_enabled"]: From eef53db2dc2e7b7e2054d3e072cdb472d17b4067 Mon Sep 17 00:00:00 2001 From: tranquil-tr0 Date: Sun, 22 Feb 2026 00:59:43 -0800 Subject: [PATCH 4/5] remove unused code --- .../src/clipboard/clipboard_manager.py | 10 --- .../src/clipboard/clipboard_monitor_linux.py | 76 +------------------ 2 files changed, 2 insertions(+), 84 deletions(-) diff --git a/ClipCascade_Desktop/src/clipboard/clipboard_manager.py b/ClipCascade_Desktop/src/clipboard/clipboard_manager.py index 1bc02db6..d53f425c 100644 --- a/ClipCascade_Desktop/src/clipboard/clipboard_manager.py +++ b/ClipCascade_Desktop/src/clipboard/clipboard_manager.py @@ -218,11 +218,6 @@ def paste(self, payload: any, payload_type: str = "text"): if PLATFORM == WINDOWS or PLATFORM == MACOS: pyperclip.copy(payload) elif PLATFORM.startswith(LINUX): - if EXT_DATA_CONTROL_SUPPORT: - if clipboard_monitor.wayland_set_clipboard( - payload.encode("utf-8"), "text/plain" - ): - return if XMODE and self.is_x_clipboard_owner: ClipboardManager.execute_command( "xclip", @@ -263,11 +258,6 @@ def paste(self, payload: any, payload_type: str = "text"): png_data = output.getvalue() clipboard_monitor.enable_block_image_once() - if EXT_DATA_CONTROL_SUPPORT: - if clipboard_monitor.wayland_set_clipboard( - png_data, "image/png" - ): - return if XMODE and self.is_x_clipboard_owner: ClipboardManager.execute_command( "xclip", diff --git a/ClipCascade_Desktop/src/clipboard/clipboard_monitor_linux.py b/ClipCascade_Desktop/src/clipboard/clipboard_monitor_linux.py index 63174d9c..18b88410 100644 --- a/ClipCascade_Desktop/src/clipboard/clipboard_monitor_linux.py +++ b/ClipCascade_Desktop/src/clipboard/clipboard_monitor_linux.py @@ -40,10 +40,11 @@ def __init__( def _handle_data_offer(self, offer): self.current_offer_mime_types = [] - @offer.event("offer") def on_offer(offer_obj, mime_type): self.current_offer_mime_types.append(mime_type) + offer.dispatcher["offer"] = on_offer + def _handle_selection(self, offer): global _block_image_once @@ -502,79 +503,6 @@ def _start(enable_image_monitoring=False, enable_file_monitoring=False): _clipboard_thread.start() -def wayland_set_clipboard(data: bytes, mime_type: str = "text/plain") -> bool: - global _wayland_monitor - - if not EXT_DATA_CONTROL_SUPPORT: - return False - - try: - from pywayland.client import Display - - display = Display() - display.connect() - - registry = display.get_registry() - - data_control_manager = None - seat = None - - def registry_handler(reg, name, interface, version): - nonlocal data_control_manager, seat - if interface == "ext_data_control_manager_v1": - data_control_manager = reg.bind(name, interface, version) - elif interface == "wl_seat": - seat = reg.bind(name, interface, version) - - registry.dispatcher["global"] = registry_handler - display.roundtrip() - - if data_control_manager is None or seat is None: - display.disconnect() - return False - - data_device = data_control_manager.get_data_device(seat) - data_source = data_control_manager.create_data_source() - - source_data = data - source_mime = mime_type - - @data_source.event("send") - def on_send(source, mime, fd): - try: - os.write(fd, source_data) - finally: - os.close(fd) - - @data_source.event("cancelled") - def on_cancelled(source): - try: - source.destroy() - except Exception: - pass - - data_source.offer(mime_type) - if mime_type == "text/plain": - data_source.offer("text/plain;charset=utf-8") - data_source.offer("UTF8_STRING") - - data_device.set_selection(data_source) - display.roundtrip() - - time.sleep(0.05) - display.disconnect() - - if _wayland_monitor is not None: - _wayland_monitor.previous_clipboard = ( - data.decode("utf-8") if mime_type.startswith("text") else data - ) - - return True - except Exception as e: - logging.error(f"Failed to set clipboard via ext_data_control_v1: {e}") - return False - - def stop(): global \ _clipboard_thread, \ From 6f43bf4b9466471028fc42ad61e2396ca152b263 Mon Sep 17 00:00:00 2001 From: tranquil-tr0 Date: Sun, 22 Feb 2026 03:14:50 -0800 Subject: [PATCH 5/5] try-catch block for wayland dependency import --- .../src/clipboard/clipboard_monitor_linux.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/ClipCascade_Desktop/src/clipboard/clipboard_monitor_linux.py b/ClipCascade_Desktop/src/clipboard/clipboard_monitor_linux.py index 18b88410..df6ca18b 100644 --- a/ClipCascade_Desktop/src/clipboard/clipboard_monitor_linux.py +++ b/ClipCascade_Desktop/src/clipboard/clipboard_monitor_linux.py @@ -6,10 +6,18 @@ import time from core.constants import * -from pywayland.protocol.ext_data_control_v1.ext_data_control_manager_v1 import ( - ExtDataControlManagerV1, -) -from pywayland.protocol.wayland.wl_seat import WlSeat + +try: + from pywayland.protocol.ext_data_control_v1.ext_data_control_manager_v1 import ( + ExtDataControlManagerV1, + ) + from pywayland.protocol.wayland.wl_seat import WlSeat +except (ImportError, ModuleNotFoundError) as e: + logging.warning( + f"pywayland protocol bindings not available: {e}. Falling back to polling mode." + ) + ExtDataControlManagerV1 = None + WlSeat = None _callback_update = None _clipboard_thread = None