diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ef7db5..4a8c70e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# v2.2.1-beta + + - Moved application bindings into ~/.config/keyd/app.conf. + - Added -d to keyd-application-mapper. + - Fixed broken gnome support. + # v2.2.0-beta - Added a new IPC mechanism for dynamically altering the keymap (-e). diff --git a/README.md b/README.md index 2296c4e..878575e 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Some of the more interesting ones include: - Keyboard specific configuration. - Instantaneous remapping (no more flashing :)). - A client-server model that facilitates scripting and display server agnostic application remapping. (Currently ships with support for X, sway, and gnome). -- System wide config (work in a VT) +- System wide config (works in a VT) - First class support for modifier overloading. ### keyd is for people who: @@ -75,6 +75,33 @@ Some of the more interesting ones include: make && sudo make install sudo systemctl enable keyd && sudo systemctl start keyd +# Quickstart + +1. Install keyd + +2. Put the following in `/etc/keyd/default.conf`: + +``` +[ids] + +* + +[main] + +# Maps capslock to escape when pressed and control when held. +capslock = overload(control, esc) + +# Remaps the escape key to capslock +esc = capslock +``` + +3. Run `sudo systemctl restart keyd` to reload the config file. + +4. See the [man page](man.md) for a comprehensive list of config options. + +*Note*: It is possible to render your machine unusable with a bad config file. +Should you find yourself in this position, the special key sequence +`backspace+backslash+enter` should cause keyd to terminate. ## Application Specific Remapping (experimental) @@ -82,25 +109,28 @@ Some of the more interesting ones include: usermod -aG keyd -- Populate `~/.keyd-mappings`: +- Populate `~/.config/keyd/app.conf`: E.G - [Alacritty] + [alacritty] alt.] = macro(C-g n) alt.[ = macro(C-g p) - [Chromium] + [chromium] alt.[ = C-S-tab alt.] = macro(C-tab) - Run: + keyd-application-mapper +You will probably want to put `keyd-application-mapper -d` in your +initialization. -Class names are discoverable with `keyd-application-mapper -m`. +Window class names are discoverable with `keyd-application-mapper -m`. See the man page for more details. ## SBC support @@ -120,34 +150,6 @@ members, no personal responsibility is taken for them. [AUR](https://aur.archlinux.org/packages/keyd-git/) package maintained by eNV25. -# Quickstart - -1. Install keyd - -2. Put the following in `/etc/keyd/default.conf`: - -``` -[ids] - -* - -[main] - -# Maps capslock to escape when pressed and control when held. -capslock = overload(control, esc) - -# Remaps the escape key to capslock -esc = capslock -``` - -3. Run `sudo systemctl restart keyd` to reload the config file. - -4. See the [man page](man.md) for a comprehensive list of config options. - -*Note*: It is possible to render your machine unusable with a bad config file. -Should you find yourself in this position, the special key sequence -`backspace+backslash+enter` should cause keyd to terminate. - # Sample Config [ids] diff --git a/keyd.1.gz b/keyd.1.gz index 1d5167f..6ae9186 100644 Binary files a/keyd.1.gz and b/keyd.1.gz differ diff --git a/man.md b/man.md index 234ada5..d71a862 100644 --- a/man.md +++ b/man.md @@ -6,7 +6,7 @@ # SYNOPSIS -**keyd** [options] +**keyd** \[options\] # OPTIONS @@ -243,9 +243,8 @@ unless they are doing something unorthodox (e.g nesting hybrid layers). ## IPC -To facilitate extensibility, keyd employs a client-server model. Thus the -keymap can be conceived of as a 'living entity' that can be modified at -runtime. +To facilitate extensibility keyd employs a client-server model. The keymap can +thus be conceived of as a 'living entity' that can be modified at run time. In addition to allowing the user to try new bindings on the fly, this enables the user to fully leverage keyd's expressive power from other programs @@ -268,7 +267,7 @@ The `-e` flag accepts one or more *expressions*, each of which must have one of Where `` is the name of an (existing) layer in which the key is to be bound. -As a special case an expression may be the string 'reset' in which case the +As a special case, an expression may be the string 'reset', in which case the current keymap will revert to its original state (all dynamically applied bindings will be dropped). @@ -282,9 +281,9 @@ By default expressions apply to the most recently active keyboard. ### Application Support -keyd ships with a python script called `keyd-application-mapper` which -reads a file called *~/.keyd-mappings* and applies the supplied mappings -whenever a window of the relevant class comes into focus. +keyd ships with a python script called `keyd-application-mapper` that +reads a file called *~/.config/keyd/app.conf* and applies the supplied bindings +whenever a window with a matching class comes into focus. The file has the following form: @@ -293,37 +292,43 @@ The file has the following form: -Where each expression is a valid argument to `-e`. +Where each expression is a valid argument to `-e` (see *Expressions*). For example: - [Alacritty] + [kitty] - control.1 = macro(Inside space alacritty) + control.1 = macro(Inside space kitty!) + + [alacritty] + + control.1 = macro(Inside space alacritty!) [chromium] - control.1 = macro(Inside space chrome) + control.1 = macro(Inside space chrome!) Will remap `C-1` to the the string 'Inside alacritty' when a window with class -'Alacritty' is active and 'Inside chrome' when a window with class 'chromium' -is active. +`alacritty` is active. -Application classes can be obtained by running `keyd-application-mapper -m`. -At the moment X, sway and gnome are supported. +In order for this to work keyd must be running and the user must have access to +*/var/run/keyd.socket* (i.e be a member of the *keyd* group). Application +classes can be obtained with `keyd-application-mapper -m`. -In order for this script to work the user must have access to */var/run/keyd.socket* -(i.e be a member of the *keyd* group). +You will probably want to put `keyd-application-mapper -d` somewhere in the +initialization path of your display server (e.g `~/.xinitrc`). + +At the moment X, sway and gnome are supported. ### A note on security Any user which can interact with a program that directly controls input devices should be assumed to have full access to the system. -While keyd is slightly better at providing some degree of isolation than other -remappers (by dint of mediating access through an IPC mechanism rather than -granting users blanket access to /dev/input/* and /dev/uinput), it still -provides the opportunity for abuse and should be treated with due deference. +While keyd offers slightly better isolation compared to other remappers by dint +of mediating access through an IPC mechanism (rather than granting users +blanket access to /dev/input/* and /dev/uinput), it still provides an +opportunity for abuse and should be treated with due deference. Specifically, access to */var/run/keyd.socket* should only be granted to trusted users and the group `keyd` should be regarded with the same reverence diff --git a/scripts/keyd-application-mapper b/scripts/keyd-application-mapper index 47c1784..a5e5a8a 100755 --- a/scripts/keyd-application-mapper +++ b/scripts/keyd-application-mapper @@ -3,17 +3,40 @@ import subprocess import argparse import os +import re +import sys +import fcntl # Good enough for now :/. -# TODO(ish): +# TODO(ish): # -# Make assorted detection hacks cleaner. +# Make assorted detection hacks cleaner. # Profile and optimize. # Consider reimplmenting in perl or C. # Produce more useful error messages :P. -CONFIG_PATH = os.getenv('HOME') + '/.keyd-mappings' +CONFIG_PATH = os.getenv('HOME')+'/.config/keyd/app.conf' +LOCKFILE = os.getenv('HOME')+'/.config/keyd/lockfile' +LOGFILE = os.getenv('HOME')+'/.config/keyd/log' + +def die(msg): + sys.stderr.write('ERROR: ') + sys.stderr.write(msg) + sys.stderr.write('\n') + exit(0) + +def assert_env(var): + if not os.getenv(var): + raise Exception(f'Missing environment variable {var}') + +def run_or_die(cmd, msg=''): + rc = subprocess.run(['/bin/sh', '-c', cmd], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL).returncode + + if rc != 0: + die(msg) def parse_config(path): map = {} @@ -35,15 +58,14 @@ def parse_config(path): return map - -class SwayWindowChangeDetector(): +class SwayMonitor(): def __init__(self, on_window_change): - import os + assert_env('SWAYSOCK') self.on_window_change = on_window_change - if not os.getenv('SWAYSOCK'): - raise Exception('SWAYSOCK not found, is sway running?') + def init(self): + pass def run(self): import json @@ -51,11 +73,11 @@ class SwayWindowChangeDetector(): swayproc = subprocess.Popen( ['swaymsg', - '--type', - 'subscribe', - '--monitor', - '--raw', - '["window"]'], stdout=subprocess.PIPE) + '--type', + 'subscribe', + '--monitor', + '--raw', + '["window"]'], stdout=subprocess.PIPE) for ev in swayproc.stdout: data = json.loads(ev) @@ -63,141 +85,201 @@ class SwayWindowChangeDetector(): try: if data['container']['focused'] == True: cls = data['container']['window_properties']['class'] - self.on_window_change([cls]) + self.on_window_change(cls) except: - self.on_window_change([data['container']['app_id']]) + cls = data['container']['app_id'] + self.on_window_change(cls) pass -class XWindowChangeDetector(): +class XMonitor(): def __init__(self, on_window_change): - import os + assert_env('DISPLAY') self.on_window_change = on_window_change - if not os.getenv('DISPLAY'): - raise Exception('DISPLAY not set, is X running?') - - # TODO: make this less kludgy - def run(self): - import time + def init(self): import Xlib import Xlib.display - dpy = Xlib.display.Display() - - class_cache = {} - - def get_class(win): - hsh = str(win) - if hsh not in class_cache: - try: - cls = win.get_wm_class() - if not cls: - return [] - class_cache[hsh] = cls - except: - return [] - - return class_cache[hsh] + self.dpy = Xlib.display.Display() + self.dpy.screen().root.change_attributes( + event_mask = Xlib.X.SubstructureNotifyMask|Xlib.X.PropertyChangeMask) - last = [] + def run(self): + last_active_class = "" while True: - win = dpy.get_input_focus().focus - - classes = get_class(win) - - if classes != last: - self.on_window_change(classes) - - last = classes - time.sleep(0.1) - + self.dpy.next_event() -class GnomeWindowChangeDetector(): - def __init__(self, on_window_change): - import dbus - import dbus.mainloop.glib + try: + cls = self.dpy.get_input_focus().focus.get_wm_class()[1] + if cls != last_active_class: + last_active_class = cls + self.on_window_change(cls) + except: + import traceback + traceback.print_exc() + pass - dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) - self.con = dbus.SessionBus() +# :( +class GnomeMonitor(): + def __init__(self, on_window_change): + assert_env('GNOME_SETUP_DISPLAY') - self.introspect = self.get_dbus_object("org.gnome.Shell.Introspect", - "/org/gnome/Shell/Introspect") + self.on_window_change = on_window_change - self.shell = self.get_dbus_object( - "org.gnome.Shell", "/org/gnome/Shell") + self.extension_dir = os.getenv('HOME') + '/.local/share/gnome-shell/extensions/keyd' + self.fifo_path = self.extension_dir + '/keyd.fifo' + + def _install(self): + os.makedirs(self.extension_dir, exist_ok=True) + + extension = ''' + const Shell = imports.gi.Shell; + + // We have to keep an explicit reference around to prevent garbage collection :/. + let file = imports.gi.Gio.File.new_for_path('%s'); + let pipe = file.append_to_async(0, 0, null, on_pipe_open); + + function send(msg) { + if (!pipe) + return; + + try { + pipe.write(msg, null); + } catch { + log('pipe closed, reopening...'); + pipe = null; + file.append_to_async(0, 0, null, on_pipe_open); + } + } + + function on_pipe_open(file, res) { + log('pipe opened'); + pipe = file.append_to_finish(res); + } + + function init() { + Shell.WindowTracker.get_default().connect('notify::focus-app', () => { + send(`${global.display.focus_window.get_wm_class()}\n`); + }); + + return { enable: ()=>{}, disable: ()=>{} }; + } + ''' % (self.fifo_path) + + metadata = ''' + { + "name": "keyd", + "description": "Used by keyd to obtain active window information.", + "uuid": "keyd", + "shell-version": [ "41" ] + } + ''' + + open(self.extension_dir + '/metadata.json', 'w').write(metadata) + open(self.extension_dir + '/extension.js', 'w').write(extension) + os.mkfifo(self.fifo_path) + + def init(self): + if not os.path.exists(self.extension_dir): + print('keyd extension not found, installing...') + self._install() + print('Success! Please restart Gnome and rerun this script.') + exit(0) + + run_or_die('gsettings set org.gnome.shell disable-user-extensions false'); + run_or_die('gnome-extensions enable keyd', 'Failed to enable keyd extension.') - self.on_window_change = on_window_change - self.introspect.connect_to_signal( - "RunningApplicationsChanged", lambda: self._on_window_change()) + def run(self): + for cls in open(self.fifo_path): + cls = cls.strip() + self.on_window_change(cls) + +def get_monitor(on_window_change): + monitors = [ + ('Sway', SwayMonitor), + ('Gnome', GnomeMonitor), + ('X', XMonitor), + ] - def get_dbus_object(self, interface, path): - import dbus - return dbus.Interface(self.con.get_object(interface, path), interface) + for name, mon in monitors: + try: + m = mon(on_window_change) + print(f'{name} detected') + return m + except: + pass - def get_window_class(self): - return self.shell.Eval('global.display.focus_window.get_wm_class()')[1].strip('"') + print('Could not detect app environment :(.') + sys.exit(-1) - def _on_window_change(self): - self.on_window_change(self.get_window_class()) +def lock(): + global lockfh + lockfh = open(LOCKFILE, 'w') + try: + fcntl.flock(lockfh, fcntl.LOCK_EX | fcntl.LOCK_NB) + except: + die('only one instance may run at a time') - def run(self): - import dbus - from gi.repository import GLib +def ping_keyd(): + run_or_die('keyd -e ping', +'could not connect to keyd instance, make sure it is running and you are a member of `keyd`') - loop = GLib.MainLoop() - loop.run() +def daemonize(): + print('Daemonizing...') + fh = open(LOGFILE, 'w') -def get_detector(on_window_change): - detectors = [ - ('Sway', SwayWindowChangeDetector), - ('Gnome', GnomeWindowChangeDetector), - ('X', XWindowChangeDetector), - ] + os.close(1) + os.close(2) + os.dup2(fh.fileno(), 1) + os.dup2(fh.fileno(), 2) - for name,detector in detectors: - try: - d = detector(on_window_change) - print(f'{name} detected') - return d - except: - pass + if os.fork(): exit(0) + if os.fork(): exit(0) - print('Could not detect app environment :(.') - exit(-1) +opt = argparse.ArgumentParser() +opt.add_argument('-m', '--monitor', default=False, action='store_true', help='print window class names in real time') +opt.add_argument('-d', '--daemonize', default=False, action='store_true', help='fork and run in the background') +args = opt.parse_args() +if not os.path.exists(CONFIG_PATH): + die('could not find app.conf, make sure it is in ~/.config/keyd/app.conf') -parser = argparse.ArgumentParser() -parser.add_argument('-m', '--monitor', default=False, action='store_true') -args = parser.parse_args() +bindings = parse_config(CONFIG_PATH) +ping_keyd() +lock() -if subprocess.run(['keyd', '-e', 'ping'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0: - print('Could not connect to keyd instance, make sure it is running and you are a member of `keyd`') - exit(-1) +def normalize_class(s): + return re.sub('[^A-Za-z0-9]', '-', s).strip('-').lower() -app_bindings = parse_config(CONFIG_PATH) last_mtime = os.path.getmtime(CONFIG_PATH) - -def on_window_change(classes): +def on_window_change(cls): global last_mtime - global app_bindings + global bindings + cls = normalize_class(cls) mtime = os.path.getmtime(CONFIG_PATH) if mtime != last_mtime: print(CONFIG_PATH + ': Updated, reloading config...') - app_bindings = parse_config(CONFIG_PATH) + bindings = parse_config(CONFIG_PATH) last_mtime = mtime if args.monitor: - print(', '.join(classes)) + print(cls) return - for cls in classes: - if cls in app_bindings: - # Apply the bindings. - subprocess.run(['keyd', '-e', *app_bindings[cls]]) + if cls in bindings: + # Apply the bindings. + subprocess.run(['keyd', '-e', *bindings[cls]]) + + +mon = get_monitor(on_window_change) +mon.init() + +if args.daemonize: + daemonize() -get_detector(on_window_change).run() +mon.run()