main
1# Port from home-manager's programs.floorp
2# See https://github.com/nix-community/home-manager/tree/c53d643b3737e2fcd04e6cb3b3580ef50b2087a0/modules/programs/firefox
3{
4 config,
5 lib,
6 pkgs,
7 ...
8}: let
9 inherit (lib) concatStrings mapAttrsToList mkIf mkMerge mkOption optionalAttrs;
10 inherit (lib.types) attrsOf bool ints listOf nullOr package path str submodule;
11
12 modulePath = ["programs" "floorp"];
13 cfg = config.programs.floorp;
14
15 appName = "Floorp";
16 configPath = ".floorp";
17 extensionPath = "extensions/{ec8030f7-c20a-464f-9b0e-13a3a9e97384}";
18
19 jsonFormat = pkgs.formats.json {};
20
21 userPrefValue = pref:
22 builtins.toJSON (
23 if lib.isBool pref || lib.isInt pref || lib.isString pref || lib.isPath pref
24 then pref
25 else builtins.toJSON pref
26 );
27
28 extensionSettingsNeedForce = extensionSettings:
29 builtins.any (ext: ext.settings != {}) (builtins.attrValues extensionSettings);
30
31 extensionSettingsMissingForce = extensionSettings:
32 builtins.any (ext: ext.settings != {} && !ext.force) (builtins.attrValues extensionSettings);
33
34 mkUserJs = prePrefs: prefs: extraPrefs: extensions: let
35 prefs' =
36 optionalAttrs (extensionSettingsNeedForce extensions) {
37 "extensions.webextensions.ExtensionStorageIDB.enabled" = false;
38 }
39 // prefs;
40 in ''
41 // Generated by Hjem.
42
43 ${prePrefs}
44
45 ${concatStrings (
46 mapAttrsToList (name: value: ''
47 user_pref("${name}", ${userPrefValue value});
48 '')
49 prefs'
50 )}
51
52 ${extraPrefs}
53 '';
54
55 profilesIni = lib.generators.toINI {} (
56 lib.flip lib.mapAttrs' cfg.profiles (
57 _: profile:
58 lib.nameValuePair "Profile${toString profile.id}" {
59 Name = profile.name;
60 Path = profile.path;
61 IsRelative = 1;
62 Default =
63 if profile.isDefault
64 then 1
65 else 0;
66 }
67 )
68 // {
69 General = {
70 StartWithLastProfile = 1;
71 };
72 }
73 );
74
75 effectivePolicies =
76 cfg.policies
77 // lib.optionalAttrs (cfg.languagePacks != []) {
78 ExtensionSettings =
79 (cfg.policies.ExtensionSettings or {})
80 // lib.listToAttrs (map (lang:
81 lib.nameValuePair "langpack-${lang}@firefox.mozilla.org" {
82 installation_mode = "normal_installed";
83 install_url = "https://releases.mozilla.org/pub/firefox/releases/${cfg.release}/linux-x86_64/xpi/${lang}.xpi";
84 })
85 cfg.languagePacks);
86 };
87
88 mkSearchModule = {
89 pkgs,
90 modulePath,
91 profilePath,
92 package,
93 }: {
94 config,
95 lib,
96 ...
97 }: let
98 internalFieldNames =
99 lib.genAttrs
100 [
101 "name"
102 "isAppProvided"
103 "loadPath"
104 "updateInterval"
105 "updateURL"
106 "iconMapObj"
107 "metaData"
108 "orderHint"
109 "definedAliases"
110 "urls"
111 ] (name: "_${name}")
112 // {
113 searchForm = "__searchForm";
114 };
115
116 iconUrl = icon:
117 if lib.isPath icon || lib.hasPrefix "/" icon
118 then "file://${icon}"
119 else icon;
120
121 processCustomEngineInput = input:
122 {
123 name = input.id;
124 }
125 // (removeAttrs input ["icon"])
126 // optionalAttrs (input ? icon || input ? iconMapObj) {
127 iconMapObj = lib.mapAttrs (_name: iconUrl) (
128 (optionalAttrs (input ? icon) {
129 # Convenience to specify single icon instead of a map
130 "16" = input.icon;
131 })
132 // (input.iconMapObj or {})
133 );
134 }
135 // {
136 # Required for custom engine configurations; loadPaths are
137 # unique identifiers generally formatted as [source]/path/to/engine
138 loadPath = "[hjem]/${lib.showAttrPath (modulePath ++ ["engines" input.id])}";
139 };
140
141 processEngineInput = id: input: let
142 requiredInput = {
143 inherit id;
144 isAppProvided = input.isAppProvided or (removeAttrs input ["metaData"] == {});
145 metaData = input.metaData or {};
146 };
147 in
148 if requiredInput.isAppProvided
149 then requiredInput
150 else processCustomEngineInput (input // requiredInput);
151
152 buildEngineConfig = name: input:
153 lib.mapAttrs' (name: value: {
154 name = internalFieldNames.${name} or name;
155 inherit value;
156 }) (processEngineInput name input);
157
158 sortEngineConfigs = configs: let
159 buildEngineConfigWithOrder = order: id: let
160 config =
161 configs.${
162 id
163 } or {
164 inherit id;
165 _isAppProvided = true;
166 _metaData = {};
167 };
168 in
169 config // {_metaData = config._metaData // {inherit order;};};
170
171 engineConfigsWithoutOrder = lib.attrValues (removeAttrs configs config.order);
172
173 sortedEngineConfigs =
174 (lib.imap buildEngineConfigWithOrder config.order) ++ engineConfigsWithoutOrder;
175 in
176 sortedEngineConfigs;
177
178 engineInput =
179 config.engines
180 // {
181 # Infer defaults as app-provided engines if they're not in engines.
182 ${config.default} = config.engines.${config.default} or {};
183 }
184 // {
185 ${config.privateDefault} = config.engines.${config.privateDefault} or {};
186 };
187
188 settings = {
189 version = 12;
190 engines = sortEngineConfigs (lib.mapAttrs buildEngineConfig engineInput);
191
192 metaData =
193 optionalAttrs (config.default != null) {
194 defaultEngineId = config.default;
195 defaultEngineIdHash = "@hash@";
196 }
197 // optionalAttrs (config.privateDefault != null) {
198 privateDefaultEngineId = config.privateDefault;
199 privateDefaultEngineIdHash = "@privateHash@";
200 }
201 // {
202 useSavedOrder = config.order != [];
203 };
204 };
205
206 disclaimer =
207 "By modifying this file, I agree that I am doing so "
208 + "only within @appName@ itself, using official, user-driven search "
209 + "engine selection processes, and in a way which does not circumvent "
210 + "user consent. I acknowledge that any attempt to change this file "
211 + "from outside of @appName@ is a malicious act, and will be responded "
212 + "to accordingly.";
213
214 salt =
215 if config.default != null
216 then profilePath + config.default + disclaimer
217 else null;
218
219 privateSalt =
220 if config.privateDefault != null
221 then profilePath + config.privateDefault + disclaimer
222 else null;
223
224 appNameVariable =
225 if package == null
226 then "appName=${lib.escapeShellArg appName}"
227 else ''
228 applicationIni="$(find ${lib.escapeShellArg package} -maxdepth 3 -path ${lib.escapeShellArg package}'/lib/*/application.ini' -print -quit)"
229 if test -n "$applicationIni"; then
230 appName="$(sed -n 's/^Name=\(.*\)$/\1/p' "$applicationIni" | head -n1)"
231 else
232 appName=${lib.escapeShellArg appName}
233 fi
234 '';
235
236 file =
237 pkgs.runCommand "search.json.mozlz4" {
238 nativeBuildInputs = with pkgs; [
239 mozlz4a
240 openssl
241 ];
242 json = builtins.toJSON settings;
243 inherit salt privateSalt;
244 } ''
245 ${appNameVariable}
246
247 salt=''${salt//@appName@/"$appName"}
248 privateSalt=''${privateSalt//@appName@/"$appName"}
249
250 if [[ -n $salt ]]; then
251 export hash=$(echo -n "$salt" | openssl dgst -sha256 -binary | base64)
252 export privateHash=$(echo -n "$privateSalt" | openssl dgst -sha256 -binary | base64)
253 mozlz4a <(substituteStream json search.json.in --subst-var hash --subst-var privateHash) "$out"
254 else
255 mozlz4a <(echo "$json") "$out"
256 fi
257 '';
258 in {
259 options = {
260 enable = mkOption {
261 type = bool;
262 default = config.default != null || config.privateDefault != null || config.order != [] || config.engines != {};
263 internal = true;
264 };
265
266 force = mkOption {
267 type = bool;
268 default = false;
269 description = ''
270 Whether to force replace the existing search configuration.
271 '';
272 };
273
274 default = mkOption {
275 type = nullOr str;
276 default = null;
277 };
278
279 privateDefault = mkOption {
280 type = nullOr str;
281 default = null;
282 };
283
284 order = mkOption {
285 type = listOf str;
286 default = [];
287 };
288
289 engines = mkOption {
290 type = attrsOf (attrsOf jsonFormat.type);
291 default = {};
292 };
293
294 file = mkOption {
295 type = path;
296 default = file;
297 internal = true;
298 readOnly = true;
299 };
300 };
301 };
302in {
303 options = lib.setAttrByPath modulePath {
304 enable = mkOption {
305 type = bool;
306 default = false;
307 example = true;
308 };
309
310 package = mkOption {
311 type = nullOr package;
312 default = pkgs.floorp-bin;
313 defaultText = lib.literalExpression "pkgs.floorp-bin";
314 };
315
316 finalPackage = mkOption {
317 type = nullOr package;
318 readOnly = true;
319 };
320
321 release = mkOption {
322 internal = true;
323 type = str;
324 };
325
326 policies = mkOption {
327 type = attrsOf jsonFormat.type;
328 default = {};
329 };
330
331 languagePacks = mkOption {
332 type = listOf str;
333 default = [];
334 };
335
336 profiles = mkOption {
337 type = attrsOf (submodule (
338 {
339 name,
340 config,
341 ...
342 }: {
343 options = {
344 name = mkOption {
345 type = str;
346 default = name;
347 };
348
349 id = mkOption {
350 type = ints.unsigned;
351 default = 0;
352 };
353
354 isDefault = mkOption {
355 type = bool;
356 default = config.id == 0;
357 };
358
359 path = mkOption {
360 type = str;
361 default = name;
362 };
363
364 preConfig = mkOption {
365 type = str;
366 default = "";
367 };
368
369 settings = mkOption {
370 type = attrsOf jsonFormat.type;
371 default = {};
372 };
373
374 extraConfig = mkOption {
375 type = str;
376 default = "";
377 };
378
379 search = mkOption {
380 type = submodule (mkSearchModule {
381 inherit pkgs;
382 modulePath = modulePath ++ ["profiles" name "search"];
383 profilePath = config.path;
384 package = cfg.finalPackage;
385 });
386 default = {};
387 };
388
389 extensions = mkOption {
390 type = submodule {
391 options = {
392 packages = mkOption {
393 type = listOf package;
394 default = [];
395 };
396
397 force = mkOption {
398 type = bool;
399 default = false;
400 };
401
402 settings = mkOption {
403 type = attrsOf (submodule {
404 options = {
405 settings = mkOption {
406 type = attrsOf jsonFormat.type;
407 default = {};
408 };
409
410 force = mkOption {
411 type = bool;
412 default = false;
413 };
414 };
415 });
416 default = {};
417 };
418 };
419 };
420 default = {};
421 };
422 };
423 }
424 ));
425 default = {};
426 };
427 };
428
429 config = mkIf cfg.enable (
430 {
431 assertions =
432 mapAttrsToList (name: profile: {
433 assertion = !(extensionSettingsMissingForce profile.extensions.settings) || profile.extensions.force;
434 message = ''
435 programs.floorp: profile '${name}': using 'profiles.${name}.extensions.settings' will
436 override all previous extension settings. Enable either
437 'profiles.${name}.extensions.force' or the corresponding
438 'profiles.${name}.extensions.settings.<extensionId>.force' to acknowledge this.
439 '';
440 })
441 cfg.profiles
442 ++ [
443 {
444 assertion = cfg.languagePacks == [] || cfg.package != null;
445 message = "programs.floorp: languagePacks requires package to be set to a non-null value.";
446 }
447 ];
448
449 packages = lib.optional (cfg.finalPackage != null) cfg.finalPackage;
450
451 files = mkMerge ([
452 (mkIf (cfg.profiles != {}) {
453 "${configPath}/profiles.ini".text = profilesIni;
454 })
455 ]
456 ++ mapAttrsToList (
457 _: profile: let
458 extensionPackages =
459 builtins.filter (pkg: pkg ? addonId) profile.extensions.packages;
460 skippedExtensions =
461 builtins.filter (pkg: !(pkg ? addonId)) profile.extensions.packages;
462 in
463 mkMerge [
464 {
465 "${configPath}/${profile.path}".type = "directory";
466 }
467
468 (mkIf (
469 profile.preConfig
470 != ""
471 || profile.settings != {}
472 || profile.extraConfig != ""
473 || extensionSettingsNeedForce profile.extensions.settings
474 ) {
475 "${configPath}/${profile.path}/user.js".text = mkUserJs profile.preConfig profile.settings profile.extraConfig profile.extensions.settings;
476 })
477
478 (mkIf profile.search.enable {
479 "${configPath}/${profile.path}/search.json.mozlz4" = {
480 source = profile.search.file;
481 clobber = profile.search.force;
482 };
483 })
484
485 (mkIf (profile.extensions.packages != []) (
486 lib.warnIf (skippedExtensions != [])
487 "programs.floorp: extensions without `addonId` cannot be linked into the profile: ${
488 builtins.concatStringsSep ", " (map (pkg: pkg.name) skippedExtensions)
489 }"
490 ({
491 "${configPath}/${profile.path}/extensions" = {
492 type = "directory";
493 clobber = true;
494 };
495 }
496 // lib.listToAttrs (map (pkg: {
497 name = "${configPath}/${profile.path}/extensions/${pkg.addonId}.xpi";
498 value = {
499 source = "${pkg}/share/mozilla/${extensionPath}/${pkg.addonId}.xpi";
500 clobber = true;
501 };
502 })
503 extensionPackages))
504 ))
505
506 (mkMerge (mapAttrsToList
507 (
508 extId: ext:
509 mkIf (ext.settings != {}) {
510 "${configPath}/${profile.path}/browser-extension-data/${extId}/storage.js" = {
511 text = lib.generators.toJSON {} ext.settings;
512 clobber = ext.force || profile.extensions.force;
513 };
514 }
515 )
516 profile.extensions.settings))
517 ]
518 )
519 cfg.profiles);
520 }
521 // lib.setAttrByPath modulePath {
522 finalPackage =
523 if cfg.package == null
524 then null
525 else if cfg.package.override.__functionArgs ? cfg
526 then
527 cfg.package.override (old: {
528 cfg = old.cfg or {};
529 extraPolicies = (old.extraPolicies or {}) // effectivePolicies;
530 })
531 else
532 lib.warn
533 "programs.floorp: package does not support overriding; policies and language packs will not be applied."
534 cfg.package;
535
536 release = lib.mkOptionDefault (builtins.head (lib.splitString "-" cfg.package.version));
537 }
538 );
539}