feat: make Chrome extensions work on custom protocols (#50588)

* chore: backport crrev.com/c/7639311

* feat: make Chrome extensions work on custom protocols
This commit is contained in:
Niklas Wenzel
2026-04-03 05:10:04 +02:00
committed by GitHub
parent 7bd1b2ab32
commit be7baccf81
22 changed files with 419 additions and 4 deletions

View File

@@ -11,3 +11,5 @@
* `stream` boolean (optional) - Default false.
* `codeCache` boolean (optional) - Enable V8 code cache for the scheme, only
works when `standard` is also set to true. Default false.
* `allowExtensions` boolean (optional) - Allow Chrome extensions to be used
on pages served over this protocol. Default false.

View File

@@ -160,3 +160,5 @@ feat_plumb_node_integration_in_worker_through_workersettings.patch
cherry-pick-fbfb27470bf6.patch
fix_fire_menu_popup_start_for_dynamically_created_aria_menus.patch
fix_out-of-bounds_read_in_diff_rulesets.patch
extensions_return_early_from_urlpattern_isvalidscheme.patch
feat_allow_enabling_extensions_on_custom_protocols.patch

View File

@@ -0,0 +1,30 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Niklas Wenzel <dev@nikwen.de>
Date: Tue, 31 Mar 2026 00:11:27 +0200
Subject: [Extensions] Return early from URLPattern::IsValidScheme()
|scheme| will match at most one entry in |kValidSchemes|. No need to
iterate through the remaining ones.
Change-Id: I1f37383faccaddc775faabb797aea2851d93382f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7639311
Commit-Queue: Andrea Orru <andreaorru@chromium.org>
Reviewed-by: Andrea Orru <andreaorru@chromium.org>
Reviewed-by: Devlin Cronin <rdevlin.cronin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1594934}
diff --git a/extensions/common/url_pattern.cc b/extensions/common/url_pattern.cc
index 8975a936d6c18c4cc53a35bf680ca2d935f29071..daf5182643b7639bb47d932ddcc3f4dbdd093197 100644
--- a/extensions/common/url_pattern.cc
+++ b/extensions/common/url_pattern.cc
@@ -397,8 +397,8 @@ bool URLPattern::IsValidScheme(std::string_view scheme) const {
}
for (size_t i = 0; i < std::size(kValidSchemes); ++i) {
- if (scheme == kValidSchemes[i] && (valid_schemes_ & kValidSchemeMasks[i])) {
- return true;
+ if (scheme == kValidSchemes[i]) {
+ return valid_schemes_ & kValidSchemeMasks[i];
}
}

View File

@@ -0,0 +1,164 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Niklas Wenzel <dev@nikwen.de>
Date: Wed, 25 Feb 2026 16:24:03 +0100
Subject: feat: allow enabling extensions on custom protocols
This allows us to use Chrome extensions on custom protocols.
The patch can't really be upstreamed, unfortunately, because there are
other URLPattern functions that we don't patch that Chrome needs.
Patching those properly would require replacing the bitmask logic in
URLPattern with a more flexible solution. This would be a larger effort
and Chromium might reject it for performance reasons.
See: https://source.chromium.org/chromium/chromium/src/+/main:extensions/common/url_pattern.h;l=53-74;drc=50dbcddad2f8e36ddfcec21d4551f389df425c37
This patch makes it work in the context of Electron.
diff --git a/extensions/browser/api/content_settings/content_settings_helpers.cc b/extensions/browser/api/content_settings/content_settings_helpers.cc
index 34fa528a82f03891c89b3bb95bc9d2a135ee5f36..f88041554b828215a32dbb4aadcc73df40e6d8c2 100644
--- a/extensions/browser/api/content_settings/content_settings_helpers.cc
+++ b/extensions/browser/api/content_settings/content_settings_helpers.cc
@@ -37,7 +37,7 @@ ContentSettingsPattern ParseExtensionPattern(const std::string& pattern_str,
std::string* error) {
const int kAllowedSchemes =
URLPattern::SCHEME_HTTP | URLPattern::SCHEME_HTTPS |
- URLPattern::SCHEME_FILE;
+ URLPattern::SCHEME_FILE | URLPattern::SCHEME_ELECTRON_CUSTOM_PROTOCOLS;
URLPattern url_pattern(kAllowedSchemes);
URLPattern::ParseResult result = url_pattern.Parse(pattern_str);
if (result != URLPattern::ParseResult::kSuccess) {
diff --git a/extensions/browser/api/web_request/extension_web_request_event_router.h b/extensions/browser/api/web_request/extension_web_request_event_router.h
index dec452ba5aa621b385011b77155705387312f82b..da2229cb0bcd18e1b3fd76ce25cb14bcb6bbf8b2 100644
--- a/extensions/browser/api/web_request/extension_web_request_event_router.h
+++ b/extensions/browser/api/web_request/extension_web_request_event_router.h
@@ -52,7 +52,8 @@ inline constexpr int kWebRequestFilterValidSchemes =
URLPattern::SCHEME_HTTP | URLPattern::SCHEME_HTTPS |
URLPattern::SCHEME_FTP | URLPattern::SCHEME_FILE |
URLPattern::SCHEME_EXTENSION | URLPattern::SCHEME_WS |
- URLPattern::SCHEME_WSS | URLPattern::SCHEME_UUID_IN_PACKAGE;
+ URLPattern::SCHEME_WSS | URLPattern::SCHEME_UUID_IN_PACKAGE |
+ URLPattern::SCHEME_ELECTRON_CUSTOM_PROTOCOLS;
class WebRequestEventRouter : public KeyedService {
public:
diff --git a/extensions/common/extension.cc b/extensions/common/extension.cc
index c15cb579d545d0640b3e936e5ca4e32610544138..de12c2894abeacf35a32d34e788ede4c38597e24 100644
--- a/extensions/common/extension.cc
+++ b/extensions/common/extension.cc
@@ -219,7 +219,8 @@ const int Extension::kValidHostPermissionSchemes =
URLPattern::SCHEME_CHROMEUI | URLPattern::SCHEME_HTTP |
URLPattern::SCHEME_HTTPS | URLPattern::SCHEME_FILE |
URLPattern::SCHEME_FTP | URLPattern::SCHEME_WS | URLPattern::SCHEME_WSS |
- URLPattern::SCHEME_UUID_IN_PACKAGE;
+ URLPattern::SCHEME_UUID_IN_PACKAGE |
+ URLPattern::SCHEME_ELECTRON_CUSTOM_PROTOCOLS;
//
// Extension
diff --git a/extensions/common/url_pattern.cc b/extensions/common/url_pattern.cc
index daf5182643b7639bb47d932ddcc3f4dbdd093197..e4c4b453e62a916bb61ca638df11f989be1e1769 100644
--- a/extensions/common/url_pattern.cc
+++ b/extensions/common/url_pattern.cc
@@ -141,6 +141,11 @@ bool URLPattern::IsValidSchemeForExtensions(std::string_view scheme) {
return true;
}
}
+ for (auto& extension_scheme : url::GetExtensionSchemes()) {
+ if (scheme == extension_scheme) {
+ return true;
+ }
+ }
return false;
}
@@ -402,6 +407,14 @@ bool URLPattern::IsValidScheme(std::string_view scheme) const {
}
}
+ if (valid_schemes_ & URLPattern::SCHEME_ELECTRON_CUSTOM_PROTOCOLS) {
+ for (auto& extension_scheme : url::GetExtensionSchemes()) {
+ if (scheme == extension_scheme) {
+ return true;
+ }
+ }
+ }
+
return false;
}
diff --git a/extensions/common/url_pattern.h b/extensions/common/url_pattern.h
index 4d09251b0160644d86682ad3db7c41b50f360e6f..8a626e14eff2d58d8218a7b0df820c6c0522b00f 100644
--- a/extensions/common/url_pattern.h
+++ b/extensions/common/url_pattern.h
@@ -64,6 +64,9 @@ class URLPattern {
SCHEME_DATA = 1 << 9,
SCHEME_UUID_IN_PACKAGE = 1 << 10,
+ // Represents the schemes returned by url::GetExtensionSchemes().
+ SCHEME_ELECTRON_CUSTOM_PROTOCOLS = 1 << 11,
+
// IMPORTANT!
// SCHEME_ALL will match every scheme, including chrome://, chrome-
// extension://, about:, etc. Because this has lots of security
diff --git a/extensions/common/user_script.cc b/extensions/common/user_script.cc
index 2d945e2f17a93ef22f9e4ed254c07cf91bc70c9b..712a2c32ab258253524f8d03fbdedd53cf9a0dc9 100644
--- a/extensions/common/user_script.cc
+++ b/extensions/common/user_script.cc
@@ -69,7 +69,8 @@ enum {
kValidUserScriptSchemes = URLPattern::SCHEME_CHROMEUI |
URLPattern::SCHEME_HTTP | URLPattern::SCHEME_HTTPS |
URLPattern::SCHEME_FILE | URLPattern::SCHEME_FTP |
- URLPattern::SCHEME_UUID_IN_PACKAGE
+ URLPattern::SCHEME_UUID_IN_PACKAGE |
+ URLPattern::SCHEME_ELECTRON_CUSTOM_PROTOCOLS
};
// static
diff --git a/url/url_util.cc b/url/url_util.cc
index 50b15e06956c47e94ccd801fb3ee91aeb77ae15c..cd357f5312f70c1405e040e792dc22292b197176 100644
--- a/url/url_util.cc
+++ b/url/url_util.cc
@@ -134,6 +134,9 @@ struct SchemeRegistry {
// Embedder schemes that have V8 code cache enabled in js and wasm scripts.
std::vector<std::string> code_cache_schemes = {};
+ // Embedder schemes on which Chrome extensions can be used.
+ std::vector<std::string> extension_schemes = {};
+
// Schemes with a predefined default custom handler.
std::vector<SchemeWithHandler> predefined_handler_schemes;
@@ -679,6 +682,15 @@ const std::vector<std::string>& GetCodeCacheSchemes() {
return GetSchemeRegistry().code_cache_schemes;
}
+void AddExtensionScheme(std::string_view new_scheme) {
+ DoAddScheme(new_scheme,
+ &GetSchemeRegistryWithoutLocking()->extension_schemes);
+}
+
+const std::vector<std::string>& GetExtensionSchemes() {
+ return GetSchemeRegistry().extension_schemes;
+}
+
void AddPredefinedHandlerScheme(std::string_view new_scheme,
std::string_view handler) {
DoAddSchemeWithHandler(
diff --git a/url/url_util.h b/url/url_util.h
index 10bf2c6e27dca530906ef7acb7ac43fa5c731d22..924ae5f2e63db6489f4365551bde7b162aedd862 100644
--- a/url/url_util.h
+++ b/url/url_util.h
@@ -124,6 +124,11 @@ COMPONENT_EXPORT(URL) const std::vector<std::string>& GetEmptyDocumentSchemes();
COMPONENT_EXPORT(URL) void AddCodeCacheScheme(std::string_view new_scheme);
COMPONENT_EXPORT(URL) const std::vector<std::string>& GetCodeCacheSchemes();
+// Adds an application-defined scheme to the list of schemes on which Chrome
+// extensions can be used.
+COMPONENT_EXPORT(URL) void AddExtensionScheme(std::string_view new_scheme);
+COMPONENT_EXPORT(URL) const std::vector<std::string>& GetExtensionSchemes();
+
// Adds a scheme with a predefined default handler.
//
// This pair of strings must be normalized protocol handler parameters as

View File

@@ -1,2 +1,3 @@
chore_expose_ui_to_allow_electron_to_set_dock_side.patch
fix_prefer_browser_runtime_over_node_in_hostruntime_detection.patch
feat_allow_enabling_extension_panels_on_custom_protocols.patch

View File

@@ -0,0 +1,42 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Niklas Wenzel <dev@nikwen.de>
Date: Wed, 25 Feb 2026 16:23:07 +0100
Subject: feat: allow enabling extension panels on custom protocols
This allows us to show Chrome extension panels on pages served over
custom protocols.
diff --git a/front_end/core/root/Runtime.ts b/front_end/core/root/Runtime.ts
index a66d4b57af461db0426bd4216d5134490240d697..5e1f465adde1668cbb645f5ff55cd62382a10890 100644
--- a/front_end/core/root/Runtime.ts
+++ b/front_end/core/root/Runtime.ts
@@ -575,6 +575,7 @@ export type HostConfig = Platform.TypeScriptUtilities.RecursivePartial<{
* or guest mode, rather than a "normal" profile.
*/
isOffTheRecord: boolean,
+ devToolsExtensionSchemes: readonly string[],
devToolsEnableOriginBoundCookies: HostConfigEnableOriginBoundCookies,
devToolsAnimationStylesInStylesTab: HostConfigAnimationStylesInStylesTab,
thirdPartyCookieControls: HostConfigThirdPartyCookieControls,
diff --git a/front_end/panels/common/ExtensionServer.ts b/front_end/panels/common/ExtensionServer.ts
index 5fd87f637a0141788951997742c6fe8712c29ceb..8e26d9592f50615ef28382a44f0917b18f91b6f8 100644
--- a/front_end/panels/common/ExtensionServer.ts
+++ b/front_end/panels/common/ExtensionServer.ts
@@ -12,6 +12,7 @@ import * as Host from '../../core/host/host.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as _ProtocolClient from '../../core/protocol_client/protocol_client.js'; // eslint-disable-line @typescript-eslint/no-unused-vars
+import * as Root from '../../core/root/root.js';
import * as SDK from '../../core/sdk/sdk.js';
import type * as Protocol from '../../generated/protocol.js';
import * as Bindings from '../../models/bindings/bindings.js';
@@ -1603,7 +1604,8 @@ export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper<EventTyp
return false;
}
- if (!kPermittedSchemes.includes(parsedURL.protocol)) {
+ if (!kPermittedSchemes.includes(parsedURL.protocol) &&
+ !Root.Runtime.hostConfig.devToolsExtensionSchemes?.includes(parsedURL.protocol)) {
return false;
}

View File

@@ -38,6 +38,7 @@ struct SchemeOptions {
bool corsEnabled = false;
bool stream = false;
bool codeCache = false;
bool allowExtensions = false;
};
struct CustomScheme {
@@ -70,6 +71,7 @@ struct Converter<CustomScheme> {
opt.Get("corsEnabled", &(out->options.corsEnabled));
opt.Get("stream", &(out->options.stream));
opt.Get("codeCache", &(out->options.codeCache));
opt.Get("allowExtensions", &(out->options.allowExtensions));
}
return true;
}
@@ -124,7 +126,7 @@ void RegisterSchemesAsPrivileged(gin_helper::ErrorThrower thrower,
}
std::vector<std::string> secure_schemes, cspbypassing_schemes, fetch_schemes,
service_worker_schemes, cors_schemes;
service_worker_schemes, cors_schemes, extension_schemes;
for (const auto& custom_scheme : custom_schemes) {
// Register scheme to privileged list (https, wss, data, chrome-extension)
if (custom_scheme.options.standard) {
@@ -160,6 +162,10 @@ void RegisterSchemesAsPrivileged(gin_helper::ErrorThrower thrower,
GetCodeCacheSchemes().push_back(custom_scheme.scheme);
url::AddCodeCacheScheme(custom_scheme.scheme.c_str());
}
if (custom_scheme.options.allowExtensions) {
extension_schemes.push_back(custom_scheme.scheme);
url::AddExtensionScheme(custom_scheme.scheme.c_str());
}
}
const auto AppendSchemesToCmdLine = [](const std::string_view switch_name,
@@ -179,6 +185,8 @@ void RegisterSchemesAsPrivileged(gin_helper::ErrorThrower thrower,
AppendSchemesToCmdLine(electron::switches::kFetchSchemes, fetch_schemes);
AppendSchemesToCmdLine(electron::switches::kServiceWorkerSchemes,
service_worker_schemes);
AppendSchemesToCmdLine(electron::switches::kExtensionSchemes,
extension_schemes);
AppendSchemesToCmdLine(electron::switches::kStandardSchemes,
GetStandardSchemes());
AppendSchemesToCmdLine(electron::switches::kStreamingSchemes,

View File

@@ -554,7 +554,7 @@ void ElectronBrowserClient::AppendExtraCommandLineSwitches(
if (process_type == ::switches::kUtilityProcess ||
process_type == ::switches::kRendererProcess) {
// Copy following switches to child process.
static constexpr std::array<const char*, 10U> kCommonSwitchNames = {
static constexpr std::array<const char*, 11U> kCommonSwitchNames = {
switches::kStandardSchemes.c_str(),
switches::kEnableSandbox.c_str(),
switches::kSecureSchemes.c_str(),
@@ -564,7 +564,8 @@ void ElectronBrowserClient::AppendExtraCommandLineSwitches(
switches::kServiceWorkerSchemes.c_str(),
switches::kStreamingSchemes.c_str(),
switches::kNoStdioInit.c_str(),
switches::kCodeCacheSchemes.c_str()};
switches::kCodeCacheSchemes.c_str(),
switches::kExtensionSchemes.c_str()};
command_line->CopySwitchesFrom(*base::CommandLine::ForCurrentProcess(),
kCommonSwitchNames);
if (process_type == ::switches::kUtilityProcess ||

View File

@@ -58,6 +58,7 @@
#include "third_party/blink/public/common/page/page_zoom.h"
#include "ui/display/display.h"
#include "ui/display/screen.h"
#include "url/url_util.h"
#include "v8/include/v8.h"
#if BUILDFLAG(ENABLE_ELECTRON_EXTENSIONS)
@@ -868,7 +869,14 @@ void InspectableWebContents::GetSyncInformation(DispatchCallback callback) {
}
void InspectableWebContents::GetHostConfig(DispatchCallback callback) {
base::Value::Dict response_dict;
base::DictValue response_dict;
base::ListValue extension_schemes;
for (const std::string& scheme : url::GetExtensionSchemes())
extension_schemes.Append(scheme + ":");
response_dict.Set("devToolsExtensionSchemes",
base::Value(std::move(extension_schemes)));
base::Value response = base::Value(std::move(response_dict));
std::move(callback).Run(&response);
}

View File

@@ -270,6 +270,9 @@ inline constexpr base::cstring_view kStreamingSchemes = "streaming-schemes";
// Register schemes as supporting V8 code cache.
inline constexpr base::cstring_view kCodeCacheSchemes = "code-cache-schemes";
// Register schemes as supporting extensions.
inline constexpr base::cstring_view kExtensionSchemes = "extension-schemes";
// The browser process app model ID
inline constexpr base::cstring_view kAppUserModelId = "app-user-model-id";

View File

@@ -162,6 +162,11 @@ RendererClientBase::RendererClientBase() {
ParseSchemesCLISwitch(command_line, switches::kSecureSchemes);
for (const std::string& scheme : secure_schemes_list)
url::AddSecureScheme(scheme.data());
// Parse --extension-schemes=scheme1,scheme2
std::vector<std::string> extension_schemes_list =
ParseSchemesCLISwitch(command_line, switches::kExtensionSchemes);
for (const std::string& scheme : extension_schemes_list)
url::AddExtensionScheme(scheme.c_str());
// We rely on the unique process host id which is notified to the
// renderer process via command line switch from the content layer,
// if this switch is removed from the content layer for some reason,

View File

@@ -1123,6 +1123,8 @@ describe('protocol module', () => {
});
});
// protocol.registerSchemesAsPrivileged allowExtensions tests are in extensions-spec.ts.
describe('handle', () => {
afterEach(closeAllWindows);

View File

@@ -3,6 +3,7 @@ import { app, session, webFrameMain, BrowserWindow, ipcMain, WebContents, Extens
import { expect } from 'chai';
import * as WebSocket from 'ws';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import * as fs from 'node:fs/promises';
import * as http from 'node:http';
@@ -1338,4 +1339,26 @@ describe('chrome extensions', () => {
});
});
});
describe('custom protocol', () => {
async function runFixture (name: string) {
const appProcess = spawn(process.execPath, [(path.join(fixtures, 'extensions', name, 'main.js'))]);
let output = '';
appProcess.stdout.on('data', (data) => { output += data; });
await once(appProcess.stdout, 'end');
return output.trim();
};
it('loads DevTools extensions on custom protocols with allowExtensions privileges and runs content and background scripts', async () => {
const output = await runFixture('custom-protocol');
expect(output).to.equal('Title: MESSAGE RECEIVED');
});
it('loads DevTools panels on custom protocols with allowExtensions privileges', async () => {
const output = await runFixture('custom-protocol-panel');
expect(output).to.equal('ELECTRON TEST PANEL created');
});
});
});

View File

@@ -0,0 +1,7 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<script src="devtools.js"></script>
</head>
</html>

View File

@@ -0,0 +1,4 @@
/* global chrome */
chrome.devtools.panels.create('ELECTRON TEST PANEL', '', 'panel.html');
console.log('ELECTRON TEST PANEL created');

View File

@@ -0,0 +1,6 @@
{
"name": "custom-protocol-panel",
"version": "1.0",
"devtools_page": "devtools.html",
"manifest_version": 3
}

View File

@@ -0,0 +1,4 @@
<!doctype html>
<body>
DevTools panel
</body>

View File

@@ -0,0 +1,41 @@
const { app, BrowserWindow, protocol, session } = require('electron/main');
const { once } = require('node:events');
const path = require('node:path');
const html = '<html><body><h1>EMPTY PAGE</h1></body></html>';
const scheme = 'custom';
protocol.registerSchemesAsPrivileged([
{
scheme,
privileges: {
standard: true,
allowExtensions: true
}
}
]);
app.whenReady().then(async () => {
const ses = session.defaultSession;
ses.protocol.handle(scheme, () => new Response(html, {
headers: { 'Content-Type': 'text/html' }
}));
await ses.extensions.loadExtension(path.join(__dirname, 'extension'));
const win = new BrowserWindow();
win.webContents.openDevTools();
await once(win.webContents, 'devtools-opened');
win.devToolsWebContents.on('console-message', ({ message }) => {
if (message === 'ELECTRON TEST PANEL created') {
console.log(message);
app.quit();
}
});
await win.loadURL(`${scheme}://app/`);
});

View File

@@ -0,0 +1,7 @@
/* global chrome */
chrome.runtime.onMessage.addListener((_message, sender, reply) => {
reply({
text: 'MESSAGE RECEIVED',
senderTabId: sender.tab && sender.tab.id
});
});

View File

@@ -0,0 +1,5 @@
/* global chrome */
chrome.runtime.sendMessage({ text: 'hello from content script' }, (response) => {
if (!response || !response.text) return;
document.title = response.text;
});

View File

@@ -0,0 +1,15 @@
{
"name": "custom-protocol",
"version": "1.0",
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content_script.js"],
"run_at": "document_start"
}
],
"manifest_version": 3
}

View File

@@ -0,0 +1,35 @@
const { app, BrowserWindow, protocol, session } = require('electron/main');
const path = require('node:path');
const html = '<html><body><h1>EMPTY PAGE</h1></body></html>';
const scheme = 'example';
protocol.registerSchemesAsPrivileged([
{
scheme,
privileges: {
standard: true,
allowExtensions: true
}
}
]);
app.whenReady().then(async () => {
const ses = session.defaultSession;
ses.protocol.handle(scheme, () => new Response(html, {
headers: { 'Content-Type': 'text/html' }
}));
await ses.extensions.loadExtension(path.join(__dirname, 'extension'));
const win = new BrowserWindow();
win.on('page-title-updated', (_event, title) => {
console.log(`Title: ${title}`);
app.quit();
});
await win.loadURL(`${scheme}://app/`);
});