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,233 @@
|
||||
// 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/mac-screen-capture';
|
||||
|
||||
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-screen-capture is only supported on macOS, got ${process.platform}`);
|
||||
}
|
||||
switch (process.arch) {
|
||||
case 'x64':
|
||||
return 'mac-screen-capture.darwin-x64.node';
|
||||
case 'arm64':
|
||||
return 'mac-screen-capture.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 getBackendInfo() {
|
||||
if (!binding) {
|
||||
return {
|
||||
backend: 'mac-screen-capture',
|
||||
supported: false,
|
||||
reason:
|
||||
process.platform === 'darwin'
|
||||
? `@fluxer/mac-screen-capture native binary unavailable: ${loadError?.message ?? 'unknown reason'}`
|
||||
: `@fluxer/mac-screen-capture is only supported on macOS, got ${process.platform}`,
|
||||
minMacosVersion: '12.3',
|
||||
detectedMacosVersion: undefined,
|
||||
sckAvailable: false,
|
||||
};
|
||||
}
|
||||
return binding.getBackendInfo();
|
||||
}
|
||||
|
||||
function getBackendAvailability() {
|
||||
if (!binding) {
|
||||
return Promise.resolve({
|
||||
sck: {supported: false},
|
||||
screenPermission: 'not-determined',
|
||||
});
|
||||
}
|
||||
return binding.getBackendAvailability();
|
||||
}
|
||||
|
||||
function listSources() {
|
||||
if (!binding) return Promise.resolve([]);
|
||||
return binding.listSources();
|
||||
}
|
||||
|
||||
function __setBindingForTests(nextBinding) {
|
||||
binding = nextBinding;
|
||||
loadError = null;
|
||||
}
|
||||
|
||||
class ScreenCapture extends EventEmitter {
|
||||
constructor(options = {}) {
|
||||
super();
|
||||
if (!binding) {
|
||||
throw loadError || new Error('@fluxer/mac-screen-capture binding unavailable');
|
||||
}
|
||||
this.sourceId = options.sourceId;
|
||||
this.sourceKind = options.sourceKind ?? 'screen';
|
||||
this.width = options.width ?? 0;
|
||||
this.height = options.height ?? 0;
|
||||
this.frameRate = options.frameRate ?? 30;
|
||||
this.captureId = typeof options.captureId === 'string' ? options.captureId : undefined;
|
||||
this.colorRange = options.colorRange;
|
||||
this.colorSpace = options.colorSpace;
|
||||
this.showCursorClicks = options.showCursorClicks === true;
|
||||
this.captureRect = options.captureRect;
|
||||
this.frameSinkHandle = options.frameSinkHandle;
|
||||
this.nativeFrameSinkRequired = options.nativeFrameSinkRequired === true;
|
||||
this.started = false;
|
||||
this.stopped = false;
|
||||
this.closedEmitted = false;
|
||||
this.native = new binding.ScreenCapture();
|
||||
this.native.setLifecycleCallback((type, message) => {
|
||||
if (type === 'diagnostic') {
|
||||
if (this.stopped) return;
|
||||
this.emit('diagnostic', message);
|
||||
return;
|
||||
}
|
||||
if (type === 'error') {
|
||||
this.emit('error', new Error(message || 'macOS screen capture 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 {
|
||||
if (this.frameSinkHandle != null) {
|
||||
if (typeof this.native.setFrameSinkHandle !== 'function') {
|
||||
throw new Error('@fluxer/mac-screen-capture native binding does not support native frame sink handles');
|
||||
}
|
||||
this.native.setFrameSinkHandle(this.frameSinkHandle);
|
||||
} else if (this.nativeFrameSinkRequired) {
|
||||
throw new Error('Native frame sink handle is required for macOS screen capture');
|
||||
}
|
||||
const result = await this.native.start(
|
||||
this.sourceId,
|
||||
this.sourceKind,
|
||||
this.width,
|
||||
this.height,
|
||||
this.frameRate,
|
||||
this.captureId,
|
||||
{
|
||||
colorRange: this.colorRange,
|
||||
colorSpace: this.colorSpace,
|
||||
showCursorClicks: this.showCursorClicks,
|
||||
captureRect: this.captureRect,
|
||||
},
|
||||
);
|
||||
if (result) {
|
||||
this.width = result.width ?? this.width;
|
||||
this.height = result.height ?? this.height;
|
||||
this.frameRate = result.frameRate ?? this.frameRate;
|
||||
this.pixelFormat = result.pixelFormat ?? 'nv12';
|
||||
}
|
||||
return {
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
frameRate: this.frameRate,
|
||||
pixelFormat: this.pixelFormat ?? 'nv12',
|
||||
};
|
||||
} catch (error) {
|
||||
this.stopped = true;
|
||||
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.stopped) return;
|
||||
this.stopped = true;
|
||||
try {
|
||||
await this.native.stop();
|
||||
} finally {
|
||||
this.emitClosedOnce();
|
||||
}
|
||||
}
|
||||
|
||||
getFrameSinkDiagnostics() {
|
||||
if (!this.native || typeof this.native.getFrameSinkDiagnostics !== 'function') {
|
||||
return {
|
||||
accepted: 0,
|
||||
coalesced: 0,
|
||||
rejected: 0,
|
||||
mediaFramesDroppedWithoutSink: 0,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return this.native.getFrameSinkDiagnostics();
|
||||
} catch (error) {
|
||||
console.warn('[mac-screen-capture] getFrameSinkDiagnostics failed:', error?.message || error);
|
||||
return {
|
||||
accepted: 0,
|
||||
coalesced: 0,
|
||||
rejected: 0,
|
||||
mediaFramesDroppedWithoutSink: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ScreenCapture,
|
||||
getBackendAvailability,
|
||||
getBackendInfo,
|
||||
listSources,
|
||||
loadError,
|
||||
__setBindingForTests,
|
||||
};
|
||||
Reference in New Issue
Block a user