Screensharing rework
Split Linux screensharing audio tracks, Rework screensharing functionality and layout This will need some refactoring soon
This commit is contained in:
@@ -1,10 +1,18 @@
|
||||
import { app } from 'electron';
|
||||
import { readDesktopSettings } from '../desktop-settings';
|
||||
|
||||
export function configureAppFlags(): void {
|
||||
const desktopSettings = readDesktopSettings();
|
||||
|
||||
if (!desktopSettings.hardwareAcceleration) {
|
||||
app.disableHardwareAcceleration();
|
||||
}
|
||||
|
||||
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
|
||||
if (process.platform === 'linux') {
|
||||
app.commandLine.appendSwitch('no-sandbox');
|
||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||
app.commandLine.appendSwitch('enable-features', 'AudioServiceOutOfProcess');
|
||||
}
|
||||
|
||||
// Suppress Autofill devtools errors
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
|
||||
import {
|
||||
initializeDatabase,
|
||||
destroyDatabase,
|
||||
@@ -38,6 +39,7 @@ export function registerAppLifecycle(): void {
|
||||
app.on('before-quit', async (event) => {
|
||||
if (getDataSource()?.isInitialized) {
|
||||
event.preventDefault();
|
||||
await cleanupLinuxScreenShareAudioRouting();
|
||||
await destroyDatabase();
|
||||
app.quit();
|
||||
}
|
||||
|
||||
753
electron/audio/linux-screen-share-routing.ts
Normal file
753
electron/audio/linux-screen-share-routing.ts
Normal file
@@ -0,0 +1,753 @@
|
||||
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 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;
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
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);
|
||||
|
||||
await setDefaultSink(SCREEN_SHARE_SINK_NAME);
|
||||
await moveSinkInputs(SCREEN_SHARE_SINK_NAME, (sinkName) => !!sinkName && sinkName !== SCREEN_SHARE_SINK_NAME && sinkName !== VOICE_SINK_NAME);
|
||||
|
||||
routingState.active = true;
|
||||
await rerouteAppSinkInputsToVoiceSink();
|
||||
startSinkInputRerouteLoop();
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function rerouteAppSinkInputsToVoiceSink(): Promise<void> {
|
||||
const [
|
||||
sinks,
|
||||
sinkInputs,
|
||||
descendantProcessIds
|
||||
] = await Promise.all([
|
||||
listSinks(),
|
||||
listSinkInputDetails(),
|
||||
collectDescendantProcessIds(process.pid)
|
||||
]);
|
||||
const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name]));
|
||||
|
||||
await Promise.all(
|
||||
sinkInputs.map(async (sinkInput) => {
|
||||
if (!isAppOwnedSinkInput(sinkInput, descendantProcessIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null;
|
||||
|
||||
if (sinkName === VOICE_SINK_NAME) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await runPactl('move-sink-input', sinkInput.index, VOICE_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 rerouteAppSinkInputsToVoiceSink();
|
||||
}, REROUTE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopSinkInputRerouteLoop(): void {
|
||||
if (!routingState.rerouteIntervalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(routingState.rerouteIntervalId);
|
||||
routingState.rerouteIntervalId = null;
|
||||
}
|
||||
|
||||
function isAppOwnedSinkInput(
|
||||
sinkInput: SinkInputDetails,
|
||||
descendantProcessIds: ReadonlySet<string>
|
||||
): boolean {
|
||||
const processId = sinkInput.properties['application.process.id'];
|
||||
|
||||
return typeof processId === 'string' && descendantProcessIds.has(processId);
|
||||
}
|
||||
|
||||
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 collectDescendantProcessIds(rootProcessId: number): Promise<Set<string>> {
|
||||
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid='], {
|
||||
env: process.env
|
||||
});
|
||||
const childrenByParentId = new Map<string, string[]>();
|
||||
|
||||
stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
const [pid, ppid] = line.split(/\s+/);
|
||||
|
||||
if (!pid || !ppid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const siblings = childrenByParentId.get(ppid) ?? [];
|
||||
|
||||
siblings.push(pid);
|
||||
childrenByParentId.set(ppid, siblings);
|
||||
});
|
||||
|
||||
const rootId = `${rootProcessId}`;
|
||||
const descendantIds = new Set<string>([rootId]);
|
||||
const queue = [rootId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentId = queue.shift();
|
||||
|
||||
if (!currentId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const childId of childrenByParentId.get(currentId) ?? []) {
|
||||
if (descendantIds.has(childId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
descendantIds.add(childId);
|
||||
queue.push(childId);
|
||||
}
|
||||
}
|
||||
|
||||
return descendantIds;
|
||||
}
|
||||
|
||||
function stripSurroundingQuotes(value: string): string {
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
65
electron/desktop-settings.ts
Normal file
65
electron/desktop-settings.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { app } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface DesktopSettings {
|
||||
hardwareAcceleration: boolean;
|
||||
}
|
||||
|
||||
export interface DesktopSettingsSnapshot extends DesktopSettings {
|
||||
runtimeHardwareAcceleration: boolean;
|
||||
restartRequired: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||
hardwareAcceleration: true
|
||||
};
|
||||
|
||||
export function getDesktopSettingsSnapshot(): DesktopSettingsSnapshot {
|
||||
const storedSettings = readDesktopSettings();
|
||||
const runtimeHardwareAcceleration = app.isHardwareAccelerationEnabled();
|
||||
|
||||
return {
|
||||
...storedSettings,
|
||||
runtimeHardwareAcceleration,
|
||||
restartRequired: storedSettings.hardwareAcceleration !== runtimeHardwareAcceleration
|
||||
};
|
||||
}
|
||||
|
||||
export function readDesktopSettings(): DesktopSettings {
|
||||
const filePath = getDesktopSettingsPath();
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { ...DEFAULT_DESKTOP_SETTINGS };
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<DesktopSettings>;
|
||||
|
||||
return {
|
||||
hardwareAcceleration: typeof parsed.hardwareAcceleration === 'boolean'
|
||||
? parsed.hardwareAcceleration
|
||||
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_DESKTOP_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopSettingsSnapshot {
|
||||
const nextSettings: DesktopSettings = {
|
||||
...readDesktopSettings(),
|
||||
...patch
|
||||
};
|
||||
const filePath = getDesktopSettingsPath();
|
||||
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(nextSettings, null, 2), 'utf8');
|
||||
|
||||
return getDesktopSettingsSnapshot();
|
||||
}
|
||||
|
||||
function getDesktopSettingsPath(): string {
|
||||
return path.join(app.getPath('userData'), 'desktop-settings.json');
|
||||
}
|
||||
@@ -7,6 +7,14 @@ import {
|
||||
} from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as fsp from 'fs/promises';
|
||||
import { getDesktopSettingsSnapshot, updateDesktopSettings } from '../desktop-settings';
|
||||
import {
|
||||
activateLinuxScreenShareAudioRouting,
|
||||
deactivateLinuxScreenShareAudioRouting,
|
||||
prepareLinuxScreenShareAudioRouting,
|
||||
startLinuxScreenShareMonitorCapture,
|
||||
stopLinuxScreenShareMonitorCapture
|
||||
} from '../audio/linux-screen-share-routing';
|
||||
|
||||
export function setupSystemHandlers(): void {
|
||||
ipcMain.handle('open-external', async (_event, url: string) => {
|
||||
@@ -31,8 +39,40 @@ export function setupSystemHandlers(): void {
|
||||
}));
|
||||
});
|
||||
|
||||
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
|
||||
return await prepareLinuxScreenShareAudioRouting();
|
||||
});
|
||||
|
||||
ipcMain.handle('activate-linux-screen-share-audio-routing', async () => {
|
||||
return await activateLinuxScreenShareAudioRouting();
|
||||
});
|
||||
|
||||
ipcMain.handle('deactivate-linux-screen-share-audio-routing', async () => {
|
||||
return await deactivateLinuxScreenShareAudioRouting();
|
||||
});
|
||||
|
||||
ipcMain.handle('start-linux-screen-share-monitor-capture', async (event) => {
|
||||
return await startLinuxScreenShareMonitorCapture(event.sender);
|
||||
});
|
||||
|
||||
ipcMain.handle('stop-linux-screen-share-monitor-capture', async (_event, captureId?: string) => {
|
||||
return await stopLinuxScreenShareMonitorCapture(captureId);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
||||
|
||||
ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot());
|
||||
|
||||
ipcMain.handle('set-desktop-settings', (_event, patch: { hardwareAcceleration?: boolean }) => {
|
||||
return updateDesktopSettings(patch);
|
||||
});
|
||||
|
||||
ipcMain.handle('relaunch-app', () => {
|
||||
app.relaunch();
|
||||
app.exit(0);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('file-exists', async (_event, filePath: string) => {
|
||||
try {
|
||||
await fsp.access(filePath, fs.constants.F_OK);
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { Command, Query } from './cqrs/types';
|
||||
|
||||
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk';
|
||||
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface LinuxScreenShareMonitorAudioChunkPayload {
|
||||
captureId: string;
|
||||
chunk: Uint8Array;
|
||||
}
|
||||
|
||||
export interface LinuxScreenShareMonitorAudioEndedPayload {
|
||||
captureId: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
minimizeWindow: () => void;
|
||||
maximizeWindow: () => void;
|
||||
@@ -8,7 +39,25 @@ export interface ElectronAPI {
|
||||
|
||||
openExternal: (url: string) => Promise<boolean>;
|
||||
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
||||
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
||||
startLinuxScreenShareMonitorCapture: () => Promise<LinuxScreenShareMonitorCaptureInfo>;
|
||||
stopLinuxScreenShareMonitorCapture: (captureId?: string) => Promise<boolean>;
|
||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
getDesktopSettings: () => Promise<{
|
||||
hardwareAcceleration: boolean;
|
||||
runtimeHardwareAcceleration: boolean;
|
||||
restartRequired: boolean;
|
||||
}>;
|
||||
setDesktopSettings: (patch: { hardwareAcceleration?: boolean }) => Promise<{
|
||||
hardwareAcceleration: boolean;
|
||||
runtimeHardwareAcceleration: boolean;
|
||||
restartRequired: boolean;
|
||||
}>;
|
||||
relaunchApp: () => Promise<boolean>;
|
||||
readFile: (filePath: string) => Promise<string>;
|
||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
@@ -26,7 +75,44 @@ const electronAPI: ElectronAPI = {
|
||||
|
||||
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
||||
getSources: () => ipcRenderer.invoke('get-sources'),
|
||||
prepareLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('prepare-linux-screen-share-audio-routing'),
|
||||
activateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('activate-linux-screen-share-audio-routing'),
|
||||
deactivateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('deactivate-linux-screen-share-audio-routing'),
|
||||
startLinuxScreenShareMonitorCapture: () => ipcRenderer.invoke('start-linux-screen-share-monitor-capture'),
|
||||
stopLinuxScreenShareMonitorCapture: (captureId) => ipcRenderer.invoke('stop-linux-screen-share-monitor-capture', captureId),
|
||||
onLinuxScreenShareMonitorAudioChunk: (listener) => {
|
||||
const wrappedListener = (_event: Electron.IpcRendererEvent, payload: LinuxScreenShareMonitorAudioChunkPayload) => {
|
||||
const chunk = payload.chunk instanceof Uint8Array
|
||||
? payload.chunk
|
||||
: Uint8Array.from((payload as { chunk?: Iterable<number> }).chunk || []);
|
||||
|
||||
listener({
|
||||
...payload,
|
||||
chunk
|
||||
});
|
||||
};
|
||||
|
||||
ipcRenderer.on(LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL, wrappedListener);
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener(LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL, wrappedListener);
|
||||
};
|
||||
},
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener) => {
|
||||
const wrappedListener = (_event: Electron.IpcRendererEvent, payload: LinuxScreenShareMonitorAudioEndedPayload) => {
|
||||
listener(payload);
|
||||
};
|
||||
|
||||
ipcRenderer.on(LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL, wrappedListener);
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener(LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL, wrappedListener);
|
||||
};
|
||||
},
|
||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
||||
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
||||
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
|
||||
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
||||
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function createWindow(): Promise<void> {
|
||||
backgroundColor: '#0a0a0f',
|
||||
...(windowIconPath ? { icon: windowIconPath } : {}),
|
||||
webPreferences: {
|
||||
backgroundThrottling: false,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, '..', 'preload.js'),
|
||||
|
||||
Reference in New Issue
Block a user