feat: add WebContents.ipc (#34959)

This commit is contained in:
Jeremy Rose 2022-08-03 16:55:12 -07:00 committed by GitHub
parent bba22ae720
commit 6d859dcd7f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 341 additions and 12 deletions

View file

@ -862,6 +862,8 @@ Returns:
Emitted when the renderer process sends an asynchronous message via `ipcRenderer.send()`.
See also [`webContents.ipc`](#contentsipc-readonly), which provides an [`IpcMain`](ipc-main.md)-like interface for responding to IPC messages specifically from this WebContents.
#### Event: 'ipc-message-sync'
Returns:
@ -872,6 +874,8 @@ Returns:
Emitted when the renderer process sends a synchronous message via `ipcRenderer.sendSync()`.
See also [`webContents.ipc`](#contentsipc-readonly), which provides an [`IpcMain`](ipc-main.md)-like interface for responding to IPC messages specifically from this WebContents.
#### Event: 'preferred-size-changed'
Returns:
@ -1985,6 +1989,35 @@ This corresponds to the [animationPolicy][] accessibility feature in Chromium.
### Instance Properties
#### `contents.ipc` _Readonly_
An [`IpcMain`](ipc-main.md) scoped to just IPC messages sent from this
WebContents.
IPC messages sent with `ipcRenderer.send`, `ipcRenderer.sendSync` or
`ipcRenderer.postMessage` will be delivered in the following order:
1. `contents.on('ipc-message')`
2. `contents.mainFrame.on(channel)`
3. `contents.ipc.on(channel)`
4. `ipcMain.on(channel)`
Handlers registered with `invoke` will be checked in the following order. The
first one that is defined will be called, the rest will be ignored.
1. `contents.mainFrame.handle(channel)`
2. `contents.handle(channel)`
3. `ipcMain.handle(channel)`
A handler or event listener registered on the WebContents will receive IPC
messages sent from any frame, including child frames. In most cases, only the
main frame can send IPC messages. However, if the `nodeIntegrationInSubFrames`
option is enabled, it is possible for child frames to send IPC messages also.
In that case, handlers should check the `senderFrame` property of the IPC event
to ensure that the message is coming from the expected frame. Alternatively,
register handlers on the appropriate frame directly using the
[`WebFrameMain.ipc`](web-frame-main.md#frameipc-readonly) interface.
#### `contents.audioMuted`
A `boolean` property that determines whether this page is muted.

View file

@ -140,6 +140,31 @@ ipcRenderer.on('port', (e, msg) => {
### Instance Properties
#### `frame.ipc` _Readonly_
An [`IpcMain`](ipc-main.md) instance scoped to the frame.
IPC messages sent with `ipcRenderer.send`, `ipcRenderer.sendSync` or
`ipcRenderer.postMessage` will be delivered in the following order:
1. `contents.on('ipc-message')`
2. `contents.mainFrame.on(channel)`
3. `contents.ipc.on(channel)`
4. `ipcMain.on(channel)`
Handlers registered with `invoke` will be checked in the following order. The
first one that is defined will be called, the rest will be ignored.
1. `contents.mainFrame.handle(channel)`
2. `contents.handle(channel)`
3. `ipcMain.handle(channel)`
In most cases, only the main frame of a WebContents can send or receive IPC
messages. However, if the `nodeIntegrationInSubFrames` option is enabled, it is
possible for child frames to send and receive IPC messages also. The
[`WebContents.ipc`](web-contents.md#contentsipc-readonly) interface may be more
convenient when `nodeIntegrationInSubFrames` is not enabled.
#### `frame.url` _Readonly_
A `string` representing the current URL of the frame.

View file

@ -2,7 +2,4 @@ import { IpcMainImpl } from '@electron/internal/browser/ipc-main-impl';
const ipcMain = new IpcMainImpl();
// Do not throw exception when channel name is "error".
ipcMain.on('error', () => {});
export default ipcMain;

View file

@ -9,12 +9,15 @@ import { ipcMainInternal } from '@electron/internal/browser/ipc-main-internal';
import * as ipcMainUtils from '@electron/internal/browser/ipc-main-internal-utils';
import { MessagePortMain } from '@electron/internal/browser/message-port-main';
import { IPC_MESSAGES } from '@electron/internal/common/ipc-messages';
import { IpcMainImpl } from '@electron/internal/browser/ipc-main-impl';
// session is not used here, the purpose is to make sure session is initialized
// before the webContents module.
// eslint-disable-next-line
session
const webFrameMainBinding = process._linkedBinding('electron_browser_web_frame_main');
let nextId = 0;
const getNextId = function () {
return ++nextId;
@ -556,6 +559,12 @@ WebContents.prototype._init = function () {
this._windowOpenHandler = null;
const ipc = new IpcMainImpl();
Object.defineProperty(this, 'ipc', {
get () { return ipc; },
enumerable: true
});
// Dispatch IPC messages to the ipc module.
this.on('-ipc-message' as any, function (this: Electron.WebContents, event: Electron.IpcMainEvent, internal: boolean, channel: string, args: any[]) {
addSenderFrameToEvent(event);
@ -564,6 +573,9 @@ WebContents.prototype._init = function () {
} else {
addReplyToEvent(event);
this.emit('ipc-message', event, channel, ...args);
const maybeWebFrame = webFrameMainBinding.fromIdOrNull(event.processId, event.frameId);
maybeWebFrame && maybeWebFrame.ipc.emit(channel, event, ...args);
ipc.emit(channel, event, ...args);
ipcMain.emit(channel, event, ...args);
}
});
@ -575,8 +587,10 @@ WebContents.prototype._init = function () {
console.error(`Error occurred in handler for '${channel}':`, error);
event.sendReply({ error: error.toString() });
};
const target = internal ? ipcMainInternal : ipcMain;
if ((target as any)._invokeHandlers.has(channel)) {
const maybeWebFrame = webFrameMainBinding.fromIdOrNull(event.processId, event.frameId);
const targets: (ElectronInternal.IpcMainInternal| undefined)[] = internal ? [ipcMainInternal] : [maybeWebFrame && maybeWebFrame.ipc, ipc, ipcMain];
const target = targets.find(target => target && (target as any)._invokeHandlers.has(channel));
if (target) {
(target as any)._invokeHandlers.get(channel)(event, ...args);
} else {
event._throw(`No handler registered for '${channel}'`);
@ -590,10 +604,13 @@ WebContents.prototype._init = function () {
ipcMainInternal.emit(channel, event, ...args);
} else {
addReplyToEvent(event);
if (this.listenerCount('ipc-message-sync') === 0 && ipcMain.listenerCount(channel) === 0) {
const maybeWebFrame = webFrameMainBinding.fromIdOrNull(event.processId, event.frameId);
if (this.listenerCount('ipc-message-sync') === 0 && ipc.listenerCount(channel) === 0 && ipcMain.listenerCount(channel) === 0 && (!maybeWebFrame || maybeWebFrame.ipc.listenerCount(channel) === 0)) {
console.warn(`WebContents #${this.id} called ipcRenderer.sendSync() with '${channel}' channel without listeners.`);
}
this.emit('ipc-message-sync', event, channel, ...args);
maybeWebFrame && maybeWebFrame.ipc.emit(channel, event, ...args);
ipc.emit(channel, event, ...args);
ipcMain.emit(channel, event, ...args);
}
});
@ -601,6 +618,9 @@ WebContents.prototype._init = function () {
this.on('-ipc-ports' as any, function (event: Electron.IpcMainEvent, internal: boolean, channel: string, message: any, ports: any[]) {
addSenderFrameToEvent(event);
event.ports = ports.map(p => new MessagePortMain(p));
ipc.emit(channel, event, message);
const maybeWebFrame = webFrameMainBinding.fromIdOrNull(event.processId, event.frameId);
maybeWebFrame && maybeWebFrame.ipc.emit(channel, event, message);
ipcMain.emit(channel, event, message);
});

View file

@ -1,7 +1,16 @@
import { MessagePortMain } from '@electron/internal/browser/message-port-main';
import { IpcMainImpl } from '@electron/internal/browser/ipc-main-impl';
const { WebFrameMain, fromId } = process._linkedBinding('electron_browser_web_frame_main');
Object.defineProperty(WebFrameMain.prototype, 'ipc', {
get () {
const ipc = new IpcMainImpl();
Object.defineProperty(this, 'ipc', { value: ipc });
return ipc;
}
});
WebFrameMain.prototype.send = function (channel, ...args) {
if (typeof channel !== 'string') {
throw new Error('Missing required channel argument');

View file

@ -4,6 +4,13 @@ import { IpcMainInvokeEvent } from 'electron/main';
export class IpcMainImpl extends EventEmitter {
private _invokeHandlers: Map<string, (e: IpcMainInvokeEvent, ...args: any[]) => void> = new Map();
constructor () {
super();
// Do not throw exception when channel name is "error".
this.on('error', () => {});
}
handle: Electron.IpcMain['handle'] = (method, fn) => {
if (this._invokeHandlers.has(method)) {
throw new Error(`Attempted to register a second handler for '${method}'`);

View file

@ -1,6 +1,3 @@
import { IpcMainImpl } from '@electron/internal/browser/ipc-main-impl';
export const ipcMainInternal = new IpcMainImpl() as ElectronInternal.IpcMainInternal;
// Do not throw exception when channel name is "error".
ipcMainInternal.on('error', () => {});

View file

@ -362,6 +362,18 @@ gin::Handle<WebFrameMain> WebFrameMain::From(v8::Isolate* isolate,
return handle;
}
// static
gin::Handle<WebFrameMain> WebFrameMain::FromOrNull(
v8::Isolate* isolate,
content::RenderFrameHost* rfh) {
if (rfh == nullptr)
return gin::Handle<WebFrameMain>();
auto* web_frame = FromRenderFrameHost(rfh);
if (web_frame)
return gin::CreateHandle(isolate, web_frame);
return gin::Handle<WebFrameMain>();
}
// static
v8::Local<v8::ObjectTemplate> WebFrameMain::FillObjectTemplate(
v8::Isolate* isolate,
@ -409,6 +421,20 @@ v8::Local<v8::Value> FromID(gin_helper::ErrorThrower thrower,
return WebFrameMain::From(thrower.isolate(), rfh).ToV8();
}
v8::Local<v8::Value> FromIDOrNull(gin_helper::ErrorThrower thrower,
int render_process_id,
int render_frame_id) {
if (!electron::Browser::Get()->is_ready()) {
thrower.ThrowError("WebFrameMain is available only after app ready");
return v8::Null(thrower.isolate());
}
auto* rfh =
content::RenderFrameHost::FromID(render_process_id, render_frame_id);
return WebFrameMain::FromOrNull(thrower.isolate(), rfh).ToV8();
}
void Initialize(v8::Local<v8::Object> exports,
v8::Local<v8::Value> unused,
v8::Local<v8::Context> context,
@ -417,6 +443,7 @@ void Initialize(v8::Local<v8::Object> exports,
gin_helper::Dictionary dict(isolate, exports);
dict.Set("WebFrameMain", WebFrameMain::GetConstructor(context));
dict.SetMethod("fromId", &FromID);
dict.SetMethod("fromIdOrNull", &FromIDOrNull);
}
} // namespace

View file

@ -44,6 +44,9 @@ class WebFrameMain : public gin::Wrappable<WebFrameMain>,
static gin::Handle<WebFrameMain> From(
v8::Isolate* isolate,
content::RenderFrameHost* render_frame_host);
static gin::Handle<WebFrameMain> FromOrNull(
v8::Isolate* isolate,
content::RenderFrameHost* render_frame_host);
static WebFrameMain* FromFrameTreeNodeId(int frame_tree_node_id);
static WebFrameMain* FromRenderFrameHost(
content::RenderFrameHost* render_frame_host);

View file

@ -3,8 +3,13 @@ import { expect } from 'chai';
import { BrowserWindow, ipcMain, IpcMainInvokeEvent, MessageChannelMain, WebContents } from 'electron/main';
import { closeAllWindows } from './window-helpers';
import { emittedOnce } from './events-helpers';
import { defer } from './spec-helpers';
import * as path from 'path';
import * as http from 'http';
import { AddressInfo } from 'net';
const v8Util = process._linkedBinding('electron_common_v8_util');
const fixturesPath = path.resolve(__dirname, 'fixtures');
describe('ipc module', () => {
describe('invoke', () => {
@ -90,7 +95,7 @@ describe('ipc module', () => {
});
it('throws an error when invoking a handler that was removed', async () => {
ipcMain.handle('test', () => {});
ipcMain.handle('test', () => { });
ipcMain.removeHandler('test');
const done = new Promise<void>(resolve => ipcMain.once('result', (e, arg) => {
expect(arg.error).to.match(/No handler registered/);
@ -101,9 +106,9 @@ describe('ipc module', () => {
});
it('forbids multiple handlers', async () => {
ipcMain.handle('test', () => {});
ipcMain.handle('test', () => { });
try {
expect(() => { ipcMain.handle('test', () => {}); }).to.throw(/second handler/);
expect(() => { ipcMain.handle('test', () => { }); }).to.throw(/second handler/);
} finally {
ipcMain.removeHandler('test');
}
@ -563,4 +568,195 @@ describe('ipc module', () => {
generateTests('WebContents.postMessage', contents => contents.postMessage.bind(contents));
generateTests('WebFrameMain.postMessage', contents => contents.mainFrame.postMessage.bind(contents.mainFrame));
});
describe('WebContents.ipc', () => {
afterEach(closeAllWindows);
it('receives ipc messages sent from the WebContents', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.send(\'test\', 42)');
const [, num] = await emittedOnce(w.webContents.ipc, 'test');
expect(num).to.equal(42);
});
it('receives sync-ipc messages sent from the WebContents', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.ipc.on('test', (event, arg) => {
event.returnValue = arg * 2;
});
const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.sendSync(\'test\', 42)');
expect(result).to.equal(42 * 2);
});
it('receives postMessage messages sent from the WebContents, w/ MessagePorts', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.postMessage(\'test\', null, [(new MessageChannel).port1])');
const [event] = await emittedOnce(w.webContents.ipc, 'test');
expect(event.ports.length).to.equal(1);
});
it('handles invoke messages sent from the WebContents', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.ipc.handle('test', (_event, arg) => arg * 2);
const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
expect(result).to.equal(42 * 2);
});
it('cascades to ipcMain', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
let gotFromIpcMain = false;
const ipcMainReceived = new Promise<void>(resolve => ipcMain.on('test', () => { gotFromIpcMain = true; resolve(); }));
const ipcReceived = new Promise<boolean>(resolve => w.webContents.ipc.on('test', () => { resolve(gotFromIpcMain); }));
defer(() => ipcMain.removeAllListeners('test'));
w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.send(\'test\', 42)');
// assert that they are delivered in the correct order
expect(await ipcReceived).to.be.false();
await ipcMainReceived;
});
it('overrides ipcMain handlers', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.ipc.handle('test', (_event, arg) => arg * 2);
ipcMain.handle('test', () => { throw new Error('should not be called'); });
defer(() => ipcMain.removeHandler('test'));
const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
expect(result).to.equal(42 * 2);
});
it('falls back to ipcMain handlers', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
ipcMain.handle('test', (_event, arg) => { return arg * 2; });
defer(() => ipcMain.removeHandler('test'));
const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
expect(result).to.equal(42 * 2);
});
it('receives ipcs from child frames', async () => {
const server = http.createServer((req, res) => {
res.setHeader('content-type', 'text/html');
res.end('');
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as AddressInfo).port;
defer(() => {
server.close();
});
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegrationInSubFrames: true, preload: path.resolve(fixturesPath, 'preload-expose-ipc.js') } });
// Preloads don't run in about:blank windows, and file:// urls can't be loaded in iframes, so use a blank http page.
await w.loadURL(`data:text/html,<iframe src="http://localhost:${port}"></iframe>`);
w.webContents.mainFrame.frames[0].executeJavaScript('ipc.send(\'test\', 42)');
const [, arg] = await emittedOnce(w.webContents.ipc, 'test');
expect(arg).to.equal(42);
});
});
describe('WebFrameMain.ipc', () => {
afterEach(closeAllWindows);
it('responds to ipc messages in the main frame', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.send(\'test\', 42)');
const [, arg] = await emittedOnce(w.webContents.mainFrame.ipc, 'test');
expect(arg).to.equal(42);
});
it('responds to sync ipc messages in the main frame', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.mainFrame.ipc.on('test', (event, arg) => {
event.returnValue = arg * 2;
});
const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.sendSync(\'test\', 42)');
expect(result).to.equal(42 * 2);
});
it('receives postMessage messages sent from the WebContents, w/ MessagePorts', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.postMessage(\'test\', null, [(new MessageChannel).port1])');
const [event] = await emittedOnce(w.webContents.mainFrame.ipc, 'test');
expect(event.ports.length).to.equal(1);
});
it('handles invoke messages sent from the WebContents', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.mainFrame.ipc.handle('test', (_event, arg) => arg * 2);
const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
expect(result).to.equal(42 * 2);
});
it('cascades to WebContents and ipcMain', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
let gotFromIpcMain = false;
let gotFromWebContents = false;
const ipcMainReceived = new Promise<void>(resolve => ipcMain.on('test', () => { gotFromIpcMain = true; resolve(); }));
const ipcWebContentsReceived = new Promise<boolean>(resolve => w.webContents.ipc.on('test', () => { gotFromWebContents = true; resolve(gotFromIpcMain); }));
const ipcReceived = new Promise<boolean>(resolve => w.webContents.mainFrame.ipc.on('test', () => { resolve(gotFromWebContents); }));
defer(() => ipcMain.removeAllListeners('test'));
w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.send(\'test\', 42)');
// assert that they are delivered in the correct order
expect(await ipcReceived).to.be.false();
expect(await ipcWebContentsReceived).to.be.false();
await ipcMainReceived;
});
it('overrides ipcMain handlers', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.mainFrame.ipc.handle('test', (_event, arg) => arg * 2);
ipcMain.handle('test', () => { throw new Error('should not be called'); });
defer(() => ipcMain.removeHandler('test'));
const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
expect(result).to.equal(42 * 2);
});
it('overrides WebContents handlers', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.ipc.handle('test', () => { throw new Error('should not be called'); });
w.webContents.mainFrame.ipc.handle('test', (_event, arg) => arg * 2);
ipcMain.handle('test', () => { throw new Error('should not be called'); });
defer(() => ipcMain.removeHandler('test'));
const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
expect(result).to.equal(42 * 2);
});
it('falls back to WebContents handlers', async () => {
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } });
w.loadURL('about:blank');
w.webContents.ipc.handle('test', (_event, arg) => { return arg * 2; });
const result = await w.webContents.executeJavaScript('require(\'electron\').ipcRenderer.invoke(\'test\', 42)');
expect(result).to.equal(42 * 2);
});
it('receives ipcs from child frames', async () => {
const server = http.createServer((req, res) => {
res.setHeader('content-type', 'text/html');
res.end('');
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as AddressInfo).port;
defer(() => {
server.close();
});
const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegrationInSubFrames: true, preload: path.resolve(fixturesPath, 'preload-expose-ipc.js') } });
// Preloads don't run in about:blank windows, and file:// urls can't be loaded in iframes, so use a blank http page.
await w.loadURL(`data:text/html,<iframe src="http://localhost:${port}"></iframe>`);
w.webContents.mainFrame.frames[0].executeJavaScript('ipc.send(\'test\', 42)');
w.webContents.mainFrame.ipc.on('test', () => { throw new Error('should not be called'); });
const [, arg] = await emittedOnce(w.webContents.mainFrame.frames[0].ipc, 'test');
expect(arg).to.equal(42);
});
});
});

View file

@ -0,0 +1,14 @@
const { contextBridge, ipcRenderer } = require('electron');
// NOTE: Never do this in an actual app! Very insecure!
contextBridge.exposeInMainWorld('ipc', {
send (...args) {
return ipcRenderer.send(...args);
},
sendSync (...args) {
return ipcRenderer.sendSync(...args);
},
invoke (...args) {
return ipcRenderer.invoke(...args);
}
});

View file

@ -237,6 +237,7 @@ declare namespace NodeJS {
_linkedBinding(name: 'electron_browser_web_frame_main'): {
WebFrameMain: typeof Electron.WebFrameMain;
fromId(processId: number, routingId: number): Electron.WebFrameMain;
fromIdOrNull(processId: number, routingId: number): Electron.WebFrameMain;
}
_linkedBinding(name: 'electron_renderer_crash_reporter'): Electron.CrashReporter;
_linkedBinding(name: 'electron_renderer_ipc'): { ipc: IpcRendererBinding };