Ajoute la configuration des associations application/preset, le service de suivi du focus, l'intégration GUI et les fichiers d'installation nécessaires. Ajoute les tests unitaires ciblés des bindings, du service de focus, des migrations et des composants GUI associés. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
549 lines
20 KiB
Python
549 lines
20 KiB
Python
# -*- 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/>.
|
|
|
|
"""Migration functions.
|
|
|
|
Only write changes to disk, if there actually are changes. Otherwise, file-modification
|
|
dates are destroyed.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import Iterator, Tuple, Dict, List, Optional, TypedDict
|
|
|
|
from evdev.ecodes import (
|
|
EV_KEY,
|
|
EV_ABS,
|
|
EV_REL,
|
|
ABS_X,
|
|
ABS_Y,
|
|
ABS_RX,
|
|
ABS_RY,
|
|
REL_X,
|
|
REL_Y,
|
|
REL_WHEEL_HI_RES,
|
|
REL_HWHEEL_HI_RES,
|
|
)
|
|
from packaging import version
|
|
|
|
from inputremapper.configs.input_config import InputCombination, InputConfig
|
|
from inputremapper.configs.keyboard_layout import keyboard_layout
|
|
from inputremapper.configs.mapping import Mapping, UIMapping
|
|
from inputremapper.configs.paths import PathUtils
|
|
from inputremapper.configs.preset import Preset
|
|
from inputremapper.injection.global_uinputs import GlobalUInputs
|
|
from inputremapper.injection.macros.parse import Parser
|
|
from inputremapper.logging.logger import logger, VERSION
|
|
from inputremapper.user import UserUtils
|
|
|
|
|
|
class Config(TypedDict):
|
|
input_combination: Optional[InputCombination]
|
|
target_uinput: str
|
|
output_type: int
|
|
output_code: Optional[int]
|
|
|
|
|
|
class Migrations:
|
|
def __init__(self, global_uinputs: GlobalUInputs):
|
|
self.global_uinputs = global_uinputs
|
|
|
|
def migrate(self):
|
|
"""Migrate config files to the current release."""
|
|
|
|
self._rename_to_input_remapper()
|
|
|
|
self._copy_to_v2()
|
|
|
|
v = self.config_version()
|
|
|
|
if v < version.parse("0.4.0"):
|
|
self._config_suffix()
|
|
self._preset_path()
|
|
|
|
if v < version.parse("1.2.2"):
|
|
self._mapping_keys()
|
|
|
|
if v < version.parse("1.4.0"):
|
|
self.global_uinputs.prepare_all()
|
|
self._add_target()
|
|
|
|
if v < version.parse("1.4.1"):
|
|
self._otherwise_to_else()
|
|
|
|
if v < version.parse("1.5.0"):
|
|
self._remove_logs()
|
|
|
|
if v < version.parse("1.6.0-beta"):
|
|
self._convert_to_individual_mappings()
|
|
|
|
if v < version.parse("2.3.0"):
|
|
self._add_app_binding_config()
|
|
|
|
# add new migrations here
|
|
|
|
if v < version.parse(VERSION):
|
|
self._update_version()
|
|
|
|
def all_presets(
|
|
self,
|
|
) -> Iterator[Tuple[os.PathLike, Dict | List]]:
|
|
"""Get all presets for all groups as list."""
|
|
if not os.path.exists(PathUtils.get_preset_path()):
|
|
return
|
|
|
|
preset_path = Path(PathUtils.get_preset_path())
|
|
for folder in preset_path.iterdir():
|
|
if not folder.is_dir():
|
|
continue
|
|
|
|
for preset in folder.iterdir():
|
|
if preset.suffix != ".json":
|
|
continue
|
|
|
|
try:
|
|
with open(preset, "r") as f:
|
|
preset_structure = json.load(f)
|
|
yield preset, preset_structure
|
|
except json.decoder.JSONDecodeError:
|
|
logger.warning('Invalid json format in preset "%s"', preset)
|
|
continue
|
|
|
|
def config_version(self):
|
|
"""Get the version string in config.json as packaging.Version object."""
|
|
config_path = os.path.join(PathUtils.config_path(), "config.json")
|
|
|
|
if not os.path.exists(config_path):
|
|
return version.parse("0.0.0")
|
|
|
|
with open(config_path, "r") as file:
|
|
config = json.load(file)
|
|
|
|
if "version" in config.keys():
|
|
return version.parse(config["version"])
|
|
|
|
return version.parse("0.0.0")
|
|
|
|
def _config_suffix(self):
|
|
"""Append the .json suffix to the config file."""
|
|
deprecated_path = os.path.join(PathUtils.config_path(), "config")
|
|
config_path = os.path.join(PathUtils.config_path(), "config.json")
|
|
if os.path.exists(deprecated_path) and not os.path.exists(config_path):
|
|
logger.info('Moving "%s" to "%s"', deprecated_path, config_path)
|
|
os.rename(deprecated_path, config_path)
|
|
|
|
def _preset_path(self):
|
|
"""Migrate the folder structure from < 0.4.0.
|
|
|
|
Move existing presets into the new subfolder 'presets'
|
|
"""
|
|
new_preset_folder = os.path.join(PathUtils.config_path(), "presets")
|
|
if os.path.exists(PathUtils.get_preset_path()) or not os.path.exists(
|
|
PathUtils.config_path()
|
|
):
|
|
return
|
|
|
|
logger.info("Migrating presets from < 0.4.0...")
|
|
group_config_files = os.listdir(PathUtils.config_path())
|
|
PathUtils.mkdir(PathUtils.get_preset_path())
|
|
for group in group_config_files:
|
|
path = os.path.join(PathUtils.config_path(), group)
|
|
if os.path.isdir(path):
|
|
target = path.replace(PathUtils.config_path(), new_preset_folder)
|
|
logger.info('Moving "%s" to "%s"', path, target)
|
|
os.rename(path, target)
|
|
|
|
logger.info("done")
|
|
|
|
def _mapping_keys(self):
|
|
"""Update all preset mappings.
|
|
|
|
Update all keys in preset to include value e.g.: '1,5'->'1,5,1'
|
|
"""
|
|
for preset, preset_structure in self.all_presets():
|
|
if isinstance(preset_structure, list):
|
|
continue # the preset must be at least 1.6-beta version
|
|
|
|
changes = 0
|
|
if "mapping" in preset_structure.keys():
|
|
mapping = copy.deepcopy(preset_structure["mapping"])
|
|
for key in mapping.keys():
|
|
if key.count(",") == 1:
|
|
preset_structure["mapping"][f"{key},1"] = preset_structure[
|
|
"mapping"
|
|
].pop(key)
|
|
changes += 1
|
|
|
|
if changes:
|
|
with open(preset, "w") as file:
|
|
logger.info('Updating mapping keys of "%s"', preset)
|
|
json.dump(preset_structure, file, indent=4)
|
|
file.write("\n")
|
|
|
|
def _update_version(self):
|
|
"""Write the current version to the config file."""
|
|
config_file = os.path.join(PathUtils.config_path(), "config.json")
|
|
if not os.path.exists(config_file):
|
|
return
|
|
|
|
with open(config_file, "r") as file:
|
|
config = json.load(file)
|
|
|
|
config["version"] = VERSION
|
|
with open(config_file, "w") as file:
|
|
logger.info('Updating version in config to "%s"', VERSION)
|
|
json.dump(config, file, indent=4)
|
|
|
|
def _add_app_binding_config(self):
|
|
"""Add the focus-driven app-binding keys to config.json if missing.
|
|
|
|
This is an additive, backwards-compatible change: presets and autoload
|
|
configuration are left untouched.
|
|
"""
|
|
config_file = os.path.join(PathUtils.config_path(), "config.json")
|
|
if not os.path.exists(config_file):
|
|
return
|
|
|
|
with open(config_file, "r") as file:
|
|
config = json.load(file)
|
|
|
|
changed = False
|
|
if "app_binding_enabled" not in config:
|
|
config["app_binding_enabled"] = False
|
|
changed = True
|
|
if "app_bindings" not in config:
|
|
config["app_bindings"] = []
|
|
changed = True
|
|
|
|
if not changed:
|
|
return
|
|
|
|
with open(config_file, "w") as file:
|
|
logger.info("Adding app-binding defaults to config")
|
|
json.dump(config, file, indent=4)
|
|
file.write("\n")
|
|
|
|
def _rename_to_input_remapper(self):
|
|
"""Rename .config/key-mapper to .config/input-remapper."""
|
|
old_config_path = os.path.join(UserUtils.home, ".config/key-mapper")
|
|
if not os.path.exists(PathUtils.config_path()) and os.path.exists(
|
|
old_config_path
|
|
):
|
|
logger.info("Moving %s to %s", old_config_path, PathUtils.config_path())
|
|
shutil.move(old_config_path, PathUtils.config_path())
|
|
|
|
def _find_target(self, symbol):
|
|
"""Try to find a uinput with the required capabilities for the symbol."""
|
|
capabilities = {EV_KEY: set(), EV_REL: set()}
|
|
|
|
if Parser.is_this_a_macro(symbol):
|
|
# deprecated mechanic, cannot figure this out anymore
|
|
# capabilities = parse(symbol).get_capabilities()
|
|
return None
|
|
|
|
capabilities[EV_KEY] = {keyboard_layout.get(symbol)}
|
|
|
|
if len(capabilities[EV_REL]) > 0:
|
|
return "mouse"
|
|
|
|
for name, uinput in self.global_uinputs.devices.items():
|
|
if capabilities[EV_KEY].issubset(uinput.capabilities()[EV_KEY]):
|
|
return name
|
|
|
|
logger.info('could not find a suitable target UInput for "%s"', symbol)
|
|
return None
|
|
|
|
def _add_target(self):
|
|
"""Add the target field to each preset mapping."""
|
|
for preset, preset_structure in self.all_presets():
|
|
if isinstance(preset_structure, list):
|
|
continue
|
|
|
|
if "mapping" not in preset_structure.keys():
|
|
continue
|
|
|
|
changed = False
|
|
for key, symbol in preset_structure["mapping"].copy().items():
|
|
if isinstance(symbol, list):
|
|
continue
|
|
|
|
target = self._find_target(symbol)
|
|
if target is None:
|
|
target = "keyboard"
|
|
symbol = (
|
|
f"{symbol}\n"
|
|
"# Broken mapping:\n"
|
|
"# No target can handle all specified keycodes"
|
|
)
|
|
|
|
logger.info(
|
|
'Changing target of mapping for "%s" in preset "%s" to "%s"',
|
|
key,
|
|
preset,
|
|
target,
|
|
)
|
|
symbol = [symbol, target]
|
|
preset_structure["mapping"][key] = symbol
|
|
changed = True
|
|
|
|
if not changed:
|
|
continue
|
|
|
|
with open(preset, "w") as file:
|
|
logger.info('Adding targets for "%s"', preset)
|
|
json.dump(preset_structure, file, indent=4)
|
|
file.write("\n")
|
|
|
|
def _otherwise_to_else(self):
|
|
"""Conditional macros should use an "else" parameter instead of "otherwise"."""
|
|
for preset, preset_structure in self.all_presets():
|
|
if isinstance(preset_structure, list):
|
|
continue
|
|
|
|
if "mapping" not in preset_structure.keys():
|
|
continue
|
|
|
|
changed = False
|
|
for key, symbol in preset_structure["mapping"].copy().items():
|
|
if not Parser.is_this_a_macro(symbol[0]):
|
|
continue
|
|
|
|
symbol_before = symbol[0]
|
|
symbol[0] = re.sub(r"otherwise\s*=\s*", "else=", symbol[0])
|
|
|
|
if symbol_before == symbol[0]:
|
|
continue
|
|
|
|
changed = changed or symbol_before != symbol[0]
|
|
|
|
logger.info(
|
|
'Changing mapping for "%s" in preset "%s" to "%s"',
|
|
key,
|
|
preset,
|
|
symbol[0],
|
|
)
|
|
|
|
preset_structure["mapping"][key] = symbol
|
|
|
|
if not changed:
|
|
continue
|
|
|
|
with open(preset, "w") as file:
|
|
logger.info('Changing otherwise to else for "%s"', preset)
|
|
json.dump(preset_structure, file, indent=4)
|
|
file.write("\n")
|
|
|
|
def _input_combination_from_string(
|
|
self, combination_string: str
|
|
) -> InputCombination:
|
|
configs = []
|
|
for event_str in combination_string.split("+"):
|
|
type_, code, analog_threshold = event_str.split(",")
|
|
configs.append(
|
|
{
|
|
"type": int(type_),
|
|
"code": int(code),
|
|
"analog_threshold": int(analog_threshold),
|
|
}
|
|
)
|
|
|
|
return InputCombination(configs)
|
|
|
|
def _convert_to_individual_mappings(
|
|
self,
|
|
) -> None:
|
|
"""Convert preset.json
|
|
from {key: [symbol, target]}
|
|
to [{input_combination: ..., output_symbol: symbol, ...}]
|
|
"""
|
|
|
|
for old_preset_path, old_preset in self.all_presets():
|
|
if isinstance(old_preset, list):
|
|
continue
|
|
|
|
migrated_preset = Preset(old_preset_path, UIMapping)
|
|
if "mapping" in old_preset.keys():
|
|
for combination, symbol_target in old_preset["mapping"].items():
|
|
logger.info(
|
|
'migrating from "%s: %s" to mapping dict',
|
|
combination,
|
|
symbol_target,
|
|
)
|
|
try:
|
|
combination = self._input_combination_from_string(combination)
|
|
except ValueError:
|
|
logger.error(
|
|
"unable to migrate mapping with invalid combination %s",
|
|
combination,
|
|
)
|
|
continue
|
|
|
|
mapping = UIMapping(
|
|
input_combination=combination,
|
|
target_uinput=symbol_target[1],
|
|
output_symbol=symbol_target[0],
|
|
)
|
|
migrated_preset.add(mapping)
|
|
|
|
if (
|
|
"gamepad" in old_preset.keys()
|
|
and "joystick" in old_preset["gamepad"].keys()
|
|
):
|
|
joystick_dict = old_preset["gamepad"]["joystick"]
|
|
left_purpose = joystick_dict.get("left_purpose")
|
|
right_purpose = joystick_dict.get("right_purpose")
|
|
# TODO if pointer_speed is migrated, why is it in my config?
|
|
pointer_speed = joystick_dict.get("pointer_speed")
|
|
if pointer_speed:
|
|
pointer_speed /= 100
|
|
# non_linearity = joystick_dict.get("non_linearity") # Todo
|
|
x_scroll_speed = joystick_dict.get("x_scroll_speed")
|
|
y_scroll_speed = joystick_dict.get("y_scroll_speed")
|
|
|
|
cfg: Config = {
|
|
"input_combination": None,
|
|
"target_uinput": "mouse",
|
|
"output_type": EV_REL,
|
|
"output_code": None,
|
|
}
|
|
|
|
if left_purpose == "mouse":
|
|
x_config = cfg.copy()
|
|
y_config = cfg.copy()
|
|
x_config["input_combination"] = InputCombination(
|
|
[InputConfig(type=EV_ABS, code=ABS_X)]
|
|
)
|
|
y_config["input_combination"] = InputCombination(
|
|
[InputConfig(type=EV_ABS, code=ABS_Y)]
|
|
)
|
|
x_config["output_code"] = REL_X
|
|
y_config["output_code"] = REL_Y
|
|
mapping_x = Mapping(**x_config)
|
|
mapping_y = Mapping(**y_config)
|
|
if pointer_speed:
|
|
mapping_x.gain = pointer_speed
|
|
mapping_y.gain = pointer_speed
|
|
migrated_preset.add(mapping_x)
|
|
migrated_preset.add(mapping_y)
|
|
|
|
if right_purpose == "mouse":
|
|
x_config = cfg.copy()
|
|
y_config = cfg.copy()
|
|
x_config["input_combination"] = InputCombination(
|
|
[InputConfig(type=EV_ABS, code=ABS_RX)]
|
|
)
|
|
y_config["input_combination"] = InputCombination(
|
|
[InputConfig(type=EV_ABS, code=ABS_RY)]
|
|
)
|
|
x_config["output_code"] = REL_X
|
|
y_config["output_code"] = REL_Y
|
|
mapping_x = Mapping(**x_config)
|
|
mapping_y = Mapping(**y_config)
|
|
if pointer_speed:
|
|
mapping_x.gain = pointer_speed
|
|
mapping_y.gain = pointer_speed
|
|
migrated_preset.add(mapping_x)
|
|
migrated_preset.add(mapping_y)
|
|
|
|
if left_purpose == "wheel":
|
|
x_config = cfg.copy()
|
|
y_config = cfg.copy()
|
|
x_config["input_combination"] = InputCombination(
|
|
[InputConfig(type=EV_ABS, code=ABS_X)]
|
|
)
|
|
y_config["input_combination"] = InputCombination(
|
|
[InputConfig(type=EV_ABS, code=ABS_Y)]
|
|
)
|
|
x_config["output_code"] = REL_HWHEEL_HI_RES
|
|
y_config["output_code"] = REL_WHEEL_HI_RES
|
|
mapping_x = Mapping(**x_config)
|
|
mapping_y = Mapping(**y_config)
|
|
if x_scroll_speed:
|
|
mapping_x.gain = x_scroll_speed
|
|
if y_scroll_speed:
|
|
mapping_y.gain = y_scroll_speed
|
|
migrated_preset.add(mapping_x)
|
|
migrated_preset.add(mapping_y)
|
|
|
|
if right_purpose == "wheel":
|
|
x_config = cfg.copy()
|
|
y_config = cfg.copy()
|
|
x_config["input_combination"] = InputCombination(
|
|
[InputConfig(type=EV_ABS, code=ABS_RX)]
|
|
)
|
|
y_config["input_combination"] = InputCombination(
|
|
[InputConfig(type=EV_ABS, code=ABS_RY)]
|
|
)
|
|
x_config["output_code"] = REL_HWHEEL_HI_RES
|
|
y_config["output_code"] = REL_WHEEL_HI_RES
|
|
mapping_x = Mapping(**x_config)
|
|
mapping_y = Mapping(**y_config)
|
|
if x_scroll_speed:
|
|
mapping_x.gain = x_scroll_speed
|
|
if y_scroll_speed:
|
|
mapping_y.gain = y_scroll_speed
|
|
migrated_preset.add(mapping_x)
|
|
migrated_preset.add(mapping_y)
|
|
|
|
migrated_preset.save()
|
|
|
|
def _copy_to_v2(self):
|
|
"""Move the beta config to the v2 path, or copy the v1 config to the v2 path."""
|
|
# TODO test
|
|
if os.path.exists(PathUtils.config_path()):
|
|
# don't copy to already existing folder
|
|
# users should delete the input-remapper-2 folder if they need to
|
|
return
|
|
|
|
# prioritize the v1 configs over beta configs
|
|
old_path = os.path.join(UserUtils.home, ".config/input-remapper")
|
|
if os.path.exists(os.path.join(old_path, "config.json")):
|
|
# no beta path, only old presets exist. COPY to v2 path, which will then be
|
|
# migrated by the various self.
|
|
logger.debug("copying all from %s to %s", old_path, PathUtils.config_path())
|
|
shutil.copytree(old_path, PathUtils.config_path())
|
|
return
|
|
|
|
# if v1 configs don't exist, try to find beta configs.
|
|
beta_path = os.path.join(
|
|
UserUtils.home, ".config/input-remapper/beta_1.6.0-beta"
|
|
)
|
|
if os.path.exists(beta_path):
|
|
# There has never been a different version than "1.6.0-beta" in beta, so we
|
|
# only need to check for that exact directory
|
|
# already migrated, possibly new presets in them, move to v2 path
|
|
logger.debug("moving %s to %s", beta_path, PathUtils.config_path())
|
|
shutil.move(beta_path, PathUtils.config_path())
|
|
|
|
def _remove_logs(self):
|
|
"""We will try to rely on journalctl for this in the future."""
|
|
try:
|
|
PathUtils.remove(f"{UserUtils.home}/.log/input-remapper")
|
|
PathUtils.remove("/var/log/input-remapper")
|
|
PathUtils.remove("/var/log/input-remapper-control")
|
|
except Exception as error:
|
|
logger.debug("Failed to remove deprecated logfiles: %s", str(error))
|
|
# this migration is not important. Continue
|
|
pass
|