diff --git a/server/core.py b/server/core.py index 58dbea1..9a63855 100644 --- a/server/core.py +++ b/server/core.py @@ -29,6 +29,9 @@ def __init__(self, rpc_impl, server, plugins=tuple(), logger=None): self.logger = logger or logging.getLogger(self.__class__.__name__) self.server = server + self.logger.debug('using {0} for input emulation'.format( + rpc_impl.__class__.__name__)) + for rpc_func, rpc_name in rpc_impl.rpc_commands.items(): self.server.register_function(rpc_name, rpc_func) self.server.register_function(self.multiple_actions, 'multiple_actions') @@ -51,17 +54,20 @@ def from_config(cls, platform_rpcs, config): log_file=getattr(config, 'LOG_FILE', None)) logger = logging.getLogger(AeneaLoggingManager.aenea_logger_name) - rpc_server = SimpleJSONRPCServer((config.HOST, config.PORT)) + rpc_server = SimpleJSONRPCServer( + (config.HOST, config.PORT), logRequests=False) # TODO: dynamically load/instantiate platform_rpcs from config instead # of requiring it as an explicit argument plugins = AeneaPluginLoader(logger).get_plugins( - getattr(config, 'PLUGIN_DIR', None)) + getattr(config, 'PLUGIN_PATH', None)) return cls(platform_rpcs, rpc_server, plugins=plugins, logger=logger) def serve_forever(self): + self.logger.debug( + 'starting server on {0}:{1}'.format(*self.server.server_address)) self.server.serve_forever() def multiple_actions(self, actions): diff --git a/server/linux_x11/requirements.txt b/server/linux_x11/requirements.txt index 6a87bfe..8fc2caf 100644 --- a/server/linux_x11/requirements.txt +++ b/server/linux_x11/requirements.txt @@ -1,4 +1,6 @@ +python-libxdo==0.1.2a1 +psutil==3.0.0 jsonrpclib >= 0.1.7 mock >= 1.3.0 -python-xlib >= 0.15rc1 -yapsy >= 1.11.223 +svn+https://svn.code.sf.net/p/python-xlib/code/trunk/ +yapsy >= 1.11.223 \ No newline at end of file diff --git a/server/linux_x11/server_x11.py b/server/linux_x11/server_x11.py index 3f001db..3794602 100755 --- a/server/linux_x11/server_x11.py +++ b/server/linux_x11/server_x11.py @@ -16,6 +16,7 @@ # # Copyright (2014) Alex Roper # Alex Roper +import argparse import os import sys from os.path import join, dirname, realpath @@ -25,30 +26,55 @@ import config from server.core import AeneaServer -from server.linux_x11.x11_xdotool import XdotoolPlatformRpcs -if __name__ == '__main__': - if '-d' in sys.argv or '--daemon' in sys.argv: + + +def daemonize(): + if os.fork() == 0: + os.setsid() if os.fork() == 0: - os.setsid() - if os.fork() == 0: - os.chdir('/') - os.umask(0) - # Safe upper bound on number of fds we could - # possibly have opened. - for fd in range(64): - try: - os.close(fd) - except OSError: - pass - os.open(os.devnull, os.O_RDWR) - os.dup2(0, 1) - os.dup2(0, 2) - else: - os._exit(0) + os.chdir('/') + os.umask(0) + # Safe upper bound on number of fds we could + # possibly have opened. + for fd in range(64): + try: + os.close(fd) + except OSError: + pass + os.open(os.devnull, os.O_RDWR) + os.dup2(0, 1) + os.dup2(0, 2) else: os._exit(0) + else: + os._exit(0) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Aenea Linux X11 Server') + parser.add_argument( + '--daemon', action='store_const', const=True, default=False, + required=False, help='If provided the server runs in the background.') + parser.add_argument( + '--input', action='store', type=str, default='xdotool', + choices=('xdotool', 'libxdo'), required=False, dest='impl', + help='Aenea Server Input Method. Providing the default, ' + '"xdotool" will make the server shell out to the xdotool ' + 'program to emulate input. "libxdo" will cause the server ' + 'to make calls to the xdo library.') + + arguments = parser.parse_args() + + if arguments.impl == 'xdotool': + from server.linux_x11.x11_xdotool import XdotoolPlatformRpcs + platform_rpcs = XdotoolPlatformRpcs(config) + elif arguments.impl == 'libxdo': + from server.linux_x11.x11_libxdo import XdoPlatformRpcs + platform_rpcs = XdoPlatformRpcs() + + if arguments.daemon: + daemonize() - platform_rpcs = XdotoolPlatformRpcs(config) server = AeneaServer.from_config(platform_rpcs, config) server.serve_forever() diff --git a/server/linux_x11/x11_libxdo.py b/server/linux_x11/x11_libxdo.py new file mode 100644 index 0000000..9b74991 --- /dev/null +++ b/server/linux_x11/x11_libxdo.py @@ -0,0 +1,332 @@ +#!/usr/bin/python + +# This file is part of Aenea +# +# Aenea is free software: you can redistribute it and/or modify it under +# the terms of version 3 of the GNU Lesser General Public License as +# published by the Free Software Foundation. +# +# Aenea is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with Aenea. If not, see . +# +# Copyright (2014) Alex Roper +# Alex Roper + +import subprocess +import time +import array +import xdo +import Xlib.display +import psutil + +from server.core import AbstractAeneaPlatformRpcs + +_MOUSE_BUTTONS = { + 'left': 1, + 'middle': 2, + 'right': 3, + 'wheelup': 4, + 'wheeldown': 5 + } + + +_SERVER_INFO = { + 'window_manager': 'awesome', + 'operating_system': 'linux', + 'platform': 'linux', + 'display': 'X11', + 'server': 'aenea_reference', + 'server_version': 1 + } + + +# Additional X properties that we'd like to expose via get_context() +_X_PROPERTIES = { + '_NET_WM_DESKTOP': 'desktop', + 'WM_WINDOW_ROLE': 'role', + '_NET_WM_WINDOW_TYPE': 'type', + '_NET_WM_PID': 'pid', + 'WM_LOCALE_NAME': 'locale', + 'WM_CLIENT_MACHINE': 'client_machine', + 'WM_NAME': 'name' +} + + +_MOD_TRANSLATION = { + 'alt': 'Alt_L', + 'shift': 'Shift_L', + 'control': 'Control_L', + 'super': 'Super_L', + 'hyper': 'Hyper_L', + 'meta': 'Meta_L', + 'win': 'Super_L', + 'flag': 'Super_L', + } + + +_KEY_TRANSLATION = { + 'ampersand': 'ampersand', + 'apostrophe': 'apostrophe', + 'apps': 'Menu', + 'asterisk': 'asterisk', + 'at': 'at', + 'backslash': 'backslash', + 'backspace': 'BackSpace', + 'backtick': 'grave', + 'bar': 'bar', + 'caret': 'asciicircum', + 'colon': 'colon', + 'comma': 'comma', + 'del': 'Delete', + 'delete': 'Delete', + 'dollar': 'dollar', + 'dot': 'period', + 'dquote': 'quotedbl', + 'enter': 'Return', + 'equal': 'equal', + 'exclamation': 'exclam', + 'hash': 'numbersign', + 'hyphen': 'minus', + 'langle': 'less', + 'lbrace': 'braceleft', + 'lbracket': 'bracketleft', + 'lparen': 'parenleft', + 'minus': 'minus', + 'npadd': 'KP_Add', + 'npdec': 'KP_Decimal', + 'npdiv': 'KP_Divide', + 'npmul': 'KP_Multiply', + 'percent': 'percent', + 'pgdown': 'Next', + 'pgup': 'Prior', + 'plus': 'plus', + 'question': 'question', + 'rangle': 'greater', + 'rbrace': 'braceright', + 'rbracket': 'bracketright', + 'rparen': 'parenright', + 'semicolon': 'semicolon', + 'shift': 'Shift_L', + 'slash': 'slash', + 'space': 'space', + 'squote': 'apostrophe', + 'tilde': 'asciitilde', + 'underscore': 'underscore', + 'win': 'Super_L', + } + + +def update_key_translation(translation): + caps_keys = [ + 'left', + 'right', + 'up', + 'down', + 'home', + 'end', + 'tab', + 'insert', + 'escape' + ] + caps_keys.extend('f%i' % i for i in xrange(1, 13)) + for key in caps_keys: + translation[key] = key[0].upper() + key[1:] + for index in xrange(10): + translation['np%i' % index] = 'KP_%i' % index + for c in range(ord('a'), ord('z')) + range(ord('0'), ord('9')): + translation[chr(c)] = chr(c) + translation[chr(c).upper()] = chr(c).upper() +update_key_translation(_KEY_TRANSLATION) + + +class XdoPlatformRpcs(AbstractAeneaPlatformRpcs): + """ + Aenea RPC implementation that uses low level C bindings to the xdo library. + """ + def __init__(self, xdo_delay=0, display=None, **kwargs): + """ + :param int xdo_delay: Default pause between keystrokes. + :param str display: reserved for future use. + :param kwargs: + """ + super(XdoPlatformRpcs, self).__init__(**kwargs) + + self.display = Xlib.display.Display(display) + self.libxdo = xdo.Xdo(display) + + self.xdotool_delay = xdo_delay + + # compute and cache {atom_name: atom_value} dict once to save us from + # having to repeatedly query X for this data. + self.x_atoms = { + name: self.display.intern_atom(name) for name in _X_PROPERTIES + } + + def server_info(self): + return { + 'window_manager': 'idk', + 'operating_system': 'linux', + 'platform': 'linux', + 'display': 'X11', + 'server': 'x11_libxdo', + 'server_version': 1 + } + + def get_context(self): + try: + window_id = self.libxdo.get_focused_window_sane() + window = self.display.create_resource_object('window', window_id) + except Exception as error: + self.logger.error('failed to get active window error=%s', error) + return {} + + properties = {'id': window_id} + + window_class = window.get_wm_class() + if window_class is not None: + properties['cls_name'] = window_class[0] + properties['cls'] = window_class[1] + + window_title = window.get_wm_name() + if window_title is not None: + properties['title'] = window_title + + # get additional window properties via xlib. if the window does not + # have the property then omit it from . + for atom_name, atom in self.x_atoms.items(): + queried_property = window.get_full_property(atom, 0) + if queried_property is not None: + value = queried_property.value + if type(value) == array.array: + value = value.tolist() + if not len(value): + continue + value = value[0] + properties[_X_PROPERTIES[atom_name]] = str(value) + + # get process related context info. if we cannot get this information + # then omit it from . + try: + pid = self.libxdo.get_pid_window(window_id) + properties['pid'] = pid + process = psutil.Process(pid) + + try: + properties['executable'] = process.exe() + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + try: + properties['cmdline'] = process.cmdline() + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + except Exception as e: + self.logger.error('failed to get process context: %s' % e) + + return properties + + def key_press(self, key=None, modifiers=(), direction='press', count=1, + count_delay=None): + assert key is not None + + delay_millis = 0 if count_delay is None or count == 1 else count_delay + delay_micros = delay_millis * 1000 + modifiers = [_MOD_TRANSLATION.get(mod, mod) for mod in modifiers] + key = _KEY_TRANSLATION.get(key, key) + + # TODO: We can distill this entire loop down to a single libxdo function + # call when we figure out how to properly user charcode_t entities from + # libxdo. + for _ in range(0, count): + # modifiers down + self.libxdo.send_keysequence_window_down( + 0, '+'.join(modifiers), delay_micros) + + if direction == 'press': + self.libxdo.send_keysequence_window(0, key, delay_micros) + elif direction == 'up': + self.libxdo.send_keysequence_window_up(0, key, delay_micros) + elif direction == 'down': + self.libxdo.send_keysequence_window_down(0, key, delay_micros) + + # modifiers up + self.libxdo.send_keysequence_window_up( + 0, '+'.join(reversed(modifiers)), delay_micros) + + time.sleep(delay_millis / 1000) # emulate xdotool sleep + + def write_text(self, text): + self.libxdo.enter_text_window(0, text, self.xdotool_delay*1000) + + def click_mouse(self, button, direction='click', count=1, count_delay=None): + delay_millis = 0 if count_delay is None or count < 2 else count_delay + + if button in _MOUSE_BUTTONS: + button = _MOUSE_BUTTONS[button] + else: + try: + button = int(button) + except ValueError: + raise ValueError('invalid "button" parameter: "%s"' % button) + + for _ in range(0, count): + if direction == 'click': + self.libxdo.click_window(0, button) + elif direction == 'down': + self.libxdo.mouse_down(0, button) + elif direction == 'up': + self.libxdo.mouse_up(0, button) + else: + raise ValueError( + 'invalid "direction" parameter: "%s"' % direction) + + time.sleep(delay_millis / 1000) + + def _get_geometry(self, window_id=None): + if window_id is None: + window_id = self.libxdo.get_focused_window_sane() + window_location = self.libxdo.get_window_location(window_id) + window_size = self.libxdo.get_window_size(window_id) + return { + 'x': int(window_location.x), + 'y': int(window_location.y), + 'screen': window_location.screen.display, + 'height': int(window_size.height), + 'width': int(window_size.width), + } + + def move_mouse(self, x, y, reference='absolute', proportional=False, + phantom=None): + original_location = self.libxdo.get_mouse_location() + geo = self._get_geometry() + + if proportional: + x = int(geo['width'] * x) + y = int(geo['height'] * y) + + if reference == 'absolute': + x = x if x > 0 else x + y = y if y > 0 else y + self.libxdo.move_mouse(x, y) + elif reference == 'relative_active': + window_location = self.libxdo.get_window_location( + self.libxdo.get_active_window()) + self.libxdo.move_mouse(window_location.x + x, window_location.y + y) + elif reference == 'relative': + self.libxdo.move_mouse_relative(x, y) + else: + raise ValueError('invalid "reference" parameter "%s"' % reference) + + if phantom is not None: + self.libxdo.click_window(0, _MOUSE_BUTTONS[phantom]) + self.libxdo.move_mouse(original_location.x, original_location.y) + + def notify(self, message): + try: + subprocess.Popen(['notify-send', message]) + except Exception as e: + self.logger.warn('failed to start notify-send process: %s' % e) diff --git a/server/linux_x11/x11_xdotool.py b/server/linux_x11/x11_xdotool.py index 36d4cd2..86e9525 100644 --- a/server/linux_x11/x11_xdotool.py +++ b/server/linux_x11/x11_xdotool.py @@ -151,8 +151,7 @@ class XdotoolPlatformRpcs(AbstractAeneaPlatformRpcs): """ def __init__(self, config, xdotool='xdotool'): super(XdotoolPlatformRpcs, self).__init__( - logger=logging.getLogger('aenea.XdotoolPlatformRpcs') - ) + logger=logging.getLogger('aenea.XdotoolPlatformRpcs')) self.xdotool = xdotool self.xdotool_delay = getattr(config, 'XDOTOOL_DELAY', 0)