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.
250 lines
6.3 KiB
JavaScript
250 lines
6.3 KiB
JavaScript
// 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,
|
|
};
|