'use strict' const { spawn, exec, execFile } = require('child_process') const path = require('path') const fs = require('fs') let ffmpegPath = null function getFfmpegPath() { if (ffmpegPath) return ffmpegPath const resourcesPath = process.resourcesPath || path.join(__dirname, '..') const candidates = [ path.join(resourcesPath, 'ffmpeg-bin', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'), path.join(__dirname, '..', 'ffmpeg-bin', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'), ] for (const p of candidates) { if (fs.existsSync(p)) { ffmpegPath = p; return p } } ffmpegPath = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' return ffmpegPath } function runCommand(cmd, args, timeoutMs = 10000) { return new Promise((resolve) => { const proc = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] }) const outBufs = [], errBufs = [] proc.stdout.on('data', d => outBufs.push(d)) proc.stderr.on('data', d => errBufs.push(d)) const finish = () => { const stdout = Buffer.concat(outBufs).toString('utf8') const stderr = Buffer.concat(errBufs).toString('utf8') resolve({ stdout, stderr }) } proc.on('close', finish) proc.on('error', () => resolve({ stdout: '', stderr: '' })) setTimeout(() => { try { proc.kill() } catch {} }, timeoutMs) }) } // ─── Windows — enumerate open windows via PowerShell (UTF-8 safe) ───────────── function getWindowsWindowsList() { return new Promise((resolve) => { const ps1 = [ '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;', '$OutputEncoding = [System.Text.Encoding]::UTF8;', 'Get-Process', '| Where-Object { $_.MainWindowTitle -ne "" }', '| Sort-Object MainWindowTitle', '| ForEach-Object { Write-Output ($_.MainWindowTitle + "|||" + $_.ProcessName) }' ].join(' ') execFile( 'powershell.exe', ['-NonInteractive', '-NoProfile', '-WindowStyle', 'Hidden', '-Command', ps1], { timeout: 8000, windowsHide: true, encoding: 'buffer' }, (err, stdoutBuf) => { if (err || !stdoutBuf || !stdoutBuf.length) { resolve([]); return } let buf = stdoutBuf // Strip UTF-8 BOM if present if (buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) buf = buf.slice(3) const stdout = buf.toString('utf8') const wins = stdout.trim().split('\n') .map(l => l.trim().replace(/\r$/, '')) .filter(Boolean) .map(l => { const sep = l.indexOf('|||') const title = sep !== -1 ? l.slice(0, sep).trim() : l.trim() const proc = sep !== -1 ? l.slice(sep + 3).trim() : '' return { title, processName: proc, type: 'window' } }) .filter(w => w.title && w.title.length > 0) resolve(wins) } ) }) } // ─── Windows — enumerate monitors with exact pixel bounds ───────────────────── // Uses System.Windows.Forms.Screen (always available on Windows) function getWindowsMonitors() { return new Promise((resolve) => { const ps1 = [ '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;', '$OutputEncoding = [System.Text.Encoding]::UTF8;', 'Add-Type -AssemblyName System.Windows.Forms;', '[System.Windows.Forms.Screen]::AllScreens', '| Sort-Object { $_.Primary } -Descending', // primary first '| ForEach-Object {', ' $b = $_.Bounds;', ' Write-Output ("{0}|{1}|{2}|{3}|{4}|{5}" -f $_.DeviceName, $b.X, $b.Y, $b.Width, $b.Height, $_.Primary)', '}' ].join(' ') execFile( 'powershell.exe', ['-NonInteractive', '-NoProfile', '-WindowStyle', 'Hidden', '-Command', ps1], { timeout: 8000, windowsHide: true, encoding: 'buffer' }, (err, stdoutBuf) => { if (err || !stdoutBuf || !stdoutBuf.length) { resolve([]); return } let buf = stdoutBuf if (buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) buf = buf.slice(3) const stdout = buf.toString('utf8') const monitors = [] for (const line of stdout.trim().split('\n')) { const parts = line.trim().replace(/\r$/, '').split('|') if (parts.length < 5) continue const [deviceName, x, y, w, h, primary] = parts monitors.push({ label: (deviceName || '').trim().replace(/\\\\.\\/, '').trim(), bounds: { x: parseInt(x) || 0, y: parseInt(y) || 0, width: parseInt(w) || 1920, height: parseInt(h) || 1080, }, primary: primary?.trim().toLowerCase() === 'true' }) } resolve(monitors) } ) }) } // ─── Windows — dShow devices + monitors + windows ───────────────────────────── async function getWindowsDevices() { const ffmpeg = getFfmpegPath() const { stderr } = await runCommand(ffmpeg, ['-list_devices', 'true', '-f', 'dshow', '-i', 'dummy']) const video = [] const audio = [] for (const line of stderr.split('\n')) { const videoMatch = line.match(/"([^"]+)"\s*\(video\)/) if (videoMatch) { video.push({ name: videoMatch[1], deviceName: videoMatch[1], type: 'device' }); continue } const audioMatch = line.match(/"([^"]+)"\s*\(audio\)/) if (audioMatch) { audio.push({ name: audioMatch[1], deviceName: audioMatch[1], type: 'device' }) } } // System audio options audio.push({ name: 'Системный звук (WASAPI Loopback)', deviceName: '__wasapi_loopback__', type: 'system', description: 'Нативный захват Windows — без стороннего ПО' }) audio.push({ name: 'Системный звук (VB-Audio / Stereo Mix)', deviceName: 'virtual-audio-capturer', type: 'system', description: 'Требует VB-Audio Cable или Stereo Mix' }) // Screen sources const screenDevices = [ { name: 'Рабочий стол (весь экран)', type: 'desktop', deviceName: 'desktop' } ] // Per-monitor entries with real pixel bounds const monitors = await getWindowsMonitors() monitors.forEach((m, i) => { const label = m.primary ? `${m.label} (основной)` : m.label screenDevices.push({ name: `Монитор ${i + 1}${label ? ': ' + label : ''} — ${m.bounds.width}×${m.bounds.height}`, type: 'desktop', deviceName: `desktop_monitor_${i}`, // unique key per monitor monitorIndex: i, monitorBounds: m.bounds // { x, y, width, height } }) }) // Open application windows const wins = await getWindowsWindowsList() const windowDevices = wins.map(w => ({ name: w.title, deviceName: w.title, processName: w.processName, type: 'window', windowTitle: w.title })) return { video: [...screenDevices, ...windowDevices, ...video], audio } } // ─── macOS ──────────────────────────────────────────────────────────────────── async function getMacDevices() { const ffmpeg = getFfmpegPath() const { stderr } = await runCommand(ffmpeg, ['-f', 'avfoundation', '-list_devices', 'true', '-i', '']) const video = [], audio = [] let section = null for (const line of stderr.split('\n')) { if (line.includes('AVFoundation video devices')) { section = 'video'; continue } if (line.includes('AVFoundation audio devices')) { section = 'audio'; continue } const m = line.match(/\[(\d+)\]\s+(.+)/) if (!m) continue const [, index, name] = m if (section === 'video') { const isScreen = /screen|capture|display/i.test(name) video.push({ name: name.trim(), deviceIndex: index, type: isScreen ? 'desktop' : 'device' }) } else if (section === 'audio') { audio.push({ name: name.trim(), deviceIndex: index, type: 'device' }) } } const wins = await getMacWindowsList() video.push(...wins.map(w => ({ name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title }))) return { video, audio } } function getMacWindowsList() { return new Promise((resolve) => { const script = 'tell application "System Events" to get the name of every window of every process whose visible is true' execFile('osascript', ['-e', script], { timeout: 5000 }, (err, stdout) => { if (err || !stdout) { resolve([]); return } resolve(stdout.trim().split(',').map(s => s.trim()).filter(Boolean) .map(title => ({ title, type: 'window' }))) }) }) } // ─── Linux ──────────────────────────────────────────────────────────────────── async function getLinuxDevices() { const video = [], audio = [] try { const { stdout: devList } = await runCommand('ls', ['/dev/video0', '/dev/video1', '/dev/video2', '/dev/video3', '/dev/video4']) for (const dev of devList.trim().split('\n').filter(Boolean)) { video.push({ name: dev, devicePath: dev, type: 'device' }) } } catch {} const display = process.env.DISPLAY || ':0' video.unshift({ name: `Рабочий стол (${display})`, type: 'desktop', devicePath: display }) const wins = await getLinuxWindowsList() video.push(...wins.map(w => ({ name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title }))) try { const { stdout: paList } = await runCommand('pactl', ['list', 'short', 'sources']) for (const line of paList.split('\n').filter(Boolean)) { const parts = line.split('\t') if (parts.length >= 2) { const devName = parts[1] audio.push({ name: devName, deviceName: devName, type: devName.includes('.monitor') ? 'system' : 'device', description: devName.includes('.monitor') ? 'Системный звук (loopback)' : '' }) } } } catch { audio.push({ name: 'default', deviceName: 'default', type: 'device' }) } return { video, audio } } function getLinuxWindowsList() { return new Promise((resolve) => { execFile('wmctrl', ['-l'], { timeout: 3000 }, (err, stdout) => { if (err || !stdout) { resolve([]); return } resolve(stdout.trim().split('\n') .map(line => { const parts = line.split(/\s+/) return { title: parts.slice(3).join(' ').trim(), type: 'window' } }) .filter(w => w.title)) }) }) } // ─── Public API ─────────────────────────────────────────────────────────────── async function getDevices() { let result if (process.platform === 'win32') { result = await getWindowsDevices() } else if (process.platform === 'darwin') { result = await getMacDevices() } else { result = await getLinuxDevices() } result.video.unshift({ name: '— Нет видео —', type: 'none', deviceName: 'none' }) result.audio.unshift({ name: '— Нет аудио —', type: 'none', deviceName: 'none' }) return result } async function getWindows() { if (process.platform === 'win32') return getWindowsWindowsList() if (process.platform === 'darwin') return getMacWindowsList() return getLinuxWindowsList() } module.exports = { getDevices, getWindows }