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.
464 lines
15 KiB
JavaScript
464 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {spawn} from 'node:child_process';
|
|
import {existsSync} from 'node:fs';
|
|
import {mkdir, readFile, writeFile} from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
const repoDir = new URL('..', import.meta.url).pathname;
|
|
const workspaceDir = path.resolve(repoDir, '..');
|
|
const args = new Set(process.argv.slice(2));
|
|
const mode = args.has('--strict')
|
|
? 'strict'
|
|
: args.has('--smoke')
|
|
? 'smoke'
|
|
: (process.env.FLUXER_NATIVE_MEDIA_MODE || 'smoke').toLowerCase();
|
|
const strict = mode === 'strict';
|
|
const packageManager = process.env.FLUXER_NATIVE_MEDIA_PACKAGE_MANAGER || 'pnpm';
|
|
const reportDir =
|
|
process.env.FLUXER_NATIVE_MEDIA_REPORT_DIR ||
|
|
path.join(repoDir, 'native-media-reports', new Date().toISOString().replace(/[:.]/g, '-'));
|
|
const livekitEnabled = strict || process.env.FLUXER_NATIVE_MEDIA_LIVEKIT !== '0';
|
|
const electronBuildEnabled = process.env.FLUXER_NATIVE_MEDIA_ELECTRON_BUILD === '1' || strict;
|
|
const strictCodecMatrix = ['vp8', 'vp9', 'h264', 'hevc', 'av1'];
|
|
const strictLiveKitFeatureFlags = [
|
|
'LIVEKIT_ENABLE_SECOND_PUBLISHER',
|
|
'LIVEKIT_ENABLE_MICROPHONE',
|
|
'LIVEKIT_ENABLE_SCREEN_AUDIO',
|
|
'LIVEKIT_ENABLE_CAMERA',
|
|
'LIVEKIT_ENABLE_DATA_PACKET',
|
|
'LIVEKIT_ENABLE_SUBSCRIPTION_CYCLE',
|
|
];
|
|
const strictLiveKitCredentialGroups = [
|
|
['LIVEKIT_URL', 'LIVEKIT_WS_URL'],
|
|
['LIVEKIT_API_KEY'],
|
|
['LIVEKIT_API_SECRET', 'LIVEKIT_SECRET'],
|
|
];
|
|
const validModes = new Set(['smoke', 'strict']);
|
|
|
|
function scriptCommand(directory, scriptName) {
|
|
return `${packageManager} --dir ${directory} ${scriptName}`;
|
|
}
|
|
|
|
function shellQuote(value) {
|
|
if (process.platform === 'win32') return `"${String(value).replace(/"/g, '""')}"`;
|
|
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
}
|
|
|
|
function findToolBinary(name) {
|
|
const executable = process.platform === 'win32' ? `${name}.cmd` : name;
|
|
const candidates = [
|
|
path.join(repoDir, 'node_modules', '.bin', executable),
|
|
path.join(workspaceDir, 'node_modules', '.bin', executable),
|
|
path.join(workspaceDir, 'node_modules', '.pnpm', 'node_modules', '.bin', executable),
|
|
];
|
|
const found = candidates.find((candidate) => existsSync(candidate));
|
|
return found ? shellQuote(found) : name;
|
|
}
|
|
|
|
function withDefaultEnv(defaults) {
|
|
const env = {...process.env};
|
|
if (env.CARGO_INCREMENTAL === undefined || env.CARGO_INCREMENTAL === '') {
|
|
env.CARGO_INCREMENTAL = '0';
|
|
}
|
|
for (const [key, value] of Object.entries(defaults)) {
|
|
if (env[key] === undefined || env[key] === '') {
|
|
env[key] = String(value);
|
|
}
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function commandEnv(defaults, overrides = {}) {
|
|
const env = withDefaultEnv(defaults);
|
|
for (const [key, value] of Object.entries(overrides)) {
|
|
env[key] = String(value);
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function envExplicitFalse(name) {
|
|
const value = process.env[name];
|
|
return value !== undefined && /^(0|false|no|off|disabled|skip)$/i.test(value.trim());
|
|
}
|
|
|
|
function envPresent(names) {
|
|
return names.some((name) => process.env[name]?.trim());
|
|
}
|
|
|
|
function parseCodecEnv(name) {
|
|
const value = process.env[name];
|
|
if (!value?.trim()) return null;
|
|
return value
|
|
.split(',')
|
|
.map((entry) => entry.trim().toLowerCase())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function codecListIncludes(codecs, codec) {
|
|
if (codec === 'hevc') return codecs.includes('hevc') || codecs.includes('h265');
|
|
return codecs.includes(codec);
|
|
}
|
|
|
|
function platformNativeCommands(platform = process.platform) {
|
|
if (platform === 'linux') {
|
|
return [
|
|
{
|
|
name: 'linux-screen-capture-build',
|
|
command: scriptCommand('native/linux-screen-capture', 'build'),
|
|
category: 'platform-native',
|
|
},
|
|
{
|
|
name: 'linux-screen-capture-tests',
|
|
command: scriptCommand('native/linux-screen-capture', 'test'),
|
|
category: 'platform-native',
|
|
},
|
|
{
|
|
name: 'linux-audio-capture-build',
|
|
command: scriptCommand('native/linux-audio-capture', 'build'),
|
|
category: 'platform-native',
|
|
},
|
|
{
|
|
name: 'linux-audio-capture-tests',
|
|
command: scriptCommand('native/linux-audio-capture', 'test'),
|
|
category: 'platform-native',
|
|
},
|
|
];
|
|
}
|
|
|
|
if (platform === 'darwin') {
|
|
return [
|
|
{
|
|
name: 'mac-screen-capture-build',
|
|
command: scriptCommand('native/mac-screen-capture', 'build'),
|
|
category: 'platform-native',
|
|
},
|
|
{
|
|
name: 'mac-screen-capture-tests',
|
|
command: scriptCommand('native/mac-screen-capture', 'test'),
|
|
category: 'platform-native',
|
|
},
|
|
{
|
|
name: 'mac-app-audio-build',
|
|
command: scriptCommand('native/mac-app-audio', 'build'),
|
|
category: 'platform-native',
|
|
},
|
|
{
|
|
name: 'mac-app-audio-rust-tests',
|
|
command: scriptCommand('native/mac-app-audio', 'test:cargo'),
|
|
category: 'platform-native',
|
|
},
|
|
{
|
|
name: 'mac-app-audio-js-tests',
|
|
command: `${findToolBinary('vitest')} run`,
|
|
cwd: 'native/mac-app-audio',
|
|
category: 'platform-native',
|
|
},
|
|
];
|
|
}
|
|
|
|
if (platform === 'win32') {
|
|
return [
|
|
{
|
|
name: 'win-game-capture-build',
|
|
command: scriptCommand('native/win-game-capture', 'build'),
|
|
category: 'platform-native',
|
|
},
|
|
{
|
|
name: 'win-game-capture-tests',
|
|
command: scriptCommand('native/win-game-capture', 'test'),
|
|
category: 'platform-native',
|
|
},
|
|
{
|
|
name: 'win-game-capture-fixtures',
|
|
command: scriptCommand('native/win-game-capture', 'test:fixtures'),
|
|
category: 'platform-native',
|
|
},
|
|
{
|
|
name: 'win-process-loopback-build',
|
|
command: scriptCommand('native/win-process-loopback', 'build'),
|
|
category: 'platform-native',
|
|
},
|
|
{
|
|
name: 'win-process-loopback-tests',
|
|
command: scriptCommand('native/win-process-loopback', 'test'),
|
|
category: 'platform-native',
|
|
},
|
|
];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function commandPlan() {
|
|
const commands = [
|
|
{
|
|
name: 'main-process-native-media-unit-tests',
|
|
command: 'node --test src/main/NativeVoiceEngine.test.mjs src/main/NativeScreenCapture.test.mjs',
|
|
category: 'shared',
|
|
},
|
|
{name: 'desktop-typecheck', command: `${packageManager} typecheck`, category: 'shared'},
|
|
{name: 'webrtc-sender-build', command: scriptCommand('native/webrtc-sender', 'build'), category: 'native-sender'},
|
|
{name: 'webrtc-sender-tests', command: scriptCommand('native/webrtc-sender', 'test'), category: 'native-sender'},
|
|
];
|
|
|
|
if (livekitEnabled) {
|
|
commands.push({
|
|
name: 'webrtc-sender-livekit-harness',
|
|
command: scriptCommand('native/webrtc-sender', 'test:livekit'),
|
|
category: 'livekit',
|
|
env: commandEnv(
|
|
{
|
|
LIVEKIT_HARNESS_STRICT: strict ? '1' : '0',
|
|
LIVEKIT_ENABLE_SECOND_PUBLISHER: strict ? '1' : '0',
|
|
LIVEKIT_ENABLE_SCREEN_AUDIO: strict ? '1' : '0',
|
|
LIVEKIT_ENABLE_DATA_PACKET: strict ? '1' : '0',
|
|
LIVEKIT_ENABLE_SUBSCRIPTION_CYCLE: strict ? '1' : '0',
|
|
LIVEKIT_ENABLE_CAMERA: strict ? '1' : '0',
|
|
LIVEKIT_ENABLE_MICROPHONE: '1',
|
|
LIVEKIT_SCREEN_CODECS: strict ? strictCodecMatrix.join(',') : 'vp8',
|
|
LIVEKIT_EXPECT_SCREEN_CODECS: strict ? strictCodecMatrix.join(',') : 'vp8',
|
|
LIVEKIT_HARNESS_REPORT_PATH: path.join(reportDir, 'livekit-harness.json'),
|
|
},
|
|
strict
|
|
? {
|
|
FLUXER_WEBRTC_SENDER_LIVEKIT_REQUIRED: '1',
|
|
LIVEKIT_REQUIRED: '1',
|
|
LIVEKIT_HARNESS_STRICT: '1',
|
|
LIVEKIT_ENABLE_SECOND_PUBLISHER: '1',
|
|
LIVEKIT_ENABLE_MICROPHONE: '1',
|
|
LIVEKIT_ENABLE_SCREEN_AUDIO: '1',
|
|
LIVEKIT_ENABLE_CAMERA: '1',
|
|
LIVEKIT_ENABLE_DATA_PACKET: '1',
|
|
LIVEKIT_ENABLE_SUBSCRIPTION_CYCLE: '1',
|
|
}
|
|
: {},
|
|
),
|
|
});
|
|
}
|
|
|
|
commands.push(...platformNativeCommands());
|
|
|
|
if (electronBuildEnabled) {
|
|
commands.push({name: 'electron-build', command: `${packageManager} build`, category: 'electron'});
|
|
}
|
|
|
|
return commands;
|
|
}
|
|
|
|
function makeGate(name, status, details = {}) {
|
|
return {
|
|
name,
|
|
status,
|
|
...details,
|
|
};
|
|
}
|
|
|
|
function modeGate() {
|
|
return makeGate(
|
|
'mode-valid',
|
|
validModes.has(mode) ? 'pass' : 'fail',
|
|
validModes.has(mode) ? {mode} : {mode, issues: [`FLUXER_NATIVE_MEDIA_MODE must be smoke or strict; got ${mode}`]},
|
|
);
|
|
}
|
|
|
|
async function documentationGate() {
|
|
const docPath = path.join(repoDir, 'native', 'NATIVE_MEDIA_INTEGRATION.md');
|
|
const packagePath = path.join(repoDir, 'package.json');
|
|
const missing = [];
|
|
try {
|
|
const [doc, packageJson] = await Promise.all([readFile(docPath, 'utf8'), readFile(packagePath, 'utf8')]);
|
|
const packageScripts = JSON.parse(packageJson).scripts ?? {};
|
|
const requiredSnippets = [
|
|
'FLUXER_NATIVE_MEDIA_MODE=strict',
|
|
'FLUXER_WEBRTC_SENDER_LIVEKIT_REQUIRED=1',
|
|
'LIVEKIT_URL',
|
|
'LIVEKIT_API_KEY',
|
|
'LIVEKIT_API_SECRET',
|
|
'FLUXER_NATIVE_MEDIA_REPORT_DIR',
|
|
'test:native-media:strict',
|
|
'fail-hard',
|
|
'macOS host',
|
|
'Windows 11 Parallels',
|
|
'Linux Parallels',
|
|
];
|
|
for (const snippet of requiredSnippets) {
|
|
if (!doc.includes(snippet)) missing.push(`native/NATIVE_MEDIA_INTEGRATION.md missing ${snippet}`);
|
|
}
|
|
if (packageScripts['test:native-media'] !== 'node scripts/native-media-integration.mjs') {
|
|
missing.push('package.json script test:native-media does not point at native-media-integration.mjs');
|
|
}
|
|
if (packageScripts['test:native-media:strict'] !== 'node scripts/native-media-integration.mjs --strict') {
|
|
missing.push('package.json script test:native-media:strict does not run strict native media integration');
|
|
}
|
|
} catch (error) {
|
|
missing.push(`documentation/package script validation failed: ${error.message}`);
|
|
}
|
|
return makeGate('documentation-current', missing.length === 0 ? 'pass' : strict ? 'fail' : 'warn', {missing});
|
|
}
|
|
|
|
function strictPrerequisiteGates() {
|
|
if (!strict) {
|
|
return [makeGate('strict-prerequisites', 'skip', {reason: 'smoke mode'})];
|
|
}
|
|
|
|
const gates = [];
|
|
const liveKitIssues = [];
|
|
if (envExplicitFalse('FLUXER_NATIVE_MEDIA_LIVEKIT')) {
|
|
liveKitIssues.push('FLUXER_NATIVE_MEDIA_LIVEKIT=0 is not allowed in strict mode');
|
|
}
|
|
for (const names of strictLiveKitCredentialGroups) {
|
|
if (!envPresent(names)) liveKitIssues.push(`missing ${names.join(' or ')}`);
|
|
}
|
|
for (const flag of strictLiveKitFeatureFlags) {
|
|
if (envExplicitFalse(flag)) liveKitIssues.push(`${flag}=0 is not allowed in strict mode`);
|
|
}
|
|
for (const codecEnvName of ['LIVEKIT_SCREEN_CODECS', 'LIVEKIT_EXPECT_SCREEN_CODECS']) {
|
|
const codecs = parseCodecEnv(codecEnvName);
|
|
if (!codecs) continue;
|
|
for (const codec of strictCodecMatrix) {
|
|
if (!codecListIncludes(codecs, codec)) {
|
|
liveKitIssues.push(`${codecEnvName} must include ${codec} in strict mode`);
|
|
}
|
|
}
|
|
}
|
|
gates.push(makeGate('livekit-required', liveKitIssues.length === 0 ? 'pass' : 'fail', {issues: liveKitIssues}));
|
|
|
|
const electronIssues = [];
|
|
if (envExplicitFalse('FLUXER_NATIVE_MEDIA_ELECTRON_BUILD')) {
|
|
electronIssues.push('FLUXER_NATIVE_MEDIA_ELECTRON_BUILD=0 is not allowed in strict mode');
|
|
}
|
|
gates.push(
|
|
makeGate('electron-build-required', electronIssues.length === 0 ? 'pass' : 'fail', {issues: electronIssues}),
|
|
);
|
|
|
|
const platformCommands = platformNativeCommands();
|
|
gates.push(
|
|
makeGate(
|
|
'platform-native-required',
|
|
platformCommands.length > 0 ? 'pass' : 'fail',
|
|
platformCommands.length > 0
|
|
? {platform: process.platform, commands: platformCommands.map((command) => command.name)}
|
|
: {
|
|
platform: process.platform,
|
|
issues: [`strict mode is only defined for linux, darwin, and win32; got ${process.platform}`],
|
|
},
|
|
),
|
|
);
|
|
|
|
gates.push(makeGate('summary-report-required', 'pass', {summaryPath: path.join(reportDir, 'summary.json')}));
|
|
return gates;
|
|
}
|
|
|
|
function commandPlanGate(commands) {
|
|
if (!strict) return makeGate('strict-command-plan', 'skip', {reason: 'smoke mode'});
|
|
|
|
const names = new Set(commands.map((command) => command.name));
|
|
const missing = [];
|
|
if (!names.has('webrtc-sender-livekit-harness')) missing.push('webrtc-sender-livekit-harness');
|
|
if (!names.has('electron-build')) missing.push('electron-build');
|
|
for (const command of platformNativeCommands()) {
|
|
if (!names.has(command.name)) missing.push(command.name);
|
|
}
|
|
return makeGate('strict-command-plan', missing.length === 0 ? 'pass' : 'fail', {missing});
|
|
}
|
|
|
|
async function evaluateGates(commands) {
|
|
return [modeGate(), await documentationGate(), ...strictPrerequisiteGates(), commandPlanGate(commands)];
|
|
}
|
|
|
|
function runCommand(step) {
|
|
const startedAtMs = Date.now();
|
|
return new Promise((resolve) => {
|
|
console.log(`[native-media] ${step.name}: ${step.command}`);
|
|
const child = spawn(step.command, {
|
|
cwd: step.cwd ? path.join(repoDir, step.cwd) : repoDir,
|
|
env: step.env ?? withDefaultEnv({}),
|
|
shell: true,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
});
|
|
let stdoutTail = '';
|
|
let stderrTail = '';
|
|
const appendTail = (current, chunk) => `${current}${chunk}`.slice(-12_000);
|
|
child.stdout.on('data', (chunk) => {
|
|
const text = chunk.toString();
|
|
stdoutTail = appendTail(stdoutTail, text);
|
|
process.stdout.write(text);
|
|
});
|
|
child.stderr.on('data', (chunk) => {
|
|
const text = chunk.toString();
|
|
stderrTail = appendTail(stderrTail, text);
|
|
process.stderr.write(text);
|
|
});
|
|
child.on('close', (code, signal) => {
|
|
const endedAtMs = Date.now();
|
|
resolve({
|
|
name: step.name,
|
|
command: step.command,
|
|
category: step.category ?? 'other',
|
|
status: code === 0 ? 'pass' : 'fail',
|
|
code,
|
|
signal,
|
|
startedAt: new Date(startedAtMs).toISOString(),
|
|
endedAt: new Date(endedAtMs).toISOString(),
|
|
durationMs: endedAtMs - startedAtMs,
|
|
stdoutTail,
|
|
stderrTail,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
async function writeSummary({startedAtMs, commands, results, gates, failedGate}) {
|
|
const failedCommand = results.find((result) => result.status === 'fail');
|
|
const report = {
|
|
status: failedGate || failedCommand ? 'fail' : 'pass',
|
|
mode,
|
|
strict,
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
hostname: os.hostname(),
|
|
startedAt: new Date(startedAtMs).toISOString(),
|
|
endedAt: new Date().toISOString(),
|
|
reportDir,
|
|
gates,
|
|
plannedCommands: commands.map((command) => ({
|
|
name: command.name,
|
|
command: command.command,
|
|
category: command.category ?? 'other',
|
|
cwd: command.cwd ?? '.',
|
|
})),
|
|
commands: results,
|
|
};
|
|
const summaryPath = path.join(reportDir, 'summary.json');
|
|
await writeFile(summaryPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
console.log(`[native-media] summary: ${summaryPath}`);
|
|
return report.status === 'pass' ? 0 : 1;
|
|
}
|
|
|
|
async function main() {
|
|
await mkdir(reportDir, {recursive: true});
|
|
const startedAtMs = Date.now();
|
|
const commands = commandPlan();
|
|
const gates = await evaluateGates(commands);
|
|
const failedGate = gates.find((gate) => gate.status === 'fail');
|
|
const results = [];
|
|
if (failedGate) {
|
|
console.error(`[native-media] FAIL: ${failedGate.name}`);
|
|
return writeSummary({startedAtMs, commands, results, gates, failedGate});
|
|
}
|
|
for (const command of commands) {
|
|
const result = await runCommand(command);
|
|
results.push(result);
|
|
if (result.status !== 'pass') {
|
|
break;
|
|
}
|
|
}
|
|
return writeSummary({startedAtMs, commands, results, gates});
|
|
}
|
|
|
|
process.exitCode = await main();
|