Files
fluxer-desktop-patched/fluxer_desktop/native/mac-app-audio/index.js
T
brenden 682afacd30 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.
2026-07-01 18:22:43 -04:00

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,
};