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,249 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const {EventEmitter} = require('node:events');
|
||||
const {existsSync} = require('node:fs');
|
||||
const {join, sep} = require('node:path');
|
||||
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
|
||||
const MODULE_NAME = '@fluxer/win-process-loopback';
|
||||
|
||||
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(arch) {
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
return 'win-process-loopback.win32-x64-msvc.node';
|
||||
case 'arm64':
|
||||
return 'win-process-loopback.win32-arm64-msvc.node';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let binding = null;
|
||||
let loadError = null;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const nativeRoot = resolveNativeRoot();
|
||||
const fileName = nativeFileName(process.arch);
|
||||
if (!fileName) {
|
||||
loadError = createNativeLoadError({
|
||||
moduleName: MODULE_NAME,
|
||||
nativeRoot,
|
||||
packageDir: __dirname,
|
||||
reason: `unsupported Windows architecture: ${process.arch}`,
|
||||
});
|
||||
throw loadError;
|
||||
} else {
|
||||
const nativePath = join(nativeRoot, fileName);
|
||||
const loaded = loadNativeBinding({
|
||||
moduleName: MODULE_NAME,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir: __dirname,
|
||||
probe: false,
|
||||
});
|
||||
binding = loaded.binding;
|
||||
loadError = loaded.loadError;
|
||||
if (loadError) throw loadError;
|
||||
}
|
||||
} else {
|
||||
loadError = createNativeLoadError({
|
||||
moduleName: MODULE_NAME,
|
||||
nativeRoot: resolveNativeRoot(),
|
||||
packageDir: __dirname,
|
||||
reason: `not supported on platform ${process.platform}`,
|
||||
});
|
||||
}
|
||||
|
||||
function isSupported() {
|
||||
if (!binding) return false;
|
||||
try {
|
||||
return Boolean(binding.isSupported());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getBackendInfo() {
|
||||
if (!binding) {
|
||||
const reason =
|
||||
process.platform !== 'win32'
|
||||
? `@fluxer/win-process-loopback only supports Windows, got ${process.platform}. Microsoft's documented process-loopback minimum is Windows build 20348.`
|
||||
: `@fluxer/win-process-loopback native binary unavailable: ${loadError?.message ?? 'unknown reason'}`;
|
||||
return {
|
||||
backend: 'win-process-loopback',
|
||||
supported: false,
|
||||
reason,
|
||||
processSupported: false,
|
||||
systemSupported: false,
|
||||
systemExcludesSelf: false,
|
||||
processIncludeSupported: false,
|
||||
processExcludeSupported: false,
|
||||
sessionMixerSupported: false,
|
||||
systemLoopbackMode: 'unavailable',
|
||||
minWindowsBuild: 20348,
|
||||
minWindowsVersionLabel: 'Windows build 20348 (documented)',
|
||||
detectedWindowsBuild: undefined,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return binding.getBackendInfo();
|
||||
} catch (err) {
|
||||
return {
|
||||
backend: 'win-process-loopback',
|
||||
supported: false,
|
||||
reason: `@fluxer/win-process-loopback getBackendInfo failed: ${err?.message ?? String(err)}`,
|
||||
processSupported: false,
|
||||
systemSupported: false,
|
||||
systemExcludesSelf: false,
|
||||
processIncludeSupported: false,
|
||||
processExcludeSupported: false,
|
||||
sessionMixerSupported: false,
|
||||
systemLoopbackMode: 'unavailable',
|
||||
minWindowsBuild: 20348,
|
||||
minWindowsVersionLabel: 'Windows build 20348 (documented)',
|
||||
detectedWindowsBuild: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function pidFromHwnd(hwnd) {
|
||||
if (!binding) return 0;
|
||||
try {
|
||||
return binding.pidFromHwnd(hwnd);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAudioRootPid(pid) {
|
||||
if (!binding) return pid;
|
||||
try {
|
||||
return binding.resolveAudioRootPid(pid);
|
||||
} catch {
|
||||
return pid;
|
||||
}
|
||||
}
|
||||
|
||||
class ProcessLoopback extends EventEmitter {
|
||||
constructor(pid, opts = {}) {
|
||||
super();
|
||||
if (!binding) {
|
||||
throw loadError ?? new Error('@fluxer/win-process-loopback binding unavailable');
|
||||
}
|
||||
const captureScope = opts.captureScope ?? opts.winCaptureScope ?? opts.scope ?? 'process';
|
||||
const includeProcessTree =
|
||||
captureScope === 'system' || captureScope === 'session-mixer' ? false : opts.includeProcessTree !== false;
|
||||
const sampleRate = opts.sampleRate;
|
||||
const channels = opts.channels;
|
||||
this._stopped = false;
|
||||
this._started = false;
|
||||
this._startPromise = null;
|
||||
this._resolveStart = null;
|
||||
this._rejectStart = null;
|
||||
this._native = new binding.ProcessLoopback(
|
||||
pid,
|
||||
{includeProcessTree, sampleRate, channels, captureScope},
|
||||
(frame) => this.emit('frame', frame),
|
||||
(err) => {
|
||||
if (!this._started && this._rejectStart) {
|
||||
this._rejectStart(err);
|
||||
this._clearStartWaiters();
|
||||
}
|
||||
this.emit('error', err);
|
||||
},
|
||||
() => {
|
||||
if (this._stopped) return;
|
||||
this._stopped = true;
|
||||
if (!this._started && this._rejectStart) {
|
||||
this._rejectStart(new Error('ProcessLoopback closed before audio capture started'));
|
||||
this._clearStartWaiters();
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
this._native.dispose();
|
||||
} catch {}
|
||||
});
|
||||
this.emit('closed');
|
||||
},
|
||||
() => {
|
||||
this._started = true;
|
||||
if (this._resolveStart) {
|
||||
this._resolveStart();
|
||||
this._clearStartWaiters();
|
||||
}
|
||||
this.emit('started');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_clearStartWaiters() {
|
||||
this._resolveStart = null;
|
||||
this._rejectStart = null;
|
||||
}
|
||||
|
||||
setScreenAudioSink(handle) {
|
||||
if (typeof this._native.setScreenAudioSink !== 'function') return false;
|
||||
try {
|
||||
return this._native.setScreenAudioSink(handle) !== false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
clearScreenAudioSink() {
|
||||
if (typeof this._native.clearScreenAudioSink === 'function') {
|
||||
this._native.clearScreenAudioSink();
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this._stopped) return Promise.resolve();
|
||||
if (this._started) return Promise.resolve();
|
||||
if (this._startPromise) return this._startPromise;
|
||||
this._startPromise = new Promise((resolve, reject) => {
|
||||
this._resolveStart = resolve;
|
||||
this._rejectStart = reject;
|
||||
try {
|
||||
this._native.start();
|
||||
} catch (error) {
|
||||
this._clearStartWaiters();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
return this._startPromise;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._stopped) return;
|
||||
this._stopped = true;
|
||||
this.clearScreenAudioSink();
|
||||
if (!this._started && this._rejectStart) {
|
||||
this._rejectStart(new Error('ProcessLoopback stopped before audio capture started'));
|
||||
this._clearStartWaiters();
|
||||
}
|
||||
this._native.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function __setBindingForTests(nextBinding, nextLoadError = null) {
|
||||
binding = nextBinding;
|
||||
loadError = nextLoadError;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isSupported,
|
||||
getBackendInfo,
|
||||
pidFromHwnd,
|
||||
resolveAudioRootPid,
|
||||
ProcessLoopback,
|
||||
get loadError() {
|
||||
return loadError;
|
||||
},
|
||||
__setBindingForTests,
|
||||
};
|
||||
Reference in New Issue
Block a user