mirror of
https://github.com/tsl0922/ttyd.git
synced 2026-01-01 16:34:23 +01:00
html: add zmodem support back
This commit is contained in:
27
html/src/components/modal/index.tsx
Normal file
27
html/src/components/modal/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Component, ComponentChildren, h } from 'preact';
|
||||
|
||||
import './modal.scss';
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
children: ComponentChildren;
|
||||
}
|
||||
|
||||
export class Modal extends Component<Props> {
|
||||
constructor(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>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
81
html/src/components/modal/modal.scss
Normal file
81
html/src/components/modal/modal.scss
Normal file
@@ -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: hsla(0, 0%, 4%, 0.86);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
margin: 0 20px;
|
||||
max-height: calc(100vh - 160px);
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
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 {
|
||||
background-color: #f5f5f5;
|
||||
color: #4a4a4a;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import { Component, h } from 'preact';
|
||||
import { ITerminalOptions, Terminal } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { WebLinksAddon } from 'xterm-addon-web-links';
|
||||
import * as Zmodem from 'zmodem.js/src/zmodem_browser';
|
||||
|
||||
import { OverlayAddon } from './overlay';
|
||||
import { Modal } from '../modal';
|
||||
|
||||
import 'xterm/dist/xterm.css';
|
||||
|
||||
@@ -31,7 +34,11 @@ interface Props {
|
||||
options: ITerminalOptions;
|
||||
}
|
||||
|
||||
export class Xterm extends Component<Props> {
|
||||
interface State {
|
||||
modal: boolean;
|
||||
}
|
||||
|
||||
export class Xterm extends Component<Props, State> {
|
||||
private textEncoder: TextEncoder;
|
||||
private textDecoder: TextDecoder;
|
||||
private container: HTMLElement;
|
||||
@@ -42,6 +49,8 @@ export class Xterm extends Component<Props> {
|
||||
private title: string;
|
||||
private autoReconnect: number;
|
||||
private resizeTimeout: number;
|
||||
private sentry: Zmodem.Sentry;
|
||||
private session: Zmodem.Session;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -50,6 +59,12 @@ export class Xterm extends Component<Props> {
|
||||
this.textDecoder = new TextDecoder();
|
||||
this.fitAddon = new FitAddon();
|
||||
this.overlayAddon = new OverlayAddon();
|
||||
this.sentry = new Zmodem.Sentry({
|
||||
to_terminal: (octets: ArrayBuffer) => this.zmodemWrite(octets),
|
||||
sender: (octets: number[]) => this.zmodemSend(octets),
|
||||
on_retract: () => {},
|
||||
on_detect: (detection: any) => this.zmodemDetect(detection),
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -64,8 +79,107 @@ export class Xterm extends Component<Props> {
|
||||
window.removeEventListener('beforeunload', this.onWindowUnload);
|
||||
}
|
||||
|
||||
render({ id }: Props) {
|
||||
return <div id={id} ref={c => (this.container = c)} />;
|
||||
render({ id }: Props, { modal }: State) {
|
||||
return (
|
||||
<div id={id} ref={c => (this.container = c)}>
|
||||
<Modal show={modal}>
|
||||
<label class="file-label">
|
||||
<input
|
||||
onChange={this.sendFile}
|
||||
class="file-input"
|
||||
type="file"
|
||||
multiple
|
||||
/>
|
||||
<span class="file-cta">
|
||||
<strong>Choose files…</strong>
|
||||
</span>
|
||||
</label>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@bind
|
||||
private zmodemWrite(data: ArrayBuffer): void {
|
||||
const { terminal } = this;
|
||||
terminal.writeUtf8(new Uint8Array(data));
|
||||
}
|
||||
|
||||
@bind
|
||||
private zmodemSend(data: number[]): void {
|
||||
const { socket } = this;
|
||||
const buffer = new Uint8Array(data.length + 1);
|
||||
buffer[0] = Command.INPUT.charCodeAt(0);
|
||||
buffer.set(data, 1);
|
||||
socket.send(buffer);
|
||||
}
|
||||
|
||||
@bind
|
||||
private zmodemDetect(detection: any): void {
|
||||
const { terminal, receiveFile } = this;
|
||||
|
||||
terminal.setOption('disableStdin', true);
|
||||
this.session = detection.confirm();
|
||||
if (this.session.type === 'send') {
|
||||
this.setState({ modal: true });
|
||||
} else {
|
||||
receiveFile();
|
||||
}
|
||||
}
|
||||
|
||||
@bind
|
||||
private sendFile(event: Event) {
|
||||
this.setState({ modal: false });
|
||||
|
||||
const { terminal, session, writeProgress } = this;
|
||||
const files: FileList = (event.target as HTMLInputElement).files;
|
||||
if (files.length === 0) {
|
||||
session.close();
|
||||
terminal.setOption('disableStdin', false);
|
||||
return;
|
||||
}
|
||||
|
||||
Zmodem.Browser.send_files(session, files, {
|
||||
on_progress: (_, xfer) => writeProgress(xfer),
|
||||
on_file_complete: () => {},
|
||||
}).then(() => {
|
||||
session.close();
|
||||
terminal.setOption('disableStdin', false);
|
||||
});
|
||||
}
|
||||
|
||||
@bind
|
||||
private receiveFile() {
|
||||
const { terminal, session, writeProgress } = this;
|
||||
|
||||
session.on('offer', (xfer: any) => {
|
||||
const fileBuffer = [];
|
||||
xfer.on('input', payload => {
|
||||
writeProgress(xfer);
|
||||
fileBuffer.push(new Uint8Array(payload));
|
||||
});
|
||||
xfer.accept().then(() => {
|
||||
Zmodem.Browser.save_to_disk(fileBuffer, xfer.get_details().name);
|
||||
terminal.setOption('disableStdin', false);
|
||||
});
|
||||
});
|
||||
|
||||
session.start();
|
||||
}
|
||||
|
||||
@bind
|
||||
private writeProgress(xfer: any) {
|
||||
const { terminal, bytesHuman } = this;
|
||||
|
||||
const file = xfer.get_details();
|
||||
const name = file.name;
|
||||
const size = file.size;
|
||||
const offset = xfer.get_offset();
|
||||
const percent = ((100 * offset) / size).toFixed(2);
|
||||
|
||||
terminal.write(
|
||||
`${name} ${percent}% ${bytesHuman(offset, 2)}/${bytesHuman(size, 2)}\r`
|
||||
);
|
||||
}
|
||||
|
||||
@bind
|
||||
@@ -154,29 +268,34 @@ export class Xterm extends Component<Props> {
|
||||
|
||||
@bind
|
||||
private onSocketData(event: MessageEvent) {
|
||||
const { terminal, textDecoder } = this;
|
||||
|
||||
const rawData = new Uint8Array(event.data);
|
||||
const cmd = String.fromCharCode(rawData[0]);
|
||||
const { terminal, textDecoder, socket, openTerminal } = 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:
|
||||
terminal.writeUtf8(data);
|
||||
try {
|
||||
this.sentry.consume(data);
|
||||
} catch (e) {
|
||||
console.log(`[ttyd] zmodem consume: `, e);
|
||||
socket.close();
|
||||
setTimeout(() => openTerminal(), 500);
|
||||
}
|
||||
break;
|
||||
case Command.SET_WINDOW_TITLE:
|
||||
this.title = textDecoder.decode(data.buffer);
|
||||
this.title = textDecoder.decode(data);
|
||||
document.title = this.title;
|
||||
break;
|
||||
case Command.SET_PREFERENCES:
|
||||
const preferences = JSON.parse(textDecoder.decode(data.buffer));
|
||||
const preferences = JSON.parse(textDecoder.decode(data));
|
||||
Object.keys(preferences).forEach(key => {
|
||||
console.log(`[ttyd] setting ${key}: ${preferences[key]}`);
|
||||
terminal.setOption(key, preferences[key]);
|
||||
});
|
||||
break;
|
||||
case Command.SET_RECONNECT:
|
||||
this.autoReconnect = Number(textDecoder.decode(data.buffer));
|
||||
this.autoReconnect = Number(textDecoder.decode(data));
|
||||
console.log(`[ttyd] enabling reconnect: ${this.autoReconnect} seconds`);
|
||||
break;
|
||||
default:
|
||||
@@ -204,4 +323,16 @@ export class Xterm extends Component<Props> {
|
||||
socket.send(textEncoder.encode(Command.INPUT + data));
|
||||
}
|
||||
}
|
||||
|
||||
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]}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user