diff --git a/electron/devices.js b/electron/devices.js index e9d92f1..cc6ff3a 100644 --- a/electron/devices.js +++ b/electron/devices.js @@ -1,218 +1,243 @@ -const { spawn, exec } = require('child_process') +'use strict' +const { spawn, exec, execFile } = require('child_process') const path = require('path') -const fs = require('fs') +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 - } + if (fs.existsSync(p)) { ffmpegPath = p; return p } } - ffmpegPath = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' return ffmpegPath } -function runCommand(cmd, args) { +function runCommand(cmd, args, timeoutMs = 10000) { return new Promise((resolve) => { const proc = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' + let stdout = '', stderr = '' proc.stdout.on('data', d => stdout += d) proc.stderr.on('data', d => stderr += d) proc.on('close', () => resolve({ stdout, stderr })) proc.on('error', () => resolve({ stdout: '', stderr: '' })) - // Timeout - setTimeout(() => { proc.kill(); resolve({ stdout, stderr }) }, 10000) + setTimeout(() => { try { proc.kill() } catch {} ; resolve({ stdout, stderr }) }, timeoutMs) }) } +// ─── Windows — enumerate application windows via PowerShell ─────────────────── +// Uses execFile to pass arguments as array → no quoting/escaping issues +function getWindowsWindowsList() { + return new Promise((resolve) => { + // Each line: "WindowTitle|||ProcessName" + const ps1 = [ + '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 }, + (err, stdout) => { + if (err || !stdout) { resolve([]); return } + 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 dShow devices + monitors ───────────────────────────── async function getWindowsDevices() { const ffmpeg = getFfmpegPath() - const { stderr } = await runCommand(ffmpeg, [ - '-list_devices', 'true', - '-f', 'dshow', - '-i', 'dummy' - ]) + const { stderr } = await runCommand(ffmpeg, ['-list_devices', 'true', '-f', 'dshow', '-i', 'dummy']) const video = [] const audio = [] - const lines = stderr.split('\n') - let section = null - - for (const line of lines) { - if (line.includes('"') && line.includes('(video)')) { - section = 'video' - const match = line.match(/"([^"]+)"\s*\(video\)/) - if (match) { - video.push({ name: match[1], deviceName: match[1], type: 'device' }) - } - } else if (line.includes('"') && line.includes('(audio)')) { - section = 'audio' - const match = line.match(/"([^"]+)"\s*\(audio\)/) - if (match) { - audio.push({ name: match[1], deviceName: match[1], type: 'device' }) - } - } + 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' }) } } - // Add system audio loopback devices (WASAPI) - // These appear as "virtual-audio-capturer" or similar dshow devices - // Also detect via registry / wmic + // System audio loopback (works with VB-Audio, Stereo Mix, or virtual-audio-capturer) audio.push({ - name: 'System Audio (Loopback)', + name: 'Системный звук (Loopback / Stereo Mix)', deviceName: 'virtual-audio-capturer', type: 'system', - description: 'Capture all system audio output' + description: 'Захват всего выходного звука' }) - // Add screen capture options + // Screen sources: full desktop const screenDevices = [ - { name: 'Desktop (Full Screen)', type: 'desktop', deviceName: 'desktop' }, + { name: 'Рабочий стол (весь экран)', type: 'desktop', deviceName: 'desktop' } ] - // Try to enumerate monitors + // Per-monitor entries const monitors = await getWindowsMonitors() monitors.forEach((m, i) => { screenDevices.push({ - name: `Monitor ${i + 1}${m.label ? ': ' + m.label : ''}`, + name: `Монитор ${i + 1}${m.label ? ': ' + m.label : ''}`, type: 'desktop', - deviceName: `desktop`, + deviceName: 'desktop', monitorIndex: i }) }) - return { video: [...screenDevices, ...video], audio } + // Enumerate open windows — included directly in video sources + 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 + } } async function getWindowsMonitors() { return new Promise((resolve) => { - exec('wmic desktopmonitor get Name,ScreenHeight,ScreenWidth /format:list', (err, stdout) => { - if (err) { resolve([]); return } + const t = setTimeout(() => resolve([]), 5000) + exec('wmic desktopmonitor get Name /format:list', (err, stdout) => { + clearTimeout(t) + if (err || !stdout) { resolve([]); return } const monitors = [] - const blocks = stdout.split('\r\n\r\n').filter(b => b.trim()) - for (const block of blocks) { - const nameMatch = block.match(/Name=(.+)/) - if (nameMatch) monitors.push({ label: nameMatch[1].trim() }) + for (const block of stdout.split(/\r?\n\r?\n/)) { + const m = block.match(/Name=(.+)/) + if (m && m[1].trim()) monitors.push({ label: m[1].trim() }) } resolve(monitors) }) - setTimeout(() => resolve([]), 5000) }) } +// ─── macOS ──────────────────────────────────────────────────────────────────── async function getMacDevices() { const ffmpeg = getFfmpegPath() - const { stderr } = await runCommand(ffmpeg, [ - '-f', 'avfoundation', - '-list_devices', 'true', - '-i', '' - ]) + const { stderr } = await runCommand(ffmpeg, ['-f', 'avfoundation', '-list_devices', 'true', '-i', '']) - const video = [] - const audio = [] - - const lines = stderr.split('\n') + const video = [], audio = [] let section = null - for (const line of lines) { - if (line.includes('AVFoundation video devices')) { - section = 'video' - continue - } - if (line.includes('AVFoundation audio devices')) { - section = 'audio' - continue - } - - const match = line.match(/\[(\d+)\]\s+(.+)/) - if (match) { - const index = match[1] - const name = match[2].trim() - if (section === 'video') { - const isScreen = name.toLowerCase().includes('screen') || name.toLowerCase().includes('capture') - video.push({ - name, - deviceIndex: index, - type: isScreen ? 'desktop' : 'device' - }) - } else if (section === 'audio') { - audio.push({ - name, - deviceIndex: index, - type: 'device' - }) - } + 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' }) } } - return { video, audio } + // macOS window list via osascript + const wins = await getMacWindowsList() + const windowDevices = wins.map(w => ({ + name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title + })) + + return { video: [...video, ...windowDevices], audio } } -async function getLinuxDevices() { - const video = [] - const audio = [] - - // V4L2 devices - try { - const v4l2Devices = await new Promise((resolve) => { - exec('ls /dev/video* 2>/dev/null', (err, stdout) => { - if (err) { resolve([]); return } - resolve(stdout.trim().split('\n').filter(Boolean)) - }) +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 } + const wins = stdout.trim().split(',').map(s => s.trim()).filter(Boolean) + .map(title => ({ title, type: 'window' })) + resolve(wins) }) + }) +} - for (const dev of v4l2Devices) { - const { stdout } = await runCommand('v4l2-ctl', ['--device', dev, '--info']) - const nameMatch = stdout.match(/Card type\s*:\s*(.+)/) - video.push({ - name: nameMatch ? nameMatch[1].trim() : dev, - devicePath: dev, - type: 'device' - }) +// ─── Linux ──────────────────────────────────────────────────────────────────── +async function getLinuxDevices() { + const video = [], audio = [] + + // V4L2 cameras + 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 (e) {} + } catch {} - // Add screen capture const display = process.env.DISPLAY || ':0' - video.unshift({ name: `Desktop (${display})`, type: 'desktop', devicePath: display }) + video.unshift({ name: `Рабочий стол (${display})`, type: 'desktop', devicePath: display }) - // PulseAudio devices + // Linux windows + const wins = await getLinuxWindowsList() + const windowDevices = wins.map(w => ({ name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title })) + video.push(...windowDevices) + + // PulseAudio / ALSA try { const { stdout: paList } = await runCommand('pactl', ['list', 'short', 'sources']) - const lines = paList.split('\n').filter(Boolean) - for (const line of lines) { + for (const line of paList.split('\n').filter(Boolean)) { const parts = line.split('\t') if (parts.length >= 2) { - const deviceName = parts[1] - const isMonitor = deviceName.includes('.monitor') + const devName = parts[1] audio.push({ - name: deviceName, - deviceName, - type: isMonitor ? 'system' : 'device', - description: isMonitor ? 'System audio (loopback)' : '' + name: devName, + deviceName: devName, + type: devName.includes('.monitor') ? 'system' : 'device', + description: devName.includes('.monitor') ? 'Системный звук (loopback)' : '' }) } } - } catch (e) { + } 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 } + const wins = stdout.trim().split('\n') + .map(line => { + const parts = line.split(/\s+/) + const title = parts.slice(3).join(' ').trim() + return { title, type: 'window' } + }) + .filter(w => w.title) + resolve(wins) + }) + }) +} + +// ─── Public API ─────────────────────────────────────────────────────────────── async function getDevices() { let result @@ -224,66 +249,17 @@ async function getDevices() { result = await getLinuxDevices() } - // Always add "None" options - result.video.unshift({ name: '— No Video —', type: 'none', deviceName: 'none' }) - result.audio.unshift({ name: '— No Audio —', type: 'none', deviceName: 'none' }) + result.video.unshift({ name: '— Нет видео —', type: 'none', deviceName: 'none' }) + result.audio.unshift({ name: '— Нет аудио —', type: 'none', deviceName: 'none' }) return result } +// Still exposed separately for the refresh button async function getWindows() { - if (process.platform === 'win32') { - return getWindowsWindowsList() - } else if (process.platform === 'darwin') { - return getMacWindowsList() - } else { - return getLinuxWindowsList() - } -} - -function getWindowsWindowsList() { - return new Promise((resolve) => { - const script = ` - $windows = Get-Process | Where-Object {$_.MainWindowTitle -ne ""} | Select-Object ProcessName, MainWindowTitle - $windows | ForEach-Object { Write-Output "$($_.MainWindowTitle)" } - ` - exec(`powershell -Command "${script.replace(/\n/g, ' ')}"`, (err, stdout) => { - if (err) { resolve([]); return } - const windows = stdout.trim().split('\n') - .map(w => w.trim()) - .filter(Boolean) - .map(title => ({ title, type: 'window' })) - resolve(windows) - }) - }) -} - -function getMacWindowsList() { - return new Promise((resolve) => { - exec(`osascript -e 'tell application "System Events" to get name of every process whose has UI elements is true'`, (err, stdout) => { - if (err) { resolve([]); return } - const apps = stdout.trim().split(', ') - .filter(Boolean) - .map(title => ({ title, type: 'window' })) - resolve(apps) - }) - }) -} - -function getLinuxWindowsList() { - return new Promise((resolve) => { - exec('wmctrl -l 2>/dev/null', (err, stdout) => { - if (err) { resolve([]); return } - const windows = stdout.trim().split('\n') - .map(line => { - const parts = line.split(/\s+/) - const title = parts.slice(3).join(' ') - return { title, type: 'window' } - }) - .filter(w => w.title) - resolve(windows) - }) - }) + if (process.platform === 'win32') return getWindowsWindowsList() + if (process.platform === 'darwin') return getMacWindowsList() + return getLinuxWindowsList() } module.exports = { getDevices, getWindows } diff --git a/src/App.jsx b/src/App.jsx index e893237..6451c74 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -204,6 +204,7 @@ export default function App() { onStart={() => startStream(stream.id)} onStop={() => stopStream(stream.id)} onRemove={() => removeStream(stream.id)} + onRefreshDevices={refreshDevices} /> ))} diff --git a/src/components/StreamCard.jsx b/src/components/StreamCard.jsx index a9d2739..e80c49e 100644 --- a/src/components/StreamCard.jsx +++ b/src/components/StreamCard.jsx @@ -15,7 +15,7 @@ const STATUS_CLASS = { error: 'status--error' } -export default function StreamCard({ stream, devices, windows, devicesLoaded, onChange, onStart, onStop, onRemove }) { +export default function StreamCard({ stream, devices, windows, devicesLoaded, onChange, onStart, onStop, onRemove, onRefreshDevices }) { const [expanded, setExpanded] = useState(true) const [editingName, setEditingName] = useState(false) const [nameValue, setNameValue] = useState(stream.name) @@ -120,6 +120,7 @@ export default function StreamCard({ stream, devices, windows, devicesLoaded, on devicesLoaded={devicesLoaded} disabled={isActive} onChange={onChange} + onRefreshDevices={onRefreshDevices} /> )} diff --git a/src/components/StreamSettings.jsx b/src/components/StreamSettings.jsx index 308ee90..f08ee1d 100644 --- a/src/components/StreamSettings.jsx +++ b/src/components/StreamSettings.jsx @@ -95,7 +95,7 @@ function detectProfile(res, fps, vbr) { } // ─── main component ─────────────────────────────────────────────────────────── -export default function StreamSettings({ stream, devices, windows, devicesLoaded, disabled, onChange }) { +export default function StreamSettings({ stream, devices, windows, devicesLoaded, disabled, onChange, onRefreshDevices }) { const [activeSection, setActiveSection] = useState('source') const updateVideo = changes => onChange({ videoSource: { ...stream.videoSource, ...changes } }) @@ -104,13 +104,16 @@ export default function StreamSettings({ stream, devices, windows, devicesLoaded const videoDevices = devices.video || [] const audioDevices = devices.audio || [] - // Build grouped video options + // Build grouped video options — windows now come from devices.video directly const noneOpts = videoDevices.filter(d => d.type === 'none') const screenOpts = videoDevices.filter(d => d.type === 'desktop') const cameraOpts = videoDevices.filter(d => d.type === 'device') - const windowOpts = (windows || []).map(w => ({ - name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title - })) + // Windows from devices.video (already enumerated server-side) + any extra from prop + const windowsFromDevices = videoDevices.filter(d => d.type === 'window') + const windowsFromProp = (windows || []) + .filter(w => !windowsFromDevices.find(d => d.deviceName === w.title)) + .map(w => ({ name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title })) + const windowOpts = [...windowsFromDevices, ...windowsFromProp] const videoOptions = [ ...noneOpts, @@ -178,7 +181,17 @@ export default function StreamSettings({ stream, devices, windows, devicesLoaded