928 lines
24 KiB
TypeScript
928 lines
24 KiB
TypeScript
import {
|
|
ChildProcess,
|
|
execFile,
|
|
spawn
|
|
} from 'child_process';
|
|
import { randomUUID } from 'crypto';
|
|
import { WebContents } from 'electron';
|
|
import { promisify } from 'util';
|
|
const execFileAsync = promisify(execFile);
|
|
const SCREEN_SHARE_SINK_NAME = 'metoyou_screenshare_sink';
|
|
const SCREEN_SHARE_MONITOR_SOURCE_NAME = `${SCREEN_SHARE_SINK_NAME}.monitor`;
|
|
const VOICE_SINK_NAME = 'metoyou_voice_sink';
|
|
const REROUTE_INTERVAL_MS = 750;
|
|
const MONITOR_CAPTURE_SAMPLE_RATE = 48_000;
|
|
const MONITOR_CAPTURE_CHANNEL_COUNT = 2;
|
|
const MONITOR_CAPTURE_BITS_PER_SAMPLE = 16;
|
|
const MONITOR_CAPTURE_STOP_TIMEOUT_MS = 1_000;
|
|
const MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk';
|
|
const MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
|
|
|
|
interface ShortSinkEntry {
|
|
index: string;
|
|
name: string;
|
|
}
|
|
|
|
interface ShortSinkInputEntry {
|
|
index: string;
|
|
sinkIndex: string;
|
|
}
|
|
|
|
interface SinkInputDetails extends ShortSinkInputEntry {
|
|
properties: Record<string, string>;
|
|
}
|
|
|
|
interface DescendantProcessInfo {
|
|
ids: ReadonlySet<string>;
|
|
binaryNames: ReadonlySet<string>;
|
|
}
|
|
|
|
interface PactlJsonSinkInputEntry {
|
|
index?: number | string;
|
|
properties?: Record<string, unknown>;
|
|
sink?: number | string;
|
|
}
|
|
|
|
interface LinuxScreenShareAudioRoutingState {
|
|
active: boolean;
|
|
restoreSinkName: string | null;
|
|
screenShareLoopbackModuleId: string | null;
|
|
voiceLoopbackModuleId: string | null;
|
|
rerouteIntervalId: ReturnType<typeof setInterval> | null;
|
|
subscribeProcess: ChildProcess | null;
|
|
}
|
|
|
|
interface LinuxScreenShareMonitorCaptureState {
|
|
captureId: string | null;
|
|
process: ChildProcess | null;
|
|
stderr: string;
|
|
stopRequested: boolean;
|
|
targetWebContents: WebContents | null;
|
|
}
|
|
|
|
export interface LinuxScreenShareAudioRoutingInfo {
|
|
available: boolean;
|
|
active: boolean;
|
|
monitorCaptureSupported: boolean;
|
|
screenShareSinkName: string;
|
|
screenShareMonitorSourceName: string;
|
|
voiceSinkName: string;
|
|
reason?: string;
|
|
}
|
|
|
|
export interface LinuxScreenShareMonitorCaptureInfo {
|
|
bitsPerSample: number;
|
|
captureId: string;
|
|
channelCount: number;
|
|
sampleRate: number;
|
|
sourceName: string;
|
|
}
|
|
|
|
const routingState: LinuxScreenShareAudioRoutingState = {
|
|
active: false,
|
|
restoreSinkName: null,
|
|
screenShareLoopbackModuleId: null,
|
|
voiceLoopbackModuleId: null,
|
|
rerouteIntervalId: null,
|
|
subscribeProcess: null
|
|
};
|
|
const monitorCaptureState: LinuxScreenShareMonitorCaptureState = {
|
|
captureId: null,
|
|
process: null,
|
|
stderr: '',
|
|
stopRequested: false,
|
|
targetWebContents: null
|
|
};
|
|
|
|
let pactlAvailableCache: boolean | null = null;
|
|
|
|
export async function prepareLinuxScreenShareAudioRouting(): Promise<LinuxScreenShareAudioRoutingInfo> {
|
|
if (process.platform !== 'linux') {
|
|
return buildRoutingInfo(false, false, 'Linux-only audio routing is unavailable on this platform.');
|
|
}
|
|
|
|
if (!await isPactlAvailable()) {
|
|
return buildRoutingInfo(false, false, 'pactl is unavailable; falling back to standard desktop audio capture.');
|
|
}
|
|
|
|
await ensureNullSink(SCREEN_SHARE_SINK_NAME);
|
|
await ensureNullSink(VOICE_SINK_NAME);
|
|
|
|
return buildRoutingInfo(true, routingState.active);
|
|
}
|
|
|
|
export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScreenShareAudioRoutingInfo> {
|
|
const prepared = await prepareLinuxScreenShareAudioRouting();
|
|
|
|
if (!prepared.available) {
|
|
return prepared;
|
|
}
|
|
|
|
if (routingState.active) {
|
|
return buildRoutingInfo(true, true);
|
|
}
|
|
|
|
const restoreSinkName = await getPreferredRestoreSinkName();
|
|
|
|
if (!restoreSinkName) {
|
|
return buildRoutingInfo(false, false, 'Unable to determine a playback sink for Linux screen-share audio routing.');
|
|
}
|
|
|
|
try {
|
|
routingState.restoreSinkName = restoreSinkName;
|
|
routingState.screenShareLoopbackModuleId = await loadLoopbackModule(SCREEN_SHARE_MONITOR_SOURCE_NAME, restoreSinkName);
|
|
routingState.voiceLoopbackModuleId = await loadLoopbackModule(`${VOICE_SINK_NAME}.monitor`, restoreSinkName);
|
|
|
|
// Set the default sink to the voice sink so that new app audio
|
|
// streams (received WebRTC voice) never land on the screenshare
|
|
// capture sink. This prevents the feedback loop where remote
|
|
// voice audio was picked up by parec before the reroute interval
|
|
// could move the stream away.
|
|
await setDefaultSink(VOICE_SINK_NAME);
|
|
|
|
routingState.active = true;
|
|
|
|
// Let the combined reroute decide placement for every existing
|
|
// stream. This avoids briefly shoving the app's own playback to the
|
|
// screenshare sink before ownership detection can move it back.
|
|
await rerouteSinkInputs();
|
|
startSinkInputRerouteLoop();
|
|
startSubscribeWatcher();
|
|
|
|
return buildRoutingInfo(true, true);
|
|
} catch (error) {
|
|
await deactivateLinuxScreenShareAudioRouting();
|
|
|
|
return buildRoutingInfo(
|
|
false,
|
|
false,
|
|
error instanceof Error ? error.message : 'Failed to activate Linux screen-share audio routing.'
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean> {
|
|
const restoreSinkName = routingState.restoreSinkName;
|
|
|
|
stopSubscribeWatcher();
|
|
stopSinkInputRerouteLoop();
|
|
await stopLinuxScreenShareMonitorCapture();
|
|
|
|
try {
|
|
if (restoreSinkName) {
|
|
await setDefaultSink(restoreSinkName);
|
|
await moveSinkInputs(restoreSinkName, (sinkName) => sinkName === SCREEN_SHARE_SINK_NAME || sinkName === VOICE_SINK_NAME);
|
|
}
|
|
} catch {
|
|
// Best-effort cleanup only.
|
|
}
|
|
|
|
await Promise.all([unloadModuleIfLoaded(routingState.screenShareLoopbackModuleId), unloadModuleIfLoaded(routingState.voiceLoopbackModuleId)]);
|
|
|
|
routingState.active = false;
|
|
routingState.restoreSinkName = null;
|
|
routingState.screenShareLoopbackModuleId = null;
|
|
routingState.voiceLoopbackModuleId = null;
|
|
routingState.subscribeProcess = null;
|
|
|
|
return true;
|
|
}
|
|
|
|
export async function cleanupLinuxScreenShareAudioRouting(): Promise<void> {
|
|
await deactivateLinuxScreenShareAudioRouting();
|
|
}
|
|
|
|
export async function startLinuxScreenShareMonitorCapture(
|
|
targetWebContents: WebContents
|
|
): Promise<LinuxScreenShareMonitorCaptureInfo> {
|
|
if (process.platform !== 'linux') {
|
|
throw new Error('Linux screen-share monitor capture is unavailable on this platform.');
|
|
}
|
|
|
|
if (!routingState.active) {
|
|
throw new Error('Linux screen-share audio routing must be active before monitor capture starts.');
|
|
}
|
|
|
|
await stopLinuxScreenShareMonitorCapture();
|
|
|
|
const captureId = randomUUID();
|
|
const captureProcess = spawn('parec', [
|
|
'--device',
|
|
SCREEN_SHARE_MONITOR_SOURCE_NAME,
|
|
'--raw',
|
|
'--format=s16le',
|
|
'--rate',
|
|
`${MONITOR_CAPTURE_SAMPLE_RATE}`,
|
|
'--channels',
|
|
`${MONITOR_CAPTURE_CHANNEL_COUNT}`
|
|
], {
|
|
env: process.env,
|
|
stdio: [
|
|
'ignore',
|
|
'pipe',
|
|
'pipe'
|
|
]
|
|
});
|
|
|
|
monitorCaptureState.captureId = captureId;
|
|
monitorCaptureState.process = captureProcess;
|
|
monitorCaptureState.stderr = '';
|
|
monitorCaptureState.stopRequested = false;
|
|
monitorCaptureState.targetWebContents = targetWebContents;
|
|
|
|
let started = false;
|
|
|
|
const startPromise = new Promise<void>((resolve, reject) => {
|
|
const onError = (error: Error): void => {
|
|
if (!started) {
|
|
cleanupMonitorCaptureState(captureId, error.message);
|
|
reject(error);
|
|
return;
|
|
}
|
|
|
|
cleanupMonitorCaptureState(captureId, error.message);
|
|
};
|
|
|
|
captureProcess.on('error', onError);
|
|
captureProcess.once('spawn', () => {
|
|
started = true;
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
captureProcess.stdout.on('data', (chunk: Buffer) => {
|
|
if (monitorCaptureState.captureId !== captureId) {
|
|
return;
|
|
}
|
|
|
|
const target = monitorCaptureState.targetWebContents;
|
|
|
|
if (!target || target.isDestroyed()) {
|
|
return;
|
|
}
|
|
|
|
target.send(MONITOR_AUDIO_CHUNK_CHANNEL, {
|
|
captureId,
|
|
chunk: Uint8Array.from(chunk)
|
|
});
|
|
});
|
|
|
|
captureProcess.stderr.on('data', (chunk: Buffer) => {
|
|
if (monitorCaptureState.captureId !== captureId) {
|
|
return;
|
|
}
|
|
|
|
const nextStderr = `${monitorCaptureState.stderr}${chunk.toString()}`;
|
|
|
|
monitorCaptureState.stderr = nextStderr.slice(-4_096);
|
|
});
|
|
|
|
captureProcess.once('close', (code, signal) => {
|
|
const reason = buildMonitorCaptureCloseReason(captureId, code, signal);
|
|
|
|
cleanupMonitorCaptureState(captureId, reason);
|
|
});
|
|
|
|
await startPromise;
|
|
|
|
return {
|
|
bitsPerSample: MONITOR_CAPTURE_BITS_PER_SAMPLE,
|
|
captureId,
|
|
channelCount: MONITOR_CAPTURE_CHANNEL_COUNT,
|
|
sampleRate: MONITOR_CAPTURE_SAMPLE_RATE,
|
|
sourceName: SCREEN_SHARE_MONITOR_SOURCE_NAME
|
|
};
|
|
}
|
|
|
|
export async function stopLinuxScreenShareMonitorCapture(captureId?: string): Promise<boolean> {
|
|
if (!monitorCaptureState.captureId || !monitorCaptureState.process) {
|
|
return true;
|
|
}
|
|
|
|
if (captureId && captureId !== monitorCaptureState.captureId) {
|
|
return false;
|
|
}
|
|
|
|
const currentCaptureId = monitorCaptureState.captureId;
|
|
const captureProcess = monitorCaptureState.process;
|
|
|
|
monitorCaptureState.stopRequested = true;
|
|
|
|
await new Promise<void>((resolve) => {
|
|
const forceKillTimeout = setTimeout(() => {
|
|
if (!captureProcess.killed) {
|
|
captureProcess.kill('SIGKILL');
|
|
}
|
|
}, MONITOR_CAPTURE_STOP_TIMEOUT_MS);
|
|
|
|
captureProcess.once('close', () => {
|
|
clearTimeout(forceKillTimeout);
|
|
resolve();
|
|
});
|
|
|
|
if (!captureProcess.killed) {
|
|
captureProcess.kill('SIGTERM');
|
|
return;
|
|
}
|
|
|
|
clearTimeout(forceKillTimeout);
|
|
resolve();
|
|
});
|
|
|
|
return monitorCaptureState.captureId !== currentCaptureId;
|
|
}
|
|
|
|
function buildRoutingInfo(
|
|
available: boolean,
|
|
active: boolean,
|
|
reason?: string
|
|
): LinuxScreenShareAudioRoutingInfo {
|
|
return {
|
|
available,
|
|
active,
|
|
monitorCaptureSupported: true,
|
|
screenShareSinkName: SCREEN_SHARE_SINK_NAME,
|
|
screenShareMonitorSourceName: SCREEN_SHARE_MONITOR_SOURCE_NAME,
|
|
voiceSinkName: VOICE_SINK_NAME,
|
|
...(reason ? { reason } : {})
|
|
};
|
|
}
|
|
|
|
async function isPactlAvailable(): Promise<boolean> {
|
|
if (pactlAvailableCache !== null) {
|
|
return pactlAvailableCache;
|
|
}
|
|
|
|
try {
|
|
await runPactl('info');
|
|
pactlAvailableCache = true;
|
|
} catch {
|
|
pactlAvailableCache = false;
|
|
}
|
|
|
|
return pactlAvailableCache;
|
|
}
|
|
|
|
async function runPactl(...args: string[]): Promise<string> {
|
|
const { stdout } = await execFileAsync('pactl', args, {
|
|
env: process.env
|
|
});
|
|
|
|
return stdout.trim();
|
|
}
|
|
|
|
async function ensureNullSink(sinkName: string): Promise<void> {
|
|
const sinks = await listSinks();
|
|
|
|
if (sinks.some((sink) => sink.name === sinkName)) {
|
|
return;
|
|
}
|
|
|
|
await runPactl(
|
|
'load-module',
|
|
'module-null-sink',
|
|
`sink_name=${sinkName}`,
|
|
`sink_properties=device.description=${sinkName}`
|
|
);
|
|
}
|
|
|
|
async function loadLoopbackModule(sourceName: string, sinkName: string): Promise<string> {
|
|
const moduleId = await runPactl(
|
|
'load-module',
|
|
'module-loopback',
|
|
`source=${sourceName}`,
|
|
`sink=${sinkName}`,
|
|
'latency_msec=10',
|
|
'source_dont_move=true',
|
|
'sink_dont_move=true'
|
|
);
|
|
|
|
return moduleId.split(/\s+/)[0] || moduleId;
|
|
}
|
|
|
|
async function unloadModuleIfLoaded(moduleId: string | null): Promise<void> {
|
|
if (!moduleId) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await runPactl('unload-module', moduleId);
|
|
} catch {
|
|
// Module may have already been unloaded externally.
|
|
}
|
|
}
|
|
|
|
async function getPreferredRestoreSinkName(): Promise<string | null> {
|
|
const defaultSinkName = await getDefaultSinkName();
|
|
|
|
if (defaultSinkName && defaultSinkName !== SCREEN_SHARE_SINK_NAME && defaultSinkName !== VOICE_SINK_NAME) {
|
|
return defaultSinkName;
|
|
}
|
|
|
|
const sinks = await listSinks();
|
|
|
|
return sinks.find((sink) => sink.name !== SCREEN_SHARE_SINK_NAME && sink.name !== VOICE_SINK_NAME)?.name ?? null;
|
|
}
|
|
|
|
async function getDefaultSinkName(): Promise<string | null> {
|
|
const info = await runPactl('info');
|
|
const defaultSinkLine = info
|
|
.split(/\r?\n/)
|
|
.find((line) => line.startsWith('Default Sink:'));
|
|
|
|
if (!defaultSinkLine) {
|
|
return null;
|
|
}
|
|
|
|
const sinkName = defaultSinkLine.replace('Default Sink:', '').trim();
|
|
|
|
return sinkName || null;
|
|
}
|
|
|
|
async function setDefaultSink(sinkName: string): Promise<void> {
|
|
await runPactl('set-default-sink', sinkName);
|
|
}
|
|
|
|
/**
|
|
* Combined reroute that enforces sink placement in both directions:
|
|
* - App-owned sink inputs that are NOT on the voice sink are moved there.
|
|
* - Non-app sink inputs that ARE on the voice sink are moved to the
|
|
* screenshare sink so they are captured by parec.
|
|
*
|
|
* This two-way approach, combined with the voice sink being the PulseAudio
|
|
* default, ensures that received WebRTC voice audio can never leak into the
|
|
* screenshare monitor source.
|
|
*/
|
|
async function rerouteSinkInputs(): Promise<void> {
|
|
const [
|
|
sinks,
|
|
sinkInputs,
|
|
descendantProcessInfo
|
|
] = await Promise.all([
|
|
listSinks(),
|
|
listSinkInputDetails(),
|
|
collectDescendantProcessInfo(process.pid)
|
|
]);
|
|
const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name]));
|
|
|
|
await Promise.all(
|
|
sinkInputs.map(async (sinkInput) => {
|
|
const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null;
|
|
const appOwned = isAppOwnedSinkInput(sinkInput, descendantProcessInfo);
|
|
|
|
// App-owned streams must stay on the voice sink.
|
|
if (appOwned && sinkName !== VOICE_SINK_NAME) {
|
|
try {
|
|
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
|
|
} catch {
|
|
// Streams can disappear or be recreated while rerouting.
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Non-app streams sitting on the voice sink should be moved to the
|
|
// screenshare sink for desktop-audio capture.
|
|
if (!appOwned && sinkName === VOICE_SINK_NAME) {
|
|
try {
|
|
await runPactl('move-sink-input', sinkInput.index, SCREEN_SHARE_SINK_NAME);
|
|
} catch {
|
|
// Streams can disappear or be recreated while rerouting.
|
|
}
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
function cleanupMonitorCaptureState(captureId: string, reason?: string): void {
|
|
if (monitorCaptureState.captureId !== captureId) {
|
|
return;
|
|
}
|
|
|
|
const target = monitorCaptureState.targetWebContents;
|
|
|
|
monitorCaptureState.captureId = null;
|
|
monitorCaptureState.process = null;
|
|
monitorCaptureState.stderr = '';
|
|
monitorCaptureState.stopRequested = false;
|
|
monitorCaptureState.targetWebContents = null;
|
|
|
|
if (!target || target.isDestroyed()) {
|
|
return;
|
|
}
|
|
|
|
target.send(MONITOR_AUDIO_ENDED_CHANNEL, {
|
|
captureId,
|
|
...(reason ? { reason } : {})
|
|
});
|
|
}
|
|
|
|
function buildMonitorCaptureCloseReason(
|
|
captureId: string,
|
|
code: number | null,
|
|
signal: NodeJS.Signals | null
|
|
): string | undefined {
|
|
if (monitorCaptureState.captureId !== captureId) {
|
|
return undefined;
|
|
}
|
|
|
|
if (monitorCaptureState.stopRequested) {
|
|
return undefined;
|
|
}
|
|
|
|
if (monitorCaptureState.stderr.trim()) {
|
|
return monitorCaptureState.stderr.trim();
|
|
}
|
|
|
|
if (signal) {
|
|
return `Linux screen-share monitor capture stopped with signal ${signal}.`;
|
|
}
|
|
|
|
if (typeof code === 'number' && code !== 0) {
|
|
return `Linux screen-share monitor capture exited with code ${code}.`;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function startSinkInputRerouteLoop(): void {
|
|
if (routingState.rerouteIntervalId) {
|
|
return;
|
|
}
|
|
|
|
routingState.rerouteIntervalId = setInterval(() => {
|
|
void rerouteSinkInputs();
|
|
}, REROUTE_INTERVAL_MS);
|
|
}
|
|
|
|
function stopSinkInputRerouteLoop(): void {
|
|
if (!routingState.rerouteIntervalId) {
|
|
return;
|
|
}
|
|
|
|
clearInterval(routingState.rerouteIntervalId);
|
|
routingState.rerouteIntervalId = null;
|
|
}
|
|
|
|
/**
|
|
* Spawns `pactl subscribe` to receive PulseAudio events in real time.
|
|
* When a new or changed sink-input is detected, a reroute is triggered
|
|
* immediately instead of waiting for the next interval tick. This
|
|
* drastically reduces the time non-app desktop audio spends on the
|
|
* voice sink before being moved to the screenshare sink.
|
|
*/
|
|
function startSubscribeWatcher(): void {
|
|
if (routingState.subscribeProcess) {
|
|
return;
|
|
}
|
|
|
|
let proc: ChildProcess;
|
|
|
|
try {
|
|
proc = spawn('pactl', ['subscribe'], {
|
|
env: process.env,
|
|
stdio: [
|
|
'ignore',
|
|
'pipe',
|
|
'ignore'
|
|
]
|
|
});
|
|
} catch {
|
|
// If pactl subscribe fails to spawn, the interval loop still covers us.
|
|
return;
|
|
}
|
|
|
|
routingState.subscribeProcess = proc;
|
|
|
|
let pending = false;
|
|
|
|
proc.stdout?.on('data', (chunk: Buffer) => {
|
|
if (!routingState.active) {
|
|
return;
|
|
}
|
|
|
|
const text = chunk.toString();
|
|
|
|
if (/Event '(?:new|change)' on sink-input/.test(text)) {
|
|
if (!pending) {
|
|
pending = true;
|
|
|
|
// Batch rapid-fire events with a short delay.
|
|
setTimeout(() => {
|
|
pending = false;
|
|
void rerouteSinkInputs();
|
|
}, 50);
|
|
}
|
|
}
|
|
});
|
|
|
|
proc.on('close', () => {
|
|
if (routingState.subscribeProcess === proc) {
|
|
routingState.subscribeProcess = null;
|
|
}
|
|
});
|
|
|
|
proc.on('error', () => {
|
|
if (routingState.subscribeProcess === proc) {
|
|
routingState.subscribeProcess = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
function stopSubscribeWatcher(): void {
|
|
const proc = routingState.subscribeProcess;
|
|
|
|
if (!proc) {
|
|
return;
|
|
}
|
|
|
|
routingState.subscribeProcess = null;
|
|
|
|
if (!proc.killed) {
|
|
proc.kill('SIGTERM');
|
|
}
|
|
}
|
|
|
|
function isAppOwnedSinkInput(
|
|
sinkInput: SinkInputDetails,
|
|
descendantProcessInfo: DescendantProcessInfo
|
|
): boolean {
|
|
const processId = sinkInput.properties['application.process.id'];
|
|
|
|
if (typeof processId === 'string' && descendantProcessInfo.ids.has(processId)) {
|
|
return true;
|
|
}
|
|
|
|
const processBinary = normalizeProcessBinary(sinkInput.properties['application.process.binary']);
|
|
|
|
if (processBinary && descendantProcessInfo.binaryNames.has(processBinary)) {
|
|
return true;
|
|
}
|
|
|
|
const applicationName = normalizeProcessBinary(sinkInput.properties['application.name']);
|
|
|
|
if (applicationName && descendantProcessInfo.binaryNames.has(applicationName)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function moveSinkInputs(
|
|
targetSinkName: string,
|
|
shouldMove: (sinkName: string | null) => boolean
|
|
): Promise<void> {
|
|
const [sinks, sinkInputs] = await Promise.all([listSinks(), listSinkInputs()]);
|
|
const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name]));
|
|
|
|
await Promise.all(
|
|
sinkInputs.map(async (sinkInput) => {
|
|
const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null;
|
|
|
|
if (!shouldMove(sinkName)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await runPactl('move-sink-input', sinkInput.index, targetSinkName);
|
|
} catch {
|
|
// Streams can disappear while iterating.
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
async function listSinks(): Promise<ShortSinkEntry[]> {
|
|
const output = await runPactl('list', 'short', 'sinks');
|
|
|
|
return output
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => line.split(/\s+/))
|
|
.filter((columns) => columns.length >= 2)
|
|
.map((columns) => ({
|
|
index: columns[0],
|
|
name: columns[1]
|
|
}));
|
|
}
|
|
|
|
async function listSinkInputs(): Promise<ShortSinkInputEntry[]> {
|
|
const output = await runPactl('list', 'short', 'sink-inputs');
|
|
|
|
return output
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => line.split(/\s+/))
|
|
.filter((columns) => columns.length >= 2)
|
|
.map((columns) => ({
|
|
index: columns[0],
|
|
sinkIndex: columns[1]
|
|
}));
|
|
}
|
|
|
|
async function listSinkInputDetails(): Promise<SinkInputDetails[]> {
|
|
try {
|
|
const output = await runPactl('--format=json', 'list', 'sink-inputs');
|
|
const entries = JSON.parse(output) as PactlJsonSinkInputEntry[];
|
|
|
|
if (!Array.isArray(entries)) {
|
|
return [];
|
|
}
|
|
|
|
return entries
|
|
.map((entry) => {
|
|
const index = typeof entry.index === 'number' || typeof entry.index === 'string'
|
|
? `${entry.index}`
|
|
: '';
|
|
const sinkIndex = typeof entry.sink === 'number' || typeof entry.sink === 'string'
|
|
? `${entry.sink}`
|
|
: '';
|
|
const properties = Object.fromEntries(
|
|
Object.entries(entry.properties || {}).map(([key, value]) => [key, typeof value === 'string' ? value : `${value ?? ''}`])
|
|
);
|
|
|
|
return {
|
|
index,
|
|
sinkIndex,
|
|
properties
|
|
} satisfies SinkInputDetails;
|
|
})
|
|
.filter((entry) => !!entry.index && !!entry.sinkIndex);
|
|
} catch {
|
|
// Fall back to the legacy text format parser below.
|
|
}
|
|
|
|
const output = await runPactl('list', 'sink-inputs');
|
|
const entries: SinkInputDetails[] = [];
|
|
|
|
let currentEntry: SinkInputDetails | null = null;
|
|
let parsingProperties = false;
|
|
|
|
const pushCurrentEntry = (): void => {
|
|
if (currentEntry) {
|
|
entries.push(currentEntry);
|
|
}
|
|
};
|
|
|
|
for (const rawLine of output.split(/\r?\n/)) {
|
|
const sinkInputMatch = rawLine.match(/^Sink Input #(\d+)/);
|
|
|
|
if (sinkInputMatch) {
|
|
pushCurrentEntry();
|
|
currentEntry = {
|
|
index: sinkInputMatch[1],
|
|
sinkIndex: '',
|
|
properties: {}
|
|
};
|
|
|
|
parsingProperties = false;
|
|
continue;
|
|
}
|
|
|
|
if (!currentEntry) {
|
|
continue;
|
|
}
|
|
|
|
const sinkMatch = rawLine.match(/^\s*Sink:\s*(\d+)/);
|
|
|
|
if (sinkMatch) {
|
|
currentEntry.sinkIndex = sinkMatch[1];
|
|
continue;
|
|
}
|
|
|
|
if (/^\s*Properties:\s*$/.test(rawLine)) {
|
|
parsingProperties = true;
|
|
continue;
|
|
}
|
|
|
|
if (!parsingProperties) {
|
|
continue;
|
|
}
|
|
|
|
if (rawLine.trim().length === 0) {
|
|
parsingProperties = false;
|
|
continue;
|
|
}
|
|
|
|
const propertyLine = rawLine.trim();
|
|
const separatorIndex = propertyLine.indexOf(' = ');
|
|
|
|
if (separatorIndex === -1) {
|
|
if (/^\S/.test(rawLine) || /^\s+\S[^=]*:\s*$/.test(rawLine)) {
|
|
parsingProperties = false;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
const key = propertyLine.slice(0, separatorIndex).trim();
|
|
const rawValue = propertyLine.slice(separatorIndex + 3).trim();
|
|
|
|
currentEntry.properties[key] = stripSurroundingQuotes(rawValue);
|
|
}
|
|
|
|
pushCurrentEntry();
|
|
|
|
return entries.filter((entry) => !!entry.sinkIndex);
|
|
}
|
|
|
|
async function collectDescendantProcessInfo(rootProcessId: number): Promise<DescendantProcessInfo> {
|
|
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid=,comm='], {
|
|
env: process.env
|
|
});
|
|
const childrenByParentId = new Map<string, string[]>();
|
|
const binaryNameByProcessId = new Map<string, string>();
|
|
|
|
stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.forEach((line) => {
|
|
const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
|
|
|
if (!match) {
|
|
return;
|
|
}
|
|
|
|
const [
|
|
,
|
|
pid,
|
|
ppid,
|
|
command
|
|
] = match;
|
|
const siblings = childrenByParentId.get(ppid) ?? [];
|
|
|
|
siblings.push(pid);
|
|
childrenByParentId.set(ppid, siblings);
|
|
|
|
const normalizedBinaryName = normalizeProcessBinary(command);
|
|
|
|
if (normalizedBinaryName) {
|
|
binaryNameByProcessId.set(pid, normalizedBinaryName);
|
|
}
|
|
});
|
|
|
|
const rootId = `${rootProcessId}`;
|
|
const descendantIds = new Set<string>([rootId]);
|
|
const descendantBinaryNames = new Set<string>();
|
|
const queue = [rootId];
|
|
|
|
while (queue.length > 0) {
|
|
const currentId = queue.shift();
|
|
|
|
if (!currentId) {
|
|
continue;
|
|
}
|
|
|
|
const binaryName = binaryNameByProcessId.get(currentId);
|
|
|
|
if (binaryName) {
|
|
descendantBinaryNames.add(binaryName);
|
|
}
|
|
|
|
for (const childId of childrenByParentId.get(currentId) ?? []) {
|
|
if (descendantIds.has(childId)) {
|
|
continue;
|
|
}
|
|
|
|
descendantIds.add(childId);
|
|
queue.push(childId);
|
|
}
|
|
}
|
|
|
|
return {
|
|
ids: descendantIds,
|
|
binaryNames: descendantBinaryNames
|
|
};
|
|
}
|
|
|
|
function normalizeProcessBinary(value: string | undefined): string | null {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = value.trim();
|
|
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const basename = trimmed
|
|
.split(/[\\/]/)
|
|
.pop()
|
|
?.trim()
|
|
.toLowerCase() ?? '';
|
|
|
|
return basename || null;
|
|
}
|
|
|
|
function stripSurroundingQuotes(value: string): string {
|
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
|
|
return value.slice(1, -1);
|
|
}
|
|
|
|
return value;
|
|
}
|