├── server ├── __init__.py ├── linux_x11 │ ├── __init__.py │ ├── aenea │ ├── requirements.txt │ ├── plugins │ │ ├── example.yapsy-plugin │ │ └── example.py │ ├── config.py.example │ ├── server_x11.py │ ├── test-client.py │ └── test_server_x11.py ├── osx │ ├── aenea │ ├── requirements.txt │ ├── config.py.example │ └── test-client.py ├── windows │ └── aenea-windows-server │ │ ├── .gitignore │ │ ├── Setup.hs │ │ ├── aenea-windows-server.cabal │ │ ├── LICENSE │ │ └── src │ │ └── Main.hs └── linux_wayland │ ├── config.py │ ├── abstractKeyboardMapping.py │ ├── qwerty.py │ ├── server_wayland.py │ ├── evdevImpl.py │ └── azerty.py ├── OWNERS ├── client ├── requirements.txt ├── setup.py ├── _hello_world_natlink.py ├── _hello_world_dragonfly.py ├── aenea │ ├── __init__.py │ ├── format.py │ ├── strict.py │ ├── misc.py │ ├── lax.py │ ├── communications.py │ ├── proxy_contexts.py │ ├── proxy_actions.py │ ├── configuration.py │ ├── wrappers.py │ ├── alias.py │ └── config.py ├── _hello_world_aenea.py ├── test │ ├── configuration_mock.py │ ├── test_configuration.py │ ├── test_proxy_contexts.py │ ├── test_proxy_actions.py │ └── test_vocabulary.py ├── _server_plugin_example.py ├── _capture_client_control.py ├── _vocabulary.py ├── _aenea.py └── aenea_client.py ├── .gitignore ├── .nopyflakes ├── aenea.json.example ├── grammar_config ├── misc.json.example ├── vocabulary.json.example └── aenea.json.example ├── test_runner.py ├── AUTHORS ├── .travis.yml ├── lint.py ├── generate_security_token.py ├── HACKING.rst └── LICENSE /server/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /OWNERS: -------------------------------------------------------------------------------- 1 | @calmofthestorm 2 | -------------------------------------------------------------------------------- /server/linux_x11/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/osx/aenea: -------------------------------------------------------------------------------- 1 | ../../client/aenea/ -------------------------------------------------------------------------------- /server/linux_x11/aenea: -------------------------------------------------------------------------------- 1 | ../../client/aenea/ -------------------------------------------------------------------------------- /server/windows/aenea-windows-server/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /server/windows/aenea-windows-server/Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | main = defaultMain 3 | -------------------------------------------------------------------------------- /client/requirements.txt: -------------------------------------------------------------------------------- 1 | dragonfly2 >= 0.8.0 2 | jsonrpclib >= 0.1.7 3 | pyparsing >= 2.0.1 4 | mock >= 1.3.0 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .*.swp 3 | client/util/personal.py 4 | client/util/config.py 5 | server/linux_x11/config.py 6 | -------------------------------------------------------------------------------- /server/osx/requirements.txt: -------------------------------------------------------------------------------- 1 | pyobjc-framework-Quartz >= 3.1.1 2 | py-applescript >= 1.0.0 3 | jsonrpclib >= 0.1.7 4 | -------------------------------------------------------------------------------- /server/linux_wayland/config.py: -------------------------------------------------------------------------------- 1 | # Host-side config (Linux host receiving commands) 2 | HOST = "0.0.0.0" 3 | PORT = 8240 4 | 5 | PLUGIN_PATH = ["plugins"] 6 | -------------------------------------------------------------------------------- /server/linux_x11/requirements.txt: -------------------------------------------------------------------------------- 1 | python-libxdo==0.1.2a1 2 | psutil==3.0.0 3 | jsonrpclib >= 0.1.7 4 | mock >= 1.3.0 5 | svn+https://svn.code.sf.net/p/python-xlib/code/trunk/ 6 | yapsy >= 1.11.223 -------------------------------------------------------------------------------- /.nopyflakes: -------------------------------------------------------------------------------- 1 | ./client/aenea/__init__.py 2 | ./client/aenea/lax.py 3 | ./client/aenea/strict.py 4 | ./client/aenea/vocabulary.py 5 | ./client/aenea/wrappers.py 6 | ./server/linux_wayland/qwerty.py 7 | ./server/osx/server_osx.py 8 | -------------------------------------------------------------------------------- /server/linux_x11/plugins/example.yapsy-plugin: -------------------------------------------------------------------------------- 1 | [Core] 2 | Name = Example plugin 3 | Module = example 4 | 5 | [Documentation] 6 | Author = Alex Roper 7 | Version = 0.1 8 | Description = Example plugin for how to add RPC commands to the server. 9 | -------------------------------------------------------------------------------- /aenea.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "host": "192.168.56.1", 3 | "port": 8240, 4 | "platform": "proxy", 5 | "use_multiple_actions": true, 6 | "screen_resolution": [6400, 1440], 7 | "project_root": "C:\\NatLink\\NatLink\\MacroSystem", 8 | "restrict_proxy_to_aenea_client": false 9 | } 10 | -------------------------------------------------------------------------------- /grammar_config/misc.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "letters.lower": { 3 | "yoinkers": "yankee" 4 | }, 5 | "letters.upper": { 6 | "upper charlie": "upper charlie", 7 | "charleston": "upper charlie" 8 | }, 9 | "digits": { 10 | "nil": "zero" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test_runner.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | 4 | 5 | import os 6 | import sys 7 | 8 | 9 | def main(): 10 | fail = False 11 | 12 | for fn in sys.argv[1:]: 13 | fail = os.system('python %s' % fn) != 0 or fail 14 | 15 | if fail: 16 | sys.exit(-1) 17 | 18 | 19 | if __name__ == '__main__': 20 | main() 21 | -------------------------------------------------------------------------------- /client/setup.py: -------------------------------------------------------------------------------- 1 | """Installation script for aenea client.""" 2 | from setuptools import setup 3 | 4 | setup( 5 | name='aenea', 6 | version='1.0', 7 | description='dragonfly via proxy', 8 | author='Alex Roper', 9 | author_email='alex@aroper.net', 10 | python_requires='>=2.7,<3', 11 | packages=['aenea'], 12 | install_requires=['dragonfly']) 13 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Alex Roper 2 | @calmofthestorm 3 | alex@aroper.net 4 | 5 | Contributors: 6 | 7 | Windows server; improvements to multiedit: 8 | Kristen Kozak 9 | @grayjay 10 | grayjay@wordroute.com 11 | 12 | aenea_client (free-form dictation capture), various improvements: 13 | @poppe1219 14 | 15 | Organization improvements, improvements to grammars on Windows. 16 | Kevin Menard 17 | @nirvdrum 18 | kevin@nirvdrum.com 19 | -------------------------------------------------------------------------------- /grammar_config/vocabulary.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "commands": { 3 | "[refresh|reload] dynamic [vocabulary|vocabularies]": "[refresh|reload] dynamic [vocabulary|vocabularies]", 4 | "enable vocabulary ": "enable vocabulary ", 5 | "disable vocabulary ": "disable vocabulary ", 6 | "": "", 7 | "": "" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/linux_x11/plugins/example.py: -------------------------------------------------------------------------------- 1 | from yapsy.IPlugin import IPlugin 2 | 3 | enabled = True 4 | 5 | 6 | def greet_user(name='Incognito'): 7 | '''RPC command to greet a user. See the _server_plugin_example grammar for 8 | how to use on the client side via voice.''' 9 | print 'Hello user %s!' % name 10 | 11 | 12 | class ExamplePlugin(IPlugin): 13 | def register_rpcs(self, server): 14 | server.register_function(greet_user) 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | language: python 3 | 4 | sudo: false 5 | 6 | install: 7 | - travis_retry pip install --upgrade pip 8 | - travis_retry pip install --upgrade six 9 | - travis_retry pip install --upgrade pyflakes 10 | - travis_retry pip install -r client/requirements.txt 11 | 12 | script: 13 | - PATH="${HOME}/.local/bin:${PATH}" python lint.py 14 | - PYTHONPATH="client:${PYTHONPATH}" python test_runner.py `find client/test/test_*.py | xargs` 15 | -------------------------------------------------------------------------------- /grammar_config/aenea.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "commands": { 3 | "set proxy server to ": "set proxy server to ", 4 | "disable proxy server": "disable proxy server", 5 | "enable proxy server": "enable proxy server", 6 | "force natlink to reload all grammars": "force natlink to reload all grammars" 7 | }, 8 | "servers": { 9 | "windows box": {"host": "192.168.1.101", "port": 8240}, 10 | "linux": {"host": "192.168.56.1", "port": 8240} 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /server/linux_wayland/abstractKeyboardMapping.py: -------------------------------------------------------------------------------- 1 | 2 | class AbstractKeyboardMapping: 3 | def __init__(self): 4 | return 5 | 6 | def solo(self): 7 | """ 8 | A solo key is a key which is different 9 | than qwerty keyboard or which needs a modifier 10 | (i.e. you don't need to release a key during the sequence) 11 | """ 12 | raise NotImplementedError() 13 | 14 | def multi(self): 15 | """ 16 | A multi key is a key which needs a key sequence (i.e. you need 17 | to press and release several keys) 18 | """ 19 | raise NotImplementedError() 20 | -------------------------------------------------------------------------------- /lint.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | 4 | import os 5 | import sys 6 | 7 | 8 | def main(): 9 | ok = True 10 | 11 | with os.popen('find -type f | egrep ".py$"') as fd: 12 | python_files = set(fd) 13 | with open('.nopyflakes') as fd: 14 | python_files -= set(fd) 15 | 16 | for filename in python_files: 17 | if os.system('pyflakes %s' % filename) != 0: 18 | ok = False 19 | 20 | if ok: 21 | sys.exit(0) 22 | else: 23 | sys.exit(1) 24 | 25 | 26 | if __name__ == '__main__': 27 | main() 28 | -------------------------------------------------------------------------------- /client/_hello_world_natlink.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | print 'NatLink hello world module successfully loaded. All it does is print this message:-)' 19 | -------------------------------------------------------------------------------- /client/_hello_world_dragonfly.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | from dragonfly import Grammar, MappingRule, Text 19 | 20 | grammar = Grammar('hello world') 21 | 22 | class TestRule(MappingRule): 23 | mapping = { 24 | 'test hello world grammar': Text('Hello world grammar: recognition successful!'), 25 | } 26 | 27 | grammar.add_rule(TestRule()) 28 | grammar.load() 29 | 30 | def unload(): 31 | global grammar 32 | if grammar: 33 | grammar.unload() 34 | grammar = None 35 | -------------------------------------------------------------------------------- /client/aenea/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | import aenea.communications 19 | import aenea.configuration 20 | import aenea.config 21 | import aenea.format 22 | import aenea.lax 23 | import aenea.strict 24 | import aenea.misc 25 | import aenea.proxy_actions 26 | import aenea.proxy_contexts 27 | import aenea.vocabulary 28 | import aenea.wrappers 29 | 30 | from aenea.wrappers import * 31 | from aenea.proxy_actions import * 32 | from aenea.proxy_contexts import * 33 | from aenea.strict import * 34 | from aenea.alias import Alias 35 | -------------------------------------------------------------------------------- /client/_hello_world_aenea.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | from aenea import Grammar, MappingRule, Text 19 | 20 | grammar = Grammar('hello world aenea') 21 | 22 | print 'Aenea hello world grammar: Loaded.' 23 | 24 | class TestRule(MappingRule): 25 | mapping = { 26 | 'test hello world remote grammar': Text('Aenea remote setup operational'), 27 | } 28 | 29 | grammar.add_rule(TestRule()) 30 | grammar.load() 31 | 32 | def unload(): 33 | global grammar 34 | if grammar: 35 | grammar.unload() 36 | grammar = None 37 | -------------------------------------------------------------------------------- /server/windows/aenea-windows-server/aenea-windows-server.cabal: -------------------------------------------------------------------------------- 1 | -- Initial aenea-windows-server.cabal generated by cabal init. For further 2 | -- documentation, see http://haskell.org/cabal/users-guide/ 3 | 4 | name: aenea-windows-server 5 | version: 0.1.0.0 6 | license: LGPL-3 7 | license-file: LICENSE 8 | author: grayjay 9 | build-type: Simple 10 | cabal-version: >=1.8 11 | extra-source-files: windows.h, winuser.h, winable.h 12 | 13 | executable aenea 14 | main-is: Main.hs 15 | other-modules: Windows 16 | other-extensions: ForeignFunctionInterface, CPP, OverloadedStrings 17 | build-depends: base >=4.6 && <4.9, 18 | Win32 >=2.3 && <2.4, 19 | text >=0.11 && <1.3, 20 | aeson >=0.6 && <0.10, 21 | containers >=0.5 && <0.6, 22 | mtl >=2.1 && <2.3, 23 | happstack-lite >=7.3 && <7.4, 24 | happstack-server >=7.3 && <7.5, 25 | bytestring >=0.10 && <0.11, 26 | json-rpc-server >=0.2 && <0.3 27 | ghc-options: -Wall -fno-warn-missing-signatures 28 | hs-source-dirs: src 29 | build-tools: hsc2hs 30 | includes: Windows.h, Winuser.h 31 | extra-libraries: User32 32 | -------------------------------------------------------------------------------- /generate_security_token.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | 4 | import os 5 | import base64 6 | import json 7 | 8 | 9 | def main(): 10 | token = base64.b64encode(os.urandom(32)).decode('utf-8') 11 | 12 | print('Your randomly generated security token is %s' % token) 13 | 14 | if not append_constant(token, os.path.join('server', 'linux_x11', 'config.py')): 15 | append_constant(token, os.path.join('server', 'linux_x11', 'config.py.example')) 16 | 17 | if not append_constant(token, os.path.join('server', 'osx', 'config.py')): 18 | append_constant(token, os.path.join('server', 'osx', 'config.py.example')) 19 | 20 | if not add_json(token, 'aenea.json'): 21 | add_json(token, 'aenea.json.example') 22 | 23 | 24 | def append_constant(token, path): 25 | if not os.path.exists(path): 26 | print('%s does not exist; not adding security token to it.' % path) 27 | return False 28 | 29 | with open(path, 'a') as fd: 30 | fd.write('SECURITY_TOKEN = \'%s\'\n' % token) 31 | print('Appended to %s.' % path) 32 | return True 33 | 34 | 35 | def add_json(token, path): 36 | if not os.path.exists(path): 37 | print('%s does not exist; not adding security token to it.' % path) 38 | return False 39 | 40 | with open(path) as fd: 41 | data = json.load(fd) 42 | 43 | data['security_token'] = token 44 | 45 | with open(path, 'w') as fd: 46 | json.dump(data, fd) 47 | 48 | print('Added to %s.' % path) 49 | 50 | return True 51 | 52 | 53 | if __name__ == '__main__': 54 | main() 55 | -------------------------------------------------------------------------------- /server/osx/config.py.example: -------------------------------------------------------------------------------- 1 | # Host-side config (Linux host receiving commands) 2 | HOST = "192.168.56.1" 3 | PORT = 8240 4 | 5 | # When using the Text action, grammars may request (the default is not to) to 6 | # input the text using the clipboard rather than emulating keypresses. This has 7 | # the advantage of allowing far more rapid entry of a large chunk of text, but 8 | # may cause strange behavior with programs that don't understand middle click 9 | # paste. This is implemented using xsel, meaning that after text entry xsel may 10 | # remain running until the clipboard is cleared (this is necessary because X11 11 | # clipboards are not buffers, they are communication protocols.). I have verified 12 | # that there should only be at most three xsel processes running at a time, 13 | # though they may be quite long-lived, they do not consume substantial resources. 14 | # 15 | # Few programs use the SECONDARY buffer, which is where we back up the PRIMARY 16 | # buffer (middle click paste) during the operation. This buffer is clobbered. 17 | # 18 | # The server should clear the text it entered from the system clipboard after 19 | # entering it, so you do not need to worry about accidentally pasting it 20 | # somewhere else later. 21 | 22 | # Currently this is implemented by sending a middle click. unfortunately, not 23 | # many programs will work with this if the mouse is not precisely where you want 24 | # to click. There is no cross when doing environment way of pasting, which means 25 | # this approach would require a great deal of per environment coding to be 26 | # functional. When and if GTK ever fixes its broken shift+insert behavior, or at 27 | # least enables users to configure it to work properly, this will become 28 | # workable. 29 | ENABLE_XSEL = False 30 | -------------------------------------------------------------------------------- /server/linux_wayland/qwerty.py: -------------------------------------------------------------------------------- 1 | from abstractKeyboardMapping import AbstractKeyboardMapping 2 | import evdev 3 | 4 | class Qwerty(AbstractKeyboardMapping): 5 | def __init__(self): 6 | super(AbstractKeyboardMapping, self).__init__() 7 | 8 | def solo(self): 9 | return { "!" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_1], 10 | "@" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_2], 11 | "#" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_3], 12 | "$" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_4], 13 | "%" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_5], 14 | "^" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_6], 15 | "&" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_7], 16 | "*" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_8], 17 | "(" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_9], 18 | ")" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_0], 19 | "_" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_MINUS], 20 | "+" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_EQUAL], 21 | 22 | "{" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_LEFTBRACE], 23 | "}" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_RIGHTBRACE], 24 | ":" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_SEMICOLON], 25 | "\"" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_APOSTROPHE], 26 | "|" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_BACKSLASH], 27 | 28 | "<" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_COMMA], 29 | ">" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_DOT], 30 | "?" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_SLASH], 31 | 32 | } 33 | 34 | def multi(self): 35 | #no multi keys I think 36 | return {} 37 | -------------------------------------------------------------------------------- /client/aenea/format.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | def format_snakeword(text): 19 | formatted = text[0][0].upper() 20 | formatted += text[0][1:] 21 | formatted += ('_' if len(text) > 1 else '') 22 | formatted += format_score(text[1:]) 23 | return formatted 24 | 25 | 26 | def format_score(text): 27 | return '_'.join(text) 28 | 29 | 30 | def format_camel(text): 31 | return text[0] + ''.join([word[0].upper() + word[1:] for word in text[1:]]) 32 | 33 | 34 | def format_proper(text): 35 | return ''.join(word.capitalize() for word in text) 36 | 37 | 38 | def format_relpath(text): 39 | return '/'.join(text) 40 | 41 | 42 | def format_abspath(text): 43 | return '/' + format_relpath(text) 44 | 45 | 46 | def format_scoperesolve(text): 47 | return '::'.join(text) 48 | 49 | 50 | def format_jumble(text): 51 | return ''.join(text) 52 | 53 | 54 | def format_dotword(text): 55 | return '.'.join(text) 56 | 57 | 58 | def format_dashword(text): 59 | return '-'.join(text) 60 | 61 | 62 | def format_natword(text): 63 | return ' '.join(text) 64 | 65 | 66 | def format_broodingnarrative(text): 67 | return '' 68 | 69 | 70 | def format_sentence(text): 71 | return ' '.join([text[0].capitalize()] + text[1:]) 72 | -------------------------------------------------------------------------------- /HACKING.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | Hacking 3 | ================= 4 | 5 | Travis CI 6 | --------- 7 | 8 | We currently have Travis CI set up to run the client unit tests on every PR, 9 | and also run pyflakes on all Python files except for a small set of excluded 10 | files. 11 | 12 | The unit test coverage is not comprehensive, and does not exercise the server 13 | at all, so this should not be depended on exclusively -- manual testing is, 14 | unfortunately, also required. But hopefully this will make it a bit easier for 15 | us to prevent bit rot of the tests and catch any small issues pyflakes may 16 | find. 17 | 18 | Running the integration tests 19 | ----------------------------- 20 | 21 | There's an automated integration test for the Linux X11 server. Note that this 22 | sends live commands to a live server, meaning your mouse will jump around and 23 | it will type some text. I've tried to make this as low risk as possible for 24 | what it is, but be cautious, or run in Xnest to be safe.:: 25 | 26 | cd server/linux_x11 27 | cp config.py.example config.py # Also may need to change the IP to localhost 28 | python server_x11.py 29 | 30 | # In another window 31 | python test-client.py 32 | 33 | These tests do not currently run in CI, and there's no automated check for 34 | success/failure -- you should just look at it to verify the correct actions 35 | occurred. 36 | 37 | It would be awesome to make this safer, more automated, and add to CI, but this 38 | is where we are at this time. 39 | 40 | Running the client unit tests 41 | ----------------------------- 42 | 43 | These tests run in Travis CI automatically. To run them locally::: 44 | 45 | export PYTHONPATH=client 46 | python test_runner.py `ls client/test/test_*.py` 47 | 48 | It'd be nice to get this onto something more standardized, but this works for 49 | now. 50 | 51 | Code style 52 | ---------- 53 | 54 | Try to conform to PEP8 where possible. When editing existing code, please keep 55 | style changes in a separate commit from logic changes (small changes ok). All 56 | new code must be pyflakes clean (enforced in CI). 57 | -------------------------------------------------------------------------------- /client/test/configuration_mock.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | class MockConfigWatcher(object): 19 | '''Provides similar API to ConfigWatcher but can be easily controlled 20 | by tests by changing the conf and dirty attributes.''' 21 | def __init__(self, path, default={}): 22 | self.dirty = True 23 | self.conf = default 24 | 25 | def __getitem__(self, item): 26 | return self.conf[item] 27 | 28 | def __setitem__(self, item, value): 29 | self.conf[item] = value 30 | 31 | def write(self): 32 | pass 33 | 34 | def read(self): 35 | self.dirty = False 36 | 37 | def refresh(self): 38 | dirty, self.dirty = self.dirty, False 39 | return dirty 40 | 41 | 42 | class MockConfigDirWatcher(object): 43 | '''Provides similar API to ConfigDirWatcher but can be easily controlled 44 | by tests by changing the files and dirty attributes.''' 45 | 46 | def __init__(self, path, default={}): 47 | self.dirty = True 48 | self.files = {} 49 | 50 | def read(self): 51 | self.dirty = False 52 | 53 | def refresh(self): 54 | dirty, self.dirty = self.dirty, False 55 | return dirty 56 | 57 | 58 | def make_mock_conf(conf): 59 | c = MockConfigWatcher(None) 60 | c.conf = conf 61 | return c 62 | 63 | 64 | def make_mock_dir(files): 65 | c = MockConfigDirWatcher(None) 66 | c.files = dict((name, make_mock_conf(conf)) 67 | for (name, conf) in files.iteritems()) 68 | return c 69 | -------------------------------------------------------------------------------- /server/linux_x11/config.py.example: -------------------------------------------------------------------------------- 1 | # Host-side config (Linux host receiving commands) 2 | HOST = "192.168.56.1" 3 | PORT = 8240 4 | 5 | PLUGIN_PATH = ["plugins"] 6 | 7 | # When using the Text action, grammars may request (the default is not to) to 8 | # input the text using the clipboard rather than emulating keypresses. This has 9 | # the advantage of allowing far more rapid entry of a large chunk of text, but 10 | # may cause strange behavior with programs that don't understand middle click 11 | # paste. This is implemented using xsel, meaning that after text entry xsel may 12 | # remain running until the clipboard is cleared (this is necessary because X11 13 | # clipboards are not buffers, they are communication protocols.). I have verified 14 | # that there should only be at most three xsel processes running at a time, 15 | # though they may be quite long-lived, they do not consume substantial resources. 16 | # 17 | # Few programs use the SECONDARY buffer, which is where we back up the PRIMARY 18 | # buffer (middle click paste) during the operation. This buffer is clobbered. 19 | # 20 | # The server should clear the text it entered from the system clipboard after 21 | # entering it, so you do not need to worry about accidentally pasting it 22 | # somewhere else later. 23 | 24 | # Currently this is implemented by sending a middle click. unfortunately, not 25 | # many programs will work with this if the mouse is not precisely where you want 26 | # to click. There is no cross when doing environment way of pasting, which means 27 | # this approach would require a great deal of per environment coding to be 28 | # functional. When and if GTK ever fixes its broken shift+insert behavior, or at 29 | # least enables users to configure it to work properly, this will become 30 | # workable. 31 | ENABLE_XSEL = False 32 | 33 | # xdotool delay. Setting this value greater than zero may help solve some text 34 | # input issues. Obviously this setting does not apply when ENABLE_XSEL = True. 35 | XDOTOOL_DELAY = 0 36 | 37 | # Server log file path 38 | #LOG_FILE = '/path/to/server.log' 39 | 40 | # Logger verbosity settings. See the following for a list of levels: 41 | # https://docs.python.org/2/library/logging.html#levels 42 | #CONSOLE_LOG_LEVEL = 'WARNING' 43 | #FILE_LOG_LEVEL = 'INFO' 44 | -------------------------------------------------------------------------------- /client/_server_plugin_example.py: -------------------------------------------------------------------------------- 1 | # This is a command module for Dragonfly. It provides support for several of 2 | # Aenea's built-in capabilities. This module is NOT required for Aenea to 3 | # work correctly, but it is strongly recommended. 4 | 5 | # This file is part of Aenea 6 | # 7 | # Aenea is free software: you can redistribute it and/or modify it under 8 | # the terms of version 3 of the GNU Lesser General Public License as 9 | # published by the Free Software Foundation. 10 | # 11 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 12 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 13 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 14 | # License for more details. 15 | # 16 | # You should have received a copy of the GNU Lesser General Public 17 | # License along with Aenea. If not, see . 18 | # 19 | # Copyright (2014) Alex Roper 20 | # Alex Roper 21 | 22 | 23 | # This is an example grammar to demonstrate the use of server plugins. It 24 | # provides a command to call the greet_user rpc added by the example plugin. 25 | 26 | import dragonfly 27 | 28 | try: 29 | import aenea.communications 30 | except ImportError: 31 | print 'Unable to import Aenea client-side modules.' 32 | raise 33 | 34 | 35 | def greeter(name): 36 | # We get a DictationContainer object since we parameterized the function 37 | # with a Dictation object. See anonymous_greeter for a simpler example. 38 | aenea.communications.server.greet_user(name.format()) 39 | 40 | 41 | def anonymous_greeter(): 42 | aenea.communications.server.greet_user('anonymous') 43 | 44 | 45 | class GreetUser(dragonfly.MappingRule): 46 | mapping = { 47 | 'greet user example ': dragonfly.Function(greeter), 48 | 'greet user anonymous example': dragonfly.Function(anonymous_greeter) 49 | } 50 | extras = [dragonfly.Dictation(name='name')] 51 | 52 | grammar = dragonfly.Grammar('server_plugin_example') 53 | 54 | grammar.add_rule(GreetUser()) 55 | 56 | grammar.load() 57 | 58 | 59 | # Unload function which will be called at unload time. 60 | def unload(): 61 | global grammar 62 | if grammar: 63 | grammar.unload() 64 | grammar = None 65 | -------------------------------------------------------------------------------- /client/_capture_client_control.py: -------------------------------------------------------------------------------- 1 | # This is a command module for Dragonfly. It lets you enable/disable the 2 | # dictation capture client by voice. 3 | 4 | # This file is part of Aenea 5 | # 6 | # Aenea is free software: you can redistribute it and/or modify it under 7 | # the terms of version 3 of the GNU Lesser General Public License as 8 | # published by the Free Software Foundation. 9 | # 10 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 11 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 13 | # License for more details. 14 | # 15 | # You should have received a copy of the GNU Lesser General Public 16 | # License along with Aenea. If not, see . 17 | # 18 | # Copyright (2014) Alex Roper 19 | # Alex Roper 20 | 21 | import dragonfly 22 | 23 | try: 24 | import aenea 25 | except ImportError: 26 | print 'Unable to import Aenea client-side modules.' 27 | raise 28 | 29 | 30 | _config = aenea.configuration.ConfigWatcher( 31 | 'dictation_capture_state', 32 | {'enabled': True}) 33 | 34 | 35 | # Commands that can be rebound. 36 | command_table = [ 37 | 'enable dictation capture', 38 | 'disable dictation capture' 39 | ] 40 | command_table = aenea.configuration.make_grammar_commands( 41 | 'capture_client_control', 42 | dict(zip(command_table, command_table)) 43 | ) 44 | 45 | 46 | def enable_capture(): 47 | _config.refresh() 48 | _config.conf['enabled'] = True 49 | _config.write() 50 | 51 | 52 | def disable_capture(): 53 | _config.refresh() 54 | _config.conf['enabled'] = False 55 | _config.write() 56 | 57 | 58 | class ControlRule(dragonfly.MappingRule): 59 | mapping = { 60 | 'enable dictation capture': dragonfly.Function(enable_capture), 61 | 'disable dictation capture': dragonfly.Function(disable_capture) 62 | } 63 | 64 | 65 | grammar = dragonfly.Grammar('capture_client_control') 66 | 67 | grammar.add_rule(ControlRule()) 68 | 69 | grammar.load() 70 | 71 | 72 | # Unload function which will be called at unload time. 73 | def unload(): 74 | global grammar 75 | if grammar: 76 | grammar.unload() 77 | grammar = None 78 | -------------------------------------------------------------------------------- /client/aenea/strict.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | '''Strict wrappers fail at CONSTRUCTION if the spec is not valid for BOTH 19 | platforms. This is preferred if you want your grammar to work identically 20 | on both platforms.''' 21 | 22 | try: 23 | import dragonfly 24 | except ImportError: 25 | import aenea.dragonfly_mock as dragonfly 26 | 27 | 28 | import aenea.config 29 | import aenea.proxy_actions 30 | 31 | from aenea.wrappers import * 32 | 33 | 34 | class Key(AeneaDynStrActionBase): 35 | def __init__(self, spec, **kwargs): 36 | proxy = aenea.proxy_actions.ProxyKey(spec) 37 | local = dragonfly.Key(spec, **kwargs) 38 | AeneaDynStrActionBase.__init__( 39 | self, 40 | proxy, 41 | local, 42 | spec, 43 | '%' not in spec 44 | ) 45 | 46 | 47 | class Text(AeneaDynStrActionBase): 48 | def __init__(self, *a, **kw): 49 | if len(a) == 2: 50 | kw['spec'], kw['static'] = a 51 | elif len(a) == 1: 52 | kw['spec'] = a[0] 53 | a = [] 54 | proxy = aenea.proxy_actions.ProxyText(*a, **kw) 55 | local = dragonfly.Text(*a, **kw) 56 | AeneaDynStrActionBase.__init__( 57 | self, 58 | proxy, 59 | local, 60 | spec=kw.get('spec', None), 61 | static=kw.get('static', False) 62 | ) 63 | 64 | class Mouse(AeneaDynStrActionBase): 65 | def __init__(self, *a, **kw): 66 | if len(a) == 2: 67 | kw['spec'], kw['static'] = a 68 | elif len(a) == 1: 69 | kw['spec'] = a[0] 70 | a = [] 71 | proxy = aenea.proxy_actions.ProxyMouse(*a, **kw) 72 | local = dragonfly.Mouse(*a, **kw) 73 | AeneaDynStrActionBase.__init__( 74 | self, 75 | proxy, 76 | local, 77 | spec=kw.get('spec', None), 78 | static=kw.get('static', False) 79 | ) 80 | 81 | 82 | __all__ = [ 83 | 'Key', 84 | 'Text', 85 | 'Mouse' 86 | ] 87 | -------------------------------------------------------------------------------- /server/linux_x11/server_x11.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of Aenea 4 | # 5 | # Aenea is free software: you can redistribute it and/or modify it under 6 | # the terms of version 3 of the GNU Lesser General Public License as 7 | # published by the Free Software Foundation. 8 | # 9 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 10 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 11 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 12 | # License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public 15 | # License along with Aenea. If not, see . 16 | # 17 | # Copyright (2014) Alex Roper 18 | # Alex Roper 19 | import argparse 20 | import os 21 | import sys 22 | from os.path import join, dirname, realpath 23 | 24 | # enable server.core imports by adding the root of the aenea project to path 25 | sys.path.append(realpath(join(dirname(__file__), '../../'))) 26 | 27 | import config 28 | from server.core import AeneaServer 29 | 30 | 31 | 32 | def daemonize(): 33 | if os.fork() == 0: 34 | os.setsid() 35 | if os.fork() == 0: 36 | os.chdir('/') 37 | os.umask(0) 38 | # Safe upper bound on number of fds we could 39 | # possibly have opened. 40 | for fd in range(64): 41 | try: 42 | os.close(fd) 43 | except OSError: 44 | pass 45 | os.open(os.devnull, os.O_RDWR) 46 | os.dup2(0, 1) 47 | os.dup2(0, 2) 48 | else: 49 | os._exit(0) 50 | else: 51 | os._exit(0) 52 | 53 | 54 | if __name__ == '__main__': 55 | parser = argparse.ArgumentParser(description='Aenea Linux X11 Server') 56 | parser.add_argument( 57 | '--daemon', action='store_const', const=True, default=False, 58 | required=False, help='If provided the server runs in the background.') 59 | parser.add_argument( 60 | '--input', action='store', type=str, default='xdotool', 61 | choices=('xdotool', 'libxdo'), required=False, dest='impl', 62 | help='Aenea Server Input Method. Providing the default, ' 63 | '"xdotool" will make the server shell out to the xdotool ' 64 | 'program to emulate input. "libxdo" will cause the server ' 65 | 'to make calls to the xdo library.') 66 | 67 | arguments = parser.parse_args() 68 | 69 | if arguments.impl == 'xdotool': 70 | from server.linux_x11.x11_xdotool import XdotoolPlatformRpcs 71 | platform_rpcs = XdotoolPlatformRpcs(config) 72 | elif arguments.impl == 'libxdo': 73 | from server.linux_x11.x11_libxdo import XdoPlatformRpcs 74 | platform_rpcs = XdoPlatformRpcs(security_token=getattr(config, 'SECURITY_TOKEN', None)) 75 | 76 | if arguments.daemon: 77 | daemonize() 78 | 79 | server = AeneaServer.from_config(platform_rpcs, config) 80 | server.serve_forever() 81 | -------------------------------------------------------------------------------- /client/test/test_configuration.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | import unittest 19 | import mock 20 | 21 | import aenea.config 22 | import aenea.configuration 23 | 24 | from configuration_mock import make_mock_conf 25 | 26 | 27 | class TestMakeGrammarCommands(unittest.TestCase): 28 | @mock.patch('aenea.configuration.ConfigWatcher') 29 | def test_simple(self, lgc): 30 | lgc.return_value = make_mock_conf({}) 31 | self.assertEquals( 32 | aenea.configuration.make_grammar_commands('foo', {'sting': '10k bees'}), 33 | {'sting': '10k bees'} 34 | ) 35 | 36 | @mock.patch('aenea.configuration.ConfigWatcher') 37 | def test_multiple(self, lgc): 38 | commands = {'ouch': 'sting', 'pain': 'sting', 'sting': 'sting'} 39 | lgc.return_value = make_mock_conf({'commands': commands}) 40 | self.assertEquals( 41 | aenea.configuration.make_grammar_commands('foo', {'sting': '10k bees'}), 42 | {'ouch': '10k bees', 'pain': '10k bees', 'sting': '10k bees'} 43 | ) 44 | 45 | @mock.patch('aenea.configuration.ConfigWatcher') 46 | def test_multiple_undef(self, lgc): 47 | commands = {'ouch': 'sting', 'pain': 'sting'} 48 | lgc.return_value = make_mock_conf({'commands': commands}) 49 | self.assertEquals( 50 | aenea.configuration.make_grammar_commands('foo', {'sting': '10k bees'}), 51 | {'ouch': '10k bees', 'pain': '10k bees'} 52 | ) 53 | 54 | @mock.patch('aenea.configuration.ConfigWatcher') 55 | def test_explicit_undefine(self, lgc): 56 | commands = {'!anythinggoeshere': 'sting'} 57 | lgc.return_value = make_mock_conf({'commands': commands}) 58 | self.assertEquals( 59 | aenea.configuration.make_grammar_commands('foo', {'sting': '10k bees'}), {}) 60 | 61 | @mock.patch('aenea.configuration.ConfigWatcher') 62 | def test_implicit_undefine(self, lgc): 63 | commands = {'honey': 'sting'} 64 | lgc.return_value = make_mock_conf({'commands': commands}) 65 | self.assertEquals( 66 | aenea.configuration.make_grammar_commands('foo', {'sting': '10k bees'}), {'honey': '10k bees'}) 67 | 68 | @mock.patch('aenea.configuration.ConfigWatcher') 69 | def test_illegal_command(self, lgc): 70 | commands = {'wasp': 'nest'} 71 | lgc.return_value = make_mock_conf({'commands': commands}) 72 | self.assertRaises( 73 | KeyError, 74 | aenea.configuration.make_grammar_commands, 75 | 'foo', {'sting': '10k bees'} 76 | ) 77 | 78 | if __name__ == '__main__': 79 | unittest.main() 80 | -------------------------------------------------------------------------------- /client/aenea/misc.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | '''Contains generic utility data and functions useful when writing grammars.''' 19 | 20 | try: 21 | import dragonfly 22 | except ImportError: 23 | import dragonfly_mock as dragonfly 24 | 25 | import configuration 26 | 27 | LOWERCASE_LETTERS = configuration.make_grammar_commands('misc', { 28 | 'alpha': 'a', 29 | 'bravo': 'b', 30 | 'charlie': 'c', 31 | 'delta': 'd', 32 | 'echo': 'e', 33 | 'foxtrot': 'f', 34 | 'golf': 'g', 35 | 'hotel': 'h', 36 | 'indigo': 'i', 37 | 'juliet': 'j', 38 | 'kilo': 'k', 39 | 'lima': 'l', 40 | 'mike': 'm', 41 | 'november': 'n', 42 | 'oscar': 'o', 43 | 'poppa': 'p', 44 | 'quiche': 'q', 45 | 'romeo': 'r', 46 | 'sierra': 's', 47 | 'tango': 't', 48 | 'uniform': 'u', 49 | 'victor': 'v', 50 | 'whiskey': 'w', 51 | 'x-ray': 'x', 52 | 'yankee': 'y', 53 | 'zulu': 'z' 54 | }, 'letters.lower') 55 | 56 | UPPERCASE_LETTERS = configuration.make_grammar_commands('misc', { 57 | 'upper alpha': 'A', 58 | 'upper bravo': 'B', 59 | 'upper charlie': 'C', 60 | 'upper delta': 'D', 61 | 'upper echo': 'E', 62 | 'upper foxtrot': 'F', 63 | 'upper golf': 'G', 64 | 'upper hotel': 'H', 65 | 'upper indigo': 'I', 66 | 'upper juliet': 'J', 67 | 'upper kilo': 'K', 68 | 'upper lima': 'L', 69 | 'upper mike': 'M', 70 | 'upper november': 'N', 71 | 'upper oscar': 'O', 72 | 'upper poppa': 'P', 73 | 'upper quiche': 'Q', 74 | 'upper romeo': 'R', 75 | 'upper sierra': 'S', 76 | 'upper tango': 'T', 77 | 'upper uniform': 'U', 78 | 'upper victor': 'V', 79 | 'upper whiskey': 'W', 80 | 'upper x-ray': 'X', 81 | 'upper yankee': 'y', 82 | 'upper zulu': 'Z' 83 | }, 'letters.upper') 84 | 85 | DIGITS = configuration.make_grammar_commands('misc', { 86 | 'zero': '0', 87 | 'one': '1', 88 | 'two': '2', 89 | 'three': '3', 90 | 'four': '4', 91 | 'five': '5', 92 | 'six': '6', 93 | 'seven': '7', 94 | 'eight': '8', 95 | 'niner': '9' 96 | }, 'digits') 97 | 98 | LETTERS = LOWERCASE_LETTERS.copy() 99 | LETTERS.update(UPPERCASE_LETTERS) 100 | 101 | ALPHANUMERIC = LETTERS.copy() 102 | ALPHANUMERIC.update(DIGITS) 103 | 104 | 105 | class DigitalInteger(dragonfly.Repetition): 106 | '''An integer element spelled digit by digit (eg, enter 50 by saying 107 | 'five zero'. Useful in places where Dragon would complain of the 108 | grammar's complexity if regular integers were used. min and max are 109 | number of digits, not value of the number.''' 110 | child = dragonfly.Choice('digit', DIGITS) 111 | 112 | def __init__(self, name, min, max, *args, **kw): 113 | dragonfly.Repetition.__init__( 114 | self, 115 | self.child, 116 | min, 117 | max, 118 | name=name, 119 | *args, 120 | **kw 121 | ) 122 | 123 | def value(self, node): 124 | return int(''.join(dragonfly.Repetition.value(self, node))) 125 | -------------------------------------------------------------------------------- /server/linux_wayland/server_wayland.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import argparse 3 | import os 4 | import sys 5 | from os.path import join, dirname, realpath 6 | import evdev 7 | 8 | # enable server.core imports by adding the root of the aenea project to path 9 | sys.path.append(realpath(join(dirname(__file__), '../../'))) 10 | 11 | import config 12 | from server.core import AeneaServer 13 | from evdevImpl import EvdevPlatformRpcs 14 | 15 | MAPPINGS = "qwerty, azerty" 16 | 17 | def daemonize(): 18 | if os.fork() == 0: 19 | os.setsid() 20 | if os.fork() == 0: 21 | os.chdir('/') 22 | os.umask(0) 23 | # Safe upper bound on number of fds we could 24 | # possibly have opened. 25 | for fd in range(64): 26 | try: 27 | os.close(fd) 28 | except OSError: 29 | pass 30 | os.open(os.devnull, os.O_RDWR) 31 | os.dup2(0, 1) 32 | os.dup2(0, 2) 33 | else: 34 | os._exit(0) 35 | else: 36 | os._exit(0) 37 | 38 | 39 | def findKeyEvent(): 40 | devices = [evdev.InputDevice(path) for path in evdev.list_devices()] 41 | for device in devices: 42 | cap = device.capabilities(); 43 | key=cap.get(evdev.ecodes.EV_KEY) 44 | if key is not None: 45 | try: 46 | #generate exception if not found 47 | key.index(evdev.ecodes.KEY_A) 48 | key.index(evdev.ecodes.KEY_B) 49 | key.index(evdev.ecodes.KEY_C) 50 | return device.path 51 | except: 52 | pass 53 | return None 54 | 55 | def findMouseEvent(): 56 | devices = [evdev.InputDevice(path) for path in evdev.list_devices()] 57 | for device in devices: 58 | cap = device.capabilities(); 59 | rel=cap.get(evdev.ecodes.EV_REL) 60 | if rel is not None: 61 | try: 62 | #generate exception if not found 63 | rel.index(evdev.ecodes.REL_X) 64 | rel.index(evdev.ecodes.REL_Y) 65 | rel.index(evdev.ecodes.REL_WHEEL) 66 | return device.path 67 | except: 68 | pass 69 | return None 70 | 71 | 72 | if __name__ == '__main__': 73 | parser = argparse.ArgumentParser(description='Aenea Linux Wayland Server') 74 | parser.add_argument('--daemon', 75 | action='store_const', 76 | const=True, 77 | default=False, 78 | required=False, 79 | help='If provided the server runs in the background.') 80 | parser.add_argument('--keyEvent', 81 | action='store', 82 | default=None, 83 | required=False, 84 | help='Keyboard event file. Default is autodetect') 85 | parser.add_argument('--mouseEvent', 86 | action='store', 87 | default=None, 88 | required=False, 89 | help='Mouse event file. Default is autodetect') 90 | parser.add_argument('--mapping', 91 | action = 'store', 92 | default="qwerty", 93 | required=False, 94 | help='If provided the server uses another keyboard mapping than qwerty. Possible mappings are: {}'.format(MAPPINGS)) 95 | 96 | arguments = parser.parse_args() 97 | 98 | if arguments.keyEvent is None: 99 | arguments.keyEvent = findKeyEvent() 100 | if arguments.mouseEvent is None: 101 | arguments.mouseEvent = findMouseEvent() 102 | 103 | platform_rpcs = EvdevPlatformRpcs(config, 104 | arguments.mapping, 105 | arguments.keyEvent, 106 | arguments.mouseEvent) 107 | 108 | if arguments.daemon: 109 | daemonize() 110 | 111 | server = AeneaServer.from_config(platform_rpcs, config) 112 | server.serve_forever() 113 | -------------------------------------------------------------------------------- /client/_vocabulary.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | '''Manages Aenea's dynamic and static vocabulary and their global context. 19 | While this grammar is not strictly necessary to use them, you'll need it if 20 | you want a global context or being able to reload configurations on the 21 | fly rather than restarting Dragon.''' 22 | 23 | import aenea.vocabulary 24 | import aenea.configuration 25 | 26 | import dragonfly 27 | 28 | vocabulary_list = aenea.vocabulary.register_list_of_dynamic_vocabularies() 29 | 30 | # Commands that can be rebound. 31 | command_table = [ 32 | '[refresh|reload] dynamic [vocabulary|vocabularies]', 33 | 'enable vocabulary ', 34 | 'disable vocabulary ', 35 | '', 36 | '' 37 | ] 38 | command_table = aenea.configuration.make_grammar_commands( 39 | 'vocabulary', 40 | dict(zip(command_table, command_table)) 41 | ) 42 | 43 | 44 | class RefreshRule(dragonfly.CompoundRule): 45 | spec = '[refresh|reload] dynamic [vocabulary|vocabularies]' 46 | 47 | def _process_begin(self): 48 | # Refresh every time the user starts to say anything. Refresh is 49 | # indeed intended to be used thus. Short of inotify and threads, this 50 | # is how to do it. 51 | aenea.vocabulary.refresh_vocabulary() 52 | 53 | def _process_recognition(self, node, extras): 54 | aenea.vocabulary.refresh_vocabulary(force_reload=True) 55 | 56 | 57 | class EnableRule(dragonfly.CompoundRule): 58 | spec = command_table['enable vocabulary '] 59 | extras = [dragonfly.ListRef('vocabulary', vocabulary_list)] 60 | 61 | def _process_recognition(self, node, extras): 62 | aenea.vocabulary.enable_dynamic_vocabulary(extras['vocabulary']) 63 | 64 | 65 | class DisableRule(dragonfly.CompoundRule): 66 | spec = command_table['disable vocabulary '] 67 | extras = [dragonfly.ListRef('vocabulary', vocabulary_list)] 68 | 69 | def _process_recognition(self, node, extras): 70 | 71 | aenea.vocabulary.disable_dynamic_vocabulary(extras['vocabulary']) 72 | 73 | 74 | class StaticRule(dragonfly.CompoundRule): 75 | spec = command_table[''] 76 | 77 | extras = [dragonfly.DictListRef( 78 | 'static', 79 | dragonfly.DictList( 80 | 'static global', 81 | aenea.vocabulary.get_static_vocabulary('global') 82 | ) 83 | )] 84 | 85 | def _process_recognition(self, node, extras): 86 | extras['static'].execute(extras) 87 | 88 | 89 | class DynamicRule(dragonfly.CompoundRule): 90 | spec = command_table[''] 91 | 92 | extras = [dragonfly.DictListRef( 93 | 'dynamic', 94 | aenea.vocabulary.register_global_dynamic_vocabulary() 95 | )] 96 | 97 | def _process_recognition(self, node, extras): 98 | extras['dynamic'].execute(extras) 99 | 100 | 101 | grammar = dragonfly.Grammar('vocabulary') 102 | grammar.add_rule(RefreshRule()) 103 | grammar.add_rule(EnableRule()) 104 | grammar.add_rule(DisableRule()) 105 | 106 | grammar.add_rule(DynamicRule()) 107 | grammar.add_rule(StaticRule()) 108 | 109 | grammar.load() 110 | 111 | 112 | # Unload function which will be called at unload time. 113 | def unload(): 114 | aenea.vocabulary.unregister_list_of_dynamic_vocabularies() 115 | global grammar 116 | if grammar: 117 | grammar.unload() 118 | grammar = None 119 | -------------------------------------------------------------------------------- /server/linux_x11/test-client.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | import config 19 | 20 | import jsonrpclib 21 | 22 | 23 | class Proxy(object): 24 | def __init__(self, host, port): 25 | self.server = jsonrpclib.Server('http://%s:%i' % (host, port)) 26 | 27 | def execute_batch(self, batch): 28 | self.server.multiple_actions(batch) 29 | 30 | 31 | class BatchProxy(object): 32 | def __init__(self): 33 | self._commands = [] 34 | 35 | def __getattr__(self, key): 36 | def call(*a, **kw): 37 | if not key.startswith('_'): 38 | self._commands.append((key, a, kw)) 39 | return call 40 | 41 | 42 | def test_key_press(distobj): 43 | '''Should write aABBB, taking a second between B's.''' 44 | distobj.key_press(key='a') 45 | distobj.key_press(key='a', modifiers=['shift']) 46 | distobj.key_press(key='shift', direction='down') 47 | distobj.key_press(key='b', count_delay=100, count=3) 48 | distobj.key_press(key='shift', direction='up') 49 | 50 | 51 | def test_write_text(distobj): 52 | '''Should write Hello world!''' 53 | distobj.write_text(text='Hello world!') 54 | 55 | 56 | def test_click_mouse(distobj): 57 | '''should double left click, then wheel up twice, then right click.''' 58 | distobj.click_mouse(button='left', count=2) 59 | distobj.click_mouse(button='wheelup', count=2) 60 | distobj.click_mouse(button='right') 61 | 62 | 63 | def test_move_mouse(distobj): 64 | '''Should move mouse to absolute upper left, then middle of screen, 65 | then middle of active window, then click the upper left and 66 | restore position, then up 50 from that position. 67 | One second pause between events. To be clear, the mouse should end 68 | 50 pixels up from the middle of the active window.''' 69 | distobj.move_mouse(x=0, y=0) 70 | distobj.pause(amount=100) 71 | distobj.move_mouse(x=0.5, y=0.5, proportional=True) 72 | distobj.pause(amount=100) 73 | distobj.move_mouse( 74 | x=0.5, 75 | y=0.5, 76 | proportional=True, 77 | reference='relative_active' 78 | ) 79 | distobj.pause(amount=100) 80 | distobj.move_mouse(x=0, y=0, phantom='left') 81 | distobj.pause(amount=100) 82 | distobj.move_mouse(x=0, y=50, reference='relative') 83 | distobj.pause(amount=100) 84 | 85 | 86 | def test_mouse_drag(distobj): 87 | '''Should left click upper left and drag to center.''' 88 | distobj.move_mouse(x=0, y=0, proportional=True) 89 | distobj.click_mouse(button='left', direction='down') 90 | distobj.move_mouse(x=1, y=1, proportional=True) 91 | distobj.click_mouse(button='left', direction='up') 92 | 93 | 94 | def test_pause(distobj): 95 | '''Should pause five seconds.''' 96 | distobj.pause(amount=500) 97 | 98 | 99 | def test_multiple_actions(distobj): 100 | batch = BatchProxy() 101 | all_tests(batch) 102 | distobj.execute_batch(batch._commands) 103 | 104 | 105 | def all_tests(distobj): 106 | test_key_press(distobj) 107 | test_write_text(distobj) 108 | test_click_mouse(distobj) 109 | test_move_mouse(distobj) 110 | test_mouse_drag(distobj) 111 | test_pause(distobj) 112 | distobj.get_context() 113 | 114 | 115 | def main(): 116 | communication = Proxy(config.HOST, config.PORT) 117 | all_tests(communication.server) 118 | test_multiple_actions(communication) 119 | print 'Get context returns:' 120 | print communication.server.get_context() 121 | 122 | if __name__ == '__main__': 123 | main() 124 | -------------------------------------------------------------------------------- /client/test/test_proxy_contexts.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | import unittest 19 | import mock 20 | 21 | from aenea.proxy_contexts import ProxyCustomAppContext, VALUE_DONT_CARE, VALUE_SET, VALUE_NOT_SET 22 | 23 | 24 | def match(ctx): 25 | return ctx.matches(None, None, None) 26 | 27 | 28 | class TestProxyCustomAppContext(unittest.TestCase): 29 | @mock.patch('aenea.proxy_contexts._get_context') 30 | def test_match_method(self, get): 31 | get.return_value = {'title': 'Hello World'} 32 | self.assertTrue(match(ProxyCustomAppContext(match='substring', title='Wor'))) 33 | self.assertTrue(match(ProxyCustomAppContext(match='substring', title='World'))) 34 | self.assertFalse(match(ProxyCustomAppContext(match='substring', title='Wirld'))) 35 | 36 | self.assertFalse(match(ProxyCustomAppContext(match='regex', title='ello [W|x]'))) 37 | self.assertTrue(match(ProxyCustomAppContext(match='regex', title='Hello [W|x]orld'))) 38 | self.assertTrue(match(ProxyCustomAppContext(match='regex', title='.*H.*W.*'))) 39 | self.assertFalse(match(ProxyCustomAppContext(match='regex', title='.*H.*X.*'))) 40 | 41 | self.assertTrue(match(ProxyCustomAppContext(match='exact', title='Hello World'))) 42 | self.assertFalse(match(ProxyCustomAppContext(match='exact', title='World'))) 43 | 44 | @mock.patch('aenea.proxy_contexts._get_context') 45 | def test_logic_method(self, get): 46 | get.return_value = {'title': 'Hello World', 'executable': '/bin/yes'} 47 | self.assertTrue(match(ProxyCustomAppContext(logic='and', title='Hello World'))) 48 | self.assertTrue(match(ProxyCustomAppContext(logic='and', title='Hello World', executable='/bin/yes'))) 49 | self.assertFalse(match(ProxyCustomAppContext(logic='and', title='Hello World', executable='/bin/no'))) 50 | self.assertTrue(match(ProxyCustomAppContext(logic='or', title='Hello World', executable='/bin/no'))) 51 | 52 | self.assertFalse(match(ProxyCustomAppContext(logic='or'))) 53 | self.assertTrue(match(ProxyCustomAppContext(logic='and'))) 54 | 55 | self.assertTrue(match(ProxyCustomAppContext(logic=1, title='Hello World', executable='/bin/no'))) 56 | self.assertFalse(match(ProxyCustomAppContext(logic=2, title='Hello World', executable='/bin/no'))) 57 | 58 | self.assertTrue(match(ProxyCustomAppContext(logic=0, title='bees', executable='/bin/no'))) 59 | 60 | @mock.patch('aenea.proxy_contexts._get_context') 61 | def test_special_values(self, get): 62 | get.return_value = {'title': 'Hello World', 'executable': '/bin/yes'} 63 | self.assertTrue(match(ProxyCustomAppContext(title=VALUE_DONT_CARE))) 64 | self.assertFalse(match(ProxyCustomAppContext(logic='or', title=VALUE_DONT_CARE))) 65 | 66 | self.assertTrue(match(ProxyCustomAppContext(title=VALUE_SET))) 67 | self.assertFalse(match(ProxyCustomAppContext(cls=VALUE_SET))) 68 | 69 | self.assertFalse(match(ProxyCustomAppContext(title=VALUE_NOT_SET))) 70 | self.assertTrue(match(ProxyCustomAppContext(cls=VALUE_NOT_SET))) 71 | 72 | @mock.patch('aenea.proxy_contexts._get_context') 73 | def test_case_sensitivity(self, get): 74 | get.return_value = {'title': 'Hello World'} 75 | self.assertTrue(match(ProxyCustomAppContext(title='hello', case_sensitive=False))) 76 | self.assertTrue(match(ProxyCustomAppContext(title='Hello', case_sensitive=False))) 77 | 78 | self.assertFalse(match(ProxyCustomAppContext(title='hello', case_sensitive=True))) 79 | self.assertTrue(match(ProxyCustomAppContext(title='Hello', case_sensitive=True))) 80 | 81 | if __name__ == '__main__': 82 | unittest.main() 83 | -------------------------------------------------------------------------------- /server/osx/test-client.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | import config 19 | 20 | import jsonrpclib 21 | 22 | 23 | class Proxy(object): 24 | def __init__(self, host, port): 25 | self.server = jsonrpclib.Server('http://%s:%i' % (host, port)) 26 | 27 | def execute_batch(self, batch): 28 | self.server.multiple_actions(batch) 29 | 30 | 31 | class BatchProxy(object): 32 | def __init__(self): 33 | self._commands = [] 34 | 35 | def __getattr__(self, key): 36 | def call(*a, **kw): 37 | if not key.startswith('_'): 38 | self._commands.append((key, a, kw)) 39 | return call 40 | 41 | 42 | def test_key_press(distobj): 43 | '''Should write aABBB, taking a second between B's.''' 44 | distobj.key_press(key='a') 45 | distobj.key_press(key='a', modifiers=['shift']) 46 | distobj.key_press(key='shift', direction='down') 47 | distobj.key_press(key='b', count_delay=100, count=3) 48 | distobj.key_press(key='shift', direction='up') 49 | 50 | 51 | def test_write_text(distobj): 52 | '''Should write Hello world!''' 53 | distobj.write_text(text='Hello world!') 54 | 55 | 56 | def test_click_mouse(distobj): 57 | '''should double left click, then wheel up twice, then right click.''' 58 | distobj.click_mouse(button='left', count=2) 59 | distobj.click_mouse(button='wheelup', count=2) 60 | distobj.click_mouse(button='right') 61 | distobj.click_mouse(button='left') 62 | 63 | 64 | def test_move_mouse(distobj): 65 | '''Should move mouse to absolute upper left, then middle of screen, 66 | then middle of active window, then click the upper left and 67 | restore position, then up 50 from that position. 68 | One second pause between events. To be clear, the mouse should end 69 | 50 pixels up from the middle of the active window.''' 70 | distobj.move_mouse(x=0, y=0) 71 | distobj.pause(amount=100) 72 | distobj.move_mouse(x=0.5, y=0.5, proportional=True) 73 | distobj.pause(amount=100) 74 | distobj.move_mouse( 75 | x=0.5, 76 | y=0.5, 77 | proportional=True, 78 | reference='relative_active' 79 | ) 80 | distobj.pause(amount=100) 81 | distobj.move_mouse(x=0, y=0, phantom='left') 82 | distobj.pause(amount=100) 83 | distobj.move_mouse(x=0, y=50, reference='relative') 84 | distobj.pause(amount=100) 85 | 86 | 87 | def test_mouse_drag(distobj): 88 | '''Should left click upper left and drag to center.''' 89 | distobj.move_mouse(x=0, y=0, proportional=True) 90 | distobj.click_mouse(button='left', direction='down') 91 | distobj.move_mouse(x=1, y=1, proportional=True) 92 | distobj.click_mouse(button='left', direction='up') 93 | 94 | 95 | def test_pause(distobj): 96 | '''Should pause five seconds.''' 97 | distobj.pause(amount=500) 98 | 99 | 100 | def test_multiple_actions(distobj): 101 | batch = BatchProxy() 102 | all_tests(batch) 103 | distobj.execute_batch(batch._commands) 104 | 105 | 106 | def all_tests(distobj): 107 | test_key_press(distobj) 108 | test_write_text(distobj) 109 | test_click_mouse(distobj) 110 | test_move_mouse(distobj) 111 | test_mouse_drag(distobj) 112 | test_pause(distobj) 113 | distobj.key_press(key='escape') 114 | distobj.get_context() 115 | 116 | 117 | def main(): 118 | communication = Proxy(config.HOST, config.PORT) 119 | all_tests(communication.server) 120 | test_multiple_actions(communication) 121 | print 'Get context returns:' 122 | print communication.server.get_context() 123 | 124 | if __name__ == '__main__': 125 | main() 126 | -------------------------------------------------------------------------------- /client/aenea/lax.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | '''Lax wrappers will only fail at EXECUTION/MATCH time if the active platform 19 | can't handle the spec. So for example if you create a Key using Linux 20 | keysyms, everything will work fine unless you try to execute the action 21 | locally, in which case the key won't be pressed and a warning will be 22 | printed to the Natlink window. If you want your grammar to work the same 23 | way on all platforms, use aenea.wrappers.strict instead.''' 24 | 25 | import aenea.proxy_actions 26 | 27 | try: 28 | import dragonfly 29 | except ImportError: 30 | import dragonfly_mock as dragonfly 31 | 32 | 33 | import traceback 34 | 35 | from aenea.wrappers import * 36 | 37 | 38 | def _spec(call, a, kw): 39 | try: 40 | return call(*a, **kw) 41 | except Exception as e: 42 | return _WarnUserUnsupportedAction(e) 43 | 44 | 45 | class _WarnUserUnsupportedAction(dragonfly.ActionBase): 46 | def __init__(self, exception): 47 | self._exception = exception 48 | 49 | def execute(self, data=None): 50 | print ('Warning: Current platform cannot handle this action. This ' 51 | 'exception was thrown at grammar load time.') 52 | traceback.print_tb(self._exception) 53 | 54 | def _parse_spec(self, spec): 55 | pass 56 | 57 | def _execute_events(self, commands): 58 | pass 59 | 60 | 61 | class AeneaLaxDynStrActionBase(AeneaDynStrActionBase): 62 | def _parse_spec(self, spec): 63 | proxy = None 64 | local = None 65 | self._proxy_exception = None 66 | self._local_exception = None 67 | try: 68 | proxy = self._proxy._parse_spec(spec) 69 | except Exception as e: 70 | self._proxy_exception = e 71 | try: 72 | local = self._local._parse_spec(spec) 73 | except Exception as e: 74 | self._local_exception = e 75 | return (proxy, local) 76 | 77 | def _execute_events(self, commands): 78 | if self.get_data()['_proxy']: 79 | if self._proxy_exception is not None: 80 | traceback.print_tb(self._proxy_exception) 81 | else: 82 | if self._local_exception is not None: 83 | traceback.print_tb(self._local_exception) 84 | AeneaDynStrActionBase._execute_events(self, commands) 85 | 86 | 87 | class Key(AeneaLaxDynStrActionBase): 88 | def __init__(self, spec): 89 | proxy = _spec(aenea.proxy_actions.ProxyKey, [spec], {}) 90 | local = _spec(dragonfly.Key, [spec], {}) 91 | AeneaLaxDynStrActionBase.__init__( 92 | self, 93 | proxy, 94 | local, 95 | spec, 96 | '%' not in spec 97 | ) 98 | 99 | 100 | class Text(AeneaLaxDynStrActionBase): 101 | def __init__(self, *a, **kw): 102 | if len(a) == 2: 103 | kw['spec'], kw['static'] = a 104 | elif len(a) == 1: 105 | kw['spec'] = a[0] 106 | a = [] 107 | proxy = _spec(aenea.proxy_actions.ProxyText, a, kw) 108 | local = _spec(dragonfly.Text, a, kw) 109 | AeneaLaxDynStrActionBase.__init__( 110 | self, 111 | proxy, 112 | local, 113 | spec=kw.get('spec', None), 114 | static=kw.get('static', False) 115 | ) 116 | 117 | 118 | class Mouse(AeneaLaxDynStrActionBase): 119 | def __init__(self, *a, **kw): 120 | proxy = _spec(aenea.proxy_actions.ProxyMouse, a, kw) 121 | local = _spec(dragonfly.Mouse, a, kw) 122 | AeneaLaxDynStrActionBase.__init__( 123 | self, 124 | proxy, 125 | local, 126 | spec=kw.get('spec', None), 127 | static=kw.get('static', False) 128 | ) 129 | 130 | 131 | __all__ = [ 132 | 'Key', 133 | 'Text', 134 | 'Mouse' 135 | ] 136 | -------------------------------------------------------------------------------- /client/aenea/communications.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | import httplib 19 | import jsonrpclib 20 | import socket 21 | import time 22 | 23 | import aenea.config 24 | import aenea.configuration 25 | 26 | _server_config = aenea.configuration.ConfigWatcher( 27 | 'server_state', 28 | {'host': aenea.config.DEFAULT_SERVER_ADDRESS[0], 29 | 'port': aenea.config.DEFAULT_SERVER_ADDRESS[1]}) 30 | _server_config.write() 31 | 32 | 33 | def set_server_address(address): 34 | '''address is (host, port).''' 35 | _server_config.refresh() 36 | _server_config['host'], _server_config['port'] = address 37 | _server_config.write() 38 | 39 | 40 | class _ImpatientTransport(jsonrpclib.jsonrpc.Transport): 41 | '''Transport for jsonrpclib that supports a timeout.''' 42 | def __init__(self, timeout=None): 43 | self._timeout = timeout 44 | jsonrpclib.jsonrpc.Transport.__init__(self) 45 | 46 | def make_connection(self, host): 47 | #return an existing connection if possible. This allows 48 | #HTTP/1.1 keep-alive. 49 | if (hasattr(self, '_connection') and 50 | self._connection is not None and 51 | host == self._connection[0]): 52 | return self._connection[1] 53 | 54 | # create a HTTP connection object from a host descriptor 55 | chost, self._extra_headers, x509 = self.get_host_info(host) 56 | #store the host argument along with the connection object 57 | if self._timeout is None: 58 | self._connection = host, httplib.HTTPConnection(chost) 59 | else: 60 | self._connection = host, httplib.HTTPConnection( 61 | chost, 62 | timeout=self._timeout 63 | ) 64 | return self._connection[1] 65 | 66 | 67 | def _adjust_arguments(a, kw, security_token): 68 | # Cannot use both positional and keyword arguments 69 | # (according to JSON-RPC spec.) 70 | assert not (a and kw) 71 | 72 | if security_token is not None: 73 | if a: 74 | a = a + (security_token,) 75 | else: 76 | kw = kw.copy() 77 | kw['security_token'] = security_token 78 | 79 | return a, kw 80 | 81 | 82 | class Proxy(object): 83 | def __init__(self): 84 | self._address = None 85 | self.last_connect_good = False 86 | self._last_failed_connect = 0 87 | self._transport = _ImpatientTransport(aenea.config.COMMAND_TIMEOUT) 88 | self._security_token = getattr(aenea.config, 'SECURITY_TOKEN', None) 89 | 90 | def _execute_batch(self, batch, use_multiple_actions=False): 91 | self._refresh_server() 92 | if self._address is None: 93 | return 94 | if time.time() - self._last_failed_connect > aenea.config.CONNECT_RETRY_COOLDOWN: 95 | try: 96 | if not self.last_connect_good: 97 | socket.create_connection(self._address, aenea.config.CONNECT_TIMEOUT) 98 | self.last_connect_good = True 99 | 100 | if len(batch) == 1: 101 | return (getattr( 102 | self._server, 103 | batch[0][0])(*batch[0][1], **batch[0][2]) 104 | ) 105 | elif use_multiple_actions: 106 | if self._security_token is not None: 107 | self._server.multiple_actions(actions=batch, security_token=self._security_token) 108 | else: 109 | self._server.multiple_actions(actions=batch) 110 | else: 111 | for (command, args, kwargs) in batch: 112 | getattr(self._server, command)(*args, **kwargs) 113 | except socket.error: 114 | self._last_failed_connect = time.time() 115 | self.last_connect_good = False 116 | print 'Socket error connecting to aenea server. To avoid slowing dictation, we won\'t try again for %i seconds.' % aenea.config.CONNECT_RETRY_COOLDOWN 117 | 118 | def execute_batch(self, batch): 119 | self._execute_batch(batch, aenea.config.USE_MULTIPLE_ACTIONS) 120 | 121 | def __getattr__(self, meth): 122 | def call(*a, **kw): 123 | a, kw = _adjust_arguments(a, kw, self._security_token) 124 | 125 | return self._execute_batch([(meth, a, kw)]) 126 | return call 127 | 128 | def _refresh_server(self): 129 | _server_config.refresh() 130 | address = _server_config.conf['host'], _server_config.conf['port'] 131 | if self._address != address: 132 | self.last_connect_good = False 133 | self._address = address 134 | self._server = jsonrpclib.Server( 135 | 'http://%s:%i' % address, 136 | transport=self._transport 137 | ) 138 | self._last_failed_connect = 0 139 | 140 | 141 | class BatchProxy(object): 142 | def __init__(self): 143 | self._security_token = getattr(aenea.config, 'SECURITY_TOKEN', None) 144 | self._commands = [] 145 | 146 | def __getattr__(self, key): 147 | def call(*a, **kw): 148 | a, kw = _adjust_arguments(a, kw, self._security_token) 149 | 150 | if not key.startswith('_'): 151 | self._commands.append((key, a, kw)) 152 | return call 153 | 154 | server = Proxy() 155 | -------------------------------------------------------------------------------- /client/test/test_proxy_actions.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | import unittest 19 | import mock 20 | 21 | from aenea.proxy_actions import ProxyKey, ProxyMouse, ProxyMousePhantomClick, ProxyText 22 | 23 | 24 | class TestActions(unittest.TestCase): 25 | @mock.patch('aenea.communications.server') 26 | def test_key(self, comm): 27 | ProxyKey('H').execute() 28 | comm.execute_batch.assert_called_with([('key_press', (), {'key': 'H', 'count': 1, 'modifiers': []})]) 29 | 30 | ProxyKey('H, e').execute() 31 | comm.execute_batch.assert_called_with([('key_press', (), {'key': 'H', 'count': 1, 'modifiers': []}), 32 | ('key_press', (), {'key': 'e', 'count': 1, 'modifiers': []})]) 33 | 34 | ProxyKey('Super_R').execute() 35 | comm.execute_batch.assert_called_with([('key_press', (), {'count': 1, 'modifiers': [], 'key': 'Super_R'})]) 36 | 37 | ProxyKey('c-home').execute() 38 | comm.execute_batch.assert_called_with([('key_press', (), {'count': 1, 'modifiers': ['control'], 'key': 'home'})]) 39 | 40 | ProxyKey('c-home:2').execute() 41 | comm.execute_batch.assert_called_with([('key_press', (), {'key': 'home', 'count': 2, 'modifiers': ['control']})]) 42 | 43 | ProxyKey('c-home:2/5').execute() 44 | comm.execute_batch.assert_called_with([('key_press', (), {'key': 'home', 'count': 2, 'modifiers': ['control']}), 45 | ('pause', (), {'amount': 0.05})]) 46 | 47 | ProxyKey('c-home/1:2/5').execute() 48 | comm.execute_batch.assert_called_with([('key_press', (), {'key': 'home', 'count': 2, 'count_delay': 0.01, 'modifiers': ['control']}), 49 | ('pause', (), {'amount': 0.05})]) 50 | 51 | ProxyKey('home').execute() 52 | comm.execute_batch.assert_called_with([('key_press', (), {'key': 'home', 'count': 1, 'modifiers': []})]) 53 | 54 | ProxyKey('home:2').execute() 55 | comm.execute_batch.assert_called_with([('key_press', (), {'key': 'home', 'count': 2, 'modifiers': []})]) 56 | 57 | ProxyKey('home:0').execute() 58 | comm.execute_batch.assert_called_with([]) 59 | 60 | @mock.patch('aenea.communications.server') 61 | def test_key_multiple_modifiers(self, comm): 62 | ProxyKey('scawh-H').execute() 63 | comm.execute_batch.assert_called_with([('key_press', (), {'key': 'H', 'count': 1, 'modifiers': ['shift', 'control', 'alt', 'super', 'hyper']})]) 64 | 65 | @mock.patch('aenea.communications.server') 66 | def test_key_manual(self, comm): 67 | ProxyKey('a:up').execute() 68 | comm.execute_batch.assert_called_with([('key_press', (), {'key': 'a', 'direction': 'up', 'modifiers': []})]) 69 | 70 | @mock.patch('aenea.communications.server') 71 | def test_text(self, comm): 72 | ProxyText('Hello world!').execute() 73 | comm.write_text.assert_called_with(text='Hello world!') 74 | 75 | @mock.patch('aenea.communications.server') 76 | def test_mouse_move(self, comm): 77 | ProxyMouse('[3, 5]').execute() 78 | comm.execute_batch.assert_called_with([('move_mouse', (), {'x': 3.0, 'y': 5.0, 'proportional': False, 'reference': 'absolute'})]) 79 | 80 | ProxyMouse('<7 9>').execute() 81 | comm.execute_batch.assert_called_with([('move_mouse', (), {'x': 7.0, 'y': 9.0, 'proportional': False, 'reference': 'relative'})]) 82 | 83 | ProxyMouse('(3, 5)').execute() 84 | comm.execute_batch.assert_called_with([('move_mouse', (), {'x': 3.0, 'y': 5.0, 'proportional': False, 'reference': 'relative_active'})]) 85 | 86 | ProxyMouse(','.join(['[3 5]'] * 3)).execute() 87 | comm.execute_batch.assert_called_with([('move_mouse', (), {'x': 3.0, 'y': 5.0, 'proportional': False, 'reference': 'absolute'}), 88 | ('move_mouse', (), {'x': 3.0, 'y': 5.0, 'proportional': False, 'reference': 'absolute'}), 89 | ('move_mouse', (), {'x': 3.0, 'y': 5.0, 'proportional': False, 'reference': 'absolute'})]) 90 | 91 | @mock.patch('aenea.communications.server') 92 | def test_mouse_click(self, comm): 93 | ProxyMouse('left').execute() 94 | comm.execute_batch.assert_called_with([('click_mouse', (), {'button': 'left', 'count': 1, 'count_delay': None, 'direction': 'click'})]) 95 | 96 | ProxyMouse('right').execute() 97 | comm.execute_batch.assert_called_with([('click_mouse', (), {'button': 'right', 'count': 1, 'count_delay': None, 'direction': 'click'})]) 98 | 99 | ProxyMouse('wheelup:5').execute() 100 | comm.execute_batch.assert_called_with([('click_mouse', (), {'button': 'wheelup', 'count': 5, 'count_delay': None, 'direction': 'click'})]) 101 | 102 | ProxyMouse('wheeldown:5/9').execute() 103 | comm.execute_batch.assert_called_with([('click_mouse', (), {'button': 'wheeldown', 'count': 5, 'direction': 'click', 'count_delay': 0.09})]) 104 | 105 | @mock.patch('aenea.communications.server') 106 | def test_drag(self, comm): 107 | ProxyMouse('middle:up/5').execute() 108 | comm.execute_batch.assert_called_with([('click_mouse', (), {'button': 'middle', 'direction': 'up', 'count_delay': 0.05, 'count': 1})]) 109 | 110 | @mock.patch('aenea.communications.server') 111 | def test_phantom_click(self, comm): 112 | ProxyMousePhantomClick('(78, 114), left').execute() 113 | comm.execute_batch.assert_called_with([('move_mouse', (), {'y': 114.0, 'x': 78.0, 'phantom': 'left', 'reference': 'relative_active', 'proportional': False})]) 114 | 115 | if __name__ == '__main__': 116 | unittest.main() 117 | -------------------------------------------------------------------------------- /client/_aenea.py: -------------------------------------------------------------------------------- 1 | # This is a command module for Dragonfly. It provides support for several of 2 | # Aenea's built-in capabilities. This module is NOT required for Aenea to 3 | # work correctly, but it is strongly recommended. 4 | 5 | # This file is part of Aenea 6 | # 7 | # Aenea is free software: you can redistribute it and/or modify it under 8 | # the terms of version 3 of the GNU Lesser General Public License as 9 | # published by the Free Software Foundation. 10 | # 11 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 12 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 13 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 14 | # License for more details. 15 | # 16 | # You should have received a copy of the GNU Lesser General Public 17 | # License along with Aenea. If not, see . 18 | # 19 | # Copyright (2014) Alex Roper 20 | # Alex Roper 21 | 22 | import os 23 | import sys 24 | 25 | import dragonfly 26 | 27 | try: 28 | # Internal NatLink module for reloading grammars. 29 | import natlinkmain 30 | except ImportError: 31 | natlinkmain = None 32 | 33 | try: 34 | import aenea 35 | import aenea.proxy_contexts 36 | import aenea.configuration 37 | import aenea.communications 38 | import aenea.config 39 | import aenea.configuration 40 | except ImportError: 41 | print 'Unable to import Aenea client-side modules.' 42 | raise 43 | 44 | print 'Aenea client-side modules loaded successfully' 45 | print 'Settings:' 46 | print '\tHOST:', aenea.config.DEFAULT_SERVER_ADDRESS[0] 47 | print '\tPORT:', aenea.config.DEFAULT_SERVER_ADDRESS[1] 48 | print '\tPLATFORM:', aenea.config.PLATFORM 49 | print '\tUSE_MULTIPLE_ACTIONS:', aenea.config.USE_MULTIPLE_ACTIONS 50 | print '\tSCREEN_RESOLUTION:', aenea.config.SCREEN_RESOLUTION 51 | 52 | try: 53 | aenea.proxy_contexts._get_context() 54 | print 'Aenea: Successfully connected to server.' 55 | except: 56 | print 'Aenea: Unable to connect to server.' 57 | 58 | 59 | # Commands that can be rebound. 60 | command_table = [ 61 | 'set proxy server to ', 62 | 'disable proxy server', 63 | 'enable proxy server', 64 | 'force natlink to reload all grammars' 65 | ] 66 | command_table = aenea.configuration.make_grammar_commands( 67 | 'aenea', 68 | dict(zip(command_table, command_table)) 69 | ) 70 | 71 | 72 | def topy(path): 73 | if path.endswith == ".pyc": 74 | return path[:-1] 75 | 76 | return path 77 | 78 | 79 | class DisableRule(dragonfly.CompoundRule): 80 | spec = command_table['disable proxy server'] 81 | 82 | def _process_recognition(self, node, extras): 83 | aenea.config.disable_proxy() 84 | 85 | 86 | class EnableRule(dragonfly.CompoundRule): 87 | spec = command_table['enable proxy server'] 88 | 89 | def _process_recognition(self, node, extras): 90 | aenea.config.enable_proxy() 91 | 92 | 93 | def reload_code(): 94 | # Do not reload anything in these directories or their subdirectories. 95 | dir_reload_blacklist = set(["core"]) 96 | macro_dir = "C:\\NatLink\\NatLink\\MacroSystem" 97 | 98 | # Unload all grammars if natlinkmain is available. 99 | if natlinkmain: 100 | natlinkmain.unloadEverything() 101 | 102 | # Unload all modules in macro_dir except for those in directories on the 103 | # blacklist. 104 | # Consider them in sorted order to try to make things as predictable as possible to ease debugging. 105 | for name, module in sorted(sys.modules.items()): 106 | if module and hasattr(module, "__file__"): 107 | # Some builtin modules only have a name so module is None or 108 | # do not have a __file__ attribute. We skip these. 109 | path = module.__file__ 110 | 111 | # Convert .pyc paths to .py paths. 112 | path = topy(path) 113 | 114 | # Do not unimport this module! This will cause major problems! 115 | if (path.startswith(macro_dir) and 116 | not bool(set(path.split(os.path.sep)) & dir_reload_blacklist) 117 | and path != topy(os.path.abspath(__file__))): 118 | 119 | print "removing %s from cache" % name 120 | 121 | # Remove the module from the cache so that it will be reloaded 122 | # the next time # that it is imported. The paths for packages 123 | # end with __init__.pyc so this # takes care of them as well. 124 | del sys.modules[name] 125 | 126 | try: 127 | # Reload the top-level modules in macro_dir if natlinkmain is available. 128 | if natlinkmain: 129 | natlinkmain.findAndLoadFiles() 130 | except Exception as e: 131 | print "reloading failed: {}".format(e) 132 | else: 133 | print "finished reloading" 134 | 135 | 136 | # Note that you do not need to turn mic off and then on after saying this. This 137 | # also unloads all modules and packages in the macro directory so that they will 138 | # be reloaded the next time that they are imported. It even reloads Aenea! 139 | class ReloadGrammarsRule(dragonfly.MappingRule): 140 | mapping = {command_table['force natlink to reload all grammars']: dragonfly.Function(reload_code)} 141 | 142 | server_list = dragonfly.DictList('aenea servers') 143 | server_list_watcher = aenea.configuration.ConfigWatcher( 144 | ('grammar_config', 'aenea')) 145 | 146 | 147 | class ChangeServer(dragonfly.CompoundRule): 148 | spec = command_table['set proxy server to '] 149 | extras = [dragonfly.DictListRef('proxy', server_list)] 150 | 151 | def _process_recognition(self, node, extras): 152 | aenea.communications.set_server_address((extras['proxy']['host'], extras['proxy']['port'])) 153 | 154 | def _process_begin(self): 155 | if server_list_watcher.refresh(): 156 | server_list.clear() 157 | for k, v in server_list_watcher.conf.get('servers', {}).iteritems(): 158 | server_list[str(k)] = v 159 | 160 | grammar = dragonfly.Grammar('aenea') 161 | 162 | grammar.add_rule(EnableRule()) 163 | grammar.add_rule(DisableRule()) 164 | grammar.add_rule(ReloadGrammarsRule()) 165 | grammar.add_rule(ChangeServer()) 166 | 167 | grammar.load() 168 | 169 | 170 | # Unload function which will be called at unload time. 171 | def unload(): 172 | global grammar 173 | if grammar: 174 | grammar.unload() 175 | grammar = None 176 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /server/windows/aenea-windows-server/LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /server/linux_wayland/evdevImpl.py: -------------------------------------------------------------------------------- 1 | import evdev 2 | import logging 3 | import subprocess 4 | 5 | import time 6 | 7 | from server.core import AbstractAeneaPlatformRpcs 8 | 9 | from qwerty import Qwerty 10 | from azerty import Azerty 11 | 12 | mappings = { "qwerty" : Qwerty(), 13 | "azerty" : Azerty(), 14 | } 15 | 16 | special = { "enter" : evdev.ecodes.KEY_ENTER, 17 | "tab" : evdev.ecodes.KEY_TAB, 18 | "alt" : evdev.ecodes.KEY_LEFTALT, 19 | "win" : evdev.ecodes.KEY_LEFTMETA, 20 | "super" : evdev.ecodes.KEY_LEFTMETA, 21 | "shift" : evdev.ecodes.KEY_LEFTSHIFT, 22 | "control" : evdev.ecodes.KEY_LEFTCTRL, 23 | "space" : " ", 24 | "plus" : "+", 25 | "minus" : "-", 26 | "backspace" : evdev.ecodes.KEY_BACKSPACE, 27 | "del" : evdev.ecodes.KEY_DELETE, 28 | "lbrace" : "{", 29 | "rbrace" : "}", 30 | "left" : evdev.ecodes.KEY_LEFT, 31 | "right" : evdev.ecodes.KEY_RIGHT, 32 | "up" : evdev.ecodes.KEY_UP, 33 | "down" : evdev.ecodes.KEY_DOWN, 34 | "lparen" : "(", 35 | "rparen" : ")", 36 | "lbracket" : "[", 37 | "rbracket" : "]", 38 | "colon" : ":", 39 | "comma" : ",", 40 | "semicolon" : ";", 41 | "dot" : ".", 42 | "slash" : "/", 43 | "hash" : "#", 44 | "percent" : "%", 45 | "asterisk" : "*", 46 | "dollar" : "$", 47 | "backslash" : "\\", 48 | "apostrophe" : "'", 49 | "dquote" : "\"", 50 | "rangle" : ">", 51 | "langle" : "<", 52 | "equal" : "=", 53 | "exclamation" : "!", 54 | "question" : "?", 55 | "bar" : "|", 56 | "underscore" : "_", 57 | "ampersand" : "&", 58 | "at" : "@", 59 | "f1" : evdev.ecodes.KEY_F1, 60 | "f2" : evdev.ecodes.KEY_F2, 61 | "f3" : evdev.ecodes.KEY_F3, 62 | "f4" : evdev.ecodes.KEY_F4, 63 | "f5" : evdev.ecodes.KEY_F5, 64 | "f6" : evdev.ecodes.KEY_F6, 65 | "f7" : evdev.ecodes.KEY_F7, 66 | "f8" : evdev.ecodes.KEY_F8, 67 | "f9" : evdev.ecodes.KEY_F9, 68 | "f10" : evdev.ecodes.KEY_F10, 69 | "f11" : evdev.ecodes.KEY_F11, 70 | "f12" : evdev.ecodes.KEY_F12, 71 | } 72 | 73 | fixed = { "\n" : [evdev.ecodes.KEY_ENTER], 74 | " " : [evdev.ecodes.KEY_SPACE], 75 | "\t" : [evdev.ecodes.KEY_TAB], 76 | } 77 | 78 | _SERVER_INFO = { 79 | "window_manager": "sway", 80 | "operating_system": "linux", 81 | "platform": "linux", 82 | "display": "Wayland", 83 | "server": "aenea_reference", 84 | "server_version": 1 85 | } 86 | 87 | buttons = { "right" : evdev.ecodes.BTN_RIGHT, 88 | "left" : evdev.ecodes.BTN_LEFT, 89 | "middle" : evdev.ecodes.BTN_MIDDLE, 90 | } 91 | 92 | class EvdevPlatformRpcs(AbstractAeneaPlatformRpcs): 93 | def __init__(self, config, mapping, keyEvent, mouseEvent): 94 | super(EvdevPlatformRpcs, self).__init__(logger=logging.getLogger("aenea.XdotoolPlatformRpcs")) 95 | self.mapping = mappings.get(mapping, "qwerty") 96 | 97 | key = evdev.InputDevice(keyEvent) 98 | mouse = evdev.InputDevice(mouseEvent) 99 | 100 | self.ui = evdev.UInput.from_device(key, mouse) 101 | 102 | def server_info(self): 103 | return _SERVER_INFO 104 | 105 | def get_context(self): 106 | self.logger.info("get_context Not implemented yet") 107 | return {} 108 | 109 | 110 | def key_press(self, 111 | key=None, 112 | modifiers=(), 113 | direction="press", 114 | count=1, 115 | count_delay=None): 116 | """press a key possibly modified by modifiers. direction may be 117 | 'press', 'down', or 'up'. modifiers may contain 'alt', 'shift', 118 | 'control', 'super'. this X11 server also supports 'hyper', 119 | 'meta', and 'flag' (same as super). count is number of times to 120 | press it. count_delay delay in ms between presses.""" 121 | assert key is not None 122 | 123 | delay_millis = 0 if count_delay is None or count == 1 else count_delay 124 | modifiers = [special.get(mod) for mod in modifiers] 125 | 126 | key = special.get(key, key) #convert to usable str or to a key code 127 | 128 | if type(key) is str: #need to convert to key codes 129 | keys = fixed.get(key) 130 | if keys is None: #not a fixed 131 | keys = self.mapping.solo().get(key) 132 | if keys is None: #basic key 133 | keys = [evdev.ecodes.ecodes["KEY_" + key.upper()]] 134 | else: 135 | keys = [key] 136 | 137 | for _ in range(0, count): 138 | #modifiers down: 139 | for m in modifiers: 140 | self.ui.write(evdev.ecodes.EV_KEY, m, 1) 141 | 142 | #key: 143 | if direction == "press" or direction == "down": 144 | for k in keys: 145 | self.ui.write(evdev.ecodes.EV_KEY, k, 1) 146 | 147 | if direction == "press" or direction == "up": 148 | for k in keys: 149 | self.ui.write(evdev.ecodes.EV_KEY, k, 0) 150 | 151 | #modifiers up: 152 | for m in modifiers: 153 | self.ui.write(evdev.ecodes.EV_KEY, m, 0) 154 | 155 | self.ui.syn() 156 | time.sleep(delay_millis / 1000.0) 157 | 158 | def write_text(self, text): 159 | for letter in text: 160 | #check if letter need more than 1 key 161 | seq = self.mapping.multi().get(letter) 162 | 163 | if seq is not None: 164 | for k in seq: 165 | self.ui.write(evdev.ecodes.EV_KEY, k[0], k[1]) 166 | else: 167 | #"standard" letter 168 | seq = fixed.get(letter) 169 | if seq is None: 170 | seq = self.mapping.solo().get(letter) 171 | if seq is not None: 172 | #fixed key or solo. 173 | for k in seq: 174 | #keys down: 175 | self.ui.write(evdev.ecodes.EV_KEY, k, 1) 176 | for k in reversed(seq): 177 | #keys up: 178 | self.ui.write(evdev.ecodes.EV_KEY, k, 0) 179 | else: 180 | # standard key: 181 | if letter.isupper(): 182 | #Press shift to have upper letter 183 | self.ui.write(evdev.ecodes.EV_KEY, 184 | evdev.ecodes.KEY_LEFTSHIFT, 185 | 1) 186 | 187 | k = evdev.ecodes.ecodes["KEY_" + letter.upper()] 188 | #press key 189 | self.ui.write(evdev.ecodes.EV_KEY,k, 1) 190 | #release key 191 | self.ui.write(evdev.ecodes.EV_KEY,k, 0) 192 | 193 | if letter.isupper(): 194 | # shift up 195 | self.ui.write(evdev.ecodes.EV_KEY, 196 | evdev.ecodes.KEY_LEFTSHIFT, 197 | 0) 198 | self.ui.syn() 199 | #if no pause, some events are lost, I don't know why 200 | time.sleep(0.000001) 201 | 202 | 203 | def click_mouse(self, button, direction="click", count=1, count_delay=None): 204 | delay_millis = 0 if count_delay is None or count == 1 else count_delay 205 | print("click mouse " + button + " " + direction) 206 | for _ in range(0, count): 207 | b = buttons.get(button) 208 | if button == "wheeldown": 209 | self.ui.write(evdev.ecodes.EV_REL, 210 | evdev.ecodes.REL_WHEEL, 211 | -1) 212 | self.ui.syn() 213 | elif button == "wheelup": 214 | print("wheelup") 215 | self.ui.write(evdev.ecodes.EV_REL, 216 | evdev.ecodes.REL_WHEEL, 217 | 1) 218 | self.ui.syn() 219 | else: 220 | if direction == "click" or direction == "down": 221 | self.ui.write(evdev.ecodes.EV_KEY, 222 | b, 223 | 1) 224 | self.ui.syn() 225 | if direction == "click" or direction == "up": 226 | self.ui.write(evdev.ecodes.EV_KEY, 227 | b, 228 | 0) 229 | self.ui.syn() 230 | time.sleep(delay_millis / 1000.0) 231 | 232 | def move_mouse(self, 233 | x, y, 234 | reference="absolute", 235 | proportional=False, 236 | phantom=None): 237 | self.logger.info("move_mouse Not implemented yet") 238 | 239 | def pause(self, amount): 240 | time.sleep(amount / 1000.) 241 | 242 | def notify(self, message): 243 | try: 244 | subprocess.Popen(["notify-send", message]) 245 | except Exception as e: 246 | self.logger.warn("failed to start notify-send process: %s" % e) 247 | 248 | -------------------------------------------------------------------------------- /client/aenea/proxy_contexts.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | '''provides proxy contexts for currently active application matching''' 19 | 20 | import re 21 | import time 22 | 23 | import aenea.communications 24 | import aenea.config 25 | 26 | try: 27 | import dragonfly 28 | except ImportError: 29 | import dragonfly_mock as dragonfly 30 | 31 | # Match only if no value set 32 | VALUE_NOT_SET = object() 33 | 34 | # Match only if value set but any value 35 | VALUE_SET = object() 36 | 37 | # Do not consider this argument. 38 | VALUE_DONT_CARE = object() 39 | 40 | # Hate to do this, but currently we hit the server once per context, 41 | # and the get_context() call is actually rather expensive. Ideally we 42 | # could propagate state through Dragonfly contexts, but that would involve 43 | # deep surgery or re-implementation. 44 | _last_context = None 45 | _last_server_info = None 46 | _last_context_time = 0 47 | 48 | 49 | class _Warn(dragonfly.Context): 50 | def matches(self, windows_executable, windows_title, windows_handle): 51 | pf = _server_info().get('platform', None) 52 | print 'Warning: grammar can\'t handle server platform %s' % pf 53 | return False 54 | 55 | 56 | def _refresh_server(): 57 | global _last_context 58 | global _last_context_time 59 | global _last_server_info 60 | if ( 61 | _last_context_time is None or 62 | _last_context_time + aenea.config.STALE_CONTEXT_DELTA < time.time()): 63 | _last_context = aenea.communications.server.get_context() 64 | _last_server_info = aenea.communications.server.server_info() 65 | _last_context_time = time.time() 66 | 67 | # If the RPC call fails for whatever reason, we return an empty dict. 68 | if _last_context is None: 69 | _last_context = {} 70 | if _last_server_info is None: 71 | _last_server_info = {} 72 | 73 | 74 | def _get_context(): 75 | global _last_context 76 | _refresh_server() 77 | return _last_context 78 | 79 | 80 | def _server_info(): 81 | global _last_server_info 82 | _refresh_server() 83 | return _last_server_info 84 | 85 | 86 | class ProxyCustomAppContext(dragonfly.Context): 87 | '''Matches based on the properties of the currently active window. 88 | Match may be 'substring', 'exact', or 'regex'. logic may be 'and', 89 | 'or' or an integer (to match if at least N clauses satisfied.)''' 90 | def __init__(self, match='substring', logic='and', case_sensitive=False, 91 | query=None, **kw): 92 | if query is None: 93 | query = {} 94 | query.update(kw) 95 | self._str = 'ProxyCustomAppContext' 96 | self.match = match 97 | self.logic = logic 98 | self.case_sensitive = case_sensitive 99 | self.arguments = query 100 | dragonfly.Context.__init__(self) 101 | 102 | assert match in ('exact', 'substring', 'regex') 103 | if logic not in ('and', 'or'): 104 | assert int(logic) >= 0 and int(logic) <= len(query) 105 | 106 | def _check_properties(self): 107 | properties = _get_context() 108 | matches = {} 109 | for (key, value) in self.arguments.iteritems(): 110 | if value == VALUE_DONT_CARE: 111 | continue 112 | matches[key] = False 113 | if value == VALUE_NOT_SET: 114 | matches[key] = (key not in properties) 115 | elif value == VALUE_SET: 116 | matches[key] = (key in properties) 117 | elif key in properties: 118 | matches[key] = self._property_match(key, properties[key], 119 | self.arguments[key]) 120 | return matches 121 | 122 | def _property_match(self, key, actual, desired): 123 | '''Overload to change how we should compare actual and 124 | desired properties.''' 125 | if not self.case_sensitive: 126 | actual = actual.lower() 127 | desired = desired.lower() 128 | if self.match == 'substring': 129 | return desired in actual 130 | elif self.match == 'exact': 131 | return desired == actual 132 | else: 133 | return bool(re.match(desired, actual)) 134 | 135 | def _reduce_matches(self, matches): 136 | '''Overload to change the logic that should be used to combine 137 | the results of the matching function.''' 138 | if self.logic == 'and': 139 | return all(matches.itervalues()) 140 | elif self.logic == 'or': 141 | return any(matches.itervalues()) 142 | else: 143 | return len(filter(None, matches.itervalues())) >= int(self.logic) 144 | 145 | def matches(self, windows_executable, windows_title, windows_handle): 146 | return self._reduce_matches(self._check_properties()) 147 | 148 | 149 | def ProxyAppContext( 150 | title=VALUE_DONT_CARE, # active window title, as determined by server 151 | app_id=VALUE_DONT_CARE, # active app name, as determined by server 152 | cls=VALUE_DONT_CARE, 153 | cls_name=VALUE_DONT_CARE, 154 | executable=VALUE_DONT_CARE, 155 | match='substring', 156 | logic='and', 157 | case_sensitive=False 158 | ): 159 | 160 | query = { 161 | 'title': title, 162 | 'id': app_id, 163 | 'cls': cls, 164 | 'cls_name': cls_name, 165 | 'executable': executable 166 | } 167 | 168 | return ProxyCustomAppContext(match=match, logic=logic, query=query, 169 | case_sensitive=case_sensitive) 170 | 171 | 172 | class ProxyPlatformContext(dragonfly.Context): 173 | '''Class that matches based on the platform the server reports. None will 174 | match the server not sending platform or it being set to None. 175 | If running locally (aenea's global context does not match or it is 176 | disabled), this context never matches.''' 177 | 178 | def __init__(self, platform): 179 | '''mapping is mapping from platform as string to Context.''' 180 | self._platform = platform 181 | self._str = 'ProxyPlatformContext' 182 | 183 | def matches(self, windows_executable, windows_title, windows_handle): 184 | enabled = aenea.config.proxy_active(( 185 | windows_executable, 186 | windows_title, 187 | windows_handle 188 | )) 189 | return (enabled and 190 | (_server_info().get('platform', None) == self._platform)) 191 | 192 | 193 | class ProxyCrossPlatformContext(dragonfly.Context): 194 | '''Class to choose between several contexts based on what the server says 195 | platform is. None key may be used for none of the above as a default.''' 196 | 197 | def __init__(self, mapping): 198 | '''mapping is mapping from platform as string to Context.''' 199 | assert all(hasattr(x, 'matches') for x in mapping) 200 | self._mapping = mapping 201 | self._str = 'ProxyCrossPlatformContext' 202 | 203 | def matches(self, windows_executable, windows_title, windows_handle): 204 | platform = _server_info().get('platform', None) 205 | chosen = self._mapping.get(platform, self._mapping.get(None, _Warn())) 206 | return chosen.matches( 207 | windows_executable, 208 | windows_title, 209 | windows_handle 210 | ) 211 | 212 | 213 | __all__ = [ 214 | 'ProxyAppContext', 215 | 'ProxyCustomAppContext', 216 | 'ProxyPlatformContext', 217 | 'ProxyCrossPlatformContext', 218 | 'VALUE_NOT_SET', 219 | 'VALUE_SET', 220 | 'VALUE_DONT_CARE' 221 | ] 222 | -------------------------------------------------------------------------------- /server/windows/aenea-windows-server/src/Main.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE CPP #-} 3 | 4 | module Main (main) where 5 | 6 | import Windows( Key, Direction (..) 7 | , nameToKey, charToKey 8 | , keyPress, keyAction 9 | , withKeyPress 10 | , getForegroundWindowText 11 | , getForegroundWindowAncestorText) 12 | import Network.JsonRpc.Server( Parameter (..) 13 | , (:+:) (..) 14 | , RpcResult 15 | , call 16 | , Method 17 | , toMethod 18 | , rpcError) 19 | import Data.Text (Text, unpack, append) 20 | import Data.String (fromString) 21 | import Data.List (intersperse) 22 | import Data.Maybe (catMaybes, mapMaybe, fromMaybe) 23 | import Data.Aeson (Value (Number, String), object, (.=)) 24 | import qualified Data.ByteString.Lazy as LB 25 | import qualified Data.ByteString.Lazy.Char8 as LBChar8 26 | import Control.Monad ((<=<), when, forM_) 27 | import Control.Monad.Trans (liftIO) 28 | import Control.Monad.Reader (lift) 29 | import Control.Concurrent (threadDelay, readMVar) 30 | import Happstack.Lite (Request, toResponse) 31 | import qualified Happstack.Server.SimpleHTTP as H 32 | import Happstack.Server.Internal.Types (noContentLength) 33 | import System.Environment (getArgs) 34 | import System.Console.GetOpt( OptDescr (Option), ArgDescr(..) 35 | , ArgOrder (Permute), getOpt, usageInfo) 36 | import System.Exit (ExitCode (ExitFailure), exitSuccess, exitWith) 37 | 38 | #if !MIN_VERSION_base(4,8,0) 39 | import Control.Applicative ((<$>)) 40 | #endif 41 | 42 | #if MIN_VERSION_mtl(2,2,1) 43 | import Control.Monad.Except (throwError) 44 | #else 45 | import Control.Monad.Error (throwError) 46 | #endif 47 | 48 | main :: IO () 49 | main = getArgs >>= \args -> 50 | case parseArgs args of 51 | Left Nothing -> putStrLn usage >> exitSuccess 52 | Left (Just errs) -> putStrLn (errs ++ usage) >> exitWith (ExitFailure 1) 53 | Right (maybeAddress, portStr, verbosity) -> 54 | case parseInt portStr of 55 | Nothing -> putStrLn "cannot parse port" >> exitWith (ExitFailure 1) 56 | Just port -> serve verbosity maybeAddress port 57 | 58 | serve :: Verbosity -> Maybe Address -> Int -> IO () 59 | serve verbosity maybeAddress port = 60 | let conf = H.nullConf {H.port = port} 61 | in case maybeAddress of 62 | Nothing -> putStrLn ("listening on port " ++ show port) >> 63 | H.simpleHTTP conf (handleRequests verbosity) 64 | Just addr -> do 65 | s <- H.bindIPv4 addr port 66 | putStrLn $ "listening on " ++ addr ++ ":" ++ show port 67 | H.simpleHTTPWithSocket s conf $ handleRequests verbosity 68 | 69 | handleRequests :: Verbosity -> H.ServerPartT IO H.Response 70 | handleRequests verbosity = do 71 | request <- H.askRq 72 | body <- lift $ getBody request 73 | printMsg "" >> printMsg body 74 | result <- lift $ call methods body 75 | let resultStr = fromMaybe "" result 76 | printMsg resultStr 77 | return $ noContentLength $ toResponse resultStr 78 | where printMsg = when (verbosity == Verbose) 79 | . lift . LBChar8.putStrLn 80 | 81 | getBody :: Request -> IO LB.ByteString 82 | getBody r = H.unBody <$> readMVar (H.rqBody r) 83 | 84 | usage :: String 85 | usage = usageInfo "usage: aenea [options]" options 86 | 87 | data Verbosity = Verbose | Quiet deriving Eq 88 | 89 | type Address = String 90 | type Port = String 91 | 92 | parseArgs :: [String] -> Either (Maybe String) (Maybe Address, Port, Verbosity) 93 | parseArgs args = case getOpt Permute options args of 94 | (opts, _, []) | Help `elem` opts -> Left Nothing 95 | (opts, [], []) -> Right $ parseOpts opts 96 | (_, _, []) -> Left $ Just msg 97 | (_, _, errs) -> Left $ Just $ concat errs 98 | where msg = "unexpected arguments\n" 99 | parseOpts opts = 100 | let addr = getArg opts getAddress Nothing 101 | port = getArg opts getPort "8240" 102 | verbosity = getArg opts getVerbosity Quiet 103 | in (addr, port, verbosity) 104 | 105 | getArg :: [Flag] -> (Flag -> Maybe a) -> a -> a 106 | getArg opts f default' = case safeLast $ mapMaybe f opts of 107 | Just arg -> arg 108 | Nothing -> default' 109 | 110 | safeLast :: [a] -> Maybe a 111 | safeLast [] = Nothing 112 | safeLast xs = Just $ last xs 113 | 114 | getAddress :: Flag -> Maybe (Maybe Address) 115 | getAddress (Address a) = Just $ Just a 116 | getAddress _ = Nothing 117 | 118 | getPort :: Flag -> Maybe Port 119 | getPort (Port p) = Just p 120 | getPort _ = Nothing 121 | 122 | getVerbosity :: Flag -> Maybe Verbosity 123 | getVerbosity Verbosity = Just Verbose 124 | getVerbosity _ = Nothing 125 | 126 | parseInt :: String -> Maybe Int 127 | parseInt x = case reads x of 128 | [(i, [])] -> Just i 129 | _ -> Nothing 130 | 131 | data Flag = Address String | Port String | Verbosity | Help 132 | deriving (Eq, Ord, Show) 133 | 134 | options :: [OptDescr Flag] 135 | options = [ Option "a" ["address"] (ReqArg Address "address") "specify host IP address (not host name)" 136 | , Option "p" ["port"] (ReqArg Port "port") "specify host port (default is 8240)" 137 | , Option "v" ["verbose"] (NoArg Verbosity) "print JSON-RPC requests and responses" 138 | , Option "h" ["help"] (NoArg Help) "show this message"] 139 | 140 | methods :: [Method IO] 141 | methods = [ getContextMethod 142 | , keyPressMethod 143 | , writeTextMethod 144 | , pauseMethod 145 | , serverInfoMethod ] 146 | 147 | keyPressMethod :: Method IO 148 | keyPressMethod = toMethod "key_press" keyPressFunction 149 | (Required "key" :+: 150 | Optional "modifiers" [] :+: 151 | Optional "direction" Press :+: 152 | Optional "count" 1 :+: 153 | Optional "delay" defaultKeyDelay :+: ()) 154 | 155 | keyPressFunction :: Text -> [Text] -> Direction -> Int -> Int -> RpcResult IO () 156 | keyPressFunction keyName modifiers direction count delayMillis = do 157 | key <- tryLookupKey nameToKey id keyName 158 | modKeys <- mapM (tryLookupKey nameToKey id) modifiers 159 | let withPress k task = withKeyPress k (delay >> task >> delay) 160 | pressKey = sequence_ $ intersperse delay $ 161 | replicate count $ keyAction direction key 162 | delay = threadDelay millis 163 | millis = if delayMillis >= 0 then delayMillis else defaultKeyDelay 164 | lift $ compose (withPress <$> modKeys) pressKey 165 | where compose = foldl (.) id 166 | 167 | defaultKeyDelay :: Int 168 | defaultKeyDelay = -1 169 | 170 | getContextMethod :: Method IO 171 | getContextMethod = toMethod "get_context" context () 172 | where context :: RpcResult IO Value 173 | context = liftIO $ (object . concat . catMaybes) <$> sequence [ancestor, active] 174 | ancestor = ((\t -> ["name" .= t]) <$>) <$> getForegroundWindowAncestorText 175 | active = (titlePair <$>) <$> getForegroundWindowText 176 | titlePair text = ["id" .= ("" :: String), "title" .= text] 177 | 178 | serverInfoMethod :: Method IO 179 | serverInfoMethod = toMethod "server_info" serverInfo () 180 | where serverInfo :: RpcResult IO Value 181 | serverInfo = return $ object 182 | [ "window_manager" .= String "windows" 183 | , "operating_system" .= String "windows" 184 | , "platform" .= String "windows" 185 | , "display" .= String "windows" 186 | , "server" .= String "aenea" 187 | , "server_version" .= Number 1 188 | ] 189 | 190 | writeTextMethod :: Method IO 191 | writeTextMethod = toMethod "write_text" writeTextFunction 192 | (Required "text" :+: ()) 193 | 194 | writeTextFunction :: Text -> RpcResult IO () 195 | writeTextFunction text = forM_ (unpack text) $ 196 | lift . keyPress <=< tryLookupKey charToKey charToText 197 | where charToText c = fromString [c] 198 | 199 | pauseMethod :: Method IO 200 | pauseMethod = toMethod "pause" pause (Required "amount" :+: ()) 201 | where pause :: Int -> RpcResult IO () 202 | pause ms = liftIO $ threadDelay (1000 * ms) 203 | 204 | tryLookupKey :: (a -> Maybe Key) -> (a -> Text) -> a -> RpcResult IO Key 205 | tryLookupKey f toText k = case f k of 206 | Nothing -> throwError $ keyNotFound $ toText k 207 | Just key -> return key 208 | where keyNotFound key = rpcError 32000 $ "Cannot find key: " `append` key 209 | -------------------------------------------------------------------------------- /client/aenea/proxy_actions.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | '''Performs black magic on the dragonfly actions objects to force them to 19 | forward their actions to a remote server.''' 20 | 21 | import aenea.communications 22 | import aenea.config 23 | import aenea.proxy_contexts 24 | 25 | try: 26 | import dragonfly 27 | except ImportError: 28 | import dragonfly_mock as dragonfly 29 | 30 | 31 | class _Warn(dragonfly.ActionBase): 32 | def execute(self, data=None): 33 | pf = aenea.proxy_contexts._server_info().get('platform', None) 34 | print 'Warning: grammar can\'t handle server platform %s' % pf 35 | return False 36 | 37 | 38 | class ProxyBase(object): 39 | pass 40 | 41 | 42 | def _make_key_parser(): 43 | from pyparsing import (Optional, Literal, Word, Group, Keyword, 44 | StringStart, StringEnd, Or) 45 | digits = '0123456789' 46 | modifier_keywords = Word(''.join(aenea.config.MODIFIERS)) 47 | key_symbols = Or([Keyword(symbol) for symbol in aenea.config.KEYS]) 48 | pause_clause = Optional(Literal('/') + Word('.' + digits)) 49 | modifier_clause = Optional(modifier_keywords + Literal('-')) 50 | key_hold_clause = Literal(':') + Or([Keyword(d) for d in ('up', 'down')]) 51 | keypress_clause = Group(Group(pause_clause) + Group(Optional(Literal(':') + 52 | Word(digits)))) 53 | 54 | return (StringStart() + Group(modifier_clause) + Group(key_symbols) + 55 | Group(key_hold_clause | keypress_clause) + Group(pause_clause) + 56 | StringEnd()) 57 | 58 | 59 | def _make_mouse_parser(): 60 | from pyparsing import (Optional, Literal, Word, Group, Keyword, 61 | Or, ZeroOrMore, Regex, Suppress) 62 | double = Regex(r'-?\d+(\.\d*)?([eE]\d+)?') 63 | coords = double + Suppress(Optional(Literal(','))) + double 64 | integer = Word('0123456789') 65 | move = ( 66 | (Literal('(') + coords + Suppress(Literal(')'))) | 67 | (Literal('[') + coords + Suppress(Literal(']'))) | 68 | (Literal('<') + coords + Suppress(Literal('>'))) 69 | ) 70 | buttons = ('left', 'middle', 'right', 'wheelup', 'wheeldown') 71 | key = (Or([Keyword(sym) for sym in buttons]) | integer) 72 | 73 | press = ( 74 | key + 75 | Optional(Literal(':') + (integer | (Literal('up') | Literal('down')))) 76 | + Optional(Literal('/') + integer) 77 | ) 78 | 79 | list_element = Group(move | press) 80 | list_parser = list_element + ZeroOrMore(Suppress(',') + list_element) 81 | 82 | return list_parser 83 | 84 | 85 | class ProxyKey(ProxyBase, dragonfly.DynStrActionBase): 86 | '''As Dragonfly's Key except the valid modifiers are a, c, s for alt, 87 | control and shift respectively, w indicates super and h 88 | indicates hyper.''' 89 | 90 | _parser = _make_key_parser() 91 | 92 | def _parse_spec(self, spec): 93 | proxy = aenea.communications.BatchProxy() 94 | for key in spec.split(','): 95 | modifier_part, key_part, command_part, outer_pause_part = \ 96 | self._parser.parseString(key.strip()) 97 | 98 | modifiers = ([aenea.config.MODIFIERS[c] for c in modifier_part[0]] 99 | if modifier_part else []) 100 | key = aenea.config.KEY_TRANSLATIONS.get(key_part[0], key_part[0]) 101 | 102 | # regular keypress event 103 | if len(command_part) == 1: 104 | ((pause_part, repeat_part),) = command_part 105 | 106 | repeat = int(repeat_part[1]) if repeat_part else 1 107 | pause = int(pause_part[1]) / 100. if pause_part else None 108 | if not repeat: 109 | continue 110 | if pause is not None: 111 | proxy.key_press(key=key, modifiers=modifiers, count=repeat, 112 | count_delay=pause) 113 | else: 114 | proxy.key_press(key=key, modifiers=modifiers, count=repeat) 115 | # manual keypress event 116 | else: 117 | (_, direction) = command_part 118 | proxy.key_press( 119 | key=key, 120 | modifiers=modifiers, 121 | direction=direction 122 | ) 123 | 124 | if outer_pause_part: 125 | proxy.pause(amount=int(outer_pause_part[1]) / 100.) 126 | 127 | return proxy._commands 128 | 129 | def _execute_events(self, commands): 130 | aenea.communications.server.execute_batch(commands) 131 | 132 | ############################################################################### 133 | # Text 134 | 135 | 136 | class ProxyText(ProxyBase, dragonfly.DynStrActionBase): 137 | def _parse_spec(self, spec): 138 | return spec 139 | 140 | def _execute_events(self, events): 141 | aenea.communications.server.write_text(text=events) 142 | 143 | ############################################################################### 144 | # Notification 145 | 146 | 147 | class ProxyNotification(ProxyBase, dragonfly.DynStrActionBase): 148 | def _parse_spec(self, spec): 149 | return spec 150 | 151 | def _execute_events(self, events): 152 | aenea.communications.server.notify(message=events) 153 | 154 | 155 | ############################################################################### 156 | # Mouse 157 | 158 | 159 | class ProxyMouse(ProxyBase, dragonfly.DynStrActionBase): 160 | def _parse_spec(self, spec): 161 | proxy = aenea.communications.BatchProxy() 162 | list_parser = _make_mouse_parser() 163 | for item in list_parser.parseString(spec): 164 | if item[0] in '[<(': 165 | reference, x, y = item 166 | reference = {'[': 'absolute', 167 | '<': 'relative', 168 | '(': 'relative_active'}[reference] 169 | proxy.move_mouse(x=float(x), y=float(y), 170 | reference=reference, 171 | proportional=('.' in (x + y))) 172 | else: 173 | pause = None 174 | repeat = 1 175 | direction = 'click' 176 | key = item[0] 177 | if len(item) >= 3 and item[2] in ('down', 'up'): 178 | assert len(item) in (3, 5) 179 | direction = item[2] 180 | if len(item) == 5: 181 | pause = int(item[-1]) / 100. 182 | else: 183 | if len(item) == 3: 184 | assert item[1] in ':/' 185 | if item[1] == ':': 186 | repeat = int(item[2]) 187 | elif item[1] == '/': 188 | pause = int(item[2]) / 100. 189 | elif len(item) == 5: 190 | assert item[1] == ':' and item[3] == '/' 191 | repeat = int(item[2]) 192 | pause = int(item[4]) / 100. 193 | 194 | proxy.click_mouse( 195 | button=key, 196 | direction=direction, 197 | count=repeat, 198 | count_delay=pause 199 | ) 200 | 201 | return proxy._commands 202 | 203 | def _execute_events(self, commands): 204 | aenea.communications.server.execute_batch(commands) 205 | 206 | ############################################################################### 207 | # click without moving mouse 208 | 209 | 210 | class ProxyMousePhantomClick(ProxyMouse): 211 | '''specification is similar to that for mouse except you should only 212 | specify one move as more events may behave strangely. 213 | the intended usage is as these examples, 214 | '(55 274), 1' # left click once at those coordinates 215 | '<9 222>, 1:2/10' # left double-click at those coordinates 216 | '1:down, [1 1], 1:up' # drag what is there to the upper left corner 217 | ''' 218 | 219 | def _parse_spec(self, spec): 220 | commands = ProxyMouse._parse_spec(self, spec) 221 | move, click = commands 222 | move[2]['phantom'] = click[2]['button'] 223 | return [move] 224 | 225 | 226 | __all__ = [ 227 | 'ProxyKey', 228 | 'ProxyText', 229 | 'ProxyMouse', 230 | 'ProxyMousePhantomClick' 231 | ] 232 | -------------------------------------------------------------------------------- /client/aenea/configuration.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | import json 19 | import os 20 | 21 | from aenea.alias import Alias 22 | import aenea.config 23 | from proxy_contexts import ProxyAppContext 24 | 25 | try: 26 | import dragonfly 27 | except ImportError: 28 | import dragonfly_mock as dragonfly 29 | 30 | 31 | class ConfigWatcher(object): 32 | '''Watches a config file for changes, and reloads as necessary based on 33 | mtime. Path is relative to project root and may be string or list. 34 | Exceptions are squelched with a warning. File not existing is 35 | never an error.''' 36 | 37 | def __init__(self, path, default={}): 38 | if not isinstance(path, basestring): 39 | path = os.path.join(*path) 40 | self._path = path = os.path.join(aenea.config.PROJECT_ROOT, path) + '.json' 41 | self._mtime_size = (0, 0) 42 | self.conf = default 43 | self._exists = False 44 | self._default = default 45 | self._first = True 46 | 47 | self.read() 48 | 49 | def __getitem__(self, item): 50 | self.refresh() 51 | return self.conf[item] 52 | 53 | def __setitem__(self, item, value): 54 | self.refresh() 55 | self.conf[item] = value 56 | 57 | def write(self): 58 | '''Writes the config file to disk.''' 59 | if not os.path.exists(os.path.split(self._path)[0]): 60 | os.makedirs(os.path.split(self._path)[0]) 61 | try: 62 | with open(self._path, 'w') as fd: 63 | json.dump(self.conf, fd) 64 | except Exception as e: 65 | print 'Error writing config file %s: %s.' % (self._path, str(e)) 66 | 67 | def read(self): 68 | '''Forces to read the file regardless of whether its mtime has 69 | changed.''' 70 | self._exists = os.path.exists(self._path) 71 | if not os.path.exists(self._path): 72 | self.conf = self._default.copy() 73 | return 74 | stat = os.stat(self._path) 75 | self._mtime_size = stat.st_mtime, stat.st_size 76 | try: 77 | with open(self._path) as fd: 78 | self.conf = json.load(fd) 79 | except Exception as e: 80 | print 'Error reading config file %s: %s.' % (self._path, str(e)) 81 | 82 | def refresh(self): 83 | '''Rereads the file if it has changed. Returns True if it changed. As a 84 | special case, always returns True on the first call.''' 85 | first = self._first 86 | self._first = False 87 | if os.path.exists(self._path) != self._exists: 88 | self.read() 89 | return True 90 | 91 | if os.path.exists(self._path): 92 | try: 93 | stat = os.stat(self._path) 94 | mtime = stat.st_mtime, stat.st_size 95 | except OSError: 96 | self.read() 97 | return True 98 | if mtime != self._mtime_size: 99 | self.read() 100 | return True 101 | return first 102 | 103 | 104 | class ConfigDirWatcher(object): 105 | '''Watches a config directory for changes in it or its files, and reloads 106 | as necessary based on mtime. Path is relative to project root and may 107 | be string or list. Exceptions are squelched with a warning. Directory 108 | not existing is never an error.''' 109 | 110 | def __init__(self, path, default={}): 111 | if not isinstance(path, basestring): 112 | path = os.path.join(*path) 113 | self._rawpath = path 114 | self._path = path = os.path.join(aenea.config.PROJECT_ROOT, path) 115 | self.files = {} 116 | self._exists = False 117 | self._default = default 118 | self._first = True 119 | 120 | self.read() 121 | 122 | def refresh(self): 123 | '''Rereads the directory if it has changed. Returns True if any files 124 | have changed. As a special case, always returns True on the first 125 | call.''' 126 | first = self._first 127 | self._first = False 128 | if os.path.exists(self._path) != self._exists: 129 | self.read() 130 | return True 131 | 132 | if os.path.exists(self._path): 133 | files = set(x[:-5] for x in os.listdir(self._path) 134 | if x.endswith('.json')) 135 | if set(files) != set(self.files): 136 | self.read() 137 | return True 138 | elif any(c.refresh() for c in self.files.itervalues()): 139 | return True 140 | 141 | return first 142 | 143 | def read(self): 144 | self._exists = os.path.exists(self._path) 145 | if not self._exists: 146 | self.files.clear() 147 | return 148 | 149 | files = set(x[:-5] for x in os.listdir(self._path) 150 | if x.endswith('.json')) 151 | for k in self.files.keys(): 152 | if k not in files: 153 | del self.files[k] 154 | 155 | for fn in files: 156 | if fn not in self.files: 157 | self.files[fn] = ConfigWatcher( 158 | (self._rawpath, fn), self._default) 159 | else: 160 | self.files[fn].refresh() 161 | 162 | 163 | def make_grammar_commands(module_name, mapping, config_key='commands', alias = Alias()): 164 | '''Given the command map from default spoken phrase to actions in mapping, 165 | constructs a mapping that takes user config, if specified, into account. 166 | config_key may be a key in the JSON to use (for modules with multiple 167 | mapping rules.) If a user phrase starts with !, 168 | no mapping is generated for that phrase.''' 169 | conf_path = ('grammar_config', module_name) 170 | conf = ConfigWatcher(conf_path).conf.get(config_key, {}) 171 | commands = mapping.copy() 172 | 173 | # Nuke the default if the user sets one or more aliases. 174 | for default_phrase in set(conf.itervalues()): 175 | del commands[str(default_phrase)] 176 | 177 | for (user_phrase, default_phrase) in conf.iteritems(): 178 | # Dragonfly chokes on unicode, JSON's default. 179 | user_phrase = str(user_phrase) 180 | default_phrase = str(default_phrase) 181 | assert default_phrase in mapping, ('Invalid mapping value in module %s config_key %s: %s' % (module_name, config_key, default_phrase)) 182 | 183 | # Allow users to nuke a command with ! 184 | if not user_phrase.startswith('!'): 185 | commands[user_phrase] = mapping[default_phrase] 186 | return alias.make_mapping_spec(commands) 187 | 188 | 189 | def make_local_disable_context(grammar_conf): 190 | """ 191 | Given a grammar config generate a local grammar disabled context. 192 | 193 | Example: to locally disable multiedit where window titles contain 194 | "VIM" multiedit.json should contain the following keys: 195 | { 196 | "local_disable_context": "VIM" 197 | } 198 | 199 | :param grammar_conf: 200 | :return: Context 201 | """ 202 | local_disable_setting = grammar_conf.get('local_disable_context', None) 203 | local_disable_context = dragonfly.NeverContext() 204 | if local_disable_setting is not None: 205 | if not isinstance(local_disable_setting, basestring): 206 | print 'Local disable context may only be a string.' 207 | else: 208 | local_disable_context = dragonfly.AppContext(str(local_disable_setting)) 209 | return local_disable_context 210 | 211 | 212 | def make_proxy_disable_context(grammar_conf): 213 | """ 214 | Given a grammar config generate a local grammar disabled context. 215 | 216 | Example: to disable multiedit in proxy contexts where window titles 217 | contain "VIM" multiedit.json should contain the following keys: 218 | { 219 | "proxy_disable_context": { 220 | "match": "regex", 221 | "title": ".*VIM.*" 222 | } 223 | } 224 | 225 | :param grammar_conf: 226 | :return: Context 227 | """ 228 | proxy_disable_setting = grammar_conf.get('proxy_disable_context', None) 229 | proxy_disable_context = dragonfly.NeverContext() 230 | if proxy_disable_setting is not None: 231 | if isinstance(proxy_disable_setting, dict): 232 | d = {} 233 | for k, v in proxy_disable_setting.iteritems(): 234 | d[str(k)] = str(v) 235 | proxy_disable_context = ProxyAppContext(**d) 236 | else: 237 | proxy_disable_context = ProxyAppContext( 238 | title=str(proxy_disable_setting), 239 | match='substring' 240 | ) 241 | return proxy_disable_context 242 | -------------------------------------------------------------------------------- /client/aenea/wrappers.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | '''Wrappers provide classes that respect the current value of config.PLATFORM. 19 | When Aenea server is enabled, we will dispatch all requests and actions 20 | there. When it is not, we execute locally. The value of PLATFORM when the 21 | action.execute() or context.matches() is called is what determines what 22 | happens.''' 23 | 24 | # TODO(calmofthestorm) See if we can combine these lists. 25 | try: 26 | from dragonfly import ( 27 | Grammar, 28 | ActionBase, 29 | ActionError, 30 | Alternative, 31 | AppContext, 32 | Choice, 33 | Clipboard, 34 | Compound, 35 | CompoundRule, 36 | Config, 37 | ConnectionGrammar, 38 | Context, 39 | DictList, 40 | DictListRef, 41 | Dictation, 42 | Digits, 43 | DigitsRef, 44 | DynStrActionBase, 45 | ElementBase, 46 | Empty, 47 | FocusWindow, 48 | Function, 49 | HardwareInput, 50 | Integer, 51 | IntegerRef, 52 | Item, 53 | Key, 54 | Keyboard, 55 | KeyboardInput, 56 | List, 57 | ListBase, 58 | ListRef, 59 | Literal, 60 | MappingRule, 61 | Mimic, 62 | Monitor, 63 | Mouse, 64 | MouseInput, 65 | Number, 66 | NumberRef, 67 | Optional, 68 | Paste, 69 | Pause, 70 | Playback, 71 | PlaybackHistory, 72 | Point, 73 | RecognitionHistory, 74 | RecognitionObserver, 75 | Rectangle, 76 | Repeat, 77 | Repetition, 78 | Rule, 79 | RuleRef, 80 | Section, 81 | Sequence, 82 | Text, 83 | Typeable, 84 | WaitWindow, 85 | Window 86 | ) 87 | except ImportError: 88 | from aenea.dragonfly_mock import ( 89 | Grammar, 90 | ActionBase, 91 | ActionError, 92 | Alternative, 93 | AppContext, 94 | Choice, 95 | Clipboard, 96 | Compound, 97 | CompoundRule, 98 | Config, 99 | ConnectionGrammar, 100 | Context, 101 | DictList, 102 | DictListRef, 103 | Dictation, 104 | Digits, 105 | DigitsRef, 106 | DynStrActionBase, 107 | ElementBase, 108 | Empty, 109 | FocusWindow, 110 | Function, 111 | HardwareInput, 112 | Integer, 113 | IntegerRef, 114 | Item, 115 | Key, 116 | Keyboard, 117 | KeyboardInput, 118 | List, 119 | ListBase, 120 | ListRef, 121 | Literal, 122 | MappingRule, 123 | Mimic, 124 | Monitor, 125 | Mouse, 126 | MouseInput, 127 | Number, 128 | NumberRef, 129 | Optional, 130 | Paste, 131 | Pause, 132 | Playback, 133 | PlaybackHistory, 134 | Point, 135 | RecognitionHistory, 136 | RecognitionObserver, 137 | Rectangle, 138 | Repeat, 139 | Repetition, 140 | Rule, 141 | RuleRef, 142 | Section, 143 | Sequence, 144 | Text, 145 | Typeable, 146 | WaitWindow, 147 | Window 148 | ) 149 | 150 | 151 | import aenea.config 152 | import aenea.communications 153 | import aenea.proxy_contexts 154 | 155 | 156 | def ensure_execution_context(data): 157 | '''Populates the data field of execute with context information if not 158 | present.''' 159 | if data is None: 160 | data = {} 161 | if '_proxy' not in data: 162 | data['_proxy'] = aenea.config.proxy_active() 163 | if '_server_info' not in data: 164 | data['_server_info'] = aenea.proxy_contexts._server_info() 165 | if '_proxy_context' not in data: 166 | data['_proxy_context'] = aenea.proxy_contexts._get_context() 167 | if '_context' not in data: 168 | data['_context'] = Window.get_foreground() 169 | return data 170 | 171 | 172 | class NoAction(ActionBase): 173 | '''Does nothing. Useful for constructing compound actions.''' 174 | def execute(self, data=None): 175 | pass 176 | 177 | 178 | class AlwaysContext(Context): 179 | '''Always matches. Useful for constructing compound contexts.''' 180 | def matches(self, windows_executable, windows_title, windows_handle): 181 | return True 182 | 183 | 184 | class NeverContext(Context): 185 | '''Never matches. Useful for constructing compound contexts.''' 186 | def matches(self, windows_executable, windows_title, windows_handle): 187 | return False 188 | 189 | 190 | class AeneaContext(Context): 191 | '''A context that delegates to either a local or proxy context object 192 | as appropriate. See also ProxyPlatformContext; which matches one of 193 | several contexts via proxy based on the OS on the other end.''' 194 | 195 | def __init__(self, proxy_context, local_context): 196 | '''proxy_context and remote_context may be dragonfly.Context 197 | subclasses or callables.''' 198 | assert(hasattr(proxy_context, 'matches') or 199 | hasattr(proxy_context, '__call__')) 200 | assert(hasattr(local_context, 'matches') or 201 | hasattr(local_context, '__call__')) 202 | self._proxy_context = proxy_context 203 | self._local_context = local_context 204 | Context.__init__(self) 205 | 206 | def matches(self, executable, title, handle): 207 | if aenea.config.PLATFORM == 'proxy': 208 | context = self._proxy_context 209 | else: 210 | context = self._local_context 211 | if hasattr(context, 'matches'): 212 | return context.matches(executable, title, handle) 213 | else: 214 | return context(executable, title, handle) 215 | 216 | 217 | class AeneaAction(ActionBase): 218 | '''Performs one action when config.PLATFORM is proxy, another for local. 219 | Useful for things that need to break out of the grammar system (eg, 220 | to query the filesystem to provide shell autocomplete), as well as 221 | providing grammars that work both on the VM and remote.''' 222 | 223 | def __init__(self, proxy_action, local_action): 224 | '''proxy_action and remote_action may be dragonfly.ActionBase 225 | subclasses or callables.''' 226 | assert(hasattr(proxy_action, 'execute') or 227 | hasattr(proxy_action, '__call__')) 228 | assert(hasattr(local_action, 'execute') or 229 | hasattr(local_action, '__call__')) 230 | self._proxy_action = proxy_action 231 | self._local_action = local_action 232 | ActionBase.__init__(self) 233 | 234 | def execute(self, data=None): 235 | data = ensure_execution_context(data) 236 | if data['_proxy']: 237 | action = self._proxy_action 238 | else: 239 | action = self._local_action 240 | if hasattr(action, 'execute'): 241 | action.execute(data) 242 | else: 243 | action(data) 244 | 245 | 246 | class AeneaDynStrActionBase(DynStrActionBase): 247 | def __init__(self, proxy, local, spec=None, static=False): 248 | self._proxy = proxy 249 | self._local = local 250 | DynStrActionBase.__init__(self, spec=spec, static=static) 251 | 252 | def _execute(self, data=None): 253 | # Crude, but better than copy-pasting the execute code. 254 | self._data = ensure_execution_context(data) 255 | DynStrActionBase._execute(self, data) 256 | 257 | def get_data(self): 258 | '''Returns the execution data.''' 259 | return self._data 260 | 261 | def _parse_spec(self, spec): 262 | proxy = self._proxy._parse_spec(spec) 263 | local = self._local._parse_spec(spec) 264 | return (proxy, local) 265 | 266 | def _execute_events(self, commands): 267 | if self.get_data()['_proxy']: 268 | return self._proxy._execute_events(commands[0]) 269 | else: 270 | return self._local._execute_events(commands[1]) 271 | 272 | 273 | class ContextAction(ActionBase): 274 | '''Take a different action depending on which context is currently 275 | active.''' 276 | def __init__(self, default=None, actions=[]): 277 | self.actions = actions 278 | self.default = default 279 | ActionBase.__init__(self) 280 | 281 | def add_context(self, context, action): 282 | self.actions.append((context, action)) 283 | 284 | def execute(self, data=None): 285 | data = ensure_execution_context(data) 286 | win = data['_context'] 287 | for (context, action) in self.actions: 288 | if context.matches(win.executable, win.title, win.handle): 289 | return action.execute(data) 290 | else: 291 | return self.default.execute(data) 292 | -------------------------------------------------------------------------------- /client/aenea/alias.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) David J. Rosenbaum 16 | # David J. Rosenbaum 17 | 18 | import copy 19 | import re 20 | 21 | try: 22 | import dragonfly 23 | except ImportError: 24 | import dragonfly_mock as dragonfly 25 | 26 | 27 | def _product(choices_product): 28 | if len(choices_product) == 0: 29 | raise Exception("Error: choices_product is empty") 30 | elif len(choices_product) == 1: 31 | return choices_product[0] 32 | else: 33 | choices = [] 34 | 35 | for choice in choices_product[0]: 36 | for text in _product(choices_product[1:]): 37 | choices.append(choice + " " + text) 38 | 39 | return choices 40 | 41 | def product(choices_product): 42 | """Return concatenation of each element of the direct product.""" 43 | choices_product = list(map(list, choices_product)) 44 | return _product(choices_product) 45 | 46 | def normalize_whitespace(text): 47 | return " ".join(text.split()) 48 | 49 | class Alias(object): 50 | """A mapping each string to a list of aliases.""" 51 | def __init__(self, aliases = []): 52 | self._map = {} 53 | self._rmap = {} 54 | self._regex = None 55 | 56 | self.update(aliases) 57 | 58 | def contains_string(self, string): 59 | return string in self._map 60 | 61 | def strings(self): 62 | return self._map.keys() 63 | 64 | def aliases(self): 65 | return self._map.values() 66 | 67 | def alias(self, string): 68 | return copy.copy(self._map[string]) 69 | 70 | def string(self, alias): 71 | return self._rmap[alias] 72 | 73 | def __contains__(self, string): 74 | return self.contains_string(string) 75 | 76 | def __iter__(self): 77 | for string in self._map: 78 | yield (string,) + tuple(self[string]) 79 | 80 | def __getitem__(self, string): 81 | """Get the aliases for string.""" 82 | return tuple(self._map[string]) 83 | 84 | def __or__(self, other): 85 | obj = self.__class__() 86 | obj.update(self) 87 | obj.update(other) 88 | return obj 89 | 90 | def get(self, string, default = None): 91 | return self._map.get(string, default) 92 | 93 | def update(self, aliases): 94 | """aliases should be an iterable of tuples where the first element is the primary string and the others are aliases for it.""" 95 | for strings in aliases: 96 | assert isinstance(strings, tuple) 97 | string = strings[0] 98 | alias_strings = strings[1:] 99 | self.add(string, alias_strings) 100 | 101 | def _cleanup(self, s): 102 | return " ".join(s.split()) 103 | 104 | def add(self, string, alias_strings): 105 | """Add the iterable of aliases for string to this object.""" 106 | self._regex = None 107 | 108 | if string not in self._map: 109 | self._map[string] = [] 110 | 111 | for alias in alias_strings: 112 | if alias not in self._rmap: 113 | # Only add aliases that are not already present in order to avoid duplicate entries. 114 | self._map[string].append(alias) 115 | self._rmap[alias] = string 116 | 117 | def discard(self, string_or_alias): 118 | """Remove string_or_alias if it is present.""" 119 | self._regex = None 120 | sora = string_or_alias 121 | 122 | if sora in self._map: 123 | for alias in self._map[sora]: 124 | del self._rmap[alias] 125 | 126 | del self._map[string_or_alias] 127 | 128 | if sora in self._rmap: 129 | string = self._rmap[sora] 130 | self._map[string].remove(sora) 131 | del self._rmap[sora] 132 | 133 | def _update_regex(self): 134 | if not self._regex: 135 | self._regex = re.compile(r"(?:^|[ \[\(])(?P" + r"|".join(sorted(self.strings(), key = lambda s: (len(s.split()), s), reverse = True)) + r")(?:$|[ \]\)])") 136 | 137 | def spec_for_word(self, word): 138 | if word in self: 139 | return "(" + " | ".join([word] + list(self[word])) + ")" 140 | else: 141 | return word 142 | 143 | def spec_for_words(self, string): 144 | """Return a dragonfly spec string for any string that can be obtained from string by making substitutions for individual words in string.""" 145 | return " ".join(map(self.spec_for_word, string.split())) 146 | 147 | def spec_for_string(self, string): 148 | """Return a dragonfly spec string for any string that can be obtained from string by making substitutions.""" 149 | assert string in self 150 | alias_strings = self[string] 151 | 152 | if len(alias_strings) == 0: 153 | return string 154 | else: 155 | return "(" + " | ".join([string] + list(map(self.spec_for_words, alias_strings))) + ")" 156 | 157 | def split(self, text): 158 | """Find all substrings in text that are strings in this object. Return an iterable of all such strings that the non-matching strings between them in the order they are encountered.""" 159 | self._update_regex() 160 | k = 0 # The end of the previous match. 161 | open_angle_brackets = 0 162 | 163 | while True: 164 | m = self._regex.search(text[k:]) 165 | 166 | if not m: 167 | if text[k:] != "": 168 | yield text[k:] 169 | 170 | break 171 | 172 | i, j = m.start("string"), m.end("string") 173 | i += k 174 | j += k 175 | open_angle_brackets += text[k:i].count("<") - text[k:i].count(">") 176 | assert open_angle_brackets >= 0 177 | 178 | # Ignore anything inside angle brackets 179 | if open_angle_brackets == 0: 180 | if text[k:i] != "": 181 | yield text[k:i] 182 | 183 | if text[i:j] != "": 184 | yield text[i:j] 185 | 186 | k = j 187 | 188 | def spec(self, spec): 189 | """Return a dragonfly spec string that allows aliases to be used instead of strings in spec.""" 190 | new_spec = "" 191 | 192 | def ensure_space(s): 193 | if len(s) > 0 and s[-1] != " ": 194 | s += " " 195 | 196 | return s 197 | 198 | for substr in self.split(spec): 199 | new_spec = ensure_space(new_spec) 200 | 201 | if substr in self: 202 | new_spec += self.spec_for_string(substr) 203 | else: 204 | new_spec += substr 205 | 206 | return normalize_whitespace(new_spec) 207 | 208 | def make_mapping_spec(self, mapping): 209 | return {self.spec(spec) : value for spec, value in dict(mapping).items()} 210 | 211 | def choices_for_word(self, word): 212 | choices = [word] 213 | 214 | if word in self: 215 | choices += list(self[word]) 216 | 217 | return choices 218 | 219 | def choices_for_words(self, string): 220 | choices_product = [] 221 | 222 | for word in string.split(): 223 | if word in self: 224 | choices_product.append(self.choices_for_word(word)) 225 | else: 226 | choices_product.append([word]) 227 | 228 | return product(choices_product) 229 | 230 | def choices_for_string(self, string): 231 | assert string in self 232 | choices = [string] 233 | 234 | for alias in self[string]: 235 | choices += self.choices_for_words(alias) 236 | 237 | return choices 238 | 239 | def substitute(self, text): 240 | """Return the strings that can be obtained from phrase by performing substitutions.""" 241 | choices_product = [] 242 | 243 | for substr in self.split(text): 244 | if substr in self: 245 | choices = list(self.choices_for_string(substr)) 246 | assert choices 247 | choices_product.append(choices) 248 | else: 249 | choices_product.append([substr]) 250 | 251 | return list(map(normalize_whitespace, product(choices_product))) 252 | 253 | def make_mapping(self, mapping): 254 | mapping = dict(mapping) 255 | new_mapping = dict(mapping) 256 | 257 | for string in mapping: 258 | for equivalent_text in self.substitute(string): 259 | new_mapping[equivalent_text] = mapping[string] 260 | 261 | return new_mapping 262 | 263 | def make_alternative(self, literal, **kwargs): 264 | return dragonfly.Alternative([dragonfly.Literal(equivalent_text) for equivalent_text in self.substitute(literal)] , **kwargs) 265 | -------------------------------------------------------------------------------- /client/aenea/config.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | import json 19 | import os 20 | import time 21 | 22 | try: 23 | # Import natlinkmain separately so that it isn't required to use Aenea with 24 | # module loaders for other engines. 25 | import natlinkmain 26 | except ImportError: 27 | pass 28 | 29 | try: 30 | import dragonfly 31 | except ImportError: 32 | import dragonfly_mock as dragonfly 33 | 34 | try: 35 | STARTING_PROJECT_ROOT = natlinkmain.userDirectory 36 | except (AttributeError, NameError): 37 | # AttributeError is for older NatLink that may not have the userDirectory value. 38 | # NameError is if the natlinkmain module can't be loaded (e.g., running in tests). 39 | STARTING_PROJECT_ROOT = 'C:\\NatLink\\NatLink\\MacroSystem' 40 | # userDirectory can be an empty string if unset 41 | if STARTING_PROJECT_ROOT == '': 42 | STARTING_PROJECT_ROOT = 'C:\\NatLink\\NatLink\\MacroSystem' 43 | 44 | 45 | _configuration = { 46 | 'project_root': STARTING_PROJECT_ROOT, 47 | 'host': 'localhost', 48 | 'port': 8240, 49 | 'platform': 'proxy', 50 | 'use_multiple_actions': True, 51 | 'screen_resolution': [1920, 1080], 52 | 'security_token': None, 53 | 'keys': ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'A', 'alt', 'Alt_L', 'Alt_R', 'ampersand', 'apostrophe', 'apps', 'asciicircum', 'asciitilde', 'asterisk', 'at', 'b', 'B', 'backslash', 'backspace', 'BackSpace', 'backtick', 'bar', 'braceleft', 'braceright', 'bracketleft', 'bracketright', 'Break', 'brokenbar', 'c', 'C', 'Cancel', 'Caps_Lock', 'caret', 'colon', 'comma', 'Control_L', 'Control_R', 'ctrl', 'd', 'D', 'dead_abovedot', 'dead_acute', 'dead_caron', 'dead_cedilla', 'dead_circumflex', 'dead_diaeresis', 'dead_doubleacute', 'dead_grave', 'dead_ogonek', 'dead_tilde', 'del', 'Delete', 'dollar', 'dot', 'down', 'Down', 'dquote', 'e', 'E', 'end', 'End', 'enter', 'equal', 'escape', 'Escape', 'exclam', 'exclamation', 'f', 'F', 'f1', 'F1', 'f10', 'F10', 'f11', 'F11', 'f12', 'F12', 'f13', 'f14', 'f15', 'f16', 'f17', 'f18', 'f19', 'f2', 'F2', 'f20', 'f21', 'f22', 'f23', 'f24', 'f3', 'F3', 'f4', 'F4', 'f5', 'F5', 'f6', 'F6', 'f7', 'F7', 'f8', 'F8', 'f9', 'F9', 'Find', 'g', 'G', 'grave', 'greater', 'greaterthan', 'h', 'H', 'Hangul', 'Hangul_Hanja', 'hash', 'Help', 'Henkan_Mode', 'Hiragana', 'Hiragana_Katakana', 'home', 'Home', 'Hyper_L', 'Hyper_R', 'hyphen', 'i', 'I', 'insert', 'Insert', 'ISO_Left_Tab', 'ISO_Level3_Shift', 'j', 'J', 'k', 'K', 'Katakana', 'KP_0', 'KP_1', 'KP_2', 'KP_3', 'KP_4', 'KP_5', 'KP_6', 'KP_7', 'KP_8', 'KP_9', 'KP_Add', 'KP_Begin', 'KP_Decimal', 'KP_Delete', 'KP_Divide', 'KP_Down', 'KP_End', 'KP_Enter', 'KP_Equal', 'KP_Home', 'KP_Insert', 'KP_Left', 'KP_Multiply', 'KP_Next', 'KP_Prior', 'KP_Right', 'KP_Subtract', 'KP_Up', 'l', 'L', 'langle', 'lbrace', 'lbracket', 'left', 'Left', 'less', 'lessthan', 'Linefeed', 'lparen', 'm', 'M', 'Menu', 'Meta_L', 'Meta_R', 'minus', 'Mode_switch', 'Muhenkan', 'n', 'N', 'Next', 'NoSymbol', 'np0', 'np1', 'np2', 'np3', 'np4', 'np5', 'np6', 'np7', 'np8', 'np9', 'npadd', 'npdec', 'npdiv', 'npmul', 'npsep', 'npsub', 'numbersign', 'Num_Lock', 'o', 'O', 'p', 'P', 'parenleft', 'parenright', 'Pause', 'percent', 'period', 'periodcentered', 'pgdown', 'pgup', 'plus', 'plusminus', 'Print', 'Prior', 'q', 'Q', 'question', 'quotedbl', 'r', 'R', 'rangle', 'rbrace', 'rbracket', 'Redo', 'Return', 'right', 'Right', 'rparen', 's', 'S', 'Scroll_Lock', 'semicolon', 'shift', 'Shift_L', 'Shift_R', 'slash', 'space', 'squote', 'SunFront', 'SunOpen', 'SunProps', 'Super_L', 'Super_R', 'Sys_Req', 't', 'T', 'tab', 'Tab', 'tilde', 'u', 'U', 'underscore', 'Undo', 'up', 'Up', 'v', 'V', 'w', 'W', 'win', 'x', 'X', 'XF86AudioForward', 'XF86AudioLowerVolume', 'XF86AudioMedia', 'XF86AudioMute', 'XF86AudioNext', 'XF86AudioPause', 'XF86AudioPlay', 'XF86AudioPrev', 'XF86AudioRaiseVolume', 'XF86AudioRecord', 'XF86AudioRewind', 'XF86AudioStop', 'XF86Back', 'XF86Battery', 'XF86Bluetooth', 'XF86Calculator', 'XF86ClearGrab', 'XF86Close', 'XF86Copy', 'XF86Cut', 'XF86Display', 'XF86Documents', 'XF86DOS', 'XF86Eject', 'XF86Explorer', 'XF86Favorites', 'XF86Finance', 'XF86Forward', 'XF86Game', 'XF86Go', 'XF86HomePage', 'XF86KbdBrightnessDown', 'XF86KbdBrightnessUp', 'XF86KbdLightOnOff', 'XF86Launch1', 'XF86Launch2', 'XF86Launch3', 'XF86Launch4', 'XF86Launch5', 'XF86Launch6', 'XF86Launch7', 'XF86Launch8', 'XF86Launch9', 'XF86LaunchA', 'XF86LaunchB', 'XF86Mail', 'XF86MailForward', 'XF86MenuKB', 'XF86Messenger', 'XF86MonBrightnessDown', 'XF86MonBrightnessUp', 'XF86MyComputer', 'XF86New', 'XF86Next_VMode', 'XF86Paste', 'XF86Phone', 'XF86PowerOff', 'XF86Prev_VMode', 'XF86Reload', 'XF86Reply', 'XF86RotateWindows', 'XF86Save', 'XF86ScreenSaver', 'XF86ScrollDown', 'XF86ScrollUp', 'XF86Search', 'XF86Send', 'XF86Shop', 'XF86Sleep', 'XF86Suspend', 'XF86Switch_VT_1', 'XF86Switch_VT_10', 'XF86Switch_VT_11', 'XF86Switch_VT_12', 'XF86Switch_VT_2', 'XF86Switch_VT_3', 'XF86Switch_VT_4', 'XF86Switch_VT_5', 'XF86Switch_VT_6', 'XF86Switch_VT_7', 'XF86Switch_VT_8', 'XF86Switch_VT_9', 'XF86Tools', 'XF86TouchpadOff', 'XF86TouchpadOn', 'XF86TouchpadToggle', 'XF86Ungrab', 'XF86WakeUp', 'XF86WebCam', 'XF86WLAN', 'XF86WWW', 'XF86Xfer', 'y', 'Y', 'z', 'Z', 'leftbrace', 'rightbrace', 'delete', 'equals',], 54 | 'key_translations' : {'lessthan' : 'less', 'greaterthan' : 'greater'}, # Dragonfly expects us to use lessthan and greaterthan rather than less and greater. 55 | 'modifiers': {'a': 'alt', 'A': 'Alt_R', 'c': 'control', 'w': 'super', 'h': 'hyper', 'm': 'meta', 'C': 'Control_R', 's': 'shift', 'S': 'Shift_R', 'M': 'Meta_R', 'W': 'Super_R', 'H': 'Hyper_R'}, 56 | } 57 | 58 | _configuration['keys'] = list(set(_configuration['keys']).union(set(dragonfly.typeables.keys()))) 59 | 60 | if os.path.exists(os.path.join(STARTING_PROJECT_ROOT, 'aenea.json')): 61 | _configuration['project_root'] = STARTING_PROJECT_ROOT 62 | 63 | _tried = set() 64 | # Recursively load the config file until we hit a self loop. 65 | while _configuration['project_root'] not in _tried: 66 | _tried.add(_configuration['project_root']) 67 | _configuration.update(json.load(open(os.path.join(_configuration['project_root'], 'aenea.json')))) 68 | 69 | PROJECT_ROOT = _configuration['project_root'] 70 | 71 | DEFAULT_SERVER_ADDRESS = _configuration['host'], _configuration['port'] 72 | 73 | # Whether to use proxy or native (not all modules support native.) 74 | PLATFORM = _configuration['platform'] 75 | 76 | # Whether to use the server's multiple_actions RPC method. 77 | USE_MULTIPLE_ACTIONS = _configuration['use_multiple_actions'] 78 | 79 | SCREEN_RESOLUTION = _configuration['screen_resolution'] 80 | 81 | KEYS = _configuration.get('keys', []) 82 | KEY_TRANSLATIONS = _configuration.get('key_translations', {}) 83 | MODIFIERS = _configuration.get('modifiers', {}) 84 | 85 | CONNECT_RETRY_COOLDOWN = _configuration.get('connect_retry_cooldown', 5) 86 | 87 | STALE_CONTEXT_DELTA = _configuration.get('stale_context_delta', 0.025) 88 | 89 | CONNECT_TIMEOUT = _configuration.get('connect_timeout', 0.1) 90 | COMMAND_TIMEOUT = _configuration.get('command_timeout', 2) 91 | 92 | SECURITY_TOKEN = _configuration['security_token'] 93 | 94 | if _configuration.get('restrict_proxy_to_aenea_client', True): 95 | proxy_enable_context = dragonfly.AppContext( 96 | executable="python", 97 | title="Aenea client - Dictation capturing" 98 | ) 99 | else: 100 | def _scoped(): 101 | class AlwaysContext(dragonfly.Context): 102 | def matches(self, windows_executable, windows_title, windows_handle): 103 | return True 104 | return AlwaysContext() 105 | proxy_enable_context = _scoped() 106 | 107 | 108 | _last_foreground_time = 0 109 | _last_foreground = None 110 | 111 | 112 | def get_window_foreground(): 113 | '''Compound actions can hammer this. 0.005 seconds per call * 100 actions 114 | = no longer insignificant. We thus cache it for a very short time as a 115 | crude hack, so that when executing a single action it isn't called too 116 | many times. Until we get an optimizer into Dragonfly, this will have 117 | to do.''' 118 | global _last_foreground_time 119 | global _last_foreground 120 | if (_last_foreground is None or time.time() - _last_foreground_time > STALE_CONTEXT_DELTA): 121 | _last_foreground_time = time.time() 122 | _last_foreground = dragonfly.Window.get_foreground() 123 | return _last_foreground 124 | 125 | 126 | def proxy_active(active_window=None): 127 | '''Returns whether the proxy is enabled, based on context and file 128 | settings.''' 129 | if active_window is None: 130 | active_window = get_window_foreground() 131 | active_window = ( 132 | active_window.executable, 133 | active_window.title, 134 | active_window.handle 135 | ) 136 | return (proxy_enable_context.matches(*active_window) and 137 | PLATFORM == 'proxy') 138 | 139 | 140 | def enable_proxy(): 141 | '''Dynamically enables proxy.''' 142 | global PLATFORM 143 | PLATFORM = 'proxy' 144 | 145 | 146 | def disable_proxy(): 147 | '''Dynamically disables proxy.''' 148 | global PLATFORM 149 | PLATFORM = 'local' 150 | -------------------------------------------------------------------------------- /client/test/test_vocabulary.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | import unittest 19 | import mock 20 | 21 | import aenea.config 22 | import aenea.vocabulary 23 | 24 | from configuration_mock import make_mock_conf, make_mock_dir 25 | 26 | 27 | class TestMakeRefreshVocabulary(unittest.TestCase): 28 | def setUp(self): 29 | aenea.vocabulary._vocabulary = {'static': {}, 'dynamic': {}} 30 | aenea.vocabulary._disabled_vocabularies = set() 31 | aenea.vocabulary._lists = {'static': {}, 'dynamic': {}} 32 | aenea.vocabulary._watchers = { 33 | 'static': make_mock_dir({}), 34 | 'dynamic': make_mock_dir({}) 35 | } 36 | aenea.vocabulary._enabled_watcher = make_mock_conf({}) 37 | 38 | aenea.vocabulary._watchers['static'].files['barbaz'] = self.simple1() 39 | aenea.vocabulary._watchers['dynamic'].files['foobaz'] = self.simple2() 40 | aenea.vocabulary._watchers['dynamic'].files['foobar'] = self.simple3() 41 | 42 | self.foo_foobar = { 43 | 'plus': 'Text("+ ")', 44 | 'literal entry': 'Key("c-v")' 45 | } 46 | 47 | self.foo_foobaz = { 48 | 'bit ore': 'Text("| ")', 49 | 'kill': 'Key("c-backslash")' 50 | } 51 | 52 | self.foo = self.foo_foobar.copy() 53 | self.foo.update(self.foo_foobaz) 54 | 55 | self.bar = { 56 | 'plus': 'Text("+ ")', 57 | 'literal entry': 'Key("c-v")' 58 | } 59 | 60 | self.baz = { 61 | 'bit ore': 'Text("| ")', 62 | 'kill': 'Key("c-backslash")' 63 | } 64 | 65 | self.s_foo = {} 66 | self.s_bar = self.s_baz = { 67 | 'compare eek': 'Text("== ")', 68 | 'interrupt': 'Key("c-c")' 69 | } 70 | 71 | def simple1(self): 72 | return make_mock_conf({ 73 | 'name': 'barbaz', 74 | 'tags': ['bar', 'baz'], 75 | 'vocabulary': { 76 | 'compare eek': '== ' 77 | }, 78 | 'shortcuts': { 79 | 'interrupt': 'c-c' 80 | } 81 | }) 82 | 83 | def simple2(self): 84 | return make_mock_conf({ 85 | 'name': 'foobaz', 86 | 'tags': ['foo', 'baz', 'global'], 87 | 'vocabulary': { 88 | 'bit ore': '| ' 89 | }, 90 | 'shortcuts': { 91 | 'kill': 'c-backslash' 92 | } 93 | }) 94 | 95 | def simple3(self): 96 | return make_mock_conf({ 97 | 'name': 'foobar', 98 | 'tags': ['foo', 'bar'], 99 | 'vocabulary': { 100 | 'plus': '+ ' 101 | }, 102 | 'shortcuts': { 103 | 'literal entry': 'c-v' 104 | } 105 | }) 106 | 107 | def mocker(self, key): 108 | return lambda n: '%s("%s")' % (key, n) 109 | 110 | @mock.patch('aenea.vocabulary.Text') 111 | @mock.patch('aenea.vocabulary.Key') 112 | def test_load(self, key, text): 113 | text.side_effect = self.mocker('Text') 114 | key.side_effect = self.mocker('Key') 115 | foo = aenea.vocabulary.register_dynamic_vocabulary('foo') 116 | bar = aenea.vocabulary.register_dynamic_vocabulary('bar') 117 | baz = aenea.vocabulary.register_dynamic_vocabulary('baz') 118 | s_foo = aenea.vocabulary.get_static_vocabulary('foo') 119 | s_bar = aenea.vocabulary.get_static_vocabulary('bar') 120 | s_baz = aenea.vocabulary.get_static_vocabulary('baz') 121 | self.assertEquals(foo, self.foo) 122 | self.assertEquals(bar, self.bar) 123 | self.assertEquals(baz, self.baz) 124 | self.assertEquals(s_foo, self.s_foo) 125 | self.assertEquals(s_bar, self.s_bar) 126 | self.assertEquals(s_baz, self.s_baz) 127 | 128 | aenea.vocabulary.unregister_dynamic_vocabulary('foo') 129 | aenea.vocabulary.unregister_dynamic_vocabulary('bar') 130 | aenea.vocabulary.unregister_dynamic_vocabulary('baz') 131 | 132 | @mock.patch('aenea.vocabulary.Text') 133 | @mock.patch('aenea.vocabulary.Key') 134 | def test_enable_disable_simple(self, key, text): 135 | text.side_effect = self.mocker('Text') 136 | key.side_effect = self.mocker('Key') 137 | foo = aenea.vocabulary.register_dynamic_vocabulary('foo') 138 | self.assertEquals(foo, self.foo) 139 | aenea.vocabulary.disable_dynamic_vocabulary('foobar') 140 | self.assertEquals(foo, self.foo_foobaz) 141 | aenea.vocabulary.disable_dynamic_vocabulary('foobaz') 142 | self.assertEquals(foo, {}) 143 | aenea.vocabulary.enable_dynamic_vocabulary('foobar') 144 | self.assertEquals(foo, self.foo_foobar) 145 | aenea.vocabulary.enable_dynamic_vocabulary('foobaz') 146 | self.assertEquals(foo, self.foo) 147 | 148 | aenea.vocabulary.unregister_dynamic_vocabulary('foo') 149 | 150 | @mock.patch('aenea.vocabulary.Text') 151 | @mock.patch('aenea.vocabulary.Key') 152 | def test_inhibitions(self, key, text): 153 | text.side_effect = self.mocker('Text') 154 | key.side_effect = self.mocker('Key') 155 | 156 | g = aenea.vocabulary.register_global_dynamic_vocabulary() 157 | foo = aenea.vocabulary.register_dynamic_vocabulary('foo') 158 | 159 | self.assertEquals(g, self.foo_foobaz) 160 | self.assertEquals(foo, self.foo) 161 | 162 | gall = aenea.vocabulary.register_dynamic_vocabulary('global') 163 | 164 | self.assertEquals(gall, self.foo_foobaz) 165 | 166 | aenea.vocabulary.inhibit_global_dynamic_vocabulary('test', 'foo') 167 | 168 | self.assertEquals(g, {}) 169 | self.assertEquals(foo, self.foo) 170 | self.assertEquals(gall, self.foo_foobaz) 171 | 172 | aenea.vocabulary.disable_dynamic_vocabulary('foobaz') 173 | 174 | self.assertEquals(g, {}) 175 | self.assertEquals(foo, self.foo_foobar) 176 | self.assertEquals(gall, {}) 177 | 178 | aenea.vocabulary.uninhibit_global_dynamic_vocabulary('test', 'foo') 179 | 180 | self.assertEquals(g, {}) 181 | self.assertEquals(foo, self.foo_foobar) 182 | self.assertEquals(gall, {}) 183 | 184 | aenea.vocabulary.enable_dynamic_vocabulary('foobaz') 185 | 186 | self.assertEquals(g, self.foo_foobaz) 187 | self.assertEquals(foo, self.foo) 188 | self.assertEquals(gall, self.foo_foobaz) 189 | 190 | aenea.vocabulary.unregister_global_dynamic_vocabulary() 191 | aenea.vocabulary.unregister_dynamic_vocabulary('foo') 192 | aenea.vocabulary.unregister_dynamic_vocabulary('global') 193 | 194 | @mock.patch('aenea.vocabulary.Text') 195 | @mock.patch('aenea.vocabulary.Key') 196 | def test_enable_synched_to_conf(self, key, text): 197 | text.side_effect = self.mocker('Text') 198 | key.side_effect = self.mocker('Key') 199 | 200 | foo = aenea.vocabulary.register_dynamic_vocabulary('foo') 201 | 202 | self.assertTrue(aenea.vocabulary._enabled_watcher['foobar']) 203 | self.assertEquals(foo, self.foo) 204 | 205 | aenea.vocabulary._enabled_watcher.dirty = True 206 | aenea.vocabulary._enabled_watcher['foobar'] = False 207 | 208 | aenea.vocabulary.refresh_vocabulary() 209 | 210 | self.assertEquals(foo, self.foo_foobaz) 211 | 212 | aenea.vocabulary.unregister_dynamic_vocabulary('foo') 213 | 214 | @mock.patch('aenea.vocabulary.Text') 215 | @mock.patch('aenea.vocabulary.Key') 216 | def test_change_vocabulary_online(self, key, text): 217 | text.side_effect = self.mocker('Text') 218 | key.side_effect = self.mocker('Key') 219 | 220 | foo = aenea.vocabulary.register_dynamic_vocabulary('foo') 221 | 222 | self.assertEquals(foo, self.foo) 223 | 224 | conf = aenea.vocabulary._watchers['dynamic'].files['foobar'].conf 225 | conf['vocabulary']['minus'] = '- ' 226 | aenea.vocabulary._watchers['dynamic'].dirty = True 227 | 228 | aenea.vocabulary.refresh_vocabulary() 229 | good = {'minus': 'Text("- ")'} 230 | good.update(self.foo) 231 | self.assertEquals(foo, good) 232 | 233 | g = aenea.vocabulary.register_global_dynamic_vocabulary() 234 | self.assertEquals(g, self.foo_foobaz) 235 | 236 | conf['tags'].append('global') 237 | conf = aenea.vocabulary._watchers['dynamic'].dirty = True 238 | 239 | aenea.vocabulary.refresh_vocabulary() 240 | 241 | good.update(self.foo) 242 | self.assertEquals(g, good) 243 | 244 | aenea.vocabulary.unregister_dynamic_vocabulary('foo') 245 | aenea.vocabulary.unregister_global_dynamic_vocabulary() 246 | 247 | 248 | @mock.patch('aenea.vocabulary.Text') 249 | @mock.patch('aenea.vocabulary.Key') 250 | def test_remove_vocabulary_online(self, key, text): 251 | text.side_effect = self.mocker('Text') 252 | key.side_effect = self.mocker('Key') 253 | 254 | foo = aenea.vocabulary.register_dynamic_vocabulary('foo') 255 | self.assertEquals(foo, self.foo) 256 | del aenea.vocabulary._watchers['dynamic'].files['foobar'] 257 | aenea.vocabulary.refresh_vocabulary() 258 | self.assertEquals(foo, self.foo_foobaz) 259 | 260 | aenea.vocabulary.unregister_dynamic_vocabulary('foo') 261 | 262 | if __name__ == '__main__': 263 | unittest.main() 264 | -------------------------------------------------------------------------------- /server/linux_x11/test_server_x11.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | # 15 | # Copyright (2014) Alex Roper 16 | # Alex Roper 17 | 18 | import mock 19 | import unittest 20 | import threading 21 | 22 | import util.communications 23 | 24 | import server_x11 25 | 26 | HOST = '127.0.0.1' 27 | PORT = 58382 28 | 29 | 30 | class test_server_x11_info(unittest.TestCase): 31 | def get_context(self, return_value): 32 | comm = util.communications.Proxy(HOST, self.port) 33 | self.assertEqual(comm.server.get_context(), return_value) 34 | self.server.shutdown() 35 | 36 | @mock.patch('server_x11.get_context') 37 | def test_get_context(self, get_context): 38 | self.port = PORT 39 | return_value = {'test_successful': 'Yep'} 40 | get_context.return_value = return_value 41 | get_context.__name__ = 'get_context' 42 | self.server = server_x11.setup_server(HOST, self.port) 43 | test_thread = threading.Thread( 44 | target=self.get_context, 45 | args=[return_value] 46 | ) 47 | test_thread.start() 48 | self.server.serve_forever() 49 | test_thread.join() 50 | get_context.assert_called_with() 51 | 52 | 53 | class test_server_x11_actions(unittest.TestCase): 54 | def setUp(self): 55 | self.server = None 56 | self.test_thread = None 57 | self.port = None 58 | 59 | def run_request_thread(self, client): 60 | self.server = server_x11.setup_server(HOST, self.port) 61 | test_thread = threading.Thread(target=client) 62 | test_thread.start() 63 | self.server.serve_forever() 64 | test_thread.join() 65 | 66 | def key_press(self, comm): 67 | comm.key_press(key='a') 68 | comm.key_press(key='a', modifiers=['shift']) 69 | comm.key_press(key='shift', direction='down') 70 | comm.key_press(key='b', count_delay=100, count=3) 71 | comm.key_press(key='shift', direction='up') 72 | 73 | @mock.patch('server_x11.run_command') 74 | def test_key_press(self, run_command): 75 | self.port = PORT + 1 76 | commands = [] 77 | run_command.side_effect = lambda cmd: commands.append(cmd) 78 | self.run_request_thread(self.single_request_client(self.key_press)) 79 | self.assertEqual(commands, [ 80 | 'key a', 81 | 'keydown Shift_L key a keyup Shift_L', 82 | 'keydown Shift_L', 83 | '--delay 100 key b key b key b', 84 | 'keyup Shift_L' 85 | ]) 86 | 87 | def write_text(self, comm): 88 | comm.write_text(text='Hello world!') 89 | 90 | @mock.patch('server_x11.write_command') 91 | def test_write_text(self, write_command): 92 | self.port = PORT + 2 93 | self.run_request_thread(self.single_request_client(self.write_text)) 94 | write_command.assert_called_with( 95 | 'Hello world!', 96 | arguments='type --file - --delay 0' 97 | ) 98 | 99 | def click_mouse(self, comm): 100 | comm.click_mouse(button='left', count=2) 101 | comm.click_mouse(button='wheelup', count=2) 102 | comm.click_mouse(button='right') 103 | comm.click_mouse(button='right', count=5, count_delay=70) 104 | comm.click_mouse(button='middle', count_delay=7) 105 | 106 | @mock.patch('server_x11.run_command') 107 | def test_click_mouse(self, run_command): 108 | self.port = PORT + 3 109 | commands = [ 110 | mock.call('click --repeat 2 1'), 111 | mock.call('click --repeat 2 4'), 112 | mock.call('click 3'), 113 | mock.call('click --delay 70 --repeat 5 3'), 114 | mock.call('click 2') 115 | ] 116 | self.run_request_thread(self.single_request_client(self.click_mouse)) 117 | self.assertEqual(commands, run_command.mock_calls) 118 | 119 | def move_mouse(self, comm): 120 | comm.move_mouse(x=0, y=0) 121 | comm.pause(amount=100) 122 | comm.move_mouse(x=0.5, y=0.5, proportional=True) 123 | comm.pause(amount=100) 124 | comm.move_mouse(x=75, y=45, reference='relative_active') 125 | comm.pause(amount=100) 126 | comm.move_mouse(x=0, y=0, phantom='left') 127 | comm.pause(amount=100) 128 | comm.move_mouse(x=0, y=50, reference='relative') 129 | comm.pause(amount=100) 130 | 131 | @mock.patch('server_x11.get_active_window') 132 | @mock.patch('time.sleep') 133 | @mock.patch('server_x11.get_geometry') 134 | @mock.patch('server_x11.run_command') 135 | def test_move_mouse(self, run_command, geo, wait, get_active_window): 136 | self.port = PORT + 4 137 | geo.return_value = { 138 | 'x': 50, 139 | 'y': 50, 140 | 'width': 100, 141 | 'height': 100, 142 | 'screen': 0 143 | } 144 | get_active_window.return_value = 103, 'test' 145 | commands = [ 146 | mock.call('mousemove 0.000000 0.000000'), 147 | mock.call('mousemove 50.000000 50.000000'), 148 | mock.call('mousemove --window 103 75.000000 45.000000'), 149 | mock.call('mousemove 0.000000 0.000000 click 1 mousemove restore'), 150 | mock.call('mousemove_relative 0.000000 50.000000') 151 | ] 152 | self.run_request_thread(self.single_request_client(self.move_mouse)) 153 | self.assertEqual(commands, run_command.mock_calls) 154 | self.assertEqual([mock.call(0.1)] * 5, wait.mock_calls) 155 | 156 | def drag_mouse(self, comm): 157 | comm.move_mouse(x=0, y=0, proportional=True) 158 | comm.click_mouse(button='left', direction='down') 159 | comm.move_mouse(x=1, y=1, proportional=True) 160 | comm.click_mouse(button='left', direction='up') 161 | 162 | @mock.patch('server_x11.get_active_window') 163 | @mock.patch('server_x11.get_geometry') 164 | @mock.patch('server_x11.run_command') 165 | def test_drag_mouse(self, run_command, geo, get_active_window): 166 | self.port = PORT + 5 167 | geo.return_value = { 168 | 'x': 50, 169 | 'y': 50, 170 | 'width': 100, 171 | 'height': 100, 172 | 'screen': 0 173 | } 174 | get_active_window.return_value = 103, 'test' 175 | commands = [ 176 | mock.call('mousemove 0.000000 0.000000'), 177 | mock.call('mousedown 1'), 178 | mock.call('mousemove 100.000000 100.000000'), 179 | mock.call('mouseup 1') 180 | ] 181 | self.run_request_thread(self.single_request_client(self.drag_mouse)) 182 | self.assertEqual(commands, run_command.mock_calls) 183 | 184 | def pause(self, comm): 185 | comm.pause(amount=500) 186 | 187 | def single_request_client(self, test_suite): 188 | def worker(): 189 | test_suite(util.communications.Proxy(HOST, self.port).server) 190 | self.server.shutdown() 191 | return worker 192 | 193 | @mock.patch('time.sleep') 194 | def test_pause(self, wait): 195 | self.port = PORT + 6 196 | self.run_request_thread(self.single_request_client(self.pause)) 197 | wait.assert_called_with(0.5) 198 | 199 | def multiple_actions(self): 200 | batch = util.communications.BatchProxy() 201 | self.key_press(batch) 202 | self.write_text(batch) 203 | self.click_mouse(batch) 204 | self.pause(batch) 205 | self.write_text(batch) 206 | self.key_press(batch) 207 | self.click_mouse(batch) 208 | self.pause(batch) 209 | proxy = util.communications.Proxy(HOST, self.port) 210 | proxy.execute_batch(batch._commands) 211 | self.server.shutdown() 212 | 213 | @mock.patch('server_x11.write_command') 214 | @mock.patch('server_x11.flush_xdotool') 215 | @mock.patch('server_x11.run_command') 216 | def test_multiple_actions(self, run_command, flush, write_command): 217 | calls = [] 218 | 219 | def mock_flush(actions): 220 | '''Mock has issues with the del [:].''' 221 | if actions: 222 | calls.append(actions[:]) 223 | del actions[:] 224 | 225 | flush.side_effect = mock_flush 226 | self.port = PORT + 7 227 | self.server = server_x11.setup_server(HOST, self.port) 228 | 229 | test_thread = threading.Thread(target=self.multiple_actions) 230 | test_thread.start() 231 | self.server.serve_forever() 232 | test_thread.join() 233 | 234 | # No easy way to test interleaving, so we rely on shape of flushes 235 | # to check proper happens-before. 236 | self.assertEqual( 237 | write_command.mock_calls, 238 | [mock.call( 239 | 'Hello world!', 240 | arguments='type --file - --delay 0' 241 | )] * 2 242 | ) 243 | 244 | step1 = [ 245 | 'key a', 246 | 'keydown Shift_L', 247 | 'key a', 248 | 'keyup Shift_L', 249 | 'keydown Shift_L', 250 | 'key b', 251 | 'key b', 252 | 'key b', 253 | 'keyup Shift_L' 254 | ] 255 | 256 | step2 = [ 257 | 'click --repeat 2 1', 258 | 'click --repeat 2 4', 259 | 'click 3', 260 | 'click --delay 70 --repeat 5 3', 261 | 'click 2', 262 | 'sleep 0.500000' 263 | ] 264 | 265 | self.assertEqual(calls, [step1, step2, step1 + step2]) 266 | 267 | 268 | if __name__ == '__main__': 269 | unittest.main() 270 | -------------------------------------------------------------------------------- /client/aenea_client.py: -------------------------------------------------------------------------------- 1 | # This file is part of Aenea 2 | # 3 | # Aenea is free software: you can redistribute it and/or modify it under 4 | # the terms of version 3 of the GNU Lesser General Public License as 5 | # published by the Free Software Foundation. 6 | # 7 | # Aenea is distributed in the hope that it will be useful, but WITHOUT 8 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 9 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 10 | # License for more details. 11 | # 12 | # You should have received a copy of the GNU Lesser General Public 13 | # License along with Aenea. If not, see . 14 | 15 | import Tkinter as tk 16 | import tkFont 17 | import datetime 18 | import threading 19 | import ttk 20 | 21 | import aenea 22 | 23 | # Keys that should be translated from a TK name to the name expected by 24 | # the server. 25 | TRANSLATE_KEYS = { 26 | 'space': ' ', 27 | 'Left': 'left', 28 | 'Right': 'right', 29 | 'Up': 'up', 30 | 'Down': 'down', 31 | 'Home': 'home', 32 | 'Next': 'pgup', 33 | 'Prior': 'pgdown', 34 | 'End': 'end', 35 | 'BackSpace': 'backspace', 36 | 'Delete': 'del', 37 | 'quoteright': 'apostrophe', 38 | } 39 | 40 | # Keys that may be sent as part of a text string. Any key pressed that 41 | # is not in the ignored set or in this mapping will be sent as a 42 | # keypress event, which is slightly less efficient. 43 | LITERAL_KEYS = ('abcdefghijklmnopqrstuvwxyz' 44 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.!? ') 45 | 46 | # Keys that should be completely ignored when pressed. This has the side 47 | # effect that Dragon commands like 'press control J' will not work via 48 | # this client. 49 | IGNORED_KEYS = ('Shift_L', 'Control_L') 50 | 51 | ALT_KEY_SEQUENCE_MAP = {u'\u2013' : 'hyphen'} 52 | 53 | _config = aenea.configuration.ConfigWatcher( 54 | 'dictation_capture_state', 55 | {'enabled': True}) 56 | 57 | 58 | class ProxyBuffer(object): 59 | def __init__(self, log=lambda msg: None): 60 | self.log = log 61 | self.text_buffer = [] 62 | self.key_buffer = [] 63 | self.buffer_lock = threading.Lock() 64 | self.buffer_ready = threading.Condition(self.buffer_lock) 65 | self.to_send = aenea.communications.BatchProxy() 66 | self.aenea_worker_active = False 67 | self.sending = False 68 | threading.Thread(target=self.worker_thread).start() 69 | 70 | def start_capture(self): 71 | with self.buffer_lock: 72 | while self.sending: 73 | self.buffer_ready.wait() 74 | aenea.ProxyKey('Control_R').execute() 75 | 76 | def send_key(self, key): 77 | with self.buffer_lock: 78 | assert not self.text_buffer or not self.key_buffer 79 | if key in LITERAL_KEYS: 80 | self.flush_key_buffer() 81 | self.text_buffer.append(key) 82 | else: 83 | self.flush_text_buffer() 84 | if self.key_buffer and self.key_buffer[-1][0] == key: 85 | self.key_buffer[-1] = (key, self.key_buffer[-1][1] + 1) 86 | else: 87 | self.key_buffer.append((key, 1)) 88 | self.buffer_ready.notify() 89 | 90 | # Requires buffer_lock 91 | def flush_text_buffer(self): 92 | if self.text_buffer: 93 | self.to_send.write_text(text=''.join(self.text_buffer)) 94 | self.text_buffer = [] 95 | 96 | # Requires buffer_lock 97 | def flush_key_buffer(self): 98 | if self.key_buffer: 99 | for (key, count) in self.key_buffer: 100 | self.to_send.key_press(key=key, count=count) 101 | self.key_buffer = [] 102 | 103 | def worker_thread(self): 104 | while 1: 105 | with self.buffer_lock: 106 | 107 | # Wait until we have something to send. 108 | while not (self.text_buffer or self.key_buffer or self.to_send._commands): 109 | assert not self.text_buffer or not self.key_buffer 110 | self.sending = False 111 | self.buffer_ready.wait() 112 | self.sending = True 113 | 114 | assert not self.text_buffer or not self.key_buffer 115 | self.flush_text_buffer() 116 | self.flush_key_buffer() 117 | 118 | todo, self.to_send = self.to_send, aenea.communications.BatchProxy() 119 | 120 | if todo._commands: 121 | aenea.communications.server.execute_batch(todo._commands) 122 | 123 | class AltKeySequenceState(object): 124 | NO_SEQUENCE = 0 125 | STARTING_ALT_SEQUENCE = 1 126 | IN_ALT_SEQUENCE = 2 127 | ENDING_ALT_SEQUENCE = 3 128 | 129 | class AeneaClient(tk.Tk): 130 | 131 | def __init__(self): 132 | tk.Tk.__init__(self) 133 | self.alt_key_sequence = AltKeySequenceState.NO_SEQUENCE 134 | self.wm_title('Aenea client - Dictation capturing') 135 | self.geometry('400x600+400+0') 136 | self.wait_visibility(self) 137 | note = ttk.Notebook(self) 138 | self.tab1 = tk.Frame(note) 139 | self.tab2 = tk.Frame(note) 140 | w = tk.LabelFrame(self.tab1, text=u'Controls') 141 | w.pack(side=tk.TOP, fill=tk.BOTH) 142 | self.button1 = tk.Button( 143 | w, 144 | text=u'Start capture', 145 | command=self.start_capture 146 | ) 147 | self.button1.pack(side=tk.LEFT) 148 | self.button2 = tk.Button( 149 | w, 150 | text=u'Stop capture', 151 | command=self.stop_capture, 152 | state=tk.DISABLED 153 | ) 154 | self.button2.pack(side=tk.LEFT) 155 | self.button3 = tk.Button( 156 | w, 157 | text=u'Clear box', 158 | command=self.clear_text 159 | ) 160 | self.button3.pack(side=tk.LEFT) 161 | self.display_entered_text = tk.IntVar() 162 | self.checkbox1 = tk.Checkbutton( 163 | w, 164 | text='Display entered text', 165 | variable=self.display_entered_text 166 | ) 167 | self.checkbox1.pack(side=tk.LEFT) 168 | 169 | dFont = tkFont.Font(family='Tahoma', size=8) 170 | 171 | l = tk.Label(self.tab1, text=u'Capture:') 172 | l.pack(side=tk.TOP) 173 | 174 | self.tab1.text1 = tk.Text(self.tab1, width=16, height=5, font=dFont) 175 | yscrollbar = tk.Scrollbar( 176 | self.tab1.text1, 177 | orient=tk.VERTICAL, 178 | command=self.tab1.text1.yview 179 | ) 180 | yscrollbar.pack(side=tk.RIGHT, fill=tk.Y) 181 | self.tab1.text1['yscrollcommand'] = yscrollbar.set 182 | self.tab1.text1.pack(side=tk.TOP, fill=tk.BOTH, expand=tk.YES) 183 | self.tab1.pack(side=tk.TOP, fill=tk.X) 184 | self.tab1.text1.bind('', lambda event: self.focus()) 185 | 186 | l = tk.Label(self.tab1, text=u'Log:') 187 | l.pack(side=tk.TOP) 188 | 189 | self.tab1.text2 = tk.Text(self.tab1, width=16, height=5, font=dFont) 190 | yscrollbar = tk.Scrollbar( 191 | self.tab1.text2, 192 | orient=tk.VERTICAL, 193 | command=self.tab1.text2.yview 194 | ) 195 | yscrollbar.pack(side=tk.RIGHT, fill=tk.Y) 196 | self.tab1.text2['yscrollcommand'] = yscrollbar.set 197 | self.tab1.text2.pack(side=tk.TOP, fill=tk.BOTH, expand=tk.YES) 198 | self.tab1.pack(side=tk.TOP, fill=tk.X) 199 | self.tab1.text2.bind('', lambda event: self.focus()) 200 | 201 | l = tk.Label(self.tab2, text=u'Todo...') 202 | l.pack(side=tk.LEFT) 203 | 204 | note.add(self.tab1, text='Capturing') 205 | note.add(self.tab2, text='Configuration') 206 | note.pack(side=tk.LEFT, fill=tk.BOTH, expand=tk.YES) 207 | 208 | self.proxy_buffer = ProxyBuffer(log=self.log) 209 | 210 | def log(self, message): 211 | timeStamp = datetime.datetime.now() 212 | self.tab1.text2.insert(tk.END, '%s: %s\n' % (timeStamp, message)) 213 | self.tab1.text2.see(tk.END) # Scroll to end. 214 | 215 | def start_capture(self): 216 | # Release VirtualBox keyboard capture. 217 | self.proxy_buffer.start_capture() 218 | self.log('Starting capture') 219 | self.bind('', lambda event: self.send_key(event.char, event.keysym)) 220 | self.button1.config(state=tk.DISABLED) 221 | self.button2.config(state=tk.NORMAL) 222 | 223 | def stop_capture(self): 224 | self.log('Stopping capture') 225 | self.bind('', self.dummy_event) 226 | self.button1.config(state=tk.NORMAL) 227 | self.button2.config(state=tk.DISABLED) 228 | 229 | def dummy_event(self, event): 230 | pass 231 | 232 | def send_key(self, char, key): 233 | _config.refresh() 234 | if not _config.conf.get('enabled', True): 235 | return 236 | 237 | if key == 'Alt_L': 238 | self.alt_key_sequence = AltKeySequenceState.STARTING_ALT_SEQUENCE 239 | return 240 | 241 | if self.alt_key_sequence == AltKeySequenceState.STARTING_ALT_SEQUENCE: 242 | if key == '0': 243 | self.alt_key_sequence = AltKeySequenceState.IN_ALT_SEQUENCE 244 | return 245 | else: 246 | self.alt_key_sequence = AltKeySequenceState.NO_SEQUENCE 247 | 248 | if self.alt_key_sequence == AltKeySequenceState.IN_ALT_SEQUENCE: 249 | if key == '??': 250 | self.alt_key_sequence = AltKeySequenceState.ENDING_ALT_SEQUENCE 251 | else: 252 | return 253 | 254 | if self.display_entered_text.get(): 255 | self.tab1.text1.insert(tk.END, (key if key != 'space' else ' ')) 256 | self.tab1.text1.see(tk.END) # Scroll to end. 257 | if key in IGNORED_KEYS: 258 | return 259 | 260 | if self.alt_key_sequence == AltKeySequenceState.ENDING_ALT_SEQUENCE: 261 | self.alt_key_sequence = AltKeySequenceState.NO_SEQUENCE 262 | self.proxy_buffer.send_key(ALT_KEY_SEQUENCE_MAP.get(char, char)) 263 | else: 264 | self.proxy_buffer.send_key(TRANSLATE_KEYS.get(key, key)) 265 | 266 | def clear_text(self): 267 | self.tab1.text1.delete('1.0', tk.END) 268 | 269 | if __name__ == '__main__': 270 | root = AeneaClient() 271 | root.mainloop() 272 | -------------------------------------------------------------------------------- /server/linux_wayland/azerty.py: -------------------------------------------------------------------------------- 1 | from abstractKeyboardMapping import AbstractKeyboardMapping 2 | import evdev 3 | 4 | class Azerty(AbstractKeyboardMapping): 5 | def __init__(self): 6 | super(AbstractKeyboardMapping, self).__init__() 7 | 8 | def solo(self): 9 | return { "1" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_1], 10 | "2" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_2], 11 | "3" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_3], 12 | "4" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_4], 13 | "5" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_5], 14 | "6" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_6], 15 | "7" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_7], 16 | "8" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_8], 17 | "9" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_9], 18 | "0" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_0], 19 | "°" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_MINUS], 20 | "+" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_EQUAL], 21 | 22 | "&" : [evdev.ecodes.KEY_1], 23 | "é" : [evdev.ecodes.KEY_2], 24 | "\"" : [evdev.ecodes.KEY_3], 25 | "'" : [evdev.ecodes.KEY_4], 26 | "(" : [evdev.ecodes.KEY_5], 27 | "-" : [evdev.ecodes.KEY_6], 28 | "è" : [evdev.ecodes.KEY_7], 29 | "_" : [evdev.ecodes.KEY_8], 30 | "ç" : [evdev.ecodes.KEY_9], 31 | "à" : [evdev.ecodes.KEY_0], 32 | ")" : [evdev.ecodes.KEY_MINUS], 33 | "=" : [evdev.ecodes.KEY_EQUAL], 34 | 35 | "¹" : [evdev.ecodes.KEY_RIGHTALT, evdev.ecodes.KEY_1], 36 | "~" : [evdev.ecodes.KEY_RIGHTALT, evdev.ecodes.KEY_2], 37 | "#" : [evdev.ecodes.KEY_RIGHTALT, evdev.ecodes.KEY_3], 38 | "{" : [evdev.ecodes.KEY_RIGHTALT, evdev.ecodes.KEY_4], 39 | "[" : [evdev.ecodes.KEY_RIGHTALT, evdev.ecodes.KEY_5], 40 | "|" : [evdev.ecodes.KEY_RIGHTALT, evdev.ecodes.KEY_6], 41 | "`" : [evdev.ecodes.KEY_RIGHTALT, evdev.ecodes.KEY_7], 42 | "\\" : [evdev.ecodes.KEY_RIGHTALT, evdev.ecodes.KEY_8], 43 | "^" : [evdev.ecodes.KEY_RIGHTALT, evdev.ecodes.KEY_9], 44 | "@" : [evdev.ecodes.KEY_RIGHTALT, evdev.ecodes.KEY_0], 45 | "]" : [evdev.ecodes.KEY_RIGHTALT, evdev.ecodes.KEY_MINUS], 46 | "}" : [evdev.ecodes.KEY_RIGHTALT, evdev.ecodes.KEY_EQUAL], 47 | 48 | "£" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_RIGHTBRACE], 49 | "¤" : [evdev.ecodes.KEY_RIGHTALT, evdev.ecodes.KEY_RIGHTBRACE], 50 | "$" : [evdev.ecodes.KEY_RIGHTBRACE], 51 | "%" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_APOSTROPHE], 52 | "ù" : [evdev.ecodes.KEY_APOSTROPHE], 53 | "µ" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_BACKSLASH], 54 | "*" : [evdev.ecodes.KEY_BACKSLASH], 55 | 56 | "?" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_M], 57 | "," : [evdev.ecodes.KEY_M], 58 | "." : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_COMMA], 59 | ";" : [evdev.ecodes.KEY_COMMA], 60 | "/" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_DOT], 61 | ":" : [evdev.ecodes.KEY_DOT], 62 | "§" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_SLASH], 63 | "!" : [evdev.ecodes.KEY_SLASH], 64 | 65 | "<" : [evdev.ecodes.KEY_102ND], 66 | ">" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_102ND], 67 | "²" : [evdev.ecodes.KEY_GRAVE], 68 | "€" : [evdev.ecodes.KEY_RIGHTALT, evdev.ecodes.KEY_E], 69 | 70 | "a" : [evdev.ecodes.KEY_Q], 71 | "A" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_Q], 72 | "z" : [evdev.ecodes.KEY_W], 73 | "Z" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_W], 74 | "q" : [evdev.ecodes.KEY_A], 75 | "Q" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_A], 76 | "w" : [evdev.ecodes.KEY_Z], 77 | "W" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_Z], 78 | "m" : [evdev.ecodes.KEY_SEMICOLON], 79 | "M" : [evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_SEMICOLON], 80 | } 81 | 82 | 83 | def multi(self): 84 | return { "â" : [[evdev.ecodes.KEY_LEFTBRACE, 1], [evdev.ecodes.KEY_LEFTBRACE, 0], [evdev.ecodes.KEY_Q, 1], [evdev.ecodes.KEY_Q, 0]], 85 | "ê" : [[evdev.ecodes.KEY_LEFTBRACE, 1], [evdev.ecodes.KEY_LEFTBRACE, 0], [evdev.ecodes.KEY_E, 1], [evdev.ecodes.KEY_E, 0]], 86 | "î" : [[evdev.ecodes.KEY_LEFTBRACE, 1], [evdev.ecodes.KEY_LEFTBRACE, 0], [evdev.ecodes.KEY_I, 1], [evdev.ecodes.KEY_I, 0]], 87 | "ô" : [[evdev.ecodes.KEY_LEFTBRACE, 1], [evdev.ecodes.KEY_LEFTBRACE, 0], [evdev.ecodes.KEY_O, 1], [evdev.ecodes.KEY_O, 0]], 88 | "û" : [[evdev.ecodes.KEY_LEFTBRACE, 1], [evdev.ecodes.KEY_LEFTBRACE, 0], [evdev.ecodes.KEY_U, 1], [evdev.ecodes.KEY_U, 0]], 89 | "ŷ" : [[evdev.ecodes.KEY_LEFTBRACE, 1], [evdev.ecodes.KEY_LEFTBRACE, 0], [evdev.ecodes.KEY_Y, 1], [evdev.ecodes.KEY_Y, 0]], 90 | 91 | "Â" : [[evdev.ecodes.KEY_LEFTBRACE, 1], 92 | [evdev.ecodes.KEY_LEFTBRACE, 0], 93 | [evdev.ecodes.KEY_LEFTSHIFT, 1], 94 | [evdev.ecodes.KEY_Q, 1], 95 | [evdev.ecodes.KEY_Q, 0], 96 | [evdev.ecodes.KEY_LEFTSHIFT, 0]], 97 | "Ê" : [[evdev.ecodes.KEY_LEFTBRACE, 1], 98 | [evdev.ecodes.KEY_LEFTBRACE, 0], 99 | [evdev.ecodes.KEY_LEFTSHIFT, 1], 100 | [evdev.ecodes.KEY_E, 1], 101 | [evdev.ecodes.KEY_E, 0], 102 | [evdev.ecodes.KEY_LEFTSHIFT, 0]], 103 | "Î" : [[evdev.ecodes.KEY_LEFTBRACE, 1], 104 | [evdev.ecodes.KEY_LEFTBRACE, 0], 105 | [evdev.ecodes.KEY_LEFTSHIFT, 1], 106 | [evdev.ecodes.KEY_I, 1], 107 | [evdev.ecodes.KEY_I, 0], 108 | [evdev.ecodes.KEY_LEFTSHIFT, 0]], 109 | "Ô" : [[evdev.ecodes.KEY_LEFTBRACE, 1], 110 | [evdev.ecodes.KEY_LEFTBRACE, 0], 111 | [evdev.ecodes.KEY_LEFTSHIFT, 1], 112 | [evdev.ecodes.KEY_O, 1], 113 | [evdev.ecodes.KEY_O, 0], 114 | [evdev.ecodes.KEY_LEFTSHIFT, 0]], 115 | "Û" : [[evdev.ecodes.KEY_LEFTBRACE, 1], 116 | [evdev.ecodes.KEY_LEFTBRACE, 0], 117 | [evdev.ecodes.KEY_LEFTSHIFT, 1], 118 | [evdev.ecodes.KEY_U, 1], 119 | [evdev.ecodes.KEY_U, 0], 120 | [evdev.ecodes.KEY_LEFTSHIFT, 0]], 121 | "Ŷ" : [[evdev.ecodes.KEY_LEFTBRACE, 1], 122 | [evdev.ecodes.KEY_LEFTBRACE, 0], 123 | [evdev.ecodes.KEY_LEFTSHIFT, 1], 124 | [evdev.ecodes.KEY_Y, 1], 125 | [evdev.ecodes.KEY_Y, 0], 126 | [evdev.ecodes.KEY_LEFTSHIFT, 0]], 127 | 128 | "ä" : [[evdev.ecodes.KEY_LEFTSHIFT, 1], 129 | [evdev.ecodes.KEY_LEFTBRACE, 1], 130 | [evdev.ecodes.KEY_LEFTBRACE, 0], 131 | [evdev.ecodes.KEY_LEFTSHIFT, 0], 132 | [evdev.ecodes.KEY_Q, 1], 133 | [evdev.ecodes.KEY_Q, 0]], 134 | "ë" : [[evdev.ecodes.KEY_LEFTSHIFT, 1], 135 | [evdev.ecodes.KEY_LEFTBRACE, 1], 136 | [evdev.ecodes.KEY_LEFTBRACE, 0], 137 | [evdev.ecodes.KEY_LEFTSHIFT, 0], 138 | [evdev.ecodes.KEY_E, 1], 139 | [evdev.ecodes.KEY_E, 0]], 140 | "ï" : [[evdev.ecodes.KEY_LEFTSHIFT, 1], 141 | [evdev.ecodes.KEY_LEFTBRACE, 1], 142 | [evdev.ecodes.KEY_LEFTBRACE, 0], 143 | [evdev.ecodes.KEY_LEFTSHIFT, 0], 144 | [evdev.ecodes.KEY_I, 1], 145 | [evdev.ecodes.KEY_I, 0]], 146 | "ö" : [[evdev.ecodes.KEY_LEFTSHIFT, 1], 147 | [evdev.ecodes.KEY_LEFTBRACE, 1], 148 | [evdev.ecodes.KEY_LEFTBRACE, 0], 149 | [evdev.ecodes.KEY_LEFTSHIFT, 0], 150 | [evdev.ecodes.KEY_O, 1], 151 | [evdev.ecodes.KEY_O, 0]], 152 | "ü" : [[evdev.ecodes.KEY_LEFTSHIFT, 1], 153 | [evdev.ecodes.KEY_LEFTBRACE, 1], 154 | [evdev.ecodes.KEY_LEFTBRACE, 0], 155 | [evdev.ecodes.KEY_LEFTSHIFT, 0], 156 | [evdev.ecodes.KEY_U, 1], 157 | [evdev.ecodes.KEY_U, 0]], 158 | "ÿ" : [[evdev.ecodes.KEY_LEFTSHIFT, 1], 159 | [evdev.ecodes.KEY_LEFTBRACE, 1], 160 | [evdev.ecodes.KEY_LEFTBRACE, 0], 161 | [evdev.ecodes.KEY_LEFTSHIFT, 0], 162 | [evdev.ecodes.KEY_Y, 1], 163 | [evdev.ecodes.KEY_Y, 0]], 164 | 165 | "Ä" : [[evdev.ecodes.KEY_LEFTSHIFT, 1], 166 | [evdev.ecodes.KEY_LEFTBRACE, 1], 167 | [evdev.ecodes.KEY_LEFTBRACE, 0], 168 | [evdev.ecodes.KEY_Q, 1], 169 | [evdev.ecodes.KEY_Q, 0], 170 | [evdev.ecodes.KEY_LEFTSHIFT, 0]], 171 | "Ë" : [[evdev.ecodes.KEY_LEFTSHIFT, 1], 172 | [evdev.ecodes.KEY_LEFTBRACE, 1], 173 | [evdev.ecodes.KEY_LEFTBRACE, 0], 174 | [evdev.ecodes.KEY_E, 1], 175 | [evdev.ecodes.KEY_E, 0], 176 | [evdev.ecodes.KEY_LEFTSHIFT, 0]], 177 | "Ï" : [[evdev.ecodes.KEY_LEFTSHIFT, 1], 178 | [evdev.ecodes.KEY_LEFTBRACE, 1], 179 | [evdev.ecodes.KEY_LEFTBRACE, 0], 180 | [evdev.ecodes.KEY_I, 1], 181 | [evdev.ecodes.KEY_I, 0], 182 | [evdev.ecodes.KEY_LEFTSHIFT, 0]], 183 | "Ö" : [[evdev.ecodes.KEY_LEFTSHIFT, 1], 184 | [evdev.ecodes.KEY_LEFTBRACE, 1], 185 | [evdev.ecodes.KEY_LEFTBRACE, 0], 186 | [evdev.ecodes.KEY_O, 1], 187 | [evdev.ecodes.KEY_O, 0], 188 | [evdev.ecodes.KEY_LEFTSHIFT, 0]], 189 | "Ü" : [[evdev.ecodes.KEY_LEFTSHIFT, 1], 190 | [evdev.ecodes.KEY_LEFTBRACE, 1], 191 | [evdev.ecodes.KEY_LEFTBRACE, 0], 192 | [evdev.ecodes.KEY_U, 1], 193 | [evdev.ecodes.KEY_U, 0], 194 | [evdev.ecodes.KEY_LEFTSHIFT, 0]], 195 | "Ÿ" : [[evdev.ecodes.KEY_LEFTSHIFT, 1], 196 | [evdev.ecodes.KEY_LEFTBRACE, 1], 197 | [evdev.ecodes.KEY_LEFTBRACE, 0], 198 | [evdev.ecodes.KEY_Y, 1], 199 | [evdev.ecodes.KEY_Y, 0], 200 | [evdev.ecodes.KEY_LEFTSHIFT, 0]], 201 | } 202 | 203 | --------------------------------------------------------------------------------