Compare commits

..

6 Commits

Author SHA1 Message Date
Niklas Wenzel
f8d373abd4 chore: update example operating systems 2026-05-01 18:10:45 +02:00
Niklas Wenzel
e429708dcd ci: add note about Wayland/X11 to bug issue template 2026-05-01 17:54:42 +02:00
Ryan Fitzgerald
4f38f357f1 build: update NMV to 148 (#51421)
Upstream PR: https://github.com/nodejs/node/pull/63016

This needs to be merged before cutting the `43-x-y` release branch.
2026-05-01 11:17:00 -04:00
Niklas Wenzel
aaf328930d docs: fix version of deprecation notice (#51406) 2026-04-30 16:14:15 -07:00
Asish Kumar
d0612e2c92 fix: preserve mouse hook handle when UnhookWindowsHookEx fails (#51098)
* fix: preserve mouse hook handle when UnhookWindowsHookEx fails

NativeWindowViews::SetForwardMouseMessages() installs a low-level mouse
hook when mouse forwarding begins and unhooks it once no window needs
forwarding. The previous code reset the shared `mouse_hook_` handle to
`nullptr` unconditionally after calling UnhookWindowsHookEx, even when
the unhook call failed.

When unhooking fails, the hook is still installed in the system. Because
`mouse_hook_` is nulled out anyway, the next call to
SetForwardMouseMessages(true) evaluates `if (!mouse_hook_)` as true and
installs a second, duplicate hook via SetWindowsHookEx, so every mouse
message is processed by MouseHookProc multiple times.

Check the return value of UnhookWindowsHookEx and only null the handle
on success. When the call fails, leave `mouse_hook_` pointing at the
existing hook so the next activation reuses it rather than stacking a
new one on top, and log the failure via PLOG to surface the underlying
Windows error.

Fixes: #51064
Signed-off-by: Asish Kumar <officialasishkumar@gmail.com>

* fix: clear invalid mouse hook handles

Signed-off-by: Asish Kumar <officialasishkumar@gmail.com>

---------

Signed-off-by: Asish Kumar <officialasishkumar@gmail.com>
2026-04-30 15:34:45 -04:00
Robo
8f0f08e818 feat: add session support to requests from utility process (#51279)
feat: add http cache support to requests from utility process

Add `session` and `partition` options to `utilityProcess.fork()` to
allow utility processes to use a session-specific network context
instead of the system network context. This enables HTTP caching,
cookie isolation, and webRequest interception for utility process
network requests.

When `respondToAuthRequestsFromMainProcess` is true and a session is
provided, HTTP 401/407 auth challenges now emit a `login` event on
the UtilityProcess instance rather than on `app`. Without a session,
auth challenges continue to emit on `app` for backward compatibility.
2026-04-30 15:03:20 -04:00
13 changed files with 819 additions and 34 deletions

View File

@@ -39,8 +39,8 @@ body:
- type: input
attributes:
label: Operating System Version
description: What operating system version are you using? On Windows, click Start button > Settings > System > About. On macOS, click the Apple Menu > About This Mac. On Linux, use lsb_release or uname -a.
placeholder: "e.g. Windows 10 version 1909, macOS Catalina 10.15.7, or Ubuntu 20.04"
description: What operating system version are you using? On Windows, click Start button > Settings > System > About. On macOS, click the Apple Menu > About This Mac. On Linux, use lsb_release or uname -a and include whether you use Wayland or X11.
placeholder: "e.g. Windows 11 25H2, macOS Tahoe 26.4.1, or Ubuntu 26.04 (Wayland)"
validations:
required: true
- type: dropdown

View File

@@ -2,7 +2,7 @@ is_electron_build = true
root_extra_deps = [ "//electron" ]
# Registry of NMVs --> https://github.com/nodejs/node/blob/main/doc/abi_version_registry.json
node_module_version = 146
node_module_version = 148
v8_promise_internal_field_count = 1
v8_embedder_string = "-electron.0"

View File

@@ -17,6 +17,16 @@ Process: [Main](../glossary.md#main-process)<br />
* `env` Object (optional) - Environment key-value pairs. Default is `process.env`.
* `execArgv` string[] (optional) - List of string arguments passed to the executable.
* `cwd` string (optional) - Current working directory of the child process.
* `session` [Session](session.md) (optional) - Sets the session used by the process for network
requests. By default, network requests from the utility process will use the system network
context which does not have HTTP cache support. Setting a session enables HTTP caching and
other session-specific network features. See [session](session.md) for more information.
* `partition` string (optional) - Sets the session used by the process according to the
session's partition string. If `partition` starts with `persist:`, the process will use a
persistent session available to all pages in the app with the same `partition`. If there is
no `persist:` prefix, the process will use an in-memory session. By assigning the same
`partition`, multiple processes can share the same session. If the `session` option is set,
this option is ignored.
* `stdio` (string[] | string) (optional) - Allows configuring the mode for `stdout` and `stderr`
of the child process. Default is `inherit`.
String value can be one of `pipe`, `ignore`, `inherit`, for more details on these values you can refer to
@@ -44,7 +54,9 @@ Process: [Main](../glossary.md#main-process)<br />
that run third-party or otherwise untrusted code. Default is `false`.
* `respondToAuthRequestsFromMainProcess` boolean (optional) - With this flag, all HTTP 401 and 407 network
requests created via the [net module](net.md) will allow responding to them via the
[`app#login`](app.md#event-login) event in the main process instead of the default
[`login`](#event-login) event on the `UtilityProcess` instance when a `session` is provided, or via
the [`app#login`](app.md#event-login) event in the main process when using the default system network
context. Without this flag, auth challenges are handled by the default
[`login`](client-request.md#event-login) event on the [`ClientRequest`](client-request.md) object. Default is
`false`.
@@ -176,6 +188,45 @@ Returns:
Emitted when the child process sends a message using [`process.parentPort.postMessage()`](process.md#processparentport).
#### Event: 'login'
Returns:
* `authenticationResponseDetails` Object
* `url` URL
* `pid` number
* `authInfo` Object
* `isProxy` boolean
* `scheme` string
* `host` string
* `port` Integer
* `realm` string
* `callback` Function
* `username` string (optional)
* `password` string (optional)
Emitted when the utility process encounters an HTTP 401 or 407 authentication challenge, if the
process was created with both `respondToAuthRequestsFromMainProcess: true` and a `session` option.
The `callback` should be called with credentials to respond to the challenge. Calling `callback`
without arguments will cancel the request.
This behaves the same as the [`login` event on `app`](app.md#event-login) but is scoped to the
individual utility process instance.
```js
const { session, utilityProcess } = require('electron')
const ses = session.defaultSession
const child = utilityProcess.fork('./worker.js', [], {
session: ses,
respondToAuthRequestsFromMainProcess: true
})
child.on('login', (authenticationResponseDetails, authInfo, callback) => {
callback('username', 'password')
})
```
[`child_process.fork`]: https://nodejs.org/dist/latest-v16.x/docs/api/child_process.html#child_processforkmodulepath-args-options
[Services API]: https://chromium.googlesource.com/chromium/src/+/main/docs/mojo_and_services.md
[stdio]: https://nodejs.org/dist/latest/docs/api/child_process.html#optionsstdio

View File

@@ -134,6 +134,12 @@ When a cookie is deleted, the change cause remains `explicit`.
When the cookie being set is identical to an existing one (same name, domain, path, and value, with no actual changes), the change cause is `inserted-no-change-overwrite`.
When the value of the cookie being set remains unchanged but some of its attributes are updated, such as the expiration attribute, the change cause will be `inserted-no-value-change-overwrite`.
### Deprecated: `showHiddenFiles` in Dialogs on Linux
This property will still be honored on macOS and Windows, but support on Linux
will be removed in Electron 42. GTK intends for this to be a user choice rather
than an app choice and has removed the API to do this programmatically.
## Planned Breaking API Changes (40.0)
### Deprecated: `clipboard` API access from renderer processes
@@ -147,12 +153,6 @@ your preload script and expose it using the [contextBridge](https://www.electron
Debug symbols for MacOS (dSYM) now use xz compression in order to handle larger file sizes. `dsym.zip` files are now
`dsym.tar.xz` files. End users using debug symbols may need to update their zip utilities.
### Deprecated: `showHiddenFiles` in Dialogs on Linux
This property will still be honored on macOS and Windows, but support on Linux
will be removed in Electron 42. GTK intends for this to be a user choice rather
than an app choice and has removed the API to do this programmatically.
## Planned Breaking API Changes (39.0)
### Deprecated: `--host-rules` command line switch

View File

@@ -18,13 +18,16 @@
#include "content/browser/network_service_instance_impl.h" // nogncheck
#include "content/public/browser/child_process_host.h"
#include "content/public/browser/service_process_host.h"
#include "content/public/browser/storage_partition.h"
#include "content/public/common/result_codes.h"
#include "gin/object_template_builder.h"
#include "gin/persistent.h"
#include "mojo/public/cpp/bindings/pending_receiver.h"
#include "services/network/public/cpp/originating_process_id.h"
#include "shell/browser/api/electron_api_session.h"
#include "shell/browser/api/message_port.h"
#include "shell/browser/browser.h"
#include "shell/browser/electron_browser_context.h"
#include "shell/browser/electron_child_process_host_flags.h"
#include "shell/browser/javascript_environment.h"
#include "shell/browser/net/system_network_context_manager.h"
@@ -92,8 +95,9 @@ UtilityProcessWrapper::UtilityProcessWrapper(
base::FilePath current_working_directory,
bool use_plugin_helper,
bool create_network_observer,
bool disclaim_responsibility)
: create_network_observer_(create_network_observer) {
bool disclaim_responsibility,
Session* session)
: create_network_observer_(create_network_observer), session_(session) {
auto& allocation_handle =
JavascriptEnvironment::GetIsolate()->GetCppHeap()->GetAllocationHandle();
#if BUILDFLAG(IS_WIN)
@@ -451,6 +455,7 @@ UtilityProcessWrapper::CreateURLLoaderFactoryParams() {
node::mojom::URLLoaderFactoryParamsPtr params =
node::mojom::URLLoaderFactoryParams::New();
mojo::PendingRemote<network::mojom::URLLoaderFactory> url_loader_factory;
network::mojom::NetworkContext* network_context;
network::mojom::URLLoaderFactoryParamsPtr loader_params =
network::mojom::URLLoaderFactoryParams::New();
loader_params->process_id = network::OriginatingProcessId::browser();
@@ -462,11 +467,27 @@ UtilityProcessWrapper::CreateURLLoaderFactoryParams() {
url_loader_network_observer_->Bind();
}
network::mojom::NetworkContext* network_context =
g_browser_process->system_network_context_manager()->GetContext();
network_context->CreateURLLoaderFactory(
url_loader_factory.InitWithNewPipeAndPassReceiver(),
std::move(loader_params));
if (session_) {
auto* browser_context = session_->browser_context();
network_context =
browser_context->GetDefaultStoragePartition()->GetNetworkContext();
// Build a factory through CreateURLLoaderFactoryBuilder so requests go
// through ProxyingURLLoaderFactory (enabling webRequest interception).
auto [factory_builder, header_client] =
browser_context->CreateURLLoaderFactoryBuilder();
loader_params->header_client = std::move(header_client);
url_loader_factory =
std::move(factory_builder)
.Finish<mojo::PendingRemote<network::mojom::URLLoaderFactory>>(
network_context, std::move(loader_params));
} else {
network_context =
g_browser_process->system_network_context_manager()->GetContext();
network_context->CreateURLLoaderFactory(
url_loader_factory.InitWithNewPipeAndPassReceiver(),
std::move(loader_params));
}
params->url_loader_factory = std::move(url_loader_factory);
mojo::PendingRemote<network::mojom::HostResolver> host_resolver;
network_context->CreateHostResolver(
@@ -503,6 +524,7 @@ UtilityProcessWrapper* UtilityProcessWrapper::Create(
bool use_plugin_helper = false;
bool create_network_observer = false;
bool disclaim_responsibility = false;
api::Session* session = nullptr;
std::map<IOHandle, IOType> stdio;
base::FilePath current_working_directory;
base::EnvironmentMap env_map;
@@ -548,12 +570,19 @@ UtilityProcessWrapper* UtilityProcessWrapper::Create(
opts.Get("allowLoadingUnsignedLibraries", &use_plugin_helper);
opts.Get("disclaim", &disclaim_responsibility);
#endif
std::string partition;
if (opts.Get("session", &session) && session) {
} else if (opts.Get("partition", &partition)) {
session = Session::FromPartition(args->isolate(), partition);
}
}
v8::Isolate* isolate = args->isolate();
return cppgc::MakeGarbageCollected<UtilityProcessWrapper>(
isolate->GetCppHeap()->GetAllocationHandle(), std::move(params),
display_name, std::move(stdio), env_map, current_working_directory,
use_plugin_helper, create_network_observer, disclaim_responsibility);
use_plugin_helper, create_network_observer, disclaim_responsibility,
session);
}
gin::ObjectTemplateBuilder UtilityProcessWrapper::GetObjectTemplateBuilder(
@@ -567,6 +596,7 @@ gin::ObjectTemplateBuilder UtilityProcessWrapper::GetObjectTemplateBuilder(
void UtilityProcessWrapper::Trace(cppgc::Visitor* visitor) const {
gin::Wrappable<UtilityProcessWrapper>::Trace(visitor);
visitor->Trace(session_);
visitor->Trace(weak_factory_);
}

View File

@@ -38,6 +38,8 @@ class Connector;
namespace electron::api {
class Session;
class UtilityProcessWrapper final
: public gin::Wrappable<UtilityProcessWrapper>,
public gin_helper::EventEmitterMixin<UtilityProcessWrapper>,
@@ -55,7 +57,8 @@ class UtilityProcessWrapper final
base::FilePath current_working_directory,
bool use_plugin_helper,
bool create_network_observer,
bool disclaim_responsibility);
bool disclaim_responsibility,
Session* session);
~UtilityProcessWrapper() override;
static UtilityProcessWrapper* Create(gin::Arguments* args);
@@ -63,6 +66,8 @@ class UtilityProcessWrapper final
void Shutdown(uint32_t exit_code);
bool has_session() const { return session_.Get(); }
// gin::Wrappable
static const gin::WrapperInfo kWrapperInfo;
static const char* GetClassName() { return "UtilityProcess"; }
@@ -126,6 +131,7 @@ class UtilityProcessWrapper final
GC_PLUGIN_IGNORE(
"Context tracking of remote is not needed in the browser process.")
mojo::Remote<node::mojom::NodeService> node_service_remote_;
cppgc::Member<Session> session_;
std::optional<electron::URLLoaderNetworkObserver>
url_loader_network_observer_;
base::CallbackListSubscription network_service_gone_subscription_;

View File

@@ -84,13 +84,10 @@ struct LoginItemSettings {
std::wstring name;
// used in browser::getLoginItemSettings
bool executable_will_launch_at_login = false;
std::vector<LaunchItem> launch_items;
#endif
// used in browser::getLoginItemSettings; only meaningful on Windows but
// always emitted so consumers don't observe `undefined`.
bool executable_will_launch_at_login = false;
LoginItemSettings();
~LoginItemSettings();
LoginItemSettings(const LoginItemSettings&);

View File

@@ -98,6 +98,10 @@ class ElectronBrowserContext : public content::BrowserContext {
scoped_refptr<network::SharedURLLoaderFactory> InterceptURLLoaderFactory(
scoped_refptr<network::SharedURLLoaderFactory> factory);
std::pair<network::URLLoaderFactoryBuilder,
mojo::PendingRemote<network::mojom::TrustedURLLoaderHeaderClient>>
CreateURLLoaderFactoryBuilder();
std::string GetMediaDeviceIDSalt();
// content::BrowserContext:
@@ -187,10 +191,6 @@ class ElectronBrowserContext : public content::BrowserContext {
content::MediaResponseCallback callback,
gin::Arguments* args);
std::pair<network::URLLoaderFactoryBuilder,
mojo::PendingRemote<network::mojom::TrustedURLLoaderHeaderClient>>
CreateURLLoaderFactoryBuilder();
// Initialize pref registry.
void InitPrefs();

View File

@@ -11,6 +11,7 @@
#include "gin/arguments.h"
#include "gin/dictionary.h"
#include "shell/browser/api/electron_api_app.h"
#include "shell/browser/api/electron_api_utility_process.h"
#include "shell/browser/api/electron_api_web_contents.h"
#include "shell/browser/javascript_environment.h"
#include "shell/common/gin_converters/callback_converter.h"
@@ -100,6 +101,17 @@ void LoginHandler::EmitEvent(
api_web_contents->Emit("login", std::move(details), auth_info,
base::BindOnce(&LoginHandler::CallbackFromJS,
weak_factory_.GetWeakPtr()));
} else if (auto* utility_process =
api::UtilityProcessWrapper::FromProcessId(process_id);
utility_process && utility_process->has_session()) {
// Route auth to the utility process wrapper when the request originated
// from a utility process with a session and
// respondToAuthRequestsFromMainProcess. Without a session, auth falls
// through to app.on('login') for backward compatibility.
default_prevented =
utility_process->Emit("login", std::move(details), auth_info,
base::BindOnce(&LoginHandler::CallbackFromJS,
weak_factory_.GetWeakPtr()));
} else {
default_prevented =
api::App::Get()->Emit("login", nullptr, std::move(details), auth_info,

View File

@@ -6,6 +6,7 @@
#include <shellapi.h>
#include <wrl/client.h>
#include "base/logging.h"
#include "base/win/atl.h" // Must be before UIAutomationCore.h
#include "base/win/registry.h"
#include "base/win/scoped_handle.h"
@@ -677,8 +678,21 @@ void NativeWindowViews::SetForwardMouseMessages(bool forward) {
RemoveWindowSubclass(legacy_window_, SubclassProc, 1);
if (forwarding_windows_->empty()) {
UnhookWindowsHookEx(mouse_hook_);
mouse_hook_ = nullptr;
// If UnhookWindowsHookEx fails, the hook is still installed in the
// system. Leave |mouse_hook_| pointing at the existing hook so that a
// subsequent SetForwardMouseMessages(true) reuses it instead of
// installing a duplicate hook.
if (UnhookWindowsHookEx(mouse_hook_)) {
mouse_hook_ = nullptr;
} else {
const DWORD error = ::GetLastError();
if (error == ERROR_INVALID_HOOK_HANDLE) {
mouse_hook_ = nullptr;
} else {
LOG(WARNING) << "Failed to unhook low-level mouse hook: "
<< logging::SystemErrorCodeToString(error);
}
}
}
}
}

View File

@@ -70,13 +70,11 @@ v8::Local<v8::Value> Converter<electron::LoginItemSettings>::ToV8(
auto dict = gin_helper::Dictionary::CreateEmpty(isolate);
#if BUILDFLAG(IS_WIN)
dict.Set("launchItems", val.launch_items);
dict.Set("executableWillLaunchAtLogin", val.executable_will_launch_at_login);
#elif BUILDFLAG(IS_MAC)
if (base::mac::MacOSMajorVersion() >= 13)
dict.Set("status", val.status);
#endif
// Always emit `executableWillLaunchAtLogin` so callers don't see undefined
// on non-Windows platforms; the value is only meaningful on Windows.
dict.Set("executableWillLaunchAtLogin", val.executable_will_launch_at_login);
dict.Set("openAtLogin", val.open_at_login);
dict.Set("openAsHidden", val.open_as_hidden);
dict.Set("restoreState", val.restore_state);

View File

@@ -1,18 +1,19 @@
import { systemPreferences } from 'electron';
import { BrowserWindow, MessageChannelMain, utilityProcess, app } from 'electron/main';
import { BrowserWindow, MessageChannelMain, utilityProcess, app, session } from 'electron/main';
import { expect } from 'chai';
import * as childProcess from 'node:child_process';
import { once } from 'node:events';
import * as fs from 'node:fs/promises';
import * as http from 'node:http';
import * as os from 'node:os';
import * as path from 'node:path';
import { setImmediate } from 'node:timers/promises';
import { pathToFileURL } from 'node:url';
import { respondOnce, randomString, kOneKiloByte } from './lib/net-helpers';
import { ifit, startRemoteControlApp } from './lib/spec-helpers';
import { ifit, listen, startRemoteControlApp } from './lib/spec-helpers';
import { closeWindow } from './lib/window-helpers';
const fixturesPath = path.resolve(__dirname, 'fixtures', 'api', 'utility-process');
@@ -993,4 +994,596 @@ describe('utilityProcess module', () => {
await exit;
});
});
describe('session', () => {
it('can use a session object for network requests', async () => {
const customSession = session.fromPartition(`utility-session-test-${Math.random()}`);
const serverUrl = await respondOnce.toSingleURL((request, response) => {
response.end('session-response');
});
const child = utilityProcess.fork(path.join(fixturesPath, 'net-session.js'), [], {
session: customSession
});
await once(child, 'spawn');
await once(child, 'message');
child.postMessage({ type: 'fetch', url: serverUrl });
const [data] = await once(child, 'message');
expect(data.ok).to.be.true();
expect(data.body).to.equal('session-response');
const exit = once(child, 'exit');
child.kill();
await exit;
});
it('can use a partition string for network requests', async () => {
const serverUrl = await respondOnce.toSingleURL((request, response) => {
response.end('partition-response');
});
const child = utilityProcess.fork(path.join(fixturesPath, 'net-session.js'), [], {
partition: `utility-partition-test-${Math.random()}`
});
await once(child, 'spawn');
await once(child, 'message');
child.postMessage({ type: 'fetch', url: serverUrl });
const [data] = await once(child, 'message');
expect(data.ok).to.be.true();
expect(data.body).to.equal('partition-response');
const exit = once(child, 'exit');
child.kill();
await exit;
});
it('uses HTTP cache when session is provided', async () => {
let requestCount = 0;
const server = http.createServer((request, response) => {
requestCount++;
response.writeHead(200, {
'Cache-Control': 'max-age=3600',
'Content-Type': 'text/plain',
'X-Request-Count': String(requestCount)
});
response.end(`response-${requestCount}`);
});
const { url } = await listen(server);
const customSession = session.fromPartition(`utility-cache-test-${Math.random()}`);
const cacheFlags: boolean[] = [];
customSession.webRequest.onResponseStarted((details) => {
cacheFlags.push(details.fromCache);
});
try {
const child = utilityProcess.fork(path.join(fixturesPath, 'net-session.js'), [], {
session: customSession
});
await once(child, 'spawn');
await once(child, 'message');
child.postMessage({ type: 'fetch-cached', url: `${url}/cached` });
const [data] = await once(child, 'message');
expect(data.ok).to.be.true();
expect(data.first.body).to.equal('response-1');
expect(data.second.body).to.equal('response-1');
expect(requestCount).to.equal(1);
// Verify cache flags: first request from network, second from cache
expect(cacheFlags).to.deep.equal([false, true]);
const exit = once(child, 'exit');
child.kill();
await exit;
} finally {
customSession.webRequest.onResponseStarted(null);
await customSession.clearCache();
server.close();
}
});
it('does not use HTTP cache when using the system network context (no session)', async () => {
let requestCount = 0;
const server = http.createServer((request, response) => {
requestCount++;
response.writeHead(200, {
'Cache-Control': 'max-age=3600',
'Content-Type': 'text/plain'
});
response.end(`response-${requestCount}`);
});
const { url } = await listen(server);
try {
const child = utilityProcess.fork(path.join(fixturesPath, 'net-session.js'));
await once(child, 'spawn');
await once(child, 'message');
child.postMessage({ type: 'fetch-cached', url: `${url}/no-cache` });
const [data] = await once(child, 'message');
expect(data.ok).to.be.true();
expect(data.first.body).to.equal('response-1');
expect(data.second.body).to.equal('response-2');
expect(requestCount).to.equal(2);
const exit = once(child, 'exit');
child.kill();
await exit;
} finally {
server.close();
}
});
it('respects cache: "no-store" fetch option to bypass cache', async () => {
let requestCount = 0;
const server = http.createServer((request, response) => {
requestCount++;
response.writeHead(200, {
'Cache-Control': 'max-age=3600',
'Content-Type': 'text/plain'
});
response.end(`response-${requestCount}`);
});
const { url } = await listen(server);
const customSession = session.fromPartition(`utility-cache-nostore-${Math.random()}`);
const cacheFlags: boolean[] = [];
customSession.webRequest.onResponseStarted((details) => {
cacheFlags.push(details.fromCache);
});
try {
const child = utilityProcess.fork(path.join(fixturesPath, 'net-session.js'), [], {
session: customSession
});
await once(child, 'spawn');
await once(child, 'message');
child.postMessage({ type: 'fetch-cached', url: `${url}/nostore`, cacheMode: 'no-store' });
const [data] = await once(child, 'message');
expect(data.ok).to.be.true();
expect(data.first.body).to.equal('response-1');
expect(data.second.body).to.equal('response-2');
expect(requestCount).to.equal(2);
// Neither response should be from cache
expect(cacheFlags).to.deep.equal([false, false]);
const exit = once(child, 'exit');
child.kill();
await exit;
} finally {
customSession.webRequest.onResponseStarted(null);
await customSession.clearCache();
server.close();
}
});
it('respects cache: "no-cache" fetch option to revalidate', async () => {
let requestCount = 0;
const server = http.createServer((request, response) => {
requestCount++;
response.writeHead(200, {
'Cache-Control': 'max-age=3600',
'Content-Type': 'text/plain',
ETag: '"test-etag"'
});
response.end(`response-${requestCount}`);
});
const { url } = await listen(server);
const customSession = session.fromPartition(`utility-cache-nocache-${Math.random()}`);
const cacheFlags: boolean[] = [];
customSession.webRequest.onResponseStarted((details) => {
cacheFlags.push(details.fromCache);
});
try {
const child = utilityProcess.fork(path.join(fixturesPath, 'net-session.js'), [], {
session: customSession
});
await once(child, 'spawn');
await once(child, 'message');
child.postMessage({ type: 'fetch-cached', url: `${url}/nocache`, cacheMode: 'no-cache' });
const [data] = await once(child, 'message');
expect(data.ok).to.be.true();
expect(data.first.body).to.equal('response-1');
expect(requestCount).to.equal(2);
// First from network, second revalidated (not from cache)
expect(cacheFlags).to.deep.equal([false, false]);
const exit = once(child, 'exit');
child.kill();
await exit;
} finally {
customSession.webRequest.onResponseStarted(null);
await customSession.clearCache();
server.close();
}
});
it('respects cache: "force-cache" fetch option to use stale cache', async () => {
let requestCount = 0;
const server = http.createServer((request, response) => {
requestCount++;
response.writeHead(200, {
'Cache-Control': 'max-age=3600',
'Content-Type': 'text/plain'
});
response.end(`response-${requestCount}`);
});
const { url } = await listen(server);
const customSession = session.fromPartition(`utility-cache-forcecache-${Math.random()}`);
const cacheFlags: boolean[] = [];
customSession.webRequest.onResponseStarted((details) => {
cacheFlags.push(details.fromCache);
});
try {
const child = utilityProcess.fork(path.join(fixturesPath, 'net-session.js'), [], {
session: customSession
});
await once(child, 'spawn');
await once(child, 'message');
child.postMessage({ type: 'fetch-cached', url: `${url}/forcecache`, cacheMode: 'force-cache' });
const [data] = await once(child, 'message');
expect(data.ok).to.be.true();
expect(data.first.body).to.equal('response-1');
expect(data.second.body).to.equal('response-1');
expect(requestCount).to.equal(1);
// First from network, second from cache
expect(cacheFlags).to.deep.equal([false, true]);
const exit = once(child, 'exit');
child.kill();
await exit;
} finally {
customSession.webRequest.onResponseStarted(null);
await customSession.clearCache();
server.close();
}
});
it('respects cache: "reload" fetch option to bypass cache entirely', async () => {
let requestCount = 0;
const server = http.createServer((request, response) => {
requestCount++;
response.writeHead(200, {
'Cache-Control': 'max-age=3600',
'Content-Type': 'text/plain'
});
response.end(`response-${requestCount}`);
});
const { url } = await listen(server);
const customSession = session.fromPartition(`utility-cache-reload-${Math.random()}`);
const cacheFlags: boolean[] = [];
customSession.webRequest.onResponseStarted((details) => {
cacheFlags.push(details.fromCache);
});
try {
const child = utilityProcess.fork(path.join(fixturesPath, 'net-session.js'), [], {
session: customSession
});
await once(child, 'spawn');
await once(child, 'message');
child.postMessage({ type: 'fetch-cached', url: `${url}/reload`, cacheMode: 'reload' });
const [data] = await once(child, 'message');
expect(data.ok).to.be.true();
expect(data.first.body).to.equal('response-1');
expect(data.second.body).to.equal('response-2');
expect(requestCount).to.equal(2);
// Neither response should be from cache
expect(cacheFlags).to.deep.equal([false, false]);
const exit = once(child, 'exit');
child.kill();
await exit;
} finally {
customSession.webRequest.onResponseStarted(null);
await customSession.clearCache();
server.close();
}
});
it('isolates cookies between different sessions', async () => {
const sess1 = session.fromPartition(`utility-cookie-test-1-${Math.random()}`);
const sess2 = session.fromPartition(`utility-cookie-test-2-${Math.random()}`);
const server = http.createServer((request, response) => {
const cookie = request.headers.cookie || 'none';
response.writeHead(200, {
'Content-Type': 'text/plain',
'Set-Cookie': 'testcookie=value; Path=/'
});
response.end(cookie);
});
const { url } = await listen(server);
try {
await sess1.cookies.set({ url, name: 'testcookie', value: 'sess1value' });
const child1 = utilityProcess.fork(path.join(fixturesPath, 'net-session.js'), [], {
session: sess1
});
await once(child1, 'spawn');
await once(child1, 'message');
child1.postMessage({ type: 'fetch', url: `${url}/cookies`, options: { credentials: 'include' } });
const [data1] = await once(child1, 'message');
expect(data1.ok).to.be.true();
expect(data1.body).to.include('testcookie=sess1value');
const exit1 = once(child1, 'exit');
child1.kill();
await exit1;
const child2 = utilityProcess.fork(path.join(fixturesPath, 'net-session.js'), [], {
session: sess2
});
await once(child2, 'spawn');
await once(child2, 'message');
child2.postMessage({ type: 'fetch', url: `${url}/cookies`, options: { credentials: 'include' } });
const [data2] = await once(child2, 'message');
expect(data2.ok).to.be.true();
expect(data2.body).to.equal('none');
const exit2 = once(child2, 'exit');
child2.kill();
await exit2;
} finally {
await sess1.clearStorageData();
await sess2.clearStorageData();
server.close();
}
});
it('shares cookies when utility processes use the same session', async () => {
const sharedSession = session.fromPartition(`utility-shared-session-${Math.random()}`);
const server = http.createServer((request, response) => {
const cookie = request.headers.cookie || 'none';
response.writeHead(200, { 'Content-Type': 'text/plain' });
response.end(cookie);
});
const { url } = await listen(server);
try {
await sharedSession.cookies.set({ url, name: 'shared', value: 'cookie123' });
const child1 = utilityProcess.fork(path.join(fixturesPath, 'net-session.js'), [], {
session: sharedSession
});
await once(child1, 'spawn');
await once(child1, 'message');
child1.postMessage({ type: 'fetch', url: `${url}/shared`, options: { credentials: 'include' } });
const [data1] = await once(child1, 'message');
expect(data1.ok).to.be.true();
expect(data1.body).to.include('shared=cookie123');
const exit1 = once(child1, 'exit');
child1.kill();
await exit1;
const child2 = utilityProcess.fork(path.join(fixturesPath, 'net-session.js'), [], {
session: sharedSession
});
await once(child2, 'spawn');
await once(child2, 'message');
child2.postMessage({ type: 'fetch', url: `${url}/shared`, options: { credentials: 'include' } });
const [data2] = await once(child2, 'message');
expect(data2.ok).to.be.true();
expect(data2.body).to.include('shared=cookie123');
const exit2 = once(child2, 'exit');
child2.kill();
await exit2;
} finally {
await sharedSession.clearStorageData();
server.close();
}
});
it('session option takes precedence over partition', async () => {
const customSession = session.fromPartition(`utility-precedence-session-${Math.random()}`);
const serverUrl = await respondOnce.toSingleURL((request, response) => {
response.end('precedence-ok');
});
const child = utilityProcess.fork(path.join(fixturesPath, 'net-session.js'), [], {
session: customSession,
partition: 'this-should-be-ignored'
} as any);
await once(child, 'spawn');
await once(child, 'message');
child.postMessage({ type: 'fetch', url: serverUrl });
const [data] = await once(child, 'message');
expect(data.ok).to.be.true();
expect(data.body).to.equal('precedence-ok');
const exit = once(child, 'exit');
child.kill();
await exit;
});
it('session webRequest handlers intercept utility process requests', async () => {
const customSession = session.fromPartition(`utility-webrequest-test-${Math.random()}`);
const server = http.createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'text/plain' });
response.end(`header: ${request.headers['x-custom-header'] || 'missing'}`);
});
const { url } = await listen(server);
try {
customSession.webRequest.onBeforeSendHeaders((details, callback) => {
details.requestHeaders['X-Custom-Header'] = 'intercepted';
callback({ requestHeaders: details.requestHeaders });
});
const child = utilityProcess.fork(path.join(fixturesPath, 'net-session.js'), [], {
session: customSession
});
await once(child, 'spawn');
await once(child, 'message');
child.postMessage({ type: 'fetch', url: `${url}/webrequest` });
const [data] = await once(child, 'message');
expect(data.ok).to.be.true();
expect(data.body).to.equal('header: intercepted');
const exit = once(child, 'exit');
child.kill();
await exit;
} finally {
customSession.webRequest.onBeforeSendHeaders(null);
server.close();
}
});
it('fires ClientRequest login event when respondToAuthRequestsFromMainProcess is false', async () => {
const customSession = session.fromPartition(`utility-login-client-${Math.random()}`);
const serverUrl = await respondOnce.toSingleURL((request, response) => {
if (!request.headers.authorization) {
return response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Foo"' }).end();
}
response.writeHead(200).end('authenticated');
});
const child = utilityProcess.fork(path.join(fixturesPath, 'net-session.js'), [], {
session: customSession
});
await once(child, 'spawn');
await once(child, 'message'); // ready
child.postMessage({ type: 'net-request-login', url: serverUrl });
const [loginMsg] = await once(child, 'message');
expect(loginMsg.event).to.equal('login');
expect(loginMsg.authInfo.realm).to.equal('Foo');
expect(loginMsg.authInfo.scheme).to.equal('basic');
const [responseMsg] = await once(child, 'message');
expect(responseMsg.event).to.equal('response');
expect(responseMsg.statusCode).to.equal(200);
const exit = once(child, 'exit');
child.kill();
await exit;
});
it('fires app login event when respondToAuthRequestsFromMainProcess is true without session', async () => {
const { remotely } = await startRemoteControlApp();
const serverUrl = await respondOnce.toSingleURL((request, response) => {
if (!request.headers.authorization) {
return response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Foo"' }).end();
}
response.writeHead(200).end('authenticated');
});
const [loginAuthInfo, statusCode] = await remotely(
async (serverUrl: string, fixture: string) => {
const { app, utilityProcess } = require('electron');
const { once } = require('node:events');
const child = utilityProcess.fork(fixture, [], {
stdio: 'ignore',
respondToAuthRequestsFromMainProcess: true
});
await once(child, 'spawn');
await once(child, 'message');
child.postMessage({ type: 'fetch-auth', url: serverUrl });
const [ev, , , authInfo, cb] = await once(app, 'login');
ev.preventDefault();
cb('user', 'pass');
const [result] = await once(child, 'message');
return [authInfo, result.status];
},
serverUrl,
path.join(fixturesPath, 'net-session.js')
);
expect(statusCode).to.equal(200);
expect(loginAuthInfo!.realm).to.equal('Foo');
expect(loginAuthInfo!.scheme).to.equal('basic');
});
it('fires utility process login event when respondToAuthRequestsFromMainProcess is true with session', async () => {
const { remotely } = await startRemoteControlApp();
const serverUrl = await respondOnce.toSingleURL((request, response) => {
if (!request.headers.authorization) {
return response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Foo"' }).end();
}
response.writeHead(200).end('authenticated');
});
const [loginAuthInfo, statusCode, appLoginFired] = await remotely(
async (serverUrl: string, fixture: string) => {
const { app, session, utilityProcess } = require('electron');
const { once } = require('node:events');
const customSession = session.fromPartition(`utility-login-session-${Math.random()}`);
let appLoginFired = false;
app.on('login', () => {
appLoginFired = true;
});
const child = utilityProcess.fork(fixture, [], {
stdio: 'ignore',
respondToAuthRequestsFromMainProcess: true,
session: customSession
});
await once(child, 'spawn');
await once(child, 'message');
child.postMessage({ type: 'fetch-auth', url: serverUrl });
const [ev, , authInfo, cb] = await once(child, 'login');
ev.preventDefault();
cb('user', 'pass');
const [result] = await once(child, 'message');
return [authInfo, result.status, appLoginFired];
},
serverUrl,
path.join(fixturesPath, 'net-session.js')
);
expect(statusCode).to.equal(200);
expect(loginAuthInfo!.realm).to.equal('Foo');
expect(loginAuthInfo!.scheme).to.equal('basic');
expect(appLoginFired).to.be.false();
});
it('resolves hosts using the session network context, not the default', async () => {
const proxyServer = http.createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'text/plain' });
response.end(`proxied:${request.headers.host}`);
});
const { port: proxyPort } = await listen(proxyServer);
const customSession = session.fromPartition(`utility-resolver-test-${Math.random()}`);
try {
await customSession.setProxy({
proxyRules: `http=127.0.0.1:${proxyPort}`,
proxyBypassRules: '<-loopback>'
});
await session.defaultSession.setProxy({});
const child = utilityProcess.fork(path.join(fixturesPath, 'net-session.js'), [], {
session: customSession
});
await once(child, 'spawn');
await once(child, 'message');
child.postMessage({ type: 'fetch', url: 'http://non-existent-host.test:12345/path' });
const [data] = await once(child, 'message');
expect(data.ok).to.be.true();
expect(data.body).to.equal('proxied:non-existent-host.test:12345');
const exit = once(child, 'exit');
child.kill();
await exit;
} finally {
await session.defaultSession.setProxy({});
proxyServer.close();
}
});
it('does not use system network proxy set via app.setProxy when session is provided', async () => {
const { remotely } = await startRemoteControlApp();
const systemProxyServer = http.createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'text/plain' });
response.end('system-proxy');
});
const { port: systemProxyPort } = await listen(systemProxyServer);
const targetServer = http.createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'text/plain' });
response.end('direct-response');
});
const { url: targetUrl } = await listen(targetServer);
try {
const body = await remotely(
async (targetUrl: string, systemProxyPort: number, fixture: string) => {
const { app, session, utilityProcess } = require('electron');
const { once } = require('node:events');
await app.setProxy({
proxyRules: `http=127.0.0.1:${systemProxyPort}`,
proxyBypassRules: '<-loopback>'
});
const customSession = session.fromPartition(`utility-app-proxy-test-${Math.random()}`);
await customSession.setProxy({});
const child = utilityProcess.fork(fixture, [], {
stdio: 'ignore',
session: customSession
});
await once(child, 'spawn');
await once(child, 'message');
child.postMessage({ type: 'fetch', url: targetUrl });
const [data] = await once(child, 'message');
const exit = once(child, 'exit');
child.kill();
await exit;
return data.body;
},
targetUrl,
systemProxyPort,
path.join(fixturesPath, 'net-session.js')
);
expect(body).to.equal('direct-response');
} finally {
targetServer.close();
systemProxyServer.close();
}
});
});
});

View File

@@ -0,0 +1,84 @@
const { net } = require('electron');
process.parentPort.on('message', async (e) => {
const { type, url, options } = e.data;
if (type === 'fetch') {
try {
const response = await net.fetch(url, options || {});
const body = await response.text();
const headers = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
process.parentPort.postMessage({
ok: response.ok,
status: response.status,
body,
headers
});
} catch (error) {
process.parentPort.postMessage({
ok: false,
error: error.message
});
}
} else if (type === 'fetch-cached') {
try {
const response1 = await net.fetch(url);
const body1 = await response1.text();
const fetchOpts = {};
if (e.data.cacheMode) {
fetchOpts.cache = e.data.cacheMode;
}
const response2 = await net.fetch(url, fetchOpts);
const body2 = await response2.text();
process.parentPort.postMessage({
ok: true,
first: { status: response1.status, body: body1 },
second: { status: response2.status, body: body2 }
});
} catch (error) {
process.parentPort.postMessage({
ok: false,
error: error.message
});
}
} else if (type === 'net-request-login') {
const request = net.request({ method: 'GET', url });
request.on('login', (authInfo, callback) => {
process.parentPort.postMessage({ event: 'login', authInfo });
callback('user', 'pass');
});
request.on('response', (response) => {
const data = [];
response.on('data', (chunk) => data.push(chunk));
response.on('end', () => {
process.parentPort.postMessage({
event: 'response',
statusCode: response.statusCode
});
});
});
request.end();
} else if (type === 'fetch-auth') {
try {
const response = await net.fetch(url);
const body = await response.text();
process.parentPort.postMessage({
event: 'response',
status: response.status,
body
});
} catch (error) {
process.parentPort.postMessage({
event: 'error',
error: error.message
});
}
}
});
process.parentPort.postMessage({ type: 'ready' });