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}