diff --git a/modules/home/brave/default.nix b/modules/home/brave/default.nix index 21650324..962f94bb 100644 --- a/modules/home/brave/default.nix +++ b/modules/home/brave/default.nix @@ -9,42 +9,217 @@ with lib; let cfg = config.modules.brave; inherit (pkgs.stdenv) isDarwin isLinux; + + braveDataDir = + if isDarwin then + "${config.home.homeDirectory}/Library/Application Support/BraveSoftware/Brave-Browser" + else + "${config.xdg.configHome}/BraveSoftware/Brave-Browser"; + + # ── Types ── + + extensionType = types.submodule { + options = { + id = mkOption { + type = types.singleLineStr; + description = "Chrome Web Store extension ID"; + }; + pinned = mkOption { + type = types.bool; + default = false; + description = "Whether to pin this extension to the toolbar"; + }; + }; + }; + + searchEngineType = types.submodule { + options = { + name = mkOption { + type = types.singleLineStr; + description = "Display name of the search engine"; + }; + url = mkOption { + type = types.singleLineStr; + description = "Search URL template. Use {searchTerms} as placeholder"; + }; + favicon = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to a favicon image, in any format ImageMagick can read + ''; + }; + }; + }; + + profileType = types.submodule { + options = { + preferences = mkOption { + type = types.attrs; + default = { }; + description = "Nested attrset of Brave JSON preferences for this profile"; + }; + searchEngines = mkOption { + type = types.attrsOf searchEngineType; + default = { }; + description = '' + Custom search engines for this profile. The attribute name is + used as the keyword (shortcut). + ''; + }; + }; + }; + + # ── Package wrapping ── + + wrappedBrave = + let + disableFlags = concatStringsSep "," cfg.disabledFeatures; + in + if isDarwin then + pkgs.symlinkJoin { + name = "brave-wrapped"; + paths = [ pkgs.brave ]; + nativeBuildInputs = [ pkgs.makeWrapper ]; + postBuild = '' + rm "$out/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" + makeWrapper \ + "${pkgs.brave}/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" \ + "$out/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" \ + --add-flags "--disable-features=${disableFlags}" + ''; + } + else + pkgs.symlinkJoin { + name = "brave-wrapped"; + paths = [ pkgs.brave ]; + nativeBuildInputs = [ pkgs.makeWrapper ]; + postBuild = '' + wrapProgram "$out/bin/brave" \ + --add-flags "--disable-features=${disableFlags}" + ''; + }; + + # ── Per-profile activation entries ── + + pinnedExtensionIds = mapAttrsToList (_: ext: ext.id) ( + filterAttrs (_: ext: ext.pinned) cfg.extensions + ); + + flattenPrefs = + prefix: attrs: + concatLists ( + mapAttrsToList ( + k: v: + let + path = prefix ++ [ k ]; + in + if isAttrs v && !(isList v) then + flattenPrefs path v + else + [ + { + inherit path; + value = v; + } + ] + ) attrs + ); + + mkPreferencesActivation = + profileName: profileCfg: + let + merged = + profileCfg.preferences + // optionalAttrs (pinnedExtensionIds != [ ]) { + extensions.pinned_extensions = pinnedExtensionIds; + }; + sanitized = strings.sanitizeDerivationName profileName; + in + nameValuePair "setBravePreferences-${profileName}" ( + lib.hm.dag.entryAfter [ "writeBoundary" ] ( + pkgs.callPackage ./set-preferences.nix { + preferencesPath = "${braveDataDir}/${profileName}/Preferences"; + prefUpdates = builtins.toJSON (flattenPrefs [ ] merged); + hashFile = "${config.xdg.cacheHome}/home-manager/brave-preferences-${sanitized}.hash"; + inherit isDarwin; + } + ) + ); + + mkSearchEnginesActivation = + profileName: profileCfg: + let + sanitized = strings.sanitizeDerivationName profileName; + in + nameValuePair "setBraveSearchEngines-${profileName}" ( + lib.hm.dag.entryAfter [ "writeBoundary" ] ( + pkgs.callPackage ./set-search-engines.nix { + engines = profileCfg.searchEngines; + dbPath = "${braveDataDir}/${profileName}/Web Data"; + faviconsDbPath = "${braveDataDir}/${profileName}/Favicons"; + hashFile = "${config.xdg.cacheHome}/home-manager/brave-search-engines-${sanitized}.hash"; + inherit isDarwin; + } + ) + ); in { options.modules.brave = { enable = mkEnableOption "Brave"; - }; - config = mkIf cfg.enable { - programs.brave = { - enable = true; - package = - if isDarwin then - pkgs.symlinkJoin { - name = "brave-wrapped"; - paths = [ pkgs.brave ]; - nativeBuildInputs = [ pkgs.makeWrapper ]; - postBuild = '' - rm "$out/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" - makeWrapper \ - "${pkgs.brave}/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" \ - "$out/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" \ - --add-flags "--disable-features=GlobalMediaControls" - ''; - } - else - pkgs.brave; - extensions = [ - { id = "ghmbeldphafepmbegfdlkpapadhbakde"; } # Proton Pass - { id = "khncfooichmfjbepaaaebmommgaepoid"; } # Unhook - ]; + isDefaultBrowser = mkOption { + type = types.bool; + default = false; + description = "Whether to set Brave as the default browser"; + }; + + extensions = mkOption { + type = types.attrsOf extensionType; + default = { }; + description = "Extensions to install, keyed by a human-readable name"; + }; + + disabledFeatures = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Chromium feature flags to disable via --disable-features"; }; # See https://chromeenterprise.google/policies/ and # https://support.brave.app/hc/en-us/articles/360039248271-Group-Policy for # the available policies. - modules.macOSPreferences.apps."com.brave.Browser" = { - forced = { + policies = mkOption { + type = types.attrs; + default = { }; + description = "Enterprise policies"; + }; + + profiles = mkOption { + type = types.attrsOf profileType; + default = { }; + description = '' + Per-profile Brave configuration. Keys are profile directory names + (e.g. "Default", "Profile-1", etc.). + ''; + }; + }; + + config = mkIf cfg.enable { + modules.brave = { + isDefaultBrowser = true; + + disabledFeatures = [ "GlobalMediaControls" ]; + + extensions = { + proton-pass = { + id = "ghmbeldphafepmbegfdlkpapadhbakde"; + pinned = true; + }; + unhook.id = "khncfooichmfjbepaaaebmommgaepoid"; + }; + + policies = { AutofillAddressEnabled = false; AutofillCreditCardEnabled = false; BookmarkBarEnabled = false; @@ -61,21 +236,100 @@ in PasswordManagerEnabled = false; SyncDisabled = true; }; + + profiles.Default = { + preferences = { + brave = { + brave_search."show-ntp-search" = false; + new_tab_page = { + background = { + random = false; + selected_value = config.modules.colorschemes.palette.primary.background; + show_background_image = true; + type = "color"; + }; + show_stats = false; + }; + show_bookmarks_button = false; + show_side_panel_button = false; + }; + # Yes, the typo in the key is from Brave itself. + ntp.shortcust_visible = false; + toolbar.pinned_actions = [ ]; + }; + + searchEngines = + let + nixFavicon = pkgs.fetchurl { + url = "https://nixos.org/favicon.svg"; + hash = "sha256-UL/Eyk/e7Yrfz8uR9MZwB80a+S4HC9CjixpW8tpJMvY="; + }; + in + { + hm = { + name = "Home Manager Options"; + url = "https://home-manager-options.extranix.com/?query={searchTerms}"; + favicon = nixFavicon; + }; + nixo = { + name = "NixOS options"; + url = "https://search.nixos.org/options?channel=unstable&query={searchTerms}"; + favicon = nixFavicon; + }; + nixp = { + name = "Nix packages"; + url = "https://search.nixos.org/packages?channel=unstable&query={searchTerms}"; + favicon = nixFavicon; + }; + std = + let + rustFavicon = pkgs.fetchurl { + name = "rust-favicon.svg"; + url = "https://rust-lang.org/static/images/favicon.svg"; + hash = "sha256-BEvjkUSrMEz56g6QFMP0duDFGUxiqlJbNmnK9dtawIg="; + }; + rustFaviconWhite = pkgs.runCommand "rust-favicon-white.png" { } '' + ${lib.getExe' pkgs.imagemagick "magick"} \ + -density 384 -background none "${rustFavicon}" \ + -fill white -colorize 100 \ + PNG32:"$out" + ''; + in + { + name = "std's docs"; + url = "https://doc.rust-lang.org/nightly/std/?search={searchTerms}"; + favicon = rustFaviconWhite; + }; + }; + }; }; - home.activation = lib.mkIf isDarwin { - setBraveAsDefaultBrowser = lib.hm.dag.entryAfter [ "writeBoundary" ] '' - run ${pkgs.defaultbrowser}/bin/defaultbrowser browser - ''; - setBravePreferences = lib.hm.dag.entryAfter [ "writeBoundary" ] ( - import ./set-preferences.nix { inherit config pkgs lib; } - ); - setBraveSearchEngines = lib.hm.dag.entryAfter [ "writeBoundary" ] ( - import ./set-search-engines.nix { inherit config pkgs lib; } - ); + programs.brave = { + enable = true; + package = if cfg.disabledFeatures != [ ] then wrappedBrave else pkgs.brave; + extensions = mapAttrsToList (_: ext: { inherit (ext) id; }) cfg.extensions; }; - xdg.mimeApps = lib.mkIf isLinux { + modules.macOSPreferences.apps."com.brave.Browser".forced = cfg.policies; + + home.activation = + ( + cfg.profiles + |> filterAttrs (_: p: p.preferences != { } || pinnedExtensionIds != [ ]) + |> mapAttrs' mkPreferencesActivation + ) + // ( + cfg.profiles + |> filterAttrs (_: p: p.searchEngines != { }) + |> mapAttrs' mkSearchEnginesActivation + ) + // optionalAttrs (isDarwin && cfg.isDefaultBrowser) { + setBraveAsDefaultBrowser = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + run ${pkgs.defaultbrowser}/bin/defaultbrowser browser + ''; + }; + + xdg.mimeApps = mkIf (isLinux && cfg.isDefaultBrowser) { enable = true; defaultApplications = { "text/html" = [ "brave.desktop" ]; diff --git a/modules/home/brave/set-preferences.nix b/modules/home/brave/set-preferences.nix index 94eff6cf..a742b431 100644 --- a/modules/home/brave/set-preferences.nix +++ b/modules/home/brave/set-preferences.nix @@ -1,110 +1,62 @@ +# A script that patches Brave's Preferences JSON file for a single profile. +# Quits and relaunches Brave if it's running. { - config, - pkgs, lib, + jq, + openssl, + # ── + preferencesPath, + prefUpdates, + hashFile, + isDarwin, }: let - # None of these settings are documented anywhere AFAIK. They were found by - # copying the current Preferences file, manually changing settings in Brave - # via the UI, and then diffing the copied file against the latest version. - preferences = { - brave = { - # Hide the Brave search box in the new tab page. - brave_search.show-ntp-search = false; - new_tab_page = { - background = { - random = false; - selected_value = config.modules.colorschemes.palette.primary.background; - show_background_image = true; - type = "color"; - }; - show_stats = false; - }; - show_bookmarks_button = false; - show_side_panel_button = false; - }; - # Pin the Proton Pass extension to the toolbar. - extensions.pinned_extensions = [ - "ghmbeldphafepmbegfdlkpapadhbakde" - ]; - # Hide the top sites in the new tab page (yes, there's a typo in the key). - ntp.shortcust_visible = false; - # Remove all buttons from the toolbar. - toolbar.pinned_actions = [ ]; - }; + sha = lib.getExe' openssl "openssl"; - profile = "Default"; + pgrep = + if isDarwin then ''/usr/bin/pgrep -x "Brave Browser"'' else "pgrep -x brave"; - preferencesPath = "${config.home.homeDirectory}/Library/Application Support/BraveSoftware/Brave-Browser/${profile}/Preferences"; + quit = + if isDarwin then + ''/usr/bin/osascript -e 'quit app "Brave Browser"' '' + else + "pkill -TERM brave"; - # Flatten nested attrset into list of [{path, value}]. - flattenPrefs = - prefix: attrs: - lib.concatLists ( - lib.mapAttrsToList ( - k: v: - let - newPrefix = prefix ++ [ k ]; - in - if lib.isAttrs v then - flattenPrefs newPrefix v - else - [ - { - path = newPrefix; - value = v; - } - ] - ) attrs - ); - - prefUpdates = builtins.toJSON (flattenPrefs [ ] preferences); + relaunch = if isDarwin then ''/usr/bin/open -a "Brave Browser"'' else "brave &"; in '' - is_brave_running() { - /usr/bin/pgrep -x "Brave Browser" > /dev/null 2>&1 - } - - apply_brave_preferences() { - # Exit early if Preferences doesn't exist yet. + _set_brave_preferences() { [[ -f "${preferencesPath}" ]] || return 0 - # Generate the checksum of the preference updates. - pref_hash=$(echo -n '${prefUpdates}' | ${pkgs.openssl}/bin/openssl dgst -sha256 | cut -d' ' -f2) - hash_file="${config.xdg.cacheHome}/home-manager/brave-preferences.hash" + local pref_hash + pref_hash=$(echo -n '${prefUpdates}' | ${sha} dgst -sha256 | cut -d' ' -f2) - # Exit early if the preferences haven't changed. - if [[ -f "$hash_file" ]] && [[ "$(cat "$hash_file")" == "$pref_hash" ]]; then - echo "Brave preferences already up to date" + if [[ -f "${hashFile}" ]] && [[ "$(cat "${hashFile}")" == "$pref_hash" ]]; then return 0 fi - # Brave writes the Preferences file on exit, so we need to quit it first. - brave_was_running=0 - if is_brave_running; then + local brave_was_running=0 + if ${pgrep} > /dev/null 2>&1; then brave_was_running=1 - run /usr/bin/osascript -e 'quit app "Brave Browser"' - while is_brave_running; do /bin/sleep 0.5; done + ${quit} + while ${pgrep} > /dev/null 2>&1; do sleep 0.5; done fi - # Apply each preference update. - run ${pkgs.jq}/bin/jq \ + run ${lib.getExe jq} \ --argjson updates '${prefUpdates}' \ 'reduce $updates[] as $update (.; setpath($update.path; $update.value))' \ "${preferencesPath}" > "${preferencesPath}.tmp" run mv "${preferencesPath}.tmp" "${preferencesPath}" - # Restart Brave if it was running. if [[ "$brave_was_running" -eq 1 ]]; then - run /usr/bin/open -a "Brave Browser" + run ${relaunch} fi - # Store the hash. - run mkdir -p "$(dirname "$hash_file")" - run echo "$pref_hash" > "$hash_file" + run mkdir -p "$(dirname "${hashFile}")" + echo "$pref_hash" > "${hashFile}" } - apply_brave_preferences + _set_brave_preferences '' diff --git a/modules/home/brave/set-search-engines.nix b/modules/home/brave/set-search-engines.nix index fd818a29..e551ac05 100644 --- a/modules/home/brave/set-search-engines.nix +++ b/modules/home/brave/set-search-engines.nix @@ -1,113 +1,133 @@ +# A script that writes custom search engines into Brave's "Web Data" SQLite +# database for a single profile, and optionally inserts favicons into the +# "Favicons" database. Quits and relaunches Brave if it's running. { - config, - pkgs, lib, + imagemagick, + openssl, + sqlite, + unixtools, + writeText, + # ── + engines, # attrsOf { name, url, favicon? } + dbPath, + faviconsDbPath, + hashFile, + isDarwin, }: let - searchEngines = { - hm = { - short_name = "Home Manager Options"; - url = "https://home-manager-options.extranix.com/?query={searchTerms}"; - favicon_url = "https://nixos.org/favicon.ico"; - }; - nixo = { - short_name = "NixOS options"; - url = "https://search.nixos.org/options?channel=unstable&query={searchTerms}"; - favicon_url = "https://nixos.org/favicon.ico"; - }; - nixp = { - short_name = "Nix packages"; - url = "https://search.nixos.org/packages?channel=unstable&query={searchTerms}"; - favicon_url = "https://nixos.org/favicon.ico"; - }; - std = { - short_name = "std's docs"; - url = "https://doc.rust-lang.org/nightly/std/?search={searchTerms}"; - favicon_url = "https://rust-lang.org/logos/rust-logo-blk.svg"; - }; - }; - - searchEnginesList = lib.mapAttrsToList ( - keyword: attrs: - attrs - // { - inherit keyword; - safe_for_autoreplace = 0; - created_by_policy = 1; - input_encodings = "UTF-8"; - } - ) searchEngines; - - sqlScript = - let - nix2Sql = - v: - if builtins.isString v then - "'${builtins.replaceStrings [ "'" ] [ "''" ] v}'" - else - toString v; - in - '' - -- Remove all policy-managed search engines. - DELETE FROM keywords WHERE created_by_policy = 1; - - -- Insert the configured search engines. - ${lib.concatMapStringsSep "\n" ( - engine: - let - columns = builtins.attrNames engine; - values = map (column: nix2Sql engine.${column}) columns; - columnsStr = lib.concatStringsSep ", " columns; - valuesStr = lib.concatStringsSep ", " values; - in - "INSERT INTO keywords (${columnsStr}) VALUES (${valuesStr});" - ) searchEnginesList} - ''; - - sqlScriptFile = pkgs.writeText "brave-search-engines.sql" sqlScript; - - dbPath = "${config.home.homeDirectory}/Library/Application Support/BraveSoftware/Brave-Browser/Default/Web Data"; + nix2Sql = + v: + if builtins.isString v then + "'${builtins.replaceStrings [ "'" ] [ "''" ] v}'" + else + toString v; + + enginesList = lib.mapAttrsToList (keyword: engine: { + inherit keyword; + short_name = engine.name; + inherit (engine) url; + favicon_url = + if engine ? favicon && engine.favicon != null then + "nix-managed://${keyword}" + else + ""; + safe_for_autoreplace = 0; + created_by_policy = 1; + input_encodings = "UTF-8"; + }) engines; + + sqlScript = writeText "brave-search-engines.sql" '' + -- Remove all policy-managed search engines. + DELETE FROM keywords WHERE created_by_policy = 1; + + -- Insert the configured search engines. + ${lib.concatMapStringsSep "\n" ( + engine: + let + columns = builtins.attrNames engine; + values = map (col: nix2Sql engine.${col}) columns; + in + "INSERT INTO keywords (${lib.concatStringsSep ", " columns}) VALUES (${lib.concatStringsSep ", " values});" + ) enginesList} + ''; + + faviconEntries = + engines + |> lib.filterAttrs (_: engine: engine ? favicon && engine.favicon != null) + |> lib.mapAttrsToList ( + keyword: engine: { + inherit keyword; + faviconUrl = "nix-managed://${keyword}"; + src = engine.favicon; + } + ); + + sha = lib.getExe' openssl "openssl"; + convert = lib.getExe' imagemagick "magick"; + sqlite3 = lib.getExe sqlite; + xxd = lib.getExe unixtools.xxd; + + pgrep = + if isDarwin then ''/usr/bin/pgrep -x "Brave Browser"'' else "pgrep -x brave"; + + quit = + if isDarwin then + ''/usr/bin/osascript -e 'quit app "Brave Browser"' '' + else + "pkill -TERM brave"; + + relaunch = if isDarwin then ''/usr/bin/open -a "Brave Browser"'' else "brave &"; in '' - is_brave_running() { - /usr/bin/pgrep -x "Brave Browser" > /dev/null 2>&1 - } - - apply_brave_search_engines() { - # Exit early if Brave hasn't yet created the DB. + _set_brave_search_engines() { [[ -f "${dbPath}" ]] || return 0 - # Generate the checksum of the SQL script. - script_hash=$(${pkgs.openssl}/bin/openssl dgst -sha256 ${sqlScriptFile} | cut -d' ' -f2) - hash_file="${config.xdg.cacheHome}/home-manager/brave-search-engines.hash" + local script_hash + script_hash=$(${sha} dgst -sha256 ${sqlScript} ${ + lib.concatMapStringsSep " " (e: toString e.src) faviconEntries + } | cut -d' ' -f2 | ${sha} dgst -sha256 | cut -d' ' -f2) - # Exit early if the search engines haven't changed. - if [[ -f "$hash_file" ]] && [[ "$(cat "$hash_file")" == "$script_hash" ]]; then - echo "Brave search engines already up to date" + if [[ -f "${hashFile}" ]] && [[ "$(cat "${hashFile}")" == "$script_hash" ]]; then return 0 fi - # The database is locked while Brave is running, so we need to quit it first. - brave_was_running=0 - if is_brave_running; then + local brave_was_running=0 + if ${pgrep} > /dev/null 2>&1; then brave_was_running=1 - run /usr/bin/osascript -e 'quit app "Brave Browser"' - while is_brave_running; do /bin/sleep 0.5; done + ${quit} + while ${pgrep} > /dev/null 2>&1; do sleep 0.5; done fi - # Apply SQL changes. - run ${pkgs.sqlite}/bin/sqlite3 "${dbPath}" < ${sqlScriptFile} + run ${sqlite3} "${dbPath}" < ${sqlScript} + + ${lib.concatMapStringsSep "\n" (entry: '' + if [[ -f "${faviconsDbPath}" ]]; then + local icon hex + icon=$(mktemp) + ${convert} -density 384 -background none "${toString entry.src}[0]" -resize 256x256 -gravity center -extent 256x256 PNG32:"$icon" + + hex=$(${xxd} -p "$icon" | tr -d '\n') + + run ${sqlite3} "${faviconsDbPath}" < "$hash_file" + run mkdir -p "$(dirname "${hashFile}")" + echo "$script_hash" > "${hashFile}" } - apply_brave_search_engines + _set_brave_search_engines ''