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,509 @@
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user