Added a module to configure browser based on the gecko engine, such as Firefox.

This module should provide similar functionality to the firefox module in
home-manager. Some notable differences between the two include:

* home-manager configures a single browser. This means that any configuration
  that cannot be done on a per-profile basis is shared between all
  profiles. This module configures a new copy of the browser for every profile,
  ensuring that *all* configuration can be on a per-profile basis.

  This might be seen as insanity in a regular distro, but in NixOS this is
  trivial to do and requires no extra storage space.

* home-manager modifies files in the user's directory to configure things such
  as extensions and search engines. This module avoids that when possible by
  pushing configuration into policies and preferences at a browser level.
  This is much nicer for impermanence-based systems.
This commit is contained in:
hylodon 2026-02-22 19:57:42 +00:00
parent 977595f7e8
commit 0f0c4c7727
10 changed files with 962 additions and 1 deletions

View file

@ -1,2 +1,9 @@
sLib: { sLib:
let
mkGeckoBrowser = import ./gecko-browser/make-module.nix sLib;
in
{
firefox = mkGeckoBrowser "firefox";
floorp = mkGeckoBrowser "floorp";
librewolf = mkGeckoBrowser "librewolf";
} }

View file

@ -0,0 +1,104 @@
{
pkgs,
lib,
info,
...
}:
let
## BookmarkConfig -> BookmarkJSON
updateBookmarkJSON =
data:
builtins.removeAttrs
(
data
// (
if (data ? children) then
{
type = "text/x-moz-place-container";
children = builtins.map updateBookmarkJSON data.children;
}
else
{
type = "text/x-moz-place";
}
)
// lib.optionalAttrs (data ? addTime) { dateAdded = data.addTime; }
// lib.optionalAttrs (data ? modTime) { lastModified = data.modTime; }
)
[
"addTime"
"modTime"
];
in
{
options = {
bookmarks = lib.mkOption {
type = lib.types.submodule {
options = {
menu = lib.mkOption {
type = lib.types.listOf (lib.types.attrsOf lib.types.anything);
default = [ ];
description = "Bookmarks to be placed in the menu.";
};
toolbar = lib.mkOption {
type = lib.types.listOf (lib.types.attrsOf lib.types.anything);
default = [ ];
description = "Bookmarks to be placed in the toolbar";
};
unfiled = lib.mkOption {
type = lib.types.listOf (lib.types.attrsOf lib.types.anything);
default = [ ];
description = "Bookmarks to be placed in the unsorted bookmarks folder.";
};
mobile = lib.mkOption {
type = lib.types.listOf (lib.types.attrsOf lib.types.anything);
default = [ ];
description = "Bookmarks to be placed in the mobile bookmarks folder. This folder is not visible on PC, but the bookmarks can still be searched for.";
};
};
};
default = { };
description = "Declarative bookmarks for this profile.";
};
};
files =
{
name,
path,
bookmarks,
...
}:
[
({
name = "${info.configPath}/${path}/bookmarkbackups/bookmarks-1970-01-01.json";
value.text = builtins.toJSON (updateBookmarkJSON {
title = "";
root = "placesRoot";
children = [
{
title = "menu";
root = "bookmarksMenuFolder";
children = bookmarks.menu;
}
{
title = "toolbar";
root = "toolbarFolder";
children = bookmarks.toolbar;
}
{
title = "unfiled";
root = "unfiledBookmarksFolder";
children = bookmarks.unfiled;
}
{
title = "mobile";
root = "mobileFolder";
children = bookmarks.mobile;
}
];
});
})
];
}

View file

@ -0,0 +1,18 @@
args:
let
args' = args // {
inherit geckoLib;
};
geckoLib = {
bookmarks = import ./bookmarks.nix args';
extensions = import ./extensions.nix args';
policies = import ./policies.nix args';
prefs = import ./prefs.nix args';
profiles = import ./profiles.nix args';
search = import ./search.nix args';
site-settings = import ./site-settings.nix args';
};
in
geckoLib

View file

@ -0,0 +1,157 @@
{
pkgs,
lib,
info,
...
}:
let
submodule =
{ name, ... }:
{
options = {
packages = lib.mkOption {
type =
lib.types.listOf
<| lib.types.either lib.types.package
<| lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
default = name;
example = "ublock-origin";
description = "The short name of the extension. To find the short name look at the extension's download link.";
};
guid = lib.mkOption {
type = lib.types.str;
example = "uBlock0@raymondhill.net";
description = "The guid of the extension. To find the guid go to https://addons.mozilla.org/api/v5/addons/addon/\${shortName}/";
};
};
};
default = [ ];
description = ''
Extensions to install for this profile. Takes either a package or a description of an extension.
* If a package is given, the extension will be preinstalled.
* If a description is given then ${info.name} will download the extension on startup.
'';
};
settings = lib.mkOption {
type =
let
jsonFormat = pkgs.formats.json { };
in
lib.types.attrsOf jsonFormat.type;
default = { };
description = "Settings for this extension, accessible by the managed storage API. The extension must be referenced by id.";
};
};
};
# (Package -> a) -> ({ name, guid } -> a) -> Extension -> a
withExtension =
f: g: x:
if lib.isDerivation x then f x else g x;
# ublock-origin wants its settings as a string, which is annoying
# to merge. For convenience, you can keep ublock-origin's config
# as a set and this function will turn it into a string at the
# last minute. As a bonus, we can also do other magic for other
# extensions if need be.
magicInfo = {
"uBlock0@raymondhill.net" =
x:
x
// lib.optionalAttrs (x ? adminSettings) {
adminSettings =
builtins.toJSON
<| (
y:
y
// lib.optionalAttrs (y ? userFilters) {
userFilters = builtins.concatStringsSep "\n" y.userFilters;
}
)
<| (
y:
y
// lib.optionalAttrs (y ? urlFilteringString) {
urlFilteringString = builtins.concatStringsSep "\n" y.urlFilteringString;
}
)
<| (
y:
y
// lib.optionalAttrs (y ? hostnameSwitchesString) {
hostnameSwitchesString = builtins.concatStringsSep "\n" y.hostnameSwitchesString;
}
)
<| (
y:
y
// lib.optionalAttrs (y ? dynamicFilteringString) {
dynamicFilteringString = builtins.concatStringsSep "\n" y.dynamicFilteringString;
}
)
<| x.adminSettings;
};
};
magic = guid: magicInfo.${guid} or lib.trivial.id;
in
{
options = {
extensions = lib.mkOption {
type = lib.types.submodule submodule;
default = { };
# example = {
# packages = [
# nur.repos.rycee.firefox-addons.ublock-origin
# { name = "decentraleyes"; guid = "jid1-BoFifL9Vbdl2zQ@jetpack"; }
# ];
# settings."uBlock0@raymondhill.net" = {
# whitelist = [ "example.org" ];
# };
# };
description = "The extensions to install.";
};
};
process =
{ extensions, ... }:
{
policies = {
"3rdparty".Extensions = extensions.settings;
ExtensionSettings =
builtins.listToAttrs
<| builtins.map (
withExtension
(x: {
name = x.addonId;
value = {
installation_mode = "force_installed";
private_browsing = true;
install_url = "file://${x}/share/mozilla/extensions/{ec8030f7-c20a-464f-9b0e-13a3a9e97384}/${x.addonId}.xpi";
};
})
(x: {
name = x.guid;
value = {
installation_mode = "force_installed";
private_browsing = true;
install_url = "https://addons.mozilla.org/firefox/downloads/latest/${x.name}/latest.xpi";
};
})
)
<| extensions.packages;
};
prefs."extensions.autoDisableScopes" = 0;
};
postprocess =
{ policies, ... }:
lib.optionalAttrs (policies ? "3rdparty".Extensions) {
policies."3rdparty".Extensions = builtins.mapAttrs magic <| policies."3rdparty".Extensions or { };
};
}

View file

@ -0,0 +1,32 @@
{ pkgs, lib, ... }:
let
type =
let
jsonFormat = pkgs.formats.json { };
in
jsonFormat.type;
in
{
inherit type;
options = {
policies = lib.mkOption {
inherit type;
default = { };
description = "[See list of policies](https://mozilla.github.io/policy-templates/).";
example = {
DefaultDownloadDirectory = "\${home}/Downloads";
BlockAboutConfig = true;
ExtensionSettings = {
"uBlock0@raymondhill.net" = {
install_url = "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi";
installation_mode = "force_installed";
default_area = "menupanel";
private_browsing = true;
};
};
};
};
};
}

View file

@ -0,0 +1,49 @@
{
pkgs,
lib,
info,
...
}:
let
type = lib.types.attrsOf (
lib.types.oneOf [
lib.types.int
lib.types.bool
lib.types.str
]
);
in
{
inherit type;
options = {
prefs = lib.mkOption {
inherit type;
default = { };
description = "User preferences.";
};
lockPref = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = "When adding user preferences, determine whether to use lockPref over user_pref";
};
};
postprocess =
{ lockPref, prefs, ... }:
{
prefs =
builtins.concatStringsSep "\n"
<| (x: [ "// Generated by ${info.name} module" ] ++ x)
<| lib.mapAttrsToList (
let
prefFunc = if lockPref then "lockPref" else "user_pref";
in
key: val: ''${prefFunc}("${key}", ${builtins.toJSON val});''
)
<| prefs;
};
}

View file

@ -0,0 +1,175 @@
{
lib,
pkgs,
sLib,
info,
geckoLib,
...
}@args:
let
# (a -> b -> c) -> [a] -> [b] -> [c]
mapProduct =
f: xs: ys:
builtins.concatMap (y: builtins.concatMap (x: f x y) xs) ys;
# (Lib -> Profile -> c) -> ProfileSettings -> [c]
mapLibAndProfile = f: x: mapProduct f allLibs <| builtins.attrValues <| x;
allLibs = builtins.attrValues <| lib.filterAttrs (k: _: k != "profiles") <| geckoLib;
allLibs' = allLibs ++ [ geckoLib.profiles ];
submodule =
{ config, name, ... }:
{
options = {
id = lib.mkOption {
type = lib.types.ints.unsigned;
default = 0;
description = "Profile ID";
};
name = lib.mkOption {
type = lib.types.str;
default = name;
description = "Profile name";
};
path = lib.mkOption {
type = lib.types.str;
default = name;
description = "Profile path";
};
isDefault = lib.mkOption {
type = lib.types.bool;
default = config.id == 0;
defaultText = "config.id == 0";
description = "Is profile the default?";
};
languagePacks = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [
"en-GB"
"de"
];
description = "The language packs to install.";
};
}
// builtins.foldl' (x: y: x // y.options) { } allLibs;
};
writeProfilesIni = configPath: profileSettings: {
name = "${configPath}/profiles.ini";
value.text =
lib.generators.toINI { }
<|
lib.mapAttrs' (
_: v:
lib.nameValuePair "Profile${builtins.toString v.id}" {
Name = v.name;
Path = v.path;
IsRelative = 1;
Default = if v.isDefault then 1 else 0;
}
) profileSettings
// {
General = {
StartWithLastProfile = 1;
Version = 2;
};
};
};
in
{
options = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule submodule);
default = { };
description = "Sets browser settings on a per-profile basis.";
};
getDefaultProfileName =
profileSettings:
builtins.head
<| builtins.filter (x: profileSettings.${x}.isDefault)
<| builtins.attrNames
<| profileSettings;
process =
{ languagePacks, ... }:
let
extractVersion =
(x: if x == [ ] then info.base.version else builtins.head x)
<| builtins.match "([^-]*)-.*"
<| info.base.version;
in
{
policies.ExtensionSettings =
builtins.listToAttrs
<| builtins.map (lang: {
name = "langpack-${lang}@firefox.mozilla.org";
value = {
installation_mode = "force_installed";
install_url = "https://releases.mozilla.org/pub/firefox/releases/${extractVersion}/linux-x86_64/xpi/${lang}.xpi";
};
})
<| languagePacks;
};
assertions = profiles: [
{
assertion = profiles != { };
message = "No profiles are defined.";
}
{
assertion =
(x: x == 1)
<| builtins.length
<| builtins.filter (x: profiles.${x}.isDefault)
<| builtins.attrNames
<| profiles;
message = "One, and only one, profile must be marked as 'isDefault'. If you have not set this option on any profile, then one and only one profile must have id '0'.";
}
];
warnings = _: [ ];
evaluateAllProfiles =
profiles:
let
processLevel =
level: userConfig:
sLib.recursiveMergeAll
<| (x: [ userConfig ] ++ x)
<| builtins.map (myLib: (myLib.${level} or (_: { })) userConfig)
<| allLibs';
# Internal libraries can create their own configuration that needs to be merged
# into the user configuration (the user's takes priority). Generate that config.
finalConfig = builtins.mapAttrs (
_: v: processLevel "postprocess" <| processLevel "process" <| processLevel "preprocess" <| v
) profiles;
in
{
assertions =
mapLibAndProfile (x: x.assertions or (_: [ ])) finalConfig
++ geckoLib.profiles.assertions finalConfig;
warnings =
mapLibAndProfile (x: x.warnings or (_: [ ])) finalConfig ++ geckoLib.profiles.warnings finalConfig;
profiles = lib.mapAttrs (_: v: {
policies = v.policies;
prefs = v.prefs;
files = builtins.concatMap (x: (x.files or (_: [ ])) v) allLibs';
extFiles = builtins.concatMap (x: (x.extFiles or (_: [ ])) v) allLibs';
}) finalConfig;
files = [
(writeProfilesIni info.configPath finalConfig)
]
++ mapLibAndProfile (x: x.files or (_: [ ])) finalConfig;
};
}

View file

@ -0,0 +1,118 @@
{ lib, ... }:
let
submodule =
{ config, ... }:
{
options = {
default = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "ddg";
description = "The default search engine used in the address bar and search bar.";
};
engines = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule engineSubmodule);
default = { };
};
};
};
engineSubmodule =
{ name, ... }:
{
options = {
name = lib.mkOption {
type = lib.types.str;
default = name;
};
searchUri = {
base = lib.mkOption {
type = lib.types.strMatching "[a-z]+://[-A-Za-z0-9.]+(:[0-9]+)?(/[^?]*)?";
};
params = lib.mkOption {
type = lib.types.listOf (
lib.types.submodule {
options = {
key = lib.mkOption { type = lib.types.str; };
value = lib.mkOption { type = lib.types.str; };
};
}
);
default = [ ];
};
};
method = lib.mkOption {
type = lib.types.enum [
"GET"
"POST"
];
default = "GET";
};
alias = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
};
icon = lib.mkOption {
type = lib.types.nullOr (
lib.types.oneOf [
lib.types.package
lib.types.str
]
);
default = null;
};
};
};
buildPolicyFromEngine =
{
name,
searchUri,
method,
alias,
icon,
}:
let
params =
builtins.concatStringsSep "&" <| builtins.map (x: "${x.key}=${x.value}") <| searchUri.params;
in
{
Name = name;
URLTemplate =
if method == "POST" then
searchUri.base
else if params == "" then
searchUri.base
else
"${searchUri.base}?${params}";
Method = method;
${if method == "POST" then "PostData" else null} = params;
${if icon != null then "IconURL" else null} = "file://${builtins.toString icon}";
${if alias != null then "Alias" else null} = alias;
};
in
{
options = {
search = lib.mkOption {
type = lib.types.submodule submodule;
default = { };
description = "Declarative search engine configuration.";
};
};
process =
{ search, ... }:
{
policies.SearchEngines = {
${if search.default == null then null else "Default"} = search.default;
Add = lib.mapAttrsToList (_: buildPolicyFromEngine) search.engines;
};
};
}

View file

@ -0,0 +1,210 @@
{
lib,
sLib,
info,
...
}:
let
submodule = isDefault: {
options = {
https-only = lib.mkOption {
type = lib.types.bool;
default = false; # if isDefault then false else config.default.https-only;
example = true;
description = "Whether ${
if isDefault then "all domains" else "this domain"
} should be accessed over https only.";
};
cookies = lib.mkOption {
type = lib.types.enum (
[
"forever"
"session"
"never"
true
false
]
++ lib.optional (!isDefault) null
);
default = if isDefault then "forever" else null;
example = "session";
description = ''
Determines how cookies are handled on this domain:
forever: Cookies are permitted
session: Cookies are permitted, but they are deleted when the session ends
never: Cookies are denied
true: Equivalent to "session"
false: Equivalent to "never"
${if isDefault then "" else "null: Use the default settings"}
'';
};
${if isDefault then null else "ubo"} = lib.mkOption {
type = lib.types.bool;
default = true;
example = false;
description = "Whether ublock-origin should be enabled on this domain.";
};
scripts = lib.mkOption {
type = (if isDefault then (x: x) else lib.types.nullOr) lib.types.bool;
default = if isDefault then true else null;
example = false;
description = "Whether to allow JavaScript to run on websites.${
if isDefault then "" else " If null, use the default settings."
}";
};
fonts = lib.mkOption {
type = (if isDefault then (x: x) else lib.types.nullOr) lib.types.bool;
default = if isDefault then true else null;
example = false;
description = "Whether to allow websites to use remote fonts.";
};
popups = lib.mkOption {
type = (if isDefault then (x: x) else lib.types.nullOr) lib.types.bool;
default = if isDefault then true else null;
example = false;
description = "Whether to allow popups.";
};
largeMedia = lib.mkOption {
type = (if isDefault then (x: x) else lib.types.nullOr) lib.types.bool;
default = if isDefault then true else null;
example = false;
description = "Whether to allow large media.";
};
subdomain = {
allow = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "google.com" ];
description = "List of subdomains that ${
if isDefault then "any" else "this"
} domain can load resources from.";
};
block = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "google.com" ];
description = "List of subdomains that ${
if isDefault then "any" else "this"
} domain cannot load resources from.";
};
noop = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "google.com" ];
description = "List of subdomains that ${
if isDefault then "any" else "this"
} domain can load resources from, if a static filter does not override this rule.";
};
};
${if isDefault then null else "filters"} = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "#irritating-popup" ];
description = "List of ublock filters to apply to this domain.";
};
};
};
in
{
options = {
per-site = {
default = lib.mkOption {
type = lib.types.submodule (submodule true);
default = { };
description = "Sets options for all websites.";
};
site = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule (submodule false));
default = { };
description = "Sets options on a per-website basis.";
};
};
};
preprocess =
{ per-site, ... }:
let
inherit (per-site) default site;
behavior =
if
builtins.elem default.cookies [
"forever"
"session"
true
]
then
"accept"
else
"reject";
filterCookies =
x:
builtins.concatLists
<| lib.mapAttrsToList (
k: v:
if builtins.elem v.cookies x then
[ "https://${k}/" ] ++ lib.optional (!v.https-only) "http://${k}/"
else
[ ]
)
<| site;
makeUboConf = domain: config: {
adminSettings = {
whitelist = lib.optional (!(config.ubo or true)) domain;
hostnameSwitchesString =
lib.optional (config.scripts != null) "no-scripting: ${domain} ${builtins.toJSON (!config.scripts)}"
++
lib.optional (config.fonts != null)
"no-remote-fonts: ${domain} ${builtins.toJSON (!config.fonts)}"
++ lib.optional (config.popups != null) "no-popups: ${domain} ${builtins.toJSON (!config.popups)}"
++
lib.optional (config.largeMedia != null)
"no-large-media: ${domain} ${builtins.toJSON (!config.largeMedia)}";
dynamicFilteringString =
builtins.map (x: "${domain} ${x} * allow") config.subdomain.allow
++ builtins.map (x: "${domain} ${x} * block") config.subdomain.block
++ builtins.map (x: "${domain} ${x} * noop") config.subdomain.noop;
userFilters = builtins.map (x: "${domain}##${x}") (config.filters or [ ]);
};
# toOverwrite = {
# trustedSiteDirectives = lib.optional (!(config.ubo or true)) domain;
# filters = builtins.map (x: "${domain}##${x}") (config.filters or []);
# };
};
in
{
policies = {
Cookies = {
Allow = filterCookies [ "forever" ];
AllowSession = filterCookies [
"session"
true
];
Block = filterCookies [
"never"
false
];
Behavior = behavior;
BehaviorPrivateBrowsing = behavior;
};
SanitizeOnShutdown.Cookies = builtins.elem default.cookies [
"session"
true
];
};
extensions.settings."uBlock0@raymondhill.net" =
sLib.recursiveMergeAll <| [ (makeUboConf "*" default) ] ++ lib.mapAttrsToList makeUboConf site;
};
}

View file

@ -0,0 +1,91 @@
sLib: fork:
{
config,
lib,
pkgs,
...
}:
let
info =
{
"firefox" = {
name = "firefox";
base = pkgs.firefox-unwrapped;
binName = "firefox";
configPath = ".mozilla/firefox";
};
"librewolf" = {
name = "librewolf";
base = pkgs.librewolf-unwrapped;
binName = "librewolf";
configPath = ".librewolf";
};
"floorp" = {
name = "floorp";
base = pkgs.floorp-bin-unwrapped;
binName = "floorp"; # TODO: test
configPath = ".floorp"; # TODO: test
};
}
.${fork};
cfg = config.hylonix.${info.name};
geckoLib = import ./gecko-lib {
inherit
geckoLib
info
lib
pkgs
sLib
;
};
finalConfig = geckoLib.profiles.evaluateAllProfiles cfg.profiles;
buildFinal =
profile: settings:
let
pkg = (
pkgs.wrapFirefox info.base {
extraPolicies = settings.policies;
extraPrefs = settings.prefs;
}
);
in
{
package = pkg;
command = "${pkg}/bin/${info.binName} -P ${profile}";
};
in
{
options.hylonix.${info.name} = {
enable = lib.mkEnableOption fork;
final = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
package = lib.mkOption { type = lib.types.package; };
command = lib.mkOption { type = lib.types.str; };
};
}
);
visible = false;
readOnly = true;
description = "Resulting browser information for this profile.";
};
profiles = geckoLib.profiles.options;
}; # end options
config = lib.mkIf cfg.enable {
inherit (finalConfig) assertions warnings;
hylonix.${info.name}.final = lib.mapAttrs buildFinal finalConfig.profiles;
home.file = builtins.listToAttrs finalConfig.files;
home.packages = [ cfg.final.${geckoLib.profiles.getDefaultProfileName cfg.profiles}.package ];
}; # end config
}