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.
510 lines
16 KiB
JavaScript
510 lines
16 KiB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
const {existsSync} = require('node:fs');
|
|
const {join, sep} = require('node:path');
|
|
|
|
const MODULE_NAME = '@fluxer/webrtc-sender';
|
|
|
|
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(platform = process.platform, arch = process.arch) {
|
|
if ((arch !== 'x64' && arch !== 'arm64') || !['darwin', 'linux', 'win32'].includes(platform)) {
|
|
throw new Error(`${MODULE_NAME} not supported on ${platform}-${arch}`);
|
|
}
|
|
if (platform === 'darwin') return `webrtc-sender.darwin-${arch}.node`;
|
|
if (platform === 'linux') return `webrtc-sender.linux-${arch}-gnu.node`;
|
|
if (platform === 'win32') return `webrtc-sender.win32-${arch}-msvc.node`;
|
|
throw new Error(`${MODULE_NAME} not supported on ${platform}-${arch}`);
|
|
}
|
|
|
|
let binding = null;
|
|
let loadError = null;
|
|
|
|
try {
|
|
const nativePath = join(resolveNativeRoot(), nativeFileName());
|
|
if (existsSync(nativePath)) {
|
|
try {
|
|
binding = require(nativePath);
|
|
} catch (error) {
|
|
loadError = error instanceof Error ? error : new Error(String(error));
|
|
}
|
|
} else {
|
|
loadError = new Error(`${MODULE_NAME} native binary missing: ${nativePath}`);
|
|
}
|
|
} catch (error) {
|
|
loadError = error instanceof Error ? error : new Error(String(error));
|
|
}
|
|
|
|
function isSupported() {
|
|
return Boolean(binding);
|
|
}
|
|
|
|
function unavailableHardwareEncoderCapability(reason, detail) {
|
|
return {
|
|
available: false,
|
|
backend: 'none',
|
|
compiled: false,
|
|
runtime: false,
|
|
codecs: [],
|
|
zeroCopy: false,
|
|
nativeInputs: [],
|
|
reason,
|
|
detail,
|
|
};
|
|
}
|
|
|
|
function getHardwareEncoderCapability() {
|
|
if (!binding) {
|
|
return unavailableHardwareEncoderCapability(
|
|
'native_binding_unavailable',
|
|
loadError ? loadError.message : `${MODULE_NAME} binding unavailable`,
|
|
);
|
|
}
|
|
if (typeof binding.getHardwareEncoderCapability !== 'function') {
|
|
return unavailableHardwareEncoderCapability(
|
|
'native_capability_unavailable',
|
|
`${MODULE_NAME} native binding does not export getHardwareEncoderCapability`,
|
|
);
|
|
}
|
|
return binding.getHardwareEncoderCapability();
|
|
}
|
|
|
|
function getHardwareEncoderCapabilities() {
|
|
return getHardwareEncoderCapability();
|
|
}
|
|
|
|
function hasVoiceEngineMethod(name) {
|
|
const prototype = binding?.VoiceEngine?.prototype;
|
|
return Boolean(prototype && typeof prototype[name] === 'function');
|
|
}
|
|
|
|
function hasNativeCameraBackgrounds() {
|
|
if (!binding) return false;
|
|
if (typeof binding.hasNativeCameraBackgrounds !== 'function') return false;
|
|
return binding.hasNativeCameraBackgrounds() === true;
|
|
}
|
|
|
|
function getCapabilities() {
|
|
const hasVoiceEngine = Boolean(binding && typeof binding.VoiceEngine === 'function');
|
|
return {
|
|
microphoneCapture: hasVoiceEngineMethod('publishDeviceMicrophone'),
|
|
syntheticMicrophonePcm: hasVoiceEngineMethod('publishMicrophone'),
|
|
cameraCapture: hasVoiceEngineMethod('publishCamera') && hasVoiceEngineMethod('listCameraDevices'),
|
|
nativeCameraBackgrounds: hasNativeCameraBackgrounds(),
|
|
screenShare: hasVoiceEngineMethod('publishScreenShare') && hasVoiceEngineMethod('unpublishScreenShare'),
|
|
screenShareEncodingUpdate: hasVoiceEngineMethod('updateScreenShareEncoding'),
|
|
screenShareAudio:
|
|
hasVoiceEngineMethod('publishScreenShareAudio') &&
|
|
hasVoiceEngineMethod('pushScreenSharePcm') &&
|
|
hasVoiceEngineMethod('pushScreenShareFloat') &&
|
|
hasVoiceEngineMethod('unpublishScreenShareAudio'),
|
|
deviceLists: hasVoiceEngineMethod('listAudioInputDevices') && hasVoiceEngineMethod('listAudioOutputDevices'),
|
|
outputDeviceSelection: hasVoiceEngineMethod('setAudioOutputDevice'),
|
|
participantVolume: hasVoiceEngineMethod('setParticipantVolume'),
|
|
remoteTrackSubscription: hasVoiceEngineMethod('setRemoteTrackSubscription'),
|
|
dataChannel: hasVoiceEngineMethod('publishData'),
|
|
connectionStats: hasVoiceEngineMethod('getConnectionStats'),
|
|
nativeVideoFrames: hasVoiceEngineMethod('setVideoFrameCallback'),
|
|
hardwareEncoderCapabilities: hasVoiceEngine && typeof getHardwareEncoderCapability === 'function',
|
|
};
|
|
}
|
|
|
|
function requireScreenShareCaptureId(options, operation) {
|
|
if (!options || typeof options.captureId !== 'string' || options.captureId.trim().length === 0) {
|
|
throw new Error(`${operation} requires a non-empty captureId`);
|
|
}
|
|
}
|
|
|
|
function getEngineBridgeVersion() {
|
|
if (!binding) return null;
|
|
if (typeof binding.getEngineBridgeVersion !== 'function') return null;
|
|
return binding.getEngineBridgeVersion();
|
|
}
|
|
|
|
function assertEngineBridgeVersion(version) {
|
|
if (!binding) {
|
|
throw loadError || new Error(`${MODULE_NAME} binding unavailable`);
|
|
}
|
|
if (typeof binding.assertEngineBridgeVersion !== 'function') {
|
|
throw new Error(`${MODULE_NAME} native binding does not export assertEngineBridgeVersion`);
|
|
}
|
|
binding.assertEngineBridgeVersion(version);
|
|
}
|
|
|
|
function prewarmVoiceEngine() {
|
|
if (!binding) {
|
|
if (loadError) throw loadError;
|
|
return;
|
|
}
|
|
if (typeof binding.prewarmVoiceEngine === 'function') {
|
|
return binding.prewarmVoiceEngine();
|
|
}
|
|
}
|
|
|
|
function probeAudioDeviceModule() {
|
|
if (!binding) {
|
|
if (loadError) throw loadError;
|
|
return Promise.resolve(false);
|
|
}
|
|
if (typeof binding.probeAudioDeviceModule === 'function') {
|
|
return Promise.resolve(binding.probeAudioDeviceModule());
|
|
}
|
|
return Promise.resolve(true);
|
|
}
|
|
|
|
function normalizeCameraOptions(opts = {}) {
|
|
return {
|
|
deviceId: opts.deviceId,
|
|
width: opts.width,
|
|
height: opts.height,
|
|
frameRate: opts.frameRate,
|
|
mirror: opts.mirror,
|
|
backgroundMode: opts.backgroundMode,
|
|
backgroundCustomMediaPath: opts.backgroundCustomMediaPath,
|
|
backgroundCustomMediaKind: opts.backgroundCustomMediaKind,
|
|
backgroundBlurStrength: opts.backgroundBlurStrength,
|
|
codec: opts.codec,
|
|
maxBitrateBps: opts.maxBitrateBps,
|
|
maxFramerate: opts.maxFramerate,
|
|
};
|
|
}
|
|
|
|
class VoiceEngine {
|
|
constructor() {
|
|
if (!binding) {
|
|
throw loadError || new Error(`${MODULE_NAME} binding unavailable`);
|
|
}
|
|
this.native = new binding.VoiceEngine();
|
|
}
|
|
|
|
setEventCallback(callback) {
|
|
return this.native.setEventCallback(callback);
|
|
}
|
|
|
|
setVideoFrameCallback(callback) {
|
|
return this.native.setVideoFrameCallback(callback);
|
|
}
|
|
|
|
clearVideoFrameCallback() {
|
|
if (typeof this.native.clearVideoFrameCallback === 'function') {
|
|
return this.native.clearVideoFrameCallback();
|
|
}
|
|
if (typeof this.native.setVideoFrameCallback === 'function') {
|
|
return this.native.setVideoFrameCallback(() => {});
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
setCountInboundAudio(enabled) {
|
|
return this.native.setCountInboundAudio(enabled);
|
|
}
|
|
|
|
connect(url, token, e2eeKey, options) {
|
|
return this.native.connect(url, token, e2eeKey, options);
|
|
}
|
|
|
|
disconnect() {
|
|
return this.native.disconnect();
|
|
}
|
|
|
|
isConnected() {
|
|
return this.native.isConnected();
|
|
}
|
|
|
|
publishScreenShare(width, height, codec = '', maxBitrateBps, maxFramerate, simulcast, options) {
|
|
requireScreenShareCaptureId(options, 'Native screen-share publish');
|
|
return this.native.publishScreenShare(width, height, codec, maxBitrateBps, maxFramerate, simulcast, options);
|
|
}
|
|
|
|
updateScreenShareEncoding(width, height, maxBitrateBps, maxFramerate, options) {
|
|
requireScreenShareCaptureId(options, 'Native screen-share encoding update');
|
|
if (typeof this.native.updateScreenShareEncoding === 'function') {
|
|
return this.native.updateScreenShareEncoding(width, height, maxBitrateBps, maxFramerate, options);
|
|
}
|
|
return Promise.reject(new Error('Native screen-share encoding update is unavailable'));
|
|
}
|
|
|
|
createScreenFrameSinkHandle(captureId) {
|
|
if (typeof this.native.createScreenFrameSinkHandle !== 'function') return null;
|
|
return this.native.createScreenFrameSinkHandle(captureId);
|
|
}
|
|
|
|
createScreenAudioSinkHandle() {
|
|
if (typeof this.native.createScreenAudioSinkHandle !== 'function') return null;
|
|
return this.native.createScreenAudioSinkHandle();
|
|
}
|
|
|
|
unpublishScreenShare() {
|
|
return this.native.unpublishScreenShare();
|
|
}
|
|
|
|
isPublishingScreen() {
|
|
return this.native.isPublishingScreen();
|
|
}
|
|
|
|
publishScreenShareAudio(sampleRate, numChannels) {
|
|
return this.native.publishScreenShareAudio(sampleRate, numChannels);
|
|
}
|
|
|
|
pushScreenSharePcm(buffer, sampleRate, numChannels) {
|
|
return this.native.pushScreenSharePcm(buffer, sampleRate, numChannels);
|
|
}
|
|
|
|
pushScreenShareFloat(buffer, sampleRate, numChannels) {
|
|
return this.native.pushScreenShareFloat(buffer, sampleRate, numChannels);
|
|
}
|
|
|
|
unpublishScreenShareAudio() {
|
|
return this.native.unpublishScreenShareAudio();
|
|
}
|
|
|
|
isPublishingScreenAudio() {
|
|
return this.native.isPublishingScreenAudio();
|
|
}
|
|
|
|
publishMicrophone(sampleRate, numChannels) {
|
|
return this.native.publishMicrophone(sampleRate, numChannels);
|
|
}
|
|
|
|
publishDeviceMicrophone(opts = {}) {
|
|
if (typeof this.native.publishDeviceMicrophone !== 'function') {
|
|
throw new Error(`${MODULE_NAME} native binding does not export publishDeviceMicrophone`);
|
|
}
|
|
return this.native.publishDeviceMicrophone({
|
|
deviceId: opts.deviceId,
|
|
echoCancellation: opts.echoCancellation,
|
|
noiseSuppression: opts.noiseSuppression,
|
|
autoGainControl: opts.autoGainControl,
|
|
...(opts.deepFilter !== undefined ? {deepFilter: opts.deepFilter} : {}),
|
|
...(opts.deepFilterNoiseReductionLevel !== undefined
|
|
? {deepFilterNoiseReductionLevel: opts.deepFilterNoiseReductionLevel}
|
|
: {}),
|
|
maxBitrateBps: opts.maxBitrateBps,
|
|
});
|
|
}
|
|
|
|
pushPcm(buffer, sampleRate, numChannels) {
|
|
return this.native.pushPcm(buffer, sampleRate, numChannels);
|
|
}
|
|
|
|
setMicEnabled(enabled) {
|
|
return this.native.setMicEnabled(enabled);
|
|
}
|
|
|
|
setSpeakingDetection(localThresholdRms, remoteThresholdRms) {
|
|
if (typeof this.native.setSpeakingDetection !== 'function') {
|
|
throw new Error(`${MODULE_NAME} native binding does not export setSpeakingDetection`);
|
|
}
|
|
return this.native.setSpeakingDetection(localThresholdRms, remoteThresholdRms);
|
|
}
|
|
|
|
publishCamera(opts = {}) {
|
|
return this.native.publishCamera(normalizeCameraOptions(opts));
|
|
}
|
|
|
|
updateCameraCapture(opts = {}) {
|
|
if (typeof this.native.updateCameraCapture !== 'function') {
|
|
throw new Error(`${MODULE_NAME} native binding does not export updateCameraCapture`);
|
|
}
|
|
return this.native.updateCameraCapture(normalizeCameraOptions(opts));
|
|
}
|
|
|
|
publishProcessedCamera(opts) {
|
|
if (typeof this.native.publishProcessedCamera !== 'function') {
|
|
throw new Error(`${MODULE_NAME} native binding does not export publishProcessedCamera`);
|
|
}
|
|
return this.native.publishProcessedCamera({
|
|
width: opts.width,
|
|
height: opts.height,
|
|
frameRate: opts.frameRate,
|
|
});
|
|
}
|
|
|
|
publishNativeCameraSink(opts = {}) {
|
|
if (typeof this.native.publishNativeCameraSink !== 'function') {
|
|
throw new Error(`${MODULE_NAME} native binding does not export publishNativeCameraSink`);
|
|
}
|
|
return this.native.publishNativeCameraSink(normalizeCameraOptions(opts));
|
|
}
|
|
|
|
createCameraFrameSinkHandle() {
|
|
if (typeof this.native.createCameraFrameSinkHandle !== 'function') return null;
|
|
return this.native.createCameraFrameSinkHandle();
|
|
}
|
|
|
|
pushProcessedCameraFrame(frame) {
|
|
if (typeof this.native.pushProcessedCameraFrame !== 'function') {
|
|
throw new Error(`${MODULE_NAME} native binding does not export pushProcessedCameraFrame`);
|
|
}
|
|
return this.native.pushProcessedCameraFrame({
|
|
format: frame.format,
|
|
width: frame.width,
|
|
height: frame.height,
|
|
timestampUs: frame.timestampUs,
|
|
data: frame.data,
|
|
});
|
|
}
|
|
|
|
startCameraPreview(opts = {}) {
|
|
if (typeof this.native.startCameraPreview !== 'function') {
|
|
throw new Error(`${MODULE_NAME} native binding does not export startCameraPreview`);
|
|
}
|
|
return this.native.startCameraPreview(normalizeCameraOptions(opts));
|
|
}
|
|
|
|
stopCameraPreview() {
|
|
if (typeof this.native.stopCameraPreview !== 'function') {
|
|
throw new Error(`${MODULE_NAME} native binding does not export stopCameraPreview`);
|
|
}
|
|
return this.native.stopCameraPreview();
|
|
}
|
|
|
|
pushCameraBackgroundFrame(frame) {
|
|
if (typeof this.native.pushCameraBackgroundFrame !== 'function') {
|
|
throw new Error(`${MODULE_NAME} native binding does not export pushCameraBackgroundFrame`);
|
|
}
|
|
return this.native.pushCameraBackgroundFrame({
|
|
format: frame.format,
|
|
width: frame.width,
|
|
height: frame.height,
|
|
timestampUs: frame.timestampUs,
|
|
data: frame.data,
|
|
});
|
|
}
|
|
|
|
clearCameraBackgroundFrame() {
|
|
if (typeof this.native.clearCameraBackgroundFrame !== 'function') {
|
|
throw new Error(`${MODULE_NAME} native binding does not export clearCameraBackgroundFrame`);
|
|
}
|
|
return this.native.clearCameraBackgroundFrame();
|
|
}
|
|
|
|
publishDeviceScreenShare(opts = {}) {
|
|
if (typeof this.native.publishDeviceScreenShare !== 'function') {
|
|
throw new Error(`${MODULE_NAME} native binding does not export publishDeviceScreenShare`);
|
|
}
|
|
return this.native.publishDeviceScreenShare(normalizeCameraOptions(opts));
|
|
}
|
|
|
|
listCameraDevices() {
|
|
return this.native.listCameraDevices();
|
|
}
|
|
|
|
unpublishCamera() {
|
|
return this.native.unpublishCamera();
|
|
}
|
|
|
|
isPublishingCamera() {
|
|
return this.native.isPublishingCamera();
|
|
}
|
|
|
|
async listAudioInputDevices() {
|
|
const json = await this.native.listAudioInputDevices();
|
|
return JSON.parse(json);
|
|
}
|
|
|
|
droppedEngineEvents() {
|
|
if (typeof this.native.droppedEngineEvents !== 'function') return 0;
|
|
return this.native.droppedEngineEvents();
|
|
}
|
|
|
|
async listAudioOutputDevices() {
|
|
const json = await this.native.listAudioOutputDevices();
|
|
return JSON.parse(json);
|
|
}
|
|
|
|
setAudioOutputDevice(deviceId) {
|
|
return this.native.setAudioOutputDevice(deviceId || '');
|
|
}
|
|
|
|
ensurePlatformAudio() {
|
|
if (typeof this.native.ensurePlatformAudio !== 'function') return Promise.resolve();
|
|
return this.native.ensurePlatformAudio();
|
|
}
|
|
|
|
setParticipantVolume(participantSid, volume) {
|
|
return this.native.setParticipantVolume(participantSid, volume);
|
|
}
|
|
|
|
setRemoteTrackSubscription(opts = {}) {
|
|
if (typeof this.native.setRemoteTrackSubscription !== 'function') return Promise.resolve();
|
|
return this.native.setRemoteTrackSubscription(
|
|
opts.participantIdentity || '',
|
|
opts.source || '',
|
|
opts.subscribed === true,
|
|
opts.enabled !== false,
|
|
opts.quality || undefined,
|
|
);
|
|
}
|
|
|
|
publishData(payload, opts = {}) {
|
|
if (typeof this.native.publishData !== 'function') {
|
|
return Promise.reject(new Error(`${MODULE_NAME} native binding does not export publishData`));
|
|
}
|
|
const buffer = Buffer.isBuffer(payload)
|
|
? payload
|
|
: payload instanceof ArrayBuffer
|
|
? Buffer.from(payload)
|
|
: ArrayBuffer.isView(payload)
|
|
? Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength)
|
|
: null;
|
|
if (!buffer) {
|
|
return Promise.reject(new TypeError('publishData payload must be a Buffer, ArrayBuffer, or typed array'));
|
|
}
|
|
return this.native.publishData(
|
|
buffer,
|
|
opts.reliable !== false,
|
|
typeof opts.topic === 'string' ? opts.topic : undefined,
|
|
Array.isArray(opts.destinationIdentities) ? opts.destinationIdentities : undefined,
|
|
);
|
|
}
|
|
|
|
getConnectionStats() {
|
|
const json = this.native.getConnectionStats();
|
|
try {
|
|
return JSON.parse(json);
|
|
} catch {
|
|
return {rttMs: null, outbound: [], inbound: []};
|
|
}
|
|
}
|
|
|
|
inboundAudioFrames() {
|
|
return this.native.inboundAudioFrames();
|
|
}
|
|
|
|
inboundVideoFrames() {
|
|
return this.native.inboundVideoFrames();
|
|
}
|
|
|
|
droppedVideoFrameCallbacks() {
|
|
if (typeof this.native.droppedVideoFrameCallbacks !== 'function') return 0;
|
|
return this.native.droppedVideoFrameCallbacks();
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
isSupported,
|
|
getEngineBridgeVersion,
|
|
assertEngineBridgeVersion,
|
|
getHardwareEncoderCapability,
|
|
getHardwareEncoderCapabilities,
|
|
getCapabilities,
|
|
hasNativeCameraBackgrounds,
|
|
prewarmVoiceEngine,
|
|
probeAudioDeviceModule,
|
|
VoiceEngine,
|
|
get loadError() {
|
|
return loadError;
|
|
},
|
|
__nativeFileNameForTests: nativeFileName,
|
|
__setBindingForTests(next) {
|
|
binding = next;
|
|
loadError = null;
|
|
},
|
|
};
|