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