mirror of
https://github.com/sezanzeb/input-remapper
synced 2024-11-04 12:00:16 +00:00
access via groups instead of root services with interfaces that are open to everyone
This commit is contained in:
parent
ba929fce23
commit
ce32423c7c
@ -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
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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>
|
@ -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
|
@ -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:
|
||||
|
0
keymapper/dev/__init__.py
Normal file
0
keymapper/dev/__init__.py
Normal file
53
keymapper/dev/permissions.py
Normal file
53
keymapper/dev/permissions.py
Normal 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
|
@ -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))
|
||||
|
@ -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:
|
||||
|
@ -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):
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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
|
||||
)
|
||||
|
||||
|
@ -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
|
||||
|
39
setup.py
39
setup.py
@ -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']),
|
||||
]
|
||||
)
|
||||
|
@ -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]
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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')
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user