summaryrefslogtreecommitdiffstats
path: root/html/src
diff options
context:
space:
mode:
Diffstat (limited to 'html/src')
-rw-r--r--html/src/components/app.tsx59
-rw-r--r--html/src/components/modal/index.tsx27
-rw-r--r--html/src/components/modal/modal.scss81
-rw-r--r--html/src/components/terminal/index.tsx372
-rw-r--r--html/src/components/terminal/overlay.ts75
-rw-r--r--html/src/components/zmodem/index.tsx209
-rw-r--r--html/src/favicon.pngbin0 -> 1657 bytes
-rw-r--r--html/src/index.tsx6
-rw-r--r--html/src/style/index.scss18
-rw-r--r--html/src/template.html17
10 files changed, 864 insertions, 0 deletions
diff --git a/html/src/components/app.tsx b/html/src/components/app.tsx
new file mode 100644
index 0000000..a899b7f
--- /dev/null
+++ b/html/src/components/app.tsx
@@ -0,0 +1,59 @@
+import { h, Component } from 'preact';
+
+import { ITerminalOptions, ITheme } from 'xterm';
+import { ClientOptions, Xterm } from './terminal';
+
+if ((module as any).hot) {
+ // tslint:disable-next-line:no-var-requires
+ require('preact/debug');
+}
+
+const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+const path = window.location.pathname.replace(/[\/]+$/, '');
+const wsUrl = [protocol, '//', window.location.host, path, '/ws', window.location.search].join('');
+const tokenUrl = [window.location.protocol, '//', window.location.host, path, '/token'].join('');
+const clientOptions = {
+ rendererType: 'webgl',
+ disableLeaveAlert: false,
+ disableResizeOverlay: false,
+ titleFixed: null,
+} as ClientOptions;
+const termOptions = {
+ fontSize: 13,
+ fontFamily: 'Menlo For Powerline,Consolas,Liberation Mono,Menlo,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 class App extends Component {
+ render() {
+ return (
+ <Xterm
+ id="terminal-container"
+ wsUrl={wsUrl}
+ tokenUrl={tokenUrl}
+ clientOptions={clientOptions}
+ termOptions={termOptions}
+ />
+ );
+ }
+}
diff --git a/html/src/components/modal/index.tsx b/html/src/components/modal/index.tsx
new file mode 100644
index 0000000..558a218
--- /dev/null
+++ b/html/src/components/modal/index.tsx
@@ -0,0 +1,27 @@
+import { h, Component, ComponentChildren } from 'preact';
+
+import './modal.scss';
+
+interface Props {
+ show: boolean;
+ children: ComponentChildren;
+}
+
+export class Modal extends Component<Props> {
+ constructor(props: Props) {
+ super(props);
+ }
+
+ render({ show, children }: Props) {
+ return (
+ show && (
+ <div className="modal">
+ <div className="modal-background" />
+ <div className="modal-content">
+ <div className="box">{children}</div>
+ </div>
+ </div>
+ )
+ );
+ }
+}
diff --git a/html/src/components/modal/modal.scss b/html/src/components/modal/modal.scss
new file mode 100644
index 0000000..a99873b
--- /dev/null
+++ b/html/src/components/modal/modal.scss
@@ -0,0 +1,81 @@
+.modal {
+ bottom: 0;
+ left: 0;
+ right: 0;
+ top: 0;
+ align-items: center;
+ display: flex;
+ overflow: hidden;
+ position: fixed;
+ z-index: 40;
+}
+
+.modal-background {
+ bottom: 0;
+ left: 0;
+ position: absolute;
+ right: 0;
+ top: 0;
+ background-color: #4a4a4acc;
+}
+
+.modal-content {
+ margin: 0 20px;
+ max-height: calc(100vh - 160px);
+ overflow: auto;
+ position: relative;
+ width: 100%;
+
+ .box {
+ background-color: #fff;
+ color: #4a4a4a;
+ display: block;
+ padding: 1.25rem;
+ }
+
+ header {
+ font-weight: bold;
+ text-align: center;
+ padding-bottom: 10px;
+ margin-bottom: 10px;
+ border-bottom: 1px solid #ddd;
+ }
+
+ .file-input {
+ height: .01em;
+ left: 0;
+ outline: none;
+ position: absolute;
+ top: 0;
+ width: .01em;
+ }
+
+ .file-cta {
+ cursor: pointer;
+ background-color: #f5f5f5;
+ color: #6200ee;
+ outline: none;
+ 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;
+ font-weight: 500;
+ padding: calc(.375em - 1px) 1em;
+ white-space: nowrap;
+ }
+}
+
+@media print, screen and (min-width: 769px) {
+ .modal-content {
+ margin: 0 auto;
+ max-height: calc(100vh - 40px);
+ width: 640px;
+ }
+}
diff --git a/html/src/components/terminal/index.tsx b/html/src/components/terminal/index.tsx
new file mode 100644
index 0000000..b344287
--- /dev/null
+++ b/html/src/components/terminal/index.tsx
@@ -0,0 +1,372 @@
+import { bind } from 'decko';
+import * as backoff from 'backoff';
+import { Component, h } from 'preact';
+import { ITerminalOptions, Terminal } from 'xterm';
+import { FitAddon } from 'xterm-addon-fit';
+import { WebglAddon } from 'xterm-addon-webgl';
+import { WebLinksAddon } from 'xterm-addon-web-links';
+
+import { OverlayAddon } from './overlay';
+import { ZmodemAddon, FlowControl } from '../zmodem';
+
+import 'xterm/css/xterm.css';
+
+interface TtydTerminal extends Terminal {
+ fit(): void;
+}
+
+declare global {
+ interface Window {
+ term: TtydTerminal;
+ }
+}
+
+const enum Command {
+ // server side
+ OUTPUT = '0',
+ SET_WINDOW_TITLE = '1',
+ SET_PREFERENCES = '2',
+
+ // client side
+ INPUT = '0',
+ RESIZE_TERMINAL = '1',
+ PAUSE = '2',
+ RESUME = '3',
+}
+
+export interface ClientOptions {
+ rendererType: 'dom' | 'canvas' | 'webgl';
+ disableLeaveAlert: boolean;
+ disableResizeOverlay: boolean;
+ titleFixed: string;
+}
+
+interface Props {
+ id: string;
+ wsUrl: string;
+ tokenUrl: string;
+ clientOptions: ClientOptions;
+ termOptions: ITerminalOptions;
+}
+
+export class Xterm extends Component<Props> {
+ private textEncoder: TextEncoder;
+ private textDecoder: TextDecoder;
+ private container: HTMLElement;
+ private terminal: Terminal;
+
+ private fitAddon: FitAddon;
+ private overlayAddon: OverlayAddon;
+ private zmodemAddon: ZmodemAddon;
+
+ private socket: WebSocket;
+ private token: string;
+ private title: string;
+ private titleFixed: string;
+ private resizeTimeout: number;
+ private resizeOverlay = true;
+
+ private backoff: backoff.Backoff;
+ private backoffLock = false;
+ private doBackoff = true;
+ private reconnect = false;
+
+ constructor(props: Props) {
+ super(props);
+
+ this.textEncoder = new TextEncoder();
+ this.textDecoder = new TextDecoder();
+ this.fitAddon = new FitAddon();
+ this.overlayAddon = new OverlayAddon();
+ this.backoff = backoff.exponential({
+ initialDelay: 100,
+ maxDelay: 10000,
+ });
+ this.backoff.failAfter(15);
+ this.backoff.on('ready', () => {
+ this.backoffLock = false;
+ this.refreshToken().then(this.connect);
+ });
+ this.backoff.on('backoff', (_, delay: number) => {
+ console.log(`[ttyd] will attempt to reconnect websocket in ${delay}ms`);
+ this.backoffLock = true;
+ });
+ this.backoff.on('fail', () => {
+ this.backoffLock = true; // break backoff
+ });
+ }
+
+ async componentDidMount() {
+ await this.refreshToken();
+ this.openTerminal();
+ this.connect();
+
+ window.addEventListener('resize', this.onWindowResize);
+ window.addEventListener('beforeunload', this.onWindowUnload);
+ }
+
+ componentWillUnmount() {
+ this.socket.close();
+ this.terminal.dispose();
+
+ window.removeEventListener('resize', this.onWindowResize);
+ window.removeEventListener('beforeunload', this.onWindowUnload);
+ }
+
+ render({ id }: Props) {
+ const control = {
+ limit: 100000,
+ highWater: 10,
+ lowWater: 4,
+ pause: () => this.pause(),
+ resume: () => this.resume(),
+ } as FlowControl;
+
+ return (
+ <div id={id} ref={c => (this.container = c)}>
+ <ZmodemAddon ref={c => (this.zmodemAddon = c)} sender={this.sendData} control={control} />
+ </div>
+ );
+ }
+
+ @bind
+ private pause() {
+ const { textEncoder, socket } = this;
+ socket.send(textEncoder.encode(Command.PAUSE));
+ }
+
+ @bind
+ private resume() {
+ const { textEncoder, socket } = this;
+ socket.send(textEncoder.encode(Command.RESUME));
+ }
+
+ @bind
+ private sendData(data: ArrayLike<number>) {
+ const { socket } = this;
+ const payload = new Uint8Array(data.length + 1);
+ payload[0] = Command.INPUT.charCodeAt(0);
+ payload.set(data, 1);
+ socket.send(payload);
+ }
+
+ @bind
+ private async refreshToken() {
+ try {
+ const resp = await fetch(this.props.tokenUrl);
+ if (resp.ok) {
+ const json = await resp.json();
+ this.token = json.token;
+ }
+ } catch (e) {
+ console.error(`[ttyd] fetch ${this.props.tokenUrl}: `, e);
+ }
+ }
+
+ @bind
+ private onWindowResize() {
+ const { fitAddon } = this;
+ clearTimeout(this.resizeTimeout);
+ this.resizeTimeout = setTimeout(() => fitAddon.fit(), 250) as any;
+ }
+
+ @bind
+ private onWindowUnload(event: BeforeUnloadEvent): any {
+ const { socket } = this;
+ if (socket && socket.readyState === WebSocket.OPEN) {
+ const message = 'Close terminal? this will also terminate the command.';
+ event.returnValue = message;
+ return message;
+ }
+ event.preventDefault();
+ }
+
+ @bind
+ private openTerminal() {
+ this.terminal = new Terminal(this.props.termOptions);
+ const { terminal, container, fitAddon, overlayAddon } = this;
+ window.term = terminal as TtydTerminal;
+ window.term.fit = () => {
+ this.fitAddon.fit();
+ };
+
+ terminal.loadAddon(fitAddon);
+ terminal.loadAddon(overlayAddon);
+ terminal.loadAddon(new WebLinksAddon());
+ terminal.loadAddon(this.zmodemAddon);
+
+ terminal.onTitleChange(data => {
+ if (data && data !== '' && !this.titleFixed) {
+ document.title = data + ' | ' + this.title;
+ }
+ });
+ terminal.onData(this.onTerminalData);
+ terminal.onResize(this.onTerminalResize);
+ if (document.queryCommandSupported && document.queryCommandSupported('copy')) {
+ terminal.onSelectionChange(() => {
+ if (terminal.getSelection() === '') return;
+ overlayAddon.showOverlay('\u2702', 200);
+ document.execCommand('copy');
+ });
+ }
+ terminal.open(container);
+ }
+
+ @bind
+ private connect() {
+ this.socket = new WebSocket(this.props.wsUrl, ['tty']);
+ const { socket } = this;
+
+ socket.binaryType = 'arraybuffer';
+ socket.onopen = this.onSocketOpen;
+ socket.onmessage = this.onSocketData;
+ socket.onclose = this.onSocketClose;
+ socket.onerror = this.onSocketError;
+ }
+
+ @bind
+ private applyOptions(options: any) {
+ const { terminal, fitAddon } = this;
+ const isWebGL2Available = () => {
+ try {
+ const canvas = document.createElement('canvas');
+ return !!(window.WebGL2RenderingContext && canvas.getContext('webgl2'));
+ } catch (e) {
+ return false;
+ }
+ };
+
+ Object.keys(options).forEach(key => {
+ const value = options[key];
+ switch (key) {
+ case 'rendererType':
+ if (value === 'webgl' && isWebGL2Available()) {
+ terminal.loadAddon(new WebglAddon());
+ console.log(`[ttyd] WebGL renderer enabled`);
+ }
+ break;
+ case 'disableLeaveAlert':
+ if (value) {
+ window.removeEventListener('beforeunload', this.onWindowUnload);
+ console.log('[ttyd] Leave site alert disabled');
+ }
+ break;
+ case 'disableResizeOverlay':
+ if (value) {
+ console.log(`[ttyd] Resize overlay disabled`);
+ this.resizeOverlay = false;
+ }
+ break;
+ case 'disableReconnect':
+ if (value) {
+ console.log(`[ttyd] Reconnect disabled`);
+ this.doBackoff = false;
+ }
+ break;
+ case 'titleFixed':
+ if (!value || value === '') return;
+ console.log(`[ttyd] setting fixed title: ${value}`);
+ this.titleFixed = value;
+ document.title = value;
+ break;
+ default:
+ console.log(`[ttyd] option: ${key}=${value}`);
+ terminal.setOption(key, value);
+ if (key.indexOf('font') === 0) fitAddon.fit();
+ break;
+ }
+ });
+ }
+
+ @bind
+ private onSocketOpen() {
+ console.log('[ttyd] websocket connection opened');
+ this.backoff.reset();
+
+ const { socket, textEncoder, terminal, fitAddon, overlayAddon } = this;
+ socket.send(textEncoder.encode(JSON.stringify({ AuthToken: this.token })));
+
+ if (this.reconnect) {
+ const dims = fitAddon.proposeDimensions();
+ terminal.reset();
+ terminal.resize(dims.cols, dims.rows);
+ this.onTerminalResize(dims); // may not be triggered by terminal.resize
+ overlayAddon.showOverlay('Reconnected', 300);
+ } else {
+ this.reconnect = true;
+ fitAddon.fit();
+ }
+
+ this.applyOptions(this.props.clientOptions);
+
+ terminal.focus();
+ }
+
+ @bind
+ private onSocketClose(event: CloseEvent) {
+ console.log(`[ttyd] websocket connection closed with code: ${event.code}`);
+
+ const { backoff, doBackoff, backoffLock, overlayAddon } = this;
+ overlayAddon.showOverlay('Connection Closed', null);
+
+ // 1000: CLOSE_NORMAL
+ if (event.code !== 1000 && doBackoff && !backoffLock) {
+ backoff.backoff();
+ }
+ }
+
+ @bind
+ private onSocketError(event: Event) {
+ console.error('[ttyd] websocket connection error: ', event);
+ const { backoff, doBackoff, backoffLock } = this;
+ if (doBackoff && !backoffLock) {
+ backoff.backoff();
+ }
+ }
+
+ @bind
+ private onSocketData(event: MessageEvent) {
+ const { textDecoder, zmodemAddon } = this;
+ const rawData = event.data as ArrayBuffer;
+ const cmd = String.fromCharCode(new Uint8Array(rawData)[0]);
+ const data = rawData.slice(1);
+
+ switch (cmd) {
+ case Command.OUTPUT:
+ zmodemAddon.consume(data);
+ break;
+ case Command.SET_WINDOW_TITLE:
+ this.title = textDecoder.decode(data);
+ document.title = this.title;
+ break;
+ case Command.SET_PREFERENCES:
+ this.applyOptions(JSON.parse(textDecoder.decode(data)));
+ break;
+ default:
+ console.warn(`[ttyd] unknown command: ${cmd}`);
+ break;
+ }
+ }
+
+ @bind
+ private onTerminalResize(size: { cols: number; rows: number }) {
+ const { overlayAddon, socket, textEncoder, resizeOverlay } = this;
+ if (socket.readyState === WebSocket.OPEN) {
+ const msg = JSON.stringify({ columns: size.cols, rows: size.rows });
+ socket.send(textEncoder.encode(Command.RESIZE_TERMINAL + msg));
+ }
+ if (resizeOverlay) {
+ setTimeout(() => {
+ overlayAddon.showOverlay(`${size.cols}x${size.rows}`);
+ }, 500);
+ }
+ }
+
+ @bind
+ private onTerminalData(data: string) {
+ const { socket, textEncoder } = this;
+ if (socket.readyState === WebSocket.OPEN) {
+ socket.send(textEncoder.encode(Command.INPUT + data));
+ }
+ }
+}
diff --git a/html/src/components/terminal/overlay.ts b/html/src/components/terminal/overlay.ts
new file mode 100644
index 0000000..0438acf
--- /dev/null
+++ b/html/src/components/terminal/overlay.ts
@@ -0,0 +1,75 @@
+// ported from hterm.Terminal.prototype.showOverlay
+// https://chromium.googlesource.com/apps/libapps/+/master/hterm/js/hterm_terminal.js
+import { ITerminalAddon, Terminal } from 'xterm';
+
+export class OverlayAddon implements ITerminalAddon {
+ private terminal: Terminal | undefined;
+ private overlayNode: HTMLElement | null;
+ private overlayTimeout: number | null;
+
+ constructor() {
+ this.overlayNode = document.createElement('div');
+ this.overlayNode.style.cssText = `border-radius: 15px;
+font-size: xx-large;
+opacity: 0.75;
+padding: 0.2em 0.5em 0.2em 0.5em;
+position: absolute;
+-webkit-user-select: none;
+-webkit-transition: opacity 180ms ease-in;
+-moz-user-select: none;
+-moz-transition: opacity 180ms ease-in;`;
+
+ this.overlayNode.addEventListener(
+ 'mousedown',
+ e => {
+ e.preventDefault();
+ e.stopPropagation();
+ },
+ true
+ );
+ }
+
+ activate(terminal: Terminal): void {
+ this.terminal = terminal;
+ }
+
+ dispose(): void {}
+
+ showOverlay(msg: string, timeout?: number): void {
+ const { terminal, overlayNode } = this;
+
+ overlayNode.style.color = '#101010';
+ overlayNode.style.backgroundColor = '#f0f0f0';
+ overlayNode.textContent = msg;
+ overlayNode.style.opacity = '0.75';
+
+ if (!overlayNode.parentNode) {
+ terminal.element.appendChild(overlayNode);
+ }
+
+ const divSize = terminal.element.getBoundingClientRect();
+ const overlaySize = overlayNode.getBoundingClientRect();
+
+ overlayNode.style.top = (divSize.height - overlaySize.height) / 2 + 'px';
+ overlayNode.style.left = (divSize.width - overlaySize.width) / 2 + 'px';
+
+ if (this.overlayTimeout) {
+ clearTimeout(this.overlayTimeout);
+ }
+ if (timeout === null) {
+ return;
+ }
+
+ const self = this;
+ self.overlayTimeout = setTimeout(() => {
+ overlayNode.style.opacity = '0';
+ self.overlayTimeout = setTimeout(() => {
+ if (overlayNode.parentNode) {
+ overlayNode.parentNode.removeChild(overlayNode);
+ }
+ self.overlayTimeout = null;
+ overlayNode.style.opacity = '0.75';
+ }, 200) as any;
+ }, timeout || 1500) as any;
+ }
+}
diff --git a/html/src/components/zmodem/index.tsx b/html/src/components/zmodem/index.tsx
new file mode 100644
index 0000000..9498863
--- /dev/null
+++ b/html/src/components/zmodem/index.tsx
@@ -0,0 +1,209 @@
+import { bind } from 'decko';
+import { h, Component } from 'preact';
+import { saveAs } from 'file-saver';
+import { IDisposable, ITerminalAddon, Terminal } from 'xterm';
+import * as Zmodem from 'zmodem.js/src/zmodem_browser';
+
+import { Modal } from '../modal';
+
+export interface FlowControl {
+ limit: number;
+ highWater: number;
+ lowWater: number;
+
+ pause: () => void;
+ resume: () => void;
+}
+
+interface Props {
+ sender: (data: ArrayLike<number>) => void;
+ control: FlowControl;
+}
+
+interface State {
+ modal: boolean;
+}
+
+export class ZmodemAddon extends Component<Props, State> implements ITerminalAddon {
+ private terminal: Terminal | undefined;
+ private keyDispose: IDisposable | undefined;
+ private sentry: Zmodem.Sentry;
+ private session: Zmodem.Session;
+
+ private written = 0;
+ private pending = 0;
+
+ constructor(props: Props) {
+ super(props);
+
+ this.zmodemInit();
+ }
+
+ render(_, { modal }: State) {
+ return (
+ <Modal show={modal}>
+ <label class="file-label">
+ <input onChange={this.sendFile} class="file-input" type="file" multiple />
+ <span class="file-cta">Choose files…</span>
+ </label>
+ </Modal>
+ );
+ }
+
+ activate(terminal: Terminal): void {
+ this.terminal = terminal;
+ }
+
+ dispose(): void {}
+
+ consume(data: ArrayBuffer) {
+ const { sentry, handleError } = this;
+ try {
+ sentry.consume(data);
+ } catch (e) {
+ handleError(e, 'consume');
+ }
+ }
+
+ @bind
+ private handleError(e: Error, reason: string) {
+ console.error(`[ttyd] zmodem ${reason}: `, e);
+ this.zmodemReset();
+ }
+
+ @bind
+ private zmodemInit() {
+ this.session = null;
+ this.sentry = new Zmodem.Sentry({
+ to_terminal: (octets: ArrayBuffer) => this.zmodemWrite(octets),
+ sender: (octets: ArrayLike<number>) => this.zmodemSend(octets),
+ on_retract: () => this.zmodemReset(),
+ on_detect: (detection: Zmodem.Detection) => this.zmodemDetect(detection),
+ });
+ }
+
+ @bind
+ private zmodemReset() {
+ this.terminal.setOption('disableStdin', false);
+
+ if (this.keyDispose) {
+ this.keyDispose.dispose();
+ this.keyDispose = null;
+ }
+ this.zmodemInit();
+
+ this.terminal.focus();
+ }
+
+ @bind
+ private zmodemWrite(data: ArrayBuffer): void {
+ const { limit, highWater, lowWater, pause, resume } = this.props.control;
+ const { terminal } = this;
+ const rawData = new Uint8Array(data);
+
+ this.written += rawData.length;
+ if (this.written > limit) {
+ terminal.write(rawData, () => {
+ this.pending = Math.max(this.pending - 1, 0);
+ if (this.pending < lowWater) {
+ resume();
+ }
+ });
+ this.pending++;
+ this.written = 0;
+ if (this.pending > highWater) {
+ pause();
+ }
+ } else {
+ terminal.write(rawData);
+ }
+ }
+
+ @bind
+ private zmodemSend(data: ArrayLike<number>): void {
+ this.props.sender(data);
+ }
+
+ @bind
+ private zmodemDetect(detection: Zmodem.Detection): void {
+ const { terminal, receiveFile, zmodemReset } = this;
+ terminal.setOption('disableStdin', true);
+
+ this.keyDispose = terminal.onKey(e => {
+ const event = e.domEvent;
+ if (event.ctrlKey && event.key === 'c') {
+ detection.deny();
+ }
+ });
+
+ this.session = detection.confirm();
+ this.session.on('session_end', zmodemReset);
+
+ if (this.session.type === 'send') {
+ this.setState({ modal: true });
+ } else {
+ receiveFile();
+ }
+ }
+
+ @bind
+ private sendFile(event: Event) {
+ this.setState({ modal: false });
+
+ const { session, writeProgress, handleError } = this;
+ const files: FileList = (event.target as HTMLInputElement).files;
+
+ Zmodem.Browser.send_files(session, files, {
+ on_progress: (_, offer: Zmodem.Offer) => writeProgress(offer),
+ })
+ .then(() => session.close())
+ .catch(e => handleError(e, 'send'));
+ }
+
+ @bind
+ private receiveFile() {
+ const { session, writeProgress, handleError } = this;
+
+ session.on('offer', (offer: Zmodem.Offer) => {
+ const fileBuffer = [];
+ offer.on('input', payload => {
+ writeProgress(offer);
+ fileBuffer.push(new Uint8Array(payload));
+ });
+ offer
+ .accept()
+ .then(() => {
+ const blob = new Blob(fileBuffer, { type: 'application/octet-stream' });
+ saveAs(blob, offer.get_details().name);
+ })
+ .catch(e => handleError(e, 'receive'));
+ });
+
+ session.start();
+ }
+
+ @bind
+ private writeProgress(offer: Zmodem.Offer) {
+ const { terminal, bytesHuman } = this;
+
+ const file = offer.get_details();
+ const name = file.name;
+ const size = file.size;
+ const offset = offer.get_offset();
+ const percent = ((100 * offset) / size).toFixed(2);
+
+ terminal.write(`${name} ${percent}% ${bytesHuman(offset, 2)}/${bytesHuman(size, 2)}\r`);
+ }
+
+ private bytesHuman(bytes: any, precision: number): string {
+ if (!/^([-+])?|(\.\d+)(\d+(\.\d+)?|(\d+\.)|Infinity)$/.test(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));
+ const value = (bytes / Math.pow(1024, Math.floor(num))).toFixed(precision);
+ return `${value} ${units[num]}`;
+ }
+}
diff --git a/html/src/favicon.png b/html/src/favicon.png
new file mode 100644
index 0000000..36b7596
--- /dev/null
+++ b/html/src/favicon.png
Binary files differ
diff --git a/html/src/index.tsx b/html/src/index.tsx
new file mode 100644
index 0000000..185872c
--- /dev/null
+++ b/html/src/index.tsx
@@ -0,0 +1,6 @@
+import 'whatwg-fetch';
+import { h, render } from 'preact';
+import { App } from './components/app';
+import './style/index.scss';
+
+render(<App />, document.body);
diff --git a/html/src/style/index.scss b/html/src/style/index.scss
new file mode 100644
index 0000000..0f9244b
--- /dev/null
+++ b/html/src/style/index.scss
@@ -0,0 +1,18 @@
+html,
+body {
+ height: 100%;
+ min-height: 100%;
+ margin: 0;
+ overflow: hidden;
+}
+
+#terminal-container {
+ width: auto;
+ height: 100%;
+ margin: 0 auto;
+ padding: 0;
+ .terminal {
+ padding: 5px;
+ height: calc(100% - 10px);
+ }
+}
diff --git a/html/src/template.html b/html/src/template.html
new file mode 100644
index 0000000..ffe2ad3
--- /dev/null
+++ b/html/src/template.html
@@ -0,0 +1,17 @@
+<!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" type="text/css" href="<%= htmlWebpackPlugin.files.css[css] %>">
+ <% } %>
+</head>
+<body>
+<% for (const js in htmlWebpackPlugin.files.js) { %>
+<script inline type="text/javascript" src="<%= htmlWebpackPlugin.files.js[js] %>"></script>
+<% } %>
+</body>
+</html>