From 15f07f57bfe2edb0b1f8f2ef7963ef0ec858439e Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 3 Mar 2018 10:32:49 +0530 Subject: [PATCH] Finish remote control via socket implementation --- kitty/boss.py | 61 ++++++++++++++++++++--------------------- kitty/child-monitor.c | 4 +-- kitty/cli.py | 13 +++++---- kitty/remote_control.py | 35 +++++++++++++++++------ kitty/utils.py | 19 +++++++++++++ 5 files changed, 85 insertions(+), 47 deletions(-) diff --git a/kitty/boss.py b/kitty/boss.py index f5ddc95e8..5ae1d7ee0 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -26,8 +26,8 @@ from .session import create_session from .tabs import SpecialWindow, SpecialWindowInstance, TabManager from .utils import ( end_startup_notification, get_primary_selection, init_startup_notification, - open_url, remove_socket_file, safe_print, set_primary_selection, - single_instance + open_url, parse_address_spec, remove_socket_file, safe_print, + set_primary_selection, single_instance ) @@ -37,21 +37,7 @@ def initialize_renderer(): def listen_on(spec): - protocol, rest = spec.split(':', 1) - socket_path = None - if protocol == 'unix': - family = socket.AF_UNIX - address = rest - if address.startswith('@') and len(address) > 1: - address = '\0' + address[1:] - else: - socket_path = address - elif protocol in ('tcp', 'tcp6'): - family = socket.AF_INET if protocol == 'tcp' else socket.AF_INET6 - host, port = rest.rsplit(':', 1) - address = host, int(port) - else: - raise ValueError('Unknown protocol in --listen-on value: {}'.format(spec)) + family, address, socket_path = parse_address_spec(spec) s = socket.socket(family) atexit.register(remove_socket_file, s, socket_path) s.bind(address) @@ -195,20 +181,7 @@ class Boss: self.child_monitor.add_child(window.id, window.child.pid, window.child.child_fd, window.screen) self.window_id_map[window.id] = window - def peer_message_received(self, msg): - import json - msg = json.loads(msg.decode('utf-8')) - if isinstance(msg, dict) and msg.get('cmd') == 'new_instance': - startup_id = msg.get('startup_id') - args, rest = parse_args(msg['args'][1:]) - args.args = rest - opts = create_opts(args) - session = create_session(opts, args) - self.add_os_window(session, wclass=args.cls, wname=args.name, size=initial_window_size(opts, self.cached_values), startup_id=startup_id) - else: - safe_print('Unknown message received from peer, ignoring') - - def handle_remote_cmd(self, cmd, window=None): + def _handle_remote_command(self, cmd, window=None): response = None if self.opts.allow_remote_control: try: @@ -218,6 +191,32 @@ class Boss: response = {'ok': False, 'error': str(err), 'tb': traceback.format_exc()} else: response = {'ok': False, 'error': 'Remote control is disabled. Add allow_remote_control yes to your kitty.conf'} + return response + + def peer_message_received(self, msg): + import json + msg = msg.decode('utf-8') + cmd_prefix = '\x1bP@kitty-cmd' + if msg.startswith(cmd_prefix): + cmd = msg[len(cmd_prefix):-2] + response = self._handle_remote_command(cmd) + if response is not None: + response = (cmd_prefix + json.dumps(response) + '\x1b\\').encode('utf-8') + return response + else: + msg = json.loads(msg) + if isinstance(msg, dict) and msg.get('cmd') == 'new_instance': + startup_id = msg.get('startup_id') + args, rest = parse_args(msg['args'][1:]) + args.args = rest + opts = create_opts(args) + session = create_session(opts, args) + self.add_os_window(session, wclass=args.cls, wname=args.name, size=initial_window_size(opts, self.cached_values), startup_id=startup_id) + else: + safe_print('Unknown message received from peer, ignoring') + + def handle_remote_cmd(self, cmd, window=None): + response = self._handle_remote_command(cmd, window) if response is not None: if window is not None: window.send_cmd_response(response) diff --git a/kitty/child-monitor.c b/kitty/child-monitor.c index e70790bd1..2032224d7 100644 --- a/kitty/child-monitor.c +++ b/kitty/child-monitor.c @@ -354,7 +354,7 @@ parse_input(ChildMonitor *self) { for (Py_ssize_t i = 0; i < PyTuple_GET_SIZE(msg); i++) { PyObject *resp = PyObject_CallMethod(global_state.boss, "peer_message_received", "O", PyTuple_GET_ITEM(PyTuple_GET_ITEM(msg, i), 0)); int peer_fd = (int)PyLong_AsLong(PyTuple_GET_ITEM(PyTuple_GET_ITEM(msg, i), 1)); - if (resp && PyBytes_Check(resp)) send_response(peer_fd, PyBytes_AS_STRING(PyTuple_GET_ITEM(PyTuple_GET_ITEM(msg, i), 0)), PyBytes_GET_SIZE(PyTuple_GET_ITEM(PyTuple_GET_ITEM(msg, i), 0))); + if (resp && PyBytes_Check(resp)) send_response(peer_fd, PyBytes_AS_STRING(resp), PyBytes_GET_SIZE(resp)); else { send_response(peer_fd, NULL, 0); if (!resp) PyErr_Print(); } Py_CLEAR(resp); } @@ -1143,7 +1143,7 @@ wakeup_talk_loop(bool in_signal_handler) { static inline void move_queued_writes() { while (talk_data.num_queued_writes) { - PeerWriteData *src = talk_data.queued_writes + talk_data.num_queued_writes--; + PeerWriteData *src = talk_data.queued_writes + --talk_data.num_queued_writes; size_t fd_idx = talk_data.num_listen_fds + talk_data.num_talk_fds; if (fd_idx < arraysz(talk_data.fds) && talk_data.num_writes < arraysz(talk_data.writes)) { talk_data.fds[fd_idx].fd = src->fd; talk_data.fds[fd_idx].events = POLLOUT; diff --git a/kitty/cli.py b/kitty/cli.py index e2eef34c8..42d97ffc0 100644 --- a/kitty/cli.py +++ b/kitty/cli.py @@ -85,12 +85,13 @@ in the first |_ {appname}| instance within that group --listen-on -Tell kitty to listen on the specified UNIX socket or TCP port for control -messages. For example, --listen-on=unix:/tmp/mykitty or ---listen-on=tcp:localhost:12345. On Linux systems, you can also use abstract -UNIX sockets, not associated with a file, like this: --listen-on=unix:@mykitty. -Note that this option will be ignored, unless you set allow_remote_control to yes -in kitty.conf +Tell kitty to listen on the specified address for control +messages. For example, |_ --listen-on=unix:/tmp/mykitty| or +|_ --listen-on=tcp:localhost:12345|. On Linux systems, you can also use abstract +UNIX sockets, not associated with a file, like this: |_ --listen-on=unix:@mykitty|. +To control kitty, you can send it commands with |_ kitty @| using the |_ --to| option +to specify this address. Note that this option will be ignored, unless you set +|_ allow_remote_control| to yes in |_ kitty.conf| # Debugging options diff --git a/kitty/remote_control.py b/kitty/remote_control.py index 502a91c6f..634bc5b80 100644 --- a/kitty/remote_control.py +++ b/kitty/remote_control.py @@ -4,6 +4,7 @@ import json import re +import socket import sys import types from functools import partial @@ -12,7 +13,7 @@ from .cli import emph, parse_args from .config import parse_send_text_bytes from .constants import appname, version from .tabs import SpecialWindow -from .utils import non_blocking_read, read_with_timeout +from .utils import non_blocking_read, parse_address_spec, read_with_timeout def cmd(short_desc, desc=None, options_spec=None, no_response=False): @@ -411,14 +412,28 @@ def handle_cmd(boss, window, cmd): global_options_spec = partial('''\ - +--to +An address for the kitty instance to control. Corresponds to the address +given to the kitty instance via the --listen-on option. If not specified, +messages are sent to the controlling terminal for this process, i.e. they +will only work if this process is run within an existing kitty window. '''.format, appname=appname) -def do_io(send, no_response): +def do_io(to, send, no_response): send = ('@kitty-cmd' + json.dumps(send)).encode('ascii') - with open('/dev/tty', 'wb') as out: - out.write(b'\x1bP' + send + b'\x1b\\') + send = b'\x1bP' + send + b'\x1b\\' + if to: + family, address = parse_address_spec(to)[:2] + s = socket.socket(family) + s.connect(address) + out = s.makefile('wb') + else: + out = open('/dev/tty', 'wb') + with out: + out.write(send) + if to: + s.shutdown(socket.SHUT_WR) if no_response: return {'ok': True} @@ -432,7 +447,11 @@ def do_io(send, no_response): match = dcs.search(received) return match is None - with open('/dev/tty', 'rb') as src: + if to: + src = s.makefile('rb') + else: + src = open('/dev/tty', 'rb') + with src: read_with_timeout(more_needed, src=src) if match is None: raise SystemExit('Failed to receive response from ' + appname) @@ -470,11 +489,11 @@ def main(args): if func.no_response and isinstance(payload, types.GeneratorType): for item in payload: send['payload'] = item - do_io(send, func.no_response) + do_io(global_opts.to, send, func.no_response) return if payload is not None: send['payload'] = payload - response = do_io(send, func.no_response) + response = do_io(global_opts.to, send, func.no_response) if not response.get('ok'): if response.get('tb'): print(response['tb'], file=sys.stderr) diff --git a/kitty/utils.py b/kitty/utils.py index bb0c42f0f..0657781d0 100644 --- a/kitty/utils.py +++ b/kitty/utils.py @@ -262,6 +262,25 @@ def single_instance(group_id=None): return True +def parse_address_spec(spec): + protocol, rest = spec.split(':', 1) + socket_path = None + if protocol == 'unix': + family = socket.AF_UNIX + address = rest + if address.startswith('@') and len(address) > 1: + address = '\0' + address[1:] + else: + socket_path = address + elif protocol in ('tcp', 'tcp6'): + family = socket.AF_INET if protocol == 'tcp' else socket.AF_INET6 + host, port = rest.rsplit(':', 1) + address = host, int(port) + else: + raise ValueError('Unknown protocol in --listen-on value: {}'.format(spec)) + return family, address, socket_path + + @contextmanager def non_blocking_read(src=sys.stdin): import termios