diff --git a/.gitignore b/.gitignore index 87bd62b85..9a680f0b1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ electron-builder.env node_modules/ bundle.js workbox-*.js -service_worker.js \ No newline at end of file +service_worker.js +test-env/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..454a662fe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +// FIXME - This file should be removed before the PR is merged! +{ + "files.exclude": { + "**/.git": true, + "**/node_modules": true + }, + "editor.formatOnSave": false, +} diff --git a/js/api.js b/js/api.js index 770a4be79..be1cfe274 100644 --- a/js/api.js +++ b/js/api.js @@ -338,6 +338,14 @@ const Blockbench = { if (LastVersion && compareVersions(version, LastVersion) && !Blockbench.isOlderThan(version)) { callback(LastVersion); } + }, + /** + * Logs output to the terminal Blockbench was started from with a fancy Blockbench prefix + * + * If the first argument is 'NO_PREFIX', the output won't include the "[Blockbench]" prefix + */ + log(...args) { + app.terminal.log(...args) } }; diff --git a/js/boot_loader.js b/js/boot_loader.js index 59ca14458..9243d1903 100644 --- a/js/boot_loader.js +++ b/js/boot_loader.js @@ -156,6 +156,7 @@ localStorage.setItem('last_version', Blockbench.version); loadInfoFromURL(); } proceeded = true; + Blockbench.dispatchEvent('all_plugins_loaded') } loadInstalledPlugins().then(proceed); setTimeout(proceed, 1200); @@ -165,4 +166,8 @@ setStartScreen(true); document.getElementById('page_wrapper').classList.remove('invisible'); +if (process.env.BLOCKBENCH_OPEN_DEV_TOOLS === 'TRUE') { + electron.getCurrentWindow().openDevTools() +} + Blockbench.setup_successful = true; diff --git a/js/cli.js b/js/cli.js new file mode 100644 index 000000000..3da4894d0 --- /dev/null +++ b/js/cli.js @@ -0,0 +1,147 @@ +const { app, ipcMain } = require('electron') +const PACKAGE = require('../package.json') +const { Command } = require('commander') +const pathjs = require('path') + +const program = new Command() + +program.name('blockbench').description(PACKAGE.description) + +program + .option('-v, --version', 'output the version number', () => { + app.terminal.log('NO_PREFIX', PACKAGE.version) + app.exit(0) + }) + .option('--userData ', 'change the folder Blockbench uses to store user data') + .option('--no-auto-update', 'disables auto update') + .option('--install-custom-plugins ', 'install plugins from the given paths or URLS on startup') + .option('--install-plugins ', 'install plugins by ID from the Blockbench plugin repository on startup') + .option('--clean-installed-plugins', 'remove all installed plugins on startup') + .option('--open-dev-tools', 'open the developer tools on startup') + .option('--hidden', 'hide the main window') + // Custom Error Handling + .exitOverride(error => { + switch (error.code) { + case 'commander.help': + case 'commander.helpDisplayed': + case 'commander.version': + app.exit(0) + case 'commander.unknownOption': + case 'commander.excessArguments': + // Uses ANSI escape codes to clear the previous line and print a warning message. + app.terminal.log('NO_PREFIX', '\x1b[1A\x1b[2K\x1b[33mUse --help to see available options\x1b[0m') + app.exit(1) + default: + app.terminal.error('\x1b[2;31m%s\x1b[0m', error) + app.exit(1) + } + }) + .configureOutput({ + getOutHasColors: () => true, + writeErr: str => { + console.error('\x1b[91m%s\x1b[0m', str) + }, + }) + // Custom Help Styling + .configureHelp({ + styleTitle(str) { + return `\x1b[1m${str}\x1b[0m` + }, + styleCommandText(str) { + return `\x1b[36m${str}\x1b[0m` + }, + styleCommandDescription(str) { + return `\x1b[1A\x1b[2K\x1b[35m${str}\x1b[0m` + }, + styleDescriptionText(str) { + return `\x1b[3m${str}\x1b[0m` + }, + styleOptionText(str) { + return `\x1b[32m${str}\x1b[0m` + }, + styleArgumentText(str) { + return `\x1b[33m${str}\x1b[0m` + }, + styleSubcommandText(str) { + return `\x1b[34m${str}\x1b[0m` + }, + }) + +/** + * Makes sure the environment variables are set to defaults if they are not set. + * + * Allows for overriding the default values by setting the environment variables before starting Blockbench. + */ +function affirmEnvironmentVariables() { + process.env.BLOCKBENCH_AUTO_UPDATE ??= 'ENABLED' + process.env.BLOCKBENCH_CLEAN_INSTALLED_PLUGINS ??= 'FALSE' + process.env.BLOCKBENCH_INSTALL_CUSTOM_PLUGINS ??= '' + process.env.BLOCKBENCH_INSTALL_PLUGINS ??= '' + process.env.BLOCKBENCH_OPEN_DEV_TOOLS ??= 'FALSE' + process.env.BLOCKBENCH_USER_DATA ??= app.getPath('userData') + process.env.BLOCKBENCH_HIDDEN ??= 'FALSE' +} + +function parseCLI() { + // Parse command line arguments. + program.parse() + /** + * @type {{ userData?: string, autoUpdate?: boolean, withPluginFiles?: string[], withPluginUrls?: string[] }} + */ + let { + userData, + autoUpdate, + installCustomPlugins, + installPlugins, + openDevTools, + cleanInstalledPlugins, + hidden, + } = program.opts() + + // --no-auto-update + if (autoUpdate === false) { + process.env.BLOCKBENCH_AUTO_UPDATE = 'DISABLED' + } + + // --userData + if (userData) { + if (!pathjs.isAbsolute(userData)) { + // Automatically resolve relative paths. + userData = pathjs.resolve(userData) + } + process.env.BLOCKBENCH_USER_DATA = userData + } + app.setPath('userData', process.env.BLOCKBENCH_USER_DATA) + + // --clean-installed-plugins + if (cleanInstalledPlugins) { + process.env.BLOCKBENCH_CLEAN_INSTALLED_PLUGINS = 'TRUE' + } + // --install-custom-plugins + if (installCustomPlugins?.length > 0) { + process.env.BLOCKBENCH_INSTALL_CUSTOM_PLUGINS = installCustomPlugins.join(',') + } + // --install-plugins + if (installPlugins?.length > 0) { + process.env.BLOCKBENCH_INSTALL_PLUGINS = installPlugins.join(',') + } + // --open-dev-tools + if (openDevTools) { + process.env.BLOCKBENCH_OPEN_DEV_TOOLS = 'TRUE' + } + // --hidden + if (hidden) { + process.env.BLOCKBENCH_HIDDEN = 'TRUE' + } +} + +module.exports = function cli() { + affirmEnvironmentVariables() + + try { + parseCLI() + } catch (error) { + app.terminal.error(error) + app.exit(1) + } +} diff --git a/js/plugin_loader.js b/js/plugin_loader.js index 3d6396706..92b31dc5a 100644 --- a/js/plugin_loader.js +++ b/js/plugin_loader.js @@ -33,8 +33,32 @@ const Plugins = { }); } } + +const ENVIRONMENT_CUSTOM_PLUGINS = [] +const ENVIRONMENT_PLUGINS = [] +if (process.env.BLOCKBENCH_INSTALL_CUSTOM_PLUGINS) { + ENVIRONMENT_CUSTOM_PLUGINS.push(...process.env.BLOCKBENCH_INSTALL_CUSTOM_PLUGINS + .split(',') + .map(file => file.trim()) + ) +} +if (process.env.BLOCKBENCH_INSTALL_PLUGINS) { + ENVIRONMENT_PLUGINS.push(...process.env.BLOCKBENCH_INSTALL_PLUGINS + .split(',') + .map(url => url.trim()) + ) +} + StateMemory.init('installed_plugins', 'array') -Plugins.installed = StateMemory.installed_plugins = StateMemory.installed_plugins.filter(p => p && typeof p == 'object'); + +if (process.env.BLOCKBENCH_CLEAN_INSTALLED_PLUGINS === 'TRUE') { + app.terminal.log('--clean-installed-plugins: Clearing installed plugins') + Plugins.installed = StateMemory.installed_plugins = [] +} else { + Plugins.installed = StateMemory.installed_plugins = StateMemory.installed_plugins.filter( + p => p && typeof p == 'object' && !ENVIRONMENT_CUSTOM_PLUGINS.includes(p.path) && !ENVIRONMENT_PLUGINS.includes(p.id) + ) +} async function runPluginFile(path, plugin_id) { let file_content; @@ -663,6 +687,16 @@ class Plugin { } return this.details; } + + /** + * Logs output to the terminal Blockbench was started from with a fancy plugin-specific prefix + * @example + * myPlugin.log('Hello World!') + * /// [Blockbench] Hello World! + */ + log(...args) { + Blockbench.log(`\x1b[90m<\x1b[33m${this.id}\x1b[90m>\x1b[0m`, ...args) + } } Plugin.prototype.menu = new Menu([ new MenuSeparator('installation'), @@ -867,7 +901,6 @@ async function loadInstalledPlugins() { if (Plugins.installed.length > 0) { var load_counter = 0; Plugins.installed.forEachReverse(function(plugin) { - if (plugin.source == 'file') { //Dev Plugins if (isApp && fs.existsSync(plugin.path)) { @@ -901,7 +934,115 @@ async function loadInstalledPlugins() { console.log(`Loaded ${load_counter} plugin${pluralS(load_counter)}`) } StateMemory.save('installed_plugins') - + + // CLI Environment Plugins + for (const path of ENVIRONMENT_CUSTOM_PLUGINS) { + const id = PathModule.basename(path, '.js'); + const pathType = path.startsWith('http') ? 'URL' : 'File'; + + const alreadyInstalled = Plugins.installed.find(plugin => plugin.id === id) + if (alreadyInstalled) { + app.terminal.error(`Failed to install Environment plugin "${id}":`) + app.terminal.error(`A Plugin with the ID "${id}" already exists in the installed plugins list!`) + app.exit(1) + } + + // Remove the plugin from the installed plugins list when Blockbench is closed. + Blockbench.on('before_closing', () => { + const plugin = Plugins.installed.find(plugin => plugin.id === id) + Plugins.installed.remove(plugin) + StateMemory.save('installed_plugins') + app.terminal.log(`Uninstalled Environment plugin "${id}"`) + }) + + if (pathType === 'URL') { + app.terminal.log(`Installing Environment plugin "${id || path}" from URL...`); + if (!(Plugins.json instanceof Object && navigator.onLine)) { + app.terminal.error(`Failed to install Environment plugins:`) + app.terminal.error(`Blockbench cannot install plugins by URL when offline.`) + app.exit(1) + } + const instance = new Plugin(id); + install_promises.push(instance.loadFromURL(path, false) + .then(() => { + app.terminal.log(`Loaded Environment plugin "${id || path}" from URL`); + console.log(`🧩🏠🌐 Loaded Environment plugin "${id || path}" from URL`); + }) + .catch(err => { + app.terminal.error(`Failed to load Environment plugin "${id || path}":`) + app.terminal.error(err) + app.exit(1) + }) + ); + } else { + if (!fs.existsSync(path)) { + app.terminal.error(`Failed to install Environment plugin "${id}":`) + app.terminal.error(`The specified plugin file does not exist: "${path}"`) + app.exit(1) + } + app.terminal.log(`Installing Environment plugin "${id || path}" from file...`); + const instance = new Plugin(id); + install_promises.push(instance.loadFromFile({path}, false) + .then(() => { + app.terminal.log(`Loaded Environment plugin "${id || path}" from file`); + console.log(`🧩🏠📁 Loaded Environment plugin "${id || path}" from file`); + }) + .catch(err => { + app.terminal.error(`Failed to load Environment plugin "${id || path}":`) + app.terminal.error(err) + app.exit(1) + }) + ); + } + } + + // Cannot install plugins by URL when offline + if (ENVIRONMENT_PLUGINS.length > 0 && !(Plugins.json instanceof Object && navigator.onLine)) { + app.terminal.error(`Failed to install Environment plugins:`) + app.terminal.error(`Blockbench cannot install plugins by URL when offline.`) + app.exit(1) + } + + for (const id of ENVIRONMENT_PLUGINS) { + const exists = Plugins.json[id] instanceof Object + if (!exists) { + app.terminal.error(`Failed to install Environment plugin "${id}":`) + app.terminal.error(`Plugin data for "${id}" does not exist in the plugin repository.`) + app.exit(1) + } + const alreadyInstalled = Plugins.installed.find(plugin => plugin.id === id) + if (alreadyInstalled) { + app.terminal.error(`Failed to install Environment plugin "${id}":`) + app.terminal.error(`A Plugin with the ID "${id}" already exists in the installed plugins list!`) + app.exit(1) + } + + // Remove the plugin from the installed plugins list when Blockbench is closed. + Blockbench.on('before_closing', () => { + const plugin = Plugins.installed.find(plugin => plugin.id === id) + Plugins.installed.remove(plugin) + StateMemory.save('installed_plugins') + app.terminal.log(`Uninstalled Environment plugin "${id}"`) + }) + + app.terminal.log(`Installing Environment plugin "${id}"`); + const plugin = new Plugin(id, Plugins.json[id]) + install_promises.push( + plugin.download().then(() => { + if (!Plugins.json[id]) { + app.terminal.error(`Failed to install Environment plugin "${id}":`) + app.terminal.error(`Failed to fetch plugin data.`) + app.exit(1) + } + app.terminal.log(`Installed Environment plugin "${id}"`); + console.log(`🧩🏠📁 Installed Environment plugin "${id}"`); + }).catch(err => { + app.terminal.error(`Failed to install Environment plugin "${id}":`) + app.terminal.error(err) + app.exit(1) + }) + ) + } install_promises.forEach(promise => { promise.catch(console.error); diff --git a/main.js b/main.js index 70b89e9d3..b1952fe39 100644 --- a/main.js +++ b/main.js @@ -5,24 +5,48 @@ const { autoUpdater } = require('electron-updater'); const fs = require('fs'); const {getColorHexRGB} = require('electron-color-picker') require('@electron/remote/main').initialize() +const cli = require('./js/cli') let orig_win; let all_wins = []; let load_project_data; -(() => { - // Allow advanced users to specify a custom userData directory. - // Useful for portable installations, and for setting up development environments. - const index = process.argv.findIndex(arg => arg === '--userData'); - if (index !== -1) { - if (!process.argv.at(index + 1)) { - console.error('No path specified after --userData') - process.exit(1) +;(() => { + const Console = require('console').Console + /** + * A console object that always prints to the terminal Blockbench was lauched from. + */ + app.terminal = new Console(process.stdout, process.stderr) + /** + * A console.log function that prints to the terminal Blockbench was lauched from. + * + * If the first argument is 'NO_PREFIX', the output won't include the "[Blockbench]" prefix + */ + app.terminal.log = function(...args) { + if (args[0] === 'NO_PREFIX') { + args.shift() + } else { + const date = new Date() + args.splice(0, 0, `\x1b[90m[\x1b[30m${ + date.toLocaleString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + hour12: false, + }).replace(', ', ' ') + }\x1b[90m] [\x1b[34mBlockbench\x1b[90m]\x1b[0m`) } - app.setPath('userData', process.argv[index + 1]); + return Console.prototype.log.apply(app.terminal, args) } })() +cli() + +app.terminal.log('Blockbench starting...') + const LaunchSettings = { path: path.join(app.getPath('userData'), 'launch_settings.json'), settings: {}, @@ -153,10 +177,10 @@ function createWindow(second_instance, options = {}) { } else { win.setMenu(null); } - - if (options.maximize !== false) win.maximize() - win.show() - + if (process.env.BLOCKBENCH_HIDDEN === 'FALSE') { + if (options.maximize !== false) win.maximize() + win.show() + } win.loadURL(url.format({ pathname: index_path, protocol: 'file:', @@ -267,15 +291,19 @@ app.on('ready', () => { } if (app_was_loaded) { - console.log('[Blockbench] App reloaded or new window opened') + app.terminal.log('App reloaded or new window opened') return; } app_was_loaded = true; if (process.execPath && process.execPath.match(/node_modules[\\\/]electron/)) { - console.log('[Blockbench] App launched in development mode') + app.terminal.log('App launched in development mode') + } else if (process.env.BLOCKBENCH_AUTO_UPDATE === 'DISABLED') { + + app.terminal.log('App launched with auto update disabled') + } else { autoUpdater.autoInstallOnAppQuit = true; @@ -286,22 +314,22 @@ app.on('ready', () => { } autoUpdater.on('update-available', (a) => { - console.log('update-available', a) + app.terminal.log('update-available', a) ipcMain.on('allow-auto-update', () => { autoUpdater.downloadUpdate() }) if (!orig_win.isDestroyed()) orig_win.webContents.send('update-available', a); }) autoUpdater.on('update-downloaded', (a) => { - console.log('update-downloaded', a) + app.terminal.log('update-downloaded', a) if (!orig_win.isDestroyed()) orig_win.webContents.send('update-downloaded', a) }) autoUpdater.on('error', (a) => { - console.log('update-error', a) + app.terminal.log('update-error', a) if (!orig_win.isDestroyed()) orig_win.webContents.send('update-error', a) }) autoUpdater.on('download-progress', (a) => { - console.log('update-progress', a) + app.terminal.log('update-progress', a) if (!orig_win.isDestroyed()) orig_win.webContents.send('update-progress', a) }) autoUpdater.checkForUpdates().catch(err => {}) diff --git a/package-lock.json b/package-lock.json index 38ae88d4e..93792e14d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "GPL-3.0-or-later", "dependencies": { "@electron/remote": "^2.1.2", + "commander": "^13.1.0", "electron-color-picker": "^0.2.0", "electron-updater": "^6.3.4", "gifenc": "^1.0.3" @@ -3924,10 +3925,13 @@ } }, "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/common-tags": { "version": "1.8.2", @@ -7859,6 +7863,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/three": { "version": "0.134.0", "resolved": "https://registry.npmjs.org/three/-/three-0.134.0.tgz", diff --git a/package.json b/package.json index 9bab49cf2..3435e2cea 100644 --- a/package.json +++ b/package.json @@ -136,8 +136,9 @@ }, "dependencies": { "@electron/remote": "^2.1.2", + "commander": "^13.1.0", "electron-color-picker": "^0.2.0", "electron-updater": "^6.3.4", "gifenc": "^1.0.3" } -} \ No newline at end of file +} diff --git a/types/blockbench-types.d.ts b/types/blockbench-types.d.ts index 9082a7901..15ab57906 100644 --- a/types/blockbench-types.d.ts +++ b/types/blockbench-types.d.ts @@ -1 +1,35 @@ /// + +// Define our custom env variables +declare namespace NodeJS { + export interface ProcessEnv { + /** + * Whether to enable auto-updates + */ + BLOCKBENCH_AUTO_UPDATE?: 'ENABLED' | 'DISABLED' + /** + * The path to the user data directory + */ + BLOCKBENCH_USER_DATA?: string + /** + * A comma-separated list of plugin file paths or URLs to install + */ + BLOCKBENCH_INSTALL_CUSTOM_PLUGINS?: string + /** + * A comma-separated list of plugin IDs to install from the Blockbench plugin repository + */ + BLOCKBENCH_INSTALL_PLUGINS?: string + /** + * Whether or not to open the dev tools on startup + */ + BLOCKBENCH_OPEN_DEV_TOOLS?: 'TRUE' | 'FALSE' + /** + * Whether or not to remove all installed plugins on startup + */ + BLOCKBENCH_CLEAN_INSTALLED_PLUGINS?: 'TRUE' | 'FALSE' + /** + * Whether or not to hide the Blockbench window + */ + BLOCKBENCH_HIDDEN?: 'TRUE' | 'FALSE' + } +}