From e2c3c182d0998d4f4fd32559940415dfbb8e7739 Mon Sep 17 00:00:00 2001 From: Alberto Miranda Date: Sun, 23 Apr 2017 13:46:52 -0300 Subject: [PATCH] Plugins divided into main and renderer processes. --- src/main/index.js | 2 +- src/{ => main}/plugins.js | 60 ++++---- src/renderer/main.js | 7 +- src/renderer/plugins.js | 287 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 320 insertions(+), 36 deletions(-) rename src/{ => main}/plugins.js (89%) create mode 100644 src/renderer/plugins.js diff --git a/src/main/index.js b/src/main/index.js index 357b0805..618756f6 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -12,7 +12,7 @@ const log = require('./log') const menu = require('./menu') const State = require('../renderer/lib/state') const windows = require('./windows') -const Plugins = require('../plugins') +const Plugins = require('./plugins') const plugins = new Plugins() let shouldQuit = false diff --git a/src/plugins.js b/src/main/plugins.js similarity index 89% rename from src/plugins.js rename to src/main/plugins.js index 665d5276..a3d2fddd 100644 --- a/src/plugins.js +++ b/src/main/plugins.js @@ -1,7 +1,7 @@ const {exec} = require('child_process') const {resolve, basename} = require('path') const {writeFileSync} = require('fs') -const State = require('./renderer/lib/state') +const State = require('../renderer/lib/state') const notifier = require('node-notifier') const {app} = require('electron') @@ -10,16 +10,15 @@ const ms = require('ms') const shellEnv = require('shell-env') const crypto = require('crypto') -const config = require('./config') +const config = require('../config') module.exports = class Plugins { constructor () { // modules path this.path = resolve(config.getConfigPath(), 'plugins') - log('path: ', this.path) + log('Path: ', this.path) this.availableExtensions = new Set([ - 'onApp', 'onWindow', 'decorateMenu', 'decorateWindow', - 'decorateConfig', 'initRenderer', 'onCheckForSubtitles' + 'onApp', 'onWindow', 'decorateMenu', 'decorateWindow', 'decorateConfig' ]) this.forceUpdate = false @@ -66,7 +65,7 @@ module.exports = class Plugins { setTimeout(() => { this.updatePlugins() }, 5000) - log('installation scheduled') + log('Installation scheduled') } // update plugins every 5 hours @@ -75,20 +74,14 @@ module.exports = class Plugins { }, ms('5h')) } - initRenderer (params) { - this.modules.forEach(plugin => { - if (plugin.initRenderer) { - plugin.initRenderer(params) - } - }) - } - on (action, params) { log(`ON ${action}:`, params) this.modules.forEach(plugin => { const actionName = this.capitalizeFirstLetter(action) - const actionHandler = plugin[`on${actionName}`] - if (actionHandler) actionHandler(params) + const methodName = `on${actionName}` + if (typeof plugin[methodName] === 'function') { + plugin[methodName](params) + } }) } @@ -173,7 +166,7 @@ module.exports = class Plugins { if (this.forceUpdate || changed) { this.watchers.forEach(fn => fn(err, {forceUpdate: this.forceUpdate})) this.alert('Installation completed') - log('installation completed') + log('Installation completed') } // save state @@ -321,28 +314,26 @@ module.exports = class Plugins { let installNeeded = false const load = (path) => { - let mod if (!path.match(/\/$/)) { path += '/' } + const mainPath = `${path}main.js` + try { - // eslint-disable-next-line import/no-dynamic-require - mod = require(path) - const exposed = mod && Object.keys(mod).some(key => this.availableExtensions.has(key)) - if (!exposed) { - this.alert(`Plugin error: Plugin "${basename(path)}" does not expose any ` + - 'WebTorrent extension API methods') - return - } + const Plugin = require(mainPath) // eslint-disable import/no-dynamic-require + const plugin = new Plugin() + + const exposed = plugin && Object.keys(plugin).some(key => this.availableExtensions.has(key)) + if (!exposed) return // populate the name for internal errors here - mod._name = basename(path) + plugin._name = basename(mainPath) - return mod + return plugin } catch (err) { log('Require plugins ERROR:', err) - this.alert(`Error loading plugin: ${path}`) + this.alert(`Error loading plugin: ${mainPath}`) // plugin not installed // node_modules removed? did a manual plugin uninstall? // try installing and then loading if successfull @@ -350,7 +341,14 @@ module.exports = class Plugins { } } - if (installNeeded) this.updatePlugins() + // Plugin installation happens on the MAIN process. + // If plugins haven't finished installing, wait for them. + if (installNeeded) { + log('Plugins install needed, wait...') + setTimeout(() => { + this.requirePlugins() + }, 3000) + } return plugins.map(load).filter(v => Boolean(v)) } @@ -415,7 +413,7 @@ module.exports = class Plugins { * */ function log () { - const prefix = '[ PLUGINS ]-->' + const prefix = '[ PLUGINS.Main ]-->' const args = [prefix] for (var i = 0; i < arguments.length; ++i) { diff --git a/src/renderer/main.js b/src/renderer/main.js index 35c7ceb1..0a125925 100644 --- a/src/renderer/main.js +++ b/src/renderer/main.js @@ -36,7 +36,7 @@ const telemetry = require('./lib/telemetry') const sound = require('./lib/sound') const TorrentPlayer = require('./lib/torrent-player') -const Plugins = require('../plugins') +const Plugins = require('./plugins') const plugins = new Plugins() // Perf optimization: Needed immediately, so do not lazy load it below @@ -117,8 +117,7 @@ function onState (err, _state) { }) } - plugins.init(state) - plugins.initRenderer({dispatch, state}) + plugins.init({dispatch, state}) // Add first page to location history state.location.go({ @@ -338,7 +337,7 @@ function dispatch (action, ...args) { } const handler = dispatchHandlers[action] - plugins.on(action, ...args) + plugins.on(action, args) if (handler) handler(...args) else console.error('Missing dispatch handler: ' + action) diff --git a/src/renderer/plugins.js b/src/renderer/plugins.js new file mode 100644 index 00000000..319a6eca --- /dev/null +++ b/src/renderer/plugins.js @@ -0,0 +1,287 @@ +const {resolve, basename} = require('path') +const notifier = require('node-notifier') +const crypto = require('crypto') + +const config = require('../config') + +module.exports = class Plugins { + constructor () { + // modules path + this.path = resolve(config.getConfigPath(), 'plugins') + log('Path: ', this.path) + this.availableExtensions = [ + 'onCheckForSubtitles' + ] + + this.forceUpdate = false + this.updating = false + this.watchers = [] + this.state = {} + } + + init (params) { + this.state = params.state + + // initialize state + this.state.saved = Object.assign(this.state.saved || {}) + + // caches + this.plugins = config.getPlugins() + this.paths = this.getPaths(this.plugins) + this.id = this.getId(this.plugins) + this.modules = this.requirePlugins() + + // TODO: fire an event when plugins finish updating and listen to that. + // Listen to plugin changes on config. + // New plugins added, plugins removed or updated. + // The actual plugin update action will take place in the MAIN process. + config.subscribe(() => { + const plugins = config.getPlugins() + if (plugins !== this.plugins) { + const id = this.getId(plugins) + if (this.id !== id) { + log('UPDATING...') + this.id = id + this.plugins = plugins + this.paths = this.getPaths(this.plugins) + } + } + }) + + // Plugins will be updated on the MAIN process after 5s. + if (this.needsUpdate()) { + setTimeout(() => { + this.init(params) + }, 6000) + log('Plugins need update, init scheduled') + return + } + + this.loadPlugins() + this.initPlugins(params) + } + + initPlugins (params) { + this.modules.forEach(plugin => { + if (plugin.init) { + plugin.init(params) + } + }) + } + + on (action, params) { + log(`ON ${action}:`, params) + this.modules.forEach(plugin => { + const actionName = this.capitalizeFirstLetter(action) + const methodName = `on${actionName}` + if (typeof plugin[methodName] === 'function') { + plugin[methodName](params) + } + }) + } + + capitalizeFirstLetter (string) { + return string.charAt(0).toUpperCase() + string.slice(1) + } + + didPluginsChange () { + return this.state.saved.installedPlugins !== this.id + } + + hasPlugins () { + return !this.isEmptyObject(this.plugins) + } + + isFirstInstall () { + return (!this.state.saved.installedPlugins && this.hasPlugins()) + } + + needsUpdate () { + return (this.didPluginsChange() || this.isFirstInstall()) + } + + getId (plugins) { + const hash = crypto.createHash('sha256') + hash.update(JSON.stringify(plugins)) + return hash.digest('hex') + } + + loadPlugins (err, localOnly = false) { + this.updating = false + + // handle errors first + if (err) { + console.error(err.stack) + if (/not a recognized/.test(err.message) || /command not found/.test(err.message)) { + this.alert( + 'Error updating plugins: We could not find the "npm" command. Make sure it\'s in $PATH' + ) + return + } + + this.alert(`Error updating plugins: Check '${this.path}/npm-debug.log' for more information.`) + return + } + + // update state with latest plugins + this.state.saved.plugins = this.plugins + + // cache modules + this.modules = this.requirePlugins() + + // clear require cache + this.clearCache() + } + + getPluginVersions () { + const paths_ = this.paths.plugins + return paths_.map(path => { + let version = null + try { + // eslint-disable-next-line import/no-dynamic-require + version = require(resolve(path, 'package.json')).version + } catch (err) { } + return [ + basename(path), + version + ] + }) + } + + clearCache () { + // clear require cache + for (const entry in require.cache) { + if (entry.indexOf(this.path) === 0) { + delete require.cache[entry] + } + } + } + + isEmptyObject (obj) { + return (Object.keys(obj).length === 0) + } + + alert (message) { + notifier.notify({ + title: 'WebTorrent Plugins', + // icon: config.icon, // TODO: save icon in webtorrent local folder and set config.icon + message: message + }) + } + + subscribe (fn) { + this.watchers.push(fn) + return () => { + this.watchers.splice(this.watchers.indexOf(fn), 1) + } + } + + isLocalPath (string) { + // matches unix and windows local paths + return string.match(/^(\/|[a-z]:\/)/i) + } + + getPaths (plugins) { + const pluginNames = Object.keys(plugins) + + return { + plugins: pluginNames.map(name => { + let url = plugins[name] + + // plugin is already on a local folder + // directly load it from its current location + if (this.isLocalPath(url)) return url + + // plugin will be installed with npm install from a remote url + return resolve(this.path, 'node_modules', name.split('#')[0]) + }) + } + } + + exposesSupportedApi (plugin) { + if (!plugin) return false + + return this.availableExtensions.some((methodName) => { + return (typeof plugin[methodName] === 'function') + }) + } + + requirePlugins () { + const {plugins} = this.paths + let installNeeded = false + + const load = (path) => { + if (!path.match(/\/$/)) { + path += '/' + } + + const rendererPath = `${path}renderer.js` + + try { + const Plugin = require(rendererPath) // eslint-disable import/no-dynamic-require + const plugin = new Plugin() + + const exposed = this.exposesSupportedApi(plugin) + if (!exposed) { + log('Plugin not exposing any available extensions.', rendererPath, Object.keys(plugin)) + return + } + + // populate the name for internal errors here + plugin._name = basename(rendererPath) + + return plugin + } catch (err) { + log('Require plugins ERROR:', err) + this.alert(`Error loading plugin: ${rendererPath}`) + // plugin not installed + // node_modules removed? did a manual plugin uninstall? + // try installing and then loading if successfull + installNeeded = true + } + } + + // Plugin installation happens on the MAIN process. + // If plugins haven't finished installing, wait for them. + if (installNeeded) { + log('Plugins install needed, wait...') + setTimeout(() => { + this.requirePlugins() + }, 3000) + } + return plugins.map(load).filter(v => Boolean(v)) + } + + // decorates the base object by calling plugin[key] + // for all the available plugins + decorateObject (base, key) { + let decorated = base + this.modules.forEach(plugin => { + if (plugin[key]) { + const res = plugin[key](decorated) + if (res && typeof res === 'object') { + decorated = res + } else { + this.alert(`Plugin error: "${plugin._name}": invalid return type for \`${key}\``) + } + } + }) + + return decorated + } +} + +/** + * Logs passed arguments to console using a prefix. + * + */ +function log () { + const prefix = '[ PLUGINS.Renderer ]-->' + const args = [prefix] + + for (var i = 0; i < arguments.length; ++i) { + args.push(arguments[i]) + } + + console.log.apply(console, args) +}