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,674 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import {createRequire} from 'node:module';
|
||||
import * as path from 'node:path';
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
const ROOT_DIR = path.resolve(import.meta.dirname, '..');
|
||||
const SRC_DIR = path.join(ROOT_DIR, 'src');
|
||||
const DIST_DIR = path.join(ROOT_DIR, 'dist');
|
||||
const NATIVE_DIR = path.join(ROOT_DIR, 'native');
|
||||
const requireModule = createRequire(import.meta.url);
|
||||
const isProduction =
|
||||
process.env.NODE_ENV === 'production' ||
|
||||
process.env.FLUXER_DESKTOP_PRODUCTION === 'true' ||
|
||||
process.env.GITHUB_ACTIONS === 'true';
|
||||
const skipNative = process.env.FLUXER_SKIP_NATIVE === 'true';
|
||||
const embeddedBuildVersion = process.env.PUBLIC_BUILD_VERSION || process.env.BUILD_VERSION || '';
|
||||
const embeddedReleaseChannel = process.env.PUBLIC_RELEASE_CHANNEL || process.env.RELEASE_CHANNEL || '';
|
||||
const requestedDesktopBuildVariant = process.env.FLUXER_DESKTOP_BUILD_VARIANT || process.env.DESKTOP_VARIANT || '';
|
||||
const windowsGameCaptureModuleEnabled =
|
||||
requestedDesktopBuildVariant === 'windows-game-capture' ||
|
||||
process.env.FLUXER_WINDOWS_GAME_CAPTURE_MODULE_ENABLED === 'true';
|
||||
const embeddedDesktopBuildVariant = windowsGameCaptureModuleEnabled ? 'windows-game-capture' : 'default';
|
||||
const publicBuildDefines = {
|
||||
'process.env.PUBLIC_BUILD_VERSION': JSON.stringify(embeddedBuildVersion),
|
||||
'process.env.BUILD_VERSION': JSON.stringify(embeddedBuildVersion),
|
||||
'process.env.PUBLIC_RELEASE_CHANNEL': JSON.stringify(embeddedReleaseChannel),
|
||||
'process.env.RELEASE_CHANNEL': JSON.stringify(embeddedReleaseChannel),
|
||||
'process.env.FLUXER_DESKTOP_BUILD_VARIANT': JSON.stringify(embeddedDesktopBuildVariant),
|
||||
'process.env.FLUXER_WINDOWS_GAME_CAPTURE_MODULE_ENABLED': JSON.stringify(
|
||||
windowsGameCaptureModuleEnabled ? 'true' : 'false',
|
||||
),
|
||||
};
|
||||
const electronExternals = [
|
||||
'electron',
|
||||
'electron-log',
|
||||
'update-electron-app',
|
||||
'velopack',
|
||||
'@fluxer/webauthn',
|
||||
'@fluxer/webrtc-sender',
|
||||
'node-mac-permissions',
|
||||
'hunspell-asm',
|
||||
];
|
||||
const pathAliasPlugin = {
|
||||
name: 'path-alias',
|
||||
setup(build) {
|
||||
build.onResolve({filter: /^@electron\//}, (args) => {
|
||||
const relativePath = args.path.replace(/^@electron\//, '');
|
||||
const absolutePath = path.join(SRC_DIR, relativePath);
|
||||
const extensions = ['.tsx', '.ts', '.js', '.jsx'];
|
||||
for (const ext of extensions) {
|
||||
const fullPath = absolutePath + ext;
|
||||
if (fs.existsSync(fullPath)) {
|
||||
return {path: fullPath};
|
||||
}
|
||||
}
|
||||
for (const ext of extensions) {
|
||||
const indexPath = path.join(absolutePath, `index${ext}`);
|
||||
if (fs.existsSync(indexPath)) {
|
||||
return {path: indexPath};
|
||||
}
|
||||
}
|
||||
return {path: `${absolutePath}.tsx`};
|
||||
});
|
||||
build.onResolve({filter: /^@fluxer\/voice_engine_v2(?:\/.*)?$/}, (args) => {
|
||||
const packageSrcDir = path.join(ROOT_DIR, '..', 'packages', 'voice_engine_v2', 'src');
|
||||
if (args.path === '@fluxer/voice_engine_v2') {
|
||||
return {path: path.join(packageSrcDir, 'index.ts')};
|
||||
}
|
||||
const relativePath = args.path.replace(/^@fluxer\/voice_engine_v2\//, '');
|
||||
const directTsPath = path.join(packageSrcDir, `${relativePath}.ts`);
|
||||
if (fs.existsSync(directTsPath)) {
|
||||
return {path: directTsPath};
|
||||
}
|
||||
const indexTsPath = path.join(packageSrcDir, relativePath, 'index.ts');
|
||||
if (fs.existsSync(indexTsPath)) {
|
||||
return {path: indexTsPath};
|
||||
}
|
||||
return {path: path.join(packageSrcDir, relativePath)};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function findNodeBinary(rootDir) {
|
||||
const matches = [];
|
||||
for (const entry of fs.readdirSync(rootDir)) {
|
||||
if (entry.endsWith('.node')) matches.push(path.join(rootDir, entry));
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
const ROOT_BIN_DIR = path.join(ROOT_DIR, 'node_modules', '.bin');
|
||||
|
||||
function toPackagePathParts(packageName) {
|
||||
const parts = packageName.split('/');
|
||||
if (parts.length === 2 && parts[0].startsWith('@')) {
|
||||
return parts;
|
||||
}
|
||||
return [packageName];
|
||||
}
|
||||
|
||||
function addExistingPackageDir(packageDirs, packageDir) {
|
||||
if (!packageDir || !fs.existsSync(path.join(packageDir, 'package.json'))) {
|
||||
return;
|
||||
}
|
||||
const realPath = fs.realpathSync.native(packageDir);
|
||||
packageDirs.set(realPath, packageDir);
|
||||
}
|
||||
|
||||
function findInstalledPackageDirs(packageName) {
|
||||
const packageDirs = new Map();
|
||||
const packagePathParts = toPackagePathParts(packageName);
|
||||
try {
|
||||
addExistingPackageDir(
|
||||
packageDirs,
|
||||
path.dirname(requireModule.resolve(`${packageName}/package.json`, {paths: [ROOT_DIR]})),
|
||||
);
|
||||
} catch {}
|
||||
addExistingPackageDir(packageDirs, path.join(ROOT_DIR, 'node_modules', ...packagePathParts));
|
||||
const pnpmRoot = path.join(ROOT_DIR, 'node_modules', '.pnpm');
|
||||
if (fs.existsSync(pnpmRoot)) {
|
||||
for (const entry of fs.readdirSync(pnpmRoot, {withFileTypes: true})) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
addExistingPackageDir(packageDirs, path.join(pnpmRoot, entry.name, 'node_modules', ...packagePathParts));
|
||||
}
|
||||
}
|
||||
return Array.from(packageDirs.keys());
|
||||
}
|
||||
|
||||
function addFilesFromDirectory(files, packageDir, relativeDir, predicate) {
|
||||
const absoluteDir = path.join(packageDir, relativeDir);
|
||||
if (!fs.existsSync(absoluteDir)) return;
|
||||
for (const entry of fs.readdirSync(absoluteDir, {withFileTypes: true})) {
|
||||
const relativePath = path.join(relativeDir, entry.name);
|
||||
const absolutePath = path.join(packageDir, relativePath);
|
||||
if (entry.isDirectory()) {
|
||||
addFilesFromDirectory(files, packageDir, relativePath, predicate);
|
||||
} else if (predicate(relativePath, absolutePath)) {
|
||||
files.add(relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectRuntimeArtifactPaths(packageDir) {
|
||||
const artifacts = new Set();
|
||||
for (const fileName of ['index.js', 'index.d.ts', 'binding.js', 'binding.d.ts', 'loader-diagnostics.cjs']) {
|
||||
if (fs.existsSync(path.join(packageDir, fileName))) {
|
||||
artifacts.add(fileName);
|
||||
}
|
||||
}
|
||||
addFilesFromDirectory(artifacts, packageDir, 'lib', () => true);
|
||||
for (const entry of fs.readdirSync(packageDir, {withFileTypes: true})) {
|
||||
if (entry.isFile() && isNativeRuntimeSidecar(entry.name)) {
|
||||
artifacts.add(entry.name);
|
||||
}
|
||||
}
|
||||
return Array.from(artifacts).sort();
|
||||
}
|
||||
|
||||
function isNativeRuntimeSidecar(fileName) {
|
||||
return (
|
||||
fileName.endsWith('.node') ||
|
||||
/\.so(?:\.|$)/.test(fileName) ||
|
||||
/\.(?:dll|exe)$/i.test(fileName) ||
|
||||
isWindowsNativeRuntimeManifest(fileName)
|
||||
);
|
||||
}
|
||||
|
||||
function isWindowsNativeRuntimeManifest(fileName) {
|
||||
return (
|
||||
fileName === 'compatibility.json' || /^fluxer-vulkan-layer\.win32-(?:x64|ia32|arm64)-msvc\.json$/i.test(fileName)
|
||||
);
|
||||
}
|
||||
|
||||
function addWinGameCaptureRuntimeArtifacts(artifacts, tag, arch) {
|
||||
if (!windowsGameCaptureModuleEnabled) return;
|
||||
const add = (relativePath) => {
|
||||
artifacts.push({
|
||||
label: '@fluxer/win-game-capture',
|
||||
relativePath,
|
||||
runtimeFiles: [],
|
||||
});
|
||||
};
|
||||
artifacts.push({
|
||||
label: '@fluxer/win-game-capture',
|
||||
relativePath: `win-game-capture.${tag}.node`,
|
||||
});
|
||||
add(`fluxer-game-hook.${tag}.dll`);
|
||||
add(`fluxer-inject-helper.${tag}.exe`);
|
||||
add(`fluxer-vulkan-layer.${tag}.dll`);
|
||||
add(`fluxer-vulkan-layer.${tag}.json`);
|
||||
if (arch === 'x64') {
|
||||
add('fluxer-game-hook.win32-ia32-msvc.dll');
|
||||
add('fluxer-inject-helper.win32-ia32-msvc.exe');
|
||||
}
|
||||
}
|
||||
|
||||
function copyRuntimeArtifactsToInstalledPackages({label, packageDir}) {
|
||||
const artifacts = collectRuntimeArtifactPaths(packageDir);
|
||||
if (artifacts.length === 0) {
|
||||
return;
|
||||
}
|
||||
const sourceRealPath = fs.realpathSync.native(packageDir);
|
||||
const installedPackageDirs = findInstalledPackageDirs(label).filter(
|
||||
(installedPackageDir) => installedPackageDir !== sourceRealPath,
|
||||
);
|
||||
if (installedPackageDirs.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const installedPackageDir of installedPackageDirs) {
|
||||
for (const artifact of artifacts) {
|
||||
const sourcePath = path.join(packageDir, artifact);
|
||||
const targetPath = path.join(installedPackageDir, artifact);
|
||||
fs.mkdirSync(path.dirname(targetPath), {recursive: true});
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
}
|
||||
console.log(
|
||||
` Synced ${artifacts.length} runtime artifact(s) for ${label} into ${path.relative(ROOT_DIR, installedPackageDir)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function platformTag(platform, arch) {
|
||||
if (platform === 'darwin') return `darwin-${arch}`;
|
||||
if (platform === 'win32') return `win32-${arch}-msvc`;
|
||||
if (platform === 'linux') return `linux-${arch}-gnu`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function expectedNativeRuntimeArtifacts(platform = process.platform, arch = process.env.ELECTRON_ARCH || process.arch) {
|
||||
const tag = platformTag(platform, arch);
|
||||
if (!tag) return [];
|
||||
const artifacts = [];
|
||||
artifacts.push({
|
||||
label: '@fluxer/webauthn',
|
||||
relativePath: `webauthn.${tag}.node`,
|
||||
});
|
||||
artifacts.push({
|
||||
label: '@fluxer/webrtc-sender',
|
||||
relativePath: `webrtc-sender.${tag}.node`,
|
||||
runtimeFiles: ['index.js'],
|
||||
});
|
||||
if (platform === 'darwin') {
|
||||
artifacts.push({
|
||||
label: '@fluxer/mac-app-audio',
|
||||
relativePath: `mac-app-audio.darwin-${arch}.node`,
|
||||
});
|
||||
artifacts.push({
|
||||
label: '@fluxer/mac-screen-capture',
|
||||
relativePath: `mac-screen-capture.darwin-${arch}.node`,
|
||||
});
|
||||
artifacts.push({
|
||||
label: '@fluxer/mac-clipboard',
|
||||
relativePath: `mac-clipboard.darwin-${arch}.node`,
|
||||
});
|
||||
artifacts.push({
|
||||
label: '@fluxer/mac-sysctl',
|
||||
relativePath: `mac-sysctl.darwin-${arch}.node`,
|
||||
});
|
||||
artifacts.push({
|
||||
label: '@fluxer/mac-tcc',
|
||||
relativePath: `mac-tcc.darwin-${arch}.node`,
|
||||
});
|
||||
artifacts.push({
|
||||
label: '@fluxer/macos-input-hook',
|
||||
relativePath: `macos-input-hook.darwin-${arch}.node`,
|
||||
});
|
||||
artifacts.push({
|
||||
label: '@fluxer/platform-info',
|
||||
relativePath: `platform-info.${tag}.node`,
|
||||
});
|
||||
} else if (platform === 'win32') {
|
||||
artifacts.push({
|
||||
label: '@fluxer/win-process-loopback',
|
||||
relativePath: `win-process-loopback.${tag}.node`,
|
||||
});
|
||||
addWinGameCaptureRuntimeArtifacts(artifacts, tag, arch);
|
||||
artifacts.push({
|
||||
label: '@fluxer/win-clipboard',
|
||||
relativePath: `win-clipboard.${tag}.node`,
|
||||
});
|
||||
artifacts.push({
|
||||
label: '@fluxer/win-shell',
|
||||
relativePath: `win-shell.${tag}.node`,
|
||||
});
|
||||
artifacts.push({
|
||||
label: '@fluxer/win-toast',
|
||||
relativePath: `win-toast.${tag}.node`,
|
||||
});
|
||||
artifacts.push({
|
||||
label: '@fluxer/windows-input-hook',
|
||||
relativePath: `windows-input-hook.${tag}.node`,
|
||||
});
|
||||
artifacts.push({
|
||||
label: '@fluxer/platform-info',
|
||||
relativePath: `platform-info.${tag}.node`,
|
||||
});
|
||||
} else if (platform === 'linux') {
|
||||
artifacts.push({
|
||||
label: '@fluxer/linux-audio-capture',
|
||||
relativePath: `linux-audio-capture.${tag}.node`,
|
||||
});
|
||||
artifacts.push({
|
||||
label: '@fluxer/linux-screen-capture',
|
||||
relativePath: `linux-screen-capture.${tag}.node`,
|
||||
});
|
||||
artifacts.push({
|
||||
label: '@fluxer/linux-portals',
|
||||
relativePath: `linux-portals.${tag}.node`,
|
||||
});
|
||||
artifacts.push({
|
||||
label: '@fluxer/linux-notifications',
|
||||
relativePath: `linux-notifications.${tag}.node`,
|
||||
});
|
||||
artifacts.push({
|
||||
label: '@fluxer/linux-evdev',
|
||||
relativePath: `linux-evdev.${tag}.node`,
|
||||
});
|
||||
artifacts.push({
|
||||
label: '@fluxer/system-hunspell',
|
||||
relativePath: `system-hunspell.${tag}.node`,
|
||||
});
|
||||
artifacts.push({
|
||||
label: '@fluxer/linux-input-hook',
|
||||
relativePath: `linux-input-hook.${tag}.node`,
|
||||
});
|
||||
artifacts.push({
|
||||
label: '@fluxer/platform-info',
|
||||
relativePath: `platform-info.${tag}.node`,
|
||||
});
|
||||
}
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
function verifyInstalledNativeArtifacts() {
|
||||
if (skipNative) return;
|
||||
const missing = [];
|
||||
for (const artifact of expectedNativeRuntimeArtifacts()) {
|
||||
const packageDirs = findInstalledPackageDirs(artifact.label);
|
||||
if (packageDirs.length === 0) {
|
||||
missing.push(`${artifact.label}: package is not installed`);
|
||||
continue;
|
||||
}
|
||||
for (const packageDir of packageDirs) {
|
||||
for (const runtimeFile of artifact.runtimeFiles ?? ['index.js', 'loader-diagnostics.cjs']) {
|
||||
const runtimePath = path.join(packageDir, runtimeFile);
|
||||
if (!fs.existsSync(runtimePath)) {
|
||||
missing.push(`${artifact.label}: missing ${runtimeFile} in ${path.relative(ROOT_DIR, packageDir)}`);
|
||||
}
|
||||
}
|
||||
const artifactPath = path.join(packageDir, artifact.relativePath);
|
||||
if (!fs.existsSync(artifactPath)) {
|
||||
missing.push(`${artifact.label}: missing ${artifact.relativePath} in ${path.relative(ROOT_DIR, packageDir)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Native runtime artifact sync failed:\n${missing.map((entry) => ` - ${entry}`).join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function runNativeCommand(packageDir, command) {
|
||||
const [bin, ...args] = command;
|
||||
console.log(` $ ${command.join(' ')}`);
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: `${ROOT_BIN_DIR}${path.delimiter}${process.env.PATH || ''}`,
|
||||
};
|
||||
if (isProduction) {
|
||||
env.NODE_ENV = 'production';
|
||||
env.FLUXER_DESKTOP_PRODUCTION = 'true';
|
||||
}
|
||||
execFileSync(bin, args, {
|
||||
cwd: packageDir,
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
}
|
||||
|
||||
function buildNativeAddon({label, dirName, commands, jsEntry = 'lib/index.js'}) {
|
||||
const packageDir = path.join(NATIVE_DIR, dirName);
|
||||
if (!fs.existsSync(packageDir)) {
|
||||
throw new Error(`Native addon directory missing: ${packageDir}`);
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
console.log(`Building native addon ${label}...`);
|
||||
for (const command of commands) {
|
||||
runNativeCommand(packageDir, command);
|
||||
}
|
||||
const jsEntryPath = path.join(packageDir, jsEntry);
|
||||
if (!fs.existsSync(jsEntryPath)) {
|
||||
throw new Error(`${label}: JS entry missing at ${jsEntryPath} after build`);
|
||||
}
|
||||
const nodeBinaries = findNodeBinary(packageDir);
|
||||
if (nodeBinaries.length === 0) {
|
||||
throw new Error(`${label}: no Rust .node binary produced in the package root`);
|
||||
}
|
||||
console.log(
|
||||
` ${label} built in ${Date.now() - startedAt}ms (binaries: ${nodeBinaries.map((file) => path.relative(packageDir, file)).join(', ')})`,
|
||||
);
|
||||
copyRuntimeArtifactsToInstalledPackages({label, packageDir});
|
||||
}
|
||||
|
||||
function buildNativeAddons() {
|
||||
if (skipNative) {
|
||||
console.log('Skipping native addons (FLUXER_SKIP_NATIVE=true).');
|
||||
return;
|
||||
}
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/webauthn',
|
||||
dirName: 'webauthn',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/webrtc-sender',
|
||||
dirName: 'webrtc-sender',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
if (process.platform === 'darwin') {
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/mac-app-audio',
|
||||
dirName: 'mac-app-audio',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/mac-screen-capture',
|
||||
dirName: 'mac-screen-capture',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/mac-clipboard',
|
||||
dirName: 'mac-clipboard',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/mac-sysctl',
|
||||
dirName: 'mac-sysctl',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/mac-tcc',
|
||||
dirName: 'mac-tcc',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/macos-input-hook',
|
||||
dirName: 'macos-input-hook',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/platform-info',
|
||||
dirName: 'platform-info',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
verifyInstalledNativeArtifacts();
|
||||
return;
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/win-process-loopback',
|
||||
dirName: 'win-process-loopback',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/win-clipboard',
|
||||
dirName: 'win-clipboard',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/win-shell',
|
||||
dirName: 'win-shell',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/win-toast',
|
||||
dirName: 'win-toast',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/windows-input-hook',
|
||||
dirName: 'windows-input-hook',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
if (windowsGameCaptureModuleEnabled) {
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/win-game-capture',
|
||||
dirName: 'win-game-capture',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
}
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/platform-info',
|
||||
dirName: 'platform-info',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
verifyInstalledNativeArtifacts();
|
||||
return;
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/linux-audio-capture',
|
||||
dirName: 'linux-audio-capture',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/linux-screen-capture',
|
||||
dirName: 'linux-screen-capture',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/linux-portals',
|
||||
dirName: 'linux-portals',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/linux-notifications',
|
||||
dirName: 'linux-notifications',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/linux-evdev',
|
||||
dirName: 'linux-evdev',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/system-hunspell',
|
||||
dirName: 'system-hunspell',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/linux-input-hook',
|
||||
dirName: 'linux-input-hook',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
buildNativeAddon({
|
||||
label: '@fluxer/platform-info',
|
||||
dirName: 'platform-info',
|
||||
commands: [['pnpm', 'build']],
|
||||
jsEntry: 'index.js',
|
||||
});
|
||||
verifyInstalledNativeArtifacts();
|
||||
return;
|
||||
}
|
||||
console.log(`No native audio addon for platform ${process.platform}; skipping.`);
|
||||
}
|
||||
|
||||
async function buildMain() {
|
||||
console.log('Building main process...');
|
||||
await Promise.all([
|
||||
esbuild.build({
|
||||
entryPoints: [path.join(SRC_DIR, 'main', 'Bootstrap.ts')],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
format: 'esm',
|
||||
outfile: path.join(DIST_DIR, 'main', 'index.js'),
|
||||
minify: isProduction,
|
||||
sourcemap: true,
|
||||
external: electronExternals,
|
||||
plugins: [pathAliasPlugin],
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify(isProduction ? 'production' : 'development'),
|
||||
...publicBuildDefines,
|
||||
},
|
||||
banner: {
|
||||
js: `import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);`,
|
||||
},
|
||||
}),
|
||||
esbuild.build({
|
||||
entryPoints: [path.join(SRC_DIR, 'main', 'index.ts')],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
format: 'esm',
|
||||
outfile: path.join(DIST_DIR, 'main', 'MainApp.js'),
|
||||
minify: isProduction,
|
||||
sourcemap: true,
|
||||
external: electronExternals,
|
||||
plugins: [pathAliasPlugin],
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify(isProduction ? 'production' : 'development'),
|
||||
...publicBuildDefines,
|
||||
},
|
||||
banner: {
|
||||
js: `import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);`,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
console.log('Main process build complete.');
|
||||
}
|
||||
|
||||
async function buildPreload() {
|
||||
console.log('Building preload script...');
|
||||
await esbuild.build({
|
||||
entryPoints: [path.join(SRC_DIR, 'preload', 'index.ts')],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
format: 'cjs',
|
||||
outfile: path.join(DIST_DIR, 'preload', 'index.cjs'),
|
||||
minify: isProduction,
|
||||
sourcemap: true,
|
||||
external: electronExternals,
|
||||
plugins: [pathAliasPlugin],
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify(isProduction ? 'production' : 'development'),
|
||||
...publicBuildDefines,
|
||||
},
|
||||
});
|
||||
console.log('Preload script build complete.');
|
||||
}
|
||||
|
||||
function ensureBuildChannelFile() {
|
||||
execFileSync(
|
||||
'cargo',
|
||||
[
|
||||
'run',
|
||||
'--manifest-path',
|
||||
path.join(ROOT_DIR, '..', 'tools', 'ci', 'Cargo.toml'),
|
||||
'--',
|
||||
'build-desktop',
|
||||
'--step',
|
||||
'set_build_channel',
|
||||
],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function build() {
|
||||
console.log(`Building Electron app (${isProduction ? 'production' : 'development'})...`);
|
||||
ensureBuildChannelFile();
|
||||
if (fs.existsSync(DIST_DIR)) {
|
||||
fs.rmSync(DIST_DIR, {recursive: true});
|
||||
}
|
||||
fs.mkdirSync(path.join(DIST_DIR, 'main'), {recursive: true});
|
||||
fs.mkdirSync(path.join(DIST_DIR, 'preload'), {recursive: true});
|
||||
buildNativeAddons();
|
||||
await Promise.all([buildMain(), buildPreload()]);
|
||||
console.log('Build complete!');
|
||||
}
|
||||
|
||||
build().catch((error) => {
|
||||
console.error('Build failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,463 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user