initial commit

This commit is contained in:
Guillermo Rauch 2016-06-30 23:01:04 -07:00
commit 1a1178bd38
23 changed files with 9463 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules
.next

5
TODO.md Normal file
View file

@ -0,0 +1,5 @@
# TODO
- [ ] Listen on `resize` event *coming* from the pty.
- [ ] Add support for zsh.
- [ ] Figure out process title extraction on other platforms.

65
app/css/hyperterm.css Normal file
View file

@ -0,0 +1,65 @@
.main {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 1px solid #333;
}
.mac.main {
border-radius: 5px;
}
header {
position: fixed;
top: 1px;
left: 1px;
right: 1px;
background: #000;
z-index: 100;
}
.mac header {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
.terms {
position: absolute;
margin-top: 28px;
top: 0;
right: 0;
left: 0;
bottom: 0;
color: #fff;
}
.terms div {
font-family: Menlo;
font-size: 11px;
}
.term {
display: none;
}
.term.active {
display: block;
}
.resize-indicator {
background: rgba(255, 255, 255, .2);
padding: 6px 14px;
color: #fff;
font: 11px Menlo;
position: fixed;
bottom: 20px;
right: 20px;
opacity: 0;
transition: opacity 150ms ease-in;
}
.resize-indicator.showing {
opacity: 1;
}

73
app/css/tabs.css Normal file
View file

@ -0,0 +1,73 @@
nav {
font-size: 12px;
font-family: -apple-system, BlinkMacSystemFont,
"Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans",
"Droid Sans", "Helvetica Neue", sans-serif;
height: 28px;
line-height: 28px;
vertical-align: middle;
color: #9B9B9B;
cursor: default;
user-select: none;
}
.single {
text-align: center;
color: #fff;
}
.tabs {
border-bottom: 1px solid #333;
max-height: 28px;
display: flex;
flex-flow: row;
}
.tabs li:first-child {
margin-left: 70px;
}
.tabs li {
list-style-type: none;
flex-grow: 1;
text-align: center;
}
.tabs li.is_active {
color: #fff;
position: relative;
}
.tabs li span {
display: block;
border-left: 1px solid transparent;
border-right: 1px solid transparent;
}
.tabs li.is_active span {
border-left-color: #333;
border-right-color: #333;
}
.tabs li.is_active:last-child span {
border-right-color: transparent;
}
.tabs li.is_active::before {
position: absolute;
content: ' ';
border-bottom: 1px solid #000;
display: block;
left: 1px;
right: 1px;
bottom: -1px;
}
.tabs li:not(.is_active):hover {
color: #ccc;
}
.tabs li.has_activity, .tabs li.has_activity:hover {
color: #50E3C2;
}

2156
app/css/xterm.css Normal file

File diff suppressed because it is too large Load diff

1160
app/dist/bundle.js vendored Normal file

File diff suppressed because one or more lines are too long

1
app/dist/bundle.js.map vendored Normal file

File diff suppressed because one or more lines are too long

363
app/hyperterm.js Normal file
View file

@ -0,0 +1,363 @@
import Tabs from './tabs';
import Term from './term';
import RPC from './rpc';
import Mousetrap from 'mousetrap';
import classes from 'classnames';
import getTextMetrics from './text-metrics';
import shallowCompare from 'react-addons-shallow-compare';
import React, { Component } from 'react';
export default class HyperTerm extends Component {
constructor () {
super();
this.state = {
hpadding: 10,
vpadding: 5,
sessions: [],
titles: {},
urls: {},
active: null,
activeMarkers: [],
mac: /Mac/.test(navigator.userAgent),
resizeIndicatorShowing: false
};
// we set this to true when the first tab
// has been initialized and ack'd by the
// node server for the *first time*
this.init = false;
// we keep track of activity in tabs to avoid
// placing an activity marker right after
// opening
this.tabWasActive = {};
this.onResize = this.onResize.bind(this);
this.onChange = this.onChange.bind(this);
this.focusActive = this.focusActive.bind(this);
this.onHeaderMouseDown = this.onHeaderMouseDown.bind(this);
this.moveLeft = this.moveLeft.bind(this);
this.moveRight = this.moveRight.bind(this);
}
render () {
return <div onClick={ this.focusActive }>
<div className={ classes('main', { mac: this.state.mac }) }>
<header onMouseDown={this.onHeaderMouseDown}>
<Tabs
active={this.state.active}
activeMarkers={this.state.activeMarkers}
data={this.state.sessions.map((uid) => {
const title = this.state.titles[uid];
return null != title ? title : 'Shell';
})}
onChange={this.onChange}
/>
</header>
<div
className='terms'
style={{ padding: `${this.state.vpadding}px ${this.state.hpadding}px` }}
ref='termWrapper'>{
this.state.sessions.map((uid, i) => {
const active = i === this.state.active;
return <div key={`d${uid}`} className={classes('term', { active })} ref='term'>
<Term
key={uid}
ref={`term-${uid}`}
url={this.state.urls[uid]}
cols={this.state.cols}
rows={this.state.rows}
onTitle={this.setTitle.bind(this, uid)}
onData={this.write.bind(this, uid)}
onURL={this.onURL.bind(this, uid)}
/>
</div>;
})
}</div>
</div>
<div className={classes('resize-indicator', { showing: this.state.resizeIndicatorShowing })}>
{ this.state.cols }x{ this.state.rows }
</div>
</div>;
}
requestTab () {
this.rpc.emit('new', this.getDimensions());
}
closeTab () {
if (this.state.sessions.length) {
const uid = this.state.sessions[this.state.active];
if (this.state.urls[uid]) {
// if we have a url loaded, closing a tab
// instead closes the url
const urls = Object.assign({}, this.state.urls);
delete urls[uid];
this.setState({ urls });
} else {
this.rpc.emit('exit', { uid });
}
}
}
write (uid, data) {
this.rpc.emit('data', { uid, data });
}
onURL (uid, url) {
const urls = Object.assign({}, this.state.urls, { [uid]: url });
this.setState({ urls });
}
onRemoteTitle ({ uid, title }) {
this.setTitle(uid, title);
}
setTitle (uid, title) {
const { titles: _titles } = this.state;
const titles = Object.assign({}, _titles, { [uid]: title });
this.setState({ titles });
}
onActive (uid) {
const i = this.state.sessions.indexOf(uid);
// we ignore activity markers all the way
// up to the tab's been active
const wasActive = this.tabWasActive[uid];
if (!wasActive) {
console.log('ignoring active, tab has not been focused', uid);
this.tabWasActive[uid] = true;
return;
}
if (this.state.active !== i && !~this.state.activeMarkers.indexOf(i)) {
const activeMarkers = this.state.activeMarkers.slice();
activeMarkers.push(i);
this.setState({ activeMarkers });
}
}
shouldComponentUpdate (nextProps, nextState) {
if (this.state.active !== nextState.active) {
const curUid = this.state.sessions[this.state.active];
if (curUid) {
this.rpc.emit('blur', { uid: curUid });
}
const nextUid = nextState.sessions[nextState.active];
this.rpc.emit('focus', { uid: nextUid });
}
return shallowCompare(this, nextProps, nextState);
}
componentDidMount () {
this.rpc = new RPC();
this.setState(this.getDimensions());
// open a new tab upon mounting
this.rpc.once('ready', () => this.requestTab());
this.rpc.on('new session', ({ uid }) => {
const { sessions: _sessions } = this.state;
const sessions = _sessions.concat(uid);
const state = { sessions };
state.active = sessions.length - 1;
this.setState(state, () => {
if (this.state.sessions.length && !this.init) {
this.rpc.emit('init');
this.init = true;
}
});
});
this.rpc.on('exit', this.onSessionExit.bind(this));
this.rpc.on('data', ({ uid, data }) => {
if (this.ignoreActivity) {
// we ignore activity for up to 300ms after triggering
// a resize to avoid setting up markers incorrectly
if (Date.now() - this.ignoreActivity < 300) {
console.log('ignore activity after resizing');
} else {
this.ignoreActivity = null;
this.onActive(uid);
}
} else {
this.onActive(uid);
}
this.refs[`term-${uid}`].write(data);
});
this.rpc.on('new tab', this.requestTab.bind(this));
this.rpc.on('close tab', this.closeTab.bind(this));
this.rpc.on('title', this.onRemoteTitle.bind(this));
window.addEventListener('resize', this.onResize);
this.rpc.on('move left', this.moveLeft);
this.rpc.on('move right', this.moveRight);
Mousetrap.bind('command+1', this.moveTo.bind(this, 0));
Mousetrap.bind('command+2', this.moveTo.bind(this, 1));
Mousetrap.bind('command+3', this.moveTo.bind(this, 2));
Mousetrap.bind('command+4', this.moveTo.bind(this, 3));
Mousetrap.bind('command+5', this.moveTo.bind(this, 4));
Mousetrap.bind('command+6', this.moveTo.bind(this, 5));
Mousetrap.bind('command+7', this.moveTo.bind(this, 6));
Mousetrap.bind('command+8', this.moveTo.bind(this, 7));
Mousetrap.bind('command+9', this.moveTo.bind(this, 8));
Mousetrap.bind('command+shift+left', this.moveLeft);
Mousetrap.bind('command+shift+right', this.moveRight);
Mousetrap.bind('command+alt+left', this.moveLeft);
Mousetrap.bind('command+alt+right', this.moveRight);
}
moveTo (n) {
if (this.state.sessions[n]) {
this.setActive(n);
}
}
moveLeft () {
const next = this.state.active - 1;
if (this.state.sessions[next]) {
this.setActive(next);
}
}
moveRight () {
const next = this.state.active + 1;
if (this.state.sessions[next]) {
this.setActive(next);
}
}
onSessionExit ({ uid }) {
const {
sessions: _sessions,
titles: _titles,
active: _active,
activeMarkers
} = this.state;
const titles = Object.assign({}, _titles);
delete titles[uid];
delete this.tabWasActive[uid];
const i = _sessions.indexOf(uid);
const sessions = _sessions.slice();
sessions.splice(i, 1);
if (!sessions.length) {
return window.close();
}
const ai = activeMarkers.indexOf(i);
if (~ai) {
activeMarkers.splice(ai, 1);
}
let active;
if (i === _active) {
if (sessions.length) {
active = sessions[i - 1] ? i - 1 : i;
} else {
active = null;
}
} else if (i < _active) {
active = _active - 1;
}
if (~activeMarkers.indexOf(active)) {
activeMarkers.splice(active, 1);
}
this.setState({
sessions,
titles,
active,
activeMarkers
});
}
componentDidUpdate () {
this.focusActive();
}
focusActive () {
// get active uid and term
const uid = this.state.sessions[this.state.active];
const term = this.refs[`term-${uid}`];
if (term) {
term.focus();
}
}
onResize () {
const dim = this.getDimensions();
if (dim.rows !== this.state.rows || dim.cols !== this.state.cols) {
this.ignoreActivity = Date.now();
this.rpc.emit('resize', dim);
const state = Object.assign({}, dim, { resizeIndicatorShowing: true });
this.setState(state);
clearTimeout(this.resizeIndicatorTimeout);
this.resizeIndicatorTimeout = setTimeout(() => {
this.setState({ resizeIndicatorShowing: false });
}, 1500);
}
}
onChange (active) {
// we ignore clicks if they're a byproduct of a drag
// motion to move the window
if (window.screenX !== this.headerMouseDownWindowX ||
window.screenY !== this.headerMouseDownWindowY) {
return;
}
this.setActive(active);
}
setActive (active) {
if (~this.state.activeMarkers.indexOf(active)) {
const { activeMarkers } = this.state;
activeMarkers.splice(activeMarkers.indexOf(active), 1);
this.setState({ active, activeMarkers });
} else {
this.setState({ active });
}
}
onHeaderMouseDown () {
this.headerMouseDownWindowX = window.screenX;
this.headerMouseDownWindowY = window.screenY;
}
getDimensions () {
const tm = getTextMetrics('Menlo', '11px', '15px');
const hp = this.state.hpadding;
const vp = this.state.vpadding;
const el = this.refs.termWrapper;
const { width, height } = el.getBoundingClientRect();
const dim = {
cols: Math.floor((width - hp * 2) / tm.width),
rows: Math.floor((height - vp * 2) / tm.height)
};
return dim;
}
componentWillUnmount () {
window.removeEventListener('resize', this.onResize);
this.rpc.destroy();
clearTimeout(this.resizeIndicatorTimeout);
Mousetrap.reset();
}
}

27
app/index.html Normal file
View file

@ -0,0 +1,27 @@
<!doctype html>
<html>
<head>
<title>HyperTerm</title>
<meta name="viewport" content="initial-scale=1.0" />
<style>
body {
background: #000;
color: #fff;
}
* {
margin: 0;
padding: 0;
text-rendering: geometricPrecision;
box-sizing: border-box;
}
</style>
</head>
<body>
<div id="mount"></div>
<script>start = performance.now();</script>
<script src="dist/bundle.js"></script>
<script>console.log(performance.now() - start);</script>
</body>
</html>

9
app/index.js Normal file
View file

@ -0,0 +1,9 @@
import { render } from 'react-dom';
import HyperTerm from './hyperterm';
import React from 'react';
require('./css/hyperterm.css');
require('./css/tabs.css');
require('./css/xterm.css');
render(<HyperTerm />, document.getElementById('mount'));

45
app/npm-debug.log Normal file
View file

@ -0,0 +1,45 @@
0 info it worked if it ends with ok
1 verbose cli [ '/usr/local/bin/node', '/usr/local/bin/npm', 'run', 'lint' ]
2 info using npm@3.9.3
3 info using node@v6.2.1
4 verbose run-script [ 'prelint', 'lint', 'postlint' ]
5 info lifecycle hyperterm-web@0.0.1~prelint: hyperterm-web@0.0.1
6 silly lifecycle hyperterm-web@0.0.1~prelint: no script for prelint, continuing
7 info lifecycle hyperterm-web@0.0.1~lint: hyperterm-web@0.0.1
8 verbose lifecycle hyperterm-web@0.0.1~lint: unsafe-perm in lifecycle true
9 verbose lifecycle hyperterm-web@0.0.1~lint: PATH: /usr/local/lib/node_modules/npm/bin/node-gyp-bin:/Users/rauchg/Projects/zeit/hyperterm/app/node_modules/.bin:/usr/local/bin:/Users/rauchg/google-cloud-sdk/bin:/Users/rauchg/.rvm/gems/ruby-2.2.1/bin:/Users/rauchg/.rvm/gems/ruby-2.2.1@global/bin:/Users/rauchg/.rvm/rubies/ruby-2.2.1/bin:/Users/rauchg/google-cloud-sdk/bin:./node_modules/.bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:./bin:/Users/rauchg/bin:/Users/rauchg/.rvm/bin:/Users/rauchg/go/bin:/usr/local/opt/go/libexec/bin
10 verbose lifecycle hyperterm-web@0.0.1~lint: CWD: /Users/rauchg/Projects/zeit/hyperterm/app
11 silly lifecycle hyperterm-web@0.0.1~lint: Args: [ '-c', 'eslint *.js' ]
12 silly lifecycle hyperterm-web@0.0.1~lint: Returned: code: 1 signal: null
13 info lifecycle hyperterm-web@0.0.1~lint: Failed to exec lint script
14 verbose stack Error: hyperterm-web@0.0.1 lint: `eslint *.js`
14 verbose stack Exit status 1
14 verbose stack at EventEmitter.<anonymous> (/usr/local/lib/node_modules/npm/lib/utils/lifecycle.js:245:16)
14 verbose stack at emitTwo (events.js:106:13)
14 verbose stack at EventEmitter.emit (events.js:191:7)
14 verbose stack at ChildProcess.<anonymous> (/usr/local/lib/node_modules/npm/lib/utils/spawn.js:24:14)
14 verbose stack at emitTwo (events.js:106:13)
14 verbose stack at ChildProcess.emit (events.js:191:7)
14 verbose stack at maybeClose (internal/child_process.js:852:16)
14 verbose stack at Process.ChildProcess._handle.onexit (internal/child_process.js:215:5)
15 verbose pkgid hyperterm-web@0.0.1
16 verbose cwd /Users/rauchg/Projects/zeit/hyperterm/app
17 error Darwin 15.4.0
18 error argv "/usr/local/bin/node" "/usr/local/bin/npm" "run" "lint"
19 error node v6.2.1
20 error npm v3.9.3
21 error code ELIFECYCLE
22 error hyperterm-web@0.0.1 lint: `eslint *.js`
22 error Exit status 1
23 error Failed at the hyperterm-web@0.0.1 lint script 'eslint *.js'.
23 error Make sure you have the latest version of node.js and npm installed.
23 error If you do, this is most likely a problem with the hyperterm-web package,
23 error not with npm itself.
23 error Tell the author that this fails on your system:
23 error eslint *.js
23 error You can get information on how to open an issue for this project with:
23 error npm bugs hyperterm-web
23 error Or if that isn't available, you can get their info via:
23 error npm owner ls hyperterm-web
23 error There is likely additional logging output above.
24 verbose exit [ 1, true ]

57
app/package.json Normal file
View file

@ -0,0 +1,57 @@
{
"name": "hyperterm-web",
"version": "0.0.1",
"description": "",
"dependencies": {
"react-addons-shallow-compare": "15.1.0",
"mousetrap": "1.6.0",
"classnames": "2.2.5",
"react": "15.1.0",
"react-dom": "15.1.0"
},
"devDependencies": {
"eslint": "2.13.1",
"eslint-config-standard": "5.3.1",
"babel-eslint": "6.1.0",
"eslint-plugin-react": "5.2.2",
"babel-plugin-transform-es2015-modules-commonjs": "6.10.3",
"babel-cli": "6.10.1",
"babel-loader": "6.2.4",
"babel-core": "6.10.4",
"style-loader": "0.13.1",
"css-loader": "0.23.1",
"eslint-plugin-standard": "1.3.2",
"eslint-plugin-promise": "1.3.2"
},
"eslintConfig": {
"extends": "standard",
"plugins": [
"react"
],
"rules": {
"yoda": 0,
"semi": [2, "always"],
"no-unused-vars": 2,
"no-extra-semi": 2,
"semi-spacing": [2, { "before": false, "after": true }],
"react/jsx-uses-react": 1,
"react/jsx-uses-vars": 1
},
"parserOptions": {
"ecmaFeatures": {
"jsx": true
}
}
},
"babel": {
"plugins": [
"transform-react-jsx",
"transform-es2015-modules-commonjs"
]
},
"scripts": {
"dev": "webpack --watch",
"lint": "eslint *.js",
"build": "NODE_ENV=production webpack"
}
}

58
app/rpc.js Normal file
View file

@ -0,0 +1,58 @@
export default class Client {
constructor () {
const electron = window.require('electron');
const EventEmitter = window.require('events');
this.emitter = new EventEmitter();
this.ipc = electron.ipcRenderer;
this.ipcListener = this.ipcListener.bind(this);
if (window.__rpcId) {
setTimeout(() => {
this.id = window.__rpcId;
this.ipc.on(this.id, this.ipcListener);
this.emitter.emit('ready');
}, 0);
} else {
this.ipc.on('init', (ev, uid) => {
// we cache so that if the object
// gets re-instantiated we don't
// wait for a `init` event
window.__rpcId = uid;
this.id = uid;
this.ipc.on(uid, this.ipcListener);
this.emitter.emit('ready');
});
}
}
ipcListener (event, { ch, data }) {
this.emitter.emit(ch, data);
}
on (ev, fn) {
this.emitter.on(ev, fn);
}
once (ev, fn) {
this.emitter.once(ev, fn);
}
emit (ev, data) {
if (!this.id) throw new Error('Not ready');
this.ipc.send(this.id, { ev, data });
}
removeListener (ev, fn) {
this.emitter.removeListener(ev, fn);
}
removeAllListeners () {
this.emitter.removeAllListeners();
}
destroy () {
this.removeAllListeners();
this.ipc.removeAllListeners();
}
}

31
app/tabs.js Normal file
View file

@ -0,0 +1,31 @@
import React from 'react';
import classes from 'classnames';
export default function ({ data = [], active, activeMarkers = {}, onChange }) {
return <nav style={{ WebkitAppRegion: 'drag' }}>{
data.length
? 1 === data.length
? <div className='single'>{ data[0] }</div>
: <ul className='tabs'>
{
data.map((tab, i) => {
const isActive = i === active;
const hasActivity = ~activeMarkers.indexOf(i);
return <li
key={`tab-${i}`}
className={classes({ is_active: isActive, has_activity: hasActivity })}
onClick={ onChange ? onClick.bind(null, i, onChange, active) : null }>
<span>{ tab }</span>
</li>;
})
}
</ul>
: null
}</nav>;
}
function onClick (i, onChange, active) {
if (i !== active) {
onChange(i);
}
}

97
app/term.js Normal file
View file

@ -0,0 +1,97 @@
import Terminal from './xterm';
import React, { Component } from 'react';
const domainRegex = /\b((?=[a-z0-9-]{1,63}\.)(xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,63}\b/;
export default class Term extends Component {
componentDidMount () {
this.term = new Terminal({
cols: this.props.cols,
rows: this.props.rows
});
this.term.on('data', (data) => {
this.props.onData(data);
});
this.term.on('title', (title) => {
this.props.onTitle(title);
});
this.term.open(this.refs.term);
}
shouldComponentUpdate (nextProps) {
if (nextProps.rows !== this.props.rows || nextProps.cols !== this.props.cols) {
this.term.resize(nextProps.cols, nextProps.rows);
}
if (this.props.url !== nextProps.url) {
// when the url prop changes, we make sure
// the terminal starts or stops ignoring
// key input so that it doesn't conflict
// with the <webview>
if (nextProps.url) {
this.term.ignoreKeyEvents = true;
} else {
this.term.ignoreKeyEvents = false;
}
return true;
}
return false;
}
write (data) {
const match = data.match(/bash: ((https?:\/\/)|(\/\/))?(.*): ((command not found)|(No such file or directory))/);
if (match) {
const url = match[4];
// extract the domain portion from the url
const domain = url.split('/')[0];
if (domainRegex.test(domain)) {
this.props.onURL(toURL(url));
return;
}
}
this.term.write(data);
}
focus () {
this.term.element.focus();
}
componentWillUnmount () {
this.term.destroy();
}
render () {
return <div>
<div ref='term' />
{ this.props.url
? <webview
src={this.props.url}
style={{
background: '#000',
position: 'absolute',
top: 0,
left: 0,
display: 'inline-flex',
width: '100%',
height: '100%'
}}></webview>
: null
}
</div>;
}
}
function toURL (domain) {
if (/^https?:\/\//.test(domain)) {
return domain;
}
if ('//' === domain.substr(0, 2)) {
return domain;
}
return 'http://' + domain;
}

20
app/text-metrics.js Normal file
View file

@ -0,0 +1,20 @@
const mem = new Map();
export default function getTextMetrics (family, fontSize, lineHeight) {
const id = family + '#' + fontSize + '#' + lineHeight;
const memd = mem.get(id);
if (memd) return memd;
const el = document.createElement('span');
const style = el.style;
style.display = 'inline-block';
style.fontFamily = family;
style.fontSize = fontSize;
style.lineHeight = lineHeight;
el.innerText = 'X';
document.body.appendChild(el);
const { width, height } = el.getBoundingClientRect();
const ret = { width, height };
document.body.removeChild(el);
mem.set(id, ret);
console.log('text metrics calculated for', family, fontSize, lineHeight, ret);
return ret;
}

32
app/webpack.config.js Normal file
View file

@ -0,0 +1,32 @@
const webpack = require('webpack');
const path = require('path');
const nodeEnv = process.env.NODE_ENV || 'development';
const isProd = nodeEnv === 'production';
module.exports = {
devtool: isProd ? 'hidden-source-map' : 'cheap-eval-source-map',
entry: './index.js',
output: {
path: path.join(__dirname, './dist'),
filename: 'bundle.js'
},
module: {
loaders: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
loaders: [
'babel-loader'
]
},
{
test: /\.css$/,
loader: 'style-loader!css-loader'
}
],
plugins: [
new webpack.optimize.OccurrenceOrderPlugin()
]
}
};

4834
app/xterm.js Normal file

File diff suppressed because it is too large Load diff

12
build.sh Executable file
View file

@ -0,0 +1,12 @@
# Electron's version.
export npm_config_target=1.2.3
# The architecture of Electron, can be ia32 or x64.
export npm_config_arch=x64
# Download headers for Electron.
export npm_config_disturl=https://atom.io/download/atom-shell
# Tell node-pre-gyp that we are building for Electron.
export npm_config_runtime=electron
# Tell node-pre-gyp to build module from source code.
export npm_config_build_from_source=true
# Install all dependencies, and store cache to ~/.electron-gyp.
HOME=~/.electron-gyp npm install

215
index.js Normal file
View file

@ -0,0 +1,215 @@
const { app, BrowserWindow, Menu } = require('electron');
const createRPC = require('./rpc');
const Session = require('./session');
const genUid = require('uid2');
const { resolve } = require('path');
if ('development' === process.env.NODE_ENV) {
console.log('initializing in dev mode (NODE_ENV)');
} else {
console.log('initializing in prod mode (NODE_ENV)');
}
app.on('window-all-closed', () => {
// by subscribing to this event and nooping
// we prevent electron's default behavior
// of quitting the app when the last
// terminal is closed
});
app.on('ready', () => {
function createWindow (fn) {
let win = new BrowserWindow({
width: 540,
height: 380,
titleBarStyle: 'hidden',
title: 'HyperTerm',
backgroundColor: '#000',
transparent: true,
// we only want to show when the prompt
// is ready for user input
show: 'development' === process.env.NODE_ENV
});
win.loadURL('file://' + resolve(__dirname, 'app', 'index.html'));
const rpc = createRPC(win);
const sessions = new Map();
rpc.on('init', () => {
win.show();
});
rpc.on('new', ({ rows = 40, cols = 100 }) => {
initSession({ rows, cols }, (uid, session) => {
sessions.set(uid, session);
rpc.emit('new session', { uid });
session.on('data', (data) => {
rpc.emit('data', { uid, data });
});
session.on('title', (title) => {
rpc.emit('title', { uid, title });
});
session.on('exit', () => {
rpc.emit('exit', { uid });
});
});
});
rpc.on('focus', ({ uid }) => {
sessions.get(uid).focus();
});
rpc.on('blur', ({ uid }) => {
sessions.get(uid).blur();
});
rpc.on('exit', ({ uid }) => {
sessions.get(uid).exit();
});
rpc.on('resize', ({ cols, rows }) => {
sessions.forEach((session) => {
session.resize({ cols, rows });
});
});
rpc.on('data', ({ uid, data }) => {
sessions.get(uid).write(data);
});
const deleteSessions = () => {
sessions.forEach((session, key) => {
session.removeAllListeners();
session.destroy();
sessions.delete(key);
});
};
// we reset the rpc channel only upon
// subsequent refreshes (ie: F5)
let i = 0;
win.webContents.on('did-navigate', () => {
if (i++) {
deleteSessions();
}
});
// the window can be closed by the browser process itself
win.on('close', () => {
rpc.destroy();
deleteSessions();
});
win.rpc = rpc;
}
// when opening create a new window
createWindow();
// set menu
Menu.setApplicationMenu(Menu.buildFromTemplate([
{
label: 'Application',
submenu: [
{
role: 'quit'
}
]
},
{
label: 'Shell',
submenu: [
{
label: 'New Window',
accelerator: 'CmdOrCtrl+N',
click (item, focusedWindow) {
createWindow();
}
},
{
label: 'New Tab',
accelerator: 'CmdOrCtrl+T',
click (item, focusedWindow) {
if (focusedWindow) {
focusedWindow.rpc.emit('new tab');
} else {
createWindow();
}
}
},
{
label: 'Close',
accelerator: 'CmdOrCtrl+W',
click (item, focusedWindow) {
if (focusedWindow) {
focusedWindow.rpc.emit('close tab');
}
}
}
]
},
{
label: 'Edit',
submenu: [
{ label: 'Copy', accelerator: 'CmdOrCtrl+C', selector: 'copy:' },
{ label: 'Paste', accelerator: 'CmdOrCtrl+V', selector: 'paste:' },
{ label: 'Select All', accelerator: 'CmdOrCtrl+A', selector: 'selectAll:' }
]
},
{
label: 'View',
submenu: [
{
label: 'Reload',
accelerator: 'CmdOrCtrl+R',
click (item, focusedWindow) {
if (focusedWindow) focusedWindow.reload();
}
},
{
label: 'Toggle Developer Tools',
accelerator: process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I',
click (item, focusedWindow) {
if (focusedWindow) {
focusedWindow.webContents.toggleDevTools();
}
}
}
]
},
{
label: 'Window',
submenu: [
{
label: 'Select Previous Tab',
accelerator: 'CmdOrCtrl+Left',
click (item, focusedWindow) {
if (focusedWindow) {
focusedWindow.rpc.emit('move left');
}
}
},
{
label: 'Select Next Tab',
accelerator: 'CmdOrCtrl+Right',
click (item, focusedWindow) {
if (focusedWindow) {
focusedWindow.rpc.emit('move right');
}
}
}
]
}
]));
});
function initSession (opts, fn) {
genUid(20, (err, uid) => {
if (err) throw err;
fn(uid, new Session(opts));
});
}

36
package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "hyperterm",
"productName": "HyperTerm",
"version": "0.0.1",
"description": "",
"dependencies": {
"electron-prebuilt": "1.2.5",
"electron-rpc": "^2.0.1",
"ptyw.js": "0.4.0",
"terminal.js": "1.0.7",
"uid-promise": "0.1.0",
"uid2": "0.0.3",
"child_pty": "3.0.1"
},
"devDependencies": {
"eslint": "2.13.1",
"eslint-config-standard": "5.3.1",
"eslint-plugin-standard": "1.3.2",
"eslint-plugin-promise": "1.3.2"
},
"eslintConfig": {
"extends": "standard",
"rules": {
"yoda": 0,
"semi": [2, "always"],
"no-unused-vars": 2,
"no-extra-semi": 2,
"semi-spacing": [2, { "before": false, "after": true }]
}
},
"scripts": {
"dev": "next -p 3006 app",
"launch": "electron index",
"lint": "eslint *.js"
}
}

69
rpc.js Normal file
View file

@ -0,0 +1,69 @@
const { EventEmitter } = require('events');
const { ipcMain } = require('electron');
const genUid = require('uid2');
class Server {
constructor (win) {
this.win = win;
this.ipcListener = this.ipcListener.bind(this);
this.emitter = new EventEmitter();
genUid(10, (err, uid) => {
if (this.destroyed) return;
if (err) return this.emitter.emit('error', err);
this.id = uid;
ipcMain.on(uid, this.ipcListener);
// we intentionally subscribe to `on` instead of `once`
// to support reloading the window and re-initializing
// the channel
this.wc.on('did-finish-load', () => {
this.wc.send('init', uid);
});
});
}
get wc () {
return this.win.webContents;
}
ipcListener (event, { ev, data }) {
this.emitter.emit(ev, data);
}
emit (ch, data) {
this.wc.send(this.id, { ch, data });
}
on (ev, fn) {
this.emitter.on(ev, fn);
}
once (ev, fn) {
this.emitter.once(ev, fn);
}
removeListener (ev, fn) {
this.emitter.removeListener(ev, fn);
}
removeAllListeners () {
this.emitter.removeAllListeners();
}
destroy () {
this.removeAllListeners();
this.wc.removeAllListeners();
if (this.id) {
ipcMain.removeListener(this.id, this.ipcListener);
} else {
// mark for `genUid` in constructor
this.destroyed = true;
}
}
}
module.exports = function createRPC (win) {
return new Server(win);
};

96
session.js Normal file
View file

@ -0,0 +1,96 @@
const { EventEmitter } = require('events');
const { spawn } = require('child_pty');
const { exec } = require('child_process');
const TITLE_POLL_INTERVAL = 1000;
module.exports = class Session extends EventEmitter {
constructor ({ rows, cols }) {
super();
this.pty = spawn('bash', [], {
cols,
rows,
cwd: process.env.HOME,
env: Object.assign({}, process.env, {
TERM: 'xterm-256color'
})
});
this.pty.stdout.on('data', (data) => {
this.emit('data', data.toString('utf8'));
});
this.pty.on('exit', () => {
if (!this.ended) {
this.ended = true;
this.emit('exit');
}
});
this.getTitle();
}
focus () {
this.getTitle(true);
}
blur () {
clearTimeout(this.titlePoll);
}
getTitle (subscribe = false) {
if ('win32' === process.platform) return;
let tty = this.pty.stdout.ttyname;
tty = tty.replace(/^\/dev\/tty/, '');
// try to exclude grep from the results
// by grepping for `[s]001` instead of `s001`
tty = `[${tty[0]}]${tty.substr(1)}`;
// TODO: limit the concurrency of how many processes we run?
// TODO: only tested on mac
exec(`ps ac | grep ${tty} | tail -n 1`, (err, out) => {
if (this.ended) return;
if (err) return;
let title = out.split(' ').pop();
if (title) {
title = title.replace(/^\(/, '');
title = title.replace(/\)?\n$/, '');
if (title !== this.lastTitle) {
this.emit('title', title);
this.lastTitle = title;
}
}
if (subscribe) {
this.titlePoll = setTimeout(() => this.getTitle(true), TITLE_POLL_INTERVAL);
}
});
}
exit () {
this.destroy();
}
write (data) {
this.pty.stdin.write(data);
}
resize ({ cols: columns, rows }) {
try {
this.pty.stdout.resize({ columns, rows });
} catch (err) {
console.log(err.message);
}
}
destroy () {
this.pty.kill('SIGHUP');
this.emit('exit');
this.ended = true;
clearTimeout(this.titlePoll);
}
};