#!/usr/bin/env python # License: GPLv3 Copyright: 2022, Kovid Goyal import io import os from enum import Enum, IntEnum from gettext import gettext as _ from tempfile import TemporaryFile from typing import IO, Callable, Dict, List, Mapping, NamedTuple, Optional, Tuple, Union from .conf.utils import uniq from .constants import supports_primary_selection from .fast_data_types import ( GLFW_CLIPBOARD, GLFW_PRIMARY_SELECTION, OSC, get_boss, get_clipboard_mime, get_options, set_clipboard_data_types, ) from .utils import log_error class Tempfile: def __init__(self, max_size: int) -> None: self.file: Union[io.BytesIO, IO[bytes]] = io.BytesIO() self.max_size = max_size def rollover_if_needed(self, sz: int) -> None: if isinstance(self.file, io.BytesIO) and self.file.tell() + sz > self.max_size: before = self.file.getvalue() self.file = TemporaryFile() self.file.write(before) def write(self, data: bytes) -> None: self.rollover_if_needed(len(data)) self.file.write(data) def tell(self) -> int: return self.file.tell() def seek(self, pos: int) -> None: self.file.seek(pos, os.SEEK_SET) def read(self, offset: int, size: int) -> bytes: self.file.seek(offset) return self.file.read(size) def create_chunker(self, offset: int, size: int) -> Callable[[], Callable[[], bytes]]: def chunk_creator() -> Callable[[], bytes]: pos = offset limit = offset + size def chunker() -> bytes: nonlocal pos, limit if pos >= limit: return b'' ans = self.read(pos, min(io.DEFAULT_BUFFER_SIZE, limit - pos)) pos = self.file.tell() return ans return chunker return chunk_creator DataType = Union[bytes, Callable[[], Callable[[], bytes]]] TARGETS_MIME = '.' class ClipboardType(IntEnum): clipboard = GLFW_CLIPBOARD primary_selection = GLFW_PRIMARY_SELECTION @staticmethod def from_osc52_where_field(where: str) -> 'ClipboardType': where = where or 's0' return ClipboardType.clipboard if 'c' in where or 's' in where else ClipboardType.primary_selection class Clipboard: def __init__(self, clipboard_type: ClipboardType = ClipboardType.clipboard) -> None: self.data: Dict[str, DataType] = {} self.clipboard_type = clipboard_type self.enabled = self.clipboard_type is ClipboardType.clipboard or supports_primary_selection def set_text(self, x: Union[str, bytes]) -> None: if isinstance(x, str): x = x.encode('utf-8') self.set_mime({'text/plain': x}) def set_mime(self, data: Mapping[str, DataType]) -> None: if self.enabled and isinstance(data, dict): self.data = data set_clipboard_data_types(self.clipboard_type, tuple(self.data)) def get_text(self) -> str: parts: List[bytes] = [] self.get_mime("text/plain", parts.append) return b''.join(parts).decode('utf-8', 'replace') def get_mime(self, mime: str, output: Callable[[bytes], None]) -> None: if self.enabled: try: get_clipboard_mime(self.clipboard_type, mime, output) except RuntimeError as err: if str(err) != 'is_self_offer': raise data = self.data.get(mime, b'') if isinstance(data, bytes): output(data) else: chunker = data() q = b' ' while q: q = chunker() output(q) def get_mime_data(self, mime: str) -> bytes: ans: List[bytes] = [] self.get_mime(mime, ans.append) return b''.join(ans) def get_available_mime_types_for_paste(self) -> Tuple[str, ...]: if self.enabled: parts: List[bytes] = [] try: get_clipboard_mime(self.clipboard_type, None, parts.append) except RuntimeError as err: if str(err) != 'is_self_offer': raise return tuple(self.data) return tuple(x.decode('utf-8', 'replace') for x in uniq(parts)) return () def __call__(self, mime: str) -> Callable[[], bytes]: data = self.data.get(mime, b'') if isinstance(data, str): # type: ignore data = data.encode('utf-8') # type: ignore if isinstance(data, bytes): def chunker() -> bytes: nonlocal data assert isinstance(data, bytes) ans = data data = b'' return ans return chunker return data() def set_clipboard_string(x: Union[str, bytes]) -> None: get_boss().clipboard.set_text(x) def get_clipboard_string() -> str: return get_boss().clipboard.get_text() def set_primary_selection(x: Union[str, bytes]) -> None: get_boss().primary_selection.set_text(x) def get_primary_selection() -> str: return get_boss().primary_selection.get_text() def develop() -> Tuple[Clipboard, Clipboard]: from .constants import detect_if_wayland_ok, is_macos from .fast_data_types import set_boss from .main import init_glfw_module glfw_module = 'cocoa' if is_macos else ('wayland' if detect_if_wayland_ok() else 'x11') class Boss: clipboard = Clipboard() primary_selection = Clipboard(ClipboardType.primary_selection) init_glfw_module(glfw_module) set_boss(Boss()) # type: ignore return Boss.clipboard, Boss.primary_selection class ProtocolType(Enum): osc_52 = 52 osc_5522 = 5522 def encode_mime(x: str) -> str: import base64 return base64.standard_b64encode(x.encode('utf-8')).decode('ascii') def decode_metadata_value(k: str, x: str) -> str: if k == 'mime': import base64 x = base64.standard_b64decode(x).decode('utf-8') return x class ReadRequest(NamedTuple): is_primary_selection: bool = False mime_types: Tuple[str, ...] = ('text/plain',) id: str = '' protocol_type: ProtocolType = ProtocolType.osc_52 def encode_response(self, status: str = 'DATA', mime: str = '', payload: bytes = b'') -> bytes: ans = f'{self.protocol_type.value};type=read:status={status}' if status == 'OK' and self.is_primary_selection: ans += ':loc=primary' if self.id: ans += f':id={self.id}' if mime: ans += f':mime={encode_mime(mime)}' a = ans.encode('ascii') if payload: import base64 a += b';' + base64.standard_b64encode(payload) return a def encode_osc52(loc: str, response: str) -> str: from base64 import standard_b64encode return '52;{};{}'.format( loc, standard_b64encode(response.encode('utf-8')).decode('ascii')) class MimePos(NamedTuple): start: int size: int class WriteRequest: def __init__( self, is_primary_selection: bool = False, protocol_type: ProtocolType = ProtocolType.osc_52, id: str = '', rollover_size: int = 16 * 1024 * 1024, max_size: int = -1, ) -> None: self.id = id self.is_primary_selection = is_primary_selection self.protocol_type = protocol_type self.max_size_exceeded = False self.tempfile = Tempfile(max_size=rollover_size) self.mime_map: Dict[str, MimePos] = {} self.currently_writing_mime = '' self.current_leftover_bytes = memoryview(b'') self.max_size = (get_options().clipboard_max_size * 1024 * 1024) if max_size < 0 else max_size self.aliases: Dict[str, str] = {} self.commited = False def encode_response(self, status: str = 'OK') -> bytes: ans = f'{self.protocol_type.value};type=write:status={status}' if self.id: ans += f':id={self.id}' a = ans.encode('ascii') return a def commit(self) -> None: if self.commited: return self.commited = True cp = get_boss().primary_selection if self.is_primary_selection else get_boss().clipboard if cp.enabled: for alias, src in self.aliases.items(): pos = self.mime_map.get(src) if pos is not None: self.mime_map[alias] = pos x = {mime: self.tempfile.create_chunker(pos.start, pos.size) for mime, pos in self.mime_map.items()} cp.set_mime(x) def add_base64_data(self, data: Union[str, bytes], mime: str = 'text/plain') -> None: if isinstance(data, str): data = data.encode('ascii') if self.currently_writing_mime and self.currently_writing_mime != mime: self.flush_base64_data() if not self.currently_writing_mime: self.mime_map[mime] = MimePos(self.tempfile.tell(), -1) self.currently_writing_mime = mime def write_saving_leftover_bytes(data: bytes) -> None: if len(data) == 0: return extra = len(data) % 4 if extra > 0: mv = memoryview(data) self.current_leftover_bytes = mv[-extra:] mv = mv[:-extra] if len(mv) > 0: self.write_base64_data(mv) else: self.write_base64_data(data) if len(self.current_leftover_bytes) > 0: extra = 4 - len(self.current_leftover_bytes) if len(data) >= extra: self.write_base64_data(memoryview(bytes(self.current_leftover_bytes) + data[:extra])) self.current_leftover_bytes = memoryview(b'') data = memoryview(data)[extra:] write_saving_leftover_bytes(data) else: self.current_leftover_bytes = memoryview(bytes(self.current_leftover_bytes) + data) else: write_saving_leftover_bytes(data) def flush_base64_data(self) -> None: if self.currently_writing_mime: b = self.current_leftover_bytes padding = 4 - len(b) if padding in (1, 2): self.write_base64_data(memoryview(bytes(b) + b'=' * padding)) start = self.mime_map[self.currently_writing_mime][0] self.mime_map[self.currently_writing_mime] = MimePos(start, self.tempfile.tell() - start) self.currently_writing_mime = '' self.current_leftover_bytes = memoryview(b'') def write_base64_data(self, b: bytes) -> None: from base64 import standard_b64decode if not self.max_size_exceeded: d = standard_b64decode(b) self.tempfile.write(d) if self.max_size > 0 and self.tempfile.tell() > (self.max_size * 1024 * 1024): log_error(f'Clipboard write request has more data than allowed by clipboard_max_size ({self.max_size}), truncating') self.max_size_exceeded = True def data_for(self, mime: str = 'text/plain', offset: int = 0, size: int = -1) -> bytes: start, full_size = self.mime_map[mime] if size == -1: size = full_size return self.tempfile.read(start+offset, size) class ClipboardRequestManager: def __init__(self, window_id: int) -> None: self.window_id = window_id self.currently_asking_permission_for: Optional[ReadRequest] = None self.in_flight_write_request: Optional[WriteRequest] = None def parse_osc_5522(self, data: str) -> None: import base64 from .notify import sanitize_id metadata, _, epayload = data.partition(';') m: Dict[str, str] = {} for record in metadata.split(':'): try: k, v = record.split('=', 1) except Exception: log_error('Malformed OSC 5522: metadata is not key=value pairs') return m[k] = decode_metadata_value(k, v) typ = m.get('type', '') if typ == 'read': payload = base64.standard_b64decode(epayload) rr = ReadRequest( is_primary_selection=m.get('loc', '') == 'primary', mime_types=tuple(payload.decode('utf-8').split()), protocol_type=ProtocolType.osc_5522, id=sanitize_id(m.get('id', '')) ) self.handle_read_request(rr) elif typ == 'write': self.in_flight_write_request = WriteRequest( is_primary_selection=m.get('loc', '') == 'primary', protocol_type=ProtocolType.osc_5522, id=sanitize_id(m.get('id', '')) ) self.handle_write_request(self.in_flight_write_request) elif typ == 'walias': wr = self.in_flight_write_request mime = m.get('mime', '') if mime and wr is not None: aliases = base64.standard_b64decode(epayload).decode('utf-8').split() for alias in aliases: wr.aliases[alias] = mime elif typ == 'wdata': wr = self.in_flight_write_request w = get_boss().window_id_map.get(self.window_id) if wr is None: return mime = m.get('mime', '') if mime: try: wr.add_base64_data(epayload, mime) except OSError: if w is not None: w.screen.send_escape_code_to_child(OSC, wr.encode_response(status='EIO')) self.in_flight_write_request = None raise except Exception: if w is not None: w.screen.send_escape_code_to_child(OSC, wr.encode_response(status='EINVAL')) self.in_flight_write_request = None raise else: wr.flush_base64_data() wr.commit() self.in_flight_write_request = None if w is not None: w.screen.send_escape_code_to_child(OSC, wr.encode_response(status='DONE')) def parse_osc_52(self, data: str, is_partial: bool = False) -> None: where, text = data.partition(';')[::2] if text == '?': rr = ReadRequest(is_primary_selection=ClipboardType.from_osc52_where_field(where) is ClipboardType.primary_selection) self.handle_read_request(rr) else: wr = self.in_flight_write_request if wr is None: wr = self.in_flight_write_request = WriteRequest(ClipboardType.from_osc52_where_field(where) is ClipboardType.primary_selection) wr.add_base64_data(text) if is_partial: return self.in_flight_write_request = None self.handle_write_request(wr) def handle_write_request(self, wr: WriteRequest) -> None: wr.flush_base64_data() q = 'write-primary' if wr.is_primary_selection else 'write-clipboard' allowed = q in get_options().clipboard_control self.fulfill_write_request(wr, allowed) def fulfill_write_request(self, wr: WriteRequest, allowed: bool = True) -> None: if wr.protocol_type is ProtocolType.osc_52: self.fulfill_legacy_write_request(wr, allowed) return w = get_boss().window_id_map.get(self.window_id) cp = get_boss().primary_selection if wr.is_primary_selection else get_boss().clipboard if not allowed or not cp.enabled: self.in_flight_write_request = None if w is not None: w.screen.send_escape_code_to_child(OSC, wr.encode_response(status='EPERM' if not allowed else 'ENOSYS')) def fulfill_legacy_write_request(self, wr: WriteRequest, allowed: bool = True) -> None: cp = get_boss().primary_selection if wr.is_primary_selection else get_boss().clipboard w = get_boss().window_id_map.get(self.window_id) if w is not None and cp.enabled and allowed: wr.commit() def handle_read_request(self, rr: ReadRequest) -> None: cc = get_options().clipboard_control if rr.is_primary_selection: ask_for_permission = 'read-primary-ask' in cc allowed = 'read-primary' in cc else: ask_for_permission = 'read-clipboard-ask' in cc allowed = 'read-clipboard' in cc if ask_for_permission: self.ask_to_read_clipboard(rr) else: self.fulfill_read_request(rr, allowed=allowed) def fulfill_read_request(self, rr: ReadRequest, allowed: bool = True) -> None: if rr.protocol_type is ProtocolType.osc_52: return self.fulfill_legacy_read_request(rr, allowed) w = get_boss().window_id_map.get(self.window_id) if w is None: return cp = get_boss().primary_selection if rr.is_primary_selection else get_boss().clipboard if not cp.enabled: w.screen.send_escape_code_to_child(OSC, rr.encode_response(status='ENOSYS')) return if not allowed: w.screen.send_escape_code_to_child(OSC, rr.encode_response(status='EPERM')) return w.screen.send_escape_code_to_child(OSC, rr.encode_response(status='OK')) current_mime = '' def write_chunks(data: bytes) -> None: assert w is not None mv = memoryview(data) while mv: w.screen.send_escape_code_to_child(OSC, rr.encode_response(payload=mv[:4096], mime=current_mime)) mv = mv[4096:] for mime in rr.mime_types: current_mime = mime if mime == TARGETS_MIME: payload = ' '.join(cp.get_available_mime_types_for_paste()).encode('utf-8') w.screen.send_escape_code_to_child(OSC, rr.encode_response(payload=payload, mime=current_mime)) continue try: cp.get_mime(mime, write_chunks) except Exception as e: log_error(f'Failed to read requested mime type {mime} with error: {e}') w.screen.send_escape_code_to_child(OSC, rr.encode_response(status='DONE')) def reject_read_request(self, rr: ReadRequest) -> None: if rr.protocol_type is ProtocolType.osc_52: return self.fulfill_legacy_read_request(rr, False) w = get_boss().window_id_map.get(self.window_id) if w is not None: w.screen.send_escape_code_to_child(OSC, rr.encode_response(status='EPERM')) def fulfill_legacy_read_request(self, rr: ReadRequest, allowed: bool = True) -> None: cp = get_boss().primary_selection if rr.is_primary_selection else get_boss().clipboard w = get_boss().window_id_map.get(self.window_id) if w is not None: text = '' if cp.enabled and allowed: text = cp.get_text() loc = 'p' if rr.is_primary_selection else 'c' w.screen.send_escape_code_to_child(OSC, encode_osc52(loc, text)) def ask_to_read_clipboard(self, rr: ReadRequest) -> None: if rr.mime_types == (TARGETS_MIME,): self.fulfill_read_request(rr, True) return if self.currently_asking_permission_for is not None: self.reject_read_request(rr) return w = get_boss().window_id_map.get(self.window_id) if w is not None: self.currently_asking_permission_for = rr get_boss().confirm(_( 'A program running in this window wants to read from the system clipboard.' ' Allow it to do so, once?'), self.handle_clipboard_confirmation, window=w, ) def handle_clipboard_confirmation(self, confirmed: bool) -> None: rr = self.currently_asking_permission_for self.currently_asking_permission_for = None if rr is not None: self.fulfill_read_request(rr, confirmed) def close(self) -> None: if self.in_flight_write_request is not None: self.in_flight_write_request = None