feat(zmodem): Allow file uploads/downloads

Using zmodem (rz and sz commands from lrzsz) you can now send and receive
files.
This commit is contained in:
Søren L. Hansen 2022-03-29 13:59:22 -07:00
parent 163fd0537c
commit 782991c356
12 changed files with 663 additions and 21 deletions

72
js/package-lock.json generated
View file

@ -13,7 +13,8 @@
"xterm": "^4.12.0",
"xterm-addon-fit": "^0.5.0",
"xterm-addon-web-links": "^0.4.0",
"xterm-addon-webgl": "^0.10.0"
"xterm-addon-webgl": "^0.10.0",
"zmodem.js": "^0.1.10"
},
"devDependencies": {
"license-loader": "^0.5.0",
@ -429,6 +430,21 @@
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
"dev": true
},
"node_modules/crc-32": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.1.tgz",
"integrity": "sha512-Dn/xm/1vFFgs3nfrpEVScHoIslO9NZRITWGz/1E/St6u4xw99vfZzVkW0OSnzx2h9egej9xwMCEut6sqwokM/w==",
"dependencies": {
"exit-on-epipe": "~1.0.1",
"printj": "~1.3.1"
},
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -615,6 +631,14 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/exit-on-epipe": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
"integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@ -1217,6 +1241,17 @@
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz",
"integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ=="
},
"node_modules/printj": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/printj/-/printj-1.3.1.tgz",
"integrity": "sha512-GA3TdL8szPK4AQ2YnOe/b+Y1jUFwmmGMMK/qbY7VcE3Z7FU8JstbKiKRzO6CIiAKPhTO8m01NoQ0V5f3jc4OGg==",
"bin": {
"printj": "bin/printj.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@ -1825,6 +1860,14 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zmodem.js": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/zmodem.js/-/zmodem.js-0.1.10.tgz",
"integrity": "sha512-Z1DWngunZ/j3BmIzSJpFZVNV73iHkj89rxXX4IciJdU9ga3nZ7rJ5LkfjV/QDsKhc7bazDWTTJCLJ+iRXD82dw==",
"dependencies": {
"crc-32": "^1.1.1"
}
}
},
"dependencies": {
@ -2167,6 +2210,15 @@
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
"dev": true
},
"crc-32": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.1.tgz",
"integrity": "sha512-Dn/xm/1vFFgs3nfrpEVScHoIslO9NZRITWGz/1E/St6u4xw99vfZzVkW0OSnzx2h9egej9xwMCEut6sqwokM/w==",
"requires": {
"exit-on-epipe": "~1.0.1",
"printj": "~1.3.1"
}
},
"cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -2297,6 +2349,11 @@
"strip-final-newline": "^2.0.0"
}
},
"exit-on-epipe": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
"integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw=="
},
"fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@ -2727,6 +2784,11 @@
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz",
"integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ=="
},
"printj": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/printj/-/printj-1.3.1.tgz",
"integrity": "sha512-GA3TdL8szPK4AQ2YnOe/b+Y1jUFwmmGMMK/qbY7VcE3Z7FU8JstbKiKRzO6CIiAKPhTO8m01NoQ0V5f3jc4OGg=="
},
"process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@ -3164,6 +3226,14 @@
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
},
"zmodem.js": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/zmodem.js/-/zmodem.js-0.1.10.tgz",
"integrity": "sha512-Z1DWngunZ/j3BmIzSJpFZVNV73iHkj89rxXX4IciJdU9ga3nZ7rJ5LkfjV/QDsKhc7bazDWTTJCLJ+iRXD82dw==",
"requires": {
"crc-32": "^1.1.1"
}
}
}
}

View file

@ -15,6 +15,7 @@
"xterm": "^4.12.0",
"xterm-addon-fit": "^0.5.0",
"xterm-addon-web-links": "^0.4.0",
"xterm-addon-webgl": "^0.10.0"
"xterm-addon-webgl": "^0.10.0",
"zmodem.js": "^0.1.10"
}
}

View file

@ -1,3 +1,5 @@
import * as Zmodem from 'zmodem.js/src/zmodem_browser';
export const protocols = ["webtty"];
export const msgInputUnknown = '0';
@ -18,6 +20,7 @@ export interface Terminal {
info(): { columns: number, rows: number };
output(data: string): void;
showMessage(message: string, timeout: number): void;
getMessage(): HTMLElement;
removeMessage(): void;
setWindowTitle(title: string): void;
setPreferences(value: object): void;
@ -46,10 +49,12 @@ export interface ConnectionFactory {
export class WebTTY {
term: Terminal;
connectionFactory: ConnectionFactory;
connection: Connection;
args: string;
authToken: string;
reconnect: number;
bufSize: number;
sentry: Zmodem.Sentry;
constructor(term: Terminal, connectionFactory: ConnectionFactory, args: string, authToken: string) {
this.term = term;
@ -58,12 +63,126 @@ export class WebTTY {
this.authToken = authToken;
this.reconnect = -1;
this.bufSize = 1024;
this.sentry = new Zmodem.Sentry({
'to_terminal': (d: any) => this.term.output(d),
'on_detect': (detection: Zmodem.Detection) => this.zmodemDetect(detection),
'sender': (x: Uint8Array) => this.sendInput(x),
'on_retract': (x: any) => alert("never mind!"),
})
};
private zmodemDetect(detection: Zmodem.Detection) {
var zsession = detection.confirm();
if (zsession.type === "send") {
this.zmodemSend(zsession);
}
else {
zsession.on("offer", (xfer: any) => this.zmodemOffer(xfer));
zsession.start();
}
}
private zmodemSend(zsession: any) {
let dialog = this.getFileSendDialog();
dialog.style.display = 'block';
let selector = document.getElementById("sendFileSelector");
if (selector != null) {
selector.onchange = (event) => {
Zmodem.Browser.send_files(zsession, (event.target as HTMLInputElement).files)
.then(() => zsession.close())
.catch(e => console.log(e));
dialog.style.display = 'none';
};
}
}
private zmodemOffer(xfer: Zmodem.Offer) {
var dialog = this.getFileAcceptanceDialog();
dialog.style.display = 'block';
var filenameElem = document.getElementById("filename");
if (filenameElem != null) {
filenameElem.textContent = xfer.get_details().name;
}
var sizeElem = document.getElementById("filesize");
if (sizeElem != null) {
sizeElem.textContent = xfer.get_details().size;
}
var skipLink = document.getElementById("skipTransfer");
if (skipLink != null) {
skipLink.onclick = (ev) => {
xfer.skip();
dialog.style.display = 'none';
}
}
var acceptLink = document.getElementById("acceptTransfer");
if (acceptLink != null) {
acceptLink.onclick = (ev) => {
dialog.style.display = 'none';
xfer.accept().then((payloads: any) => {
//Now you need some mechanism to save the file.
//An example of how you can do this in a browser:
Zmodem.Browser.save_to_disk(
payloads,
xfer.get_details().name
);
});
}
}
}
private sendInput(input: string | Uint8Array) {
let effectiveBufferSize = this.bufSize - 1;
let dataString: string
if (Array.isArray(input)) {
dataString = String.fromCharCode.apply(null, input);
} else {
dataString = (input as string);
}
// Account for base64 encoding
let maxChunkSize = Math.floor(effectiveBufferSize / 4)*3;
for (let i = 0; i < Math.ceil(dataString.length / maxChunkSize); i++) {
let inputChunk = dataString.substring(i * effectiveBufferSize, Math.min((i + 1) * effectiveBufferSize, dataString.length))
this.connection.send(msgInput + btoa(inputChunk));
}
}
getFileAcceptanceDialog(): HTMLElement {
let dialog = document.getElementById("acceptFileDialog");
if (dialog == null) {
dialog = document.createElement("div");
dialog.id = 'acceptFileDialog';
dialog.className = 'fileDialog';
dialog.innerHTML = '<p>Incoming file transfer: <tt id="filename"></tt> (<span id="filesize"></span> bytes)</p><a id="acceptTransfer" href="#">Accept</a> <a id="skipTransfer" href="#">Decline</a>';
document.body.appendChild(dialog);
}
return dialog;
}
getFileSendDialog(): HTMLElement {
let dialog = document.getElementById("sendFileDialog");
if (dialog == null) {
dialog = document.createElement("div");
dialog.id = 'sendFileDialog';
dialog.className = 'fileDialog';
dialog.innerHTML = '<p>Remote ready to receive files. <input id="sendFileSelector" class="file-input" type="file" multiple="" /></p>';
document.body.appendChild(dialog);
}
return dialog;
}
open() {
let connection = this.connectionFactory.create();
let pingTimer: NodeJS.Timeout;
let reconnectTimeout: NodeJS.Timeout;
this.connection = connection;
const setup = () => {
connection.onOpen(() => {
@ -93,14 +212,7 @@ export class WebTTY {
this.term.onInput(
(input: string) => {
// Leave room for message type id
let effectiveBufferSize = this.bufSize - 1;
// Split input into buffer sized chunks
for (let i = 0; i < Math.ceil(input.length/effectiveBufferSize); i++) {
let inputChunk = input.substring(i*effectiveBufferSize, Math.min((i+1)*effectiveBufferSize, input.length))
connection.send(msgInput + inputChunk);
}
this.sendInput(input);
}
);
@ -114,7 +226,7 @@ export class WebTTY {
const payload = data.slice(1);
switch (data[0]) {
case msgOutput:
this.term.output(atob(payload));
this.sentry.consume(Uint8Array.from(atob(payload), c => c.charCodeAt(0)));
break;
case msgPong:
break;

View file

@ -43,11 +43,19 @@ export class Xterm {
};
output(data: string) {
this.term.write(Uint8Array.from(data, c => c.charCodeAt(0)));
this.term.write(data);
};
getMessage(): HTMLElement {
return this.message;
}
showMessage(message: string, timeout: number) {
this.message.textContent = message;
this.message.innerHTML = message;
this.showMessageElem(timeout);
}
showMessageElem(timeout: number) {
this.elem.appendChild(this.message);
if (this.messageTimer) {