firefox: add handlers.json configuration

Adds support for configuring Firefox's handlers.json file to
manage MIME type and URL scheme handlers declaratively (at
`programs.firefox.profiles.<profile>.handlers`). Handlers control
how Firefox opens files and protocols (e.g., PDF viewers,
`mailto:` handlers).
This commit is contained in:
André Kugland 2026-02-13 18:40:30 -03:00 committed by Austin Horstman
parent c9507a9aa5
commit a913ae61bf
6 changed files with 364 additions and 0 deletions

View file

@ -0,0 +1,18 @@
{ config, ... }:
{
time = "2025-12-10T07:15:59+00:00";
condition = config.programs.firefox.enable;
message = ''
The Firefox module now provides a
'programs.firefox.profiles.<name>.handlers' option.
It allows declarative configuration of MIME type and URL scheme handlers
through Firefox's handlers.json file, controlling how Firefox opens files
and protocols (e.g., PDF viewers, mailto handlers).
Configure handlers with:
programs.firefox.profiles.<name>.handlers.mimeTypes
programs.firefox.profiles.<name>.handlers.schemes
'';
}

View file

@ -548,6 +548,25 @@ in
description = "Declarative search engine configuration."; description = "Declarative search engine configuration.";
}; };
handlers = mkOption {
type = types.submodule (
args:
import ./profiles/handlers.nix {
inherit (args) config;
inherit lib pkgs appName;
package = cfg.finalPackage;
modulePath = modulePath ++ [
"profiles"
name
"handlers"
];
profilePath = config.path;
}
);
default = { };
description = "Declarative handlers configuration for MIME types and URL schemes.";
};
containersForce = mkOption { containersForce = mkOption {
type = types.bool; type = types.bool;
default = false; default = false;
@ -1048,6 +1067,11 @@ in
source = profile.search.file; source = profile.search.file;
}; };
"${cfg.profilesPath}/${profile.path}/handlers.json" = mkIf (profile.handlers.enable) {
source = profile.handlers.configFile;
force = profile.handlers.force;
};
"${cfg.profilesPath}/${profile.path}/extensions" = mkIf (profile.extensions.packages != [ ]) { "${cfg.profilesPath}/${profile.path}/extensions" = mkIf (profile.extensions.packages != [ ]) {
source = source =
let let

View file

@ -0,0 +1,209 @@
{
config,
lib,
pkgs,
appName,
...
}:
let
jsonFormat = pkgs.formats.json { };
# Process configuration, remove null values and empty handlers arrays.
genCfg =
cfg:
lib.mapAttrs (
_: item:
(removeAttrs item [ "handlers" ])
// (lib.optionalAttrs (item.handlers != [ ]) {
handlers = map (handler: lib.filterAttrsRecursive (_: v: v != null) handler) item.handlers;
})
) cfg;
# Common options shared between mimeTypes and schemes
commonHandlerOptions = {
action = lib.mkOption {
type = lib.types.enum [
0
1
2
3
4
];
default = 1;
description = ''
The action to take for this MIME type / URL scheme. Possible values:
- 0: Save file
- 1: Always ask
- 2: Use helper app
- 3: Open in ${appName}
- 4: Use system default
'';
};
ask = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
If true, the user is asked what they want to do with the file.
If false, the action is taken without user intervention.
'';
};
handlers = lib.mkOption {
type = lib.types.listOf (
lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Display name of the handler.
'';
};
path = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Path to the executable to be used.
Only one of 'path' or 'uriTemplate' should be set.
'';
};
uriTemplate = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
URI for the application handler.
Only one of 'path' or 'uriTemplate' should be set.
'';
};
};
}
);
default = [ ];
description = ''
An array of handlers with the first one being the default.
If you don't want to have a default handler, use an empty object for the first handler.
Only valid when action is set to 2 (Use helper app).
'';
};
};
in
{
imports = [ (pkgs.path + "/nixos/modules/misc/meta.nix") ];
meta.maintainers = with lib.maintainers; [ kugland ];
options = {
enable = lib.mkOption {
type = lib.types.bool;
default = config.schemes != { } || config.mimeTypes != { };
internal = true;
};
force = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to force replace the existing handlers configuration.
'';
};
mimeTypes = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = commonHandlerOptions // {
extensions = lib.mkOption {
type = lib.types.listOf (lib.types.strMatching "^[^\\.].+$");
default = [ ];
example = [
"jpg"
"jpeg"
];
description = ''
List of file extensions associated with this MIME type.
'';
};
};
}
);
default = { };
example = lib.literalExpression ''
{
"application/pdf" = {
action = 2;
ask = false;
handlers = [
{
name = "Okular";
path = "''${pkgs.okular}/bin/okular";
}
];
extensions = [ "pdf" ];
};
}
'';
description = ''
Attribute set mapping MIME types to their handler configurations.
For a configuration example, see [this file on Firefoxs source code](https://github.com/mozilla-firefox/firefox/blob/c3797cdebac1316dd7168e995e3468c5a597e8d1/uriloader/exthandler/tests/unit/handlers.json).
'';
};
schemes = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = commonHandlerOptions;
}
);
default = { };
example = lib.literalExpression ''
{
mailto = {
action = 2;
ask = false;
handlers = [
{
name = "Gmail";
uriTemplate = "https://mail.google.com/mail/?extsrc=mailto&url=%s";
}
];
};
}
'';
description = ''
Attribute set mapping URL schemes to their handler configurations.
For a configuration example, see [this file on Firefoxs source code](https://github.com/mozilla-firefox/firefox/blob/c3797cdebac1316dd7168e995e3468c5a597e8d1/uriloader/exthandler/tests/unit/handlers.json).
'';
};
finalSettings = lib.mkOption {
type = jsonFormat.type;
internal = true;
readOnly = true;
default = {
defaultHandlersVersion = { };
isDownloadsImprovementsAlreadyMigrated = false;
mimeTypes = genCfg config.mimeTypes;
schemes = genCfg config.schemes;
};
description = ''
Resulting handlers.json settings.
'';
};
configFile = lib.mkOption {
type = lib.types.path;
internal = true;
readOnly = true;
default = jsonFormat.generate "handlers.json" config.finalSettings;
description = ''
JSON representation of the handlers configuration.
'';
};
};
}

View file

@ -22,6 +22,7 @@ builtins.mapAttrs
"${name}-profiles-extensions-assertions" = ./profiles/extensions/assertions.nix; "${name}-profiles-extensions-assertions" = ./profiles/extensions/assertions.nix;
"${name}-profiles-extensions-exhaustive" = ./profiles/extensions/exhaustive.nix; "${name}-profiles-extensions-exhaustive" = ./profiles/extensions/exhaustive.nix;
"${name}-profiles-extensions-exact" = ./profiles/extensions/exact.nix; "${name}-profiles-extensions-exact" = ./profiles/extensions/exact.nix;
"${name}-profiles-handlers" = ./profiles/handlers;
"${name}-profiles-overwrite" = ./profiles/overwrite; "${name}-profiles-overwrite" = ./profiles/overwrite;
"${name}-profiles-search" = ./profiles/search; "${name}-profiles-search" = ./profiles/search;
"${name}-profiles-settings" = ./profiles/settings; "${name}-profiles-settings" = ./profiles/settings;

View file

@ -0,0 +1,69 @@
modulePath:
{
config,
lib,
pkgs,
...
}:
let
cfg = lib.getAttrFromPath modulePath config;
firefoxMockOverlay = import ../../setup-firefox-mock-overlay.nix modulePath;
in
{
imports = [ firefoxMockOverlay ];
config = lib.mkIf config.test.enableBig (
lib.setAttrByPath modulePath {
enable = true;
profiles.handlers = {
id = 0;
handlers = {
mimeTypes = {
"application/pdf" = {
action = 2;
ask = false;
handlers = [
{
name = "Hello App";
path = "${pkgs.hello}/bin/hello";
}
];
extensions = [ "pdf" ];
};
"text/html" = {
action = 4;
ask = true;
extensions = [
"html"
"htm"
];
};
};
schemes = {
mailto = {
action = 2;
ask = false;
handlers = [
{
name = "Gmail";
uriTemplate = "https://mail.google.com/mail/?extsrc=mailto&url=%s";
}
];
};
http = {
action = 3;
ask = true;
};
};
};
};
}
// {
nmt.script = ''
assertFileContent \
home-files/${cfg.configPath}/handlers/handlers.json \
${./expected-handlers.json}
'';
}
);
}

View file

@ -0,0 +1,43 @@
{
"defaultHandlersVersion": {},
"isDownloadsImprovementsAlreadyMigrated": false,
"mimeTypes": {
"application/pdf": {
"action": 2,
"ask": false,
"extensions": [
"pdf"
],
"handlers": [
{
"name": "Hello App",
"path": "@hello@/bin/hello"
}
]
},
"text/html": {
"action": 4,
"ask": true,
"extensions": [
"html",
"htm"
]
}
},
"schemes": {
"http": {
"action": 3,
"ask": true
},
"mailto": {
"action": 2,
"ask": false,
"handlers": [
{
"name": "Gmail",
"uriTemplate": "https://mail.google.com/mail/?extsrc=mailto&url=%s"
}
]
}
}
}