mirror of
https://github.com/yudai/gotty.git
synced 2026-02-15 12:48:07 +01:00
Use bootstrap components for up- and downloads
This commit is contained in:
parent
736ad294e7
commit
7f05f2fe17
22 changed files with 6840 additions and 1717 deletions
6268
js/package-lock.json
generated
6268
js/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -3,14 +3,27 @@
|
|||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bootstrap": "^5.1.9",
|
||||
"compression-webpack-plugin": "^9.2.0",
|
||||
"license-loader": "^0.5.0",
|
||||
"license-webpack-plugin": "^4.0.2",
|
||||
"purgecss": "^4.1.3",
|
||||
"sass": "^1.50.0",
|
||||
"sass-loader": "^12.6.0",
|
||||
"terser-webpack-plugin": "^5.3.1",
|
||||
"ts-loader": "^8.3.0",
|
||||
"typescript": "^4.3.2",
|
||||
"webpack": "^5.38.1",
|
||||
"webpack": "^5.72.0",
|
||||
"webpack-cli": "^4.7.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.5",
|
||||
"bootstrap": "^5.1.3",
|
||||
"css-loader": "^5.2.6",
|
||||
"debounce": "^1.2.1",
|
||||
"node-sass": "^7.0.1",
|
||||
"preact": "^10.7.1",
|
||||
"react-bootstrap": "^2.2.3",
|
||||
"style-loader": "^2.0.0",
|
||||
"xterm": "^4.12.0",
|
||||
"xterm-addon-fit": "^0.5.0",
|
||||
|
|
|
|||
64
js/src/MyModal.tsx
Normal file
64
js/src/MyModal.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { createRef, Component, ComponentChildren } from "preact";
|
||||
import { Modal } from "bootstrap";
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
//import './bootstrap.scss';
|
||||
|
||||
interface ModalProps {
|
||||
children: ComponentChildren;
|
||||
buttons?: ComponentChildren;
|
||||
title: string;
|
||||
dismissHandler?: (hideModal?: () => void) => void;
|
||||
}
|
||||
|
||||
export class MyModal extends Component<ModalProps, {}> {
|
||||
ref = createRef<HTMLDivElement>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
Modal.getOrCreateInstance(this.ref.current!).show();
|
||||
this.ref.current?.addEventListener('hide.bs.modal', () => { this.props.dismissHandler && this.props.dismissHandler(); });
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.hide()
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
Modal.getOrCreateInstance(this.ref.current!).hide();
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div class="modal fade" ref={this.ref} tabIndex={-1} aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{this.props.title}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{this.props.children}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
{this.props.buttons}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
interface ButtonProps {
|
||||
priority: "primary" | "secondary" | "danger"
|
||||
clickHandler?: () => void
|
||||
children: ComponentChildren
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Button(props:ButtonProps) {
|
||||
let classes: string = "btn btn-" + props.priority
|
||||
|
||||
return <button type="button" disabled={props.disabled} class={classes} onClick={ () => { props.clickHandler ? props.clickHandler() : null; }}>{ props.children}</button>
|
||||
}
|
||||
27
js/src/bootstrap.scss
vendored
Normal file
27
js/src/bootstrap.scss
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/*!
|
||||
* Bootstrap v5.1.3 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2021 The Bootstrap Authors
|
||||
* Copyright 2011-2021 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/
|
||||
|
||||
// scss-docs-start import-stack
|
||||
// Configuration
|
||||
@import "functions";
|
||||
@import "variables";
|
||||
@import "mixins";
|
||||
@import "utilities";
|
||||
|
||||
// Layout & components
|
||||
@import "root";
|
||||
@import "reboot";
|
||||
@import "type";
|
||||
@import "images";
|
||||
@import "containers";
|
||||
@import "grid";
|
||||
@import "buttons";
|
||||
@import "transitions";
|
||||
@import "progress";
|
||||
@import "close";
|
||||
@import "modal";
|
||||
@import "spinners";
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Xterm } from "./xterm";
|
||||
import { OurXterm } from "./xterm";
|
||||
import { Terminal, WebTTY, protocols } from "./webtty";
|
||||
import { ConnectionFactory } from "./websocket";
|
||||
|
||||
|
|
@ -10,7 +10,7 @@ const elem = document.getElementById("terminal")
|
|||
|
||||
if (elem !== null) {
|
||||
var term: Terminal;
|
||||
term = new Xterm(elem);
|
||||
term = new OurXterm(elem);
|
||||
|
||||
const httpsEnabled = window.location.protocol == "https:";
|
||||
const url = (httpsEnabled ? 'wss://' : 'ws://') + window.location.host + window.location.pathname + 'ws';
|
||||
|
|
@ -19,6 +19,9 @@ if (elem !== null) {
|
|||
const wt = new WebTTY(term, factory, args, gotty_auth_token);
|
||||
const closer = wt.open();
|
||||
|
||||
// According to https://developer.mozilla.org/en-US/docs/Web/API/Window/unload_event
|
||||
// this event is unreliable and in some cases (Firefox is mentioned), having an
|
||||
// "unload" event handler can have unwanted side effects. Consider commenting it out.
|
||||
window.addEventListener("unload", () => {
|
||||
closer();
|
||||
term.close();
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ export class ConnectionFactory {
|
|||
export class Connection {
|
||||
bare: WebSocket;
|
||||
|
||||
|
||||
constructor(url: string, protocols: string[]) {
|
||||
this.bare = new WebSocket(url, protocols);
|
||||
}
|
||||
|
|
|
|||
277
js/src/webtty.ts
277
js/src/webtty.ts
|
|
@ -1,277 +0,0 @@
|
|||
import * as Zmodem from 'zmodem.js/src/zmodem_browser';
|
||||
|
||||
export const protocols = ["webtty"];
|
||||
|
||||
export const msgInputUnknown = '0';
|
||||
export const msgInput = '1';
|
||||
export const msgPing = '2';
|
||||
export const msgResizeTerminal = '3';
|
||||
|
||||
export const msgUnknownOutput = '0';
|
||||
export const msgOutput = '1';
|
||||
export const msgPong = '2';
|
||||
export const msgSetWindowTitle = '3';
|
||||
export const msgSetPreferences = '4';
|
||||
export const msgSetReconnect = '5';
|
||||
export const msgSetBufferSize = '6';
|
||||
|
||||
|
||||
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;
|
||||
onInput(callback: (input: string) => void): void;
|
||||
onResize(callback: (colmuns: number, rows: number) => void): void;
|
||||
reset(): void;
|
||||
deactivate(): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
open(): void;
|
||||
close(): void;
|
||||
send(data: string): void;
|
||||
isOpen(): boolean;
|
||||
onOpen(callback: () => void): void;
|
||||
onReceive(callback: (data: string) => void): void;
|
||||
onClose(callback: () => void): void;
|
||||
}
|
||||
|
||||
export interface ConnectionFactory {
|
||||
create(): Connection;
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
this.connectionFactory = connectionFactory;
|
||||
this.args = args;
|
||||
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 * maxChunkSize, Math.min((i + 1) * maxChunkSize, 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(() => {
|
||||
const termInfo = this.term.info();
|
||||
|
||||
connection.send(JSON.stringify(
|
||||
{
|
||||
Arguments: this.args,
|
||||
AuthToken: this.authToken,
|
||||
}
|
||||
));
|
||||
|
||||
|
||||
const resizeHandler = (colmuns: number, rows: number) => {
|
||||
connection.send(
|
||||
msgResizeTerminal + JSON.stringify(
|
||||
{
|
||||
columns: colmuns,
|
||||
rows: rows
|
||||
}
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
this.term.onResize(resizeHandler);
|
||||
resizeHandler(termInfo.columns, termInfo.rows);
|
||||
|
||||
// Set encoding to base64 (TODO: Fix this up)
|
||||
connection.send("4base64");
|
||||
|
||||
this.term.onInput(
|
||||
(input: string) => {
|
||||
this.sendInput(input);
|
||||
}
|
||||
);
|
||||
|
||||
pingTimer = setInterval(() => {
|
||||
connection.send(msgPing)
|
||||
}, 30 * 1000);
|
||||
|
||||
});
|
||||
|
||||
connection.onReceive((data) => {
|
||||
const payload = data.slice(1);
|
||||
switch (data[0]) {
|
||||
case msgOutput:
|
||||
this.sentry.consume(Uint8Array.from(atob(payload), c => c.charCodeAt(0)));
|
||||
break;
|
||||
case msgPong:
|
||||
break;
|
||||
case msgSetWindowTitle:
|
||||
this.term.setWindowTitle(payload);
|
||||
break;
|
||||
case msgSetPreferences:
|
||||
const preferences = JSON.parse(payload);
|
||||
this.term.setPreferences(preferences);
|
||||
break;
|
||||
case msgSetReconnect:
|
||||
const autoReconnect = JSON.parse(payload);
|
||||
console.log("Enabling reconnect: " + autoReconnect + " seconds")
|
||||
this.reconnect = autoReconnect;
|
||||
break;
|
||||
case msgSetBufferSize:
|
||||
const bufSize = JSON.parse(payload);
|
||||
this.bufSize = bufSize;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
connection.onClose(() => {
|
||||
clearInterval(pingTimer);
|
||||
this.term.deactivate();
|
||||
this.term.showMessage("Connection Closed", 0);
|
||||
if (this.reconnect > 0) {
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
connection = this.connectionFactory.create();
|
||||
this.term.reset();
|
||||
setup();
|
||||
}, this.reconnect * 1000);
|
||||
}
|
||||
});
|
||||
|
||||
connection.open();
|
||||
}
|
||||
|
||||
setup();
|
||||
return () => {
|
||||
clearTimeout(reconnectTimeout);
|
||||
connection.close();
|
||||
}
|
||||
};
|
||||
};
|
||||
270
js/src/webtty.tsx
Normal file
270
js/src/webtty.tsx
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
export const protocols = ["webtty"];
|
||||
|
||||
export const msgInputUnknown = '0';
|
||||
export const msgInput = '1';
|
||||
export const msgPing = '2';
|
||||
export const msgResizeTerminal = '3';
|
||||
export const msgSetEncoding = '4';
|
||||
|
||||
export const msgUnknownOutput = '0';
|
||||
export const msgOutput = '1';
|
||||
export const msgPong = '2';
|
||||
export const msgSetWindowTitle = '3';
|
||||
export const msgSetPreferences = '4';
|
||||
export const msgSetReconnect = '5';
|
||||
export const msgSetBufferSize = '6';
|
||||
|
||||
|
||||
export interface Terminal {
|
||||
/*
|
||||
* Get dimensions of the terminal
|
||||
*/
|
||||
info(): { columns: number, rows: number };
|
||||
|
||||
/*
|
||||
* Process output from the server side
|
||||
*/
|
||||
output(data: Uint8Array): void;
|
||||
|
||||
/*
|
||||
* Display a message overlay on the terminal
|
||||
*/
|
||||
showMessage(message: string, timeout: number): void;
|
||||
|
||||
// Don't think we need this anymore
|
||||
// getMessage(): HTMLElement;
|
||||
|
||||
/*
|
||||
* Remove message shown by shoMessage. You only need to call
|
||||
* this if you want to dismiss it sooner than the timeout.
|
||||
*/
|
||||
removeMessage(): void;
|
||||
|
||||
|
||||
/*
|
||||
* Set window title
|
||||
*/
|
||||
setWindowTitle(title: string): void;
|
||||
|
||||
/*
|
||||
* Set preferences. TODO: Add typings
|
||||
*/
|
||||
setPreferences(value: object): void;
|
||||
|
||||
|
||||
/*
|
||||
* Sets an input (e.g. user types something) handler
|
||||
*/
|
||||
onInput(callback: (input: string) => void): void;
|
||||
|
||||
/*
|
||||
* Sets a resize handler
|
||||
*/
|
||||
onResize(callback: (colmuns: number, rows: number) => void): void;
|
||||
|
||||
reset(): void;
|
||||
deactivate(): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
open(): void;
|
||||
close(): void;
|
||||
|
||||
/*
|
||||
* This takes fucking strings??
|
||||
*/
|
||||
send(s: string): void;
|
||||
|
||||
isOpen(): boolean;
|
||||
onOpen(callback: () => void): void;
|
||||
onReceive(callback: (data: string) => void): void;
|
||||
onClose(callback: () => void): void;
|
||||
}
|
||||
|
||||
export interface ConnectionFactory {
|
||||
create(): Connection;
|
||||
}
|
||||
|
||||
export class WebTTY {
|
||||
/*
|
||||
* A terminal instance that implements the Terminal interface.
|
||||
* This made a lot of sense when we had both HTerm and xterm, but
|
||||
* now I wonder if the abstraction makes sense. Keeping it for now,
|
||||
* though.
|
||||
*/
|
||||
term: Terminal;
|
||||
|
||||
/*
|
||||
* ConnectionFactory and connection instance. We pass the factory
|
||||
* in instead of just a connection so that we can reconnect.
|
||||
*/
|
||||
connectionFactory: ConnectionFactory;
|
||||
connection: Connection;
|
||||
|
||||
/*
|
||||
* Arguments passed in by the user. We forward them to the backend
|
||||
* where they are appended to the command line.
|
||||
*/
|
||||
args: string;
|
||||
|
||||
/*
|
||||
* An authentication token. The client gets this from `/auth_token.js`.
|
||||
*/
|
||||
authToken: string;
|
||||
|
||||
/*
|
||||
* If connection is dropped, reconnect after `reconnect` seconds.
|
||||
* -1 means do not reconnect.
|
||||
*/
|
||||
reconnect: number;
|
||||
|
||||
/*
|
||||
* The server's buffer size. If a single message exceeds this size, it will
|
||||
* be truncated on the server, so we track it here so that we can split messages
|
||||
* into chunks small enough that we don't hurt the server's feelings.
|
||||
*/
|
||||
bufSize: number;
|
||||
|
||||
constructor(term: Terminal, connectionFactory: ConnectionFactory, args: string, authToken: string) {
|
||||
this.term = term;
|
||||
this.connectionFactory = connectionFactory;
|
||||
this.args = args;
|
||||
this.authToken = authToken;
|
||||
this.reconnect = -1;
|
||||
this.bufSize = 1024;
|
||||
};
|
||||
|
||||
open() {
|
||||
let connection = this.connectionFactory.create();
|
||||
let pingTimer: NodeJS.Timeout;
|
||||
let reconnectTimeout: NodeJS.Timeout;
|
||||
this.connection = connection;
|
||||
|
||||
const setup = () => {
|
||||
connection.onOpen(() => {
|
||||
const termInfo = this.term.info();
|
||||
|
||||
this.initializeConnection(this.args, this.authToken);
|
||||
|
||||
this.term.onResize((columns: number, rows: number) => {
|
||||
this.sendResizeTerminal(columns, rows);
|
||||
});
|
||||
|
||||
this.sendResizeTerminal(termInfo.columns, termInfo.rows);
|
||||
|
||||
this.sendSetEncoding("base64");
|
||||
|
||||
this.term.onInput(
|
||||
(input: string | Uint8Array) => {
|
||||
this.sendInput(input);
|
||||
}
|
||||
);
|
||||
|
||||
pingTimer = setInterval(() => {
|
||||
this.sendPing()
|
||||
}, 30 * 1000);
|
||||
});
|
||||
|
||||
connection.onReceive((data) => {
|
||||
const payload = data.slice(1);
|
||||
switch (data[0]) {
|
||||
case msgOutput:
|
||||
this.term.output(Uint8Array.from(atob(payload), c => c.charCodeAt(0)));
|
||||
break;
|
||||
case msgPong:
|
||||
break;
|
||||
case msgSetWindowTitle:
|
||||
this.term.setWindowTitle(payload);
|
||||
break;
|
||||
case msgSetPreferences:
|
||||
const preferences = JSON.parse(payload);
|
||||
this.term.setPreferences(preferences);
|
||||
break;
|
||||
case msgSetReconnect:
|
||||
const autoReconnect = JSON.parse(payload);
|
||||
console.log("Enabling reconnect: " + autoReconnect + " seconds")
|
||||
this.reconnect = autoReconnect;
|
||||
break;
|
||||
case msgSetBufferSize:
|
||||
const bufSize = JSON.parse(payload);
|
||||
this.bufSize = bufSize;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
connection.onClose(() => {
|
||||
clearInterval(pingTimer);
|
||||
this.term.deactivate();
|
||||
this.term.showMessage("Connection Closed", 0);
|
||||
if (this.reconnect > 0) {
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
connection = this.connectionFactory.create();
|
||||
this.term.reset();
|
||||
setup();
|
||||
}, this.reconnect * 1000);
|
||||
}
|
||||
});
|
||||
|
||||
connection.open();
|
||||
}
|
||||
|
||||
setup();
|
||||
return () => {
|
||||
clearTimeout(reconnectTimeout);
|
||||
connection.close();
|
||||
}
|
||||
};
|
||||
|
||||
private initializeConnection(args, authToken) {
|
||||
this.connection.send(JSON.stringify(
|
||||
{
|
||||
Arguments: args,
|
||||
AuthToken: authToken,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/*
|
||||
* sendInput sends data to the server. It accepts strings or Uint8Arrays.
|
||||
* strings will be encoded as UTF-8. Uint8Arrays are passed along as-is.
|
||||
*/
|
||||
private sendInput(input: string | Uint8Array) {
|
||||
let effectiveBufferSize = this.bufSize - 1;
|
||||
let dataString: string;
|
||||
|
||||
if (typeof input === "string") {
|
||||
dataString = input;
|
||||
} else {
|
||||
dataString = String.fromCharCode(...input)
|
||||
}
|
||||
|
||||
// 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 * maxChunkSize, Math.min((i + 1) * maxChunkSize, dataString.length))
|
||||
this.connection.send(msgInput + btoa(inputChunk));
|
||||
}
|
||||
}
|
||||
|
||||
private sendPing(): void {
|
||||
this.connection.send(msgPing);
|
||||
}
|
||||
|
||||
private sendResizeTerminal(colmuns: number, rows: number) {
|
||||
this.connection.send(
|
||||
msgResizeTerminal + JSON.stringify(
|
||||
{
|
||||
columns: colmuns,
|
||||
rows: rows
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private sendSetEncoding(encoding: "base64" | "null") {
|
||||
this.connection.send(msgSetEncoding + encoding)
|
||||
}
|
||||
|
||||
};
|
||||
|
|
@ -2,25 +2,40 @@ import { Terminal, IDisposable } from "xterm";
|
|||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { WebLinksAddon } from 'xterm-addon-web-links';
|
||||
import { WebglAddon } from 'xterm-addon-webgl';
|
||||
import { ZModemAddon } from "./zmodem";
|
||||
|
||||
export class Xterm {
|
||||
export class OurXterm {
|
||||
// The HTMLElement that contains our terminal
|
||||
elem: HTMLElement;
|
||||
|
||||
// The xtermjs.XTerm
|
||||
term: Terminal;
|
||||
|
||||
resizeListener: () => void;
|
||||
|
||||
message: HTMLElement;
|
||||
messageTimeout: number;
|
||||
messageTimer: NodeJS.Timeout;
|
||||
|
||||
onResizeHandler: IDisposable;
|
||||
onDataHandler: IDisposable;
|
||||
|
||||
fitAddOn: FitAddon;
|
||||
zmodemAddon: ZModemAddon;
|
||||
toServer: (data: string | Uint8Array) => void;
|
||||
encoder: TextEncoder
|
||||
|
||||
constructor(elem: HTMLElement) {
|
||||
this.elem = elem;
|
||||
this.term = new Terminal();
|
||||
this.fitAddOn = new FitAddon();
|
||||
this.zmodemAddon = new ZModemAddon({
|
||||
toTerminal: (x: Uint8Array) => this.term.write(x),
|
||||
toServer: (x: Uint8Array) => this.sendInput(x)
|
||||
});
|
||||
this.term.loadAddon(new WebLinksAddon());
|
||||
this.term.loadAddon(this.fitAddOn);
|
||||
this.term.loadAddon(this.zmodemAddon);
|
||||
|
||||
this.message = elem.ownerDocument.createElement("div");
|
||||
this.message.className = "xterm-overlay";
|
||||
|
|
@ -35,6 +50,7 @@ export class Xterm {
|
|||
this.term.open(elem);
|
||||
this.term.focus();
|
||||
this.resizeListener();
|
||||
|
||||
window.addEventListener("resize", () => { this.resizeListener(); });
|
||||
};
|
||||
|
||||
|
|
@ -42,8 +58,9 @@ export class Xterm {
|
|||
return { columns: this.term.cols, rows: this.term.rows };
|
||||
};
|
||||
|
||||
output(data: string) {
|
||||
this.term.write(data);
|
||||
// This gets called from the Websocket's onReceive handler
|
||||
output(data: Uint8Array) {
|
||||
this.zmodemAddon.consume(data);
|
||||
};
|
||||
|
||||
getMessage(): HTMLElement {
|
||||
|
|
@ -63,7 +80,11 @@ export class Xterm {
|
|||
}
|
||||
if (timeout > 0) {
|
||||
this.messageTimer = setTimeout(() => {
|
||||
this.elem.removeChild(this.message);
|
||||
try {
|
||||
this.elem.removeChild(this.message);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, timeout);
|
||||
}
|
||||
};
|
||||
|
|
@ -90,11 +111,23 @@ export class Xterm {
|
|||
});
|
||||
};
|
||||
|
||||
onInput(callback: (input: string) => void) {
|
||||
this.onDataHandler = this.term.onData((data) => {
|
||||
callback(data);
|
||||
});
|
||||
sendInput(data: Uint8Array) {
|
||||
return this.toServer(data)
|
||||
}
|
||||
|
||||
onInput(callback: (input: string) => void) {
|
||||
this.encoder = new TextEncoder()
|
||||
this.toServer = callback;
|
||||
|
||||
// I *think* we're ok like this, but if not, we can dispose
|
||||
// of the previous handler and put the new one in place.
|
||||
if (this.onDataHandler !== undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
this.onDataHandler = this.term.onData((input) => {
|
||||
this.toServer(this.encoder.encode(input));
|
||||
});
|
||||
};
|
||||
|
||||
onResize(callback: (colmuns: number, rows: number) => void) {
|
||||
|
|
@ -118,4 +151,16 @@ export class Xterm {
|
|||
window.removeEventListener("resize", this.resizeListener);
|
||||
this.term.dispose();
|
||||
}
|
||||
|
||||
disableStdin(): void {
|
||||
this.term.options.disableStdin = true;
|
||||
}
|
||||
|
||||
enableStdin(): void {
|
||||
this.term.options.disableStdin = false;
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.term.focus();
|
||||
}
|
||||
}
|
||||
245
js/src/zmodem.tsx
Normal file
245
js/src/zmodem.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { ITerminalAddon, Terminal } from "xterm";
|
||||
import { Browser, Detection, Offer, Sentry, Session } from 'zmodem.js/src/zmodem_browser';
|
||||
import { MyModal, Button } from "./MyModal";
|
||||
import { Component, ComponentChildren, createRef, render } from "preact";
|
||||
|
||||
export class ZModemAddon implements ITerminalAddon {
|
||||
term: Terminal;
|
||||
elem: HTMLDivElement;
|
||||
sentry: Sentry;
|
||||
toTerminal: (data: Uint8Array) => void;
|
||||
toServer: (data: Uint8Array) => void;
|
||||
|
||||
constructor(props: {
|
||||
toTerminal: (data: Uint8Array) => void,
|
||||
toServer: (data: Uint8Array) => void
|
||||
}) {
|
||||
this.createElement();
|
||||
this.toTerminal = props.toTerminal;
|
||||
this.toServer = props.toServer;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
private createElement() {
|
||||
this.elem = document.createElement("div");
|
||||
document.body.prepend(this.elem);
|
||||
}
|
||||
|
||||
consume(data: Uint8Array) {
|
||||
this.sentry.consume(data)
|
||||
}
|
||||
|
||||
activate(terminal: Terminal): void {
|
||||
this.term = terminal
|
||||
}
|
||||
|
||||
dispose() {
|
||||
}
|
||||
|
||||
private init() {
|
||||
render(<></>, this.elem);
|
||||
|
||||
this.sentry = new Sentry({
|
||||
'to_terminal': (d: Uint8Array) => this.toTerminal(d),
|
||||
'on_detect': (detection: Detection) => this.onDetect(detection),
|
||||
'sender': (x: Uint8Array) => { this.toServer(x) },
|
||||
'on_retract': () => this.reset(),
|
||||
});
|
||||
}
|
||||
|
||||
private reset() {
|
||||
this.init();
|
||||
this.term.options.disableStdin = false;
|
||||
this.term.focus();
|
||||
}
|
||||
|
||||
private onDetect(detection: Detection) {
|
||||
var zsession = detection.confirm();
|
||||
|
||||
this.term.options.disableStdin = true;
|
||||
|
||||
zsession.on('session_end', () => { this.reset() });
|
||||
|
||||
if (zsession.type === "send") {
|
||||
this.send(zsession);
|
||||
}
|
||||
else {
|
||||
zsession.on("offer", (xfer: any) => this.onOffer(xfer));
|
||||
zsession.start();
|
||||
}
|
||||
}
|
||||
|
||||
private send(zsession: Session) {
|
||||
render(<SendFileModal session={zsession} />, this.elem)
|
||||
}
|
||||
|
||||
private onOffer(xfer: Offer) {
|
||||
render(<ReceiveFileModal xfer={xfer} onFinish={() => this.reset()} />, this.elem)
|
||||
}
|
||||
}
|
||||
|
||||
// Renders a bootstrap progress bar
|
||||
function Progress(props: { min: number, max: number, now: number, children?: ComponentChildren }) {
|
||||
let { min, max, now } = props;
|
||||
let percentage = "0";
|
||||
|
||||
if ((typeof min === "number") &&
|
||||
(typeof max === "number") &&
|
||||
(typeof now === "number") &&
|
||||
(min != max)) {
|
||||
percentage = (100 * (now - min) / (max - min)).toFixed(0);
|
||||
}
|
||||
|
||||
return <div class="progress">
|
||||
<div class="progress-bar" role="progressbar" style={"width: " + percentage + "%"} aria-valuenow={now} aria-valuemin={min} aria-valuemax={max}>{props.children}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
interface ReceiveFileModalProps {
|
||||
xfer: Offer;
|
||||
onFinish?: () => void;
|
||||
}
|
||||
|
||||
interface ReceiveFileModalState {
|
||||
state: "notstarted" | "started" | "skipped" | "done"
|
||||
}
|
||||
|
||||
export class ReceiveFileModal extends Component<ReceiveFileModalProps, ReceiveFileModalState> {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.setState({ state: "notstarted" })
|
||||
}
|
||||
|
||||
accept() {
|
||||
this.setState({ state: "started" });
|
||||
|
||||
let timerID = setInterval(
|
||||
() => this.forceUpdate(),
|
||||
100
|
||||
);
|
||||
|
||||
this.props.xfer.accept().then((payloads: any) => {
|
||||
// All done, so stop updating the progress bar
|
||||
// and perform a final render.
|
||||
clearInterval(timerID);
|
||||
this.forceUpdate();
|
||||
|
||||
if (this.state.state != "skipped") {
|
||||
Browser.save_to_disk(
|
||||
payloads,
|
||||
this.props.xfer.get_details().name
|
||||
);
|
||||
}
|
||||
this.setState({ state: "done" })
|
||||
})
|
||||
}
|
||||
|
||||
finish() {
|
||||
console.log('finished');
|
||||
if (this.props.onFinish) this.props.onFinish();
|
||||
}
|
||||
|
||||
progress() {
|
||||
if (this.state.state !== "notstarted") {
|
||||
return <Progress min={0} max={this.props.xfer.get_details().size} now={this.props.xfer.get_offset()} />
|
||||
}
|
||||
}
|
||||
|
||||
skip() {
|
||||
this.props.xfer.skip()
|
||||
this.setState({ state: "skipped" })
|
||||
}
|
||||
|
||||
buttons() {
|
||||
switch (this.state.state) {
|
||||
case "notstarted":
|
||||
return <>
|
||||
<Button priority="primary" clickHandler={() => { this.accept(); }}>Accept</Button>
|
||||
<Button priority="secondary" clickHandler={() => { this.skip(); }}>Decline</Button>
|
||||
</>
|
||||
case "started":
|
||||
return <>
|
||||
<Button priority="danger" clickHandler={() => { this.skip(); }}>Cancel</Button>
|
||||
</>
|
||||
case "skipped":
|
||||
return <>
|
||||
<Button priority="danger" disabled={true}>Skipping...</Button>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.state != "done")
|
||||
return <MyModal title="Incoming file"
|
||||
buttons={this.buttons()}>
|
||||
Accept <code>{this.props.xfer.get_details().name}</code> ({this.props.xfer.get_details().size.toLocaleString(undefined, { maximumFractionDigits: 0 })} bytes)?
|
||||
{this.progress()}
|
||||
</MyModal>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class SendFileModal extends Component<SendFileModalProps, SendFileModalState> {
|
||||
filePickerRef = createRef<HTMLInputElement>();
|
||||
|
||||
constructor(props: SendFileModalProps) {
|
||||
super(props)
|
||||
this.setState({ state: "notstarted" })
|
||||
}
|
||||
|
||||
buttons() {
|
||||
switch (this.state.state) {
|
||||
case "started":
|
||||
return <>
|
||||
<Button priority="primary" clickHandler={() => { this.send(); }} disabled={true}>
|
||||
Sending...
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</Button>
|
||||
</>
|
||||
case "notstarted":
|
||||
return <>
|
||||
<Button priority="primary" clickHandler={() => { this.send(); }}>Send</Button>
|
||||
</>
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
send() {
|
||||
Browser.send_files(this.props.session,
|
||||
this.filePickerRef.current!.files, {
|
||||
on_offer_response: (f, xfer) => { this.setState({ state: "started" }) },
|
||||
}).then(() => {
|
||||
this.setState({ state: "done" })
|
||||
this.props.session.close()
|
||||
if (this.props.onFinish !== undefined) {
|
||||
this.props.onFinish();
|
||||
}
|
||||
})
|
||||
.catch(e => console.log(e));
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.state != "done")
|
||||
return <MyModal title="Send file(s)"
|
||||
buttons={this.buttons()}>
|
||||
<div class="mb-3">
|
||||
<label for="formFileMultiple" class="form-label">
|
||||
Remote requested file transfer
|
||||
</label>
|
||||
<input ref={this.filePickerRef} class="form-control form-control-sm" type="file" id="formFileMultiple" multiple />
|
||||
</div>
|
||||
</MyModal>
|
||||
}
|
||||
}
|
||||
|
||||
interface SendFileModalProps {
|
||||
onFinish?: () => void;
|
||||
session: Session;
|
||||
}
|
||||
|
||||
interface SendFileModalState {
|
||||
state: "notstarted" | "started" | "done"
|
||||
currentFile: any
|
||||
}
|
||||
|
|
@ -1,16 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals" : true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"outDir": "./dist/",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"target": "es5",
|
||||
"target": "esnext",
|
||||
"module": "commonJS",
|
||||
"baseUrl": ".",
|
||||
"types": ["preact", "node"],
|
||||
"paths": {
|
||||
"*": ["./typings/*"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
const path = require('path');
|
||||
const TerserPlugin = require("terser-webpack-plugin");
|
||||
const LicenseWebpackPlugin = require('license-webpack-plugin').LicenseWebpackPlugin;
|
||||
|
||||
module.exports = {
|
||||
entry: "./src/main.ts",
|
||||
|
|
@ -12,6 +14,9 @@ module.exports = {
|
|||
resolve: {
|
||||
extensions: [".ts", ".tsx", ".js"],
|
||||
},
|
||||
plugins: [
|
||||
new LicenseWebpackPlugin()
|
||||
],
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
|
|
@ -24,10 +29,21 @@ module.exports = {
|
|||
use: ["style-loader", "css-loader"],
|
||||
},
|
||||
{
|
||||
test: /\.js$/,
|
||||
include: /node_modules/,
|
||||
loader: 'license-loader'
|
||||
test: /\.scss$/i,
|
||||
use: ["style-loader", "css-loader", {
|
||||
loader: "sass-loader",
|
||||
options: {
|
||||
sassOptions: {
|
||||
includePaths: ["node_modules/bootstrap/scss"]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
optimization: {
|
||||
minimize: true,
|
||||
minimizer: [new TerserPlugin()],
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue