import logging import re import subprocess import threading import time from core.constants import * _callback_update = None _clipboard_thread = None _block_image_once = False _is_gdk_running = False _run_poll = threading.Event() _wl_watch_proc = None def _on_clipboard_changed( clipboard, event=None, enable_image_monitoring=False, enable_file_monitoring=False ): global _block_image_once # Files if enable_file_monitoring: uris = clipboard.wait_for_uris() if uris is not None and len(uris) > 0: if _callback_update: _callback_update("files", uris) return # Text text = clipboard.wait_for_text() if text is not None and len(text) > 0: if _callback_update: _callback_update("text", text) return # Image if enable_image_monitoring: pixbuf = clipboard.wait_for_image() if pixbuf is not None: if _block_image_once: _block_image_once = False return if _callback_update: success, buffer = pixbuf.save_to_bufferv("png") if success: _callback_update("image", bytes(buffer)) else: logging.error("Failed to convert image(pixbuf) to buffer") return def _monitor_x_wl_clipboard( x_mode: bool, enable_image_monitoring=False, enable_file_monitoring=False, ): global _block_image_once last_error = None previous_clipboard: str | bytes | None = None ignore_patterns = [ r"target .+ not available", # xclip pattern r"no suitable type of content copied", # wl-clipboard pattern ] if LINUX_CLIPBOARD_POLL_INTERVAL_SEC is not None: timeout = LINUX_CLIPBOARD_POLL_INTERVAL_SEC logging.info(f"Clipboard polling interval (--polling): {timeout}s") elif x_mode: timeout = 0.3 # xclip seconds else: timeout = 3 # wl-clipboard seconds while _run_poll.is_set(): if x_mode: success, mime_list = execute_command( "xclip", "-selection", "clipboard", "-t", "TARGETS", "-o" ) else: success, mime_list = execute_command("wl-paste", "-l") if not success: error_msg = f"Failed to retrieve MIME types: {mime_list}" if error_msg != last_error: logging.error(error_msg) last_error = error_msg time.sleep(timeout) continue mime_list = mime_list.decode("utf-8") mime_list = mime_list.replace("\r\n", "\n").replace("\r", "\n").split("\n") mime_list = [m.strip() for m in mime_list if len(m.strip()) > 0] type_ = convert_mime_to_generic_type(mime_list) # Text if type_ == "text": if x_mode: success, text = execute_command( "xclip", "-selection", "clipboard", "-o" ) else: success, text = execute_command("wl-paste", "-n") if success: text = text.decode("utf-8") if len(text) > 0 and text != previous_clipboard: previous_clipboard = text if _callback_update: _callback_update("text", text) else: error_msg = f"Failed to retrieve text content from clipboard. {text}" if error_msg != last_error: if not any( re.search(pattern, error_msg.lower()) for pattern in ignore_patterns ): logging.error(error_msg) last_error = error_msg # Image if type_ == "image" and enable_image_monitoring: if x_mode: success, image = execute_command( "xclip", "-selection", "clipboard", "-t", "image/png", "-o", ) else: success, image = execute_command("wl-paste", "-t", "image/png") if success: if image != previous_clipboard: previous_clipboard = image if _callback_update and not _block_image_once: _callback_update("image", image) else: _block_image_once = False else: error_msg = f"Failed to retrieve image content from clipboard. {image}" if error_msg != last_error: if not any( re.search(pattern, error_msg.lower()) for pattern in ignore_patterns ): logging.error(error_msg) last_error = error_msg # Files if type_ == "files" and enable_file_monitoring: if x_mode: success, files = execute_command( "xclip", "-selection", "clipboard", "-t", "text/uri-list", "-o", ) else: success, files = execute_command( "wl-paste", "-t", "text/uri-list", "-n" ) if success: files = files.decode("utf-8") files = files.replace("\r\n", "\n").replace("\r", "\n").split("\n") files = [f.strip() for f in files if len(f.strip()) > 0] if files != previous_clipboard: previous_clipboard = files if _callback_update: _callback_update("files", files) else: error_msg = f"Failed to retrieve files content from clipboard. {files}" if error_msg != last_error: if not any( re.search(pattern, error_msg.lower()) for pattern in ignore_patterns ): logging.error(error_msg) last_error = error_msg time.sleep(timeout) def _monitor_wl_watch(enable_image_monitoring=False, enable_file_monitoring=False): """Event-driven Wayland clipboard monitoring using wl-paste --watch. Uses the wlr-data-control-v1 protocol which does not create visible surfaces or steal focus. Supported by wlroots-based compositors (Sway, Hyprland, etc.) and KDE Plasma on Wayland. Returns True if watch mode ran successfully, False to fall back to polling.""" global _block_image_once, _wl_watch_proc last_error = None previous_clipboard = None ignore_patterns = [ r"target .+ not available", r"no suitable type of content copied", ] try: _wl_watch_proc = subprocess.Popen( ["wl-paste", "--watch", "echo"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) time.sleep(0.5) if _wl_watch_proc.poll() is not None: stderr_out = _wl_watch_proc.stderr.read().decode("utf-8", errors="ignore") logging.warning( f"wl-paste --watch exited immediately: {stderr_out.strip()}" ) _wl_watch_proc = None return False logging.info( "Using wl-paste --watch for clipboard monitoring (no focus stealing)" ) while _run_poll.is_set(): line = _wl_watch_proc.stdout.readline() if not line: break if not _run_poll.is_set(): break success, mime_output = execute_command("wl-paste", "-l") if not success: error_msg = f"Failed to retrieve MIME types: {mime_output}" if error_msg != last_error: logging.error(error_msg) last_error = error_msg continue mime_list = mime_output.decode("utf-8") mime_list = mime_list.replace("\r\n", "\n").replace("\r", "\n").split("\n") mime_list = [m.strip() for m in mime_list if len(m.strip()) > 0] type_ = convert_mime_to_generic_type(mime_list) # Text if type_ == "text": success, text = execute_command("wl-paste", "-n") if success: text = text.decode("utf-8") if len(text) > 0 and text != previous_clipboard: previous_clipboard = text if _callback_update: _callback_update("text", text) else: error_msg = ( f"Failed to retrieve text content from clipboard. {text}" ) if error_msg != last_error: if not any( re.search(pattern, error_msg.lower()) for pattern in ignore_patterns ): logging.error(error_msg) last_error = error_msg # Image elif type_ == "image" and enable_image_monitoring: success, image = execute_command("wl-paste", "-t", "image/png") if success: if image != previous_clipboard: previous_clipboard = image if _callback_update and not _block_image_once: _callback_update("image", image) else: _block_image_once = False else: error_msg = ( f"Failed to retrieve image content from clipboard. {image}" ) if error_msg != last_error: if not any( re.search(pattern, error_msg.lower()) for pattern in ignore_patterns ): logging.error(error_msg) last_error = error_msg # Files elif type_ == "files" and enable_file_monitoring: success, files = execute_command( "wl-paste", "-t", "text/uri-list", "-n" ) if success: files = files.decode("utf-8") files = ( files.replace("\r\n", "\n").replace("\r", "\n").split("\n") ) files = [f.strip() for f in files if len(f.strip()) > 0] if files != previous_clipboard: previous_clipboard = files if _callback_update: _callback_update("files", files) else: error_msg = ( f"Failed to retrieve files content from clipboard. {files}" ) if error_msg != last_error: if not any( re.search(pattern, error_msg.lower()) for pattern in ignore_patterns ): logging.error(error_msg) last_error = error_msg return True except FileNotFoundError: logging.warning("wl-paste not found, cannot use --watch mode") return False except Exception as e: logging.warning(f"wl-paste --watch failed: {e}") return False finally: if _wl_watch_proc is not None: _wl_watch_proc.terminate() try: _wl_watch_proc.wait(timeout=2) except subprocess.TimeoutExpired: _wl_watch_proc.kill() _wl_watch_proc = None def convert_mime_to_generic_type(mime_list): if "text/uri-list" in mime_list: return "files" if any(mime.startswith("image/") for mime in mime_list): return "image" text_mime = [ "text/plain", "text/plain;charset=utf-8", "STRING", "TEXT", "COMPOUND_TEXT", "UTF8_STRING", ] if any(t_mime in mime_list for t_mime in text_mime): return "text" return "unknown" def execute_command(*args) -> tuple: """ Executes a command with the given arguments and returns the output or error. Parameters: *args: Variable-length argument list to be passed as the command and arguments. Returns: tuple: (success: bool, result: str) success is True if the command executed successfully, False otherwise. result is the output or error of the command. """ process = subprocess.Popen( args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) output, error = process.communicate() if process.returncode == 0: # Success return (True, output) else: # Failure return (False, error.decode()) def is_x_clipboard_owner(): # Check if the X clipboard is owned by the current user return execute_command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")[0] def _start_clipboard_polling(enable_image_monitoring, enable_file_monitoring): if XMODE: x_clipboard_owner = is_x_clipboard_owner() if not x_clipboard_owner: logging.warning( "x-clip is not owned by the current user. Switching to wl-clipboard." ) _monitor_x_wl_clipboard( x_mode=x_clipboard_owner, enable_image_monitoring=enable_image_monitoring, enable_file_monitoring=enable_file_monitoring, ) else: if not _monitor_wl_watch( enable_image_monitoring=enable_image_monitoring, enable_file_monitoring=enable_file_monitoring, ): logging.info( "Falling back to wl-paste polling mode for clipboard monitoring" ) _monitor_x_wl_clipboard( x_mode=False, enable_image_monitoring=enable_image_monitoring, enable_file_monitoring=enable_file_monitoring, ) def _runner(enable_image_monitoring=False, enable_file_monitoring=False): global _is_gdk_running, _run_poll logging.info(f"XMODE: {XMODE}") try: _run_poll.set() import gi gi.require_version("Gtk", "3.0") gi.require_version("Gdk", "3.0") from gi.repository import Gtk, Gdk if "x11" in str(type(Gdk.Display.get_default())).lower(): # X11 logging.info("Starting GTK clipboard monitoring for X11 display server.") clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) clipboard.connect( "owner-change", lambda clip, event: _on_clipboard_changed( clip, event, enable_image_monitoring, enable_file_monitoring ), ) _is_gdk_running = True Gtk.main() else: logging.warning( f"Unsupported display server detected ${str(type(Gdk.Display.get_default())).lower()}. Starting polling mode for {detect_linux_display_server()} server as fallback." ) _start_clipboard_polling(enable_image_monitoring, enable_file_monitoring) except Exception as e: logging.error( f"Failed to start clipboard monitor: Error {e}\nStarting polling mode for {detect_linux_display_server()} server as fallback." ) _start_clipboard_polling(enable_image_monitoring, enable_file_monitoring) def _start(enable_image_monitoring=False, enable_file_monitoring=False): global _clipboard_thread if not _clipboard_thread: _clipboard_thread = threading.Thread( target=_runner, args=(enable_image_monitoring, enable_file_monitoring), daemon=True, ) _clipboard_thread.start() def stop(): global _clipboard_thread, _callback_update, _block_image_once, _run_poll, _is_gdk_running, _wl_watch_proc if _clipboard_thread: if _is_gdk_running: import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk Gtk.main_quit() _is_gdk_running = False _run_poll.clear() if _wl_watch_proc is not None: _wl_watch_proc.terminate() _clipboard_thread.join() # Wait for the thread to finish _clipboard_thread = None _callback_update = None _block_image_once = False _wl_watch_proc = None logging.info("Clipboard monitor stopped") def wait(): global _clipboard_thread if _clipboard_thread: _clipboard_thread.join() def enable_block_image_once(): global _block_image_once _block_image_once = True def on_update(callback, enable_image_monitoring=False, enable_file_monitoring=False): global _callback_update _callback_update = callback _start(enable_image_monitoring, enable_file_monitoring)