{ modulePath, name, description ? null, wrappedPackageName ? null, unwrappedPackageName ? null, platforms, visible ? false, enableBookmarks ? true, }: { config, lib, pkgs, ... }: let inherit (lib) attrValues concatStringsSep length literalExpression mapAttrsToList mkIf mkMerge mkOption mkOptionDefault optionalString optional setAttrByPath types ; inherit (pkgs.stdenv.hostPlatform) isDarwin; appName = name; moduleName = concatStringsSep "." modulePath; cfg = lib.getAttrFromPath modulePath config; jsonFormat = pkgs.formats.json { }; supportedPlatforms = lib.flatten (lib.attrVals (lib.attrNames platforms) lib.platforms); isWrapped = lib.versionAtLeast config.home.stateVersion "19.09" && wrappedPackageName != null; defaultPackageName = if isWrapped then wrappedPackageName else unwrappedPackageName; packageName = if wrappedPackageName != null then wrappedPackageName else unwrappedPackageName; # The extensions path shared by all profiles; will not be supported # by future browser versions. extensionPath = "extensions/{ec8030f7-c20a-464f-9b0e-13a3a9e97384}"; profiles = lib.flip lib.mapAttrs' cfg.profiles ( _: profile: lib.nameValuePair "Profile${toString profile.id}" { Name = profile.name; Path = if isDarwin then "Profiles/${profile.path}" else profile.path; IsRelative = 1; Default = if profile.isDefault then 1 else 0; } ) // { General = { StartWithLastProfile = 1; } // lib.optionalAttrs (cfg.profileVersion != null) { Version = cfg.profileVersion; }; }; profilesIni = lib.generators.toINI { } profiles; userPrefValue = pref: builtins.toJSON ( if lib.isBool pref || lib.isInt pref || lib.isString pref then pref else builtins.toJSON pref ); extensionSettingsNeedForce = extensionSettings: builtins.any (ext: ext.settings != { }) (attrValues extensionSettings); mkUserJs = prePrefs: prefs: extraPrefs: bookmarksFile: extensions: let prefs' = lib.optionalAttrs (bookmarksFile != null) { "browser.bookmarks.file" = toString bookmarksFile; "browser.places.importBookmarksHTML" = true; } // lib.optionalAttrs (extensionSettingsNeedForce extensions) { "extensions.webextensions.ExtensionStorageIDB.enabled" = false; } // prefs; in '' // Generated by Home Manager. ${prePrefs} ${lib.concatStrings ( mapAttrsToList (name: value: '' user_pref("${name}", ${userPrefValue value}); '') prefs' )} ${extraPrefs} ''; mkContainersJson = containers: let containerToIdentity = _: container: { userContextId = container.id; name = container.name; icon = container.icon; color = container.color; public = true; }; in '' ${builtins.toJSON { version = 5; lastUserContextId = lib.foldlAttrs ( acc: _: value: if value.id > acc then value.id else acc ) 0 containers; identities = mapAttrsToList containerToIdentity containers ++ [ { userContextId = 4294967294; # 2^32 - 2 name = "userContextIdInternal.thumbnail"; icon = ""; color = ""; accessKey = ""; public = false; } { userContextId = 4294967295; # 2^32 - 1 name = "userContextIdInternal.webextStorageLocal"; icon = ""; color = ""; accessKey = ""; public = false; } ]; }} ''; mkNoDuplicateAssertion = entities: entityKind: ( let # Return an attribute set with entity IDs as keys and a list of # entity names with corresponding ID as value. An ID is present in # the result only if more than one entity has it. The argument # entities is a list of AttrSet of one id/name pair. findDuplicateIds = entities: lib.filterAttrs (_entityId: entityNames: length entityNames != 1) (lib.zipAttrs entities); duplicates = findDuplicateIds ( mapAttrsToList (entityName: entity: { "${toString entity.id}" = entityName; }) entities ); mkMsg = entityId: entityNames: " - ID ${entityId} is used by " + concatStringsSep ", " entityNames; in { assertion = duplicates == { }; message = '' Must not have a ${appName} ${entityKind} with an existing ID but '' + concatStringsSep "\n" (mapAttrsToList mkMsg duplicates); } ); wrapPackage = package: let # The configuration expected by the Firefox wrapper. fcfg = { enableGnomeExtensions = cfg.enableGnomeExtensions; }; # A bit of hackery to force a config into the wrapper. browserName = package.browserName or (builtins.parseDrvName package.name).name; # The configuration expected by the Firefox wrapper builder. bcfg = setAttrByPath [ browserName ] fcfg; in if package == null then null else if isWrapped then package.override (old: { cfg = old.cfg or { } // fcfg; extraPolicies = (old.extraPolicies or { }) // cfg.policies; pkcs11Modules = (old.pkcs11Modules or [ ]) ++ cfg.pkcs11Modules; }) else (pkgs.wrapFirefox.override { config = bcfg; }) package { }; bookmarkTypes = import ./profiles/bookmark-types.nix { inherit lib; }; in { options = setAttrByPath modulePath { enable = mkOption { type = types.bool; default = false; example = true; description = '' Whether to enable ${appName}.${optionalString (description != null) " ${description}"} ${optionalString (!visible) "See `${moduleName}` for more configuration options."} ''; }; package = mkOption { inherit visible; type = with types; nullOr package; default = pkgs.${defaultPackageName}; defaultText = literalExpression "pkgs.${packageName}"; example = literalExpression '' pkgs.${packageName}.override { # See nixpkgs' firefox/wrapper.nix to check which options you can use nativeMessagingHosts = [ # Gnome shell native connector pkgs.gnome-browser-connector # Tridactyl native connector pkgs.tridactyl-native ]; } ''; description = '' The ${appName} package to use. If state version ≥ 19.09 then this should be a wrapped ${appName} package. For earlier state versions it should be an unwrapped ${appName} package. Set to `null` to disable installing ${appName}. ''; }; release = mkOption { internal = true; type = types.str; description = "Upstream release version used to fetch from `releases.mozilla.org`."; }; languagePacks = mkOption { type = types.listOf types.str; default = [ ]; description = '' The language packs to install. Available language codes can be found on the releases page: `https://releases.mozilla.org/pub/firefox/releases/''${version}/linux-x86_64/xpi/`, replacing `''${version}` with the version of ${appName} you have. If the version string of your Firefox derivative diverts from the upstream version, try setting the `release` option. ''; example = [ "en-GB" "de" ]; }; name = mkOption { internal = true; type = types.str; default = name; example = "Firefox"; description = "The name of the browser."; }; wrappedPackageName = mkOption { internal = true; type = with types; nullOr str; default = wrappedPackageName; description = "Name of the wrapped browser package."; }; darwinDefaultsId = mkOption rec { type = types.nullOr types.str; default = if platforms.darwin ? "defaultsId" then platforms.darwin.defaultsId else null; example = if default != null then default else "com.developer.app"; description = ''The id for the darwin defaults in order to set policies''; }; darwinAppName = mkOption { internal = true; type = types.str; default = lib.toUpper (lib.substring 0 1 cfg.wrappedPackageName) + lib.toLower ( lib.substring 1 ((lib.stringLength cfg.wrappedPackageName) - 1) cfg.wrappedPackageName ); description = "Name of browser app on Darwin."; }; profilesPath = mkOption { internal = true; type = types.str; default = if isDarwin then "${cfg.configPath}/Profiles" else cfg.configPath; description = "Path to profiles."; }; vendorPath = mkOption { internal = true; type = with types; nullOr str; default = null; defaultText = literalExpression "platform specific vendor path"; example = ".mozilla"; description = "Directory containing the native messaging hosts directory."; }; configPath = mkOption { internal = true; type = types.str; default = with platforms; if isDarwin then darwin.configPath else linux.configPath; example = ".mozilla/firefox"; description = "Directory containing the ${appName} configuration files."; }; nativeMessagingHosts = mkOption { inherit visible; type = types.listOf types.package; default = [ ]; description = '' Additional packages containing native messaging hosts that should be made available to ${appName} extensions. ''; }; finalPackage = mkOption { inherit visible; type = with types; nullOr package; readOnly = true; description = "Resulting ${appName} package."; }; policies = lib.optionalAttrs (wrappedPackageName != null) (mkOption { inherit visible; type = types.attrsOf jsonFormat.type; default = { }; description = "[See list of policies](https://mozilla.github.io/policy-templates/)."; example = { DefaultDownloadDirectory = "\${home}/Downloads"; BlockAboutConfig = true; ExtensionSettings = { "uBlock0@raymondhill.net" = { install_url = "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi"; installation_mode = "force_installed"; default_area = "menupanel"; private_browsing = true; }; }; }; }); profileVersion = mkOption { internal = true; type = types.nullOr types.ints.unsigned; default = if isDarwin then null else 2; description = "profile version, set null for nix-darwin"; }; profiles = mkOption { inherit visible; type = types.attrsOf ( types.submodule ( { config, name, ... }: let profilePath = modulePath ++ [ "profiles" name ]; in { imports = [ (pkgs.path + "/nixos/modules/misc/assertions.nix") ]; 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. ''; }; preConfig = mkOption { type = types.lines; default = ""; description = '' Extra preferences to add to {file}`user.js`, before [](#opt-programs.firefox.profiles._name_.settings). Use [](#opt-programs.firefox.profiles._name_.extraConfig), unless you want to overwrite in [](#opt-programs.firefox.profiles._name_.settings), then use this option. ''; }; settings = mkOption { type = types.attrsOf ( jsonFormat.type // { description = "${appName} 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 ${appName} preferences. ${appName} 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 {file}`user.js`. ''; }; userChrome = mkOption { type = types.oneOf [ types.lines types.path ]; default = ""; description = "Custom ${appName} user chrome CSS."; example = '' /* Hide tab bar in FF Quantum */ @-moz-document url(chrome://browser/content/browser.xul), url(chrome://browser/content/browser.xhtml) { #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.oneOf [ types.lines types.path ]; default = ""; description = "Custom ${appName} user content CSS."; example = '' /* Hide scrollbar in FF Quantum */ *{scrollbar-width:none !important} ''; }; bookmarks = mkOption { type = ( types.coercedTo bookmarkTypes.settingsType ( bookmarks: if bookmarks != { } then lib.warn '' ${cfg.name} bookmarks have been refactored into a submodule that now explicitly require a 'force' option to be enabled. Replace: ${moduleName}.profiles.${name}.bookmarks = [ ... ]; With: ${moduleName}.profiles.${name}.bookmarks = { force = true; settings = [ ... ]; }; '' { force = true; settings = bookmarks; } else { } ) ( types.submodule ( { config, ... }: import ./profiles/bookmarks.nix { inherit config lib pkgs; modulePath = modulePath ++ [ "profiles" name "bookmarks" ]; } ) ) ); default = { }; internal = !enableBookmarks; description = "Declarative 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 = mkOption { type = types.submodule ( args: import ./profiles/search.nix { inherit (args) config; inherit lib pkgs appName; package = cfg.finalPackage; modulePath = modulePath ++ [ "profiles" name "search" ]; profilePath = config.path; } ); default = { }; description = "Declarative search engine configuration."; }; containersForce = mkOption { type = types.bool; default = false; description = '' Whether to force replace the existing containers configuration. This is recommended since ${appName} will replace the symlink on every launch, but note that you'll lose any existing configuration by enabling this. ''; }; containers = mkOption { type = types.attrsOf ( types.submodule ( { name, ... }: { options = { name = mkOption { type = types.str; default = name; description = "Container name, e.g., shopping."; }; id = mkOption { type = types.ints.unsigned; default = 0; description = '' Container ID. This should be set to a unique number per container in this profile. ''; }; # List of colors at # https://searchfox.org/mozilla-central/rev/5ad226c7379b0564c76dc3b54b44985356f94c5a/toolkit/components/extensions/parent/ext-contextualIdentities.js#32 color = mkOption { type = types.enum [ "blue" "turquoise" "green" "yellow" "orange" "red" "pink" "purple" "toolbar" ]; default = "pink"; description = "Container color."; }; icon = mkOption { type = types.enum [ "briefcase" "cart" "circle" "dollar" "fence" "fingerprint" "gift" "vacation" "food" "fruit" "pet" "tree" "chill" ]; default = "fruit"; description = "Container icon."; }; }; } ) ); default = { }; example = { "shopping" = { id = 1; color = "blue"; icon = "cart"; }; "dangerous" = { id = 2; color = "red"; icon = "fruit"; }; }; description = '' Attribute set of container configurations. See [Multi-Account Containers](https://support.mozilla.org/en-US/kb/containers) for more information. ''; }; 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..extensions to ${moduleName}.profiles..extensions.packages '' packages ); }) ( 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 '' -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..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..extensions.packages` in {option}`${moduleName}.profiles..extensions.settings..permissions` ''; default = false; example = true; type = types.bool; }; exactPermissions = mkOption { description = '' When enabled, {option}`${moduleName}.profiles..extensions.settings..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 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. ''; example = literalExpression '' { packages = with pkgs.nur.repos.rycee.firefox-addons; [ ublock-origin ]; settings."uBlock0@raymondhill.net".settings = { selectedFilterLists = [ "ublock-filters" "ublock-badware" "ublock-privacy" "ublock-unbreak" "ublock-quick-fixes" ]; }; } ''; }; }; config = { assertions = [ (mkNoDuplicateAssertion config.containers "container") { assertion = !(extensionSettingsNeedForce config.extensions.settings) || config.extensions.force; message = '' Using '${lib.showOption profilePath}.extensions.settings' will override all previous extensions settings. Enable '${lib.showOption profilePath}.extensions.force' to acknowledge this. ''; } ] ++ (builtins.concatMap ( { addonId ? null, name, meta, ... }: let safeAddonId = if addonId != null then addonId else name; permissions = config.extensions.settings.${safeAddonId}.permissions or null; requireCheck = config.extensions.exhaustivePermissions || permissions != null; authorizedPermissions = lib.optionals (permissions != null) permissions; missingPermissions = lib.subtractLists authorizedPermissions meta.mozPermissions; redundantPermissions = lib.subtractLists meta.mozPermissions authorizedPermissions; checkSatisfied = if config.extensions.exactPermissions then missingPermissions == [ ] && redundantPermissions == [ ] else missingPermissions == [ ]; errorMessage = if config.extensions.exactPermissions && missingPermissions != [ ] && redundantPermissions != [ ] then '' Extension ${safeAddonId} requests permissions that weren't authorized: ${builtins.toJSON missingPermissions}. Additionally, the following permissions were authorized, but extension ${safeAddonId} did not request them: ${builtins.toJSON redundantPermissions}. Consider adjusting the permissions in'' else if config.extensions.exactPermissions && redundantPermissions != [ ] then '' The following permissions were authorized, but extension ${safeAddonId} did not request them: ${builtins.toJSON redundantPermissions}. Consider removing the redundant permissions from'' else '' Extension ${safeAddonId} requests permissions that weren't authorized: ${builtins.toJSON missingPermissions}. Consider adding the missing permissions to''; in [ { assertion = !requireCheck || checkSatisfied; message = '' ${errorMessage} '${ lib.showAttrPath ( profilePath ++ [ "extensions" safeAddonId ] ) }.permissions'. ''; } ] ) config.extensions.packages) ++ (builtins.concatMap ( { name, value }: let packages = builtins.filter (pkg: (pkg.addonId or pkg.name) == name) config.extensions.packages; in [ { assertion = value.permissions == null || length packages == 1; message = '' Must have exactly one extension with addonId '${name}' in '${lib.showOption profilePath}.extensions.packages' but found ${toString (length packages)}. ''; } ] ) (lib.attrsToList config.extensions.settings)) ++ config.bookmarks.assertions; }; } ) ); default = { }; description = "Attribute set of ${appName} profiles."; }; enableGnomeExtensions = mkOption { inherit visible; type = types.bool; default = false; description = '' Whether to enable the GNOME Shell native host connector. Note, you also need to set the NixOS option `services.gnome.gnome-browser-connector.enable` to `true`. ''; }; pkcs11Modules = mkOption { type = types.listOf types.package; default = [ ]; description = '' Additional packages to be loaded as PKCS #11 modules in Firefox. ''; }; }; config = mkIf cfg.enable ( { assertions = [ (lib.hm.assertions.assertPlatform moduleName pkgs supportedPlatforms) ( let defaults = lib.catAttrs "name" (lib.filter (a: a.isDefault) (attrValues cfg.profiles)); in { assertion = cfg.profiles == { } || length defaults == 1; message = "Must have exactly one default ${appName} profile but found " + toString (length defaults) + optionalString (length defaults > 1) (", namely " + concatStringsSep ", " defaults); } ) ( let getContainers = profiles: lib.flatten (mapAttrsToList (_: value: (attrValues value.containers)) profiles); findInvalidContainerIds = profiles: lib.filter (container: container.id >= 4294967294) (getContainers profiles); in { assertion = cfg.profiles == { } || length (findInvalidContainerIds cfg.profiles) == 0; message = "Container id must be smaller than 4294967294 (2^32 - 2)"; } ) { assertion = cfg.languagePacks == [ ] || cfg.package != null; message = '' '${moduleName}.languagePacks' requires '${moduleName}.package' to be set to a non-null value. ''; } (mkNoDuplicateAssertion cfg.profiles "profile") ] ++ (lib.concatMap (profile: profile.assertions) (attrValues cfg.profiles)); warnings = optional (cfg.enableGnomeExtensions or false) '' Using '${moduleName}.enableGnomeExtensions' has been deprecated and will be removed in the future. Please change to overriding the package configuration using '${moduleName}.package' instead. You can refer to its example for how to do this. '' ++ optional (cfg.vendorPath != null) '' Using '${moduleName}.vendorPath' has been deprecated and will be removed in the future. Native messaging hosts will function normally without specifying this path. ''; targets.darwin.defaults = ( mkIf (cfg.darwinDefaultsId != null && isDarwin) { ${cfg.darwinDefaultsId} = { EnterprisePoliciesEnabled = true; } // cfg.policies; } ); home.packages = lib.optional (cfg.finalPackage != null) cfg.finalPackage; home.file = mkMerge ( [ { "${cfg.configPath}/profiles.ini" = mkIf (cfg.profiles != { }) { text = profilesIni; }; } ] ++ lib.flip mapAttrsToList cfg.profiles ( _: profile: # Merge the regular profile settings with extension settings mkMerge [ { "${cfg.profilesPath}/${profile.path}/.keep".text = ""; "${cfg.profilesPath}/${profile.path}/chrome/userChrome.css" = mkIf (profile.userChrome != "") ( let key = if builtins.isString profile.userChrome then "text" else "source"; in { "${key}" = profile.userChrome; } ); "${cfg.profilesPath}/${profile.path}/chrome/userContent.css" = mkIf (profile.userContent != "") ( let key = if builtins.isString profile.userContent then "text" else "source"; in { "${key}" = profile.userContent; } ); "${cfg.profilesPath}/${profile.path}/user.js" = mkIf ( profile.preConfig != "" || profile.settings != { } || profile.extraConfig != "" || profile.bookmarks.configFile != null || extensionSettingsNeedForce profile.extensions.settings ) { text = mkUserJs profile.preConfig profile.settings profile.extraConfig profile.bookmarks.configFile profile.extensions.settings; }; "${cfg.profilesPath}/${profile.path}/containers.json" = mkIf (profile.containers != { }) { text = mkContainersJson profile.containers; force = profile.containersForce; }; "${cfg.profilesPath}/${profile.path}/search.json.mozlz4" = mkIf (profile.search.enable) { enable = profile.search.enable; force = profile.search.force; source = profile.search.file; }; "${cfg.profilesPath}/${profile.path}/extensions" = mkIf (profile.extensions.packages != [ ]) { source = let extensionsEnvPkg = pkgs.buildEnv { name = "hm-firefox-extensions"; paths = profile.extensions.packages; }; in "${extensionsEnvPkg}/share/mozilla/${extensionPath}"; recursive = true; force = true; }; } (mkMerge ( mapAttrsToList ( name: settingConfig: mkIf (settingConfig.settings != { }) { "${cfg.profilesPath}/${profile.path}/browser-extension-data/${name}/storage.js" = { force = settingConfig.force || profile.extensions.force; text = lib.generators.toJSON { } settingConfig.settings; }; } ) profile.extensions.settings )) ] ) ); } // setAttrByPath modulePath { finalPackage = wrapPackage cfg.package; release = mkOptionDefault (builtins.head (lib.splitString "-" cfg.package.version)); policies = { NoDefaultBookmarks = lib.mkIf (builtins.any (profile: profile.bookmarks.enable) ( builtins.attrValues cfg.profiles )) false; ExtensionSettings = lib.mkIf (cfg.languagePacks != [ ]) ( lib.listToAttrs ( map ( lang: lib.nameValuePair "langpack-${lang}@firefox.mozilla.org" { installation_mode = "normal_installed"; install_url = "https://releases.mozilla.org/pub/firefox/releases/${cfg.release}/linux-x86_64/xpi/${lang}.xpi"; } ) cfg.languagePacks ) ); }; } ); }