acbd3b6349
- Auto-reconnect: stream retries with exponential backoff (2s→30s max) on unexpected exit; stops only when user clicks Stop - Recording: global RecordingBar with folder picker, segment duration (min), per-stream checkbox, Select All; uses FFmpeg tee muxer for simultaneous SRT + segmented .ts file output - Monitor capture: PowerShell System.Windows.Forms.Screen gives exact pixel bounds (x/y/width/height); per-monitor deviceName is now unique (desktop_monitor_N); gdigrab uses -offset_x/-offset_y/-video_size - Window capture: windowTitle now explicitly propagated in handleVideoSelect - System audio: added WASAPI Loopback option (no VB-Audio required), existing virtual-audio-capturer kept as fallback - Reconnecting event forwarded to renderer; status shows "Reconnecting…" with attempt count in logs - IPC: pick-folder (dialog.showOpenDialog) and open-folder (shell.openPath) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
308 lines
11 KiB
JavaScript
308 lines
11 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'] })
|
||
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 }
|