Compare commits

..

1 Commits

Author SHA1 Message Date
Niklas Wenzel
346d31f5e0 feat: make Chrome extensions work on custom protocols 2026-02-25 20:18:53 +01:00
25 changed files with 373 additions and 11 deletions

View File

@@ -1556,6 +1556,19 @@ Enables full sandbox mode on the app. This means that all renderers will be laun
This method can only be called before app is ready.
### `app.enableExtensionsOnAllProtocols()`
Enables Chrome extensions on all protocols.
By default, Chrome extensions are enabled only for a select number of protocols
such as `http`, `https`, and `file`. Calling this function will enable Chrome extensions
on all protocols, including [custom protocols](protocol.md).
This can have security implications. For most apps, it is recommended to enable
this during development only and keep it disabled in production builds.
This method can only be called before app is ready.
### `app.isInApplicationsFolder()` _macOS_
Returns `boolean` - Whether the application is currently running from the

View File

@@ -144,3 +144,4 @@ fix_linux_tray_id.patch
expose_gtk_ui_platform_field.patch
patch_osr_control_screen_info.patch
refactor_allow_customizing_config_in_freedesktopsecretkeyprovider.patch
feat_allow_enabling_extensions_on_all_protocols.patch

View File

@@ -0,0 +1,84 @@
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 all 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 bitmap 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/common/url_pattern.cc b/extensions/common/url_pattern.cc
index 4054af728030306c5473f9a47e580595596768a0..38c3f5976a122e6e4b7e8512ef977242fa395d8d 100644
--- a/extensions/common/url_pattern.cc
+++ b/extensions/common/url_pattern.cc
@@ -133,6 +133,13 @@ std::string_view CanonicalizeHostForMatching(std::string_view host_piece) {
} // namespace
+bool URLPattern::enable_extensions_on_all_protocols_ = false;
+
+// static
+void URLPattern::EnableExtensionsOnAllProtocols() {
+ enable_extensions_on_all_protocols_ = true;
+}
+
// static
bool URLPattern::IsValidSchemeForExtensions(std::string_view scheme) {
for (auto* valid_scheme : kValidSchemes) {
@@ -140,11 +147,14 @@ bool URLPattern::IsValidSchemeForExtensions(std::string_view scheme) {
return true;
}
}
- return false;
+ return enable_extensions_on_all_protocols_;
}
// static
int URLPattern::GetValidSchemeMaskForExtensions() {
+ if (enable_extensions_on_all_protocols_) {
+ return SCHEME_ALL;
+ }
int result = 0;
for (int valid_scheme_mask : kValidSchemeMasks) {
result |= valid_scheme_mask;
@@ -401,7 +411,7 @@ bool URLPattern::IsValidScheme(std::string_view scheme) const {
}
}
- return false;
+ return enable_extensions_on_all_protocols_;
}
void URLPattern::SetPath(std::string_view path) {
diff --git a/extensions/common/url_pattern.h b/extensions/common/url_pattern.h
index 4d09251b0160644d86682ad3db7c41b50f360e6f..78978b82e37e080add6680300d09503acdb663db 100644
--- a/extensions/common/url_pattern.h
+++ b/extensions/common/url_pattern.h
@@ -96,6 +96,8 @@ class URLPattern {
// Returns the mask for all schemes considered valid for extensions.
static int GetValidSchemeMaskForExtensions();
+ static void EnableExtensionsOnAllProtocols();
+
explicit URLPattern(int valid_schemes);
// Convenience to construct a URLPattern from a string. If the string is not
@@ -251,6 +253,9 @@ class URLPattern {
// Get an error string for a ParseResult.
static const char* GetParseResultString(URLPattern::ParseResult parse_result);
+ protected:
+ static bool enable_extensions_on_all_protocols_;
+
private:
// Returns true if any of the `schemes` items matches our scheme.
bool MatchesAnyScheme(const std::vector<std::string>& schemes) const;

View File

@@ -1 +1,2 @@
chore_expose_ui_to_allow_electron_to_set_dock_side.patch
feat_allow_enabling_extension_panels_on_all_protocols.patch

View File

@@ -0,0 +1,41 @@
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 all 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 19824217973f002a52478c7fa63a3faa217b0c63..6406fff61691fab0d2a8cc5344aaee743937c84e 100644
--- a/front_end/core/root/Runtime.ts
+++ b/front_end/core/root/Runtime.ts
@@ -639,6 +639,7 @@ export type HostConfig = Platform.TypeScriptUtilities.RecursivePartial<{
* or guest mode, rather than a "normal" profile.
*/
isOffTheRecord: boolean,
+ devToolsExtensionsOnAllProtocols: boolean,
devToolsEnableOriginBoundCookies: HostConfigEnableOriginBoundCookies,
devToolsAnimationStylesInStylesTab: HostConfigAnimationStylesInStylesTab,
thirdPartyCookieControls: HostConfigThirdPartyCookieControls,
diff --git a/front_end/panels/common/ExtensionServer.ts b/front_end/panels/common/ExtensionServer.ts
index 0a5ec620b135b128013d6ddbb5299f9a5813f122..1a6118b4fa1607a634720b5579a50d1b4478b60a 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';
@@ -1607,7 +1608,7 @@ export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper<EventTyp
return false;
}
- if (!kPermittedSchemes.includes(parsedURL.protocol)) {
+ if (!Root.Runtime.hostConfig.devToolsExtensionsOnAllProtocols && !kPermittedSchemes.includes(parsedURL.protocol)) {
return false;
}

View File

@@ -86,6 +86,10 @@
#include "v8/include/cppgc/allocation.h"
#include "v8/include/v8-traced-handle.h"
#if BUILDFLAG(ENABLE_ELECTRON_EXTENSIONS)
#include "extensions/common/url_pattern.h"
#endif
#if BUILDFLAG(IS_WIN)
#include "base/strings/utf_string_conversions.h"
#include "shell/browser/notifications/win/windows_toast_activator.h"
@@ -1546,6 +1550,28 @@ void App::EnableSandbox(gin_helper::ErrorThrower thrower) {
command_line->AppendSwitch(switches::kEnableSandbox);
}
void App::EnableExtensionsOnAllProtocols(gin_helper::ErrorThrower thrower) {
if (Browser::Get()->is_ready()) {
thrower.ThrowError(
"app.enableExtensionsOnAllProtocols() can only be called "
"before app is ready");
return;
}
#if BUILDFLAG(ENABLE_ELECTRON_EXTENSIONS)
enable_extensions_on_all_protocols_ = true;
URLPattern::EnableExtensionsOnAllProtocols();
#endif
}
bool App::AreExtensionsEnabledOnAllProtocols() const {
#if BUILDFLAG(ENABLE_ELECTRON_EXTENSIONS)
return enable_extensions_on_all_protocols_;
#else
return false;
#endif
}
v8::Local<v8::Promise> App::SetProxy(gin::Arguments* args) {
v8::Isolate* isolate = args->isolate();
gin_helper::Promise<void> promise(isolate);
@@ -1949,6 +1975,8 @@ gin::ObjectTemplateBuilder App::GetObjectTemplateBuilder(v8::Isolate* isolate) {
&App::IsHardwareAccelerationEnabled)
.SetMethod("disableDomainBlockingFor3DAPIs",
&App::DisableDomainBlockingFor3DAPIs)
.SetMethod("enableExtensionsOnAllProtocols",
&App::EnableExtensionsOnAllProtocols)
.SetMethod("getFileIcon", &App::GetFileIcon)
.SetMethod("getAppMetrics", &App::GetAppMetrics)
.SetMethod("getGPUFeatureStatus", &App::GetGPUFeatureStatus)

View File

@@ -89,6 +89,8 @@ class App final : public gin::Wrappable<App>,
static bool IsPackaged();
bool AreExtensionsEnabledOnAllProtocols() const;
App();
~App() override;
@@ -236,6 +238,7 @@ class App final : public gin::Wrappable<App>,
v8::Local<v8::Promise> GetGPUInfo(v8::Isolate* isolate,
const std::string& info_type);
void EnableSandbox(gin_helper::ErrorThrower thrower);
void EnableExtensionsOnAllProtocols(gin_helper::ErrorThrower thrower);
void SetUserAgentFallback(const std::string& user_agent);
std::string GetUserAgentFallback();
v8::Local<v8::Promise> SetProxy(gin::Arguments* args);
@@ -293,6 +296,10 @@ class App final : public gin::Wrappable<App>,
bool disable_domain_blocking_for_3DAPIs_ = false;
bool watch_singleton_socket_on_ready_ = false;
#if BUILDFLAG(ENABLE_ELECTRON_EXTENSIONS)
bool enable_extensions_on_all_protocols_ = false;
#endif
std::unique_ptr<content::ScopedAccessibilityMode> scoped_accessibility_mode_;
};

View File

@@ -9,7 +9,6 @@
#include "base/environment.h"
#include "base/functional/bind.h"
#include "base/logging.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/utf_string_conversions.h"
#include "base/task/single_thread_task_runner.h"
#include "content/public/browser/browser_task_traits.h"
@@ -280,8 +279,7 @@ void OnDeploymentCompleted(std::unique_ptr<DeploymentCallbackData> data,
HRESULT error_code;
hr = async_info->get_ErrorCode(&error_code);
if (SUCCEEDED(hr)) {
error +=
" (" + base::NumberToString(static_cast<int>(error_code)) + ")";
error += " (" + std::to_string(static_cast<int>(error_code)) + ")";
}
}
}
@@ -800,10 +798,10 @@ v8::Local<v8::Value> GetPackageInfo() {
ABI::Windows::ApplicationModel::PackageVersion pkg_version;
hr = package_id->get_Version(&pkg_version);
if (SUCCEEDED(hr)) {
std::string version = base::NumberToString(pkg_version.Major) + "." +
base::NumberToString(pkg_version.Minor) + "." +
base::NumberToString(pkg_version.Build) + "." +
base::NumberToString(pkg_version.Revision);
std::string version = std::to_string(pkg_version.Major) + "." +
std::to_string(pkg_version.Minor) + "." +
std::to_string(pkg_version.Build) + "." +
std::to_string(pkg_version.Revision);
result.Set("version", version);
}
}

View File

@@ -23,7 +23,6 @@
#include "base/json/json_reader.h"
#include "base/no_destructor.h"
#include "base/strings/strcat.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/utf_string_conversions.h"
#include "base/task/current_thread.h"
#include "base/threading/scoped_blocking_call.h"
@@ -2660,7 +2659,7 @@ void WebContents::RestoreHistory(
thrower.ThrowError(
"Failed to restore navigation history: Invalid navigation entry at "
"index " +
base::NumberToString(index) + ".");
std::to_string(index) + ".");
return;
}

View File

@@ -617,6 +617,12 @@ void ElectronBrowserClient::AppendExtraCommandLineSwitches(
command_line->AppendSwitch(switches::kServiceWorkerPreload);
}
}
#if BUILDFLAG(ENABLE_ELECTRON_EXTENSIONS)
if (api::App::Get()->AreExtensionsEnabledOnAllProtocols()) {
command_line->AppendSwitch(switches::kEnableExtensionsOnAllProtocols);
}
#endif
}
}

View File

@@ -44,6 +44,7 @@
#include "services/network/public/cpp/simple_url_loader_stream_consumer.h"
#include "services/network/public/cpp/wrapper_shared_url_loader_factory.h"
#include "services/network/public/mojom/url_response_head.mojom.h"
#include "shell/browser/api/electron_api_app.h"
#include "shell/browser/api/electron_api_web_contents.h"
#include "shell/browser/native_window_views.h"
#include "shell/browser/net/asar/asar_url_loader_factory.h"
@@ -865,6 +866,8 @@ void InspectableWebContents::GetSyncInformation(DispatchCallback callback) {
void InspectableWebContents::GetHostConfig(DispatchCallback callback) {
base::DictValue response_dict;
response_dict.Set("devToolsExtensionsOnAllProtocols",
api::App::Get()->AreExtensionsEnabledOnAllProtocols());
base::Value response = base::Value(std::move(response_dict));
std::move(callback).Run(&response);
}

View File

@@ -18,7 +18,6 @@
#include "base/environment.h"
#include "base/path_service.h"
#include "base/run_loop.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_split.h"
#include "base/strings/utf_string_conversions.h"
#include "base/task/single_thread_task_runner.h"
@@ -193,7 +192,7 @@ void V8OOMErrorCallback(const char* location, const v8::OOMDetails& details) {
#if !IS_MAS_BUILD()
electron::crash_keys::SetCrashKey("electron.v8-oom.is_heap_oom",
base::NumberToString(details.is_heap_oom));
std::to_string(details.is_heap_oom));
if (location) {
electron::crash_keys::SetCrashKey("electron.v8-oom.location", location);
}

View File

@@ -8,6 +8,7 @@
#include <string_view>
#include "base/strings/cstring_view.h"
#include "electron/buildflags/buildflags.h"
namespace electron {
@@ -270,6 +271,12 @@ inline constexpr base::cstring_view kStreamingSchemes = "streaming-schemes";
// Register schemes as supporting V8 code cache.
inline constexpr base::cstring_view kCodeCacheSchemes = "code-cache-schemes";
#if BUILDFLAG(ENABLE_ELECTRON_EXTENSIONS)
// Enable Chrome extensions on all protocols.
inline constexpr base::cstring_view kEnableExtensionsOnAllProtocols =
"enable-extensions-on-all-protocols";
#endif
// 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());
#if BUILDFLAG(ENABLE_ELECTRON_EXTENSIONS)
// Parse --enable-extensions-on-all-protocols
if (command_line->HasSwitch(switches::kEnableExtensionsOnAllProtocols))
URLPattern::EnableExtensionsOnAllProtocols();
#endif
// 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

@@ -1765,6 +1765,15 @@ describe('app module', () => {
});
});
describe('enableExtensionsOnAllProtocols() API', () => {
// Proper tests are in extensions-spec.ts
it('throws when called after app is ready', () => {
expect(() => {
app.enableExtensionsOnAllProtocols();
}).to.throw(/before app is ready/);
});
});
describe('disableDomainBlockingFor3DAPIs() API', () => {
it('throws when called after app is ready', () => {
expect(() => {

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 app.enableExtensionsOnAllProtocols() 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 app.enableExtensionsOnAllProtocols()', 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,48 @@
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,
secure: true,
allowServiceWorkers: true,
supportFetchAPI: true,
bypassCSP: false,
corsEnabled: true,
stream: true
}
}
]);
app.enableExtensionsOnAllProtocols();
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,42 @@
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,
secure: true,
allowServiceWorkers: true,
supportFetchAPI: true,
bypassCSP: false,
corsEnabled: true,
stream: true
}
}
]);
app.enableExtensionsOnAllProtocols();
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/`);
});