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.
209 lines
5.4 KiB
JavaScript
209 lines
5.4 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 {performance} = require('node:perf_hooks');
|
|
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
|
|
const MODULE_NAME = '@fluxer/mac-app-audio';
|
|
|
|
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 !== 'darwin') {
|
|
throw new Error(`@fluxer/mac-app-audio is only supported on macOS, got ${process.platform}`);
|
|
}
|
|
switch (process.arch) {
|
|
case 'x64':
|
|
return 'mac-app-audio.darwin-x64.node';
|
|
case 'arm64':
|
|
return 'mac-app-audio.darwin-arm64.node';
|
|
default:
|
|
throw new Error(`Unsupported macOS architecture: ${process.arch}`);
|
|
}
|
|
}
|
|
|
|
let binding = null;
|
|
let loadError = null;
|
|
|
|
if (process.platform === 'darwin') {
|
|
try {
|
|
const nativeRoot = resolveNativeRoot();
|
|
const nativePath = join(nativeRoot, nativeFileName());
|
|
const loaded = loadNativeBinding({
|
|
moduleName: MODULE_NAME,
|
|
nativePath,
|
|
nativeRoot,
|
|
packageDir: __dirname,
|
|
probe: false,
|
|
});
|
|
binding = loaded.binding;
|
|
loadError = loaded.loadError;
|
|
if (loadError) throw loadError;
|
|
} catch (error) {
|
|
loadError = createNativeLoadError({
|
|
moduleName: MODULE_NAME,
|
|
nativeRoot: resolveNativeRoot(),
|
|
packageDir: __dirname,
|
|
reason: 'native loader threw before binding load completed',
|
|
cause: error,
|
|
});
|
|
throw loadError;
|
|
}
|
|
}
|
|
|
|
function pidFromWindowId(windowId) {
|
|
if (!binding) return 0;
|
|
return binding.pidFromWindowId(windowId);
|
|
}
|
|
|
|
function listAudibleApplications() {
|
|
if (!binding) return Promise.resolve([]);
|
|
return binding.listAudibleApplications();
|
|
}
|
|
|
|
function getBackendInfo() {
|
|
if (!binding) {
|
|
return {
|
|
backend: 'mac-app-audio',
|
|
supported: false,
|
|
reason:
|
|
process.platform === 'darwin'
|
|
? `@fluxer/mac-app-audio native binary unavailable: ${loadError?.message ?? 'unknown reason'}`
|
|
: `@fluxer/mac-app-audio is only supported on macOS, got ${process.platform}`,
|
|
minMacosVersion: '12.3',
|
|
minMacosVersionCoreaudio: '14.2',
|
|
detectedMacosVersion: undefined,
|
|
sckAvailable: false,
|
|
coreaudioAvailable: false,
|
|
};
|
|
}
|
|
return binding.getBackendInfo();
|
|
}
|
|
|
|
function getBackendAvailability() {
|
|
if (!binding) {
|
|
return Promise.resolve({
|
|
sck: {supported: false},
|
|
coreaudio: {supported: false},
|
|
screenPermission: 'not-determined',
|
|
audioPermission: 'not-determined',
|
|
});
|
|
}
|
|
return binding.getBackendAvailability();
|
|
}
|
|
|
|
function __setBindingForTests(nextBinding) {
|
|
binding = nextBinding;
|
|
loadError = null;
|
|
}
|
|
|
|
class ProcessLoopback extends EventEmitter {
|
|
constructor(pid, options = {}) {
|
|
super();
|
|
if (!binding) {
|
|
throw loadError || new Error('@fluxer/mac-app-audio binding unavailable');
|
|
}
|
|
this.pid = pid;
|
|
void options.excludeSelf;
|
|
this.excludeSelf = true;
|
|
this.includeProcessTree = options.includeProcessTree ?? true;
|
|
this.backend = options.backend ?? options.macBackend ?? 'auto';
|
|
this.captureScope = options.captureScope ?? options.macCaptureScope ?? options.scope ?? 'process';
|
|
this.started = false;
|
|
this.stopped = false;
|
|
this.closedEmitted = false;
|
|
this.nextTimestampUs = Math.round(performance.now() * 1000);
|
|
this.native = new binding.ProcessLoopback();
|
|
this.native.setFrameCallback((samples) => {
|
|
if (this.stopped) return;
|
|
const copied = new Float32Array(samples);
|
|
const numFrames = copied.length / 2;
|
|
const frame = {
|
|
samples: copied,
|
|
sampleRate: 48000,
|
|
channels: 2,
|
|
timestampUs: this.nextTimestampUs,
|
|
};
|
|
this.nextTimestampUs += Math.round((numFrames / frame.sampleRate) * 1_000_000);
|
|
this.emit('frame', frame);
|
|
});
|
|
this.native.setLifecycleCallback((type, message) => {
|
|
if (type === 'error') {
|
|
this.emit('error', new Error(message || 'macOS app audio stream stopped'));
|
|
return;
|
|
}
|
|
if (type === 'closed') {
|
|
if (this.stopped) {
|
|
this.emitClosedOnce();
|
|
return;
|
|
}
|
|
this.stopped = true;
|
|
Promise.resolve()
|
|
.then(() => this.native.stop())
|
|
.catch(() => {});
|
|
this.emitClosedOnce();
|
|
}
|
|
});
|
|
}
|
|
|
|
emitClosedOnce() {
|
|
if (this.closedEmitted) return;
|
|
this.closedEmitted = true;
|
|
this.emit('closed');
|
|
}
|
|
|
|
async start() {
|
|
if (this.started || this.stopped) return;
|
|
this.started = true;
|
|
try {
|
|
await this.native.start(this.pid, this.excludeSelf, this.includeProcessTree, this.backend, this.captureScope);
|
|
} catch (error) {
|
|
this.stopped = true;
|
|
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
async stop() {
|
|
if (this.stopped) return;
|
|
this.stopped = true;
|
|
this.clearScreenAudioSink();
|
|
try {
|
|
await this.native.stop();
|
|
} finally {
|
|
this.emitClosedOnce();
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
ProcessLoopback,
|
|
listAudibleApplications,
|
|
getBackendAvailability,
|
|
getBackendInfo,
|
|
pidFromWindowId,
|
|
loadError,
|
|
__setBindingForTests,
|
|
};
|