packages/telestion-client-cli/src/actions/electron.mts
import { join, basename } from 'node:path';
import {
ChildProcess,
exec,
existsSync,
getDescription,
getElectronDependencies,
getLogger,
getName,
getVersion,
mkdir,
readFileSync,
rmIfExists,
spawn,
symlink,
writeFile
} from '../lib/index.mjs';
import { hasWorkspaceTag } from './workspace.mjs';
const logger = getLogger('Electron Action');
const distFolderName = 'dist';
const mainProcessFileName = 'electron.js';
// TODO: Switch to PNPM once Electron doesn't break with std::bad_alloc
// on sym-linked dependencies in node_modules
const usePnpmAsNativeInstaller = false;
// Electron-packager needs access to all dependencies.
const distNpmRcContent = `; autogenerated via tc-cli. DO NOT EDIT!
shamefully-hoist = true
lockfile = false`;
/**
* Extracts the installed package version from the pnpm list output
* for a specific package.
*
* For example:
*
* basename: `@wuespace+telestion-client-common@0.23.11_wtyhs37xbpfjrses3hpvo5azxm`
*
* extracted version: `0.23.11`
*
* @param basename the basename of the dependency folder
* that contains the package version string
*/
function versionFromBasename(basename: string): string {
const versionAndHash = basename.split('@').at(-1);
if (!versionAndHash) {
throw new Error(
'No version specifier found. ' +
'Please report this issue in https://github.com/wuespace/telestion-client/issues/new. ' +
'Sorry for the inconvenience. ' +
`Received basename: ${basename}`
);
}
// position 0 is always there
return versionAndHash.split('_').at(0) as string;
}
/**
* Generates the distribution `package.json`.
* The Electron main process reads this file on startup.
*
* Dependencies listed in the project's `package.json`
* as `"electronDependencies"` are mapped to production dependencies.
* In the next step these production dependencies should be installed.
*
* @param projectDir - path to project directory
* @param packageJson - object that represents the project's `package.json`
* @return a list of resolved native dependencies
*/
export async function generateDistPackageJson(
projectDir: string,
packageJson: Record<string, unknown>
): Promise<string[]> {
logger.debug('Project directory:', projectDir);
logger.debug('Received project package.json:', packageJson);
const electronDependencies = await getElectronDependencies(packageJson);
logger.debug('Extracted Electron dependencies:', electronDependencies);
const dependencies: Record<string, string> = {};
for (const electronDependency of electronDependencies) {
logger.debug('Find installed version for:', electronDependency);
const result = await exec('pnpm', [
'list',
electronDependency,
'--parseable'
]);
const fullPath = result.stdout.toString().split('\n').at(1);
if (!fullPath) {
throw new Error(
`Dependency '${electronDependency}' is not installed. ` +
'Please add it to the dependencies list in your package.json and try again.'
);
}
const folderName = basename(fullPath);
const version = versionFromBasename(folderName);
logger.debug('Dependency folder name:', folderName);
logger.debug('Installed version:', version);
dependencies[electronDependency] = version;
}
const distPackageJsonPath = join(projectDir, distFolderName, 'package.json');
const distPackageJson: Record<string, unknown> = {
name: await getName(packageJson),
description: await getDescription(packageJson),
version: await getVersion(packageJson),
main: mainProcessFileName,
dependencies
};
logger.debug('Distribution package.json path:', distPackageJsonPath);
logger.debug('Generated distribution package.json:', distPackageJson);
await writeFile(
distPackageJsonPath,
JSON.stringify(distPackageJson, null, 2)
);
return getElectronDependencies(packageJson);
}
/**
* Copies a dependency from the project's `node_modules` store
* to the distribution `node_modules` store.
* This gives Electron access to its native dependencies.
*
* > Note: This method doesn't work when you need to create Electron containers.
* The `electron-packager` needs a flat `node_modules` tree.
* Only really use this method if you have workspace dependencies
* in your project. Otherwise, use the npm install method from below.
*
* @param specifier - the scope + package name of the dependency
* @param projectDir - path to the project directory
*/
export async function linkNativeDependency(
specifier: string,
projectDir: string
): Promise<void> {
logger.debug(`Link dependency ${specifier}`);
const parts = specifier.split('/');
const scope = parts.slice(0, -1);
const packageName = parts.at(-1) as string;
logger.debug('Dependency scope:', scope);
logger.debug('Dependency package name:', packageName);
const modulesDir = join(projectDir, 'node_modules');
const distModulesDir = join(projectDir, distFolderName, 'node_modules');
logger.debug('Modules directory:', modulesDir);
logger.debug('Dist modules directory:', distModulesDir);
const sourcePath = join(modulesDir, ...scope, packageName);
const destDir = join(distModulesDir, ...scope);
const destPath = join(destDir, packageName);
logger.debug('Source path:', sourcePath);
logger.debug('Destination directory:', destDir);
logger.debug('Destination path:', destPath);
await mkdir(destDir, true);
await rmIfExists(destPath, true);
await symlink(sourcePath, destPath);
}
/**
* Installs the native dependencies specified in the distribution `package.json`.
* @param projectDir - path to the project directory
* @param nativeDependencies - a list of resolved native dependencies
*/
export async function installNativeDependencies(
projectDir: string,
nativeDependencies: string[]
): Promise<void> {
const distDir = join(projectDir, distFolderName);
logger.debug('Project directory:', projectDir);
logger.debug('Distribution directory:', distDir);
if (await hasWorkspaceTag(projectDir)) {
logger.debug(
'Workspace tag detected. Use symbolic links to refer to already installed native dependencies'
);
await Promise.all(
nativeDependencies.map(specifier =>
linkNativeDependency(specifier, projectDir)
)
);
} else {
logger.debug(
'Has no workspace tag. Use package manager to install native dependencies'
);
if (usePnpmAsNativeInstaller) {
logger.debug('Use PNPM as package manager');
const distNpmRcPath = join(distDir, '.npmrc');
logger.debug('Distribution .npmrc path:', distNpmRcPath);
logger.debug('Write distribution .npmrc to configure pnpm');
await writeFile(distNpmRcPath, distNpmRcContent);
return new Promise<void>((resolve, reject) => {
// install native dependencies via npm in the distribution folder
logger.debug('Install native dependencies');
const pnpmProcess = spawn('pnpm', ['install'], { cwd: distDir });
// pass through process output
pnpmProcess.stdout?.pipe(process.stdout);
pnpmProcess.stderr?.pipe(process.stderr);
pnpmProcess.on('exit', (code, signal) =>
code === 0 || !signal
? resolve()
: reject(
new Error(
`Cannot install native dependencies with PNPM. Exit code: ${code}, Signal: ${signal}`
)
)
);
});
} else {
logger.debug('Use NPM as package manager');
return new Promise<void>((resolve, reject) => {
logger.debug('Install native dependencies');
const npmProcess = spawn('npm', ['install', '--no-package-lock'], {
cwd: distDir
});
// pass through process output
npmProcess.stdout?.pipe(process.stdout);
npmProcess.stderr?.pipe(process.stderr);
npmProcess.on('exit', (code, signal) =>
code === 0 || !signal
? resolve()
: reject(
new Error(
`Cannot install native dependencies with NPM. Exit code: ${code}, Signal: ${signal}`
)
)
);
});
}
}
}
/**
* Removes any residing native dependencies
* from the distribution `node_modules` store.
*
* @param projectDir - path to the project directory
*/
export async function clearNativeDependencies(
projectDir: string
): Promise<void> {
const distModulesDir = join(projectDir, distFolderName, 'node_modules');
logger.debug('Project directory:', projectDir);
logger.debug('Distribution module directory:', distModulesDir);
await rmIfExists(distModulesDir, true);
}
/**
* Starts Electron in development mode. It reads all components
* from the distribution folder.
*
* @param projectDir - path to the project directory
* @param devServerPort - port on which the development listens.
* Electron receives this port via the `"DEV_SERVER_PORT"` environment variable.
* @return the spawned Electron child process
*/
export function startElectron(
projectDir: string,
devServerPort: number
): ChildProcess {
logger.debug('Project directory:', projectDir);
logger.debug('Development server port:', devServerPort);
const electronPackagePath = join(projectDir, 'node_modules', 'electron');
if (!existsSync(electronPackagePath)) {
throw new Error(
'Electron binary not found. Is Electron installed in your project?'
);
}
const electronBinaryName = readFileSync(
join(electronPackagePath, 'path.txt')
);
const electronBinaryPath = join(
electronPackagePath,
'dist',
electronBinaryName
);
logger.debug('Electron binary name:', electronBinaryName);
logger.debug('Electron package path:', electronPackagePath);
logger.debug('Electron binary path:', electronBinaryPath);
const distFolderPath = join(projectDir, distFolderName);
logger.debug('Distribution folder path:', distFolderPath);
logger.debug('Spawn electron main process');
const electronProcess = spawn(electronBinaryPath, [distFolderPath], {
cwd: projectDir,
env: {
...process.env,
DEV_SERVER_PORT: `${devServerPort}`
}
});
// pass through process output
electronProcess.stdout?.pipe(process.stdout);
electronProcess.stderr?.pipe(process.stderr);
return electronProcess;
}