chore(repo): baseline du fork input-remapper (upstream v2.2.1)

Le dépôt ne contenait que README.md ; ajout de l'intégralité du code
fonctionnel du fork comme socle stable de la branche de release.
L'état runtime d'orchestration (.ideai/) est exclu du suivi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 00:47:12 +02:00
parent 1d790a78fb
commit 0ab77d2a64
257 changed files with 51859 additions and 0 deletions

View File

@ -0,0 +1,24 @@
# -*- 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/>.
"""Since I'm not forking, I can't use multiprocessing.Pipe.
Processes that need privileges are spawned with pkexec, which connect to
known pipe paths to communicate with the non-privileged parent process.
"""

183
inputremapper/ipc/pipe.py Normal file
View File

@ -0,0 +1,183 @@
# -*- 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/>.
"""Named bidirectional non-blocking pipes.
>>> p1 = Pipe('foo')
>>> p2 = Pipe('foo')
>>> p1.send(1)
>>> p2.poll()
>>> p2.recv()
>>> p2.send(2)
>>> p1.poll()
>>> p1.recv()
Beware that pipes read any available messages,
even those written by themselves.
"""
import asyncio
import json
import os
import time
from typing import Optional, AsyncIterator, Union
from inputremapper.configs.paths import PathUtils
from inputremapper.logging.logger import logger
class Pipe:
"""Pipe object.
This is not for secure communication. If pipes already exist, they will be used,
but existing pipes might have open permissions! Only use this for stuff that
non-privileged users would be allowed to read.
"""
def __init__(self, path):
"""Create a pipe, or open it if it already exists."""
self._path = path
self._unread = []
self._created_at = time.time()
self._transport: Optional[asyncio.ReadTransport] = None
self._async_iterator: Optional[AsyncIterator] = None
paths = (f"{path}r", f"{path}w")
PathUtils.mkdir(os.path.dirname(path))
if not os.path.exists(paths[0]):
logger.debug("Creating new pipes %s", paths)
# The fd the link points to is closed, or none ever existed
# If there is a link, remove it.
if os.path.islink(paths[0]):
os.remove(paths[0])
if os.path.islink(paths[1]):
os.remove(paths[1])
self._fds = os.pipe()
fds_dir = f"/proc/{os.getpid()}/fd/"
PathUtils.chown(f"{fds_dir}{self._fds[0]}")
PathUtils.chown(f"{fds_dir}{self._fds[1]}")
# to make it accessible by path constants, create symlinks
os.symlink(f"{fds_dir}{self._fds[0]}", paths[0])
os.symlink(f"{fds_dir}{self._fds[1]}", paths[1])
else:
logger.debug("Using existing pipes %s", paths)
# thanks to os.O_NONBLOCK, readline will return b'' when there
# is nothing to read
self._fds = (
os.open(paths[0], os.O_RDONLY | os.O_NONBLOCK),
os.open(paths[1], os.O_WRONLY | os.O_NONBLOCK),
)
self._handles = (open(self._fds[0], "r"), open(self._fds[1], "w"))
# clear the pipe of any contents, to avoid leftover messages from breaking
# the reader-client or reader-service
while self.poll():
leftover = self.recv()
logger.debug('Cleared leftover message "%s"', leftover)
def __del__(self):
if self._transport:
logger.debug("closing transport")
self._transport.close()
for file in self._handles:
file.close()
def recv(self):
"""Read an object from the pipe or None if nothing available.
Doesn't transmit pickles, to avoid injection attacks on the
privileged reader-service. Only messages that can be converted to json
are allowed.
"""
if len(self._unread) > 0:
return self._unread.pop(0)
line = self._handles[0].readline()
if len(line) == 0:
return None
return self._get_msg(line)
def _get_msg(self, line: str):
parsed = json.loads(line)
if parsed[0] < self._created_at and os.environ.get("UNITTEST"):
# important to avoid race conditions between multiple unittests,
# for example old terminate messages reaching a new instance of
# the reader-service.
logger.debug("Ignoring old message %s", parsed)
return None
return parsed[1]
def send(self, message: Union[str, int, float, dict, list, tuple]):
"""Write a serializable object to the pipe."""
dump = json.dumps((time.time(), message))
# there aren't any newlines supposed to be,
# but if there are it breaks readline().
self._handles[1].write(dump.replace("\n", ""))
self._handles[1].write("\n")
self._handles[1].flush()
def poll(self):
"""Check if there is anything that can be read."""
if len(self._unread) > 0:
return True
# using select.select apparently won't mark the pipe as ready
# anymore when there are multiple lines to read but only a single
# line is retreived. Using read instead.
msg = self.recv()
if msg is not None:
self._unread.append(msg)
return len(self._unread) > 0
def fileno(self):
"""Compatibility to select.select."""
return self._handles[0].fileno()
def __aiter__(self):
return self
async def __anext__(self):
if not self._async_iterator:
loop = asyncio.get_running_loop()
reader = asyncio.StreamReader()
self._transport, _ = await loop.connect_read_pipe(
lambda: asyncio.StreamReaderProtocol(reader), self._handles[0]
)
self._async_iterator = reader.__aiter__()
return self._get_msg(await self._async_iterator.__anext__())
async def recv_async(self):
"""Read the next line with async. Do not use this when using
the async for loop."""
return await self.__aiter__().__anext__()

View File

@ -0,0 +1,122 @@
# -*- 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/>.
"""Share a dictionary across processes."""
import atexit
import multiprocessing
import select
from typing import Optional, Any
from inputremapper.logging.logger import logger
class SharedDict:
"""Share a dictionary across processes."""
# because unittests terminate all child processes in cleanup I can't use
# multiprocessing.Manager
def __init__(self):
"""Create a shared dictionary."""
super().__init__()
# To avoid blocking forever if something goes wrong. The maximum
# observed time communication takes was 0.001 for me on a slow pc
self._timeout = 0.02
self.pipe = multiprocessing.Pipe()
self.process = None
atexit.register(self._stop)
def start(self):
"""Ensure the process to manage the dictionary is running."""
if self.process is not None and self.process.is_alive():
logger.debug("SharedDict process already running")
return
# if the manager has already been running in the past but stopped
# for some reason, the dictionary contents are lost.
logger.debug("Starting SharedDict process")
self.process = multiprocessing.Process(target=self.manage)
self.process.start()
def manage(self):
"""Manage the dictionary, handle read and write requests."""
logger.debug("SharedDict process started")
shared_dict = {}
while True:
message = self.pipe[0].recv()
logger.debug("SharedDict got %s", message)
if message[0] == "stop":
return
if message[0] == "set":
shared_dict[message[1]] = message[2]
if message[0] == "clear":
shared_dict.clear()
if message[0] == "get":
self.pipe[0].send(shared_dict.get(message[1]))
if message[0] == "ping":
self.pipe[0].send("pong")
def _stop(self):
"""Stop the managing process."""
self.pipe[1].send(("stop",))
def _clear(self):
"""Clears the memory."""
self.pipe[1].send(("clear",))
def get(self, key: str):
"""Get a value from the dictionary.
If it doesn't exist, returns None.
"""
return self[key]
def is_alive(self, timeout: Optional[int] = None):
"""Check if the manager process is running."""
self.pipe[1].send(("ping",))
select.select([self.pipe[1]], [], [], timeout or self._timeout)
if self.pipe[1].poll():
return self.pipe[1].recv() == "pong"
return False
def __setitem__(self, key: str, value: Any):
self.pipe[1].send(("set", key, value))
def __getitem__(self, key: str):
self.pipe[1].send(("get", key))
select.select([self.pipe[1]], [], [], self._timeout)
if self.pipe[1].poll():
return self.pipe[1].recv()
logger.error("select.select timed out")
return None
def __del__(self):
self._stop()

303
inputremapper/ipc/socket.py Normal file
View File

@ -0,0 +1,303 @@
# -*- 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/>.
"""Non-blocking abstraction of unix domain sockets.
>>> server = Server('foo')
>>> client = Client('foo')
>>> server.send(1)
>>> client.poll()
>>> client.recv()
>>> client.send(2)
>>> server.poll()
>>> server.recv()
I seems harder to sniff on a socket than using pipes for other non-root
processes, but it doesn't guarantee security. As long as the GUI is open
and not running as root user, it is most likely possible to somehow log
keycodes by looking into the memory of the gui process (just like with most
other applications because they end up receiving keyboard input as well).
It still appears to be a bit overkill to use a socket considering pipes
are much easier to handle.
"""
# Issues:
# - Tests don't pass with Server and Client instead of Pipe for reader-client
# and service communication or something
# - Had one case of a test that was blocking forever, seems very rare.
# - Hard to debug, generally very problematic compared to Pipes
# The tool works fine, it's just the tests. BrokenPipe errors reported
# by _Server all the time.
import json
import os
import select
import socket
import time
from typing import Union
from inputremapper.configs.paths import PathUtils
from inputremapper.logging.logger import logger
# something funny that most likely won't appear in messages.
# also add some ones so that 01 in the payload won't offset
# a match by 2 bits
END = b"\x55\x55\xff\x55" # should be 01010101 01010101 11111111 01010101
ENCODING = "utf8"
# reusing existing objects makes tests easier, no headaches about closing
# and reopening anymore. The ui also only runs only one instance of each all
# the time.
existing_servers = {}
existing_clients = {}
class Base:
"""Abstract base class for Socket and Client."""
def __init__(self, path):
self._path = path
self._unread = []
self.unsent = []
PathUtils.mkdir(os.path.dirname(path))
self.connection = None
self.socket = None
self._created_at = 0
self.reset()
def reset(self):
"""Ignore older messages than now."""
# ensure it is connected
self.connect()
self._created_at = time.time()
def connect(self):
"""Returns True if connected, and if not attempts to connect."""
raise NotImplementedError
def fileno(self):
"""For compatibility with select.select."""
raise NotImplementedError
def reconnect(self):
"""Try to make a new connection."""
raise NotImplementedError
def _receive_new_messages(self):
if not self.connect():
logger.debug("Not connected")
return
messages = b""
attempts = 0
while True:
try:
chunk = self.connection.recvmsg(4096)[0]
messages += chunk
if len(chunk) == 0:
# select keeps telling me the socket has messages
# ready to be received, and I keep getting empty
# buffers. Happened during a test that ran two reader-service
# processes without stopping the first one.
attempts += 1
if attempts == 2 or not self.reconnect():
return
except (socket.timeout, BlockingIOError):
break
split = messages.split(END)
for message in split:
if len(message) > 0:
parsed = json.loads(message.decode(ENCODING))
if parsed[0] < self._created_at:
# important to avoid race conditions between multiple
# unittests, for example old terminate messages reaching
# a new instance of the reader-service.
logger.debug("Ignoring old message %s", parsed)
continue
self._unread.append(parsed[1])
def recv(self):
"""Get the next message or None if nothing to read.
Doesn't transmit pickles, to avoid injection attacks on the
privileged reader-service. Only messages that can be converted to json
are allowed.
"""
self._receive_new_messages()
if len(self._unread) == 0:
return None
return self._unread.pop(0)
def poll(self):
"""Check if a message to read is available."""
if len(self._unread) > 0:
return True
self._receive_new_messages()
return len(self._unread) > 0
def send(self, message: Union[str, int, float, dict, list, tuple]):
"""Send json-serializable messages."""
dump = bytes(json.dumps((time.time(), message)), ENCODING)
self.unsent.append(dump)
if not self.connect():
logger.debug("Not connected")
return
def send_all():
while len(self.unsent) > 0:
unsent = self.unsent[0]
self.connection.sendall(unsent + END)
# sending worked, remove message
self.unsent.pop(0)
# attempt sending twice in case it fails
try:
send_all()
except BrokenPipeError:
if not self.reconnect():
logger.error(
'%s: The other side of "%s" disappeared',
type(self).__name__,
self._path,
)
return
try:
send_all()
except BrokenPipeError as error:
logger.error(
'%s: Failed to send via "%s": %s',
type(self).__name__,
self._path,
error,
)
class _Client(Base):
"""A socket that can be written to and read from."""
def connect(self):
if self.socket is not None:
return True
try:
_socket = socket.socket(socket.AF_UNIX)
_socket.connect(self._path)
logger.debug('Connected to socket: "%s"', self._path)
_socket.setblocking(False)
except Exception as error:
logger.debug('Failed to connect to "%s": "%s"', self._path, error)
return False
self.socket = _socket
self.connection = _socket
existing_clients[self._path] = self
return True
def fileno(self):
"""For compatibility with select.select."""
self.connect()
return self.socket.fileno()
def reconnect(self):
self.connection = None
self.socket = None
return self.connect()
def Client(path):
if path in existing_clients:
# ensure it is running, might have been closed
existing_clients[path].reset()
return existing_clients[path]
return _Client(path)
class _Server(Base):
"""A socket that can be written to and read from.
It accepts one connection at a time, and drops old connections if
a new one is in sight.
"""
def connect(self):
if self.socket is None:
if os.path.exists(self._path):
# leftover from the previous execution
os.remove(self._path)
_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
_socket.bind(self._path)
_socket.listen(1)
PathUtils.chown(self._path)
logger.debug('Created socket: "%s"', self._path)
self.socket = _socket
self.socket.setblocking(False)
existing_servers[self._path] = self
incoming = len(select.select([self.socket], [], [], 0)[0]) != 0
if not incoming and self.connection is None:
# no existing connection, no client attempting to connect
return False
if not incoming and self.connection is not None:
# old connection
return True
if incoming:
logger.debug('Incoming connection: "%s"', self._path)
connection = self.socket.accept()[0]
self.connection = connection
self.connection.setblocking(False)
return True
def fileno(self):
"""For compatibility with select.select."""
self.connect()
return self.connection.fileno()
def reconnect(self):
self.connection = None
return self.connect()
def Server(path):
if path in existing_servers:
# ensure it is running, might have been closed
existing_servers[path].reset()
return existing_servers[path]
return _Server(path)