#!/usr/bin/env python # vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2018, Kovid Goyal import json import os import re import sys import types from functools import partial from io import DEFAULT_BUFFER_SIZE from .cli import emph, parse_args from .cmds import cmap, parse_subcommand_cli from .constants import appname, version from .fast_data_types import close_tty, open_tty from .utils import parse_address_spec, write_all def handle_cmd(boss, window, cmd): cmd = json.loads(cmd) v = cmd['version'] if tuple(v)[:2] > version[:2]: return {'ok': False, 'error': 'The kitty client you are using to send remote commands is newer than this kitty instance. This is not supported.'} c = cmap[cmd['cmd']] func = partial(c.impl(), boss, window) payload = cmd.get('payload') ans = func() if payload is None else func(payload) response = {'ok': True} if ans is not None: response['data'] = ans if not c.no_response: return response global_options_spec = partial('''\ --to An address for the kitty instance to control. Corresponds to the address given to the kitty instance via the :option:`kitty --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 encode_send(send): send = ('@kitty-cmd' + json.dumps(send)).encode('ascii') return b'\x1bP' + send + b'\x1b\\' class SocketIO: def __init__(self, to): self.family, self.address = parse_address_spec(to)[:2] def __enter__(self): import socket self.socket = socket.socket(self.family) self.socket.setblocking(True) self.socket.connect(self.address) def __exit__(self, *a): import socket self.socket.shutdown(socket.SHUT_RDWR) self.socket.close() def send(self, data): import socket with self.socket.makefile('wb') as out: out.write(data) self.socket.shutdown(socket.SHUT_WR) def recv(self, more_needed, timeout): # We dont bother with more_needed since the server will close the # connection after transmission self.socket.settimeout(timeout) with self.socket.makefile('rb') as src: data = src.read() more_needed(data) class TTYIO: def __enter__(self): self.tty_fd, self.original_termios = open_tty() def __exit__(self, *a): close_tty(self.tty_fd, self.original_termios) def send(self, data): write_all(self.tty_fd, data) def recv(self, more_needed, timeout): import select from time import monotonic fd = self.tty_fd start_time = monotonic() while timeout > monotonic() - start_time: rd = select.select([fd], [], [], max(0, timeout - (monotonic() - start_time)))[0] if rd: data = os.read(fd, DEFAULT_BUFFER_SIZE) if not data: break # eof if not more_needed(data): break else: break def do_io(to, send, no_response): send = encode_send(send) io = SocketIO(to) if to else TTYIO() with io: io.send(send) if no_response: return {'ok': True} dcs = re.compile(br'\x1bP@kitty-cmd([^\x1b]+)\x1b\\') received = b'' match = None def more_needed(data): nonlocal received, match received += data match = dcs.search(received) return match is None io.recv(more_needed, timeout=10) if match is None: raise SystemExit('Failed to receive response from ' + appname) response = json.loads(match.group(1).decode('ascii')) return response all_commands = tuple(sorted(cmap)) cli_msg = ( 'Control {appname} by sending it commands. Set the' ' :opt:`allow_remote_control` option to yes in :file:`kitty.conf` for this' ' to work.' ).format(appname=appname) def parse_rc_args(args): cmds = (' :green:`{}`\n {}'.format(cmap[c].name, cmap[c].short_desc) for c in all_commands) msg = cli_msg + ( '\n\n:title:`Commands`:\n{cmds}\n\n' 'You can get help for each individual command by using:\n' '{appname} @ :italic:`command` -h').format(appname=appname, cmds='\n'.join(cmds)) return parse_args(args[1:], global_options_spec, 'command ...', msg, '{} @'.format(appname)) def main(args): global_opts, items = parse_rc_args(args) if not items: from kitty.shell import main main(global_opts) return cmd = items[0] try: func = cmap[cmd] except KeyError: raise SystemExit('{} is not a known command. Known commands are: {}'.format( emph(cmd), ', '.join(all_commands))) opts, items = parse_subcommand_cli(func, items) payload = func(global_opts, opts, items) send = { 'cmd': cmd, 'version': version, } if func.no_response and isinstance(payload, types.GeneratorType): for item in payload: send['payload'] = item do_io(global_opts.to, send, func.no_response) return if payload is not None: send['payload'] = payload 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) raise SystemExit(response['error']) data = response.get('data') if data is not None: if func.string_return_is_error and isinstance(data, str): raise SystemExit(data) print(data)