File: input_remapper_control.py

package info (click to toggle)
input-remapper 2.1.1-2
  • links: PTS, VCS
  • area: main
  • in suites: forky
  • size: 2,856 kB
  • sloc: python: 27,277; sh: 191; xml: 33; makefile: 3
file content (416 lines) | stat: -rwxr-xr-x 13,629 bytes parent folder | download | duplicates (3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper 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.
#
# input-remapper 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 input-remapper.  If not, see <https://www.gnu.org/licenses/>.

"""Control the dbus service from the command line."""

import argparse
import logging
import os
import subprocess
import sys
from enum import Enum
from typing import Optional

import gi

gi.require_version("GLib", "2.0")
from gi.repository import GLib

from inputremapper.configs.global_config import GlobalConfig
from inputremapper.configs.migrations import Migrations
from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput
from inputremapper.logging.logger import logger
from inputremapper.user import UserUtils


class Commands(Enum):
    AUTOLOAD = "autoload"
    START = "start"
    STOP = "stop"
    STOP_ALL = "stop-all"
    HELLO = "hello"
    QUIT = "quit"


class Internals(Enum):
    # internal stuff that the gui uses
    START_DAEMON = "start-daemon"
    START_READER_SERVICE = "start-reader-service"


class Options:
    command: str
    config_dir: str
    preset: str
    device: str
    list_devices: bool
    key_names: str
    debug: bool
    version: str


class InputRemapperControlBin:
    def __init__(
        self,
        global_config: GlobalConfig,
        migrations: Migrations,
    ):
        self.global_config = global_config
        self.migrations = migrations

    @staticmethod
    def main(options: Options) -> None:
        global_config = GlobalConfig()
        global_uinputs = GlobalUInputs(FrontendUInput)
        migrations = Migrations(global_uinputs)
        input_remapper_control = InputRemapperControlBin(
            global_config,
            migrations,
        )

        if options.debug:
            logger.update_verbosity(True)

        if options.version:
            logger.log_info()
            return

        logger.debug('Call for "%s"', sys.argv)

        boot_finished_ = input_remapper_control.boot_finished()
        is_root = UserUtils.user == "root"
        is_autoload = options.command == Commands.AUTOLOAD
        config_dir_set = options.config_dir is not None
        if is_autoload and not boot_finished_ and is_root and not config_dir_set:
            # this is probably happening during boot time and got
            # triggered by udev. There is no need to try to inject anything if the
            # service doesn't know where to look for a config file. This avoids a lot
            # of confusing service logs. And also avoids potential for problems when
            # input-remapper-control stresses about evdev, dbus and multiprocessing already
            # while the system hasn't even booted completely.
            logger.warning("Skipping autoload command without a logged in user")
            return

        if options.command is not None:
            if options.command in [command.value for command in Internals]:
                input_remapper_control.internals(options.command, options.debug)
            elif options.command in [command.value for command in Commands]:
                from inputremapper.daemon import Daemon

                daemon = Daemon.connect(fallback=False)

                input_remapper_control.set_daemon(daemon)

                input_remapper_control.communicate(
                    options.command,
                    options.device,
                    options.config_dir,
                    options.preset,
                )
            else:
                logger.error('Unknown command "%s"', options.command)
        else:
            if options.list_devices:
                input_remapper_control.list_devices()

            if options.key_names:
                input_remapper_control.list_key_names()

        if options.command:
            logger.info("Done")

    def list_devices(self):
        logger.setLevel(logging.ERROR)
        from inputremapper.groups import groups

        for group in groups:
            print(group.key)

    def list_key_names(self):
        from inputremapper.configs.keyboard_layout import keyboard_layout

        print("\n".join(keyboard_layout.list_names()))

    def communicate(
        self,
        command: str,
        device: str,
        config_dir: Optional[str],
        preset: str,
    ) -> None:
        """Commands that require a running daemon."""
        if self.daemon is None:
            # probably broken tests
            logger.error("Daemon missing")
            sys.exit(5)

        if config_dir is not None:
            self._load_config(config_dir)

        self.ensure_migrated()

        if command == Commands.AUTOLOAD.value:
            self._autoload(device)

        if command == Commands.START.value:
            self._start(device, preset)

        if command == Commands.STOP.value:
            self._stop(device)

        if command == Commands.STOP_ALL.value:
            self.daemon.stop_all()

        if command == Commands.HELLO.value:
            self._hello()

        if command == Commands.QUIT.value:
            self._quit()

    def _hello(self):
        response = self.daemon.hello("hello")
        logger.info('Daemon answered with "%s"', response)

    def _load_config(self, config_dir: str) -> None:
        path = os.path.abspath(
            os.path.expanduser(os.path.join(config_dir, "config.json"))
        )
        if not os.path.exists(path):
            logger.error('"%s" does not exist', path)
            sys.exit(6)

        logger.info('Using config from "%s" instead', path)
        self.global_config.load_config(path)

    def ensure_migrated(self) -> None:
        # import stuff late to make sure the correct log level is applied
        # before anything is logged
        # TODO since imports shouldn't run any code, this is fixed by moving towards DI
        from inputremapper.user import UserUtils

        if UserUtils.user != "root":
            # Might be triggered by udev, so skip the root user.
            # This will also refresh the config of the daemon if the user changed
            # it in the meantime.
            # config_dir is either the cli arg or the default path in home
            config_dir = os.path.dirname(self.global_config.path)
            self.daemon.set_config_dir(config_dir)
            self.migrations.migrate()

    def _stop(self, device: str) -> None:
        group = self._require_group(device)
        self.daemon.stop_injecting(group.key)

    def _quit(self) -> None:
        try:
            self.daemon.quit()
        except GLib.GError as error:
            if "NoReply" in str(error):
                # The daemon is expected to terminate, so there won't be a reply.
                return

            raise

    def _start(self, device: str, preset: str) -> None:
        group = self._require_group(device)

        logger.info(
            'Starting injection: "%s", "%s"',
            device,
            preset,
        )

        self.daemon.start_injecting(group.key, preset)

    def _require_group(self, device: str):
        # import stuff late to make sure the correct log level is applied
        # before anything is logged
        # TODO since imports shouldn't run any code, this is fixed by moving towards DI
        from inputremapper.groups import groups

        if device is None:
            logger.error("--device missing")
            sys.exit(3)

        if device.startswith("/dev"):
            group = groups.find(path=device)
        else:
            group = groups.find(key=device)

        if group is None:
            logger.error(
                'Device "%s" is unknown or not an appropriate input device',
                device,
            )
            sys.exit(4)

        return group

    def _autoload(self, device: str) -> None:
        # if device was specified, autoload for that one. if None autoload
        # for all devices.
        if device is None:
            logger.info("Autoloading all")
            # timeout is not documented, for more info see
            # https://github.com/LEW21/pydbus/blob/master/pydbus/proxy_method.py
            self.daemon.autoload(timeout=10)
        else:
            group = self._require_group(device)
            logger.info("Asking daemon to autoload for %s", device)
            self.daemon.autoload_single(group.key, timeout=2)

    def internals(self, command: str, debug: True) -> None:
        """Methods that are needed to get the gui to work and that require root.

        input-remapper-control should be started with sudo or pkexec for this.
        """
        debug = " -d" if debug else ""

        if command == Internals.START_READER_SERVICE.value:
            cmd = f"input-remapper-reader-service{debug}"
        elif command == Internals.START_DAEMON.value:
            cmd = f"input-remapper-service --hide-info{debug}"
        else:
            return

        # daemonize
        cmd = f"{cmd} &"
        logger.debug(f"Running `{cmd}`")
        os.system(cmd)

    def _num_logged_in_users(self) -> int:
        """Check how many users are logged in."""
        who = subprocess.run(["who"], stdout=subprocess.PIPE).stdout.decode()
        return len([user for user in who.split("\n") if user.strip() != ""])

    def _is_systemd_finished(self) -> bool:
        """Check if systemd finished booting."""
        try:
            systemd_analyze = subprocess.run(
                ["systemd-analyze"], stdout=subprocess.PIPE
            )
        except FileNotFoundError:
            # probably not systemd, lets assume true to not block input-remapper for good
            # on certain installations
            return True

        if "finished" in systemd_analyze.stdout.decode():
            # it writes into stderr otherwise or something
            return True

        return False

    def boot_finished(self) -> bool:
        """Check if booting is completed."""
        # Get as much information as needed to really safely determine if booting up is
        # complete.
        # - `who` returns an empty list on some system for security purposes
        # - something might be broken and might make systemd_analyze fail:
        #       Bootup is not yet finished
        #       (org.freedesktop.systemd1.Manager.FinishTimestampMonotonic=0).
        #       Please try again later.
        #       Hint: Use 'systemctl list-jobs' to see active jobs
        if self._is_systemd_finished():
            logger.debug("System is booted")
            return True

        if self._num_logged_in_users() > 0:
            logger.debug("User(s) logged in")
            return True

        return False

    def set_daemon(self, daemon):
        # TODO DI?
        self.daemon = daemon

    @staticmethod
    def parse_args() -> Options:
        parser = argparse.ArgumentParser()
        parser.add_argument(
            "--command",
            action="store",
            dest="command",
            help=(
                "Communicate with the daemon. Available commands are "
                f"{', '.join([command.value for command in Commands])}"
            ),
            default=None,
            metavar="NAME",
        )
        parser.add_argument(
            "--config-dir",
            action="store",
            dest="config_dir",
            help=(
                "path to the config directory containing config.json, "
                "xmodmap.json and the presets folder. "
                "defaults to ~/.config/input-remapper/"
            ),
            default=None,
            metavar="PATH",
        )
        parser.add_argument(
            "--preset",
            action="store",
            dest="preset",
            help="The filename of the preset without the .json extension.",
            default=None,
            metavar="NAME",
        )
        parser.add_argument(
            "--device",
            action="store",
            dest="device",
            help="One of the device keys from --list-devices",
            default=None,
            metavar="NAME",
        )
        parser.add_argument(
            "--list-devices",
            action="store_true",
            dest="list_devices",
            help="List available device keys and exit",
            default=False,
        )
        parser.add_argument(
            "--symbol-names",
            action="store_true",
            dest="key_names",
            help="Print all available names for the preset",
            default=False,
        )
        parser.add_argument(
            "-d",
            "--debug",
            action="store_true",
            dest="debug",
            help="Displays additional debug information",
            default=False,
        )
        parser.add_argument(
            "-v",
            "--version",
            action="store_true",
            dest="version",
            help="Print the version and exit",
            default=False,
        )

        return parser.parse_args(sys.argv[1:])  # type: ignore