diff --git a/app/index.js b/app/index.js index 2aa54bb5..bee1cff7 100644 --- a/app/index.js +++ b/app/index.js @@ -2,8 +2,15 @@ import { render } from 'react-dom'; import HyperTerm from './hyperterm'; import React from 'react'; import Config from './config'; +import Plugins from './plugins'; require('./css/hyperterm.css'); require('./css/tabs.css'); -render(, document.getElementById('mount')); +const app = + + + +; + +render(app, document.getElementById('mount')); diff --git a/app/plugins.js b/app/plugins.js new file mode 100644 index 00000000..91955736 --- /dev/null +++ b/app/plugins.js @@ -0,0 +1,18 @@ +import React from 'react'; + +export default class Plugins extends React.Component { + + componentDidMount () { + + } + + render () { + const child = React.Children.only(this.props.children); + return React.cloneElement(child, this.props); + } + + componentWillUnmount () { + + } + +} diff --git a/index.js b/index.js index 092f5f0c..ca380bf4 100644 --- a/index.js +++ b/index.js @@ -143,16 +143,30 @@ app.on('ready', () => { } }); - // the window can be closed by the browser process itself - win.on('close', () => { - rpc.destroy(); - deleteSessions(); - winCount--; - cfgUnsubscribe(); - }); - + // expose internals to extension authors win.rpc = rpc; win.sessions = sessions; + + const load = () => { + plugins.onWindow(win); + }; + + // load plugins + load(); + + const pluginsUnsubscribe = plugins.subscribe(() => { + load(); + win.webContents.send('plugins change'); + }); + + // the window can be closed by the browser process itself + win.on('close', () => { + winCount--; + rpc.destroy(); + deleteSessions(); + cfgUnsubscribe(); + pluginsUnsubscribe(); + }); } // when opening create a new window @@ -167,9 +181,21 @@ app.on('ready', () => { } }); - // set menu - const tpl = createMenu({ createWindow }); - Menu.setApplicationMenu(Menu.buildFromTemplate(tpl)); + const setupMenu = () => { + const tpl = plugins.decorateMenu(createMenu({ + createWindow, + updatePlugins: plugins.updatePlugins + })); + Menu.setApplicationMenu(Menu.buildFromTemplate(tpl)); + }; + + const load = () => { + plugins.onApp(app); + setupMenu(); + }; + + load(); + plugins.subscribe(load); }); function initSession (opts, fn) { diff --git a/plugins.js b/plugins.js new file mode 100644 index 00000000..f9a1b367 --- /dev/null +++ b/plugins.js @@ -0,0 +1,259 @@ +const { dialog } = require('electron'); +const { homedir } = require('os'); +const { resolve, basename } = require('path'); +const { writeFileSync } = require('fs'); +const config = require('./config'); +const { sync: mkdirpSync } = require('mkdirp'); +const { exec } = require('child_process'); +const Config = require('electron-config'); +const ms = require('ms'); +const notify = require('./notify'); + +// local storage +const cache = new Config(); + +// modules path +const path = resolve(homedir(), '.hyperterm_modules'); +const localPath = resolve(homedir(), '.hyperterm_local_modules'); + +// init plugin directories if not present +mkdirpSync(path); +mkdirpSync(localPath); + +// caches +let plugins = config.getPlugins(); +let paths = getPaths(plugins); +let id = getId(plugins); +let modules = requirePlugins(); + +function getId (plugins_) { + return JSON.stringify(plugins_); +} + +const watchers = []; + +// we listen on configuration updates to trigger +// plugin installation +config.subscribe(() => { + const plugins_ = config.getPlugins(); + if (plugins !== plugins_) { + const id_ = getId(plugins_); + if (id !== id_) { + id = id_; + plugins = plugins_; + updatePlugins(); + } + } +}); + +let updating = false; + +function updatePlugins () { + if (updating) return notify('Plugin update in progress'); + updating = true; + syncPackageJSON(); + const id_ = id; + install((err) => { + updating = false; + + if (err) { + notify( + 'Error updating plugins.', + 'Check `~/.hyperterm_modules/npm-debug.log` for more information.' + ); + } else { + // flag successful plugin update + cache.set('plugins', id_); + + // cache paths + paths = getPaths(plugins); + + // clear require cache + paths.plugins.forEach(clearCache); + paths.localPlugins.forEach(clearCache); + + // cache modules + modules = requirePlugins(); + + // notify watchers + watchers.forEach((fn) => fn(err)); + + // we consider it a success if we loaded *all* modules + if ((paths.plugins.length + paths.localPlugins.length) === modules.length) { + notify('HyperTerm plugins updated!'); + } + } + }); +} + +function clearCache (mod) { + for (const entry in require.cache) { + if (0 === entry.indexOf(mod + '/')) { + delete require.cache[entry]; + } + } +} + +exports.updatePlugins = updatePlugins; + +// we schedule the initial plugins update +// a bit after the user launches the terminal +// to prevent slowness +if (cache.get('plugins') !== id) { + // install immediately if the user changed plugins + console.log('plugins have changed / not init, scheduling plugins installation'); + setTimeout(() => { + updatePlugins(); + }, 5000); +} + +// otherwise update plugins every 5 hours +setInterval(updatePlugins, ms('5h')); + +function syncPackageJSON () { + const dependencies = toDependencies(plugins); + const pkg = { + name: 'hyperterm-plugins', + private: true, + version: '0.0.1', + homepage: 'https://hyperterm.org', + dependencies + }; + + const file = resolve(path, 'package.json'); + try { + writeFileSync(file, JSON.stringify(pkg, null, 2)); + } catch (err) { + alert(`An error occurred writing to ${file}`); + } +} + +function alert (message) { + dialog.showMessageBox({ + message, + buttons: ['Ok'] + }); +} + +function toDependencies (plugins) { + const obj = {}; + plugins.plugins.forEach((plugin) => { + const pieces = plugin.split('#'); + obj[pieces[0]] = null == pieces[1] ? 'latest' : pieces[1]; + }); + return obj; +} + +function install (fn) { + exec('npm prune && npm install --production', { + cwd: path + }, (err, stdout, stderr) => { + if (err) { + if (/(command not found|not recognized as an)/.test(err.message)) { + if (plugins.plugins.length) { + alert('We found `plugins` in `.hyperterm.js`, but `npm` is ' + + 'not installed or not in $PATH!\nPlease head to ' + + 'https://nodejs.org and install the Node.js runtime.'); + } else { + console.log('npm not found, but no plugins defined'); + } + } + return fn(err); + } + fn(null); + }); +} + +exports.subscribe = function (fn) { + watchers.push(fn); + return () => { + watchers.splice(watchers.indexOf(fn), 1); + }; +}; + +function getPaths () { + return { + plugins: plugins.plugins.map((name) => { + return resolve(path, 'node_modules', name); + }), + localPlugins: plugins.localPlugins.map((name) => { + return resolve(localPath, name); + }) + }; +} + +exports.getPaths = getPaths; + +function requirePlugins () { + const { plugins, localPlugins } = paths; + + const load = (path) => { + let mod; + try { + mod = require(path); + + if (!mod || (!mod.onApp && !mod.onWindow && !mod.onUnload && + !mod.decorateConfig && !mod.decorateMenu && + !mod.decorateTerm && !mod.decorateHyperTerm && + !mod.decorateTabs && !mod.decorateConfig)) { + notify('Plugin error!', `Plugin "${basename(path)}" does not expose any ` + + 'HyperTerm extension API methods'); + return; + } + return mod; + } catch (err) { + notify('Plugin error!', `Plugin "${basename(path)}" failed to load (${err.message})`); + } + }; + + return plugins.map(load) + .concat(localPlugins.map(load)) + .filter(v => !!v); +} + +exports.onApp = function (app) { + modules.forEach((plugin) => { + if (plugin.onApp) { + plugin.onApp(app); + } + }); +}; + +exports.onWindow = function (win, app) { + modules.forEach((plugin) => { + if (plugin.onWindow) { + plugin.onWindow(app); + } + }); +}; + +exports.decorateTerm = function (Term) { + return Term; +}; + +exports.decorateTabs = function (Tabs) { + return Tabs; +}; + +exports.decorateHyperTerm = function (HyperTerm) { + return HyperTerm; +}; + +exports.decorateMenu = function (tpl) { + return tpl; +}; + +exports.decorateConfig = function (config) { + let decorated = config; + modules.forEach((plugin) => { + if (plugin.decorateConfig) { + const res = plugin.decorateConfig(decorated); + if (res) { + decorated = res; + } else { + console.error('incompatible response type for `decorateConfig`'); + } + } + }); + return decorated; +};