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}