1{ config, lib, pkgs, utils, ... }:
2
3with lib;
4
5let
6 cfg = config.services.kanata;
7
8 keyboard = {
9 options = {
10 devices = mkOption {
11 type = types.addCheck (types.listOf types.str)
12 (devices: (length devices) > 0);
13 example = [ "/dev/input/by-id/usb-0000_0000-event-kbd" ];
14 # TODO replace note with tip, which has not been implemented yet in
15 # nixos/lib/make-options-doc/mergeJSON.py
16 description = mdDoc ''
17 Paths to keyboard devices.
18
19 ::: {.note}
20 To avoid unnecessary triggers of the service unit, unplug devices in
21 the order of the list.
22 :::
23 '';
24 };
25 config = mkOption {
26 type = types.lines;
27 example = ''
28 (defsrc
29 grv 1 2 3 4 5 6 7 8 9 0 - = bspc
30 tab q w e r t y u i o p [ ] \
31 caps a s d f g h j k l ; ' ret
32 lsft z x c v b n m , . / rsft
33 lctl lmet lalt spc ralt rmet rctl)
34
35 (deflayer qwerty
36 grv 1 2 3 4 5 6 7 8 9 0 - = bspc
37 tab q w e r t y u i o p [ ] \
38 @cap a s d f g h j k l ; ' ret
39 lsft z x c v b n m , . / rsft
40 lctl lmet lalt spc ralt rmet rctl)
41
42 (defalias
43 ;; tap within 100ms for capslk, hold more than 100ms for lctl
44 cap (tap-hold 100 100 caps lctl))
45 '';
46 description = mdDoc ''
47 Configuration other than `defcfg`. See [example config
48 files](https://github.com/jtroo/kanata) for more information.
49 '';
50 };
51 extraDefCfg = mkOption {
52 type = types.lines;
53 default = "";
54 example = "danger-enable-cmd yes";
55 description = mdDoc ''
56 Configuration of `defcfg` other than `linux-dev`. See [example
57 config files](https://github.com/jtroo/kanata) for more information.
58 '';
59 };
60 extraArgs = mkOption {
61 type = types.listOf types.str;
62 default = [ ];
63 description = mdDoc "Extra command line arguments passed to kanata.";
64 };
65 port = mkOption {
66 type = types.nullOr types.port;
67 default = null;
68 example = 6666;
69 description = mdDoc ''
70 Port to run the notification server on. `null` will not run the
71 server.
72 '';
73 };
74 };
75 };
76
77 mkName = name: "kanata-${name}";
78
79 mkDevices = devices: concatStringsSep ":" devices;
80
81 mkConfig = name: keyboard: pkgs.writeText "${mkName name}-config.kdb" ''
82 (defcfg
83 ${keyboard.extraDefCfg}
84 linux-dev ${mkDevices keyboard.devices})
85
86 ${keyboard.config}
87 '';
88
89 mkService = name: keyboard: nameValuePair (mkName name) {
90 description = "kanata for ${mkDevices keyboard.devices}";
91
92 # Because path units are used to activate service units, which
93 # will start the old stopped services during "nixos-rebuild
94 # switch", stopIfChanged here is a workaround to make sure new
95 # services are running after "nixos-rebuild switch".
96 stopIfChanged = false;
97
98 serviceConfig = {
99 ExecStart = ''
100 ${cfg.package}/bin/kanata \
101 --cfg ${mkConfig name keyboard} \
102 --symlink-path ''${RUNTIME_DIRECTORY}/${name} \
103 ${optionalString (keyboard.port != null) "--port ${toString keyboard.port}"} \
104 ${utils.escapeSystemdExecArgs keyboard.extraArgs}
105 '';
106
107 DynamicUser = true;
108 RuntimeDirectory = mkName name;
109 SupplementaryGroups = with config.users.groups; [
110 input.name
111 uinput.name
112 ];
113
114 # hardening
115 DeviceAllow = [
116 "/dev/uinput rw"
117 "char-input r"
118 ];
119 CapabilityBoundingSet = [ "" ];
120 DevicePolicy = "closed";
121 IPAddressAllow = optional (keyboard.port != null) "localhost";
122 IPAddressDeny = [ "any" ];
123 LockPersonality = true;
124 MemoryDenyWriteExecute = true;
125 PrivateNetwork = keyboard.port == null;
126 PrivateUsers = true;
127 ProcSubset = "pid";
128 ProtectClock = true;
129 ProtectControlGroups = true;
130 ProtectHome = true;
131 ProtectHostname = true;
132 ProtectKernelLogs = true;
133 ProtectKernelModules = true;
134 ProtectKernelTunables = true;
135 ProtectProc = "invisible";
136 RestrictAddressFamilies =
137 if (keyboard.port == null) then "none" else [ "AF_INET" ];
138 RestrictNamespaces = true;
139 RestrictRealtime = true;
140 SystemCallArchitectures = [ "native" ];
141 SystemCallFilter = [
142 "@system-service"
143 "~@privileged"
144 "~@resources"
145 ];
146 UMask = "0077";
147 };
148 };
149
150 mkPathName = i: name: "${mkName name}-${toString i}";
151
152 mkPath = name: n: i: device:
153 nameValuePair (mkPathName i name) {
154 description =
155 "${toString (i+1)}/${toString n} kanata trigger for ${name}, watching ${device}";
156 wantedBy = optional (i == 0) "multi-user.target";
157 pathConfig = {
158 PathExists = device;
159 # (ab)use systemd.path to construct a trigger chain so that the
160 # service unit is only started when all paths exist
161 # however, manual of systemd.path says Unit's suffix is not ".path"
162 Unit =
163 if (i + 1) == n
164 then "${mkName name}.service"
165 else "${mkPathName (i + 1) name}.path";
166 };
167 unitConfig.StopPropagatedFrom = optional (i > 0) "${mkName name}.service";
168 };
169
170 mkPaths = name: keyboard:
171 let
172 n = length keyboard.devices;
173 in
174 imap0 (mkPath name n) keyboard.devices
175 ;
176in
177{
178 options.services.kanata = {
179 enable = mkEnableOption (lib.mdDoc "kanata");
180 package = mkOption {
181 type = types.package;
182 default = pkgs.kanata;
183 defaultText = literalExpression "pkgs.kanata";
184 example = literalExpression "pkgs.kanata-with-cmd";
185 description = mdDoc ''
186 The kanata package to use.
187
188 ::: {.note}
189 If `danger-enable-cmd` is enabled in any of the keyboards, the
190 `kanata-with-cmd` package should be used.
191 :::
192 '';
193 };
194 keyboards = mkOption {
195 type = types.attrsOf (types.submodule keyboard);
196 default = { };
197 description = mdDoc "Keyboard configurations.";
198 };
199 };
200
201 config = mkIf cfg.enable {
202 hardware.uinput.enable = true;
203
204 systemd = {
205 paths = trivial.pipe cfg.keyboards [
206 (mapAttrsToList mkPaths)
207 concatLists
208 listToAttrs
209 ];
210 services = mapAttrs' mkService cfg.keyboards;
211 };
212 };
213
214 meta.maintainers = with maintainers; [ linj ];
215}