Initial commit
This commit is contained in:
73
ui/app.css
Normal file
73
ui/app.css
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@charset "utf-8";
|
||||
|
||||
#app {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
body.app-error {
|
||||
background: #b44;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#app-loading {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#app-loading-frame {
|
||||
flex: 0 0;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#app-loading-icon {
|
||||
background: url("./widgets/busy.svg") center center no-repeat;
|
||||
background-size: contain;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
#app-loading-error {
|
||||
font-size: 5em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#app-loading-title {
|
||||
color: #fab;
|
||||
font-size: 1.2em;
|
||||
font-weight: lighter;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#app-loading-title.error {
|
||||
color: #fff;
|
||||
}
|
||||
304
ui/app.js
Normal file
304
ui/app.js
Normal file
@@ -0,0 +1,304 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import "./common.css";
|
||||
import "./app.css";
|
||||
import "./landing.css";
|
||||
|
||||
import { Socket } from "./socket.js";
|
||||
|
||||
import Vue from "vue";
|
||||
import Home from "./home.vue";
|
||||
import Auth from "./auth.vue";
|
||||
import Loading from "./loading.vue";
|
||||
|
||||
import { Commands } from "./commands/commands.js";
|
||||
import * as ssh from "./commands/ssh.js";
|
||||
import * as telnet from "./commands/telnet.js";
|
||||
|
||||
import { Controls } from "./commands/controls.js";
|
||||
import { Color as ControlColor } from "./commands/color.js";
|
||||
import * as telnetctl from "./control/telnet.js";
|
||||
import * as sshctl from "./control/ssh.js";
|
||||
|
||||
import * as xhr from "./xhr.js";
|
||||
import * as cipher from "./crypto.js";
|
||||
|
||||
const maxTimeDiff = 20000;
|
||||
|
||||
const mainTemplate = `
|
||||
<home
|
||||
v-if="page == 'app'"
|
||||
:host-path="hostPath"
|
||||
:query="query"
|
||||
:connection="socket"
|
||||
:controls="controls"
|
||||
:commands="commands"
|
||||
@navigate-to="changeURLHash"></home>
|
||||
<auth
|
||||
v-else-if="page == 'auth'"
|
||||
:error="authErr"
|
||||
@auth="submitAuth"
|
||||
></auth>
|
||||
<loading v-else :error="loadErr"></loading>
|
||||
`.trim();
|
||||
|
||||
let clientInitialized = false;
|
||||
|
||||
function startApp(rootEl) {
|
||||
let uiControlColor = new ControlColor();
|
||||
|
||||
new Vue({
|
||||
el: rootEl,
|
||||
components: {
|
||||
loading: Loading,
|
||||
auth: Auth,
|
||||
home: Home
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hostPath:
|
||||
window.location.protocol +
|
||||
"//" +
|
||||
window.location.host +
|
||||
window.location.pathname,
|
||||
query:
|
||||
window.location.hash.length > 0 &&
|
||||
window.location.hash.indexOf("#") === 0
|
||||
? window.location.hash.slice(1, window.location.hash.length)
|
||||
: "",
|
||||
page: "loading",
|
||||
key: "",
|
||||
authErr: "",
|
||||
loadErr: "",
|
||||
socket: null,
|
||||
controls: new Controls([
|
||||
new telnetctl.Telnet(uiControlColor),
|
||||
new sshctl.SSH(uiControlColor)
|
||||
]),
|
||||
commands: new Commands([new telnet.Command(), new ssh.Command()])
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
loadErr() {
|
||||
this.isErrored()
|
||||
? document.body.classList.add("app-error")
|
||||
: document.body.classList.remove("app-error");
|
||||
},
|
||||
authErr() {
|
||||
this.isErrored()
|
||||
? document.body.classList.add("app-error")
|
||||
: document.body.classList.remove("app-error");
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.tryInitialAuth();
|
||||
},
|
||||
methods: {
|
||||
changeURLHash(newHash) {
|
||||
window.location.hash = newHash;
|
||||
},
|
||||
isErrored() {
|
||||
return this.authErr.length > 0 || this.loadErr.length > 0;
|
||||
},
|
||||
async getSocketAuthKey(privateKey, randomKey) {
|
||||
const enc = new TextEncoder("utf-8");
|
||||
|
||||
return new Uint8Array(
|
||||
await cipher.hmac512(enc.encode(privateKey), enc.encode(randomKey))
|
||||
).slice(0, 32);
|
||||
},
|
||||
buildBackendSocketURL() {
|
||||
let r = "";
|
||||
|
||||
switch (location.protocol) {
|
||||
case "https:":
|
||||
r = "wss://";
|
||||
break;
|
||||
|
||||
default:
|
||||
r = "ws://";
|
||||
}
|
||||
|
||||
r += location.host + "/socket";
|
||||
|
||||
return r;
|
||||
},
|
||||
buildSocket(key, dialTimeout, heartbeatInterval) {
|
||||
return new Socket(
|
||||
this.buildBackendSocketURL(),
|
||||
key,
|
||||
dialTimeout * 1000,
|
||||
heartbeatInterval * 1000
|
||||
);
|
||||
},
|
||||
async tryInitialAuth() {
|
||||
try {
|
||||
let result = await this.doAuth("");
|
||||
|
||||
if (result.date) {
|
||||
let serverTime = result.date.getTime(),
|
||||
clientTime = new Date().getTime(),
|
||||
timeDiff = Math.abs(serverTime - clientTime);
|
||||
|
||||
if (timeDiff > maxTimeDiff) {
|
||||
this.loadErr =
|
||||
"The time difference between this client " +
|
||||
"and the backend server is beyond operational limit.\r\n\r\n" +
|
||||
"Please adjust your time so both client and server are " +
|
||||
"running at same date time";
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let self = this;
|
||||
|
||||
switch (result.result) {
|
||||
case 200:
|
||||
this.socket = this.buildSocket(
|
||||
{
|
||||
data: result.key,
|
||||
async fetch() {
|
||||
if (this.data) {
|
||||
let dKey = this.data;
|
||||
|
||||
this.data = null;
|
||||
|
||||
return dKey;
|
||||
}
|
||||
|
||||
let result = await self.doAuth("");
|
||||
|
||||
if (result.result !== 200) {
|
||||
throw new Error(
|
||||
"Unable to fetch key from remote, unexpected " +
|
||||
"error code: " +
|
||||
result.result
|
||||
);
|
||||
}
|
||||
|
||||
return result.key;
|
||||
}
|
||||
},
|
||||
result.timeout,
|
||||
result.heartbeat
|
||||
);
|
||||
this.page = "app";
|
||||
break;
|
||||
|
||||
case 403:
|
||||
this.page = "auth";
|
||||
break;
|
||||
|
||||
default:
|
||||
alert("Unexpected backend query status: " + result.result);
|
||||
}
|
||||
} catch (e) {
|
||||
this.loadErr = "Unable to initialize client application: " + e;
|
||||
}
|
||||
},
|
||||
async doAuth(privateKey) {
|
||||
let result = await this.requestAuth(privateKey);
|
||||
|
||||
if (result.key) {
|
||||
this.key = result.key;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
async requestAuth(privateKey) {
|
||||
let authKey =
|
||||
!privateKey || !this.key
|
||||
? null
|
||||
: await this.getSocketAuthKey(privateKey, this.key);
|
||||
|
||||
let h = await xhr.head("/socket", {
|
||||
"X-Key": authKey ? btoa(String.fromCharCode.apply(null, authKey)) : ""
|
||||
});
|
||||
|
||||
let serverDate = h.getResponseHeader("Date");
|
||||
|
||||
return {
|
||||
result: h.status,
|
||||
key: h.getResponseHeader("X-Key"),
|
||||
timeout: h.getResponseHeader("X-Timeout"),
|
||||
heartbeat: h.getResponseHeader("X-Heartbeat"),
|
||||
date: serverDate ? new Date(serverDate) : null
|
||||
};
|
||||
},
|
||||
async submitAuth(passpharse) {
|
||||
this.authErr = "";
|
||||
|
||||
try {
|
||||
let result = await this.doAuth(passpharse);
|
||||
|
||||
switch (result.result) {
|
||||
case 200:
|
||||
this.socket = this.buildSocket(
|
||||
{
|
||||
data: passpharse,
|
||||
fetch() {
|
||||
return this.data;
|
||||
}
|
||||
},
|
||||
result.timeout,
|
||||
result.heartbeat
|
||||
);
|
||||
this.page = "app";
|
||||
break;
|
||||
|
||||
case 403:
|
||||
this.authErr = "Authentication has failed. Wrong passpharse?";
|
||||
break;
|
||||
|
||||
default:
|
||||
this.authErr =
|
||||
"Unexpected backend query status: " + result.result;
|
||||
}
|
||||
} catch (e) {
|
||||
this.authErr = "Unable to authenticate: " + e;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initializeClient() {
|
||||
if (clientInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
clientInitialized = true;
|
||||
|
||||
document.body.classList.remove("landing");
|
||||
|
||||
let landingRoot = document.getElementById("landing");
|
||||
landingRoot.parentNode.removeChild(landingRoot);
|
||||
|
||||
let normalRoot = document.createElement("div");
|
||||
normalRoot.setAttribute("id", "app");
|
||||
normalRoot.innerHTML = mainTemplate;
|
||||
|
||||
document.body.insertBefore(normalRoot, document.body.firstChild);
|
||||
|
||||
startApp(normalRoot);
|
||||
}
|
||||
|
||||
window.addEventListener("load", initializeClient);
|
||||
document.addEventListener("load", initializeClient);
|
||||
document.addEventListener("DOMContentLoaded", initializeClient);
|
||||
126
ui/auth.vue
Normal file
126
ui/auth.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div id="auth">
|
||||
<div id="auth-frame">
|
||||
<div id="auth-content">
|
||||
<h1>Authentication required</h1>
|
||||
|
||||
<form class="form1" action="javascript:;" method="POST" @submit="auth">
|
||||
<fieldset>
|
||||
<div
|
||||
class="field"
|
||||
:class="{
|
||||
error: passpharseErr.length > 0 || error.length > 0
|
||||
}"
|
||||
>
|
||||
Passpharse
|
||||
|
||||
<input
|
||||
v-model="passpharse"
|
||||
v-focus="true"
|
||||
:disabled="submitting"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
name="field.field.name"
|
||||
placeholder="----------"
|
||||
autofocus="autofocus"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="passpharseErr.length <= 0 && error.length <= 0"
|
||||
class="message"
|
||||
>
|
||||
A valid password is required in order to use this
|
||||
<a href="https://github.com/niruix/sshwifty">Sshwifty</a>
|
||||
instance
|
||||
</div>
|
||||
<div v-else class="error">
|
||||
{{ passpharseErr || error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<button type="submit" :disabled="submitting" @click="auth">
|
||||
Authenticate
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
directives: {
|
||||
focus: {
|
||||
inserted(el, binding) {
|
||||
if (!binding.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
el.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
error: {
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
submitting: false,
|
||||
passpharse: "",
|
||||
passpharseErr: ""
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
error(newVal) {
|
||||
if (newVal.length > 0) {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
methods: {
|
||||
auth() {
|
||||
if (this.passpharse.length <= 0) {
|
||||
this.passpharseErr = "Passpharse cannot be empty";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
|
||||
this.passpharseErr = "";
|
||||
|
||||
this.$emit("auth", this.passpharse);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
50
ui/body.html
Normal file
50
ui/body.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<div id="landing">
|
||||
<div id="landing-message">
|
||||
<div id="landing-message-logo"></div>
|
||||
|
||||
<h1 id="landing-message-title">Loading Sshwifty</h1>
|
||||
|
||||
<div id="landing-message-info">
|
||||
<p>
|
||||
Client is currently being loaded. Should only take a few seconds, please
|
||||
wait
|
||||
</p>
|
||||
<noscript>
|
||||
<p>
|
||||
Also, surely you smart people knows that application such like this
|
||||
one require JavaScript to run :)
|
||||
</p>
|
||||
</noscript>
|
||||
<p class="copy copy-first">
|
||||
Copyright © 2019 Rui NI <nirui@gmx.com>
|
||||
</p>
|
||||
<p class="copy">
|
||||
<a href="https://github.com/niruix/sshwifty" target="blank">
|
||||
Source Code
|
||||
</a>
|
||||
|
||||
<a href="/README.md" target="blank">
|
||||
Release note
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
228
ui/commands/address.js
Normal file
228
ui/commands/address.js
Normal file
@@ -0,0 +1,228 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import Exception from "./exception.js";
|
||||
import * as reader from "../stream/reader.js";
|
||||
import * as common from "./common.js";
|
||||
|
||||
export const LOOPBACK = 0x00;
|
||||
export const IPV4 = 0x01;
|
||||
export const IPV6 = 0x02;
|
||||
export const HOSTNAME = 0x03;
|
||||
|
||||
export const MAX_ADDR_LEN = 0x3f;
|
||||
|
||||
export class Address {
|
||||
/**
|
||||
* Read builds an Address from data readed from the reader
|
||||
*
|
||||
* @param {reader.Reader} rd The reader
|
||||
*
|
||||
* @returns {Address} The Address
|
||||
*
|
||||
* @throws {Exception} when address type is invalid
|
||||
*/
|
||||
static async read(rd) {
|
||||
let readed = await reader.readN(rd, 3),
|
||||
portNum = 0,
|
||||
addrType = LOOPBACK,
|
||||
addrData = null;
|
||||
|
||||
portNum |= readed[0];
|
||||
portNum <<= 8;
|
||||
portNum |= readed[1];
|
||||
|
||||
addrType = readed[2] >> 6;
|
||||
|
||||
switch (addrType) {
|
||||
case LOOPBACK:
|
||||
break;
|
||||
|
||||
case IPV4:
|
||||
addrData = await reader.readN(rd, 4);
|
||||
break;
|
||||
|
||||
case IPV6:
|
||||
addrData = await reader.readN(rd, 16);
|
||||
break;
|
||||
|
||||
case HOSTNAME:
|
||||
addrData = await reader.readN(rd, 0x3f & readed[2]);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Exception("Unknown address type");
|
||||
}
|
||||
|
||||
return new Address(addrType, addrData, portNum);
|
||||
}
|
||||
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {number} type Type of the address
|
||||
* @param {Uint8Array} address Address data
|
||||
* @param {number} port port number of the address
|
||||
*
|
||||
*/
|
||||
constructor(type, address, port) {
|
||||
this.addrType = type;
|
||||
this.addrData = address;
|
||||
this.addrPort = port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the address type
|
||||
*
|
||||
*/
|
||||
type() {
|
||||
return this.addrType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the address data
|
||||
*
|
||||
*/
|
||||
address() {
|
||||
return this.addrData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the port data
|
||||
*
|
||||
*/
|
||||
port() {
|
||||
return this.addrPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffer returns the marshalled address
|
||||
*
|
||||
* @returns {Uint8Array} Marshalled address
|
||||
*
|
||||
* @throws {Exception} When address data is invalid
|
||||
*
|
||||
*/
|
||||
buffer() {
|
||||
switch (this.type()) {
|
||||
case LOOPBACK:
|
||||
return new Uint8Array([
|
||||
this.addrPort >> 8,
|
||||
this.addrPort & 0xff,
|
||||
LOOPBACK << 6
|
||||
]);
|
||||
|
||||
case IPV4:
|
||||
if (this.addrData.length != 4) {
|
||||
throw new Exception("Invalid address length");
|
||||
}
|
||||
|
||||
return new Uint8Array([
|
||||
this.addrPort >> 8,
|
||||
this.addrPort & 0xff,
|
||||
IPV4 << 6,
|
||||
this.addrData[0],
|
||||
this.addrData[1],
|
||||
this.addrData[2],
|
||||
this.addrData[3]
|
||||
]);
|
||||
|
||||
case IPV6:
|
||||
if (this.addrData.length != 16) {
|
||||
throw new Exception("Invalid address length");
|
||||
}
|
||||
|
||||
return new Uint8Array([
|
||||
this.addrPort >> 8,
|
||||
this.addrPort & 0xff,
|
||||
IPV6 << 6,
|
||||
this.addrData[0],
|
||||
this.addrData[1],
|
||||
this.addrData[2],
|
||||
this.addrData[3],
|
||||
this.addrData[4],
|
||||
this.addrData[5],
|
||||
this.addrData[6],
|
||||
this.addrData[7],
|
||||
this.addrData[8],
|
||||
this.addrData[9],
|
||||
this.addrData[10],
|
||||
this.addrData[11],
|
||||
this.addrData[12],
|
||||
this.addrData[13],
|
||||
this.addrData[14],
|
||||
this.addrData[15]
|
||||
]);
|
||||
|
||||
case HOSTNAME:
|
||||
if (this.addrData.length > MAX_ADDR_LEN) {
|
||||
throw new Exception("Host name cannot longer than " + MAX_ADDR_LEN);
|
||||
}
|
||||
|
||||
let dataBuf = new Uint8Array(this.addrData.length + 3);
|
||||
|
||||
dataBuf[0] = (this.addrPort >> 8) & 0xff;
|
||||
dataBuf[1] = this.addrPort & 0xff;
|
||||
dataBuf[2] = HOSTNAME << 6;
|
||||
dataBuf[2] |= this.addrData.length;
|
||||
|
||||
dataBuf.set(this.addrData, 3);
|
||||
|
||||
return dataBuf;
|
||||
|
||||
default:
|
||||
throw new Exception("Unknown address type");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get address data
|
||||
*
|
||||
* @param {string} s Address string
|
||||
* @param {number} defaultPort Default port number
|
||||
*
|
||||
* @returns {object} result
|
||||
*
|
||||
* @throws {Exception} when the address is invalid
|
||||
*/
|
||||
export function parseHostPort(s, defaultPort) {
|
||||
let d = common.splitHostPort(s, defaultPort),
|
||||
t = HOSTNAME;
|
||||
|
||||
switch (d.type) {
|
||||
case "IPv4":
|
||||
t = IPV4;
|
||||
break;
|
||||
|
||||
case "IPv6":
|
||||
t = IPV6;
|
||||
break;
|
||||
|
||||
case "Hostname":
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Exception("Invalid address type");
|
||||
}
|
||||
|
||||
return {
|
||||
type: t,
|
||||
address: d.addr,
|
||||
port: d.port
|
||||
};
|
||||
}
|
||||
102
ui/commands/address_test.js
Normal file
102
ui/commands/address_test.js
Normal file
@@ -0,0 +1,102 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import assert from "assert";
|
||||
import * as reader from "../stream/reader.js";
|
||||
import * as address from "./address.js";
|
||||
|
||||
describe("Address", () => {
|
||||
it("Address Loopback", async () => {
|
||||
let addr = new address.Address(address.LOOPBACK, null, 8080),
|
||||
buf = addr.buffer();
|
||||
|
||||
let r = new reader.Reader(new reader.Multiple(), data => {
|
||||
return data;
|
||||
});
|
||||
|
||||
r.feed(buf);
|
||||
|
||||
let addr2 = await address.Address.read(r);
|
||||
|
||||
assert.equal(addr2.type(), addr.type());
|
||||
assert.deepEqual(addr2.address(), addr.address());
|
||||
assert.equal(addr2.port(), addr.port());
|
||||
});
|
||||
|
||||
it("Address IPv4", async () => {
|
||||
let addr = new address.Address(
|
||||
address.IPV4,
|
||||
new Uint8Array([127, 0, 0, 1]),
|
||||
8080
|
||||
),
|
||||
buf = addr.buffer();
|
||||
|
||||
let r = new reader.Reader(new reader.Multiple(() => {}), data => {
|
||||
return data;
|
||||
});
|
||||
|
||||
r.feed(buf);
|
||||
|
||||
let addr2 = await address.Address.read(r);
|
||||
|
||||
assert.equal(addr2.type(), addr.type());
|
||||
assert.deepEqual(addr2.address(), addr.address());
|
||||
assert.equal(addr2.port(), addr.port());
|
||||
});
|
||||
|
||||
it("Address IPv6", async () => {
|
||||
let addr = new address.Address(
|
||||
address.IPV6,
|
||||
new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]),
|
||||
8080
|
||||
),
|
||||
buf = addr.buffer();
|
||||
|
||||
let r = new reader.Reader(new reader.Multiple(() => {}), data => {
|
||||
return data;
|
||||
});
|
||||
|
||||
r.feed(buf);
|
||||
|
||||
let addr2 = await address.Address.read(r);
|
||||
|
||||
assert.equal(addr2.type(), addr.type());
|
||||
assert.deepEqual(addr2.address(), addr.address());
|
||||
assert.equal(addr2.port(), addr.port());
|
||||
});
|
||||
|
||||
it("Address HostName", async () => {
|
||||
let addr = new address.Address(
|
||||
address.HOSTNAME,
|
||||
new Uint8Array(["v", "a", "g", "u", "l", "1", "2", "3"]),
|
||||
8080
|
||||
),
|
||||
buf = addr.buffer();
|
||||
|
||||
let r = new reader.Reader(new reader.Multiple(() => {}), data => {
|
||||
return data;
|
||||
});
|
||||
|
||||
r.feed(buf);
|
||||
|
||||
let addr2 = await address.Address.read(r);
|
||||
|
||||
assert.equal(addr2.type(), addr.type());
|
||||
assert.deepEqual(addr2.address(), addr.address());
|
||||
assert.equal(addr2.port(), addr.port());
|
||||
});
|
||||
});
|
||||
107
ui/commands/color.js
Normal file
107
ui/commands/color.js
Normal file
@@ -0,0 +1,107 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Get one color hex byte
|
||||
*
|
||||
* @param {number} from Min color number
|
||||
* @param {number} to Max color number
|
||||
*
|
||||
* @returns {string} color byte in string
|
||||
*
|
||||
*/
|
||||
function getRandHex(from, to) {
|
||||
let color = Math.random() * (to - from) + from,
|
||||
colorDark = color - color / 20;
|
||||
|
||||
let r = Math.round(color).toString(16),
|
||||
rDark = Math.round(colorDark).toString(16);
|
||||
|
||||
if (r.length % 2 !== 0) {
|
||||
r = "0" + r;
|
||||
}
|
||||
|
||||
if (rDark.length % 2 !== 0) {
|
||||
rDark = "0" + rDark;
|
||||
}
|
||||
|
||||
return [r, rDark];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rand color
|
||||
*
|
||||
* @param {number} from Min color number
|
||||
* @param {number} to Max color number
|
||||
*
|
||||
* @returns {string} Color bytes in string
|
||||
*/
|
||||
function getRandColor(from, to) {
|
||||
let r = getRandHex(from, to),
|
||||
g = getRandHex(from, to),
|
||||
b = getRandHex(from, to);
|
||||
|
||||
return ["#" + r[0] + g[0] + b[0], "#" + r[1] + g[1] + b[1]];
|
||||
}
|
||||
|
||||
export class Color {
|
||||
/**
|
||||
* constructor
|
||||
*/
|
||||
constructor() {
|
||||
this.assignedColors = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get one color
|
||||
*
|
||||
* @returns {string} Color code
|
||||
*
|
||||
*/
|
||||
get() {
|
||||
const maxTries = 10;
|
||||
let tried = 0;
|
||||
|
||||
for (;;) {
|
||||
let color = getRandColor(0x22, 0x33);
|
||||
|
||||
if (this.assignedColors[color[0]]) {
|
||||
tried++;
|
||||
|
||||
if (tried < maxTries) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
this.assignedColors[color[0]] = true;
|
||||
|
||||
return {
|
||||
color: color[0],
|
||||
dark: color[1]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* forget already assigned color
|
||||
*
|
||||
* @param {string} color Color code
|
||||
*/
|
||||
forget(color) {
|
||||
delete this.assignedColors[color];
|
||||
}
|
||||
}
|
||||
720
ui/commands/commands.js
Normal file
720
ui/commands/commands.js
Normal file
@@ -0,0 +1,720 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import Exception from "./exception.js";
|
||||
import * as stream from "../stream/streams.js";
|
||||
import * as subscribe from "../stream/subscribe.js";
|
||||
|
||||
export const NEXT_PROMPT = 1;
|
||||
export const NEXT_WAIT = 2;
|
||||
export const NEXT_DONE = 3;
|
||||
|
||||
export class Result {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {string} name Result type
|
||||
* @param {Info} info Result info
|
||||
* @param {object} control Result controller
|
||||
*/
|
||||
constructor(name, info, control) {
|
||||
this.name = name;
|
||||
this.info = info;
|
||||
this.control = control;
|
||||
}
|
||||
}
|
||||
|
||||
class Done {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {object} data Step data
|
||||
*
|
||||
*/
|
||||
constructor(data) {
|
||||
this.s = !!data.success;
|
||||
this.d = data.successData;
|
||||
this.errorTitle = data.errorTitle;
|
||||
this.errorMessage = data.errorMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the error of current Done
|
||||
*
|
||||
* @returns {string} title
|
||||
*
|
||||
*/
|
||||
error() {
|
||||
return this.errorTitle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the error message of current Done
|
||||
*
|
||||
* @returns {string} message
|
||||
*
|
||||
*/
|
||||
message() {
|
||||
return this.errorMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not current Done is representing a success
|
||||
*
|
||||
* @returns {boolean} True when success, false otherwise
|
||||
*/
|
||||
success() {
|
||||
return this.s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns final data
|
||||
*
|
||||
* @returns {Result} Successful result
|
||||
*/
|
||||
data() {
|
||||
return this.d;
|
||||
}
|
||||
}
|
||||
|
||||
class Wait {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {object} data Step data
|
||||
*
|
||||
*/
|
||||
constructor(data) {
|
||||
this.t = data.title;
|
||||
this.m = data.message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the title of current Wait
|
||||
*
|
||||
* @returns {string} title
|
||||
*
|
||||
*/
|
||||
title() {
|
||||
return this.t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the message of current Wait
|
||||
*
|
||||
* @returns {string} message
|
||||
*
|
||||
*/
|
||||
message() {
|
||||
return this.m;
|
||||
}
|
||||
}
|
||||
|
||||
const defField = {
|
||||
name: "",
|
||||
description: "",
|
||||
type: "",
|
||||
value: "",
|
||||
example: "",
|
||||
verify(v) {
|
||||
return "OK";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a Prompt field
|
||||
*
|
||||
* @param {object} def Field default value
|
||||
* @param {object} f Field value
|
||||
*
|
||||
* @returns {object} Field data
|
||||
*
|
||||
* @throws {Exception} When input field is invalid
|
||||
*
|
||||
*/
|
||||
export function field(def, f) {
|
||||
let n = {};
|
||||
|
||||
for (let i in def) {
|
||||
n[i] = def[i];
|
||||
}
|
||||
|
||||
for (let i in f) {
|
||||
if (typeof n[i] !== typeof f[i]) {
|
||||
throw new Exception(
|
||||
'Field data type for "' +
|
||||
i +
|
||||
'" was not unmatched. Expecting "' +
|
||||
typeof def[i] +
|
||||
'", got "' +
|
||||
typeof f[i] +
|
||||
'" instead'
|
||||
);
|
||||
}
|
||||
|
||||
n[i] = f[i];
|
||||
}
|
||||
|
||||
if (!n["name"]) {
|
||||
throw new Exception('Field "name" must be specified');
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a group of field value
|
||||
*
|
||||
* @param {object} definitions Definition of a group of fields
|
||||
* @param {array<object>} fs Data of the field group
|
||||
*
|
||||
* @returns {array<object>} Result fields
|
||||
*
|
||||
* @throws {Exception} When input field is invalid
|
||||
*
|
||||
*/
|
||||
export function fields(definitions, fs) {
|
||||
let fss = [];
|
||||
|
||||
for (let i in fs) {
|
||||
if (!fs[i]["name"]) {
|
||||
throw new Exception('Field "name" must be specified');
|
||||
}
|
||||
|
||||
if (!definitions[fs[i].name]) {
|
||||
throw new Exception('Undefined field "' + fs[i].name + '"');
|
||||
}
|
||||
|
||||
fss.push(field(definitions[fs[i].name], fs[i]));
|
||||
}
|
||||
|
||||
return fss;
|
||||
}
|
||||
|
||||
class Prompt {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {object} data Step data
|
||||
*
|
||||
* @throws {Exception} If the field verify is not a function while
|
||||
* not null
|
||||
*/
|
||||
constructor(data) {
|
||||
this.t = data.title;
|
||||
this.m = data.message;
|
||||
this.a = data.actionText;
|
||||
this.r = data.respond;
|
||||
this.c = data.cancel;
|
||||
|
||||
this.i = [];
|
||||
this.f = {};
|
||||
|
||||
for (let i in data.inputs) {
|
||||
let f = field(defField, data.inputs[i]);
|
||||
|
||||
this.i.push(f);
|
||||
|
||||
this.f[data.inputs[i].name.toLowerCase()] = {
|
||||
value: f.value,
|
||||
verify: f.verify
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the title of current Prompt
|
||||
*
|
||||
* @returns {string} title
|
||||
*
|
||||
*/
|
||||
title() {
|
||||
return this.t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the message of current Prompt
|
||||
*
|
||||
* @returns {string} message
|
||||
*
|
||||
*/
|
||||
message() {
|
||||
return this.m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the input field of current prompt
|
||||
*
|
||||
* @returns {array} Input fields
|
||||
*
|
||||
*/
|
||||
inputs() {
|
||||
let inputs = [];
|
||||
|
||||
for (let i in this.i) {
|
||||
inputs.push(this.i[i]);
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the action
|
||||
*
|
||||
* @returns {string} Action name
|
||||
*
|
||||
*/
|
||||
actionText() {
|
||||
return this.a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive the submit of current prompt
|
||||
*
|
||||
* @param {object} inputs Input value
|
||||
*
|
||||
* @returns {any} The result of the step responder
|
||||
*
|
||||
* @throws {Exception} When the field is undefined or invalid
|
||||
*
|
||||
*/
|
||||
submit(inputs) {
|
||||
let fields = {};
|
||||
|
||||
for (let i in this.f) {
|
||||
fields[i] = this.f[i].value;
|
||||
}
|
||||
|
||||
for (let i in inputs) {
|
||||
let k = i.toLowerCase();
|
||||
|
||||
if (typeof fields[k] === "undefined") {
|
||||
throw new Exception('Field "' + k + '" is undefined');
|
||||
}
|
||||
|
||||
try {
|
||||
this.f[k].verify(inputs[i]);
|
||||
} catch (e) {
|
||||
throw new Exception('Field "' + k + '" is invalid: ' + e);
|
||||
}
|
||||
|
||||
fields[k] = inputs[i];
|
||||
}
|
||||
|
||||
return this.r(fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel current wait operation
|
||||
*
|
||||
*/
|
||||
cancel() {
|
||||
return this.c();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Wizard step
|
||||
*
|
||||
* @param {string} type Step type
|
||||
* @param {object} data Step data
|
||||
*
|
||||
* @returns {object} Step data
|
||||
*
|
||||
*/
|
||||
function next(type, data) {
|
||||
return {
|
||||
type() {
|
||||
return type;
|
||||
},
|
||||
data() {
|
||||
return data;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create data for a Done step of the wizard
|
||||
*
|
||||
* @param {boolean} success
|
||||
* @param {Success} successData
|
||||
* @param {string} errorTitle
|
||||
* @param {string} errorMessage
|
||||
*
|
||||
* @returns {object} Done step data
|
||||
*
|
||||
*/
|
||||
export function done(success, successData, errorTitle, errorMessage) {
|
||||
return next(NEXT_DONE, {
|
||||
success: success,
|
||||
successData: successData,
|
||||
errorTitle: errorTitle,
|
||||
errorMessage: errorMessage
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create data for a Wait step of the wizard
|
||||
*
|
||||
* @param {string} title Waiter title
|
||||
* @param {message} message Waiter message
|
||||
*
|
||||
* @returns {object} Done step data
|
||||
*
|
||||
*/
|
||||
export function wait(title, message) {
|
||||
return next(NEXT_WAIT, {
|
||||
title: title,
|
||||
message: message
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create data for a Prompt step of the wizard
|
||||
*
|
||||
* @param {string} title Title of the prompt
|
||||
* @param {string} message Message of the prompt
|
||||
* @param {string} actionText Text of the action (button)
|
||||
* @param {function} respond Respond callback
|
||||
* @param {function} cancel cancel handler
|
||||
* @param {object} inputs Input field objects
|
||||
*
|
||||
* @returns {object} Prompt step data
|
||||
*
|
||||
*/
|
||||
export function prompt(title, message, actionText, respond, cancel, inputs) {
|
||||
return next(NEXT_PROMPT, {
|
||||
title: title,
|
||||
message: message,
|
||||
actionText: actionText,
|
||||
inputs: inputs,
|
||||
respond: respond,
|
||||
cancel: cancel
|
||||
});
|
||||
}
|
||||
|
||||
class Next {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {object} data Step data
|
||||
*/
|
||||
constructor(data) {
|
||||
this.t = data.type();
|
||||
this.d = data.data();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return step type
|
||||
*
|
||||
* @returns {string} Step type
|
||||
*/
|
||||
type() {
|
||||
return this.t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return step data
|
||||
*
|
||||
* @returns {Done|Prompt} Step data
|
||||
*
|
||||
* @throws {Exception} When the step type is unknown
|
||||
*
|
||||
*/
|
||||
data() {
|
||||
switch (this.type()) {
|
||||
case NEXT_PROMPT:
|
||||
return new Prompt(this.d);
|
||||
|
||||
case NEXT_WAIT:
|
||||
return new Wait(this.d);
|
||||
|
||||
case NEXT_DONE:
|
||||
return new Done(this.d);
|
||||
|
||||
default:
|
||||
throw new Exception("Unknown data type");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Wizard {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {function} builder Command builder
|
||||
* @param {subscribe.Subscribe} subs Wizard step subscriber
|
||||
*
|
||||
*/
|
||||
constructor(built, subs) {
|
||||
this.built = built;
|
||||
this.subs = subs;
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the Next step
|
||||
*
|
||||
* @returns {Next} Next step
|
||||
*
|
||||
* @throws {Exception} When wizard is closed
|
||||
*
|
||||
*/
|
||||
async next() {
|
||||
if (this.closed) {
|
||||
throw new Exception("Wizard already closed, no next step is available");
|
||||
}
|
||||
|
||||
let n = await this.subs.subscribe();
|
||||
|
||||
if (n.type() === NEXT_DONE) {
|
||||
this.close();
|
||||
}
|
||||
|
||||
return new Next(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether or not the command is started
|
||||
*
|
||||
* @returns {boolean} True when the command already started, false otherwise
|
||||
*
|
||||
*/
|
||||
started() {
|
||||
return this.built.started();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close current wizard
|
||||
*
|
||||
* @returns {any} Close result
|
||||
*
|
||||
*/
|
||||
close() {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closed = true;
|
||||
|
||||
return this.built.close();
|
||||
}
|
||||
}
|
||||
|
||||
export class Info {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {Builder} info Builder info
|
||||
*
|
||||
*/
|
||||
constructor(info) {
|
||||
this.type = info.name();
|
||||
this.info = info.description();
|
||||
this.tcolor = info.color();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return command name
|
||||
*
|
||||
* @returns {string} Command name
|
||||
*
|
||||
*/
|
||||
name() {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return command description
|
||||
*
|
||||
* @returns {string} Command description
|
||||
*
|
||||
*/
|
||||
description() {
|
||||
return this.info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the theme color of the command
|
||||
*
|
||||
* @returns {string} Command name
|
||||
*
|
||||
*/
|
||||
color() {
|
||||
return this.tcolor;
|
||||
}
|
||||
}
|
||||
|
||||
class Builder {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {object} command Command builder
|
||||
*
|
||||
*/
|
||||
constructor(command) {
|
||||
this.cid = command.id();
|
||||
this.builder = (n, i, r, u, y, x) => {
|
||||
return command.builder(n, i, r, u, y, x);
|
||||
};
|
||||
this.launchCmd = (n, i, r, u, y, x) => {
|
||||
return command.launch(n, i, r, u, y, x);
|
||||
};
|
||||
this.launcherCmd = c => {
|
||||
return command.launcher(c);
|
||||
};
|
||||
this.type = command.name();
|
||||
this.info = command.description();
|
||||
this.tcolor = command.color();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the command ID
|
||||
*
|
||||
* @returns {number} Command ID
|
||||
*
|
||||
*/
|
||||
id() {
|
||||
return this.cid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return command name
|
||||
*
|
||||
* @returns {string} Command name
|
||||
*
|
||||
*/
|
||||
name() {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return command description
|
||||
*
|
||||
* @returns {string} Command description
|
||||
*
|
||||
*/
|
||||
description() {
|
||||
return this.info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the theme color of the command
|
||||
*
|
||||
* @returns {string} Command name
|
||||
*
|
||||
*/
|
||||
color() {
|
||||
return this.tcolor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build command wizard
|
||||
*
|
||||
* @param {stream.Streams} streams
|
||||
* @param {controls.Controls} controls
|
||||
* @param {history.History} history
|
||||
* @param {object} config
|
||||
*
|
||||
* @returns {Wizard} Command wizard
|
||||
*
|
||||
*/
|
||||
build(streams, controls, history, config) {
|
||||
let subs = new subscribe.Subscribe();
|
||||
|
||||
return new Wizard(
|
||||
this.builder(new Info(this), config, streams, subs, controls, history),
|
||||
subs
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch command wizard out of given launcher string
|
||||
*
|
||||
* @param {stream.Streams} streams
|
||||
* @param {controls.Controls} controls
|
||||
* @param {history.History} history
|
||||
* @param {string} launcher Launcher format
|
||||
*
|
||||
* @returns {Wizard} Command wizard
|
||||
*
|
||||
*/
|
||||
launch(streams, controls, history, launcher) {
|
||||
let subs = new subscribe.Subscribe();
|
||||
|
||||
return new Wizard(
|
||||
this.launchCmd(
|
||||
new Info(this),
|
||||
launcher,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history
|
||||
),
|
||||
subs
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build launcher string out of given config
|
||||
*
|
||||
* @param {object} config Configuration object
|
||||
*
|
||||
* @return {string} Launcher string
|
||||
*/
|
||||
launcher(config) {
|
||||
return this.name() + ":" + this.launcherCmd(config);
|
||||
}
|
||||
}
|
||||
|
||||
export class Commands {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {[]object} commands Command array
|
||||
*
|
||||
*/
|
||||
constructor(commands) {
|
||||
this.commands = [];
|
||||
|
||||
for (let i in commands) {
|
||||
this.commands.push(new Builder(commands[i]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all commands
|
||||
*
|
||||
* @returns {[]Builder} A group of command
|
||||
*
|
||||
*/
|
||||
all() {
|
||||
return this.commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select one command
|
||||
*
|
||||
* @param {number} id Command ID
|
||||
*
|
||||
* @returns {Builder} Command builder
|
||||
*
|
||||
*/
|
||||
select(id) {
|
||||
return this.commands[id];
|
||||
}
|
||||
}
|
||||
360
ui/commands/common.js
Normal file
360
ui/commands/common.js
Normal file
@@ -0,0 +1,360 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import Exception from "./exception.js";
|
||||
|
||||
const numCharators = {
|
||||
"0": true,
|
||||
"1": true,
|
||||
"2": true,
|
||||
"3": true,
|
||||
"4": true,
|
||||
"5": true,
|
||||
"6": true,
|
||||
"7": true,
|
||||
"8": true,
|
||||
"9": true
|
||||
};
|
||||
|
||||
const hexCharators = {
|
||||
"0": true,
|
||||
"1": true,
|
||||
"2": true,
|
||||
"3": true,
|
||||
"4": true,
|
||||
"5": true,
|
||||
"6": true,
|
||||
"7": true,
|
||||
"8": true,
|
||||
"9": true,
|
||||
a: true,
|
||||
b: true,
|
||||
c: true,
|
||||
d: true,
|
||||
e: true,
|
||||
f: true
|
||||
};
|
||||
|
||||
const hostnameCharators = {
|
||||
"0": true,
|
||||
"1": true,
|
||||
"2": true,
|
||||
"3": true,
|
||||
"4": true,
|
||||
"5": true,
|
||||
"6": true,
|
||||
"7": true,
|
||||
"8": true,
|
||||
"9": true,
|
||||
a: true,
|
||||
b: true,
|
||||
c: true,
|
||||
d: true,
|
||||
e: true,
|
||||
f: true,
|
||||
g: true,
|
||||
h: true,
|
||||
i: true,
|
||||
j: true,
|
||||
k: true,
|
||||
l: true,
|
||||
n: true,
|
||||
m: true,
|
||||
o: true,
|
||||
p: true,
|
||||
q: true,
|
||||
r: true,
|
||||
s: true,
|
||||
t: true,
|
||||
u: true,
|
||||
v: true,
|
||||
w: true,
|
||||
x: true,
|
||||
y: true,
|
||||
z: true,
|
||||
".": true,
|
||||
"-": true,
|
||||
_: true
|
||||
};
|
||||
|
||||
/**
|
||||
* Test whether or not given string is all number
|
||||
*
|
||||
* @param {string} d Input data
|
||||
*
|
||||
* @returns {boolean} Return true if given string is all number, false otherwise
|
||||
*
|
||||
*/
|
||||
function isNumber(d) {
|
||||
for (let i = 0; i < d.length; i++) {
|
||||
if (!numCharators[d[i]]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether or not given string is all hex
|
||||
*
|
||||
* @param {string} d Input data
|
||||
*
|
||||
* @returns {boolean} Return true if given string is all hex, false otherwise
|
||||
*
|
||||
*/
|
||||
function isHex(d) {
|
||||
let dd = d.toLowerCase();
|
||||
|
||||
for (let i = 0; i < dd.length; i++) {
|
||||
if (!hexCharators[dd[i]]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether or not given string is all hex
|
||||
*
|
||||
* @param {string} d Input data
|
||||
*
|
||||
* @returns {boolean} Return true if given string is all hex, false otherwise
|
||||
*
|
||||
*/
|
||||
function isHostname(d) {
|
||||
let dd = d.toLowerCase();
|
||||
|
||||
for (let i = 0; i < dd.length; i++) {
|
||||
if (!hostnameCharators[dd[i]]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse IPv4 address
|
||||
*
|
||||
* @param {string} d IP address
|
||||
*
|
||||
* @returns {Uint8Array} Parsed IPv4 Address
|
||||
*
|
||||
* @throws {Exception} When the given ip address was not an IPv4 addr
|
||||
*
|
||||
*/
|
||||
export function parseIPv4(d) {
|
||||
const addrSeg = 4;
|
||||
|
||||
let s = d.split(".");
|
||||
|
||||
if (s.length != addrSeg) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
let r = new Uint8Array(addrSeg);
|
||||
|
||||
for (let i in s) {
|
||||
if (!isNumber(s[i])) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
let ii = parseInt(s[i], 10); // Only support dec
|
||||
|
||||
if (isNaN(ii)) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
if (ii > 0xff) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
r[i] = ii;
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse IPv6 address. ::ffff: notation is NOT supported
|
||||
*
|
||||
* @param {string} d IP address
|
||||
*
|
||||
* @returns {Uint16Array} Parsed IPv6 Address
|
||||
*
|
||||
* @throws {Exception} When the given ip address was not an IPv6 addr
|
||||
*
|
||||
*/
|
||||
export function parseIPv6(d) {
|
||||
const addrSeg = 8;
|
||||
|
||||
let s = d.split(":");
|
||||
|
||||
if (s.length > addrSeg || s.length <= 1) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
if (s[0].charAt(0) === "[") {
|
||||
s[0] = s[0].substring(1, s[0].length);
|
||||
|
||||
let end = s.length - 1;
|
||||
|
||||
if (s[end].charAt(s[end].length - 1) !== "]") {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
s[end] = s[end].substring(0, s[end].length - 1);
|
||||
}
|
||||
|
||||
let r = new Uint16Array(addrSeg),
|
||||
rIndexShift = 0;
|
||||
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (s[i].length <= 0) {
|
||||
rIndexShift = addrSeg - s.length;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isHex(s[i])) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
let ii = parseInt(s[i], 16); // Only support hex
|
||||
|
||||
if (isNaN(ii)) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
if (ii > 0xffff) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
r[rIndexShift + i] = ii;
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert string into a {Uint8Array}
|
||||
*
|
||||
* @param {string} d Input
|
||||
*
|
||||
* @returns {Uint8Array} Output
|
||||
*
|
||||
*/
|
||||
export function strToUint8Array(d) {
|
||||
let r = new Uint8Array(d.length);
|
||||
|
||||
for (let i = 0, j = d.length; i < j; i++) {
|
||||
r[i] = d.charCodeAt(i);
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse IPv6 address. ::ffff: notation is NOT supported
|
||||
*
|
||||
* @param {string} d IP address
|
||||
*
|
||||
* @returns {Uint8Array} Parsed IPv6 Address
|
||||
*
|
||||
* @throws {Exception} When the given ip address was not an IPv6 addr
|
||||
*
|
||||
*/
|
||||
export function parseHostname(d) {
|
||||
if (d.length <= 0) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
if (!isHostname(d)) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
return strToUint8Array(d);
|
||||
}
|
||||
|
||||
function parseIP(d) {
|
||||
try {
|
||||
return {
|
||||
type: "IPv4",
|
||||
data: parseIPv4(d)
|
||||
};
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
type: "IPv6",
|
||||
data: new Uint8Array(parseIPv6(d).buffer)
|
||||
};
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
return {
|
||||
type: "Hostname",
|
||||
data: parseHostname(d)
|
||||
};
|
||||
}
|
||||
|
||||
export function splitHostPort(d, defPort) {
|
||||
let hps = d.lastIndexOf(":"),
|
||||
fhps = d.indexOf(":"),
|
||||
ipv6hps = d.indexOf("[");
|
||||
|
||||
if ((hps < 0 || hps != fhps) && ipv6hps < 0) {
|
||||
let a = parseIP(d);
|
||||
|
||||
return {
|
||||
type: a.type,
|
||||
addr: a.data,
|
||||
port: defPort
|
||||
};
|
||||
}
|
||||
|
||||
if (ipv6hps > 0) {
|
||||
throw new Exception("Invalid address");
|
||||
} else if (ipv6hps === 0) {
|
||||
let ipv6hpse = d.lastIndexOf("]");
|
||||
|
||||
if (ipv6hpse <= ipv6hps || ipv6hpse + 1 != hps) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
}
|
||||
|
||||
let addr = d.slice(0, hps),
|
||||
port = d.slice(hps + 1, d.length);
|
||||
|
||||
if (!isNumber(port)) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
let portNum = parseInt(port, 10),
|
||||
a = parseIP(addr);
|
||||
|
||||
return {
|
||||
type: a.type,
|
||||
addr: a.data,
|
||||
port: portNum
|
||||
};
|
||||
}
|
||||
278
ui/commands/common_test.js
Normal file
278
ui/commands/common_test.js
Normal file
@@ -0,0 +1,278 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import assert from "assert";
|
||||
import * as common from "./common.js";
|
||||
|
||||
describe("Common", () => {
|
||||
it("parseIPv4", () => {
|
||||
let tests = [
|
||||
{
|
||||
sample: "127.0.0.1",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([127, 0, 0, 1])
|
||||
},
|
||||
{
|
||||
sample: "255.255.255.255",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([255, 255, 255, 255])
|
||||
},
|
||||
{
|
||||
sample: "255.255.a.255",
|
||||
expectingFailure: true,
|
||||
expected: null
|
||||
},
|
||||
{
|
||||
sample: "255.255.255",
|
||||
expectingFailure: true,
|
||||
expected: null
|
||||
},
|
||||
{
|
||||
sample: "2001:db8:1f70::999:de8:7648:6e8",
|
||||
expectingFailure: true,
|
||||
expected: null
|
||||
},
|
||||
{
|
||||
sample: "a.ssh.vaguly.com",
|
||||
expectingFailure: true,
|
||||
expected: null
|
||||
}
|
||||
];
|
||||
|
||||
for (let i in tests) {
|
||||
if (tests[i].expectingFailure) {
|
||||
let ee = null;
|
||||
|
||||
try {
|
||||
common.parseIPv4(tests[i].sample);
|
||||
} catch (e) {
|
||||
ee = e;
|
||||
}
|
||||
|
||||
assert.notEqual(ee, null, "Test " + tests[i].sample);
|
||||
} else {
|
||||
let data = common.parseIPv4(tests[i].sample);
|
||||
|
||||
assert.deepEqual(data, tests[i].expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("parseIPv6", () => {
|
||||
let tests = [
|
||||
{
|
||||
sample: "2001:db8:1f70:0:999:de8:7648:6e8",
|
||||
expectingFailure: false,
|
||||
expected: new Uint16Array([
|
||||
0x2001,
|
||||
0xdb8,
|
||||
0x1f70,
|
||||
0x0,
|
||||
0x999,
|
||||
0xde8,
|
||||
0x7648,
|
||||
0x6e8
|
||||
])
|
||||
},
|
||||
{
|
||||
sample: "2001:db8:85a3::8a2e:370:7334",
|
||||
expectingFailure: false,
|
||||
expected: new Uint16Array([
|
||||
0x2001,
|
||||
0xdb8,
|
||||
0x85a3,
|
||||
0x0,
|
||||
0x0,
|
||||
0x8a2e,
|
||||
0x370,
|
||||
0x7334
|
||||
])
|
||||
},
|
||||
{
|
||||
sample: "::1",
|
||||
expectingFailure: false,
|
||||
expected: new Uint16Array([0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x01])
|
||||
},
|
||||
{
|
||||
sample: "::",
|
||||
expectingFailure: false,
|
||||
expected: new Uint16Array([0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x00])
|
||||
},
|
||||
{
|
||||
sample: "2001:db8:1f70::999:de8:7648:6e8",
|
||||
expectingFailure: false,
|
||||
expected: new Uint16Array([
|
||||
0x2001,
|
||||
0xdb8,
|
||||
0x1f70,
|
||||
0x0,
|
||||
0x999,
|
||||
0xde8,
|
||||
0x7648,
|
||||
0x6e8
|
||||
])
|
||||
},
|
||||
{
|
||||
sample: "2001:0db8:ac10:fe01::",
|
||||
expectingFailure: false,
|
||||
expected: new Uint16Array([
|
||||
0x2001,
|
||||
0x0db8,
|
||||
0xac10,
|
||||
0xfe01,
|
||||
0x0,
|
||||
0x0,
|
||||
0x0,
|
||||
0x0
|
||||
])
|
||||
},
|
||||
{
|
||||
sample: "::7f00:1",
|
||||
expectingFailure: false,
|
||||
expected: new Uint16Array([
|
||||
0x0000,
|
||||
0x0000,
|
||||
0x0000,
|
||||
0x0000,
|
||||
0x0000,
|
||||
0x0000,
|
||||
0x7f00,
|
||||
0x0001
|
||||
])
|
||||
},
|
||||
{
|
||||
sample: "127.0.0.1",
|
||||
expectingFailure: true,
|
||||
expected: null
|
||||
},
|
||||
{
|
||||
sample: "255.255.255.255",
|
||||
expectingFailure: true,
|
||||
expected: null
|
||||
},
|
||||
{
|
||||
sample: "255.255.a.255",
|
||||
expectingFailure: true,
|
||||
expected: null
|
||||
},
|
||||
{
|
||||
sample: "255.255.255",
|
||||
expectingFailure: true,
|
||||
expected: null
|
||||
},
|
||||
{
|
||||
sample: "a.ssh.vaguly.com",
|
||||
expectingFailure: true,
|
||||
expected: null
|
||||
}
|
||||
];
|
||||
|
||||
for (let i in tests) {
|
||||
if (tests[i].expectingFailure) {
|
||||
let ee = null;
|
||||
|
||||
try {
|
||||
common.parseIPv6(tests[i].sample);
|
||||
} catch (e) {
|
||||
ee = e;
|
||||
}
|
||||
|
||||
assert.notEqual(ee, null, "Test " + tests[i].sample);
|
||||
} else {
|
||||
let data = common.parseIPv6(tests[i].sample);
|
||||
|
||||
assert.deepEqual(data, tests[i].expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("splitHostPort", () => {
|
||||
let tests = [
|
||||
// Host name
|
||||
{
|
||||
sample: "ssh.vaguly.com",
|
||||
expectedType: "Hostname",
|
||||
expectedAddr: common.strToUint8Array("ssh.vaguly.com"),
|
||||
expectedPort: 22
|
||||
},
|
||||
{
|
||||
sample: "ssh.vaguly.com:22",
|
||||
expectedType: "Hostname",
|
||||
expectedAddr: common.strToUint8Array("ssh.vaguly.com"),
|
||||
expectedPort: 22
|
||||
},
|
||||
|
||||
// IPv4
|
||||
{
|
||||
sample: "10.220.179.110",
|
||||
expectedType: "IPv4",
|
||||
expectedAddr: new Uint8Array([10, 220, 179, 110]),
|
||||
expectedPort: 22
|
||||
},
|
||||
{
|
||||
sample: "10.220.179.110:3333",
|
||||
expectedType: "IPv4",
|
||||
expectedAddr: new Uint8Array([10, 220, 179, 110]),
|
||||
expectedPort: 3333
|
||||
},
|
||||
|
||||
// IPv6
|
||||
{
|
||||
sample: "2001:db8:1f70::999:de8:7648:6e8",
|
||||
expectedType: "IPv6",
|
||||
expectedAddr: new Uint8Array(
|
||||
new Uint16Array([
|
||||
0x2001,
|
||||
0xdb8,
|
||||
0x1f70,
|
||||
0x0,
|
||||
0x999,
|
||||
0xde8,
|
||||
0x7648,
|
||||
0x6e8
|
||||
]).buffer
|
||||
),
|
||||
expectedPort: 22
|
||||
},
|
||||
{
|
||||
sample: "[2001:db8:1f70::999:de8:7648:6e8]:100",
|
||||
expectedType: "IPv6",
|
||||
expectedAddr: new Uint8Array(
|
||||
new Uint16Array([
|
||||
0x2001,
|
||||
0xdb8,
|
||||
0x1f70,
|
||||
0x0,
|
||||
0x999,
|
||||
0xde8,
|
||||
0x7648,
|
||||
0x6e8
|
||||
]).buffer
|
||||
),
|
||||
expectedPort: 100
|
||||
}
|
||||
];
|
||||
|
||||
for (let i in tests) {
|
||||
let hostport = common.splitHostPort(tests[i].sample, 22);
|
||||
|
||||
assert.deepEqual(hostport.type, tests[i].expectedType);
|
||||
assert.deepEqual(hostport.addr, tests[i].expectedAddr);
|
||||
assert.equal(hostport.port, tests[i].expectedPort);
|
||||
}
|
||||
});
|
||||
});
|
||||
61
ui/commands/controls.js
vendored
Normal file
61
ui/commands/controls.js
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import Exception from "./exception.js";
|
||||
|
||||
export class Controls {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {[]object} controls
|
||||
*
|
||||
* @throws {Exception} When control type already been defined
|
||||
*
|
||||
*/
|
||||
constructor(controls) {
|
||||
this.controls = {};
|
||||
|
||||
for (let i in controls) {
|
||||
let cType = controls[i].type();
|
||||
|
||||
if (typeof this.controls[cType] === "object") {
|
||||
throw new Exception('Control "' + cType + '" already been defined');
|
||||
}
|
||||
|
||||
this.controls[cType] = controls[i];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a control
|
||||
*
|
||||
* @param {string} type Type of the control
|
||||
* @param {...any} data Data needed to build the control
|
||||
*
|
||||
* @returns {object} Control object
|
||||
*
|
||||
* @throws {Exception} When given control type is undefined
|
||||
*
|
||||
*/
|
||||
get(type, ...data) {
|
||||
if (typeof this.controls[type] !== "object") {
|
||||
throw new Exception('Control "' + type + '" was undefined');
|
||||
}
|
||||
|
||||
return this.controls[type].build(...data);
|
||||
}
|
||||
}
|
||||
106
ui/commands/events.js
Normal file
106
ui/commands/events.js
Normal file
@@ -0,0 +1,106 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import Exception from "./exception.js";
|
||||
|
||||
export class Events {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {[]string} events required events
|
||||
* @param {object} callbacks Callbacks
|
||||
*
|
||||
* @throws {Exception} When event handler is not registered
|
||||
*
|
||||
*/
|
||||
constructor(events, callbacks) {
|
||||
this.events = {};
|
||||
this.placeHolders = {};
|
||||
|
||||
for (let i in events) {
|
||||
if (typeof callbacks[events[i]] !== "function") {
|
||||
throw new Exception(
|
||||
'Unknown event type for "' +
|
||||
events[i] +
|
||||
'". Expecting "function" got "' +
|
||||
typeof callbacks[events[i]] +
|
||||
'" instead.'
|
||||
);
|
||||
}
|
||||
|
||||
let name = events[i];
|
||||
|
||||
if (name.indexOf("@") === 0) {
|
||||
name = name.substring(1);
|
||||
|
||||
this.placeHolders[name] = null;
|
||||
}
|
||||
|
||||
this.events[name] = callbacks[events[i]];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Place callbacks to pending placeholder events
|
||||
*
|
||||
* @param {string} type Event Type
|
||||
* @param {function} callback Callback function
|
||||
*/
|
||||
place(type, callback) {
|
||||
if (this.placeHolders[type] !== null) {
|
||||
throw new Exception(
|
||||
'Event type "' +
|
||||
type +
|
||||
'" cannot be appended. It maybe ' +
|
||||
"unregistered or already been acquired"
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof callback !== "function") {
|
||||
throw new Exception(
|
||||
'Unknown event type for "' +
|
||||
type +
|
||||
'". Expecting "function" got "' +
|
||||
typeof callback +
|
||||
'" instead.'
|
||||
);
|
||||
}
|
||||
|
||||
delete this.placeHolders[type];
|
||||
|
||||
this.events[type] = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire an event
|
||||
*
|
||||
* @param {string} type Event type
|
||||
* @param {...any} data Event data
|
||||
*
|
||||
* @returns {any} The result of the event handler
|
||||
*
|
||||
* @throws {Exception} When event type is not registered
|
||||
*
|
||||
*/
|
||||
fire(type, ...data) {
|
||||
if (!this.events[type] && this.placeHolders[type] !== null) {
|
||||
throw new Exception("Unknown event type: " + type);
|
||||
}
|
||||
|
||||
return this.events[type](...data);
|
||||
}
|
||||
}
|
||||
38
ui/commands/exception.js
Normal file
38
ui/commands/exception.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
export default class Exception {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {string} message error message
|
||||
*
|
||||
*/
|
||||
constructor(message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the error string
|
||||
*
|
||||
* @returns {string} Error message
|
||||
*
|
||||
*/
|
||||
toString() {
|
||||
return this.message;
|
||||
}
|
||||
}
|
||||
115
ui/commands/history.js
Normal file
115
ui/commands/history.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import * as command from "./commands.js";
|
||||
|
||||
export class History {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {array<object>} records
|
||||
* @param {function} saver
|
||||
* @param {number} maxItems
|
||||
*
|
||||
*/
|
||||
constructor(records, saver, maxItems) {
|
||||
this.records = records;
|
||||
this.maxItems = maxItems;
|
||||
this.saver = saver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save record to history
|
||||
*
|
||||
* @param {string} uname unique name
|
||||
* @param {string} title Title
|
||||
* @param {command.Info} info Command info
|
||||
* @param {Date} lastUsed Last used
|
||||
* @param {object} data Data
|
||||
*
|
||||
*/
|
||||
save(uname, title, lastUsed, info, data) {
|
||||
for (let i in this.records) {
|
||||
if (this.records[i].uname !== uname) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.records.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
|
||||
this.records.push({
|
||||
uname: uname,
|
||||
title: title,
|
||||
type: info.name(),
|
||||
color: info.color(),
|
||||
last: lastUsed.getTime(),
|
||||
data: data
|
||||
});
|
||||
|
||||
if (this.records.length > this.maxItems) {
|
||||
this.records = this.records.slice(
|
||||
this.records.length - this.maxItems,
|
||||
this.records.length
|
||||
);
|
||||
}
|
||||
|
||||
this.saver(this, this.records);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save record to history
|
||||
*
|
||||
* @param {string} uid unique name
|
||||
*
|
||||
*/
|
||||
del(uid) {
|
||||
for (let i in this.records) {
|
||||
if (this.records[i].uname !== uid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.records.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
|
||||
this.saver(this, this.records);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all history records
|
||||
*
|
||||
* @returns {array<object>} Records
|
||||
*
|
||||
*/
|
||||
all() {
|
||||
let r = [];
|
||||
|
||||
for (let i in this.records) {
|
||||
r.push({
|
||||
uid: this.records[i].uname,
|
||||
title: this.records[i].title,
|
||||
type: this.records[i].type,
|
||||
color: this.records[i].color,
|
||||
last: new Date(this.records[i].last),
|
||||
data: this.records[i].data
|
||||
});
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
}
|
||||
90
ui/commands/integer.js
Normal file
90
ui/commands/integer.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import Exception from "./exception.js";
|
||||
import * as reader from "../stream/reader.js";
|
||||
|
||||
export const MAX = 0x3fff;
|
||||
export const MAX_BYTES = 2;
|
||||
|
||||
const integerHasNextBit = 0x80;
|
||||
const integerValueCutter = 0x7f;
|
||||
|
||||
export class Integer {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {number} num Integer number
|
||||
*
|
||||
*/
|
||||
constructor(num) {
|
||||
this.num = num;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marshal integer to buffer
|
||||
*
|
||||
* @returns {Uint8Array} Integer buffer
|
||||
*
|
||||
* @throws {Exception} When number is too large
|
||||
*
|
||||
*/
|
||||
marshal() {
|
||||
if (this.num > MAX) {
|
||||
throw new Exception("Integer number cannot be greater than 0x3fff");
|
||||
}
|
||||
|
||||
if (this.num <= integerValueCutter) {
|
||||
return new Uint8Array([this.num & integerValueCutter]);
|
||||
}
|
||||
|
||||
return new Uint8Array([
|
||||
(this.num >> 7) | integerHasNextBit,
|
||||
this.num & integerValueCutter
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the reader to build an Integer
|
||||
*
|
||||
* @param {reader.Reader} rd Data reader
|
||||
*
|
||||
*/
|
||||
async unmarshal(rd) {
|
||||
for (let i = 0; i < MAX_BYTES; i++) {
|
||||
let r = await reader.readOne(rd);
|
||||
|
||||
this.num |= r[0] & integerValueCutter;
|
||||
|
||||
if ((integerHasNextBit & r[0]) == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.num <<= 7;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the value of the number
|
||||
*
|
||||
* @returns {number} The integer value
|
||||
*
|
||||
*/
|
||||
value() {
|
||||
return this.num;
|
||||
}
|
||||
}
|
||||
60
ui/commands/integer_test.js
Normal file
60
ui/commands/integer_test.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import assert from "assert";
|
||||
import * as reader from "../stream/reader.js";
|
||||
import * as integer from "./integer.js";
|
||||
|
||||
describe("Integer", () => {
|
||||
it("Integer 127", async () => {
|
||||
let i = new integer.Integer(127),
|
||||
marshalled = i.marshal();
|
||||
|
||||
let r = new reader.Reader(new reader.Multiple(() => {}), data => {
|
||||
return data;
|
||||
});
|
||||
|
||||
assert.equal(marshalled.length, 1);
|
||||
|
||||
r.feed(marshalled);
|
||||
|
||||
let i2 = new integer.Integer(0);
|
||||
|
||||
await i2.unmarshal(r);
|
||||
|
||||
assert.equal(i.value(), i2.value());
|
||||
});
|
||||
|
||||
it("Integer MAX", async () => {
|
||||
let i = new integer.Integer(integer.MAX),
|
||||
marshalled = i.marshal();
|
||||
|
||||
let r = new reader.Reader(new reader.Multiple(() => {}), data => {
|
||||
return data;
|
||||
});
|
||||
|
||||
assert.equal(marshalled.length, 2);
|
||||
|
||||
r.feed(marshalled);
|
||||
|
||||
let i2 = new integer.Integer(0);
|
||||
|
||||
await i2.unmarshal(r);
|
||||
|
||||
assert.equal(i.value(), i2.value());
|
||||
});
|
||||
});
|
||||
801
ui/commands/ssh.js
Normal file
801
ui/commands/ssh.js
Normal file
@@ -0,0 +1,801 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import * as address from "./address.js";
|
||||
import * as command from "./commands.js";
|
||||
import * as common from "./common.js";
|
||||
import * as event from "./events.js";
|
||||
import * as reader from "../stream/reader.js";
|
||||
import * as stream from "../stream/stream.js";
|
||||
import * as controls from "./controls.js";
|
||||
import * as header from "../stream/header.js";
|
||||
import * as history from "./history.js";
|
||||
import * as strings from "./string.js";
|
||||
import Exception from "./exception.js";
|
||||
|
||||
const AUTHMETHOD_NONE = 0x01;
|
||||
const AUTHMETHOD_PASSPHARSE = 0x01;
|
||||
const AUTHMETHOD_PRIVATE_KEY = 0x02;
|
||||
|
||||
const COMMAND_ID = 0x01;
|
||||
|
||||
const MAX_USERNAME_LEN = 64;
|
||||
const MAX_PASSWORD_LEN = 4096;
|
||||
const DEFAULT_PORT = 22;
|
||||
|
||||
const SERVER_REMOTE_STDOUT = 0x00;
|
||||
const SERVER_REMOTE_STDERR = 0x01;
|
||||
const SERVER_CONNECT_FAILED = 0x02;
|
||||
const SERVER_CONNECTED = 0x03;
|
||||
const SERVER_CONNECT_REQUEST_FINGERPRINT = 0x04;
|
||||
const SERVER_CONNECT_REQUEST_CREDENTIAL = 0x05;
|
||||
|
||||
const CLIENT_DATA_STDIN = 0x00;
|
||||
const CLIENT_DATA_RESIZE = 0x01;
|
||||
const CLIENT_CONNECT_RESPOND_FINGERPRINT = 0x02;
|
||||
const CLIENT_CONNECT_RESPOND_CREDENTIAL = 0x03;
|
||||
|
||||
const SERVER_REQUEST_ERROR_BAD_USERNAME = 0x01;
|
||||
const SERVER_REQUEST_ERROR_BAD_ADDRESS = 0x02;
|
||||
const SERVER_REQUEST_ERROR_BAD_AUTHMETHOD = 0x03;
|
||||
|
||||
const FingerprintPromptVerifyPassed = 0x00;
|
||||
const FingerprintPromptVerifyNoRecord = 0x01;
|
||||
const FingerprintPromptVerifyMismatch = 0x02;
|
||||
|
||||
class SSH {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {stream.Sender} sd Stream sender
|
||||
* @param {object} config configuration
|
||||
* @param {object} callbacks Event callbacks
|
||||
*
|
||||
*/
|
||||
constructor(sd, config, callbacks) {
|
||||
this.sender = sd;
|
||||
this.config = config;
|
||||
this.connected = false;
|
||||
this.events = new event.Events(
|
||||
[
|
||||
"initialization.failed",
|
||||
"initialized",
|
||||
"connect.failed",
|
||||
"connect.succeed",
|
||||
"connect.fingerprint",
|
||||
"connect.credential",
|
||||
"@stdout",
|
||||
"@stderr",
|
||||
"close",
|
||||
"@completed"
|
||||
],
|
||||
callbacks
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send intial request
|
||||
*
|
||||
* @param {stream.InitialSender} initialSender Initial stream request sender
|
||||
*
|
||||
*/
|
||||
run(initialSender) {
|
||||
let user = new strings.String(this.config.user),
|
||||
userBuf = user.buffer(),
|
||||
addr = new address.Address(
|
||||
this.config.host.type,
|
||||
this.config.host.address,
|
||||
this.config.host.port
|
||||
),
|
||||
addrBuf = addr.buffer(),
|
||||
authMethod = new Uint8Array([this.config.auth]);
|
||||
|
||||
let data = new Uint8Array(userBuf.length + addrBuf.length + 1);
|
||||
|
||||
data.set(userBuf, 0);
|
||||
data.set(addrBuf, userBuf.length);
|
||||
data.set(authMethod, userBuf.length + addrBuf.length);
|
||||
|
||||
initialSender.send(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive the initial stream request
|
||||
*
|
||||
* @param {header.InitialStream} streamInitialHeader Server respond on the
|
||||
* initial stream request
|
||||
*
|
||||
*/
|
||||
initialize(streamInitialHeader) {
|
||||
if (!streamInitialHeader.success()) {
|
||||
this.events.fire("initialization.failed", streamInitialHeader);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.events.fire("initialized", streamInitialHeader);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tick the command
|
||||
*
|
||||
* @param {header.Stream} streamHeader Stream data header
|
||||
* @param {reader.Limited} rd Data reader
|
||||
*
|
||||
* @returns {any} The result of the ticking
|
||||
*
|
||||
* @throws {Exception} When the stream header type is unknown
|
||||
*
|
||||
*/
|
||||
tick(streamHeader, rd) {
|
||||
switch (streamHeader.marker()) {
|
||||
case SERVER_CONNECTED:
|
||||
if (!this.connected) {
|
||||
this.connected = true;
|
||||
|
||||
return this.events.fire("connect.succeed", rd, this);
|
||||
}
|
||||
break;
|
||||
|
||||
case SERVER_CONNECT_FAILED:
|
||||
if (!this.connected) {
|
||||
return this.events.fire("connect.failed", rd);
|
||||
}
|
||||
break;
|
||||
|
||||
case SERVER_CONNECT_REQUEST_FINGERPRINT:
|
||||
if (!this.connected) {
|
||||
return this.events.fire("connect.fingerprint", rd, this.sender);
|
||||
}
|
||||
break;
|
||||
|
||||
case SERVER_CONNECT_REQUEST_CREDENTIAL:
|
||||
if (!this.connected) {
|
||||
return this.events.fire("connect.credential", rd, this.sender);
|
||||
}
|
||||
break;
|
||||
|
||||
case SERVER_REMOTE_STDOUT:
|
||||
if (this.connected) {
|
||||
return this.events.fire("stdout", rd);
|
||||
}
|
||||
break;
|
||||
|
||||
case SERVER_REMOTE_STDERR:
|
||||
if (this.connected) {
|
||||
return this.events.fire("stderr", rd);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
throw new Exception("Unknown stream header marker");
|
||||
}
|
||||
|
||||
/**
|
||||
* Send close signal to remote
|
||||
*
|
||||
*/
|
||||
async sendClose() {
|
||||
return await this.sender.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send data to remote
|
||||
*
|
||||
* @param {Uint8Array} data
|
||||
*
|
||||
*/
|
||||
async sendData(data) {
|
||||
return this.sender.send(CLIENT_DATA_STDIN, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send resize request
|
||||
*
|
||||
* @param {number} rows
|
||||
* @param {number} cols
|
||||
*
|
||||
*/
|
||||
async sendResize(rows, cols) {
|
||||
let data = new DataView(new ArrayBuffer(4));
|
||||
|
||||
data.setUint16(0, rows);
|
||||
data.setUint16(2, cols);
|
||||
|
||||
return this.sender.send(CLIENT_DATA_RESIZE, new Uint8Array(data.buffer));
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the command
|
||||
*
|
||||
*/
|
||||
async close() {
|
||||
await this.sendClose();
|
||||
|
||||
return this.events.fire("close");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the command completely
|
||||
*
|
||||
*/
|
||||
completed() {
|
||||
return this.events.fire("completed");
|
||||
}
|
||||
}
|
||||
|
||||
const initialFieldDef = {
|
||||
User: {
|
||||
name: "User",
|
||||
description: "",
|
||||
type: "text",
|
||||
value: "",
|
||||
example: "root",
|
||||
verify(d) {
|
||||
if (d.length <= 0) {
|
||||
throw new Error("Username must be specified");
|
||||
}
|
||||
|
||||
if (d.length > MAX_USERNAME_LEN) {
|
||||
throw new Error(
|
||||
"Username must not longer than " + MAX_USERNAME_LEN + " bytes"
|
||||
);
|
||||
}
|
||||
|
||||
return "We'll login as user \"" + d + '"';
|
||||
}
|
||||
},
|
||||
Host: {
|
||||
name: "Host",
|
||||
description: "",
|
||||
type: "text",
|
||||
value: "",
|
||||
example: "ssh.vaguly.com:22",
|
||||
verify(d) {
|
||||
if (d.length <= 0) {
|
||||
throw new Error("Hostname must be specified");
|
||||
}
|
||||
|
||||
let addr = common.splitHostPort(d, DEFAULT_PORT);
|
||||
|
||||
if (addr.addr.length <= 0) {
|
||||
throw new Error("Cannot be empty");
|
||||
}
|
||||
|
||||
if (addr.addr.length > address.MAX_ADDR_LEN) {
|
||||
throw new Error(
|
||||
"Can no longer than " + address.MAX_ADDR_LEN + " bytes"
|
||||
);
|
||||
}
|
||||
|
||||
if (addr.port <= 0) {
|
||||
throw new Error("Port must be specified");
|
||||
}
|
||||
|
||||
return "Look like " + addr.type + " address";
|
||||
}
|
||||
},
|
||||
Notice: {
|
||||
name: "Notice",
|
||||
description: "",
|
||||
type: "textdata",
|
||||
value:
|
||||
"SSH session is handled by the backend. Traffic will be decrypted " +
|
||||
"on the backend server and then be transmitted back to your client.",
|
||||
example: "",
|
||||
verify(d) {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
Passpharse: {
|
||||
name: "Passpharse",
|
||||
description: "",
|
||||
type: "password",
|
||||
value: "",
|
||||
example: "----------",
|
||||
verify(d) {
|
||||
if (d.length <= 0) {
|
||||
throw new Error("Passpharse must be specified");
|
||||
}
|
||||
|
||||
if (d.length > MAX_PASSWORD_LEN) {
|
||||
throw new Error(
|
||||
"It's too long, make it shorter than " + MAX_PASSWORD_LEN + " bytes"
|
||||
);
|
||||
}
|
||||
|
||||
return "We'll login with this passpharse";
|
||||
}
|
||||
},
|
||||
"Private Key": {
|
||||
name: "Private Key",
|
||||
description: "Like the one inside ~/.ssh/id_rsa, can't be encrypted",
|
||||
type: "textarea",
|
||||
value: "",
|
||||
example:
|
||||
"-----BEGIN RSA PRIVATE KEY-----\r\n" +
|
||||
"..... yBQZobkBQ50QqhDivQz4i1Pb33Z0Znjnzjoid4 ....\r\n" +
|
||||
"-----END RSA PRIVATE KEY-----",
|
||||
verify(d) {
|
||||
if (d.length <= 0) {
|
||||
throw new Error("Private Key must be specified");
|
||||
}
|
||||
|
||||
if (d.length > MAX_PASSWORD_LEN) {
|
||||
throw new Error(
|
||||
"It's too long, make it shorter than " + MAX_PASSWORD_LEN + " bytes"
|
||||
);
|
||||
}
|
||||
|
||||
return "We'll login with this private key";
|
||||
}
|
||||
},
|
||||
Authentication: {
|
||||
name: "Authentication",
|
||||
description:
|
||||
"Please make sure the authentication method that you selected is " +
|
||||
"supported by the server, otherwise it will be ignored and likely " +
|
||||
"cause the login to fail",
|
||||
type: "radio",
|
||||
value: "",
|
||||
example: "Password,Private Key,None",
|
||||
verify(d) {
|
||||
switch (d) {
|
||||
case "Password":
|
||||
case "Private Key":
|
||||
case "None":
|
||||
return "";
|
||||
|
||||
default:
|
||||
throw new Error("Authentication method must be specified");
|
||||
}
|
||||
}
|
||||
},
|
||||
Fingerprint: {
|
||||
name: "Fingerprint",
|
||||
description:
|
||||
"Please carefully verify the fingerprint. DO NOT continue " +
|
||||
"if the fingerprint is unknown to you, otherwise you maybe " +
|
||||
"giving your own secrets to an imposter",
|
||||
type: "textdata",
|
||||
value: "",
|
||||
example: "",
|
||||
verify(d) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Return auth method from given string
|
||||
*
|
||||
* @param {string} d string data
|
||||
*
|
||||
* @returns {number} Auth method
|
||||
*
|
||||
* @throws {Exception} When auth method is invalid
|
||||
*
|
||||
*/
|
||||
function getAuthMethodFromStr(d) {
|
||||
switch (d) {
|
||||
case "None":
|
||||
return AUTHMETHOD_NONE;
|
||||
|
||||
case "Password":
|
||||
return AUTHMETHOD_PASSPHARSE;
|
||||
|
||||
case "Private Key":
|
||||
return AUTHMETHOD_PRIVATE_KEY;
|
||||
|
||||
default:
|
||||
throw new Exception("Unknown Auth method");
|
||||
}
|
||||
}
|
||||
|
||||
class Wizard {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {command.Info} info
|
||||
* @param {object} config
|
||||
* @param {streams.Streams} streams
|
||||
* @param {subscribe.Subscribe} subs
|
||||
* @param {controls.Controls} controls
|
||||
* @param {history.History} history
|
||||
*
|
||||
*/
|
||||
constructor(info, config, streams, subs, controls, history) {
|
||||
this.info = info;
|
||||
this.hasStarted = false;
|
||||
this.streams = streams;
|
||||
this.config = config;
|
||||
this.step = subs;
|
||||
this.controls = controls;
|
||||
this.history = history;
|
||||
|
||||
this.step.resolve(this.stepInitialPrompt());
|
||||
}
|
||||
|
||||
started() {
|
||||
return this.hasStarted;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.step.resolve(
|
||||
this.stepErrorDone(
|
||||
"Action cancelled",
|
||||
"Action has been cancelled without reach any success"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
stepErrorDone(title, message) {
|
||||
return command.done(false, null, title, message);
|
||||
}
|
||||
|
||||
stepSuccessfulDone(data) {
|
||||
return command.done(
|
||||
true,
|
||||
data,
|
||||
"Success!",
|
||||
"We have connected to the remote"
|
||||
);
|
||||
}
|
||||
|
||||
stepWaitForAcceptWait() {
|
||||
return command.wait(
|
||||
"Requesting",
|
||||
"Waiting for request to be accepted by the backend"
|
||||
);
|
||||
}
|
||||
|
||||
stepWaitForEstablishWait(host) {
|
||||
return command.wait(
|
||||
"Connecting to " + host,
|
||||
"Establishing connection with the remote host, may take a while"
|
||||
);
|
||||
}
|
||||
|
||||
stepContinueWaitForEstablishWait() {
|
||||
return command.wait(
|
||||
"Connecting",
|
||||
"Establishing connection with the remote host, may take a while"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {stream.Sender} sender
|
||||
* @param {object} configInput
|
||||
*
|
||||
*/
|
||||
buildCommand(sender, configInput) {
|
||||
let self = this;
|
||||
|
||||
let config = {
|
||||
user: common.strToUint8Array(configInput.user),
|
||||
auth: getAuthMethodFromStr(configInput.authentication),
|
||||
host: address.parseHostPort(configInput.host, DEFAULT_PORT),
|
||||
fingerprint: configInput.fingerprint
|
||||
};
|
||||
|
||||
return new SSH(sender, config, {
|
||||
"initialization.failed"(hd) {
|
||||
switch (hd.data()) {
|
||||
case SERVER_REQUEST_ERROR_BAD_USERNAME:
|
||||
self.step.resolve(
|
||||
self.stepErrorDone("Request failed", "Invalid username")
|
||||
);
|
||||
return;
|
||||
|
||||
case SERVER_REQUEST_ERROR_BAD_ADDRESS:
|
||||
self.step.resolve(
|
||||
self.stepErrorDone("Request failed", "Invalid address")
|
||||
);
|
||||
return;
|
||||
|
||||
case SERVER_REQUEST_ERROR_BAD_AUTHMETHOD:
|
||||
self.step.resolve(
|
||||
self.stepErrorDone("Request failed", "Invalid authication method")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
self.step.resolve(
|
||||
self.stepErrorDone("Request failed", "Unknown error: " + hd.data())
|
||||
);
|
||||
},
|
||||
initialized(hd) {
|
||||
self.step.resolve(self.stepWaitForEstablishWait(configInput.host));
|
||||
},
|
||||
async "connect.failed"(rd) {
|
||||
let d = new TextDecoder("utf-8").decode(
|
||||
await reader.readCompletely(rd)
|
||||
);
|
||||
|
||||
self.step.resolve(self.stepErrorDone("Connection failed", d));
|
||||
},
|
||||
"connect.succeed"(rd, commandHandler) {
|
||||
self.connectionSucceed = true;
|
||||
|
||||
self.step.resolve(
|
||||
self.stepSuccessfulDone(
|
||||
new command.Result(
|
||||
configInput.user + "@" + configInput.host,
|
||||
self.info,
|
||||
self.controls.get("SSH", {
|
||||
send(data) {
|
||||
return commandHandler.sendData(data);
|
||||
},
|
||||
close() {
|
||||
return commandHandler.sendClose();
|
||||
},
|
||||
resize(rows, cols) {
|
||||
return commandHandler.sendResize(rows, cols);
|
||||
},
|
||||
events: commandHandler.events
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
self.history.save(
|
||||
self.info.name() + ":" + configInput.user + "@" + configInput.host,
|
||||
configInput.user + "@" + configInput.host,
|
||||
new Date(),
|
||||
self.info,
|
||||
configInput
|
||||
);
|
||||
},
|
||||
async "connect.fingerprint"(rd, sd) {
|
||||
self.step.resolve(
|
||||
await self.stepFingerprintPrompt(
|
||||
rd,
|
||||
sd,
|
||||
v => {
|
||||
if (!configInput.fingerprint) {
|
||||
return FingerprintPromptVerifyNoRecord;
|
||||
}
|
||||
|
||||
if (configInput.fingerprint === v) {
|
||||
return FingerprintPromptVerifyPassed;
|
||||
}
|
||||
|
||||
return FingerprintPromptVerifyMismatch;
|
||||
},
|
||||
newFingerprint => {
|
||||
configInput.fingerprint = newFingerprint;
|
||||
}
|
||||
)
|
||||
);
|
||||
},
|
||||
async "connect.credential"(rd, sd) {
|
||||
self.step.resolve(self.stepCredentialPrompt(rd, sd, config));
|
||||
},
|
||||
"@stdout"(rd) {},
|
||||
"@stderr"(rd) {},
|
||||
close() {},
|
||||
"@completed"() {
|
||||
self.step.resolve(
|
||||
self.stepErrorDone(
|
||||
"Operation has failed",
|
||||
"Connection has been cancelled"
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
stepInitialPrompt() {
|
||||
let self = this;
|
||||
|
||||
if (this.config) {
|
||||
self.hasStarted = true;
|
||||
|
||||
self.streams.request(COMMAND_ID, sd => {
|
||||
return self.buildCommand(sd, this.config);
|
||||
});
|
||||
|
||||
return self.stepWaitForAcceptWait();
|
||||
}
|
||||
|
||||
return command.prompt(
|
||||
"SSH",
|
||||
"Secure Shell Host",
|
||||
"Connect",
|
||||
r => {
|
||||
self.hasStarted = true;
|
||||
|
||||
self.streams.request(COMMAND_ID, sd => {
|
||||
return self.buildCommand(sd, {
|
||||
user: r.user,
|
||||
authentication: r.authentication,
|
||||
host: r.host,
|
||||
fingerprint: ""
|
||||
});
|
||||
});
|
||||
|
||||
self.step.resolve(self.stepWaitForAcceptWait());
|
||||
},
|
||||
() => {},
|
||||
command.fields(initialFieldDef, [
|
||||
{ name: "User" },
|
||||
{ name: "Host" },
|
||||
{ name: "Authentication" },
|
||||
{ name: "Notice" }
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
async stepFingerprintPrompt(rd, sd, verify, newFingerprint) {
|
||||
let self = this,
|
||||
fingerprintData = new TextDecoder("utf-8").decode(
|
||||
await reader.readCompletely(rd)
|
||||
),
|
||||
fingerprintChanged = false;
|
||||
|
||||
switch (verify(fingerprintData)) {
|
||||
case FingerprintPromptVerifyPassed:
|
||||
sd.send(CLIENT_CONNECT_RESPOND_FINGERPRINT, new Uint8Array([0]));
|
||||
|
||||
return this.stepContinueWaitForEstablishWait();
|
||||
|
||||
case FingerprintPromptVerifyMismatch:
|
||||
fingerprintChanged = true;
|
||||
}
|
||||
|
||||
return command.prompt(
|
||||
!fingerprintChanged
|
||||
? "Do you recognize this server?"
|
||||
: "Danger! Server fingerprint has changed!",
|
||||
!fingerprintChanged
|
||||
? "Verify server fingerprint displayed below"
|
||||
: "It's very unusual. Please verify the new server fingerprint below",
|
||||
!fingerprintChanged ? "Yes, I do" : "I'm aware of the change",
|
||||
r => {
|
||||
newFingerprint(fingerprintData);
|
||||
|
||||
sd.send(CLIENT_CONNECT_RESPOND_FINGERPRINT, new Uint8Array([0]));
|
||||
|
||||
self.step.resolve(self.stepContinueWaitForEstablishWait());
|
||||
},
|
||||
() => {
|
||||
sd.send(CLIENT_CONNECT_RESPOND_FINGERPRINT, new Uint8Array([1]));
|
||||
|
||||
self.step.resolve(
|
||||
command.wait("Rejecting", "Sending rejection to the backend")
|
||||
);
|
||||
},
|
||||
command.fields(initialFieldDef, [
|
||||
{
|
||||
name: "Fingerprint",
|
||||
value: fingerprintData
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
async stepCredentialPrompt(rd, sd, config) {
|
||||
let self = this,
|
||||
fieldName = "";
|
||||
|
||||
switch (config.auth) {
|
||||
case AUTHMETHOD_PASSPHARSE:
|
||||
fieldName = "Passpharse";
|
||||
break;
|
||||
|
||||
case AUTHMETHOD_PRIVATE_KEY:
|
||||
fieldName = "Private Key";
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Exception(
|
||||
"Prompt is not support by auth method: " + config.auth
|
||||
);
|
||||
}
|
||||
|
||||
return command.prompt(
|
||||
"Provide credential",
|
||||
"Please input your credential",
|
||||
"Login",
|
||||
r => {
|
||||
let vv = r[fieldName.toLowerCase()];
|
||||
|
||||
sd.send(
|
||||
CLIENT_CONNECT_RESPOND_CREDENTIAL,
|
||||
new TextEncoder("utf-8").encode(vv)
|
||||
);
|
||||
|
||||
self.step.resolve(self.stepContinueWaitForEstablishWait());
|
||||
},
|
||||
() => {
|
||||
sd.close();
|
||||
|
||||
self.step.resolve(
|
||||
command.wait(
|
||||
"Cancelling login",
|
||||
"Cancelling login request, please wait"
|
||||
)
|
||||
);
|
||||
},
|
||||
command.fields(initialFieldDef, [{ name: fieldName }])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class Command {
|
||||
constructor() {}
|
||||
|
||||
id() {
|
||||
return COMMAND_ID;
|
||||
}
|
||||
|
||||
name() {
|
||||
return "SSH";
|
||||
}
|
||||
|
||||
description() {
|
||||
return "Secure Shell Host";
|
||||
}
|
||||
|
||||
color() {
|
||||
return "#3c8";
|
||||
}
|
||||
|
||||
builder(info, config, streams, subs, controls, history) {
|
||||
return new Wizard(info, config, streams, subs, controls, history);
|
||||
}
|
||||
|
||||
launch(info, launcher, streams, subs, controls, history) {
|
||||
let matchResult = launcher.match(new RegExp("^(.*)\\@(.*)\\|(.*)$"));
|
||||
|
||||
if (!matchResult || matchResult.length !== 4) {
|
||||
throw new Exception('Given launcher "' + launcher + '" was malformed');
|
||||
}
|
||||
|
||||
let user = matchResult[1],
|
||||
host = matchResult[2],
|
||||
auth = matchResult[3];
|
||||
|
||||
try {
|
||||
initialFieldDef["User"].verify(user);
|
||||
initialFieldDef["Host"].verify(host);
|
||||
initialFieldDef["Authentication"].verify(auth);
|
||||
} catch (e) {
|
||||
throw new Exception(
|
||||
'Given launcher "' + launcher + '" was malformed ' + e
|
||||
);
|
||||
}
|
||||
|
||||
return this.builder(
|
||||
info,
|
||||
{
|
||||
user: user,
|
||||
host: host,
|
||||
authentication: auth
|
||||
},
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history
|
||||
);
|
||||
}
|
||||
|
||||
launcher(config) {
|
||||
return config.user + "@" + config.host + "|" + config.authentication;
|
||||
}
|
||||
}
|
||||
72
ui/commands/string.js
Normal file
72
ui/commands/string.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import * as reader from "../stream/reader.js";
|
||||
import * as integer from "./integer.js";
|
||||
|
||||
export class String {
|
||||
/**
|
||||
* Read String from given reader
|
||||
*
|
||||
* @param {reader.Reader} rd Source reader
|
||||
*
|
||||
* @returns {String} readed string
|
||||
*
|
||||
*/
|
||||
static async read(rd) {
|
||||
let l = new integer.Integer(0);
|
||||
|
||||
await l.unmarshal(rd);
|
||||
|
||||
return new String(await reader.readN(rd, l.value()));
|
||||
}
|
||||
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {Uint8Array} str String data
|
||||
*/
|
||||
constructor(str) {
|
||||
this.str = str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the string
|
||||
*
|
||||
* @returns {Uint8Array} String data
|
||||
*
|
||||
*/
|
||||
data() {
|
||||
return this.str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return serialized String as array
|
||||
*
|
||||
* @returns {Uint8Array} serialized String
|
||||
*
|
||||
*/
|
||||
buffer() {
|
||||
let lBytes = new integer.Integer(this.str.length).marshal(),
|
||||
buf = new Uint8Array(lBytes.length + this.str.length);
|
||||
|
||||
buf.set(lBytes, 0);
|
||||
buf.set(this.str, lBytes.length);
|
||||
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
265
ui/commands/string_test.js
Normal file
265
ui/commands/string_test.js
Normal file
@@ -0,0 +1,265 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import * as strings from "./string.js";
|
||||
import * as reader from "../stream/reader.js";
|
||||
import assert from "assert";
|
||||
|
||||
describe("String", () => {
|
||||
it("String 1", async () => {
|
||||
let s = new strings.String(new Uint8Array(["H", "E", "L", "L", "O"])),
|
||||
sBuf = s.buffer();
|
||||
|
||||
let r = new reader.Reader(new reader.Multiple(() => {}), data => {
|
||||
return data;
|
||||
});
|
||||
|
||||
r.feed(sBuf);
|
||||
|
||||
let s2 = await strings.String.read(r);
|
||||
|
||||
assert.deepEqual(s2.data(), s.data());
|
||||
});
|
||||
|
||||
it("String 2", async () => {
|
||||
let s = new strings.String(
|
||||
new Uint8Array([
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D"
|
||||
])
|
||||
),
|
||||
sBuf = s.buffer();
|
||||
|
||||
let r = new reader.Reader(new reader.Multiple(() => {}), data => {
|
||||
return data;
|
||||
});
|
||||
|
||||
r.feed(sBuf);
|
||||
|
||||
let s2 = await strings.String.read(r);
|
||||
|
||||
assert.deepEqual(s2.data(), s.data());
|
||||
});
|
||||
});
|
||||
430
ui/commands/telnet.js
Normal file
430
ui/commands/telnet.js
Normal file
@@ -0,0 +1,430 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import * as address from "./address.js";
|
||||
import * as command from "./commands.js";
|
||||
import * as common from "./common.js";
|
||||
import * as event from "./events.js";
|
||||
import * as reader from "../stream/reader.js";
|
||||
import * as stream from "../stream/stream.js";
|
||||
import * as controls from "./controls.js";
|
||||
import * as history from "./history.js";
|
||||
import * as header from "../stream/header.js";
|
||||
import Exception from "./exception.js";
|
||||
|
||||
const COMMAND_ID = 0x00;
|
||||
|
||||
const SERVER_INITIAL_ERROR_BAD_ADDRESS = 0x01;
|
||||
|
||||
const SERVER_REMOTE_BAND = 0x00;
|
||||
const SERVER_DIAL_FAILED = 0x01;
|
||||
const SERVER_DIAL_CONNECTED = 0x02;
|
||||
|
||||
const DEFAULT_PORT = 23;
|
||||
|
||||
class Telnet {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {stream.Sender} sd Stream sender
|
||||
* @param {object} config configuration
|
||||
* @param {object} callbacks Event callbacks
|
||||
*
|
||||
*/
|
||||
constructor(sd, config, callbacks) {
|
||||
this.sender = sd;
|
||||
this.config = config;
|
||||
this.connected = false;
|
||||
this.events = new event.Events(
|
||||
[
|
||||
"initialization.failed",
|
||||
"initialized",
|
||||
"connect.failed",
|
||||
"connect.succeed",
|
||||
"@inband",
|
||||
"close",
|
||||
"@completed"
|
||||
],
|
||||
callbacks
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send intial request
|
||||
*
|
||||
* @param {stream.InitialSender} initialSender Initial stream request sender
|
||||
*
|
||||
*/
|
||||
run(initialSender) {
|
||||
let addr = new address.Address(
|
||||
this.config.host.type,
|
||||
this.config.host.address,
|
||||
this.config.host.port
|
||||
),
|
||||
addrBuf = addr.buffer();
|
||||
|
||||
let data = new Uint8Array(addrBuf.length);
|
||||
|
||||
data.set(addrBuf, 0);
|
||||
|
||||
initialSender.send(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive the initial stream request
|
||||
*
|
||||
* @param {header.InitialStream} streamInitialHeader Server respond on the
|
||||
* initial stream request
|
||||
*
|
||||
*/
|
||||
initialize(streamInitialHeader) {
|
||||
if (!streamInitialHeader.success()) {
|
||||
this.events.fire("initialization.failed", streamInitialHeader);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.events.fire("initialized", streamInitialHeader);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tick the command
|
||||
*
|
||||
* @param {header.Stream} streamHeader Stream data header
|
||||
* @param {reader.Limited} rd Data reader
|
||||
*
|
||||
* @returns {any} The result of the ticking
|
||||
*
|
||||
* @throws {Exception} When the stream header type is unknown
|
||||
*
|
||||
*/
|
||||
tick(streamHeader, rd) {
|
||||
switch (streamHeader.marker()) {
|
||||
case SERVER_DIAL_CONNECTED:
|
||||
if (!this.connected) {
|
||||
this.connected = true;
|
||||
|
||||
return this.events.fire("connect.succeed", rd, this);
|
||||
}
|
||||
break;
|
||||
|
||||
case SERVER_DIAL_FAILED:
|
||||
if (!this.connected) {
|
||||
return this.events.fire("connect.failed", rd);
|
||||
}
|
||||
break;
|
||||
|
||||
case SERVER_REMOTE_BAND:
|
||||
if (this.connected) {
|
||||
return this.events.fire("inband", rd);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
throw new Exception("Unknown stream header marker");
|
||||
}
|
||||
|
||||
/**
|
||||
* Send close signal to remote
|
||||
*
|
||||
*/
|
||||
sendClose() {
|
||||
return this.sender.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send data to remote
|
||||
*
|
||||
* @param {Uint8Array} data
|
||||
*
|
||||
*/
|
||||
sendData(data) {
|
||||
return this.sender.send(0x00, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the command
|
||||
*
|
||||
*/
|
||||
close() {
|
||||
this.sendClose();
|
||||
|
||||
return this.events.fire("close");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the command completely
|
||||
*
|
||||
*/
|
||||
completed() {
|
||||
return this.events.fire("completed");
|
||||
}
|
||||
}
|
||||
|
||||
const initialFieldDef = {
|
||||
Host: {
|
||||
name: "Host",
|
||||
description:
|
||||
"Looking for server to connect? Checkout " +
|
||||
'<a href="http://www.telnet.org/htm/places.htm" target="blank">' +
|
||||
"telnet.org</a> for public servers.",
|
||||
type: "text",
|
||||
value: "",
|
||||
example: "telnet.vaguly.com:23",
|
||||
verify(d) {
|
||||
if (d.length <= 0) {
|
||||
throw new Error("Hostname must be specified");
|
||||
}
|
||||
|
||||
let addr = common.splitHostPort(d, DEFAULT_PORT);
|
||||
|
||||
if (addr.addr.length <= 0) {
|
||||
throw new Error("Cannot be empty");
|
||||
}
|
||||
|
||||
if (addr.addr.length > address.MAX_ADDR_LEN) {
|
||||
throw new Error(
|
||||
"Can no longer than " + address.MAX_ADDR_LEN + " bytes"
|
||||
);
|
||||
}
|
||||
|
||||
if (addr.port <= 0) {
|
||||
throw new Error("Port must be specified");
|
||||
}
|
||||
|
||||
return "Look like " + addr.type + " address";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class Wizard {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {command.Info} info
|
||||
* @param {object} config
|
||||
* @param {streams.Streams} streams
|
||||
* @param {subscribe.Subscribe} subs
|
||||
* @param {controls.Controls} controls
|
||||
* @param {history.History} history
|
||||
*
|
||||
*/
|
||||
constructor(info, config, streams, subs, controls, history) {
|
||||
this.info = info;
|
||||
this.hasStarted = false;
|
||||
this.streams = streams;
|
||||
this.config = config;
|
||||
this.step = subs;
|
||||
this.controls = controls;
|
||||
this.history = history;
|
||||
|
||||
this.step.resolve(this.stepInitialPrompt());
|
||||
}
|
||||
|
||||
started() {
|
||||
return this.hasStarted;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.step.resolve(
|
||||
this.stepErrorDone(
|
||||
"Action cancelled",
|
||||
"Action has been cancelled without reach any success"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
stepErrorDone(title, message) {
|
||||
return command.done(false, null, title, message);
|
||||
}
|
||||
|
||||
stepSuccessfulDone(data) {
|
||||
return command.done(
|
||||
true,
|
||||
data,
|
||||
"Success!",
|
||||
"We have connected to the remote"
|
||||
);
|
||||
}
|
||||
|
||||
stepWaitForAcceptWait() {
|
||||
return command.wait(
|
||||
"Requesting",
|
||||
"Waiting for request to be accepted by the backend"
|
||||
);
|
||||
}
|
||||
|
||||
stepWaitForEstablishWait(host) {
|
||||
return command.wait(
|
||||
"Connecting to " + host,
|
||||
"Establishing connection with the remote host, may take a while"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {stream.Sender} sender
|
||||
* @param {object} configInput
|
||||
*
|
||||
*/
|
||||
buildCommand(sender, configInput) {
|
||||
let self = this;
|
||||
|
||||
let parsedConfig = {
|
||||
host: address.parseHostPort(configInput.host, DEFAULT_PORT)
|
||||
};
|
||||
|
||||
return new Telnet(sender, parsedConfig, {
|
||||
"initialization.failed"(streamInitialHeader) {
|
||||
switch (streamInitialHeader.data()) {
|
||||
case SERVER_INITIAL_ERROR_BAD_ADDRESS:
|
||||
self.step.resolve(
|
||||
self.stepErrorDone("Request rejected", "Invalid address")
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
self.step.resolve(
|
||||
self.stepErrorDone(
|
||||
"Request rejected",
|
||||
"Unknown error code: " + streamInitialHeader.data()
|
||||
)
|
||||
);
|
||||
},
|
||||
initialized(streamInitialHeader) {
|
||||
self.step.resolve(self.stepWaitForEstablishWait(configInput.host));
|
||||
},
|
||||
"connect.succeed"(rd, commandHandler) {
|
||||
self.step.resolve(
|
||||
self.stepSuccessfulDone(
|
||||
new command.Result(
|
||||
configInput.host,
|
||||
self.info,
|
||||
self.controls.get("Telnet", {
|
||||
send(data) {
|
||||
return commandHandler.sendData(data);
|
||||
},
|
||||
close() {
|
||||
return commandHandler.sendClose();
|
||||
},
|
||||
events: commandHandler.events
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
self.history.save(
|
||||
self.info.name() + ":" + configInput.host,
|
||||
configInput.host,
|
||||
new Date(),
|
||||
self.info,
|
||||
configInput
|
||||
);
|
||||
},
|
||||
async "connect.failed"(rd) {
|
||||
let readed = await reader.readCompletely(rd),
|
||||
message = new TextDecoder("utf-8").decode(readed.buffer);
|
||||
|
||||
self.step.resolve(self.stepErrorDone("Connection failed", message));
|
||||
},
|
||||
"@inband"(rd) {},
|
||||
close() {},
|
||||
"@completed"() {}
|
||||
});
|
||||
}
|
||||
|
||||
stepInitialPrompt() {
|
||||
let self = this;
|
||||
|
||||
if (this.config) {
|
||||
self.hasStarted = true;
|
||||
|
||||
self.streams.request(COMMAND_ID, sd => {
|
||||
return self.buildCommand(sd, this.config);
|
||||
});
|
||||
|
||||
return self.stepWaitForAcceptWait();
|
||||
}
|
||||
|
||||
return command.prompt(
|
||||
"Telnet",
|
||||
"Teletype Network",
|
||||
"Connect",
|
||||
r => {
|
||||
self.hasStarted = true;
|
||||
|
||||
self.streams.request(COMMAND_ID, sd => {
|
||||
return self.buildCommand(sd, r);
|
||||
});
|
||||
|
||||
self.step.resolve(self.stepWaitForAcceptWait());
|
||||
},
|
||||
() => {},
|
||||
command.fields(initialFieldDef, [{ name: "Host" }])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class Command {
|
||||
constructor() {}
|
||||
|
||||
id() {
|
||||
return COMMAND_ID;
|
||||
}
|
||||
|
||||
name() {
|
||||
return "Telnet";
|
||||
}
|
||||
|
||||
description() {
|
||||
return "Teletype Network";
|
||||
}
|
||||
|
||||
color() {
|
||||
return "#6ac";
|
||||
}
|
||||
|
||||
builder(info, config, streams, subs, controls, history) {
|
||||
return new Wizard(info, config, streams, subs, controls, history);
|
||||
}
|
||||
|
||||
launch(info, launcher, streams, subs, controls, history) {
|
||||
try {
|
||||
initialFieldDef["Host"].verify(launcher);
|
||||
} catch (e) {
|
||||
throw new Exception(
|
||||
'Given launcher "' + launcher + '" was invalid: ' + e
|
||||
);
|
||||
}
|
||||
|
||||
return this.builder(
|
||||
info,
|
||||
{
|
||||
host: launcher
|
||||
},
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history
|
||||
);
|
||||
}
|
||||
|
||||
launcher(config) {
|
||||
return config.host;
|
||||
}
|
||||
}
|
||||
542
ui/common.css
Normal file
542
ui/common.css
Normal file
@@ -0,0 +1,542 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@charset "UTF-8";
|
||||
|
||||
@import "~normalize.css";
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #444;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 1em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tab1 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
justify-items: center;
|
||||
list-style: none;
|
||||
list-style-position: inside;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tab1 > li {
|
||||
padding: 0 15px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
word-wrap: none;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
flex: initial;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab1 > li.active {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.tab1.tab1-list {
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab1.tab1-list > li {
|
||||
flex: 0 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tab2 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
justify-items: center;
|
||||
list-style: none;
|
||||
list-style-position: inside;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
color: #aaa;
|
||||
border-bottom: 1px solid #a56;
|
||||
background: #333;
|
||||
padding: 0 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab2::before {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -3px 3px #0003;
|
||||
}
|
||||
|
||||
.tab2 > li {
|
||||
flex: auto;
|
||||
cursor: pointer;
|
||||
border-color: transparent;
|
||||
border-width: 1px 1px 0 1px;
|
||||
border-style: solid;
|
||||
padding: 7px 10px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tab2 > li.active {
|
||||
color: #fff;
|
||||
background: #644;
|
||||
margin-bottom: -1px;
|
||||
border-color: #a56;
|
||||
border-style: solid;
|
||||
box-shadow: 0 -2px 2px #0002;
|
||||
}
|
||||
|
||||
/* List */
|
||||
.lstcl1 {
|
||||
list-style: none;
|
||||
list-style-position: inside;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.lstcl1 > li {
|
||||
width: 33%;
|
||||
float: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lstcl1 > li .lst-wrap {
|
||||
padding: 10px;
|
||||
margin: 10px;
|
||||
background: #333;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
box-shadow: 2px 2px 0 0 #222;
|
||||
}
|
||||
|
||||
.lstcl1 > li .lst-wrap:hover {
|
||||
background: #3a3a3a;
|
||||
box-shadow: 2px 2px 0 0 #222;
|
||||
}
|
||||
|
||||
.lstcl1 > li .lst-wrap:active {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.lst1 {
|
||||
list-style: none;
|
||||
list-style-position: inside;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lst1 > li {
|
||||
border-bottom: 1px solid #555;
|
||||
}
|
||||
|
||||
.lst1 > li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.lst1 > li .lst-wrap {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* Icon */
|
||||
.icon {
|
||||
line-height: 1;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.icon.icon-close1 {
|
||||
margin-top: -2.5px;
|
||||
font-size: 26px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.icon.icon-close1::before {
|
||||
content: "\00d7";
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.icon.icon-plus1 {
|
||||
color: #fff;
|
||||
background: #a56;
|
||||
}
|
||||
|
||||
.icon.icon-plus1::before {
|
||||
content: "+";
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.icon.icon-more1 {
|
||||
color: #fff;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.icon.icon-more1::before {
|
||||
content: "\2261";
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.icon.icon-warning1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.icon.icon-warning1::after {
|
||||
content: "!";
|
||||
font-weight: bold;
|
||||
background: #e11;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.icon.icon-point1 {
|
||||
position: relative;
|
||||
text-shadow: 0 0 3px #fff;
|
||||
}
|
||||
|
||||
.icon.icon-point1::after {
|
||||
content: "\25CF";
|
||||
}
|
||||
|
||||
/* Windows */
|
||||
.window {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.window.window1 {
|
||||
background: #a56;
|
||||
box-shadow: 0 0 5px #0006;
|
||||
color: #fff;
|
||||
font-size: 1em;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.window.window1.display {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.window.window1::before {
|
||||
top: -5px;
|
||||
position: absolute;
|
||||
display: block;
|
||||
content: " ";
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #a56;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.window.window1 .window-frame {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.window.window1 .window-title {
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: #e9a;
|
||||
}
|
||||
|
||||
.window.window1 .window-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
border-color: #844;
|
||||
cursor: pointer;
|
||||
}
|
||||
.window.window1 .window-close::after {
|
||||
border-color: #844;
|
||||
}
|
||||
|
||||
/* Form1 */
|
||||
.form1 {
|
||||
}
|
||||
|
||||
.form1 > fieldset,
|
||||
.form1 > fieldset * {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
outline: none;
|
||||
border: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field {
|
||||
color: #ccc;
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field.horizontal {
|
||||
width: auto;
|
||||
float: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.form1 > fieldset .items {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field.horizontal.item {
|
||||
margin-top: 3px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field.horizontal:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field,
|
||||
.form1 > fieldset .field input,
|
||||
.form1 > fieldset .field textarea,
|
||||
.form1 > fieldset .field button {
|
||||
vertical-align: middle;
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field {
|
||||
font-size: 0.95em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > .textinfo {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > .textinfo > .info {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
font-size: 1.1em;
|
||||
background: #292929;
|
||||
border: 1px solid #444;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input,
|
||||
.form1 > fieldset .field > textarea,
|
||||
.form1 > fieldset .field > button {
|
||||
box-sizing: border-box;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input:focus::placeholder {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input[type="text"],
|
||||
.form1 > fieldset .field > input[type="email"],
|
||||
.form1 > fieldset .field > input[type="number"],
|
||||
.form1 > fieldset .field > input[type="search"],
|
||||
.form1 > fieldset .field > input[type="tel"],
|
||||
.form1 > fieldset .field > input[type="url"],
|
||||
.form1 > fieldset .field > input[type="password"],
|
||||
.form1 > fieldset .field > textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 0;
|
||||
background: #2e2e2e;
|
||||
margin-top: 5px;
|
||||
border-bottom: 2px solid #3e3e3e;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > textarea {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field.error > .error {
|
||||
margin-top: 5px;
|
||||
color: #f55;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > .message {
|
||||
margin-top: 5px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > .message * {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > .message > p {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > .message > a {
|
||||
color: #e9a;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field.error > input[type="text"],
|
||||
.form1 > fieldset .field.error > input[type="email"],
|
||||
.form1 > fieldset .field.error > input[type="number"],
|
||||
.form1 > fieldset .field.error > input[type="search"],
|
||||
.form1 > fieldset .field.error > input[type="tel"],
|
||||
.form1 > fieldset .field.error > input[type="url"],
|
||||
.form1 > fieldset .field.error > input[type="password"],
|
||||
.form1 > fieldset .field.error > textarea {
|
||||
background: #483535;
|
||||
border-bottom: 2px solid #a83333;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input:disabled,
|
||||
.form1 > fieldset .field > textarea:disabled,
|
||||
.form1 > fieldset .field > button:disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input:disabled:active,
|
||||
.form1 > fieldset .field > textarea:disabled:active,
|
||||
.form1 > fieldset .field > button:disabled:active {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input[type="checkbox"],
|
||||
.form1 > fieldset .field > input[type="radio"] {
|
||||
background: #2e2e2e;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input[type="checkbox"]:focus,
|
||||
.form1 > fieldset .field > input[type="radio"]:focus {
|
||||
outline: 1px solid #e9a;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input[type="text"]:focus,
|
||||
.form1 > fieldset .field > input[type="email"]:focus,
|
||||
.form1 > fieldset .field > input[type="number"]:focus,
|
||||
.form1 > fieldset .field > input[type="search"]:focus,
|
||||
.form1 > fieldset .field > input[type="tel"]:focus,
|
||||
.form1 > fieldset .field > input[type="url"]:focus,
|
||||
.form1 > fieldset .field > input[type="password"]:focus,
|
||||
.form1 > fieldset .field > textarea:focus {
|
||||
background: #222;
|
||||
border-bottom: 2px solid #e9a;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > button {
|
||||
padding: 8px 13px;
|
||||
font-weight: normal;
|
||||
background: #c78;
|
||||
border-color: #c78;
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > button:hover {
|
||||
border-color: #a56;
|
||||
background: #c78;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > button:active {
|
||||
background: #a56;
|
||||
border-color: #a56;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > button:focus {
|
||||
outline: 1px dotted #eee;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > button.secondary {
|
||||
float: right;
|
||||
background: transparent;
|
||||
color: #eee;
|
||||
border-color: #eee;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > button.secondary:hover {
|
||||
border-color: #ddd;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > button.secondary:active {
|
||||
border-color: #666;
|
||||
color: #666;
|
||||
}
|
||||
132
ui/control/ssh.js
Normal file
132
ui/control/ssh.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import * as subscribe from "../stream/subscribe.js";
|
||||
import * as reader from "../stream/reader.js";
|
||||
import * as color from "../commands/color.js";
|
||||
|
||||
class Control {
|
||||
constructor(data, color) {
|
||||
this.colorM = color;
|
||||
this.colors = this.colorM.get();
|
||||
|
||||
this.enable = false;
|
||||
this.sender = data.send;
|
||||
this.closer = data.close;
|
||||
this.resizer = data.resize;
|
||||
this.subs = new subscribe.Subscribe();
|
||||
|
||||
let self = this;
|
||||
|
||||
data.events.place("stdout", async rd => {
|
||||
try {
|
||||
self.subs.resolve(await reader.readCompletely(rd));
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
});
|
||||
|
||||
data.events.place("stderr", async rd => {
|
||||
try {
|
||||
self.subs.resolve(await reader.readCompletely(rd));
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
});
|
||||
|
||||
data.events.place("completed", () => {
|
||||
self.closed = true;
|
||||
self.colorM.forget(self.colors.color);
|
||||
|
||||
self.subs.reject("Remote connection has been terminated");
|
||||
});
|
||||
}
|
||||
|
||||
echo() {
|
||||
return false;
|
||||
}
|
||||
|
||||
resize(dim) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resizer(dim.rows, dim.cols);
|
||||
}
|
||||
|
||||
ui() {
|
||||
return "Console";
|
||||
}
|
||||
|
||||
enabled() {
|
||||
this.enable = true;
|
||||
}
|
||||
|
||||
disabled() {
|
||||
this.enable = false;
|
||||
}
|
||||
|
||||
receive() {
|
||||
return this.subs.subscribe();
|
||||
}
|
||||
|
||||
send(data) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.sender(new TextEncoder("utf-8").encode(data));
|
||||
}
|
||||
|
||||
color() {
|
||||
return this.colors.dark;
|
||||
}
|
||||
|
||||
activeColor() {
|
||||
return this.colors.color;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.closer === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cc = this.closer;
|
||||
this.closer = null;
|
||||
|
||||
return cc();
|
||||
}
|
||||
}
|
||||
|
||||
export class SSH {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {color.Color} c
|
||||
*/
|
||||
constructor(c) {
|
||||
this.color = c;
|
||||
}
|
||||
|
||||
type() {
|
||||
return "SSH";
|
||||
}
|
||||
|
||||
build(data) {
|
||||
return new Control(data, this.color);
|
||||
}
|
||||
}
|
||||
451
ui/control/telnet.js
Normal file
451
ui/control/telnet.js
Normal file
@@ -0,0 +1,451 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import * as subscribe from "../stream/subscribe.js";
|
||||
import * as reader from "../stream/reader.js";
|
||||
import * as color from "../commands/color.js";
|
||||
import Exception from "../commands/exception.js";
|
||||
|
||||
// const maxReadBufSize = 1024;
|
||||
|
||||
const cmdSE = 240;
|
||||
// const cmdNOP = 241;
|
||||
// const cmdDataMark = 242;
|
||||
// const cmdBreak = 243;
|
||||
// const cmdInterrputProcess = 244;
|
||||
// const cmdAbortOutput = 245;
|
||||
// const cmdAreYouThere = 246;
|
||||
// const cmdEraseCharacter = 247;
|
||||
// const cmdEraseLine = 248;
|
||||
const cmdGoAhead = 249;
|
||||
const cmdSB = 250;
|
||||
const cmdWill = 251;
|
||||
const cmdWont = 252;
|
||||
const cmdDo = 253;
|
||||
const cmdDont = 254;
|
||||
const cmdIAC = 255;
|
||||
|
||||
const optEcho = 1;
|
||||
const optSuppressGoAhead = 3;
|
||||
const optTerminalType = 24;
|
||||
const optNAWS = 31;
|
||||
|
||||
const optTerminalTypeIs = 0;
|
||||
const optTerminalTypeSend = 1;
|
||||
|
||||
const unknownTermTypeSendData = new Uint8Array([
|
||||
optTerminalTypeIs,
|
||||
88,
|
||||
84,
|
||||
69,
|
||||
82,
|
||||
77
|
||||
]);
|
||||
|
||||
// Most of code of this class is directly from
|
||||
// https://github.com/ziutek/telnet/blob/master/conn.go#L122
|
||||
// Thank you!
|
||||
class Parser {
|
||||
constructor(sender, flusher, callbacks) {
|
||||
this.sender = sender;
|
||||
this.flusher = flusher;
|
||||
this.callbacks = callbacks;
|
||||
this.reader = new reader.Multiple(() => {});
|
||||
this.options = {
|
||||
echoEnabled: false,
|
||||
suppressGoAhead: false,
|
||||
nawsAccpeted: false
|
||||
};
|
||||
this.current = 0;
|
||||
}
|
||||
|
||||
sendNego(cmd, option) {
|
||||
return this.sender(new Uint8Array([cmdIAC, cmd, option]));
|
||||
}
|
||||
|
||||
sendDeny(cmd, o) {
|
||||
switch (cmd) {
|
||||
case cmdDo:
|
||||
return this.sendNego(cmdWont, o);
|
||||
|
||||
case (cmdWill, cmdWont):
|
||||
return this.sendNego(cmdDont, o);
|
||||
}
|
||||
}
|
||||
|
||||
sendWillSubNego(willCmd, data, option) {
|
||||
let b = new Uint8Array(6 + data.length + 2);
|
||||
|
||||
b.set([cmdIAC, willCmd, option, cmdIAC, cmdSB, option], 0);
|
||||
b.set(data, 6);
|
||||
b.set([cmdIAC, cmdSE], data.length + 6);
|
||||
|
||||
return this.sender(b);
|
||||
}
|
||||
|
||||
sendSubNego(data, option) {
|
||||
let b = new Uint8Array(3 + data.length + 2);
|
||||
|
||||
b.set([cmdIAC, cmdSB, option], 0);
|
||||
b.set(data, 3);
|
||||
b.set([cmdIAC, cmdSE], data.length + 3);
|
||||
|
||||
return this.sender(b);
|
||||
}
|
||||
|
||||
async handleTermTypeSubNego(rd) {
|
||||
let action = await reader.readOne(rd);
|
||||
|
||||
if (action[0] !== optTerminalTypeSend) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let self = this;
|
||||
|
||||
return () => {
|
||||
self.sendSubNego(unknownTermTypeSendData, optTerminalType);
|
||||
};
|
||||
}
|
||||
|
||||
async handleSubNego(rd) {
|
||||
let endExec = null;
|
||||
|
||||
for (;;) {
|
||||
let d = await reader.readOne(rd);
|
||||
|
||||
switch (d[0]) {
|
||||
case optTerminalType:
|
||||
endExec = await this.handleTermTypeSubNego(rd);
|
||||
continue;
|
||||
|
||||
case cmdIAC:
|
||||
break;
|
||||
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
|
||||
let e = await reader.readOne(rd);
|
||||
|
||||
if (e[0] !== cmdSE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (endExec !== null) {
|
||||
endExec();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handleOption(cmd, option, oldVal, newVal) {
|
||||
switch (cmd) {
|
||||
case cmdWill:
|
||||
if (!oldVal) {
|
||||
this.sendNego(cmdDo, option);
|
||||
|
||||
newVal(true);
|
||||
}
|
||||
return;
|
||||
|
||||
case cmdWont:
|
||||
if (oldVal) {
|
||||
this.sendNego(cmdDont, option);
|
||||
|
||||
newVal(false);
|
||||
}
|
||||
return;
|
||||
|
||||
case cmdDo:
|
||||
if (!oldVal) {
|
||||
this.sendNego(cmdWill, option);
|
||||
|
||||
newVal(true);
|
||||
}
|
||||
return;
|
||||
|
||||
case cmdDont:
|
||||
if (oldVal) {
|
||||
this.sendNego(cmdWont, option);
|
||||
|
||||
newVal(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async handleCmd(rd) {
|
||||
let d = await reader.readOne(rd);
|
||||
|
||||
switch (d[0]) {
|
||||
case cmdWill:
|
||||
case cmdWont:
|
||||
case cmdDo:
|
||||
case cmdDont:
|
||||
break;
|
||||
|
||||
case cmdIAC:
|
||||
this.flusher(d);
|
||||
return;
|
||||
|
||||
case cmdGoAhead:
|
||||
return;
|
||||
|
||||
case cmdSB:
|
||||
await this.handleSubNego(rd);
|
||||
return;
|
||||
|
||||
default:
|
||||
throw new Exception("Unknown command");
|
||||
}
|
||||
|
||||
let o = await reader.readOne(rd);
|
||||
|
||||
switch (o[0]) {
|
||||
case optEcho:
|
||||
return this.handleOption(d[0], o[0], this.options.echoEnabled, d => {
|
||||
this.options.echoEnabled = d;
|
||||
|
||||
this.callbacks.setEcho(this.options.echoEnabled);
|
||||
});
|
||||
|
||||
case optSuppressGoAhead:
|
||||
return this.handleOption(
|
||||
d[0],
|
||||
o[0],
|
||||
this.options.suppressGoAhead,
|
||||
d => {
|
||||
this.options.suppressGoAhead = d;
|
||||
}
|
||||
);
|
||||
|
||||
case optNAWS:
|
||||
// Window resize allowed?
|
||||
if (d[0] !== cmdDo) {
|
||||
this.sendDeny(d[0], o[0]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let dim = this.callbacks.getWindowDim(),
|
||||
dimData = new DataView(new ArrayBuffer(4));
|
||||
|
||||
dimData.setUint16(0, dim.cols);
|
||||
dimData.setUint16(2, dim.rows);
|
||||
|
||||
let dimBytes = new Uint8Array(dimData.buffer);
|
||||
|
||||
if (this.options.nawsAccpeted) {
|
||||
this.sendSubNego(dimBytes, optNAWS);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.options.nawsAccpeted = true;
|
||||
this.sendWillSubNego(cmdWill, dimBytes, optNAWS);
|
||||
return;
|
||||
|
||||
case optTerminalType:
|
||||
if (d[0] !== cmdDo) {
|
||||
this.sendDeny(d[0], o[0]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendNego(cmdWill, o[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendDeny(d[0], o[0]);
|
||||
}
|
||||
|
||||
requestWindowResize() {
|
||||
this.options.nawsAccpeted = true;
|
||||
|
||||
this.sendNego(cmdWill, optNAWS);
|
||||
}
|
||||
|
||||
async run() {
|
||||
try {
|
||||
for (;;) {
|
||||
let d = await reader.readUntil(this.reader, cmdIAC);
|
||||
|
||||
if (!d.found) {
|
||||
this.flusher(d.data);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (d.data.length > 1) {
|
||||
this.flusher(d.data.slice(0, d.data.length - 1));
|
||||
}
|
||||
|
||||
await this.handleCmd(this.reader);
|
||||
}
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
feed(rd, cb) {
|
||||
this.reader.feed(rd, cb);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.reader.close();
|
||||
}
|
||||
}
|
||||
|
||||
class Control {
|
||||
constructor(data, color) {
|
||||
this.colorM = color;
|
||||
this.colors = this.colorM.get();
|
||||
|
||||
this.sender = data.send;
|
||||
this.closer = data.close;
|
||||
this.closed = false;
|
||||
this.echoEnabled = true;
|
||||
this.subs = new subscribe.Subscribe();
|
||||
this.enable = false;
|
||||
this.windowDim = {
|
||||
cols: 65535,
|
||||
rows: 65535
|
||||
};
|
||||
|
||||
let self = this;
|
||||
|
||||
this.parser = new Parser(
|
||||
this.sender,
|
||||
d => {
|
||||
self.subs.resolve(d);
|
||||
},
|
||||
{
|
||||
setEcho(newVal) {
|
||||
if (newVal) {
|
||||
self.echoEnabled = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
self.echoEnabled = true;
|
||||
},
|
||||
getWindowDim() {
|
||||
return self.windowDim;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
let runWait = this.parser.run();
|
||||
|
||||
data.events.place("inband", rd => {
|
||||
return new Promise((resolve, reject) => {
|
||||
self.parser.feed(rd, () => {
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
data.events.place("completed", async () => {
|
||||
self.parser.close();
|
||||
self.closed = true;
|
||||
|
||||
self.colorM.forget(self.colors.color);
|
||||
|
||||
await runWait;
|
||||
|
||||
self.subs.reject("Remote connection has been terminated");
|
||||
});
|
||||
}
|
||||
|
||||
echo() {
|
||||
return this.echoEnabled;
|
||||
}
|
||||
|
||||
resize(dim) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.windowDim.cols = dim.cols;
|
||||
this.windowDim.rows = dim.rows;
|
||||
|
||||
this.parser.requestWindowResize();
|
||||
}
|
||||
|
||||
ui() {
|
||||
return "Console";
|
||||
}
|
||||
|
||||
enabled() {
|
||||
this.enable = true;
|
||||
}
|
||||
|
||||
disabled() {
|
||||
this.enable = false;
|
||||
}
|
||||
|
||||
receive() {
|
||||
return this.subs.subscribe();
|
||||
}
|
||||
|
||||
send(data) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.sender(new TextEncoder("utf-8").encode(data));
|
||||
}
|
||||
|
||||
color() {
|
||||
return this.colors.dark;
|
||||
}
|
||||
|
||||
activeColor() {
|
||||
return this.colors.color;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.closer === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cc = this.closer;
|
||||
this.closer = null;
|
||||
|
||||
return cc();
|
||||
}
|
||||
}
|
||||
|
||||
export class Telnet {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {color.Color} c
|
||||
*/
|
||||
constructor(c) {
|
||||
this.color = c;
|
||||
}
|
||||
|
||||
type() {
|
||||
return "Telnet";
|
||||
}
|
||||
|
||||
build(data) {
|
||||
return new Control(data, this.color);
|
||||
}
|
||||
}
|
||||
103
ui/crypto.js
Normal file
103
ui/crypto.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Generate HMAC 512 of given data
|
||||
*
|
||||
* @param {Uint8Array} secret Secret key
|
||||
* @param {Uint8Array} data Data to be HMAC'ed
|
||||
*/
|
||||
export async function hmac512(secret, data) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
secret,
|
||||
{
|
||||
name: "HMAC",
|
||||
hash: { name: "SHA-512" }
|
||||
},
|
||||
false,
|
||||
["sign", "verify"]
|
||||
);
|
||||
|
||||
return crypto.subtle.sign("HMAC", key, data);
|
||||
}
|
||||
|
||||
export const GCMNonceSize = 12;
|
||||
|
||||
/**
|
||||
* Build AES GCM Encryption/Decryption key
|
||||
*
|
||||
* @param {Uint8Array} keyData Key data
|
||||
*/
|
||||
export function buildGCMKey(keyData) {
|
||||
return crypto.subtle.importKey("raw", keyData, "aes-gcm", false, [
|
||||
"encrypt",
|
||||
"decrypt"
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt data
|
||||
*
|
||||
* @param {CryptoKey} key Key
|
||||
* @param {Uint8Array} iv Nonce
|
||||
* @param {Uint8Array} plaintext Data to be encrypted
|
||||
*/
|
||||
export function encryptGCM(key, iv, plaintext) {
|
||||
return crypto.subtle.encrypt({ name: "aes-gcm", iv: iv }, key, plaintext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data
|
||||
*
|
||||
* @param {CryptoKey} key Key
|
||||
* @param {Uint8Array} iv Nonce
|
||||
* @param {Uint8Array} cipherText Data to be decrypted
|
||||
*/
|
||||
export function decryptGCM(key, iv, cipherText) {
|
||||
return crypto.subtle.decrypt({ name: "aes-gcm", iv: iv }, key, cipherText);
|
||||
}
|
||||
|
||||
/**
|
||||
* generate Random nonce
|
||||
*
|
||||
*/
|
||||
export function generateNonce() {
|
||||
return crypto.getRandomValues(new Uint8Array(GCMNonceSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* Increase nonce by one
|
||||
*
|
||||
* @param {Uint8Array} nonce Nonce data
|
||||
*
|
||||
* @returns {Uint8Array} New nonce
|
||||
*
|
||||
*/
|
||||
export function increaseNonce(nonce) {
|
||||
for (let i = nonce.length; i > 0; i--) {
|
||||
nonce[i - 1]++;
|
||||
|
||||
if (nonce[i - 1] <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return nonce;
|
||||
}
|
||||
45
ui/history.js
Normal file
45
ui/history.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
export class Records {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {array} data Data space
|
||||
*/
|
||||
constructor(data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert new item into the history records
|
||||
*
|
||||
* @param {number} newData New value
|
||||
*/
|
||||
update(newData) {
|
||||
this.data.shift();
|
||||
this.data.push(newData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return data
|
||||
*
|
||||
*/
|
||||
get() {
|
||||
return this.data;
|
||||
}
|
||||
}
|
||||
442
ui/home.css
Normal file
442
ui/home.css
Normal file
@@ -0,0 +1,442 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@charset "utf-8";
|
||||
|
||||
@import "~roboto-fontface/css/roboto/roboto-fontface.css";
|
||||
|
||||
@keyframes home-window-display-flash {
|
||||
0% {
|
||||
top: -2px;
|
||||
opacity: 0;
|
||||
box-shadow: 0 0 0 transparent;
|
||||
}
|
||||
|
||||
20% {
|
||||
height: 20px;
|
||||
box-shadow: 0 0 50px #fff;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
box-shadow: 0 0 10px #fff;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 100%;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
box-shadow: 0 0 0 transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.home-window-display {
|
||||
}
|
||||
|
||||
.home-window-display::after {
|
||||
opacity: 0;
|
||||
z-index: 2;
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
top: -2px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
animation-name: home-window-display-flash;
|
||||
animation-duration: 0.3s;
|
||||
animation-iteration-count: 1;
|
||||
box-shadow: 0 0 10px #fff;
|
||||
}
|
||||
|
||||
#home {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font: 1em "Roboto", sans-serif;
|
||||
}
|
||||
|
||||
#home-header {
|
||||
flex: 0 0 40px;
|
||||
font-size: 0.9em;
|
||||
color: #fff;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#home-hd-title {
|
||||
font-size: 1.1em;
|
||||
padding: 0 0 0 20px;
|
||||
font-weight: bold;
|
||||
flex: 0 0 65px;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#home-hd-delay {
|
||||
font-size: 0.95em;
|
||||
display: flex;
|
||||
flex: 0 0 70px;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
margin: 0 10px;
|
||||
color: #aaa;
|
||||
text-decoration: none;
|
||||
justify-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#home-hd-title {
|
||||
padding: 0 0 0 10px;
|
||||
}
|
||||
|
||||
#home-hd-delay {
|
||||
flex: 0 0 60px;
|
||||
}
|
||||
}
|
||||
|
||||
#home-hd-delay-icon {
|
||||
color: #bbb;
|
||||
text-shadow: 0 0 3px #999;
|
||||
transition: linear 0.2s color, text-shadow;
|
||||
margin: 5px;
|
||||
font-size: 0.54em;
|
||||
}
|
||||
|
||||
#home-hd-delay-value {
|
||||
display: inline-block;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
word-wrap: none;
|
||||
}
|
||||
|
||||
@keyframes home-hd-delay-icon-flash {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
10% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
30% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
90% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes home-hd-delay-icon-working {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#home-hd-delay-icon.green {
|
||||
color: #1e8;
|
||||
text-shadow: 0 0 3px #1e8;
|
||||
}
|
||||
|
||||
#home-hd-delay-icon.yellow {
|
||||
color: #ff4;
|
||||
text-shadow: 0 0 3px #ff4;
|
||||
}
|
||||
|
||||
#home-hd-delay-icon.orange {
|
||||
color: #f80;
|
||||
text-shadow: 0 0 3px #f80;
|
||||
}
|
||||
|
||||
#home-hd-delay-icon.red {
|
||||
color: #e11;
|
||||
text-shadow: 0 0 3px #e11;
|
||||
}
|
||||
|
||||
#home-hd-delay-icon.flash {
|
||||
animation-name: home-hd-delay-icon-flash;
|
||||
animation-duration: 1s;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
#home-hd-delay-icon.working {
|
||||
animation-name: home-hd-delay-icon-working;
|
||||
animation-duration: 1.5s;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
#home-hd-plus {
|
||||
flex: 0 0;
|
||||
padding: 0 13px;
|
||||
text-decoration: none;
|
||||
font-size: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@keyframes home-hd-plus-icon-flash {
|
||||
0% {
|
||||
background: #a56;
|
||||
}
|
||||
|
||||
20% {
|
||||
background: #5a7;
|
||||
}
|
||||
|
||||
40% {
|
||||
background: #96a;
|
||||
}
|
||||
|
||||
60% {
|
||||
background: #379;
|
||||
}
|
||||
|
||||
80% {
|
||||
background: #da0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background: #a56;
|
||||
}
|
||||
}
|
||||
|
||||
#home-hd-plus.working {
|
||||
color: #fff;
|
||||
background: #a56;
|
||||
animation-name: home-hd-plus-icon-flash;
|
||||
animation-duration: 10s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: normal;
|
||||
transition: linear 2s background;
|
||||
}
|
||||
|
||||
#home-hd-plus.working.intensify {
|
||||
animation-duration: 3s;
|
||||
}
|
||||
|
||||
#home-hd-tabs {
|
||||
background: #333;
|
||||
flex: auto;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: left;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs {
|
||||
flex: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li {
|
||||
flex: 0 0 180px;
|
||||
display: flex;
|
||||
position: relative;
|
||||
padding: 0 15px;
|
||||
opacity: 0.5;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li::after {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
height: 0;
|
||||
transition: all 0.1s linear;
|
||||
transition-property: height, right, left;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li.active::after {
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li.updated::after {
|
||||
background: #fff3;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li.error::after {
|
||||
background: #d55;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li > span.title {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li > span.title > span.type {
|
||||
display: inline-block;
|
||||
font-size: 0.85em;
|
||||
font-weight: bold;
|
||||
margin-right: 3px;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
background: #222;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li > .icon-close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li.active {
|
||||
color: #fff;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li.active > span.title {
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li.active > .icon-close {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 10px;
|
||||
margin-top: -5px;
|
||||
color: #fff6;
|
||||
}
|
||||
|
||||
#home-hd-tabs-list {
|
||||
display: flex;
|
||||
font-size: 22px;
|
||||
flex: 0 0;
|
||||
padding: 0 13px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 0 3px #333;
|
||||
}
|
||||
|
||||
#home-content {
|
||||
flex: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
color: #fff;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#home-content {
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
#home-content > .screen {
|
||||
display: flex;
|
||||
justify-content: top;
|
||||
flex-direction: column;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
#home-content > .screen > .screen-error {
|
||||
display: block;
|
||||
padding: 10px;
|
||||
background: #b44;
|
||||
color: #fff;
|
||||
font-size: 0.75em;
|
||||
flex: 0 0;
|
||||
}
|
||||
|
||||
#home-content > .screen > .screen-screen {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
#home-content-wrap {
|
||||
max-width: 520px;
|
||||
margin: 50px auto;
|
||||
padding: 0 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#home-content h1 {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
#home-content p {
|
||||
margin: 10px 0;
|
||||
font-size: 0.9em;
|
||||
color: #eee;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#home-content p.secondary {
|
||||
margin: 5px 0;
|
||||
line-height: 1.5;
|
||||
font-size: 0.7em;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
#home-content p a {
|
||||
color: #e9a;
|
||||
}
|
||||
|
||||
#home-content hr {
|
||||
height: 2px;
|
||||
background: #3c3c3c;
|
||||
border: none;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
#home-content-connect {
|
||||
padding: 5px;
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
495
ui/home.vue
Normal file
495
ui/home.vue
Normal file
@@ -0,0 +1,495 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div id="home">
|
||||
<header id="home-header">
|
||||
<h1 id="home-hd-title">Sshwifty</h1>
|
||||
|
||||
<a id="home-hd-delay" href="javascript:;" @click="showDelayWindow">
|
||||
<span
|
||||
id="home-hd-delay-icon"
|
||||
class="icon icon-point1"
|
||||
:class="socket.classStyle"
|
||||
></span>
|
||||
<span v-if="socket.message.length > 0" id="home-hd-delay-value">{{
|
||||
socket.message
|
||||
}}</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
id="home-hd-plus"
|
||||
class="icon icon-plus1"
|
||||
href="javascript:;"
|
||||
:class="{
|
||||
working: connector.inputting,
|
||||
intensify: connector.inputting && !windows.connect
|
||||
}"
|
||||
@click="showConnectWindow"
|
||||
></a>
|
||||
|
||||
<tabs
|
||||
id="home-hd-tabs"
|
||||
:tab="tab.current"
|
||||
:tabs="tab.tabs"
|
||||
tabs-class="tab1"
|
||||
list-trigger-class="icon icon-more1"
|
||||
@current="switchTab"
|
||||
@list="showTabsWindow"
|
||||
@close="closeTab"
|
||||
></tabs>
|
||||
</header>
|
||||
|
||||
<screens
|
||||
id="home-content"
|
||||
:screen="tab.current"
|
||||
:screens="tab.tabs"
|
||||
@stopped="tabStopped"
|
||||
@updated="tabUpdated"
|
||||
>
|
||||
<div id="home-content-wrap">
|
||||
<h1>
|
||||
Hi, this is Sshwifty
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
An Open Source Web SSH Client that enables you to connect to SSH
|
||||
servers without download any additional software.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To get started, click the
|
||||
<span
|
||||
id="home-content-connect"
|
||||
class="icon icon-plus1"
|
||||
@click="showConnectWindow"
|
||||
></span>
|
||||
icon near the top left corner.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p class="secondary">
|
||||
Programmers in China are having a campaign against
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/996_working_hour_system"
|
||||
target="blank"
|
||||
>forced overtime work</a
|
||||
>. Sshwifty wouldn't exist if it's author have to work such extreme
|
||||
hours. If you're benefiting from hobbyist project like this one,
|
||||
please consider to
|
||||
<a
|
||||
href="https://github.com/996icu/996.ICU/#what-can-i-do"
|
||||
target="blank"
|
||||
>support the action</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</screens>
|
||||
|
||||
<connect-widget
|
||||
:inputting="connector.inputting"
|
||||
:display="windows.connect"
|
||||
:connectors="connector.connectors"
|
||||
:knowns="connector.knowns"
|
||||
:knowns-launcher-builder="buildknownLauncher"
|
||||
:busy="connector.busy"
|
||||
@display="windows.connect = $event"
|
||||
@connector-select="connectNew"
|
||||
@known-select="connectKnown"
|
||||
@known-remove="removeKnown"
|
||||
>
|
||||
<connector
|
||||
:connector="connector.connector"
|
||||
@cancel="cancelConnection"
|
||||
@done="connectionSucceed"
|
||||
>
|
||||
</connector>
|
||||
</connect-widget>
|
||||
<status-widget
|
||||
:class="socket.windowClass"
|
||||
:display="windows.delay"
|
||||
:status="socket.status"
|
||||
@display="windows.delay = $event"
|
||||
></status-widget>
|
||||
<tab-window
|
||||
:tab="tab.current"
|
||||
:tabs="tab.tabs"
|
||||
:display="windows.tabs"
|
||||
tabs-class="tab1 tab1-list"
|
||||
@display="windows.tabs = $event"
|
||||
@current="switchTab"
|
||||
@close="closeTab"
|
||||
></tab-window></div
|
||||
></template>
|
||||
|
||||
<script>
|
||||
import "./home.css";
|
||||
|
||||
import ConnectWidget from "./widgets/connect.vue";
|
||||
import StatusWidget from "./widgets/status.vue";
|
||||
import Connector from "./widgets/connector.vue";
|
||||
import Tabs from "./widgets/tabs.vue";
|
||||
import TabWindow from "./widgets/tab_window.vue";
|
||||
import Screens from "./widgets/screens.vue";
|
||||
|
||||
import * as home_socket from "./home_socketctl.js";
|
||||
import * as home_history from "./home_historyctl.js";
|
||||
|
||||
const BACKEND_CONNECT_ERROR =
|
||||
"Unable to connect to the Sshwifty backend server: ";
|
||||
const BACKEND_REQUEST_ERROR = "Unable to perform request: ";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
"connect-widget": ConnectWidget,
|
||||
"status-widget": StatusWidget,
|
||||
connector: Connector,
|
||||
tabs: Tabs,
|
||||
"tab-window": TabWindow,
|
||||
screens: Screens
|
||||
},
|
||||
props: {
|
||||
hostPath: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
query: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
connection: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
controls: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
commands: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
let history = home_history.build(this);
|
||||
|
||||
return {
|
||||
ticker: null,
|
||||
windows: {
|
||||
delay: false,
|
||||
connect: false,
|
||||
tabs: false
|
||||
},
|
||||
socket: home_socket.build(this),
|
||||
connector: {
|
||||
historyRec: history,
|
||||
connector: null,
|
||||
connectors: this.commands.all(),
|
||||
inputting: false,
|
||||
acquired: false,
|
||||
busy: false,
|
||||
knowns: history.all()
|
||||
},
|
||||
tab: {
|
||||
current: -1,
|
||||
lastID: 0,
|
||||
tabs: []
|
||||
}
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.ticker = setInterval(() => {
|
||||
this.tick();
|
||||
}, 1000);
|
||||
|
||||
if (this.query.length > 1 && this.query.indexOf("+") === 0) {
|
||||
this.connectLaunch(this.query.slice(1, this.query.length));
|
||||
|
||||
this.$emit("navigate-to", "");
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.ticker === null) {
|
||||
clearInterval(this.ticker);
|
||||
this.ticker = null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
tick() {
|
||||
let now = new Date();
|
||||
|
||||
this.socket.update(now, this);
|
||||
},
|
||||
closeAllWindow() {
|
||||
for (let i in this.windows) {
|
||||
this.windows[i] = false;
|
||||
}
|
||||
},
|
||||
showDelayWindow() {
|
||||
this.closeAllWindow();
|
||||
this.windows.delay = true;
|
||||
},
|
||||
showConnectWindow() {
|
||||
this.closeAllWindow();
|
||||
this.windows.connect = true;
|
||||
},
|
||||
showTabsWindow() {
|
||||
this.closeAllWindow();
|
||||
this.windows.tabs = true;
|
||||
},
|
||||
async getStreamThenRun(run, end) {
|
||||
let errStr = null;
|
||||
|
||||
try {
|
||||
let conn = await this.connection.get(this.socket);
|
||||
|
||||
try {
|
||||
run(conn);
|
||||
} catch (e) {
|
||||
errStr = BACKEND_REQUEST_ERROR + e;
|
||||
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
} catch (e) {
|
||||
errStr = BACKEND_CONNECT_ERROR + e;
|
||||
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
|
||||
end();
|
||||
|
||||
if (errStr !== null) {
|
||||
alert(errStr);
|
||||
}
|
||||
},
|
||||
runConnect(callback) {
|
||||
if (this.connector.acquired) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.connector.acquired = true;
|
||||
this.connector.busy = true;
|
||||
|
||||
this.getStreamThenRun(
|
||||
stream => {
|
||||
this.connector.busy = false;
|
||||
|
||||
callback(stream);
|
||||
},
|
||||
() => {
|
||||
this.connector.busy = false;
|
||||
this.connector.acquired = false;
|
||||
}
|
||||
);
|
||||
},
|
||||
connectNew(connector) {
|
||||
this.runConnect(stream => {
|
||||
this.connector.connector = {
|
||||
id: connector.id(),
|
||||
name: connector.name(),
|
||||
description: connector.description(),
|
||||
wizard: connector.build(
|
||||
stream,
|
||||
this.controls,
|
||||
this.connector.historyRec,
|
||||
null
|
||||
)
|
||||
};
|
||||
|
||||
this.connector.inputting = true;
|
||||
});
|
||||
},
|
||||
getConnectorByType(type) {
|
||||
let connector = null;
|
||||
|
||||
for (let c in this.connector.connectors) {
|
||||
if (this.connector.connectors[c].name() !== type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
connector = this.connector.connectors[c];
|
||||
}
|
||||
|
||||
return connector;
|
||||
},
|
||||
connectKnown(known) {
|
||||
this.runConnect(stream => {
|
||||
let connector = this.getConnectorByType(known.type);
|
||||
|
||||
if (!connector) {
|
||||
alert("Unknown connector: " + known.type);
|
||||
|
||||
this.connector.inputting = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.connector.connector = {
|
||||
id: connector.id(),
|
||||
name: connector.name(),
|
||||
description: connector.description(),
|
||||
wizard: connector.build(
|
||||
stream,
|
||||
this.controls,
|
||||
this.connector.historyRec,
|
||||
known.data
|
||||
)
|
||||
};
|
||||
|
||||
this.connector.inputting = true;
|
||||
});
|
||||
},
|
||||
parseConnectLauncher(ll) {
|
||||
let llSeparatorIdx = ll.indexOf(":");
|
||||
|
||||
// Type must contain at least one charater
|
||||
if (llSeparatorIdx <= 0) {
|
||||
throw new Error("Invalid Launcher string");
|
||||
}
|
||||
|
||||
return {
|
||||
type: ll.slice(0, llSeparatorIdx),
|
||||
query: ll.slice(llSeparatorIdx + 1, ll.length)
|
||||
};
|
||||
},
|
||||
connectLaunch(launcher) {
|
||||
this.showConnectWindow();
|
||||
|
||||
this.runConnect(stream => {
|
||||
let ll = this.parseConnectLauncher(launcher),
|
||||
connector = this.getConnectorByType(ll.type);
|
||||
|
||||
if (!connector) {
|
||||
alert("Unknown connector: " + ll.type);
|
||||
|
||||
this.connector.inputting = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.connector.connector = {
|
||||
id: connector.id(),
|
||||
name: connector.name(),
|
||||
description: connector.description(),
|
||||
wizard: connector.launch(
|
||||
stream,
|
||||
this.controls,
|
||||
this.connector.historyRec,
|
||||
ll.query
|
||||
)
|
||||
};
|
||||
|
||||
this.connector.inputting = true;
|
||||
});
|
||||
},
|
||||
buildknownLauncher(known) {
|
||||
let connector = this.getConnectorByType(known.type);
|
||||
|
||||
if (!connector) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.hostPath + "#+" + connector.launcher(known.data);
|
||||
},
|
||||
removeKnown(uid) {
|
||||
this.connector.historyRec.del(uid);
|
||||
},
|
||||
cancelConnection() {
|
||||
this.connector.inputting = false;
|
||||
this.connector.acquired = false;
|
||||
},
|
||||
connectionSucceed(data) {
|
||||
this.connector.inputting = false;
|
||||
this.connector.acquired = false;
|
||||
this.windows.connect = false;
|
||||
|
||||
this.addToTab(data);
|
||||
},
|
||||
async addToTab(data) {
|
||||
await this.switchTab(
|
||||
this.tab.tabs.push({
|
||||
id: this.tab.lastID++,
|
||||
name: data.name,
|
||||
info: data.info,
|
||||
control: data.control,
|
||||
indicator: {
|
||||
error: "",
|
||||
updated: false
|
||||
},
|
||||
status: {
|
||||
closing: false
|
||||
}
|
||||
}) - 1
|
||||
);
|
||||
},
|
||||
removeFromTab(index) {
|
||||
let isLast = index === this.tab.tabs.length - 1;
|
||||
|
||||
this.tab.tabs.splice(index, 1);
|
||||
this.tab.current = isLast ? this.tab.tabs.length - 1 : index;
|
||||
},
|
||||
async switchTab(to) {
|
||||
if (this.tab.current >= 0) {
|
||||
await this.tab.tabs[this.tab.current].control.disabled();
|
||||
}
|
||||
|
||||
this.tab.current = to;
|
||||
|
||||
this.tab.tabs[this.tab.current].indicator.updated = false;
|
||||
await this.tab.tabs[this.tab.current].control.enabled();
|
||||
},
|
||||
async closeTab(index) {
|
||||
if (this.tab.tabs[index].status.closing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tab.tabs[index].status.closing = true;
|
||||
|
||||
try {
|
||||
this.tab.tabs[index].control.disabled();
|
||||
|
||||
await this.tab.tabs[index].control.close();
|
||||
} catch (e) {
|
||||
alert("Cannot close tab due to error: " + e);
|
||||
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
|
||||
this.removeFromTab(index);
|
||||
},
|
||||
tabStopped(index, reason) {
|
||||
if (reason === null) {
|
||||
this.tab.tabs[index].indicator.error = "";
|
||||
} else {
|
||||
this.tab.tabs[index].indicator.error = "" + reason;
|
||||
}
|
||||
},
|
||||
tabUpdated(index) {
|
||||
this.tab.tabs[index].indicator.updated = this.tab.current !== index;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
22
ui/home_historyctl.js
Normal file
22
ui/home_historyctl.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { History } from "./commands/history.js";
|
||||
|
||||
export function build(ctx) {
|
||||
let rec = JSON.parse(localStorage.getItem("knowns"));
|
||||
|
||||
if (!rec) {
|
||||
rec = [];
|
||||
}
|
||||
|
||||
return new History(
|
||||
rec,
|
||||
(h, d) => {
|
||||
try {
|
||||
localStorage.setItem("knowns", JSON.stringify(d));
|
||||
ctx.connector.knowns = h.all();
|
||||
} catch (e) {
|
||||
alert("Unable to save remote history due to error: " + e);
|
||||
}
|
||||
},
|
||||
32
|
||||
);
|
||||
}
|
||||
184
ui/home_socketctl.js
Normal file
184
ui/home_socketctl.js
Normal file
@@ -0,0 +1,184 @@
|
||||
import { ECHO_FAILED } from "./socket.js";
|
||||
import * as history from "./history.js";
|
||||
|
||||
export function build(ctx) {
|
||||
const connectionStatusNotConnected = "Sshwifty is not connected";
|
||||
const connectionStatusConnecting =
|
||||
"Sshwifty is connecting to it's backend server, it should only take " +
|
||||
"less than a second or two";
|
||||
const connectionStatusDisconnected =
|
||||
"Sshwifty has disconnected from it's backend server";
|
||||
const connectionStatusConnected =
|
||||
"Sshwifty has connected to it's backend server, user interface " +
|
||||
"is operational";
|
||||
const connectionStatusMeasurable =
|
||||
"Unable to measure connection delay. The connection maybe very " +
|
||||
"busy or already lost";
|
||||
|
||||
const connectionDelayGood =
|
||||
"Connection delay is low, operation should be very responsive";
|
||||
const connectionDelayFair =
|
||||
"Experiencing minor connection delay, operation should be responded " +
|
||||
"within reasonable time";
|
||||
const connectionDelayMedian =
|
||||
"Experiencing median connection delay, consider slow down your input " +
|
||||
"to avoid misoperate";
|
||||
const connectionDelayHeavy =
|
||||
"Experiencing long connection delay, operation may appear to be sticked";
|
||||
|
||||
const buildEmptyHistory = () => {
|
||||
let r = [];
|
||||
|
||||
for (let i = 0; i < 32; i++) {
|
||||
r.push(0);
|
||||
}
|
||||
|
||||
return r;
|
||||
};
|
||||
|
||||
let inboundPerSecond = 0,
|
||||
outboundPerSecond = 0,
|
||||
trafficPreSecondNextUpdate = new Date(),
|
||||
inboundPre10Seconds = 0,
|
||||
outboundPre10Seconds = 0,
|
||||
trafficPre10sNextUpdate = new Date(),
|
||||
inboundHistory = new history.Records(buildEmptyHistory()),
|
||||
outboundHistory = new history.Records(buildEmptyHistory()),
|
||||
trafficSamples = 1;
|
||||
|
||||
let delayHistory = new history.Records(buildEmptyHistory()),
|
||||
delaySamples = 0,
|
||||
delayPerInterval = 0;
|
||||
|
||||
return {
|
||||
update(time) {
|
||||
if (time >= trafficPreSecondNextUpdate) {
|
||||
trafficPreSecondNextUpdate = new Date(time.getTime() + 1000);
|
||||
inboundPre10Seconds += inboundPerSecond;
|
||||
outboundPre10Seconds += outboundPerSecond;
|
||||
|
||||
this.status.inbound = inboundPerSecond;
|
||||
this.status.outbound = outboundPerSecond;
|
||||
|
||||
inboundPerSecond = 0;
|
||||
outboundPerSecond = 0;
|
||||
|
||||
trafficSamples++;
|
||||
}
|
||||
|
||||
if (time >= trafficPre10sNextUpdate) {
|
||||
trafficPre10sNextUpdate = new Date(time.getTime() + 10000);
|
||||
|
||||
inboundHistory.update(inboundPre10Seconds / trafficSamples);
|
||||
outboundHistory.update(outboundPre10Seconds / trafficSamples);
|
||||
|
||||
inboundPre10Seconds = 0;
|
||||
outboundPre10Seconds = 0;
|
||||
trafficSamples = 1;
|
||||
|
||||
if (delaySamples > 0) {
|
||||
delayHistory.update(delayPerInterval / delaySamples);
|
||||
|
||||
delaySamples = 0;
|
||||
delayPerInterval = 0;
|
||||
}
|
||||
}
|
||||
},
|
||||
classStyle: "",
|
||||
windowClass: "",
|
||||
message: "",
|
||||
status: {
|
||||
description: connectionStatusNotConnected,
|
||||
delay: 0,
|
||||
delayHistory: delayHistory.get(),
|
||||
inbound: 0,
|
||||
inboundHistory: inboundHistory.get(),
|
||||
outbound: 0,
|
||||
outboundHistory: outboundHistory.get()
|
||||
},
|
||||
connecting() {
|
||||
this.message = "--";
|
||||
this.classStyle = "working";
|
||||
this.windowClass = "";
|
||||
this.status.description = connectionStatusConnecting;
|
||||
},
|
||||
connected() {
|
||||
this.message = "??";
|
||||
this.classStyle = "working";
|
||||
this.windowClass = "";
|
||||
this.status.description = connectionStatusConnected;
|
||||
},
|
||||
traffic(inb, outb) {
|
||||
inboundPerSecond += inb;
|
||||
outboundPerSecond += outb;
|
||||
},
|
||||
echo(delay) {
|
||||
delayPerInterval += delay > 0 ? delay : 0;
|
||||
delaySamples++;
|
||||
|
||||
if (delay == ECHO_FAILED) {
|
||||
this.message = "";
|
||||
this.classStyle = "red flash";
|
||||
this.windowClass = "red";
|
||||
this.status.description = connectionStatusMeasurable;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let avgDelay = Math.round(delayPerInterval / delaySamples);
|
||||
|
||||
this.message = Number(avgDelay).toLocaleString() + "ms";
|
||||
this.status.delay = avgDelay;
|
||||
|
||||
if (avgDelay < 30) {
|
||||
this.classStyle = "green";
|
||||
this.windowClass = "green";
|
||||
this.status.description =
|
||||
connectionStatusConnected + ". " + connectionDelayGood;
|
||||
} else if (avgDelay < 100) {
|
||||
this.classStyle = "yellow";
|
||||
this.windowClass = "yellow";
|
||||
this.status.description =
|
||||
connectionStatusConnected + ". " + connectionDelayFair;
|
||||
} else if (avgDelay < 300) {
|
||||
this.classStyle = "orange";
|
||||
this.windowClass = "orange";
|
||||
this.status.description =
|
||||
connectionStatusConnected + ". " + connectionDelayMedian;
|
||||
} else {
|
||||
this.classStyle = "red";
|
||||
this.windowClass = "red";
|
||||
this.status.description =
|
||||
connectionStatusConnected + ". " + connectionDelayHeavy;
|
||||
}
|
||||
},
|
||||
close(e) {
|
||||
ctx.connector.inputting = false;
|
||||
|
||||
if (e === null) {
|
||||
this.message = "";
|
||||
this.classStyle = "";
|
||||
this.status.description = connectionStatusDisconnected;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.message = "ERR";
|
||||
this.classStyle = "red flash";
|
||||
this.windowClass = "red";
|
||||
this.status.description = connectionStatusDisconnected + ". Error: " + e;
|
||||
},
|
||||
failed(e) {
|
||||
ctx.connector.inputting = false;
|
||||
|
||||
if (e.code) {
|
||||
this.message = "E" + e.code;
|
||||
} else {
|
||||
this.message = "E????";
|
||||
}
|
||||
|
||||
this.classStyle = "red flash";
|
||||
this.status.description = connectionStatusDisconnected + ". Error: " + e;
|
||||
}
|
||||
};
|
||||
}
|
||||
132
ui/landing.css
Normal file
132
ui/landing.css
Normal file
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@charset "utf-8";
|
||||
|
||||
body.landing {
|
||||
background: #945;
|
||||
}
|
||||
|
||||
#landing {
|
||||
min-height: 100%;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
justify-items: center;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#landing-message {
|
||||
font-size: 0.9em;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
padding: 50px;
|
||||
flex: 0 0;
|
||||
}
|
||||
|
||||
#landing-message a {
|
||||
text-decoration: none;
|
||||
color: #fab;
|
||||
}
|
||||
|
||||
#landing-message p {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
#landing-message p.copy {
|
||||
margin: 20px 0 10px 0;
|
||||
color: #fab;
|
||||
}
|
||||
|
||||
#landing-message p.copy.copy-first {
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
#landing-message p.copy a {
|
||||
border: 1px solid #fab;
|
||||
display: inline-block;
|
||||
padding: 3px 7px;
|
||||
margin-left: 5px;
|
||||
border-radius: 5px;
|
||||
line-height: initial;
|
||||
}
|
||||
|
||||
#landing-message-logo {
|
||||
background: url("./widgets/busy.svg") center center no-repeat;
|
||||
background-size: contain;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
#landing-message-info {
|
||||
margin-top: 50px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
#auth {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#auth-frame {
|
||||
flex: 0 0;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#auth-content {
|
||||
background: #333;
|
||||
box-shadow: 0 0 3px #111;
|
||||
padding: 30px;
|
||||
max-width: 380px;
|
||||
margin: 0 auto;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
#auth-content > h1 {
|
||||
margin-bottom: 20px;
|
||||
color: #fab;
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
#auth-content > h1:after {
|
||||
content: "\2731";
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
#auth-content > form {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
#auth-content > form .field:last-child {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
#auth-content > form > fieldset {
|
||||
margin-top: 30px;
|
||||
}
|
||||
47
ui/loading.vue
Normal file
47
ui/loading.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div id="app-loading">
|
||||
<div id="app-loading-frame">
|
||||
<div v-if="error.length <= 0" id="app-loading-icon"></div>
|
||||
<div v-else id="app-loading-error">
|
||||
×
|
||||
</div>
|
||||
|
||||
<h1 v-if="error.length <= 0" id="app-loading-title">
|
||||
Preparing client application
|
||||
</h1>
|
||||
<h1 v-else id="app-loading-title" class="error">
|
||||
{{ error }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
error: {
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
4
ui/robots.txt
Normal file
4
ui/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
user-agent: *
|
||||
|
||||
Allow: /$
|
||||
Disallow: /
|
||||
335
ui/socket.js
Normal file
335
ui/socket.js
Normal file
@@ -0,0 +1,335 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import * as streams from "./stream/streams.js";
|
||||
import * as reader from "./stream/reader.js";
|
||||
import * as sender from "./stream/sender.js";
|
||||
import * as crypt from "./crypto.js";
|
||||
|
||||
export const ECHO_FAILED = streams.ECHO_FAILED;
|
||||
|
||||
class Dial {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {string} address Address to the Websocket server
|
||||
* @param {number} Dial timeout
|
||||
* @param {object} privateKey String key that will be used to encrypt and
|
||||
* decrypt socket traffic
|
||||
*
|
||||
*/
|
||||
constructor(address, timeout, privateKey) {
|
||||
this.address = address;
|
||||
this.timeout = timeout;
|
||||
this.privateKey = privateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the remote server
|
||||
*
|
||||
* @param {number} timeout Connect timeout
|
||||
*
|
||||
* @returns {Promise<WebSocket>} When connection is established
|
||||
*
|
||||
*/
|
||||
connect(timeout) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let ws = new WebSocket(this.address),
|
||||
promised = false,
|
||||
timeoutTimer = setTimeout(() => {
|
||||
ws.close();
|
||||
}, timeout),
|
||||
myRes = w => {
|
||||
if (promised) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutTimer);
|
||||
promised = true;
|
||||
|
||||
return resolve(w);
|
||||
},
|
||||
myRej = e => {
|
||||
if (promised) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutTimer);
|
||||
promised = true;
|
||||
|
||||
return reject(e);
|
||||
};
|
||||
|
||||
ws.addEventListener("open", event => {
|
||||
myRes(ws);
|
||||
});
|
||||
|
||||
ws.addEventListener("close", event => {
|
||||
event.toString = () => {
|
||||
return "WebSocket Error (" + event.code + ")";
|
||||
};
|
||||
|
||||
myRej(event);
|
||||
});
|
||||
|
||||
ws.addEventListener("error", event => {
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an socket encrypt and decrypt key string
|
||||
*
|
||||
*/
|
||||
async buildKeyString() {
|
||||
const enc = new TextEncoder("utf-8");
|
||||
|
||||
let rTime = Number(Math.trunc(new Date().getTime() / 100000)),
|
||||
key = await crypt.hmac512(
|
||||
enc.encode(await this.privateKey.fetch()),
|
||||
enc.encode(rTime)
|
||||
);
|
||||
|
||||
return key.slice(0, 24);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build encrypt and decrypt key
|
||||
*
|
||||
*/
|
||||
async buildKey() {
|
||||
let kStr = await this.buildKeyString();
|
||||
|
||||
return await crypt.buildGCMKey(kStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the server
|
||||
*
|
||||
* @param {object} callbacks Callbacks
|
||||
*
|
||||
* @returns {object} A pair of ReadWriter which can be used to read and
|
||||
* send data to the underlaying websocket connection
|
||||
*
|
||||
*/
|
||||
async dial(callbacks) {
|
||||
let ws = await this.connect(this.timeout),
|
||||
rd = new reader.Reader(new reader.Multiple(() => {}), data => {
|
||||
return new Promise(resolve => {
|
||||
let bufferReader = new FileReader();
|
||||
|
||||
bufferReader.onload = event => {
|
||||
let d = new Uint8Array(event.target.result);
|
||||
|
||||
resolve(d);
|
||||
|
||||
callbacks.inboundUnpacked(d);
|
||||
};
|
||||
|
||||
bufferReader.readAsArrayBuffer(data);
|
||||
});
|
||||
}),
|
||||
sdDataConvert = rawData => {
|
||||
return rawData;
|
||||
},
|
||||
sd = new sender.Sender(
|
||||
async rawData => {
|
||||
let data = await sdDataConvert(rawData);
|
||||
|
||||
ws.send(data.buffer);
|
||||
callbacks.outbound(data);
|
||||
},
|
||||
15,
|
||||
4096 - 64 // Server has a 4096 bytes receive buffer, can be no greater
|
||||
);
|
||||
|
||||
ws.addEventListener("message", event => {
|
||||
callbacks.inbound(event.data);
|
||||
|
||||
rd.feed(event.data);
|
||||
});
|
||||
|
||||
ws.addEventListener("error", event => {
|
||||
rd.close();
|
||||
});
|
||||
|
||||
ws.addEventListener("close", event => {
|
||||
rd.close();
|
||||
});
|
||||
|
||||
let senderNonce = crypt.generateNonce();
|
||||
sd.send(senderNonce);
|
||||
|
||||
let receiverNonce = await reader.readN(rd, crypt.GCMNonceSize);
|
||||
|
||||
let key = await this.buildKey();
|
||||
|
||||
sdDataConvert = async rawData => {
|
||||
let encoded = await crypt.encryptGCM(key, senderNonce, rawData);
|
||||
|
||||
crypt.increaseNonce(senderNonce);
|
||||
|
||||
let dataToSend = new Uint8Array(encoded.byteLength + 2);
|
||||
|
||||
dataToSend[0] = (encoded.byteLength >> 8) & 0xff;
|
||||
dataToSend[1] = encoded.byteLength & 0xff;
|
||||
|
||||
dataToSend.set(new Uint8Array(encoded), 2);
|
||||
|
||||
return dataToSend;
|
||||
};
|
||||
|
||||
let cgmReader = new reader.Multiple(async r => {
|
||||
try {
|
||||
let dSizeBytes = await reader.readN(rd, 2),
|
||||
dSize = 0;
|
||||
|
||||
dSize = dSizeBytes[0];
|
||||
dSize <<= 8;
|
||||
dSize |= dSizeBytes[1];
|
||||
|
||||
let decoded = await crypt.decryptGCM(
|
||||
key,
|
||||
receiverNonce,
|
||||
await reader.readN(rd, dSize)
|
||||
);
|
||||
|
||||
crypt.increaseNonce(receiverNonce);
|
||||
|
||||
r.feed(new reader.Buffer(new Uint8Array(decoded), () => {}), () => {});
|
||||
} catch (e) {
|
||||
r.close();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
reader: cgmReader,
|
||||
sender: sd,
|
||||
ws: ws
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Socket {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {string} address Address of the WebSocket server
|
||||
* @param {object} privateKey String key that will be used to encrypt and
|
||||
* decrypt socket traffic
|
||||
* @param {number} timeout Dial timeout
|
||||
* @param {number} echoInterval Echo interval
|
||||
*/
|
||||
constructor(address, privateKey, timeout, echoInterval) {
|
||||
this.dial = new Dial(address, timeout, privateKey);
|
||||
this.echoInterval = echoInterval;
|
||||
this.streamHandler = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a stream handler
|
||||
*
|
||||
* @param {object} callbacks A group of callbacks to call when needed
|
||||
*
|
||||
* @returns {Promise<streams.Streams>} The stream manager
|
||||
*
|
||||
*/
|
||||
async get(callbacks) {
|
||||
let self = this;
|
||||
|
||||
if (this.streamHandler) {
|
||||
return this.streamHandler;
|
||||
}
|
||||
|
||||
callbacks.connecting();
|
||||
|
||||
let streamPaused = false,
|
||||
currentReceived = 0,
|
||||
currentUnpacked = 0;
|
||||
|
||||
const shouldPause = () => {
|
||||
return currentReceived > currentUnpacked;
|
||||
};
|
||||
|
||||
try {
|
||||
let conn = await this.dial.dial({
|
||||
inbound(data) {
|
||||
currentReceived += data.size;
|
||||
|
||||
callbacks.traffic(data.size, 0);
|
||||
},
|
||||
inboundUnpacked(data) {
|
||||
currentUnpacked += data.length;
|
||||
|
||||
if (currentUnpacked >= currentReceived) {
|
||||
currentUnpacked = 0;
|
||||
currentReceived = 0;
|
||||
}
|
||||
|
||||
if (self.streamHandler !== null) {
|
||||
if (streamPaused && !shouldPause()) {
|
||||
streamPaused = false;
|
||||
self.streamHandler.resume();
|
||||
|
||||
return;
|
||||
} else if (!streamPaused && shouldPause()) {
|
||||
streamPaused = true;
|
||||
self.streamHandler.pause();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
outbound(data) {
|
||||
callbacks.traffic(0, data.length);
|
||||
}
|
||||
});
|
||||
|
||||
let streamHandler = new streams.Streams(conn.reader, conn.sender, {
|
||||
echoInterval: self.echoInterval,
|
||||
echoUpdater(delay) {
|
||||
return callbacks.echo(delay);
|
||||
},
|
||||
cleared(e) {
|
||||
if (self.streamHandler === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.streamHandler = null;
|
||||
|
||||
// Close connection first otherwise we may
|
||||
// risk sending things out
|
||||
conn.ws.close();
|
||||
callbacks.close(e);
|
||||
}
|
||||
});
|
||||
|
||||
streamHandler.serve();
|
||||
|
||||
callbacks.connected();
|
||||
|
||||
this.streamHandler = streamHandler;
|
||||
} catch (e) {
|
||||
callbacks.failed(e);
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
return this.streamHandler;
|
||||
}
|
||||
}
|
||||
BIN
ui/sshwifty.png
Normal file
BIN
ui/sshwifty.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
48
ui/stream/common.js
Normal file
48
ui/stream/common.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Get one unsafe random number
|
||||
*
|
||||
* @param {number} min Min value (included)
|
||||
* @param {number} max Max value (not included)
|
||||
*
|
||||
* @returns {number} Get random number
|
||||
*
|
||||
*/
|
||||
export function getRand(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a group of random number
|
||||
*
|
||||
* @param {number} n How many number to get
|
||||
* @param {number} min Min value (included)
|
||||
* @param {number} max Max value (not included)
|
||||
*
|
||||
* @returns {Array<number>} A group of random number
|
||||
*/
|
||||
export function getRands(n, min, max) {
|
||||
let r = [];
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
r.push(getRand(min, max));
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
40
ui/stream/exception.js
Normal file
40
ui/stream/exception.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
export default class Exception {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {string} message error message
|
||||
* @param {boolean} temporary whether or not the error is temporary
|
||||
*
|
||||
*/
|
||||
constructor(message, temporary) {
|
||||
this.message = message;
|
||||
this.temporary = temporary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the error string
|
||||
*
|
||||
* @returns {string} Error message
|
||||
*
|
||||
*/
|
||||
toString() {
|
||||
return this.message;
|
||||
}
|
||||
}
|
||||
264
ui/stream/header.js
Normal file
264
ui/stream/header.js
Normal file
@@ -0,0 +1,264 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import Exception from "./exception.js";
|
||||
|
||||
export const CONTROL = 0x00;
|
||||
export const STREAM = 0x40;
|
||||
export const CLOSE = 0x80;
|
||||
export const COMPLETED = 0xc0;
|
||||
|
||||
export const CONTROL_ECHO = 0x00;
|
||||
export const CONTROL_PAUSESTREAM = 0x01;
|
||||
export const CONTROL_RESUMESTREAM = 0x02;
|
||||
|
||||
const headerHeaderCutter = 0xc0;
|
||||
const headerDataCutter = 0x3f;
|
||||
|
||||
export const HEADER_MAX_DATA = headerDataCutter;
|
||||
|
||||
export class Header {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {number} headerByte one byte data of the header
|
||||
*/
|
||||
constructor(headerByte) {
|
||||
this.headerByte = headerByte;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the header type
|
||||
*
|
||||
* @returns {number} Type number
|
||||
*
|
||||
*/
|
||||
type() {
|
||||
return this.headerByte & headerHeaderCutter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the header data
|
||||
*
|
||||
* @returns {number} Data number
|
||||
*
|
||||
*/
|
||||
data() {
|
||||
return this.headerByte & headerDataCutter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the reader data
|
||||
*
|
||||
* @param {number} data
|
||||
*/
|
||||
set(data) {
|
||||
if (data > headerDataCutter) {
|
||||
throw new Exception("data must not be greater than 0x3f", false);
|
||||
}
|
||||
|
||||
this.headerByte |= headerDataCutter & data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the header value
|
||||
*
|
||||
* @returns {number} Header byte data
|
||||
*
|
||||
*/
|
||||
value() {
|
||||
return this.headerByte;
|
||||
}
|
||||
}
|
||||
|
||||
export const STREAM_HEADER_BYTE_LENGTH = 2;
|
||||
export const STREAM_MAX_LENGTH = 0x1fff;
|
||||
export const STREAM_MAX_MARKER = 0x07;
|
||||
|
||||
const streamHeaderLengthFirstByteCutter = 0x1f;
|
||||
|
||||
export class Stream {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {number} headerByte1 First header byte
|
||||
* @param {number} headerByte2 Second header byte
|
||||
*
|
||||
*/
|
||||
constructor(headerByte1, headerByte2) {
|
||||
this.headerByte1 = headerByte1;
|
||||
this.headerByte2 = headerByte2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the marker data
|
||||
*
|
||||
* @returns {number} the marker
|
||||
*
|
||||
*/
|
||||
marker() {
|
||||
return this.headerByte1 >> 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the stream data length
|
||||
*
|
||||
* @returns {number} Length of the stream data
|
||||
*
|
||||
*/
|
||||
length() {
|
||||
let r = 0;
|
||||
|
||||
r |= this.headerByte1 & streamHeaderLengthFirstByteCutter;
|
||||
r <<= 8;
|
||||
r |= this.headerByte2;
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the header
|
||||
*
|
||||
* @param {number} marker Header marker
|
||||
* @param {number} length Stream data length
|
||||
*
|
||||
*/
|
||||
set(marker, length) {
|
||||
if (marker > STREAM_MAX_MARKER) {
|
||||
throw new Exception("marker must not be greater than 0x07", false);
|
||||
}
|
||||
|
||||
if (length > STREAM_MAX_LENGTH) {
|
||||
throw new Exception("n must not be greater than 0x1fff", false);
|
||||
}
|
||||
|
||||
this.headerByte1 =
|
||||
(marker << 5) | ((length >> 8) & streamHeaderLengthFirstByteCutter);
|
||||
this.headerByte2 = length & 0xff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the header data
|
||||
*
|
||||
* @returns {Uint8Array} Header data
|
||||
*
|
||||
*/
|
||||
buffer() {
|
||||
return new Uint8Array([this.headerByte1, this.headerByte2]);
|
||||
}
|
||||
}
|
||||
|
||||
export class InitialStream extends Stream {
|
||||
/**
|
||||
* Return how large the data can be
|
||||
*
|
||||
* @returns {number} Max data size
|
||||
*
|
||||
*/
|
||||
static maxDataSize() {
|
||||
return 0x07ff;
|
||||
}
|
||||
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {number} headerByte1 First header byte
|
||||
* @param {number} headerByte2 Second header byte
|
||||
*
|
||||
*/
|
||||
constructor(headerByte1, headerByte2) {
|
||||
super(headerByte1, headerByte2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return command ID
|
||||
*
|
||||
* @returns {number} Command ID
|
||||
*
|
||||
*/
|
||||
command() {
|
||||
return this.headerByte1 >> 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return data
|
||||
*
|
||||
* @returns {number} Data
|
||||
*
|
||||
*/
|
||||
data() {
|
||||
let r = 0;
|
||||
|
||||
r |= this.headerByte1 & 0x07;
|
||||
r <<= 8;
|
||||
r |= this.headerByte2 & 0xff;
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether or not the respond is success
|
||||
*
|
||||
* @returns {boolean} True when the request is successful, false otherwise
|
||||
*
|
||||
*/
|
||||
success() {
|
||||
return (this.headerByte1 & 0x08) != 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the header
|
||||
*
|
||||
* @param {number} commandID Command ID
|
||||
* @param {number} data Stream data
|
||||
* @param {boolean} success Whether or not the request is successful
|
||||
*
|
||||
*/
|
||||
set(commandID, data, success) {
|
||||
if (commandID > 0x0f) {
|
||||
throw new Exception("Command ID must not greater than 0x0f", false);
|
||||
}
|
||||
|
||||
if (data > InitialStream.maxDataSize()) {
|
||||
throw new Exception("Data must not greater than 0x07ff", false);
|
||||
}
|
||||
|
||||
let dd = data & InitialStream.maxDataSize();
|
||||
|
||||
if (success) {
|
||||
dd |= 0x0800;
|
||||
}
|
||||
|
||||
this.headerByte1 = 0;
|
||||
this.headerByte1 |= commandID << 4;
|
||||
this.headerByte1 |= dd >> 8;
|
||||
this.headerByte2 = 0;
|
||||
this.headerByte2 |= dd & 0xff;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a new Header
|
||||
*
|
||||
* @param {number} h Header number
|
||||
*
|
||||
* @returns {Header} The header which been built
|
||||
*
|
||||
*/
|
||||
export function header(h) {
|
||||
return new Header(h);
|
||||
}
|
||||
56
ui/stream/header_test.js
Normal file
56
ui/stream/header_test.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import * as header from "./header.js";
|
||||
import assert from "assert";
|
||||
|
||||
describe("Header", () => {
|
||||
it("Header", () => {
|
||||
let h = new header.Header(header.ECHO);
|
||||
|
||||
h.set(63);
|
||||
|
||||
let n = new header.Header(h.value());
|
||||
|
||||
assert.equal(h.type(), n.type());
|
||||
assert.equal(h.data(), n.data());
|
||||
assert.equal(n.type(), header.CONTROL);
|
||||
assert.equal(n.data(), 63);
|
||||
});
|
||||
|
||||
it("Stream", () => {
|
||||
let h = new header.Stream(0, 0);
|
||||
|
||||
h.set(header.STREAM_MAX_MARKER, header.STREAM_MAX_LENGTH);
|
||||
|
||||
assert.equal(h.marker(), header.STREAM_MAX_MARKER);
|
||||
assert.equal(h.length(), header.STREAM_MAX_LENGTH);
|
||||
|
||||
assert.equal(h.headerByte1, 0xff);
|
||||
assert.equal(h.headerByte2, 0xff);
|
||||
});
|
||||
|
||||
it("InitialStream", () => {
|
||||
let h = new header.InitialStream(0, 0);
|
||||
|
||||
h.set(15, 128, true);
|
||||
|
||||
assert.equal(h.command(), 15);
|
||||
assert.equal(h.data(), 128);
|
||||
assert.equal(h.success(), true);
|
||||
});
|
||||
});
|
||||
570
ui/stream/reader.js
Normal file
570
ui/stream/reader.js
Normal file
@@ -0,0 +1,570 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import Exception from "./exception.js";
|
||||
import * as subscribe from "./subscribe.js";
|
||||
|
||||
export class Buffer {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {Uint8Array} buffer Array buffer
|
||||
* @param {function} depleted Callback that will be called when the buffer
|
||||
* is depleted
|
||||
*/
|
||||
constructor(buffer, depleted) {
|
||||
this.buffer = buffer;
|
||||
this.used = 0;
|
||||
this.onDepleted = depleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the index of given byte inside current available (unused) read
|
||||
* buffer
|
||||
*
|
||||
* @param {number} byteData Target data
|
||||
* @param {number} maxLen Max search length
|
||||
*
|
||||
* @returns {number} Return number >= 0 when found, -1 when not
|
||||
*/
|
||||
searchBuffer(byteData, maxLen) {
|
||||
let searchLen = this.remains();
|
||||
|
||||
if (searchLen > maxLen) {
|
||||
searchLen = maxLen;
|
||||
}
|
||||
|
||||
for (let i = 0; i < searchLen; i++) {
|
||||
if (this.buffer[i + this.used] !== byteData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the index of given byte inside current available (unused) read
|
||||
* buffer
|
||||
*
|
||||
* @param {number} byteData Target data
|
||||
*
|
||||
* @returns {number} Return number >= 0 when found, -1 when not
|
||||
*/
|
||||
indexOf(byteData) {
|
||||
return this.searchBuffer(byteData, this.remains());
|
||||
}
|
||||
|
||||
/**
|
||||
* Return how many bytes in the source + buffer is still available to be
|
||||
* read, return 0 when reader is depleted and thus can be ditched
|
||||
*
|
||||
* @returns {number} Remaining size
|
||||
*
|
||||
*/
|
||||
remains() {
|
||||
return this.buffer.length - this.used;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return how many bytes is still availale in the buffer.
|
||||
*
|
||||
* Note: This reader don't have renewable data source, so when buffer
|
||||
* depletes, the reader is done
|
||||
*
|
||||
* @returns {number} Remaining size
|
||||
*
|
||||
*/
|
||||
buffered() {
|
||||
return this.remains();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export max n bytes from current buffer
|
||||
*
|
||||
* @param {number} n suggested max byte length, set to 0 to refresh buffer
|
||||
* if current buffer is deplated
|
||||
*
|
||||
* @returns {Uint8Array} Exported data
|
||||
*
|
||||
* @throws {Exception} When reader has been depleted
|
||||
*
|
||||
*/
|
||||
export(n) {
|
||||
let remain = this.remains();
|
||||
|
||||
if (remain <= 0) {
|
||||
throw new Exception("Reader has been depleted", false);
|
||||
}
|
||||
|
||||
if (remain > n) {
|
||||
remain = n;
|
||||
}
|
||||
|
||||
let exported = this.buffer.slice(this.used, this.used + remain);
|
||||
this.used += exported.length;
|
||||
|
||||
if (this.remains() <= 0) {
|
||||
this.onDepleted();
|
||||
}
|
||||
|
||||
return exported;
|
||||
}
|
||||
}
|
||||
|
||||
export class Multiple {
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param {function} depleted Callback will be called when all reader is
|
||||
* depleted
|
||||
*
|
||||
*/
|
||||
constructor(depleted) {
|
||||
this.reader = null;
|
||||
this.depleted = depleted;
|
||||
this.subscribe = new subscribe.Subscribe();
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new reader as sub reader
|
||||
*
|
||||
* @param {Buffer} reader
|
||||
* @param {function} depleted Callback that will be called when given reader
|
||||
* is depleted
|
||||
*
|
||||
* @throws {Exception} When the reader is closed
|
||||
*
|
||||
*/
|
||||
feed(reader, depleted) {
|
||||
if (this.closed) {
|
||||
throw new Exception("Reader is closed", false);
|
||||
}
|
||||
|
||||
if (this.reader === null && this.subscribe.pendings() <= 0) {
|
||||
this.reader = {
|
||||
reader: reader,
|
||||
depleted: depleted
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.subscribe.resolve({
|
||||
reader: reader,
|
||||
depleted: depleted
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the index of given byte inside current available (unused) read
|
||||
* buffer
|
||||
*
|
||||
* @param {number} byteData Target data
|
||||
* @param {number} maxLen Max search length
|
||||
*
|
||||
* @returns {number} Return number >= 0 when found, -1 when not
|
||||
*
|
||||
*/
|
||||
searchBuffer(byteData, maxLen) {
|
||||
if (this.reader === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return this.reader.reader.searchBuffer(byteData, maxLen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the index of given byte inside current available (unused) read
|
||||
* buffer
|
||||
*
|
||||
* @param {number} byteData Target data
|
||||
*
|
||||
* @returns {number} Return number >= 0 when found, -1 when not
|
||||
*/
|
||||
indexOf(byteData) {
|
||||
return this.searchBuffer(byteData, this.buffered());
|
||||
}
|
||||
|
||||
/**
|
||||
* Return how many bytes still available in the buffer (How many bytes of
|
||||
* buffer is left for read before reloading from data source)
|
||||
*
|
||||
* @returns {number} How many bytes left in the current buffer
|
||||
*/
|
||||
buffered() {
|
||||
if (this.reader == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.reader.reader.buffered();
|
||||
}
|
||||
|
||||
/**
|
||||
* close current reading
|
||||
*
|
||||
*/
|
||||
close() {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closed = true;
|
||||
this.subscribe.reject(new Exception("Reader is closed", false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Export max n bytes from current buffer
|
||||
*
|
||||
* @param {number} n suggested max byte length, set to 0 to refresh buffer
|
||||
* if current buffer is deplated
|
||||
*
|
||||
* @returns {Uint8Array} Exported data
|
||||
*
|
||||
*/
|
||||
async export(n) {
|
||||
for (;;) {
|
||||
if (this.reader !== null) {
|
||||
let exported = await this.reader.reader.export(n);
|
||||
|
||||
if (this.reader.reader.remains() <= 0) {
|
||||
this.reader.depleted();
|
||||
|
||||
this.reader = null;
|
||||
}
|
||||
|
||||
return exported;
|
||||
}
|
||||
|
||||
this.depleted(this);
|
||||
|
||||
this.reader = await this.subscribe.subscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Reader {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {Multiple} multiple Source reader
|
||||
* @param {function} bufferConverter Function convert
|
||||
*
|
||||
*/
|
||||
constructor(multiple, bufferConverter) {
|
||||
this.multiple = multiple;
|
||||
this.buffers = new subscribe.Subscribe();
|
||||
this.bufferConverter =
|
||||
bufferConverter ||
|
||||
(d => {
|
||||
return d;
|
||||
});
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add buffer into current reader
|
||||
*
|
||||
* @param {Uint8Array} buffer buffer to add
|
||||
*
|
||||
* @throws {Exception} When the reader is closed
|
||||
*
|
||||
*/
|
||||
feed(buffer) {
|
||||
if (this.closed) {
|
||||
throw new Exception("Reader is closed, new data has been deined", false);
|
||||
}
|
||||
|
||||
this.buffers.resolve(buffer);
|
||||
}
|
||||
|
||||
async reader() {
|
||||
if (this.closed) {
|
||||
throw new Exception("Reader is closed, unable to read", false);
|
||||
}
|
||||
|
||||
if (this.multiple.buffered() > 0) {
|
||||
return this.multiple;
|
||||
}
|
||||
|
||||
let self = this,
|
||||
converted = await this.bufferConverter(await self.buffers.subscribe());
|
||||
|
||||
this.multiple.feed(new Buffer(converted, () => {}), () => {});
|
||||
|
||||
return this.multiple;
|
||||
}
|
||||
|
||||
/**
|
||||
* close current reading
|
||||
*
|
||||
*/
|
||||
close() {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closed = true;
|
||||
this.buffers.reject(
|
||||
new Exception(
|
||||
"Reader is closed, and thus " + "cannot be operated on",
|
||||
false
|
||||
)
|
||||
);
|
||||
|
||||
return this.multiple.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the index of given byte inside current available (unused) read
|
||||
* buffer
|
||||
*
|
||||
* @param {number} byteData Target data
|
||||
* @param {number} maxLen Max search length
|
||||
*
|
||||
* @returns {number} Return number >= 0 when found, -1 when not
|
||||
*/
|
||||
async searchBuffer(byteData, maxLen) {
|
||||
return (await this.reader()).searchBuffer(byteData, maxLen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the index of given byte inside current available (unused) read
|
||||
* buffer
|
||||
*
|
||||
* @param {number} byteData Target data
|
||||
*
|
||||
* @returns {number} Return number >= 0 when found, -1 when not
|
||||
*/
|
||||
async indexOf(byteData) {
|
||||
return (await this.reader()).indexOf(byteData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return how many bytes still available in the buffer (How many bytes of
|
||||
* buffer is left for read before reloading from data source)
|
||||
*
|
||||
* @returns {number} How many bytes left in the current buffer
|
||||
*/
|
||||
async buffered() {
|
||||
return (await this.reader()).buffered();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export max n bytes from current buffer
|
||||
*
|
||||
* @param {number} n suggested max byte length, set to 0 to refresh buffer
|
||||
* if current buffer is deplated
|
||||
*
|
||||
* @returns {Uint8Array} Exported data
|
||||
*
|
||||
*/
|
||||
async export(n) {
|
||||
return (await this.reader()).export(n);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read exactly one bytes from the reader
|
||||
*
|
||||
* @param {Reader} reader the source reader
|
||||
*
|
||||
* @returns {Uint8Array} Exported data
|
||||
*
|
||||
*/
|
||||
export async function readOne(reader) {
|
||||
for (;;) {
|
||||
let d = await reader.export(1);
|
||||
|
||||
if (d.length <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return d;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read exactly n bytes from the reader
|
||||
*
|
||||
* @param {Reader} reader the source reader
|
||||
* @param {number} n length to read
|
||||
*
|
||||
* @returns {Uint8Array} Exported data
|
||||
*
|
||||
*/
|
||||
export async function readN(reader, n) {
|
||||
let readed = 0,
|
||||
result = new Uint8Array(n);
|
||||
|
||||
while (readed < n) {
|
||||
let exported = await reader.export(n - readed);
|
||||
|
||||
result.set(exported, readed);
|
||||
readed += exported.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export class Limited {
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param {Reader} reader the source reader
|
||||
* @param {number} maxN max bytes to read
|
||||
*
|
||||
* @returns {boolean} true when the reader is completed, false otherwise
|
||||
*
|
||||
*/
|
||||
constructor(reader, maxN) {
|
||||
this.reader = reader;
|
||||
this.remain = maxN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate whether or not the current reader is completed
|
||||
*
|
||||
* @returns {boolean} true when the reader is completed, false otherwise
|
||||
*
|
||||
*/
|
||||
completed() {
|
||||
return this.remain <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the index of given byte inside current available (unused) read
|
||||
* buffer
|
||||
*
|
||||
* @param {number} byteData Target data
|
||||
* @param {number} maxLen Max search length
|
||||
*
|
||||
* @returns {number} Return number >= 0 when found, -1 when not
|
||||
*
|
||||
*/
|
||||
searchBuffer(byteData, maxLen) {
|
||||
return this.reader.searchBuffer(
|
||||
byteData,
|
||||
maxLen > this.remain ? this.remain : maxLen
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the index of given byte inside current read buffer
|
||||
*
|
||||
* @param {number} byteData Target data
|
||||
*
|
||||
* @returns {number} Return number >= 0 when found, -1 when not
|
||||
*/
|
||||
indexOf(byteData) {
|
||||
return this.reader.searchBuffer(byteData, this.remain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return how many bytes still available to be read
|
||||
*
|
||||
* @returns {number} Remaining size
|
||||
*
|
||||
*/
|
||||
remains() {
|
||||
return this.remain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return how many bytes still available in the buffer (How many bytes of
|
||||
* buffer is left for read before reloading from data source)
|
||||
*
|
||||
* @returns {number} Remaining size
|
||||
*
|
||||
*/
|
||||
buffered() {
|
||||
let buf = this.reader.buffered();
|
||||
|
||||
return buf > this.remain ? this.remain : buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export max n bytes from current buffer
|
||||
*
|
||||
* @param {number} n suggested max length
|
||||
*
|
||||
* @throws {Exception} when reading already completed
|
||||
*
|
||||
* @returns {Uint8Array} Exported data
|
||||
*
|
||||
*/
|
||||
async export(n) {
|
||||
if (this.completed()) {
|
||||
throw new Exception("Reader already completed", false);
|
||||
}
|
||||
|
||||
let toRead = n > this.remain ? this.remain : n,
|
||||
exported = await this.reader.export(toRead);
|
||||
|
||||
this.remain -= exported.length;
|
||||
|
||||
return exported;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the whole Limited reader and return the result
|
||||
*
|
||||
* @param {Limited} limited the Limited reader
|
||||
*
|
||||
* @returns {Uint8Array} Exported data
|
||||
*
|
||||
*/
|
||||
export async function readCompletely(limited) {
|
||||
return await readN(limited, limited.remains());
|
||||
}
|
||||
|
||||
/**
|
||||
* Read until given byteData is reached. This function is guaranteed to spit
|
||||
* out at least one byte
|
||||
*
|
||||
* @param {Reader} indexOfReader
|
||||
* @param {number} byteData
|
||||
*/
|
||||
export async function readUntil(indexOfReader, byteData) {
|
||||
let pos = await indexOfReader.indexOf(byteData),
|
||||
buffered = await indexOfReader.buffered();
|
||||
|
||||
if (pos >= 0) {
|
||||
return {
|
||||
data: await readN(indexOfReader, pos + 1),
|
||||
found: true
|
||||
};
|
||||
}
|
||||
|
||||
if (buffered <= 0) {
|
||||
let d = await readOne(indexOfReader);
|
||||
|
||||
return {
|
||||
data: d,
|
||||
found: d[0] === byteData
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: await readN(indexOfReader, buffered),
|
||||
found: false
|
||||
};
|
||||
}
|
||||
220
ui/stream/reader_test.js
Normal file
220
ui/stream/reader_test.js
Normal file
@@ -0,0 +1,220 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import assert from "assert";
|
||||
import * as reader from "./reader.js";
|
||||
|
||||
describe("Reader", () => {
|
||||
it("Buffer", async () => {
|
||||
let buf = new reader.Buffer(
|
||||
new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]),
|
||||
() => {}
|
||||
);
|
||||
|
||||
let ex = buf.export(1);
|
||||
|
||||
assert.equal(ex.length, 1);
|
||||
assert.equal(ex[0], 0);
|
||||
assert.equal(buf.remains(), 7);
|
||||
|
||||
ex = await reader.readCompletely(buf);
|
||||
|
||||
assert.equal(ex.length, 7);
|
||||
assert.deepEqual(ex, new Uint8Array([1, 2, 3, 4, 5, 6, 7]));
|
||||
assert.equal(buf.remains(), 0);
|
||||
});
|
||||
|
||||
it("Reader", async () => {
|
||||
const maxTests = 3;
|
||||
let IntvCount = 0,
|
||||
r = new reader.Reader(new reader.Multiple(() => {}), data => {
|
||||
return data;
|
||||
}),
|
||||
expected = [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7
|
||||
],
|
||||
feedIntv = setInterval(() => {
|
||||
r.feed(Uint8Array.from(expected.slice(0, 8)));
|
||||
|
||||
IntvCount++;
|
||||
|
||||
if (IntvCount < maxTests) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(feedIntv);
|
||||
}, 300);
|
||||
|
||||
let result = [];
|
||||
|
||||
while (result.length < expected.length) {
|
||||
result.push((await r.export(1))[0]);
|
||||
}
|
||||
|
||||
assert.deepEqual(result, expected);
|
||||
});
|
||||
|
||||
it("readOne", async () => {
|
||||
let r = new reader.Reader(new reader.Multiple(() => {}), data => {
|
||||
return data;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
r.feed(Uint8Array.from([0, 1, 2, 3, 4, 5, 7]));
|
||||
}, 100);
|
||||
|
||||
let rr = await reader.readOne(r);
|
||||
|
||||
assert.deepEqual(rr, [0]);
|
||||
|
||||
rr = await reader.readOne(r);
|
||||
|
||||
assert.deepEqual(rr, [1]);
|
||||
});
|
||||
|
||||
it("readN", async () => {
|
||||
let r = new reader.Reader(new reader.Multiple(() => {}), data => {
|
||||
return data;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
r.feed(Uint8Array.from([0, 1, 2, 3, 4, 5, 7]));
|
||||
}, 100);
|
||||
|
||||
let rr = await reader.readN(r, 3);
|
||||
|
||||
assert.deepEqual(rr, [0, 1, 2]);
|
||||
|
||||
rr = await reader.readN(r, 3);
|
||||
|
||||
assert.deepEqual(rr, [3, 4, 5]);
|
||||
});
|
||||
|
||||
it("Limited", async () => {
|
||||
const maxTests = 3;
|
||||
let IntvCount = 0,
|
||||
r = new reader.Reader(new reader.Multiple(() => {}), data => {
|
||||
return data;
|
||||
}),
|
||||
expected = [0, 1, 2, 3, 4, 5, 6, 7, 0, 1],
|
||||
limited = new reader.Limited(r, 10),
|
||||
feedIntv = setInterval(() => {
|
||||
r.feed(Uint8Array.from(expected.slice(0, 8)));
|
||||
|
||||
IntvCount++;
|
||||
|
||||
if (IntvCount < maxTests) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(feedIntv);
|
||||
}, 300);
|
||||
|
||||
let result = [];
|
||||
|
||||
while (!limited.completed()) {
|
||||
result.push((await limited.export(1))[0]);
|
||||
}
|
||||
|
||||
assert.equal(limited.completed(), true);
|
||||
assert.deepEqual(result, expected);
|
||||
});
|
||||
|
||||
it("readCompletely", async () => {
|
||||
const maxTests = 3;
|
||||
let IntvCount = 0,
|
||||
r = new reader.Reader(new reader.Multiple(() => {}), data => {
|
||||
return data;
|
||||
}),
|
||||
expected = [0, 1, 2, 3, 4, 5, 6, 7, 0, 1],
|
||||
limited = new reader.Limited(r, 10),
|
||||
feedIntv = setInterval(() => {
|
||||
r.feed(Uint8Array.from(expected.slice(0, 8)));
|
||||
|
||||
IntvCount++;
|
||||
|
||||
if (IntvCount < maxTests) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(feedIntv);
|
||||
}, 300);
|
||||
|
||||
let result = await reader.readCompletely(limited);
|
||||
|
||||
assert.equal(limited.completed(), true);
|
||||
assert.deepEqual(result, expected);
|
||||
});
|
||||
|
||||
it("readUntil", async () => {
|
||||
const maxTests = 3;
|
||||
let IntvCount = 0,
|
||||
r = new reader.Reader(new reader.Multiple(() => {}), data => {
|
||||
return data;
|
||||
}),
|
||||
sample = [0, 1, 2, 3, 4, 5, 6, 7, 0, 1],
|
||||
expected1 = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]),
|
||||
expected2 = new Uint8Array([0, 1]),
|
||||
limited = new reader.Limited(r, 10),
|
||||
feedIntv = setInterval(() => {
|
||||
r.feed(Uint8Array.from(sample));
|
||||
|
||||
IntvCount++;
|
||||
|
||||
if (IntvCount < maxTests) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(feedIntv);
|
||||
}, 300);
|
||||
|
||||
let result = await reader.readUntil(limited, 7);
|
||||
|
||||
assert.equal(limited.completed(), false);
|
||||
assert.deepEqual(result.data, expected1);
|
||||
assert.deepEqual(result.found, true);
|
||||
|
||||
result = await reader.readUntil(limited, 7);
|
||||
|
||||
assert.equal(limited.completed(), true);
|
||||
assert.deepEqual(result.data, expected2);
|
||||
assert.deepEqual(result.found, false);
|
||||
});
|
||||
});
|
||||
200
ui/stream/sender.js
Normal file
200
ui/stream/sender.js
Normal file
@@ -0,0 +1,200 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import Exception from "./exception.js";
|
||||
import * as subscribe from "./subscribe.js";
|
||||
|
||||
export class Sender {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {function} sender Underlaying sender
|
||||
* @param {number} bufferDelay in ms
|
||||
*
|
||||
*/
|
||||
constructor(sender, bufferDelay, maxSegSize) {
|
||||
this.sender = sender;
|
||||
this.delay = bufferDelay;
|
||||
this.maxSegSize = maxSegSize;
|
||||
this.timeout = null;
|
||||
this.buffered = new Uint8Array(this.maxSegSize);
|
||||
this.bufferedSize = 0;
|
||||
this.subscribe = new subscribe.Subscribe();
|
||||
this.sendingPoc = this.sending();
|
||||
this.resolves = [];
|
||||
this.rejects = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sender proc
|
||||
*
|
||||
*/
|
||||
async sending() {
|
||||
for (;;) {
|
||||
let fetched = await this.subscribe.subscribe();
|
||||
|
||||
await this.sender(fetched);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear everything
|
||||
*
|
||||
*/
|
||||
async clear() {
|
||||
if (this.timeout !== null) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = null;
|
||||
}
|
||||
|
||||
this.buffered = null;
|
||||
this.bufferedSize = 0;
|
||||
|
||||
this.subscribe.reject(new Exception("Sender has been closed", false));
|
||||
|
||||
try {
|
||||
await this.sendingPoc;
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
this.reject(new Exception("Sending has been cancelled", true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Call resolves
|
||||
*
|
||||
* @param {any} d Data
|
||||
*/
|
||||
resolve(d) {
|
||||
for (let i in this.resolves) {
|
||||
this.resolves[i](d);
|
||||
}
|
||||
|
||||
this.resolves = [];
|
||||
this.rejects = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Call rejects
|
||||
*
|
||||
* @param {any} d Data
|
||||
*/
|
||||
reject(d) {
|
||||
for (let i in this.rejects) {
|
||||
this.rejects[i](d);
|
||||
}
|
||||
|
||||
this.resolves = [];
|
||||
this.rejects = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send buffer to the sender
|
||||
*
|
||||
*/
|
||||
flushBuffer() {
|
||||
if (this.bufferedSize <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.timeout !== null) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = null;
|
||||
}
|
||||
|
||||
this.resolve(true);
|
||||
|
||||
let d = this.buffered.slice(0, this.bufferedSize);
|
||||
|
||||
this.subscribe.resolve(d);
|
||||
|
||||
if (d.length >= this.buffered.length) {
|
||||
this.buffered = new Uint8Array(this.maxSegSize);
|
||||
this.bufferedSize = 0;
|
||||
} else {
|
||||
this.buffered = this.buffered.slice(d.length, this.buffered.length);
|
||||
this.bufferedSize = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append buffer to internal data storage
|
||||
*
|
||||
* @param {Uint8Array} buf Buffer data
|
||||
*/
|
||||
appendBuffer(buf) {
|
||||
let remain = this.buffered.length - this.bufferedSize;
|
||||
|
||||
if (remain <= 0) {
|
||||
this.flushBuffer();
|
||||
|
||||
remain = this.buffered.length - this.bufferedSize;
|
||||
}
|
||||
|
||||
let start = 0,
|
||||
end = remain;
|
||||
|
||||
while (start < buf.length) {
|
||||
if (end > buf.length) {
|
||||
end = buf.length;
|
||||
}
|
||||
|
||||
let d = buf.slice(start, end);
|
||||
|
||||
this.buffered.set(d, this.bufferedSize);
|
||||
this.bufferedSize += d.length;
|
||||
|
||||
if (this.buffered.length >= this.bufferedSize) {
|
||||
this.flushBuffer();
|
||||
}
|
||||
|
||||
start += d.length;
|
||||
end = start + (this.buffered.length - this.bufferedSize);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send data
|
||||
*
|
||||
* @param {Uint8Array} data data to send
|
||||
*
|
||||
* @throws {Exception} when sending has been cancelled
|
||||
*
|
||||
* @returns {Promise} will be resolved when the data is send and will be
|
||||
* rejected when the data is not
|
||||
*
|
||||
*/
|
||||
send(data) {
|
||||
let self = this;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
self.resolves.push(resolve);
|
||||
self.rejects.push(reject);
|
||||
|
||||
this.appendBuffer(data);
|
||||
|
||||
if (this.bufferedSize <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.timeout = setTimeout(() => {
|
||||
self.flushBuffer();
|
||||
}, self.delay);
|
||||
});
|
||||
}
|
||||
}
|
||||
325
ui/stream/stream.js
Normal file
325
ui/stream/stream.js
Normal file
@@ -0,0 +1,325 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import Exception from "./exception.js";
|
||||
import * as header from "./header.js";
|
||||
import * as reader from "./reader.js";
|
||||
import * as sender from "./sender.js";
|
||||
|
||||
export class Sender {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {number} id ID of the stream
|
||||
* @param {sender.Sender} sd The data sender
|
||||
*
|
||||
*/
|
||||
constructor(id, sd) {
|
||||
this.id = id;
|
||||
this.sender = sd;
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends data to remote
|
||||
*
|
||||
* @param {number} marker binary marker
|
||||
* @param {Uint8Array} data data to be sent
|
||||
*
|
||||
* @throws {Exception} When the sender already been closed
|
||||
*
|
||||
*/
|
||||
send(marker, data) {
|
||||
if (this.closed) {
|
||||
throw new Exception(
|
||||
"Sender already been closed. No data can be send",
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
let reqHeader = new header.Header(header.STREAM),
|
||||
stHeader = new header.Stream(0, 0),
|
||||
d = new Uint8Array(data.length + 3);
|
||||
|
||||
reqHeader.set(this.id);
|
||||
stHeader.set(marker, data.length);
|
||||
|
||||
d[0] = reqHeader.value();
|
||||
d.set(stHeader.buffer(), 1);
|
||||
d.set(data, 3);
|
||||
|
||||
return this.sender.send(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send stream signals
|
||||
*
|
||||
* @param {number} signal Signal value
|
||||
*
|
||||
* @throws {Exception} When the sender already been closed
|
||||
*
|
||||
*/
|
||||
signal(signal) {
|
||||
if (this.closed) {
|
||||
throw new Exception(
|
||||
"Sender already been closed. No signal can be send",
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
let reqHeader = new header.Header(signal);
|
||||
|
||||
reqHeader.set(this.id);
|
||||
|
||||
return this.sender.send(new Uint8Array([reqHeader.value()]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send close signal and close current sender
|
||||
*
|
||||
*/
|
||||
close() {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let r = this.signal(header.CLOSE);
|
||||
|
||||
this.closed = true;
|
||||
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
export class InitialSender {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {number} id ID of the stream
|
||||
* @param {number} commandID ID of the command
|
||||
* @param {sender.Sender} sd The data sender
|
||||
*
|
||||
*/
|
||||
constructor(id, commandID, sd) {
|
||||
this.id = id;
|
||||
this.command = commandID;
|
||||
this.sender = sd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return how large the data can be
|
||||
*
|
||||
* @returns {number} Max data size
|
||||
*
|
||||
*/
|
||||
static maxDataLength() {
|
||||
return header.InitialStream.maxDataSize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends data to remote
|
||||
*
|
||||
* @param {Uint8Array} data data to be sent
|
||||
*
|
||||
*/
|
||||
send(data) {
|
||||
let reqHeader = new header.Header(header.STREAM),
|
||||
stHeader = new header.InitialStream(0, 0),
|
||||
d = new Uint8Array(data.length + 3);
|
||||
|
||||
reqHeader.set(this.id);
|
||||
stHeader.set(this.command, data.length, true);
|
||||
|
||||
d[0] = reqHeader.value();
|
||||
d.set(stHeader.buffer(), 1);
|
||||
d.set(data, 3);
|
||||
|
||||
return this.sender.send(d);
|
||||
}
|
||||
}
|
||||
|
||||
export class Stream {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {number} id ID of the stream
|
||||
*
|
||||
*/
|
||||
constructor(id) {
|
||||
this.id = id;
|
||||
this.command = null;
|
||||
this.isInitializing = false;
|
||||
this.isShuttingDown = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not current stream is running
|
||||
*
|
||||
* @returns {boolean} True when it's running, false otherwise
|
||||
*
|
||||
*/
|
||||
running() {
|
||||
return this.command !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not current stream is initializing
|
||||
*
|
||||
* @returns {boolean} True when it's initializing, false otherwise
|
||||
*
|
||||
*/
|
||||
initializing() {
|
||||
return this.isInitializing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsets current stream
|
||||
*
|
||||
*/
|
||||
clear() {
|
||||
this.command = null;
|
||||
this.isInitializing = false;
|
||||
this.isShuttingDown = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the stream for a new command
|
||||
*
|
||||
* @param {number} commandID Command ID
|
||||
* @param {function} commandBuilder Function that returns a command
|
||||
* @param {sender.Sender} sd Data sender
|
||||
*
|
||||
* @throws {Exception} when stream already running
|
||||
*
|
||||
*/
|
||||
run(commandID, commandBuilder, sd) {
|
||||
if (this.running()) {
|
||||
throw new Exception(
|
||||
"Stream already running, cannot accept new commands",
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
this.isInitializing = true;
|
||||
this.command = commandBuilder(new Sender(this.id, sd));
|
||||
|
||||
return this.command.run(new InitialSender(this.id, commandID, sd));
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when initialization respond has been received
|
||||
*
|
||||
* @param {header.InitialStream} streamInitialHeader Stream Initial header
|
||||
*
|
||||
* @throws {Exception} When the stream is not running, or been shutting down
|
||||
*
|
||||
*/
|
||||
initialize(hd) {
|
||||
if (!this.running()) {
|
||||
throw new Exception(
|
||||
"Cannot initialize a stream that is not running",
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
if (this.isShuttingDown) {
|
||||
throw new Exception(
|
||||
"Cannot initialize a stream that is about to shutdown",
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
this.command.initialize(hd);
|
||||
|
||||
if (!hd.success()) {
|
||||
this.clear();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.isInitializing = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when Stream data has been received
|
||||
*
|
||||
* @param {header.Stream} streamHeader Stream header
|
||||
* @param {reader.Limited} rd Data reader
|
||||
*
|
||||
* @throws {Exception} When the stream is not running, or shutting down
|
||||
*
|
||||
*/
|
||||
tick(streamHeader, rd) {
|
||||
if (!this.running()) {
|
||||
throw new Exception("Cannot tick a stream that is not running", false);
|
||||
}
|
||||
|
||||
if (this.isShuttingDown) {
|
||||
throw new Exception(
|
||||
"Cannot tick a stream that is about to shutdown",
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
return this.command.tick(streamHeader, rd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when stream close request has been received
|
||||
*
|
||||
* @throws {Exception} When the stream is not running, or shutting down
|
||||
*
|
||||
*/
|
||||
close() {
|
||||
if (!this.running()) {
|
||||
throw new Exception("Cannot close a stream that is not running", false);
|
||||
}
|
||||
|
||||
if (this.isShuttingDown) {
|
||||
throw new Exception(
|
||||
"Cannot close a stream that is about to shutdown",
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
this.isShuttingDown = true;
|
||||
this.command.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when stream completed respond has been received
|
||||
*
|
||||
* @throws {Exception} When stream isn't running, or not shutting down
|
||||
*
|
||||
*/
|
||||
completed() {
|
||||
if (!this.running()) {
|
||||
throw new Exception("Cannot close a stream that is not running", false);
|
||||
}
|
||||
|
||||
if (!this.isShuttingDown) {
|
||||
throw new Exception(
|
||||
"Can't complete current stream because Close " +
|
||||
"signal is not received",
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
this.command.completed();
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
436
ui/stream/streams.js
Normal file
436
ui/stream/streams.js
Normal file
@@ -0,0 +1,436 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import Exception from "./exception.js";
|
||||
import * as header from "./header.js";
|
||||
import * as stream from "./stream.js";
|
||||
import * as reader from "./reader.js";
|
||||
import * as sender from "./sender.js";
|
||||
import * as common from "./common.js";
|
||||
|
||||
export const ECHO_FAILED = -1;
|
||||
|
||||
export class Requested {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {stream.Stream} stream The selected stream
|
||||
* @param {any} result Result of the run
|
||||
*
|
||||
*/
|
||||
constructor(stream, result) {
|
||||
this.stream = stream;
|
||||
this.result = result;
|
||||
}
|
||||
}
|
||||
|
||||
export class Streams {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {reader.Reader} reader The data reader
|
||||
* @param {sender.Sender} sender The data sender
|
||||
* @param {object} config Configuration
|
||||
*/
|
||||
constructor(reader, sender, config) {
|
||||
this.reader = reader;
|
||||
this.sender = sender;
|
||||
this.config = config;
|
||||
this.echoTimer = null;
|
||||
this.lastEchoTime = null;
|
||||
this.lastEchoData = null;
|
||||
this.stop = false;
|
||||
|
||||
this.streams = [];
|
||||
|
||||
for (let i = 0; i <= header.HEADER_MAX_DATA; i++) {
|
||||
this.streams.push(new stream.Stream(i));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts stream proccessing
|
||||
*
|
||||
* @returns {Promise<true>} When service is completed
|
||||
*
|
||||
* @throws {Exception} When the process already started
|
||||
*
|
||||
*/
|
||||
async serve() {
|
||||
if (this.echoTimer !== null) {
|
||||
throw new Exception("Already started", false);
|
||||
}
|
||||
|
||||
this.echoTimer = setInterval(() => {
|
||||
this.sendEcho();
|
||||
}, this.config.echoInterval);
|
||||
|
||||
this.stop = false;
|
||||
|
||||
this.sendEcho();
|
||||
|
||||
let ee = null;
|
||||
|
||||
while (!this.stop && ee === null) {
|
||||
try {
|
||||
await this.tick();
|
||||
} catch (e) {
|
||||
if (!e.temporary) {
|
||||
ee = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.clear(ee);
|
||||
|
||||
if (ee !== null) {
|
||||
throw new Exception("Streams is closed: " + ee, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear current proccess
|
||||
*
|
||||
* @param {Exception} e An error caused this clear. Null when no error
|
||||
*
|
||||
*/
|
||||
clear(e) {
|
||||
if (this.stop) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stop = true;
|
||||
|
||||
if (this.echoTimer != null) {
|
||||
clearInterval(this.echoTimer);
|
||||
this.echoTimer = null;
|
||||
}
|
||||
|
||||
for (let i in this.streams) {
|
||||
if (!this.streams[i].running()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
this.streams[i].close();
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
try {
|
||||
this.streams[i].completed();
|
||||
} catch (e) {
|
||||
//Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this.sender.clear();
|
||||
} catch (e) {
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
|
||||
try {
|
||||
this.reader.close();
|
||||
} catch (e) {
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
|
||||
this.config.cleared(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request remote to pause stream sending
|
||||
*
|
||||
*/
|
||||
pause() {
|
||||
let pauseHeader = header.header(header.CONTROL);
|
||||
|
||||
pauseHeader.set(1);
|
||||
|
||||
return this.sender.send(
|
||||
new Uint8Array([pauseHeader.value(), header.CONTROL_PAUSESTREAM])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request remote to resume stream sending
|
||||
*
|
||||
*/
|
||||
resume() {
|
||||
let pauseHeader = header.header(header.CONTROL);
|
||||
|
||||
pauseHeader.set(1);
|
||||
|
||||
return this.sender.send(
|
||||
new Uint8Array([pauseHeader.value(), header.CONTROL_RESUMESTREAM])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request stream for given command
|
||||
*
|
||||
* @param {number} commandID Command ID
|
||||
* @param {function} commandBuilder Command builder
|
||||
*
|
||||
* @returns {Requested} The result of the stream command
|
||||
*
|
||||
*/
|
||||
request(commandID, commandBuilder) {
|
||||
try {
|
||||
for (let i in this.streams) {
|
||||
if (this.streams[i].running()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return new Requested(
|
||||
this.streams[i],
|
||||
this.streams[i].run(commandID, commandBuilder, this.sender)
|
||||
);
|
||||
}
|
||||
|
||||
throw new Exception("No stream is currently available", true);
|
||||
} catch (e) {
|
||||
throw new Exception("Stream request has failed: " + e, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send echo request
|
||||
*
|
||||
*/
|
||||
sendEcho() {
|
||||
let echoHeader = header.header(header.CONTROL),
|
||||
randomNum = new Uint8Array(common.getRands(8, 0, 255));
|
||||
|
||||
echoHeader.set(randomNum.length - 1);
|
||||
|
||||
randomNum[0] = echoHeader.value();
|
||||
randomNum[1] = header.CONTROL_ECHO;
|
||||
|
||||
this.sender.send(randomNum).then(() => {
|
||||
if (this.lastEchoTime !== null || this.lastEchoData !== null) {
|
||||
this.lastEchoTime = null;
|
||||
this.lastEchoData = null;
|
||||
|
||||
this.config.echoUpdater(ECHO_FAILED);
|
||||
}
|
||||
|
||||
this.lastEchoTime = new Date();
|
||||
this.lastEchoData = randomNum.slice(2, randomNum.length);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* handle received control request
|
||||
*
|
||||
* @param {reader.Reader} rd The reader
|
||||
*
|
||||
*/
|
||||
async handleControl(rd) {
|
||||
let controlType = await reader.readOne(rd),
|
||||
delay = 0,
|
||||
echoBytes = null;
|
||||
|
||||
switch (controlType[0]) {
|
||||
case header.CONTROL_ECHO:
|
||||
echoBytes = await reader.readCompletely(rd);
|
||||
|
||||
if (this.lastEchoTime === null || this.lastEchoData === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.lastEchoData.length !== echoBytes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i in this.lastEchoData) {
|
||||
if (this.lastEchoData[i] == echoBytes[i]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.lastEchoTime = null;
|
||||
this.lastEchoData = null;
|
||||
|
||||
this.config.echoUpdater(ECHO_FAILED);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
delay = new Date().getTime() - this.lastEchoTime.getTime();
|
||||
|
||||
if (delay < 0) {
|
||||
delay = 0;
|
||||
}
|
||||
|
||||
this.lastEchoTime = null;
|
||||
this.lastEchoData = null;
|
||||
|
||||
this.config.echoUpdater(delay);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await reader.readCompletely(rd);
|
||||
|
||||
throw new Exception("Unknown control signal: " + controlType);
|
||||
}
|
||||
|
||||
/**
|
||||
* handle received stream respond
|
||||
*
|
||||
* @param {header.Header} hd The header
|
||||
* @param {reader.Reader} rd The reader
|
||||
*
|
||||
* @throws {Exception} when given stream is not running
|
||||
*
|
||||
*/
|
||||
async handleStream(hd, rd) {
|
||||
if (hd.data() >= this.streams.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
let stream = this.streams[hd.data()];
|
||||
|
||||
if (!stream.running()) {
|
||||
// WARNING: Connection must be reset at this point because we cannot
|
||||
// determine how many bytes to read
|
||||
throw new Exception(
|
||||
'Remote is requesting for stream "' +
|
||||
hd.data() +
|
||||
'" which is not running',
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
let initialHeaderBytes = await reader.readN(rd, 2);
|
||||
|
||||
// WARNING: It's the stream's responsibility to ensure stream data is
|
||||
// completely readed before return
|
||||
if (stream.initializing()) {
|
||||
let streamHeader = new header.InitialStream(
|
||||
initialHeaderBytes[0],
|
||||
initialHeaderBytes[1]
|
||||
);
|
||||
|
||||
return stream.initialize(streamHeader);
|
||||
}
|
||||
|
||||
let streamHeader = new header.Stream(
|
||||
initialHeaderBytes[0],
|
||||
initialHeaderBytes[1]
|
||||
),
|
||||
streamReader = new reader.Limited(rd, streamHeader.length());
|
||||
|
||||
let tickResult = await stream.tick(streamHeader, streamReader);
|
||||
|
||||
await reader.readCompletely(streamReader);
|
||||
|
||||
return tickResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* handle received close respond
|
||||
*
|
||||
* @param {header.Header} hd The header
|
||||
*
|
||||
* @throws {Exception} when given stream is not running
|
||||
*
|
||||
*/
|
||||
async handleClose(hd) {
|
||||
if (hd.data() >= this.streams.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
let stream = this.streams[hd.data()];
|
||||
|
||||
if (!stream.running()) {
|
||||
// WARNING: Connection must be reset at this point because we cannot
|
||||
// determine how many bytes to read
|
||||
throw new Exception(
|
||||
'Remote is requesting for stream "' +
|
||||
hd.data() +
|
||||
'" to be closed, but the stream is not running',
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
let cResult = await stream.close();
|
||||
|
||||
let completedHeader = new header.Header(header.COMPLETED);
|
||||
completedHeader.set(hd.data());
|
||||
this.sender.send(new Uint8Array([completedHeader.value()]));
|
||||
|
||||
return cResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* handle received close respond
|
||||
*
|
||||
* @param {header.Header} hd The header
|
||||
*
|
||||
* @throws {Exception} when given stream is not running
|
||||
*
|
||||
*/
|
||||
async handleCompleted(hd) {
|
||||
if (hd.data() >= this.streams.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
let stream = this.streams[hd.data()];
|
||||
|
||||
if (!stream.running()) {
|
||||
// WARNING: Connection must be reset at this point because we cannot
|
||||
// determine how many bytes to read
|
||||
throw new Exception(
|
||||
'Remote is requesting for stream "' +
|
||||
hd.data() +
|
||||
'" to be completed, but the stream is not running',
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
return stream.completed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Main proccess loop
|
||||
*
|
||||
* @throws {Exception} when encountered an unknown header
|
||||
*/
|
||||
async tick() {
|
||||
let headerBytes = await reader.readOne(this.reader),
|
||||
hd = new header.Header(headerBytes[0]);
|
||||
|
||||
switch (hd.type()) {
|
||||
case header.CONTROL:
|
||||
return this.handleControl(new reader.Limited(this.reader, hd.data()));
|
||||
|
||||
case header.STREAM:
|
||||
return this.handleStream(hd, this.reader);
|
||||
|
||||
case header.CLOSE:
|
||||
return this.handleClose(hd);
|
||||
|
||||
case header.COMPLETED:
|
||||
return this.handleCompleted(hd);
|
||||
|
||||
default:
|
||||
throw new Exception("Unknown header", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
22
ui/stream/streams_test.js
Normal file
22
ui/stream/streams_test.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import assert from "assert";
|
||||
|
||||
describe("Streams", () => {
|
||||
it("Header", () => {});
|
||||
});
|
||||
114
ui/stream/subscribe.js
Normal file
114
ui/stream/subscribe.js
Normal file
@@ -0,0 +1,114 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import Exception from "./exception.js";
|
||||
|
||||
const typeReject = 0;
|
||||
const typeResolve = 1;
|
||||
|
||||
export class Subscribe {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
*/
|
||||
constructor() {
|
||||
this.res = null;
|
||||
this.rej = null;
|
||||
this.pending = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how many resolve/reject in the pending
|
||||
*/
|
||||
pendings() {
|
||||
return (
|
||||
this.pending.length + (this.rej !== null || this.res !== null ? 1 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the subscribe waiter
|
||||
*
|
||||
* @param {any} d Resolve data which will be send to the subscriber
|
||||
*/
|
||||
resolve(d) {
|
||||
if (this.res === null) {
|
||||
this.pending.push([typeResolve, d]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.res(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject the subscribe waiter
|
||||
*
|
||||
* @param {any} e Error message that will be send to the subscriber
|
||||
*
|
||||
*/
|
||||
reject(e) {
|
||||
if (this.rej === null) {
|
||||
this.pending.push([typeReject, e]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.rej(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waiting and receive subscribe data
|
||||
*
|
||||
* @returns {Promise<any>} Data receiver
|
||||
*
|
||||
*/
|
||||
subscribe() {
|
||||
if (this.pending.length > 0) {
|
||||
let p = this.pending.shift();
|
||||
|
||||
switch (p[0]) {
|
||||
case typeReject:
|
||||
throw p[1];
|
||||
|
||||
case typeResolve:
|
||||
return p[1];
|
||||
|
||||
default:
|
||||
throw new Exception("Unknown pending type", false);
|
||||
}
|
||||
}
|
||||
|
||||
let self = this;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
self.res = d => {
|
||||
self.res = null;
|
||||
self.rej = null;
|
||||
|
||||
resolve(d);
|
||||
};
|
||||
|
||||
self.rej = e => {
|
||||
self.res = null;
|
||||
self.rej = null;
|
||||
|
||||
reject(e);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
5
ui/widgets/busy.svg
Normal file
5
ui/widgets/busy.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="150" height="150" viewBox="0 0 26 26">
|
||||
<path transform="scale(.26)" d="M21.041 34.48c-.026 0-.056.005-.082.008a.716.716 0 0 0-.232.074L6.092 42.113a.698.698 0 0 0-.375.631V44.6a.2.2 0 0 0 0 .007.695.695 0 0 0 .367.608l13.297 7.347a.262.262 0 0 1 .135.233v7.242c.003.384.322.7.707.697h.834a.703.703 0 0 0 .691-.697v-8.055c0-.162.107-.261.27-.261h7.894c.162 0 .264.1.264.261v8.055c.003.384.323.7.707.697h.832a.705.705 0 0 0 .691-.697V45.605c0-.11.024-.155.053-.187a.292.292 0 0 1 .158-.076.3.3 0 0 1 .18.008c.036.015.072.048.113.129.004.007.004.004.008.013a.2.2 0 0 0 0 .008l6.092 13.334c.124.27.39.418.646.414a.703.703 0 0 0 .631-.436l3.838-9.54c.06-.15.129-.165.256-.165s.188.015.248.164l3.832 9.542c.109.283.37.444.63.45a.71.71 0 0 0 .661-.421L55.836 45.5c.046-.1.083-.126.121-.143a.278.278 0 0 1 .172-.007.292.292 0 0 1 .158.076c.03.032.052.077.053.187v14.424a.2.2 0 0 0 0 .008.703.703 0 0 0 .707.69h.834a.696.696 0 0 0 .697-.69.2.2 0 0 0 0-.008V43.795a.696.696 0 0 0-.697-.691H55.01a.695.695 0 0 0-.639.398.2.2 0 0 0-.006 0c-1.688 3.661-3.369 7.33-5.056 10.99-.065.141-.132.161-.256.158-.123-.003-.182-.03-.24-.173l-3.786-9.329a.7.7 0 0 0-.646-.45.71.71 0 0 0-.654.45l-3.793 9.329c-.06.145-.118.17-.24.173-.125.003-.184-.017-.249-.158l-5.054-10.99a.705.705 0 0 0-.639-.398h-2.87a.702.702 0 0 0-.706.69v5.41c0 .161-.107.278-.264.278h-7.894c-.158 0-.27-.117-.27-.279v-5.408a.698.698 0 0 0-.691-.691h-.834a.702.702 0 0 0-.707.69v5.741c0 .11-.078.261-.172.33-.114.084-.13.087-.211.045a.2.2 0 0 0-.008 0 .2.2 0 0 0-.03-.015.2.2 0 0 0-.023-.008c-3.673-1.892-7.333-3.954-11.011-5.889a.276.276 0 0 1-.135-.166.91.91 0 0 1 .008-.277.61.61 0 0 1 .142-.203.694.694 0 0 1 .104-.075c.049-.014.1-.035.144-.06 4.332-2.275 8.706-4.465 13.047-6.71a.702.702 0 0 0 .377-.622v-.699a.703.703 0 0 0-.707-.706zm40.654 8.624a.705.705 0 0 0-.697.707v16.224a.71.71 0 0 0 .705.7h.826a.704.704 0 0 0 .7-.7v-8.053c0-.16.109-.261.271-.261h6.887a.702.702 0 0 0 .699-.707v-.842a.698.698 0 0 0-.7-.69H63.5c-.157 0-.271-.118-.271-.279v-3.598c0-.16.109-.263.271-.263h12.334c.162 0 .264.102.264.263v14.43c.003.38.31.695.69.7a.2.2 0 0 0 .007 0h.826a.709.709 0 0 0 .707-.7v-14.43c0-.162.102-.263.264-.263h4.543c.094 0 .177.043.226.12l4.846 7.686a.25.25 0 0 1 .045.135v6.752a.707.707 0 0 0 .691.7h.832a.707.707 0 0 0 .692-.7v-6.752c0-.044.014-.086.045-.135l5.642-8.97c.289-.456-.046-1.072-.586-1.074h-.955a.7.7 0 0 0-.6.324l-4.433 7.031c-.064.101-.145.135-.225.135-.08 0-.16-.034-.224-.135l-4.44-7.031a.696.696 0 0 0-.586-.324c-7.465 0-14.936-.004-22.402 0h-.008zM4.447 47.303c-.022 0-.046.005-.068.008a.696.696 0 0 0-.639.69v.812a.713.713 0 0 0 .37.617l10.816 5.949.008.008a.2.2 0 0 0 .023.015.2.2 0 0 0 .03.022c.093.05.142.127.142.226v1.23c0 .1-.047.186-.135.235a.2.2 0 0 0-.045.03l-10.847 6.22a.7.7 0 0 0-.362.608v.85a.2.2 0 0 0 0 .007c.004.528.597.858 1.053.592l11.898-6.887a.699.699 0 0 0 .346-.61v-3.267a.703.703 0 0 0-.361-.61c-3.964-2.217-11.89-6.655-11.89-6.655a.2.2 0 0 0-.009 0 .689.689 0 0 0-.33-.09z" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="0.8" stroke="#e9a" stroke-dasharray="20 20 20 20 20" stroke-dashoffset="-50">
|
||||
<animate begin="0s" attributeName="stroke-dashoffset" from="-100" to="100" dur="5s" repeatCount="indefinite" restart="always"/>
|
||||
</path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
316
ui/widgets/chart.vue
Normal file
316
ui/widgets/chart.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<slot />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/* eslint vue/attribute-hyphenation: 0 */
|
||||
|
||||
const XMLNG = "http://www.w3.org/2000/svg";
|
||||
const XMLNS = "http://www.w3.org/2000/xmlns/";
|
||||
const XMLNGLink = "http://www.w3.org/1999/xlink";
|
||||
|
||||
class Data {
|
||||
constructor(data) {
|
||||
this.data = data;
|
||||
this.max = this.getMax(data);
|
||||
}
|
||||
|
||||
setMax(max) {
|
||||
this.max = this.max > max ? this.max : max;
|
||||
}
|
||||
|
||||
getMax(data) {
|
||||
let max = 0;
|
||||
|
||||
for (let i in data) {
|
||||
if (data[i] <= max) {
|
||||
continue;
|
||||
}
|
||||
|
||||
max = data[i];
|
||||
}
|
||||
|
||||
return max;
|
||||
}
|
||||
}
|
||||
|
||||
class BaseDrawer {
|
||||
constructor() {
|
||||
this.elements = [];
|
||||
}
|
||||
|
||||
toCellHeight(cellHeight, data, n) {
|
||||
if (data.max === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (cellHeight / data.max) * n;
|
||||
}
|
||||
|
||||
toBottomHeight(cellHeight, n) {
|
||||
return cellHeight - n;
|
||||
}
|
||||
|
||||
cellWidth(rootDim, data) {
|
||||
return rootDim.width / data.data.length;
|
||||
}
|
||||
|
||||
createEl(parent, tag, properties) {
|
||||
let np = document.createElementNS(XMLNG, tag);
|
||||
|
||||
for (let p in properties) {
|
||||
if (p.indexOf("xlink:") === 0) {
|
||||
np.setAttributeNS(XMLNGLink, p, properties[p]);
|
||||
} else if (p.indexOf("xmlns:") === 0) {
|
||||
np.setAttributeNS(XMLNS, p, properties[p]);
|
||||
} else {
|
||||
np.setAttribute(p, properties[p]);
|
||||
}
|
||||
}
|
||||
|
||||
parent.appendChild(np);
|
||||
|
||||
this.elements.push(np);
|
||||
|
||||
return np;
|
||||
}
|
||||
|
||||
removeAllEl(parent) {
|
||||
for (let i in this.elements) {
|
||||
parent.removeChild(this.elements[i]);
|
||||
}
|
||||
|
||||
this.elements = [];
|
||||
}
|
||||
|
||||
draw(parent, rootDim, data) {}
|
||||
}
|
||||
|
||||
class BarDrawer extends BaseDrawer {
|
||||
constructor(topBottomPadding) {
|
||||
super();
|
||||
|
||||
this.topBottomPadding = topBottomPadding;
|
||||
}
|
||||
|
||||
draw(parent, rootDim, data) {
|
||||
let cellWidth = this.cellWidth(rootDim, data),
|
||||
currentWidth = cellWidth / 2,
|
||||
cellHalfHeight = rootDim.height - this.topBottomPadding / 2,
|
||||
cellHeight = rootDim.height - this.topBottomPadding;
|
||||
|
||||
for (let i in data.data) {
|
||||
let h = this.toCellHeight(cellHeight, data, data.data[i]);
|
||||
|
||||
this.createEl(parent, "path", {
|
||||
d:
|
||||
"M" +
|
||||
currentWidth +
|
||||
"," +
|
||||
Math.round(this.toBottomHeight(cellHalfHeight, h)) +
|
||||
" L" +
|
||||
currentWidth +
|
||||
"," +
|
||||
cellHalfHeight,
|
||||
class: h > 0 ? "" : "zero"
|
||||
});
|
||||
|
||||
currentWidth += cellWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UpsideDownBarDrawer extends BarDrawer {
|
||||
draw(parent, rootDim, data) {
|
||||
let cellWidth = this.cellWidth(rootDim, data),
|
||||
currentWidth = cellWidth / 2,
|
||||
padHalfHeight = this.topBottomPadding / 2,
|
||||
cellHeight = rootDim.height - this.topBottomPadding;
|
||||
|
||||
for (let i in data.data) {
|
||||
let h = this.toCellHeight(cellHeight, data, data.data[i]);
|
||||
|
||||
this.createEl(parent, "path", {
|
||||
d:
|
||||
"M" +
|
||||
currentWidth +
|
||||
"," +
|
||||
padHalfHeight +
|
||||
" L" +
|
||||
currentWidth +
|
||||
"," +
|
||||
(Math.round(h) + padHalfHeight),
|
||||
class: h > 0 ? "" : "zero"
|
||||
});
|
||||
|
||||
currentWidth += cellWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Chart {
|
||||
constructor(el, width, height, drawer) {
|
||||
this.el = el;
|
||||
this.drawer = drawer;
|
||||
this.group = null;
|
||||
this.paths = [];
|
||||
this.dim = { width, height };
|
||||
|
||||
this.el.setAttribute(
|
||||
"viewBox",
|
||||
"0 0 " +
|
||||
parseInt(this.dim.width, 10) +
|
||||
" " +
|
||||
parseInt(this.dim.height, 10)
|
||||
);
|
||||
|
||||
this.el.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
||||
}
|
||||
|
||||
getGroupRoot() {
|
||||
if (this.group) {
|
||||
return this.group;
|
||||
}
|
||||
|
||||
this.group = document.createElementNS(XMLNG, "g");
|
||||
|
||||
this.el.appendChild(this.group);
|
||||
|
||||
return this.group;
|
||||
}
|
||||
|
||||
draw(data, manualMax) {
|
||||
let d = new Data(data);
|
||||
let max = d.max;
|
||||
|
||||
d.setMax(manualMax);
|
||||
|
||||
this.drawer.removeAllEl(this.getGroupRoot());
|
||||
this.drawer.draw(this.getGroupRoot(), this.dim, d);
|
||||
|
||||
return {
|
||||
dataMax: max,
|
||||
resultMax: d.max
|
||||
};
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.drawer.removeAllEl();
|
||||
this.el.removeChild(this.getGroupRoot());
|
||||
}
|
||||
}
|
||||
|
||||
function buildDrawer(type) {
|
||||
switch (type) {
|
||||
case "Bar":
|
||||
return new BarDrawer(10);
|
||||
|
||||
case "UpsideDownBar":
|
||||
return new UpsideDownBarDrawer(10);
|
||||
}
|
||||
|
||||
return new Error("Undefined drawer: " + type);
|
||||
}
|
||||
|
||||
export default {
|
||||
props: {
|
||||
values: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
enabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
chart: null,
|
||||
previousMax: 0
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
values() {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.draw();
|
||||
},
|
||||
max() {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.draw();
|
||||
},
|
||||
enabled(newVal) {
|
||||
if (!newVal) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.draw();
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.chart = new Chart(
|
||||
this.$el,
|
||||
this.width,
|
||||
this.height,
|
||||
buildDrawer(this.type)
|
||||
);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.chart.clear();
|
||||
},
|
||||
methods: {
|
||||
draw() {
|
||||
let r = this.chart.draw(this.values, this.max);
|
||||
|
||||
if (r.dataMax === this.previousMax) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("max", r.dataMax);
|
||||
this.previousMax = r.dataMax;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
109
ui/widgets/connect.css
Normal file
109
ui/widgets/connect.css
Normal file
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@charset "utf-8";
|
||||
|
||||
#connect {
|
||||
z-index: 999999;
|
||||
top: 40px;
|
||||
left: 159px;
|
||||
display: none;
|
||||
background: #333;
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
#connect .window-frame {
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#connect:before {
|
||||
left: 30px;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#connect {
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
#connect:before {
|
||||
left: 149px;
|
||||
}
|
||||
}
|
||||
|
||||
#connect.display {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#connect h1 {
|
||||
padding: 15px 15px 0 15px;
|
||||
margin-bottom: 10px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
#connect-close {
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
right: 10px;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
#connect-busy-overlay {
|
||||
z-index: 2;
|
||||
background: #2229 url("busy.svg") center center no-repeat;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
#connect-warning {
|
||||
padding: 10px;
|
||||
font-size: 0.85em;
|
||||
background: #b44;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#connect-warning-icon {
|
||||
float: left;
|
||||
display: block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
#connect-warning-icon::after {
|
||||
background: #c55;
|
||||
}
|
||||
|
||||
#connect-warning-msg {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#connect-warning-msg p {
|
||||
margin: 0 0 5px 0;
|
||||
}
|
||||
|
||||
#connect-warning-msg a {
|
||||
color: #faa;
|
||||
text-decoration: underline;
|
||||
}
|
||||
152
ui/widgets/connect.vue
Normal file
152
ui/widgets/connect.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<window
|
||||
id="connect"
|
||||
flash-class="home-window-display"
|
||||
:display="display"
|
||||
@display="$emit('display', $event)"
|
||||
>
|
||||
<h1 class="window-title">Establish connection with</h1>
|
||||
|
||||
<slot v-if="inputting"></slot>
|
||||
|
||||
<connect-switch
|
||||
v-if="!inputting"
|
||||
:knowns-length="knowns.length"
|
||||
:tab="tab"
|
||||
@switch="switchTab"
|
||||
></connect-switch>
|
||||
|
||||
<connect-new
|
||||
v-if="tab === 'new' && !inputting"
|
||||
:connectors="connectors"
|
||||
@select="selectConnector"
|
||||
></connect-new>
|
||||
|
||||
<connect-known
|
||||
v-if="tab === 'known' && !inputting"
|
||||
:knowns="knowns"
|
||||
:launcher-builder="knownsLauncherBuilder"
|
||||
@select="selectKnown"
|
||||
@remove="removeKnown"
|
||||
></connect-known>
|
||||
|
||||
<div id="connect-warning">
|
||||
<span id="connect-warning-icon" class="icon icon-warning1"></span>
|
||||
<div id="connect-warning-msg">
|
||||
<p>
|
||||
<strong>An insecured service may steal your secrects.</strong>
|
||||
Always exam the safty of the service before using it.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Sshwifty is a free software, you can deploy it on your own trusted
|
||||
infrastructure.
|
||||
<a href="https://github.com/niruix/sshwifty" target="_blank"
|
||||
>Learn more</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="busy" id="connect-busy-overlay"></div>
|
||||
</window>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "./connect.css";
|
||||
|
||||
import Window from "./window.vue";
|
||||
import ConnectSwitch from "./connect_switch.vue";
|
||||
import ConnectKnown from "./connect_known.vue";
|
||||
import ConnectNew from "./connect_new.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
window: Window,
|
||||
"connect-switch": ConnectSwitch,
|
||||
"connect-known": ConnectKnown,
|
||||
"connect-new": ConnectNew
|
||||
},
|
||||
props: {
|
||||
display: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
inputting: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
knowns: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
knownsLauncherBuilder: {
|
||||
type: Function,
|
||||
default: () => []
|
||||
},
|
||||
connectors: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
busy: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tab: "new",
|
||||
canSelect: true
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
switchTab(to) {
|
||||
if (this.inputting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tab = to;
|
||||
},
|
||||
selectConnector(connector) {
|
||||
if (this.inputting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("connector-select", connector);
|
||||
},
|
||||
selectKnown(known) {
|
||||
if (this.inputting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("known-select", known);
|
||||
},
|
||||
removeKnown(uid) {
|
||||
if (this.inputting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("known-remove", uid);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
111
ui/widgets/connect_known.css
Normal file
111
ui/widgets/connect_known.css
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@charset "utf-8";
|
||||
|
||||
#connect-known-list {
|
||||
min-height: 200px;
|
||||
font-size: 0.75em;
|
||||
padding: 15px;
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
#connect-known-list li {
|
||||
width: 50%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
#connect-known-list li {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
#connect-known-list li .lst-wrap {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#connect-known-list li .lst-wrap:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
#connect-known-list li .labels {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85em;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
#connect-known-list li .labels > .type {
|
||||
display: inline-block;
|
||||
padding: 3px;
|
||||
background: #a56;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#connect-known-list li .labels > .opt {
|
||||
display: none;
|
||||
padding: 3px;
|
||||
background: #a56;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
#connect-known-list li .labels > .opt {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
#connect-known-list li .labels > .opt.link {
|
||||
background: #287;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#connect-known-list li .labels > .opt.link:after {
|
||||
content: "\02936";
|
||||
}
|
||||
|
||||
#connect-known-list li .labels > .opt.del {
|
||||
background: #a56;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#connect-known-list li:hover .labels > .opt,
|
||||
#connect-known-list li:focus .labels > .opt {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#connect-known-list li h2 {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#connect-known-list li h2::before {
|
||||
content: ">_";
|
||||
color: #555;
|
||||
font-size: 0.8em;
|
||||
margin-right: 5px;
|
||||
font-weight: normal;
|
||||
}
|
||||
141
ui/widgets/connect_known.vue
Normal file
141
ui/widgets/connect_known.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div id="connect-known-list">
|
||||
<ul class="lstcl1">
|
||||
<li v-for="(known, kk) in knownList" :key="kk">
|
||||
<div class="labels">
|
||||
<span class="type" :style="'background-color: ' + known.data.color">
|
||||
{{ known.data.type }}
|
||||
</span>
|
||||
<a
|
||||
class="opt link"
|
||||
href="javascript:;"
|
||||
@click="launcher(known, $event)"
|
||||
>
|
||||
{{ known.copyStatus }}
|
||||
</a>
|
||||
<a
|
||||
class="opt del"
|
||||
href="javascript:;"
|
||||
@click="remove(known.data.uid)"
|
||||
>
|
||||
Remove
|
||||
</a>
|
||||
</div>
|
||||
<div class="lst-wrap" @click="select(known.data)">
|
||||
<h2 :title="known.data.title">{{ known.data.title }}</h2>
|
||||
Last: {{ known.data.last.toLocaleString() }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "./connect_known.css";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
knowns: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
launcherBuilder: {
|
||||
type: Function,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
knownList: [],
|
||||
busy: false
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
knowns(newVal) {
|
||||
this.reload(newVal);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.reload(this.knowns);
|
||||
},
|
||||
methods: {
|
||||
reload(knownList) {
|
||||
this.knownList = [];
|
||||
|
||||
for (let i in knownList) {
|
||||
this.knownList.unshift({
|
||||
data: knownList[i],
|
||||
copying: false,
|
||||
copyStatus: "Copy link"
|
||||
});
|
||||
}
|
||||
},
|
||||
select(known) {
|
||||
if (this.busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("select", known);
|
||||
},
|
||||
async launcher(known, ev) {
|
||||
if (known.copying || this.busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
|
||||
this.busy = true;
|
||||
known.copying = true;
|
||||
known.copyStatus = "Copying";
|
||||
|
||||
let lnk = this.launcherBuilder(known.data);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(lnk);
|
||||
|
||||
(() => {
|
||||
known.copyStatus = "Copied!";
|
||||
})();
|
||||
} catch (e) {
|
||||
(() => {
|
||||
known.copyStatus = "Failed";
|
||||
ev.target.setAttribute("href", lnk);
|
||||
})();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
known.copyStatus = "Copy link";
|
||||
known.copying = false;
|
||||
}, 2000);
|
||||
|
||||
this.busy = false;
|
||||
},
|
||||
remove(uid) {
|
||||
if (this.busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("remove", uid);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
58
ui/widgets/connect_new.css
Normal file
58
ui/widgets/connect_new.css
Normal file
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@charset "utf-8";
|
||||
|
||||
#connect-new {
|
||||
min-height: 200px;
|
||||
background: #3a3a3a;
|
||||
font-size: 0.75em;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
#connect-new li .lst-wrap:hover {
|
||||
background: #544;
|
||||
}
|
||||
|
||||
#connect-new li .lst-wrap:active {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
#connect-new li .lst-wrap {
|
||||
cursor: pointer;
|
||||
color: #aaa;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
#connect-new li h2 {
|
||||
color: #e9a;
|
||||
}
|
||||
|
||||
#connect-new li h2::before {
|
||||
content: ">";
|
||||
margin: 0 5px 0 0;
|
||||
color: #555;
|
||||
font-weight: normal;
|
||||
transition: ease 0.3s margin;
|
||||
}
|
||||
|
||||
#connect-new li .lst-wrap:hover h2::before {
|
||||
content: ">";
|
||||
margin: 0 3px 0 2px;
|
||||
}
|
||||
53
ui/widgets/connect_new.vue
Normal file
53
ui/widgets/connect_new.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div id="connect-new">
|
||||
<ul class="lst1">
|
||||
<li
|
||||
v-for="(connector, ck) in connectors"
|
||||
:key="ck"
|
||||
@click="select(connector)"
|
||||
>
|
||||
<div class="lst-wrap">
|
||||
<h2 :style="'color: ' + connector.color()">{{ connector.name() }}</h2>
|
||||
{{ connector.description() }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "./connect_new.css";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
connectors: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
select(connector) {
|
||||
this.$emit("select", connector);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
56
ui/widgets/connect_switch.css
Normal file
56
ui/widgets/connect_switch.css
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@charset "utf-8";
|
||||
|
||||
#connect-switch {
|
||||
font-size: 0.88em;
|
||||
color: #aaa;
|
||||
clear: both;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
#connect-switch li .label {
|
||||
padding: 2px 7px;
|
||||
margin-left: 3px;
|
||||
font-size: 0.85em;
|
||||
background: #444;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#connect-switch li.active {
|
||||
border-color: #555;
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
#connect-switch li.active .label {
|
||||
background: #888;
|
||||
}
|
||||
|
||||
#connect-switch li.disabled {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
#connect-switch.red {
|
||||
border-color: #a56;
|
||||
}
|
||||
|
||||
#connect-switch.red li.active {
|
||||
border-color: #a56;
|
||||
}
|
||||
65
ui/widgets/connect_switch.vue
Normal file
65
ui/widgets/connect_switch.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<ul id="connect-switch" class="tab2" :class="{ red: tab === 'known' }">
|
||||
<li :class="{ active: tab === 'new' }" @click="switchTab('new')">
|
||||
New remote
|
||||
</li>
|
||||
|
||||
<li
|
||||
:class="{ active: tab === 'known', disabled: knownsLength <= 0 }"
|
||||
@click="knownsLength > 0 && switchTab('known')"
|
||||
>
|
||||
Known remotes
|
||||
<span v-if="knownsLength > 0" class="label">{{ knownsLength }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "./connect_switch.css";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
tab: {
|
||||
type: String,
|
||||
default: "new"
|
||||
},
|
||||
knownsLength: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
knownsLength(newVal) {
|
||||
if (newVal > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.switchTab("new");
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
switchTab(to) {
|
||||
this.$emit("switch", to);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
141
ui/widgets/connecting.svg
Normal file
141
ui/widgets/connecting.svg
Normal file
@@ -0,0 +1,141 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 121.708 21.167">
|
||||
<g transform="translate(-.748 -275.833)">
|
||||
<g transform="translate(-75.306 -116.7)">
|
||||
<rect ry="2.064" y="396.011" x="90.919" height="9.647" width="12.406" fill="none" stroke="gray" stroke-width=".882"/>
|
||||
<rect ry="1.621" y="397.954" x="93.361" height="8.7" width="11.319" fill="#262626"/>
|
||||
<rect ry="1.861" y="397.092" x="92.235" height="8.7" width="11.319" fill="#d18b8d"/>
|
||||
<g transform="translate(1.455)" fill="#fff">
|
||||
<circle cx="99.925" cy="404.292" r=".801">
|
||||
<animate id="r2e11" begin="5s;r2e12.end+5s" attributeName="r" from=".801" to="0" dur="0.1s" calcMode="paced"/>
|
||||
<animate id="r2e12" begin="r2e11.end" attributeName="r" from="0" to=".801" dur="0.1s" calcMode="paced"/>
|
||||
</circle>
|
||||
<circle cx="93.956" cy="404.292" r=".801">
|
||||
<animate begin="r2e11.begin" attributeName="r" from=".801" to="0" dur="0.1s" calcMode="paced"/>
|
||||
<animate begin="r2e12.begin" attributeName="r" from="0" to=".801" dur="0.1s" calcMode="paced"/>
|
||||
</circle>
|
||||
<rect width="2.062" height=".677" x="95.8" y="404.047" ry=".338"/>
|
||||
</g>
|
||||
<g transform="translate(1.455)">
|
||||
<rect width="7.039" height="4.755" x="93.352" y="398.217" ry=".988" fill="#ed9499"/>
|
||||
<rect width="6.262" height="4.021" x="94.073" y="398.948" ry=".835" fill="#cc7f80"/>
|
||||
<g transform="translate(-.023 .21)" fill="#f5d0d0">
|
||||
<rect ry=".185" y="399.571" x="94.849" height=".369" width="4.864"/>
|
||||
<rect ry=".185" y="400.538" x="94.849" height=".369" width="4.864"/>
|
||||
<rect ry=".185" y="401.506" x="94.849" height=".369" width="4.864"/>
|
||||
</g>
|
||||
</g>
|
||||
<animateMotion id="r2ud1" begin="0s;r2ud2.end" from="0,0" to="0,1" dur="1s" calcMode="paced"/>
|
||||
<animateMotion id="r2ud2" begin="r2ud1.end" from="0,1" to="0,0" dur="1s" calcMode="paced"/>
|
||||
</g>
|
||||
<rect width="10" height="1.2" x="17" y="295" ry="1" fill="#262626">
|
||||
<animate id="r2ssw1" begin="0s;r2ssw2.end" attributeName="width" from="10" to="11" dur="1s" calcMode="paced"/>
|
||||
<animate id="r2ssw2" begin="r2ssw1.end" attributeName="width" from="11" to="10" dur="1s" calcMode="paced"/>
|
||||
<animate id="r2ssx1" begin="0s;r2ssx2.end" attributeName="x" from="17" to="16.5" dur="1s" calcMode="paced"/>
|
||||
<animate id="r2ssx2" begin="r2ssx1.end" attributeName="x" from="16.5" to="17" dur="1s" calcMode="paced"/>
|
||||
</rect>
|
||||
</g>
|
||||
<g transform="translate(-.748 -275.833)">
|
||||
<g>
|
||||
<g transform="translate(-76.79 -117.665)">
|
||||
<rect width="12.171" height="15.081" x="134.651" y="394.724" ry="3.796" fill="#262626"/>
|
||||
<rect width="10.909" height="13.555" x="134.566" y="394.751" ry="2.959" fill="none" stroke="gray" stroke-width=".8"/>
|
||||
<path d="M60.99 282.131a2.308 2.308 0 1 1 2.308 2.308v3.236" fill="none" stroke="#d18b8d" stroke-width="1.058" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="25.4000003,25.4000003"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(-76.79 -117.665)">
|
||||
<g fill="#d18b8d">
|
||||
<rect width="4.491" height="1.852" x="137.861" y="404.937" ry=".926"/>
|
||||
<circle cx="136.63" cy="405.863" r=".926"/>
|
||||
</g>
|
||||
<rect width="6.648" height="6.35" x="135.704" y="395.855" ry="2.052" fill="#d18b8d"/>
|
||||
<g>
|
||||
<rect ry=".573" y="397" x="136.697" height="2" width="1.146" fill="#f9f9f9">
|
||||
<animate id="r1e21" begin="r1lr1.begin" attributeName="height" from="2" to="1.3" dur="0.2s" fill="freeze"/>
|
||||
<animate begin="r1e21.begin" attributeName="y" from="397" to="397.7" dur="0.2s" fill="freeze"/>
|
||||
|
||||
<animate id="r1e22" begin="r1lr2.begin" attributeName="height" from="1.3" to="2" dur="0.2s" fill="freeze"/>
|
||||
<animate begin="r1e22.begin" attributeName="y" from="397.7" to="397" dur="0.2s" fill="freeze"/>
|
||||
</rect>
|
||||
<rect ry=".573" y="397.7" x="139.492" height="1.3" width="1.146" fill="#f9f9f9">
|
||||
<animate id="r1e11" begin="r1lr1.begin" attributeName="height" from="1.3" to="2" dur="0.2s" fill="freeze"/>
|
||||
<animate begin="r1e11.begin" attributeName="y" from="397.7" to="397" dur="0.2s" fill="freeze"/>
|
||||
|
||||
<animate id="r1e12" begin="r1lr2.begin" attributeName="height" from="2" to="1.3" dur="0.2s" fill="freeze"/>
|
||||
<animate begin="r1e12.begin" attributeName="y" from="397" to="397.7" dur="0.2s" fill="freeze"/>
|
||||
</rect>
|
||||
|
||||
<animateMotion begin="r1lr1.begin" from="0,0" to="1,0" dur="0.2s" calcMode="paced" fill="freeze"/>
|
||||
<animateMotion begin="r1lr2.begin" from="1,0" to="0,0" dur="0.2s" calcMode="paced" fill="freeze"/>
|
||||
</g>
|
||||
<rect width="6.648" height=".865" x="135.704" y="403.138" ry=".433" fill="gray"/>
|
||||
|
||||
<animateMotion id="r1lr1" begin="r2tor1.end" from="0,0" to="2,0" dur="0.2s" calcMode="paced" fill="freeze"/>
|
||||
<animateMotion id="r1lr2" begin="r1tor3.end" from="2,0" to="0,0" dur="0.2s" calcMode="paced" fill="freeze"/>
|
||||
</g>
|
||||
|
||||
<animateMotion id="r1ud2" begin="0s;r1ud1.end" from="0,1" to="0,0" dur="1.2s" calcMode="paced"/>
|
||||
<animateMotion id="r1ud1" begin="r1ud2.end" from="0,0" to="0,1" dur="1.2s" calcMode="paced"/>
|
||||
</g>
|
||||
<rect width="9" height="1.2" x="59" y="295" ry="1" fill="#262626">
|
||||
<animate id="r1ssw1" begin="r1ssw2.end" attributeName="width" from="9" to="10" dur="1.2s" calcMode="paced"/>
|
||||
<animate id="r1ssw2" begin="0s;r1ssw1.end" attributeName="width" from="10" to="9" dur="1.2s" calcMode="paced"/>
|
||||
<animate id="r1ssx1" begin="r1ssx2.end" attributeName="x" from="59" to="58.5" dur="1.2s" calcMode="paced"/>
|
||||
<animate id="r1ssx2" begin="0s;r1ssx1.end" attributeName="x" from="58.5" to="59" dur="1.2s" calcMode="paced"/>
|
||||
</rect>
|
||||
</g>
|
||||
<g transform="translate(-.748 -275.833)">
|
||||
<g>
|
||||
<g transform="translate(-75.306 -119.101)">
|
||||
<rect width="8.852" height="9.42" x="174.008" y="399.075" ry="1.492" fill="#262626"/>
|
||||
<rect width="8.008" height="8.522" x="173.653" y="398.845" ry=".976" fill="gray" stroke="gray" stroke-width=".628"/>
|
||||
<rect width="7.01" height="7.46" x="173.878" y="399.387" ry=".854" fill="#262626"/>
|
||||
</g>
|
||||
<g transform="translate(-75.306 -119.101)">
|
||||
<rect width="6.215" height="6.614" x="174.463" y="400.102" ry=".757" fill="#848484"/>
|
||||
<g fill="#fff">
|
||||
<rect ry=".278" y="401.302" x="175.442" height=".889" width="1.169">
|
||||
<animate id="r3e11" begin="4s;r3e12.end+4s" attributeName="width" from="1.169" to="1.769" dur="0.3s" calcMode="paced" fill="freeze"/>
|
||||
<animate id="r3e12" begin="r3e11.end+4s" attributeName="width" from="1.769" to="1.169" dur="0.3s" calcMode="paced" fill="freeze"/>
|
||||
</rect>
|
||||
<rect ry=".278" y="401.302" x="177.29" height=".889" width="2.1">
|
||||
<animate begin="r3e11.begin" attributeName="width" from="2.1" to="1.5" dur="0.3s" calcMode="paced" fill="freeze"/>
|
||||
<animate begin="r3e12.begin" attributeName="width" from="1.5" to="2.1" dur="0.3s" calcMode="paced" fill="freeze"/>
|
||||
|
||||
<animate begin="r3e11.begin" attributeName="x" from="177.29" to="177.89" dur="0.3s" calcMode="paced" fill="freeze"/>
|
||||
<animate begin="r3e12.begin" attributeName="x" from="177.89" to="177.29" dur="0.3s" calcMode="paced" fill="freeze"/>
|
||||
</rect>
|
||||
</g>
|
||||
<circle r="1.485" cy="404.653" cx="176.998" fill="#262626"/>
|
||||
<circle r=".887" cy="404.635" cx="176.998" fill="#d18b8d"/>
|
||||
<circle r="1.23" cy="404.509" cx="176.817" fill="none" stroke="#aaa" stroke-width=".429"/>
|
||||
</g>
|
||||
|
||||
<animateMotion id="r3ud2" begin="r3ud1.end" from="0,1" to="0,0" dur="1.3s" calcMode="paced"/>
|
||||
<animateMotion id="r3ud1" begin="0s;r3ud2.end" from="0,0" to="0,1" dur="1.3s" calcMode="paced"/>
|
||||
</g>
|
||||
<rect width="7" height="1.2" x="99" y="295" ry="1" fill="#262626">
|
||||
<animate id="r3ssw1" begin="r3ssw2.end" attributeName="width" from="7" to="8" dur="1.3s" calcMode="paced"/>
|
||||
<animate id="r3ssw2" begin="0s;r3ssw1.end" attributeName="width" from="8" to="7" dur="1.3s" calcMode="paced"/>
|
||||
<animate id="r3ssx1" begin="r3ssx2.end" attributeName="x" from="99" to="98.5" dur="1.3s" calcMode="paced"/>
|
||||
<animate id="r3ssx2" begin="0s;r3ssx1.end" attributeName="x" from="98.5" to="99" dur="1.3s" calcMode="paced"/>
|
||||
</rect>
|
||||
</g>
|
||||
<g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="0.8">
|
||||
<g stroke-dasharray="20 20 20 20 20 100" stroke-dashoffset="-100">
|
||||
<path d="M30.13 11.531s5.576-3.02 12.455-3.02c6.88 0 12.456 3.02 12.456 3.02" stroke="#d18b8d">
|
||||
<animate id="r1tor2" begin="r1lr2.end" attributeName="stroke-dashoffset" from="-100" to="100" dur="1.5s"/>
|
||||
</path>
|
||||
<path d="M55.04 8.514s-5.576 3.02-12.455 3.02c-6.879 0-12.455-3.02-12.455-3.02" stroke="gray">
|
||||
<animate id="r2tor1" begin="0s;r1tor2.end" attributeName="stroke-dashoffset" from="-100" to="100" dur="1.5s"/>
|
||||
</path>
|
||||
</g>
|
||||
<g stroke-dasharray="10 20 10 20 10 20 10 100" stroke-dashoffset="-100">
|
||||
<path d="M71.24 9.743h16.196a1.897 1.897 0 1 0 0-3.795 1.897 1.897 0 0 0 0 3.795h8.023" stroke="#d18b8d">
|
||||
<animate id="r1tor3" begin="r3tor1.end" attributeName="stroke-dashoffset" from="-100" to="100" dur="1.5s"/>
|
||||
</path>
|
||||
<path d="M 96.192792,9.7509803 H 72.003172" stroke="gray">
|
||||
<animate id="r3tor1" begin="r1lr1.end" attributeName="stroke-dashoffset" from="-100" to="100" dur="1.5s"/>
|
||||
</path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
124
ui/widgets/connector.css
Normal file
124
ui/widgets/connector.css
Normal file
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@charset "utf-8";
|
||||
|
||||
#connector {
|
||||
padding: 0 20px 40px 20px;
|
||||
}
|
||||
|
||||
#connector-cancel {
|
||||
text-decoration: none;
|
||||
color: #e9a;
|
||||
}
|
||||
|
||||
#connector-cancel.disabled {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
#connector-cancel::before {
|
||||
content: "\000AB";
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
#connector-title {
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
font-size: 0.9em;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
#connector-title > h2 {
|
||||
color: #e9a;
|
||||
font-size: 1.3em;
|
||||
font-weight: bold;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
#connector-title.big {
|
||||
margin: 50px 0;
|
||||
}
|
||||
|
||||
#connector-title.big > h2 {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
#connector-fields {
|
||||
margin-top: 10px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
#connector-continue {
|
||||
margin-top: 10px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
#connector-proccess {
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
font-size: 0.9em;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
#connector-proccess-message {
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
#connector-proccess-message > h2 {
|
||||
font-weight: normal;
|
||||
margin: 10px 0;
|
||||
color: #e9a;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
#connector-proccess-message > h2 > span {
|
||||
padding: 2px 10px;
|
||||
border: 2px solid transparent;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes connector-proccess-message-alert {
|
||||
0% {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
50% {
|
||||
outline: 2px solid #e9a;
|
||||
}
|
||||
|
||||
60% {
|
||||
border-color: #e9a;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
#connector-proccess-message.alert > h2 > span {
|
||||
outline: 2px solid transparent;
|
||||
animation-name: connector-proccess-message-alert;
|
||||
animation-duration: 1.5s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: normal;
|
||||
animation-timing-function: steps(1, end);
|
||||
}
|
||||
|
||||
#connector-proccess-indicater {
|
||||
width: 100%;
|
||||
margin: 20px auto;
|
||||
padding: 0;
|
||||
}
|
||||
463
ui/widgets/connector.vue
Normal file
463
ui/widgets/connector.vue
Normal file
@@ -0,0 +1,463 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<form
|
||||
id="connector"
|
||||
class="form1"
|
||||
action="javascript:;"
|
||||
method="POST"
|
||||
@submit="submitAndGetNext"
|
||||
>
|
||||
<a
|
||||
id="connector-cancel"
|
||||
href="javascript:;"
|
||||
:class="{ disabled: working || cancelled }"
|
||||
@click="cancel()"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
|
||||
<div
|
||||
v-if="!working"
|
||||
id="connector-title"
|
||||
:class="{ big: current.fields.length <= 0 }"
|
||||
>
|
||||
<h2>{{ current.title || connector.name }}</h2>
|
||||
|
||||
<p>{{ current.message || connector.description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="working" id="connector-proccess">
|
||||
<img id="connector-proccess-indicater" src="./connecting.svg" />
|
||||
|
||||
<div id="connector-proccess-message" :class="{ alert: current.alert }">
|
||||
<h2>
|
||||
<span>{{ current.title || connector.name }}</span>
|
||||
</h2>
|
||||
|
||||
<p>{{ current.message || connector.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset id="connector-fields">
|
||||
<div
|
||||
v-for="(field, key) in current.fields"
|
||||
:key="key"
|
||||
class="field"
|
||||
:class="{ error: field.error.length > 0 }"
|
||||
>
|
||||
{{ field.field.name }}
|
||||
|
||||
<input
|
||||
v-if="field.field.type === 'text'"
|
||||
v-model="field.field.value"
|
||||
v-focus="field.autofocus"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:name="field.field.name"
|
||||
:placeholder="field.field.example"
|
||||
:autofocus="field.autofocus"
|
||||
@input="verify(key, field, false)"
|
||||
@change="verify(key, field, true)"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-if="field.field.type === 'password'"
|
||||
v-model="field.field.value"
|
||||
v-focus="field.autofocus"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:name="field.field.name"
|
||||
:placeholder="field.field.example"
|
||||
:autofocus="field.autofocus"
|
||||
@input="verify(key, field, false)"
|
||||
@change="verify(key, field, true)"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-if="field.field.type === 'checkbox'"
|
||||
v-model="field.field.value"
|
||||
type="checkbox"
|
||||
autocomplete="off"
|
||||
:name="field.field.name"
|
||||
@input="verify(key, field, false)"
|
||||
@change="verify(key, field, true)"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
v-if="field.field.type === 'textarea'"
|
||||
v-model="field.field.value"
|
||||
v-focus="field.autofocus"
|
||||
autocomplete="off"
|
||||
:placeholder="field.field.example"
|
||||
:name="field.field.name"
|
||||
:autofocus="field.autofocus"
|
||||
@input="verify(key, field, false)"
|
||||
@keyup="expandTextarea"
|
||||
@change="verify(key, field, true)"
|
||||
></textarea>
|
||||
|
||||
<div v-if="field.field.type === 'textdata'" class="textinfo">
|
||||
<div class="info">{{ field.field.value }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="field.field.type === 'radio'" class="items">
|
||||
<label
|
||||
v-for="(option, oKey) in field.field.example.split(',')"
|
||||
:key="oKey"
|
||||
class="field horizontal item"
|
||||
>
|
||||
<input
|
||||
v-model="field.field.value"
|
||||
type="radio"
|
||||
autocomplete="off"
|
||||
:name="field.field.name"
|
||||
:value="option"
|
||||
@input="verify(key, field, false)"
|
||||
@change="verify(key, field, true)"
|
||||
/>
|
||||
{{ option }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="field.error.length > 0" class="error">{{ field.error }}</div>
|
||||
<div v-else-if="field.message.length > 0" class="message">
|
||||
{{ field.message }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="field.field.description.length > 0"
|
||||
class="message"
|
||||
v-html="field.field.description"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<button
|
||||
v-if="current.submittable"
|
||||
type="submit"
|
||||
:disabled="current.submitting || disabled"
|
||||
@click="submitAndGetNext"
|
||||
>
|
||||
{{ current.actionText }}
|
||||
</button>
|
||||
<button
|
||||
v-if="current.cancellable"
|
||||
:disabled="current.submitting || disabled"
|
||||
class="secondary"
|
||||
@click="cancelAndGetNext"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "./connector.css";
|
||||
import * as command from "../commands/commands.js";
|
||||
|
||||
function buildField(i, field) {
|
||||
return {
|
||||
verified: false,
|
||||
inputted: false,
|
||||
error: "",
|
||||
message: "",
|
||||
field: field,
|
||||
autofocus: i == 0
|
||||
};
|
||||
}
|
||||
|
||||
function buildEmptyCurrent() {
|
||||
return {
|
||||
data: null,
|
||||
alert: false,
|
||||
title: "",
|
||||
message: "",
|
||||
fields: [],
|
||||
actionText: "Continue",
|
||||
cancellable: false,
|
||||
submittable: false,
|
||||
submitting: false
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
directives: {
|
||||
focus: {
|
||||
inserted(el, binding) {
|
||||
if (!binding.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
el.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
connector: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentConnector: null,
|
||||
currentConnectorCloseWait: null,
|
||||
current: buildEmptyCurrent(),
|
||||
working: false,
|
||||
disabled: false,
|
||||
cancelled: false
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
async connector(oldV, newV) {
|
||||
if (this.currentConnector !== null) {
|
||||
await this.closeWizard();
|
||||
}
|
||||
|
||||
this.cancelled = false;
|
||||
this.currentConnector = newV;
|
||||
this.runWizard();
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.closeWizard();
|
||||
this.runWizard();
|
||||
this.cancelled = false;
|
||||
},
|
||||
async beforeDestroy() {
|
||||
try {
|
||||
await this.closeWizard();
|
||||
} catch (e) {
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async sendCancel() {
|
||||
await this.closeWizard();
|
||||
|
||||
this.$emit("cancel", true);
|
||||
},
|
||||
cancel() {
|
||||
if (this.cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cancelled = true;
|
||||
|
||||
if (this.working) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendCancel();
|
||||
},
|
||||
buildCurrent(next) {
|
||||
this.current = buildEmptyCurrent();
|
||||
|
||||
this.working = this.getConnector().wizard.started();
|
||||
|
||||
this.current.type = next.type();
|
||||
this.current.data = next.data();
|
||||
|
||||
switch (this.current.type) {
|
||||
case command.NEXT_PROMPT:
|
||||
let fields = this.current.data.inputs();
|
||||
|
||||
for (let i in fields) {
|
||||
this.current.fields.push(buildField(i, fields[i]));
|
||||
}
|
||||
|
||||
this.current.actionText = this.current.data.actionText();
|
||||
this.current.submittable = true;
|
||||
this.current.alert = true;
|
||||
this.current.cancellable = true;
|
||||
|
||||
// Fallthrough
|
||||
|
||||
case command.NEXT_WAIT:
|
||||
this.current.title = this.current.data.title();
|
||||
this.current.message = this.current.data.message();
|
||||
break;
|
||||
|
||||
case command.NEXT_DONE:
|
||||
this.working = false;
|
||||
this.disabled = true;
|
||||
|
||||
if (!this.current.data.success()) {
|
||||
this.current.title = this.current.data.error();
|
||||
this.current.message = this.current.data.message();
|
||||
} else {
|
||||
this.$emit("done", this.current.data.data());
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error("Unknown command type");
|
||||
}
|
||||
|
||||
if (!this.working) {
|
||||
this.current.cancellable = false;
|
||||
}
|
||||
|
||||
return next;
|
||||
},
|
||||
getConnector() {
|
||||
if (this.currentConnector === null) {
|
||||
this.currentConnector = this.connector;
|
||||
}
|
||||
|
||||
return this.currentConnector;
|
||||
},
|
||||
async closeWizard() {
|
||||
if (this.currentConnectorCloseWait === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let waiter = this.currentConnectorCloseWait;
|
||||
|
||||
this.currentConnectorCloseWait = null;
|
||||
|
||||
this.getConnector().wizard.close();
|
||||
|
||||
await waiter;
|
||||
},
|
||||
runWizard() {
|
||||
if (this.currentConnectorCloseWait !== null) {
|
||||
throw new Error("Cannot run wizard multiple times");
|
||||
}
|
||||
|
||||
this.currentConnectorCloseWait = (async () => {
|
||||
while (!this.disabled) {
|
||||
let next = this.buildCurrent(await this.getConnector().wizard.next());
|
||||
|
||||
switch (next.type()) {
|
||||
case command.NEXT_PROMPT:
|
||||
case command.NEXT_WAIT:
|
||||
continue;
|
||||
case command.NEXT_DONE:
|
||||
return;
|
||||
|
||||
default:
|
||||
throw new Error("Unknown command type");
|
||||
}
|
||||
}
|
||||
})();
|
||||
},
|
||||
getFieldValues() {
|
||||
let mod = {};
|
||||
|
||||
for (let i in this.current.fields) {
|
||||
mod[this.current.fields[i].field.name] = this.current.fields[
|
||||
i
|
||||
].field.value;
|
||||
}
|
||||
|
||||
return mod;
|
||||
},
|
||||
expandTextarea(event) {
|
||||
event.target.style.overflowY = "hidden";
|
||||
event.target.style.height = "";
|
||||
event.target.style.height = event.target.scrollHeight + "px";
|
||||
},
|
||||
async verify(key, field, force) {
|
||||
try {
|
||||
field.message = "" + (await field.field.verify(field.field.value));
|
||||
field.inputted = true;
|
||||
field.verified = true;
|
||||
field.error = "";
|
||||
} catch (e) {
|
||||
field.error = "";
|
||||
field.message = "";
|
||||
field.verified = false;
|
||||
|
||||
if (field.inputted || force) {
|
||||
field.error = "" + e;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!field.verified &&
|
||||
(field.inputted || force) &&
|
||||
field.error.length <= 0
|
||||
) {
|
||||
field.error = "Invalid";
|
||||
}
|
||||
|
||||
this.current.fields[key] = field;
|
||||
|
||||
return field.verified;
|
||||
},
|
||||
async verifyAll() {
|
||||
let verified = true;
|
||||
|
||||
for (let i in this.current.fields) {
|
||||
if (await this.verify(i, this.current.fields[i], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
verified = false;
|
||||
}
|
||||
|
||||
return verified;
|
||||
},
|
||||
async submitAndGetNext() {
|
||||
if (this.current.submitting || this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.current.data === null || !this.current.submittable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await this.verifyAll())) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.current.submitting = true;
|
||||
|
||||
try {
|
||||
await this.current.data.submit(this.getFieldValues());
|
||||
} catch (e) {
|
||||
this.current.submitting = false;
|
||||
|
||||
alert("Submission has failed: " + e);
|
||||
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
|
||||
return;
|
||||
}
|
||||
},
|
||||
async cancelAndGetNext() {
|
||||
if (this.current.submitting || this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.current.data === null || !this.current.cancellable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.current.submitting = true;
|
||||
|
||||
await this.current.data.cancel();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
23
ui/widgets/screen_console.css
Normal file
23
ui/widgets/screen_console.css
Normal file
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@charset "utf-8";
|
||||
|
||||
#home-content > .screen > .screen-screen > .screen-console {
|
||||
}
|
||||
304
ui/widgets/screen_console.vue
Normal file
304
ui/widgets/screen_console.vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="screen-console"
|
||||
:style="'background-color: ' + control.activeColor()"
|
||||
style="top: 0; right: 0; left: 0; bottom: 0; padding: 0; margin: 0; position: absolute; overflow: hidden"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Terminal } from "xterm";
|
||||
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||
import { FitAddon } from "xterm-addon-fit";
|
||||
|
||||
import "./screen_console.css";
|
||||
import "xterm/css/xterm.css";
|
||||
import { isNumber } from "util";
|
||||
|
||||
class Term {
|
||||
constructor(control) {
|
||||
const resizeDelayInterval = 500;
|
||||
|
||||
this.term = new Terminal({
|
||||
allowTransparency: false,
|
||||
cursorBlink: true,
|
||||
cursorStyle: "block",
|
||||
logLevel: process.env.NODE_ENV === "development" ? "info" : "off"
|
||||
});
|
||||
this.fit = new FitAddon();
|
||||
|
||||
this.term.loadAddon(this.fit);
|
||||
this.term.loadAddon(new WebLinksAddon());
|
||||
|
||||
this.term.setOption("theme", {
|
||||
background: control.activeColor()
|
||||
});
|
||||
|
||||
this.term.onData(data => {
|
||||
control.send(data);
|
||||
});
|
||||
|
||||
this.term.onKey(ev => {
|
||||
if (!control.echo()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const printable =
|
||||
!ev.domEvent.altKey &&
|
||||
!ev.domEvent.altGraphKey &&
|
||||
!ev.domEvent.ctrlKey &&
|
||||
!ev.domEvent.metaKey;
|
||||
|
||||
if (ev.domEvent.keyCode === 13) {
|
||||
this.writeStr("\r\n");
|
||||
} else if (ev.domEvent.keyCode === 8) {
|
||||
this.writeStr("\b \b");
|
||||
} else if (printable) {
|
||||
this.writeStr(ev.key);
|
||||
}
|
||||
});
|
||||
|
||||
let resizeDelay = null,
|
||||
oldRows = 0,
|
||||
oldCols = 0;
|
||||
|
||||
this.term.onResize(dim => {
|
||||
if (dim.cols === oldCols && dim.rows === oldRows) {
|
||||
return;
|
||||
}
|
||||
|
||||
oldRows = dim.rows;
|
||||
oldCols = dim.cols;
|
||||
|
||||
if (resizeDelay !== null) {
|
||||
clearTimeout(resizeDelay);
|
||||
resizeDelay = null;
|
||||
}
|
||||
|
||||
resizeDelay = setTimeout(() => {
|
||||
if (!isNumber(dim.cols) || !isNumber(dim.rows)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dim.cols || !dim.rows) {
|
||||
return;
|
||||
}
|
||||
|
||||
control.resize({
|
||||
rows: dim.rows,
|
||||
cols: dim.cols
|
||||
});
|
||||
|
||||
resizeDelay = null;
|
||||
}, resizeDelayInterval);
|
||||
});
|
||||
}
|
||||
|
||||
init(root, callbacks) {
|
||||
this.term.open(root);
|
||||
|
||||
this.term.textarea.addEventListener("focus", callbacks.focus);
|
||||
this.term.textarea.addEventListener("blur", callbacks.blur);
|
||||
|
||||
this.term.element.addEventListener("click", () => {
|
||||
this.term.textarea.blur();
|
||||
this.term.textarea.click();
|
||||
this.term.textarea.focus();
|
||||
});
|
||||
|
||||
this.refit();
|
||||
}
|
||||
|
||||
writeStr(d) {
|
||||
try {
|
||||
this.term.write(d);
|
||||
} catch (e) {
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
}
|
||||
|
||||
write(d) {
|
||||
try {
|
||||
this.term.writeUtf8(d);
|
||||
} catch (e) {
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
}
|
||||
|
||||
focus() {
|
||||
try {
|
||||
this.term.focus();
|
||||
} catch (e) {
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
}
|
||||
|
||||
blur() {
|
||||
try {
|
||||
this.term.blur();
|
||||
} catch (e) {
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
}
|
||||
|
||||
refit() {
|
||||
try {
|
||||
this.fit.fit();
|
||||
} catch (e) {
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
try {
|
||||
this.term.dispose();
|
||||
} catch (e) {
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// So it turns out, display: none + xterm.js == trouble, so I changed this
|
||||
// to a visibility + position: absolute appoarch. Problem resolved, and I
|
||||
// like to keep it that way.
|
||||
|
||||
export default {
|
||||
props: {
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
control: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
change: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
term: new Term(this.control),
|
||||
runner: null
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
active() {
|
||||
this.triggerActive();
|
||||
},
|
||||
change: {
|
||||
handler() {
|
||||
if (!this.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fit();
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init();
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.deinit();
|
||||
},
|
||||
methods: {
|
||||
triggerActive() {
|
||||
this.active ? this.activate() : this.deactivate();
|
||||
},
|
||||
init() {
|
||||
let self = this;
|
||||
|
||||
this.term.init(this.$el, {
|
||||
focus(e) {
|
||||
document.addEventListener("keyup", self.localKeypress);
|
||||
document.addEventListener("keydown", self.localKeypress);
|
||||
},
|
||||
blur(e) {
|
||||
document.removeEventListener("keyup", self.localKeypress);
|
||||
document.removeEventListener("keydown", self.localKeypress);
|
||||
}
|
||||
});
|
||||
|
||||
this.triggerActive();
|
||||
this.runRunner();
|
||||
},
|
||||
async deinit() {
|
||||
await this.closeRunner();
|
||||
await this.deactivate();
|
||||
this.term.destroy();
|
||||
},
|
||||
fit() {
|
||||
this.term.refit();
|
||||
},
|
||||
localKeypress(e) {
|
||||
if (!e.altKey && !e.shiftKey && !e.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
},
|
||||
activate() {
|
||||
this.fit();
|
||||
window.addEventListener("resize", this.fit);
|
||||
this.term.focus();
|
||||
},
|
||||
async deactivate() {
|
||||
window.removeEventListener("resize", this.fit);
|
||||
document.removeEventListener("keyup", this.localKeypress);
|
||||
document.removeEventListener("keydown", this.localKeypress);
|
||||
this.term.blur();
|
||||
},
|
||||
runRunner() {
|
||||
if (this.runner !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let self = this;
|
||||
|
||||
this.runner = (async () => {
|
||||
try {
|
||||
for (;;) {
|
||||
this.term.write(await this.control.receive());
|
||||
|
||||
self.$emit("updated");
|
||||
}
|
||||
} catch (e) {
|
||||
self.$emit("stopped", e);
|
||||
}
|
||||
})();
|
||||
},
|
||||
async closeRunner() {
|
||||
if (this.runner === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let runner = this.runner;
|
||||
this.runner = null;
|
||||
|
||||
await runner;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
85
ui/widgets/screens.vue
Normal file
85
ui/widgets/screens.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<main style="position: relative">
|
||||
<slot v-if="screens.length <= 0"></slot>
|
||||
|
||||
<div
|
||||
v-for="(screenInfo, idx) in screens"
|
||||
v-if="screens.length > 0"
|
||||
:key="screenInfo.id"
|
||||
:style="'visibility: ' + (screen === idx ? 'visible' : 'hidden')"
|
||||
class="screen"
|
||||
style="top: 0; right: 0; left: 0; bottom: 0; padding: 0; margin: 0; overflow: auto; position: absolute;"
|
||||
>
|
||||
<div v-if="screenInfo.indicator.error.length > 0" class="screen-error">
|
||||
{{ screenInfo.indicator.error }}
|
||||
</div>
|
||||
|
||||
<div class="screen-screen" style="position: relative">
|
||||
<component
|
||||
:is="getComponent(screenInfo.control.ui())"
|
||||
:active="screen === idx"
|
||||
:control="screenInfo.control"
|
||||
:change="screenInfo.indicator"
|
||||
@stopped="stopped(idx, $event)"
|
||||
@updated="updated(idx)"
|
||||
></component>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ConsoleScreen from "./screen_console.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ConsoleScreen
|
||||
},
|
||||
props: {
|
||||
screen: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
screens: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getComponent(ui) {
|
||||
switch (ui) {
|
||||
case "Console":
|
||||
return "ConsoleScreen";
|
||||
|
||||
default:
|
||||
throw new Error("Unknown UI: " + ui);
|
||||
}
|
||||
},
|
||||
stopped(index, stopErr) {
|
||||
this.$emit("stopped", index, stopErr);
|
||||
},
|
||||
updated(index) {
|
||||
this.$emit("updated", index);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
217
ui/widgets/status.css
Normal file
217
ui/widgets/status.css
Normal file
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@charset "utf-8";
|
||||
|
||||
#conn-status {
|
||||
z-index: 999999;
|
||||
top: 40px;
|
||||
left: 96px;
|
||||
display: none;
|
||||
width: 500px;
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
#conn-status .window-frame {
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#conn-status:before {
|
||||
left: 30px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#conn-status {
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
#conn-status:before {
|
||||
left: 91px;
|
||||
}
|
||||
}
|
||||
|
||||
#conn-status.display {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#conn-status h1 {
|
||||
padding: 15px 15px 10px 15px;
|
||||
background: #a56;
|
||||
}
|
||||
|
||||
#conn-status-info {
|
||||
padding: 0 15px 15px 15px;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
background: #a56;
|
||||
}
|
||||
|
||||
#conn-status.green:before {
|
||||
background: #287;
|
||||
}
|
||||
|
||||
#conn-status.green h1 {
|
||||
color: #6ba;
|
||||
background: #287;
|
||||
}
|
||||
|
||||
#conn-status.green #conn-status-info {
|
||||
background: #287;
|
||||
}
|
||||
|
||||
#conn-status.yellow:before {
|
||||
background: #da0;
|
||||
}
|
||||
|
||||
#conn-status.yellow h1 {
|
||||
color: #fff;
|
||||
background: #da0;
|
||||
}
|
||||
|
||||
#conn-status.yellow #conn-status-info {
|
||||
background: #da0;
|
||||
}
|
||||
|
||||
#conn-status.orange:before {
|
||||
background: #b73;
|
||||
}
|
||||
|
||||
#conn-status.orange h1 {
|
||||
color: #fff;
|
||||
background: #b73;
|
||||
}
|
||||
|
||||
#conn-status.orange #conn-status-info {
|
||||
background: #b73;
|
||||
}
|
||||
|
||||
#conn-status.red:before {
|
||||
background: #a33;
|
||||
}
|
||||
|
||||
#conn-status.red h1 {
|
||||
color: #fff;
|
||||
background: #a33;
|
||||
}
|
||||
|
||||
#conn-status.red #conn-status-info {
|
||||
background: #a33;
|
||||
}
|
||||
|
||||
.conn-status-chart {
|
||||
}
|
||||
|
||||
.conn-status-chart > .counters {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.conn-status-chart > .counters > .counter {
|
||||
width: 50%;
|
||||
display: block;
|
||||
float: left;
|
||||
margin: 10px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.conn-status-chart > .counters > .counter .name {
|
||||
font-size: 0.8em;
|
||||
color: #777;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.conn-status-chart > .counters > .counter .value {
|
||||
font-size: 1.5em;
|
||||
font-weight: lighter;
|
||||
}
|
||||
|
||||
.conn-status-chart > .counters > .counter .value span {
|
||||
font-size: 0.7em;
|
||||
}
|
||||
|
||||
.conn-status-chart > .chart {
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.conn-status-chart > .chart g {
|
||||
fill: none;
|
||||
stroke-width: 7px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.conn-status-chart > .chart .zero {
|
||||
stroke: #3a3a3a;
|
||||
stroke-width: 3px;
|
||||
}
|
||||
|
||||
#conn-status-delay {
|
||||
padding: 15px 0;
|
||||
background: #292929;
|
||||
}
|
||||
|
||||
#conn-status-delay > .counters > .counter {
|
||||
width: 100%;
|
||||
float: none;
|
||||
}
|
||||
|
||||
#conn-status-delay-chart-background {
|
||||
--color-start: #e43989;
|
||||
--color-stop: #9a5fca;
|
||||
}
|
||||
|
||||
#conn-status-delay-chart > g {
|
||||
fill: none;
|
||||
stroke: url(#conn-status-delay-chart-background) #2a6;
|
||||
}
|
||||
|
||||
#conn-status-traffic {
|
||||
padding: 15px 0;
|
||||
}
|
||||
|
||||
#conn-status-traffic-chart-in-background {
|
||||
--color-start: #0287a8;
|
||||
--color-stop: #06e7b6;
|
||||
}
|
||||
|
||||
#conn-status-traffic-chart-in > g {
|
||||
stroke: url(#conn-status-traffic-chart-in-background) #2a6;
|
||||
}
|
||||
|
||||
#conn-status-traffic-chart-out-background {
|
||||
--color-start: #e46226;
|
||||
--color-stop: #da356c;
|
||||
}
|
||||
|
||||
#conn-status-traffic-chart-out > g {
|
||||
stroke: url(#conn-status-traffic-chart-out-background) #2a6;
|
||||
}
|
||||
|
||||
#conn-status-close {
|
||||
/* ID mainly use for document.getElementById */
|
||||
cursor: pointer;
|
||||
right: 10px;
|
||||
top: 20px;
|
||||
}
|
||||
247
ui/widgets/status.vue
Normal file
247
ui/widgets/status.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<window
|
||||
id="conn-status"
|
||||
flash-class="home-window-display"
|
||||
:display="display"
|
||||
@display="$emit('display', $event)"
|
||||
>
|
||||
<h1 class="window-title">Connection status</h1>
|
||||
|
||||
<div id="conn-status-info">
|
||||
{{ status.description }}
|
||||
</div>
|
||||
|
||||
<div id="conn-status-delay" class="conn-status-chart">
|
||||
<div class="counters">
|
||||
<div class="counter">
|
||||
<div class="name">Delay</div>
|
||||
<div
|
||||
class="value"
|
||||
v-html="$options.filters.mSecondString(status.delay)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart">
|
||||
<chart
|
||||
id="conn-status-delay-chart"
|
||||
:width="480"
|
||||
:height="50"
|
||||
type="Bar"
|
||||
:enabled="display"
|
||||
:values="status.delayHistory"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="conn-status-delay-chart-background"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="100%"
|
||||
>
|
||||
<stop stop-color="var(--color-start)" offset="0%" />
|
||||
<stop stop-color="var(--color-stop)" offset="100%" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</chart>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="conn-status-traffic" class="conn-status-chart">
|
||||
<div class="counters">
|
||||
<div class="counter">
|
||||
<div class="name">Inbound</div>
|
||||
<div
|
||||
class="value"
|
||||
v-html="$options.filters.bytePerSecondString(status.inbound)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="counter">
|
||||
<div class="name">Outbound</div>
|
||||
<div
|
||||
class="value"
|
||||
v-html="$options.filters.bytePerSecondString(status.outbound)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart">
|
||||
<chart
|
||||
id="conn-status-traffic-chart-in"
|
||||
:width="480"
|
||||
:height="25"
|
||||
type="Bar"
|
||||
:max="inoutBoundMax"
|
||||
:enabled="display"
|
||||
:values="status.inboundHistory"
|
||||
@max="inboundMaxColUpdated"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="conn-status-traffic-chart-in-background"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="100%"
|
||||
>
|
||||
<stop stop-color="var(--color-start)" offset="0%" />
|
||||
<stop stop-color="var(--color-stop)" offset="100%" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</chart>
|
||||
</div>
|
||||
|
||||
<div class="chart">
|
||||
<chart
|
||||
id="conn-status-traffic-chart-out"
|
||||
:width="480"
|
||||
:height="25"
|
||||
type="UpsideDownBar"
|
||||
:max="inoutBoundMax"
|
||||
:enabled="display"
|
||||
:values="status.outboundHistory"
|
||||
@max="outboundMaxColUpdated"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="conn-status-traffic-chart-out-background"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="100%"
|
||||
>
|
||||
<stop stop-color="var(--color-start)" offset="0%" />
|
||||
<stop stop-color="var(--color-stop)" offset="100%" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</chart>
|
||||
</div>
|
||||
</div>
|
||||
</window>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/* eslint vue/attribute-hyphenation: 0 */
|
||||
|
||||
import "./status.css";
|
||||
|
||||
import Window from "./window.vue";
|
||||
import Chart from "./chart.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
window: Window,
|
||||
chart: Chart
|
||||
},
|
||||
filters: {
|
||||
bytePerSecondString(n) {
|
||||
const bNames = ["byte/s", "kib/s", "mib/s", "gib/s", "tib/s"];
|
||||
let remain = n,
|
||||
nUnit = bNames[0];
|
||||
|
||||
for (let i in bNames) {
|
||||
nUnit = bNames[i];
|
||||
|
||||
if (remain < 1024) {
|
||||
break;
|
||||
}
|
||||
|
||||
remain /= 1024;
|
||||
}
|
||||
|
||||
return (
|
||||
Number(remain.toFixed(2)).toLocaleString() +
|
||||
" <span>" +
|
||||
nUnit +
|
||||
"</span>"
|
||||
);
|
||||
},
|
||||
mSecondString(n) {
|
||||
const bNames = ["ms", "s", "m"];
|
||||
let remain = n,
|
||||
nUnit = bNames[0];
|
||||
|
||||
for (let i in bNames) {
|
||||
nUnit = bNames[i];
|
||||
|
||||
if (remain < 1000) {
|
||||
break;
|
||||
}
|
||||
|
||||
remain /= 1000;
|
||||
}
|
||||
|
||||
return (
|
||||
Number(remain.toFixed(2)).toLocaleString() +
|
||||
" <span>" +
|
||||
nUnit +
|
||||
"</span>"
|
||||
);
|
||||
}
|
||||
},
|
||||
props: {
|
||||
display: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
status: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {
|
||||
description: "",
|
||||
delay: 0,
|
||||
delayHistory: [],
|
||||
inbound: 0,
|
||||
inboundHistory: [],
|
||||
outbound: 0,
|
||||
outboundHistory: []
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
inoutBoundMax: 0,
|
||||
inboundMax: 0,
|
||||
outboundMax: 0
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
inboundMaxColUpdated(d) {
|
||||
this.inboundMax = d;
|
||||
|
||||
this.inoutBoundMax =
|
||||
this.inboundMax > this.outboundMax ? this.inboundMax : this.outboundMax;
|
||||
},
|
||||
outboundMaxColUpdated(d) {
|
||||
this.outboundMax = d;
|
||||
|
||||
this.inoutBoundMax =
|
||||
this.inboundMax > this.outboundMax ? this.inboundMax : this.outboundMax;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
103
ui/widgets/tab_list.vue
Normal file
103
ui/widgets/tab_list.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<ul :id="id" :class="tabsClass">
|
||||
<li
|
||||
v-for="(tabInfo, idx) in tabs"
|
||||
:key="tabInfo.id"
|
||||
:class="{
|
||||
active: tab === idx,
|
||||
error: tabInfo.indicator.error.length > 0,
|
||||
updated: tabInfo.indicator.updated
|
||||
}"
|
||||
:style="
|
||||
'background: ' +
|
||||
(tab === idx
|
||||
? tabInfo.control.activeColor()
|
||||
: tabInfo.control.color())
|
||||
"
|
||||
@click="switchTo(idx)"
|
||||
>
|
||||
<span class="title" :title="tabInfo.name">
|
||||
<span
|
||||
class="type"
|
||||
:title="tabInfo.info.name()"
|
||||
:style="'background: ' + tabInfo.info.color()"
|
||||
>
|
||||
{{ tabInfo.info.name()[0] }}
|
||||
</span>
|
||||
{{ tabInfo.name }}
|
||||
</span>
|
||||
|
||||
<span class="icon icon-close icon-close1" @click="closeAt(idx)"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
tab: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
tabs: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
tabsClass: {
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
tab(newVal) {
|
||||
this.switchTo(newVal);
|
||||
},
|
||||
tabs(newVal) {
|
||||
if (newVal.length > this.tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.switchTo(newVal.length - 1);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
switchTo(index) {
|
||||
if (index < 0 || index >= this.tabs.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tab == index) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("current", index);
|
||||
},
|
||||
closeAt(index) {
|
||||
this.$emit("close", index);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
148
ui/widgets/tab_window.css
Normal file
148
ui/widgets/tab_window.css
Normal file
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@charset "utf-8";
|
||||
|
||||
#tab-window {
|
||||
z-index: 999999;
|
||||
top: 40px;
|
||||
right: 0px;
|
||||
display: none;
|
||||
width: 400px;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
#tab-window .window-frame {
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#tab-window:before {
|
||||
right: 19px;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#tab-window {
|
||||
width: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
#tab-window.display {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#tab-window-close {
|
||||
cursor: pointer;
|
||||
right: 10px;
|
||||
top: 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
#tab-window h1 {
|
||||
padding: 15px 15px 0 15px;
|
||||
margin-bottom: 10px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
#tab-window-list > li > .lst-wrap {
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#tab-window-list > li {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
#tab-window-tabs {
|
||||
flex: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#tab-window-tabs > li {
|
||||
display: flex;
|
||||
position: relative;
|
||||
padding: 15px;
|
||||
opacity: 0.5;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#tab-window-tabs > li::after {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
bottom: 5px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
transition: all 0.1s linear;
|
||||
transition-property: width, top, bottom;
|
||||
}
|
||||
|
||||
#tab-window-tabs > li.active::after {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
#tab-window-tabs > li.updated::after {
|
||||
background: #fff3;
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
#tab-window-tabs > li.error::after {
|
||||
background: #d55;
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
#tab-window-tabs > li > span.title {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#tab-window-tabs > li > span.title > span.type {
|
||||
display: inline-block;
|
||||
font-size: 0.85em;
|
||||
font-weight: bold;
|
||||
margin-right: 3px;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
background: #222;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
#tab-window-tabs > li > .icon-close {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 10px;
|
||||
margin-top: -5px;
|
||||
color: #fff6;
|
||||
}
|
||||
|
||||
#tab-window-tabs > li.active {
|
||||
color: #fff;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#tab-window-tabs > li.active > span.title {
|
||||
padding-right: 20px;
|
||||
}
|
||||
79
ui/widgets/tab_window.vue
Normal file
79
ui/widgets/tab_window.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<window
|
||||
id="tab-window"
|
||||
flash-class="home-window-display"
|
||||
:display="display"
|
||||
@display="$emit('display', $event)"
|
||||
>
|
||||
<h1 class="window-title">Opened tabs</h1>
|
||||
|
||||
<tab-list
|
||||
id="tab-window-tabs"
|
||||
:tab="tab"
|
||||
:tabs="tabs"
|
||||
:tabs-class="tabsClass"
|
||||
@current="$emit('current', $event)"
|
||||
@close="$emit('close', $event)"
|
||||
></tab-list>
|
||||
</window>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "./tab_window.css";
|
||||
|
||||
import Window from "./window.vue";
|
||||
import TabList from "./tab_list.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
window: Window,
|
||||
"tab-list": TabList
|
||||
},
|
||||
props: {
|
||||
display: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
tab: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
tabs: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
tabsClass: {
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
tabs(newV) {
|
||||
if (newV.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("display", false);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
76
ui/widgets/tabs.vue
Normal file
76
ui/widgets/tabs.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :id="id">
|
||||
<tab-list
|
||||
:id="id + '-tabs'"
|
||||
:tab="tab"
|
||||
:tabs="tabs"
|
||||
:tabs-class="tabsClass"
|
||||
@current="$emit('current', $event)"
|
||||
@close="$emit('close', $event)"
|
||||
></tab-list>
|
||||
|
||||
<a
|
||||
v-if="tabs.length > 0"
|
||||
:id="id + '-list'"
|
||||
:class="listTriggerClass"
|
||||
href="javascript:;"
|
||||
@click="showList"
|
||||
></a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TabList from "./tab_list.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
"tab-list": TabList
|
||||
},
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
tab: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
tabs: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
tabsClass: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
listTriggerClass: {
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showList() {
|
||||
this.$emit("list", this.tabs);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
20
ui/widgets/window.css
Normal file
20
ui/widgets/window.css
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@charset "utf-8";
|
||||
77
ui/widgets/window.vue
Normal file
77
ui/widgets/window.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
:id="id"
|
||||
class="window window1"
|
||||
:class="[{ display: displaying }, { [flashClass]: displaying }]"
|
||||
>
|
||||
<div class="window-frame">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<span
|
||||
:id="id + '-close'"
|
||||
class="window-close icon icon-close1"
|
||||
@click="hide"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
display: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
flashClass: {
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
displaying: false
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
display(newVal) {
|
||||
newVal ? this.show() : this.hide();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
show() {
|
||||
this.displaying = true;
|
||||
|
||||
this.$emit("display", this.displaying);
|
||||
},
|
||||
hide() {
|
||||
this.displaying = false;
|
||||
|
||||
this.$emit("display", this.displaying);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
46
ui/xhr.js
Normal file
46
ui/xhr.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
export function head(url, headers) {
|
||||
return new Promise((res, rej) => {
|
||||
let authReq = new XMLHttpRequest();
|
||||
|
||||
authReq.onreadystatechange = () => {
|
||||
if (authReq.readyState !== authReq.DONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
res(authReq);
|
||||
};
|
||||
|
||||
authReq.onerror = e => {
|
||||
rej(e);
|
||||
};
|
||||
|
||||
authReq.ontimeout = e => {
|
||||
rej(e);
|
||||
};
|
||||
|
||||
authReq.open("HEAD", url, true);
|
||||
|
||||
for (let h in headers) {
|
||||
authReq.setRequestHeader(h, headers[h]);
|
||||
}
|
||||
|
||||
authReq.send();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user