hyper/app/plugins.js
2016-07-26 11:01:09 -07:00

319 lines
7.9 KiB
JavaScript

const { app, 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');
const shellEnv = require('shell-env');
// local storage
const cache = new Config();
// modules path
const path = resolve(homedir(), '.hyperterm_plugins');
const localPath = resolve(homedir(), '.hyperterm_plugins', 'local');
const availableExtensions = new Set([
'onApp', 'onWindow', 'onUnload', 'middleware',
'reduceUI', 'reduceSessions', 'decorateMenu',
'decorateTerm', 'decorateHyperTerm', 'decorateTab',
'decorateNotification', 'decorateNotifications',
'decorateTabs', 'decorateConfig', 'decorateEnv'
]);
// 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 ({ force = false } = {}) {
if (updating) return notify('Plugin update in progress');
updating = true;
syncPackageJSON();
const id_ = id;
install((err) => {
updating = false;
if (err) {
console.error(err.stack);
if (/not a recognized/.test(err.message) || /command not found/.test(err.message)) {
notify(
'Error updating plugins.',
'We could not find the `npm` command. Make sure it\'s in $PATH'
);
} else {
notify(
'Error updating plugins.',
'Check `~/.hyperterm_plugins/npm-debug.log` for more information.'
);
}
} else {
// flag successful plugin update
cache.set('plugins', id_);
// cache paths
paths = getPaths(plugins);
// clear require cache
clearCache();
// cache modules
modules = requirePlugins();
const loaded = modules.length;
const total = paths.plugins.length + paths.localPlugins.length;
const pluginVersions = JSON.stringify(getPluginVersions());
const changed = cache.get('plugin-versions') !== pluginVersions && loaded === total;
cache.set('plugin-versions', pluginVersions);
// notify watchers
if (force || changed) {
if (changed) {
notify(
'Plugins Updated',
'Restart the app or hot-reload with "View" > "Reload" to enjoy the updates!'
);
} else {
notify(
'Plugins Updated',
'No changes!'
);
}
watchers.forEach((fn) => fn(err, { force }));
}
}
});
}
function getPluginVersions () {
const paths_ = paths.plugins.concat(paths.localPlugins);
return paths_.map((path) => {
let version = null;
try {
version = require(resolve(path, 'package.json')).version;
} catch (err) { }
return [
basename(path),
version
];
});
}
function clearCache (mod) {
// trigger unload hooks
modules.forEach((mod) => {
if (mod.onUnload) mod.onUnload(app);
});
// clear require cache
for (const entry in require.cache) {
if (entry.indexOf(path) === 0 || entry.indexOf(localPath) === 0) {
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 || process.env.HYPERTERM_FORCE_UPDATE) {
// 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',
description: 'Auto-generated from `~/.hyperterm.js`!',
private: true,
version: '0.0.1',
repository: 'zeit/hyperterm',
license: 'MIT',
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) {
shellEnv().then((env) => {
let registry = exports.getDecoratedConfig().npmRegistry;
if (registry) env.NPM_CONFIG_REGISTRY = registry;
env.npm_config_runtime = 'electron';
env.npm_config_target = '1.2.8';
env.npm_config_disturl = 'https://atom.io/download/atom-shell';
exec('npm prune && npm install --production', {
cwd: path,
env: env
}, (err, stdout, stderr) => {
if (err) return fn(err);
fn(null);
});
}).catch(fn);
}
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.split('#')[0]);
}),
localPlugins: plugins.localPlugins.map((name) => {
return resolve(localPath, name);
})
};
}
// expose to renderer
exports.getPaths = getPaths;
// get paths from renderer
exports.getBasePaths = function () {
return { path, localPath };
};
function requirePlugins () {
const { plugins, localPlugins } = paths;
const load = (path) => {
let mod;
try {
mod = require(path);
const exposed = mod && Object.keys(mod).some(key => availableExtensions.has(key));
if (!exposed) {
notify('Plugin error!', `Plugin "${basename(path)}" does not expose any ` +
'HyperTerm extension API methods');
return;
}
// populate the name for internal errors here
mod._name = basename(path);
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) {
modules.forEach((plugin) => {
if (plugin.onWindow) {
plugin.onWindow(win);
}
});
};
// decorates the base object by calling plugin[key]
// for all the available plugins
function decorateObject (base, key) {
let decorated = base;
modules.forEach((plugin) => {
if (plugin[key]) {
const res = plugin[key](decorated);
if (res && 'object' === typeof res) {
decorated = res;
} else {
notify('Plugin error!', `"${plugin._name}": invalid return type for \`${key}\``);
}
}
});
return decorated;
}
exports.decorateMenu = function (tpl) {
return decorateObject(tpl, 'decorateMenu');
};
exports.getDecoratedEnv = function (baseEnv) {
return decorateObject(baseEnv, 'decorateEnv');
};
exports.getDecoratedConfig = function () {
const baseConfig = config.getConfig();
return decorateObject(baseConfig, 'decorateConfig');
};
exports.getDecoratedBrowserOptions = function (defaults) {
return decorateObject(defaults, 'decorateBrowserOptions');
};