diff --git a/package.json b/package.json index 4a40882f..14dba0d4 100644 --- a/package.json +++ b/package.json @@ -18,14 +18,17 @@ "bitfield": "^1.0.2", "capture-frame": "^1.0.0", "chromecasts": "^1.8.0", + "color": "^1.0.3", "cp-file": "^4.0.1", "create-torrent": "^3.24.5", "debounce": "^1.0.0", "deep-equal": "^1.0.1", "dlnacasts": "^0.1.0", "drag-drop": "^2.12.1", + "electron-config": "^0.2.1", "es6-error": "^4.0.0", "fn-getter": "^1.0.0", + "gaze": "^1.1.2", "iso-639-1": "^1.2.1", "languagedetect": "^1.1.1", "location-history": "^1.0.0", @@ -41,6 +44,7 @@ "rimraf": "^2.5.2", "run-parallel": "^1.1.6", "semver": "^5.1.0", + "shell-env": "^0.3.0", "simple-concat": "^1.0.0", "simple-get": "^2.0.0", "srt-to-vtt": "^1.1.1", diff --git a/src/config.js b/src/config.js index 5388885d..cfd0d266 100644 --- a/src/config.js +++ b/src/config.js @@ -2,6 +2,8 @@ const appConfig = require('application-config')('WebTorrent') const path = require('path') const electron = require('electron') const arch = require('arch') +const {resolve} = require('path') +const gaze = require('gaze') const APP_NAME = 'WebTorrent' const APP_TEAM = 'WebTorrent, LLC' @@ -17,7 +19,7 @@ const IS_PORTABLE = isPortable() const UI_HEADER_HEIGHT = 38 const UI_TORRENT_HEIGHT = 100 -module.exports = { +const exports = module.exports = { ANNOUNCEMENT_URL: 'https://webtorrent.io/desktop/announcement', AUTO_UPDATE_URL: 'https://webtorrent.io/desktop/update', CRASH_REPORT_URL: 'https://webtorrent.io/desktop/crash-report', @@ -102,6 +104,73 @@ module.exports = { UI_TORRENT_HEIGHT: UI_TORRENT_HEIGHT } +const configFile = appConfig.filePath +const config = require(configFile) +const watchers = [] + +function watch() { + gaze(configFile, function (err) { + if (err) { + throw err + } + this.on('changed', () => { + try { + if (exec(readFileSync(configFile, 'utf8'))) { + notify('WebTorrent configuration reloaded!') + watchers.forEach(fn => fn()) + } + } catch (err) { + dialog.showMessageBox({ + message: `An error occurred loading your configuration (${configFile}): ${err.message}`, + buttons: ['Ok'] + }) + } + }) + this.on('error', () => { + // Ignore file watching errors + }) + }) +} + +let _str // last script +function exec(str) { + if (str === _str) { + return false + } + _str = str + const script = new vm.Script(str) + const module = {} + script.runInNewContext({module}) + if (!module.exports) { + throw new Error('Error reading configuration: `module.exports` not set') + } + const _cfg = module.exports + if (!_cfg.config) { + throw new Error('Error reading configuration: `config` key is missing') + } + _cfg.plugins = _cfg.plugins || [] + _cfg.localPlugins = _cfg.localPlugins || [] + cfg = _cfg + return true +} + +exports.subscribe = function (fn) { + watchers.push(fn) + return () => { + watchers.splice(watchers.indexOf(fn), 1) + } +} + +exports.getPlugins = function () { + return config.plugins +} + +exports.getConfigPath = getConfigPath + +exports.getConfig = function () { + return config +} + function getConfigPath () { if (IS_PORTABLE) { return PORTABLE_PATH diff --git a/src/main/index.js b/src/main/index.js index 38286977..9d4ed325 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -12,6 +12,8 @@ const log = require('./log') const menu = require('./menu') const State = require('../renderer/lib/state') const windows = require('./windows') +const Plugins = require('../plugins') +const plugins = new Plugins() let shouldQuit = false let argv = sliceArgv(process.argv) @@ -72,10 +74,19 @@ function init () { if (err) throw err isReady = true + const state = results.state - windows.main.init(results.state, {hidden: hidden}) - windows.webtorrent.init() - menu.init() + // init new plugins then notify user + plugins.subscribe(() => { + console.log('-- new plugins finished installing') + + // update menu and windows + // passing thru new plugin decorators + initApp(state) + }) + + plugins.init(state) + initApp(state) // To keep app startup fast, some code is delayed. setTimeout(delayedInit, config.DELAYED_INIT) @@ -88,6 +99,26 @@ function init () { }) } + function initApp (state) { + // decorate app + plugins.onApp(app) + + // init decorate menu + menu.init((tpl) => plugins.decorateMenu(tpl)) + + // init and decorate window + windows.main.init( + state, + {hidden: hidden}, + (options) => plugins.decorateWindow(options) + ) + windows.webtorrent.init((options) => plugins.decorateWindow(options)) + plugins.onWindow([ + windows.main.win, + windows.webtorrent.win + ]) + } + app.on('open-file', onOpen) app.on('open-url', onOpen) diff --git a/src/main/menu.js b/src/main/menu.js index 8268cf10..b8a9cb9c 100644 --- a/src/main/menu.js +++ b/src/main/menu.js @@ -17,8 +17,11 @@ const windows = require('./windows') let menu = null -function init () { - menu = electron.Menu.buildFromTemplate(getMenuTemplate()) +function init (decorate) { + let template = getMenuTemplate() + if (decorate) template = decorate(template) + + menu = electron.Menu.buildFromTemplate(template) electron.Menu.setApplicationMenu(menu) } diff --git a/src/main/windows/main.js b/src/main/windows/main.js index 9da3bb58..713effaf 100644 --- a/src/main/windows/main.js +++ b/src/main/windows/main.js @@ -23,14 +23,14 @@ const config = require('../../config') const log = require('../log') const menu = require('../menu') -function init (state, options) { +function init (state, options, decorate) { if (main.win) { return main.win.show() } const initialBounds = Object.assign(config.WINDOW_INITIAL_BOUNDS, state.saved.bounds) - const win = main.win = new electron.BrowserWindow({ + let windowOptions = { backgroundColor: '#282828', darkTheme: true, // Forces dark theme (GTK+3) icon: getIconPath(), // Window icon (Windows, Linux) @@ -44,7 +44,10 @@ function init (state, options) { height: initialBounds.height, x: initialBounds.x, y: initialBounds.y - }) + } + if (decorate) windowOptions = decorate(windowOptions) + + const win = main.win = new electron.BrowserWindow(windowOptions) win.loadURL(config.WINDOW_MAIN) diff --git a/src/main/windows/webtorrent.js b/src/main/windows/webtorrent.js index a524c686..fb1d1d83 100644 --- a/src/main/windows/webtorrent.js +++ b/src/main/windows/webtorrent.js @@ -10,8 +10,8 @@ const electron = require('electron') const config = require('../../config') -function init () { - const win = webtorrent.win = new electron.BrowserWindow({ +function init (decorate) { + let options = { backgroundColor: '#1E1E1E', center: true, fullscreen: false, @@ -25,7 +25,9 @@ function init () { title: 'webtorrent-hidden-window', useContentSize: true, width: 150 - }) + } + if (decorate) options = decorate(options) + const win = webtorrent.win = new electron.BrowserWindow(options) win.loadURL(config.WINDOW_WEBTORRENT) diff --git a/src/plugins.js b/src/plugins.js new file mode 100644 index 00000000..2f3aab85 --- /dev/null +++ b/src/plugins.js @@ -0,0 +1,422 @@ +const {exec} = require('child_process') +const {resolve, basename} = require('path') +const {writeFileSync} = require('fs') +const State = require('./renderer/lib/state') + +const {app, dialog} = require('electron') +const {sync: mkdirpSync} = require('mkdirp') +const ms = require('ms') +const shellEnv = require('shell-env') + +const config = require('./config') + +module.exports = class Plugins { + constructor () { + console.log('-- constructing plugins') + + // modules path + this.path = resolve(config.getConfigPath(), 'webtorrent-plugins') + console.log('- plugins path: ', this.path) + this.availableExtensions = new Set([ + 'onApp', 'onWindow', 'onRendererWindow', 'onUnload', 'middleware', + 'reduceUI', 'reduceSessions', 'reduceTermGroups', + 'decorateMenu', 'decorateTerm', 'decorateWindow', + 'decorateTab', 'decorateNotification', 'decorateNotifications', + 'decorateTabs', 'decorateConfig', 'decorateEnv', + 'decorateTermGroup', 'getTermProps', + 'getTabProps', 'getTabsProps', 'getTermGroupProps', + 'mapTermsState', 'mapHeaderState', 'mapNotificationsState', + 'mapTermsDispatch', 'mapHeaderDispatch', 'mapNotificationsDispatch' + ]) + + this.forceUpdate = false + this.updating = false + this.watchers = [] + } + + init (state) { + console.log('-- initializing plugins') + this.state = state + + // initialize unsaved state + this.state.unsaved = Object.assign(this.state.unsaved || {}, { + installedPlugins: Object.assign({}, this.state.saved.installedPlugins), + installedPluginVersions: Object.assign({}, this.state.saved.installedPluginVersions) + }) + + // init plugin directories if not present + mkdirpSync(this.path) + + // caches + this.plugins = config.getPlugins() + this.paths = this.getPaths(this.plugins) + this.id = this.getId(this.plugins) + this.modules = this.requirePlugins() + + // we listen on configuration updates to trigger + // plugin installation + config.subscribe(() => { + const plugins_ = config.getPlugins() + if (plugins !== plugins_) { + const id_ = this.getId(plugins_) + if (this.id !== id_) { + this.id = id_ + this.plugins = plugins_ + this.updatePlugins() + } + } + }) + + // we schedule the initial plugins update + // a bit after the user launches the terminal + // to prevent slowness + // TODO: handle force updates + if (this.state.saved.installedPlugins !== this.id) { + // install immediately if the user changed plugins + console.log('plugins have changed / not init, scheduling plugins installation') + setTimeout(() => { + this.updatePlugins() + }, 5000) + } + + // otherwise update plugins every 5 hours + setInterval(this.updatePlugins, ms('5h')) + + console.log(` + -- id: ${this.id} + -- installedPlugins: ${this.state.saved.installedPlugins})} + `) + } + + getId (plugins_) { + return JSON.stringify(plugins_) + } + + updatePlugins (forceUpdate = false) { + console.log('-- update plugins') + this.forceUpdate = forceUpdate + if (this.updating) { + // TODO + // return notify('Plugin update in progress') + } + this.updating = true + this.id_ = this.id + const hasPackages = this.syncPackageJSON() + + // there are plugins loaded from repositories + // npm install must run for these ones + if (hasPackages) { + this.installPackages((err) => this.loadPlugins(err)) + return + } + + // only local plugins to be loaded + this.loadPlugins(null, true) + } + + loadPlugins (err, localOnly = false) { + console.log('- loadPlugins') + 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 `~/.webtorrent_plugins/npm-debug.log` for more information.' + ) + return + } + + // cache paths + // this.paths = this.getPaths(this.plugins) + + // cache modules + this.modules = this.requirePlugins() + + // clear require cache + this.clearCache() + + // we're done with local plugins + if (localOnly) return + + // OK, no errors + // flag successful plugin update + this.state.unsaved.installedPlugins = this.id_ + console.log('-- id_: ', this.id_) + + // check if package based plugins were updated + const loaded = this.modules.length + const total = this.paths.plugins.length + const pluginVersions = JSON.stringify(this.getPluginVersions()) + console.log('-- pluginVersions: ', pluginVersions) + const changed = this.state.saved.installedPluginVersions !== pluginVersions && loaded === total + this.state.unsaved.installedPluginVersions = pluginVersions + + // notify watchers + if (this.forceUpdate || changed) { + console.log(`- notify watchers: this.forceUpdate: ${this.forceUpdate} / changed: ${changed}`) + if (changed) { + // this.alert( + // 'Plugins Updated: Restart the app or hot-reload with "View" > "Reload" to enjoy the updates!' + // ) + } else { + this.alert( + 'Plugins Updated: No changes!' + ) + } + this.watchers.forEach(fn => fn(err, {forceUpdate: this.forceUpdate})) + } + + // save state + State.save(this.state) + } + + 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 () { + // trigger unload hooks + this.modules.forEach(mod => { + if (mod.onUnload) { + mod.onUnload(app) + } + }) + + // 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) + } + + syncPackageJSON () { + console.log('- syncPackageJSON') + const dependencies = this.toDependencies(this.plugins) + if (this.isEmptyObject(dependencies)) return false + + console.log('- set plugins package file') + const pkg = { + name: 'webtorrent-plugins', + description: 'Auto-generated from WebTorrent config.', + private: true, + version: '0.0.1', + repository: 'feross/webtorrent-desktop', + license: 'MIT', + homepage: 'https://webtorrent.io', + dependencies + } + + const file = resolve(this.path, 'package.json') + try { + writeFileSync(file, JSON.stringify(pkg, null, 2)) + return true + } catch (err) { + this.alert(`An error occurred writing to ${file}`) + } + } + + alert (message) { + console.log(`[ PLUGINS MSG ]--> ${message}`) + // dialog.showMessageBox({ + // message, + // buttons: ['Ok'] + // }) + } + + isLocalPath (string) { + // matches unix and windows local paths + return string.match(/^(\/|[a-z]:\/)/i) + } + + toDependencies (plugins) { + console.log('- toDependencies: plugins: ', plugins) + const obj = {} + const pluginNames = Object.keys(plugins) + + pluginNames.forEach(name => { + let url = plugins[name] + if (this.isLocalPath(url)) return + + console.log('- set package as plugin dependency') + obj[name] = url + }) + console.log('- dependencies: ', obj) + return obj + } + + installPackages (fn) { + console.log('- installPackages') + const {shell = '', npmRegistry} = config + + shellEnv(shell).then(env => { + console.log('- SHELL') + if (npmRegistry) { + env.NPM_CONFIG_REGISTRY = npmRegistry + } + /* eslint-disable camelcase */ + env.npm_config_runtime = 'electron' + env.npm_config_target = process.versions.electron + env.npm_config_disturl = 'https://atom.io/download/atom-shell' + /* eslint-enable camelcase */ + // Shell-specific installation commands + const installCommands = { + fish: 'npm prune; and npm install --production', + posix: 'npm prune && npm install --production' + } + // determine the shell we're running in + const whichShell = shell.match(/fish/) ? 'fish' : 'posix' + + // Use the install command that is appropriate for our shell + console.log('- installPackages: exec: ', installCommands[whichShell]) + console.log('- install path: ', this.path) + exec(installCommands[whichShell], { + cwd: this.path//, + // env, + // shell + }, err => { + if (err) { + return fn(err) + } + fn(null) + }) + }).catch(fn) + } + + subscribe (fn) { + this.watchers.push(fn) + return () => { + this.watchers.splice(this.watchers.indexOf(fn), 1) + } + } + + 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]) + }) + } + } + + requirePlugins () { + console.log('- requirePlugins') + const {plugins} = this.paths + console.log('- requirePlugins: paths: ', plugins) + + const load = (path) => { + let mod + 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 + } + + // populate the name for internal errors here + mod._name = basename(path) + + return mod + } catch (err) { + console.log('- plugin not installed: ', path) + // console.error(err) + // this.alert(`Plugin error: Plugin "${basename(path)}" failed to load (${err.message})`) + } + } + + return plugins.map(load) + .filter(v => Boolean(v)) + } + + onApp (app) { + console.log('-- plugins onApp') + this.modules.forEach(plugin => { + if (plugin.onApp) { + plugin.onApp(app) + } + }) + } + + onWindow (win) { + this.modules.forEach(plugin => { + if (plugin.onWindow) { + plugin.onWindow(win) + } + }) + } + + // 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 + } + + decorateMenu (tpl) { + console.log('-- plugins: decorate menu') + return this.decorateObject(tpl, 'decorateMenu') + } + + decorateWindow (options) { + console.log('-- plugins: decorate window') + return this.decorateObject(options, 'decorateWindow') + } + + getDecoratedEnv (baseEnv) { + return this.decorateObject(baseEnv, 'decorateEnv') + } + + getDecoratedConfig () { + const baseConfig = config.getConfig() + return this.decorateObject(baseConfig, 'decorateConfig') + } + + getDecoratedBrowserOptions (defaults) { + return this.decorateObject(defaults, 'decorateBrowserOptions') + } +}