at 25.11-pre 6.8 kB view raw
1{ 2 config, 3 lib, 4 pkgs, 5 utils, 6 ... 7}: 8let 9 cfg = config.services.kanata; 10 11 upstreamDoc = "See [the upstream documentation](https://github.com/jtroo/kanata/blob/main/docs/config.adoc) and [example config files](https://github.com/jtroo/kanata/tree/main/cfg_samples) for more information."; 12 13 keyboard = 14 { name, config, ... }: 15 { 16 options = { 17 devices = lib.mkOption { 18 type = lib.types.listOf lib.types.str; 19 default = [ ]; 20 example = [ "/dev/input/by-id/usb-0000_0000-event-kbd" ]; 21 description = '' 22 Paths to keyboard devices. 23 24 An empty list, the default value, lets kanata detect which 25 input devices are keyboards and intercept them all. 26 ''; 27 }; 28 config = lib.mkOption { 29 type = lib.types.lines; 30 example = '' 31 (defsrc 32 caps) 33 34 (deflayermap (default-layer) 35 ;; tap caps lock as caps lock, hold caps lock as left control 36 caps (tap-hold 100 100 caps lctl)) 37 ''; 38 description = '' 39 Configuration other than `defcfg`. 40 41 ${upstreamDoc} 42 ''; 43 }; 44 extraDefCfg = lib.mkOption { 45 type = lib.types.lines; 46 default = ""; 47 example = "danger-enable-cmd yes"; 48 description = '' 49 Configuration of `defcfg` other than `linux-dev` (generated 50 from the devices option) and 51 `linux-continue-if-no-devs-found` (hardcoded to be yes). 52 53 ${upstreamDoc} 54 ''; 55 }; 56 configFile = lib.mkOption { 57 type = lib.types.path; 58 default = mkConfig name config; 59 defaultText = "A config file generated by values from other kanata module options."; 60 description = '' 61 The config file. 62 63 By default, it is generated by values from other kanata 64 module options. 65 66 You can also set it to your own full config file which 67 overrides all other kanata module options. ${upstreamDoc} 68 ''; 69 }; 70 extraArgs = lib.mkOption { 71 type = lib.types.listOf lib.types.str; 72 default = [ ]; 73 description = "Extra command line arguments passed to kanata."; 74 }; 75 port = lib.mkOption { 76 type = lib.types.nullOr lib.types.port; 77 default = null; 78 example = 6666; 79 description = '' 80 Port to run the TCP server on. `null` will not run the server. 81 ''; 82 }; 83 }; 84 }; 85 86 mkName = name: "kanata-${name}"; 87 88 mkDevices = 89 devices: 90 let 91 devicesString = lib.pipe devices [ 92 (map (device: "\"" + device + "\"")) 93 (lib.concatStringsSep " ") 94 ]; 95 in 96 lib.optionalString ((lib.length devices) > 0) "linux-dev (${devicesString})"; 97 98 mkConfig = 99 name: keyboard: 100 pkgs.writeTextFile { 101 name = "${mkName name}-config.kdb"; 102 text = '' 103 (defcfg 104 ${keyboard.extraDefCfg} 105 ${mkDevices keyboard.devices} 106 linux-continue-if-no-devs-found yes) 107 108 ${keyboard.config} 109 ''; 110 # Only the config file generated by this module is checked. A 111 # user-provided one is not checked because it may not be available 112 # at build time. I think this is a good balance between module 113 # complexity and functionality. 114 checkPhase = '' 115 ${lib.getExe cfg.package} --cfg "$target" --check --debug 116 ''; 117 }; 118 119 mkService = 120 name: keyboard: 121 lib.nameValuePair (mkName name) { 122 wantedBy = [ "multi-user.target" ]; 123 serviceConfig = { 124 Type = "notify"; 125 ExecStart = '' 126 ${lib.getExe cfg.package} \ 127 --cfg ${keyboard.configFile} \ 128 --symlink-path ''${RUNTIME_DIRECTORY}/${name} \ 129 ${lib.optionalString (keyboard.port != null) "--port ${toString keyboard.port}"} \ 130 ${utils.escapeSystemdExecArgs keyboard.extraArgs} 131 ''; 132 133 DynamicUser = true; 134 RuntimeDirectory = mkName name; 135 SupplementaryGroups = with config.users.groups; [ 136 input.name 137 uinput.name 138 ]; 139 140 # hardening 141 DeviceAllow = [ 142 "/dev/uinput rw" 143 "char-input r" 144 ]; 145 CapabilityBoundingSet = [ "" ]; 146 DevicePolicy = "closed"; 147 IPAddressAllow = lib.optional (keyboard.port != null) "localhost"; 148 IPAddressDeny = [ "any" ]; 149 LockPersonality = true; 150 MemoryDenyWriteExecute = true; 151 PrivateNetwork = keyboard.port == null; 152 PrivateUsers = true; 153 ProcSubset = "pid"; 154 ProtectClock = true; 155 ProtectControlGroups = true; 156 ProtectHome = true; 157 ProtectHostname = true; 158 ProtectKernelLogs = true; 159 ProtectKernelModules = true; 160 ProtectKernelTunables = true; 161 ProtectProc = "invisible"; 162 RestrictAddressFamilies = [ "AF_UNIX" ] ++ lib.optional (keyboard.port != null) "AF_INET"; 163 RestrictNamespaces = true; 164 RestrictRealtime = true; 165 SystemCallArchitectures = [ "native" ]; 166 SystemCallFilter = [ 167 "@system-service" 168 "~@privileged" 169 "~@resources" 170 ]; 171 UMask = "0077"; 172 }; 173 }; 174in 175{ 176 options.services.kanata = { 177 enable = lib.mkEnableOption "kanata, a tool to improve keyboard comfort and usability with advanced customization"; 178 package = lib.mkPackageOption pkgs "kanata" { 179 example = [ "kanata-with-cmd" ]; 180 extraDescription = '' 181 ::: {.note} 182 If {option}`danger-enable-cmd` is enabled in any of the keyboards, the 183 `kanata-with-cmd` package should be used. 184 ::: 185 ''; 186 }; 187 keyboards = lib.mkOption { 188 type = lib.types.attrsOf (lib.types.submodule keyboard); 189 default = { }; 190 description = "Keyboard configurations."; 191 }; 192 }; 193 194 config = lib.mkIf cfg.enable { 195 warnings = 196 let 197 keyboardsWithEmptyDevices = lib.filterAttrs (name: keyboard: keyboard.devices == [ ]) cfg.keyboards; 198 existEmptyDevices = lib.length (lib.attrNames keyboardsWithEmptyDevices) > 0; 199 moreThanOneKeyboard = lib.length (lib.attrNames cfg.keyboards) > 1; 200 in 201 lib.optional (existEmptyDevices && moreThanOneKeyboard) 202 "One device can only be intercepted by one kanata instance. Setting services.kanata.keyboards.${lib.head (lib.attrNames keyboardsWithEmptyDevices)}.devices = [ ] and using more than one services.kanata.keyboards may cause a race condition."; 203 204 hardware.uinput.enable = true; 205 206 systemd.services = lib.mapAttrs' mkService cfg.keyboards; 207 }; 208 209 meta.maintainers = with lib.maintainers; [ linj ]; 210}