feat: Allow creation of new window to be customizable. (#41432)

This commit is contained in:
Krzysztof Halwa 2024-02-29 16:15:01 +01:00 committed by GitHub
parent 04df5ce492
commit a0dad83ded
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 441 additions and 220 deletions

View file

@ -0,0 +1,7 @@
# WindowOpenHandlerResponse Object
* `action` string - Can be `allow` or `deny`. Controls whether new window should be created.
* `overrideBrowserWindowOptions` BrowserWindowConstructorOptions (optional) - Allows customization of the created window.
* `outlivesOpener` boolean (optional) - By default, child windows are closed when their opener is closed. This can be
changed by specifying `outlivesOpener: true`, in which case the opened window will not be closed when its opener is closed.
* `createWindow` (options: BrowserWindowConstructorOptions) => WebContents (optional) - If specified, will be called instead of `new BrowserWindow` to create the new child window and event [`did-create-window`](../web-contents.md#event-did-create-window) will not be emitted. Constructed child window should use passed `options` object. This can be used for example to have the new window open as a BrowserView instead of in a separate window.

View file

@ -1288,7 +1288,7 @@ Ignore application menu shortcuts while this web contents is focused.
#### `contents.setWindowOpenHandler(handler)` #### `contents.setWindowOpenHandler(handler)`
* `handler` Function<{action: 'deny'} | {action: 'allow', outlivesOpener?: boolean, overrideBrowserWindowOptions?: BrowserWindowConstructorOptions}> * `handler` Function<[WindowOpenHandlerResponse](structures/window-open-handler-response.md)>
* `details` Object * `details` Object
* `url` string - The _resolved_ version of the URL passed to `window.open()`. e.g. opening a window with `window.open('foo')` will yield something like `https://the-origin/the/current/path/foo`. * `url` string - The _resolved_ version of the URL passed to `window.open()`. e.g. opening a window with `window.open('foo')` will yield something like `https://the-origin/the/current/path/foo`.
* `frameName` string - Name of the window provided in `window.open()` * `frameName` string - Name of the window provided in `window.open()`
@ -1303,11 +1303,8 @@ Ignore application menu shortcuts while this web contents is focused.
be set. If no post data is to be sent, the value will be `null`. Only defined be set. If no post data is to be sent, the value will be `null`. Only defined
when the window is being created by a form that set `target=_blank`. when the window is being created by a form that set `target=_blank`.
Returns `{action: 'deny'} | {action: 'allow', outlivesOpener?: boolean, overrideBrowserWindowOptions?: BrowserWindowConstructorOptions}` - `deny` cancels the creation of the new Returns `WindowOpenHandlerResponse` - When set to `{ action: 'deny' }` cancels the creation of the new
window. `allow` will allow the new window to be created. Specifying `overrideBrowserWindowOptions` allows customization of the created window. window. `{ action: 'allow' }` will allow the new window to be created.
By default, child windows are closed when their opener is closed. This can be
changed by specifying `outlivesOpener: true`, in which case the opened window
will not be closed when its opener is closed.
Returning an unrecognized value such as a null, undefined, or an object Returning an unrecognized value such as a null, undefined, or an object
without a recognized 'action' value will result in a console error and have without a recognized 'action' value will result in a console error and have
the same effect as returning `{action: 'deny'}`. the same effect as returning `{action: 'deny'}`.
@ -1318,6 +1315,26 @@ submitting a form with `<form target="_blank">`. See
[`window.open()`](window-open.md) for more details and how to use this in [`window.open()`](window-open.md) for more details and how to use this in
conjunction with `did-create-window`. conjunction with `did-create-window`.
An example showing how to customize the process of new `BrowserWindow` creation to be `BrowserView` attached to main window instead:
```js
const { BrowserView, BrowserWindow } = require('electron')
const mainWindow = new BrowserWindow()
mainWindow.webContents.setWindowOpenHandler((details) => {
return {
action: 'allow',
createWindow: (options) => {
const browserView = new BrowserView(options)
mainWindow.addBrowserView(browserView)
browserView.setBounds({ x: 0, y: 0, width: 640, height: 480 })
return browserView.webContents
}
}
})
```
#### `contents.setAudioMuted(muted)` #### `contents.setAudioMuted(muted)`
* `muted` boolean * `muted` boolean

View file

@ -144,6 +144,7 @@ auto_filenames = {
"docs/api/structures/web-preferences.md", "docs/api/structures/web-preferences.md",
"docs/api/structures/web-request-filter.md", "docs/api/structures/web-request-filter.md",
"docs/api/structures/web-source.md", "docs/api/structures/web-source.md",
"docs/api/structures/window-open-handler-response.md",
] ]
sandbox_bundle_deps = [ sandbox_bundle_deps = [

View file

@ -435,14 +435,15 @@ WebContents.prototype.loadURL = function (url, options) {
return p; return p;
}; };
WebContents.prototype.setWindowOpenHandler = function (handler: (details: Electron.HandlerDetails) => ({action: 'deny'} | {action: 'allow', overrideBrowserWindowOptions?: BrowserWindowConstructorOptions, outlivesOpener?: boolean})) { WebContents.prototype.setWindowOpenHandler = function (handler: (details: Electron.HandlerDetails) => Electron.WindowOpenHandlerResponse) {
this._windowOpenHandler = handler; this._windowOpenHandler = handler;
}; };
WebContents.prototype._callWindowOpenHandler = function (event: Electron.Event, details: Electron.HandlerDetails): {browserWindowConstructorOptions: BrowserWindowConstructorOptions | null, outlivesOpener: boolean} { WebContents.prototype._callWindowOpenHandler = function (event: Electron.Event, details: Electron.HandlerDetails): {browserWindowConstructorOptions: BrowserWindowConstructorOptions | null, outlivesOpener: boolean, createWindow?: Electron.CreateWindowFunction} {
const defaultResponse = { const defaultResponse = {
browserWindowConstructorOptions: null, browserWindowConstructorOptions: null,
outlivesOpener: false outlivesOpener: false,
createWindow: undefined
}; };
if (!this._windowOpenHandler) { if (!this._windowOpenHandler) {
return defaultResponse; return defaultResponse;
@ -468,7 +469,8 @@ WebContents.prototype._callWindowOpenHandler = function (event: Electron.Event,
} else if (response.action === 'allow') { } else if (response.action === 'allow') {
return { return {
browserWindowConstructorOptions: typeof response.overrideBrowserWindowOptions === 'object' ? response.overrideBrowserWindowOptions : null, browserWindowConstructorOptions: typeof response.overrideBrowserWindowOptions === 'object' ? response.overrideBrowserWindowOptions : null,
outlivesOpener: typeof response.outlivesOpener === 'boolean' ? response.outlivesOpener : false outlivesOpener: typeof response.outlivesOpener === 'boolean' ? response.outlivesOpener : false,
createWindow: typeof response.createWindow === 'function' ? response.createWindow : undefined
}; };
} else { } else {
event.preventDefault(); event.preventDefault();
@ -655,13 +657,16 @@ WebContents.prototype._init = function () {
postData, postData,
overrideBrowserWindowOptions: options || {}, overrideBrowserWindowOptions: options || {},
windowOpenArgs: details, windowOpenArgs: details,
outlivesOpener: result.outlivesOpener outlivesOpener: result.outlivesOpener,
createWindow: result.createWindow
}); });
} }
}); });
let windowOpenOverriddenOptions: BrowserWindowConstructorOptions | null = null; let windowOpenOverriddenOptions: BrowserWindowConstructorOptions | null = null;
let windowOpenOutlivesOpenerOption: boolean = false; let windowOpenOutlivesOpenerOption: boolean = false;
let createWindow: Electron.CreateWindowFunction | undefined;
this.on('-will-add-new-contents' as any, (event: Electron.Event, url: string, frameName: string, rawFeatures: string, disposition: Electron.HandlerDetails['disposition'], referrer: Electron.Referrer, postData: PostData) => { this.on('-will-add-new-contents' as any, (event: Electron.Event, url: string, frameName: string, rawFeatures: string, disposition: Electron.HandlerDetails['disposition'], referrer: Electron.Referrer, postData: PostData) => {
const postBody = postData ? { const postBody = postData ? {
data: postData, data: postData,
@ -686,6 +691,7 @@ WebContents.prototype._init = function () {
windowOpenOutlivesOpenerOption = result.outlivesOpener; windowOpenOutlivesOpenerOption = result.outlivesOpener;
windowOpenOverriddenOptions = result.browserWindowConstructorOptions; windowOpenOverriddenOptions = result.browserWindowConstructorOptions;
createWindow = result.createWindow;
if (!event.defaultPrevented) { if (!event.defaultPrevented) {
const secureOverrideWebPreferences = windowOpenOverriddenOptions ? { const secureOverrideWebPreferences = windowOpenOverriddenOptions ? {
// Allow setting of backgroundColor as a webPreference even though // Allow setting of backgroundColor as a webPreference even though
@ -715,6 +721,9 @@ WebContents.prototype._init = function () {
referrer: Electron.Referrer, rawFeatures: string, postData: PostData) => { referrer: Electron.Referrer, rawFeatures: string, postData: PostData) => {
const overriddenOptions = windowOpenOverriddenOptions || undefined; const overriddenOptions = windowOpenOverriddenOptions || undefined;
const outlivesOpener = windowOpenOutlivesOpenerOption; const outlivesOpener = windowOpenOutlivesOpenerOption;
const windowOpenFunction = createWindow;
createWindow = undefined;
windowOpenOverriddenOptions = null; windowOpenOverriddenOptions = null;
// false is the default // false is the default
windowOpenOutlivesOpenerOption = false; windowOpenOutlivesOpenerOption = false;
@ -737,7 +746,8 @@ WebContents.prototype._init = function () {
frameName, frameName,
features: rawFeatures features: rawFeatures
}, },
outlivesOpener outlivesOpener,
createWindow: windowOpenFunction
}); });
}); });
} }

View file

@ -16,16 +16,16 @@ export type WindowOpenArgs = {
features: string, features: string,
} }
const frameNamesToWindow = new Map<string, BrowserWindow>(); const frameNamesToWindow = new Map<string, WebContents>();
const registerFrameNameToGuestWindow = (name: string, win: BrowserWindow) => frameNamesToWindow.set(name, win); const registerFrameNameToGuestWindow = (name: string, webContents: WebContents) => frameNamesToWindow.set(name, webContents);
const unregisterFrameName = (name: string) => frameNamesToWindow.delete(name); const unregisterFrameName = (name: string) => frameNamesToWindow.delete(name);
const getGuestWindowByFrameName = (name: string) => frameNamesToWindow.get(name); const getGuestWebContentsByFrameName = (name: string) => frameNamesToWindow.get(name);
/** /**
* `openGuestWindow` is called to create and setup event handling for the new * `openGuestWindow` is called to create and setup event handling for the new
* window. * window.
*/ */
export function openGuestWindow ({ embedder, guest, referrer, disposition, postData, overrideBrowserWindowOptions, windowOpenArgs, outlivesOpener }: { export function openGuestWindow ({ embedder, guest, referrer, disposition, postData, overrideBrowserWindowOptions, windowOpenArgs, outlivesOpener, createWindow }: {
embedder: WebContents, embedder: WebContents,
guest?: WebContents, guest?: WebContents,
referrer: Referrer, referrer: Referrer,
@ -34,7 +34,8 @@ export function openGuestWindow ({ embedder, guest, referrer, disposition, postD
overrideBrowserWindowOptions?: BrowserWindowConstructorOptions, overrideBrowserWindowOptions?: BrowserWindowConstructorOptions,
windowOpenArgs: WindowOpenArgs, windowOpenArgs: WindowOpenArgs,
outlivesOpener: boolean, outlivesOpener: boolean,
}): BrowserWindow | undefined { createWindow?: Electron.CreateWindowFunction
}): void {
const { url, frameName, features } = windowOpenArgs; const { url, frameName, features } = windowOpenArgs;
const { options: parsedOptions } = parseFeatures(features); const { options: parsedOptions } = parseFeatures(features);
const browserWindowOptions = { const browserWindowOptions = {
@ -48,17 +49,42 @@ export function openGuestWindow ({ embedder, guest, referrer, disposition, postD
// To spec, subsequent window.open calls with the same frame name (`target` in // To spec, subsequent window.open calls with the same frame name (`target` in
// spec parlance) will reuse the previous window. // spec parlance) will reuse the previous window.
// https://html.spec.whatwg.org/multipage/window-object.html#apis-for-creating-and-navigating-browsing-contexts-by-name // https://html.spec.whatwg.org/multipage/window-object.html#apis-for-creating-and-navigating-browsing-contexts-by-name
const existingWindow = getGuestWindowByFrameName(frameName); const existingWebContents = getGuestWebContentsByFrameName(frameName);
if (existingWindow) { if (existingWebContents) {
if (existingWindow.isDestroyed() || existingWindow.webContents.isDestroyed()) { if (existingWebContents.isDestroyed()) {
// FIXME(t57ser): The webContents is destroyed for some reason, unregister the frame name // FIXME(t57ser): The webContents is destroyed for some reason, unregister the frame name
unregisterFrameName(frameName); unregisterFrameName(frameName);
} else { } else {
existingWindow.loadURL(url); existingWebContents.loadURL(url);
return existingWindow; return;
} }
} }
if (createWindow) {
const webContents = createWindow({
webContents: guest,
...browserWindowOptions
});
if (guest != null) {
if (webContents !== guest) {
throw new Error('Invalid webContents. Created window should be connected to webContents passed with options object.');
}
webContents.loadURL(url, {
httpReferrer: referrer,
...(postData && {
postData,
extraHeaders: formatPostDataHeaders(postData as Electron.UploadRawData[])
})
});
handleWindowLifecycleEvents({ embedder, frameName, guest, outlivesOpener });
}
return;
}
const window = new BrowserWindow({ const window = new BrowserWindow({
webContents: guest, webContents: guest,
...browserWindowOptions ...browserWindowOptions
@ -77,11 +103,9 @@ export function openGuestWindow ({ embedder, guest, referrer, disposition, postD
}); });
} }
handleWindowLifecycleEvents({ embedder, frameName, guest: window, outlivesOpener }); handleWindowLifecycleEvents({ embedder, frameName, guest: window.webContents, outlivesOpener });
embedder.emit('did-create-window', window, { url, frameName, options: browserWindowOptions, disposition, referrer, postData }); embedder.emit('did-create-window', window, { url, frameName, options: browserWindowOptions, disposition, referrer, postData });
return window;
} }
/** /**
@ -92,12 +116,12 @@ export function openGuestWindow ({ embedder, guest, referrer, disposition, postD
*/ */
const handleWindowLifecycleEvents = function ({ embedder, guest, frameName, outlivesOpener }: { const handleWindowLifecycleEvents = function ({ embedder, guest, frameName, outlivesOpener }: {
embedder: WebContents, embedder: WebContents,
guest: BrowserWindow, guest: WebContents,
frameName: string, frameName: string,
outlivesOpener: boolean outlivesOpener: boolean
}) { }) {
const closedByEmbedder = function () { const closedByEmbedder = function () {
guest.removeListener('closed', closedByUser); guest.removeListener('destroyed', closedByUser);
guest.destroy(); guest.destroy();
}; };
@ -110,11 +134,11 @@ const handleWindowLifecycleEvents = function ({ embedder, guest, frameName, outl
if (!outlivesOpener) { if (!outlivesOpener) {
embedder.once('current-render-view-deleted' as any, closedByEmbedder); embedder.once('current-render-view-deleted' as any, closedByEmbedder);
} }
guest.once('closed', closedByUser); guest.once('destroyed', closedByUser);
if (frameName) { if (frameName) {
registerFrameNameToGuestWindow(frameName, guest); registerFrameNameToGuestWindow(frameName, guest);
guest.once('closed', function () { guest.once('destroyed', function () {
unregisterFrameName(frameName); unregisterFrameName(frameName);
}); });
} }

View file

@ -1,12 +1,14 @@
import { BrowserWindow, screen } from 'electron'; import { BrowserWindow, screen } from 'electron';
import { expect, assert } from 'chai'; import { expect, assert } from 'chai';
import { HexColors, ScreenCapture } from './lib/screen-helpers'; import { HexColors, ScreenCapture } from './lib/screen-helpers';
import { ifit } from './lib/spec-helpers'; import { ifit, listen } from './lib/spec-helpers';
import { closeAllWindows } from './lib/window-helpers'; import { closeAllWindows } from './lib/window-helpers';
import { once } from 'node:events'; import { once } from 'node:events';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises'; import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import * as http from 'node:http';
describe('webContents.setWindowOpenHandler', () => { describe('webContents.setWindowOpenHandler', () => {
describe('native window', () => {
let browserWindow: BrowserWindow; let browserWindow: BrowserWindow;
beforeEach(async () => { beforeEach(async () => {
browserWindow = new BrowserWindow({ show: false }); browserWindow = new BrowserWindow({ show: false });
@ -213,4 +215,162 @@ describe('webContents.setWindowOpenHandler', () => {
// color-scheme is set to dark so background should not be white // color-scheme is set to dark so background should not be white
await screenCapture.expectColorAtCenterDoesNotMatch(HexColors.WHITE); await screenCapture.expectColorAtCenterDoesNotMatch(HexColors.WHITE);
}); });
});
describe('custom window', () => {
let browserWindow: BrowserWindow;
let server: http.Server;
let url: string;
before(async () => {
server = http.createServer((request, response) => {
switch (request.url) {
case '/index':
response.statusCode = 200;
response.end('<title>Index page</title>');
break;
case '/child':
response.statusCode = 200;
response.end('<title>Child page</title>');
break;
default:
throw new Error(`Unsupported endpoint: ${request.url}`);
}
});
url = (await listen(server)).url;
});
after(() => {
server.close();
});
beforeEach(async () => {
browserWindow = new BrowserWindow({ show: false });
await browserWindow.loadURL(`${url}/index`);
});
afterEach(closeAllWindows);
it('throws error when created window uses invalid webcontents', async () => {
const listeners = process.listeners('uncaughtException');
process.removeAllListeners('uncaughtException');
const uncaughtExceptionEmitted = new Promise<void>((resolve, reject) => {
process.on('uncaughtException', (thrown) => {
try {
expect(thrown.message).to.equal('Invalid webContents. Created window should be connected to webContents passed with options object.');
resolve();
} catch (e) {
reject(e);
} finally {
process.removeAllListeners('uncaughtException');
listeners.forEach((listener) => process.on('uncaughtException', listener));
}
});
});
browserWindow.webContents.setWindowOpenHandler(() => {
return {
action: 'allow',
createWindow: () => {
const childWindow = new BrowserWindow({ title: 'New window' });
return childWindow.webContents;
}
};
});
browserWindow.webContents.executeJavaScript("window.open('about:blank', '', 'show=no') && true");
await uncaughtExceptionEmitted;
});
it('spawns browser window when createWindow is provided', async () => {
const browserWindowTitle = 'Child browser window';
const childWindow = await new Promise<Electron.BrowserWindow>(resolve => {
browserWindow.webContents.setWindowOpenHandler(() => {
return {
action: 'allow',
createWindow: (options) => {
const childWindow = new BrowserWindow({ ...options, title: browserWindowTitle });
resolve(childWindow);
return childWindow.webContents;
}
};
});
browserWindow.webContents.executeJavaScript("window.open('about:blank', '', 'show=no') && true");
});
expect(childWindow.title).to.equal(browserWindowTitle);
});
it('spawns browser window with overriden options', async () => {
const childWindow = await new Promise<Electron.BrowserWindow>(resolve => {
browserWindow.webContents.setWindowOpenHandler(() => {
return {
action: 'allow',
overrideBrowserWindowOptions: {
width: 640,
height: 480
},
createWindow: (options) => {
expect(options.width).to.equal(640);
expect(options.height).to.equal(480);
const childWindow = new BrowserWindow(options);
resolve(childWindow);
return childWindow.webContents;
}
};
});
browserWindow.webContents.executeJavaScript("window.open('about:blank', '', 'show=no') && true");
});
const size = childWindow.getSize();
expect(size[0]).to.equal(640);
expect(size[1]).to.equal(480);
});
it('spawns browser window with access to opener property', async () => {
const childWindow = await new Promise<Electron.BrowserWindow>(resolve => {
browserWindow.webContents.setWindowOpenHandler(() => {
return {
action: 'allow',
createWindow: (options) => {
const childWindow = new BrowserWindow(options);
resolve(childWindow);
return childWindow.webContents;
}
};
});
browserWindow.webContents.executeJavaScript(`window.open('${url}/child', '', 'show=no') && true`);
});
await once(childWindow.webContents, 'ready-to-show');
const childWindowOpenerTitle = await childWindow.webContents.executeJavaScript('window.opener.document.title');
expect(childWindowOpenerTitle).to.equal(browserWindow.title);
});
it('spawns browser window without access to opener property because of noopener attribute ', async () => {
const childWindow = await new Promise<Electron.BrowserWindow>(resolve => {
browserWindow.webContents.setWindowOpenHandler(() => {
return {
action: 'allow',
createWindow: (options) => {
const childWindow = new BrowserWindow(options);
resolve(childWindow);
return childWindow.webContents;
}
};
});
browserWindow.webContents.executeJavaScript(`window.open('${url}/child', '', 'noopener,show=no') && true`);
});
await once(childWindow.webContents, 'ready-to-show');
await expect(childWindow.webContents.executeJavaScript('window.opener.document.title')).to.be.rejectedWith('Script failed to execute, this normally means an error was thrown. Check the renderer console for the error.');
});
});
}); });

View file

@ -77,7 +77,7 @@ declare namespace Electron {
equal(other: WebContents): boolean; equal(other: WebContents): boolean;
browserWindowOptions: BrowserWindowConstructorOptions; browserWindowOptions: BrowserWindowConstructorOptions;
_windowOpenHandler: ((details: Electron.HandlerDetails) => any) | null; _windowOpenHandler: ((details: Electron.HandlerDetails) => any) | null;
_callWindowOpenHandler(event: any, details: Electron.HandlerDetails): {browserWindowConstructorOptions: Electron.BrowserWindowConstructorOptions | null, outlivesOpener: boolean}; _callWindowOpenHandler(event: any, details: Electron.HandlerDetails): {browserWindowConstructorOptions: Electron.BrowserWindowConstructorOptions | null, outlivesOpener: boolean, createWindow?: Electron.CreateWindowFunction};
_setNextChildWebPreferences(prefs: Partial<Electron.BrowserWindowConstructorOptions['webPreferences']> & Pick<Electron.BrowserWindowConstructorOptions, 'backgroundColor'>): void; _setNextChildWebPreferences(prefs: Partial<Electron.BrowserWindowConstructorOptions['webPreferences']> & Pick<Electron.BrowserWindowConstructorOptions, 'backgroundColor'>): void;
_send(internal: boolean, channel: string, args: any): boolean; _send(internal: boolean, channel: string, args: any): boolean;
_sendInternal(channel: string, ...args: any[]): void; _sendInternal(channel: string, ...args: any[]): void;
@ -113,6 +113,8 @@ declare namespace Electron {
type?: 'backgroundPage' | 'window' | 'browserView' | 'remote' | 'webview' | 'offscreen'; type?: 'backgroundPage' | 'window' | 'browserView' | 'remote' | 'webview' | 'offscreen';
} }
type CreateWindowFunction = (options: BrowserWindowConstructorOptions) => WebContents;
interface Menu { interface Menu {
_init(): void; _init(): void;
_isCommandIdChecked(id: string): boolean; _isCommandIdChecked(id: string): boolean;