diff --git a/modules/misc/news.nix b/modules/misc/news.nix index b88ba7e41..88a1dce96 100644 --- a/modules/misc/news.nix +++ b/modules/misc/news.nix @@ -770,6 +770,29 @@ in A new module is available: 'programs.looking-glass-client'. ''; } + + { + time = "2022-10-22T17:52:30+00:00"; + condition = config.programs.firefox.enable; + message = '' + It is now possible to configure the default search engine in Firefox + with + + programs.firefox.profiles..search.default + + and add custom engines with + + programs.firefox.profiles..search.engines. + + It is also recommended to set + + programs.firefox.profiles..search.force = true + + since Firefox will replace the symlink for the search configuration on + every launch, but note that you'll lose any existing configuration by + enabling this. + ''; + } ]; }; } diff --git a/modules/programs/firefox.nix b/modules/programs/firefox.nix index bf0eac15b..06c9aac7a 100644 --- a/modules/programs/firefox.nix +++ b/modules/programs/firefox.nix @@ -8,6 +8,8 @@ let cfg = config.programs.firefox; + jsonFormat = pkgs.formats.json { }; + mozillaConfigPath = if isDarwin then "Library/Application Support/Mozilla" else ".mozilla"; @@ -106,7 +108,7 @@ let ''; in { - meta.maintainers = [ maintainers.rycee ]; + meta.maintainers = [ maintainers.rycee maintainers.kira-bruneau ]; imports = [ (mkRemovedOptionModule [ "programs" "firefox" "enableAdobeFlash" ] @@ -351,6 +353,87 @@ in { defaultText = "true if profile ID is 0"; description = "Whether this is a default profile."; }; + + search = { + force = mkOption { + type = with types; bool; + default = false; + description = '' + Whether to force replace the existing search + configuration. This is recommended since Firefox will + replace the symlink for the search configuration on every + launch, but note that you'll lose any existing + configuration by enabling this. + ''; + }; + + default = mkOption { + type = with types; nullOr str; + default = null; + example = "DuckDuckGo"; + description = '' + The default search engine used in the address bar and search bar. + ''; + }; + + order = mkOption { + type = with types; uniq (listOf str); + default = [ ]; + example = [ "DuckDuckGo" "Google" ]; + description = '' + The order the search engines are listed in. Any engines + that aren't included in this list will be listed after + these in an unspecified order. + ''; + }; + + engines = mkOption { + type = with types; attrsOf (attrsOf jsonFormat.type); + default = { }; + example = literalExpression '' + { + "Nix Packages" = { + urls = [{ + template = "https://search.nixos.org/packages"; + params = [ + { name = "type"; value = "packages"; } + { name = "query"; value = "{searchTerms}"; } + ]; + }]; + + icon = "''${pkgs.nixos-icons}/share/icons/hicolor/scalable/apps/nix-snowflake.svg"; + definedAliases = [ "@np" ]; + }; + + "NixOS Wiki" = { + urls = [{ template = "https://nixos.wiki/index.php?search={searchTerms}"; }]; + iconUpdateURL = "https://nixos.wiki/favicon.png"; + updateInterval = 24 * 60 * 60 * 1000; # every day + definedAliases = [ "@nw" ]; + }; + + "Bing".metaData.hidden = true; + "Google".metaData.alias = "@g"; # builtin engines only support specifying one additional alias + } + ''; + description = '' + Attribute set of search engine configurations. Engines + that only have metaData specified will + be treated as builtin to Firefox. + + See SearchEngine.jsm + in Firefox's source for available options. We maintain a + mapping to let you specify all options in the referenced + link without underscores, but it may fall out of date with + future options. + + Note, icon is also a special option + added by Home Manager to make it convenient to specify + absolute icon paths. + ''; + }; + }; }; })); default = { }; @@ -444,6 +527,119 @@ in { mkUserJs profile.settings profile.extraConfig profile.bookmarks; }; + "${profilesPath}/${profile.path}/search.json.mozlz4" = mkIf + (profile.search.default != null || profile.search.order != [ ] + || profile.search.engines != { }) { + force = profile.search.force; + source = let + settings = { + version = 6; + + engines = let + allEngines = (profile.search.engines // + # If search.default isn't in search.engines, assume it's app + # provided and include it in the set of all engines + optionalAttrs (profile.search.default != null + && !(hasAttr profile.search.default + profile.search.engines)) { + ${profile.search.default} = { }; + }); + + # Map allEngines to a list and order by search.order + orderedEngineList = (imap (order: name: + let engine = allEngines.${name} or { }; + in engine // { + inherit name; + metaData = (engine.metaData or { }) // { inherit order; }; + }) profile.search.order) ++ (mapAttrsToList + (name: config: config // { inherit name; }) + (removeAttrs allEngines profile.search.order)); + + engines = map (config: + let + name = config.name; + isAppProvided = removeAttrs config [ "name" "metaData" ] + == { }; + metaData = config.metaData or { }; + in mapAttrs' (name: value: { + # Map nice field names to internal field names. This is + # intended to be exhaustive, but any future fields will + # either have to be specified with an underscore, or added + # to this map. + name = ((genAttrs [ + "name" + "isAppProvided" + "loadPath" + "hasPreferredIcon" + "updateInterval" + "updateURL" + "iconUpdateURL" + "iconURL" + "iconMapObj" + "metaData" + "orderHint" + "definedAliases" + "urls" + ] (name: "_${name}")) // { + "searchForm" = "__searchForm"; + }).${name} or name; + + inherit value; + }) ((removeAttrs config [ "icon" ]) + // (optionalAttrs (!isAppProvided) + (optionalAttrs (config ? iconUpdateURL) { + # Convenience to default iconURL to iconUpdateURL so + # the icon is immediately downloaded from the URL + iconURL = config.iconURL or config.iconUpdateURL; + } // optionalAttrs (config ? icon) { + # Convenience to specify absolute path to icon + iconURL = "file://${config.icon}"; + } // { + # Required for custom engine configurations, loadPaths + # are unique identifiers that are generally formatted + # like: [source]/path/to/engine.xml + loadPath = '' + [home-manager]/programs.firefox.profiles.${profile.name}.search.engines."${ + replaceChars [ "\\" ] [ "\\\\" ] name + }"''; + })) // { + # Required fields for all engine configurations + inherit name isAppProvided metaData; + })) orderedEngineList; + in engines; + + metaData = optionalAttrs (profile.search.default != null) { + current = profile.search.default; + hash = "@hash@"; + } // { + useSavedOrder = profile.search.order != [ ]; + }; + }; + + # Home Manager doesn't circumvent user consent and isn't acting + # maliciously. We're modifying the search outside of Firefox, but + # a claim by Mozilla to remove this would be very anti-user, and + # is unlikely to be an issue for our use case. + disclaimer = appName: + "By modifying this file, I agree that I am doing so " + + "only within ${appName} itself, using official, user-driven search " + + "engine selection processes, and in a way which does not circumvent " + + "user consent. I acknowledge that any attempt to change this file " + + "from outside of ${appName} is a malicious act, and will be responded " + + "to accordingly."; + + salt = profile.path + profile.search.default + + disclaimer "Firefox"; + in pkgs.runCommand "search.json.mozlz4" { + nativeBuildInputs = with pkgs; [ mozlz4a openssl ]; + json = builtins.toJSON settings; + inherit salt; + } '' + export hash=$(echo -n "$salt" | openssl dgst -sha256 -binary | base64) + mozlz4a <(substituteStream json search.json.in --subst-var hash) "$out" + ''; + }; + "${profilesPath}/${profile.path}/extensions" = mkIf (cfg.extensions != [ ]) { source = "${extensionsEnvPkg}/share/mozilla/${extensionPath}"; diff --git a/tests/modules/programs/firefox/profile-settings-expected-search.json b/tests/modules/programs/firefox/profile-settings-expected-search.json new file mode 100644 index 000000000..ceee27ee6 --- /dev/null +++ b/tests/modules/programs/firefox/profile-settings-expected-search.json @@ -0,0 +1,75 @@ +{ + "engines": [ + { + "_definedAliases": [ + "@np" + ], + "_iconURL": "file:///run/current-system/sw/share/icons/hicolor/scalable/apps/nix-snowflake.svg", + "_isAppProvided": false, + "_loadPath": "[home-manager]/programs.firefox.profiles.search.search.engines.\"Nix Packages\"", + "_metaData": { + "order": 1 + }, + "_name": "Nix Packages", + "_urls": [ + { + "params": [ + { + "name": "type", + "value": "packages" + }, + { + "name": "query", + "value": "{searchTerms}" + } + ], + "template": "https://search.nixos.org/packages" + } + ] + }, + { + "_definedAliases": [ + "@nw" + ], + "_iconURL": "https://nixos.wiki/favicon.png", + "_iconUpdateURL": "https://nixos.wiki/favicon.png", + "_isAppProvided": false, + "_loadPath": "[home-manager]/programs.firefox.profiles.search.search.engines.\"NixOS Wiki\"", + "_metaData": { + "order": 2 + }, + "_name": "NixOS Wiki", + "_updateInterval": 86400000, + "_urls": [ + { + "template": "https://nixos.wiki/index.php?search={searchTerms}" + } + ] + }, + { + "_isAppProvided": true, + "_metaData": { + "hidden": true + }, + "_name": "Bing" + }, + { + "_isAppProvided": true, + "_metaData": {}, + "_name": "DuckDuckGo" + }, + { + "_isAppProvided": true, + "_metaData": { + "alias": "@g" + }, + "_name": "Google" + } + ], + "metaData": { + "current": "DuckDuckGo", + "hash": "BWvqUiaCuMJ20lbymFf2dqzWyl1cgm1LZhhdWNEp0Cc=", + "useSavedOrder": true + }, + "version": 6 +} diff --git a/tests/modules/programs/firefox/profile-settings.nix b/tests/modules/programs/firefox/profile-settings.nix index 23bf1285d..b28e64597 100644 --- a/tests/modules/programs/firefox/profile-settings.nix +++ b/tests/modules/programs/firefox/profile-settings.nix @@ -60,6 +60,49 @@ lib.mkIf config.test.enableBig { } ]; }; + + profiles.search = { + id = 3; + search = { + force = true; + default = "DuckDuckGo"; + order = [ "Nix Packages" "NixOS Wiki" ]; + engines = { + "Nix Packages" = { + urls = [{ + template = "https://search.nixos.org/packages"; + params = [ + { + name = "type"; + value = "packages"; + } + { + name = "query"; + value = "{searchTerms}"; + } + ]; + }]; + + icon = + "/run/current-system/sw/share/icons/hicolor/scalable/apps/nix-snowflake.svg"; + + definedAliases = [ "@np" ]; + }; + + "NixOS Wiki" = { + urls = [{ + template = "https://nixos.wiki/index.php?search={searchTerms}"; + }]; + iconUpdateURL = "https://nixos.wiki/favicon.png"; + updateInterval = 24 * 60 * 60 * 1000; + definedAliases = [ "@nw" ]; + }; + + "Bing".metaData.hidden = true; + "Google".metaData.alias = "@g"; + }; + }; + }; }; nixpkgs.overlays = [ @@ -101,5 +144,15 @@ lib.mkIf config.test.enableBig { assertFileContent \ $bookmarksFile \ ${./profile-settings-expected-bookmarks.html} + + compressedSearch=$(normalizeStorePaths \ + home-files/.mozilla/firefox/search/search.json.mozlz4) + + decompressedSearch=$(dirname $compressedSearch)/search.json + ${pkgs.mozlz4a}/bin/mozlz4a -d "$compressedSearch" >(${pkgs.jq}/bin/jq . > "$decompressedSearch") + + assertFileContent \ + $decompressedSearch \ + ${./profile-settings-expected-search.json} ''; }