def autoload_single(self, device): """Inject the configured autoload preset for the device. If the preset is already being injected, it won't autoload it again. Parameters ---------- device : str The name of the device as indexed in get_devices() """ if device.startswith('/dev/input/'): # this is only here to avoid confusing console output, # block invalid requests before any logs are written. # Those requests are rejected later anyway. try: name = evdev.InputDevice(device).name if 'key-mapper' in name: return except OSError: return logger.info('Request to autoload for "%s"', device) if self.config_dir is None: logger.error( 'Tried to autoload %s without configuring the daemon first ' 'via set_config_dir.', device) return self._autoload(device)
def save(self, path): """Dump as JSON into home.""" logger.info('Saving preset to %s', path) touch(path) with open(path, 'w') as file: if self._config.get('mapping') is not None: logger.error( '"mapping" is reserved and cannot be used as config key') preset_dict = self._config # make sure to keep the option to add metadata if ever needed, # so put the mapping into a special key json_ready_mapping = {} # tuple keys are not possible in json, encode them as string for key, value in self._mapping.items(): new_key = '+'.join([ ','.join([str(value) for value in sub_key]) for sub_key in key ]) json_ready_mapping[new_key] = value preset_dict['mapping'] = json_ready_mapping json.dump(preset_dict, file, indent=4) file.write('\n') self.changed = False
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)
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))
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)
def show_device_mapping_status(self): """Figure out if this device is currently under keymappers control.""" device = self.selected_device if self.dbus.is_injecting(device): logger.info('Device "%s" is currently mapped', device) self.get('apply_system_layout').set_opacity(1) else: self.get('apply_system_layout').set_opacity(0.4)
def stop_injecting(self): """Stop injecting keycodes. Can be safely called from the main procss. """ logger.info('Stopping injecting keycodes for device "%s"', self.device) self._msg_pipe[1].send(CLOSE) self._state = STOPPED
def stop(self): """Stop all injections and end the service. Raises dbus.exceptions.DBusException in your main process. """ logger.info('Stopping all injections') for injector in self.injectors.values(): injector.stop_injecting()
def show_device_mapping_status(self): """Figure out if this device is currently under keymappers control.""" group_key = self.group.key state = self.dbus.get_state(group_key) if state == RUNNING: logger.info('Group "%s" is currently mapped', group_key) self.get('apply_system_layout').set_opacity(1) else: self.get('apply_system_layout').set_opacity(0.4)
def save_config(self): """Save the config to the file system.""" 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) shutil.chown(self.path, USER, USER) file.write('\n')
def can_modify_mapping(self, *_): """Show a message if changing the mapping is not possible.""" if not self.dbus.is_injecting(self.selected_device): return # because the device is in grab mode by the daemon and # therefore the original keycode inaccessible logger.info('Cannot change keycodes while injecting') self.show_status(CTX_ERROR, 'Use "Restore Defaults" before editing')
def __init__(self): self.path = os.path.join(CONFIG_PATH, 'config.json') # migrate from < 0.4.0, add the .json ending deprecated_path = os.path.join(CONFIG_PATH, 'config') if os.path.exists(deprecated_path) and not os.path.exists(self.path): logger.info('Moving "%s" to "%s"', deprecated_path, self.path) os.rename(os.path.join(CONFIG_PATH, 'config'), self.path) super().__init__()
def save_preset(self): """Write changes to presets to disk.""" logger.info('Updating configs for "%s", "%s"', self.selected_device, self.selected_preset) path = get_preset_path(self.selected_device, self.selected_preset) custom_mapping.save(path) custom_mapping.changed = False self.unhighlight_all_rows()
def touch(path, log=True): """Create an empty file and all its parent dirs, give it to the user.""" if os.path.exists(path): return if log: logger.info('Creating file "%s"', path) mkdir(os.path.dirname(path), log=False) os.mknod(path) shutil.chown(path, USER, USER)
def on_apply_preset_clicked(self, _): """Apply a preset without saving changes.""" self.save_preset() if custom_mapping.num_saved_keys == 0: logger.error('Cannot apply empty preset file') # also helpful for first time use if custom_mapping.changed: self.show_status( CTX_ERROR, 'You need to save your changes first', 'No mappings are stored in the preset .json file yet') else: self.show_status(CTX_ERROR, 'You need to add keys and save first') return preset = self.selected_preset device = self.selected_device logger.info('Applying preset "%s" for "%s"', preset, device) if not self.button_left_warn: if custom_mapping.dangerously_mapped_btn_left(): self.show_status( CTX_ERROR, 'This would disable your click button', 'Map a button to BTN_LEFT to avoid this.\n' 'To overwrite this warning, press apply again.') self.button_left_warn = True return if not self.unreleased_warn: unreleased = reader.get_unreleased_keys() if unreleased is not None and unreleased != Key.btn_left(): # it's super annoying if that happens and may break the user # input in such a way to prevent disabling the mapping logger.error( 'Tried to apply a preset while keys were held down: %s', unreleased) self.show_status( CTX_ERROR, 'Please release your pressed keys first', 'X11 will think they are held down forever otherwise.\n' 'To overwrite this warning, press apply again.') self.unreleased_warn = True return self.unreleased_warn = False self.button_left_warn = False self.dbus.set_config_dir(get_config_path()) self.dbus.start_injecting(device, preset) self.show_status(CTX_APPLY, 'Starting injection...') GLib.timeout_add(100, self.show_injection_result)
def set_new_key(self, new_key): """Check if a keycode has been pressed and if so, display it. Parameters ---------- new_key : Key """ if new_key is not None and not isinstance(new_key, Key): raise TypeError('Expected new_key to be a Key object') # the newest_keycode is populated since the ui regularly polls it # in order to display it in the status bar. previous_key = self.get_key() # no input if new_key is None: return # it might end up being a key combination self.state = HOLDING # keycode didn't change, do nothing if new_key == previous_key: return # keycode is already set by some other row existing = custom_mapping.get_character(new_key) if existing is not None: msg = f'"{to_string(new_key)}" already mapped to "{existing}"' logger.info(msg) self.window.show_status(CTX_KEYCODE, msg) return # it's legal to display the keycode # always ask for get_child to set the label, otherwise line breaking # has to be configured again. self.set_keycode_input_label(to_string(new_key)) self.key = new_key self.highlight() character = self.get_character() # the character is empty and therefore the mapping is not complete if character is None: return # else, the keycode has changed, the character is set, all good custom_mapping.change(new_key=new_key, character=character, previous_key=previous_key)
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')
def rename_preset(device, old_preset_name, new_preset_name): """Rename one of the users presets while avoiding name conflicts.""" if new_preset_name == old_preset_name: return None new_preset_name = get_available_preset_name(device, new_preset_name) logger.info('Moving "%s" to "%s"', old_preset_name, new_preset_name) os.rename(get_preset_path(device, old_preset_name), get_preset_path(device, new_preset_name)) # set the modification date to now now = time.time() os.utime(get_preset_path(device, new_preset_name), (now, now)) return new_preset_name
def load(self, path): """Load a dumped JSON from home to overwrite the mappings. Parameters path : string Path of the preset file """ logger.info('Loading preset from "%s"', path) if not os.path.exists(path): raise FileNotFoundError( f'Tried to load non-existing preset "{path}"' ) self.clear_config() with open(path, 'r') as file: preset_dict = json.load(file) if not isinstance(preset_dict.get('mapping'), dict): logger.error( 'Expected mapping to be a dict, but was %s. ' 'Invalid preset config at "%s"', preset_dict.get('mapping'), path ) return for key, symbol in preset_dict['mapping'].items(): try: key = Key(*[ split_key(chunk) for chunk in key.split('+') if chunk.strip() != '' ]) except ValueError as error: logger.error(str(error)) continue if None in key: continue logger.spam('%s maps to %s', key, symbol) self._mapping[key] = symbol # add any metadata of the mapping for key in preset_dict: if key == 'mapping': continue self._config[key] = preset_dict[key] self.changed = False self.num_saved_keys = len(self)
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)
def mkdir(path, log=True): """Create a folder, give it to the user.""" if os.path.exists(path): return if log: logger.info('Creating dir "%s"', path) # give all newly created folders to the user. # e.g. if .config/key-mapper/mouse/ is created the latter two base = os.path.split(path)[0] mkdir(base, log=False) os.makedirs(path) shutil.chown(path, USER, USER)
def touch(path, log=True): """Create an empty file and all its parent dirs, give it to the user.""" if path.endswith('/'): raise ValueError(f'Expected path to not end with a slash: {path}') if os.path.exists(path): return if log: logger.info('Creating file "%s"', path) mkdir(os.path.dirname(path), log=False) os.mknod(path) chown(path)
def connect(cls, 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 """ try: bus = SystemBus() interface = bus.get(BUS_NAME) logger.info('Connected to the service') except GLib.GError as error: if not fallback: logger.error('Service not running? %s', error) return None logger.info('Starting the service') # Blocks until pkexec is done asking for the password. # Runs via key-mapper-control so that auth_admin_keep works # for all pkexec calls of the gui debug = ' -d' if is_debug() else '' cmd = f'pkexec key-mapper-control --command start-daemon {debug}' # using pkexec will also cause the service to continue running in # the background after the gui has been closed, which will keep # the injections ongoing logger.debug('Running `%s`', cmd) os.system(cmd) time.sleep(0.2) # try a few times if the service was just started for attempt in range(3): try: interface = bus.get(BUS_NAME) break except GLib.GError as error: logger.debug( 'Attempt %d to connect to the service failed: "%s"', attempt + 1, error) time.sleep(0.2) else: logger.error('Failed to connect to the service') sys.exit(1) return interface
def set_autoload_preset(self, device, preset): """Set a preset to be automatically applied on start. Parameters ---------- device : string preset : string or None if None, don't autoload something for this device. """ if preset is not None: self.set(f'autoload.{device}', preset) else: logger.info( 'Not loading injecting for "%s" automatically anmore', device ) self.remove(f'autoload.{device}')
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']).decode() xmodmap = xmodmap.lower() mappings = re.findall(r'(\d+) = (.+)\n', xmodmap + '\n') for keycode, names in mappings: # there might be multiple, like: # keycode 64 = Alt_L Meta_L Alt_L Meta_L # keycode 204 = NoSymbol Alt_L NoSymbol Alt_L # Alt_L should map to code 64. Writing code 204 only works # if a modifier is applied at the same time. So take the first # one. name = names.split()[0] xmodmap_dict[name] = int(keycode) - XKB_KEYCODE_OFFSET for keycode, names in mappings: # but since KP may be mapped like KP_Home KP_7 KP_Home KP_7, # make another pass and add all of them if they don't already # exist. don't overwrite any keycodes. for name in names.split(): if xmodmap_dict.get(name) is None: xmodmap_dict[name] = int(keycode) - XKB_KEYCODE_OFFSET 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.info('Writing "%s"', path) json.dump(xmodmap_dict, file, indent=4) self._mapping.update(xmodmap_dict) 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)
def set(self, path, value): """Set a config key. Parameters ---------- path : string or string[] For example 'macros.keystroke_sleep_ms' or ['macros', 'keystroke_sleep_ms'] value : any """ logger.info('Changing "%s" to "%s" in %s', path, value, self.__class__.__name__) def callback(parent, child, chunk): parent[chunk] = value self._resolve(path, callback)
def _find_groups(self): """Look for devices and group them together. Since this needs to do some stuff with /dev and spawn processes the result is cached. Use refresh_groups if you need up to date devices. """ pipe = multiprocessing.Pipe() _FindGroups(pipe[1]).start() # block until groups are available self.loads(pipe[0].recv()) if len(self._groups) == 0: logger.debug('Did not find any input device') else: keys = [f'"{group.key}"' for group in self._groups] logger.info('Found %s', ', '.join(keys))
def set_autoload_preset(self, group_key, preset): """Set a preset to be automatically applied on start. Parameters ---------- group_key : string the unique identifier of the group. This is used instead of the name to enable autoloading two different presets when two similar devices are connected. preset : string or None if None, don't autoload something for this device. """ if preset is not None: self.set(['autoload', group_key], preset) else: logger.info('Not injecting for "%s" automatically anmore', group_key) self.remove(['autoload', group_key])
def parse(macro, mapping, return_errors=False): """parse and generate a _Macro that can be run as often as you want. If it could not be parsed, possibly due to syntax errors, will log the error and return None. Parameters ---------- macro : string "r(3, k(a).w(10))" "r(2, k(a).k(-)).k(b)" "w(1000).m(Shift_L, r(2, k(a))).w(10, 20).k(b)" mapping : Mapping The preset object, needed for some config stuff return_errors : bool If True, returns errors as a string or None if parsing worked. If False, returns the parsed macro. """ macro = handle_plus_syntax(macro) # whitespaces, tabs, newlines and such don't serve a purpose. make # the log output clearer and the parsing easier. macro = re.sub(r'\s', '', macro) if '"' in macro or "'" in macro: logger.info('Quotation marks in macros are not needed') macro = macro.replace('"', '').replace("'", '') if return_errors: logger.spam('checking the syntax of %s', macro) else: logger.spam('preparing macro %s for later execution', macro) try: macro_object = _parse_recurse(macro, mapping) return macro_object if not return_errors else None except Exception as error: logger.error('Failed to parse macro "%s": %s', macro, error.__repr__()) # print the traceback in case this is a bug of key-mapper logger.debug(''.join(traceback.format_tb(error.__traceback__)).strip()) return str(error) if return_errors else None
def autoload(self): """Load all autoloaded presets for the current config_dir. If the preset is already being injected, it won't autoload it again. """ if self.config_dir is None: logger.error( 'Tried to autoload without configuring the daemon first ' 'via set_config_dir.') return autoload_presets = list(config.iterate_autoload_presets()) logger.info('Autoloading for all devices') if len(autoload_presets) == 0: logger.error('No presets configured to autoload') return for group_key, _ in autoload_presets: self._autoload(group_key)