diff --git a/package.json b/package.json index 4ca93a3bfe..1bc25e3607 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@electron/github-app-auth": "^3.2.0", "@electron/lint-roller": "^3.2.0", "@electron/typescript-definitions": "^9.1.5", + "@hurdlegroup/robotjs": "^0.12.3", "@octokit/rest": "^20.1.2", "@primer/octicons": "^10.0.0", "@types/minimist": "^1.2.5", diff --git a/spec/api-browser-window-spec.ts b/spec/api-browser-window-spec.ts index 73d31312a0..6e228df49d 100755 --- a/spec/api-browser-window-spec.ts +++ b/spec/api-browser-window-spec.ts @@ -7034,119 +7034,4 @@ describe('BrowserWindow module', () => { await screenCapture.expectColorAtCenterMatches(HexColors.BLUE); }); }); - - describe('draggable regions', () => { - afterEach(closeAllWindows); - - ifit(hasCapturableScreen())('should allow the window to be dragged when enabled', async () => { - // FIXME: nut-js has been removed from npm; we need to find a replacement - // WOA fails to load libnut so we're using require to defer loading only - // on supported platforms. - // "@nut-tree\libnut-win32\build\Release\libnut.node is not a valid Win32 application." - // @ts-ignore: nut-js is an optional dependency so it may not be installed - const { mouse, straightTo, centerOf, Region, Button } = require('@nut-tree/nut-js') as typeof import('@nut-tree/nut-js'); - - const display = screen.getPrimaryDisplay(); - - const w = new BrowserWindow({ - x: 0, - y: 0, - width: display.bounds.width / 2, - height: display.bounds.height / 2, - frame: false, - titleBarStyle: 'hidden' - }); - - const overlayHTML = path.join(__dirname, 'fixtures', 'pages', 'overlay.html'); - w.loadFile(overlayHTML); - await once(w, 'ready-to-show'); - - const winBounds = w.getBounds(); - const titleBarHeight = 30; - const titleBarRegion = new Region(winBounds.x, winBounds.y, winBounds.width, titleBarHeight); - const screenRegion = new Region(display.bounds.x, display.bounds.y, display.bounds.width, display.bounds.height); - - const startPos = w.getPosition(); - - await mouse.setPosition(await centerOf(titleBarRegion)); - await mouse.pressButton(Button.LEFT); - await mouse.drag(straightTo(centerOf(screenRegion))); - - // Wait for move to complete - await Promise.race([ - once(w, 'move'), - setTimeout(100) // fallback for possible race condition - ]); - - const endPos = w.getPosition(); - - expect(startPos).to.not.deep.equal(endPos); - }); - - ifit(hasCapturableScreen())('should allow the window to be dragged when no WCO and --webkit-app-region: drag enabled', async () => { - // FIXME: nut-js has been removed from npm; we need to find a replacement - // @ts-ignore: nut-js is an optional dependency so it may not be installed - const { mouse, straightTo, centerOf, Region, Button } = require('@nut-tree/nut-js') as typeof import('@nut-tree/nut-js'); - - const display = screen.getPrimaryDisplay(); - const w = new BrowserWindow({ - x: 0, - y: 0, - width: display.bounds.width / 2, - height: display.bounds.height / 2, - frame: false - }); - - const basePageHTML = path.join(__dirname, 'fixtures', 'pages', 'base-page.html'); - w.loadFile(basePageHTML); - await once(w, 'ready-to-show'); - - await w.webContents.executeJavaScript(` - const style = document.createElement('style'); - style.innerHTML = \` - #titlebar { - - background-color: red; - height: 30px; - width: 100%; - -webkit-user-select: none; - -webkit-app-region: drag; - position: fixed; - top: 0; - left: 0; - z-index: 1000000000000; - } - \`; - - const titleBar = document.createElement('title-bar'); - titleBar.id = 'titlebar'; - titleBar.textContent = 'test-titlebar'; - - document.body.append(style); - document.body.append(titleBar); - `); - // allow time for titlebar to finish loading - await setTimeout(2000); - - const winBounds = w.getBounds(); - const titleBarHeight = 30; - const titleBarRegion = new Region(winBounds.x, winBounds.y, winBounds.width, titleBarHeight); - const screenRegion = new Region(display.bounds.x, display.bounds.y, display.bounds.width, display.bounds.height); - - const startPos = w.getPosition(); - await mouse.setPosition(await centerOf(titleBarRegion)); - await mouse.pressButton(Button.LEFT); - await mouse.drag(straightTo(centerOf(screenRegion))); - - // Wait for move to complete - await Promise.race([ - once(w, 'move'), - setTimeout(1000) // fallback for possible race condition - ]); - - const endPos = w.getPosition(); - - expect(startPos).to.not.deep.equal(endPos); - }); - }); }); diff --git a/spec/drag-region-spec.ts b/spec/drag-region-spec.ts new file mode 100644 index 0000000000..ed2ef8cb25 --- /dev/null +++ b/spec/drag-region-spec.ts @@ -0,0 +1,267 @@ +import { BrowserWindow, screen } from 'electron/main'; + +import { expect } from 'chai'; + +import { once } from 'node:events'; +import * as path from 'node:path'; +import { setTimeout } from 'node:timers/promises'; +import { pathToFileURL } from 'node:url'; + +import { hasCapturableScreen } from './lib/screen-helpers'; +import { closeAllWindows } from './lib/window-helpers'; + +const display = screen.getPrimaryDisplay(); + +const fixtures = path.resolve(__dirname, 'fixtures'); + +// Try to load robotjs +let robot: typeof import('@hurdlegroup/robotjs'); +try { + robot = require('@hurdlegroup/robotjs'); +} catch { + // ignore. tests are skipped below if this is undefined. +} + +const draggablePageURL = pathToFileURL( + path.join(fixtures, 'pages', 'draggable-page.html') +); +const iframePageURL = pathToFileURL( + path.join(fixtures, 'pages', 'iframe.html') +); +const webviewPageURL = pathToFileURL( + path.join(fixtures, 'pages', 'webview.html') +); + +const testWindowOpts: Electron.BrowserWindowConstructorOptions = { + x: 0, + y: 0, + width: Math.round(display.bounds.width / 2), + height: Math.round(display.bounds.height / 2), + frame: false +}; + +const center = (rect: Electron.Rectangle): Electron.Point => ({ + x: Math.round(rect.x + rect.width / 2), + y: Math.round(rect.y + rect.height / 2) +}); + +const performDrag = async ( + w: BrowserWindow +): Promise<{ + start: [number, number]; + end: [number, number]; +}> => { + const winBounds = w.getBounds(); + const winCenter = center(winBounds); + const screenCenter = center(display.bounds); + + const start = w.getPosition() as [number, number]; + const moved = once(w, 'move'); + + // Extra events based on research from https://github.com/octalmage/robotjs/issues/389 + robot.moveMouse(winCenter.x, winCenter.y); + robot.mouseToggle('down', 'left'); + robot.moveMouse(winCenter.x + 2, winCenter.y + 2); // extra + await setTimeout(200); // extra + robot.dragMouse(screenCenter.x, screenCenter.y); + robot.mouseToggle('up', 'left'); + + await Promise.race([moved, setTimeout(1000)]); + + const end = w.getPosition() as [number, number]; + return { start, end }; +}; + +const loadDraggableSubframe = async (w: BrowserWindow): Promise => { + let selector: string; + let eventName: string; + if (w.getURL() === iframePageURL.href) { + selector = 'iframe'; + eventName = 'load'; + } else if (w.getURL() === webviewPageURL.href) { + selector = 'webview'; + eventName = 'did-finish-load'; + } else { + throw new Error('Unexpected page loaded'); + } + + await w.webContents.executeJavaScript(` + new Promise((resolve) => { + const frame = document.querySelector('${selector}'); + frame.addEventListener( + '${eventName}', + () => resolve(), + { once: true } + ); + frame.src = '${draggablePageURL.href}'; + }) + `); +}; + +describe('draggable regions', function () { + before(async function () { + if (!robot || !robot.moveMouse || !hasCapturableScreen()) { + this.skip(); + } + + // The first window may not properly receive events due to UI transitions or + // focus management. To mitigate this, warm up with a test run. + const w = new BrowserWindow(testWindowOpts); + await w.loadURL(draggablePageURL.href); + await performDrag(w); + w.destroy(); + }); + + afterEach(closeAllWindows); + + describe('main window', () => { + let w: BrowserWindow; + + beforeEach(() => { + w = new BrowserWindow(testWindowOpts); + }); + + it('drags with app-region: drag', async () => { + await w.loadURL(draggablePageURL.href); + + const { start, end } = await performDrag(w); + + expect(start).to.not.deep.equal(end); + }); + + it('does not drag when app-region: no-drag overlaps drag region', async () => { + const noDragURL = new URL(draggablePageURL.href); + noDragURL.searchParams.set('no-drag', '1'); + await w.loadURL(noDragURL.href); + + const { start, end } = await performDrag(w); + + expect(start).to.deep.equal(end); + }); + + it('drags after navigation', async () => { + await w.loadFile(path.join(fixtures, 'pages', 'base-page.html')); + await w.loadURL(draggablePageURL.href); + + const { start, end } = await performDrag(w); + + expect(start).to.not.deep.equal(end); + }); + + it('drags after in-page navigation', async () => { + await w.loadURL(draggablePageURL.href); + + const didNavigate = once(w.webContents, 'did-navigate-in-page'); + await w.webContents.executeJavaScript(` + window.history.pushState({}, '', '/new-path'); + `); + await didNavigate; + + const { start, end } = await performDrag(w); + + expect(start).to.not.deep.equal(end); + }); + }); + + describe('child windows (window.open)', () => { + let childWindow: BrowserWindow; + + beforeEach(async () => { + const parentWindow = new BrowserWindow({ show: false }); + await parentWindow.loadFile( + path.join(fixtures, 'pages', 'base-page.html') + ); + + parentWindow.webContents.setWindowOpenHandler(() => ({ + action: 'allow', + overrideBrowserWindowOptions: testWindowOpts + })); + + const newBrowserWindow = once(parentWindow.webContents, 'did-create-window'); + + await parentWindow.webContents.executeJavaScript( + `void window.open('${draggablePageURL.href}', '_blank');` + ); + + [childWindow] = await newBrowserWindow; + await once(childWindow, 'ready-to-show'); + }); + + it('drags with app-region: drag', async () => { + const { start, end } = await performDrag(childWindow); + + expect(start).to.not.deep.equal(end); + }); + + it('drags after navigation', async () => { + await childWindow.loadURL(draggablePageURL.href); + + const { start, end } = await performDrag(childWindow); + + expect(start).to.not.deep.equal(end); + }); + }); + + for (const frameType of ['webview', 'iframe'] as const) { + // FIXME: this behavior is broken before the tests were added + // See: https://github.com/electron/electron/issues/49256 + describe.skip(`child frames (${frameType})`, () => { + const subframePageURL = frameType === 'webview' ? webviewPageURL : iframePageURL; + let w: BrowserWindow; + + beforeEach(() => { + w = new BrowserWindow({ + ...testWindowOpts, + webPreferences: frameType === 'webview' + ? { + webviewTag: true + } + : {} + }); + }); + + it('drags in subframe with app-region: drag', async () => { + await w.loadURL(subframePageURL.href); + await loadDraggableSubframe(w); + + const { start, end } = await performDrag(w); + + expect(start).to.not.deep.equal(end); + }); + + it('drags after subframe navigation', async () => { + await w.loadURL(subframePageURL.href); + await loadDraggableSubframe(w); + await loadDraggableSubframe(w); + + const { start, end } = await performDrag(w); + + expect(start).to.not.deep.equal(end); + }); + + it('does not drag after host page navigation without draggable region', async () => { + await w.loadURL(subframePageURL.href); + await loadDraggableSubframe(w); + + await w.loadFile( + path.join(fixtures, 'pages', 'base-page.html') + ); + + const { start, end } = await performDrag(w); + + expect(start).to.deep.equal(end); + }); + + it('drags after host page navigation', async () => { + await w.loadURL(subframePageURL.href); + await loadDraggableSubframe(w); + await w.loadURL(subframePageURL.href); + await loadDraggableSubframe(w); + + const { start, end } = await performDrag(w); + + expect(start).to.not.deep.equal(end); + }); + }); + } +}); diff --git a/spec/fixtures/pages/draggable-page.html b/spec/fixtures/pages/draggable-page.html index 7b106e5cea..093d003bec 100644 --- a/spec/fixtures/pages/draggable-page.html +++ b/spec/fixtures/pages/draggable-page.html @@ -1,22 +1,36 @@ - + + + + Draggable Page + - - - -
- - + display: flex; + justify-content: center; + align-items: center; + } + #no-drag { + width: 100px; + height: 100px; + background-color: orangered; + app-region: no-drag; + } + + + + + diff --git a/spec/fixtures/pages/iframe.html b/spec/fixtures/pages/iframe.html new file mode 100644 index 0000000000..47b59ce008 --- /dev/null +++ b/spec/fixtures/pages/iframe.html @@ -0,0 +1,27 @@ + + + + + iframe Host + + + + + + diff --git a/spec/fixtures/pages/webview.html b/spec/fixtures/pages/webview.html new file mode 100644 index 0000000000..403672fffa --- /dev/null +++ b/spec/fixtures/pages/webview.html @@ -0,0 +1,26 @@ + + + + + webview Host + + + + + + diff --git a/yarn.lock b/yarn.lock index 6b2438c22d..0fe5852da6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -594,6 +594,7 @@ __metadata: "@electron/github-app-auth": "npm:^3.2.0" "@electron/lint-roller": "npm:^3.2.0" "@electron/typescript-definitions": "npm:^9.1.5" + "@hurdlegroup/robotjs": "npm:^0.12.3" "@octokit/rest": "npm:^20.1.2" "@primer/octicons": "npm:^10.0.0" "@types/minimist": "npm:^1.2.5" @@ -1091,6 +1092,17 @@ __metadata: languageName: node linkType: hard +"@hurdlegroup/robotjs@npm:^0.12.3": + version: 0.12.3 + resolution: "@hurdlegroup/robotjs@npm:0.12.3" + dependencies: + node-addon-api: "npm:*" + node-gyp: "npm:latest" + node-gyp-build: "npm:^4.8.1" + checksum: 10c0/6d5310320187bacfccaa7f636315b8d5d5a4ecdc586b7d3467b1def22ee31428d6b39f06dadb240e72d0ac4548068be1f896f2501dd7115d0100820a0640c562 + languageName: node + linkType: hard + "@inquirer/external-editor@npm:^1.0.0": version: 1.0.2 resolution: "@inquirer/external-editor@npm:1.0.2" @@ -9975,6 +9987,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:*": + version: 8.5.0 + resolution: "node-addon-api@npm:8.5.0" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/e4de0b4e70998fed7ef41933946f60565fc3a17cb83b7d626a0c0bb1f734cf7852e0e596f12681e7c8ed424163ee3cdbb4f0abaa9cc269d03f48834c263ba162 + languageName: node + linkType: hard + "node-addon-api@npm:8.0.0": version: 8.0.0 resolution: "node-addon-api@npm:8.0.0" @@ -10012,6 +10033,17 @@ __metadata: languageName: node linkType: hard +"node-gyp-build@npm:^4.8.1": + version: 4.8.4 + resolution: "node-gyp-build@npm:4.8.4" + bin: + node-gyp-build: bin.js + node-gyp-build-optional: optional.js + node-gyp-build-test: build-test.js + checksum: 10c0/444e189907ece2081fe60e75368784f7782cfddb554b60123743dfb89509df89f1f29c03bbfa16b3a3e0be3f48799a4783f487da6203245fa5bed239ba7407e1 + languageName: node + linkType: hard + "node-gyp@npm:^11.4.2, node-gyp@npm:latest": version: 11.5.0 resolution: "node-gyp@npm:11.5.0"