mkFirefoxModule: make profile extensions extensible

Restore nested option extensibility for
`programs.firefox.profiles.<name>.extensions` by keeping it as a plain
submodule.

This removes the deprecated `programs.firefox.profiles.<name>.extensions
= [ ... ]` shorthand. Add-ons must now be declared with
`programs.firefox.profiles.<name>.extensions.packages`, while
declarative settings remain under `extensions.settings`.

Add a regression test for downstream `extensions.*` option extension and
a news entry documenting the migration.
This commit is contained in:
Austin Horstman
2026-04-23 17:50:38 -05:00
parent b869d6cadb
commit 6012cf1fed
4 changed files with 211 additions and 145 deletions

View File

@@ -0,0 +1,13 @@
{ config, ... }:
{
time = "2026-04-23T18:35:00+00:00";
condition = config.programs.firefox.enable;
message = ''
The deprecated `programs.firefox.profiles.<name>.extensions = [ ... ]`
shorthand has been removed.
Use `programs.firefox.profiles.<name>.extensions.packages = [ ... ]`
to install add-ons, and keep declarative extension settings under
`programs.firefox.profiles.<name>.extensions.settings`.
'';
}

View File

@@ -661,152 +661,137 @@ in
'';
};
extensions = mkOption {
type =
types.coercedTo (types.listOf types.package)
(packages: {
packages = mkIf (builtins.length packages > 0) (
lib.warn ''
In order to support declarative extension configuration,
extension installation has been moved from
${moduleName}.profiles.<profile>.extensions
to
${moduleName}.profiles.<profile>.extensions.packages
'' packages
type = types.submodule {
options = {
packages = mkOption {
type = types.listOf types.package;
default = [ ];
example = literalExpression ''
with pkgs.nur.repos.rycee.firefox-addons; [
privacy-badger
]
'';
description = ''
List of ${name} add-on packages to install for this profile.
Some pre-packaged add-ons are accessible from the Nix User Repository.
Once you have NUR installed run
```console
$ nix-env -f '<nixpkgs>' -qaP -A nur.repos.rycee.firefox-addons
```
to list the available ${name} add-ons.
Note that it is necessary to manually enable these extensions
inside ${name} after the first installation.
To automatically enable extensions add
`"extensions.autoDisableScopes" = 0;`
to
[{option}`${moduleName}.profiles.<profile>.settings`](#opt-${moduleName}.profiles._name_.settings)
'';
};
force = mkOption {
description = ''
Whether to override all previous firefox settings.
This is required when using `settings`.
'';
default = false;
example = true;
type = types.bool;
};
exhaustivePermissions = mkOption {
description = ''
When enabled, the user must authorize requested
permissions for all extensions from
{option}`${moduleName}.profiles.<profile>.extensions.packages`
in
{option}`${moduleName}.profiles.<profile>.extensions.settings.<extensionID>.permissions`
'';
default = false;
example = true;
type = types.bool;
};
exactPermissions = mkOption {
description = ''
When enabled,
{option}`${moduleName}.profiles.<profile>.extensions.settings.<extensionID>.permissions`
must specify the exact set of permissions that the
extension will request.
This means that if the authorized permissions are
broader than what the extension requests, the
assertion will fail.
'';
default = false;
example = true;
type = types.bool;
};
settings = mkOption {
default = { };
example = literalExpression ''
{
# Example with uBlock origin's extensionID
"uBlock0@raymondhill.net".settings = {
selectedFilterLists = [
"ublock-filters"
"ublock-badware"
"ublock-privacy"
"ublock-unbreak"
"ublock-quick-fixes"
];
};
# Example with Stylus' UUID-form extensionID
"{7a7a4a92-a2a0-41d1-9fd7-1e92480d612d}".settings = {
dbInChromeStorage = true; # required for Stylus
}
}
'';
description = ''
Attribute set of options for each extension.
The keys of the attribute set consist of the ID of the extension
or its UUID wrapped in curly braces.
'';
type = types.attrsOf (
types.submodule {
options = {
settings = mkOption {
type = types.attrsOf jsonFormat.type;
default = { };
description = "Json formatted options for this extension.";
};
permissions = mkOption {
type = types.nullOr (types.listOf types.str);
default = null;
example = [ "activeTab" ];
defaultText = "Any permissions";
description = ''
Allowed permissions for this extension. See
<https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions>
for a list of relevant permissions.
'';
};
force = mkOption {
type = types.bool;
default = false;
example = true;
description = ''
Forcibly override any existing configuration for
this extension.
'';
};
};
}
);
})
(
types.submodule {
options = {
packages = mkOption {
type = types.listOf types.package;
default = [ ];
example = literalExpression ''
with pkgs.nur.repos.rycee.firefox-addons; [
privacy-badger
]
'';
description = ''
List of ${name} add-on packages to install for this profile.
Some pre-packaged add-ons are accessible from the Nix User Repository.
Once you have NUR installed run
```console
$ nix-env -f '<nixpkgs>' -qaP -A nur.repos.rycee.firefox-addons
```
to list the available ${name} add-ons.
Note that it is necessary to manually enable these extensions
inside ${name} after the first installation.
To automatically enable extensions add
`"extensions.autoDisableScopes" = 0;`
to
[{option}`${moduleName}.profiles.<profile>.settings`](#opt-${moduleName}.profiles._name_.settings)
'';
};
force = mkOption {
description = ''
Whether to override all previous firefox settings.
This is required when using `settings`.
'';
default = false;
example = true;
type = types.bool;
};
exhaustivePermissions = mkOption {
description = ''
When enabled, the user must authorize requested
permissions for all extensions from
{option}`${moduleName}.profiles.<profile>.extensions.packages`
in
{option}`${moduleName}.profiles.<profile>.extensions.settings.<extensionID>.permissions`
'';
default = false;
example = true;
type = types.bool;
};
exactPermissions = mkOption {
description = ''
When enabled,
{option}`${moduleName}.profiles.<profile>.extensions.settings.<extensionID>.permissions`
must specify the exact set of permissions that the
extension will request.
This means that if the authorized permissions are
broader than what the extension requests, the
assertion will fail.
'';
default = false;
example = true;
type = types.bool;
};
settings = mkOption {
default = { };
example = literalExpression ''
{
# Example with uBlock origin's extensionID
"uBlock0@raymondhill.net".settings = {
selectedFilterLists = [
"ublock-filters"
"ublock-badware"
"ublock-privacy"
"ublock-unbreak"
"ublock-quick-fixes"
];
};
# Example with Stylus' UUID-form extensionID
"{7a7a4a92-a2a0-41d1-9fd7-1e92480d612d}".settings = {
dbInChromeStorage = true; # required for Stylus
}
}
'';
description = ''
Attribute set of options for each extension.
The keys of the attribute set consist of the ID of the extension
or its UUID wrapped in curly braces.
'';
type = types.attrsOf (
types.submodule {
options = {
settings = mkOption {
type = types.attrsOf jsonFormat.type;
default = { };
description = "Json formatted options for this extension.";
};
permissions = mkOption {
type = types.nullOr (types.listOf types.str);
default = null;
example = [ "activeTab" ];
defaultText = "Any permissions";
description = ''
Allowed permissions for this extension. See
<https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions>
for a list of relevant permissions.
'';
};
force = mkOption {
type = types.bool;
default = false;
example = true;
description = ''
Forcibly override any existing configuration for
this extension.
'';
};
};
}
);
};
};
}
);
};
};
};
default = { };
description = ''
Submodule for installing and configuring extensions.

View File

@@ -21,6 +21,7 @@ builtins.mapAttrs
"${name}-profiles-duplicate-ids" = ./profiles/duplicate-ids.nix;
"${name}-profiles-extensions" = ./profiles/extensions;
"${name}-profiles-extensions-assertions" = ./profiles/extensions/assertions.nix;
"${name}-profiles-extensions-extensible" = ./profiles/extensions/extensible.nix;
"${name}-profiles-extensions-exhaustive" = ./profiles/extensions/exhaustive.nix;
"${name}-profiles-extensions-exact" = ./profiles/extensions/exact.nix;
"${name}-profiles-handlers" = ./profiles/handlers;

View File

@@ -0,0 +1,67 @@
modulePath:
{ lib, pkgs, ... }:
let
homeManagerModules = import ../../../../../../modules/modules.nix {
inherit lib pkgs;
check = false;
};
extensionsOptionPath = modulePath ++ [
"profiles"
"default"
"extensions"
];
thirdPartyOptionPath = extensionsOptionPath ++ [ "thirdParty" ];
packagesOptionPath = extensionsOptionPath ++ [ "packages" ];
evalResult = builtins.tryEval (
let
evaluated = lib.evalModules {
specialArgs = { inherit pkgs; };
modules = homeManagerModules ++ [
{
home = {
username = "hm-user";
homeDirectory = "/home/hm-user";
stateVersion = "25.05";
};
}
{
options = lib.setAttrByPath (modulePath ++ [ "profiles" ]) (
lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options.extensions.thirdParty = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Third-party regression test option.";
};
}
);
}
);
config = lib.setAttrByPath thirdPartyOptionPath true;
}
{
config = lib.setAttrByPath packagesOptionPath [ pkgs.hello ];
}
];
};
in
{
thirdParty = lib.getAttrFromPath thirdPartyOptionPath evaluated.config;
packageCount = builtins.length (lib.getAttrFromPath packagesOptionPath evaluated.config);
}
);
in
{
nmt.script = ''
test '${builtins.toJSON evalResult.success}' = 'true'
test '${
builtins.toJSON (if evalResult.success then evalResult.value.thirdParty else null)
}' = 'true'
test '${builtins.toJSON (if evalResult.success then evalResult.value.packageCount else null)}' = '1'
'';
}