Skip to content

Commit b50c48e

Browse files
committed
firefox: add handlers.json configuration
Adds support for configuring Firefox's handlers.json file to manage MIME type and URL scheme handlers declaratively. Handlers control how Firefox opens files and protocols (e.g., PDF viewers, mailto handlers). The implementation validates handler configurations and generates properly formatted JSON output. Includes comprehensive test coverage for edge cases and assertions.
1 parent 13cc1ef commit b50c48e

File tree

12 files changed

+595
-1
lines changed

12 files changed

+595
-1
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{ config, ... }:
2+
{
3+
time = "2025-12-10T07:15:59+00:00";
4+
condition = config.programs.firefox.enable;
5+
message = ''
6+
The Firefox module now provides a
7+
'programs.firefox.profiles.<name>.handlers' option.
8+
9+
It allows declarative configuration of MIME type and URL scheme handlers
10+
through Firefox's handlers.json file, controlling how Firefox opens files
11+
and protocols (e.g., PDF viewers, mailto handlers).
12+
13+
Configure handlers with:
14+
15+
programs.firefox.profiles.<name>.handlers.mimeTypes
16+
programs.firefox.profiles.<name>.handlers.schemes
17+
'';
18+
}

modules/programs/firefox/mkFirefoxModule.nix

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,25 @@ in
555555
description = "Declarative search engine configuration.";
556556
};
557557

558+
handlers = mkOption {
559+
type = types.submodule (
560+
args:
561+
import ./profiles/handlers.nix {
562+
inherit (args) config;
563+
inherit lib pkgs appName;
564+
package = cfg.finalPackage;
565+
modulePath = modulePath ++ [
566+
"profiles"
567+
name
568+
"handlers"
569+
];
570+
profilePath = config.path;
571+
}
572+
);
573+
default = { };
574+
description = "Declarative handlers configuration for MIME types and URL schemes.";
575+
};
576+
558577
containersForce = mkOption {
559578
type = types.bool;
560579
default = false;
@@ -903,7 +922,8 @@ in
903922
}
904923
]
905924
) (lib.attrsToList config.extensions.settings))
906-
++ config.bookmarks.assertions;
925+
++ config.bookmarks.assertions
926+
++ config.handlers.assertions;
907927
};
908928
}
909929
)
@@ -1056,6 +1076,11 @@ in
10561076
source = profile.search.file;
10571077
};
10581078

1079+
"${cfg.profilesPath}/${profile.path}/handlers.json" = mkIf (profile.handlers.enable) {
1080+
text = builtins.toJSON profile.handlers.settings;
1081+
force = profile.handlers.force;
1082+
};
1083+
10591084
"${cfg.profilesPath}/${profile.path}/extensions" = mkIf (profile.extensions.packages != [ ]) {
10601085
source =
10611086
let
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
{
2+
config,
3+
lib,
4+
pkgs,
5+
appName,
6+
package,
7+
modulePath,
8+
profilePath,
9+
}:
10+
with lib;
11+
let
12+
jsonFormat = pkgs.formats.json { };
13+
14+
# Process a handler entry, validating path vs uriTemplate
15+
processHandler =
16+
mimeTypeOrScheme: handler:
17+
let
18+
hasPath = handler.path or null != null;
19+
hasUriTemplate = handler.uriTemplate or null != null;
20+
hasName = handler.name or null != null;
21+
in
22+
{
23+
assertion = !(hasPath && hasUriTemplate);
24+
message = "'${mimeTypeOrScheme}': handler can't have both 'path' and 'uriTemplate' set.";
25+
result =
26+
(optionalAttrs hasName { inherit (handler) name; })
27+
// (optionalAttrs hasPath { inherit (handler) path; })
28+
// (optionalAttrs hasUriTemplate { inherit (handler) uriTemplate; });
29+
};
30+
31+
# Process all handlers for a mime type or scheme
32+
processHandlers =
33+
name: value:
34+
let
35+
hasHandlers = value.handlers != [ ];
36+
processedHandlers = map (processHandler name) value.handlers;
37+
handlerAssertions = map (h: { inherit (h) assertion message; }) processedHandlers;
38+
handlerResults = map (h: h.result) processedHandlers;
39+
in
40+
{
41+
assertions = handlerAssertions ++ [
42+
{
43+
assertion = !hasHandlers || value.action == 2;
44+
message = "'${name}': handlers can only be set when 'action' is set to 2 (Use helper app).";
45+
}
46+
];
47+
result = {
48+
inherit (value) action ask;
49+
}
50+
// optionalAttrs hasHandlers { handlers = handlerResults; };
51+
};
52+
53+
# Process mime types, including extensions field
54+
processMimeType =
55+
name: value:
56+
let
57+
processed = processHandlers name value;
58+
in
59+
{
60+
inherit (processed) assertions;
61+
result = processed.result // {
62+
inherit (value) extensions;
63+
};
64+
};
65+
66+
# Process all mime types
67+
processedMimeTypes = mapAttrs processMimeType (config.mimeTypes or { });
68+
mimeTypeAssertions = flatten (mapAttrsToList (_: v: v.assertions) processedMimeTypes);
69+
mimeTypeResults = mapAttrs (_: v: v.result) processedMimeTypes;
70+
71+
# Process all schemes
72+
processedSchemes = mapAttrs processHandlers (config.schemes or { });
73+
schemeAssertions = flatten (mapAttrsToList (_: v: v.assertions) processedSchemes);
74+
schemeResults = mapAttrs (_: v: v.result) processedSchemes;
75+
76+
# Combine all assertions
77+
allAssertions = mimeTypeAssertions ++ schemeAssertions;
78+
79+
# Build the final handlers.json structure
80+
settings = {
81+
defaultHandlersVersion = { };
82+
isDownloadsImprovementsAlreadyMigrated = false;
83+
mimeTypes = mimeTypeResults;
84+
schemes = schemeResults;
85+
};
86+
87+
# Common options shared between mimeTypes and schemes
88+
commonHandlerOptions = {
89+
action = mkOption {
90+
type = types.enum [
91+
0
92+
1
93+
2
94+
3
95+
4
96+
];
97+
default = 1;
98+
description = ''
99+
The action to take for this MIME type / URL scheme. Possible values:
100+
- 0: Save file
101+
- 1: Always ask
102+
- 2: Use helper app
103+
- 3: Open in ${appName}
104+
- 4: Use system default
105+
'';
106+
};
107+
108+
ask = mkOption {
109+
type = types.bool;
110+
default = true;
111+
description = ''
112+
If true, the user is asked what they want to do with the file.
113+
If false, the action is taken without user intervention.
114+
'';
115+
};
116+
117+
handlers = mkOption {
118+
type = types.listOf (
119+
types.submodule {
120+
options = {
121+
name = mkOption {
122+
type = types.nullOr types.str;
123+
default = null;
124+
description = ''
125+
The display name of the handler.
126+
'';
127+
};
128+
129+
path = mkOption {
130+
type = types.nullOr types.str;
131+
default = null;
132+
description = ''
133+
The native path to the executable to be used.
134+
Choose between path or uriTemplate.
135+
'';
136+
};
137+
138+
uriTemplate = mkOption {
139+
type = types.nullOr types.str;
140+
default = null;
141+
description = ''
142+
A URL to a web based application handler.
143+
The URL must be https and contain a %s to be used for substitution.
144+
Choose between path or uriTemplate.
145+
'';
146+
};
147+
};
148+
}
149+
);
150+
default = [ ];
151+
description = ''
152+
An array of handlers with the first one being the default.
153+
If you don't want to have a default handler, use an empty object for the first handler.
154+
Only valid when action is set to 2 (Use helper app).
155+
'';
156+
};
157+
};
158+
in
159+
{
160+
imports = [ (pkgs.path + "/nixos/modules/misc/meta.nix") ];
161+
162+
meta.maintainers = with maintainers; [ kugland ];
163+
164+
options = {
165+
enable = mkOption {
166+
type = types.bool;
167+
default = false;
168+
internal = true;
169+
description = ''
170+
Whether to enable handlers configuration for this profile.
171+
'';
172+
};
173+
174+
force = mkOption {
175+
type = types.bool;
176+
default = false;
177+
description = ''
178+
Whether to force replace the existing handlers configuration.
179+
'';
180+
};
181+
182+
mimeTypes = mkOption {
183+
type = types.attrsOf (
184+
types.submodule {
185+
options = commonHandlerOptions // {
186+
extensions = mkOption {
187+
type = types.listOf types.str;
188+
default = [ ];
189+
example = [
190+
"jpg"
191+
"jpeg"
192+
];
193+
description = ''
194+
List of file extensions associated with this MIME type.
195+
'';
196+
};
197+
};
198+
}
199+
);
200+
default = { };
201+
example = literalExpression ''
202+
{
203+
"application/pdf" = {
204+
action = 2;
205+
ask = false;
206+
handlers = [
207+
{
208+
name = "Okular";
209+
path = "''${pkgs.okular}/bin/okular";
210+
}
211+
];
212+
extensions = [ "pdf" ];
213+
};
214+
}
215+
'';
216+
description = ''
217+
Attribute set mapping MIME types to their handler configurations.
218+
'';
219+
};
220+
221+
schemes = mkOption {
222+
type = types.attrsOf (
223+
types.submodule {
224+
options = commonHandlerOptions;
225+
}
226+
);
227+
default = { };
228+
example = literalExpression ''
229+
{
230+
mailto = {
231+
action = 2;
232+
ask = false;
233+
handlers = [
234+
{
235+
name = "Gmail";
236+
uriTemplate = "https://mail.google.com/mail/?extsrc=mailto&url=%s";
237+
}
238+
];
239+
};
240+
}
241+
'';
242+
description = ''
243+
Attribute set mapping URL schemes to their handler configurations.
244+
'';
245+
};
246+
247+
assertions = mkOption {
248+
type = types.listOf types.unspecified;
249+
default = allAssertions;
250+
internal = true;
251+
readOnly = true;
252+
description = ''
253+
Validation assertions for handler configuration.
254+
'';
255+
};
256+
257+
settings = mkOption {
258+
type = jsonFormat.type;
259+
default = settings;
260+
internal = true;
261+
readOnly = true;
262+
description = ''
263+
Resulting handlers.json settings.
264+
'';
265+
};
266+
};
267+
268+
config = {
269+
enable = mkDefault (config.mimeTypes != { } || config.schemes != { });
270+
};
271+
}

tests/modules/programs/firefox/common.nix

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ builtins.mapAttrs
2222
"${name}-profiles-extensions-assertions" = ./profiles/extensions/assertions.nix;
2323
"${name}-profiles-extensions-exhaustive" = ./profiles/extensions/exhaustive.nix;
2424
"${name}-profiles-extensions-exact" = ./profiles/extensions/exact.nix;
25+
"${name}-profiles-handlers" = ./profiles/handlers;
26+
"${name}-profiles-handlers-both-path-and-uri" = ./profiles/handlers/both-path-and-uri.nix;
27+
"${name}-profiles-handlers-without-action" = ./profiles/handlers/handlers-without-action.nix;
28+
"${name}-profiles-handlers-empty-handler" = ./profiles/handlers/empty-handler.nix;
29+
"${name}-profiles-handlers-multiple-handlers" = ./profiles/handlers/multiple-handlers.nix;
2530
"${name}-profiles-overwrite" = ./profiles/overwrite;
2631
"${name}-profiles-search" = ./profiles/search;
2732
"${name}-profiles-settings" = ./profiles/settings;
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
modulePath:
2+
{
3+
config,
4+
lib,
5+
pkgs,
6+
...
7+
}:
8+
with lib;
9+
let
10+
firefoxMockOverlay = import ../../setup-firefox-mock-overlay.nix modulePath;
11+
in
12+
{
13+
imports = [ firefoxMockOverlay ];
14+
15+
config = mkIf config.test.enableBig (
16+
{
17+
home.enableNixpkgsReleaseCheck = false;
18+
}
19+
// setAttrByPath modulePath {
20+
enable = true;
21+
22+
profiles.test = {
23+
id = 0;
24+
handlers = {
25+
mimeTypes = {
26+
"application/pdf" = {
27+
action = 2;
28+
handlers = [
29+
{
30+
name = "Test";
31+
path = "${pkgs.hello}/bin/hello";
32+
uriTemplate = "https://example.com/?url=%s";
33+
}
34+
];
35+
extensions = [ "pdf" ];
36+
};
37+
};
38+
};
39+
};
40+
}
41+
// {
42+
test.asserts.assertions.expected = [
43+
"'application/pdf': handler can't have both 'path' and 'uriTemplate' set."
44+
];
45+
}
46+
);
47+
}

0 commit comments

Comments
 (0)