Swapping Chrome for Brave on Windows

Unfortunately, Google phased out Manifest V2 in Chrome, so the full version of uBlock Origin is no longer available:

In late 2024, Google completed its transition to Manifest V3 in Chrome, which fundamentally changed how browser extensions work. As a result, the full version of uBlock Origin is no longer available for Chrome users. Here's what happened and what your options are now.

Let's switch to Brave, a more privacy-focused browser.

Installation

We can install it from the command-line:

winget install Brave.Brave

Disable Brave features

I don't need Leo, Wallet, or the built-in VPN. We can disable these features with a registry policy for the current Windows user:

& {
    $ErrorActionPreference = 'Stop'
    if (!(whoami /groups | findstr S-1-16-12288)) {
        throw 'Run PowerShell as Administrator.'
    }
    $policyPath = 'HKCU:\SOFTWARE\Policies\BraveSoftware\Brave'

    New-Item -Path $policyPath -Force | Out-Null

    $policies = @{
        BraveAIChatEnabled  = 0
        BraveWalletDisabled = 1
        BraveVPNDisabled    = 1
    }

    foreach ($policy in $policies.GetEnumerator()) {
        New-ItemProperty `
            -Path $policyPath `
            -Name $policy.Key `
            -PropertyType DWord `
            -Value $policy.Value `
            -Force | Out-Null
    }

    Write-Host 'Brave policies applied successfully.'
    Write-Host 'Restart Brave for the changes to take effect.'
}

Restart Brave and check brave://policy to verify that the policies are active.

Create the profiles

I want two profiles: Personal and Company. This script lets Brave initialize any missing profiles, then configures their names and avatar slots. It leaves the avatar image files alone; we'll replace those manually in the next section.

& {
    $ErrorActionPreference = 'Stop'

    function Wait-BraveClosed {
        while (Get-Process brave -ErrorAction SilentlyContinue) {
            Read-Host 'Close Brave, then press Enter to continue'
        }
    }

    Wait-BraveClosed
    $brave = @($env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:LOCALAPPDATA) |
        ForEach-Object { "$_\BraveSoftware\Brave-Browser\Application\brave.exe" } |
        Where-Object { Test-Path $_ } | Select-Object -First 1
    if (-not $brave) { throw 'Brave could not be found.' }

    $userData = "$env:LOCALAPPDATA\BraveSoftware\Brave-Browser\User Data"
    $profiles = @(
        @{ Name = 'Personal'; Directory = 'Default'; Index = 57 }
        @{ Name = 'Company'; Directory = 'Profile 1'; Index = 56 }
    )
    foreach ($profile in $profiles) {
        $name = Read-Host "Profile name [$($profile.Name)]"
        if (-not [string]::IsNullOrWhiteSpace($name)) { $profile.Name = $name }
    }

    function Wait-ForFile($Path, $Seconds = 15) {
        $timeout = [DateTime]::UtcNow.AddSeconds($Seconds)
        while (-not (Test-Path $Path) -and [DateTime]::UtcNow -lt $timeout) {
            Start-Sleep -Milliseconds 500
        }
        if (-not (Test-Path $Path)) { throw "File creation timed out: $Path" }
    }

    function Set-JsonProperty($Object, $Name, $Value) {
        if ($Object.PSObject.Properties.Name -contains $Name) {
            $Object.$Name = $Value
        }
        else {
            $Object | Add-Member NoteProperty $Name $Value
        }
    }

    function New-BraveProfile($Profile) {
        $preferences = "$userData\$($Profile.Directory)\Preferences"
        if (Test-Path $preferences) { return }

        Write-Host "Creating profile: $($Profile.Name)"
        $arguments = "--headless --dump-dom --user-data-dir=`"$userData`" --profile-directory=`"$($Profile.Directory)`" --no-first-run --no-default-browser-check about:blank"
        Start-Process $brave -ArgumentList $arguments -Wait | Out-Null
        Wait-ForFile $preferences
    }

    foreach ($profile in $profiles) { New-BraveProfile $profile }

    $statePath = "$userData\Local State"
    $state = Get-Content $statePath -Raw | ConvertFrom-Json
    if (-not $state.profile) { Set-JsonProperty $state 'profile' ([pscustomobject]@{}) }
    if (-not $state.profile.info_cache) {
        Set-JsonProperty $state.profile 'info_cache' ([pscustomobject]@{})
    }
    foreach ($profile in $profiles) {
        $preferences = "$userData\$($profile.Directory)\Preferences"
        Write-Host "Updating profile: $($profile.Name)"
        $prefs = Get-Content $preferences -Raw | ConvertFrom-Json
        Set-JsonProperty $prefs.profile 'name' $profile.Name
        Set-JsonProperty $prefs.profile 'avatar_index' $profile.Index
        Set-JsonProperty $prefs.profile 'using_default_name' $false
        Set-JsonProperty $prefs.profile 'using_default_avatar' $false
        Set-JsonProperty $prefs.profile 'using_gaia_avatar' $false
        if (-not $prefs.browser) { Set-JsonProperty $prefs 'browser' ([pscustomobject]@{}) }
        if (-not $prefs.browser.theme) { Set-JsonProperty $prefs.browser 'theme' ([pscustomobject]@{}) }
        Set-JsonProperty $prefs.browser.theme 'color_scheme2' 2
        if (-not $prefs.NewTabPage) { Set-JsonProperty $prefs 'NewTabPage' ([pscustomobject]@{}) }
        Set-JsonProperty $prefs.NewTabPage 'FooterVisible' $false
        Copy-Item $preferences "$preferences.bak" -Force
        Set-Content $preferences ($prefs | ConvertTo-Json -Depth 100) -Encoding UTF8

        if (-not $state.profile.info_cache.($profile.Directory)) {
            Set-JsonProperty $state.profile.info_cache $profile.Directory ([pscustomobject]@{})
        }
        $info = $state.profile.info_cache.($profile.Directory)
        Set-JsonProperty $info 'name' $profile.Name
        Set-JsonProperty $info 'avatar_icon' "chrome://theme/IDR_PROFILE_AVATAR_$($profile.Index)"
        Set-JsonProperty $info 'is_using_default_name' $false
        Set-JsonProperty $info 'is_using_default_avatar' $false
        Write-Host "Configured profile: $($profile.Name)"
    }
    Copy-Item $statePath "$statePath.bak" -Force
    Set-Content $statePath ($state | ConvertTo-Json -Depth 100) -Encoding UTF8
}

Fix the profile icons

I'm using 2 profiles on my browser: Company for business and Personal for private browsing. In Chrome I had custom profile pictures, but Brave does not have this much wanted feature.

Fortunately, there is a workaround. This script reads the current profile names and avatar slots, then prompts for an image URL for each profile. Leave a prompt empty to skip that profile. It will download the image and put it in the local avatar directory under the known avatar name, thereby overriding the image contents.

& {
    $ErrorActionPreference = 'Stop'

    function Save-Avatar($Name, $Profile) {
        $Prompt = "$($Profile.name) avatar URL (leave empty to skip)"
        $url = Read-Host $Prompt
        if ([string]::IsNullOrWhiteSpace($url)) { return }
        Invoke-WebRequest $url -OutFile "$avatarDirectory\$Name" -UseBasicParsing
    }

    $userData = "$env:LOCALAPPDATA\BraveSoftware\Brave-Browser\User Data"
    $state = Get-Content "$userData\Local State" -Raw | ConvertFrom-Json
    $source = Invoke-WebRequest `
        'https://raw.githubusercontent.com/brave/brave-core/master/chromium_src/chrome/browser/profiles/profile_avatar_icon_util.cc' `
        -UseBasicParsing
    $avatars = @{}
    [regex]::Matches($source.Content, 'IDR_PROFILE_AVATAR_(\d+),\s*"([^"]+\.png)"') |
        ForEach-Object { $avatars["IDR_PROFILE_AVATAR_$($_.Groups[1].Value)"] = $_.Groups[2].Value }
    if (-not $avatars.Count) { throw 'Brave avatar mapping could not be read.' }

    $avatarDirectory = "$userData\Avatars"
    New-Item $avatarDirectory -ItemType Directory -Force | Out-Null

    foreach ($entry in $state.profile.info_cache.PSObject.Properties) {
        $info = $entry.Value
        $icon = $info.avatar_icon -replace '^chrome://theme/', ''
        if (-not $avatars.ContainsKey($icon)) {
            throw "No avatar file mapping for profile '$($info.name)' ($($info.avatar_icon))."
        }
        Save-Avatar $avatars[$icon] $info
    }

    Write-Host 'Profile images configured. Restart Brave to load them.'
}

And now you're greeted by:

Use custom profile icons in the Brave startup screen.
Bingo, profile pics solved!

Install the extensions

Finally, I want the same extensions in both profiles. Brave does not have a supported command-line switch for installing Web Store extensions, so this script uses a per-user normal_installed policy. The extensions are installed automatically in every Brave profile, but you can still disable them.

& {
    $ErrorActionPreference = 'Stop'
    if (!(whoami /groups | findstr S-1-16-12288)) {
        throw 'Run PowerShell as Administrator.'
    }
    $policyPath = 'HKCU:\SOFTWARE\Policies\BraveSoftware\Brave'

    $extensions = [ordered]@{
        Bitwarden      = 'nngceckbapebfimnlniiiahkandclblb'
        ColorZilla     = 'bhlhnicpbhignbdhedgjhgdocnmhomnp'
        MagicCSS       = 'ifhikkcafabcgolfjegfcgloomalapol'
        MarkdownViewer = 'ckkdlimhmcjmikdlpkmbgfkaikojcbjk'
        MinusUnsplash  = 'ancgmphhglnbebohehppcfjfdpakkcca'
        Momentum       = 'laookkfknpbbblfpciffpaejjkokdgca'
        SponsorBlock   = 'mnjggcdmjocbbbhaepdhchncahnbgone'
        WAVE           = 'jbbplnpkjmmeebjpijfedlgcdilocofh'
    }

    $settings = @{}
    foreach ($extension in $extensions.Values) {
        $settings[$extension] = @{
            installation_mode = 'normal_installed'
            update_url = 'https://clients2.google.com/service/update2/crx'
        }
    }

    New-Item $policyPath -Force | Out-Null
    New-ItemProperty $policyPath ExtensionSettings -PropertyType String `
        -Value ($settings | ConvertTo-Json -Depth 3 -Compress) -Force | Out-Null

    Write-Host 'Brave extension policy configured. Restart Brave to install the extensions.'
}

Restart Brave and check brave://policy to verify that the extension policy is active.

"Help" my muscle memory

I'm quick to do ctrl+v and type chrome. But if you want to run Brave (and you still have Chrome due to company policy), you have to patch it in the registry:

& {
  $ErrorActionPreference = 'Stop'
  $brave = @(
    "$env:ProgramFiles\BraveSoftware\Brave-Browser\Application\brave.exe"
    "${env:ProgramFiles(x86)}\BraveSoftware\Brave-Browser\Application\brave.exe"
    "$env:LOCALAPPDATA\BraveSoftware\Brave-Browser\Application\brave.exe"
  ) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1

  if (-not $brave) {
    throw 'Brave niet gevonden.'
  }

  $key = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe'

  New-Item $key -Force | Out-Null
  Set-Item $key -Value $brave
  Set-ItemProperty $key -Name 'Path' -Value (Split-Path $brave)

  $item = Get-Item $key

  Write-Host 'Registry patch gezet:'
  Write-Host "  chrome.exe -> $($item.GetValue(''))"
  Write-Host "  Path       -> $($item.GetValue('Path'))"
}

expand_less brightness_auto