From 39f03953fc16544a67359654c8f902a5c2c17cd5 Mon Sep 17 00:00:00 2001 From: Bintang Date: Fri, 24 May 2024 02:57:05 +0700 Subject: [PATCH] nixos/keymapper: init module Added option to enable `keymapperd` service used to provide communication between the user-level program `keymapper` which needs permission to grab keyboard and inject keys. --- .../manual/release-notes/rl-2411.section.md | 2 + nixos/modules/module-list.nix | 1 + nixos/modules/services/hardware/keymapper.nix | 267 ++++++++++++++++++ nixos/tests/all-tests.nix | 1 + nixos/tests/keymapper.nix | 81 ++++++ 5 files changed, 352 insertions(+) create mode 100644 nixos/modules/services/hardware/keymapper.nix create mode 100644 nixos/tests/keymapper.nix diff --git a/nixos/doc/manual/release-notes/rl-2411.section.md b/nixos/doc/manual/release-notes/rl-2411.section.md index a0600247bcd13..95c29d1712638 100644 --- a/nixos/doc/manual/release-notes/rl-2411.section.md +++ b/nixos/doc/manual/release-notes/rl-2411.section.md @@ -194,6 +194,8 @@ - [tiny-dfr](https://github.com/WhatAmISupposedToPutHere/tiny-dfr), a dynamic function row daemon for the Touch Bar found on some Apple laptops. Available as [hardware.apple.touchBar.enable](options.html#opt-hardware.apple.touchBar.enable). +- [keymapper](https://github.com/houmain/keymapper), A cross-platform context-aware key remapper. Available as [services.keymapper](#opt-services.keymapper.enable). + ## Backward Incompatibilities {#sec-release-24.11-incompatibilities} - The `sound` options have been removed or renamed, as they had a lot of unintended side effects. See [below](#sec-release-24.11-migration-sound) for details. diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index a014c93afedec..29bbcb0b97e00 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -597,6 +597,7 @@ ./services/hardware/joycond.nix ./services/hardware/kanata.nix ./services/hardware/kmonad.nix + ./services/hardware/keymapper.nix ./services/hardware/lcd.nix ./services/hardware/libinput.nix ./services/hardware/lirc.nix diff --git a/nixos/modules/services/hardware/keymapper.nix b/nixos/modules/services/hardware/keymapper.nix new file mode 100644 index 0000000000000..8b13507372ede --- /dev/null +++ b/nixos/modules/services/hardware/keymapper.nix @@ -0,0 +1,267 @@ +{ + config, + pkgs, + lib, + ... +}: +let + inherit (lib) + concatStringsSep + mapAttrsToList + removeAttrs + pipe + hasPrefix + removePrefix + filter + optionalString + zipListsWith + mkOption + types + foldAttrs + mergeAttrs + mkEnableOption + literalExpression + foldl + optional + ; + + cfg = config.services.keymapper; + + generateDirectives = "@forward-modifiers " + (concatStringsSep " " cfg.forwardModifiers); + + generateAliases = aliases: concatStringsSep "\n" (mapAttrsToList (n: v: n + " = " + v) aliases); + + generateConfig = + settings: + let + genContextDefinition = + context: + let + contextBody = pipe (removeAttrs context [ "stage" ]) [ + (mapAttrsToList ( + n: v: + if hasPrefix "no" n && v != null then + ''${removePrefix "no" n}!="${v}"'' + else if v != null then + ''${n}="${v}"'' + else + null + )) + (filter (x: x != null)) + (concatStringsSep " ") + ]; + in + (optionalString context.stage "[stage]") + + (optionalString (context.stage && contextBody != "") "\n\n") + + (optionalString (contextBody != "") "[${contextBody}]"); + + genMappingDefinition = + mappings: + optionalString (mappings != [ ]) ( + concatStringsSep "\n" (map (v: "${v.input} >> ${v.output}") mappings) + ); + + contextDefinitions = map (x: genContextDefinition (removeAttrs x [ "mappings" ])) settings; + mappingDefinitions = map (x: genMappingDefinition x.mappings) settings; + in + optionalString (settings != [ ]) ( + concatStringsSep "\n\n" ( + zipListsWith ( + a: b: + if a == "" then + b + else if b == "" then + a + else + a + "\n" + b + ) contextDefinitions mappingDefinitions + ) + ); + + mkContextOption = name: example: desc: { + ${name} = mkOption { + type = types.nullOr types.str; + default = null; + inherit example; + description = desc + " where the mappings are enabled."; + }; + "no${name}" = mkOption { + type = types.nullOr types.str; + default = null; + visible = false; + }; + }; + + mappingModule = types.submodule { + options = { + input = mkOption { + type = types.str; + description = "Input expression of a mapping."; + }; + output = mkOption { + type = types.str; + description = "Output expression of a mapping."; + }; + }; + }; + + contextModule = types.submodule { + options = + (foldAttrs mergeAttrs { } [ + (mkContextOption "system" "Linux" "The system") + (mkContextOption "title" "Chromium" "The focused window by title") + (mkContextOption "class" "qtcreator" "The focused window by class name") + (mkContextOption "path" "notepad.exe" "Process path") + (mkContextOption "device" null "Input device an event originates from") + (mkContextOption "device_id" null "Input device an event originates from") + (mkContextOption "modifier" "Virtual1 !Virtual2" "State of one or more keys") + ]) + // { + stage = mkEnableOption null // { + description = '' + Whether to split the configuration into stages. + This option is equivalent to adding `[stage]` to the {file}`keymapper.conf`."; + ''; + }; + mappings = mkOption { + type = types.listOf mappingModule; + default = [ ]; + example = literalExpression '' + [ + { input = "CapsLock"; output = "Backspace"; } + { input = "Z"; output = "Y"; } + { input = "Y"; output = "Z"; } + { input = "Control{Q}"; output = "Alt{F4}"; } + ] + ''; + description = '' + Declaration of mappings. The order of the list is reflected in the config file. + ''; + }; + }; + }; +in +{ + options.services.keymapper = { + enable = lib.mkEnableOption '' + keymapper, A cross-platform context-aware key remapper. + + The program is split into two parts: + - {command}`keymapperd` is the service which needs to be given the permissions to grab the keyboard devices and inject keys. + - {command}`keymapper` should be run as normal user in a graphical environment. It loads the configuration, informs the service about it and the active context and also executes mapped terminal commands. + This module only enables {command}`keymapperd`. You have to add {command}`keymapper` to the desktop environment's auto-started application. + ''; + + package = lib.mkPackageOption pkgs "keymapper" { }; + + aliases = mkOption { + type = types.attrsOf types.str; + default = { }; + example = literalExpression '' + { + Win = "Meta"; + Alt = "AltLeft | AltRight"; + } + ''; + description = "Aliases of keys and/or sequences."; + }; + + contexts = mkOption { + type = types.listOf contextModule; + default = [ ]; + example = literalExpression '' + [ + { + mappings = [ + { input = "CapsLock"; output = "Backspace"; } + { input = "Z"; output = "Y"; } + { input = "Y"; output = "Z"; } + { input = "Control{Q}"; output = "Alt{F4}"; } + ]; + } + { stage = true; } + { + system = "Linux"; + noclass = "Thunar"; + mappings = [ + { input = "Control{H}"; output = "Backspace"; } + { input = "Control{M}"; output = "Enter"; } + ]; + } + ] + ''; + description = '' + Enable mappings only in specific contexts or if only {option}`mappings` + is specified then it is enabled unconditionally. + + Prepend "no" to the option's name (not applicable for {option}`mappings` and {option}`stage`) + to indicate reverse option. + For example, {option}`system` will activate the mappings in specified + system but {option}`nosystem` will activate it in all systems except + the one specified. + + If at least one of the context is specified but {option}`mappings` is + left unspecified, then the context shares {option}`mappings` of the next context. + ''; + }; + + forwardModifiers = mkOption { + type = types.listOf types.str; + default = [ + "Shift" + "Control" + "Alt" + ]; + description = '' + Allows to set a list of keys which should never be [held back](https://github.com/houmain/keymapper?tab=readme-ov-file#order-of-mappings). + ''; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = "Extra configuration lines to add."; + }; + + extraConfigFirst = mkEnableOption null // { + description = "Whether to put lines in {option}`extraConfig` before {option}`contexts`"; + }; + }; + + config = lib.mkIf cfg.enable { + environment.systemPackages = [ cfg.package ]; + + environment.etc."keymapper.conf".text = + concatStringsSep "\n\n" ( + foldl (acc: x: acc ++ (optional (x != "") x)) [ ] ( + if !cfg.extraConfigFirst then + [ + generateDirectives + (generateAliases cfg.aliases) + (generateConfig cfg.contexts) + cfg.extraConfig + ] + else + [ + generateDirectives + (generateAliases cfg.aliases) + cfg.extraConfig + (generateConfig cfg.contexts) + ] + ) + ) + + "\n"; + + systemd.services.keymapperd = { + description = "Keymapper Daemon"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "exec"; + ExecStart = "${cfg.package}/bin/keymapperd -v"; + Restart = "always"; + }; + }; + }; + + meta.maintainers = with lib.maintainers; [ spitulax ]; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 8b674bfb73423..833a06e8f3e7e 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -512,6 +512,7 @@ in { keycloak = discoverTests (import ./keycloak.nix); keyd = handleTest ./keyd.nix {}; keymap = handleTest ./keymap.nix {}; + keymapper = handleTest ./keymapper.nix {}; knot = handleTest ./knot.nix {}; komga = handleTest ./komga.nix {}; krb5 = discoverTests (import ./krb5); diff --git a/nixos/tests/keymapper.nix b/nixos/tests/keymapper.nix new file mode 100644 index 0000000000000..f2fab8f4f9d36 --- /dev/null +++ b/nixos/tests/keymapper.nix @@ -0,0 +1,81 @@ +import ./make-test-python.nix ( + { lib, pkgs, ... }: + let + expectedConfig = pkgs.writeText "keymapper.conf" '' + @forward-modifiers Shift Control Alt + + Alt = AltLeft + AltGr = AltRight + Super = Meta + + ScrollLock >> CapsLock + CapsLock >> Escape + Alt{C} >> edit_copy + Alt{V} >> edit_paste + + [stage] + + [class="kitty" system!="Darwin"] + edit_copy >> Control{Shift{C}} + edit_paste >> Control{Shift{V}} + ''; + in + { + name = "keymapper"; + meta.maintainers = with lib.maintainers; [ spitulax ]; + + nodes.machine.services.keymapper = { + enable = true; + aliases = { + "Alt" = "AltLeft"; + "AltGr" = "AltRight"; + "Super" = "Meta"; + }; + contexts = [ + { + mappings = [ + { + input = "ScrollLock"; + output = "CapsLock"; + } + { + input = "CapsLock"; + output = "Escape"; + } + { + input = "Alt{C}"; + output = "edit_copy"; + } + { + input = "Alt{V}"; + output = "edit_paste"; + } + ]; + } + { stage = true; } + { + class = "kitty"; + nosystem = "Darwin"; + mappings = [ + { + input = "edit_copy"; + output = "Control{Shift{C}}"; + } + { + input = "edit_paste"; + output = "Control{Shift{V}}"; + } + ]; + } + ]; + }; + + testScript = '' + machine.wait_for_unit("keymapperd.service") + machine.wait_for_file("/etc/keymapper.conf") + machine.succeed("keymapper --check") + machine.copy_from_host("${expectedConfig}", "/tmp/keymapper.conf") + print(machine.succeed("diff /etc/keymapper.conf /tmp/keymapper.conf")) + ''; + } +)