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,141 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import {copyFileSync} from 'node:fs';
|
||||
import {createRequire} from 'node:module';
|
||||
import {setTimeout as sleep} from 'node:timers/promises';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const work = process.env.FLX_WORK;
|
||||
const addonNode = `${work}/flx_direct_addon.node`;
|
||||
copyFileSync(process.env.FLX_ADDON_SO, addonNode);
|
||||
const addon = require(addonNode);
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({name, ok});
|
||||
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` -- ${detail}` : ''}`);
|
||||
}
|
||||
function pwDump() {
|
||||
return JSON.parse(execFileSync('pw-dump', {encoding: 'utf8', maxBuffer: 64e6}));
|
||||
}
|
||||
function directSinkNode(dump) {
|
||||
return dump.find(
|
||||
(o) => o.type === 'PipeWire:Interface:Node' && /^fluxer-direct-capture-/.test(o.info?.props?.['node.name'] || ''),
|
||||
);
|
||||
}
|
||||
async function waitFor(p, ms, step = 150) {
|
||||
const end = Date.now() + ms;
|
||||
let last;
|
||||
while (Date.now() < end) {
|
||||
last = p();
|
||||
if (last) return last;
|
||||
await sleep(step);
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
function rms(samples) {
|
||||
if (samples.length === 0) return 0;
|
||||
let sum = 0;
|
||||
for (const s of samples) sum += s * s;
|
||||
return Math.sqrt(sum / samples.length);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dc = new addon.DirectAudioCapture();
|
||||
let lifecycle = null;
|
||||
dc.setLifecycleCallback((...args) => {
|
||||
const flat = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
|
||||
lifecycle = {kind: flat[0], msg: flat[1]};
|
||||
});
|
||||
|
||||
const started = dc.start({include: [{'application.name': 'Music Player Demo'}]});
|
||||
record('DirectAudioCapture.start(include rule) accepted', started === true);
|
||||
|
||||
const sinkUp = await waitFor(() => {
|
||||
const d = pwDump();
|
||||
return directSinkNode(d) ? d : null;
|
||||
}, 8000);
|
||||
record(
|
||||
'hidden private sink fluxer-direct-capture-* created',
|
||||
!!sinkUp,
|
||||
sinkUp ? directSinkNode(sinkUp).info.props['node.name'] : 'timeout',
|
||||
);
|
||||
|
||||
if (sinkUp) {
|
||||
const sinkProps = directSinkNode(sinkUp).info.props;
|
||||
record(
|
||||
'private sink node.hidden=true (not user-visible)',
|
||||
String(sinkProps['node.hidden']) === 'true',
|
||||
`node.hidden=${sinkProps['node.hidden']}`,
|
||||
);
|
||||
record('private sink media.class=Audio/Sink', sinkProps['media.class'] === 'Audio/Sink', sinkProps['media.class']);
|
||||
}
|
||||
|
||||
let maxRms = 0;
|
||||
let frames = 0;
|
||||
let totalSamples = 0;
|
||||
const captureDeadline = Date.now() + 6000;
|
||||
while (Date.now() < captureDeadline) {
|
||||
const f = dc.read();
|
||||
if (f) {
|
||||
const samples = new Float32Array(f.samples);
|
||||
frames += 1;
|
||||
totalSamples += samples.length;
|
||||
maxRms = Math.max(maxRms, rms(samples));
|
||||
if (maxRms > 0.01 && frames > 5) break;
|
||||
}
|
||||
await sleep(20);
|
||||
}
|
||||
record('DirectAudioCapture yields real frames', frames > 0, `${frames} frames, ${totalSamples} samples`);
|
||||
record('captured audio is non-silent (real 440Hz tone tapped)', maxRms > 0.01, `peak rms=${maxRms.toFixed(4)}`);
|
||||
|
||||
const dump = pwDump();
|
||||
const sink = directSinkNode(dump);
|
||||
const sinkId = Number(sink?.id);
|
||||
const linkSrcNodes = new Set(
|
||||
dump
|
||||
.filter((o) => o.type === 'PipeWire:Interface:Link' && Number(o.info?.props?.['link.input.node']) === sinkId)
|
||||
.map((o) => Number(o.info?.props?.['link.output.node'])),
|
||||
);
|
||||
const fluxerStreamIds = dump
|
||||
.filter((o) => o.type === 'PipeWire:Interface:Node' && o.info?.props?.['application.name'] === 'Fluxer')
|
||||
.map((o) => Number(o.id));
|
||||
record(
|
||||
'Fluxer-named app excluded from per-process capture',
|
||||
fluxerStreamIds.every((id) => !linkSrcNodes.has(id)),
|
||||
`fluxer=${fluxerStreamIds} linkedSrc=${[...linkSrcNodes]}`,
|
||||
);
|
||||
|
||||
const musicIds = dump
|
||||
.filter((o) => o.type === 'PipeWire:Interface:Node' && o.info?.props?.['application.name'] === 'Music Player Demo')
|
||||
.map((o) => Number(o.id));
|
||||
record(
|
||||
'targeted app IS linked to the private sink',
|
||||
musicIds.some((id) => linkSrcNodes.has(id)),
|
||||
`music=${musicIds} linkedSrc=${[...linkSrcNodes]}`,
|
||||
);
|
||||
|
||||
dc.stop();
|
||||
await sleep(600);
|
||||
record('stop() emits closed-clean lifecycle', lifecycle?.kind === 'closed-clean', JSON.stringify(lifecycle));
|
||||
const afterStop = pwDump();
|
||||
const sinkAfter = directSinkNode(afterStop);
|
||||
const residualLinks = sinkAfter
|
||||
? afterStop.filter(
|
||||
(o) =>
|
||||
o.type === 'PipeWire:Interface:Link' && Number(o.info?.props?.['link.input.node']) === Number(sinkAfter.id),
|
||||
).length
|
||||
: 0;
|
||||
record('stop() removes capture links', residualLinks === 0, `residual=${residualLinks}`);
|
||||
|
||||
const failed = results.filter((r) => !r.ok).length;
|
||||
console.log(`\n=== direct: ${results.length - failed}/${results.length} checks passed ===`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('HARNESS ERROR:', e?.stack || e);
|
||||
process.exit(2);
|
||||
});
|
||||
Reference in New Issue
Block a user