From 6a40f326d59c0eef26f5fc3bbc61be094a4376aa Mon Sep 17 00:00:00 2001 From: Shuanglei Tao Date: Sun, 23 Jun 2019 17:19:29 +0800 Subject: [PATCH] html: add zmodem support back --- html/package.json | 3 +- html/src/components/modal/index.tsx | 27 +++++ html/src/components/modal/modal.scss | 81 +++++++++++++ html/src/components/terminal/index.tsx | 153 +++++++++++++++++++++++-- html/yarn.lock | 25 ++++ src/index.html | 2 +- 6 files changed, 278 insertions(+), 13 deletions(-) create mode 100644 html/src/components/modal/index.tsx create mode 100644 html/src/components/modal/modal.scss diff --git a/html/package.json b/html/package.json index 77e6374..9432206 100644 --- a/html/package.json +++ b/html/package.json @@ -62,6 +62,7 @@ "preact": "^8.4.2", "xterm": "^3.14.2", "xterm-addon-fit": "^0.1.0-beta1", - "xterm-addon-web-links": "^0.1.0-beta10" + "xterm-addon-web-links": "^0.1.0-beta10", + "zmodem.js": "^0.1.9" } } diff --git a/html/src/components/modal/index.tsx b/html/src/components/modal/index.tsx new file mode 100644 index 0000000..b0b30f3 --- /dev/null +++ b/html/src/components/modal/index.tsx @@ -0,0 +1,27 @@ +import { Component, ComponentChildren, h } from 'preact'; + +import './modal.scss'; + +interface Props { + show: boolean; + children: ComponentChildren; +} + +export class Modal extends Component { + constructor(props) { + super(props); + } + + render({ show, children }: Props) { + return ( + show && ( +
+
+
+
{children}
+
+
+ ) + ); + } +} diff --git a/html/src/components/modal/modal.scss b/html/src/components/modal/modal.scss new file mode 100644 index 0000000..9dc17b2 --- /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: 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; + } +} diff --git a/html/src/components/terminal/index.tsx b/html/src/components/terminal/index.tsx index 00abb6e..759215d 100644 --- a/html/src/components/terminal/index.tsx +++ b/html/src/components/terminal/index.tsx @@ -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 { +interface State { + modal: boolean; +} + +export class Xterm extends Component { private textEncoder: TextEncoder; private textDecoder: TextDecoder; private container: HTMLElement; @@ -42,6 +49,8 @@ export class Xterm extends Component { 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 { 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 { window.removeEventListener('beforeunload', this.onWindowUnload); } - render({ id }: Props) { - return
(this.container = c)} />; + render({ id }: Props, { modal }: State) { + return ( +
(this.container = c)}> + + + +
+ ); + } + + @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 { @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 { 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]}`; + } } diff --git a/html/yarn.lock b/html/yarn.lock index c5cd108..69b8c8b 100644 --- a/html/yarn.lock +++ b/html/yarn.lock @@ -1601,6 +1601,14 @@ cosmiconfig@^5.0.0, cosmiconfig@^5.2.0: js-yaml "^3.13.1" parse-json "^4.0.0" +crc-32@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.0.tgz#cb2db6e29b88508e32d9dd0ec1693e7b41a18208" + integrity sha512-1uBwHxF+Y/4yF5G48fwnKq6QsIXheor3ZLPT80yGBV1oEUwpPojlEhQbWKVw1VwcTQyMGHK1/XMmTjmlsmTTGA== + dependencies: + exit-on-epipe "~1.0.1" + printj "~1.1.0" + create-ecdh@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.3.tgz#c9111b6f33045c4697f144787f9254cdc77c45ff" @@ -2519,6 +2527,11 @@ execa@^1.0.0: signal-exit "^3.0.0" strip-eof "^1.0.0" +exit-on-epipe@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz#0bdd92e87d5285d267daa8171d0eb06159689692" + integrity sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw== + expand-brackets@^2.1.4: version "2.1.4" resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" @@ -6643,6 +6656,11 @@ pretty-hrtime@^1.0.0: resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE= +printj@~1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/printj/-/printj-1.1.2.tgz#d90deb2975a8b9f600fb3a1c94e3f4c53c78a222" + integrity sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ== + process-nextick-args@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3" @@ -9164,3 +9182,10 @@ yup@^0.27.0: property-expr "^1.5.0" synchronous-promise "^2.0.6" toposort "^2.0.2" + +zmodem.js@^0.1.9: + version "0.1.9" + resolved "https://registry.yarnpkg.com/zmodem.js/-/zmodem.js-0.1.9.tgz#8dda36d45091bbdf263819f961d3c1a20223daf7" + integrity sha512-xixLjW1eML0uiWULsXDInyfwNW9mqESzz7ra+2MWHNG2F5JINEkE5vzF5MigpPcLvrYoHdnehPcJwQZlDph3hQ== + dependencies: + crc-32 "^1.1.1" diff --git a/src/index.html b/src/index.html index 36263cd..3b6cbe1 100644 --- a/src/index.html +++ b/src/index.html @@ -1 +1 @@ -ttyd - Terminal \ No newline at end of file +ttyd - Terminal \ No newline at end of file -- 2.43.4