Improve application support

master
Raheman Vaiya 4 years ago
parent 6c5a75f3be
commit 0430dc502b
  1. 6
      CHANGELOG.md
  2. 68
      README.md
  3. BIN
      keyd.1.gz
  4. 49
      man.md
  5. 296
      scripts/keyd-application-mapper

@ -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).

@ -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 <user>
- 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]

Binary file not shown.

@ -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 `<layer>` 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:
<expression 1>
<expression 2...>
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

@ -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()

Loading…
Cancel
Save