html: replace with preact version

This commit is contained in:
Shuanglei Tao
2019-05-27 00:00:02 +08:00
parent 3f52934418
commit 66718bc1d4
32 changed files with 2037 additions and 10373 deletions

51
html/.gitignore vendored
View File

@@ -1,49 +1,4 @@
# Logs
logs
*.log
npm-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules
jspm_packages
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
dist/
/build
/dist
/*.log

View File

@@ -11,4 +11,4 @@ task('default', () => {
return src('dist/index.html')
.pipe(inlinesource())
.pipe(dest('../src/'));
});
});

View File

@@ -1,45 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>ttyd - Terminal</title>
<link inline rel="icon" type="image/png" href="favicon.png">
</head>
<body>
<div id="terminal-container"></div>
<div class="modal" id="modal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box">
<header id="header"></header>
<div id="status">
<strong>Files remaining: </strong><span id="files-remaining"></span>,
<strong>Bytes remaining: </strong><span id="bytes-remaining"></span>
</div>
<div id="choose" class="file has-name is-fullwidth">
<label class="file-label">
<input id="files" class="file-input" type="file" multiple>
<span class="file-cta">
<strong class="file-label">
Choose file(s)…
</strong>
</span>
<span id="file-names" class="file-name"></span>
</label>
</div>
<div id="progress">
<p id="file-name"></p>
<progress id="progress-bar" class="progress" max="100"></progress>
<p id="progress-info">
<span id="bytes-received">-</span>/<span id="bytes-file">-</span> (<span id="percent-received"></span>)
transferred
<a id="skip" class="button">Skip</a>
</p>
</div>
</div>
</div>
</div>
<script src="auth_token.js"></script>
</body>
</html>

View File

@@ -1,234 +0,0 @@
import '../sass/app.scss';
import { Terminal, ITerminalOptions, ITheme } from 'xterm';
import * as fit from 'xterm/lib/addons/fit/fit';
import * as Zmodem from 'zmodem.js/src/zmodem_browser';
import * as overlay from './overlay';
import { Modal } from './zmodem';
Terminal.applyAddon(fit);
Terminal.applyAddon(overlay);
const enum TtydCommand {
// server side
OUTPUT = '0',
SET_WINDOW_TITLE = '1',
SET_PREFERENCES = '2',
SET_RECONNECT = '3',
// client side
INPUT = '0',
RESIZE_TERMINAL = '1'
}
interface ITtydTerminal extends Terminal {
reconnectTimeout: number;
showOverlay(msg: string, timeout?: number): void;
fit(): void;
}
export interface IWindowWithTerminal extends Window {
term: ITtydTerminal;
resizeTimeout?: number;
tty_auth_token?: string;
}
declare let window: IWindowWithTerminal;
const modal = new Modal();
const terminalContainer = document.getElementById('terminal-container');
const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const wsPath = window.location.pathname.endsWith('/') ? 'ws' : '/ws';
const url = [protocol, window.location.host, window.location.pathname, wsPath, window.location.search].join('');
const textDecoder = new TextDecoder();
const textEncoder = new TextEncoder();
const termOptions = {
fontSize: 13,
fontFamily: '"Menlo for Powerline", Menlo, Consolas, "Liberation Mono", Courier, monospace',
theme: {
foreground: '#d2d2d2',
background: '#2b2b2b',
cursor: '#adadad',
black: '#000000',
red: '#d81e00',
green: '#5ea702',
yellow: '#cfae00',
blue: '#427ab3',
magenta: '#89658e',
cyan: '#00a7aa',
white: '#dbded8',
brightBlack: '#686a66',
brightRed: '#f54235',
brightGreen: '#99e343',
brightYellow: '#fdeb61',
brightBlue: '#84b0d8',
brightMagenta: '#bc94b7',
brightCyan: '#37e6e8',
brightWhite: '#f1f1f0'
} as ITheme
} as ITerminalOptions;
const authToken = (typeof window.tty_auth_token !== 'undefined') ? window.tty_auth_token : null;
let autoReconnect = -1;
let term: ITtydTerminal;
let title: string;
let wsError: boolean;
const openWs = function(): void {
const ws = new WebSocket(url, ['tty']);
const sendMessage = function (message: string): void {
if (ws.readyState === WebSocket.OPEN) {
ws.send(textEncoder.encode(message));
}
};
const unloadCallback = function (event: BeforeUnloadEvent): string {
const message = 'Close terminal? this will also terminate the command.';
event.returnValue = message;
return message;
};
const resetTerm = function(): void {
modal.hide();
clearTimeout(term.reconnectTimeout);
if (ws.readyState !== WebSocket.CLOSED) {
ws.close();
}
openWs();
};
const zsentry = new Zmodem.Sentry({
to_terminal: function(octets: ArrayBuffer): any {
const buffer = new Uint8Array(octets).buffer;
term.write(textDecoder.decode(buffer));
},
sender: function(octets: number[]): any {
// limit max packet size to 4096
while (octets.length) {
const chunk = octets.splice(0, 4095);
const buffer = new Uint8Array(chunk.length + 1);
buffer[0] = TtydCommand.INPUT.charCodeAt(0);
buffer.set(chunk, 1);
ws.send(buffer);
}
},
on_retract: function(): any {
// console.log('on_retract');
},
on_detect: function(detection: any): any {
term.setOption('disableStdin', true);
const zsession = detection.confirm();
const promise = zsession.type === 'send' ? modal.handleSend(zsession) : modal.handleReceive(zsession);
promise.catch(console.error.bind(console)).then(() => {
modal.hide();
term.setOption('disableStdin', false);
});
}
});
ws.binaryType = 'arraybuffer';
ws.onopen = function(): void {
console.log('[ttyd] websocket opened');
wsError = false;
sendMessage(JSON.stringify({AuthToken: authToken}));
if (typeof term !== 'undefined') {
term.dispose();
}
// expose term handle for some programatic cases
// which need to get the content of the terminal
term = window.term = <ITtydTerminal>new Terminal(termOptions);
term.onResize((size: {cols: number, rows: number}) => {
if (ws.readyState === WebSocket.OPEN) {
sendMessage(TtydCommand.RESIZE_TERMINAL + JSON.stringify({columns: size.cols, rows: size.rows}));
}
setTimeout(() => term.showOverlay(size.cols + 'x' + size.rows), 500);
});
term.onTitleChange((data: string) => {
if (data && data !== '') {
document.title = (data + ' | ' + title);
}
});
term.onData((data: string) => sendMessage(TtydCommand.INPUT + data));
while (terminalContainer.firstChild) {
terminalContainer.removeChild(terminalContainer.firstChild);
}
// https://stackoverflow.com/a/27923937/1727928
window.addEventListener('resize', () => {
clearTimeout(window.resizeTimeout);
window.resizeTimeout = <number><any>setTimeout(() => term.fit(), 250);
});
window.addEventListener('beforeunload', unloadCallback);
term.open(terminalContainer);
term.fit();
term.focus();
};
ws.onmessage = function(event: MessageEvent): void {
const rawData = new Uint8Array(event.data);
const cmd = String.fromCharCode(rawData[0]);
const data = rawData.slice(1).buffer;
switch (cmd) {
case TtydCommand.OUTPUT:
try {
zsentry.consume(data);
} catch (e) {
console.error(e);
resetTerm();
}
break;
case TtydCommand.SET_WINDOW_TITLE:
title = textDecoder.decode(data);
document.title = title;
break;
case TtydCommand.SET_PREFERENCES:
const preferences = JSON.parse(textDecoder.decode(data));
Object.keys(preferences).forEach((key) => {
console.log('[ttyd] xterm option: ' + key + '=' + preferences[key]);
term.setOption(key, preferences[key]);
});
break;
case TtydCommand.SET_RECONNECT:
autoReconnect = JSON.parse(textDecoder.decode(data));
console.log('[ttyd] reconnect: ' + autoReconnect + ' seconds');
break;
default:
console.log('[ttyd] unknown command: ' + cmd);
break;
}
};
ws.onclose = function(event: CloseEvent): void {
console.log('[ttyd] websocket closed, code: ' + event.code);
modal.hide();
if (term) {
if (!wsError) {
term.showOverlay('Connection Closed', null);
}
}
window.removeEventListener('beforeunload', unloadCallback);
// 1008: POLICY_VIOLATION - Auth failure
if (event.code === 1008) {
window.location.reload();
}
// 1000: CLOSE_NORMAL
if (event.code !== 1000 && autoReconnect > 0) {
term.reconnectTimeout = <number><any>setTimeout(openWs, autoReconnect * 1000);
}
};
};
if (document.readyState === 'complete' || document.readyState !== 'loading') {
openWs();
} else {
document.addEventListener('DOMContentLoaded', openWs);
}

View File

@@ -1,190 +0,0 @@
import * as Zmodem from 'zmodem.js/src/zmodem_browser';
class Status {
element: HTMLElement;
filesRemaining: HTMLElement;
bytesRemaining: HTMLElement;
constructor() {
this.element = document.getElementById('status');
this.filesRemaining = document.getElementById('files-remaining');
this.bytesRemaining = document.getElementById('bytes-remaining');
}
}
class Choose {
element: HTMLElement;
files: HTMLInputElement;
filesNames: HTMLElement;
constructor() {
this.element = document.getElementById('choose');
this.files = <HTMLInputElement>document.getElementById('files');
this.filesNames = document.getElementById('file-names');
}
}
class Progress {
element: HTMLElement;
fileName: HTMLElement;
progressBar: HTMLProgressElement;
bytesReceived: HTMLElement;
bytesFile: HTMLElement;
percentReceived: HTMLElement;
skip: HTMLLinkElement;
constructor() {
this.element = document.getElementById('progress');
this.fileName = document.getElementById('file-name');
this.progressBar = <HTMLProgressElement>document.getElementById('progress-bar');
this.bytesReceived = document.getElementById('bytes-received');
this.bytesFile = document.getElementById('bytes-file');
this.percentReceived = document.getElementById('percent-received');
this.skip = <HTMLLinkElement>document.getElementById('skip');
}
}
function bytesHuman (bytes: any, precision: number): string {
if (isNaN(parseFloat(bytes)) || !isFinite(bytes)) return '-';
if (bytes === 0) return '0';
if (typeof precision === 'undefined') precision = 1;
const units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
const num = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, Math.floor(num))).toFixed(precision) + ' ' + units[num];
}
export class Modal {
element: HTMLElement;
header: HTMLElement;
status: Status;
choose: Choose;
progress: Progress;
constructor() {
this.element = document.getElementById('modal');
this.header = document.getElementById('header');
this.status = new Status();
this.choose = new Choose();
this.progress = new Progress();
}
public reset(title: string): void {
this.header.textContent = title;
this.status.element.style.display = 'none';
this.choose.element.style.display = 'none';
this.progress.element.style.display = 'none';
this.progress.bytesReceived.textContent = '-';
this.progress.percentReceived.textContent = '-%';
this.progress.progressBar.textContent = '0%';
this.progress.progressBar.value = 0;
this.progress.skip.style.display = 'none';
}
public hide(): void {
this.element.classList.remove('is-active');
}
public updateFileInfo(fileInfo: any): void {
this.status.element.style.display = '';
this.choose.element.style.display = 'none';
this.progress.element.style.display = '';
this.status.filesRemaining.textContent = fileInfo.files_remaining;
this.status.bytesRemaining.textContent = bytesHuman(fileInfo.bytes_remaining, 2);
this.progress.fileName.textContent = fileInfo.name;
}
public showReceive(xfer: any): void {
this.reset('Receiving files');
this.updateFileInfo(xfer.get_details());
this.progress.skip.disabled = false;
this.progress.skip.onclick = function (): void {
(<HTMLLinkElement>this).disabled = true;
xfer.skip();
};
this.progress.skip.style.display = '';
this.element.classList.add('is-active');
}
public showSend(callback: (files: FileList) => any): void {
this.reset('Sending files');
this.choose.element.style.display = '';
this.choose.files.disabled = false;
this.choose.files.value = '';
this.choose.filesNames.textContent = '';
const self: Modal = this;
this.choose.files.onchange = function (): void {
(<HTMLInputElement>this).disabled = true;
const files: FileList = (<HTMLInputElement>this).files;
let fileNames: string = '';
for (let i = 0; i < files.length; i++) {
if (i === 0) {
fileNames = files[i].name;
} else {
fileNames += ', ' + files[i].name;
}
}
self.choose.filesNames.textContent = fileNames;
callback(files);
};
this.element.classList.add('is-active');
}
public updateProgress(xfer: any): void {
const size = xfer.get_details().size;
const offset = xfer.get_offset();
this.progress.bytesReceived.textContent = bytesHuman(offset, 2);
this.progress.bytesFile.textContent = bytesHuman(size, 2);
const percentReceived = (100 * offset / size).toFixed(2);
this.progress.percentReceived.textContent = percentReceived + '%';
this.progress.progressBar.textContent = percentReceived + '%';
this.progress.progressBar.setAttribute('value', percentReceived);
}
public handleSend(zsession: any): Promise<any> {
return new Promise((res) => {
this.showSend((files) => {
Zmodem.Browser.send_files(
zsession,
files,
{
on_progress: (obj, xfer) => {
this.updateFileInfo(xfer.get_details());
this.updateProgress(xfer);
},
on_file_complete: (obj) => {
// console.log(obj);
}
}
).then(
zsession.close.bind(zsession),
console.error.bind(console)
).then(() => res());
});
});
}
public handleReceive(zsession: any): Promise<any> {
zsession.on('offer', (xfer) => {
this.showReceive(xfer);
const fileBuffer = [];
xfer.on('input', (payload) => {
this.updateProgress(xfer);
fileBuffer.push(new Uint8Array(payload));
});
xfer.accept().then(() => {
Zmodem.Browser.save_to_disk(
fileBuffer,
xfer.get_details().name
);
}, console.error.bind(console));
});
const promise = new Promise((res) => {
zsession.on('session_end', () => res());
});
zsession.start();
return promise;
}
}

View File

@@ -1,45 +1,48 @@
{
"name": "ttyd",
"version": "1.1.0",
"description": "Share your terminal over the web",
"main": "js/app.js",
"repository": {
"url": "git@github.com:tsl0922/ttyd.git",
"type": "git"
},
"author": "Shuanglei Tao <tsl0922@gmail.com>",
"license": "MIT",
"scripts": {
"build": "NODE_ENV=production webpack --config webpack.prod.js && gulp",
"prestart": "gulp clean",
"start": "webpack-dev-server --config webpack.dev.js"
},
"dependencies": {
"xterm": "^3.13.2",
"zmodem.js": "^0.1.9"
},
"devDependencies": {
"copy-webpack-plugin": "^5.0.3",
"css-loader": "^2.1.1",
"gulp": "^4.0.2",
"gulp-clean": "^0.4.0",
"gulp-inline-source": "^4.0.0",
"html-webpack-inline-source-plugin": "^0.0.10",
"html-webpack-plugin": "^3.2.0",
"mini-css-extract-plugin": "^0.6.0",
"node-sass": "^4.12.0",
"optimize-css-assets-webpack-plugin": "^5.0.1",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.1",
"terser-webpack-plugin": "^1.2.4",
"ts-loader": "^6.0.0",
"tslint": "^5.16.0",
"tslint-consistent-codestyle": "^1.15.1",
"tslint-loader": "^3.5.4",
"typescript": "^3.4.5",
"webpack": "^4.31.0",
"webpack-cli": "^3.3.2",
"webpack-dev-server": "^3.3.1",
"webpack-merge": "^4.2.1"
}
"private": true,
"name": "ttyd",
"version": "1.0.0",
"description": "Share your terminal over the web",
"repository": {
"url": "git@github.com:tsl0922/ttyd.git",
"type": "git"
},
"author": "Shuanglei Tao <tsl0922@gmail.com>",
"license": "MIT",
"scripts": {
"prestart": "gulp clean",
"start": "webpack-dev-server",
"build": "NODE_ENV=production webpack && gulp",
"lint": "tslint -c tslint.json 'src/**/*.ts'"
},
"devDependencies": {
"copy-webpack-plugin": "^5.0.3",
"css-loader": "^1.0.1",
"gulp": "^4.0.2",
"gulp-clean": "^0.4.0",
"gulp-inline-source": "^4.0.0",
"html-webpack-plugin": "^3.2.0",
"mini-css-extract-plugin": "^0.6.0",
"node-sass": "^4.12.0",
"optimize-css-assets-webpack-plugin": "^5.0.1",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.1",
"terser-webpack-plugin": "^1.3.0",
"ts-loader": "^6.0.1",
"tslint": "^5.16.0",
"tslint-consistent-codestyle": "^1.15.1",
"tslint-loader": "^3.5.4",
"typescript": "^3.4.5",
"webpack": "^4.32.2",
"webpack-cli": "^3.3.2",
"webpack-dev-server": "^3.4.1",
"webpack-merge": "^4.2.1"
},
"dependencies": {
"decko": "^1.2.0",
"preact": "^8.4.2",
"preact-compat": "^3.18.5",
"preact-router": "^2.6.1",
"xterm": "^3.13.2"
}
}

View File

@@ -1,64 +0,0 @@
@import "~xterm/src/xterm.css";
@import "modal";
html, body {
height: 100%;
min-height: 100%;
margin: 0;
overflow: hidden;
}
#terminal-container {
width: auto;
height: 100%;
margin: 0 auto;
padding: 0;
background-color: #2b2b2b;
.terminal {
padding: 5px;
}
}
#modal {
strong {
color: #268bd2;
}
span {
color: #2aa198;
}
header {
font-weight: bold;
text-align: center;
padding-bottom: 10px;
margin-bottom: 10px;
border-bottom: 1px solid #ddd;
}
}
#progress {
padding-top: 10px;
progress {
margin: 10px 0;
}
color: #93a1a1;
span {
font-weight: bold;
}
}
#status {
margin-top: 10px;
text-align: center;
}
#choose {
padding-top: 10px;
.file-name {
border-color: transparent;
}
}
#file-name {
background-color: #fafffd;
text-align: center;
}

View File

@@ -1,149 +0,0 @@
.box {
background-color: #fff;
border-radius: 5px;
box-shadow: 0 2px 3px hsla(0, 0%, 4%, 0.1), 0 0 0 1px hsla(0, 0%, 4%, 0.1);
color: #4a4a4a;
display: block;
padding: 1.25rem;
}
.progress {
border: none;
border-radius: 290486px;
display: block;
height: 1rem;
overflow: hidden;
padding: 0;
width: 100%;
&:not(:last-child) {
margin-bottom: 1.5rem;
}
&::-webkit-progress-bar {
background-color: #dbdbdb;
}
&::-webkit-progress-value {
background-color: #3273dc;
}
&::-moz-progress-bar {
background-color: #3273dc;
}
&::-ms-fill {
background-color: #3273dc;
border: none;
}
}
.modal {
bottom: 0;
left: 0;
right: 0;
top: 0;
align-items: center;
display: none;
overflow: hidden;
position: fixed;
z-index: 40;
&.is-active {
display: flex;
}
}
.file-input {
height: .01em;
left: 0;
outline: none;
position: absolute;
top: 0;
width: .01em;
}
.file-cta, .file-name {
align-items: center;
box-shadow: none;
display: inline-flex;
height: 2.25em;
justify-content: flex-start;
line-height: 1.5;
position: relative;
vertical-align: top;
border-color: #dbdbdb;
border-radius: 3px;
font-size: 1em;
padding: calc(.375em - 1px) 1em;
white-space: nowrap;
}
.file-name {
border-color: #dbdbdb;
border-style: solid;
border-width: 1px 1px 1px 0;
display: block;
max-width: 16em;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
&:active, &:focus {
outline: none;
}
}
.file-cta {
background-color: #f5f5f5;
color: #4a4a4a;
&:active, &:focus {
outline: none;
}
}
.button {
float: right;
align-items: center;
border-radius: 2px;
display: inline-flex;
font-size: .75rem;
height: 2em;
line-height: 1.5;
position: relative;
vertical-align: top;
background-color: #3273dc;
border-color: transparent;
color: #fff;
cursor: pointer;
justify-content: center;
padding: calc(.375em - 1px) 0.75em;
text-align: center;
white-space: nowrap;
&:active, &:focus {
outline: none;
}
&:hover {
background-color: #276cda;
border-color: transparent;
color: #fff;
}
}
.modal-background {
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
background-color: hsla(0, 0%, 4%, 0.86);
}
.modal-content {
margin: 0 20px;
max-height: calc(100vh - 160px);
overflow: auto;
position: relative;
width: 100%;
}
@media print, screen and (min-width: 769px) {
.modal-content {
margin: 0 auto;
max-height: calc(100vh - 40px);
width: 640px;
}
}

View File

@@ -0,0 +1,45 @@
import { h, Component } from 'preact';
import { ITerminalOptions, ITheme } from 'xterm';
import Terminal from './terminal';
if ((module as any).hot) {
require('preact/debug');
}
const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const wsPath = window.location.pathname.endsWith('/') ? 'ws' : '/ws';
const url = [protocol, window.location.host, window.location.pathname, wsPath, window.location.search].join('');
const termOptions = {
fontSize: 13,
fontFamily: '"Menlo for Powerline", Menlo, Consolas, "Liberation Mono", Courier, monospace',
theme: {
foreground: '#d2d2d2',
background: '#2b2b2b',
cursor: '#adadad',
black: '#000000',
red: '#d81e00',
green: '#5ea702',
yellow: '#cfae00',
blue: '#427ab3',
magenta: '#89658e',
cyan: '#00a7aa',
white: '#dbded8',
brightBlack: '#686a66',
brightRed: '#f54235',
brightGreen: '#99e343',
brightYellow: '#fdeb61',
brightBlue: '#84b0d8',
brightMagenta: '#bc94b7',
brightCyan: '#37e6e8',
brightWhite: '#f1f1f0'
} as ITheme
} as ITerminalOptions;
export default class App extends Component {
public render() {
return (
<Terminal id='terminal-container' url={url} options={termOptions} />
);
}
}

View File

@@ -0,0 +1,188 @@
import { h, Component } from 'preact';
import { bind } from 'decko';
import { Terminal as Xterm, ITerminalOptions } from 'xterm';
import * as fit from 'xterm/lib/addons/fit/fit';
import * as overlay from './overlay';
import 'xterm/dist/xterm.css';
enum Command {
// server side
OUTPUT = '0',
SET_WINDOW_TITLE = '1',
SET_PREFERENCES = '2',
SET_RECONNECT = '3',
// client side
INPUT = '0',
RESIZE_TERMINAL = '1'
}
interface ITerminal extends Xterm {
fit(): void;
showOverlay(msg: string, timeout?: number): void;
}
interface Props {
id: string;
url: string;
options: ITerminalOptions;
}
export default class Terminal extends Component<Props> {
private textEncoder: TextEncoder;
private textDecoder: TextDecoder;
private container: HTMLElement;
private terminal: ITerminal;
private socket: WebSocket;
private title: string;
private autoReconnect: number;
private resizeTimeout: number;
constructor(props) {
super(props);
Xterm.applyAddon(fit);
Xterm.applyAddon(overlay);
this.textEncoder = new TextEncoder();
this.textDecoder = new TextDecoder();
}
componentDidMount() {
this.openTerminal();
}
componentWillUnmount() {
this.socket.close();
this.terminal.dispose();
window.removeEventListener('resize', this.onWindowResize);
window.removeEventListener('beforeunload', this.onWindowUnload);
}
@bind
onWindowResize() {
const { terminal } = this;
clearTimeout(this.resizeTimeout);
this.resizeTimeout = setTimeout(() => terminal.fit(), 250) as any;
}
onWindowUnload(event: BeforeUnloadEvent): string {
const message = 'Close terminal? this will also terminate the command.';
event.returnValue = message;
return message;
}
@bind
openTerminal() {
if (this.terminal) {
this.terminal.dispose();
}
this.socket = new WebSocket(this.props.url, ['tty']);
this.terminal = new Xterm(this.props.options) as ITerminal;
const { socket, terminal, container } = this;
socket.binaryType = 'arraybuffer';
socket.onopen = this.onSocketOpen;
socket.onmessage = this.onSocketData;
socket.onclose = this.onSocketClose;
terminal.onTitleChange((data) => {
if (data && data !== '') {
document.title = (data + ' | ' + this.title);
}
});
terminal.onData(this.onTerminalData);
terminal.onResize(this.onTerminalResize);
terminal.open(container);
terminal.focus();
window.addEventListener('resize', this.onWindowResize);
window.addEventListener('beforeunload', this.onWindowUnload);
}
@bind
onSocketOpen() {
console.log('Websocket connection opened');
const { socket, textEncoder, terminal } = this;
socket.send(textEncoder.encode(JSON.stringify({AuthToken: ''})));
terminal.fit();
}
@bind
onSocketClose(event: CloseEvent) {
console.log('Websocket connection closed with code: ' + event.code);
const { terminal, openTerminal, autoReconnect } = this;
terminal.showOverlay('Connection Closed', null);
window.removeEventListener('beforeunload', this.onWindowUnload);
// 1008: POLICY_VIOLATION - Auth failure
if (event.code === 1008) {
window.location.reload();
}
// 1000: CLOSE_NORMAL
if (event.code !== 1000 && autoReconnect > 0) {
setTimeout(openTerminal, autoReconnect * 1000);
}
}
@bind
onSocketData(event: MessageEvent) {
const { terminal, textDecoder } = this;
let rawData = new Uint8Array(event.data),
cmd = String.fromCharCode(rawData[0]),
data = rawData.slice(1).buffer;
switch(cmd) {
case Command.OUTPUT:
terminal.write(textDecoder.decode(data));
break;
case Command.SET_WINDOW_TITLE:
this.title = textDecoder.decode(data);
document.title = this.title;
break;
case Command.SET_PREFERENCES:
let preferences = JSON.parse(textDecoder.decode(data));
Object.keys(preferences).forEach(function(key) {
console.log('Setting ' + key + ': ' + preferences[key]);
terminal.setOption(key, preferences[key]);
});
break;
case Command.SET_RECONNECT:
this.autoReconnect = JSON.parse(textDecoder.decode(data));
console.log('Enabling reconnect: ' + this.autoReconnect + ' seconds');
break;
default:
console.warn('Unknown command: ' + cmd);
break;
}
}
@bind
onTerminalResize(size: {cols: number, rows: number}) {
const { terminal, socket, textEncoder } = this;
if (socket.readyState === WebSocket.OPEN) {
let msg = JSON.stringify({columns: size.cols, rows: size.rows});
socket.send(textEncoder.encode(Command.RESIZE_TERMINAL + msg));
}
setTimeout(() => {terminal.showOverlay(size.cols + 'x' + size.rows)}, 500);
}
@bind
onTerminalData(data: string) {
const { socket, textEncoder } = this;
if (socket.readyState === WebSocket.OPEN) {
socket.send(textEncoder.encode(Command.INPUT + data));
}
}
public render({ id }: Props) {
return (
<div id={id} ref={(c) => this.container = c}></div>
);
}
}

View File

@@ -3,11 +3,11 @@
import { Terminal } from 'xterm';
interface IOverlayAddonTerminal extends Terminal {
__overlayNode?: HTMLElement;
__overlayTimeout?: number;
__overlayNode: HTMLElement | null;
__overlayTimeout: number | null;
}
export function showOverlay(term: Terminal, msg: string, timeout: number): void {
export function showOverlay(term: Terminal, msg: string, timeout?: number): void {
const addonTerminal = <IOverlayAddonTerminal> term;
if (!addonTerminal.__overlayNode) {
if (!term.element) {
@@ -30,6 +30,7 @@ export function showOverlay(term: Terminal, msg: string, timeout: number): void
e.stopPropagation();
}, true);
}
addonTerminal.__overlayNode.style.color = '#101010';
addonTerminal.__overlayNode.style.backgroundColor = '#f0f0f0';

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

5
html/src/index.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { h, render } from 'preact';
import App from './components/app';
import './style/index.scss';
render(<App />, document.body);

17
html/src/style/index.scss Normal file
View File

@@ -0,0 +1,17 @@
html, body {
height: 100%;
min-height: 100%;
margin: 0;
overflow: hidden;
}
#terminal-container {
width: auto;
height: 100%;
margin: 0 auto;
padding: 0;
background-color: #2b2b2b;
.terminal {
padding: 5px;
}
}

18
html/src/template.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title><%= htmlWebpackPlugin.options.title %></title>
<link inline rel="icon" type="image/png" href="favicon.png">
<% for (const css in htmlWebpackPlugin.files.css) { %>
<link inline rel="stylesheet" src="<%= htmlWebpackPlugin.files.css[css] %>">
<% } %>
</head>
<body>
<script src="auth_token.js"></script>
<% for (const js in htmlWebpackPlugin.files.js) { %>
<script inline type="text/javascript" src="<%= htmlWebpackPlugin.files.js[js] %>"></script>
<% } %>
</body>
</html>

View File

@@ -1,11 +1,22 @@
{
"compilerOptions": {
"outDir": "./dist/",
"sourceMap": true,
"module": "commonjs",
"target": "es5",
"lib": [ "dom", "es5", "es2015.promise"],
"target": "ES5",
"module": "ESNext",
"lib": [
"dom",
"es5",
"es2015.promise"
],
"allowJs": true,
"jsx": "react",
"allowJs": true
}
}
"jsxFactory": "h",
"sourceMap": true,
"moduleResolution": "node",
"esModuleInterop": true,
"experimentalDecorators": true
},
"include": [
"src/**/*.tsx",
"src/**/*.ts"
]
}

View File

@@ -1,13 +1,20 @@
const path = require('path');
const merge = require('webpack-merge');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin");
const TerserPlugin = require('terser-webpack-plugin');
const devMode = process.env.NODE_ENV !== 'production';
module.exports = {
const baseConfig = {
context: path.resolve(__dirname, 'src'),
entry: {
app: './js/app.ts'
app: './index.tsx'
},
output: {
path: __dirname + '/dist',
path: path.resolve(__dirname, 'dist'),
filename: devMode ? '[name].js' : '[name].[hash].js',
},
module: {
@@ -37,11 +44,20 @@ module.exports = {
},
plugins: [
new CopyWebpackPlugin([
{ from: 'favicon.png', to: '.' }
{ from: './favicon.png', to: '.' }
], {}),
new MiniCssExtractPlugin({
filename: devMode ? '[name].css' : '[name].[hash].css',
chunkFilename: devMode ? '[id].css' : '[id].[hash].css',
}),
new HtmlWebpackPlugin({
inject: false,
minify: {
removeComments: true,
collapseWhitespace: true,
},
title: 'ttyd - Terminal',
template: './template.html'
})
],
performance : {
@@ -49,3 +65,39 @@ module.exports = {
},
devtool: 'source-map',
};
const devConfig = {
mode: 'development',
devServer: {
contentBase: path.join(__dirname, 'dist'),
compress: true,
port: 9000,
proxy: [{
context: ['/auth_token.js', '/ws'],
target: 'http://localhost:7681',
ws: true
}]
}
};
const prodConfig = {
mode: 'production',
optimization: {
minimizer: [
new TerserPlugin({
sourceMap: true
}),
new OptimizeCSSAssetsPlugin({
cssProcessorOptions: {
map: {
inline: false,
annotation: true
}
}
}),
]
}
};
module.exports = merge(baseConfig, devMode ? devConfig : prodConfig);

View File

@@ -1,24 +0,0 @@
const path = require('path');
const merge = require('webpack-merge');
const config = require('./webpack.config.js');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = merge(config, {
mode: 'development',
devServer: {
contentBase: path.join(__dirname, 'dist'),
compress: true,
port: 9000,
proxy: [{
context: ['/auth_token.js', '/ws'],
target: 'http://localhost:7681',
ws: true
}]
},
plugins: [
new HtmlWebpackPlugin({
template: 'index.html'
})
]
});

View File

@@ -1,37 +0,0 @@
const merge = require('webpack-merge');
const config = require('./webpack.config.js');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin');
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin");
const TerserPlugin = require('terser-webpack-plugin');
module.exports = merge(config, {
mode: 'production',
optimization: {
minimizer: [
new TerserPlugin({
sourceMap: true
}),
new OptimizeCSSAssetsPlugin({
cssProcessorOptions: {
map: {
inline: false,
annotation: true
}
}
}),
]
},
plugins: [
new HtmlWebpackPlugin({
minify: {
removeComments: true,
collapseWhitespace: true,
},
inlineSource: '.(js|css)$',
template: 'index.html',
}),
new HtmlWebpackInlineSourcePlugin(),
]
});

File diff suppressed because it is too large Load Diff