Пример #1
0
 async def ifeq(handler):
     set_value = macro_variables.get(variable)
     logger.debug('"%s" is "%s"', variable, set_value)
     if set_value == value:
         await then.run(handler)
     elif otherwise is not None:
         await otherwise.run(handler)
Пример #2
0
def get_dbus_interface(fallback=True):
    """Get an interface to start and stop injecting keystrokes.

    Parameters
    ----------
    fallback : bool
        If true, returns an instance of the daemon instead if it cannot
        connect
    """
    msg = ('The daemon "key-mapper-service" is not running, mapping keys '
           'only works as long as the window is open. '
           'Try `sudo systemctl start key-mapper`')

    if not is_service_running():
        if not fallback:
            logger.error('Service not running')
            return None

        logger.warning(msg)
        return Daemon()

    bus = SystemBus()
    try:
        interface = bus.get(BUS_NAME)
    except GLib.GError as error:
        logger.debug(error)

        if not fallback:
            logger.error('Failed to connect to the running service')
            return None

        logger.warning(msg)
        return Daemon()

    return interface
Пример #3
0
    def test_debug(self):
        path = os.path.join(tmp, 'logger-test')
        add_filehandler(path)
        logger.error('abc')
        logger.warning('foo')
        logger.info('123')
        logger.debug('456')
        logger.spam('789')
        with open(path, 'r') as f:
            content = f.read().lower()
            self.assertIn('logger.py', content)

            self.assertIn('error', content)
            self.assertIn('abc', content)

            self.assertIn('warn', content)
            self.assertIn('foo', content)

            self.assertIn('info', content)
            self.assertIn('123', content)

            self.assertIn('debug', content)
            self.assertIn('456', content)

            self.assertIn('spam', content)
            self.assertIn('789', content)
Пример #4
0
    def test_default(self):
        path = add_filehandler(os.path.join(tmp, 'logger-test'))
        update_verbosity(debug=False)
        logger.error('abc')
        logger.warning('foo')
        logger.info('123')
        logger.debug('456')
        logger.spam('789')
        with open(path, 'r') as f:
            content = f.read().lower()
            self.assertNotIn('logger.py', content)
            self.assertNotIn('line', content)

            self.assertIn('error', content)
            self.assertIn('abc', content)

            self.assertIn('warn', content)
            self.assertIn('foo', content)

            self.assertNotIn('info', content)
            self.assertIn('123', content)

            self.assertNotIn('debug', content)
            self.assertNotIn('456', content)

            self.assertNotIn('spam', content)
            self.assertNotIn('789', content)
Пример #5
0
    def change(self, new_key, symbol, previous_key=None):
        """Replace the mapping of a keycode with a different one.

        Parameters
        ----------
        new_key : Key
        symbol : string
            A single symbol known to xkb or linux.
            Examples: KEY_KP1, Shift_L, a, B, BTN_LEFT.
        previous_key : Key or None
            the previous key

            If not set, will not remove any previous mapping. If you recently
            used (1, 10, 1) for new_key and want to overwrite that with
            (1, 11, 1), provide (1, 10, 1) here.
        """
        if not isinstance(new_key, Key):
            raise TypeError(f'Expected {new_key} to be a Key object')

        if symbol is None:
            raise ValueError('Expected `symbol` not to be None')

        symbol = symbol.strip()
        logger.debug('%s will map to "%s"', new_key, symbol)
        self.clear(new_key)  # this also clears all equivalent keys
        self._mapping[new_key] = symbol

        if previous_key is not None:
            code_changed = new_key != previous_key
            if code_changed:
                # clear previous mapping of that code, because the line
                # representing that one will now represent a different one
                self.clear(previous_key)

        self.changed = True
Пример #6
0
    def load_config(self, path=None):
        """Load the config from the file system.

        Parameters
        ----------
        path : string or None
            If set, will change the path to load from and save to.
        """
        if path is not None:
            if not os.path.exists(path):
                logger.error('Config at "%s" not found', path)
                return

            self.path = path

        self.clear_config()

        if not os.path.exists(self.path):
            # treated like an empty config
            logger.debug('Config "%s" doesn\'t exist yet', self.path)
            self.clear_config()
            self._config = copy.deepcopy(INITIAL_CONFIG)
            self.save_config()
            return

        with open(self.path, 'r') as file:
            try:
                self._config.update(json.load(file))
                logger.info('Loaded config from "%s"', self.path)
            except json.decoder.JSONDecodeError as error:
                logger.error('Failed to parse config "%s": %s. Using defaults',
                             self.path, str(error))
Пример #7
0
    def populate_presets(self):
        """Show the available presets for the selected device.

        This will destroy unsaved changes in the custom_mapping.
        """
        device = self.selected_device
        presets = get_presets(device)

        if len(presets) == 0:
            new_preset = get_available_preset_name(self.selected_device)
            custom_mapping.empty()
            path = get_preset_path(self.selected_device, new_preset)
            custom_mapping.save(path)
            presets = [new_preset]
        else:
            logger.debug('"%s" presets: "%s"', device, '", "'.join(presets))

        preset_selection = self.get('preset_selection')

        with HandlerDisabled(preset_selection, self.on_select_preset):
            # otherwise the handler is called with None for each preset
            preset_selection.remove_all()

        for preset in presets:
            preset_selection.append(preset, preset)
        # and select the newest one (on the top). triggers on_select_preset
        preset_selection.set_active(0)
Пример #8
0
def handle_plus_syntax(macro):
    """transform a + b + c to m(a, m(b, m(c, h())))"""
    if '+' not in macro:
        return macro

    if '(' in macro or ')' in macro:
        logger.error('Mixing "+" and macros is unsupported: "%s"', macro)
        return macro

    chunks = [chunk.strip() for chunk in macro.split('+')]
    output = ''
    depth = 0
    for chunk in chunks:
        if chunk == '':
            # invalid syntax
            logger.error('Invalid syntax for "%s"', macro)
            return macro

        depth += 1
        output += f'm({chunk},'

    output += 'h()'
    output += depth * ')'

    logger.debug('Transformed "%s" to "%s"', macro, output)
    return output
Пример #9
0
    def _handle_commands(self):
        """Handle all unread commands."""
        # wait for something to do
        select.select([self._commands], [], [])

        while self._commands.poll():
            cmd = self._commands.recv()
            logger.debug('Received command "%s"', cmd)

            if cmd == TERMINATE:
                logger.debug('Helper terminates')
                sys.exit(0)

            if cmd == REFRESH_GROUPS:
                groups.refresh()
                self._send_groups()
                continue

            group = groups.find(key=cmd)
            if group is None:
                groups.refresh()
                group = groups.find(key=cmd)

            if group is not None:
                self.group = group
                continue

            logger.error('Received unknown command "%s"', cmd)
Пример #10
0
    def on_select_device(self, dropdown):
        """List all presets, create one if none exist yet."""
        self.save_preset()

        if dropdown.get_active_id() == self.selected_device:
            return

        # selecting a device will also automatically select a different
        # preset. Prevent another unsaved-changes dialog to pop up
        custom_mapping.changed = False

        device = dropdown.get_active_id()

        if device is None:
            return

        logger.debug('Selecting device "%s"', device)

        self.selected_device = device
        self.selected_preset = None

        self.populate_presets()

        reader.start_reading(device)

        self.show_device_mapping_status()
Пример #11
0
    def populate(self):
        """Get a mapping of all available names to their keycodes."""
        logger.debug('Gathering available keycodes')
        self.clear()
        xmodmap_dict = {}
        try:
            xmodmap = subprocess.check_output(
                ['xmodmap', '-pke'], stderr=subprocess.STDOUT).decode()
            xmodmap = xmodmap
            self._xmodmap = re.findall(r'(\d+) = (.+)\n', xmodmap + '\n')
            xmodmap_dict = self._find_legit_mappings()
        except (subprocess.CalledProcessError, FileNotFoundError):
            # might be within a tty
            pass

        if USER != 'root':
            # write this stuff into the key-mapper config directory, because
            # the systemd service won't know the user sessions xmodmap
            path = get_config_path(XMODMAP_FILENAME)
            touch(path)
            with open(path, 'w') as file:
                logger.debug('Writing "%s"', path)
                json.dump(xmodmap_dict, file, indent=4)

        for name, code in xmodmap_dict.items():
            self._set(name, code)

        for name, ecode in evdev.ecodes.ecodes.items():
            if name.startswith('KEY') or name.startswith('BTN'):
                self._set(name, ecode)

        self._set(DISABLE_NAME, DISABLE_CODE)
Пример #12
0
    def on_select_device(self, dropdown):
        """List all presets, create one if none exist yet."""
        self.save_preset()

        if self.group and dropdown.get_active_id() == self.group.key:
            return

        # selecting a device will also automatically select a different
        # preset. Prevent another unsaved-changes dialog to pop up
        custom_mapping.changed = False

        group_key = dropdown.get_active_id()

        if group_key is None:
            return

        logger.debug('Selecting device "%s"', group_key)

        self.group = groups.find(key=group_key)
        self.preset_name = None

        self.populate_presets()

        reader.start_reading(groups.find(key=group_key))

        self.show_device_mapping_status()
Пример #13
0
    def _read_worker(self):
        """Thread that reads keycodes and buffers them into a pipe."""
        # using a thread that blocks instead of read_one made it easier
        # to debug via the logs, because the UI was not polling properly
        # at some point which caused logs for events not to be written.
        rlist = {device.fd: device for device in self.virtual_devices}
        rlist[self._pipe[1]] = self._pipe[1]

        while True:
            ready = select.select(rlist, [], [])[0]
            for fd in ready:
                readable = rlist[fd]  # a device or a pipe
                if isinstance(readable, multiprocessing.connection.Connection):
                    msg = readable.recv()
                    if msg == CLOSE:
                        logger.debug('Reader stopped')
                        return
                    continue

                try:
                    for event in rlist[fd].read():
                        self._consume_event(event, readable)
                except OSError:
                    logger.debug('Device "%s" disappeared from the reader',
                                 rlist[fd].path)
                    del rlist[fd]
Пример #14
0
    def _consume_event(self, event, device):
        """Write the event code into the pipe if it is a key-down press."""
        # value: 1 for down, 0 for up, 2 for hold.
        if self._pipe is None or self._pipe[1].closed:
            logger.debug('Pipe closed, reader stops.')
            sys.exit(0)

        click_events = [evdev.ecodes.BTN_LEFT, evdev.ecodes.BTN_TOOL_DOUBLETAP]

        if event.type == EV_KEY and event.value == 2:
            # ignore hold-down events
            return

        if event.type == EV_KEY and event.code in click_events:
            # disable mapping the left mouse button because it would break
            # the mouse. Also it is emitted right when focusing the row
            # which breaks the current workflow.
            return

        if not utils.should_map_event_as_btn(device, event, custom_mapping):
            return

        if not (event.value == 0 and event.type == EV_ABS):
            # avoid gamepad trigger spam
            logger.spam('got (%s, %s, %s)', event.type, event.code,
                        event.value)
        self._pipe[1].send(event)
Пример #15
0
    async def _event_consumer(self, source, forward_to):
        """Reads input events to inject keycodes or talk to the event_producer.

        Can be stopped by stopping the asyncio loop. This loop
        reads events from a single device only. Other devnodes may be
        present for the hardware device, in which case this needs to be
        started multiple times.

        Parameters
        ----------
        source : evdev.InputDevice
            where to read keycodes from
        forward_to : evdev.UInput
            where to write keycodes to that were not mapped to anything.
            Should be an UInput with capabilities that work for all forwarded
            events, so ideally they should be copied from source.
        """
        logger.debug(
            'Started consumer to inject to %s, fd %s',
            source.path, source.fd
        )

        gamepad = classify(source) == GAMEPAD

        keycode_handler = KeycodeMapper(self.context, source, forward_to)

        async for event in source.async_read_loop():
            if self._event_producer.is_handled(event):
                # the event_producer will take care of it
                self._event_producer.notify(event)
                continue

            # for mapped stuff
            if utils.should_map_as_btn(event, self.context.mapping, gamepad):
                will_report_key_up = utils.will_report_key_up(event)

                keycode_handler.handle_keycode(event)

                if not will_report_key_up:
                    # simulate a key-up event if no down event arrives anymore.
                    # this may release macros, combinations or keycodes.
                    release = evdev.InputEvent(0, 0, event.type, event.code, 0)
                    self._event_producer.debounce(
                        debounce_id=(event.type, event.code, event.value),
                        func=keycode_handler.handle_keycode,
                        args=(release, False),
                        ticks=3,
                    )

                continue

            # forward the rest
            forward_to.write(event.type, event.code, event.value)
            # this already includes SYN events, so need to syn here again

        # This happens all the time in tests because the async_read_loop
        # stops when there is nothing to read anymore. Otherwise tests
        # would block.
        logger.error('The consumer for "%s" stopped early', source.path)
Пример #16
0
 def on_close(self, *_):
     """Safely close the application."""
     logger.debug('Closing window')
     self.window.hide()
     for timeout in self.timeouts:
         GLib.source_remove(timeout)
         self.timeouts = []
     keycode_reader.stop_reading()
     Gtk.main_quit()
Пример #17
0
    def stop_injecting(self, group_key):
        """Stop injecting the mapping for a single device."""
        if self.injectors.get(group_key) is None:
            logger.debug(
                'Tried to stop injector, but none is running for group "%s"',
                group_key)
            return

        self.injectors[group_key].stop_injecting()
        self.autoload_history.forget(group_key)
Пример #18
0
    def stop_injecting(self, device):
        """Stop injecting the mapping for a single device."""
        if self.injectors.get(device) is None:
            logger.debug(
                'Tried to stop injector, but none is running for device "%s"',
                device)
            return

        self.injectors[device].stop_injecting()
        self.autoload_history.forget(device)
Пример #19
0
    def __init__(self):
        """Constructs the daemon."""
        logger.debug('Creating daemon')
        self.injectors = {}
        self.config_dir = None

        self.autoload_history = AutoloadHistory()
        self.refreshed_devices_at = 0

        atexit.register(self.stop_all)
Пример #20
0
 def on_close(self, *_):
     """Safely close the application."""
     logger.debug('Closing window')
     self.save_preset()
     self.window.hide()
     for timeout in self.timeouts:
         GLib.source_remove(timeout)
         self.timeouts = []
     reader.terminate()
     Gtk.main_quit()
Пример #21
0
    def _grab_device(self, path):
        """Try to grab the device, return None if not needed/possible."""
        try:
            device = evdev.InputDevice(path)
        except (FileNotFoundError, OSError):
            logger.error('Could not find "%s"', path)
            return None

        capabilities = device.capabilities(absinfo=False)

        needed = False
        for key, _ in self.context.mapping:
            if is_in_capabilities(key, capabilities):
                logger.debug('Grabbing "%s" because of "%s"', path, key)
                needed = True
                break

        gamepad = classify(device) == GAMEPAD

        if gamepad and self.context.maps_joystick():
            logger.debug('Grabbing "%s" because of maps_joystick', path)
            needed = True

        if not needed:
            # skipping reading and checking on events from those devices
            # may be beneficial for performance.
            logger.debug('No need to grab %s', path)
            return None

        attempts = 0
        while True:
            try:
                # grab to avoid e.g. the disabled keycode of 10 to confuse
                # X, especially when one of the buttons of your mouse also
                # uses 10. This also avoids having to load an empty xkb
                # symbols file to prevent writing any unwanted keys.
                device.grab()
                logger.debug('Grab %s', path)
                break
            except IOError as error:
                attempts += 1

                # it might take a little time until the device is free if
                # it was previously grabbed.
                logger.debug('Failed attempts to grab %s: %d', path, attempts)

                if attempts >= 4:
                    logger.error('Cannot grab %s, it is possibly in use', path)
                    logger.error(str(error))
                    return None

            time.sleep(self.regrab_timeout)

        return device
Пример #22
0
    def _grab_device(self, path):
        """Try to grab the device, return None if not needed/possible.

        Without grab, original events from it would reach the display server
        even though they are mapped.
        """
        try:
            device = evdev.InputDevice(path)
        except (FileNotFoundError, OSError):
            logger.error('Could not find "%s"', path)
            return None

        capabilities = device.capabilities(absinfo=False)

        needed = False
        for key, _ in self.context.mapping:
            if is_in_capabilities(key, capabilities):
                logger.debug('Grabbing "%s" because of "%s"', path, key)
                needed = True
                break

        gamepad = classify(device) == GAMEPAD

        if gamepad and self.context.maps_joystick():
            logger.debug('Grabbing "%s" because of maps_joystick', path)
            needed = True

        if not needed:
            # skipping reading and checking on events from those devices
            # may be beneficial for performance.
            logger.debug('No need to grab %s', path)
            return None

        attempts = 0
        while True:
            try:
                device.grab()
                logger.debug('Grab %s', path)
                break
            except IOError as error:
                attempts += 1

                # it might take a little time until the device is free if
                # it was previously grabbed.
                logger.debug('Failed attempts to grab %s: %d', path, attempts)

                if attempts >= 10:
                    logger.error('Cannot grab %s, it is possibly in use', path)
                    logger.error(str(error))
                    return None

            time.sleep(self.regrab_timeout)

        return device
Пример #23
0
    def start_injecting(self, device, path, xmodmap_path=None):
        """Start injecting the preset for the device.

        Returns True on success.

        Parameters
        ----------
        device : string
            The name of the device
        path : string
            Path to the preset. The daemon, if started via systemctl, has no
            knowledge of the user and their home path, so the complete
            absolute path needs to be provided here.
        xmodmap_path : string, None
            Path to a dump of the xkb mappings, to provide more human
            readable keys in the correct keyboard layout to the service.
            The service cannot use `xmodmap -pke` because it's running via
            systemd.
        """
        if device not in get_devices():
            logger.debug('Devices possibly outdated, refreshing')
            refresh_devices()

        # reload the config, since it may have been changed
        config.load_config()
        if self.injectors.get(device) is not None:
            self.injectors[device].stop_injecting()

        mapping = Mapping()
        try:
            mapping.load(path)
        except FileNotFoundError as error:
            logger.error(str(error))
            return False

        if xmodmap_path is not None:
            try:
                with open(xmodmap_path, 'r') as file:
                    xmodmap = json.load(file)
                    logger.debug('Using keycodes from "%s"', xmodmap_path)
                    system_mapping.update(xmodmap)
                    # the service now has process wide knowledge of xmodmap
                    # keys of the users session
            except FileNotFoundError:
                logger.error('Could not find "%s"', xmodmap_path)

        try:
            injector = KeycodeInjector(device, mapping)
            injector.start_injecting()
            self.injectors[device] = injector
        except OSError:
            return False

        return True
Пример #24
0
def get_data_path(filename=''):
    """Depending on the installation prefix, return the data dir.

    Since it is a nightmare to get stuff installed with pip across
    distros this is somewhat complicated. Ubuntu uses /usr/local/share
    for data_files (setup.py) and manjaro uses /usr/share.
    """
    global logged

    source = None
    try:
        source = pkg_resources.require('key-mapper')[0].location
        # failed in some ubuntu installations
    except pkg_resources.DistributionNotFound:
        pass

    # depending on where this file is installed to, make sure to use the proper
    # prefix path for data
    # https://docs.python.org/3/distutils/setupscript.html?highlight=package_data#installing-additional-files # noqa pylint: disable=line-too-long

    data = None
    # python3.8/dist-packages python3.7/site-packages, /usr/share,
    # /usr/local/share, endless options
    if source and '-packages' not in source and 'python' not in source:
        # probably installed with -e, running from the cloned git source
        data = os.path.join(source, 'data')
        if not os.path.exists(data):
            if not logged:
                logger.debug('-e, but data missing at "%s"', data)
            data = None

    candidates = [
        '/usr/share/key-mapper',
        '/usr/local/share/key-mapper',
        os.path.join(site.USER_BASE, 'share/key-mapper'),
    ]

    if data is None:
        # try any of the options
        for candidate in candidates:
            if os.path.exists(candidate):
                data = candidate
                break

        if data is None:
            logger.error('Could not find the application data')
            sys.exit(1)

    if not logged:
        logger.debug('Found data at "%s"', data)
        logged = True

    return os.path.join(data, filename)
Пример #25
0
    def save_config(self):
        """Save the config to the file system."""
        if USER == 'root':
            logger.debug('Skipping config file creation for the root user')
            return

        touch(self.path)

        with open(self.path, 'w') as file:
            json.dump(self._config, file, indent=4)
            logger.info('Saved config to %s', self.path)
            file.write('\n')
Пример #26
0
    def clear(self):
        """Next time when reading don't return the previous keycode."""
        logger.debug('Clearing reader')
        while self._results.poll():
            # clear the results pipe and handle any non-event messages,
            # otherwise a 'groups' message might get lost
            message = self._results.recv()
            self._get_event(message)

        self._unreleased = {}
        self.previous_event = None
        self.previous_result = None
Пример #27
0
    def run(self):
        """Do what get_devices describes."""
        # evdev needs asyncio to work
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)

        logger.debug('Discovering device paths')
        devices = [evdev.InputDevice(path) for path in evdev.list_devices()]

        # group them together by usb device because there could be stuff like
        # "Logitech USB Keyboard" and "Logitech USB Keyboard Consumer Control"
        grouped = {}
        for device in devices:
            if device.name == 'Power Button':
                continue

            # only keyboard devices
            # https://www.kernel.org/doc/html/latest/input/event-codes.html
            capabilities = device.capabilities(absinfo=False)
            if EV_KEY not in capabilities and EV_ABS not in capabilities:
                # or gamepads, because they can be mapped like a keyboard
                continue

            is_gamepad = map_abs_to_rel(capabilities)

            name = device.name
            path = device.path

            info = str(device.info)
            if grouped.get(info) is None:
                grouped[info] = []

            logger.spam('Found "%s", "%s", "%s"', info, path, name)

            grouped[info].append((name, path, is_gamepad))

        # now write down all the paths of that group
        result = {}
        for group in grouped.values():
            names = [entry[0] for entry in group]
            devs = [entry[1] for entry in group]
            is_gamepad = True in [entry[2] for entry in group]
            shortest_name = sorted(names, key=len)[0]
            result[shortest_name] = {
                'paths': devs,
                'devices': names,
                'gamepad': is_gamepad
            }

        self.pipe.send(result)
Пример #28
0
    def start_processes(self):
        """Start helper and daemon via pkexec to run in the background."""
        # this function is overwritten in tests
        self.dbus = Daemon.connect()

        debug = ' -d' if is_debug() else ''
        cmd = f'pkexec key-mapper-control --command helper {debug}'

        logger.debug('Running `%s`', cmd)
        exit_code = os.system(cmd)

        if exit_code != 0:
            logger.error('Failed to pkexec the helper, code %d', exit_code)
            sys.exit()
Пример #29
0
def delete_preset(device, preset):
    """Delete one of the users presets."""
    preset_path = get_preset_path(device, preset)
    if not os.path.exists(preset_path):
        logger.debug('Cannot remove non existing path "%s"', preset_path)
        return

    logger.info('Removing "%s"', preset_path)
    os.remove(preset_path)

    device_path = get_preset_path(device)
    if os.path.exists(device_path) and len(os.listdir(device_path)) == 0:
        logger.debug('Removing empty dir "%s"', device_path)
        os.rmdir(device_path)
Пример #30
0
 async def _msg_listener(self, loop):
     """Wait for messages from the main process to do special stuff."""
     while True:
         frame_available = asyncio.Event()
         loop.add_reader(self._msg_pipe[0].fileno(), frame_available.set)
         await frame_available.wait()
         frame_available.clear()
         msg = self._msg_pipe[0].recv()
         if msg == CLOSE:
             logger.debug('Received close signal')
             # stop the event loop and cause the process to reach its end
             # cleanly. Using .terminate prevents coverage from working.
             loop.stop()
             return