diff --git a/docs/api/structures/window-open-handler-response.md b/docs/api/structures/window-open-handler-response.md new file mode 100644 index 0000000000000..6ea1c99628156 --- /dev/null +++ b/docs/api/structures/window-open-handler-response.md @@ -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. diff --git a/docs/api/web-contents.md b/docs/api/web-contents.md index d2d4dc0d35d21..1530acf29e7a2 100644 --- a/docs/api/web-contents.md +++ b/docs/api/web-contents.md @@ -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 `
`. 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 diff --git a/filenames.auto.gni b/filenames.auto.gni index 3fbf80fabaaf7..cac988467ffbb 100644 --- a/filenames.auto.gni +++ b/filenames.auto.gni @@ -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 = [ diff --git a/lib/browser/api/web-contents.ts b/lib/browser/api/web-contents.ts index 070db86390554..8253eb1831204 100644 --- a/lib/browser/api/web-contents.ts +++ b/lib/browser/api/web-contents.ts @@ -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 }); }); } diff --git a/lib/browser/guest-window-manager.ts b/lib/browser/guest-window-manager.ts index ef122bd8778bd..24ce9b761a1eb 100644 --- a/lib/browser/guest-window-manager.ts +++ b/lib/browser/guest-window-manager.ts @@ -16,16 +16,16 @@ export type WindowOpenArgs = { features: string, } -const frameNamesToWindow = new Map(); -const registerFrameNameToGuestWindow = (name: string, win: BrowserWindow) => frameNamesToWindow.set(name, win); +const frameNamesToWindow = new Map(); +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); }); } diff --git a/spec/guest-window-manager-spec.ts b/spec/guest-window-manager-spec.ts index eff675b03a345..fbe3377897ca6 100644 --- a/spec/guest-window-manager-spec.ts +++ b/spec/guest-window-manager-spec.ts @@ -1,216 +1,376 @@ 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', () => { - let browserWindow: BrowserWindow; - beforeEach(async () => { - browserWindow = new BrowserWindow({ show: false }); - await browserWindow.loadURL('about:blank'); - }); - - afterEach(closeAllWindows); - - it('does not fire window creation events if the handler callback throws an error', (done) => { - const error = new Error('oh no'); - const listeners = process.listeners('uncaughtException'); - process.removeAllListeners('uncaughtException'); - process.on('uncaughtException', (thrown) => { - try { - expect(thrown).to.equal(error); - done(); - } catch (e) { - done(e); - } finally { - process.removeAllListeners('uncaughtException'); - for (const listener of listeners) { - process.on('uncaughtException', listener); - } - } + describe('native window', () => { + let browserWindow: BrowserWindow; + beforeEach(async () => { + browserWindow = new BrowserWindow({ show: false }); + await browserWindow.loadURL('about:blank'); }); - browserWindow.webContents.on('did-create-window', () => { - assert.fail('did-create-window should not be called with an overridden window.open'); - }); + afterEach(closeAllWindows); + + it('does not fire window creation events if the handler callback throws an error', (done) => { + const error = new Error('oh no'); + const listeners = process.listeners('uncaughtException'); + process.removeAllListeners('uncaughtException'); + process.on('uncaughtException', (thrown) => { + try { + expect(thrown).to.equal(error); + done(); + } catch (e) { + done(e); + } finally { + process.removeAllListeners('uncaughtException'); + for (const listener of listeners) { + process.on('uncaughtException', listener); + } + } + }); - browserWindow.webContents.executeJavaScript("window.open('about:blank', '', 'show=no') && true"); + browserWindow.webContents.on('did-create-window', () => { + assert.fail('did-create-window should not be called with an overridden window.open'); + }); - browserWindow.webContents.setWindowOpenHandler(() => { - throw error; - }); - }); + browserWindow.webContents.executeJavaScript("window.open('about:blank', '', 'show=no') && true"); - it('does not fire window creation events if the handler callback returns a bad result', async () => { - const bad = new Promise((resolve) => { browserWindow.webContents.setWindowOpenHandler(() => { - setTimeout(resolve); - return [1, 2, 3] as any; + throw error; }); }); - browserWindow.webContents.on('did-create-window', () => { - assert.fail('did-create-window should not be called with an overridden window.open'); - }); + it('does not fire window creation events if the handler callback returns a bad result', async () => { + const bad = new Promise((resolve) => { + browserWindow.webContents.setWindowOpenHandler(() => { + setTimeout(resolve); + return [1, 2, 3] as any; + }); + }); - browserWindow.webContents.executeJavaScript("window.open('about:blank', '', 'show=no') && true"); + browserWindow.webContents.on('did-create-window', () => { + assert.fail('did-create-window should not be called with an overridden window.open'); + }); - await bad; - }); + browserWindow.webContents.executeJavaScript("window.open('about:blank', '', 'show=no') && true"); - it('does not fire window creation events if an override returns action: deny', async () => { - const denied = new Promise((resolve) => { - browserWindow.webContents.setWindowOpenHandler(() => { - setTimeout(resolve); - return { action: 'deny' }; - }); + await bad; }); - browserWindow.webContents.on('did-create-window', () => { - assert.fail('did-create-window should not be called with an overridden window.open'); + it('does not fire window creation events if an override returns action: deny', async () => { + const denied = new Promise((resolve) => { + browserWindow.webContents.setWindowOpenHandler(() => { + setTimeout(resolve); + return { action: 'deny' }; + }); + }); + + browserWindow.webContents.on('did-create-window', () => { + assert.fail('did-create-window should not be called with an overridden window.open'); + }); + + browserWindow.webContents.executeJavaScript("window.open('about:blank', '', 'show=no') && true"); + + await denied; }); - browserWindow.webContents.executeJavaScript("window.open('about:blank', '', 'show=no') && true"); + it('is called when clicking on a target=_blank link', async () => { + const denied = new Promise((resolve) => { + browserWindow.webContents.setWindowOpenHandler(() => { + setTimeout(resolve); + return { action: 'deny' }; + }); + }); - await denied; - }); + browserWindow.webContents.on('did-create-window', () => { + assert.fail('did-create-window should not be called with an overridden window.open'); + }); - it('is called when clicking on a target=_blank link', async () => { - const denied = new Promise((resolve) => { - browserWindow.webContents.setWindowOpenHandler(() => { - setTimeout(resolve); - return { action: 'deny' }; + await browserWindow.webContents.loadURL('data:text/html,link'); + browserWindow.webContents.sendInputEvent({ type: 'mouseDown', x: 10, y: 10, button: 'left', clickCount: 1 }); + browserWindow.webContents.sendInputEvent({ type: 'mouseUp', x: 10, y: 10, button: 'left', clickCount: 1 }); + + await denied; + }); + + it('is called when shift-clicking on a link', async () => { + const denied = new Promise((resolve) => { + browserWindow.webContents.setWindowOpenHandler(() => { + setTimeout(resolve); + return { action: 'deny' }; + }); + }); + + browserWindow.webContents.on('did-create-window', () => { + assert.fail('did-create-window should not be called with an overridden window.open'); }); + + await browserWindow.webContents.loadURL('data:text/html,link'); + browserWindow.webContents.sendInputEvent({ type: 'mouseDown', x: 10, y: 10, button: 'left', clickCount: 1, modifiers: ['shift'] }); + browserWindow.webContents.sendInputEvent({ type: 'mouseUp', x: 10, y: 10, button: 'left', clickCount: 1, modifiers: ['shift'] }); + + await denied; }); - browserWindow.webContents.on('did-create-window', () => { - assert.fail('did-create-window should not be called with an overridden window.open'); + it('fires handler with correct params', async () => { + const testFrameName = 'test-frame-name'; + const testFeatures = 'top=10&left=10&something-unknown&show=no'; + const testUrl = 'app://does-not-exist/'; + const details = await new Promise(resolve => { + browserWindow.webContents.setWindowOpenHandler((details) => { + setTimeout(() => resolve(details)); + return { action: 'deny' }; + }); + + browserWindow.webContents.executeJavaScript(`window.open('${testUrl}', '${testFrameName}', '${testFeatures}') && true`); + }); + const { url, frameName, features, disposition, referrer } = details; + expect(url).to.equal(testUrl); + expect(frameName).to.equal(testFrameName); + expect(features).to.equal(testFeatures); + expect(disposition).to.equal('new-window'); + expect(referrer).to.deep.equal({ + policy: 'strict-origin-when-cross-origin', + url: '' + }); }); - await browserWindow.webContents.loadURL('data:text/html,link'); - browserWindow.webContents.sendInputEvent({ type: 'mouseDown', x: 10, y: 10, button: 'left', clickCount: 1 }); - browserWindow.webContents.sendInputEvent({ type: 'mouseUp', x: 10, y: 10, button: 'left', clickCount: 1 }); + it('includes post body', async () => { + const details = await new Promise(resolve => { + browserWindow.webContents.setWindowOpenHandler((details) => { + setTimeout(() => resolve(details)); + return { action: 'deny' }; + }); + + browserWindow.webContents.loadURL(`data:text/html,${encodeURIComponent(` + + + + + `)}`); + }); + const { url, frameName, features, disposition, referrer, postBody } = details; + expect(url).to.equal('http://example.com/'); + expect(frameName).to.equal(''); + expect(features).to.deep.equal(''); + expect(disposition).to.equal('foreground-tab'); + expect(referrer).to.deep.equal({ + policy: 'strict-origin-when-cross-origin', + url: '' + }); + expect(postBody).to.deep.equal({ + contentType: 'application/x-www-form-urlencoded', + data: [{ + type: 'rawData', + bytes: Buffer.from('key=value') + }] + }); + }); - await denied; - }); + it('does fire window creation events if an override returns action: allow', async () => { + browserWindow.webContents.setWindowOpenHandler(() => ({ action: 'allow' })); - it('is called when shift-clicking on a link', async () => { - const denied = new Promise((resolve) => { - browserWindow.webContents.setWindowOpenHandler(() => { - setTimeout(resolve); - return { action: 'deny' }; + setImmediate(() => { + browserWindow.webContents.executeJavaScript("window.open('about:blank', '', 'show=no') && true"); }); + + await once(browserWindow.webContents, 'did-create-window'); }); - browserWindow.webContents.on('did-create-window', () => { - assert.fail('did-create-window should not be called with an overridden window.open'); + it('can change webPreferences of child windows', async () => { + browserWindow.webContents.setWindowOpenHandler(() => ({ action: 'allow', overrideBrowserWindowOptions: { webPreferences: { defaultFontSize: 30 } } })); + + const didCreateWindow = once(browserWindow.webContents, 'did-create-window') as Promise<[BrowserWindow, Electron.DidCreateWindowDetails]>; + browserWindow.webContents.executeJavaScript("window.open('about:blank', '', 'show=no') && true"); + const [childWindow] = await didCreateWindow; + + await childWindow.webContents.executeJavaScript("document.write('hello')"); + const size = await childWindow.webContents.executeJavaScript("getComputedStyle(document.querySelector('body')).fontSize"); + expect(size).to.equal('30px'); }); - await browserWindow.webContents.loadURL('data:text/html,link'); - browserWindow.webContents.sendInputEvent({ type: 'mouseDown', x: 10, y: 10, button: 'left', clickCount: 1, modifiers: ['shift'] }); - browserWindow.webContents.sendInputEvent({ type: 'mouseUp', x: 10, y: 10, button: 'left', clickCount: 1, modifiers: ['shift'] }); + it('does not hang parent window when denying window.open', async () => { + browserWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + browserWindow.webContents.executeJavaScript("window.open('https://127.0.0.1')"); + expect(await browserWindow.webContents.executeJavaScript('42')).to.equal(42); + }); - await denied; + // Linux and arm64 platforms (WOA and macOS) do not return any capture sources + ifit(process.platform === 'darwin' && process.arch === 'x64')('should not make child window background transparent', async () => { + browserWindow.webContents.setWindowOpenHandler(() => ({ action: 'allow' })); + const didCreateWindow = once(browserWindow.webContents, 'did-create-window'); + browserWindow.webContents.executeJavaScript("window.open('about:blank') && true"); + const [childWindow] = await didCreateWindow; + const display = screen.getPrimaryDisplay(); + childWindow.setBounds(display.bounds); + await childWindow.webContents.executeJavaScript("const meta = document.createElement('meta'); meta.name = 'color-scheme'; meta.content = 'dark'; document.head.appendChild(meta); true;"); + await setTimeoutAsync(1000); + const screenCapture = await ScreenCapture.createForDisplay(display); + // color-scheme is set to dark so background should not be white + await screenCapture.expectColorAtCenterDoesNotMatch(HexColors.WHITE); + }); }); - it('fires handler with correct params', async () => { - const testFrameName = 'test-frame-name'; - const testFeatures = 'top=10&left=10&something-unknown&show=no'; - const testUrl = 'app://does-not-exist/'; - const details = await new Promise(resolve => { - browserWindow.webContents.setWindowOpenHandler((details) => { - setTimeout(() => resolve(details)); - return { action: 'deny' }; + 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('Index page'); + break; + case '/child': + response.statusCode = 200; + response.end('Child page'); + break; + default: + throw new Error(`Unsupported endpoint: ${request.url}`); + } }); - browserWindow.webContents.executeJavaScript(`window.open('${testUrl}', '${testFrameName}', '${testFeatures}') && true`); + url = (await listen(server)).url; }); - const { url, frameName, features, disposition, referrer } = details; - expect(url).to.equal(testUrl); - expect(frameName).to.equal(testFrameName); - expect(features).to.equal(testFeatures); - expect(disposition).to.equal('new-window'); - expect(referrer).to.deep.equal({ - policy: 'strict-origin-when-cross-origin', - url: '' + + after(() => { + server.close(); }); - }); - it('includes post body', async () => { - const details = await new Promise(resolve => { - browserWindow.webContents.setWindowOpenHandler((details) => { - setTimeout(() => resolve(details)); - return { action: 'deny' }; - }); - - browserWindow.webContents.loadURL(`data:text/html,${encodeURIComponent(` -
- -
- - `)}`); - }); - const { url, frameName, features, disposition, referrer, postBody } = details; - expect(url).to.equal('http://example.com/'); - expect(frameName).to.equal(''); - expect(features).to.deep.equal(''); - expect(disposition).to.equal('foreground-tab'); - expect(referrer).to.deep.equal({ - policy: 'strict-origin-when-cross-origin', - url: '' - }); - expect(postBody).to.deep.equal({ - contentType: 'application/x-www-form-urlencoded', - data: [{ - type: 'rawData', - bytes: Buffer.from('key=value') - }] + beforeEach(async () => { + browserWindow = new BrowserWindow({ show: false }); + await browserWindow.loadURL(`${url}/index`); }); - }); - it('does fire window creation events if an override returns action: allow', async () => { - browserWindow.webContents.setWindowOpenHandler(() => ({ action: 'allow' })); + afterEach(closeAllWindows); + + it('throws error when created window uses invalid webcontents', async () => { + const listeners = process.listeners('uncaughtException'); + process.removeAllListeners('uncaughtException'); + const uncaughtExceptionEmitted = new Promise((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; + } + }; + }); - setImmediate(() => { browserWindow.webContents.executeJavaScript("window.open('about:blank', '', 'show=no') && true"); + + await uncaughtExceptionEmitted; }); - await once(browserWindow.webContents, 'did-create-window'); - }); + it('spawns browser window when createWindow is provided', async () => { + const browserWindowTitle = 'Child browser window'; + + const childWindow = await new Promise(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"); + }); - it('can change webPreferences of child windows', async () => { - browserWindow.webContents.setWindowOpenHandler(() => ({ action: 'allow', overrideBrowserWindowOptions: { webPreferences: { defaultFontSize: 30 } } })); + expect(childWindow.title).to.equal(browserWindowTitle); + }); - const didCreateWindow = once(browserWindow.webContents, 'did-create-window') as Promise<[BrowserWindow, Electron.DidCreateWindowDetails]>; - browserWindow.webContents.executeJavaScript("window.open('about:blank', '', 'show=no') && true"); - const [childWindow] = await didCreateWindow; + it('spawns browser window with overriden options', async () => { + const childWindow = await new Promise(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"); + }); - await childWindow.webContents.executeJavaScript("document.write('hello')"); - const size = await childWindow.webContents.executeJavaScript("getComputedStyle(document.querySelector('body')).fontSize"); - expect(size).to.equal('30px'); - }); + const size = childWindow.getSize(); + expect(size[0]).to.equal(640); + expect(size[1]).to.equal(480); + }); - it('does not hang parent window when denying window.open', async () => { - browserWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); - browserWindow.webContents.executeJavaScript("window.open('https://127.0.0.1')"); - expect(await browserWindow.webContents.executeJavaScript('42')).to.equal(42); - }); + it('spawns browser window with access to opener property', async () => { + const childWindow = await new Promise(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(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`); + }); - // Linux and arm64 platforms (WOA and macOS) do not return any capture sources - ifit(process.platform === 'darwin' && process.arch === 'x64')('should not make child window background transparent', async () => { - browserWindow.webContents.setWindowOpenHandler(() => ({ action: 'allow' })); - const didCreateWindow = once(browserWindow.webContents, 'did-create-window'); - browserWindow.webContents.executeJavaScript("window.open('about:blank') && true"); - const [childWindow] = await didCreateWindow; - const display = screen.getPrimaryDisplay(); - childWindow.setBounds(display.bounds); - await childWindow.webContents.executeJavaScript("const meta = document.createElement('meta'); meta.name = 'color-scheme'; meta.content = 'dark'; document.head.appendChild(meta); true;"); - await setTimeoutAsync(1000); - const screenCapture = await ScreenCapture.createForDisplay(display); - // color-scheme is set to dark so background should not be white - await screenCapture.expectColorAtCenterDoesNotMatch(HexColors.WHITE); + 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.'); + }); }); }); diff --git a/typings/internal-electron.d.ts b/typings/internal-electron.d.ts index c68b9bf4d4ee3..6907f0712e859 100644 --- a/typings/internal-electron.d.ts +++ b/typings/internal-electron.d.ts @@ -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 & Pick): void; _send(internal: boolean, channel: string, args: any): boolean; _sendInternal(channel: string, ...args: any[]): void; @@ -112,6 +112,8 @@ declare namespace Electron { embedder?: Electron.WebContents; type?: 'backgroundPage' | 'window' | 'browserView' | 'remote' | 'webview' | 'offscreen'; } + + type CreateWindowFunction = (options: BrowserWindowConstructorOptions) => WebContents; interface Menu { _init(): void;