Add native self-hosted instance connection to fluxer_desktop
Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 + tools/ci) with a "Connect to a Different Server" menu item and popout that lets the desktop app switch to any self-hosted Fluxer instance, plus fixes for well-known discovery on single-domain self-hosted deployments and a false-positive ERR_ABORTED on same-origin client redirects during the switch. Defaults to chat.fluxr.chat and uses an isolated userData directory from the official build.
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const {EventEmitter} = require('node:events');
|
||||
const {existsSync, readdirSync, readFileSync} = require('node:fs');
|
||||
const {join, sep} = require('node:path');
|
||||
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
|
||||
const MODULE_NAME = '@fluxer/linux-audio-capture';
|
||||
const SKIP_NATIVE_PROBE_ENV = 'FLUXER_LINUX_AUDIO_CAPTURE_SKIP_NATIVE_PROBE';
|
||||
|
||||
function resolveNativeRoot() {
|
||||
const asarSegment = `${sep}app.asar${sep}`;
|
||||
if (!__dirname.includes(asarSegment)) return __dirname;
|
||||
const unpackedDir = __dirname.replace(asarSegment, `${sep}app.asar.unpacked${sep}`);
|
||||
return existsSync(unpackedDir) ? unpackedDir : __dirname;
|
||||
}
|
||||
|
||||
function nativeFileName() {
|
||||
if (process.platform !== 'linux') {
|
||||
throw new Error(`@fluxer/linux-audio-capture is only supported on Linux, got ${process.platform}`);
|
||||
}
|
||||
switch (process.arch) {
|
||||
case 'x64':
|
||||
return 'linux-audio-capture.linux-x64-gnu.node';
|
||||
case 'arm64':
|
||||
return 'linux-audio-capture.linux-arm64-gnu.node';
|
||||
default:
|
||||
throw new Error(`Unsupported Linux architecture: ${process.arch}`);
|
||||
}
|
||||
}
|
||||
|
||||
let binding;
|
||||
|
||||
try {
|
||||
const nativeRoot = resolveNativeRoot();
|
||||
const nativePath = join(nativeRoot, nativeFileName());
|
||||
const loadedNative = loadNativeBinding({
|
||||
moduleName: MODULE_NAME,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir: __dirname,
|
||||
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
|
||||
});
|
||||
if (loadedNative.loadError) {
|
||||
throw loadedNative.loadError;
|
||||
}
|
||||
binding = loadedNative.binding;
|
||||
} catch (error) {
|
||||
throw createNativeLoadError({
|
||||
moduleName: MODULE_NAME,
|
||||
nativeRoot: resolveNativeRoot(),
|
||||
packageDir: __dirname,
|
||||
reason: 'native loader threw before binding load completed',
|
||||
cause: error,
|
||||
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRoutingRule(rule) {
|
||||
if (!rule || typeof rule !== 'object') return {};
|
||||
const normalizeList = (value) =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
.filter((entry) => entry && typeof entry === 'object' && !Array.isArray(entry))
|
||||
.map((entry) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(entry)
|
||||
.filter(([, v]) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')
|
||||
.map(([k, v]) => [k, String(v)]),
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
include: normalizeList(rule.include),
|
||||
exclude: normalizeList(rule.exclude),
|
||||
workaround: normalizeList(rule.workaround),
|
||||
ignoreDevices: rule.ignoreDevices ?? rule.ignore_devices,
|
||||
onlySpeakers: rule.onlySpeakers ?? rule.only_speakers,
|
||||
onlyDefaultSpeakers: rule.onlyDefaultSpeakers ?? rule.only_default_speakers,
|
||||
};
|
||||
}
|
||||
|
||||
function readProcParentMap() {
|
||||
const parents = new Map();
|
||||
let entries = [];
|
||||
try {
|
||||
entries = readdirSync('/proc', {withFileTypes: true});
|
||||
} catch {
|
||||
return parents;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
|
||||
try {
|
||||
const stat = readFileSync(`/proc/${entry.name}/stat`, 'utf8');
|
||||
const end = stat.lastIndexOf(')');
|
||||
if (end < 0) continue;
|
||||
const fields = stat
|
||||
.slice(end + 1)
|
||||
.trim()
|
||||
.split(/\s+/);
|
||||
const parentPid = Number(fields[1]);
|
||||
if (Number.isSafeInteger(parentPid) && parentPid > 0) {
|
||||
parents.set(Number(entry.name), parentPid);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return parents;
|
||||
}
|
||||
|
||||
function isDescendantPid(pid, rootPid, parents) {
|
||||
let current = pid;
|
||||
const seen = new Set();
|
||||
while (parents.has(current) && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
const parent = parents.get(current);
|
||||
if (parent === rootPid) return true;
|
||||
current = parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function targetPidList(pid, includeProcessTree) {
|
||||
if (!includeProcessTree) return [pid];
|
||||
const parents = readProcParentMap();
|
||||
const pids = [pid];
|
||||
for (const candidate of parents.keys()) {
|
||||
if (candidate !== pid && isDescendantPid(candidate, pid, parents)) {
|
||||
pids.push(candidate);
|
||||
}
|
||||
}
|
||||
return pids;
|
||||
}
|
||||
|
||||
function appendUniquePattern(patterns, pattern) {
|
||||
if (!pattern || typeof pattern !== 'object') return;
|
||||
const entries = Object.entries(pattern).filter(([, value]) => typeof value === 'string' && value.length > 0);
|
||||
if (entries.length === 0) return;
|
||||
const normalized = Object.fromEntries(entries);
|
||||
const key = JSON.stringify(Object.entries(normalized).sort(([a], [b]) => a.localeCompare(b)));
|
||||
if (
|
||||
patterns.some((existing) => JSON.stringify(Object.entries(existing).sort(([a], [b]) => a.localeCompare(b))) === key)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
patterns.push(normalized);
|
||||
}
|
||||
|
||||
function inventoryPatternsForTargetPids(pids) {
|
||||
if (!(binding && typeof binding.AudioBridge === 'function')) return [];
|
||||
const wanted = new Set(pids.map((pid) => String(pid)));
|
||||
const patterns = [];
|
||||
let bridge = null;
|
||||
try {
|
||||
bridge = new binding.AudioBridge();
|
||||
const inventory = bridge.inventory();
|
||||
if (!Array.isArray(inventory)) return patterns;
|
||||
for (const props of inventory) {
|
||||
if (!props || typeof props !== 'object') continue;
|
||||
if (props['media.class'] !== 'Stream/Output/Audio') continue;
|
||||
const processId = props['application.process.id'] || props['pipewire.sec.pid'];
|
||||
if (!wanted.has(String(processId || ''))) continue;
|
||||
appendUniquePattern(patterns, {'object.serial': String(props['object.serial'] || '')});
|
||||
appendUniquePattern(patterns, {'node.name': String(props['node.name'] || '')});
|
||||
appendUniquePattern(patterns, {'client.id': String(props['client.id'] || '')});
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
try {
|
||||
bridge?.release?.();
|
||||
} catch {}
|
||||
}
|
||||
return patterns;
|
||||
}
|
||||
|
||||
function routingRuleFromTarget(target, options) {
|
||||
if (target && typeof target === 'object') {
|
||||
return normalizeRoutingRule(target.linuxRule || target);
|
||||
}
|
||||
const pid = Number(target);
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
||||
throw new TypeError('ProcessLoopback target pid must be a positive integer');
|
||||
}
|
||||
const targetPids = targetPidList(pid, Boolean(options?.includeProcessTree));
|
||||
const include = inventoryPatternsForTargetPids(targetPids);
|
||||
for (const candidate of targetPids) {
|
||||
appendUniquePattern(include, {'application.process.id': String(candidate)});
|
||||
appendUniquePattern(include, {'pipewire.sec.pid': String(candidate)});
|
||||
}
|
||||
return normalizeRoutingRule({
|
||||
include,
|
||||
ignoreDevices: options?.ignoreDevices ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
const LATE_SPAWN_REFRESH_INTERVAL_MS = 2_000;
|
||||
const MAX_DRAIN_FRAMES_PER_TICK = 16;
|
||||
const MAX_IDLE_DIRECT_CAPTURES = 2;
|
||||
const MIX_TICK_PERIOD_MS = 20;
|
||||
|
||||
let idleDirectCaptures = [];
|
||||
|
||||
function acquireDirectAudioCapture() {
|
||||
if (typeof binding.DirectAudioCapture !== 'function') {
|
||||
throw new Error('DirectAudioCapture native export missing');
|
||||
}
|
||||
const pooled = idleDirectCaptures.pop();
|
||||
return pooled ?? new binding.DirectAudioCapture();
|
||||
}
|
||||
|
||||
function releaseDirectAudioCapture(capture) {
|
||||
if (!capture || idleDirectCaptures.includes(capture)) return;
|
||||
if (idleDirectCaptures.length < MAX_IDLE_DIRECT_CAPTURES) {
|
||||
idleDirectCaptures.push(capture);
|
||||
}
|
||||
}
|
||||
|
||||
function clearIdleDirectCapturePool() {
|
||||
idleDirectCaptures = [];
|
||||
}
|
||||
|
||||
function patternsEqual(a, b) {
|
||||
if (a === b) return true;
|
||||
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
||||
if (a.length !== b.length) return false;
|
||||
const serialize = (entry) =>
|
||||
JSON.stringify(
|
||||
Object.entries(entry)
|
||||
.filter(([, v]) => typeof v === 'string')
|
||||
.sort(([x], [y]) => x.localeCompare(y)),
|
||||
);
|
||||
const aSet = a.map(serialize).sort();
|
||||
const bSet = b.map(serialize).sort();
|
||||
for (let i = 0; i < aSet.length; i++) {
|
||||
if (aSet[i] !== bSet[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
class ProcessLoopback extends EventEmitter {
|
||||
constructor(target, options = {}) {
|
||||
super();
|
||||
this.capture = acquireDirectAudioCapture();
|
||||
if (typeof this.capture.setLifecycleCallback === 'function') {
|
||||
this.capture.setLifecycleCallback((type, message) => this.handleNativeLifecycle(type, message));
|
||||
}
|
||||
this.targetPid = null;
|
||||
this.includeProcessTree = false;
|
||||
if (!(target && typeof target === 'object')) {
|
||||
const pid = Number(target);
|
||||
if (Number.isSafeInteger(pid) && pid > 0) {
|
||||
this.targetPid = pid;
|
||||
this.includeProcessTree = Boolean(options?.includeProcessTree);
|
||||
}
|
||||
}
|
||||
this.rule = routingRuleFromTarget(target, options);
|
||||
this.options = options;
|
||||
this.timer = null;
|
||||
this.refreshTimer = null;
|
||||
this.closed = false;
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
handleNativeLifecycle(type, message) {
|
||||
if (type === 'error') {
|
||||
this.emit('error', new Error(message || 'Linux direct audio capture stopped'));
|
||||
if (!this.closed) void this.stop();
|
||||
return;
|
||||
}
|
||||
if (type === 'closed' || type === 'closed-clean') {
|
||||
if (!this.closed) void this.stop();
|
||||
return;
|
||||
}
|
||||
if (type === 'diagnostic') {
|
||||
this.emit('diagnostic', message || '');
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.closed) {
|
||||
throw new Error('ProcessLoopback already closed');
|
||||
}
|
||||
if (this.started) return;
|
||||
if (!this.capture.start(this.rule)) {
|
||||
throw new Error('failed to start Linux direct audio capture');
|
||||
}
|
||||
this.started = true;
|
||||
this.timer = setInterval(() => this.tick(), MIX_TICK_PERIOD_MS);
|
||||
this.timer.unref?.();
|
||||
if (this.targetPid !== null && this.includeProcessTree && typeof this.capture.setRule === 'function') {
|
||||
this.refreshTimer = setInterval(() => this.refreshRuleForLateChildren(), LATE_SPAWN_REFRESH_INTERVAL_MS);
|
||||
this.refreshTimer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
refreshRuleForLateChildren() {
|
||||
if (this.closed || !this.started || this.targetPid === null) return;
|
||||
let nextRule;
|
||||
try {
|
||||
nextRule = routingRuleFromTarget(this.targetPid, {
|
||||
...(this.options ?? {}),
|
||||
includeProcessTree: this.includeProcessTree,
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (patternsEqual(nextRule.include, this.rule.include) && patternsEqual(nextRule.exclude, this.rule.exclude)) {
|
||||
return;
|
||||
}
|
||||
this.rule = nextRule;
|
||||
try {
|
||||
this.capture.setRule(this.rule);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (this.closed || !this.started) return;
|
||||
try {
|
||||
this.drainCaptureFrames();
|
||||
} catch (error) {
|
||||
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
||||
void this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
drainCaptureFrames() {
|
||||
for (let i = 0; i < MAX_DRAIN_FRAMES_PER_TICK; i++) {
|
||||
const frame = this.capture.read();
|
||||
if (!frame || !(frame.samples instanceof ArrayBuffer) || frame.samples.byteLength === 0) return;
|
||||
this.emit('frame', {
|
||||
samples: new Float32Array(frame.samples),
|
||||
sampleRate: frame.sampleRate,
|
||||
channels: frame.channels,
|
||||
timestampUs: frame.timestampUs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
routingGraph() {
|
||||
return this.capture && typeof this.capture.routingGraph === 'function' ? this.capture.routingGraph() : null;
|
||||
}
|
||||
|
||||
setScreenAudioSink(handle) {
|
||||
if (!this.capture || typeof this.capture.setScreenAudioSink !== 'function') return false;
|
||||
try {
|
||||
return this.capture.setScreenAudioSink(handle) !== false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
clearScreenAudioSink() {
|
||||
if (this.capture && typeof this.capture.clearScreenAudioSink === 'function') {
|
||||
this.capture.clearScreenAudioSink();
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.clearScreenAudioSink();
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
const wasStarted = this.started;
|
||||
this.started = false;
|
||||
const capture = this.capture;
|
||||
this.capture = null;
|
||||
let stopped = false;
|
||||
try {
|
||||
capture?.stop();
|
||||
stopped = true;
|
||||
} finally {
|
||||
if (stopped && wasStarted) {
|
||||
releaseDirectAudioCapture(capture);
|
||||
}
|
||||
this.emit('closed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AudioBridge: binding.AudioBridge,
|
||||
DirectAudioCapture: binding.DirectAudioCapture,
|
||||
AudioMixRuntimeHandle: binding.AudioMixRuntimeHandle,
|
||||
ProcessLoopback,
|
||||
pipeWireAvailable: binding.pipeWireAvailable,
|
||||
audioBackend: binding.audioBackend ?? (() => (binding.pipeWireAvailable?.() ? 'pipewire' : 'none')),
|
||||
__setBindingForTests(nextBinding) {
|
||||
binding = nextBinding;
|
||||
clearIdleDirectCapturePool();
|
||||
},
|
||||
__getIdleDirectCaptureCountForTests() {
|
||||
return idleDirectCaptures.length;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user