Start work on a new clipboard protocol to allow terminal programs to access more than just text/plain content from the clipboard

This commit is contained in:
Kovid Goyal 2022-11-28 14:23:03 +05:30
parent 4c72f92939
commit a8725d6307
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
4 changed files with 117 additions and 109 deletions

View File

@ -21,8 +21,8 @@ from .child import cached_process_data, default_env, set_default_env
from .cli import create_opts, parse_args
from .cli_stub import CLIOptions
from .clipboard import (
Clipboard, get_clipboard_string, get_primary_selection, set_clipboard_string,
set_primary_selection,
Clipboard, ClipboardType, get_clipboard_string, get_primary_selection,
set_clipboard_string, set_primary_selection,
)
from .conf.utils import BadLine, KeyAction, to_cmdline
from .config import common_opts_as_dict, prepare_config_file_for_editing
@ -33,19 +33,18 @@ from .constants import (
)
from .fast_data_types import (
CLOSE_BEING_CONFIRMED, GLFW_MOD_ALT, GLFW_MOD_CONTROL, GLFW_MOD_SHIFT,
GLFW_MOD_SUPER, GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, GLFW_PRIMARY_SELECTION,
IMPERATIVE_CLOSE_REQUESTED, NO_CLOSE_REQUESTED, ChildMonitor, Color,
EllipticCurveKey, KeyEvent, SingleKey, add_timer, apply_options_update,
background_opacity_of, change_background_opacity, change_os_window_state,
cocoa_set_menubar_title, create_os_window, current_application_quit_request,
current_focused_os_window_id, current_os_window, destroy_global_data,
focus_os_window, get_boss, get_options, get_os_window_size, global_font_size,
last_focused_os_window_id, mark_os_window_for_close, os_window_font_size,
patch_global_colors, redirect_mouse_handling, ring_bell, run_with_activation_token,
safe_pipe, send_data_to_peer, set_application_quit_request, set_background_image,
set_boss, set_in_sequence_mode, set_options, set_os_window_size,
set_os_window_title, thread_write, toggle_fullscreen, toggle_maximized,
toggle_secure_input,
GLFW_MOD_SUPER, GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, IMPERATIVE_CLOSE_REQUESTED,
NO_CLOSE_REQUESTED, ChildMonitor, Color, EllipticCurveKey, KeyEvent, SingleKey,
add_timer, apply_options_update, background_opacity_of, change_background_opacity,
change_os_window_state, cocoa_set_menubar_title, create_os_window,
current_application_quit_request, current_focused_os_window_id, current_os_window,
destroy_global_data, focus_os_window, get_boss, get_options, get_os_window_size,
global_font_size, last_focused_os_window_id, mark_os_window_for_close,
os_window_font_size, patch_global_colors, redirect_mouse_handling, ring_bell,
run_with_activation_token, safe_pipe, send_data_to_peer,
set_application_quit_request, set_background_image, set_boss, set_in_sequence_mode,
set_options, set_os_window_size, set_os_window_title, thread_write,
toggle_fullscreen, toggle_maximized, toggle_secure_input,
)
from .key_encoding import get_name_to_functional_number_map
from .keys import get_shortcut, shortcut_matches
@ -243,7 +242,7 @@ class Boss:
):
set_layout_options(opts)
self.clipboard = Clipboard()
self.primary_selection = Clipboard(GLFW_PRIMARY_SELECTION)
self.primary_selection = Clipboard(ClipboardType.primary_selection)
self.update_check_started = False
self.encryption_key = EllipticCurveKey()
self.encryption_public_key = f'{RC_ENCRYPTION_PROTOCOL_VERSION}:{base64.b85encode(self.encryption_key.public).decode("ascii")}'

View File

@ -2,24 +2,33 @@
# License: GPLv3 Copyright: 2022, Kovid Goyal <kovid at kovidgoyal.net>
import io
from typing import IO, Callable, Dict, List, Tuple, Union
from enum import Enum, IntEnum, auto
from gettext import gettext as _
from typing import (
IO, Callable, Dict, List, NamedTuple, Optional, Sequence, Tuple, Union,
)
from .conf.utils import uniq
from .constants import supports_primary_selection
from .fast_data_types import (
GLFW_CLIPBOARD, GLFW_PRIMARY_SELECTION, get_boss, get_clipboard_mime,
set_clipboard_data_types
GLFW_CLIPBOARD, GLFW_PRIMARY_SELECTION, OSC, get_boss, get_clipboard_mime,
get_options, set_clipboard_data_types,
)
DataType = Union[bytes, 'IO[bytes]']
class ClipboardType(IntEnum):
clipboard = GLFW_CLIPBOARD
primary_selection = GLFW_PRIMARY_SELECTION
class Clipboard:
def __init__(self, clipboard_type: int = GLFW_CLIPBOARD) -> None:
def __init__(self, clipboard_type: ClipboardType = ClipboardType.clipboard) -> None:
self.data: Dict[str, DataType] = {}
self.clipboard_type = clipboard_type
self.enabled = self.clipboard_type == GLFW_CLIPBOARD or supports_primary_selection
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):
@ -108,14 +117,93 @@ def get_primary_selection() -> str:
def develop() -> Tuple[Clipboard, Clipboard]:
from .constants import is_macos, detect_if_wayland_ok
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(GLFW_PRIMARY_SELECTION)
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 = auto()
class ReadRequest(NamedTuple):
clipboard_type: ClipboardType = ClipboardType.clipboard
mime_types: Sequence[str] = ('text/plain',)
id: str = ''
protocol_type: ProtocolType = ProtocolType.osc_52
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 ClipboardRequestManager:
def __init__(self, window_id: int) -> None:
self.window_id = window_id
self.currently_asking_permission_for: Optional[ReadRequest] = None
def parse_osc_52(self, data: str, is_partial: bool = False) -> None:
where, text = data.partition(';')[::2]
ct = ClipboardType.clipboard if 's' in where or 'c' in where else ClipboardType.primary_selection
if text == '?':
rr = ReadRequest(clipboard_type=ct)
self.handle_read_request(rr)
def handle_read_request(self, rr: ReadRequest) -> None:
cc = get_options().clipboard_control
if rr.clipboard_type is ClipboardType.primary_selection:
ask_for_permission = 'read-primary-ask' in cc
else:
ask_for_permission = 'read-clipboard-ask' in cc
if ask_for_permission:
self.ask_to_read_clipboard(rr)
else:
self.fulfill_read_request(rr)
def fulfill_read_request(self, rr: ReadRequest, allowed: bool = True) -> None:
if rr.protocol_type is ProtocolType.osc_52:
self.fulfill_legacy_read_request(rr, allowed)
def reject_read_request(self, rr: ReadRequest) -> None:
if rr.protocol_type is ProtocolType.osc_52:
self.fulfill_legacy_read_request(rr, False)
def fulfill_legacy_read_request(self, rr: ReadRequest, allowed: bool = True) -> None:
cp = get_boss().primary_selection if rr.clipboard_type is ClipboardType.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 = 'c' if rr.clipboard_type is ClipboardType.clipboard else 'p'
w.screen.send_escape_code_to_child(OSC, encode_osc52(loc, text))
def ask_to_read_clipboard(self, rr: ReadRequest) -> None:
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 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)

View File

@ -1989,7 +1989,8 @@ set_dynamic_color(Screen *self, unsigned int code, PyObject *color) {
void
clipboard_control(Screen *self, int code, PyObject *data) {
CALLBACK("clipboard_control", "OO", data, code == -52 ? Py_True: Py_False);
if (code == 52 || code == -52) { CALLBACK("clipboard_control", "OO", data, code == -52 ? Py_True: Py_False); }
else { CALLBACK("clipboard_control", "OO", data, Py_None);}
}
void

View File

@ -20,10 +20,7 @@ from typing import (
from .child import ProcessDesc
from .cli_stub import CLIOptions
from .clipboard import (
get_clipboard_string, get_primary_selection, set_clipboard_string,
set_primary_selection,
)
from .clipboard import ClipboardRequestManager, set_clipboard_string
from .config import build_ansi_color_table
from .constants import (
appname, clear_handled_signals, config_dir, is_macos, wakeup_io_loop,
@ -508,6 +505,7 @@ class Window:
self.child_title = self.default_title
self.title_stack: Deque[str] = deque(maxlen=10)
self.id: int = add_window(tab.os_window_id, tab.id, self.title)
self.clipboard_request_manager = ClipboardRequestManager(self.id)
self.margin = EdgeWidths()
self.padding = EdgeWidths()
self.kitten_result: Optional[Dict[str, Any]] = None
@ -516,7 +514,6 @@ class Window:
self.tab_id = tab.id
self.os_window_id = tab.os_window_id
self.tabref: Callable[[], Optional[TabType]] = weakref.ref(tab)
self.clipboard_pending: Optional[ClipboardPending] = None
self.destroyed = False
self.geometry: WindowGeometry = WindowGeometry(0, 0, 0, 0, 0, 0)
self.needs_layout = True
@ -1172,86 +1169,9 @@ class Window:
def file_transmission(self, data: str) -> None:
self.file_transmission_control.handle_serialized_command(data)
def clipboard_control(self, data: str, is_partial: bool = False) -> None:
where, text = data.partition(';')[::2]
if is_partial:
if self.clipboard_pending is None:
self.clipboard_pending = ClipboardPending(where, text)
else:
self.clipboard_pending = self.clipboard_pending._replace(data=self.clipboard_pending[1] + text)
limit = get_options().clipboard_max_size
if limit and len(self.clipboard_pending.data) > limit * 1024 * 1024:
log_error('Discarding part of too large OSC 52 paste request')
self.clipboard_pending = self.clipboard_pending._replace(data='', truncated=True)
return
if not where:
if self.clipboard_pending is not None:
text = self.clipboard_pending.data + text
where = self.clipboard_pending.where
try:
if self.clipboard_pending.truncated:
return
finally:
self.clipboard_pending = None
else:
where = 's0'
cc = get_options().clipboard_control
if text == '?':
response = None
if 's' in where or 'c' in where:
if 'read-clipboard-ask' in cc:
return self.ask_to_read_clipboard(False)
response = get_clipboard_string() if 'read-clipboard' in cc else ''
loc = 'c'
elif 'p' in where:
if 'read-primary-ask' in cc:
return self.ask_to_read_clipboard(True)
response = get_primary_selection() if 'read-primary' in cc else ''
loc = 'p'
response = response or ''
self.send_osc52(loc, response or '')
else:
from base64 import standard_b64decode
try:
text = standard_b64decode(text).decode('utf-8')
except Exception:
text = ''
if 's' in where or 'c' in where:
if 'write-clipboard' in cc:
set_clipboard_string(text)
if 'p' in where:
if 'write-primary' in cc:
set_primary_selection(text)
self.clipboard_pending = None
def send_osc52(self, loc: str, response: str) -> None:
from base64 import standard_b64encode
self.screen.send_escape_code_to_child(OSC, '52;{};{}'.format(
loc, standard_b64encode(response.encode('utf-8')).decode('ascii')))
def ask_to_read_clipboard(self, primary: bool = False) -> None:
if self.current_clipboard_read_ask is not None:
self.current_clipboard_read_ask = primary
return
self.current_clipboard_read_ask = primary
get_boss().confirm(_(
'A program running in this window wants to read from the system clipboard.'
' Allow it do so, once?'),
self.handle_clipboard_confirmation, window=self,
)
def handle_clipboard_confirmation(self, confirmed: bool) -> None:
try:
loc = 'p' if self.current_clipboard_read_ask else 'c'
response = ''
if confirmed:
response = get_primary_selection() if self.current_clipboard_read_ask else get_clipboard_string()
self.send_osc52(loc, response)
finally:
self.current_clipboard_read_ask = None
def clipboard_control(self, data: str, is_partial: Optional[bool] = False) -> None:
if is_partial is not None:
self.clipboard_request_manager.parse_osc_52(data, is_partial)
def manipulate_title_stack(self, pop: bool, title: str, icon: Any) -> None:
if title: