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: [ {