Files
srt-streamer/electron/devices.js
T
admin 73aa97ae7c fix: window capture list not showing in video sources
Root cause: PowerShell command was wrapped in exec() with double-quote
escaping that silently failed. Rewrote using execFile() with args array
which requires no manual escaping.

Changes:
- electron/devices.js: use execFile('powershell.exe', [...args]) for
  window enumeration — eliminates quoting issues entirely
- Window list now returned INSIDE getDevices() video array (type='window')
  so they arrive together with cameras/screens in one IPC call
- StreamSettings: windows grouped from devices.video directly, with 🪟
  emoji prefix for visual distinction
- Added inline "↻ Обновить" button in video source field header to
  refresh devices+windows on demand without switching tabs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 01:17:03 +03:00

266 lines
9.6 KiB
JavaScript

'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'] })
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: '' }))
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 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 loopback (works with VB-Audio, Stereo Mix, or virtual-audio-capturer)
audio.push({
name: 'Системный звук (Loopback / Stereo Mix)',
deviceName: 'virtual-audio-capturer',
type: 'system',
description: 'Захват всего выходного звука'
})
// Screen sources: full desktop
const screenDevices = [
{ name: 'Рабочий стол (весь экран)', type: 'desktop', deviceName: 'desktop' }
]
// Per-monitor entries
const monitors = await getWindowsMonitors()
monitors.forEach((m, i) => {
screenDevices.push({
name: `Монитор ${i + 1}${m.label ? ': ' + m.label : ''}`,
type: 'desktop',
deviceName: 'desktop',
monitorIndex: i
})
})
// 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) => {
const t = setTimeout(() => resolve([]), 5000)
exec('wmic desktopmonitor get Name /format:list', (err, stdout) => {
clearTimeout(t)
if (err || !stdout) { resolve([]); return }
const monitors = []
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)
})
})
}
// ─── 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' })
}
}
// 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 }
}
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)
})
})
}
// ─── 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 {}
const display = process.env.DISPLAY || ':0'
video.unshift({ name: `Рабочий стол (${display})`, type: 'desktop', devicePath: display })
// 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'])
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 }
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
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
}
// Still exposed separately for the refresh button
async function getWindows() {
if (process.platform === 'win32') return getWindowsWindowsList()
if (process.platform === 'darwin') return getMacWindowsList()
return getLinuxWindowsList()
}
module.exports = { getDevices, getWindows }