diff --git a/src/tests/xpra/clipboard/test_clipboard_helper.py b/src/tests/xpra/clipboard/test_clipboard_helper.py new file mode 100755 index 0000000000..eb98ea69dd --- /dev/null +++ b/src/tests/xpra/clipboard/test_clipboard_helper.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python + +from xpra.platform import program_context +from xpra.platform.win32.clipboard import Win32Clipboard, log + +def main(): + with program_context("Clipboard-Test", "Clipboard Test Tool"): + def send_packet_cb(*args): + print("send_packet_cb%s" % (args,)) + def progress_cb(*args): + print("progress_cb%s" % (args,)) + try: + log("creating %s", Win32Clipboard) + c = Win32Clipboard(send_packet_cb, progress_cb) + log("sending all tokens") + c.enable_selections(["CLIPBOARD"]) + c.set_direction(True, True) + c.send_all_tokens() + log("faking clipboard request") + c._process_clipboard_request(["clipboard-request", 1, "CLIPBOARD", "TARGETS"]) + def set_contents(): + pass + #c._process_clipboard_token(["clipboard-token", "CLIPBOARD", ]) + #_process_clipboard_contents(self, packet): + from xpra.gtk_common.gobject_compat import import_glib + glib = import_glib() + main_loop = glib.MainLoop() + glib.timeout_add(1000, set_contents) + log("main loop=%s", main_loop) + main_loop.run() + except: + log.error("", exc_info=True) + +if __name__ == "__main__": + main() diff --git a/src/xpra/clipboard/clipboard_core.py b/src/xpra/clipboard/clipboard_core.py index cf9cf2b787..9ec48dd562 100644 --- a/src/xpra/clipboard/clipboard_core.py +++ b/src/xpra/clipboard/clipboard_core.py @@ -473,7 +473,8 @@ def got_contents(dtype, dformat, data): data = data[:max_send_datalen] munged = self._munge_raw_selection_to_wire(target, dtype, dformat, data) wire_encoding, wire_data = munged - log("clipboard raw -> wire: %r -> %r", (dtype, dformat, data), munged) + log("clipboard raw -> wire: %r -> %r", + (dtype, dformat, repr_ellipsized(str(data))), repr_ellipsized(str(munged))) if wire_encoding is None: no_contents() return diff --git a/src/xpra/clipboard/clipboard_timeout_helper.py b/src/xpra/clipboard/clipboard_timeout_helper.py new file mode 100644 index 0000000000..32b549ac0b --- /dev/null +++ b/src/xpra/clipboard/clipboard_timeout_helper.py @@ -0,0 +1,109 @@ +# This file is part of Xpra. +# Copyright (C) 2019 Antoine Martin +# Xpra is released under the terms of the GNU GPL v2, or, at your option, any +# later version. See the file COPYING for details. + +from xpra.gtk_common.gobject_compat import import_glib +from xpra.clipboard.clipboard_core import ClipboardProtocolHelperCore +from xpra.util import repr_ellipsized, envint +from xpra.log import Logger +from xpra.platform.features import CLIPBOARD_GREEDY + +glib = import_glib() + +log = Logger("clipboard") + +CONVERT_TIMEOUT = envint("XPRA_CLIPBOARD_CONVERT_TIMEOUT", 500) +REMOTE_TIMEOUT = envint("XPRA_CLIPBOARD_REMOTE_TIMEOUT", 1500) +assert 0=2: + target, dtype, dformat, data = packet_data[1] + wire_encoding, wire_data = self._munge_raw_selection_to_wire(target, dtype, dformat, data) + if wire_encoding: + wire_data = self._may_compress(dtype, dformat, wire_data) + if wire_data: + packet += [target, dtype, dformat, wire_encoding, wire_data] + claim = proxy._can_send + packet += [claim, CLIPBOARD_GREEDY] + self.send(*packet) + + def _send_clipboard_request_handler(self, proxy, selection, target): + log("send_clipboard_request_handler%s", (proxy, selection, target)) + request_id = self._clipboard_request_counter + self._clipboard_request_counter += 1 + log("send_clipboard_request id=%s", request_id) + timer = glib.timeout_add(REMOTE_TIMEOUT, self.timeout_request, request_id) + self._clipboard_outstanding_requests[request_id] = (timer, selection, target) + self.progress() + self.send("clipboard-request", request_id, self.local_to_remote(selection), target) + + def timeout_request(self, request_id): + try: + selection, target = self._clipboard_outstanding_requests.pop(request_id)[1:] + except KeyError: + log.warn("Warning: request id %i not found", request_id) + return + finally: + self.progress() + log.warn("Warning: remote clipboard request timed out") + log.warn(" request id %i, selection=%s, target=%s", request_id, selection, target) + proxy = self._get_proxy(selection) + if proxy: + proxy.got_contents(target) + + def _clipboard_got_contents(self, request_id, dtype=None, dformat=None, data=None): + try: + timer, selection, target = self._clipboard_outstanding_requests.pop(request_id) + except KeyError: + log.warn("Warning: request id %i not found", request_id) + return + finally: + self.progress() + glib.source_remove(timer) + proxy = self._get_proxy(selection) + log("clipboard got contents%s: proxy=%s for selection=%s", + (request_id, dtype, dformat, repr_ellipsized(str(data))), proxy, selection) + if proxy: + proxy.got_contents(target, dtype, dformat, data) diff --git a/src/xpra/platform/win32/clipboard.py b/src/xpra/platform/win32/clipboard.py new file mode 100644 index 0000000000..4e76fbd7ec --- /dev/null +++ b/src/xpra/platform/win32/clipboard.py @@ -0,0 +1,290 @@ +# This file is part of Xpra. +# Copyright (C) 2019 Antoine Martin +# Xpra is released under the terms of the GNU GPL v2, or, at your option, any +# later version. See the file COPYING for details. + +from ctypes import ( + sizeof, byref, cast, + get_last_error, create_string_buffer, + WinError, FormatError, + ) +from xpra.platform.win32.common import ( + WNDCLASSEX, GetLastError, ERROR_ACCESS_DENIED, WNDPROC, LPCWSTR, LPWSTR, + DefWindowProcW, + GetModuleHandleA, RegisterClassExW, UnregisterClassA, + CreateWindowExW, DestroyWindow, + OpenClipboard, EmptyClipboard, CloseClipboard, GetClipboardData, + GlobalLock, GlobalUnlock, GlobalAlloc, GlobalFree, + WideCharToMultiByte, MultiByteToWideChar, + AddClipboardFormatListener, RemoveClipboardFormatListener, + SetClipboardData) +from xpra.platform.win32 import win32con +from xpra.clipboard.clipboard_timeout_helper import ClipboardTimeoutHelper +from xpra.clipboard.clipboard_core import ( + ClipboardProxyCore, log, _filter_targets, + TEXT_TARGETS, MAX_CLIPBOARD_PACKET_SIZE, + ) +from xpra.util import csv, repr_ellipsized +from xpra.os_util import bytestostr, strtobytes +from xpra.gtk_common.gobject_compat import import_glib + +glib = import_glib() + + +CP_UTF8 = 65001 +MB_ERR_INVALID_CHARS = 0x00000008 +GMEM_MOVEABLE = 0x0002 + +WM_CLIPBOARDUPDATE = 0x031D + +CLIPBOARD_EVENTS = { + win32con.WM_CLEAR : "CLEAR", + win32con.WM_CUT : "CUT", + win32con.WM_COPY : "COPY", + win32con.WM_PASTE : "PASTE", + win32con.WM_ASKCBFORMATNAME : "ASKCBFORMATNAME", + win32con.WM_CHANGECBCHAIN : "CHANGECBCHAIN", + WM_CLIPBOARDUPDATE : "CLIPBOARDUPDATE", + win32con.WM_DESTROYCLIPBOARD : "DESTROYCLIPBOARD", + win32con.WM_DRAWCLIPBOARD : "DRAWCLIPBOARD", + win32con.WM_HSCROLLCLIPBOARD : "HSCROLLCLIPBOARD", + win32con.WM_PAINTCLIPBOARD : "PAINTCLIPBOARD", + win32con.WM_RENDERALLFORMATS : "RENDERALLFORMATS", + win32con.WM_RENDERFORMAT : "RENDERFORMAT", + win32con.WM_SIZECLIPBOARD : "SIZECLIPBOARD", + win32con.WM_VSCROLLCLIPBOARD : "WM_VSCROLLCLIPBOARD", + } + +#initialize the window we will use +#for communicating with the OS clipboard API: + +class Win32Clipboard(ClipboardTimeoutHelper): + """ + Use Native win32 API to access the clipboard + """ + def __init__(self, send_packet_cb, progress_cb=None, **kwargs): + self.init_window() + ClipboardTimeoutHelper.__init__(self, send_packet_cb, progress_cb, **kwargs) + + def init_window(self): + log("Win32Clipboard.init_window() creating clipboard window class and instance") + class_name = "XpraWin32Clipboard" + self.wndclass = WNDCLASSEX() + self.wndclass.cbSize = sizeof(WNDCLASSEX) + self.wndclass.lpfnWndProc = WNDPROC(self.wnd_proc) + self.wndclass.style = win32con.CS_GLOBALCLASS + self.wndclass.hInstance = GetModuleHandleA(0) + self.wndclass.lpszClassName = class_name + self.wndclass_handle = RegisterClassExW(byref(self.wndclass)) + log("RegisterClassExA(%s)=%#x", self.wndclass.lpszClassName, self.wndclass_handle) + if self.wndclass_handle==0: + raise WinError() + style = win32con.WS_CAPTION #win32con.WS_OVERLAPPED + self.window = CreateWindowExW(0, self.wndclass_handle, u"Clipboard", style, + 0, 0, win32con.CW_USEDEFAULT, win32con.CW_USEDEFAULT, + win32con.HWND_MESSAGE, 0, self.wndclass.hInstance, None) + log("clipboard window=%s", self.window) + if not self.window: + raise WinError() + if not AddClipboardFormatListener(self.window): + log.warn("Warning: failed to setup clipboard format listener") + log.warn(" %s", get_last_error()) + + def wnd_proc(self, hwnd, msg, wparam, lparam): + r = DefWindowProcW(hwnd, msg, wparam, lparam) + if msg in CLIPBOARD_EVENTS: + log("clipboard event: %s", CLIPBOARD_EVENTS.get(msg)) + if msg==WM_CLIPBOARDUPDATE: + for proxy in self._clipboard_proxies.values(): + #TODO: handle greedy clients + self._send_clipboard_token_handler(proxy, packet_data=()) + return r + + + def cleanup(self): + ClipboardTimeoutHelper.cleanup(self) + self.cleanup_window() + + def cleanup_window(self): + w = self.window + if w: + self.window = None + RemoveClipboardFormatListener(w) + DestroyWindow(w) + wch = self.wndclass_handle + if wch: + self.wndclass = None + self.wndclass_handle = None + UnregisterClassA(wch, GetModuleHandleA(0)) + + def make_proxy(self, selection): + proxy = Win32ClipboardProxy(self.window, selection, self._send_clipboard_request_handler) + proxy.set_want_targets(self._want_targets) + proxy.set_direction(self.can_send, self.can_receive) + return proxy + + ############################################################################ + # just pass ATOM targets through + # (we use them internally as strings) + ############################################################################ + def _munge_wire_selection_to_raw(self, encoding, dtype, dformat, data): + if encoding=="atoms": + return _filter_targets(data) + return ClipboardTimeoutHelper._munge_wire_selection_to_raw(self, encoding, dtype, dformat, data) + + +class Win32ClipboardProxy(ClipboardProxyCore): + def __init__(self, window, selection, send_clipboard_request_handler): + self.window = window + self.send_clipboard_request_handler = send_clipboard_request_handler + ClipboardProxyCore.__init__(self, selection) + + def set_want_targets(self, want_targets): + self._want_targets = want_targets + + def with_clipboard_lock(self, success_callback, failure_callback, retries=5, delay=5): + r = OpenClipboard(self.window) + if r: + try: + success_callback() + return + finally: + CloseClipboard() + if GetLastError()!=ERROR_ACCESS_DENIED: + failure_callback() + return + if retries<=0: + failure_callback() + return + #try again later: + glib.timeout_add(delay, self.with_clipboard_lock, + success_callback, failure_callback, retries-1, delay) + + def clear(self): + def clear_error(): + log.error("Error: failed to clear the clipboard") + self.with_clipboard_lock(EmptyClipboard, clear_error) + + def get_contents(self, target, got_contents): + def got_text(text): + log("got_text(%s)", repr_ellipsized(str(text))) + got_contents("bytes", 8, text) + def errback(error_text): + log.error("Error: failed to get clipboard data") + log.error(" %s", error_text) + got_contents("bytes", 8, b"") + self.get_clipboard_text(got_text, errback) + + def got_token(self, targets, target_data=None, claim=True, _synchronous_client=False): + # the remote end now owns the clipboard + self.cancel_emit_token() + if not self._enabled: + return + self._got_token_events += 1 + log("got token, selection=%s, targets=%s, target data=%s, claim=%s, can-receive=%s", + self._selection, targets, target_data, claim, self._can_receive) + if self._can_receive: + self.targets = tuple(bytestostr(x) for x in (targets or ())) + self.target_data = target_data or {} + if targets: + self.got_contents("TARGETS", "ATOM", 32, targets) + if target_data: + for target, td_def in target_data.items(): + dtype, dformat, data = td_def + dtype = bytestostr(dtype) + self.got_contents(target, dtype, dformat, data) + #since we claim to be greedy + #the peer should have sent us the target and target_data, + #if not then request it: + if not targets: + self.send_clipboard_request_handler(self, self._selection, "TARGETS") + if not claim: + log("token packet without claim, not setting the token flag") + return + self._have_token = True + if self._can_receive: + self.claim() + + def got_contents(self, target, dtype=None, dformat=None, data=None): + #if this is the special target 'TARGETS', cache the result: + if target=="TARGETS" and dtype=="ATOM" and dformat==32: + self.targets = tuple(data) + #TODO: tell system what targets we have + log.warn("got_contents: tell OS we have %s", csv(self.targets)) + if dformat==8 and dtype in TEXT_TARGETS: + log.warn("we got a byte string: %s", data) + self.set_clipboard_text(data) + + + def get_clipboard_text(self, callback, errback): + def get_text(): + data_handle = GetClipboardData(win32con.CF_UNICODETEXT) + if not data_handle: + errback("no data handle") + return + data = GlobalLock(data_handle) + if not data: + errback("failed to lock handle") + return + try: + wstr = cast(data, LPCWSTR) + ulen = WideCharToMultiByte(CP_UTF8, 0, wstr, -1, None, 0, None, None) + if ulen>MAX_CLIPBOARD_PACKET_SIZE: + errback("too much data") + return + buf = create_string_buffer(ulen) + l = WideCharToMultiByte(CP_UTF8, 0, wstr, -1, byref(buf), ulen, None, None) + if l>0: + if buf.raw[l-1:l]==b"\0": + s = buf.raw[:l-1] + else: + s = buf.raw[:l] + log("got %i bytes of data: %s", len(s), repr_ellipsized(str(s))) + callback(strtobytes(s)) + else: + errback("failed to convert to UTF8: %s" % FormatError(get_last_error())) + finally: + GlobalUnlock(data) + + self.with_clipboard_lock(get_text, errback) + + def set_clipboard_text(self, text): + #convert to wide char + #get the length in wide chars: + log("set_clipboard_text(%s)", text) + wlen = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text, len(text), None, 0) + if not wlen: + return + log("MultiByteToWideChar wlen=%i", wlen) + #allocate some memory for it: + buf = GlobalAlloc(GMEM_MOVEABLE, wlen*2) + if not buf: + return + log("GlobalAlloc buf=%#x", buf) + locked = GlobalLock(buf) + if not locked: + GlobalFree(buf) + return + try: + locked_buf = cast(locked, LPWSTR) + r = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text, len(text), locked_buf, wlen) + if not r: + return + finally: + GlobalUnlock(locked) + + def do_set_data(): + try: + EmptyClipboard() + if not SetClipboardData(win32con.CF_UNICODETEXT, buf): + return + #done! + finally: + GlobalFree(buf) + def set_error(): + GlobalFree(buf) + log.error("Error: failed to set clipboard data") + self.with_clipboard_lock(do_set_data, set_error) + + def __repr__(self): + return "Win32ClipboardProxy" diff --git a/src/xpra/platform/win32/features.py b/src/xpra/platform/win32/features.py index e4f5898e95..51e604065d 100644 --- a/src/xpra/platform/win32/features.py +++ b/src/xpra/platform/win32/features.py @@ -11,6 +11,7 @@ CLIPBOARDS=["CLIPBOARD"] CLIPBOARD_GREEDY = True CLIPBOARD_NATIVE_CLASS = "xpra.clipboard.translated_clipboard.TranslatedClipboardProtocolHelper" +#CLIPBOARD_NATIVE_CLASS = "xpra.platform.win32.clipboard.Win32Clipboard" EXECUTABLE_EXTENSION = "exe" diff --git a/src/xpra/x11/gtk_x11/clipboard.py b/src/xpra/x11/gtk_x11/clipboard.py index 725772186b..2e0473fec7 100644 --- a/src/xpra/x11/gtk_x11/clipboard.py +++ b/src/xpra/x11/gtk_x11/clipboard.py @@ -20,12 +20,13 @@ ClipboardProtocolHelperCore, ClipboardProxyCore, TEXT_TARGETS, must_discard, must_discard_extra, _filter_targets, ) +from xpra.clipboard.clipboard_timeout_helper import ClipboardTimeoutHelper, CONVERT_TIMEOUT from xpra.x11.bindings.window_bindings import ( #@UnresolvedImport - constants, PropertyError, - X11WindowBindings, + constants, PropertyError, #@UnresolvedImport + X11WindowBindings, #@UnresolvedImport ) from xpra.os_util import bytestostr -from xpra.util import csv, repr_ellipsized, envint +from xpra.util import csv, repr_ellipsized from xpra.log import Logger gdk = import_gdk() @@ -42,11 +43,6 @@ sizeof_long = struct.calcsize(b'@L') -CONVERT_TIMEOUT = envint("XPRA_CLIPBOARD_CONVERT_TIMEOUT", 500) -REMOTE_TIMEOUT = envint("XPRA_CLIPBOARD_REMOTE_TIMEOUT", 1500) -assert 0=2: - target, dtype, dformat, data = packet_data[1] - wire_encoding, wire_data = self._munge_raw_selection_to_wire(target, dtype, dformat, data) - if wire_encoding: - claim = True - greedy = False - packet += [ - target, dtype, dformat, wire_encoding, wire_data, - claim, greedy, - ] - self.send(*packet) - - def _send_clipboard_request_handler(self, proxy, selection, target): - log("send_clipboard_request_handler%s", (proxy, selection, target)) - request_id = self._clipboard_request_counter - self._clipboard_request_counter += 1 - log("send_clipboard_request id=%s", request_id) - timer = glib.timeout_add(REMOTE_TIMEOUT, self.timeout_request, request_id) - self._clipboard_outstanding_requests[request_id] = (timer, selection, target) - self.progress() - self.send("clipboard-request", request_id, self.local_to_remote(selection), target) - - def timeout_request(self, request_id): - try: - selection, target = self._clipboard_outstanding_requests.pop(request_id)[1:] - except KeyError: - log.warn("Warning: request id %i not found", request_id) - return - finally: - self.progress() - log.warn("Warning: remote clipboard request timed out") - log.warn(" request id %i, selection=%s, target=%s", request_id, selection, target) - proxy = self._get_proxy(selection) - if proxy: - proxy.got_contents(target) - - def _clipboard_got_contents(self, request_id, dtype=None, dformat=None, data=None): - try: - timer, selection, target = self._clipboard_outstanding_requests.pop(request_id) - except KeyError: - log.warn("Warning: request id %i not found", request_id) - return - finally: - self.progress() - glib.source_remove(timer) - proxy = self._get_proxy(selection) - log("clipboard got contents%s: proxy=%s for selection=%s", - (request_id, dtype, dformat, repr_ellipsized(str(data))), proxy, selection) - if proxy: - proxy.got_contents(target, dtype, dformat, data) - def _munge_raw_selection_to_wire(self, target, dtype, dformat, data): if dformat==32 and dtype in ("ATOM", "ATOM_PAIR"): @@ -560,7 +485,7 @@ def get_contents(self, target, got_contents, time=0): log("we are the %s selection owner, using empty reply", self._selection) got_contents(None, None, None) return - log("requesting local XConvertSelection from %#x for '%s' into '%s'", owner, target, prop) + log("requesting local XConvertSelection from %s for '%s' into '%s'", self.get_wininfo(owner), target, prop) X11Window.ConvertSelection(self._selection, target, prop, self.xid, time=time) def timeout_get_contents(self, target, request_id):