diff --git a/package.json b/package.json index 55aecf5d..c05eeb29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@meteor-community/meteor-desktop", - "version": "3.2.1", + "version": "3.3.0", "bin": { "meteor-desktop": "dist/bin/cli.js" }, diff --git a/plugins/bundler/bundler.js b/plugins/bundler/bundler.js index 1864c809..18658893 100644 --- a/plugins/bundler/bundler.js +++ b/plugins/bundler/bundler.js @@ -1,9 +1,15 @@ /* eslint-disable no-console, no-param-reassign */ const { fs, path } = Plugin; const versionFilePath = './version.desktop'; -const Future = Npm.require('fibers/future'); const chokidar = Npm.require('chokidar'); +/** + * Utility function to compare two arrays for identical elements. + * + * @param {Array} a - First array. + * @param {Array} b - Second array. + * @returns {boolean} - True if arrays are identical, else false. + */ function arraysIdentical(a, b) { let i = a.length; if (i !== b.length) return false; @@ -14,59 +20,72 @@ function arraysIdentical(a, b) { return true; } -// TODO: purge cache every now and then +// TODO: Implement periodic cache purging. -fs.existsSync = (function existsSync(pathToCheck) { +fs.existsSync = function existsSync(pathToCheck) { try { return !!this.statSync(pathToCheck); } catch (e) { return null; } -}).bind(fs); +}.bind(fs); +/** + * Adds 'version.desktop' to the .gitignore file if it's not already present. + */ function addToGitIgnore() { let gitIgnore; try { gitIgnore = fs.readFileSync('./.gitignore', 'utf8'); - if (!~gitIgnore.indexOf('version.desktop')) { + if (!gitIgnore.includes('version.desktop')) { gitIgnore += '\nversion.desktop\n'; fs.writeFileSync('./.gitignore', gitIgnore); } } catch (e) { - console.warn('[meteor-desktop] could not add version.desktop to .gitignore, please do' + - ' it manually'); + console.warn('[meteor-desktop] could not add version.desktop to .gitignore, please do it manually'); } } +// Initialize the version.desktop file if it doesn't exist. if (!fs.existsSync(versionFilePath)) { - fs.writeFileSync(versionFilePath, JSON.stringify({ - version: 'initial', - }, null, 2), 'UTF-8'); + fs.writeFileSync( + versionFilePath, + JSON.stringify({ version: 'initial' }, null, 2), + 'UTF-8' + ); addToGitIgnore(); } - +/** + * Converts a given string to camelCase. + * + * @param {string} name - The string to convert. + * @returns {string} - The camelCase version of the input string. + */ function toCamelCase(name) { return name - .replace(/[-/](.)/g, $1 => $1.toUpperCase()) + .replace(/[-/](.)/g, (_, group1) => group1.toUpperCase()) .replace(/[-@/]/g, ''); } /* - * Important! This is a POC. + * Important! This is a Proof of Concept (POC). * - * A lot of stuff is basically duplicated here with the main npm package. This is because I had real - * trouble with just requiring `meteor-desktop`. Because the stack trace that comes from a build - * plugin was not really specific what was the problem I decided to implement the minimum needed - * here with just copying the code. This needs to be investigated and fixed. + * Much of this code is duplicated from the main npm package due to issues with requiring + * `meteor-desktop`. The stack traces from build plugins were not sufficiently descriptive, + * leading to the necessity of implementing minimal required functionality by copying code. + * This duplication should be investigated and resolved. */ class MeteorDesktopBundler { + /** + * Initializes the bundler with the provided file system. + * + * @param {Object} fileSystem - The file system interface. + */ constructor(fileSystem) { this.fs = fileSystem; - this.deps = [ - 'cacache' - ]; + this.deps = ['cacache']; this.buildDeps = [ '@electron/asar', 'shelljs', @@ -83,131 +102,148 @@ class MeteorDesktopBundler { this.requireLocal = null; this.cachePath = './.meteor/local/desktop-cache'; this.desktopPath = './.desktop'; + this.utils = null; + this.performanceStamps = {}; + + this.watcherEnabled = false; + this.timeout = null; + + // Initialize file watcher. this.watcher = chokidar.watch(this.desktopPath, { persistent: true, ignored: /tmp___/, ignoreInitial: true }); - this.utils = null; - - this.watcherEnabled = false; - - this.timeout = null; - this.watcher - .on('all', (event, filePath) => { - if (this.timeout) { - clearTimeout(this.timeout); + // Handle file system events. + this.watcher.on('all', (event, filePath) => { + if (this.timeout) { + clearTimeout(this.timeout); + } + // Simple 2-second debounce to prevent excessive rebuilds. + this.timeout = setTimeout(() => { + if (this.watcherEnabled && this.utils) { + console.log(`[meteor-desktop] ${filePath} has been changed, triggering desktop rebuild.`); + this.handleFileChange(); } - // Simple 2s debounce. - this.timeout = setTimeout(() => { - if (this.watcherEnabled && this.utils) { - console.log(`[meteor-desktop] ${filePath} have been changed, triggering` + - ' desktop rebuild.'); - - this.utils.readFilesAndComputeHash(this.desktopPath, file => file.replace('.desktop', '')) - .then((result) => { - const { hash } = result; - fs.writeFileSync(versionFilePath, JSON.stringify({ - version: `${hash}_dev`, - }, null, 2), 'UTF-8'); - }) - .catch((e) => { throw new Error(`[meteor-desktop] failed to compute .desktop hash: ${e}`); }); - } - }, 2000); - }); + }, 2000); + }); } /** - * Tries to read a settings.json file from desktop dir. + * Handles file changes by computing the hash and updating the version file. + */ + async handleFileChange() { + try { + const result = await this.utils.readFilesAndComputeHash( + this.desktopPath, + file => file.replace('.desktop', '') + ); + const { hash } = result; + fs.writeFileSync( + versionFilePath, + JSON.stringify({ version: `${hash}_dev` }, null, 2), + 'UTF-8' + ); + } catch (e) { + throw new Error(`[meteor-desktop] failed to compute .desktop hash: ${e}`); + } + } + + /** + * Reads and parses the settings.json from the desktop directory. * - * @param {Object} file - The file being processed by the build plugin. - * @param {string} desktopPath - Path to the desktop dir. - * @returns {Object} + * @param {string} desktopPath - Path to the desktop directory. + * @param {Object} file - The file being processed by the build plugin. + * @returns {Object} - Parsed settings object. */ getSettings(desktopPath, file) { let settings = {}; try { - settings = JSON.parse( - this.fs.readFileSync(path.join(desktopPath, 'settings.json'), 'UTF-8') - ); + const settingsPath = path.join(desktopPath, 'settings.json'); + const settingsContent = this.fs.readFileSync(settingsPath, 'UTF-8'); + settings = JSON.parse(settingsContent); } catch (e) { file.error({ - message: `error while trying to read 'settings.json' from '${desktopPath}' module` + message: `Error while trying to read 'settings.json' from '${desktopPath}' module` }); } return settings; } /** - * Tries to read a module.json file from module at provided path. + * Reads and parses the module.json from a specific module path. * - * @param {string} modulePath - Path to the module dir. - * @param {Object} file - The file being processed by the build plugin. - * @returns {Object} + * @param {string} modulePath - Path to the module directory. + * @param {Object} file - The file being processed by the build plugin. + * @returns {Object} - Parsed module configuration. */ getModuleConfig(modulePath, file) { let moduleConfig = {}; try { - moduleConfig = JSON.parse( - this.fs.readFileSync(path.join(modulePath, 'module.json'), 'UTF-8') - ); + const moduleJsonPath = path.join(modulePath, 'module.json'); + const moduleJsonContent = this.fs.readFileSync(moduleJsonPath, 'UTF-8'); + moduleConfig = JSON.parse(moduleJsonContent); } catch (e) { file.error({ - message: `error while trying to read 'module.json' from '${modulePath}' module` + message: `Error while trying to read 'module.json' from '${modulePath}' module` }); } return moduleConfig; } /** - * Checks if the path is empty. - * @param {string} searchPath - * @returns {boolean} + * Checks if a given path is empty. + * + * @param {string} searchPath - Path to check. + * @returns {boolean} - True if the path is empty, else false. */ isEmpty(searchPath) { - let stat; try { - stat = this.fs.statSync(searchPath); + const stat = this.fs.statSync(searchPath); + if (stat.isDirectory()) { + const items = this.fs.readdirSync(searchPath); + return !items || !items.length; + } + return false; } catch (e) { return true; } - if (stat.isDirectory()) { - const items = this.fs.readdirSync(searchPath); - return !items || !items.length; - } - return false; } /** - * Scans all modules for module.json and gathers this configuration altogether. + * Gathers module configurations from the modules directory. * - * @returns {[]} + * @param {Object} shell - ShellJS instance. + * @param {string} modulesPath - Path to the modules directory. + * @param {Object} file - The file being processed by the build plugin. + * @returns {Array} - Array of module configurations. */ gatherModuleConfigs(shell, modulesPath, file) { const configs = []; if (!this.isEmpty(modulesPath)) { - this.fs.readdirSync(modulesPath).forEach( - (module) => { - if (this.fs.lstatSync(path.join(modulesPath, module)).isDirectory()) { - const moduleConfig = - this.getModuleConfig(path.join(modulesPath, module), file); - if (path.parse) { - moduleConfig.dirName = path.parse(module).name; - } else { - moduleConfig.dirName = path.basename(module); - } - configs.push(moduleConfig); - } + const modules = this.fs.readdirSync(modulesPath); + modules.forEach(module => { + const moduleDir = path.join(modulesPath, module); + if (this.fs.lstatSync(moduleDir).isDirectory()) { + const moduleConfig = this.getModuleConfig(moduleDir, file); + moduleConfig.dirName = path.parse(module).name || path.basename(module); + configs.push(moduleConfig); } - ); + }); } return configs; } /** - * Merges core dependency list with the list made from .desktop. + * Merges core dependency lists with those defined in .desktop settings. + * + * @param {string} desktopPath - Path to the desktop directory. + * @param {Object} file - The file being processed by the build plugin. + * @param {Array} configs - Array of module configurations. + * @param {Object} depsManager - DependenciesManager instance. + * @returns {Object} - Updated DependenciesManager instance. */ getDependencies(desktopPath, file, configs, depsManager) { const settings = this.getSettings(desktopPath, file); @@ -221,54 +257,41 @@ class MeteorDesktopBundler { dependencies.fromSettings = settings.dependencies; } - // Plugins are also a npm packages. + // Plugins are also treated as npm packages. if ('plugins' in settings) { dependencies.plugins = Object.keys(settings.plugins).reduce((plugins, plugin) => { - if (typeof settings.plugins[plugin] === 'object') { - plugins[plugin] = settings.plugins[plugin].version; - } else { - plugins[plugin] = settings.plugins[plugin]; - } + const pluginVersion = typeof settings.plugins[plugin] === 'object' + ? settings.plugins[plugin].version + : settings.plugins[plugin]; + plugins[plugin] = pluginVersion; return plugins; }, {}); } - // Each module can have its own dependencies defined. + // Each module can have its own dependencies. const moduleDependencies = {}; - configs.forEach( - (moduleConfig) => { - if (!('dependencies' in moduleConfig)) { - moduleConfig.dependencies = {}; - } - if (moduleConfig.name in moduleDependencies) { - file.error({ - message: `duplicate name '${moduleConfig.name}' in 'module.json' in ` + - `'${moduleConfig.dirName}' - another module already registered the ` + - 'same name.' - }); - } - moduleDependencies[moduleConfig.name] = moduleConfig.dependencies; + configs.forEach(moduleConfig => { + if (!('dependencies' in moduleConfig)) { + moduleConfig.dependencies = {}; } - ); + if (moduleConfig.name in moduleDependencies) { + file.error({ + message: `Duplicate name '${moduleConfig.name}' in 'module.json' within '${moduleConfig.dirName}'. Another module has already registered the same name.` + }); + } + moduleDependencies[moduleConfig.name] = moduleConfig.dependencies; + }); dependencies.modules = moduleDependencies; try { - depsManager.mergeDependencies( - 'settings.json[dependencies]', - dependencies.fromSettings - ); - depsManager.mergeDependencies( - 'settings.json[plugins]', - dependencies.plugins - ); + depsManager.mergeDependencies('settings.json[dependencies]', dependencies.fromSettings); + depsManager.mergeDependencies('settings.json[plugins]', dependencies.plugins); Object.keys(dependencies.modules).forEach(module => - depsManager.mergeDependencies( - `module[${module}]`, - dependencies.modules[module] - )); + depsManager.mergeDependencies(`module[${module}]`, dependencies.modules[module]) + ); return depsManager; } catch (e) { @@ -278,19 +301,24 @@ class MeteorDesktopBundler { } /** - * Calculates a md5 from all dependencies. + * Calculates an MD5 hash based on all dependencies. + * + * @param {Object} dependencies - Object containing all dependencies. + * @param {string} desktopPath - Path to the desktop directory. + * @param {Object} file - The file being processed by the build plugin. + * @param {Function} md5 - MD5 hashing function. + * @returns {string} - Calculated compatibility version. */ calculateCompatibilityVersion(dependencies, desktopPath, file, md5) { const settings = this.getSettings(desktopPath, file); - if (('desktopHCPCompatibilityVersion' in settings)) { - console.log(`[meteor-desktop] compatibility version overridden to ${settings.desktopHCPCompatibilityVersion}`); + if ('desktopHCPCompatibilityVersion' in settings) { + console.log(`[meteor-desktop] Compatibility version overridden to ${settings.desktopHCPCompatibilityVersion}`); return `${settings.desktopHCPCompatibilityVersion}`; } let deps = Object.keys(dependencies).sort(); - deps = deps.map(dependency => - `${dependency}:${dependencies[dependency]}`); + deps = deps.map(dependency => `${dependency}:${dependencies[dependency]}`); const mainCompatibilityVersion = this.requireLocal('@meteor-community/meteor-desktop/package.json') .version .split('.'); @@ -300,44 +328,43 @@ class MeteorDesktopBundler { if (process.env.METEOR_DESKTOP_DEBUG_DESKTOP_COMPATIBILITY_VERSION || process.env.METEOR_DESKTOP_DEBUG ) { - console.log('[meteor-desktop] compatibility version calculated from', deps); + console.log('[meteor-desktop] Compatibility version calculated from', deps); } return md5(JSON.stringify(deps)); } /** - * Tries to require a dependency from either apps node_module or meteor-desktop/node_modules. - * Also verifies if the version is correct. + * Attempts to require a dependency from either the app's node_modules or meteor-desktop's node_modules. + * Also verifies the version if necessary. * - * @param {string} dependency - * @param {string} version - * @returns {null|Object} + * @param {string} dependency - The dependency name. + * @param {string} version - The required version. + * @returns {Object|null} - The required dependency or null if not found. */ getDependency(dependency, version) { let appScope = null; let meteorDesktopScope = null; try { - // Try to require the dependency from apps node_modules. + // Attempt to require the dependency from the app's node_modules. const requiredDependency = this.requireLocal(dependency); - // If that succeeded lets load the version information. - appScope = { dependency: requiredDependency, version: this.requireLocal(`${dependency}/package.json`).version }; + const requiredVersion = this.requireLocal(`${dependency}/package.json`).version; + appScope = { dependency: requiredDependency, version: requiredVersion }; if (process.env.METEOR_DESKTOP_DEBUG) { - console.log(`found ${dependency}@${appScope.version} [required: ${version}]`); + console.log(`Found ${dependency}@${appScope.version} [required: ${version}]`); } } catch (e) { - // No harm at this moment... + // Silently fail if not found. } try { - // Look for the dependency in meteor-desktop/node_modules. - // No need to check the version, npm ensures that. + // Attempt to require the dependency from meteor-desktop's node_modules. meteorDesktopScope = this.requireLocal(`@meteor-community/meteor-desktop/node_modules/${dependency}`); if (process.env.METEOR_DESKTOP_DEBUG) { - console.log(`found ${dependency} in meteor-desktop scope`); + console.log(`Found ${dependency} in meteor-desktop scope`); } } catch (e) { - // Also no harm... + // Silently fail if not found. } if (appScope !== null && appScope.version === version) { @@ -351,52 +378,53 @@ class MeteorDesktopBundler { } /** - * Returns package.json field from meteor-desktop package. - * @param {string} field - field name + * Retrieves a specific field from meteor-desktop's package.json. + * + * @param {string} field - The field name to retrieve. + * @returns {*} - The value of the specified field. */ getPackageJsonField(field) { if (!this.packageJson) { try { this.packageJson = this.requireLocal('@meteor-community/meteor-desktop/package.json'); } catch (e) { - throw new Error('could not load package.json from meteor-desktop, is meteor-desktop' + - ' installed?'); + throw new Error('Could not load package.json from meteor-desktop. Is meteor-desktop installed?'); } } return this.packageJson[field]; } /** - * Returns meteor-desktop version. + * Retrieves the version of meteor-desktop. + * + * @returns {string} - The version string. */ getVersion() { return this.getPackageJsonField('version'); } /** - * Tries to find and require all node_modules dependencies. - * @returns {{}} + * Finds and requires all specified node_modules dependencies. + * + * @param {Array} deps - Array of dependency names. + * @returns {Object} - Object containing all required dependencies. */ lookForAndRequireDependencies(deps) { const dependencies = {}; - // Try to load the dependencies section from meteor-desktop so we will know what are - // the correct versions. + // Load the dependencies section from meteor-desktop's package.json to get correct versions. const versions = this.getPackageJsonField('dependencies'); - deps.forEach((dependency) => { + deps.forEach(dependency => { const dependencyCamelCased = toCamelCase(dependency); this.stampPerformance(`deps get ${dependency}`); - // Lets try to find that dependency. - dependencies[dependencyCamelCased] = - this.getDependency(dependency, versions[dependency]); + dependencies[dependencyCamelCased] = this.getDependency(dependency, versions[dependency]); this.stampPerformance(`deps get ${dependency}`); if (dependencies[dependencyCamelCased] === null) { throw new Error( - `error while trying to require ${dependency}, are you sure you have ` + - 'meteor-desktop installed?' + `Error while trying to require ${dependency}. Are you sure you have meteor-desktop installed?` ); } }); @@ -405,8 +433,9 @@ class MeteorDesktopBundler { } /** - * Makes a performance stamp. - * @param {string} id + * Records a performance timestamp for a given identifier. + * + * @param {string} id - Identifier for the performance stamp. */ stampPerformance(id) { if (id in this.performanceStamps) { @@ -417,66 +446,69 @@ class MeteorDesktopBundler { } /** - * Prints out a performance report. + * Logs a summary of all performance stamps. */ getPerformanceReport() { - console.log('[meteor-desktop] performance summary:'); - Object.keys(this.performanceStamps).forEach((stampName) => { + console.log('[meteor-desktop] Performance summary:'); + Object.keys(this.performanceStamps).forEach(stampName => { if (typeof this.performanceStamps[stampName] === 'number') { - console.log(`\t\t${stampName}: ${this.performanceStamps[stampName]}ms`); + console.log(`\t${stampName}: ${this.performanceStamps[stampName]}ms`); } }); } /** - * Checks if the stats objects are identical. - * @param {Object} stat1 - * @param {Object} stat2 - * @returns {boolean} + * Compares two stats objects for equality. + * + * @param {Object} stat1 - First stats object. + * @param {Object} stat2 - Second stats object. + * @returns {boolean} - True if stats are equal, else false. */ static areStatsEqual(stat1, stat2) { - let keys1 = Object.keys(stat1); - let keys2 = Object.keys(stat2); + const keys1 = Object.keys(stat1).sort(); + const keys2 = Object.keys(stat2).sort(); + if (keys1.length !== keys2.length) return false; - keys1 = keys1.sort(); - keys2 = keys2.sort(); if (!arraysIdentical(keys1, keys2)) return false; - return keys1.every( - key => - stat1[key].size === stat2[key].size && - stat1[key].dates[0] === stat2[key].dates[0] && - stat1[key].dates[1] === stat2[key].dates[1] && - stat1[key].dates[2] === stat2[key].dates[2] + + return keys1.every(key => + stat1[key].size === stat2[key].size && + stat1[key].dates[0] === stat2[key].dates[0] && + stat1[key].dates[1] === stat2[key].dates[1] && + stat1[key].dates[2] === stat2[key].dates[2] ); } /** * Compiles the protocols.index.js file. * - * @param {Array} files - Array with files to process. + * @param {Array} files - Array of files to process. */ - processFilesForTarget(files) { + async processFilesForTarget(files) { this.performanceStamps = {}; let inputFile = null; let versionFile = null; let requireLocal = null; - // We need to find the files we are interested in. - // version._desktop_.js -> METEOR_DESKTOP_VERSION is put there - // version.desktop -> this file is in the root dir of the project so we can use it's - // `require` to load things from app's node_modules - files.forEach((file) => { + // Identify relevant files. + files.forEach(file => { if (file.getArch() === 'web.cordova') { - if (file.getPackageName() === 'communitypackages:meteor-desktop-bundler' && + if ( + file.getPackageName() === 'communitypackages:meteor-desktop-bundler' && file.getPathInPackage() === 'version._desktop_.js' ) { versionFile = file; } - if (file.getPackageName() === null && file.getPathInPackage() === 'version.desktop') { + if ( + file.getPackageName() === null && + file.getPathInPackage() === 'version.desktop' + ) { requireLocal = file.require.bind(file); inputFile = file; } - } else if (file.getArch() !== 'web.browser' && this.version && + } else if ( + file.getArch() !== 'web.browser' && + this.version && file.getPathInPackage() === 'version._desktop_.js' ) { file.addJavaScript({ @@ -496,95 +528,42 @@ class MeteorDesktopBundler { this.requireLocal = requireLocal; - Profile.time('meteor-desktop: preparing desktop.asar', () => { + // Profile the build process. + Profile.time('meteor-desktop: preparing desktop.asar', async () => { this.watcherEnabled = false; this.stampPerformance('whole build'); const desktopPath = './.desktop'; const settings = this.getSettings(desktopPath, inputFile); + if (!settings.desktopHCP) { - console.warn('[meteor-desktop] not preparing desktop.asar because desktopHCP ' + - 'is set to false. Remove this plugin if you do not want to use desktopHCP.'); + console.warn('[meteor-desktop] Skipping desktop.asar preparation because desktopHCP is set to false. Remove this plugin if you do not wish to use desktopHCP.'); return; } console.time('[meteor-desktop] preparing desktop.asar took'); - let electronAsar; - let shelljs; - let babelCore; - let babelPresetEnv; - let terser; - let del; - let cacache; - let md5; + let electronAsar, shelljs, babelCore, babelPresetEnv, terser, del, cacache, md5; /** - * https://github.com/wojtkowiak/meteor-desktop/issues/33 - * - * Below we are saving to a safe place a String.to prototype to restore it later. - * - * Without this this plugin would break building for Android - causing either the - * built app to be broken or a 'No Java files found that extend CordovaActivity' error - * to be displayed during build. - * - * Here is an attempt to describe the bug's mechanism... - * - * Cordova at each build tries to update file that extends CordovaActivity (even if - * it is not necessary). To locate that file it just greps through source files - * trying to locate that file: - * https://github.com/apache/cordova-android/blob/6.1.x/bin/templates/cordova/lib/prepare.js#L196 - * usually it finds it in the default file which is 'MainActivity.java'. - * - * Later, a `sed` is applied to that file: - * https://github.com/apache/cordova-android/blob/6.1.x/bin/templates/cordova/lib/prepare.js#L207 - * - * Unfortunately this line fails and cleans the file contents, leaving it blank. - * Therefore the built app is broken and on the next build the error appears because - * the file was left empty and there are no files that extend the 'CordovaActivity` now. - * - * Now the fun part. Why does shelljs's sed cleans the file? Look: - * `shell.sed(/package [\w\.]*;/, 'package ' + pkg + ';', java_files[0]).to(destFile);` - * the part with `.to(destFile)` writes the output - and in this case writes an - * empty file. It happens because cordova is using shelljs at version 0.5.x while - * this plugin uses 0.7.x. At first it seemed like cordova-android would use the - * package from wrong node_modules but that scenario was verified not to be true. - * - * Instead take a look how version 0.5.3 loads `.to` method: - * https://github.com/shelljs/shelljs/blob/v0.5.3/shell.js#L58 - * It adds it to a String's prototype. `sed` returns a `ShellString` which returns - * plain string, which has `to` method from the prototype. Well, messing with builtin - * objects prototypes is an anti-pattern for a reason... - * - * Even though 0.7.5 does not add `to` to String's prototype anymore: - * https://github.com/shelljs/shelljs/blob/v0.7.5/shell.js#L72 - * after first use of any command it somehow magically replaces that - * String.prototype.to to its own. I am using the term 'magically' because from reading - * the code I could not actually understand how does that happen. - * Finally, because `to` implementation differs between those versions, when cordova - * uses it by accident it does not receive the results of `sed` writing an empty file - * as a result. + * Explanation regarding String.prototype.to manipulation to prevent conflicts between different shelljs versions. */ const StringPrototypeToOriginal = String.prototype.to; this.stampPerformance('basic deps lookout'); - let DependenciesManager; - let ElectronAppScaffold; + let DependenciesManager, ElectronAppScaffold; try { const deps = this.lookForAndRequireDependencies(this.deps); - ({ - cacache - } = deps); + ({ cacache } = deps); - DependenciesManager = requireLocal('@meteor-community/meteor-desktop/dist/dependenciesManager').default; - this.utils = requireLocal('@meteor-community/meteor-desktop/dist/utils'); - ElectronAppScaffold = - requireLocal('@meteor-community/meteor-desktop/dist/electronAppScaffold').default; + DependenciesManager = this.requireLocal('@meteor-community/meteor-desktop/dist/dependenciesManager').default; + this.utils = this.requireLocal('@meteor-community/meteor-desktop/dist/utils'); + ElectronAppScaffold = this.requireLocal('@meteor-community/meteor-desktop/dist/electronAppScaffold').default; } catch (e) { - // Look at the declaration of StringPrototypeToOriginal for explanation. + // Restore original String.prototype.to to prevent side effects. String.prototype.to = StringPrototypeToOriginal; // eslint-disable-line inputFile.error({ - message: e + message: e.message || e }); return; } @@ -598,17 +577,30 @@ class MeteorDesktopBundler { } } }; + if (context.env.isProductionBuild()) { - console.log('[meteor-desktop] creating a production build'); + console.log('[meteor-desktop] Creating a production build'); } let shelljsConfig; const self = this; + /** + * Logs debug messages if debugging is enabled. + * + * @param {...any} args - Arguments to log. + */ function logDebug(...args) { if (process.env.METEOR_DESKTOP_DEBUG) console.log(...args); } + /** + * Adds necessary files to the build output. + * + * @param {Buffer} contents - ASAR package contents. + * @param {Object} desktopSettings - Settings object for the desktop build. + * @returns {Object} - Version object. + */ function addFiles(contents, desktopSettings) { const versionObject = { version: desktopSettings.desktopVersion, @@ -637,10 +629,13 @@ class MeteorDesktopBundler { return versionObject; } + /** + * Finalizes the build process by restoring configurations and logging performance. + */ function endProcess() { console.timeEnd('[meteor-desktop] preparing desktop.asar took'); - // Look at the declaration of StringPrototypeToOriginal for explanation. + // Restore the original String.prototype.to to prevent conflicts. String.prototype.to = StringPrototypeToOriginal; // eslint-disable-line if (shelljs) { @@ -654,50 +649,65 @@ class MeteorDesktopBundler { const scaffold = new ElectronAppScaffold(context); const depsManager = new DependenciesManager( - context, scaffold.getDefaultPackageJson().dependencies + context, + scaffold.getDefaultPackageJson().dependencies ); this.stampPerformance('readdir'); - const readDirFuture = Future.fromPromise(this.utils.readDir(desktopPath)); - const readDirResult = readDirFuture.wait(); + let readDirResult; + try { + readDirResult = await this.utils.readDir(desktopPath); + } catch (e) { + inputFile.error({ + message: e.message || e + }); + return; + } this.stampPerformance('readdir'); this.stampPerformance('cache check'); - const cacheGetPromise = Future.fromPromise(cacache.get(this.cachePath, 'last')); let lastStats = null; try { - lastStats = cacheGetPromise.wait().data.toString('utf8'); - lastStats = JSON.parse(lastStats); + const cacheGetResult = await cacache.get(this.cachePath, 'last'); + lastStats = JSON.parse(cacheGetResult.data.toString('utf8')); } catch (e) { - logDebug('[meteor-desktop] no cache found'); + logDebug('[meteor-desktop] No cache found'); } - if (settings.env !== 'prod' && + if ( + settings.env !== 'prod' && lastStats && MeteorDesktopBundler.areStatsEqual(lastStats.stats, readDirResult.stats) ) { - logDebug('[meteor-desktop] cache match'); - const cacheAsarGetPromise = Future.fromPromise(cacache.get(this.cachePath, 'lastAsar')); - const contents = cacheAsarGetPromise.wait(); - if (contents.integrity === lastStats.asarIntegrity) { - const cacheSettingsGetPromise = Future.fromPromise(cacache.get(this.cachePath, 'lastSettings')); - const lastSettings = JSON.parse(cacheSettingsGetPromise.wait().data.toString('utf8')); - if (lastSettings.asarIntegrity === lastStats.asarIntegrity) { - addFiles(contents.data, lastSettings.settings); - endProcess(); - return; + logDebug('[meteor-desktop] Cache match found'); + try { + const cacheAsarResult = await cacache.get(this.cachePath, 'lastAsar'); + const contents = cacheAsarResult.data; + if (cacheAsarResult.integrity === lastStats.asarIntegrity) { + const cacheSettingsResult = await cacache.get(this.cachePath, 'lastSettings'); + const lastSettings = JSON.parse(cacheSettingsResult.data.toString('utf8')); + if (lastSettings.asarIntegrity === lastStats.asarIntegrity) { + addFiles(contents, lastSettings.settings); + endProcess(); + return; + } + logDebug('[meteor-desktop] Integrity check of settings failed'); + } else { + logDebug('[meteor-desktop] Integrity check of ASAR failed'); } - logDebug('[meteor-desktop] integrity check of settings failed'); - } else { - logDebug('[meteor-desktop] integrity check of asar failed'); + } catch (e) { + logDebug('[meteor-desktop] Cache miss during integrity checks'); } } else { if (settings.env !== 'prod') { - logDebug('[meteor-desktop] cache miss'); + logDebug('[meteor-desktop] Cache miss detected'); + } + try { + await cacache.rm(this.cachePath, 'last'); + logDebug('[meteor-desktop] Cache invalidated'); + } catch (e) { + logDebug('[meteor-desktop] Failed to invalidate cache:', e); } - cacache.rm(this.cachePath, 'last') - .then(() => logDebug('[meteor-desktop] cache invalidate')) - .catch(e => logDebug('[meteor-desktop] failed to invalidate cache', e)); } this.stampPerformance('cache check'); @@ -714,16 +724,16 @@ class MeteorDesktopBundler { md5 } = deps); } catch (e) { - // Look at the declaration of StringPrototypeToOriginal for explanation. + // Restore original String.prototype.to to prevent side effects. String.prototype.to = StringPrototypeToOriginal; // eslint-disable-line inputFile.error({ - message: e + message: e.message || e }); return; } this.stampPerformance('build deps lookout'); - shelljsConfig = Object.assign({}, shelljs.config); + shelljsConfig = { ...shelljs.config }; shelljs.config.fatal = true; shelljs.config.silent = false; @@ -734,46 +744,41 @@ class MeteorDesktopBundler { this.stampPerformance('copy .desktop'); shelljs.rm('-rf', desktopTmpPath); shelljs.cp('-rf', desktopPath, desktopTmpPath); - del.sync([ - path.join(desktopTmpPath, '**', '*.test.js') - ]); + del.sync([path.join(desktopTmpPath, '**', '*.test.js')]); this.stampPerformance('copy .desktop'); this.stampPerformance('compute dependencies'); const configs = this.gatherModuleConfigs(shelljs, modulesPath, inputFile); const dependencies = this.getDependencies(desktopPath, inputFile, configs, depsManager); - // Pass information about build type to the settings.json. + // Update settings with build environment. settings.env = process.env.NODE_ENV === 'production' ? 'prod' : 'dev'; this.stampPerformance('compute dependencies'); this.stampPerformance('desktop hash'); - const hashFuture = new Future(); - const hashFutureResolve = hashFuture.resolver(); - - let desktopHash; - let hashes; - let fileContents; - - this.utils.readFilesAndComputeHash(desktopPath, file => file.replace('.desktop', '')) - .then((result) => { - ({ fileContents, fileHashes: hashes, hash: desktopHash } = result); - hashFutureResolve(); - }) - .catch((e) => { hashFuture.throw(e); }); + let desktopHash, hashes, fileContents; - hashFuture.wait(); + try { + const hashResult = await this.utils.readFilesAndComputeHash( + desktopPath, + file => file.replace('.desktop', '') + ); + ({ fileContents, fileHashes: hashes, hash: desktopHash } = hashResult); + } catch (e) { + throw new Error(`[meteor-desktop] Failed to compute .desktop hash: ${e}`); + } this.stampPerformance('desktop hash'); const version = `${desktopHash}_${settings.env}`; - - console.log(`[meteor-desktop] calculated .desktop hash version is ${version}`); + console.log(`[meteor-desktop] Calculated .desktop hash version is ${version}`); settings.desktopVersion = version; - settings.compatibilityVersion = - this.calculateCompatibilityVersion( - dependencies.getDependencies(), desktopPath, inputFile, md5 - ); + settings.compatibilityVersion = this.calculateCompatibilityVersion( + dependencies.getDependencies(), + desktopPath, + inputFile, + md5 + ); settings.meteorDesktopVersion = this.getVersion(); @@ -782,147 +787,148 @@ class MeteorDesktopBundler { } fs.writeFileSync( - path.join(desktopTmpPath, 'settings.json'), JSON.stringify(settings, null, 4) + path.join(desktopTmpPath, 'settings.json'), + JSON.stringify(settings, null, 4) ); - // Move files that should not be asar'ed. + // Remove files that should not be packaged into the ASAR archive. this.stampPerformance('extract'); - - configs.forEach((config) => { - const moduleConfig = config; - if ('extract' in moduleConfig) { - if (!Array.isArray(moduleConfig.extract)) { - moduleConfig.extract = [moduleConfig.extract]; - } - moduleConfig.extract.forEach((file) => { - const filePath = path.join( - modulesPath, moduleConfig.dirName, file - ); - + configs.forEach(config => { + if ('extract' in config) { + const filesToExtract = Array.isArray(config.extract) ? config.extract : [config.extract]; + filesToExtract.forEach(file => { + const filePath = path.join(modulesPath, config.dirName, file); shelljs.rm(filePath); }); } }); - this.stampPerformance('extract'); const options = 'uglifyOptions' in settings ? settings.uglifyOptions : {}; - const uglifyingEnabled = 'uglify' in settings && !!settings.uglify; + const uglifyingEnabled = 'uglify' in settings && Boolean(settings.uglify); + // Handle potential default export from babelPresetEnv. if (babelPresetEnv.default) { babelPresetEnv = babelPresetEnv.default; } - const preset = babelPresetEnv({ - version: this.getPackageJsonField('dependencies')['@babel/preset-env'], - assertVersion: () => { } - }, { targets: { node: '14' } }); + + const preset = babelPresetEnv( + { + version: this.getPackageJsonField('dependencies')['@babel/preset-env'], + assertVersion: () => {} + }, + { targets: { node: '14' } } + ); this.stampPerformance('babel/uglify'); - const promises = []; - Object.keys(fileContents).forEach((file) => { + const processingPromises = Object.keys(fileContents).map(file => { const filePath = path.join(desktopTmpPath, file); const cacheKey = `${file}-${hashes[file]}`; - promises.push(new Promise((resolve, reject) => { - cacache.get(this.cachePath, cacheKey) - .then((cacheEntry) => { - logDebug(`[meteor-desktop] loaded from cache: ${file}`); - let code = cacheEntry.data; - let error; - if (settings.env === 'prod' && uglifyingEnabled) { - ({ code, error } = terser.minify(code.toString('utf8'), options)); + return (async () => { + try { + const cacheEntry = await cacache.get(this.cachePath, cacheKey); + logDebug(`[meteor-desktop] Loaded from cache: ${file}`); + let code = cacheEntry.data; + + if (settings.env === 'prod' && uglifyingEnabled) { + const terserResult = await terser.minify(code.toString('utf8'), options); + if (terserResult.error) { + throw terserResult.error; } - if (error) { - reject(error); - } else { - fs.writeFileSync(filePath, code); - resolve(); + code = terserResult.code; + } + + fs.writeFileSync(filePath, code); + } catch (cacheError) { + logDebug(`[meteor-desktop] Processing from disk: ${file}`); + try { + const transformed = await babelCore.transformAsync(fileContents[file], { + presets: [preset] + }); + + if (!transformed || !transformed.code) { + throw new Error(`Babel transformation failed for file ${file}`); } - }) - .catch(() => { - logDebug(`[meteor-desktop] from disk ${file}`); - const fileContent = fileContents[file]; - let code; - babelCore.transform( - fileContent, - { - presets: [preset] - }, - (err, result) => { - if (err) { - this.watcherEnabled = true; - reject(err); - } else { - ({ code } = result); - cacache.put(this.cachePath, `${file}-${hashes[file]}`, code).then(() => { - logDebug(`[meteor-desktop] cached ${file}`); - }); - - let uglifiedCode; - let error; - if (settings.env === 'prod' && uglifyingEnabled) { - ({ code: uglifiedCode, error } = - terser.minify(code, options)); - } - - if (error) { - reject(error); - } else { - // in development mode, uglifiedCode will be undefined, which causes an error since fs.writeFileSync introduced type checking of the data parameter in Node 14. - // https://github.com/wojtkowiak/meteor-desktop/issues/303#issuecomment-1025337912 - fs.writeFileSync(filePath, uglifiedCode || code); - resolve(); - } - } + + let code = transformed.code; + + await cacache.put(this.cachePath, `${file}-${hashes[file]}`, code); + logDebug(`[meteor-desktop] Cached: ${file}`); + + if (settings.env === 'prod' && uglifyingEnabled) { + const terserResult = await terser.minify(code, options); + if (terserResult.error) { + throw terserResult.error; } - ); - }); - })); + code = terserResult.code; + } + + fs.writeFileSync(filePath, code); + } catch (transformError) { + this.watcherEnabled = true; + throw transformError; + } + } + })(); }); - const all = Future.fromPromise(Promise.all(promises)); - all.wait(); + try { + await Promise.all(processingPromises); + } catch (e) { + inputFile.error({ + message: e.message || e + }); + return; + } this.stampPerformance('babel/uglify'); this.stampPerformance('@electron/asar'); - const future = new Future(); - const resolve = future.resolver(); const asarPath = path.join(desktopTmpAsarPath, 'desktop.asar'); - electronAsar.createPackage( - desktopTmpPath, - asarPath - ) - .then(() => { - resolve(); + try { + await electronAsar.createPackage(desktopTmpPath, asarPath); + } catch (e) { + inputFile.error({ + message: e.message || e }); - future.wait(); + return; + } this.stampPerformance('@electron/asar'); const contents = fs.readFileSync(asarPath); - function saveCache(desktopAsar, stats, desktopSettings) { + /** + * Saves the current build state to the cache. + * + * @param {Buffer} desktopAsar - The ASAR package buffer. + * @param {Object} stats - File system stats. + * @param {Object} desktopSettings - Desktop build settings. + * @returns {Promise} - The integrity hash of the saved ASAR. + */ + async function saveCache(desktopAsar, stats, desktopSettings) { let asarIntegrity; - return new Promise((saveCacheResolve, saveCacheReject) => { - cacache.put(self.cachePath, 'lastAsar', desktopAsar) - .then((integrity) => { - asarIntegrity = integrity; - return cacache.put(self.cachePath, 'last', JSON.stringify({ stats, asarIntegrity })); - }) - .then(() => cacache.put( - self.cachePath, - 'lastSettings', - JSON.stringify({ settings: desktopSettings, asarIntegrity }) - )) - .then(finalIntegrity => saveCacheResolve(finalIntegrity)) - .catch(saveCacheReject); - }); + try { + asarIntegrity = await cacache.put(self.cachePath, 'lastAsar', desktopAsar); + await cacache.put(self.cachePath, 'last', JSON.stringify({ stats, asarIntegrity })); + await cacache.put( + self.cachePath, + 'lastSettings', + JSON.stringify({ settings: desktopSettings, asarIntegrity }) + ); + return asarIntegrity; + } catch (e) { + throw e; + } } if (settings.env !== 'prod') { - saveCache(contents, readDirResult.stats, settings) - .then(integrity => logDebug('[meteor-desktop] cache saved:', integrity)) - .catch(e => console.error('[meteor-desktop]: saving cache failed:', e)); + try { + const integrity = await saveCache(contents, readDirResult.stats, settings); + logDebug('[meteor-desktop] Cache saved:', integrity); + } catch (e) { + console.error('[meteor-desktop]: Saving cache failed:', e); + } } addFiles(contents, settings); @@ -939,6 +945,7 @@ class MeteorDesktopBundler { } } +// Register the compiler with Meteor if Plugin is defined. if (typeof Plugin !== 'undefined') { Plugin.registerCompiler( { extensions: ['desktop', '_desktop_.js'] }, diff --git a/plugins/bundler/package.js b/plugins/bundler/package.js index e16726a9..abe52419 100644 --- a/plugins/bundler/package.js +++ b/plugins/bundler/package.js @@ -1,7 +1,7 @@ /* eslint-disable prefer-arrow-callback */ Package.describe({ name: 'communitypackages:meteor-desktop-bundler', - version: '3.2.0', + version: '3.3.0', summary: 'Bundles .desktop dir into desktop.asar.', git: 'https://github.com/Meteor-Community-Packages/meteor-desktop', documentation: 'README.md'