diff --git a/installer/win/WixSharpInstaller/Symphony.cs b/installer/win/WixSharpInstaller/Symphony.cs index 1444c9d5..a977db98 100644 --- a/installer/win/WixSharpInstaller/Symphony.cs +++ b/installer/win/WixSharpInstaller/Symphony.cs @@ -119,6 +119,9 @@ class Script new Dir(@"ffi-napi\build\Release", new File(@"..\..\..\dist\win-unpacked\resources\app.asar.unpacked\node_modules\ffi-napi\build\Release\ffi_bindings.node") ), + new Dir(@"ffi-napi\node_modules\ref-napi\prebuilds\win32-x64", + new File(@"..\..\..\dist\win-unpacked\resources\app.asar.unpacked\node_modules\ffi-napi\node_modules\ref-napi\prebuilds\win32-x64\electron.napi.node") + ), new Dir(@"ref-napi\build\Release", new File(@"..\..\..\dist\win-unpacked\resources\app.asar.unpacked\node_modules\ref-napi\build\Release\binding.node") ), diff --git a/spec/hwndHandler.spec.ts b/spec/hwndHandler.spec.ts new file mode 100644 index 00000000..cd32beec --- /dev/null +++ b/spec/hwndHandler.spec.ts @@ -0,0 +1,159 @@ +import { getContentWindowHandle } from '../src/app/hwnd-handler'; + +jest.mock('../src/common/env', () => { + return { + isWindowsOS: true, + isLinux: false, + isMac: false, + }; +}); + +const mockFindWindowExA = jest.fn(); +const mockGetWindowRect = jest.fn(); + +jest.mock('ffi-napi', () => { + return { + Library: jest.fn(() => { + return { + FindWindowExA: mockFindWindowExA, + GetWindowRect: mockGetWindowRect, + }; + }), + }; +}); + +function writeRect( + buffer: Buffer, + left: number, + top: number, + right: number, + bottom: number, +) { + buffer.writeInt32LE(left, 0); + buffer.writeInt32LE(top, 4); + buffer.writeInt32LE(right, 8); + buffer.writeInt32LE(bottom, 12); +} + +describe('hwnd handler', () => { + beforeEach(() => { + jest.clearAllMocks().resetModules(); + + mockGetWindowRect.mockImplementation((_hwnd: bigint, _rect: Buffer) => { + return 0; + }); + mockFindWindowExA.mockImplementation(() => { + return 0; + }); + }); + + it('not using windows', () => { + jest.mock('../src/common/env', () => { + return { + isWindowsOS: false, + isLinux: true, + isMac: false, + }; + }); + const { getContentWindowHandle } = require('../src/app/hwnd-handler'); + const parent = Buffer.from('hwnd', 'utf8'); + const hwnd = getContentWindowHandle(parent); + expect(hwnd).toBe(parent); + }); + + it('unexpected buffer size', () => { + const parent = Buffer.from('hwnd', 'utf8'); + const hwnd = getContentWindowHandle(parent); + expect(hwnd).toBe(parent); + }); + + it('no rect found for parent window', () => { + const parent = Buffer.from('validhwnd', 'utf8'); + const hwnd = getContentWindowHandle(parent); + expect(hwnd).toBe(parent); + }); + + it('no child window found', () => { + mockGetWindowRect.mockImplementationOnce((_hwnd, rect) => { + writeRect(rect, 10, 10, 100, 100); + return 1; + }); + + const parent = Buffer.from('validhwnd', 'utf8'); + const hwnd = getContentWindowHandle(parent); + + expect(mockGetWindowRect).toBeCalledTimes(1); + expect(mockFindWindowExA).toBeCalledTimes(1); + expect(hwnd).toBe(parent); + }); + + it('matching child window found', () => { + mockGetWindowRect.mockImplementationOnce((_hwnd, rect) => { + writeRect(rect, 10, 10, 100, 100); + return 1; + }); + mockGetWindowRect.mockImplementationOnce((_hwnd, rect) => { + writeRect(rect, 10, 20, 100, 100); + return 1; + }); + mockFindWindowExA.mockImplementationOnce(() => { + return 4711; + }); + + const parent = Buffer.from('validhwnd', 'utf8'); + const hwnd = getContentWindowHandle(parent); + + expect(mockGetWindowRect).toBeCalledTimes(2); + expect(mockFindWindowExA).toBeCalledTimes(1); + expect(hwnd.readInt32LE(0)).toBe(4711); + }); + + it('matching child window found second', () => { + mockGetWindowRect.mockImplementationOnce((_hwnd, rect) => { + writeRect(rect, 10, 10, 100, 100); + return 1; + }); + mockGetWindowRect.mockImplementationOnce((_hwnd, rect) => { + writeRect(rect, 100, 10, 100, 100); + return 1; + }); + mockGetWindowRect.mockImplementationOnce((_hwnd, rect) => { + writeRect(rect, 10, 20, 100, 100); + return 1; + }); + mockFindWindowExA.mockImplementationOnce(() => { + return 4711; + }); + mockFindWindowExA.mockImplementationOnce(() => { + return 42; + }); + + const parent = Buffer.from('validhwnd', 'utf8'); + const hwnd = getContentWindowHandle(parent); + + expect(mockGetWindowRect).toBeCalledTimes(3); + expect(mockFindWindowExA).toBeCalledTimes(2); + expect(hwnd.readInt32LE(0)).toBe(42); + }); + + it('no matching child window found', () => { + mockGetWindowRect.mockImplementationOnce((_hwnd, rect) => { + writeRect(rect, 10, 10, 100, 100); + return 1; + }); + mockGetWindowRect.mockImplementationOnce((_hwnd, rect) => { + writeRect(rect, 10, 10, 100, 100); + return 1; + }); + mockFindWindowExA.mockImplementationOnce(() => { + return 4711; + }); + + const parent = Buffer.from('validhwnd', 'utf8'); + const hwnd = getContentWindowHandle(parent); + + expect(mockGetWindowRect).toBeCalledTimes(2); + expect(mockFindWindowExA).toBeCalledTimes(2); + expect(hwnd).toBe(parent); + }); +}); diff --git a/spec/mainApiHandler.spec.ts b/spec/mainApiHandler.spec.ts index 367fd232..a3d5c207 100644 --- a/spec/mainApiHandler.spec.ts +++ b/spec/mainApiHandler.spec.ts @@ -58,6 +58,7 @@ jest.mock('../src/app/window-handler', () => { jest.mock('../src/app/window-utils', () => { return { downloadManagerAction: jest.fn(), + getWindowByName: jest.fn(), isValidWindow: jest.fn(() => true), sanitize: jest.fn(), setDataUrl: jest.fn(), @@ -471,17 +472,35 @@ describe('main api handler', () => { }); it('should call `getNativeWindowHandle` correctly', () => { - const fromWebContentsMocked = { - getNativeWindowHandle: jest.fn(), + const windows = { + main: { + getNativeWindowHandle: jest.fn(), + }, + popout1: { + getNativeWindowHandle: jest.fn(), + }, + popout2: { + getNativeWindowHandle: jest.fn(), + }, }; - jest.spyOn(BrowserWindow, 'fromWebContents').mockImplementation(() => { - return fromWebContentsMocked; - }); - const value = { + jest + .spyOn(utils, 'getWindowByName') + .mockImplementation((windowName: string) => { + return windows[windowName]; + }); + + ipcMain.send(apiName.symphonyApi, { cmd: apiCmds.getNativeWindowHandle, - }; - ipcMain.send(apiName.symphonyApi, value); - expect(fromWebContentsMocked.getNativeWindowHandle).toBeCalledTimes(1); + windowName: 'main', + }); + expect(windows['main'].getNativeWindowHandle).toBeCalledTimes(1); + + ipcMain.send(apiName.symphonyApi, { + cmd: apiCmds.getNativeWindowHandle, + windowName: 'popout1', + }); + expect(windows['popout1'].getNativeWindowHandle).toBeCalledTimes(1); + expect(windows['popout2'].getNativeWindowHandle).toBeCalledTimes(0); }); }); }); diff --git a/src/app/hwnd-handler.ts b/src/app/hwnd-handler.ts new file mode 100644 index 00000000..7ec5d965 --- /dev/null +++ b/src/app/hwnd-handler.ts @@ -0,0 +1,70 @@ +import { Library } from 'ffi-napi'; +import { isWindowsOS } from '../common/env'; + +/** + * Translate the nativeWindowHandle of an Electron BrowserWindow to the handle + * of the window where the main content is hosted. On Windows, Chrome uses a separate + * window handle for the title bar and the main content has a different window handle + * that is positioned below the title bar. + * @returns translated window handle, or original handle if no applicable translation found + */ +export const getContentWindowHandle = (nativeWindowHandle: Buffer): Buffer => { + if (!isWindowsOS) { + return nativeWindowHandle; + } + if (nativeWindowHandle.byteLength < 8) { + return nativeWindowHandle; + } + + const user32 = Library('user32.dll', { + FindWindowExA: ['int', ['int', 'int', 'string', 'string']], + GetWindowRect: ['int', ['int', 'pointer']], + }); + + const getWindowRect = (hwnd: bigint) => { + const rect = Buffer.alloc(16); + + const ret = user32.GetWindowRect(hwnd.toString(), rect); + if (ret) { + return { + left: rect.readInt32LE(0), + top: rect.readInt32LE(4), + right: rect.readInt32LE(8), + bottom: rect.readInt32LE(12), + }; + } else { + return undefined; + } + }; + + const parentHwnd = nativeWindowHandle.readBigUInt64LE(); + const parentRect = getWindowRect(parentHwnd); + if (!parentRect) { + return nativeWindowHandle; + } + + let child = user32.FindWindowExA( + parentHwnd.toString(), + 0, + 'Chrome_RenderWidgetHostHWND', + null, + ); + while (child !== 0) { + const rect = getWindowRect(child); + + // The candidate child window is located at the same x position as the parent window, but + // has a higher y position (due to the window title frame at the top). + if (rect && parentRect.left === rect.left && parentRect.top < rect.top) { + const ret = Buffer.alloc(8); + ret.writeBigUInt64LE(BigInt(child)); + return ret; + } + child = user32.FindWindowExA( + parentHwnd.toString(), + child, + 'Chrome_RenderWidgetHostHWND', + null, + ); + } + return nativeWindowHandle; +}; diff --git a/src/app/main-api-handler.ts b/src/app/main-api-handler.ts index 736c0dd0..77bce061 100644 --- a/src/app/main-api-handler.ts +++ b/src/app/main-api-handler.ts @@ -21,6 +21,7 @@ import appStateHandler from './app-state-handler'; import { getCitrixMediaRedirectionStatus } from './citrix-handler'; import { CloudConfigDataTypes, config, ICloudConfig } from './config-handler'; import { downloadHandler } from './download-handler'; +import { getContentWindowHandle } from './hwnd-handler'; import { mainEvents } from './main-event-handler'; import { memoryMonitor } from './memory-monitor'; import notificationHelper from './notifications/notification-helper'; @@ -31,6 +32,7 @@ import { activate, handleKeyPress } from './window-actions'; import { ICustomBrowserWindow, windowHandler } from './window-handler'; import { downloadManagerAction, + getWindowByName, isValidView, isValidWindow, sanitize, @@ -416,11 +418,10 @@ ipcMain.handle( thumbnailSize, }); case apiCmds.getNativeWindowHandle: - const browserWin = BrowserWindow.fromWebContents( - event.sender, - ) as ICustomBrowserWindow; + const browserWin = getWindowByName(arg.windowName); if (browserWin && windowExists(browserWin)) { - return browserWin.getNativeWindowHandle(); + const windowHandle = browserWin.getNativeWindowHandle(); + return getContentWindowHandle(windowHandle); } break; case apiCmds.getCitrixMediaRedirectionStatus: diff --git a/src/demo/index.html b/src/demo/index.html index eae9f49f..253e2bac 100644 --- a/src/demo/index.html +++ b/src/demo/index.html @@ -279,7 +279,10 @@
Native Window Handle:
- + +