diff --git a/home/common/mime.nix b/home/common/mime.nix index b1e2a2f..6dfa925 100644 --- a/home/common/mime.nix +++ b/home/common/mime.nix @@ -1,13 +1,14 @@ -_: { +{pkgs, ...}: { xdg.mimeApps = { enable = true; + # TODO: make this a module (this is impractical, i should make it more generic) defaultApplications = { - "default-web-browser" = ["firefox.desktop"]; - "text/html" = ["firefox.desktop"]; - "x-scheme-handler/http" = ["firefox.desktop"]; - "x-scheme-handler/https" = ["firefox.desktop"]; - "x-scheme-handler/about" = ["firefox.desktop"]; - "x-scheme-handler/unknown" = ["firefox.desktop"]; + "default-web-browser" = ["floorp.desktop"]; + "text/html" = ["floorp.desktop"]; + "x-scheme-handler/http" = ["floorp.desktop"]; + "x-scheme-handler/https" = ["floorp.desktop"]; + "x-scheme-handler/about" = ["floorp.desktop"]; + "x-scheme-handler/unknown" = ["floorp.desktop"]; "application/pdf" = ["org.gnome.Evince.desktop"]; "audio/wav" = ["rhythmbox"]; }; diff --git a/home/common/programs/browsers.nix b/home/common/programs/browsers.nix index d6e9197..b25d9a6 100644 --- a/home/common/programs/browsers.nix +++ b/home/common/programs/browsers.nix @@ -1,4 +1,4 @@ -_: { +{pkgs, ...}: { programs.chromium.enable = true; - programs.firefox.enable = true; + home.packages = [pkgs.floorp]; } diff --git a/home/common/result b/home/common/result new file mode 120000 index 0000000..acadf39 --- /dev/null +++ b/home/common/result @@ -0,0 +1 @@ +/nix/store/2g6fyzmqrp5bn7dyp6qsl1hpnl82jw4c-floorp-11.17.8 \ No newline at end of file diff --git a/modules/gui/floorp/default.nix b/modules/gui/floorp/default.nix new file mode 100644 index 0000000..734b78e --- /dev/null +++ b/modules/gui/floorp/default.nix @@ -0,0 +1,915 @@ +{ + config, + lib, + pkgs, + ... +}: +with lib; let + inherit (pkgs.stdenv.hostPlatform) isDarwin; + + cfg = config.programs.floorp; + + jsonFormat = pkgs.formats.json {}; + + floorpConfigPath = + if isDarwin + then "Library/Application Support/Floorp" + else "${config.home.homeDirectory}/.floorp"; + + profilesPath = + if isDarwin + then "${floorpConfigPath}/Profiles" + else floorpConfigPath; + + # The extensions path shared by all profiles; will not be supported + # by future Floorp versions. + extensionPath = "extensions/{ec8030f7-c20a-464f-9b0e-13a3a9e97384}"; + + profiles = + flip mapAttrs' cfg.profiles (_: profile: + 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;}; + }; + + profilesIni = generators.toINI {} profiles; + + userPrefValue = pref: + builtins.toJSON ( + if isBool pref || isInt pref || isString pref + then pref + else builtins.toJSON pref + ); + + mkUserJs = prefs: extraPrefs: bookmarks: let + prefs' = + lib.optionalAttrs ([] != bookmarks) { + "browser.bookmarks.file" = toString (floorpBookmarksFile bookmarks); + "browser.places.importBookmarksHTML" = true; + } + // prefs; + in '' + // Generated by Home Manager. + + ${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 = 4; + lastUserContextId = + elemAt (mapAttrsToList (_: container: container.id) containers) 0; + identities = mapAttrsToList containerToIdentity containers; + }} + ''; + + floorpBookmarksFile = bookmarks: let + indent = level: + lib.concatStringsSep "" (map (lib.const " ") (lib.range 1 level)); + + bookmarkToHTML = indentLevel: bookmark: '' + ${indent indentLevel}
${escapeXML bookmark.name}''; + + directoryToHTML = indentLevel: directory: '' + ${indent indentLevel}
${ + if directory.toolbar + then '' +

Bookmarks Toolbar'' + else ''

${escapeXML directory.name}'' + }

+ ${indent indentLevel}

+ ${allItemsToHTML (indentLevel + 1) directory.bookmarks} + ${indent indentLevel}

''; + + 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 "floorp-bookmarks.html" '' + + + + Bookmarks +

Bookmarks Menu

+

+ ${bookmarkEntries} +

+ ''; + + 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: + filterAttrs (_entityId: entityNames: length entityNames != 1) + (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 Floorp ${entityKind} with an existing ID but + '' + + concatStringsSep "\n" (mapAttrsToList mkMsg duplicates); + }); + + wrapPackage = package: let + # The configuration expected by the Floorp 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 Floorp wrapper builder. + bcfg = setAttrByPath [browserName] fcfg; + in + if package == null + then null + else if isDarwin + then package + else if versionAtLeast config.home.stateVersion "19.09" + then + package.override (old: { + cfg = old.cfg or {} // fcfg; + extraPolicies = cfg.policies; + }) + else (pkgs.wrapFloorp.override {config = bcfg;}) package {}; +in { + meta.maintainers = [maintainers.rycee maintainers.kira-bruneau]; + + imports = [ + (mkRemovedOptionModule ["programs" "floorp" "extensions"] '' + + Extensions are now managed per-profile. That is, change from + + programs.floorp.extensions = [ foo bar ]; + + to + + programs.floorp.profiles.myprofile.extensions = [ foo bar ];'') + (mkRemovedOptionModule ["programs" "floorp" "enableAdobeFlash"] + "Support for this option has been removed.") + (mkRemovedOptionModule ["programs" "floorp" "enableGoogleTalk"] + "Support for this option has been removed.") + (mkRemovedOptionModule ["programs" "floorp" "enableIcedTea"] + "Support for this option has been removed.") + ]; + + options = { + programs.floorp = { + enable = mkEnableOption "Floorp"; + + package = mkOption { + type = with types; nullOr package; + default = + if versionAtLeast config.home.stateVersion "19.09" + then pkgs.floorp + else pkgs.floorp-unwrapped; + defaultText = literalExpression "pkgs.floorp"; + example = literalExpression '' + pkgs.floorp.override { + # See nixpkgs' floorp/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 Floorp package to use. If state version ≥ 19.09 then + this should be a wrapped Floorp package. For earlier state + versions it should be an unwrapped Floorp package. + Set to `null` to disable installing Floorp. + ''; + }; + + finalPackage = mkOption { + type = with types; nullOr package; + readOnly = true; + description = "Resulting Floorp package."; + }; + + policies = mkOption { + type = types.attrsOf jsonFormat.type; + default = {}; + description = "[See list of policies](https://mozilla.github.io/policy-templates/)."; + example = { + DefaultDownloadDirectory = "\${home}/Downloads"; + BlockAboutConfig = true; + }; + }; + + 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 = "Floorp 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 Floorp preferences. + + Floorp 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.lines; + default = ""; + description = "Custom Floorp 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 Floorp 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."; + }; + + tags = mkOption { + type = types.listOf types.str; + default = []; + description = "Bookmark tags."; + }; + + 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 = '' + Make this the toolbar directory. Note, this does _not_ + mean that this directory will be added to the toolbar, + this directory _is_ the toolbar. + ''; + }; + }; + }) + // { + description = "directory submodule"; + }; + + nodeType = types.either bookmarkType directoryType; + in + with types; + coercedTo (attrsOf nodeType) attrValues (listOf nodeType); + default = []; + example = literalExpression '' + [ + { + name = "wikipedia"; + tags = [ "wiki" ]; + 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"; + toolbar = true; + bookmarks = [ + { + name = "homepage"; + url = "https://nixos.org/"; + } + { + name = "wiki"; + tags = [ "wiki" "nix" ]; + 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 Floorp 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. + ''; + }; + + privateDefault = mkOption { + type = with types; nullOr str; + default = null; + example = "DuckDuckGo"; + description = '' + The default search engine used in the Private Browsing. + ''; + }; + + 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 {var}`metaData` specified will + be treated as builtin to Floorp. + + See [SearchEngine.jsm](https://searchfox.org/mozilla-central/rev/669329e284f8e8e2bb28090617192ca9b4ef3380/toolkit/components/search/SearchEngine.jsm#1138-1177) + in Floorp'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, {var}`icon` is also a special option + added by Home Manager to make it convenient to specify + absolute icon paths. + ''; + }; + }; + + 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.listOf types.package; + default = []; + example = literalExpression '' + with pkgs.nur.repos.rycee.floorp-addons; [ + privacy-badger + ] + ''; + description = '' + List of Floorp add-on packages to install for this profile. + Some pre-packaged add-ons are accessible from the + [Nix User Repository](https://github.com/nix-community/NUR). + Once you have NUR installed run + + ```console + $ nix-env -f '' -qaP -A nur.repos.rycee.floorp-addons + ``` + + to list the available Floorp add-ons. + + Note that it is necessary to manually enable these extensions + inside Floorp after the first installation. + ''; + }; + }; + })); + default = {}; + description = "Attribute set of Floorp profiles."; + }; + + enableGnomeExtensions = mkOption { + 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`. + ''; + }; + }; + }; + + 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 Floorp profile but found " + + toString (length defaults) + + optionalString (length defaults > 1) + (", namely " + concatStringsSep ", " defaults); + }) + + (mkNoDuplicateAssertion cfg.profiles "profile") + ] + ++ (mapAttrsToList + (_: profile: mkNoDuplicateAssertion profile.containers "container") + cfg.profiles); + + warnings = optional (cfg.enableGnomeExtensions or false) '' + Using 'programs.floorp.enableGnomeExtensions' has been deprecated and + will be removed in the future. Please change to overriding the package + configuration using 'programs.floorp.package' instead. You can refer to + its example for how to do this. + ''; + + programs.floorp.finalPackage = wrapPackage cfg.package; + + home.packages = lib.optional (cfg.finalPackage != null) cfg.finalPackage; + + home.file = mkMerge ([ + { + "${floorpConfigPath}/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}/containers.json" = mkIf (profile.containers != {}) { + text = mkContainersJson profile.containers; + }; + + "${profilesPath}/${profile.path}/search.json.mozlz4" = + mkIf + (profile.search.default + != null + || profile.search.privateDefault != 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.floorp.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 {}; + } + // { + ${profile.search.privateDefault} = + profile.search.engines.${profile.search.privateDefault} or {}; + }; + in + sortEngineConfigs (mapAttrs buildEngineConfig engineInput); + + metaData = + optionalAttrs (profile.search.default != null) { + current = profile.search.default; + hash = "@hash@"; + } + // optionalAttrs (profile.search.privateDefault != null) { + private = profile.search.privateDefault; + privateHash = "@privateHash@"; + } + // { + useSavedOrder = profile.search.order != []; + }; + }; + + # Home Manager doesn't circumvent user consent and isn't acting + # maliciously. We're modifying the search outside of Floorp, 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 "Floorp" + else null; + + privateSalt = + if profile.search.privateDefault != null + then + profile.path + + profile.search.privateDefault + + disclaimer "Floorp" + else null; + in + pkgs.runCommand "search.json.mozlz4" { + nativeBuildInputs = with pkgs; [mozlz4a openssl]; + json = builtins.toJSON settings; + inherit salt privateSalt; + } '' + if [[ -n $salt ]]; then + export hash=$(echo -n "$salt" | openssl dgst -sha256 -binary | base64) + export privateHash=$(echo -n "$privateSalt" | openssl dgst -sha256 -binary | base64) + mozlz4a <(substituteStream json search.json.in --subst-var hash --subst-var privateHash) "$out" + else + mozlz4a <(echo "$json") "$out" + fi + ''; + }; + + "${profilesPath}/${profile.path}/extensions" = mkIf (profile.extensions != []) { + source = let + extensionsEnvPkg = pkgs.buildEnv { + name = "hm-floorp-extensions"; + paths = profile.extensions; + }; + in "${extensionsEnvPkg}/share/mozilla/${extensionPath}"; + recursive = true; + force = true; + }; + })); + }; +}