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;
+};