import { spawn } from 'node:child_process'; import os from 'node:os'; import path from 'node:path'; import { watch } from 'chokidar'; import electronPath from 'electron'; import { build as esbuildBuild } from 'esbuild'; import { createServer } from 'vite'; import fileUrlToDirname from '../config/fileUrlToDirname.js'; /** @type {string} */ const thisDir = fileUrlToDirname(import.meta.url); /** @type {string} */ const sharedModule = path.join(thisDir, '../packages/shared/dist/index.mjs'); /** @type {string} */ const serviceSharedModule = path.join( thisDir, '../packages/service-shared/dist/index.mjs', ); /** @type {string} */ const serviceInjectModule = path.join( thisDir, '../packages/service-inject/dist/index.js', ); /** @type {string} */ const userDataDir = path.join(thisDir, '../userDataDir/development'); /** @type {RegExp[]} */ const stderrIgnorePatterns = [ // Warning about devtools extension // https://github.com/cawa-93/vite-electron-builder/issues/492 // https://github.com/MarshallOfSound/electron-devtools-installer/issues/143 /ExtensionLoadWarning/, // GPU sandbox error with the mesa GLSL cache // https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=918433 /InitializeSandbox\(\) called with multiple threads in process gpu-process/, // Very spammy GPU error reporting // https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=901831 /GetVSyncParametersIfAvailable\(\) failed/, // Does not seem to occur in production // https://github.com/electron/electron/issues/32133#issuecomment-1079916988 /(sandbox_bundle\.js script failed to run|object null is not iterable).+ node:electron\/js2c\/sandbox_bundle \(160\)$/, // Warning when GPU-accelerated video decoding is not available (some wayland configs) /Passthrough is not supported, GL is/, ]; /** * @param {string} packageName * @param {string[]} [extraPaths] * @param {() => void} [callback] * @returns {Promise} */ async function setupEsbuildWatcher(packageName, extraPaths, callback) { /** @type {{ default: import('esbuild').BuildOptions }} */ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Read untyped config file. const { default: config } = await import( `../packages/${packageName}/esbuild.config.js` ); config.logLevel = 'info'; config.incremental = true; const incrementalBuild = await esbuildBuild(config); const paths = [ path.join(thisDir, `../packages/${packageName}/src`), ...(extraPaths || []), ]; const watcher = watch(paths, { ignored: /(^|[/\\])\.|__(tests|mocks)__|\.(spec|test)\.[jt]sx?$/, ignoreInitial: true, persistent: true, }); if (callback) { callback(); } /** * @returns {Promise} */ async function rebuild() { try { await incrementalBuild.rebuild?.(); } catch (error) { if (typeof error === 'object' && error !== null && 'errors' in error) { const { errors } = /** @type {{ errors: unknown }} */ (error); if (Array.isArray(errors)) { const errCount = errors.length; console.error( '\uD83D\uDD25', errCount, errCount > 1 ? 'errors' : 'error', 'while rebuilding package', packageName, ); return; } } console.error( '\uD83D\uDD25', 'error while rebuilding package', packageName, error, ); return; } if (callback) { console.log(`\u26A1 Reloading package ${packageName}`); callback(); } } watcher.on('change', () => { rebuild().catch((error) => { console.error('Unexpected error while rebuilding', error); }); }); } /** * @param {import('vite').ViteDevServer} viteDevServer * @returns {Promise} */ function setupMainPackageWatcher(viteDevServer) { // Write a value to an environment variable to pass it to the main process. const { config: { server: { port, https, host }, }, } = viteDevServer; const protocol = `http${https ? 's' : ''}:`; const hostOrDefault = typeof host === 'string' ? host : 'localhost'; const portOrDefault = port || 3000; process.env.VITE_DEV_SERVER_URL = `${protocol}//${hostOrDefault}:${portOrDefault}/`; /** @type {string[]} */ let extraArgs = []; if (['aix', 'freebsd', 'linux', 'openbsd', 'sunos'].includes(os.platform())) { // Use wayland display server if available. extraArgs = 'WAYLAND_DISPLAY' in process.env ? [ '--enable-features=WaylandWindowDecorations,WebRTCPipeWireCapturer', '--ozone-platform=wayland', ] : ['--ozone-platform=x11']; } /** @type {import('child_process').ChildProcessByStdio | undefined} */ let childProcess; // Prevent overlapping restarts and restarting while exiting. let mayRestart = true; function spawnProcess() { childProcess = spawn( String(electronPath), [ '.', `--user-data-dir=${userDataDir}`, ...extraArgs, ...process.argv.slice(2), ], { stdio: ['inherit', 'inherit', 'pipe'], }, ); childProcess.stderr.on('data', (/** @type {Buffer} */ data) => { const stderrString = data.toString('utf8').trimEnd(); if (!stderrIgnorePatterns.some((r) => r.test(stderrString))) { console.error(stderrString); } }); childProcess.on('exit', () => process.exit()); } function killProcess() { mayRestart = false; if (childProcess === undefined) { return; } childProcess.stderr.removeAllListeners('data'); childProcess.kill('SIGINT'); } process.on('SIGINT', () => { if (childProcess !== undefined) { childProcess.removeAllListeners('exit'); childProcess.on('exit', process.exit()); killProcess(); } }); return setupEsbuildWatcher( 'main', [serviceSharedModule, sharedModule], () => { if (!mayRestart) { // We're already about to restart electron. return; } if (childProcess === undefined) { spawnProcess(); return; } childProcess.removeAllListeners('exit'); childProcess.on('exit', () => { spawnProcess(); mayRestart = true; }); killProcess(); }, ); } /** * @param {(event: import('vite').HMRPayload) => void} sendEvent */ function setupTranslationsWatcher(sendEvent) { const localesDir = path.join(thisDir, '../locales'); const watcher = watch(localesDir, { ignored: /\.missing\.json$/, ignoreInitial: true, persistent: true, }); watcher.on('change', () => { console.log(`\u26A1 Reloading translations`); sendEvent({ type: 'custom', event: 'sophie:reload-translations', }); }); } /** * @returns {Promise} */ async function setupDevEnvironment() { process.env.MODE = 'development'; process.env.NODE_ENV = 'development'; /** @type {import('vite').ViteDevServer | undefined} */ let viteDevServer; /** * @param {import('vite').HMRPayload} event * @returns {void} */ function sendEvent(event) { if (viteDevServer !== undefined) { viteDevServer.ws.send(event); } } /** * @returns {Promise} */ async function startDevServer() { viteDevServer = await createServer({ build: { watch: { skipWrite: true, clearScreen: false, }, }, configFile: path.join(thisDir, `../packages/renderer/vite.config.js`), }); await viteDevServer.listen(); } /** * @returns {Promise} */ async function watchRendererPackages() { await setupEsbuildWatcher('shared'); await Promise.all([ setupEsbuildWatcher('preload', [sharedModule], () => sendEvent({ type: 'full-reload', }), ), startDevServer(), ]); } /** * @returns {Promise} */ async function watchServicePackages() { await setupEsbuildWatcher('service-shared'); await setupEsbuildWatcher('service-inject', [serviceSharedModule]); await setupEsbuildWatcher( 'service-preload', [serviceSharedModule, serviceInjectModule], () => sendEvent({ type: 'custom', event: 'sophie:reload-services', }), ); } await Promise.all([ watchRendererPackages(), watchServicePackages(), setupTranslationsWatcher(sendEvent), ]); if (viteDevServer === undefined) { console.error('Failed to create vite dev server'); return; } console.log('\uD83C\uDF80 Sophie is starting up'); await setupMainPackageWatcher(viteDevServer); } setupDevEnvironment().catch((error) => { console.error(error); process.exit(1); });