first pass at plugins system

This commit is contained in:
Guillermo Rauch 2016-07-07 17:16:44 -07:00
parent eb147d9b9a
commit 9f635021c9
4 changed files with 322 additions and 12 deletions

View file

@ -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(<Config><HyperTerm /></Config>, document.getElementById('mount'));
const app = <Config>
<Plugins>
<HyperTerm />
</Plugins>
</Config>;
render(app, document.getElementById('mount'));

18
app/plugins.js Normal file
View file

@ -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 () {
}
}

View file

@ -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) {

259
plugins.js Normal file
View file

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