From 3048ddbaaa77bf2ade26d78e6e2e893bb42a2aad Mon Sep 17 00:00:00 2001 From: Vladimir Poluliashenko Date: Thu, 23 Jul 2026 19:54:20 +0100 Subject: [PATCH] feat(screensharing): list and share minimized windows on Windows On Windows, Chromium/WebRTC omits minimized (iconic) windows from desktopCapturer.getSources() because they are not rendered and cannot produce frames. They are therefore missing from the "Choose what to share" picker, which is especially painful for full-screen Remote Desktop windows that have to be minimized to reach Talk. Enumerate top-level windows natively via user32 (koffi FFI) and add the minimized ones that desktopCapturer omits, shown with a "Minimized" badge, the window's own icon and no live preview. When such a window is selected, restore it (ShowWindowAsync + SetForegroundWindow) so WebRTC can capture it, then start sharing - the approach suggested in #1788. The icon is read from the window itself (WM_GETICON / class icon) and converted from its HICON via GDI, matching the icon Windows shows in the taskbar and the one desktopCapturer provides for non-minimized windows. koffi is a prebuilt, MIT-licensed FFI module. It is loaded lazily and only on Windows, kept out of the webpack bundle and packaged for win32 only via a packageAfterCopy hook, with its .node unpacked from the asar archive. Signed-off-by: Vladimir Poluliashenko --- forge.config.js | 33 +- package-lock.json | 16 + package.json | 1 + src/app/nativeWindows.ts | 368 ++++++++++++++++++ src/main.js | 140 ++++++- src/preload.js | 14 +- .../DesktopMediaSourceDialog.vue | 31 +- .../DesktopMediaSourcePreview.vue | 29 +- .../screensharing/screensharing.types.ts | 6 + webpack.main.config.js | 7 + 10 files changed, 628 insertions(+), 17 deletions(-) create mode 100644 src/app/nativeWindows.ts diff --git a/forge.config.js b/forge.config.js index cc8a6ebce..4ad99a123 100644 --- a/forge.config.js +++ b/forge.config.js @@ -173,6 +173,34 @@ module.exports = { } }, + packageAfterCopy(_forgeConfig, buildPath, _electronVersion, platform, arch) { + // koffi is only loaded on Windows (see src/app/nativeWindows.ts), so there is no + // reason to ship the native module on other platforms. + if (platform !== 'win32') { + return + } + + // koffi (native FFI) is kept out of the webpack bundle (see webpack.main.config.js externals), + // so webpack does not copy it into the package. Copy it into the packaged app's node_modules + // so require('koffi') resolves at runtime. koffi ships a prebuilt binary for every platform + // under build/koffi/_/koffi.node — copy only the one we are packaging to keep + // the app lean. The .node is unpacked from the asar archive via packagerConfig.asar.unpack. + const koffiSource = path.join(__dirname, 'node_modules', 'koffi') + const koffiTarget = path.join(buildPath, 'node_modules', 'koffi') + const prebuiltsPrefix = `build${path.sep}koffi${path.sep}` + const keepTriplet = `${platform}_${arch}` + fs.cpSync(koffiSource, koffiTarget, { + recursive: true, + filter: (source) => { + const relative = path.relative(koffiSource, source) + if (relative.startsWith(prebuiltsPrefix)) { + return relative.slice(prebuiltsPrefix.length).split(path.sep)[0] === keepTriplet + } + return true + }, + }) + }, + postStart() { console.log(`Started with built-in Nextcloud Talk v${talkPackageJson.version} on path: ${TALK_PATH}`) }, @@ -207,7 +235,10 @@ module.exports = { name: BUILD_CONFIG.applicationName, icon: path.join(__dirname, './img/icons/icon'), appCopyright: BUILD_CONFIG.copyright, - asar: true, + // Unpack the koffi native FFI module (its prebuilt .node cannot run from inside the asar archive) + asar: { + unpack: '**/node_modules/koffi/**', + }, // Windows win32metadata: { diff --git a/package-lock.json b/package-lock.json index 306ade85a..4d8279fe4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "core-js": "^3.49.0", "electron-squirrel-startup": "^1.0.1", "howler": "^2.2.4", + "koffi": "^2.16.3", "mri": "^1.2.0", "pinia": "^4.0.2", "semver": "^7.8.5", @@ -10429,6 +10430,16 @@ "node": ">=14.14.0" } }, + "node_modules/koffi": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.3.tgz", + "integrity": "sha512-E9y1AsgYGlaxMhcZzHr8y96QF2U5XzA12GGVAfbWqIubTwPNMXQarfBzePNXHe0xtIEtNd6ifAv3GAKYGUeBAQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/launch-editor": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", @@ -23614,6 +23625,11 @@ "integrity": "sha512-1zGZ9MF9H22UnkpVeuaGKOjfA2t6WrfdrJmGjy16ykcjnKQDmHVX+KI477rpbGevz/5FD4MC3xf1oxylBgcaQw==", "dev": true }, + "koffi": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.3.tgz", + "integrity": "sha512-E9y1AsgYGlaxMhcZzHr8y96QF2U5XzA12GGVAfbWqIubTwPNMXQarfBzePNXHe0xtIEtNd6ifAv3GAKYGUeBAQ==" + }, "launch-editor": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", diff --git a/package.json b/package.json index 813255d69..987a6134b 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "core-js": "^3.49.0", "electron-squirrel-startup": "^1.0.1", "howler": "^2.2.4", + "koffi": "^2.16.3", "mri": "^1.2.0", "pinia": "^4.0.2", "semver": "^7.8.5", diff --git a/src/app/nativeWindows.ts b/src/app/nativeWindows.ts new file mode 100644 index 000000000..4b8bf923f --- /dev/null +++ b/src/app/nativeWindows.ts @@ -0,0 +1,368 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type * as Koffi from 'koffi' + +import { isWindows } from './system.utils.ts' + +/** + * A window icon as raw bitmap pixels. + */ +export type WindowIconBitmap = { + width: number + height: number + /** Raw BGRA pixels, top-down, straight (non-premultiplied) alpha */ + data: Buffer +} + +/** + * Information about a top-level native window (Windows only). + */ +export type NativeWindowInfo = { + /** Native window handle (HWND) as a number */ + hwnd: number + /** Window title */ + title: string + /** Whether the window is currently minimized (iconic) */ + minimized: boolean + /** The window's own icon (same one Windows shows in the taskbar), or null if it has none */ + icon: WindowIconBitmap | null +} + +// Win32 constants +const GW_OWNER = 4 +const GWL_EXSTYLE = -20 +const WS_EX_TOOLWINDOW = 0x00000080 +const DWMWA_CLOAKED = 14 +const SW_RESTORE = 9 +const WM_GETICON = 0x007f +const ICON_BIG = 1 +const ICON_SMALL2 = 2 +const GCLP_HICON = -14 +const GCLP_HICONSM = -34 +const BI_RGB = 0 +const DIB_RGB_COLORS = 0 +const SMTO_ABORTIFHUNG = 0x0002 +// WM_GETICON is a blocking SendMessage; use a short timeout so an unresponsive window +// can never freeze the enumeration (and with it the whole picker). +const WM_GETICON_TIMEOUT_MS = 200 + +/** + * Bound user32/gdi32/dwmapi functions, resolved lazily on first use. + */ +type NativeBindings = { + koffi: typeof Koffi + sizeofBITMAP: number + EnumWindowsProc: Koffi.IKoffiCType + EnumWindows: (proc: unknown, lparam: number) => boolean + IsWindowVisible: (hwnd: number) => boolean + IsIconic: (hwnd: number) => boolean + GetWindow: (hwnd: number, cmd: number) => number + GetWindowLongW: (hwnd: number, index: number) => number + GetWindowTextLengthW: (hwnd: number) => number + GetWindowTextW: (hwnd: number, buffer: Uint16Array, max: number) => number + DwmGetWindowAttribute: (hwnd: number, attribute: number, value: Int32Array, size: number) => number + ShowWindowAsync: (hwnd: number, cmd: number) => boolean + SetForegroundWindow: (hwnd: number) => boolean + SendMessageTimeoutW: (hwnd: number, msg: number, wparam: number, lparam: number, flags: number, timeout: number, result: BigUint64Array) => number + GetClassLongPtrW: (hwnd: number, index: number) => number + GetIconInfo: (hicon: number, info: Record) => boolean + GetObjectW: (handle: number, cb: number, obj: Record) => number + GetDC: (hwnd: number) => number + ReleaseDC: (hwnd: number, hdc: number) => number + GetDIBits: (hdc: number, hbmp: number, start: number, lines: number, bits: Uint8Array, bmi: Record, usage: number) => number + DeleteObject: (obj: number) => boolean +} + +let bindings: NativeBindings | null = null +let bindingsUnavailable = false + +/** + * Lazily load koffi and bind the required user32/gdi32/dwmapi functions. + * Returns null on non-Windows platforms or if the native module cannot be loaded, + * so callers can treat native access as a best-effort enhancement. + */ +function getBindings(): NativeBindings | null { + if (bindings) { + return bindings + } + if (bindingsUnavailable || !isWindows) { + return null + } + + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- koffi is a native module kept external from the bundle + const koffi = require('koffi') as typeof Koffi + const user32 = koffi.load('user32.dll') + const gdi32 = koffi.load('gdi32.dll') + const dwmapi = koffi.load('dwmapi.dll') + + koffi.struct('ICONINFO', { + fIcon: 'int', + xHotspot: 'uint32', + yHotspot: 'uint32', + hbmMask: 'uintptr_t', + hbmColor: 'uintptr_t', + }) + koffi.struct('BITMAP', { + bmType: 'int32', + bmWidth: 'int32', + bmHeight: 'int32', + bmWidthBytes: 'int32', + bmPlanes: 'uint16', + bmBitsPixel: 'uint16', + bmBits: 'uintptr_t', + }) + koffi.struct('BITMAPINFOHEADER', { + biSize: 'uint32', + biWidth: 'int32', + biHeight: 'int32', + biPlanes: 'uint16', + biBitCount: 'uint16', + biCompression: 'uint32', + biSizeImage: 'uint32', + biXPelsPerMeter: 'int32', + biYPelsPerMeter: 'int32', + biClrUsed: 'uint32', + biClrImportant: 'uint32', + }) + + // HWND and other handles are marshaled as pointer-sized integers (uintptr_t), + // which fit in a JS number for all real-world handles. + bindings = { + koffi, + sizeofBITMAP: koffi.sizeof('BITMAP'), + EnumWindowsProc: koffi.proto('bool __stdcall EnumWindowsProc(uintptr_t hwnd, intptr_t lparam)'), + EnumWindows: user32.func('bool __stdcall EnumWindows(void *proc, intptr_t lparam)'), + IsWindowVisible: user32.func('bool __stdcall IsWindowVisible(uintptr_t hwnd)'), + IsIconic: user32.func('bool __stdcall IsIconic(uintptr_t hwnd)'), + GetWindow: user32.func('uintptr_t __stdcall GetWindow(uintptr_t hwnd, uint cmd)'), + GetWindowLongW: user32.func('long __stdcall GetWindowLongW(uintptr_t hwnd, int index)'), + GetWindowTextLengthW: user32.func('int __stdcall GetWindowTextLengthW(uintptr_t hwnd)'), + GetWindowTextW: user32.func('int __stdcall GetWindowTextW(uintptr_t hwnd, _Out_ uint16_t *buffer, int max)'), + DwmGetWindowAttribute: dwmapi.func('int __stdcall DwmGetWindowAttribute(uintptr_t hwnd, uint attribute, _Out_ int *value, uint size)'), + ShowWindowAsync: user32.func('bool __stdcall ShowWindowAsync(uintptr_t hwnd, int cmd)'), + SetForegroundWindow: user32.func('bool __stdcall SetForegroundWindow(uintptr_t hwnd)'), + SendMessageTimeoutW: user32.func('uintptr_t __stdcall SendMessageTimeoutW(uintptr_t hwnd, uint msg, uintptr_t wparam, intptr_t lparam, uint flags, uint timeout, _Out_ uintptr_t *result)'), + GetClassLongPtrW: user32.func('uintptr_t __stdcall GetClassLongPtrW(uintptr_t hwnd, int index)'), + GetIconInfo: user32.func('bool __stdcall GetIconInfo(uintptr_t hicon, _Out_ ICONINFO *info)'), + GetObjectW: gdi32.func('int __stdcall GetObjectW(uintptr_t handle, int cb, _Out_ BITMAP *obj)'), + GetDC: user32.func('uintptr_t __stdcall GetDC(uintptr_t hwnd)'), + ReleaseDC: user32.func('int __stdcall ReleaseDC(uintptr_t hwnd, uintptr_t hdc)'), + GetDIBits: gdi32.func('int __stdcall GetDIBits(uintptr_t hdc, uintptr_t hbmp, uint start, uint lines, _Out_ uint8_t *bits, _Inout_ BITMAPINFOHEADER *bmi, uint usage)'), + DeleteObject: gdi32.func('bool __stdcall DeleteObject(uintptr_t obj)'), + } + return bindings + } catch (error) { + console.error('[nativeWindows] Failed to load native bindings:', error) + bindingsUnavailable = true + return null + } +} + +/** + * Read a window title via GetWindowTextW. + * + * @param native - Resolved native bindings + * @param hwnd - Native window handle + */ +function getWindowTitle(native: NativeBindings, hwnd: number): string { + const length = native.GetWindowTextLengthW(hwnd) + if (length <= 0) { + return '' + } + const buffer = new Uint16Array(length + 1) + const written = native.GetWindowTextW(hwnd, buffer, buffer.length) + if (written <= 0) { + return '' + } + return Buffer.from(buffer.buffer, 0, written * 2).toString('utf16le').replace(/[\r\n]+/g, ' ').trim() +} + +/** + * Query a window's icon via WM_GETICON with a timeout, so an unresponsive window + * cannot block the enumeration. Returns the HICON as a number, or 0. + * + * @param native - Resolved native bindings + * @param hwnd - Native window handle + * @param iconType - ICON_BIG or ICON_SMALL2 + */ +function queryWindowIcon(native: NativeBindings, hwnd: number, iconType: number): number { + const result = new BigUint64Array(1) + const ok = native.SendMessageTimeoutW(hwnd, WM_GETICON, iconType, 0, SMTO_ABORTIFHUNG, WM_GETICON_TIMEOUT_MS, result) + return ok ? Number(result[0]) : 0 +} + +/** + * Get the window's own icon (the same one shown in the taskbar) as a raw BGRA bitmap. + * Tries WM_GETICON first, then the window class icon. Returns null if the window has no icon. + * + * @param native - Resolved native bindings + * @param hwnd - Native window handle + */ +function getWindowIconBitmap(native: NativeBindings, hwnd: number): WindowIconBitmap | null { + let hicon = queryWindowIcon(native, hwnd, ICON_BIG) + if (!hicon) { + hicon = queryWindowIcon(native, hwnd, ICON_SMALL2) + } + if (!hicon) { + hicon = native.GetClassLongPtrW(hwnd, GCLP_HICON) + } + if (!hicon) { + hicon = native.GetClassLongPtrW(hwnd, GCLP_HICONSM) + } + if (!hicon) { + return null + } + + const iconInfo: Record = {} + if (!native.GetIconInfo(hicon, iconInfo)) { + return null + } + const hbmColor = iconInfo.hbmColor + const hbmMask = iconInfo.hbmMask + try { + if (!hbmColor) { + return null + } + const bitmap: Record = {} + if (!native.GetObjectW(hbmColor, native.sizeofBITMAP, bitmap)) { + return null + } + const width = bitmap.bmWidth + const height = bitmap.bmHeight + if (width <= 0 || height <= 0 || width > 512 || height > 512) { + return null + } + + // Negative height requests a top-down 32bpp DIB, so rows don't need flipping + const header = { + biSize: 40, + biWidth: width, + biHeight: -height, + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB, + biSizeImage: 0, + biXPelsPerMeter: 0, + biYPelsPerMeter: 0, + biClrUsed: 0, + biClrImportant: 0, + } + const pixels = new Uint8Array(width * height * 4) + const hdc = native.GetDC(0) + try { + if (!native.GetDIBits(hdc, hbmColor, 0, height, pixels, header, DIB_RGB_COLORS)) { + return null + } + } finally { + native.ReleaseDC(0, hdc) + } + return { width, height, data: Buffer.from(pixels) } + } finally { + if (hbmColor) { + native.DeleteObject(hbmColor) + } + if (hbmMask) { + native.DeleteObject(hbmMask) + } + } +} + +/** + * Whether a window is cloaked (hidden by DWM, e.g. a UWP window on another virtual desktop). + * + * @param native - Resolved native bindings + * @param hwnd - Native window handle + */ +function isCloaked(native: NativeBindings, hwnd: number): boolean { + const value = new Int32Array(1) + const result = native.DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED, value, 4) + // result is an HRESULT; only trust the value on success (S_OK === 0) + return result === 0 && value[0] !== 0 +} + +/** + * Enumerate top-level windows the user would expect to be able to share, + * including minimized ones (which Chromium/WebRTC omits from desktopCapturer). + * + * Applies the standard Alt-Tab eligibility heuristic: visible, unowned, not a tool window, + * has a title, and not cloaked. For minimized windows, also resolves the window icon + * (desktopCapturer only provides icons for the non-minimized ones). + * + * Returns an empty array on non-Windows platforms or on any failure — the caller + * treats native enumeration as a best-effort enhancement over desktopCapturer. + */ +export async function listNativeWindows(): Promise { + const native = getBindings() + if (!native) { + return [] + } + + const windows: NativeWindowInfo[] = [] + let callback: Koffi.IKoffiRegisteredCallback | undefined + + try { + callback = native.koffi.register((hwnd: number): boolean => { + try { + if (!native.IsWindowVisible(hwnd)) { + return true + } + if (native.GetWindow(hwnd, GW_OWNER) !== 0) { + return true + } + if ((native.GetWindowLongW(hwnd, GWL_EXSTYLE) & WS_EX_TOOLWINDOW) !== 0) { + return true + } + const title = getWindowTitle(native, hwnd) + if (!title) { + return true + } + if (isCloaked(native, hwnd)) { + return true + } + const minimized = native.IsIconic(hwnd) + const icon = minimized ? getWindowIconBitmap(native, hwnd) : null + windows.push({ hwnd: Number(hwnd), title, minimized, icon }) + } catch { + // Skip any window that throws — never break the enumeration + } + return true + }, native.koffi.pointer(native.EnumWindowsProc)) + + native.EnumWindows(callback, 0) + } catch (error) { + console.error('[nativeWindows] Failed to enumerate windows:', error) + } finally { + if (callback) { + native.koffi.unregister(callback) + } + } + + return windows +} + +/** + * Restore (un-minimize) and foreground a native window so that it becomes + * renderable and therefore capturable by WebRTC. + * + * @param hwnd - Native window handle + * @return true on success, false on failure or non-Windows platforms + */ +export async function restoreNativeWindow(hwnd: number): Promise { + const native = getBindings() + if (!native || !Number.isFinite(hwnd) || hwnd === 0) { + return false + } + + try { + native.ShowWindowAsync(hwnd, SW_RESTORE) + native.SetForegroundWindow(hwnd) + return true + } catch (error) { + console.error('[nativeWindows] Failed to restore window:', error) + return false + } +} diff --git a/src/main.js b/src/main.js index 2d17a26af..fe1357799 100644 --- a/src/main.js +++ b/src/main.js @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -const { app, ipcMain, desktopCapturer, systemPreferences, shell, session } = require('electron') +const { app, ipcMain, desktopCapturer, nativeImage, systemPreferences, shell, session } = require('electron') const { default: mri } = require('mri') const { spawn } = require('node:child_process') const path = require('node:path') @@ -18,6 +18,7 @@ const { triggerDownloadUrl } = require('./app/downloads.ts') const { setupReleaseNotificationScheduler, checkForUpdate } = require('./app/githubRelease.service.ts') const { initLaunchAtStartupListener } = require('./app/launchAtStartup.config.ts') const { runMigrations } = require('./app/migration.service.ts') +const { listNativeWindows, restoreNativeWindow } = require('./app/nativeWindows.ts') const { systemInfo, isMac, isWindows, isSameExecution, isSquirrel, relaunchApp } = require('./app/system.utils.ts') const { applyTheme } = require('./app/theme.config.ts') const { buildTitle, onReadyToShow } = require('./app/utils.ts') @@ -87,16 +88,24 @@ ipcMain.on('app:toggleDevTools', (event) => event.sender.toggleDevTools()) ipcMain.handle('app:anything', () => { /* Put any code here to run it from UI */ }) ipcMain.on('app:openChromeWebRtcInternals', () => openChromeWebRtcInternals()) ipcMain.handle('app:update:check', async () => await checkForUpdate({ forceRequest: true })) -ipcMain.handle('app:getDesktopCapturerSources', async () => { - // macOS 10.15 Catalina or higher requires consent for screen access - if (isMac && systemPreferences.getMediaAccessStatus('screen') !== 'granted') { - // Open System Preferences to allow screen recording - await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture') - // We cannot detect that the user has granted access, so return no sources - // The user will have to try again after granting access - return null - } +/** + * Extract the native window handle (HWND) encoded in an Electron window sourceId. + * On Windows, Electron formats window sources as `window::`. + * + * @param {string} sourceId - Electron desktopCapturer window sourceId + * @return {number|null} The HWND as a number, or null if not a window source + */ +function parseHwndFromSourceId(sourceId) { + const match = /^window:(\d+):\d+$/.exec(sourceId) + return match ? Number(match[1]) : null +} +/** + * Fetch and normalize Electron desktopCapturer sources (screens + windows). + * + * @return {Promise>} + */ +async function fetchDesktopCapturerSources() { const thumbnailWidth = 800 const sources = await desktopCapturer.getSources({ @@ -114,6 +123,117 @@ ipcMain.handle('app:getDesktopCapturerSources', async () => { icon: source.appIcon && !source.appIcon.isEmpty() ? source.appIcon.toDataURL() : null, thumbnail: source.thumbnail && !source.thumbnail.isEmpty() ? source.thumbnail.toDataURL() : null, })) +} + +/** + * Convert a native window icon (raw BGRA bitmap) into a data URL. + * The bitmap uses straight alpha; Electron expects premultiplied BGRA on Windows. + * + * @param {{ width: number, height: number, data: Buffer }|null} icon - Raw window icon bitmap + * @return {string|null} + */ +function windowIconToDataUrl(icon) { + if (!icon || !icon.width || !icon.height || !icon.data || icon.data.length !== icon.width * icon.height * 4) { + return null + } + try { + const data = Buffer.from(icon.data) + + // Some legacy icons carry no alpha channel (all zero) and rely on a mask instead; + // treat those as fully opaque so they are not rendered invisible. + let hasAlpha = false + for (let i = 3; i < data.length; i += 4) { + if (data[i] !== 0) { + hasAlpha = true + break + } + } + + for (let i = 0; i < data.length; i += 4) { + const alpha = hasAlpha ? data[i + 3] : 255 + data[i] = Math.round((data[i] * alpha) / 255) + data[i + 1] = Math.round((data[i + 1] * alpha) / 255) + data[i + 2] = Math.round((data[i + 2] * alpha) / 255) + data[i + 3] = alpha + } + + const image = nativeImage.createFromBitmap(data, { width: icon.width, height: icon.height }) + return image.isEmpty() ? null : image.toDataURL() + } catch (error) { + console.error('[main] Failed to build window icon:', error) + return null + } +} + +ipcMain.handle('app:getDesktopCapturerSources', async () => { + // macOS 10.15 Catalina or higher requires consent for screen access + if (isMac && systemPreferences.getMediaAccessStatus('screen') !== 'granted') { + // Open System Preferences to allow screen recording + await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture') + // We cannot detect that the user has granted access, so return no sources + // The user will have to try again after granting access + return null + } + + // On Windows, Chromium/WebRTC omits minimized (iconic) windows from desktopCapturer + // because they are not rendered and cannot produce frames. Enumerate top-level windows + // natively (in parallel) and add the minimized ones that are missing, so the user can + // pick them. They are restored on selection (see app:activateWindowForCapture). + // listNativeWindows() is best-effort and resolves to [] on any failure or non-Windows. + const [sources, nativeWindows] = await Promise.all([ + fetchDesktopCapturerSources(), + listNativeWindows(), + ]) + + if (nativeWindows.length > 0) { + const existingHwnds = new Set(sources.map((source) => parseHwndFromSourceId(source.id)).filter((hwnd) => hwnd !== null)) + for (const nativeWindow of nativeWindows) { + if (nativeWindow.minimized && !existingHwnds.has(nativeWindow.hwnd)) { + sources.push({ + id: `window:${nativeWindow.hwnd}:0`, + name: nativeWindow.title, + icon: windowIconToDataUrl(nativeWindow.icon), + thumbnail: null, + minimized: true, + }) + } + } + } + + return sources +}) + +/** + * Milliseconds to wait after restoring a window before capturing it, giving the + * compositor time to render the freshly un-minimized window (otherwise the first + * captured frames may be blank). + */ +const RESTORE_RENDER_DELAY_MS = 350 + +/** + * Restore (un-minimize) a window selected for sharing. + * + * A minimized window produces no frames and cannot be captured by WebRTC. When the user picks + * one, we restore it and give the compositor a moment to render it before capturing. + * On Windows, Electron encodes the HWND in the window sourceId (`window::0`), so the id + * stays valid after restoring and can be returned as-is. + */ +ipcMain.handle('app:activateWindowForCapture', async (event, source) => { + if (!isWindows || !source?.id) { + return { sourceId: source?.id ?? '' } + } + + const hwnd = parseHwndFromSourceId(source.id) + if (hwnd === null) { + return { sourceId: source.id } + } + + const restored = await restoreNativeWindow(hwnd) + if (restored) { + await new Promise((resolve) => setTimeout(resolve, RESTORE_RENDER_DELAY_MS)) + } + + return { sourceId: source.id } }) /** diff --git a/src/preload.js b/src/preload.js index 80bd416c8..ffd35a969 100644 --- a/src/preload.js +++ b/src/preload.js @@ -74,11 +74,21 @@ const TALK_DESKTOP = { */ flashAppIcon: (shouldFlash) => ipcRenderer.send('talk:flashAppIcon', shouldFlash), /** - * Get available desktop capture sources: screens and windows + * Get available desktop capture sources: screens and windows. + * On Windows, the list also includes minimized windows (marked with `minimized: true`), + * which Chromium omits by default. * - * @return {Promise<{ id: string, name: string, icon?: string }[]|null>} + * @return {Promise<{ id: string, name: string, icon?: string, minimized?: boolean }[]|null>} */ getDesktopCapturerSources: () => ipcRenderer.invoke('app:getDesktopCapturerSources'), + /** + * Restore (un-minimize) a window selected for sharing and resolve the capturable sourceId. + * No-op on non-Windows platforms — resolves with the original sourceId. + * + * @param {{ id: string }} source - The selected screensharing source + * @return {Promise<{ sourceId: string }>} + */ + activateWindowForCapture: (source) => ipcRenderer.invoke('app:activateWindowForCapture', source), /** * Relaunch an entire application */ diff --git a/src/talk/renderer/screensharing/DesktopMediaSourceDialog.vue b/src/talk/renderer/screensharing/DesktopMediaSourceDialog.vue index fef29fa97..5781daa91 100644 --- a/src/talk/renderer/screensharing/DesktopMediaSourceDialog.vue +++ b/src/talk/renderer/screensharing/DesktopMediaSourceDialog.vue @@ -32,6 +32,11 @@ const windowSources = computed(() => sources.value?.filter((source) => source.id const singleSource = computed(() => sources.value && sources.value.length === 1) +const selectedSource = computed(() => sources.value?.find((source) => source.id === selectedSourceId.value) ?? null) + +// Whether we are restoring a minimized window before resolving the selection +const activating = ref(false) + // On Wayland instead of the list of all available sources, // the system picker is used to have a list of a single selected source. // Getting the stream for the selected source triggers the system picker again. @@ -98,6 +103,10 @@ async function requestDesktopCapturerSources() { * @param source - The source that was suspended */ function handleVideoSuspend(source: ScreensharingSource) { + // Minimized windows legitimately produce no frames — never remove them on suspend + if (source.minimized) { + return + } sources.value!.splice(sources.value!.indexOf(source), 1) if (selectedSourceId.value === source.id) { selectedSourceId.value = null @@ -105,9 +114,25 @@ function handleVideoSuspend(source: ScreensharingSource) { } /** - * Handle the submit event of the dialog + * Handle the submit event of the dialog. + * A minimized window must be restored before it can be captured — do it here, + * then resolve with the reconciled (capturable) sourceId. */ -function handleSubmit() { +async function handleSubmit() { + const source = selectedSource.value + if (source?.minimized) { + activating.value = true + try { + const { sourceId } = await window.TALK_DESKTOP.activateWindowForCapture({ id: source.id }) + emit('submit', (sourceId || source.id) as ScreensharingSourceId) + } catch { + // Restoring failed — still resolve with the original id and let capture attempt it + emit('submit', source.id as ScreensharingSourceId) + } finally { + activating.value = false + } + return + } emit('submit', selectedSourceId.value!) } @@ -169,7 +194,7 @@ function handleCancel() { :icon="IconMonitorShare" :label="t('talk_desktop', 'Share screen')" variant="primary" - :disabled="!selectedSourceId" + :disabled="!selectedSourceId || activating" @click="handleSubmit" /> diff --git a/src/talk/renderer/screensharing/DesktopMediaSourcePreview.vue b/src/talk/renderer/screensharing/DesktopMediaSourcePreview.vue index f21353b6d..5682f7c4e 100644 --- a/src/talk/renderer/screensharing/DesktopMediaSourcePreview.vue +++ b/src/talk/renderer/screensharing/DesktopMediaSourcePreview.vue @@ -10,6 +10,7 @@ import { t } from '@nextcloud/l10n' import IconApplicationOutline from 'vue-material-design-icons/ApplicationOutline.vue' import IconMonitor from 'vue-material-design-icons/Monitor.vue' import IconVolumeHigh from 'vue-material-design-icons/VolumeHigh.vue' +import IconWindowMinimize from 'vue-material-design-icons/WindowMinimize.vue' import DesktopMediaSourcePreviewLive from './DesktopMediaSourcePreviewLive.vue' defineProps<{ @@ -34,8 +35,9 @@ const emit = defineEmits<{ :checked="selected" @change="emit('select')"> + @@ -44,6 +46,15 @@ const emit = defineEmits<{ alt="" :src="source.thumbnail" class="capture-source__preview"> + + + + {{ t('talk_desktop', 'Minimized — will be restored when shared') }} + {{ t('talk_desktop', 'Preview is not available') }} @@ -54,6 +65,7 @@ const emit = defineEmits<{ alt="" :src="source.icon" class="capture-source__caption-icon"> + @@ -96,6 +108,21 @@ const emit = defineEmits<{ font-size: 120%; } + &__preview-minimized { + grid-auto-flow: row; + gap: var(--default-grid-baseline); + justify-items: center; + text-align: center; + font-size: 100%; + padding-inline: calc(2 * var(--default-grid-baseline)); + } + + &__preview-minimized-icon { + width: 48px; + height: 48px; + object-fit: contain; + } + &:focus, &:hover { background-color: var(--color-background-hover); diff --git a/src/talk/renderer/screensharing/screensharing.types.ts b/src/talk/renderer/screensharing/screensharing.types.ts index 82ad06b25..6654ff765 100644 --- a/src/talk/renderer/screensharing/screensharing.types.ts +++ b/src/talk/renderer/screensharing/screensharing.types.ts @@ -16,4 +16,10 @@ export type ScreensharingSource = { * data:image/png;base64 encoded thumbnail of the source */ thumbnail: string | null + /** + * Whether the source is a minimized window (Windows only). + * Minimized windows produce no frames until restored, so they have no live preview + * and must be restored via activateWindowForCapture() before capturing. + */ + minimized?: boolean } diff --git a/webpack.main.config.js b/webpack.main.config.js index a8168af60..16707b821 100644 --- a/webpack.main.config.js +++ b/webpack.main.config.js @@ -16,6 +16,13 @@ module.exports = merge(baseConfig, { assetModuleFilename: '[file]', }, + // koffi is a native FFI module that loads its own prebuilt .node at runtime. + // Keep it external so webpack does not try to bundle it — it is shipped in + // node_modules and unpacked from the asar archive (see forge.config.js asar.unpack). + externals: { + koffi: 'commonjs koffi', + }, + module: { rules: [ {