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.
142 lines
4.6 KiB
JavaScript
142 lines
4.6 KiB
JavaScript
// 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);
|
|
});
|