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)`
* `handler` Function<{action: 'deny'} | {action: 'allow', outlivesOpener?: boolean, overrideBrowserWindowOptions?: BrowserWindowConstructorOptions}>
* `handler` Function<[WindowOpenHandlerResponse](structures/window-open-handler-response.md)>
* `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`.
* `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
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
window. `allow` will allow the new window to be created. Specifying `overrideBrowserWindowOptions` allows customization of the created window.
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.
Returns `WindowOpenHandlerResponse` - When set to `{ action: 'deny' }` cancels the creation of the new
window. `{ action: 'allow' }` will allow the new window to be created.
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
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
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)`
* `muted` boolean

View file

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

View file

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

View file

@ -16,16 +16,16 @@ export type WindowOpenArgs = {
features: string,
}
const frameNamesToWindow = new Map<string, BrowserWindow>();
const registerFrameNameToGuestWindow = (name: string, win: BrowserWindow) => frameNamesToWindow.set(name, win);
const frameNamesToWindow = new Map<string, WebContents>();
const registerFrameNameToGuestWindow = (name: string, webContents: WebContents) => frameNamesToWindow.set(name, webContents);
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
* 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,
guest?: WebContents,
referrer: Referrer,
@ -34,7 +34,8 @@ export function openGuestWindow ({ embedder, guest, referrer, disposition, postD
overrideBrowserWindowOptions?: BrowserWindowConstructorOptions,
windowOpenArgs: WindowOpenArgs,
outlivesOpener: boolean,
}): BrowserWindow | undefined {
createWindow?: Electron.CreateWindowFunction
}): void {
const { url, frameName, features } = windowOpenArgs;
const { options: parsedOptions } = parseFeatures(features);
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
// 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
const existingWindow = getGuestWindowByFrameName(frameName);
if (existingWindow) {
if (existingWindow.isDestroyed() || existingWindow.webContents.isDestroyed()) {
const existingWebContents = getGuestWebContentsByFrameName(frameName);
if (existingWebContents) {
if (existingWebContents.isDestroyed()) {
// FIXME(t57ser): The webContents is destroyed for some reason, unregister the frame name
unregisterFrameName(frameName);
} else {
existingWindow.loadURL(url);
return existingWindow;
existingWebContents.loadURL(url);
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({
webContents: guest,
...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 });
return window;
}
/**
@ -92,12 +116,12 @@ export function openGuestWindow ({ embedder, guest, referrer, disposition, postD
*/
const handleWindowLifecycleEvents = function ({ embedder, guest, frameName, outlivesOpener }: {
embedder: WebContents,
guest: BrowserWindow,
guest: WebContents,
frameName: string,
outlivesOpener: boolean
}) {
const closedByEmbedder = function () {
guest.removeListener('closed', closedByUser);
guest.removeListener('destroyed', closedByUser);
guest.destroy();
};
@ -110,11 +134,11 @@ const handleWindowLifecycleEvents = function ({ embedder, guest, frameName, outl
if (!outlivesOpener) {
embedder.once('current-render-view-deleted' as any, closedByEmbedder);
}
guest.once('closed', closedByUser);
guest.once('destroyed', closedByUser);
if (frameName) {
registerFrameNameToGuestWindow(frameName, guest);
guest.once('closed', function () {
guest.once('destroyed', function () {
unregisterFrameName(frameName);
});
}

View file

@ -1,12 +1,14 @@
import { BrowserWindow, screen } from 'electron';
import { expect, assert } from 'chai';
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 { once } from 'node:events';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import * as http from 'node:http';
describe('webContents.setWindowOpenHandler', () => {
describe('native window', () => {
let browserWindow: BrowserWindow;
beforeEach(async () => {
browserWindow = new BrowserWindow({ show: false });
@ -214,3 +216,161 @@ describe('webContents.setWindowOpenHandler', () => {
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;
browserWindowOptions: BrowserWindowConstructorOptions;
_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;
_send(internal: boolean, channel: string, args: any): boolean;
_sendInternal(channel: string, ...args: any[]): void;
@ -113,6 +113,8 @@ declare namespace Electron {
type?: 'backgroundPage' | 'window' | 'browserView' | 'remote' | 'webview' | 'offscreen';
}
type CreateWindowFunction = (options: BrowserWindowConstructorOptions) => WebContents;
interface Menu {
_init(): void;
_isCommandIdChecked(id: string): boolean;