access via groups instead of root services with interfaces that are open to everyone

This commit is contained in:
sezanzeb 2020-11-25 23:36:03 +01:00
parent ba929fce23
commit ce32423c7c
25 changed files with 127 additions and 134 deletions

View File

@ -15,6 +15,7 @@ git clone https://github.com/sezanzeb/key-mapper.git
cd key-mapper
sudo python3 setup.py install
usermod -a -G input $USER
usermod -a -G plugdev $USER
```
To keep injecting the mapping after closing the window, the daemon needs to
@ -61,7 +62,6 @@ sudo python3 setup.py install && python3 tests/test.py
- [x] highlight changes and alert before discarding unsaved changes
- [ ] automatically load presets on login for plugged in devices
- [ ] automatically load presets when devices get plugged in after login
- [x] ask for administrator permissions using polkit
- [x] make sure it works on wayland
- [ ] add to the AUR, provide .deb and .appimage files
- [ ] support timed macros, maybe using some sort of syntax

View File

@ -33,9 +33,9 @@ gi.require_version('GLib', '2.0')
from gi.repository import Gtk
from keymapper.logger import update_verbosity, log_info
from keymapper.gtk.error import ErrorDialog
from keymapper.gtk.window import Window
from keymapper.daemon import Daemon
from keymapper.dev.permissions import can_read_devices
if __name__ == '__main__':
@ -50,12 +50,7 @@ if __name__ == '__main__':
update_verbosity(options.debug)
log_info()
"""if getpass.getuser() != 'root' and 'unittest' not in sys.modules.keys():
ErrorDialog(
'Error',
'Sudo is required to talk to the daemon and discover devices'
)
raise SystemExit(1)"""
can_read_devices()
window = Window()

View File

@ -24,7 +24,6 @@
import sys
import atexit
import getpass
from argparse import ArgumentParser
import gi
@ -36,6 +35,7 @@ from dbus.mainloop.glib import DBusGMainLoop
from keymapper.logger import logger, update_verbosity, log_info, \
add_filehandler
from keymapper.daemon import Daemon
from keymapper.dev.permissions import can_read_devices
if __name__ == '__main__':
@ -51,8 +51,7 @@ if __name__ == '__main__':
add_filehandler()
log_info()
if getpass.getuser() != 'root' and 'unittest' not in sys.modules.keys():
logger.warning('Without sudo, your devices may not be visible')
can_read_devices()
session_bus = dbus.SessionBus(mainloop=DBusGMainLoop())
name = dbus.service.BusName('keymapper.Control', session_bus)

View File

@ -4,5 +4,5 @@ Name=Key Mapper Service
Icon=mouse
Exec=key-mapper-service
Terminal=false
Categories=Settings;
Categories=Settings
Comment=The Key Mapper service to keep mappings alive after closing the UI

View File

@ -2,7 +2,7 @@
Type=Application
Name=key-mapper
Icon=mouse
Exec=pkexec key-mapper-gtk
Exec=key-mapper-gtk
Terminal=false
Categories=Settings
Comment=GUI for device specific keyboard mappings

View File

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd"
>
<policyconfig>
<icon_name>mouse</icon_name>
<action id="keymapper">
<description>Run Key Mapper as root</description>
<message>Authentication is required to discover devices.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>auth_admin</allow_inactive>
<allow_active>auth_admin</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">{executable}</annotate>
<annotate key="org.freedesktop.policykit.exec.allow_gui">true</annotate>
</action>
</policyconfig>

View File

@ -1,12 +0,0 @@
# https://wiki.archlinux.org/index.php/Systemd
[Unit]
Description=Service to inject keycodes without the GUI application
[Service]
Type=dbus
BusName=keymapper.Control
ExecStart=/usr/bin/key-mapper-service -d
[Install]
WantedBy=default.target

View File

@ -28,17 +28,9 @@ from dbus import service
import dbus.mainloop.glib
from keymapper.logger import logger
from keymapper.injector import KeycodeInjector
from keymapper.dev.injector import KeycodeInjector
from keymapper.mapping import Mapping
# TODO service file in data for a root daemon
# - start service as root with .service https://wiki.archlinux.org/index.php/Systemd#Writing_unit_files # noqa
# - starting service (root), running key-mapper-gtk (non-root), will the dbus
# be found? (Error says dbus not provided by .service files)
# TODO the daemon creates an initial config in my home dir. it shouldn't.
from keymapper.config import config
def is_service_running():
@ -57,7 +49,7 @@ def get_dbus_interface():
'The daemon "key-mapper-service" is not running, mapping keys '
'only works as long as the window is open.'
)
return Daemon()
return Daemon(autoload=False)
try:
logger.debug('Found the daemon process')
@ -68,8 +60,7 @@ def get_dbus_interface():
except Exception as error:
logger.error(
'Could not connect to the dbus of "key-mapper-service", mapping '
'keys only works as long as the window is open. Is one of your '
'key-mapper processes not running as root?'
'keys only works as long as the window is open.'
)
logger.error(error)
return Daemon()
@ -87,9 +78,14 @@ class Daemon(service.Object):
continue to do so afterwards, but it can't decide to start injecting
on its own.
"""
def __init__(self, *args, **kwargs):
def __init__(self, *args, autoload=True, **kwargs):
"""Constructs the daemon. You still need to run the GLib mainloop."""
self.injectors = {}
if autoload:
for device, preset in config.iterate_autoload_presets():
mapping = Mapping()
mapping.load(device, preset)
self.injectors[device] = KeycodeInjector(device, mapping)
super().__init__(*args, **kwargs)
@dbus.service.method(
@ -113,8 +109,8 @@ class Daemon(service.Object):
'keymapper.Interface',
in_signature='ss'
)
def start_injecting(self, device, path):
"""Start injecting the preset on the path for the device.
def start_injecting(self, device, preset):
"""Start injecting the preset for the device.
Returns True on success.
@ -122,16 +118,14 @@ class Daemon(service.Object):
----------
device : string
The name of the device
path : string
Path to the json file that describes the preset
preset : string
The name of the preset
"""
# TODO the integration tests don't seem to test that path actually
# exists
if self.injectors.get(device) is not None:
self.injectors[device].stop_injecting()
mapping = Mapping()
mapping.load(path)
mapping.load(device, preset)
try:
self.injectors[device] = KeycodeInjector(device, mapping)
except OSError:

View File

View File

@ -0,0 +1,53 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# key-mapper - GUI for device specific keyboard mappings
# Copyright (C) 2020 sezanzeb <proxima@hip70890b.de>
#
# This file is part of key-mapper.
#
# key-mapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# key-mapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
"""To check if access to devices in /dev is possible."""
import os
import sys
import grp
import getpass
from keymapper.logger import logger
def can_read_devices():
"""If the people ever looks into the console, make sure to help them."""
is_root = getpass.getuser() == 'root'
is_test = 'unittest' in sys.modules.keys()
is_in_input_group = os.getlogin() in grp.getgrnam('input').gr_mem
is_in_plugdev_group = os.getlogin() in grp.getgrnam('plugdev').gr_mem
def warn(group):
logger.warning(
'Some devices may not be visible without being in the '
f'"{group}" user group. Try `sudo usermod -a -G {group} $USER` '
'and log out and back in.'
)
if not is_root and not is_test:
if not is_in_plugdev_group:
warn('plugdev')
if not is_in_input_group:
warn('input')
return is_root or is_test or is_in_input_group

View File

@ -133,7 +133,11 @@ def get_devices(include_keymapper=False):
# block until devices are available
_devices = pipe[0].recv()
if len(_devices) == 0:
logger.error('Did not find any device')
logger.error(
'Did not find any device. If you added yourself to the '
'needed groups (see `ls -l /dev/input`) already, make sure '
'you also logged out and back in.'
)
else:
names = [f'"{name}"' for name in _devices]
logger.info('Found %s', ', '.join(names))

View File

@ -35,7 +35,7 @@ from keymapper.logger import logger
from keymapper.getdevices import get_devices
from keymapper.gtk.row import Row
from keymapper.gtk.unsaved import unsaved_changes_dialog, GO_BACK
from keymapper.reader import keycode_reader
from keymapper.dev.reader import keycode_reader
from keymapper.daemon import get_dbus_interface
from keymapper.paths import get_config_path
@ -160,19 +160,18 @@ class Window:
def populate_presets(self):
"""Show the available presets for the selected device."""
self.get('preset_name_input').set_text('')
device = self.selected_device
presets = get_presets(device)
self.get('preset_name_input').set_text('')
if len(presets) == 0:
new_preset = get_available_preset_name(self.selected_device)
custom_mapping.save(self.selected_device, new_preset)
presets = [new_preset]
else:
logger.debug(
'Presets for "%s": "%s"',
device,
'", "'.join(presets)
)
logger.debug('"%s" presets: "%s"', device, '", "'.join(presets))
preset_selection = self.get('preset_selection')
preset_selection.handler_block_by_func(self.on_select_preset)
@ -276,7 +275,7 @@ class Window:
)
success = self.dbus.start_injecting(
self.selected_device,
get_config_path(self.selected_device, self.selected_preset)
self.selected_preset
)
if not success:
@ -344,8 +343,7 @@ class Window:
logger.debug('Selecting preset "%s"', preset)
self.selected_preset = preset
path = get_config_path(self.selected_device, self.selected_preset)
custom_mapping.load(path)
custom_mapping.load(self.selected_device, self.selected_preset)
key_list = self.get('key_list')
for keycode, output in custom_mapping:

View File

@ -129,10 +129,9 @@ class Mapping:
self.changed = True
@update_reverse_mapping
def load(self, path):
"""Load a dumped JSON from a path to overwrite the mappings."""
# since the daemon needs to load presets and runs as root, it
# needs the full path.
def load(self, device, preset):
"""Load a dumped JSON from home to overwrite the mappings."""
path = get_config_path(device, preset)
logger.info('Loading preset from "%s"', path)
if not os.path.exists(path):

View File

@ -35,7 +35,8 @@ def get_config_path(device=None, preset=None):
if preset is not None:
# the extension of the preset should not be shown in the ui.
# currently only .json files are used.
# if a .json extension arrives this place, it has not been
# stripped away properly prior to this.
assert not preset.endswith('.json')
preset = f'{preset}.json'

View File

@ -55,12 +55,11 @@ def get_presets(device):
device_folder = get_config_path(device)
if not os.path.exists(device_folder):
os.makedirs(device_folder)
paths = glob.glob(os.path.join(device_folder, '*.json'))
presets = [
os.path.splitext(os.path.basename(path))[0]
for path in sorted(
glob.glob(os.path.join(device_folder, '*')),
key=os.path.getmtime
)
for path in sorted(paths, key=os.path.getmtime)
]
# the highest timestamp to the front
presets.reverse()
@ -91,12 +90,12 @@ def find_newest_preset(device=None):
# sort the oldest files to the front in order to use pop to get the newest
if device is None:
paths = sorted(
glob.glob(os.path.join(get_config_path(), '*/*')),
glob.glob(os.path.join(get_config_path(), '*/*.json')),
key=os.path.getmtime
)
else:
paths = sorted(
glob.glob(os.path.join(get_config_path(device), '*')),
glob.glob(os.path.join(get_config_path(device), '*.json')),
key=os.path.getmtime
)

View File

@ -19,7 +19,7 @@
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
"""Create some files and objects that are needed for the app to work."""
"""Create some singleton objects that are needed for the app to work."""
import stat

View File

@ -19,39 +19,9 @@
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
import os
import DistUtilsExtra.auto
class Install(DistUtilsExtra.auto.install_auto):
def run(self):
DistUtilsExtra.auto.install_auto.run(self)
self.ensure_polkit_prefix()
def ensure_polkit_prefix(self):
"""Make sure the policy file uses the right prefix."""
executable = os.path.join(self.install_data, 'bin/key-mapper-gtk')
assert os.path.exists(executable)
policy_path = '/usr/share/polkit-1/actions/key-mapper.policy'
with open(policy_path, 'r') as file:
contents = file.read()
if '{executable}' not in contents:
# already done previously
return
with open(policy_path, 'w') as file:
print(
f'Inserting the correct path "{executable}" into '
'keymapper.policy'
)
file.write(contents.format(
executable=executable
))
DistUtilsExtra.auto.setup(
name='key-mapper',
version='0.1.0',
@ -59,11 +29,6 @@ DistUtilsExtra.auto.setup(
license='GPL-3.0',
data_files=[
('share/applications/', ['data/key-mapper.desktop']),
('/usr/share/polkit-1/actions/', ['data/key-mapper.policy']),
('/usr/lib/systemd/system', ['data/key-mapper.service']),
('/etc/xdg/autostart/', ['data/key-mapper-autoload']),
],
cmdclass={
'install': Install
}
('/etc/xdg/autostart/', ['data/key-mapper-service.desktop']),
]
)

View File

@ -224,6 +224,7 @@ if __name__ == "__main__":
# so provide both options.
if len(modules) > 0:
# for example `tests/test.py integration.Integration.test_can_start`
# or `tests/test.py integration daemon`
testsuite = unittest.defaultTestLoader.loadTestsFromNames(
[f'testcases.{module}' for module in modules]
)

View File

@ -20,7 +20,6 @@
import unittest
import time
import evdev
@ -32,10 +31,9 @@ from test import uinput_write_history_pipe, Event, pending_events
class TestDaemon(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.grab = evdev.InputDevice.grab
cls.daemon = None
def setUp(self):
self.grab = evdev.InputDevice.grab
self.daemon = None
def tearDown(self):
# avoid race conditions with other tests, daemon may run processes
@ -43,6 +41,7 @@ class TestDaemon(unittest.TestCase):
self.daemon.stop()
self.daemon = None
evdev.InputDevice.grab = self.grab
config.clear_config()
def test_daemon(self):
keycode_from = 9

View File

@ -23,7 +23,7 @@ import unittest
import evdev
from keymapper.injector import _start_injecting_worker, _grab, \
from keymapper.dev.injector import _start_injecting_worker, _grab, \
is_numlock_on, toggle_numlock, ensure_numlock, _modify_capabilities, \
KeycodeInjector
from keymapper.getdevices import get_devices

View File

@ -45,7 +45,7 @@ class TestMapping(unittest.TestCase):
self.mapping.save('device 1', 'test')
loaded = Mapping()
self.assertEqual(len(loaded), 0)
loaded.load(get_config_path('device 1', 'test'))
loaded.load('device 1', 'test')
self.assertEqual(len(loaded), 3)
self.assertEqual(loaded.get_character(10), '1')
self.assertEqual(loaded.get_character(11), '2')

View File

@ -25,7 +25,7 @@ import shutil
import time
from keymapper.presets import find_newest_preset, rename_preset, \
get_any_preset, delete_preset, get_available_preset_name
get_any_preset, delete_preset, get_available_preset_name, get_presets
from keymapper.paths import CONFIG
from keymapper.state import custom_mapping
@ -105,10 +105,27 @@ class TestFindPresets(unittest.TestCase):
if os.path.exists(tmp):
shutil.rmtree(tmp)
def test_get_presets(self):
os.makedirs(os.path.join(CONFIG, '1234'))
os.mknod(os.path.join(CONFIG, '1234', 'picture.png'))
self.assertEqual(len(get_presets('1234')), 0)
os.mknod(os.path.join(CONFIG, '1234', 'foo bar 1.json'))
time.sleep(0.01)
os.mknod(os.path.join(CONFIG, '1234', 'foo bar 2.json'))
# the newest to the front
self.assertListEqual(get_presets('1234'), ['foo bar 2', 'foo bar 1'])
def test_find_newest_preset_1(self):
create_preset('device 1', 'preset 1')
time.sleep(0.01)
create_preset('device 2', 'preset 2')
# not a preset, ignore
time.sleep(0.01)
os.mknod(os.path.join(CONFIG, 'device 2', 'picture.png'))
self.assertEqual(find_newest_preset(), ('device 2', 'preset 2'))
def test_find_newest_preset_2(self):

View File

@ -23,7 +23,7 @@ import unittest
import evdev
from keymapper.reader import keycode_reader
from keymapper.dev.reader import keycode_reader
from test import Event, pending_events