feat: Allow creation of new window to be customizable. (#41432)
This commit is contained in:
parent
04df5ce492
commit
a0dad83ded
7 changed files with 441 additions and 220 deletions
7
docs/api/structures/window-open-handler-response.md
Normal file
7
docs/api/structures/window-open-handler-response.md
Normal 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.
|
|
@ -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
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
|
@ -213,4 +215,162 @@ describe('webContents.setWindowOpenHandler', () => {
|
|||
// color-scheme is set to dark so background should not be 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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
4
typings/internal-electron.d.ts
vendored
4
typings/internal-electron.d.ts
vendored
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue