From 8beb27389a8ae78c5ef505624f1ea10f00942783 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Felix=20Schr=C3=B6ter?= <dev@felschr.com>
Date: Thu, 13 Feb 2025 23:00:00 +0100
Subject: [PATCH] refactor(home): replace custom Firefox module with
 home-manager's new mkFirefoxModule

- replaced core module with `mkFirefoxModule` from home-manager
- `mkFirefoxModuleCompat` was created as a wrapper around
  `mkFirefoxModule` for compatibility with other Firefox-based browser
  packages such as Tor/Mullvad Browser
- profile binary & desktop file creation moved into
  `mkFirefoxProfileBinModule`
---
 home/modules/firefox/common.nix               | 749 +-----------------
 home/modules/firefox/firefox.nix              |  30 +-
 .../modules/firefox/mkFirefoxModuleCompat.nix |  67 ++
 .../firefox/mkFirefoxProfileBinModule.nix     |  67 ++
 home/modules/firefox/mullvad-browser.nix      |  41 +-
 home/modules/firefox/tor-browser.nix          |  41 +-
 6 files changed, 212 insertions(+), 783 deletions(-)
 create mode 100644 home/modules/firefox/mkFirefoxModuleCompat.nix
 create mode 100644 home/modules/firefox/mkFirefoxProfileBinModule.nix

diff --git a/home/modules/firefox/common.nix b/home/modules/firefox/common.nix
index 8fda3fe..449cc0f 100644
--- a/home/modules/firefox/common.nix
+++ b/home/modules/firefox/common.nix
@@ -1,751 +1,18 @@
-{
-  config,
-  lib,
-  pkgs,
-  ...
-}:
-
-with lib;
+{ lib, ... }:
 
 let
-  jsonFormat = pkgs.formats.json { };
-
-  # The extensions path shared by all profiles; will not be supported
-  # by future Firefox versions.
-  extensionPath = "extensions/{ec8030f7-c20a-464f-9b0e-13a3a9e97384}";
-
   userPrefValue =
     pref:
-    builtins.toJSON (if isBool pref || isInt pref || isString pref then pref else builtins.toJSON pref);
-
+    builtins.toJSON (
+      if lib.isBool pref || lib.isInt pref || lib.isString pref then pref else builtins.toJSON pref
+    );
+in
+{
   mkConfig =
     prefs:
-    concatStrings (
-      mapAttrsToList (name: value: ''
+    lib.concatStrings (
+      lib.mapAttrsToList (name: value: ''
         user_pref("${name}", ${userPrefValue value});
       '') prefs
     );
-
-  mkUserJs =
-    prefs: extraPrefs: bookmarks:
-    let
-      prefs' =
-        lib.optionalAttrs ([ ] != bookmarks) {
-          "browser.bookmarks.file" = toString (firefoxBookmarksFile bookmarks);
-          "browser.places.importBookmarksHTML" = true;
-        }
-        // prefs;
-    in
-    ''
-      // Generated by Home Manager.
-
-      ${mkConfig prefs'}
-
-      ${extraPrefs}
-    '';
-
-  firefoxBookmarksFile =
-    bookmarks:
-    let
-      indent = level: lib.concatStringsSep "" (map (lib.const "  ") (lib.range 1 level));
-
-      bookmarkToHTML =
-        indentLevel: bookmark:
-        ''${indent indentLevel}<DT><A HREF="${escapeXML bookmark.url}" ADD_DATE="0" LAST_MODIFIED="0"${
-          lib.optionalString (bookmark.keyword != null) " SHORTCUTURL=\"${escapeXML bookmark.keyword}\""
-        }>${escapeXML bookmark.name}</A>'';
-
-      directoryToHTML = indentLevel: directory: ''
-        ${indent indentLevel}<DT>${
-          if directory.toolbar then
-            ''<H3 PERSONAL_TOOLBAR_FOLDER="true">Bookmarks Toolbar''
-          else
-            "<H3>${escapeXML directory.name}"
-        }</H3>
-        ${indent indentLevel}<DL><p>
-        ${allItemsToHTML (indentLevel + 1) directory.bookmarks}
-        ${indent indentLevel}</p></DL>'';
-
-      itemToHTMLOrRecurse =
-        indentLevel: item:
-        if item ? "url" then bookmarkToHTML indentLevel item else directoryToHTML indentLevel item;
-
-      allItemsToHTML =
-        indentLevel: bookmarks: lib.concatStringsSep "\n" (map (itemToHTMLOrRecurse indentLevel) bookmarks);
-
-      bookmarkEntries = allItemsToHTML 1 bookmarks;
-    in
-    pkgs.writeText "firefox-bookmarks.html" ''
-      <!DOCTYPE NETSCAPE-Bookmark-file-1>
-      <!-- This is an automatically generated file.
-        It will be read and overwritten.
-        DO NOT EDIT! -->
-      <META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
-      <TITLE>Bookmarks</TITLE>
-      <H1>Bookmarks Menu</H1>
-      <DL><p>
-      ${bookmarkEntries}
-      </p></DL>
-    '';
-in
-{
-  inherit mkConfig;
-
-  mkModule =
-    browser:
-    let
-      cfg = config.programs.${browser.name};
-      inherit (browser)
-        displayName
-        dataConfigPath
-        defaultPackage
-        defaultPackageName
-        isSecure
-        ;
-
-      profilesPath = dataConfigPath;
-
-      profiles =
-        flip mapAttrs' cfg.profiles (
-          _: profile:
-          nameValuePair "Profile${toString profile.id}" {
-            Name = profile.name;
-            Path = profile.path;
-            IsRelative = 1;
-            Default = if profile.isDefault then 1 else 0;
-          }
-        )
-        // {
-          General = {
-            StartWithLastProfile = 1;
-          };
-        };
-
-      profilesIni = generators.toINI { } profiles;
-
-      mkProfileBin =
-        profile:
-        let
-          name = "${browser.name}-${profile}";
-          scriptBin = pkgs.writeScriptBin name ''
-            ${browser.name} -P "${profile}" --name="${name}" $@
-          '';
-          desktopFile = pkgs.makeDesktopItem {
-            inherit name;
-            exec = "${scriptBin}/bin/${name} %U";
-            icon = browser.name;
-            extraConfig.StartupWMClass = name;
-            desktopName = "${displayName} (${profile})";
-            genericName = "Web Browser";
-            categories = [
-              "Network"
-              "WebBrowser"
-            ] ++ optional isSecure "Security";
-          };
-        in
-        pkgs.runCommand name { } ''
-          mkdir -p $out/{bin,share}
-          cp -r ${scriptBin}/bin/${name} $out/bin/${name}
-          cp -r ${desktopFile}/share/applications $out/share/applications
-        '';
-    in
-    {
-      options = {
-        programs.${browser.name} = {
-          enable = mkEnableOption displayName;
-
-          createProfileBins = mkOption {
-            type = types.bool;
-            default = false;
-            description = ''
-              When enabled installs a binary for all non-default profiles named `${browser.name}-''${profile}`.
-              This also includes a `.desktop` file that is configured to show separate icons (on GNOME at least).
-            '';
-          };
-
-          package = mkOption {
-            type = types.package;
-            default = defaultPackage;
-            defaultText = literalExpression defaultPackageName;
-            example = literalExpression ''
-              ${defaultPackageName}.override {
-                # See nixpkgs' ${defaultPackageName} to check which options you can use
-                pulseaudioSupport = true;
-              }
-            '';
-            description = "The ${displayName} package to use.";
-          };
-
-          profiles = mkOption {
-            type = types.attrsOf (
-              types.submodule (
-                { config, name, ... }:
-                {
-                  options = {
-                    name = mkOption {
-                      type = types.str;
-                      default = name;
-                      description = "Profile name.";
-                    };
-
-                    id = mkOption {
-                      type = types.ints.unsigned;
-                      default = 0;
-                      description = ''
-                        Profile ID. This should be set to a unique number per profile.
-                      '';
-                    };
-
-                    settings = mkOption {
-                      type = types.attrsOf (
-                        jsonFormat.type
-                        // {
-                          description = "Firefox preference (int, bool, string, and also attrs, list, float as a JSON string)";
-                        }
-                      );
-                      default = { };
-                      example = literalExpression ''
-                        {
-                          "browser.startup.homepage" = "https://nixos.org";
-                          "browser.search.region" = "GB";
-                          "browser.search.isUS" = false;
-                          "distribution.searchplugins.defaultLocale" = "en-GB";
-                          "general.useragent.locale" = "en-GB";
-                          "browser.bookmarks.showMobileBookmarks" = true;
-                          "browser.newtabpage.pinned" = [{
-                            title = "NixOS";
-                            url = "https://nixos.org";
-                          }];
-                        }
-                      '';
-                      description = ''
-                        Attribute set of Firefox preferences.
-
-                        Firefox only supports int, bool, and string types for
-                        preferences, but home-manager will automatically
-                        convert all other JSON-compatible values into strings.
-                      '';
-                    };
-
-                    extraConfig = mkOption {
-                      type = types.lines;
-                      default = "";
-                      description = ''
-                        Extra preferences to add to <filename>user.js</filename>.
-                      '';
-                    };
-
-                    userChrome = mkOption {
-                      type = types.lines;
-                      default = "";
-                      description = "Custom Firefox user chrome CSS.";
-                      example = ''
-                        /* Hide tab bar in FF Quantum */
-                        @-moz-document url("chrome://browser/content/browser.xul") {
-                          #TabsToolbar {
-                            visibility: collapse !important;
-                            margin-bottom: 21px !important;
-                          }
-
-                          #sidebar-box[sidebarcommand="treestyletab_piro_sakura_ne_jp-sidebar-action"] #sidebar-header {
-                            visibility: collapse !important;
-                          }
-                        }
-                      '';
-                    };
-
-                    userContent = mkOption {
-                      type = types.lines;
-                      default = "";
-                      description = "Custom Firefox user content CSS.";
-                      example = ''
-                        /* Hide scrollbar in FF Quantum */
-                        *{scrollbar-width:none !important}
-                      '';
-                    };
-
-                    bookmarks = mkOption {
-                      type =
-                        let
-                          bookmarkSubmodule =
-                            types.submodule (
-                              { config, name, ... }:
-                              {
-                                options = {
-                                  name = mkOption {
-                                    type = types.str;
-                                    default = name;
-                                    description = "Bookmark name.";
-                                  };
-
-                                  keyword = mkOption {
-                                    type = types.nullOr types.str;
-                                    default = null;
-                                    description = "Bookmark search keyword.";
-                                  };
-
-                                  url = mkOption {
-                                    type = types.str;
-                                    description = "Bookmark url, use %s for search terms.";
-                                  };
-                                };
-                              }
-                            )
-                            // {
-                              description = "bookmark submodule";
-                            };
-
-                          bookmarkType = types.addCheck bookmarkSubmodule (x: x ? "url");
-
-                          directoryType =
-                            types.submodule (
-                              { config, name, ... }:
-                              {
-                                options = {
-                                  name = mkOption {
-                                    type = types.str;
-                                    default = name;
-                                    description = "Directory name.";
-                                  };
-
-                                  bookmarks = mkOption {
-                                    type = types.listOf nodeType;
-                                    default = [ ];
-                                    description = "Bookmarks within directory.";
-                                  };
-
-                                  toolbar = mkOption {
-                                    type = types.bool;
-                                    default = false;
-                                    description = "If directory should be shown in toolbar.";
-                                  };
-                                };
-                              }
-                            )
-                            // {
-                              description = "directory submodule";
-                            };
-
-                          nodeType = types.either bookmarkType directoryType;
-                        in
-                        with types;
-                        coercedTo (attrsOf nodeType) attrValues (listOf nodeType);
-                      default = [ ];
-                      example = literalExpression ''
-                        [
-                          {
-                            name = "wikipedia";
-                            keyword = "wiki";
-                            url = "https://en.wikipedia.org/wiki/Special:Search?search=%s&go=Go";
-                          }
-                          {
-                            name = "kernel.org";
-                            url = "https://www.kernel.org";
-                          }
-                          {
-                            name = "Nix sites";
-                            bookmarks = [
-                              {
-                                name = "homepage";
-                                url = "https://nixos.org/";
-                              }
-                              {
-                                name = "wiki";
-                                url = "https://nixos.wiki/";
-                              }
-                            ];
-                          }
-                        ]
-                      '';
-                      description = ''
-                        Preloaded bookmarks. Note, this may silently overwrite any
-                        previously existing bookmarks!
-                      '';
-                    };
-
-                    path = mkOption {
-                      type = types.str;
-                      default = name;
-                      description = "Profile path.";
-                    };
-
-                    isDefault = mkOption {
-                      type = types.bool;
-                      default = config.id == 0;
-                      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 <varname>metaData</varname> specified will
-                          be treated as builtin to Firefox.
-                          </para><para>
-                          See <link xlink:href=
-                          "https://searchfox.org/mozilla-central/rev/669329e284f8e8e2bb28090617192ca9b4ef3380/toolkit/components/search/SearchEngine.jsm#1138-1177">SearchEngine.jsm</link>
-                          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.
-                          </para><para>
-                          Note, <varname>icon</varname> is also a special option
-                          added by Home Manager to make it convenient to specify
-                          absolute icon paths.
-                        '';
-                      };
-                    };
-
-                    extensions = mkOption {
-                      type = types.listOf types.package;
-                      default = [ ];
-                      example = literalExpression ''
-                        with firefox-addons; [
-                          privacy-badger
-                        ]
-                      '';
-                      description = ''
-                        List of Firefox add-on packages to install for this profile.
-                        Some pre-packaged add-ons are accessible from NUR,
-                        <link xlink:href="https://github.com/nix-community/NUR"/>.
-                        Once you have NUR installed run
-
-                        <screen language="console">
-                          <prompt>$</prompt> <userinput>nix-env -f '&lt;nixpkgs&gt;' -qaP -A firefox-addons</userinput>
-                        </screen>
-
-                        to list the available Firefox add-ons.
-
-                        </para><para>
-
-                        Note that it is necessary to manually enable these extensions
-                        inside Firefox after the first installation.
-                      '';
-                    };
-                  };
-                }
-              )
-            );
-            default = { };
-            description = "Attribute set of Firefox profiles.";
-          };
-        };
-      };
-
-      config = mkIf cfg.enable {
-        assertions = [
-          (
-            let
-              defaults = catAttrs "name" (filter (a: a.isDefault) (attrValues cfg.profiles));
-            in
-            {
-              assertion = cfg.profiles == { } || length defaults == 1;
-              message =
-                "Must have exactly one default Firefox profile but found "
-                + toString (length defaults)
-                + optionalString (length defaults > 1) (", namely " + concatStringsSep ", " defaults);
-            }
-          )
-
-          (
-            let
-              duplicates = filterAttrs (_: v: length v != 1) (
-                zipAttrs (mapAttrsToList (n: v: { "${toString v.id}" = n; }) (cfg.profiles))
-              );
-
-              mkMsg = n: v: "  - ID ${n} is used by ${concatStringsSep ", " v}";
-            in
-            {
-              assertion = duplicates == { };
-              message =
-                ''
-                  Must not have Firefox profiles with duplicate IDs but
-                ''
-                + concatStringsSep "\n" (mapAttrsToList mkMsg duplicates);
-            }
-          )
-        ];
-
-        warnings = optional (cfg.enableGnomeExtensions or false) ''
-          Using 'programs.${browser.name}.enableGnomeExtensions' has been deprecated and
-          will be removed in the future. Please change to overriding the package
-          configuration using 'programs.${browser.name}.package' instead. You can refer to
-          its example for how to do this.
-        '';
-
-        home.packages =
-          [ cfg.package ]
-          ++ (
-            if cfg.createProfileBins then
-              (mapAttrsToList (name: profile: (mkProfileBin name)) (
-                filterAttrs (_: p: !p.isDefault) cfg.profiles
-              ))
-            else
-              [ ]
-          );
-
-        home.file = mkMerge (
-          [ { "${dataConfigPath}/profiles.ini" = mkIf (cfg.profiles != { }) { text = profilesIni; }; } ]
-          ++ flip mapAttrsToList cfg.profiles (
-            _: profile: {
-              "${profilesPath}/${profile.path}/.keep".text = "";
-
-              "${profilesPath}/${profile.path}/chrome/userChrome.css" = mkIf (profile.userChrome != "") {
-                text = profile.userChrome;
-              };
-
-              "${profilesPath}/${profile.path}/chrome/userContent.css" = mkIf (profile.userContent != "") {
-                text = profile.userContent;
-              };
-
-              "${profilesPath}/${profile.path}/user.js" = mkIf (
-                profile.settings != { } || profile.extraConfig != "" || profile.bookmarks != [ ]
-              ) { text = 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
-                              # Map of nice field names to internal field names.
-                              # This is intended to be exhaustive and should be
-                              # updated at every version bump.
-                              internalFieldNames =
-                                (genAttrs [
-                                  "name"
-                                  "isAppProvided"
-                                  "loadPath"
-                                  "hasPreferredIcon"
-                                  "updateInterval"
-                                  "updateURL"
-                                  "iconUpdateURL"
-                                  "iconURL"
-                                  "iconMapObj"
-                                  "metaData"
-                                  "orderHint"
-                                  "definedAliases"
-                                  "urls"
-                                ] (name: "_${name}"))
-                                // {
-                                  searchForm = "__searchForm";
-                                };
-
-                              processCustomEngineInput =
-                                input:
-                                (removeAttrs input [ "icon" ])
-                                // optionalAttrs (input ? icon) {
-                                  # Convenience to specify absolute path to icon
-                                  iconURL = "file://${input.icon}";
-                                }
-                                // (
-                                  optionalAttrs (input ? iconUpdateURL) {
-                                    # Convenience to default iconURL to iconUpdateURL so
-                                    # the icon is immediately downloaded from the URL
-                                    iconURL = input.iconURL or input.iconUpdateURL;
-                                  }
-                                  // {
-                                    # Required for custom engine configurations, loadPaths
-                                    # are unique identifiers that are generally formatted
-                                    # like: [source]/path/to/engine.xml
-                                    loadPath = ''[home-manager]/programs.${browser.name}.profiles.${profile.name}.search.engines."${
-                                      replaceStrings [ "\\" ] [ "\\\\" ] input.name
-                                    }"'';
-                                  }
-                                );
-
-                              processEngineInput =
-                                name: input:
-                                let
-                                  requiredInput = {
-                                    inherit name;
-                                    isAppProvided = input.isAppProvided or removeAttrs input [ "metaData" ] == { };
-                                    metaData = input.metaData or { };
-                                  };
-                                in
-                                if requiredInput.isAppProvided then
-                                  requiredInput
-                                else
-                                  processCustomEngineInput (input // requiredInput);
-
-                              buildEngineConfig =
-                                name: input:
-                                mapAttrs' (name: value: {
-                                  name = internalFieldNames.${name} or name;
-                                  inherit value;
-                                }) (processEngineInput name input);
-
-                              sortEngineConfigs =
-                                configs:
-                                let
-                                  buildEngineConfigWithOrder =
-                                    order: name:
-                                    let
-                                      config =
-                                        configs.${name} or {
-                                          _name = name;
-                                          _isAppProvided = true;
-                                          _metaData = { };
-                                        };
-                                    in
-                                    config
-                                    // {
-                                      _metaData = config._metaData // {
-                                        inherit order;
-                                      };
-                                    };
-
-                                  engineConfigsWithoutOrder = attrValues (removeAttrs configs profile.search.order);
-
-                                  sortedEngineConfigs =
-                                    (imap buildEngineConfigWithOrder profile.search.order) ++ engineConfigsWithoutOrder;
-                                in
-                                sortedEngineConfigs;
-
-                              engineInput = profile.search.engines // {
-                                # Infer profile.search.default as an app provided
-                                # engine if it's not in profile.search.engines
-                                ${profile.search.default} = profile.search.engines.${profile.search.default} or { };
-                              };
-                            in
-                            sortEngineConfigs (mapAttrs buildEngineConfig engineInput);
-
-                          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 =
-                          if profile.search.default != null then
-                            profile.path + profile.search.default + disclaimer "Firefox"
-                          else
-                            null;
-                      in
-                      pkgs.runCommand "search.json.mozlz4"
-                        {
-                          nativeBuildInputs = with pkgs; [
-                            mozlz4a
-                            openssl
-                          ];
-                          json = builtins.toJSON settings;
-                          inherit salt;
-                        }
-                        ''
-                          if [[ -n $salt ]]; then
-                            export hash=$(echo -n "$salt" | openssl dgst -sha256 -binary | base64)
-                            mozlz4a <(substituteStream json search.json.in --subst-var hash) "$out"
-                          else
-                            mozlz4a <(echo "$json") "$out"
-                          fi
-                        '';
-                  };
-
-              "${profilesPath}/${profile.path}/extensions" = mkIf (profile.extensions != [ ]) {
-                source =
-                  let
-                    extensionsEnvPkg = pkgs.buildEnv {
-                      name = "hm-${browser.name}-extensions";
-                      paths = profile.extensions;
-                    };
-                  in
-                  "${extensionsEnvPkg}/share/mozilla/${extensionPath}";
-                recursive = true;
-                force = true;
-                onChange = ''
-                  # Includes references to old versions that are shown in UI.
-                  rm -f "${profilesPath}/${profile.path}/extensions.json"
-                '';
-              };
-            }
-          )
-        );
-      };
-    };
 }
diff --git a/home/modules/firefox/firefox.nix b/home/modules/firefox/firefox.nix
index 4e2ab4c..0aad241 100644
--- a/home/modules/firefox/firefox.nix
+++ b/home/modules/firefox/firefox.nix
@@ -1,18 +1,20 @@
-{
-  config,
-  lib,
-  pkgs,
-  ...
-}:
+_:
 
 let
-  common = import ./common.nix { inherit config lib pkgs; };
+  mkFirefoxProfileBinModule = import ./mkFirefoxProfileBinModule.nix;
+
+  modulePath = [
+    "programs"
+    "firefox"
+  ];
+  name = "Firefox";
+  packageName = "firefox";
 in
-common.mkModule {
-  name = "firefox";
-  displayName = "Firefox";
-  dataConfigPath = ".mozilla/firefox";
-  defaultPackage = pkgs.firefox;
-  defaultPackageName = "pkgs.firefox";
-  isSecure = false;
+{
+  imports = [
+    (mkFirefoxProfileBinModule {
+      inherit modulePath name packageName;
+      isSecure = true;
+    })
+  ];
 }
diff --git a/home/modules/firefox/mkFirefoxModuleCompat.nix b/home/modules/firefox/mkFirefoxModuleCompat.nix
new file mode 100644
index 0000000..dabeac4
--- /dev/null
+++ b/home/modules/firefox/mkFirefoxModuleCompat.nix
@@ -0,0 +1,67 @@
+{ modulePath, ... }@moduleArgs:
+
+{
+  inputs,
+  config,
+  lib,
+  ...
+}:
+
+let
+  mkFirefoxModule = import "${inputs.home-manager.outPath}/modules/programs/firefox/mkFirefoxModule.nix";
+
+  cfg = lib.getAttrFromPath modulePath config;
+
+  # HINT home-manager's Firefox module uses a read-only `finalPackage` option
+  # that creates a wrapper around `package`. However, this wrapper is not
+  # compatible with all Firefox-based browser packages. Thus, we adjust the module
+  # to always set `finalPackage` to `package` & remove unsupported options.
+  fixFirefoxModuleCompat =
+    module:
+    {
+      config,
+      lib,
+      pkgs,
+      ...
+    }:
+    let
+      optionsPath = [ "options" ] ++ modulePath;
+      configPath = [
+        "config"
+        "content" # due to mkIf
+      ] ++ modulePath;
+    in
+    lib.updateManyAttrsByPath
+      [
+        {
+          path = optionsPath ++ [ "languagePacks" ];
+          update = old: { };
+        }
+        {
+          path = configPath ++ [ "finalPackage" ];
+          update = old: cfg.package;
+        }
+        {
+          path = configPath ++ [ "policies" ];
+          update = old: { };
+        }
+      ]
+      (module {
+        inherit config lib pkgs;
+      });
+in
+{
+  imports = [
+    (fixFirefoxModuleCompat (mkFirefoxModule moduleArgs))
+  ];
+
+  options = lib.setAttrByPath modulePath { };
+
+  config = lib.mkIf cfg.enable (
+    { }
+    // lib.setAttrByPath modulePath {
+      # Tor & Mullvad Browser don't support profile version 2 yet
+      profileVersion = null;
+    }
+  );
+}
diff --git a/home/modules/firefox/mkFirefoxProfileBinModule.nix b/home/modules/firefox/mkFirefoxProfileBinModule.nix
new file mode 100644
index 0000000..ebae797
--- /dev/null
+++ b/home/modules/firefox/mkFirefoxProfileBinModule.nix
@@ -0,0 +1,67 @@
+{
+  modulePath,
+  name,
+  packageName,
+  isSecure ? false,
+}:
+
+{
+  config,
+  lib,
+  pkgs,
+  ...
+}:
+
+let
+  appName = name;
+  cfg = lib.getAttrFromPath modulePath config;
+
+  mkProfileBin =
+    profileName: profile:
+    let
+      pname = "${packageName}-${profileName}";
+      scriptBin = pkgs.writeScriptBin pname ''
+        ${packageName} -P "${profileName}" --name="${pname}" $@
+      '';
+      desktopFile = pkgs.makeDesktopItem {
+        name = pname;
+        exec = "${scriptBin}/bin/${pname} %U";
+        icon = packageName;
+        extraConfig.StartupWMClass = pname;
+        desktopName = "${appName} (${profileName})";
+        genericName = "Web Browser";
+        categories = [
+          "Network"
+          "WebBrowser"
+        ] ++ lib.optional isSecure "Security";
+      };
+    in
+    pkgs.runCommand pname { } ''
+      mkdir -p $out/{bin,share}
+      cp -r ${scriptBin}/bin/${pname} $out/bin/${pname}
+      cp -r ${desktopFile}/share/applications $out/share/applications
+    '';
+in
+{
+  options = lib.setAttrByPath modulePath {
+    createProfileBins = lib.mkOption {
+      type = lib.types.bool;
+      default = false;
+      description = ''
+        When enabled installs a binary for all non-default profiles named `${packageName}-''${profile}`.
+        This also includes a `.desktop` file that is configured to show separate icons (on GNOME at least).
+      '';
+    };
+  };
+
+  config = lib.mkIf cfg.enable (
+    {
+      home.packages =
+        if cfg.createProfileBins then
+          (lib.mapAttrsToList mkProfileBin) (lib.filterAttrs (_: p: !p.isDefault) cfg.profiles)
+        else
+          [ ];
+    }
+    // lib.setAttrByPath modulePath { }
+  );
+}
diff --git a/home/modules/firefox/mullvad-browser.nix b/home/modules/firefox/mullvad-browser.nix
index 7ad5f77..8542da4 100644
--- a/home/modules/firefox/mullvad-browser.nix
+++ b/home/modules/firefox/mullvad-browser.nix
@@ -1,18 +1,31 @@
-{
-  config,
-  lib,
-  pkgs,
-  ...
-}:
+_:
 
 let
-  common = import ./common.nix { inherit config lib pkgs; };
+  mkFirefoxModuleCompat = import ./mkFirefoxModuleCompat.nix;
+  mkFirefoxProfileBinModule = import ./mkFirefoxProfileBinModule.nix;
+
+  modulePath = [
+    "programs"
+    "mullvad-browser"
+  ];
+  name = "Mullvad Browser";
+  packageName = "mullvad-browser";
 in
-common.mkModule {
-  name = "mullvad-browser";
-  displayName = "Mullvad Browser";
-  dataConfigPath = ".mullvad/mullvadbrowser";
-  defaultPackage = pkgs.mullvad-browser;
-  defaultPackageName = "pkgs.mullvad-browser";
-  isSecure = true;
+{
+  imports = [
+    (mkFirefoxModuleCompat {
+      inherit modulePath name;
+      description = "Privacy-focused browser made in a collaboration between The Tor Project and Mullvad";
+      unwrappedPackageName = packageName;
+      visible = true;
+      platforms.linux = rec {
+        vendorPath = ".mullvad";
+        configPath = "${vendorPath}/mullvadbrowser";
+      };
+    })
+    (mkFirefoxProfileBinModule {
+      inherit modulePath name packageName;
+      isSecure = true;
+    })
+  ];
 }
diff --git a/home/modules/firefox/tor-browser.nix b/home/modules/firefox/tor-browser.nix
index 9626100..ee9ec23 100644
--- a/home/modules/firefox/tor-browser.nix
+++ b/home/modules/firefox/tor-browser.nix
@@ -1,18 +1,31 @@
-{
-  config,
-  lib,
-  pkgs,
-  ...
-}:
+_:
 
 let
-  common = import ./common.nix { inherit config lib pkgs; };
+  mkFirefoxModuleCompat = import ./mkFirefoxModuleCompat.nix;
+  mkFirefoxProfileBinModule = import ./mkFirefoxProfileBinModule.nix;
+
+  modulePath = [
+    "programs"
+    "tor-browser"
+  ];
+  name = "Tor Browser";
+  packageName = "tor-browser";
 in
-common.mkModule {
-  name = "tor-browser";
-  displayName = "Tor Browser";
-  dataConfigPath = ".tor project/firefox";
-  defaultPackage = pkgs.tor-browser;
-  defaultPackageName = "pkgs.tor-browser";
-  isSecure = true;
+{
+  imports = [
+    (mkFirefoxModuleCompat {
+      inherit modulePath name;
+      description = "Privacy-focused browser routing traffic through the Tor network";
+      unwrappedPackageName = packageName;
+      visible = true;
+      platforms.linux = rec {
+        vendorPath = ".tor project";
+        configPath = "${vendorPath}/firefox";
+      };
+    })
+    (mkFirefoxProfileBinModule {
+      inherit modulePath name packageName;
+      isSecure = true;
+    })
+  ];
 }