maj docker image + logo
This commit is contained in:
parent
953f018484
commit
3d57a2974e
432
README.md
432
README.md
@ -1,53 +1,410 @@
|
||||
# Sshwifty Web SSH & Telnet Client
|
||||
|
||||
Ce projet permet de déployer un client web [Sshwifty]() permettant de se connecter en SSH protéger par une authentification [Keycloak-gatekeeper]() via Keycloak
|
||||
**Sshwifty is a SSH and Telnet connector made for the Web.** It can be deployed
|
||||
on your computer or server to provide SSH and Telnet access interface for any
|
||||
compatible (standard) web browser.
|
||||
|
||||

|
||||
|
||||
## Configurer
|
||||

|
||||
|
||||
```bash
|
||||
git clone --recursive https://git.legaragenumerique.fr/GARAGENUM/sshwifty
|
||||
cd sshwifty
|
||||
## Install
|
||||
|
||||
### Binary
|
||||
|
||||
Compiled binaries can be found at the [release] section of the page.
|
||||
|
||||
Please be advised that those binaries are generated by an automatic proccess,
|
||||
the author of this project will NOT verify that they work. You will have to try
|
||||
it at your own risk.
|
||||
|
||||
[release]: https://github.com/nirui/sshwifty/releases
|
||||
|
||||
### Docker Image
|
||||
|
||||
If [Docker] is installed on your machine, you may use our prebuilt Docker Image
|
||||
by executing following command:
|
||||
|
||||
```
|
||||
$ docker run --detach \
|
||||
--restart always \
|
||||
--publish 8182:8182 \
|
||||
--name sshwifty \
|
||||
niruix/sshwifty:latest
|
||||
```
|
||||
|
||||
### Keycloak
|
||||
This will open port `8182` on the Docker host to accept traffic from all remote
|
||||
clients, and serve these clients with Sshwifty instance running inside the
|
||||
container.
|
||||
|
||||
- Créer un client "sshwifty":
|
||||
Operator can also use `--publish 127.0.0.1:8182:8182` to open Sshwifty to only
|
||||
local clients, which is useful if the Sshwifty instance is intended to run
|
||||
behind a reverse-proxy hosted on the same host.
|
||||
|
||||
- Mapper aud -> clien id:
|
||||
When TLS is desired and you don't want to setup Docker Volumes, you can use
|
||||
`SSHWIFTY_DOCKER_TLSCERT` and `SSHWIFTY_DOCKER_TLSCERTKEY` environment variables
|
||||
to import credential files to the container and automatically apply them:
|
||||
|
||||
### Gatekeeper
|
||||
|
||||
- Editer le docker-compose.yml:
|
||||
```bash
|
||||
nano docker-compose.yml
|
||||
```
|
||||
$ openssl req \
|
||||
-newkey rsa:4096 -nodes -keyout domain.key -x509 -days 90 -out domain.crt
|
||||
$ docker run --detach \
|
||||
--restart always \
|
||||
--publish 8182:8182 \
|
||||
--env SSHWIFTY_DOCKER_TLSCERT="$(cat domain.crt)" \
|
||||
--env SSHWIFTY_DOCKER_TLSCERTKEY="$(cat domain.key)" \
|
||||
--name sshwifty \
|
||||
niruix/sshwifty:latest
|
||||
```
|
||||
|
||||
```yml
|
||||
- "--discovery-url=https://<keycloak-dns>/auth/realms/<realm>/.well-known/openid-configuration"
|
||||
- "--client-id=sshwifty"
|
||||
- "--client-secret=<keycloak-client-secret>"
|
||||
The `domain.crt` and `domain.key` must be a valid TLS certificate and key file
|
||||
located on the same machine which the `docker run` command will be executed
|
||||
upon.
|
||||
|
||||
[docker]: https://www.docker.com
|
||||
|
||||
### Compile from source code (Recommanded if you're a developer)
|
||||
|
||||
The following tools are required in order to build the software from source code:
|
||||
|
||||
- `git` to download the source code
|
||||
- `node` and `npm` to build front-end application
|
||||
- `go` to build back-end application
|
||||
|
||||
To start the build process, execute:
|
||||
|
||||
```
|
||||
$ git clone https://github.com/nirui/sshwifty
|
||||
$ cd sshwifty
|
||||
$ npm install
|
||||
$ npm run build
|
||||
```
|
||||
|
||||
### Si reverse-proxy devant
|
||||
When done, you can found the newly generated `sshwifty` binary inside current
|
||||
working directory.
|
||||
|
||||
Notice: `Dockerfile` contains the entire build procedure of this software.
|
||||
Please refer to it when you encounter any compile/build related issue.
|
||||
|
||||
### Third-party Homebrew Formulae from @unbeatable-101
|
||||
|
||||
If you're a macOS user, [@unbeatable-101] is kindly hosting a Homebrew
|
||||
Formulae that allows you to install his custom Sshwifty builds for macOS via
|
||||
`homebrew`. You can hop over to [unbeatable-101/homebrew-sshwifty] for detailed
|
||||
instruction and contribute to his work.
|
||||
|
||||
Please note that, due to the third-party nature of the work, the author(s) of
|
||||
Sshwifty are unable to provide any audit, warranty or support for it. If you
|
||||
have any question or request regarding to the Formulae, please contact
|
||||
@unbeatable-101 directly through appreciate channels.
|
||||
|
||||
Thank @unbeatable-101 for his work.
|
||||
|
||||
[@unbeatable-101]: https://github.com/unbeatable-101
|
||||
[unbeatable-101/homebrew-sshwifty]: https://github.com/unbeatable-101/homebrew-sshwifty
|
||||
|
||||
## Configure
|
||||
|
||||
Sshwifty can be configured through either file or environment variables. By
|
||||
default, the configuration loader will try to load file from default paths
|
||||
first, when failed, environment variables will be used.
|
||||
|
||||
You can also specify your own configuration file by setting up `SSHWIFTY_CONFIG`
|
||||
environment variable before start the software. For example:
|
||||
|
||||
- Ajouter les directives suivantes pour le websocket dans la config du reverse-proxy:
|
||||
```
|
||||
location /sshwifty/socket {
|
||||
proxy_pass http://sshwifty;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
$ SSHWIFTY_CONFIG=./sshwifty.conf.json ./sshwifty
|
||||
```
|
||||
|
||||
This way, Sshwifty will try to load the configuration from file
|
||||
`./sshwifty.conf.json`, and never reach for other environment variables.
|
||||
|
||||
### Configuration file
|
||||
|
||||
Here is all the options of a configuration file:
|
||||
|
||||
```
|
||||
{
|
||||
// HTTP Host. Keep it empty to accept request from all hosts, otherwise, only
|
||||
// specified host is allowed to access
|
||||
"HostName": "localhost",
|
||||
|
||||
// Web interface access password. Set to empty to allow public access to the
|
||||
// web interface (By pass the Authenticate page)
|
||||
"SharedKey": "WEB_ACCESS_PASSWORD",
|
||||
|
||||
// Remote dial timeout. This limits how long of time the backend can spend
|
||||
// to connect to a remote host. The max timeout will be determined by
|
||||
// server configuration (ReadTimeout).
|
||||
// (In Seconds)
|
||||
"DialTimeout": 10,
|
||||
|
||||
// Socks5 proxy. When set, Sshwifty backend will try to connect remote through
|
||||
// the given proxy
|
||||
"Socks5": "localhost:1080",
|
||||
|
||||
// Username of the Socks5 server. Please set when needed
|
||||
"Socks5User": "",
|
||||
|
||||
// Password of the Socks5 server. Please set when needed
|
||||
"Socks5Password": "",
|
||||
|
||||
// Sshwifty HTTP server, you can set multiple ones to serve on different
|
||||
// ports
|
||||
"Servers": [
|
||||
{
|
||||
// Which local network interface this server will be listening
|
||||
"ListenInterface": "0.0.0.0",
|
||||
|
||||
// Which local network port this server will be listening
|
||||
"ListenPort": 8182,
|
||||
|
||||
// Timeout of initial request. HTTP handshake must be finished within
|
||||
// this time
|
||||
// (In Seconds)
|
||||
"InitialTimeout": 3,
|
||||
|
||||
// How long do the connection can stay in idle before the backend server
|
||||
// disconnects the client
|
||||
// (In Seconds)
|
||||
"ReadTimeout": 60,
|
||||
|
||||
// How long the server will wait until the client connection is ready to
|
||||
// recieve new data. If this timeout is exceed, the connection will be
|
||||
// closed.
|
||||
// (In Seconds)
|
||||
"WriteTimeout": 60,
|
||||
|
||||
// The interval between internal echo requests
|
||||
// (In Seconds)
|
||||
"HeartbeatTimeout": 20,
|
||||
|
||||
// Forced delay between each request
|
||||
// (In Milliseconds)
|
||||
"ReadDelay": 10,
|
||||
|
||||
// Forced delay between each write
|
||||
// (In Milliseconds)
|
||||
"WriteDelay": 10,
|
||||
|
||||
// Path to TLS certificate file. Set empty to use HTTP
|
||||
"TLSCertificateFile": "",
|
||||
|
||||
// Path to TLS certificate key file. Set empty to use HTTP
|
||||
"TLSCertificateKeyFile": ""
|
||||
|
||||
// Display a short text message on the Home page. Link is supported
|
||||
// through `[Title text](https://link.example.com)` format
|
||||
"ServerMessage": ""
|
||||
},
|
||||
{
|
||||
"ListenInterface": "0.0.0.0",
|
||||
"ListenPort": 8182,
|
||||
"InitialTimeout": 3,
|
||||
.....
|
||||
}
|
||||
],
|
||||
|
||||
// Remote Presets, the operater can define few presets for user so the user
|
||||
// won't have to manually fill-in all the form fields
|
||||
//
|
||||
// Presets will be displayed in the "Known remotes" tab on the Connector
|
||||
// window
|
||||
//
|
||||
// Notice: You can use the same JSON value for `SSHWIFTY_PRESETS` if you are
|
||||
// configuring your Sshwifty through enviroment variables.
|
||||
//
|
||||
// Warning: Presets Data will be sent to user client WITHOUT any protection.
|
||||
// DO NOT add any secret information into Preset.
|
||||
//
|
||||
"Presets": [
|
||||
{
|
||||
// Title of the preset
|
||||
"Title": "SDF.org Unix Shell",
|
||||
|
||||
// Preset Types, i.e. Telnet, and SSH
|
||||
"Type": "SSH",
|
||||
|
||||
// Target address and port
|
||||
"Host": "sdf.org:22",
|
||||
|
||||
// Form fields and values, you have to manually validate the correctness
|
||||
// of the field value
|
||||
//
|
||||
// Defining a Meta field will prevent user from changing it on their
|
||||
// Connector Wizard. If you want to allow users to use their own settings,
|
||||
// leave the field unsetted
|
||||
//
|
||||
// Values in Meta are scheme enabled, and supports following scheme
|
||||
// prefixes:
|
||||
// - "literal://": Text literal (Default)
|
||||
// Example: literal://Data value
|
||||
// (The final value will be "Data value")
|
||||
// Example: literal://file:///tmp/afile
|
||||
// (The final value will be "file:///tmp/afile")
|
||||
// - "file://": Load Meta value from given file.
|
||||
// Example: file:///home/user/.ssh/private_key
|
||||
// (The file path is /home/user/.ssh/private_key)
|
||||
// - "environment://": Load Meta value from an Environment Variable.
|
||||
// Example: environment://PRIVATE_KEY_DATA
|
||||
// (The name of the target environment variable is
|
||||
// PRIVATE_KEY_DATA)
|
||||
//
|
||||
// All data in Meta is loaded during start up, and will not be updated
|
||||
// even the source already been modified.
|
||||
//
|
||||
"Meta": {
|
||||
// Data for predefined User field
|
||||
"User": "pre-defined-username",
|
||||
|
||||
// Data for predefined Encoding field. Valid data is those displayed on
|
||||
// the page
|
||||
"Encoding": "pre-defined-encoding",
|
||||
|
||||
// Data for predefined Password field
|
||||
"Password": "pre-defined-password",
|
||||
|
||||
// Data for predefined Private Key field, should contains the content
|
||||
// of a Key file
|
||||
"Private Key": "file:///home/user/.ssh/private_key",
|
||||
|
||||
// Data for predefined Authentication field. Valid values is what
|
||||
// displayed on the page (Password, Private Key, None)
|
||||
"Authentication": "Password",
|
||||
|
||||
// Data for server public key fingerprint. You can acquire the value of
|
||||
// the fingerprint by manually connect to a new SSH host with Sshwifty,
|
||||
// the fingerprint will be displayed on the Fingerprint comformation
|
||||
// page.
|
||||
"Fingerprint": "SHA256:bgO...."
|
||||
}
|
||||
},
|
||||
{
|
||||
"Title": "Endpoint Telnet",
|
||||
"Type": "Telnet",
|
||||
"Host": "endpoint.vaguly.com:23",
|
||||
"Meta": {
|
||||
// Data for predefined Encoding field. Valid data is those displayed on
|
||||
// the page
|
||||
"Encoding": "utf-8"
|
||||
....
|
||||
}
|
||||
},
|
||||
....
|
||||
],
|
||||
|
||||
// Allow the Preset Remotes only, and refuse to connect to any other remote
|
||||
// host
|
||||
//
|
||||
// NOTICE: You can only configure OnlyAllowPresetRemotes through a config
|
||||
// file. This option is not supported when you are configuring with
|
||||
// environment variables
|
||||
"OnlyAllowPresetRemotes": false
|
||||
}
|
||||
```
|
||||
|
||||
## Déployer
|
||||
`sshwifty.conf.example.json` is an example of a valid configuration file.
|
||||
|
||||
### Environment variables
|
||||
|
||||
Valid environment variables are:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
SSHWIFTY_HOSTNAME
|
||||
SSHWIFTY_SHAREDKEY
|
||||
SSHWIFTY_DIALTIMEOUT
|
||||
SSHWIFTY_SOCKS5
|
||||
SSHWIFTY_SOCKS5_USER
|
||||
SSHWIFTY_SOCKS5_PASSWORD
|
||||
SSHWIFTY_LISTENPORT
|
||||
SSHWIFTY_INITIALTIMEOUT
|
||||
SSHWIFTY_READTIMEOUT
|
||||
SSHWIFTY_WRITETIMEOUT
|
||||
SSHWIFTY_HEARTBEATTIMEOUT
|
||||
SSHWIFTY_READDELAY
|
||||
SSHWIFTY_WRITEELAY
|
||||
SSHWIFTY_LISTENINTERFACE
|
||||
SSHWIFTY_TLSCERTIFICATEFILE
|
||||
SSHWIFTY_TLSCERTIFICATEKEYFILE
|
||||
SSHWIFTY_SERVERMESSAGE
|
||||
SSHWIFTY_PRESETS
|
||||
SSHWIFTY_ONLYALLOWPRESETREMOTES
|
||||
```
|
||||
|
||||
The options they represent correspond to their counterparts in the
|
||||
configuration file.
|
||||
|
||||
Notice: When you're using environment variables to configure Sshwifty, only one
|
||||
Sshwifty HTTP server is then allowed. There is no way to setup mulitple servers
|
||||
under this method of configuration. If you need to serve on multiple ports, use
|
||||
the configuration file instead.
|
||||
|
||||
Be aware: An invalid value inside following environment variables will cause
|
||||
the value to be sliently reset to default during configuration parsing phase
|
||||
without warning:
|
||||
|
||||
```
|
||||
SSHWIFTY_DIALTIMEOUT
|
||||
SSHWIFTY_INITIALTIMEOUT
|
||||
SSHWIFTY_READTIMEOUT
|
||||
SSHWIFTY_WRITETIMEOUT
|
||||
SSHWIFTY_HEARTBEATTIMEOUT
|
||||
SSHWIFTY_READDELAY
|
||||
SSHWIFTY_WRITEELAY
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
### Why the software says "The time difference is beyond operational limit"?
|
||||
|
||||
This usually happens when the clock on the client and/or the server is unsynced
|
||||
beyond tolerance.
|
||||
|
||||
Please make sure the clock time on both the client and the server are correct by
|
||||
resync them with a NTP server, and then reload the page. The problem should be
|
||||
gone afterwards.
|
||||
|
||||
### Why I got error "TypeError: Cannot read property 'importKey' of undefined"
|
||||
|
||||
It's usually because your web browser does not support WebCrypt API (such as
|
||||
`window.crypto.subtle` or anything under `window.crypto`), or the support has
|
||||
been disabled.
|
||||
|
||||
If you're using Google Chrome, please connect Sshwifty with HTTPS. Chrome will
|
||||
disable WebCrypt and many other APIs when the connection is not safe.
|
||||
|
||||
### Can I serve Sshwifty under a subpath such as `https://my.domain/ssh`?
|
||||
|
||||
The short story is NO. Sshwifty was designed based on an assumption that it will
|
||||
run as the only service under a given hostname, allowing web browsers to better
|
||||
enforce their data isolation rules. This is very important because Sshwifty
|
||||
saves user data locally.
|
||||
|
||||
However, if you really want to put Sshwifty into a subpath, you can do so by
|
||||
taking advantage of the fact that Sshwifty backend interface and assets are
|
||||
always located under an URL prefix `/sshwifty`. You can thus redirect or proxy
|
||||
those requests to their new location.
|
||||
|
||||
Keep in mind, doing so is really hacky, and it's not recommended by the author
|
||||
thus no support will be provided if you decide to go with that.
|
||||
|
||||
### Why I can't add my own key combinations to the Console tool bar?
|
||||
|
||||
The pre-defined key combinations are there mainly to make mobile operation
|
||||
possible as well as to resolve some hotkey conflicts. However, if efficiency is
|
||||
your first goal, please consider to use a software/on screen keyboard which is
|
||||
specially designed for terminal.
|
||||
|
||||
And if that's not enough, connect a physical keyboard through Bluetooth or OTA
|
||||
could be a better alternative. This way you can type as if you're using a
|
||||
computer console.
|
||||
|
||||
## Credits
|
||||
|
||||
- Thanks to [Ryan Fortner](https://github.com/ryanfortner) for the grammar fix
|
||||
- Thanks to [Tweak](https://github.com/Tweak4141) for the grammer fix too
|
||||
- Thanks to [CJendantix](https://github.com/CJendantix) for the grammer and typo
|
||||
fix
|
||||
|
||||
## License
|
||||
|
||||
@ -60,7 +417,20 @@ project and read their copyright statements.
|
||||
[LICENSE.md]: LICENSE.md
|
||||
[DEPENDENCIES.md]: DEPENDENCIES.md
|
||||
|
||||
## Contribuer
|
||||
## Contribute
|
||||
|
||||
- [ ] Config Keycloak Audience mapping
|
||||
- [ ] SSHWIFTY à traduire en français
|
||||
This is a hobbyist project, meaning I don't have that much time to put into it,
|
||||
sorry.
|
||||
|
||||
Upon release (Which is then you're able to read this file), this project will
|
||||
enter the _maintaining_ state, which includes doing some bug fixes and security
|
||||
updates. _Adding new features however, is not a part of the state_.
|
||||
|
||||
Please do not send any pull requests. If you need new feature, fork it, add it
|
||||
by yourself, then maintain it like one of your own project. It's not that hard
|
||||
with some Github features.
|
||||
|
||||
(Notice: Typos, grammar errors or invalid use of language in the source code and
|
||||
document is categorized as bug, please report them if you found any. Thank you!)
|
||||
|
||||
Appreciate your help, enjoy!
|
||||
|
||||
73
app.css
Normal file
73
app.css
Normal file
@ -0,0 +1,73 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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;
|
||||
}
|
||||
459
app.js
Normal file
459
app.js
Normal file
@ -0,0 +1,459 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 Vue from "vue";
|
||||
import "./app.css";
|
||||
import Auth from "./auth.vue";
|
||||
import { Color as ControlColor } from "./commands/color.js";
|
||||
import { Commands } from "./commands/commands.js";
|
||||
import { Controls } from "./commands/controls.js";
|
||||
import { Presets } from "./commands/presets.js";
|
||||
import * as ssh from "./commands/ssh.js";
|
||||
import * as telnet from "./commands/telnet.js";
|
||||
import "./common.css";
|
||||
import * as sshctl from "./control/ssh.js";
|
||||
import * as telnetctl from "./control/telnet.js";
|
||||
import * as cipher from "./crypto.js";
|
||||
import Home from "./home.vue";
|
||||
import "./landing.css";
|
||||
import Loading from "./loading.vue";
|
||||
import { Socket } from "./socket.js";
|
||||
import * as stream from "./stream/common";
|
||||
import * as xhr from "./xhr.js";
|
||||
|
||||
const backendQueryRetryDelay = 2000;
|
||||
|
||||
const maxTimeDiff = 30000;
|
||||
|
||||
const updateIndicatorMaxDisplayTime = 3000;
|
||||
|
||||
const mainTemplate = `
|
||||
<home
|
||||
v-if="page == 'app'"
|
||||
:host-path="hostPath"
|
||||
:query="query"
|
||||
:connection="socket"
|
||||
:controls="controls"
|
||||
:commands="commands"
|
||||
:server-message="serverMessage"
|
||||
:preset-data="presetData.presets"
|
||||
:restricted-to-presets="presetData.restricted"
|
||||
:view-port="viewPort"
|
||||
@navigate-to="changeURLHash"
|
||||
@tab-opened="tabOpened"
|
||||
@tab-closed="tabClosed"
|
||||
@tab-updated="tabUpdated"
|
||||
></home>
|
||||
<auth
|
||||
v-else-if="page == 'auth'"
|
||||
:error="authErr"
|
||||
@auth="submitAuth"
|
||||
></auth>
|
||||
<loading v-else :error="loadErr"></loading>
|
||||
`.trim();
|
||||
|
||||
const socksInterface = "/sshwifty/socket";
|
||||
const socksVerificationInterface = socksInterface + "/verify";
|
||||
const socksKeyTimeTruncater = 100 * 1000;
|
||||
|
||||
function startApp(rootEl) {
|
||||
const pageTitle = document.title;
|
||||
|
||||
let uiControlColor = new ControlColor();
|
||||
|
||||
function getCurrentKeyMixer() {
|
||||
return Number(
|
||||
Math.trunc(new Date().getTime() / socksKeyTimeTruncater),
|
||||
).toString();
|
||||
}
|
||||
|
||||
async function buildSocketKey(privateKey) {
|
||||
return new Uint8Array(
|
||||
await cipher.hmac512(
|
||||
stream.buildBufferFromString(privateKey),
|
||||
stream.buildBufferFromString(getCurrentKeyMixer()),
|
||||
),
|
||||
).slice(0, 16);
|
||||
}
|
||||
|
||||
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: "",
|
||||
serverMessage: "",
|
||||
presetData: {
|
||||
presets: new Presets([]),
|
||||
restricted: false,
|
||||
},
|
||||
authErr: "",
|
||||
loadErr: "",
|
||||
socket: null,
|
||||
controls: new Controls([
|
||||
new telnetctl.Telnet(uiControlColor),
|
||||
new sshctl.SSH(uiControlColor),
|
||||
]),
|
||||
commands: new Commands([new telnet.Command(), new ssh.Command()]),
|
||||
tabUpdateIndicator: null,
|
||||
viewPort: {
|
||||
dim: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
renew(width, height) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
},
|
||||
},
|
||||
},
|
||||
viewPortUpdaters: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
dimResizer: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
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() {
|
||||
const self = this;
|
||||
|
||||
self.tryInitialAuth();
|
||||
|
||||
self.viewPortUpdaters.dimResizer = () => {
|
||||
self.viewPortUpdaters.height = window.innerHeight;
|
||||
self.viewPortUpdaters.width = window.innerWidth;
|
||||
|
||||
self.$nextTick(() => {
|
||||
self.viewPort.dim.renew(
|
||||
self.viewPortUpdaters.width,
|
||||
self.viewPortUpdaters.height,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("resize", self.viewPortUpdaters.dimResizer);
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener("resize", self.viewPortUpdaters.dimResizer);
|
||||
},
|
||||
methods: {
|
||||
changeTitleInfo(newTitleInfo) {
|
||||
document.title = newTitleInfo + " " + pageTitle;
|
||||
},
|
||||
resetTitleInfo() {
|
||||
document.title = pageTitle;
|
||||
},
|
||||
changeURLHash(newHash) {
|
||||
window.location.hash = newHash;
|
||||
},
|
||||
isErrored() {
|
||||
return this.authErr.length > 0 || this.loadErr.length > 0;
|
||||
},
|
||||
async getSocketAuthKey(privateKey) {
|
||||
const enc = new TextEncoder(),
|
||||
rTime = Number(Math.trunc(new Date().getTime() / 100000));
|
||||
|
||||
var finalKey = "";
|
||||
|
||||
if (privateKey.length <= 0) {
|
||||
finalKey = "DEFAULT VERIFY KEY";
|
||||
} else {
|
||||
finalKey = privateKey;
|
||||
}
|
||||
|
||||
return new Uint8Array(
|
||||
await cipher.hmac512(enc.encode(finalKey), enc.encode(rTime)),
|
||||
).slice(0, 32);
|
||||
},
|
||||
buildBackendSocketURLs() {
|
||||
let r = {
|
||||
webSocket: "",
|
||||
keepAlive: "",
|
||||
};
|
||||
|
||||
switch (location.protocol) {
|
||||
case "https:":
|
||||
r.webSocket = "wss://";
|
||||
break;
|
||||
|
||||
default:
|
||||
r.webSocket = "ws://";
|
||||
}
|
||||
|
||||
r.webSocket += location.host + socksInterface;
|
||||
r.keepAlive = location.protocol + "//" + location.host + socksInterface;
|
||||
|
||||
return r;
|
||||
},
|
||||
buildSocket(key, dialTimeout, heartbeatInterval) {
|
||||
return new Socket(
|
||||
this.buildBackendSocketURLs(),
|
||||
key,
|
||||
dialTimeout * 1000,
|
||||
heartbeatInterval * 1000,
|
||||
);
|
||||
},
|
||||
executeHomeApp(authResult, key) {
|
||||
let authData = JSON.parse(authResult.data);
|
||||
this.serverMessage = authData.server_message
|
||||
? authData.server_message
|
||||
: "";
|
||||
this.presetData = {
|
||||
presets: new Presets(authData.presets ? authData.presets : []),
|
||||
restricted: authResult.onlyAllowPresetRemotes,
|
||||
};
|
||||
this.socket = this.buildSocket(
|
||||
key,
|
||||
authResult.timeout,
|
||||
authResult.heartbeat,
|
||||
);
|
||||
this.page = "app";
|
||||
},
|
||||
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);
|
||||
|
||||
let h = await xhr.get(socksVerificationInterface, {
|
||||
"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,
|
||||
data: h.responseText,
|
||||
onlyAllowPresetRemotes:
|
||||
h.getResponseHeader("X-OnlyAllowPresetRemotes") === "yes",
|
||||
};
|
||||
},
|
||||
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 try reload the page, and if the problem persisted, " +
|
||||
"consider to adjust your local time so both the client and " +
|
||||
"the server are running at same date time";
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let self = this;
|
||||
switch (result.result) {
|
||||
case 200:
|
||||
this.executeHomeApp(result, {
|
||||
async fetch() {
|
||||
let result = await self.doAuth("");
|
||||
|
||||
if (result.result !== 200) {
|
||||
throw new Error(
|
||||
"Unable to fetch key from remote, unexpected " +
|
||||
"error code: " +
|
||||
result.result,
|
||||
);
|
||||
}
|
||||
|
||||
return await buildSocketKey(atob(result.key) + "+");
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case 403:
|
||||
this.page = "auth";
|
||||
break;
|
||||
|
||||
case 0:
|
||||
setTimeout(() => {
|
||||
this.tryInitialAuth();
|
||||
}, backendQueryRetryDelay);
|
||||
break;
|
||||
|
||||
default:
|
||||
alert("Unexpected backend query status: " + result.result);
|
||||
}
|
||||
} catch (e) {
|
||||
this.loadErr = "Unable to initialize client application: " + e;
|
||||
}
|
||||
},
|
||||
async submitAuth(passphrase) {
|
||||
this.authErr = "";
|
||||
|
||||
try {
|
||||
let result = await this.doAuth(passphrase);
|
||||
|
||||
let self = this;
|
||||
switch (result.result) {
|
||||
case 200:
|
||||
this.executeHomeApp(result, {
|
||||
async fetch() {
|
||||
let result = await self.doAuth(passphrase);
|
||||
|
||||
if (result.result !== 200) {
|
||||
throw new Error(
|
||||
"Unable to fetch key from remote, unexpected " +
|
||||
"error code: " +
|
||||
result.result,
|
||||
);
|
||||
}
|
||||
|
||||
return await buildSocketKey(
|
||||
atob(result.key) + "+" + passphrase,
|
||||
);
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case 403:
|
||||
this.authErr = "Authentication has failed. Wrong passphrase?";
|
||||
break;
|
||||
|
||||
default:
|
||||
this.authErr =
|
||||
"Unexpected backend query status: " + result.result;
|
||||
}
|
||||
} catch (e) {
|
||||
this.authErr = "Unable to authenticate: " + e;
|
||||
}
|
||||
},
|
||||
updateTabTitleInfo(tabs, updated) {
|
||||
if (tabs.length <= 0) {
|
||||
this.resetTitleInfo();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.changeTitleInfo("(" + tabs.length + (updated ? "*" : "") + ")");
|
||||
},
|
||||
tabOpened(tabs) {
|
||||
this.tabUpdated(tabs);
|
||||
},
|
||||
tabClosed(tabs) {
|
||||
if (tabs.length > 0) {
|
||||
this.updateTabTitleInfo(tabs, this.tabUpdateIndicator !== null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tabUpdateIndicator) {
|
||||
clearTimeout(this.tabUpdateIndicator);
|
||||
this.tabUpdateIndicator = null;
|
||||
}
|
||||
|
||||
this.updateTabTitleInfo(tabs, false);
|
||||
},
|
||||
tabUpdated(tabs) {
|
||||
if (this.tabUpdateIndicator) {
|
||||
clearTimeout(this.tabUpdateIndicator);
|
||||
this.tabUpdateIndicator = null;
|
||||
}
|
||||
|
||||
this.updateTabTitleInfo(tabs, true);
|
||||
|
||||
this.tabUpdateIndicator = setTimeout(() => {
|
||||
this.tabUpdateIndicator = null;
|
||||
this.updateTabTitleInfo(tabs, false);
|
||||
}, updateIndicatorMaxDisplayTime);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function initializeClient() {
|
||||
let landingRoot = document.getElementById("landing");
|
||||
|
||||
if (!landingRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.log("Currently in Development environment");
|
||||
}
|
||||
|
||||
window.addEventListener("unhandledrejection", function (e) {
|
||||
console.error("Error:", e);
|
||||
});
|
||||
|
||||
window.addEventListener("error", function (e) {
|
||||
console.error("Error:", e);
|
||||
});
|
||||
|
||||
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
auth.vue
Normal file
126
auth.vue
Normal file
@ -0,0 +1,126 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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: passphraseErr.length > 0 || error.length > 0,
|
||||
}"
|
||||
>
|
||||
Passphrase
|
||||
|
||||
<input
|
||||
v-model="passphrase"
|
||||
v-focus="true"
|
||||
:disabled="submitting"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
name="field.field.name"
|
||||
placeholder="----------"
|
||||
autofocus="autofocus"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="passphraseErr.length <= 0 && error.length <= 0"
|
||||
class="message"
|
||||
>
|
||||
A valid password is required in order to use this
|
||||
<a href="https://github.com/nirui/sshwifty">Sshwifty</a>
|
||||
instance
|
||||
</div>
|
||||
<div v-else class="error">
|
||||
{{ passphraseErr || 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,
|
||||
passphrase: "",
|
||||
passphraseErr: "",
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
error(newVal) {
|
||||
if (newVal.length > 0) {
|
||||
this.submitting = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {},
|
||||
methods: {
|
||||
auth() {
|
||||
if (this.passphrase.length <= 0) {
|
||||
this.passphraseErr = "Passphrase cannot be empty";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
|
||||
this.passphraseErr = "";
|
||||
|
||||
this.$emit("auth", this.passphrase);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
230
commands/address.js
Normal file
230
commands/address.js
Normal file
@ -0,0 +1,230 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 common from "./common.js";
|
||||
import Exception from "./exception.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
commands/address_test.js
Normal file
102
commands/address_test.js
Normal file
@ -0,0 +1,102 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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.strictEqual(addr2.type(), addr.type());
|
||||
assert.deepStrictEqual(addr2.address(), addr.address());
|
||||
assert.strictEqual(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.strictEqual(addr2.type(), addr.type());
|
||||
assert.deepStrictEqual(addr2.address(), addr.address());
|
||||
assert.strictEqual(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.strictEqual(addr2.type(), addr.type());
|
||||
assert.deepStrictEqual(addr2.address(), addr.address());
|
||||
assert.strictEqual(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.strictEqual(addr2.type(), addr.type());
|
||||
assert.deepStrictEqual(addr2.address(), addr.address());
|
||||
assert.strictEqual(addr2.port(), addr.port());
|
||||
});
|
||||
});
|
||||
107
commands/color.js
Normal file
107
commands/color.js
Normal file
@ -0,0 +1,107 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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];
|
||||
}
|
||||
}
|
||||
880
commands/commands.js
Normal file
880
commands/commands.js
Normal file
@ -0,0 +1,880 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 Exception from "./exception.js";
|
||||
import * as presets from "./presets.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
|
||||
* @param {string} ui User interfact this command will use
|
||||
*/
|
||||
constructor(name, info, control, ui) {
|
||||
this.name = name;
|
||||
this.info = info;
|
||||
this.control = control;
|
||||
this.ui = ui;
|
||||
}
|
||||
}
|
||||
|
||||
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: "",
|
||||
readonly: false,
|
||||
suggestions(input) {
|
||||
return [];
|
||||
},
|
||||
verify(v) {
|
||||
return "";
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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]) {
|
||||
n[i] = f[i];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Exception(
|
||||
'Field data type for "' +
|
||||
i +
|
||||
'" was unmatched. Expecting "' +
|
||||
typeof n[i] +
|
||||
'", got "' +
|
||||
typeof f[i] +
|
||||
'" instead',
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build command fields with preset data
|
||||
*
|
||||
* @param {object} definitions Definition of a group of fields
|
||||
* @param {object} fieldsData field data object, formated like a `defField`
|
||||
* @param {presets.Preset} presetData Preset data
|
||||
* @param {function} presetApplied Called when a preset is used for a field
|
||||
*
|
||||
* @returns {object}
|
||||
*
|
||||
*/
|
||||
export function fieldsWithPreset(
|
||||
definitions,
|
||||
fieldsData,
|
||||
presetData,
|
||||
presetApplied,
|
||||
) {
|
||||
let newFields = fields(definitions, fieldsData);
|
||||
|
||||
for (let i in newFields) {
|
||||
try {
|
||||
newFields[i].value = presetData.meta(newFields[i].name);
|
||||
newFields[i].readonly = true;
|
||||
|
||||
presetApplied(newFields[i].name);
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
return newFields;
|
||||
}
|
||||
|
||||
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 {object} built Command executer
|
||||
* @param {subscribe.Subscribe} subs Wizard step subscriber
|
||||
* @param {function} done Callback which will be called when the wizard
|
||||
* is done
|
||||
*
|
||||
*/
|
||||
constructor(built, subs, done) {
|
||||
this.built = built;
|
||||
this.subs = subs;
|
||||
this.done = done;
|
||||
this.closed = false;
|
||||
|
||||
this.built.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
this.done(n);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the name of the control info of current wizard
|
||||
*
|
||||
* @returns {object}
|
||||
*
|
||||
*/
|
||||
control() {
|
||||
return this.built.control();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.represeter = (n) => {
|
||||
return command.represet(n);
|
||||
};
|
||||
this.wizarder = (n, i, r, u, y, x, l, p) => {
|
||||
return command.wizard(n, i, r, u, y, x, l, p);
|
||||
};
|
||||
this.executer = (n, i, r, u, y, x, l, p) => {
|
||||
return command.execute(n, i, r, u, y, x, l, p);
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an automatic command wizard
|
||||
*
|
||||
* @param {stream.Streams} streams
|
||||
* @param {controls.Controls} controls
|
||||
* @param {history.History} history
|
||||
* @param {presets.Preset} preset
|
||||
* @param {object} session
|
||||
* @param {Array<string>} keptSessions
|
||||
* @param {function} done Callback which will be called when wizard is done
|
||||
*
|
||||
* @returns {Wizard} Command wizard
|
||||
*
|
||||
*/
|
||||
wizard(streams, controls, history, preset, session, keptSessions, done) {
|
||||
let subs = new subscribe.Subscribe();
|
||||
|
||||
return new Wizard(
|
||||
this.wizarder(
|
||||
new Info(this),
|
||||
preset,
|
||||
session,
|
||||
keptSessions,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
),
|
||||
subs,
|
||||
done,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an automatic command wizard
|
||||
*
|
||||
* @param {stream.Streams} streams
|
||||
* @param {controls.Controls} controls
|
||||
* @param {history.History} history
|
||||
* @param {object} config
|
||||
* @param {object} session
|
||||
* @param {Array<string>} keptSessions
|
||||
* @param {function} done Callback which will be called when wizard is done
|
||||
*
|
||||
* @returns {Wizard} Command wizard
|
||||
*
|
||||
*/
|
||||
execute(streams, controls, history, config, session, keptSessions, done) {
|
||||
let subs = new subscribe.Subscribe();
|
||||
|
||||
return new Wizard(
|
||||
this.executer(
|
||||
new Info(this),
|
||||
config,
|
||||
session,
|
||||
keptSessions,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
),
|
||||
subs,
|
||||
done,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param {function} done Callback which will be called when launching is done
|
||||
*
|
||||
* @returns {Wizard} Command wizard
|
||||
*
|
||||
*/
|
||||
launch(streams, controls, history, launcher, done) {
|
||||
let subs = new subscribe.Subscribe();
|
||||
|
||||
return new Wizard(
|
||||
this.launchCmd(
|
||||
new Info(this),
|
||||
decodeURI(launcher),
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
),
|
||||
subs,
|
||||
done,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build launcher string out of given config
|
||||
*
|
||||
* @param {object} config Configuration object
|
||||
*
|
||||
* @return {string} Launcher string
|
||||
*/
|
||||
launcher(config) {
|
||||
return this.name() + ":" + encodeURI(this.launcherCmd(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconfigure the preset data for the command wizard
|
||||
*
|
||||
* @param {presets.Preset} n preset
|
||||
*
|
||||
* @return {presets.Preset} modified new preset
|
||||
*/
|
||||
represet(n) {
|
||||
return this.represeter(n);
|
||||
}
|
||||
}
|
||||
|
||||
export class Preset {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {presets.Preset} preset preset
|
||||
* @param {Builder} command executor
|
||||
*
|
||||
*/
|
||||
constructor(preset, command) {
|
||||
this.preset = preset;
|
||||
this.command = command;
|
||||
}
|
||||
}
|
||||
|
||||
export class Commands {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {Array<object>} commands Command array
|
||||
*
|
||||
*/
|
||||
constructor(commands) {
|
||||
this.commands = [];
|
||||
|
||||
for (let i = 0; i < commands.length; i++) {
|
||||
this.commands.push(new Builder(commands[i]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all commands
|
||||
*
|
||||
* @returns {Array<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];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns presets with merged command
|
||||
*
|
||||
* @param {presets.Presets} ps
|
||||
*
|
||||
* @returns {Array<Preset>}
|
||||
*
|
||||
*/
|
||||
mergePresets(ps) {
|
||||
let pp = [];
|
||||
|
||||
for (let i = 0; i < this.commands.length; i++) {
|
||||
const fetched = ps.fetch(this.commands[i].name());
|
||||
|
||||
for (let j = 0; j < fetched.length; j++) {
|
||||
pp.push(
|
||||
new Preset(this.commands[i].represet(fetched[j]), this.commands[i]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return pp;
|
||||
}
|
||||
}
|
||||
409
commands/common.js
Normal file
409
commands/common.js
Normal file
@ -0,0 +1,409 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 buffer from "buffer/";
|
||||
import * as iconv from "iconv-lite";
|
||||
import Exception from "./exception.js";
|
||||
|
||||
const availableEncodings = [
|
||||
"utf-8",
|
||||
"ibm866",
|
||||
"iso-8859-2",
|
||||
"iso-8859-3",
|
||||
"iso-8859-4",
|
||||
"iso-8859-5",
|
||||
"iso-8859-6",
|
||||
"iso-8859-7",
|
||||
"iso-8859-8",
|
||||
"iso-8859-10",
|
||||
"iso-8859-13",
|
||||
"iso-8859-14",
|
||||
"iso-8859-15",
|
||||
"iso-8859-16",
|
||||
"koi8-r",
|
||||
"koi8-u",
|
||||
"macintosh",
|
||||
"windows-874",
|
||||
"windows-1250",
|
||||
"windows-1251",
|
||||
"windows-1252",
|
||||
"windows-1253",
|
||||
"windows-1254",
|
||||
"windows-1255",
|
||||
"windows-1256",
|
||||
"windows-1257",
|
||||
"windows-1258",
|
||||
"gbk",
|
||||
"gb18030",
|
||||
"big5",
|
||||
"euc-jp",
|
||||
"shift-jis",
|
||||
"euc-kr",
|
||||
"utf-16be",
|
||||
"utf-16le",
|
||||
];
|
||||
|
||||
export const charsetPresets = (() => {
|
||||
let r = [];
|
||||
|
||||
for (let i in availableEncodings) {
|
||||
try {
|
||||
if (!iconv.encodingExists(availableEncodings[i])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
new TextDecoder(availableEncodings[i]);
|
||||
|
||||
r.push(availableEncodings[i]);
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
return r;
|
||||
})();
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
*/
|
||||
export 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
|
||||
*
|
||||
*/
|
||||
export 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 a valid hostname as far as the Sshwifty
|
||||
* client consider. This function will return true if the string contains only
|
||||
* printable charactors
|
||||
*
|
||||
* @param {string} d Input data
|
||||
*
|
||||
* @returns {boolean} Return true if given string is all hex, false otherwise
|
||||
*
|
||||
*/
|
||||
function isHostname(d) {
|
||||
for (let i = 0; i < d.length; i++) {
|
||||
const dChar = d.charCodeAt(i);
|
||||
|
||||
if (dChar >= 32 && dChar <= 126) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dChar === 128) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dChar >= 130 && dChar <= 140) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dChar === 142) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dChar >= 145 && dChar <= 156) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dChar >= 158 && dChar <= 159) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dChar >= 161 && dChar <= 255) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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 {Uint8Array} 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 Uint8Array(addrSeg * 2),
|
||||
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");
|
||||
}
|
||||
let j = (rIndexShift + i) * 2;
|
||||
r[j] = ii >> 8;
|
||||
r[j + 1] = ii & 0xff;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert string into a binary {Uint8Array}
|
||||
*
|
||||
* @param {string} d Input
|
||||
*
|
||||
* @returns {Uint8Array} Output
|
||||
*
|
||||
*/
|
||||
export function strToBinary(d) {
|
||||
return new Uint8Array(buffer.Buffer.from(d, "binary").buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
246
commands/common_test.js
Normal file
246
commands/common_test.js
Normal file
@ -0,0 +1,246 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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.notStrictEqual(ee, null, "Test " + tests[i].sample);
|
||||
} else {
|
||||
let data = common.parseIPv4(tests[i].sample);
|
||||
|
||||
assert.deepStrictEqual(data, tests[i].expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("parseIPv6", () => {
|
||||
let tests = [
|
||||
{
|
||||
sample: "2001:db8:1f70:0:999:de8:7648:6e8",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([
|
||||
0x20, 0x01, 0xd, 0xb8, 0x1f, 0x70, 0x0, 0x0, 0x9, 0x99, 0xd, 0xe8,
|
||||
0x76, 0x48, 0x6, 0xe8,
|
||||
]),
|
||||
},
|
||||
{
|
||||
sample: "2001:db8:85a3::8a2e:370:7334",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([
|
||||
0x20, 0x01, 0xd, 0xb8, 0x85, 0xa3, 0x0, 0x0, 0x0, 0x0, 0x8a, 0x2e,
|
||||
0x3, 0x70, 0x73, 0x34,
|
||||
]),
|
||||
},
|
||||
{
|
||||
sample: "fdef:90fb:4138::8ca",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([
|
||||
0xfd, 0xef, 0x90, 0xfb, 0x41, 0x38, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
||||
0x0, 0x8, 0xca,
|
||||
]),
|
||||
},
|
||||
{
|
||||
sample: "::1",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([
|
||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
||||
0x0, 0x1,
|
||||
]),
|
||||
},
|
||||
{
|
||||
sample: "::",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([
|
||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
||||
0x0, 0x0,
|
||||
]),
|
||||
},
|
||||
{
|
||||
sample: "2001:db8:1f70::999:de8:7648:6e8",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([
|
||||
0x20, 0x01, 0xd, 0xb8, 0x1f, 0x70, 0x0, 0x0, 0x9, 0x99, 0xd, 0xe8,
|
||||
0x76, 0x48, 0x6, 0xe8,
|
||||
]),
|
||||
},
|
||||
{
|
||||
sample: "2001:0db8:ac10:fe01::",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([
|
||||
0x20, 0x01, 0x0d, 0xb8, 0xac, 0x10, 0xfe, 0x01, 0x0, 0x0, 0x0, 0x0,
|
||||
0x0, 0x0, 0x0, 0x0,
|
||||
]),
|
||||
},
|
||||
{
|
||||
sample: "::7f00:1",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([
|
||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x7f,
|
||||
0x00, 0x00, 0x01,
|
||||
]),
|
||||
},
|
||||
{
|
||||
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.notStrictEqual(ee, null, "Test " + tests[i].sample);
|
||||
} else {
|
||||
let data = common.parseIPv6(tests[i].sample);
|
||||
|
||||
assert.deepStrictEqual(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([
|
||||
0x20, 0x01, 0xd, 0xb8, 0x1f, 0x70, 0x0, 0x0, 0x9, 0x99, 0xd, 0xe8,
|
||||
0x76, 0x48, 0x6, 0xe8,
|
||||
]),
|
||||
expectedPort: 22,
|
||||
},
|
||||
{
|
||||
sample: "[2001:db8:1f70::999:de8:7648:6e8]:100",
|
||||
expectedType: "IPv6",
|
||||
expectedAddr: new Uint8Array([
|
||||
0x20, 0x01, 0xd, 0xb8, 0x1f, 0x70, 0x0, 0x0, 0x9, 0x99, 0xd, 0xe8,
|
||||
0x76, 0x48, 0x6, 0xe8,
|
||||
]),
|
||||
expectedPort: 100,
|
||||
},
|
||||
];
|
||||
|
||||
for (let i in tests) {
|
||||
let hostport = common.splitHostPort(tests[i].sample, 22);
|
||||
|
||||
assert.deepStrictEqual(hostport.type, tests[i].expectedType);
|
||||
assert.deepStrictEqual(hostport.addr, tests[i].expectedAddr);
|
||||
assert.strictEqual(hostport.port, tests[i].expectedPort);
|
||||
}
|
||||
});
|
||||
});
|
||||
60
commands/controls.js
vendored
Normal file
60
commands/controls.js
vendored
Normal file
@ -0,0 +1,60 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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
|
||||
*
|
||||
* @returns {object} Control object
|
||||
*
|
||||
* @throws {Exception} When given control type is undefined
|
||||
*
|
||||
*/
|
||||
get(type) {
|
||||
if (typeof this.controls[type] !== "object") {
|
||||
throw new Exception('Control "' + type + '" was undefined');
|
||||
}
|
||||
|
||||
return this.controls[type];
|
||||
}
|
||||
}
|
||||
106
commands/events.js
Normal file
106
commands/events.js
Normal file
@ -0,0 +1,106 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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);
|
||||
}
|
||||
}
|
||||
28
commands/exception.js
Normal file
28
commands/exception.js
Normal file
@ -0,0 +1,28 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 extends Error {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {string} message error message
|
||||
*
|
||||
*/
|
||||
constructor(message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
311
commands/history.js
Normal file
311
commands/history.js
Normal file
@ -0,0 +1,311 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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";
|
||||
|
||||
/**
|
||||
* Extract needed data
|
||||
*
|
||||
* @param {Array<string>} kept The keys of of the data to be kept
|
||||
* @param {object} input Input data
|
||||
*
|
||||
* @return {object} Extracted data
|
||||
*/
|
||||
function extractSelectedData(kept, input) {
|
||||
if (!kept || typeof kept !== "object" || kept.length < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let data = {},
|
||||
length = 0;
|
||||
|
||||
for (let k in kept) {
|
||||
if (!input[kept[k]]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
data[kept[k]] = input[kept[k]];
|
||||
length++;
|
||||
}
|
||||
|
||||
if (length <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {object} data to be searched
|
||||
* @param {string} metaName name of the meta
|
||||
* @param {string} valContains target string to search
|
||||
*
|
||||
*/
|
||||
function metaContains(data, metaName, valContains) {
|
||||
switch (typeof data[metaName]) {
|
||||
case "string":
|
||||
return data[metaName].indexOf(valContains) >= 0;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the index of given uname, or -1 when not found
|
||||
*
|
||||
* @param {string} uname the unique name
|
||||
*
|
||||
* @returns {integer} The index of given uname
|
||||
*
|
||||
*/
|
||||
indexOf(uname) {
|
||||
for (let i in this.records) {
|
||||
if (this.records[i].uname !== uname) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param {object} sessionData Data which only available for current session
|
||||
* @param {Array<string>} keptSessions Keys of the session data that should
|
||||
* be saved
|
||||
*
|
||||
*/
|
||||
save(uname, title, lastUsed, info, data, sessionData, keptSessions) {
|
||||
const unameIdx = this.indexOf(uname);
|
||||
|
||||
if (unameIdx >= 0) {
|
||||
this.records.splice(unameIdx, 1);
|
||||
}
|
||||
|
||||
this.records.push({
|
||||
uname: uname,
|
||||
title: title,
|
||||
type: info.name(),
|
||||
color: info.color(),
|
||||
last: lastUsed.getTime(),
|
||||
data: data,
|
||||
session: sessionData,
|
||||
keptSessions: keptSessions,
|
||||
});
|
||||
|
||||
if (this.records.length > this.maxItems) {
|
||||
this.records = this.records.slice(
|
||||
this.records.length - this.maxItems,
|
||||
this.records.length,
|
||||
);
|
||||
}
|
||||
|
||||
this.store();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current records to storage
|
||||
*
|
||||
*/
|
||||
store() {
|
||||
this.saver(this, this.export());
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete record from 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear session data
|
||||
*
|
||||
* @param {string} uid unique name
|
||||
*
|
||||
*/
|
||||
clearSession(uid) {
|
||||
for (let i in this.records) {
|
||||
if (this.records[i].uname !== uid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.records[i].session = null;
|
||||
this.records[i].keptSessions = [];
|
||||
break;
|
||||
}
|
||||
|
||||
this.store();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all history records. The exported data is differ than the
|
||||
* internal ones, it cannot be directly import back
|
||||
*
|
||||
* @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,
|
||||
session: this.records[i].session,
|
||||
keptSessions: this.records[i].keptSessions,
|
||||
});
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export current history records
|
||||
*
|
||||
* @returns {Array<object>} Records
|
||||
*
|
||||
*/
|
||||
export() {
|
||||
let r = [];
|
||||
|
||||
for (let i in this.records) {
|
||||
r.push({
|
||||
uname: this.records[i].uname,
|
||||
title: this.records[i].title,
|
||||
type: this.records[i].type,
|
||||
color: this.records[i].color,
|
||||
last: this.records[i].last,
|
||||
data: this.records[i].data,
|
||||
session: extractSelectedData(
|
||||
this.records[i].keptSessions,
|
||||
this.records[i].session,
|
||||
),
|
||||
keptSessions: this.records[i].keptSessions,
|
||||
});
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import data into current history records
|
||||
*
|
||||
* @param {Array<object>} records Records
|
||||
*
|
||||
*/
|
||||
import(records) {
|
||||
for (let i in records) {
|
||||
if (this.indexOf(records[i].uname) >= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.records.push({
|
||||
uname: records[i].uname,
|
||||
title: records[i].title,
|
||||
type: records[i].type,
|
||||
color: records[i].color,
|
||||
last: records[i].last,
|
||||
data: records[i].data,
|
||||
session: extractSelectedData(
|
||||
records[i].keptSessions,
|
||||
records[i].session,
|
||||
),
|
||||
keptSessions: records[i].keptSessions,
|
||||
});
|
||||
}
|
||||
|
||||
this.store();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for partly matched results
|
||||
*
|
||||
* @param {string} type of the history record
|
||||
* @param {string} metaName name of the meta data
|
||||
* @param {string} keyword keyword to search
|
||||
* @param {number} max max results
|
||||
*/
|
||||
search(type, metaName, keyword, max) {
|
||||
let maxResults = max > this.records.length ? this.records.length : max;
|
||||
let s = [];
|
||||
|
||||
if (maxResults < 0) {
|
||||
maxResults = this.records.length;
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.records.length && s.length < maxResults; i++) {
|
||||
if (this.records[i].type !== type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.records[i].data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!metaContains(this.records[i].data, metaName, keyword)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
s.push(this.records[i]);
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
}
|
||||
90
commands/integer.js
Normal file
90
commands/integer.js
Normal file
@ -0,0 +1,90 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 Exception from "./exception.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
commands/integer_test.js
Normal file
60
commands/integer_test.js
Normal file
@ -0,0 +1,60 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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.strictEqual(marshalled.length, 1);
|
||||
|
||||
r.feed(marshalled);
|
||||
|
||||
let i2 = new integer.Integer(0);
|
||||
|
||||
await i2.unmarshal(r);
|
||||
|
||||
assert.strictEqual(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.strictEqual(marshalled.length, 2);
|
||||
|
||||
r.feed(marshalled);
|
||||
|
||||
let i2 = new integer.Integer(0);
|
||||
|
||||
await i2.unmarshal(r);
|
||||
|
||||
assert.strictEqual(i.value(), i2.value());
|
||||
});
|
||||
});
|
||||
327
commands/presets.js
Normal file
327
commands/presets.js
Normal file
@ -0,0 +1,327 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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";
|
||||
|
||||
/**
|
||||
* Default preset item, contains data of a default preset
|
||||
*
|
||||
*/
|
||||
const presetItem = {
|
||||
title: "",
|
||||
type: "",
|
||||
host: "",
|
||||
meta: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify Preset Item Meta
|
||||
*
|
||||
* @param {object} preset
|
||||
*
|
||||
*/
|
||||
function verifyPresetItemMeta(preset) {
|
||||
for (let i in preset.meta) {
|
||||
if (typeof preset.meta[i] === "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Exception(
|
||||
'The data type of meta field "' +
|
||||
i +
|
||||
'" was "' +
|
||||
typeof preset.meta[i] +
|
||||
'" instead of expected "string"',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and verify the given preset, return a valid preset
|
||||
*
|
||||
* @param {object} item
|
||||
*
|
||||
* @throws {Exception} when invalid data is given
|
||||
*
|
||||
* @return {object}
|
||||
*
|
||||
*/
|
||||
function parsePresetItem(item) {
|
||||
let preset = {};
|
||||
|
||||
for (let i in presetItem) {
|
||||
preset[i] = presetItem[i];
|
||||
}
|
||||
|
||||
for (let i in presetItem) {
|
||||
if (typeof presetItem[i] === typeof item[i]) {
|
||||
preset[i] = item[i];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Exception(
|
||||
'Expecting the data type of "' +
|
||||
i +
|
||||
'" is "' +
|
||||
typeof presetItem[i] +
|
||||
'", given "' +
|
||||
typeof item[i] +
|
||||
'" instead',
|
||||
);
|
||||
}
|
||||
|
||||
verifyPresetItemMeta(preset.meta);
|
||||
|
||||
return preset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset data
|
||||
*
|
||||
*/
|
||||
export class Preset {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {object} preset preset data
|
||||
*
|
||||
*/
|
||||
constructor(preset) {
|
||||
this.preset = parsePresetItem(preset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the title of the preset
|
||||
*
|
||||
* @returns {string}
|
||||
*
|
||||
*/
|
||||
title() {
|
||||
return this.preset.title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the type of the preset
|
||||
*
|
||||
* @returns {string}
|
||||
*
|
||||
*/
|
||||
type() {
|
||||
return this.preset.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the host of the preset
|
||||
*
|
||||
* @returns {string}
|
||||
*
|
||||
*/
|
||||
host() {
|
||||
return this.preset.host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the given meta of current preset
|
||||
*
|
||||
* @param {string} name name of the meta data
|
||||
*
|
||||
* @throws {Exception} when invalid data is given
|
||||
*
|
||||
* @returns {string}
|
||||
*
|
||||
*/
|
||||
meta(name) {
|
||||
if (typeof this.preset.meta[name] !== "string") {
|
||||
throw new Exception('Meta "' + name + '" was undefined');
|
||||
}
|
||||
|
||||
return this.preset.meta[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the given meta of current preset, and if failed, return the given
|
||||
* default value
|
||||
*
|
||||
* @param {string} name name of the meta data
|
||||
* @param {string} defaultValue default value to be returned when the meta was
|
||||
* not found
|
||||
*
|
||||
* @returns {string}
|
||||
*
|
||||
*/
|
||||
metaDefault(name, defaultValue) {
|
||||
try {
|
||||
return this.meta(name);
|
||||
} catch (e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert new meta item
|
||||
*
|
||||
* @param {string} name name of the meta data
|
||||
* @param {string} data data of the meta data
|
||||
*
|
||||
* @throws {Exception} when invalid data is given
|
||||
*
|
||||
*/
|
||||
insertMeta(name, data) {
|
||||
if (typeof this.preset.meta[name] !== "undefined") {
|
||||
throw new Exception('Meta "' + name + '" has already been defined');
|
||||
}
|
||||
|
||||
this.preset.meta[name] = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all meta keys
|
||||
*
|
||||
* @returns {Array<string>} All meta keys
|
||||
*
|
||||
*/
|
||||
metaKeys() {
|
||||
let keys = [];
|
||||
|
||||
for (let k in this.preset.meta) {
|
||||
keys.push(k);
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an empty preset
|
||||
*
|
||||
* @returns {Preset}
|
||||
*
|
||||
*/
|
||||
export function emptyPreset() {
|
||||
return new Preset({
|
||||
title: "Default",
|
||||
type: "Default",
|
||||
host: "",
|
||||
meta: {},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Command Preset manager
|
||||
*
|
||||
*/
|
||||
export class Presets {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {Array<object>} presets Array of preset data
|
||||
*
|
||||
*/
|
||||
constructor(presets) {
|
||||
this.presets = [];
|
||||
|
||||
for (let i = 0; i < presets.length; i++) {
|
||||
this.presets.push(new Preset(presets[i]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all presets of a type
|
||||
*
|
||||
* @param {string} type type of the presets data
|
||||
*
|
||||
* @returns {Array<Preset>}
|
||||
*
|
||||
*/
|
||||
fetch(type) {
|
||||
let presets = [];
|
||||
|
||||
for (let i = 0; i < this.presets.length; i++) {
|
||||
if (this.presets[i].type() !== type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
presets.push(this.presets[i]);
|
||||
}
|
||||
|
||||
return presets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return presets with matched type and meta data
|
||||
*
|
||||
* @param {string} type type of the presets data
|
||||
* @param {string} metaName name of the meta data
|
||||
* @param {string} metaVal value of the meta data
|
||||
*
|
||||
* @returns {Array<Preset>}
|
||||
*
|
||||
*/
|
||||
meta(type, metaName, metaVal) {
|
||||
let presets = [];
|
||||
|
||||
for (let i = 0; i < this.presets.length; i++) {
|
||||
if (this.presets[i].type() !== type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.presets[i].meta(metaName) !== metaVal) {
|
||||
continue;
|
||||
}
|
||||
} catch (e) {
|
||||
if (!(e instanceof Exception)) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
presets.push(this.presets[i]);
|
||||
}
|
||||
|
||||
return presets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return presets with matched type and host
|
||||
*
|
||||
* @param {string} type type of the presets
|
||||
* @param {string} host host of the presets
|
||||
*
|
||||
* @returns {Array<Preset>}
|
||||
*
|
||||
*/
|
||||
hosts(type, host) {
|
||||
let presets = [];
|
||||
|
||||
for (let i = 0; i < this.presets.length; i++) {
|
||||
if (this.presets[i].type() !== type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.presets[i].host() !== host) {
|
||||
continue;
|
||||
}
|
||||
|
||||
presets.push(this.presets[i]);
|
||||
}
|
||||
|
||||
return presets;
|
||||
}
|
||||
}
|
||||
1124
commands/ssh.js
Normal file
1124
commands/ssh.js
Normal file
File diff suppressed because it is too large
Load Diff
72
commands/string.js
Normal file
72
commands/string.js
Normal file
@ -0,0 +1,72 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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
commands/string_test.js
Normal file
265
commands/string_test.js
Normal file
@ -0,0 +1,265 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 strings from "./string.js";
|
||||
|
||||
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.deepStrictEqual(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.deepStrictEqual(s2.data(), s.data());
|
||||
});
|
||||
});
|
||||
648
commands/telnet.js
Normal file
648
commands/telnet.js
Normal file
@ -0,0 +1,648 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 "../stream/header.js";
|
||||
import * as reader from "../stream/reader.js";
|
||||
import * as stream from "../stream/stream.js";
|
||||
import * as address from "./address.js";
|
||||
import * as command from "./commands.js";
|
||||
import * as common from "./common.js";
|
||||
import * as controls from "./controls.js";
|
||||
import * as event from "./events.js";
|
||||
import Exception from "./exception.js";
|
||||
import * as history from "./history.js";
|
||||
import * as presets from "./presets.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;
|
||||
|
||||
const HostMaxSearchResults = 3;
|
||||
|
||||
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.sendData(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",
|
||||
readonly: false,
|
||||
suggestions(input) {
|
||||
return [];
|
||||
},
|
||||
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";
|
||||
},
|
||||
},
|
||||
Encoding: {
|
||||
name: "Encoding",
|
||||
description: "The character encoding of the server",
|
||||
type: "select",
|
||||
value: "utf-8",
|
||||
example: common.charsetPresets.join(","),
|
||||
readonly: false,
|
||||
suggestions(input) {
|
||||
return [];
|
||||
},
|
||||
verify(d) {
|
||||
for (let i in common.charsetPresets) {
|
||||
if (common.charsetPresets[i] !== d) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
throw new Error('The character encoding "' + d + '" is not supported');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
class Wizard {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {command.Info} info
|
||||
* @param {presets.Preset} preset
|
||||
* @param {object} session
|
||||
* @param {Array<string>} keptSessions
|
||||
* @param {streams.Streams} streams
|
||||
* @param {subscribe.Subscribe} subs
|
||||
* @param {controls.Controls} controls
|
||||
* @param {history.History} history
|
||||
*
|
||||
*/
|
||||
constructor(
|
||||
info,
|
||||
preset,
|
||||
session,
|
||||
keptSessions,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
) {
|
||||
this.info = info;
|
||||
this.preset = preset;
|
||||
this.hasStarted = false;
|
||||
this.streams = streams;
|
||||
this.session = session;
|
||||
this.keptSessions = keptSessions;
|
||||
this.step = subs;
|
||||
this.controls = controls.get("Telnet");
|
||||
this.history = history;
|
||||
}
|
||||
|
||||
run() {
|
||||
this.step.resolve(this.stepInitialPrompt());
|
||||
}
|
||||
|
||||
started() {
|
||||
return this.hasStarted;
|
||||
}
|
||||
|
||||
control() {
|
||||
return this.controls;
|
||||
}
|
||||
|
||||
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 the 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
|
||||
* @param {object} sessionData
|
||||
*
|
||||
*/
|
||||
buildCommand(sender, configInput, sessionData) {
|
||||
let self = this;
|
||||
|
||||
let parsedConfig = {
|
||||
host: address.parseHostPort(configInput.host, DEFAULT_PORT),
|
||||
charset: configInput.charset,
|
||||
};
|
||||
|
||||
// Copy the keptSessions from the record so it will not be overwritten here
|
||||
let keptSessions = self.keptSessions ? [].concat(...self.keptSessions) : [];
|
||||
|
||||
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.build({
|
||||
charset: parsedConfig.charset,
|
||||
send(data) {
|
||||
return commandHandler.sendData(data);
|
||||
},
|
||||
close() {
|
||||
return commandHandler.sendClose();
|
||||
},
|
||||
events: commandHandler.events,
|
||||
}),
|
||||
self.controls.ui(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
self.history.save(
|
||||
self.info.name() + ":" + configInput.host,
|
||||
configInput.host,
|
||||
new Date(),
|
||||
self.info,
|
||||
configInput,
|
||||
sessionData,
|
||||
keptSessions,
|
||||
);
|
||||
},
|
||||
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() {
|
||||
const self = this;
|
||||
|
||||
return command.prompt(
|
||||
"Telnet",
|
||||
"Teletype Network",
|
||||
"Connect",
|
||||
(r) => {
|
||||
self.hasStarted = true;
|
||||
|
||||
self.streams.request(COMMAND_ID, (sd) => {
|
||||
return self.buildCommand(
|
||||
sd,
|
||||
{
|
||||
host: r.host,
|
||||
charset: r.encoding,
|
||||
},
|
||||
self.session,
|
||||
);
|
||||
});
|
||||
|
||||
self.step.resolve(self.stepWaitForAcceptWait());
|
||||
},
|
||||
() => {},
|
||||
command.fieldsWithPreset(
|
||||
initialFieldDef,
|
||||
[
|
||||
{
|
||||
name: "Host",
|
||||
suggestions(input) {
|
||||
const hosts = self.history.search(
|
||||
"Telnet",
|
||||
"host",
|
||||
input,
|
||||
HostMaxSearchResults,
|
||||
);
|
||||
|
||||
let sugg = [];
|
||||
|
||||
for (let i = 0; i < hosts.length; i++) {
|
||||
sugg.push({
|
||||
title: hosts[i].title,
|
||||
value: hosts[i].data.host,
|
||||
meta: {
|
||||
Encoding: hosts[i].data.charset,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return sugg;
|
||||
},
|
||||
},
|
||||
{ name: "Encoding" },
|
||||
],
|
||||
self.preset,
|
||||
(r) => {},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Executor extends Wizard {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {command.Info} info
|
||||
* @param {object} config
|
||||
* @param {object} session
|
||||
* @param {Array<string>} keptSessions
|
||||
* @param {streams.Streams} streams
|
||||
* @param {subscribe.Subscribe} subs
|
||||
* @param {controls.Controls} controls
|
||||
* @param {history.History} history
|
||||
*
|
||||
*/
|
||||
constructor(
|
||||
info,
|
||||
config,
|
||||
session,
|
||||
keptSessions,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
) {
|
||||
super(
|
||||
info,
|
||||
presets.emptyPreset(),
|
||||
session,
|
||||
keptSessions,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
);
|
||||
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
stepInitialPrompt() {
|
||||
const self = this;
|
||||
|
||||
self.hasStarted = true;
|
||||
|
||||
self.streams.request(COMMAND_ID, (sd) => {
|
||||
return self.buildCommand(
|
||||
sd,
|
||||
{
|
||||
host: self.config.host,
|
||||
charset: self.config.charset ? self.config.charset : "utf-8",
|
||||
},
|
||||
self.session,
|
||||
);
|
||||
});
|
||||
|
||||
return self.stepWaitForAcceptWait();
|
||||
}
|
||||
}
|
||||
|
||||
export class Command {
|
||||
constructor() {}
|
||||
|
||||
id() {
|
||||
return COMMAND_ID;
|
||||
}
|
||||
|
||||
name() {
|
||||
return "Telnet";
|
||||
}
|
||||
|
||||
description() {
|
||||
return "Teletype Network";
|
||||
}
|
||||
|
||||
color() {
|
||||
return "#6ac";
|
||||
}
|
||||
|
||||
wizard(
|
||||
info,
|
||||
preset,
|
||||
session,
|
||||
keptSessions,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
) {
|
||||
return new Wizard(
|
||||
info,
|
||||
preset,
|
||||
session,
|
||||
keptSessions,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
);
|
||||
}
|
||||
|
||||
execute(
|
||||
info,
|
||||
config,
|
||||
session,
|
||||
keptSessions,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
) {
|
||||
return new Executor(
|
||||
info,
|
||||
config,
|
||||
session,
|
||||
keptSessions,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
);
|
||||
}
|
||||
|
||||
launch(info, launcher, streams, subs, controls, history) {
|
||||
const d = launcher.split("|", 2);
|
||||
|
||||
if (d.length <= 0) {
|
||||
throw new Exception('Given launcher "' + launcher + '" was invalid');
|
||||
}
|
||||
|
||||
try {
|
||||
initialFieldDef["Host"].verify(d[0]);
|
||||
} catch (e) {
|
||||
throw new Exception(
|
||||
'Given launcher "' + launcher + '" was invalid: ' + e,
|
||||
);
|
||||
}
|
||||
|
||||
let charset = "utf-8";
|
||||
|
||||
if (d.length > 1) {
|
||||
// TODO: Remove this check after depreciation period.
|
||||
try {
|
||||
initialFieldDef["Encoding"].verify(d[1]);
|
||||
|
||||
charset = d[1];
|
||||
} catch (e) {
|
||||
throw new Exception(
|
||||
'Given launcher "' + launcher + '" was invalid: ' + e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.execute(
|
||||
info,
|
||||
{
|
||||
host: d[0],
|
||||
charset: charset,
|
||||
},
|
||||
null,
|
||||
null,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
);
|
||||
}
|
||||
|
||||
launcher(config) {
|
||||
return config.host + "|" + (config.charset ? config.charset : "utf-8");
|
||||
}
|
||||
|
||||
represet(preset) {
|
||||
const host = preset.host();
|
||||
|
||||
if (host.length > 0) {
|
||||
preset.insertMeta("Host", host);
|
||||
}
|
||||
|
||||
return preset;
|
||||
}
|
||||
}
|
||||
694
common.css
Normal file
694
common.css
Normal file
@ -0,0 +1,694 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 */
|
||||
.lst-nostyle {
|
||||
list-style: none;
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
.hlst {
|
||||
}
|
||||
|
||||
.hlst > li {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.lst1 {
|
||||
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;
|
||||
}
|
||||
|
||||
.hlst.lstcl1 {
|
||||
list-style: none;
|
||||
list-style-position: inside;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.hlst.lstcl1 > li {
|
||||
width: 33%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hlst.lstcl1 > li .lst-wrap {
|
||||
padding: 10px;
|
||||
margin: 10px;
|
||||
background: #333;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
box-shadow: 2px 2px 0 0 #222;
|
||||
}
|
||||
|
||||
.hlst.lstcl1 > li .lst-wrap:hover {
|
||||
background: #3a3a3a;
|
||||
box-shadow: 2px 2px 0 0 #222;
|
||||
}
|
||||
|
||||
.hlst.lstcl1 > li .lst-wrap:active {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.hlst.lstcl2 {
|
||||
list-style: none;
|
||||
list-style-position: inside;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.hlst.lstcl2 > li {
|
||||
width: 33%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hlst.lstcl2 > li .lst-wrap {
|
||||
padding: 10px;
|
||||
margin: 5px;
|
||||
background: #333;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 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: 3px 13px;
|
||||
}
|
||||
|
||||
.icon.icon-point1 {
|
||||
position: relative;
|
||||
text-shadow: 0 0 3px #fff;
|
||||
}
|
||||
|
||||
.icon.icon-point1::after {
|
||||
content: "\25CF";
|
||||
}
|
||||
|
||||
.icon.icon-keyboardkey1 {
|
||||
background: #fff;
|
||||
color: #999;
|
||||
padding: 4px 6px;
|
||||
display: inline-block;
|
||||
border-radius: 3px;
|
||||
box-shadow: 1px 1px 0 2px #0003;
|
||||
}
|
||||
|
||||
.icon.icon-iconed-bottom1 {
|
||||
padding: 4px 6px;
|
||||
display: inline-block;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
}
|
||||
.icon.icon-iconed-bottom1 > i {
|
||||
font-style: normal;
|
||||
display: block;
|
||||
margin: 3px;
|
||||
font-size: 2em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.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 select,
|
||||
.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 > select,
|
||||
.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="file"],
|
||||
.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 > select,
|
||||
.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.highlight > input[type="text"],
|
||||
.form1 > fieldset .field.highlight > input[type="file"],
|
||||
.form1 > fieldset .field.highlight > input[type="email"],
|
||||
.form1 > fieldset .field.highlight > input[type="number"],
|
||||
.form1 > fieldset .field.highlight > input[type="search"],
|
||||
.form1 > fieldset .field.highlight > input[type="tel"],
|
||||
.form1 > fieldset .field.highlight > input[type="url"],
|
||||
.form1 > fieldset .field.highlight > input[type="password"],
|
||||
.form1 > fieldset .field.highlight > select,
|
||||
.form1 > fieldset .field.highlight > textarea {
|
||||
background: #666;
|
||||
border-bottom: 2px solid #ccc;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field.error > input[type="text"],
|
||||
.form1 > fieldset .field.error > input[type="file"],
|
||||
.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 > select,
|
||||
.form1 > fieldset .field.error > textarea {
|
||||
background: #483535;
|
||||
border-bottom: 2px solid #a83333;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input:disabled,
|
||||
.form1 > fieldset .field > select:disabled,
|
||||
.form1 > fieldset .field > textarea:disabled,
|
||||
.form1 > fieldset .field > button:disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input:disabled:active,
|
||||
.form1 > fieldset .field > select: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: 1px 3px 1px 1px;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input[type="checkbox"]:active,
|
||||
.form1 > fieldset .field > input[type="radio"]:active,
|
||||
.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 > select: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:focus,
|
||||
.form1 > fieldset .field > button:hover {
|
||||
border-color: #a56;
|
||||
background: #c78;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > button:active {
|
||||
background: #a56;
|
||||
border-color: #a56;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > button.secondary {
|
||||
float: right;
|
||||
background: transparent;
|
||||
color: #eee;
|
||||
border-color: #eee;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > button.secondary:focus,
|
||||
.form1 > fieldset .field > button.secondary:hover {
|
||||
border-color: #ddd;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > button.secondary:active {
|
||||
border-color: #666;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > ul.input-suggestions {
|
||||
background: #262626;
|
||||
box-shadow: 0 0 3px #0006;
|
||||
border: 1px solid #666;
|
||||
position: relative;
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > ul.input-suggestions::before,
|
||||
.form1 > fieldset .field > ul.input-suggestions::after {
|
||||
top: -5px;
|
||||
left: 5px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
content: " ";
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #262626;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > ul.input-suggestions::after {
|
||||
top: -6px;
|
||||
z-index: 0;
|
||||
background: #666;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > ul.input-suggestions > li {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #222;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > ul.input-suggestions > li:hover,
|
||||
.form1 > fieldset .field > ul.input-suggestions > li.current {
|
||||
background: #555;
|
||||
border-bottom: 1px solid #555;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > ul.input-suggestions > li:first-child:hover::before,
|
||||
.form1
|
||||
> fieldset
|
||||
.field
|
||||
> ul.input-suggestions
|
||||
> li.current:first-child::before {
|
||||
top: -6px;
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
left: 5px;
|
||||
display: block;
|
||||
content: " ";
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #555;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > ul.input-suggestions > li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > ul.input-suggestions > li > .sugt-title {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > ul.input-suggestions > li > .sugt-value {
|
||||
color: #fdd;
|
||||
font-size: 0.9em;
|
||||
margin: 0 5px;
|
||||
}
|
||||
152
control/ssh.js
Normal file
152
control/ssh.js
Normal file
@ -0,0 +1,152 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 iconv from "iconv-lite";
|
||||
import * as color from "../commands/color.js";
|
||||
import * as common from "../commands/common.js";
|
||||
import * as reader from "../stream/reader.js";
|
||||
import * as subscribe from "../stream/subscribe.js";
|
||||
|
||||
class Control {
|
||||
constructor(data, color) {
|
||||
this.colorM = color;
|
||||
this.colors = this.colorM.get();
|
||||
this.charset = data.charset;
|
||||
|
||||
this.charsetDecoder = (d) => {
|
||||
return iconv.decode(d, this.charset);
|
||||
};
|
||||
this.charsetEncoder = (dStr) => {
|
||||
return iconv.encode(dStr, this.charset);
|
||||
};
|
||||
|
||||
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(self.charsetDecoder(await reader.readCompletely(rd)));
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
});
|
||||
|
||||
data.events.place("stderr", async (rd) => {
|
||||
try {
|
||||
self.subs.resolve(self.charsetDecoder(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);
|
||||
}
|
||||
|
||||
enabled() {
|
||||
this.enable = true;
|
||||
}
|
||||
|
||||
disabled() {
|
||||
this.enable = false;
|
||||
}
|
||||
|
||||
retap(isOn) {}
|
||||
|
||||
receive() {
|
||||
return this.subs.subscribe();
|
||||
}
|
||||
|
||||
send(data) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.sender(this.charsetEncoder(data));
|
||||
}
|
||||
|
||||
sendBinary(data) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.sender(common.strToBinary(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";
|
||||
}
|
||||
|
||||
ui() {
|
||||
return "Console";
|
||||
}
|
||||
|
||||
build(data) {
|
||||
return new Control(data, this.color);
|
||||
}
|
||||
}
|
||||
513
control/telnet.js
Normal file
513
control/telnet.js
Normal file
@ -0,0 +1,513 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 iconv from "iconv-lite";
|
||||
import * as color from "../commands/color.js";
|
||||
import * as common from "../commands/common.js";
|
||||
import Exception from "../commands/exception.js";
|
||||
import * as reader from "../stream/reader.js";
|
||||
import * as subscribe from "../stream/subscribe.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, cmdWill);
|
||||
return;
|
||||
|
||||
case cmdWont:
|
||||
if (oldVal) {
|
||||
this.sendNego(cmdDont, option);
|
||||
}
|
||||
|
||||
newVal(false, cmdWont);
|
||||
return;
|
||||
|
||||
case cmdDo:
|
||||
if (!oldVal) {
|
||||
this.sendNego(cmdWill, option);
|
||||
}
|
||||
|
||||
newVal(true, cmdDo);
|
||||
return;
|
||||
|
||||
case cmdDont:
|
||||
if (oldVal) {
|
||||
this.sendNego(cmdWont, option);
|
||||
}
|
||||
|
||||
newVal(false, cmdDont);
|
||||
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, action) => {
|
||||
this.options.echoEnabled = d;
|
||||
|
||||
switch (action) {
|
||||
case cmdWill:
|
||||
case cmdDont:
|
||||
this.callbacks.setEcho(false);
|
||||
break;
|
||||
|
||||
case cmdWont:
|
||||
case cmdDo:
|
||||
this.callbacks.setEcho(true);
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
case optSuppressGoAhead:
|
||||
return this.handleOption(
|
||||
d[0],
|
||||
o[0],
|
||||
this.options.suppressGoAhead,
|
||||
(d, _action) => {
|
||||
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.charset = data.charset;
|
||||
|
||||
this.charsetDecoder = (d) => {
|
||||
return iconv.decode(d, this.charset);
|
||||
};
|
||||
this.charsetEncoder = (dStr) => {
|
||||
return iconv.encode(dStr, this.charset);
|
||||
};
|
||||
|
||||
this.sender = data.send;
|
||||
this.closer = data.close;
|
||||
this.closed = false;
|
||||
this.localEchoEnabled = 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(this.charsetDecoder(d));
|
||||
},
|
||||
{
|
||||
setEcho(newVal) {
|
||||
self.localEchoEnabled = newVal;
|
||||
},
|
||||
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.localEchoEnabled;
|
||||
}
|
||||
|
||||
resize(dim) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.windowDim.cols = dim.cols;
|
||||
this.windowDim.rows = dim.rows;
|
||||
|
||||
this.parser.requestWindowResize();
|
||||
}
|
||||
|
||||
enabled() {
|
||||
this.enable = true;
|
||||
}
|
||||
|
||||
disabled() {
|
||||
this.enable = false;
|
||||
}
|
||||
|
||||
retap(_isOn) {}
|
||||
|
||||
receive() {
|
||||
return this.subs.subscribe();
|
||||
}
|
||||
|
||||
searchNextIAC(start, data) {
|
||||
for (let i = start; i < data.length; i++) {
|
||||
if (data[i] !== cmdIAC) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
sendSeg(enc) {
|
||||
let currentLen = 0;
|
||||
|
||||
while (currentLen < enc.length) {
|
||||
const iacPos = this.searchNextIAC(currentLen, enc);
|
||||
|
||||
if (iacPos < 0) {
|
||||
this.sender(enc.slice(currentLen, enc.length));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.sender(enc.slice(currentLen, iacPos + 1));
|
||||
this.sender(enc.slice(iacPos, iacPos + 1));
|
||||
|
||||
currentLen = iacPos + 1;
|
||||
}
|
||||
}
|
||||
|
||||
send(data) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendSeg(this.charsetEncoder(data));
|
||||
}
|
||||
|
||||
sendBinary(data) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.sendSeg(common.strToBinary(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";
|
||||
}
|
||||
|
||||
ui() {
|
||||
return "Console";
|
||||
}
|
||||
|
||||
build(data) {
|
||||
return new Control(data, this.color);
|
||||
}
|
||||
}
|
||||
118
crypto.js
Normal file
118
crypto.js
Normal file
@ -0,0 +1,118 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
secret,
|
||||
{
|
||||
name: "HMAC",
|
||||
hash: { name: "SHA-512" },
|
||||
},
|
||||
false,
|
||||
["sign", "verify"],
|
||||
);
|
||||
|
||||
return window.crypto.subtle.sign(key.algorithm, key, data);
|
||||
}
|
||||
|
||||
export const GCMNonceSize = 12;
|
||||
export const GCMKeyBitLen = 128;
|
||||
|
||||
/**
|
||||
* Build AES GCM Encryption/Decryption key
|
||||
*
|
||||
* @param {Uint8Array} keyData Key data
|
||||
*/
|
||||
export function buildGCMKey(keyData) {
|
||||
return window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyData,
|
||||
{
|
||||
name: "AES-GCM",
|
||||
length: GCMKeyBitLen,
|
||||
},
|
||||
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 window.crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: iv, tagLength: GCMKeyBitLen },
|
||||
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 window.crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: iv, tagLength: GCMKeyBitLen },
|
||||
key,
|
||||
cipherText,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* generate Random nonce
|
||||
*
|
||||
*/
|
||||
export function generateNonce() {
|
||||
return window.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;
|
||||
}
|
||||
@ -15,21 +15,21 @@ services:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
|
||||
# KEYCLOAK GATEKEEPER
|
||||
gatekeeper:
|
||||
container_name: gatekeeper
|
||||
restart: always
|
||||
build:
|
||||
context: ./keycloak-gatekeeper/.
|
||||
ports:
|
||||
- 8880:8880
|
||||
depends_on:
|
||||
- sshwifty
|
||||
command:
|
||||
- "--discovery-url=https://<keycloak-dns>/auth/realms/<realm>/.well-known/openid-configuration"
|
||||
- "--client-id=sshwifty"
|
||||
- "--client-secret="
|
||||
- "--listen=0.0.0.0:8880"
|
||||
- "--upstream-url=http://sshwifty:8182"
|
||||
volumes:
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
# gatekeeper:
|
||||
# container_name: gatekeeper
|
||||
# restart: always
|
||||
# build:
|
||||
# context: ./keycloak-gatekeeper/.
|
||||
# ports:
|
||||
# - 8880:8880
|
||||
# depends_on:
|
||||
# - sshwifty
|
||||
# command:
|
||||
# - "--discovery-url=https://<keycloak-dns>/auth/realms/<realm>/.well-known/openid-configuration"
|
||||
# - "--client-id=sshwifty"
|
||||
# - "--client-secret="
|
||||
# - "--listen=0.0.0.0:8880"
|
||||
# - "--upstream-url=http://sshwifty:8182"
|
||||
# volumes:
|
||||
# - /etc/timezone:/etc/timezone:ro
|
||||
# - /etc/localtime:/etc/localtime:ro
|
||||
37
error.html
Normal file
37
error.html
Normal file
@ -0,0 +1,37 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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/>.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Error</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body class="app-error">
|
||||
<div id="app-loading">
|
||||
<div id="app-loading-frame">
|
||||
<div id="app-loading-error">×</div>
|
||||
|
||||
<h1 id="app-loading-title" class="error">
|
||||
Server was unable to complete the request
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
2
go.mod
2
go.mod
@ -21,7 +21,7 @@ go 1.21.5
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
golang.org/x/crypto v0.16.0
|
||||
golang.org/x/crypto v0.17.0
|
||||
golang.org/x/net v0.19.0
|
||||
)
|
||||
|
||||
|
||||
2
go.sum
2
go.sum
@ -47,6 +47,8 @@ golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
|
||||
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
|
||||
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
|
||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
|
||||
54
history.js
Normal file
54
history.js
Normal file
@ -0,0 +1,54 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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({ data: newData, class: "" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set all existing data as expired
|
||||
*/
|
||||
expire() {
|
||||
for (let i = 0; i < this.data.length; i++) {
|
||||
this.data[i].class = "expired";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return data
|
||||
*
|
||||
*/
|
||||
get() {
|
||||
return this.data;
|
||||
}
|
||||
}
|
||||
422
home.css
Normal file
422
home.css
Normal file
@ -0,0 +1,422 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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-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;
|
||||
}
|
||||
617
home.vue
Normal file
617
home.vue
Normal file
@ -0,0 +1,617 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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"
|
||||
@retap="retapTab"
|
||||
@list="showTabsWindow"
|
||||
@close="closeTab"
|
||||
></tabs>
|
||||
</header>
|
||||
|
||||
<screens
|
||||
id="home-content"
|
||||
:screen="tab.current"
|
||||
:screens="tab.tabs"
|
||||
:view-port="viewPort"
|
||||
@stopped="tabStopped"
|
||||
@warning="tabWarning"
|
||||
@info="tabInfo"
|
||||
@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 downloading 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>
|
||||
|
||||
<div v-if="serverMessage.length > 0">
|
||||
<hr />
|
||||
<p class="secondary" v-html="serverMessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
</screens>
|
||||
|
||||
<connect-widget
|
||||
:inputting="connector.inputting"
|
||||
:display="windows.connect"
|
||||
:connectors="connector.connectors"
|
||||
:presets="presets"
|
||||
:restricted-to-presets="restrictedToPresets"
|
||||
:knowns="connector.knowns"
|
||||
:knowns-launcher-builder="buildknownLauncher"
|
||||
:knowns-export="exportKnowns"
|
||||
:knowns-import="importKnowns"
|
||||
:busy="connector.busy"
|
||||
@display="windows.connect = $event"
|
||||
@connector-select="connectNew"
|
||||
@known-select="connectKnown"
|
||||
@known-remove="removeKnown"
|
||||
@preset-select="connectPreset"
|
||||
@known-clear-session="clearSessionKnown"
|
||||
>
|
||||
<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"
|
||||
@retap="retapTab"
|
||||
@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";
|
||||
|
||||
import * as presets from "./commands/presets.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: () => null,
|
||||
},
|
||||
controls: {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
commands: {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
serverMessage: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
presetData: {
|
||||
type: Object,
|
||||
default: () => new presets.Presets([]),
|
||||
},
|
||||
restrictedToPresets: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
viewPort: {
|
||||
type: Object,
|
||||
default: () => 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(),
|
||||
},
|
||||
presets: this.commands.mergePresets(this.presetData),
|
||||
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), (success) => {
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("navigate-to", "");
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", this.onBrowserClose);
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener("beforeunload", this.onBrowserClose);
|
||||
|
||||
if (this.ticker === null) {
|
||||
clearInterval(this.ticker);
|
||||
this.ticker = null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onBrowserClose(e) {
|
||||
if (this.tab.current < 0) {
|
||||
return undefined;
|
||||
}
|
||||
const msg = "Some tabs are still open, are you sure you want to exit?";
|
||||
(e || window.event).returnValue = msg;
|
||||
return msg;
|
||||
},
|
||||
tick() {
|
||||
let now = new Date();
|
||||
|
||||
this.socket.update(now, this);
|
||||
},
|
||||
closeAllWindow(e) {
|
||||
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) {
|
||||
const self = this;
|
||||
|
||||
self.runConnect((stream) => {
|
||||
self.connector.connector = {
|
||||
id: connector.id(),
|
||||
name: connector.name(),
|
||||
description: connector.description(),
|
||||
wizard: connector.wizard(
|
||||
stream,
|
||||
self.controls,
|
||||
self.connector.historyRec,
|
||||
presets.emptyPreset(),
|
||||
null,
|
||||
false,
|
||||
() => {},
|
||||
),
|
||||
};
|
||||
|
||||
self.connector.inputting = true;
|
||||
});
|
||||
},
|
||||
connectPreset(preset) {
|
||||
const self = this;
|
||||
|
||||
self.runConnect((stream) => {
|
||||
self.connector.connector = {
|
||||
id: preset.command.id(),
|
||||
name: preset.command.name(),
|
||||
description: preset.command.description(),
|
||||
wizard: preset.command.wizard(
|
||||
stream,
|
||||
self.controls,
|
||||
self.connector.historyRec,
|
||||
preset.preset,
|
||||
null,
|
||||
[],
|
||||
() => {},
|
||||
),
|
||||
};
|
||||
|
||||
self.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) {
|
||||
const self = this;
|
||||
|
||||
self.runConnect((stream) => {
|
||||
let connector = self.getConnectorByType(known.type);
|
||||
|
||||
if (!connector) {
|
||||
alert("Unknown connector: " + known.type);
|
||||
|
||||
self.connector.inputting = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
self.connector.connector = {
|
||||
id: connector.id(),
|
||||
name: connector.name(),
|
||||
description: connector.description(),
|
||||
wizard: connector.execute(
|
||||
stream,
|
||||
self.controls,
|
||||
self.connector.historyRec,
|
||||
known.data,
|
||||
known.session,
|
||||
known.keptSessions,
|
||||
() => {
|
||||
self.connector.knowns = self.connector.historyRec.all();
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
self.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, done) {
|
||||
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;
|
||||
}
|
||||
|
||||
const self = this;
|
||||
|
||||
this.connector.connector = {
|
||||
id: connector.id(),
|
||||
name: connector.name(),
|
||||
description: connector.description(),
|
||||
wizard: connector.launch(
|
||||
stream,
|
||||
this.controls,
|
||||
this.connector.historyRec,
|
||||
ll.query,
|
||||
(n) => {
|
||||
self.connector.knowns = self.connector.historyRec.all();
|
||||
|
||||
done(n.data().success);
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
this.connector.inputting = true;
|
||||
});
|
||||
},
|
||||
buildknownLauncher(known) {
|
||||
let connector = this.getConnectorByType(known.type);
|
||||
|
||||
if (!connector) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.hostPath + "#+" + connector.launcher(known.data);
|
||||
},
|
||||
exportKnowns() {
|
||||
return this.connector.historyRec.export();
|
||||
},
|
||||
importKnowns(d) {
|
||||
this.connector.historyRec.import(d);
|
||||
|
||||
this.connector.knowns = this.connector.historyRec.all();
|
||||
},
|
||||
removeKnown(uid) {
|
||||
this.connector.historyRec.del(uid);
|
||||
|
||||
this.connector.knowns = this.connector.historyRec.all();
|
||||
},
|
||||
clearSessionKnown(uid) {
|
||||
this.connector.historyRec.clearSession(uid);
|
||||
|
||||
this.connector.knowns = this.connector.historyRec.all();
|
||||
},
|
||||
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);
|
||||
|
||||
this.$emit("tab-opened", this.tab.tabs);
|
||||
},
|
||||
async addToTab(data) {
|
||||
await this.switchTab(
|
||||
this.tab.tabs.push({
|
||||
id: this.tab.lastID++,
|
||||
name: data.name,
|
||||
info: data.info,
|
||||
control: data.control,
|
||||
ui: data.ui,
|
||||
toolbar: false,
|
||||
indicator: {
|
||||
level: "",
|
||||
message: "",
|
||||
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 retapTab(tab) {
|
||||
this.tab.tabs[tab].toolbar = !this.tab.tabs[tab].toolbar;
|
||||
|
||||
await this.tab.tabs[tab].control.retap(this.tab.tabs[tab].toolbar);
|
||||
},
|
||||
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);
|
||||
|
||||
this.$emit("tab-closed", this.tab.tabs);
|
||||
},
|
||||
tabStopped(index, reason) {
|
||||
if (reason !== null) {
|
||||
this.tab.tabs[index].indicator.message = "" + reason;
|
||||
this.tab.tabs[index].indicator.level = "error";
|
||||
} else {
|
||||
this.tab.tabs[index].indicator.message = "";
|
||||
this.tab.tabs[index].indicator.level = "";
|
||||
}
|
||||
},
|
||||
tabMessage(index, msg, type) {
|
||||
if (msg.toDismiss) {
|
||||
if (
|
||||
this.tab.tabs[index].indicator.message !== msg.text ||
|
||||
this.tab.tabs[index].indicator.level !== type
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tab.tabs[index].indicator.message = "";
|
||||
this.tab.tabs[index].indicator.level = "";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.tab.tabs[index].indicator.message = msg.text;
|
||||
this.tab.tabs[index].indicator.level = type;
|
||||
},
|
||||
tabWarning(index, msg) {
|
||||
this.tabMessage(index, msg, "warning");
|
||||
},
|
||||
tabInfo(index, msg) {
|
||||
this.tabMessage(index, msg, "info");
|
||||
},
|
||||
tabUpdated(index) {
|
||||
this.$emit("tab-updated", this.tab.tabs);
|
||||
|
||||
this.tab.tabs[index].indicator.updated = index !== this.tab.current;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
58
home_historyctl.js
Normal file
58
home_historyctl.js
Normal file
@ -0,0 +1,58 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 { History } from "./commands/history.js";
|
||||
|
||||
export function build(ctx) {
|
||||
let rec = [];
|
||||
|
||||
// This renames "knowns" to "sshwifty-knowns"
|
||||
// TODO: Remove this after some few years
|
||||
try {
|
||||
let oldStore = localStorage.getItem("knowns");
|
||||
|
||||
if (oldStore) {
|
||||
localStorage.setItem("sshwifty-knowns", oldStore);
|
||||
localStorage.removeItem("knowns");
|
||||
}
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
try {
|
||||
rec = JSON.parse(localStorage.getItem("sshwifty-knowns"));
|
||||
|
||||
if (!rec) {
|
||||
rec = [];
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Unable to load data of Known remotes: " + e);
|
||||
}
|
||||
|
||||
return new History(
|
||||
rec,
|
||||
(h, d) => {
|
||||
try {
|
||||
localStorage.setItem("sshwifty-knowns", JSON.stringify(d));
|
||||
ctx.connector.knowns = h.all();
|
||||
} catch (e) {
|
||||
alert("Unable to save remote history due to error: " + e);
|
||||
}
|
||||
},
|
||||
64,
|
||||
);
|
||||
}
|
||||
223
home_socketctl.js
Normal file
223
home_socketctl.js
Normal file
@ -0,0 +1,223 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 history from "./history.js";
|
||||
import { ECHO_FAILED } from "./socket.js";
|
||||
|
||||
export function build(ctx) {
|
||||
const connectionStatusNotConnected = "Sshwifty is ready to connect";
|
||||
const connectionStatusConnecting =
|
||||
"Connecting to Sshwifty backend server. It should only take " +
|
||||
"less than a second, or two";
|
||||
const connectionStatusDisconnected =
|
||||
"Sshwifty is disconnected from it's backend server";
|
||||
const connectionStatusConnected =
|
||||
"Sshwifty is connected to it's backend server, user interface operational";
|
||||
const connectionStatusUnmeasurable =
|
||||
"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 a reasonable time";
|
||||
const connectionDelayMedian =
|
||||
"Experiencing median connection delay, consider to slow down your input " +
|
||||
"to avoid misoperation";
|
||||
const connectionDelayHeavy =
|
||||
"Experiencing bad connection delay, operation may freeze at any moment. " +
|
||||
"Consider to pause your input until remote is responsive";
|
||||
|
||||
const buildEmptyHistory = () => {
|
||||
let r = [];
|
||||
|
||||
for (let i = 0; i < 32; i++) {
|
||||
r.push({ data: 0, class: "" });
|
||||
}
|
||||
|
||||
return r;
|
||||
};
|
||||
|
||||
let isClosed = false,
|
||||
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 = 0;
|
||||
|
||||
let delayHistory = new history.Records(buildEmptyHistory()),
|
||||
delaySamples = 0,
|
||||
delayPerInterval = 0;
|
||||
|
||||
return {
|
||||
update(time) {
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (trafficSamples > 0) {
|
||||
inboundHistory.update(inboundPre10Seconds / trafficSamples);
|
||||
outboundHistory.update(outboundPre10Seconds / trafficSamples);
|
||||
|
||||
inboundPre10Seconds = 0;
|
||||
outboundPre10Seconds = 0;
|
||||
trafficSamples = 0;
|
||||
}
|
||||
|
||||
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() {
|
||||
isClosed = false;
|
||||
|
||||
this.message = "--";
|
||||
this.classStyle = "working";
|
||||
this.windowClass = "";
|
||||
this.status.description = connectionStatusConnecting;
|
||||
},
|
||||
connected() {
|
||||
isClosed = false;
|
||||
|
||||
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.status.delay = -1;
|
||||
this.message = "";
|
||||
this.classStyle = "red flash";
|
||||
this.windowClass = "red";
|
||||
this.status.description = connectionStatusUnmeasurable;
|
||||
|
||||
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) {
|
||||
isClosed = true;
|
||||
delayHistory.expire();
|
||||
inboundHistory.expire();
|
||||
outboundHistory.expire();
|
||||
|
||||
ctx.connector.inputting = false;
|
||||
|
||||
if (e === null) {
|
||||
this.message = "";
|
||||
this.classStyle = "";
|
||||
this.status.description = connectionStatusDisconnected;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.status.delay = -1;
|
||||
this.message = "ERR";
|
||||
this.classStyle = "red flash";
|
||||
this.windowClass = "red";
|
||||
this.status.description = connectionStatusDisconnected + ": " + e;
|
||||
},
|
||||
failed(e) {
|
||||
isClosed = true;
|
||||
|
||||
ctx.connector.inputting = false;
|
||||
|
||||
if (e.code) {
|
||||
this.message = "E" + e.code;
|
||||
} else {
|
||||
this.message = "E????";
|
||||
}
|
||||
|
||||
this.status.delay = -1;
|
||||
this.classStyle = "red flash";
|
||||
this.windowClass = "red";
|
||||
this.status.description = connectionStatusDisconnected + ". Error: " + e;
|
||||
},
|
||||
};
|
||||
}
|
||||
64
index.html
Normal file
64
index.html
Normal file
@ -0,0 +1,64 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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/>.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Sshwifty Web SSH Client</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<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-2023 Ni Rui <ranqus@gmail.com>
|
||||
</p>
|
||||
<p class="copy">
|
||||
<a href="https://github.com/nirui/sshwifty" target="blank">
|
||||
Source code
|
||||
</a>
|
||||
|
||||
<a href="/sshwifty/assets/DEPENDENCIES.md" target="blank">
|
||||
Third-party
|
||||
</a>
|
||||
|
||||
<a href="/sshwifty/assets/README.md" target="blank"> Readme </a>
|
||||
|
||||
<a href="/sshwifty/assets/LICENSE.md" target="blank"> License </a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
132
landing.css
Normal file
132
landing.css
Normal file
@ -0,0 +1,132 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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: 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;
|
||||
}
|
||||
45
loading.vue
Normal file
45
loading.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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>
|
||||
1276
package-lock.json
generated
1276
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@ -5,12 +5,12 @@
|
||||
"main": "",
|
||||
"devDependencies": {
|
||||
"@azurity/pure-nerd-font": "^3.0.1",
|
||||
"@babel/core": "^7.23.5",
|
||||
"@babel/core": "^7.23.7",
|
||||
"@babel/eslint-parser": "^7.23.3",
|
||||
"@babel/plugin-transform-runtime": "^7.23.4",
|
||||
"@babel/preset-env": "^7.23.5",
|
||||
"@babel/register": "^7.22.15",
|
||||
"@babel/runtime": "^7.23.5",
|
||||
"@babel/plugin-transform-runtime": "^7.23.7",
|
||||
"@babel/preset-env": "^7.23.7",
|
||||
"@babel/register": "^7.23.7",
|
||||
"@babel/runtime": "^7.23.7",
|
||||
"@xterm/addon-fit": "^0.9.0-beta.1",
|
||||
"@xterm/addon-unicode11": "^0.7.0-beta.1",
|
||||
"@xterm/addon-web-links": "^0.10.0-beta.1",
|
||||
@ -23,16 +23,16 @@
|
||||
"css-loader": "^6.8.1",
|
||||
"css-minimizer-webpack-plugin": "^5.0.1",
|
||||
"cwebp-bin": "^8.0.0",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"eslint-plugin-prettier": "^5.1.2",
|
||||
"eslint-plugin-vue": "^9.19.2",
|
||||
"eslint-webpack-plugin": "^4.0.1",
|
||||
"favicons": "^7.1.4",
|
||||
"favicons": "^7.1.5",
|
||||
"fontfaceobserver": "^2.3.0",
|
||||
"hack-font": "^3.3.0",
|
||||
"html-loader": "^4.2.0",
|
||||
"html-webpack-plugin": "^5.5.3",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"image-minimizer-webpack-plugin": "^3.8.3",
|
||||
"imagemin": "^8.0.1",
|
||||
@ -44,10 +44,10 @@
|
||||
"mini-css-extract-plugin": "^2.7.6",
|
||||
"mocha": "^10.2.0",
|
||||
"normalize.css": "^8.0.1",
|
||||
"prettier": "^3.1.0",
|
||||
"prettier": "^3.1.1",
|
||||
"roboto-fontface": "^0.10.0",
|
||||
"style-loader": "^3.3.3",
|
||||
"terser-webpack-plugin": "^5.3.9",
|
||||
"terser-webpack-plugin": "^5.3.10",
|
||||
"vue": "^2.6.14",
|
||||
"vue-loader": "^15.9.8",
|
||||
"webpack": "^5.89.0",
|
||||
|
||||
4
robots.txt
Normal file
4
robots.txt
Normal file
@ -0,0 +1,4 @@
|
||||
user-agent: *
|
||||
|
||||
Disallow: /
|
||||
Allow: /$
|
||||
397
socket.js
Normal file
397
socket.js
Normal file
@ -0,0 +1,397 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 crypt from "./crypto.js";
|
||||
import * as reader from "./stream/reader.js";
|
||||
import * as sender from "./stream/sender.js";
|
||||
import * as streams from "./stream/streams.js";
|
||||
import * as xhr from "./xhr.js";
|
||||
|
||||
export const ECHO_FAILED = streams.ECHO_FAILED;
|
||||
|
||||
const maxSenderDelay = 200;
|
||||
const minSenderDelay = 30;
|
||||
|
||||
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;
|
||||
this.keepAliveTicker = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the remote server
|
||||
*
|
||||
* @param {string} address Target URL address
|
||||
* @param {number} timeout Connect timeout
|
||||
*
|
||||
* @returns {Promise<WebSocket>} When connection is established
|
||||
*
|
||||
*/
|
||||
connect(address, timeout) {
|
||||
const self = this;
|
||||
return new Promise((resolve, reject) => {
|
||||
let ws = new WebSocket(address.webSocket),
|
||||
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);
|
||||
};
|
||||
|
||||
if (!self.keepAliveTicker) {
|
||||
self.keepAliveTicker = setInterval(() => {
|
||||
xhr.options(address.keepAlive, {});
|
||||
}, self.timeout);
|
||||
}
|
||||
|
||||
ws.addEventListener("open", (_event) => {
|
||||
myRes(ws);
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (event) => {
|
||||
event.toString = () => {
|
||||
return "WebSocket Error (" + event.code + ")";
|
||||
};
|
||||
|
||||
myRej(event);
|
||||
clearInterval(self.keepAliveTicker);
|
||||
self.keepAliveTicker = null;
|
||||
});
|
||||
|
||||
ws.addEventListener("error", (_event) => {
|
||||
ws.close();
|
||||
clearInterval(self.keepAliveTicker);
|
||||
self.keepAliveTicker = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an socket encrypt and decrypt key string
|
||||
*
|
||||
*/
|
||||
async buildKeyString() {
|
||||
return this.privateKey.fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.address, this.timeout);
|
||||
|
||||
try {
|
||||
let 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);
|
||||
});
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
callbacks.inbound(event.data);
|
||||
|
||||
rd.feed(event.data);
|
||||
});
|
||||
|
||||
ws.addEventListener("error", (event) => {
|
||||
event.toString = () => {
|
||||
return (
|
||||
"WebSocket Error (" + (event.code ? event.code : "Unknown") + ")"
|
||||
);
|
||||
};
|
||||
|
||||
rd.closeWithReason(event);
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (_event) => {
|
||||
rd.closeWithReason("Connection is closed");
|
||||
});
|
||||
|
||||
let sdDataConvert = (rawData) => {
|
||||
return rawData;
|
||||
},
|
||||
getSdDataConvert = () => {
|
||||
return sdDataConvert;
|
||||
},
|
||||
sd = new sender.Sender(
|
||||
async (rawData) => {
|
||||
try {
|
||||
let data = await getSdDataConvert()(rawData);
|
||||
|
||||
ws.send(data.buffer);
|
||||
callbacks.outbound(data);
|
||||
} catch (e) {
|
||||
ws.close();
|
||||
rd.closeWithReason(e);
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
4096 - 64, // Server has a 4096 bytes receive buffer, can be no greater,
|
||||
minSenderDelay, // 30ms input delay
|
||||
10, // max 10 buffered requests
|
||||
);
|
||||
|
||||
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.closeWithReason(e);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
reader: cgmReader,
|
||||
sender: sd,
|
||||
ws: ws,
|
||||
};
|
||||
} catch (e) {
|
||||
ws.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
const receiveToPauseFactor = 6,
|
||||
minReceivedToPause = 1024 * 16;
|
||||
|
||||
let streamPaused = false,
|
||||
currentReceived = 0,
|
||||
currentUnpacked = 0;
|
||||
|
||||
const shouldPause = () => {
|
||||
return (
|
||||
currentReceived > minReceivedToPause &&
|
||||
currentReceived > currentUnpacked * receiveToPauseFactor
|
||||
);
|
||||
};
|
||||
|
||||
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) {
|
||||
const sendDelay = delay / 2;
|
||||
|
||||
if (sendDelay > maxSenderDelay) {
|
||||
conn.sender.setDelay(maxSenderDelay);
|
||||
} else if (sendDelay < minSenderDelay) {
|
||||
conn.sender.setDelay(minSenderDelay);
|
||||
} else {
|
||||
conn.sender.setDelay(sendDelay);
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
callbacks.connected();
|
||||
|
||||
streamHandler.serve().catch((e) => {
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
return;
|
||||
}
|
||||
|
||||
console.trace(e);
|
||||
});
|
||||
|
||||
this.streamHandler = streamHandler;
|
||||
} catch (e) {
|
||||
callbacks.failed(e);
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
return this.streamHandler;
|
||||
}
|
||||
}
|
||||
22
sshwifty.svg
Normal file
22
sshwifty.svg
Normal file
@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="100.000000pt" height="75.000000pt" viewBox="0 0 100.000000 75.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,75.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M104 557 c-62 -43 -85 -82 -89 -154 -6 -87 4 -137 39 -195 33 -56 71
|
||||
-74 95 -45 9 12 4 25 -24 67 -34 50 -35 55 -35 148 l0 96 49 45 c45 43 56 71
|
||||
26 71 -7 0 -35 -15 -61 -33z"/>
|
||||
<path d="M870 575 c-14 -16 -11 -28 24 -120 9 -24 15 -74 16 -126 0 -105 14
|
||||
-127 56 -88 26 23 26 26 20 113 -6 90 -35 176 -73 218 -20 22 -27 22 -43 3z"/>
|
||||
<path d="M251 537 c-59 -72 -51 -266 14 -327 l25 -24 21 20 20 20 -27 47 c-21
|
||||
38 -26 61 -26 117 -1 38 5 83 12 99 20 49 -9 84 -39 48z"/>
|
||||
<path d="M636 511 c-17 -19 -17 -22 9 -65 24 -41 26 -53 23 -133 -3 -84 -2
|
||||
-88 19 -91 49 -7 81 138 49 223 -16 40 -56 85 -76 85 -4 0 -15 -9 -24 -19z"/>
|
||||
<path d="M412 469 c-32 -21 -52 -63 -52 -109 0 -40 63 -94 99 -85 35 8 81 59
|
||||
81 91 0 45 -40 102 -81 117 -9 3 -30 -4 -47 -14z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
109
stream/common.js
Normal file
109
stream/common.js
Normal file
@ -0,0 +1,109 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Separate given buffer to multiple ones based on input max length
|
||||
*
|
||||
* @param {Uint8Array} buf Buffer to separate
|
||||
* @param {number} max Max length of each buffer
|
||||
*
|
||||
* @returns {Array<Uint8Array>} Separated buffers
|
||||
*
|
||||
*/
|
||||
export function separateBuffer(buf, max) {
|
||||
let start = 0,
|
||||
result = [];
|
||||
|
||||
while (start < buf.length) {
|
||||
let remain = buf.length - start;
|
||||
|
||||
if (remain <= max) {
|
||||
result.push(buf.slice(start, start + remain));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
remain = max;
|
||||
|
||||
result.push(buf.slice(start, start + remain));
|
||||
start += remain;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Uint8Array out of given binary string
|
||||
*
|
||||
* @param {string} str binary string
|
||||
*
|
||||
* @returns {Uint8Array} Separated buffers
|
||||
*
|
||||
*/
|
||||
export function buildBufferFromString(str) {
|
||||
let r = [],
|
||||
t = [];
|
||||
|
||||
for (let i in str) {
|
||||
let c = str.charCodeAt(i);
|
||||
|
||||
while (c > 0xff) {
|
||||
t.push(c & 0xff);
|
||||
c >>= 8;
|
||||
}
|
||||
|
||||
r.push(c);
|
||||
|
||||
for (let j = t.length; j > 0; j--) {
|
||||
r.push(t[j]);
|
||||
}
|
||||
|
||||
t = [];
|
||||
}
|
||||
|
||||
return new Uint8Array(r);
|
||||
}
|
||||
41
stream/common_test.js
Normal file
41
stream/common_test.js
Normal file
@ -0,0 +1,41 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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("separateBuffer", async () => {
|
||||
let resultArr = [];
|
||||
const expected = new Uint8Array([
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3,
|
||||
4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7,
|
||||
8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1,
|
||||
2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5,
|
||||
6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
|
||||
]),
|
||||
sepSeg = common.separateBuffer(expected, 16);
|
||||
|
||||
sepSeg.forEach((d) => {
|
||||
resultArr.push(...d);
|
||||
});
|
||||
|
||||
const result = new Uint8Array(resultArr);
|
||||
|
||||
assert.deepStrictEqual(result, expected);
|
||||
});
|
||||
});
|
||||
31
stream/exception.js
Normal file
31
stream/exception.js
Normal file
@ -0,0 +1,31 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 extends Error {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {string} message error message
|
||||
* @param {boolean} temporary whether or not the error is temporary
|
||||
*
|
||||
*/
|
||||
constructor(message, temporary) {
|
||||
super(message);
|
||||
|
||||
this.temporary = temporary;
|
||||
}
|
||||
}
|
||||
264
stream/header.js
Normal file
264
stream/header.js
Normal file
@ -0,0 +1,264 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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
stream/header_test.js
Normal file
56
stream/header_test.js
Normal file
@ -0,0 +1,56 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 header from "./header.js";
|
||||
|
||||
describe("Header", () => {
|
||||
it("Header", () => {
|
||||
let h = new header.Header(header.ECHO);
|
||||
|
||||
h.set(63);
|
||||
|
||||
let n = new header.Header(h.value());
|
||||
|
||||
assert.strictEqual(h.type(), n.type());
|
||||
assert.strictEqual(h.data(), n.data());
|
||||
assert.strictEqual(n.type(), header.CONTROL);
|
||||
assert.strictEqual(n.data(), 63);
|
||||
});
|
||||
|
||||
it("Stream", () => {
|
||||
let h = new header.Stream(0, 0);
|
||||
|
||||
h.set(header.STREAM_MAX_MARKER, header.STREAM_MAX_LENGTH);
|
||||
|
||||
assert.strictEqual(h.marker(), header.STREAM_MAX_MARKER);
|
||||
assert.strictEqual(h.length(), header.STREAM_MAX_LENGTH);
|
||||
|
||||
assert.strictEqual(h.headerByte1, 0xff);
|
||||
assert.strictEqual(h.headerByte2, 0xff);
|
||||
});
|
||||
|
||||
it("InitialStream", () => {
|
||||
let h = new header.InitialStream(0, 0);
|
||||
|
||||
h.set(15, 128, true);
|
||||
|
||||
assert.strictEqual(h.command(), 15);
|
||||
assert.strictEqual(h.data(), 128);
|
||||
assert.strictEqual(h.success(), true);
|
||||
});
|
||||
});
|
||||
587
stream/reader.js
Normal file
587
stream/reader.js
Normal file
@ -0,0 +1,587 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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() {
|
||||
return this.closeWithReason("Reader is closed");
|
||||
}
|
||||
|
||||
/**
|
||||
* close current reading
|
||||
*
|
||||
* @param {string} reason Reason
|
||||
*
|
||||
*/
|
||||
closeWithReason(reason) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closed = true;
|
||||
this.subscribe.reject(new Exception(reason, false));
|
||||
this.subscribe.disable(reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
return this.closeWithReason("Reader is closed");
|
||||
}
|
||||
|
||||
/**
|
||||
* close current reading
|
||||
*
|
||||
* @param {string} reason Reason
|
||||
*
|
||||
*/
|
||||
closeWithReason(reason) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closed = true;
|
||||
this.buffers.reject(new Exception(reason, false));
|
||||
this.buffers.disable(reason);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
197
stream/reader_test.js
Normal file
197
stream/reader_test.js
Normal file
@ -0,0 +1,197 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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.strictEqual(ex.length, 1);
|
||||
assert.strictEqual(ex[0], 0);
|
||||
assert.strictEqual(buf.remains(), 7);
|
||||
|
||||
ex = await reader.readCompletely(buf);
|
||||
|
||||
assert.strictEqual(ex.length, 7);
|
||||
assert.deepStrictEqual(ex, new Uint8Array([1, 2, 3, 4, 5, 6, 7]));
|
||||
assert.strictEqual(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.deepStrictEqual(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.deepStrictEqual(rr, Uint8Array.from([0]));
|
||||
|
||||
rr = await reader.readOne(r);
|
||||
|
||||
assert.deepStrictEqual(rr, Uint8Array.from([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.deepStrictEqual(rr, Uint8Array.from([0, 1, 2]));
|
||||
|
||||
rr = await reader.readN(r, 3);
|
||||
|
||||
assert.deepStrictEqual(rr, Uint8Array.from([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.strictEqual(limited.completed(), true);
|
||||
assert.deepStrictEqual(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.strictEqual(limited.completed(), true);
|
||||
assert.deepStrictEqual(result, Uint8Array.from(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.strictEqual(limited.completed(), false);
|
||||
assert.deepStrictEqual(result.data, expected1);
|
||||
assert.deepStrictEqual(result.found, true);
|
||||
|
||||
result = await reader.readUntil(limited, 7);
|
||||
|
||||
assert.strictEqual(limited.completed(), true);
|
||||
assert.deepStrictEqual(result.data, expected2);
|
||||
assert.deepStrictEqual(result.found, false);
|
||||
});
|
||||
});
|
||||
225
stream/sender.js
Normal file
225
stream/sender.js
Normal file
@ -0,0 +1,225 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 {integer} maxSegSize The size of max data segment
|
||||
* @param {integer} bufferFlushDelay Buffer flush delay
|
||||
* @param {integer} maxBufferedRequests Buffer flush delay
|
||||
*
|
||||
*/
|
||||
constructor(sender, maxSegSize, bufferFlushDelay, maxBufferedRequests) {
|
||||
this.sender = sender;
|
||||
this.maxSegSize = maxSegSize;
|
||||
this.subscribe = new subscribe.Subscribe();
|
||||
this.sendingPoc = this.sending();
|
||||
this.sendDelay = null;
|
||||
this.bufferFlushDelay = bufferFlushDelay;
|
||||
this.maxBufferedRequests = maxBufferedRequests;
|
||||
this.buffer = new Uint8Array(maxSegSize);
|
||||
this.bufferUsed = 0;
|
||||
this.bufferReq = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the send delay of current sender
|
||||
*
|
||||
* @param {integer} newDelay the new delay
|
||||
*
|
||||
*/
|
||||
setDelay(newDelay) {
|
||||
this.bufferFlushDelay = newDelay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends data to the this.sender
|
||||
*
|
||||
* @param {Uint8Array} data to send
|
||||
* @param {Array<function>} callbacks to call to return send result
|
||||
*
|
||||
*/
|
||||
async sendData(data, callbacks) {
|
||||
try {
|
||||
await this.sender(data);
|
||||
|
||||
for (let i in callbacks) {
|
||||
callbacks[i].resolve();
|
||||
}
|
||||
} catch (e) {
|
||||
for (let i in callbacks) {
|
||||
callbacks[i].reject(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append data to the end of internal buffer
|
||||
*
|
||||
* @param {Uint8Array} data data to add
|
||||
*
|
||||
* @returns {integer} How many bytes of data is added
|
||||
*
|
||||
*/
|
||||
appendBuffer(data) {
|
||||
const remainSize = this.buffer.length - this.bufferUsed,
|
||||
appendLength = data.length > remainSize ? remainSize : data.length;
|
||||
|
||||
this.buffer.set(data.slice(0, appendLength), this.bufferUsed);
|
||||
this.bufferUsed += appendLength;
|
||||
|
||||
return appendLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export current buffer and reset it to empty
|
||||
*
|
||||
* @returns {Uint8Array} Exported buffer
|
||||
*
|
||||
*/
|
||||
exportBuffer() {
|
||||
const buffer = this.buffer.slice(0, this.bufferUsed);
|
||||
|
||||
this.bufferUsed = 0;
|
||||
this.bufferedRequests = 0;
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sender proc
|
||||
*
|
||||
*/
|
||||
async sending() {
|
||||
let callbacks = [];
|
||||
|
||||
for (;;) {
|
||||
const fetched = await this.subscribe.subscribe();
|
||||
|
||||
// Force flush?
|
||||
if (fetched === true) {
|
||||
if (this.bufferUsed <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.sendData(this.exportBuffer(), callbacks);
|
||||
callbacks = [];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
callbacks.push({
|
||||
resolve: fetched.resolve,
|
||||
reject: fetched.reject,
|
||||
});
|
||||
|
||||
// Add data to buffer and maybe flush when the buffer is full
|
||||
let currentSendDataLen = 0;
|
||||
|
||||
while (fetched.data.length > currentSendDataLen) {
|
||||
const sentLen = this.appendBuffer(
|
||||
fetched.data.slice(currentSendDataLen, fetched.data.length),
|
||||
);
|
||||
|
||||
// Buffer not full, wait for the force flush
|
||||
if (this.buffer.length > this.bufferUsed) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentSendDataLen += sentLen;
|
||||
|
||||
await this.sendData(this.exportBuffer(), callbacks);
|
||||
callbacks = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear everything
|
||||
*
|
||||
*/
|
||||
close() {
|
||||
if (this.sendDelay !== null) {
|
||||
clearTimeout(this.sendDelay);
|
||||
this.sendDelay = null;
|
||||
}
|
||||
|
||||
this.buffered = null;
|
||||
this.bufferUsed = 0;
|
||||
this.bufferedRequests = 0;
|
||||
|
||||
this.subscribe.reject(new Exception("Sender has been cleared", false));
|
||||
this.subscribe.disable();
|
||||
|
||||
this.sendingPoc.catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 delayCleared = false;
|
||||
|
||||
if (this.sendDelay !== null) {
|
||||
clearTimeout(this.sendDelay);
|
||||
this.sendDelay = null;
|
||||
delayCleared = true;
|
||||
}
|
||||
|
||||
const self = this;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
self.subscribe.resolve({
|
||||
data: data,
|
||||
resolve: resolve,
|
||||
reject: reject,
|
||||
});
|
||||
|
||||
if (self.bufferedRequests >= self.maxBufferedRequests) {
|
||||
self.bufferedRequests = 0;
|
||||
|
||||
self.subscribe.resolve(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (delayCleared) {
|
||||
self.bufferedRequests++;
|
||||
}
|
||||
|
||||
self.sendDelay = setTimeout(() => {
|
||||
self.sendDelay = null;
|
||||
self.bufferedRequests = 0;
|
||||
|
||||
self.subscribe.resolve(true);
|
||||
}, self.bufferFlushDelay);
|
||||
});
|
||||
}
|
||||
}
|
||||
121
stream/sender_test.js
Normal file
121
stream/sender_test.js
Normal file
@ -0,0 +1,121 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 sender from "./sender.js";
|
||||
|
||||
describe("Sender", () => {
|
||||
function generateTestData(size) {
|
||||
let d = new Uint8Array(size);
|
||||
|
||||
for (let i = 0; i < d.length; i++) {
|
||||
d[i] = i % 256;
|
||||
}
|
||||
|
||||
return d;
|
||||
}
|
||||
|
||||
it("Send", async () => {
|
||||
const maxSegSize = 64;
|
||||
let result = [];
|
||||
let sd = new sender.Sender(
|
||||
(rawData) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
for (let i in rawData) {
|
||||
result.push(rawData[i]);
|
||||
}
|
||||
|
||||
resolve();
|
||||
}, 5);
|
||||
});
|
||||
},
|
||||
maxSegSize,
|
||||
300,
|
||||
3,
|
||||
);
|
||||
let expected = generateTestData(maxSegSize * 16);
|
||||
|
||||
sd.send(expected);
|
||||
|
||||
let sendCompleted = new Promise((resolve) => {
|
||||
let timer = setInterval(() => {
|
||||
if (result.length < expected.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
await sendCompleted;
|
||||
|
||||
assert.deepStrictEqual(new Uint8Array(result), expected);
|
||||
});
|
||||
|
||||
it("Send (Multiple calls)", async () => {
|
||||
const maxSegSize = 64;
|
||||
let result = [];
|
||||
let sd = new sender.Sender(
|
||||
(rawData) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
for (let i in rawData) {
|
||||
result.push(rawData[i]);
|
||||
}
|
||||
|
||||
resolve();
|
||||
}, 10);
|
||||
});
|
||||
},
|
||||
maxSegSize,
|
||||
300,
|
||||
100,
|
||||
);
|
||||
let expectedSingle = generateTestData(maxSegSize * 2),
|
||||
expectedLen = expectedSingle.length * 16,
|
||||
expected = new Uint8Array(expectedLen);
|
||||
|
||||
for (let i = 0; i < expectedLen; i += expectedSingle.length) {
|
||||
expected.set(expectedSingle, i);
|
||||
}
|
||||
|
||||
for (let i = 0; i < expectedLen; i += expectedSingle.length) {
|
||||
setTimeout(() => {
|
||||
sd.send(expectedSingle);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
let sendCompleted = new Promise((resolve) => {
|
||||
let timer = setInterval(() => {
|
||||
if (result.length < expectedLen) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
await sendCompleted;
|
||||
|
||||
assert.deepStrictEqual(new Uint8Array(result), expected);
|
||||
});
|
||||
});
|
||||
363
stream/stream.js
Normal file
363
stream/stream.js
Normal file
@ -0,0 +1,363 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 common from "./common.js";
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends data to remote, if the data is too long, it will be separated into
|
||||
* different stream requests
|
||||
*
|
||||
* @param {number} marker binary marker
|
||||
* @param {Uint8Array} data data to be sent
|
||||
*
|
||||
* @throws {Exception} When the sender already been closed
|
||||
*
|
||||
*/
|
||||
async sendData(marker, data) {
|
||||
if (this.closed) {
|
||||
throw new Exception(
|
||||
"Sender already been closed. No data can be send",
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
let dataSeg = common.separateBuffer(data, header.STREAM_MAX_LENGTH),
|
||||
reqHeader = new header.Header(header.STREAM);
|
||||
|
||||
reqHeader.set(this.id);
|
||||
|
||||
for (let i in dataSeg) {
|
||||
let stHeader = new header.Stream(0, 0),
|
||||
d = new Uint8Array(dataSeg[i].length + 3);
|
||||
|
||||
stHeader.set(marker, dataSeg[i].length);
|
||||
|
||||
d[0] = reqHeader.value();
|
||||
d.set(stHeader.buffer(), 1);
|
||||
d.set(dataSeg[i], 3);
|
||||
|
||||
await 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
stream/streams.js
Normal file
436
stream/streams.js
Normal file
@ -0,0 +1,436 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 common from "./common.js";
|
||||
import Exception from "./exception.js";
|
||||
import * as header from "./header.js";
|
||||
import * as reader from "./reader.js";
|
||||
import * as sender from "./sender.js";
|
||||
import * as stream from "./stream.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.close();
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
stream/streams_test.js
Normal file
20
stream/streams_test.js
Normal file
@ -0,0 +1,20 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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/>.
|
||||
|
||||
describe("Streams", () => {
|
||||
it("Header", () => {});
|
||||
});
|
||||
129
stream/subscribe.js
Normal file
129
stream/subscribe.js
Normal file
@ -0,0 +1,129 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 = [];
|
||||
this.disabled = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.res(d);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.pending.push([typeResolve, d]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject the subscribe waiter
|
||||
*
|
||||
* @param {any} e Error message that will be send to the subscriber
|
||||
*
|
||||
*/
|
||||
reject(e) {
|
||||
if (this.rej !== null) {
|
||||
this.rej(e);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.pending.push([typeReject, 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);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.disabled) {
|
||||
throw new Exception(this.disabled, 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);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable current subscriber when all internal data is readed
|
||||
*
|
||||
* @param {string} reason Reason of the disable
|
||||
*
|
||||
*/
|
||||
disable(reason) {
|
||||
this.disabled = reason;
|
||||
}
|
||||
}
|
||||
@ -370,7 +370,7 @@ function startApp(rootEl) {
|
||||
break;
|
||||
|
||||
case 403:
|
||||
this.authErr = "Authentication échoué. Mauvais mot de passe?";
|
||||
this.authErr = "Authentication has failed. Wrong passphrase?";
|
||||
break;
|
||||
|
||||
default:
|
||||
@ -378,7 +378,7 @@ function startApp(rootEl) {
|
||||
"Unexpected backend query status: " + result.result;
|
||||
}
|
||||
} catch (e) {
|
||||
this.authErr = "Authentification impossible: " + e;
|
||||
this.authErr = "Unable to authenticate: " + e;
|
||||
}
|
||||
},
|
||||
updateTabTitleInfo(tabs, updated) {
|
||||
|
||||
@ -68,20 +68,21 @@
|
||||
@updated="tabUpdated"
|
||||
>
|
||||
<div id="home-content-wrap">
|
||||
<h1>Bienvenue sur SSHWIFTY</h1>
|
||||
<h1>Hi, this is Sshwifty</h1>
|
||||
|
||||
<p>
|
||||
Un client web SSH open source permettant de se connecter à un serveur sans terminal.
|
||||
An Open Source Web SSH Client that enables you to connect to SSH
|
||||
servers without downloading any additional software.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Cliquer sur le
|
||||
To get started, click the
|
||||
<span
|
||||
id="home-content-connect"
|
||||
class="icon icon-plus1"
|
||||
@click="showConnectWindow"
|
||||
></span>
|
||||
en haut à gauche.
|
||||
icon near the top left corner.
|
||||
</p>
|
||||
|
||||
<div v-if="serverMessage.length > 0">
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Garage Web SSH Client</title>
|
||||
<title>Sshwifty Web SSH Client</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
@ -28,21 +28,23 @@
|
||||
<div id="landing-message">
|
||||
<div id="landing-message-logo"></div>
|
||||
|
||||
<h1 id="landing-message-title">Chargement de Sshwifty</h1>
|
||||
<h1 id="landing-message-title">Loading Sshwifty</h1>
|
||||
|
||||
<div id="landing-message-info">
|
||||
<p>
|
||||
Ca charge!
|
||||
Client is currently being loaded. Should only take a few seconds,
|
||||
please wait
|
||||
</p>
|
||||
<noscript>
|
||||
<p>
|
||||
Il faut activer Javascript pour que ça marche... :)
|
||||
Also, surely you smart people knows that application such like
|
||||
this one require JavaScript to run :)
|
||||
</p>
|
||||
</noscript>
|
||||
<!-- <p class="copy copy-first">
|
||||
<p class="copy copy-first">
|
||||
Copyright © 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
</p> -->
|
||||
<!-- <p class="copy">
|
||||
</p>
|
||||
<p class="copy">
|
||||
<a href="https://github.com/nirui/sshwifty" target="blank">
|
||||
Source code
|
||||
</a>
|
||||
@ -54,7 +56,7 @@
|
||||
<a href="/sshwifty/assets/README.md" target="blank"> Readme </a>
|
||||
|
||||
<a href="/sshwifty/assets/LICENSE.md" target="blank"> License </a>
|
||||
</p> -->
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
<div v-else id="app-loading-error">×</div>
|
||||
|
||||
<h1 v-if="error.length <= 0" id="app-loading-title">
|
||||
Chargement
|
||||
Preparing client application
|
||||
</h1>
|
||||
<h1 v-else id="app-loading-title" class="error">
|
||||
{{ error }}
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 1.1 KiB |
@ -25,7 +25,7 @@
|
||||
@display="$emit('display', $event)"
|
||||
>
|
||||
<div id="connect-frame">
|
||||
<h1 class="window-title">On se connecte à</h1>
|
||||
<h1 class="window-title">Establish connection with</h1>
|
||||
|
||||
<slot v-if="inputting"></slot>
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
:class="{ disabled: working || cancelled }"
|
||||
@click="cancel()"
|
||||
>
|
||||
Abandonner
|
||||
Cancel
|
||||
</a>
|
||||
|
||||
<div
|
||||
@ -809,7 +809,7 @@ export default {
|
||||
} catch (e) {
|
||||
this.current.submitting = false;
|
||||
|
||||
alert("Erreur de connection: " + e);
|
||||
alert("Submission has failed: " + e);
|
||||
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
|
||||
|
||||
@ -125,6 +125,24 @@ const termDefaultFontSize = 16;
|
||||
const termMinFontSize = 8;
|
||||
const termMaxFontSize = 36;
|
||||
|
||||
function webglSupported() {
|
||||
try {
|
||||
if (typeof window !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (typeof window.WebGLRenderingContext !== "function") {
|
||||
return false;
|
||||
}
|
||||
if (typeof window.WebGL2RenderingContext !== "function") {
|
||||
return false;
|
||||
}
|
||||
return document.createElement('canvas').getContext('webgl') &&
|
||||
document.createElement('canvas').getContext('webgl2');
|
||||
} catch(e) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
class Term {
|
||||
constructor(control) {
|
||||
const resizeDelayInterval = 500;
|
||||
@ -230,16 +248,11 @@ class Term {
|
||||
this.term.loadAddon(this.fit);
|
||||
this.term.loadAddon(new WebLinksAddon());
|
||||
this.term.loadAddon(new Unicode11Addon());
|
||||
if (() => {
|
||||
try {
|
||||
return !!window.WebGLRenderingContext &&
|
||||
document.createElement('canvas').getContext('webgl');
|
||||
} catch(e) {
|
||||
return false;
|
||||
try {
|
||||
if (webglSupported()) {
|
||||
this.term.loadAddon(new WebglAddon());
|
||||
}
|
||||
}) {
|
||||
this.term.loadAddon(new WebglAddon());
|
||||
}
|
||||
} catch(e) {}
|
||||
this.term.unicode.activeVersion = '11';
|
||||
this.refit();
|
||||
}
|
||||
|
||||
7
widgets/busy.svg
Normal file
7
widgets/busy.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="150" width="150">
|
||||
<path d="M29.49 50.27a.912.912 0 0 0-.13.012 1.125 1.141 0 0 0-.364.117l-23 12.035a1.097 1.112 0 0 0-.589 1.005v2.958a.314.319 0 0 0 0 .012 1.092 1.108 0 0 0 .577.97l20.897 11.708a.412.418 0 0 1 .212.372V91a1.118 1.118 0 0 0 1.111 1.11h1.311a1.105 1.12 0 0 0 1.086-1.11V78.163c0-.258.168-.416.424-.416h12.406c.255 0 .415.16.415.416v12.838a1.119 1.119 0 0 0 1.111 1.11h1.308a1.108 1.124 0 0 0 1.085-1.11V68c0-.175.038-.247.084-.298a.459.465 0 0 1 .248-.121.471.478 0 0 1 .283.012c.056.025.113.077.178.205.006.013.006.006.012.019a.314.319 0 0 0 0 .012l9.574 21.25c.195.431.613.667 1.015.66a1.105 1.12 0 0 0 .992-.694l6.031-15.204c.095-.24.203-.263.403-.263s.295.024.39.26l6.022 15.208c.17.451.581.708.99.717a1.116 1.132 0 0 0 1.039-.67l9.56-21.264c.073-.16.131-.201.191-.228a.437.443 0 0 1 .27-.013.459.465 0 0 1 .249.122c.047.049.081.122.083.298v22.988a.314.319 0 0 0 0 .012 1.105 1.12 0 0 0 1.11 1.1h1.312a1.094 1.11 0 0 0 1.095-1.1.314.319 0 0 0 0-.012V65.11a1.094 1.11 0 0 0-1.095-1.1h-4.512a1.092 1.108 0 0 0-1.005.633.314.319 0 0 0-.01 0c-2.652 5.835-5.294 11.682-7.945 17.515-.102.225-.207.257-.402.252-.194-.004-.286-.049-.377-.276l-5.95-14.867a1.1 1.116 0 0 0-1.016-.717 1.116 1.132 0 0 0-1.027.717L59.18 82.134c-.094.232-.186.271-.377.276-.197.005-.29-.024-.392-.252L50.47 64.643a1.108 1.124 0 0 0-1.004-.634h-4.51a1.103 1.119 0 0 0-1.11 1.1v8.622c0 .257-.169.443-.415.443H31.025c-.248 0-.424-.186-.424-.444v-8.62a1.097 1.112 0 0 0-1.086-1.1h-1.311a1.103 1.119 0 0 0-1.111 1.1v9.149a.752.752 0 0 1-.27.526c-.18.133-.205.138-.332.071a.314.319 0 0 0-.013 0 .314.319 0 0 0-.047-.024.314.319 0 0 0-.036-.012c-5.772-3.016-11.524-6.302-17.304-9.386a.434.44 0 0 1-.212-.265 1.43 1.45 0 0 1 .012-.441.959.972 0 0 1 .223-.324 1.09 1.106 0 0 1 .164-.12c.077-.024.157-.054.226-.095 6.808-3.626 13.682-7.116 20.504-10.694a1.103 1.119 0 0 0 .593-.99V51.39a1.105 1.12 0 0 0-1.111-1.125zm63.89 13.744a1.108 1.124 0 0 0-1.096 1.126v25.857a1.116 1.132 0 0 0 1.108 1.116h1.298a1.106 1.122 0 0 0 1.1-1.116V78.163c0-.255.172-.417.426-.417h10.823a1.103 1.119 0 0 0 1.099-1.126v-1.342a1.097 1.112 0 0 0-1.1-1.1H96.216c-.247 0-.426-.188-.426-.444V68c0-.255.172-.42.426-.42H115.6c.254 0 .414.163.414.42v22.997c.005.606.488 1.108 1.085 1.116a.314.319 0 0 0 .01 0h1.299a1.114 1.13 0 0 0 1.11-1.116V68c0-.259.161-.42.416-.42h7.14a.41.41 0 0 1 .355.192l7.615 12.249a.393.398 0 0 1 .07.215v10.76a1.111 1.127 0 0 0 1.087 1.116h1.307a1.111 1.127 0 0 0 1.088-1.115V80.236c0-.07.022-.137.07-.215l8.867-14.296c.454-.727-.072-1.708-.92-1.711h-1.501a1.1 1.116 0 0 0-.943.516l-6.967 11.205c-.1.161-.228.215-.354.215-.125 0-.251-.055-.352-.215L129.52 64.53a1.094 1.11 0 0 0-.921-.516c-11.732 0-23.473-.007-35.206 0zM3.411 70.706a.631.631 0 0 0-.107.012 1.094 1.11 0 0 0-1.005 1.1v1.294a1.12 1.136 0 0 0 .582.983l16.998 9.481.012.012a.314.319 0 0 0 .037.025.314.319 0 0 0 .047.037c.146.08.223.202.223.36v1.96c0 .16-.074.297-.212.375a.314.319 0 0 0-.071.049L2.869 96.307a1.1 1.116 0 0 0-.569.969v1.354a.314.319 0 0 0 0 .013c.007.841.939 1.367 1.655.943l18.699-10.98a1.099 1.114 0 0 0 .543-.972v-5.207a1.105 1.12 0 0 0-.567-.972C16.4 77.922 3.944 70.85 3.944 70.85a.314.319 0 0 0-.014 0 1.083 1.098 0 0 0-.518-.143z" fill="none" stroke="#e9a" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="20 30 10 40" stroke-dashoffset="-100">
|
||||
<animate restart="always" repeatCount="indefinite" dur="3s" to="100" from="-100" attributeName="stroke-dashoffset" begin="0s"/>
|
||||
<animate id="l1" dur="5s" to="40 10 50 0" from="20 30 10 40" attributeName="stroke-dasharray" begin="0s;l2.end" calcMode="paced"/>
|
||||
<animate id="l2" dur="5s" to="20 30 10 40" from="40 10 50 0" attributeName="stroke-dasharray" begin="l1.end" calcMode="paced"/>
|
||||
</path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
316
widgets/chart.vue
Normal file
316
widgets/chart.vue
Normal file
@ -0,0 +1,316 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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].data <= max) {
|
||||
continue;
|
||||
}
|
||||
|
||||
max = data[i].data;
|
||||
}
|
||||
|
||||
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].data);
|
||||
|
||||
this.createEl(parent, "path", {
|
||||
d:
|
||||
"M" +
|
||||
currentWidth +
|
||||
"," +
|
||||
Math.round(this.toBottomHeight(cellHalfHeight, h)) +
|
||||
" L" +
|
||||
currentWidth +
|
||||
"," +
|
||||
cellHalfHeight,
|
||||
class: h === 0 ? "zero" : data.data[i].class,
|
||||
});
|
||||
|
||||
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].data);
|
||||
|
||||
this.createEl(parent, "path", {
|
||||
d:
|
||||
"M" +
|
||||
currentWidth +
|
||||
"," +
|
||||
padHalfHeight +
|
||||
" L" +
|
||||
currentWidth +
|
||||
"," +
|
||||
(Math.round(h) + padHalfHeight),
|
||||
class: h === 0 ? "zero" : data.data[i].class,
|
||||
});
|
||||
|
||||
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>
|
||||
120
widgets/connect.css
Normal file
120
widgets/connect.css
Normal file
@ -0,0 +1,120 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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: 700px;
|
||||
}
|
||||
|
||||
#connect-frame {
|
||||
z-index: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#connect .window-frame {
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#connect:before {
|
||||
left: 30px;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
#connect {
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
#connect:before {
|
||||
left: 169px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#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: 20px;
|
||||
font-size: 0.85em;
|
||||
background: #b44;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#connect-warning-icon {
|
||||
float: left;
|
||||
display: block;
|
||||
margin: 5px 20px 5px 0;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
190
widgets/connect.vue
Normal file
190
widgets/connect.vue
Normal file
@ -0,0 +1,190 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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)"
|
||||
>
|
||||
<div id="connect-frame">
|
||||
<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"
|
||||
:presets="presets"
|
||||
:restricted-to-presets="restrictedToPresets"
|
||||
:knowns="knowns"
|
||||
:launcher-builder="knownsLauncherBuilder"
|
||||
:knowns-export="knownsExport"
|
||||
:knowns-import="knownsImport"
|
||||
@select="selectKnown"
|
||||
@select-preset="selectPreset"
|
||||
@remove="removeKnown"
|
||||
@clear-session="clearSessionKnown"
|
||||
></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 secrets.</strong>
|
||||
Always exam the safety 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/nirui/sshwifty" target="_blank"
|
||||
>Learn more</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="busy" id="connect-busy-overlay"></div>
|
||||
</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,
|
||||
},
|
||||
presets: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
restrictedToPresets: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
knowns: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
knownsLauncherBuilder: {
|
||||
type: Function,
|
||||
default: () => [],
|
||||
},
|
||||
knownsExport: {
|
||||
type: Function,
|
||||
default: () => [],
|
||||
},
|
||||
knownsImport: {
|
||||
type: Function,
|
||||
default: () => [],
|
||||
},
|
||||
connectors: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
busy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tab: !this.restrictedToPresets ? "new" : "known",
|
||||
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);
|
||||
},
|
||||
selectPreset(preset) {
|
||||
if (this.inputting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("preset-select", preset);
|
||||
},
|
||||
clearSessionKnown(uid) {
|
||||
if (this.inputting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("known-clear-session", uid);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
245
widgets/connect_known.css
Normal file
245
widgets/connect_known.css
Normal file
@ -0,0 +1,245 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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;
|
||||
background: #3a3a3a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#connect-known-list h3 {
|
||||
font-size: 1.1em;
|
||||
color: #999;
|
||||
margin: 5px 0 15px 0;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#connect-known-list.reloaded {
|
||||
}
|
||||
|
||||
#connect-known-list.reloaded::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.5s;
|
||||
animation-iteration-count: 1;
|
||||
box-shadow: 0 0 10px #fff;
|
||||
}
|
||||
|
||||
#connect-known-list-list,
|
||||
#connect-known-list-empty {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
#connect-known-list-empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 1.2em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#connect-known-list-import {
|
||||
margin: 15px;
|
||||
color: #aaa;
|
||||
font-size: 1.1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#connect-known-list-import a {
|
||||
color: #e9a;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#connect-known-list-list {
|
||||
padding: 15px 20px 20px 20px;
|
||||
}
|
||||
|
||||
#connect-known-list-list li {
|
||||
width: 50%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#connect-known-list-list li {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
#connect-known-list-list li > .lst-wrap {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#connect-known-list-list li > .lst-wrap:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
#connect-known-list-list li > .labels {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85em;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
#connect-known-list-list li > .labels > .type {
|
||||
display: inline-block;
|
||||
padding: 3px;
|
||||
background: #a56;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#connect-known-list-list li > .labels > .opt {
|
||||
display: none;
|
||||
padding: 3px;
|
||||
background: #a56;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#connect-known-list-list li > .labels > .opt {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
#connect-known-list-list li > .labels > .opt.link {
|
||||
background: #287;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#connect-known-list-list li > .labels > .opt.del {
|
||||
background: #a56;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#connect-known-list-list li > .labels > .opt.clr {
|
||||
background: #b71;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#connect-known-list-list li:hover > .labels > .opt,
|
||||
#connect-known-list-list li:focus > .labels > .opt {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#connect-known-list-list li > .lst-wrap > h4 {
|
||||
font-size: 1.5em;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#connect-known-list-list li > .lst-wrap > h4::before {
|
||||
content: ">_";
|
||||
color: #555;
|
||||
font-size: 0.8em;
|
||||
margin-right: 5px;
|
||||
font-weight: normal;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
#connect-known-list-list li > .lst-wrap > h4.highlight::before {
|
||||
color: #eee;
|
||||
background: #555;
|
||||
}
|
||||
|
||||
#connect-known-list-presets {
|
||||
margin-top: 10px;
|
||||
padding: 15px 20px 20px 20px;
|
||||
}
|
||||
|
||||
#connect-known-list-presets.last-planel {
|
||||
background: #3f3f3f;
|
||||
}
|
||||
|
||||
#connect-known-list-presets li {
|
||||
width: 50%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#connect-known-list-presets li.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#connect-known-list-presets li {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
#connect-known-list-presets li > .lst-wrap {
|
||||
cursor: pointer;
|
||||
border-radius: 0 3px 3px 3px;
|
||||
margin: 12px 10px 10px 0;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#connect-known-list-presets li > .lst-wrap > .labels {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85em;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
#connect-known-list-presets li > .lst-wrap > .labels > .type {
|
||||
display: inline-block;
|
||||
padding: 3px;
|
||||
background: #a56;
|
||||
color: #fff;
|
||||
border-radius: 3px 3px 3px 0;
|
||||
}
|
||||
|
||||
#connect-known-list-presets li > .lst-wrap > h4 {
|
||||
font-size: 1.3em;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#connect-known-list-presets-alert {
|
||||
font-size: 1.15em;
|
||||
color: #fff;
|
||||
background: #c73;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
352
widgets/connect_known.vue
Normal file
352
widgets/connect_known.vue
Normal file
@ -0,0 +1,352 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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" :class="{ reloaded: reloaded }">
|
||||
<div
|
||||
v-if="knownList.length <= 0 && presets <= 0"
|
||||
id="connect-known-list-empty"
|
||||
>
|
||||
No known remote available
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="knownList.length > 0" id="connect-known-list-list">
|
||||
<h3>Connected before</h3>
|
||||
|
||||
<ul class="hlst 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
|
||||
v-if="!known.data.session"
|
||||
class="opt del"
|
||||
href="javascript:;"
|
||||
@click="remove(known.data.uid)"
|
||||
>
|
||||
Remove
|
||||
</a>
|
||||
<a
|
||||
v-else
|
||||
class="opt clr"
|
||||
href="javascript:;"
|
||||
title="Clear session data"
|
||||
@click="clearSession(known.data.uid)"
|
||||
>
|
||||
Clear
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="lst-wrap" @click="select(known.data)">
|
||||
<h4
|
||||
:title="known.data.title"
|
||||
:class="{ highlight: known.data.session }"
|
||||
>
|
||||
{{ known.data.title }}
|
||||
</h4>
|
||||
Last: {{ known.data.last.toLocaleString() }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="presets.length > 0"
|
||||
id="connect-known-list-presets"
|
||||
:class="{
|
||||
'last-planel': knownList.length > 0,
|
||||
}"
|
||||
>
|
||||
<h3>Presets</h3>
|
||||
|
||||
<ul class="hlst lstcl2">
|
||||
<li
|
||||
v-for="(preset, pk) in presets"
|
||||
:key="pk"
|
||||
:class="{ disabled: presetDisabled(preset) }"
|
||||
>
|
||||
<div class="lst-wrap" @click="selectPreset(preset)">
|
||||
<div class="labels">
|
||||
<span
|
||||
class="type"
|
||||
:style="'background-color: ' + preset.command.color()"
|
||||
>
|
||||
{{ preset.command.name() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 :title="preset.preset.title()">
|
||||
{{ preset.preset.title() }}
|
||||
</h4>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="restrictedToPresets" id="connect-known-list-presets-alert">
|
||||
The operator has restricted the outgoing connections. You can only
|
||||
connect to remotes from the pre-defined presets.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="connect-known-list-import">
|
||||
Tip: You can
|
||||
<a href="javascript:;" @click="importHosts">import</a> and
|
||||
<a href="javascript:;" @click="exportHosts">export</a>
|
||||
known remotes from and to a file.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "./connect_known.css";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
presets: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
restrictedToPresets: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
knowns: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
launcherBuilder: {
|
||||
type: Function,
|
||||
default: () => [],
|
||||
},
|
||||
knownsExport: {
|
||||
type: Function,
|
||||
default: () => [],
|
||||
},
|
||||
knownsImport: {
|
||||
type: Function,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
knownList: [],
|
||||
reloaded: false,
|
||||
busy: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
knowns(newVal) {
|
||||
// Only play reload animation when we're adding data into the records,
|
||||
// not reducing
|
||||
const playReloaded = newVal.length > this.knownList.length;
|
||||
|
||||
this.reload(newVal);
|
||||
|
||||
if (!playReloaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const self = this;
|
||||
|
||||
self.reloaded = true;
|
||||
setTimeout(() => {
|
||||
self.reloaded = false;
|
||||
}, 500);
|
||||
},
|
||||
},
|
||||
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);
|
||||
},
|
||||
presetDisabled(preset) {
|
||||
if (!this.restrictedToPresets || preset.preset.host().length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
selectPreset(preset) {
|
||||
if (this.busy || this.presetDisabled(preset)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("select-preset", preset);
|
||||
},
|
||||
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);
|
||||
},
|
||||
clearSession(uid) {
|
||||
if (this.busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("clear-session", uid);
|
||||
},
|
||||
exportHosts() {
|
||||
let el = null;
|
||||
|
||||
try {
|
||||
const dataStr = JSON.stringify(this.knownsExport());
|
||||
|
||||
el = document.createElement("a");
|
||||
el.setAttribute(
|
||||
"href",
|
||||
"data:text/plain;charset=utf-8," + btoa(dataStr),
|
||||
);
|
||||
el.setAttribute("target", "_blank");
|
||||
el.setAttribute("download", "sshwifty.known-remotes.txt");
|
||||
el.setAttribute(
|
||||
"style",
|
||||
"overflow: hidden; opacity: 0; width: 1px; height: 1px; top: -1px;" +
|
||||
"left: -1px; position: absolute;",
|
||||
);
|
||||
|
||||
document.body.appendChild(el);
|
||||
|
||||
el.click();
|
||||
} catch (e) {
|
||||
alert("Unable to export known remotes: " + e);
|
||||
}
|
||||
|
||||
if (el === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.removeChild(el);
|
||||
},
|
||||
importHosts() {
|
||||
const self = this;
|
||||
|
||||
let el = null;
|
||||
|
||||
try {
|
||||
el = document.createElement("input");
|
||||
el.setAttribute("type", "file");
|
||||
el.setAttribute(
|
||||
"style",
|
||||
"overflow: hidden; opacity: 0; width: 1px; height: 1px; top: -1px;" +
|
||||
"left: -1px; position: absolute;",
|
||||
);
|
||||
el.addEventListener("change", (ev) => {
|
||||
const t = ev.target;
|
||||
|
||||
if (t.files.length <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
t.disabled = "disabled";
|
||||
|
||||
let r = new FileReader();
|
||||
|
||||
r.onload = () => {
|
||||
try {
|
||||
self.knownsImport(JSON.parse(atob(r.result)));
|
||||
} catch (e) {
|
||||
alert("Unable to import known remotes due to error: " + e);
|
||||
}
|
||||
};
|
||||
|
||||
r.readAsText(t.files[0], "utf-8");
|
||||
});
|
||||
|
||||
document.body.appendChild(el);
|
||||
|
||||
el.click();
|
||||
} catch (e) {
|
||||
alert("Unable to load known remotes data due to error: " + e);
|
||||
}
|
||||
|
||||
if (el === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.removeChild(el);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
58
widgets/connect_new.css
Normal file
58
widgets/connect_new.css
Normal file
@ -0,0 +1,58 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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
widgets/connect_new.vue
Normal file
53
widgets/connect_new.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 lst-nostyle">
|
||||
<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
widgets/connect_switch.css
Normal file
56
widgets/connect_switch.css
Normal file
@ -0,0 +1,56 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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;
|
||||
}
|
||||
52
widgets/connect_switch.vue
Normal file
52
widgets/connect_switch.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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' }" @click="switchTab('known')">
|
||||
Known remotes <span 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,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
switchTab(to) {
|
||||
this.$emit("switch", to);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
132
widgets/connecting.svg
Normal file
132
widgets/connecting.svg
Normal file
@ -0,0 +1,132 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="450" height="80">
|
||||
<path d="M104.9 44.333s22.069-11.953 49.294-11.953c27.23 0 49.298 11.953 49.298 11.953" fill="none" stroke="#d18b8d" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="100 150" stroke-dashoffset="100">
|
||||
<animate id="j" begin="0s;a.end+1s" attributeName="stroke-dashoffset" from="100" to="-1150" dur="2s" fill="freeze" calcMode="linear"/>
|
||||
</path>
|
||||
<path d="M203.49 32.393s-22.069 11.953-49.294 11.953c-27.226 0-49.294-11.953-49.294-11.953" fill="none" stroke="gray" stroke-width="3.166" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="100 150" stroke-dashoffset="100">
|
||||
<animate id="a" begin="b.end" attributeName="stroke-dashoffset" from="100" to="-1150" dur="2s" fill="freeze" calcMode="linear"/>
|
||||
</path>
|
||||
<path d="M267.6 37.257h64.101a7.51 7.51 0 1 0 0-15.02 7.51 7.51 0 0 0 0 15.02h31.753" fill="none" stroke="#d18b8d" stroke-width="3.166" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="100 150" stroke-dashoffset="100">
|
||||
<animate id="d" begin="c.end" attributeName="stroke-dashoffset" from="100" to="-1150" dur="2s" fill="freeze" calcMode="linear"/>
|
||||
</path>
|
||||
<path d="M366.36 37.288h-95.739" fill="none" stroke="gray" stroke-width="3.166" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="100 150" stroke-dashoffset="100">
|
||||
<animate id="k" begin="d.end+0.5s" attributeName="stroke-dashoffset" from="100" to="-1150" dur="2s" fill="freeze" calcMode="linear"/>
|
||||
</path>
|
||||
<g>
|
||||
<path d="M54.652 10.707c-5.462 0-9.914 4.452-9.914 9.914v21.844c0 5.462 4.452 9.914 9.914 9.914h32.762c5.462 0 9.914-4.452 9.914-9.914V20.62c0-5.462-4.452-9.914-9.914-9.914zm0 3.492h32.762c3.589 0 6.424 2.833 6.424 6.422v21.844c0 3.589-2.835 6.424-6.424 6.424H54.652c-3.588 0-6.423-2.835-6.423-6.424V20.62c0-3.589 2.835-6.422 6.423-6.422z" style="line-height:normal;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000;text-transform:none;text-orientation:mixed;white-space:normal;shape-padding:0;isolation:auto;mix-blend-mode:normal;solid-color:#000;solid-opacity:1" color="#000" font-weight="400" font-family="sans-serif" overflow="visible" fill="gray"/>
|
||||
<g>
|
||||
<path d="M62.564 20.131H94.53a6.402 6.402 0 0 1 6.416 6.416v21.601a6.402 6.402 0 0 1-6.416 6.416H62.564a6.402 6.402 0 0 1-6.416-6.416V26.547a6.402 6.402 0 0 1 6.416-6.416z" fill="#262626"/>
|
||||
<g>
|
||||
<path d="M59.058 16.727h30.067a7.35 7.35 0 0 1 7.365 7.365v19.702a7.35 7.35 0 0 1-7.365 7.366H59.058a7.35 7.35 0 0 1-7.366-7.366V24.092a7.35 7.35 0 0 1 7.366-7.365z" fill="#d18b8d"/>
|
||||
<g>
|
||||
<g>
|
||||
<path d="M91.056 45.224a3.17 3.17 0 0 1-3.17 3.17 3.17 3.17 0 0 1-3.17-3.17 3.17 3.17 0 0 1 3.17-3.17 3.17 3.17 0 0 1 3.17 3.17z" fill="#fff">
|
||||
<animate id="e" begin="3s;e.end+10s" attributeName="d" from="m 91.056202,45.223999 a 3.1702001,3.1702001 0 0 1 -3.1702,3.1702 3.1702001,3.1702001 0 0 1 -3.170201,-3.1702 3.1702001,3.1702001 0 0 1 3.170201,-3.1702 3.1702001,3.1702001 0 0 1 3.1702,3.1702 z" to="m 88.773133,45.223999 a 0.8871316,0.8871316 0 0 1 -0.887131,0.887132 0.8871316,0.8871316 0 0 1 -0.887132,-0.887132 0.8871316,0.8871316 0 0 1 0.887132,-0.887132 0.8871316,0.8871316 0 0 1 0.887131,0.887132 z" dur="0.1s" calcMode="linear"/>
|
||||
</path>
|
||||
<path d="M67.432 45.224a3.17 3.17 0 0 1-3.17 3.17 3.17 3.17 0 0 1-3.17-3.17 3.17 3.17 0 0 1 3.17-3.17 3.17 3.17 0 0 1 3.17 3.17z" fill="#fff">
|
||||
<animate begin="e.begin" end="r1fe1.end" attributeName="d" from="m 67.432201,45.223999 a 3.1702001,3.1702001 0 0 1 -3.1702,3.1702 3.1702001,3.1702001 0 0 1 -3.1702,-3.1702 3.1702001,3.1702001 0 0 1 3.1702,-3.1702 3.1702001,3.1702001 0 0 1 3.1702,3.1702 z" to="m 65.149133,45.223999 a 0.8871316,0.8871316 0 0 1 -0.887132,0.887132 0.8871316,0.8871316 0 0 1 -0.887132,-0.887132 0.8871316,0.8871316 0 0 1 0.887132,-0.887132 0.8871316,0.8871316 0 0 1 0.887132,0.887132 z" dur="0.1s" calcMode="linear"/>
|
||||
</path>
|
||||
<animateMotion id="f" begin="1s;f.end+2s" from="0.3,0.5" to="0,0.3" dur="5s" calcMode="discrete"/>
|
||||
</g>
|
||||
<path d="M72.898 44.274h5.485c.741 0 1.338.597 1.338 1.338v.004c0 .74-.597 1.337-1.338 1.337h-5.485a1.335 1.335 0 0 1-1.338-1.337v-.004c0-.741.597-1.338 1.338-1.338z" fill="#fff"/>
|
||||
<animateMotion begin="f.begin" end="r1ff1b.end" from="1.2,0.5" to="0,1.2" dur="5s" calcMode="discrete"/>
|
||||
</g>
|
||||
<path d="M65.781 21.2H85.82a3.902 3.902 0 0 1 3.91 3.91V36.11a3.902 3.902 0 0 1-3.91 3.91H65.78a3.902 3.902 0 0 1-3.91-3.91V25.11a3.902 3.902 0 0 1 3.91-3.91z" fill="#ed9499"/>
|
||||
<path d="M68.03 24.089h18.174a3.298 3.298 0 0 1 3.305 3.305v9.304a3.298 3.298 0 0 1-3.305 3.305H68.03a3.298 3.298 0 0 1-3.305-3.305v-9.304a3.298 3.298 0 0 1 3.305-3.305z" fill="#cc7f80"/>
|
||||
<g fill="#f5d0d0">
|
||||
<path d="M68.437 27.374h17.787a.73.73 0 1 1 0 1.46H68.437a.73.73 0 1 1 0-1.46zM68.437 31.213h17.787a.73.73 0 1 1 0 1.46H68.437a.73.73 0 1 1 0-1.46zM68.437 35.052h17.787a.73.73 0 1 1 0 1.46H68.437a.73.73 0 1 1 0-1.46z"/>
|
||||
</g>
|
||||
<animateMotion begin="f.begin" from="0,0" to="1,0" dur="0.2s" fill="freeze" calcMode="linear"/>
|
||||
<animateMotion begin="f.end" from="1,0" to="0,0" dur="0.2s" fill="freeze" calcMode="linear"/>
|
||||
</g>
|
||||
<animateMotion id="h" begin="g.begin;r1ba2.end" from="0,0" to="0,2" dur="1s" fill="freeze" calcMode="linear"/>
|
||||
<animateMotion begin="h.end" from="0,2" to="0,0" dur="1s" fill="freeze" calcMode="linear"/>
|
||||
</g>
|
||||
<animateMotion id="g" begin="0s;i.end" from="0,0" to="0,3" dur="1s" fill="freeze" calcMode="linear"/>
|
||||
<animateMotion id="i" begin="g.end" from="0,3" to="0,0" dur="1s" fill="freeze" calcMode="linear"/>
|
||||
</g>
|
||||
<path d="M55.93 74.551h31.663c2.193 0 3.958 1.06 3.958 2.375S89.786 79.3 87.593 79.3H55.931c-2.193 0-3.958-1.059-3.958-2.374 0-1.316 1.765-2.375 3.958-2.375z" fill="#262626">
|
||||
<animate begin="g.begin" end="r1bb1.end" attributeName="d" from="m 60.268629,74.551003 h 22.98674 c 1.591832,0 2.873342,1.059116 2.873342,2.3747 0,1.315583 -1.28151,2.3747 -2.873342,2.3747 h -22.98674 c -1.591831,0 -2.873341,-1.059117 -2.873341,-2.3747 0,-1.315584 1.28151,-2.3747 2.873341,-2.3747 z" to="m 55.930799,74.551003 h 31.6624 c 2.192621,0 3.9578,1.059116 3.9578,2.3747 0,1.315583 -1.765179,2.3747 -3.9578,2.3747 h -31.6624 c -2.192621,0 -3.957799,-1.059117 -3.957799,-2.3747 0,-1.315584 1.765178,-2.3747 3.957799,-2.3747 z" dur="1s" fill="freeze" calcMode="linear"/>
|
||||
<animate begin="i.begin" end="r1bb2.end" attributeName="d" from="m 55.930799,74.551003 h 31.6624 c 2.192621,0 3.9578,1.059116 3.9578,2.3747 0,1.315583 -1.765179,2.3747 -3.9578,2.3747 h -31.6624 c -2.192621,0 -3.957799,-1.059117 -3.957799,-2.3747 0,-1.315584 1.765178,-2.3747 3.957799,-2.3747 z" to="m 60.268629,74.551003 h 22.98674 c 1.591832,0 2.873342,1.059116 2.873342,2.3747 0,1.315583 -1.28151,2.3747 -2.873342,2.3747 h -22.98674 c -1.591831,0 -2.873341,-1.059117 -2.873341,-2.3747 0,-1.315584 1.28151,-2.3747 2.873341,-2.3747 z" dur="1s" fill="freeze" calcMode="linear"/>
|
||||
</path>
|
||||
<g>
|
||||
<path d="M228.714 3.548h18.122c8.323 0 15.024 6.7 15.024 15.024v29.64c0 8.323-6.7 15.024-15.024 15.024h-18.122c-8.323 0-15.024-6.7-15.024-15.024v-29.64c0-8.323 6.7-15.024 15.024-15.024z" fill="#262626"/>
|
||||
<g>
|
||||
<path style="line-height:normal;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000;text-transform:none;text-orientation:mixed;white-space:normal;shape-padding:0;isolation:auto;mix-blend-mode:normal;solid-color:#000;solid-opacity:1" d="M225.08 2.084c-7.337 0-13.293 5.955-13.293 13.293v30.227c0 7.337 5.956 13.294 13.293 13.294h19.756c7.337 0 13.293-5.957 13.293-13.294V15.377c0-7.338-5.956-13.293-13.293-13.293zm0 3.166h19.756a10.07 10.07 0 0 1 10.127 10.127v30.227a10.071 10.071 0 0 1-10.127 10.128H225.08a10.071 10.071 0 0 1-10.127-10.128V15.377A10.07 10.07 0 0 1 225.08 5.25z" color="#000" font-weight="400" font-family="sans-serif" overflow="visible" fill="gray"/>
|
||||
<g>
|
||||
<path d="M230.055 43.997h10.444a3.657 3.657 0 0 1 3.665 3.665 3.657 3.657 0 0 1-3.665 3.665h-10.444a3.657 3.657 0 0 1-3.665-3.665 3.657 3.657 0 0 1 3.665-3.665zM225.185 47.638a3.665 3.665 0 0 1-3.665 3.665 3.665 3.665 0 0 1-3.665-3.665 3.665 3.665 0 0 1 3.665-3.665 3.665 3.665 0 0 1 3.665 3.665zM225.961 8.06h10.069c4.499 0 8.121 3.622 8.121 8.121v8.89c0 4.499-3.622 8.12-8.121 8.12H225.96a8.104 8.104 0 0 1-8.121-8.12v-8.89c0-4.499 3.622-8.121 8.121-8.121z" fill="#d18b8d"/>
|
||||
<g>
|
||||
<path d="M224.068 12.572a2.263 2.263 0 0 1 2.268 2.268v3.38a2.263 2.263 0 0 1-2.268 2.268 2.263 2.263 0 0 1-2.268-2.268v-3.38a2.263 2.263 0 0 1 2.268-2.268z" fill="#f9f9f9">
|
||||
<animate begin="c.begin" attributeName="d" from="m 224.0678,12.572 c 1.25636,0 2.2678,1.011438 2.2678,2.2678 v 3.379999 c 0,1.256362 -1.01144,2.2678 -2.2678,2.2678 -1.25636,0 -2.2678,-1.011438 -2.2678,-2.2678 V 14.8398 c 0,-1.256362 1.01144,-2.2678 2.2678,-2.2678 z" to="m 224.0678,15.498051 c 1.25636,0 2.2678,1.011438 2.2678,2.2678 v 0.453948 c 0,1.256362 -1.01144,2.2678 -2.2678,2.2678 -1.25636,0 -2.2678,-1.011438 -2.2678,-2.2678 v -0.453948 c 0,-1.256362 1.01144,-2.2678 2.2678,-2.2678 z" dur="0.1s" fill="freeze" calcMode="linear"/>
|
||||
<animate begin="b.begin" attributeName="d" from="m 224.0678,15.498051 c 1.25636,0 2.2678,1.011438 2.2678,2.2678 v 0.453948 c 0,1.256362 -1.01144,2.2678 -2.2678,2.2678 -1.25636,0 -2.2678,-1.011438 -2.2678,-2.2678 v -0.453948 c 0,-1.256362 1.01144,-2.2678 2.2678,-2.2678 z" to="m 224.0678,12.572 c 1.25636,0 2.2678,1.011438 2.2678,2.2678 v 3.379999 c 0,1.256362 -1.01144,2.2678 -2.2678,2.2678 -1.25636,0 -2.2678,-1.011438 -2.2678,-2.2678 V 14.8398 c 0,-1.256362 1.01144,-2.2678 2.2678,-2.2678 z" dur="0.1s" fill="freeze" calcMode="linear"/>
|
||||
</path>
|
||||
<path d="M235.108 15.342a2.263 2.263 0 0 1 2.268 2.268v.61a2.263 2.263 0 0 1-2.268 2.267 2.263 2.263 0 0 1-2.268-2.268v-.61a2.263 2.263 0 0 1 2.268-2.267z" fill="#f9f9f9">
|
||||
<animate begin="c.begin" attributeName="d" from="m 235.1078,15.342 c 1.25636,0 2.2678,1.011439 2.2678,2.2678 v 0.6095 c 0,1.256361 -1.01144,2.2678 -2.2678,2.2678 -1.25636,0 -2.2678,-1.011439 -2.2678,-2.2678 v -0.6095 c 0,-1.256361 1.01144,-2.2678 2.2678,-2.2678 z" to="m 235.1078,12.819542 c 1.25636,0 2.2678,1.011439 2.2678,2.2678 V 18.2193 c 0,1.256361 -1.01144,2.2678 -2.2678,2.2678 -1.25636,0 -2.2678,-1.011439 -2.2678,-2.2678 v -3.131958 c 0,-1.256361 1.01144,-2.2678 2.2678,-2.2678 z" dur="0.1s" fill="freeze" calcMode="linear"/>
|
||||
<animate begin="b.begin" attributeName="d" from="m 235.1078,12.819542 c 1.25636,0 2.2678,1.011439 2.2678,2.2678 V 18.2193 c 0,1.256361 -1.01144,2.2678 -2.2678,2.2678 -1.25636,0 -2.2678,-1.011439 -2.2678,-2.2678 v -3.131958 c 0,-1.256361 1.01144,-2.2678 2.2678,-2.2678 z" to="m 235.1078,15.342 c 1.25636,0 2.2678,1.011439 2.2678,2.2678 v 0.6095 c 0,1.256361 -1.01144,2.2678 -2.2678,2.2678 -1.25636,0 -2.2678,-1.011439 -2.2678,-2.2678 v -0.6095 c 0,-1.256361 1.01144,-2.2678 2.2678,-2.2678 z" dur="0.1s" fill="freeze" calcMode="linear"/>
|
||||
</path>
|
||||
<animateMotion begin="c.begin" from="0,0" to="5,0" dur="0.2s" fill="freeze" calcMode="linear"/>
|
||||
<animateMotion begin="b.begin" from="5,0" to="0,0" dur="0.2s" fill="freeze" calcMode="linear"/>
|
||||
</g>
|
||||
<path d="M219.554 36.873h22.883c.95 0 1.714.763 1.714 1.712 0 .948-.764 1.712-1.714 1.712h-22.883c-.95 0-1.714-.764-1.714-1.712 0-.949.764-1.712 1.714-1.712z" fill="gray"/>
|
||||
<animateMotion id="c" begin="j.end" from="0,0" to="7,0" dur="0.2s" fill="freeze" calcMode="linear"/>
|
||||
<animateMotion id="b" begin="k.end" from="7,0" to="0,0" dur="0.2s" fill="freeze" calcMode="linear"/>
|
||||
</g>
|
||||
<animateMotion begin="l.begin" from="0,0" to="0,2" dur="1.5s" fill="freeze" calcMode="linear"/>
|
||||
<animateMotion begin="m.begin" from="0,2" to="0,0" dur="1.5s" fill="freeze" calcMode="linear"/>
|
||||
</g>
|
||||
<animateMotion id="l" begin="0;m.end" from="0,0" to="0,2" dur="1.7s" fill="freeze" calcMode="linear"/>
|
||||
<animateMotion id="m" begin="l.end" from="0,2" to="0,0" dur="1.7s" fill="freeze" calcMode="linear"/>
|
||||
</g>
|
||||
<path d="M222.158 74.551h27.704c2.193 0 3.958 1.06 3.958 2.375s-1.765 2.374-3.958 2.374h-27.704c-2.193 0-3.958-1.059-3.958-2.374 0-1.316 1.765-2.375 3.958-2.375z" fill="#262626">
|
||||
<animate begin="l.begin" end="r3ba1.end" attributeName="d" from="m 223.41342,74.551003 h 25.19316 c 1.99387,0 3.59905,1.059116 3.59905,2.3747 0,1.315583 -1.60518,2.3747 -3.59905,2.3747 h -25.19316 c -1.99387,0 -3.59905,-1.059117 -3.59905,-2.3747 0,-1.315584 1.60518,-2.3747 3.59905,-2.3747 z" to="m 222.1578,74.551003 h 27.7044 c 2.19262,0 3.9578,1.059116 3.9578,2.3747 0,1.315583 -1.76518,2.3747 -3.9578,2.3747 h -27.7044 c -2.19262,0 -3.9578,-1.059117 -3.9578,-2.3747 0,-1.315584 1.76518,-2.3747 3.9578,-2.3747 z" dur="1s" fill="freeze" calcMode="linear"/>
|
||||
<animate begin="m.begin" end="r3ba2.end" attributeName="d" from="m 222.1578,74.551003 h 27.7044 c 2.19262,0 3.9578,1.059116 3.9578,2.3747 0,1.315583 -1.76518,2.3747 -3.9578,2.3747 h -27.7044 c -2.19262,0 -3.9578,-1.059117 -3.9578,-2.3747 0,-1.315584 1.76518,-2.3747 3.9578,-2.3747 z" to="m 223.41342,74.551003 h 25.19316 c 1.99387,0 3.59905,1.059116 3.59905,2.3747 0,1.315583 -1.60518,2.3747 -3.59905,2.3747 h -25.19316 c -1.99387,0 -3.59905,-1.059117 -3.59905,-2.3747 0,-1.315584 1.60518,-2.3747 3.59905,-2.3747 z" dur="1s" fill="freeze" calcMode="linear"/>
|
||||
</path>
|
||||
<g>
|
||||
<g>
|
||||
<path d="M381.245 15.105h23.224a5.892 5.892 0 0 1 5.905 5.905v25.472a5.892 5.892 0 0 1-5.905 5.905h-23.224a5.892 5.892 0 0 1-5.905-5.905V21.01a5.892 5.892 0 0 1 5.905-5.905z" fill="#262626"/>
|
||||
<g>
|
||||
<path d="M377.783 14.155h23.968a3.854 3.854 0 0 1 3.863 3.863V44.02a3.854 3.854 0 0 1-3.863 3.863h-23.968a3.854 3.854 0 0 1-3.863-3.863V18.018a3.854 3.854 0 0 1 3.863-3.863z" fill="gray" stroke="gray" stroke-width="2.486"/>
|
||||
<g>
|
||||
<path d="M378.21 16.332h20.984a3.373 3.373 0 0 1 3.38 3.38v22.765a3.373 3.373 0 0 1-3.38 3.38H378.21a3.373 3.373 0 0 1-3.38-3.38V19.712a3.373 3.373 0 0 1 3.38-3.38z" fill="#262626"/>
|
||||
<g>
|
||||
<path d="M380.116 19.142h18.606a2.99 2.99 0 0 1 2.996 2.996v20.185a2.99 2.99 0 0 1-2.996 2.996h-18.606a2.99 2.99 0 0 1-2.996-2.996V22.138a2.99 2.99 0 0 1 2.996-2.996z" fill="#848484"/>
|
||||
<g>
|
||||
<g>
|
||||
<path d="M382.1 23.891h2.426c.61 0 1.1.49 1.1 1.1v1.318c0 .61-.49 1.1-1.1 1.1H382.1c-.61 0-1.1-.49-1.1-1.1v-1.318c0-.61.49-1.1 1.1-1.1z" fill="#fff">
|
||||
<animate id="o" begin="5s;n.end+3s" attributeName="d" from="m 382.1003,23.891001 h 2.4261 c 0.60957,0 1.1003,0.490734 1.1003,1.1003 v 1.3179 c 0,0.609566 -0.49073,1.1003 -1.1003,1.1003 h -2.4261 c -0.60957,0 -1.1003,-0.490734 -1.1003,-1.1003 v -1.3179 c 0,-0.609566 0.49073,-1.1003 1.1003,-1.1003 z" to="m 382.1003,23.891001 4.04047,0 c 0.60957,0 1.1003,0.490734 1.1003,1.1003 v 1.3179 c 0,0.609566 -0.49073,1.1003 -1.1003,1.1003 l -4.04047,0 c -0.60957,0 -1.1003,-0.490734 -1.1003,-1.1003 v -1.3179 c 0,-0.609566 0.49073,-1.1003 1.1003,-1.1003 z" dur="0.1s" fill="freeze" calcMode="linear"/>
|
||||
<animate id="n" begin="o.end+5s" attributeName="d" from="m 382.1003,23.891001 4.04047,0 c 0.60957,0 1.1003,0.490734 1.1003,1.1003 v 1.3179 c 0,0.609566 -0.49073,1.1003 -1.1003,1.1003 l -4.04047,0 c -0.60957,0 -1.1003,-0.490734 -1.1003,-1.1003 v -1.3179 c 0,-0.609566 0.49073,-1.1003 1.1003,-1.1003 z" to="m 382.1003,23.891001 h 2.4261 c 0.60957,0 1.1003,0.490734 1.1003,1.1003 v 1.3179 c 0,0.609566 -0.49073,1.1003 -1.1003,1.1003 h -2.4261 c -0.60957,0 -1.1003,-0.490734 -1.1003,-1.1003 v -1.3179 c 0,-0.609566 0.49073,-1.1003 1.1003,-1.1003 z" dur="0.1s" fill="freeze" calcMode="linear"/>
|
||||
</path>
|
||||
<path d="M389.42 23.891h6.111c.61 0 1.1.49 1.1 1.1v1.318c0 .61-.49 1.1-1.1 1.1h-6.11c-.61 0-1.101-.49-1.101-1.1v-1.318c0-.61.49-1.1 1.1-1.1z" fill="#fff">
|
||||
<animate begin="o.begin" attributeName="d" from="m 389.42031,23.891001 h 6.1108 c 0.60956,0 1.1003,0.490734 1.1003,1.1003 v 1.3179 c 0,0.609566 -0.49074,1.1003 -1.1003,1.1003 h -6.1108 c -0.60957,0 -1.1003,-0.490734 -1.1003,-1.1003 v -1.3179 c 0,-0.609566 0.49073,-1.1003 1.1003,-1.1003 z" to="m 391.43827,23.891001 h 4.09284 c 0.60956,0 1.1003,0.490734 1.1003,1.1003 v 1.3179 c 0,0.609566 -0.49074,1.1003 -1.1003,1.1003 h -4.09284 c -0.60957,0 -1.1003,-0.490734 -1.1003,-1.1003 v -1.3179 c 0,-0.609566 0.49073,-1.1003 1.1003,-1.1003 z" dur="0.1s" fill="freeze" calcMode="linear"/>
|
||||
<animate begin="n.begin" attributeName="d" from="m 391.43827,23.891001 h 4.09284 c 0.60956,0 1.1003,0.490734 1.1003,1.1003 v 1.3179 c 0,0.609566 -0.49074,1.1003 -1.1003,1.1003 h -4.09284 c -0.60957,0 -1.1003,-0.490734 -1.1003,-1.1003 v -1.3179 c 0,-0.609566 0.49073,-1.1003 1.1003,-1.1003 z" to="m 389.42031,23.891001 h 6.1108 c 0.60956,0 1.1003,0.490734 1.1003,1.1003 v 1.3179 c 0,0.609566 -0.49074,1.1003 -1.1003,1.1003 h -6.1108 c -0.60957,0 -1.1003,-0.490734 -1.1003,-1.1003 v -1.3179 c 0,-0.609566 0.49073,-1.1003 1.1003,-1.1003 z" dur="0.1s" fill="freeze" calcMode="linear"/>
|
||||
</path>
|
||||
<animateMotion id="p" begin="6s;p.end+5s" from="0,0" to="0,1" dur="0.1s" fill="freeze" calcMode="linear"/>
|
||||
<animateMotion begin="p.end+2s" from="0,1" to="0,0" dur="0.1s" fill="freeze" calcMode="linear"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M393.047 37.15a5.877 5.877 0 0 1-5.877 5.877 5.877 5.877 0 0 1-5.877-5.877 5.877 5.877 0 0 1 5.877-5.877 5.877 5.877 0 0 1 5.877 5.877z" fill="#262626"/>
|
||||
<path d="M390.68 37.11a3.51 3.51 0 0 1-3.51 3.51 3.51 3.51 0 0 1-3.51-3.51 3.51 3.51 0 0 1 3.51-3.51 3.51 3.51 0 0 1 3.51 3.51z" fill="#d18b8d"/>
|
||||
<path style="line-height:normal;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000;text-transform:none;text-orientation:mixed;white-space:normal;shape-padding:0;isolation:auto;mix-blend-mode:normal;solid-color:#000;solid-opacity:1" d="M386.46 30.879c-3.146 0-5.718 2.57-5.718 5.717s2.572 5.717 5.719 5.717c3.147 0 5.717-2.57 5.717-5.717 0-3.148-2.57-5.717-5.717-5.717zm0 1.697c2.23 0 4.019 1.79 4.019 4.02 0 2.23-1.788 4.02-4.018 4.02-2.23 0-4.02-1.79-4.02-4.02 0-2.23 1.79-4.02 4.02-4.02z" color="#000" font-weight="400" font-family="sans-serif" overflow="visible" fill="#aaa"/>
|
||||
<animateMotion begin="o.begin" from="0,0" to="1,0" dur="0.1s" fill="freeze" calcMode="linear"/>
|
||||
<animateMotion begin="n.begin" from="1,0" to="0,0" dur="0.1s" fill="freeze" calcMode="linear"/>
|
||||
</g>
|
||||
<animateMotion begin="o.begin" from="0,0" to="1,0" dur="0.1s" fill="freeze" calcMode="linear"/>
|
||||
<animateMotion begin="n.begin" from="1,0" to="0,0" dur="0.1s" fill="freeze" calcMode="linear"/>
|
||||
</g>
|
||||
<animateMotion begin="o.begin" from="0,0" to="-1,0" dur="0.1s" fill="freeze" calcMode="linear"/>
|
||||
<animateMotion begin="n.begin" from="-1,0" to="0,0" dur="0.1s" fill="freeze" calcMode="linear"/>
|
||||
</g>
|
||||
<animateMotion begin="o.begin" from="0,0" to="1.3,0" dur="0.1s" fill="freeze" calcMode="linear"/>
|
||||
<animateMotion begin="n.begin" from="1.3,0" to="0,0" dur="0.1s" fill="freeze" calcMode="linear"/>
|
||||
</g>
|
||||
<animateMotion id="r" begin="0s;q.end" from="0,0" to="0,1" dur="1.1s" fill="freeze" calcMode="linear"/>
|
||||
<animateMotion id="q" begin="r.end" from="0,1" to="0,0" dur="1.1s" fill="freeze" calcMode="linear"/>
|
||||
</g>
|
||||
<animateMotion begin="r.begin" from="0,0" to="0,2" dur="1.1s" fill="freeze" calcMode="linear"/>
|
||||
<animateMotion begin="q.begin" from="0,2" to="0,0" dur="1.1s" fill="freeze" calcMode="linear"/>
|
||||
</g>
|
||||
<path d="M380.468 74.551h19.79c2.192 0 3.957 1.06 3.957 2.375s-1.765 2.374-3.958 2.374h-19.79c-2.192 0-3.957-1.059-3.957-2.374 0-1.316 1.765-2.375 3.958-2.375z" fill="#262626">
|
||||
<animate begin="r.begin" end="r2ba1.end" attributeName="d" from="m 382.77407,74.551003 h 15.17688 c 1.68156,0 3.03531,1.059116 3.03531,2.3747 0,1.315583 -1.35375,2.3747 -3.03531,2.3747 h -15.17688 c -1.68156,0 -3.03531,-1.059117 -3.03531,-2.3747 0,-1.315584 1.35375,-2.3747 3.03531,-2.3747 z" to="m 380.46781,74.551003 h 19.7894 c 2.19262,0 3.9578,1.059116 3.9578,2.3747 0,1.315583 -1.76518,2.3747 -3.9578,2.3747 h -19.7894 c -2.19262,0 -3.9578,-1.059117 -3.9578,-2.3747 0,-1.315584 1.76518,-2.3747 3.9578,-2.3747 z" dur="1s" fill="freeze" calcMode="linear"/>
|
||||
<animate begin="q.begin" end="r2ba2.end" attributeName="d" from="m 380.46781,74.551003 h 19.7894 c 2.19262,0 3.9578,1.059116 3.9578,2.3747 0,1.315583 -1.76518,2.3747 -3.9578,2.3747 h -19.7894 c -2.19262,0 -3.9578,-1.059117 -3.9578,-2.3747 0,-1.315584 1.76518,-2.3747 3.9578,-2.3747 z" to="m 382.77407,74.551003 h 15.17688 c 1.68156,0 3.03531,1.059116 3.03531,2.3747 0,1.315583 -1.35375,2.3747 -3.03531,2.3747 h -15.17688 c -1.68156,0 -3.03531,-1.059117 -3.03531,-2.3747 0,-1.315584 1.35375,-2.3747 3.03531,-2.3747 z" dur="1s" fill="freeze" calcMode="linear"/>
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 22 KiB |
124
widgets/connector.css
Normal file
124
widgets/connector.css
Normal file
@ -0,0 +1,124 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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;
|
||||
}
|
||||
834
widgets/connector.vue
Normal file
834
widgets/connector.vue
Normal file
@ -0,0 +1,834 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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, highlight: field.highlighted }"
|
||||
>
|
||||
{{ 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"
|
||||
:tabindex="field.tabIndex"
|
||||
:disabled="field.field.readonly"
|
||||
@keydown="triggerSuggestions($event, key, field)"
|
||||
@focus="focus(key, field, true)"
|
||||
@blur="focus(key, field, false)"
|
||||
@input="changed(key, field, false)"
|
||||
@change="changed(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"
|
||||
:tabindex="field.tabIndex"
|
||||
:disabled="field.field.readonly"
|
||||
@focus="focus(key, field, true)"
|
||||
@blur="focus(key, field, false)"
|
||||
@input="changed(key, field, false)"
|
||||
@change="changed(key, field, true)"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-if="field.field.type === 'checkbox'"
|
||||
v-model="field.field.value"
|
||||
v-focus="field.autofocus"
|
||||
type="checkbox"
|
||||
autocomplete="off"
|
||||
:name="field.field.name"
|
||||
:autofocus="field.autofocus"
|
||||
:tabindex="field.tabIndex"
|
||||
:disabled="field.field.readonly"
|
||||
@focus="focus(key, field, true)"
|
||||
@blur="focus(key, field, false)"
|
||||
@input="changed(key, field, false)"
|
||||
@change="changed(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"
|
||||
:tabindex="field.tabIndex"
|
||||
:disabled="field.field.readonly"
|
||||
@keyup="expandTextarea($event)"
|
||||
@keydown="
|
||||
triggerSuggestions($event, key, field) || expandTextarea($event)
|
||||
"
|
||||
@focus="focus(key, field, true)"
|
||||
@blur="focus(key, field, false)"
|
||||
@input="changed(key, field, false)"
|
||||
@change="changed(key, field, true)"
|
||||
></textarea>
|
||||
|
||||
<input
|
||||
v-if="field.field.type === 'textfile'"
|
||||
v-focus="field.autofocus"
|
||||
type="file"
|
||||
autocomplete="off"
|
||||
:placeholder="field.field.example"
|
||||
:name="field.field.name + '-file'"
|
||||
:autofocus="field.autofocus"
|
||||
:tabindex="field.tabIndex"
|
||||
:disabled="field.field.readonly"
|
||||
@focus="focus(key, field, true)"
|
||||
@blur="focus(key, field, false)"
|
||||
@change="importFile($event.target, field)"
|
||||
/>
|
||||
<input
|
||||
v-if="field.field.type === 'textfile'"
|
||||
v-model="field.field.value"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:name="field.field.name"
|
||||
:placeholder="field.field.example"
|
||||
style="display: none"
|
||||
@input="changed(key, field, false)"
|
||||
@change="changed(key, field, true)"
|
||||
/>
|
||||
|
||||
<select
|
||||
v-if="field.field.type === 'select'"
|
||||
v-model="field.field.value"
|
||||
v-focus="field.autofocus"
|
||||
autocomplete="off"
|
||||
:name="field.field.name"
|
||||
:autofocus="field.autofocus"
|
||||
:value="field.field.value"
|
||||
:tabindex="field.tabIndex"
|
||||
:disabled="field.field.readonly"
|
||||
@focus="focus(key, field, true)"
|
||||
@blur="focus(key, field, false)"
|
||||
@input="changed(key, field, false)"
|
||||
@change="changed(key, field, true)"
|
||||
>
|
||||
<option
|
||||
v-for="(option, oKey) in field.field.example.split(',')"
|
||||
:key="oKey"
|
||||
:value="option"
|
||||
:selected="field.field.value === option"
|
||||
:aria-selected="field.field.value === option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<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"
|
||||
v-focus="field.autofocus && oKey === 0"
|
||||
type="radio"
|
||||
autocomplete="off"
|
||||
:name="field.field.name"
|
||||
:autofocus="field.autofocus && oKey === 0"
|
||||
:value="option"
|
||||
:checked="field.field.value === option"
|
||||
:aria-checked="field.field.value === option"
|
||||
:tabindex="field.nextSubTabIndex(oKey)"
|
||||
:disabled="field.field.readonly"
|
||||
@focus="focus(key, field, true)"
|
||||
@blur="focus(key, field, false)"
|
||||
@input="changed(key, field, false)"
|
||||
@change="changed(key, field, true)"
|
||||
/>
|
||||
{{ option }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
v-if="field.suggestion.suggestions.length > 0"
|
||||
class="input-suggestions lst-nostyle"
|
||||
@mouseenter="field.holdSuggestions(true)"
|
||||
@mouseleave="field.holdSuggestions(false)"
|
||||
>
|
||||
<li
|
||||
v-for="(suggestion, sKey) in field.suggestion.suggestions"
|
||||
:key="sKey"
|
||||
:class="{ current: sKey === field.suggestion.selected }"
|
||||
@click="clickInputSuggestion(key, field, sKey)"
|
||||
>
|
||||
<div class="sugt-title">
|
||||
{{ suggestion.title }}
|
||||
</div>
|
||||
<div class="sugt-value">
|
||||
{{ suggestion.value }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<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"
|
||||
v-focus="submitterTabIndex === 1"
|
||||
type="submit"
|
||||
:disabled="current.submitting || disabled"
|
||||
:tabindex="submitterTabIndex"
|
||||
:autofocus="submitterTabIndex === 1"
|
||||
@click="submitAndGetNext"
|
||||
>
|
||||
{{ current.actionText }}
|
||||
</button>
|
||||
<button
|
||||
v-if="current.cancellable"
|
||||
:disabled="current.submitting || disabled"
|
||||
:tabindex="submitterTabIndex + 1"
|
||||
class="secondary"
|
||||
@click="cancelAndGetNext"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div
|
||||
v-if="preloaderIDName.length > 0"
|
||||
style="
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: 10px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
"
|
||||
>
|
||||
<div :id="preloaderIDName">
|
||||
{{ current.title || connector.name }} wizard
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "./connector.css";
|
||||
import * as command from "../commands/commands.js";
|
||||
import * as fieldBuilder from "./connector_field_builder.js";
|
||||
|
||||
const preloaderIDPrefix = "connector-resource-preload-control-";
|
||||
const hightlightClearTimeout = 1000;
|
||||
|
||||
function buildEmptyCurrent() {
|
||||
return {
|
||||
data: null,
|
||||
alert: false,
|
||||
clearHightlightTimeout: null,
|
||||
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(),
|
||||
preloaderIDName: "",
|
||||
fieldValueBackup: [],
|
||||
submitterTabIndex: 1,
|
||||
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.working) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cancelled = true;
|
||||
|
||||
this.sendCancel();
|
||||
},
|
||||
buildCurrent(next) {
|
||||
try {
|
||||
this.current = buildEmptyCurrent();
|
||||
|
||||
this.working = this.getConnector().wizard.started();
|
||||
|
||||
this.current.type = next.type();
|
||||
this.current.data = next.data();
|
||||
|
||||
let fields = null,
|
||||
tabIndex = 1;
|
||||
|
||||
switch (this.current.type) {
|
||||
case command.NEXT_PROMPT:
|
||||
fields = this.current.data.inputs();
|
||||
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const f = fieldBuilder.build(tabIndex, i, fields[i]);
|
||||
if (f.field.readonly) {
|
||||
this.verify(i, f, true);
|
||||
}
|
||||
this.current.fields.push(f);
|
||||
tabIndex = f.nextTabIndex();
|
||||
}
|
||||
|
||||
this.submitterTabIndex = tabIndex > 0 ? tabIndex : 1;
|
||||
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;
|
||||
} catch (e) {
|
||||
this.current.title = "Encountered an error";
|
||||
this.current.message = e;
|
||||
|
||||
this.working = false;
|
||||
this.disabled = true;
|
||||
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
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();
|
||||
|
||||
try {
|
||||
await waiter;
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
},
|
||||
runWizard() {
|
||||
if (this.currentConnectorCloseWait !== null) {
|
||||
throw new Error("Cannot run wizard multiple times");
|
||||
}
|
||||
|
||||
this.preloaderIDName =
|
||||
preloaderIDPrefix +
|
||||
this.getConnector().wizard.control().ui().toLowerCase();
|
||||
|
||||
this.currentConnectorCloseWait = (async () => {
|
||||
while (!this.disabled) {
|
||||
this.clearFieldValueBackup();
|
||||
|
||||
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 = 0; i < this.current.fields.length; i++) {
|
||||
mod[this.current.fields[i].field.name] =
|
||||
this.current.fields[i].field.value;
|
||||
}
|
||||
|
||||
return mod;
|
||||
},
|
||||
createFieldValueBackup() {
|
||||
let backup = [];
|
||||
|
||||
for (let i = 0; i < this.current.fields.length; i++) {
|
||||
backup.push(this.current.fields[i].field.value);
|
||||
}
|
||||
|
||||
this.fieldValueBackup = backup;
|
||||
},
|
||||
clearFieldValueBackup() {
|
||||
this.fieldValueBackup = [];
|
||||
},
|
||||
clearFieldHighlights() {
|
||||
for (let i = 0; i < this.current.fields.length; i++) {
|
||||
this.current.fields[i].highlighted = false;
|
||||
}
|
||||
},
|
||||
delayedClearFieldHighlights(timeout) {
|
||||
const self = this;
|
||||
|
||||
if (self.clearHightlightTimeout === null) {
|
||||
clearTimeout(self.clearHightlightTimeout);
|
||||
self.clearHightlightTimeout = null;
|
||||
}
|
||||
|
||||
self.clearHightlightTimeout = setTimeout(() => {
|
||||
self.clearHightlightTimeout = null;
|
||||
self.clearFieldHighlights();
|
||||
}, timeout);
|
||||
},
|
||||
restoreFieldValuesFromBackup(except) {
|
||||
for (let i = 0; i < this.fieldValueBackup.length; i++) {
|
||||
if (except === i) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.current.fields[i].field.value = this.fieldValueBackup[i];
|
||||
}
|
||||
},
|
||||
expandTextarea(event) {
|
||||
// WARNING: This function may cause rendering stutter due to
|
||||
// combined problem of CSS "Position" and Vue render.
|
||||
// Use of "TextArea" element is thus not recommended.
|
||||
event.target.style.height = "";
|
||||
event.target.style.height = event.target.scrollHeight + "px";
|
||||
},
|
||||
importFile(el, field) {
|
||||
if (el.files.length <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
el.disabled = "disabled";
|
||||
|
||||
let r = new FileReader();
|
||||
|
||||
r.onload = () => {
|
||||
let s = el.nextSibling;
|
||||
|
||||
for (;;) {
|
||||
if (s.tagName !== "INPUT") {
|
||||
s = s.nextSibling;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
field.field.value = r.result;
|
||||
s.dispatchEvent(new Event("change"));
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
el.disabled = "";
|
||||
};
|
||||
|
||||
r.readAsText(el.files[0], "utf-8");
|
||||
},
|
||||
verify(key, field, force) {
|
||||
try {
|
||||
field.message = "" + field.field.verify(field.field.value);
|
||||
field.modified = true;
|
||||
field.verified = true;
|
||||
field.error = "";
|
||||
} catch (e) {
|
||||
field.error = "";
|
||||
field.message = "";
|
||||
field.verified = false;
|
||||
|
||||
if (field.modified || force) {
|
||||
field.error = "" + e;
|
||||
}
|
||||
}
|
||||
|
||||
field.highlighted = false;
|
||||
|
||||
if (
|
||||
!field.verified &&
|
||||
(field.modified || force) &&
|
||||
field.error.length <= 0
|
||||
) {
|
||||
field.error = "Invalid";
|
||||
}
|
||||
|
||||
return field.verified;
|
||||
},
|
||||
verifyAll() {
|
||||
let verified = true;
|
||||
|
||||
for (let i = 0; i < this.current.fields.length; i++) {
|
||||
if (this.verify(i, this.current.fields[i], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
verified = false;
|
||||
}
|
||||
|
||||
return verified;
|
||||
},
|
||||
focus(key, field, focused) {
|
||||
field.highlighted = false;
|
||||
|
||||
if (!focused) {
|
||||
// Don't reset a holding field
|
||||
if (!field.inputted) {
|
||||
field.resetSuggestions(false);
|
||||
} else if (field.resetSuggestions(false)) {
|
||||
this.clickInputSuggestion(
|
||||
key,
|
||||
field,
|
||||
field.selectedSuggestionIndex(),
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.createFieldValueBackup();
|
||||
field.reloadSuggestions();
|
||||
},
|
||||
applySuggestion(key, field, suggestion) {
|
||||
this.restoreFieldValuesFromBackup(-1);
|
||||
|
||||
field.field.value = suggestion.value;
|
||||
|
||||
for (let i = 0; i < this.current.fields.length; i++) {
|
||||
this.current.fields[i].highlighted = false;
|
||||
|
||||
if (
|
||||
i === key ||
|
||||
this.current.fields[i].inputted ||
|
||||
this.current.fields[i].field.readonly
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof suggestion.fields[this.current.fields[i].field.name] ===
|
||||
"undefined"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.current.fields[i].field.value =
|
||||
suggestion.fields[this.current.fields[i].field.name];
|
||||
|
||||
if (!this.verify(i, this.current.fields[i], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.current.fields[i].highlighted = true;
|
||||
}
|
||||
},
|
||||
applySuggestionAndVerify(key, field, force, suggestion) {
|
||||
field.inputted = true;
|
||||
|
||||
this.applySuggestion(key, field, suggestion);
|
||||
|
||||
return this.verify(key, field, force);
|
||||
},
|
||||
changed(key, field, force) {
|
||||
this.createFieldValueBackup();
|
||||
|
||||
field.highlighted = false;
|
||||
field.inputted = true;
|
||||
field.enableInputSuggestionsOnAllInput();
|
||||
field.reloadSuggestions();
|
||||
|
||||
this.verify(key, field, force);
|
||||
},
|
||||
triggerSuggestions(event, key, field) {
|
||||
switch (event.key) {
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
field.moveSuggestionsCursor(true);
|
||||
|
||||
this.applySuggestionAndVerify(
|
||||
key,
|
||||
field,
|
||||
true,
|
||||
field.curentSuggestion(),
|
||||
);
|
||||
break;
|
||||
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
field.moveSuggestionsCursor(false);
|
||||
|
||||
this.applySuggestionAndVerify(
|
||||
key,
|
||||
field,
|
||||
true,
|
||||
field.curentSuggestion(),
|
||||
);
|
||||
break;
|
||||
|
||||
case "Escape":
|
||||
if (!field.suggestionsPending()) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
this.restoreFieldValuesFromBackup(key);
|
||||
this.clearFieldValueBackup();
|
||||
this.clearFieldHighlights();
|
||||
this.verify(key, field, true);
|
||||
|
||||
field.disableSuggestionsForInput(field.field.value);
|
||||
field.resetSuggestions(true);
|
||||
break;
|
||||
|
||||
case "Enter":
|
||||
if (!field.suggestionsPending()) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
this.clickInputSuggestion(
|
||||
key,
|
||||
field,
|
||||
field.selectedSuggestionIndex(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
},
|
||||
clickInputSuggestion(key, field, index) {
|
||||
const self = this;
|
||||
|
||||
field.selectSuggestion(index);
|
||||
|
||||
if (
|
||||
self.applySuggestionAndVerify(
|
||||
key,
|
||||
field,
|
||||
true,
|
||||
field.curentSuggestion(),
|
||||
)
|
||||
) {
|
||||
field.disableSuggestionsForInput(field.field.value);
|
||||
} else {
|
||||
field.enableInputSuggestionsOnAllInput();
|
||||
}
|
||||
|
||||
field.resetSuggestions(true);
|
||||
|
||||
self.clearFieldValueBackup();
|
||||
self.delayedClearFieldHighlights(hightlightClearTimeout);
|
||||
},
|
||||
async submitAndGetNext() {
|
||||
if (this.current.submitting || this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.current.data === null || !this.current.submittable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!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>
|
||||
222
widgets/connector_field_builder.js
Normal file
222
widgets/connector_field_builder.js
Normal file
@ -0,0 +1,222 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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/>.
|
||||
|
||||
function getTabIndex(tabIndex, field) {
|
||||
if (field.readonly) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case "text":
|
||||
case "password":
|
||||
case "checkbox":
|
||||
case "textarea":
|
||||
case "textfile":
|
||||
case "select":
|
||||
case "radio":
|
||||
return tabIndex;
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function build(tabIndex, i, field) {
|
||||
return {
|
||||
verified: false,
|
||||
modified: false,
|
||||
inputted: false,
|
||||
highlighted: false,
|
||||
error: "",
|
||||
message: "",
|
||||
field: field,
|
||||
autofocus: tabIndex === 1 && !field.readonly,
|
||||
tabIndex: getTabIndex(tabIndex, field),
|
||||
blockedSuggestionValue: "",
|
||||
blockingSuggestion: false,
|
||||
nextTabIndex() {
|
||||
let nextTabIndex = 0;
|
||||
|
||||
if (this.field.readonly) {
|
||||
nextTabIndex = this.tabIndex;
|
||||
} else {
|
||||
switch (this.field.type) {
|
||||
case "radio":
|
||||
nextTabIndex = this.tabIndex + this.field.example.split(",").length;
|
||||
break;
|
||||
|
||||
default:
|
||||
nextTabIndex = this.tabIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (tabIndex >= nextTabIndex) {
|
||||
return tabIndex;
|
||||
}
|
||||
|
||||
return nextTabIndex;
|
||||
},
|
||||
nextSubTabIndex(subIndex) {
|
||||
if (this.field.readonly) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.tabIndex + subIndex;
|
||||
},
|
||||
suggestion: {
|
||||
selected: -1,
|
||||
suggestions: [],
|
||||
orignalValue: "",
|
||||
orignalValueStored: false,
|
||||
holding: false,
|
||||
needsReset: false,
|
||||
reset() {
|
||||
this.selected = -1;
|
||||
this.suggestions = [];
|
||||
this.holding = false;
|
||||
this.needsReset = false;
|
||||
this.clearStored();
|
||||
|
||||
return true;
|
||||
},
|
||||
softReset() {
|
||||
if (this.holding) {
|
||||
this.needsReset = true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.reset();
|
||||
},
|
||||
hold(toHold) {
|
||||
this.holding = toHold;
|
||||
|
||||
if (this.holding || !this.needsReset) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reset();
|
||||
},
|
||||
storeOrignal(val) {
|
||||
if (this.orignalValueStored) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.orignalValue = val;
|
||||
this.orignalValueStored = true;
|
||||
},
|
||||
loadStored(defaultValue) {
|
||||
return this.orignalValueStored ? this.orignalValue : defaultValue;
|
||||
},
|
||||
clearStored() {
|
||||
this.orignalValue = "";
|
||||
this.orignalValueStored = false;
|
||||
},
|
||||
select(index, fieldValue) {
|
||||
if (this.selected < 0) {
|
||||
this.storeOrignal(fieldValue);
|
||||
}
|
||||
|
||||
if (index < -1 || index >= this.suggestions.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selected = index;
|
||||
},
|
||||
cursorUp(fieldValue) {
|
||||
this.select(this.selected - 1, fieldValue);
|
||||
},
|
||||
cursorDown(fieldValue) {
|
||||
this.select(this.selected + 1, fieldValue);
|
||||
},
|
||||
cursorMove(toUp, fieldValue) {
|
||||
toUp ? this.cursorUp(fieldValue) : this.cursorDown(fieldValue);
|
||||
},
|
||||
reload(fieldValue, suggestions) {
|
||||
this.selected = -1;
|
||||
this.suggestions = [];
|
||||
|
||||
this.clearStored();
|
||||
|
||||
if (suggestions.length === 1 && suggestions[0].value === fieldValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let v in suggestions) {
|
||||
this.suggestions.push({
|
||||
title: suggestions[v].title,
|
||||
value: suggestions[v].value,
|
||||
fields: suggestions[v].meta,
|
||||
});
|
||||
}
|
||||
},
|
||||
current(defaultValue) {
|
||||
if (this.selected < 0) {
|
||||
return {
|
||||
title: "Input",
|
||||
value: this.loadStored(defaultValue),
|
||||
fields: {},
|
||||
};
|
||||
}
|
||||
|
||||
return this.suggestions[this.selected];
|
||||
},
|
||||
},
|
||||
disableSuggestionsForInput(val) {
|
||||
this.blockedSuggestionValue = val;
|
||||
this.blockingSuggestion = true;
|
||||
},
|
||||
enableInputSuggestionsOnAllInput() {
|
||||
this.blockedSuggestionValue = "";
|
||||
this.blockingSuggestion = false;
|
||||
},
|
||||
suggestionsPending() {
|
||||
return this.suggestion.suggestions.length > 0;
|
||||
},
|
||||
reloadSuggestions() {
|
||||
if (
|
||||
this.blockingSuggestion &&
|
||||
this.field.value === this.blockedSuggestionValue
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.suggestion.reload(
|
||||
this.field.value,
|
||||
this.field.suggestions(this.field.value),
|
||||
);
|
||||
},
|
||||
resetSuggestions(force) {
|
||||
return force ? this.suggestion.reset() : this.suggestion.softReset();
|
||||
},
|
||||
holdSuggestions(toHold) {
|
||||
this.suggestion.hold(toHold);
|
||||
},
|
||||
moveSuggestionsCursor(toUp) {
|
||||
this.suggestion.cursorMove(toUp, this.field.value);
|
||||
},
|
||||
selectSuggestion(index) {
|
||||
this.suggestion.select(index, this.field.value);
|
||||
},
|
||||
curentSuggestion() {
|
||||
return this.suggestion.current(this.field.value);
|
||||
},
|
||||
selectedSuggestionIndex() {
|
||||
return this.suggestion.selected;
|
||||
},
|
||||
};
|
||||
}
|
||||
246
widgets/screen_console.css
Normal file
246
widgets/screen_console.css
Normal file
@ -0,0 +1,246 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 "~hack-font/build/web/hack.css";
|
||||
@import "~@azurity/pure-nerd-font/pure-nerd-font.css";
|
||||
|
||||
#connector-resource-preload-control-console {
|
||||
font-family: PureNerdFont, Hack;
|
||||
}
|
||||
#connector-resource-preload-control-console::after {
|
||||
content: " ";
|
||||
font-family: PureNerdFont, Hack;
|
||||
font-weight: bold;
|
||||
}
|
||||
#connector-resource-preload-control-console::before {
|
||||
content: " ";
|
||||
font-family: PureNerdFont, Hack;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#home-content > .screen > .screen-screen > .screen-console {
|
||||
position: relative;
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
#home-content > .screen > .screen-screen > .screen-console > .console-toolbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
background: #222;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 5px #0006;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#home-content
|
||||
> .screen
|
||||
> .screen-screen
|
||||
> .screen-console
|
||||
> .console-toolbar
|
||||
> .console-toolbar-group.console-toolbar-group-left {
|
||||
border-right: 1px solid #0001;
|
||||
margin-right: -1px;
|
||||
float: left;
|
||||
}
|
||||
|
||||
#home-content
|
||||
> .screen
|
||||
> .screen-screen
|
||||
> .screen-console
|
||||
> .console-toolbar
|
||||
> .console-toolbar-group.console-toolbar-group-main {
|
||||
border-left: 1px solid #0001;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#home-content
|
||||
> .screen
|
||||
> .screen-screen
|
||||
> .screen-console
|
||||
> .console-toolbar
|
||||
> .console-toolbar-group
|
||||
> .console-toolbar-item {
|
||||
padding: 15px;
|
||||
float: left;
|
||||
}
|
||||
|
||||
#home-content
|
||||
> .screen
|
||||
> .screen-screen
|
||||
> .screen-console
|
||||
> .console-toolbar
|
||||
> .console-toolbar-group
|
||||
> .console-toolbar-item
|
||||
.tb-title {
|
||||
font-size: 0.7em;
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 5px 10px;
|
||||
color: #fff9;
|
||||
text-shadow: 1px 1px 1px #0005;
|
||||
}
|
||||
|
||||
#home-content
|
||||
> .screen
|
||||
> .screen-screen
|
||||
> .screen-console
|
||||
> .console-toolbar
|
||||
> .console-toolbar-group
|
||||
> .console-toolbar-item
|
||||
.tb-item {
|
||||
display: block;
|
||||
font-size: 0.7em;
|
||||
padding: 10px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#home-content
|
||||
> .screen
|
||||
> .screen-screen
|
||||
> .screen-console
|
||||
> .console-toolbar
|
||||
> .console-toolbar-group
|
||||
> .console-toolbar-item
|
||||
.tb-item:active {
|
||||
background: #fff3;
|
||||
}
|
||||
|
||||
#home-content
|
||||
> .screen
|
||||
> .screen-screen
|
||||
> .screen-console
|
||||
> .console-toolbar
|
||||
> .console-toolbar-group
|
||||
> .console-toolbar-item
|
||||
.tb-item
|
||||
> .tb-key-icon {
|
||||
margin: 0 5px;
|
||||
background: #fff2;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#home-content
|
||||
> .screen
|
||||
> .screen-screen
|
||||
> .screen-console
|
||||
> .console-toolbar
|
||||
> .console-toolbar-group
|
||||
> .console-toolbar-item
|
||||
.tb-item
|
||||
> .tb-key-icon.tb-key-resize-icon {
|
||||
display: block;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#home-content
|
||||
> .screen
|
||||
> .screen-screen
|
||||
> .screen-console
|
||||
> .console-toolbar
|
||||
> .console-toolbar-group
|
||||
> .console-toolbar-item
|
||||
.tb-item
|
||||
> .tb-key-icon:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
#home-content
|
||||
> .screen
|
||||
> .screen-screen
|
||||
> .screen-console
|
||||
> .console-toolbar
|
||||
> .console-toolbar-group
|
||||
> .console-toolbar-item
|
||||
.tb-item
|
||||
> .tb-key-icon:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
#home-content
|
||||
> .screen
|
||||
> .screen-screen
|
||||
> .screen-console
|
||||
> .console-toolbar
|
||||
> .console-toolbar-group
|
||||
> .console-toolbar-item
|
||||
.tb-item:active
|
||||
.tb-key-icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#home-content > .screen > .screen-screen > .screen-console > .console-console {
|
||||
color: #fff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
#home-content
|
||||
> .screen
|
||||
> .screen-screen
|
||||
> .screen-console
|
||||
> .console-console
|
||||
> .console-loading {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#home-content
|
||||
> .screen
|
||||
> .screen-screen
|
||||
> .screen-console
|
||||
> .console-console
|
||||
> .console-loading
|
||||
> .console-loading-frame {
|
||||
text-align: center;
|
||||
font-size: 1em;
|
||||
font-weight: lighter;
|
||||
padding: 20px;
|
||||
margin: 10px auto;
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
#home-content
|
||||
> .screen
|
||||
> .screen-screen
|
||||
> .screen-console
|
||||
> .console-console
|
||||
> .console-loading
|
||||
> .console-loading-frame
|
||||
> .console-loading-icon {
|
||||
background: url(./busy.svg) 50% no-repeat;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
}
|
||||
591
widgets/screen_console.vue
Normal file
591
widgets/screen_console.vue
Normal file
@ -0,0 +1,591 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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">
|
||||
<div
|
||||
class="console-console"
|
||||
:style="'font-family: ' + typefaces + ', inherit'"
|
||||
>
|
||||
<h2 style="display: none">Console</h2>
|
||||
|
||||
<div class="console-loading">
|
||||
<div class="console-loading-frame">
|
||||
<div class="console-loading-icon"></div>
|
||||
<div class="console-loading-message">Initializing console ...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Tell you this: the background transparent below is probably the most
|
||||
important transparent setting in the entire application. Make sure user
|
||||
can see through it so they can operate the console while keep the toolbar
|
||||
open.
|
||||
-->
|
||||
<div
|
||||
v-if="toolbar"
|
||||
class="console-toolbar"
|
||||
:style="'background-color: ' + control.activeColor() + 'ee'"
|
||||
>
|
||||
<h2 style="display: none">Tool bar</h2>
|
||||
|
||||
<div class="console-toolbar-group console-toolbar-group-left">
|
||||
<div class="console-toolbar-item">
|
||||
<h3 class="tb-title">Text size</h3>
|
||||
|
||||
<ul class="lst-nostyle">
|
||||
<li>
|
||||
<a class="tb-item" href="javascript:;" @click="fontSizeUp">
|
||||
<span
|
||||
class="tb-key-icon tb-key-resize-icon icon icon-keyboardkey1 icon-iconed-bottom1"
|
||||
>
|
||||
<i>+</i>
|
||||
Increase
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="tb-item" href="javascript:;" @click="fontSizeDown">
|
||||
<span
|
||||
class="tb-key-icon tb-key-resize-icon icon icon-keyboardkey1 icon-iconed-bottom1"
|
||||
>
|
||||
<i>-</i>
|
||||
Decrease
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="console-toolbar-group console-toolbar-group-main">
|
||||
<div
|
||||
v-for="(keyType, keyTypeIdx) in screenKeys"
|
||||
:key="keyTypeIdx"
|
||||
class="console-toolbar-item"
|
||||
>
|
||||
<h3 class="tb-title">{{ keyType.title }}</h3>
|
||||
|
||||
<ul class="hlst lst-nostyle">
|
||||
<li v-for="(key, keyIdx) in keyType.keys" :key="keyIdx">
|
||||
<a
|
||||
class="tb-item"
|
||||
href="javascript:;"
|
||||
@click="sendSpecialKey(key[1])"
|
||||
v-html="$options.filters.specialKeyHTML(key[0])"
|
||||
></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FontFaceObserver from "fontfaceobserver";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11';
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { isNumber } from "../commands/common.js";
|
||||
import { consoleScreenKeys } from "./screen_console_keys.js";
|
||||
|
||||
import "./screen_console.css";
|
||||
import "xterm/css/xterm.css";
|
||||
|
||||
const termTypeFaces = "PureNerdFont, Hack";
|
||||
const termFallbackTypeFace = '"Cascadia Code" , monospace';
|
||||
const termTypeFaceLoadTimeout = 3000;
|
||||
const termTypeFaceLoadError =
|
||||
"Remote font " +
|
||||
termTypeFaces +
|
||||
" is unavailable, using " +
|
||||
termFallbackTypeFace +
|
||||
" instead until the remote font is loaded";
|
||||
const termDefaultFontSize = 16;
|
||||
const termMinFontSize = 8;
|
||||
const termMaxFontSize = 36;
|
||||
|
||||
function webglSupported() {
|
||||
try {
|
||||
if (typeof window !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (typeof window.WebGLRenderingContext !== "function") {
|
||||
return false;
|
||||
}
|
||||
if (typeof window.WebGL2RenderingContext !== "function") {
|
||||
return false;
|
||||
}
|
||||
return document.createElement('canvas').getContext('webgl') &&
|
||||
document.createElement('canvas').getContext('webgl2');
|
||||
} catch(e) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
class Term {
|
||||
constructor(control) {
|
||||
const resizeDelayInterval = 500;
|
||||
|
||||
this.control = control;
|
||||
this.closed = false;
|
||||
this.fontSize = termDefaultFontSize;
|
||||
this.term = new Terminal({
|
||||
allowProposedApi: true,
|
||||
allowTransparency: false,
|
||||
cursorBlink: true,
|
||||
cursorStyle: "block",
|
||||
fontFamily: termTypeFaces + ", " + termFallbackTypeFace,
|
||||
fontSize: this.fontSize,
|
||||
logLevel: process.env.NODE_ENV === "development" ? "info" : "off",
|
||||
theme: {
|
||||
background: this.control.activeColor(),
|
||||
},
|
||||
});
|
||||
this.fit = new FitAddon();
|
||||
|
||||
this.term.onData((data) => {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.control.send(data);
|
||||
});
|
||||
|
||||
this.term.onBinary((data) => {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.control.sendBinary(data);
|
||||
});
|
||||
|
||||
this.term.onKey((ev) => {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
if (!this.control.echo()) {
|
||||
return;
|
||||
}
|
||||
const printable =
|
||||
!ev.domEvent.altKey &&
|
||||
!ev.domEvent.altGraphKey &&
|
||||
!ev.domEvent.ctrlKey &&
|
||||
!ev.domEvent.metaKey;
|
||||
switch (ev.domEvent.key) {
|
||||
case "Enter":
|
||||
ev.domEvent.preventDefault();
|
||||
this.writeStr("\r\n");
|
||||
break;
|
||||
case "Backspace":
|
||||
ev.domEvent.preventDefault();
|
||||
this.writeStr("\b \b");
|
||||
break;
|
||||
default:
|
||||
if (printable) {
|
||||
ev.domEvent.preventDefault();
|
||||
this.writeStr(ev.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let resizeDelay = null,
|
||||
oldRows = 0,
|
||||
oldCols = 0;
|
||||
|
||||
this.term.onResize((dim) => {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
if (dim.cols === oldCols && dim.rows === oldRows) {
|
||||
return;
|
||||
}
|
||||
oldRows = dim.rows;
|
||||
oldCols = dim.cols;
|
||||
if (resizeDelay !== null) {
|
||||
clearTimeout(resizeDelay);
|
||||
resizeDelay = null;
|
||||
}
|
||||
resizeDelay = setTimeout(() => {
|
||||
resizeDelay = null;
|
||||
if (!isNumber(dim.cols) || !isNumber(dim.rows)) {
|
||||
return;
|
||||
}
|
||||
if (!dim.cols || !dim.rows) {
|
||||
return;
|
||||
}
|
||||
this.control.resize({
|
||||
rows: dim.rows,
|
||||
cols: dim.cols,
|
||||
});
|
||||
}, resizeDelayInterval);
|
||||
});
|
||||
}
|
||||
|
||||
init(root) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.term.open(root);
|
||||
this.term.loadAddon(this.fit);
|
||||
this.term.loadAddon(new WebLinksAddon());
|
||||
this.term.loadAddon(new Unicode11Addon());
|
||||
try {
|
||||
if (webglSupported()) {
|
||||
this.term.loadAddon(new WebglAddon());
|
||||
}
|
||||
} catch(e) {}
|
||||
this.term.unicode.activeVersion = '11';
|
||||
this.refit();
|
||||
}
|
||||
|
||||
dispatch(event) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.term.textarea.dispatchEvent(event);
|
||||
} catch (e) {
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
}
|
||||
|
||||
writeStr(d) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.term.write(d);
|
||||
} catch (e) {
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
}
|
||||
|
||||
setFont(value) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.term.options.fontFamily = value;
|
||||
this.refit();
|
||||
}
|
||||
|
||||
fontSizeUp() {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
if (this.fontSize >= termMaxFontSize) {
|
||||
return;
|
||||
}
|
||||
this.fontSize += 2;
|
||||
this.term.options.fontSize = this.fontSize;
|
||||
this.refit();
|
||||
}
|
||||
|
||||
fontSizeDown() {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
if (this.fontSize <= termMinFontSize) {
|
||||
return;
|
||||
}
|
||||
this.fontSize -= 2;
|
||||
this.term.options.fontSize = this.fontSize;
|
||||
this.refit();
|
||||
}
|
||||
|
||||
focus() {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.term.focus();
|
||||
this.refit();
|
||||
} catch (e) {
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
}
|
||||
|
||||
blur() {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.term.blur();
|
||||
} catch (e) {
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
}
|
||||
|
||||
refit() {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.fit.fit();
|
||||
} catch (e) {
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
}
|
||||
|
||||
destroyed() {
|
||||
return this.closed;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.closed = true;
|
||||
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 {
|
||||
filters: {
|
||||
specialKeyHTML(key) {
|
||||
const head = '<span class="tb-key-icon icon icon-keyboardkey1">',
|
||||
tail = "</span>";
|
||||
|
||||
return head + key.split("+").join(tail + "+" + head) + tail;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
control: {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
change: {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
toolbar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
viewPort: {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
screenKeys: consoleScreenKeys,
|
||||
term: new Term(this.control),
|
||||
typefaces: termTypeFaces,
|
||||
runner: null,
|
||||
eventHandlers: {
|
||||
keydown: null,
|
||||
keyup: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
active(newVal, oldVal) {
|
||||
this.triggerActive(newVal);
|
||||
},
|
||||
change: {
|
||||
handler() {
|
||||
if (!this.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fit();
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
viewPort: {
|
||||
handler() {
|
||||
if (!this.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fit();
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.init();
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.deinit();
|
||||
},
|
||||
methods: {
|
||||
loadRemoteFont(typefaces, timeout) {
|
||||
const tfs = typefaces.split(",");
|
||||
let observers = [];
|
||||
for (let v in tfs) {
|
||||
observers.push(new FontFaceObserver(tfs[v].trim()).load(null, timeout));
|
||||
observers.push(
|
||||
new FontFaceObserver(tfs[v].trim(), {
|
||||
weight: "bold",
|
||||
}).load(null, timeout),
|
||||
);
|
||||
}
|
||||
return Promise.all(observers);
|
||||
},
|
||||
async retryLoadRemoteFont(typefaces, timeout, onSuccess) {
|
||||
const self = this;
|
||||
for (;;) {
|
||||
try {
|
||||
onSuccess(await self.loadRemoteFont(typefaces, timeout));
|
||||
return;
|
||||
} catch (e) {
|
||||
// Retry
|
||||
}
|
||||
}
|
||||
},
|
||||
async openTerm(root, callbacks) {
|
||||
const self = this;
|
||||
try {
|
||||
await self.loadRemoteFont(termTypeFaces, termTypeFaceLoadTimeout);
|
||||
if (self.term.destroyed()) {
|
||||
return;
|
||||
}
|
||||
root.innerHTML = "";
|
||||
self.term.init(root);
|
||||
return;
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
if (self.term.destroyed()) {
|
||||
return;
|
||||
}
|
||||
root.innerHTML = "";
|
||||
callbacks.warn(termTypeFaceLoadError, false);
|
||||
self.term.setFont(termFallbackTypeFace);
|
||||
self.term.init(root);
|
||||
self.retryLoadRemoteFont(termTypeFaces, termTypeFaceLoadTimeout, () => {
|
||||
if (self.term.destroyed()) {
|
||||
return;
|
||||
}
|
||||
self.term.setFont(termTypeFaces);
|
||||
callbacks.warn(termTypeFaceLoadError, true);
|
||||
});
|
||||
},
|
||||
triggerActive(active) {
|
||||
active ? this.activate() : this.deactivate();
|
||||
},
|
||||
async init() {
|
||||
let self = this;
|
||||
|
||||
await self.openTerm(
|
||||
self.$el.getElementsByClassName("console-console")[0],
|
||||
{
|
||||
warn(msg, toDismiss) {
|
||||
self.$emit("warning", {
|
||||
text: msg,
|
||||
toDismiss: toDismiss,
|
||||
});
|
||||
},
|
||||
info(msg, toDismiss) {
|
||||
self.$emit("info", {
|
||||
text: msg,
|
||||
toDismiss: toDismiss,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (self.term.destroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.triggerActive(this.active);
|
||||
self.runRunner();
|
||||
},
|
||||
async deinit() {
|
||||
await this.closeRunner();
|
||||
await this.deactivate();
|
||||
this.term.destroy();
|
||||
},
|
||||
fit() {
|
||||
this.term.refit();
|
||||
},
|
||||
activate() {
|
||||
this.term.focus();
|
||||
this.fit();
|
||||
},
|
||||
async deactivate() {
|
||||
this.term.blur();
|
||||
},
|
||||
runRunner() {
|
||||
if (this.runner !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let self = this;
|
||||
|
||||
this.runner = (async () => {
|
||||
try {
|
||||
for (;;) {
|
||||
if (self.term.destroyed()) {
|
||||
break;
|
||||
}
|
||||
|
||||
self.term.writeStr(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;
|
||||
},
|
||||
sendSpecialKey(key) {
|
||||
if (!this.term) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.term.dispatch(new KeyboardEvent("keydown", key));
|
||||
this.term.dispatch(new KeyboardEvent("keyup", key));
|
||||
},
|
||||
fontSizeUp() {
|
||||
this.term.fontSizeUp();
|
||||
},
|
||||
fontSizeDown() {
|
||||
this.term.fontSizeDown();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
677
widgets/screen_console_keys.js
Normal file
677
widgets/screen_console_keys.js
Normal file
@ -0,0 +1,677 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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/>.
|
||||
|
||||
// Generated by:
|
||||
//
|
||||
// <!doctype html>
|
||||
// <html>
|
||||
// <meta charset="UTF-8" />
|
||||
// <title>KEYBOARDEVENT KEY DUMP</title>
|
||||
|
||||
// <input name="key" id="input" />
|
||||
|
||||
// <script>
|
||||
// var keyHistory = [];
|
||||
// var targetProps = [
|
||||
// "altKey",
|
||||
// "char",
|
||||
// "charCode",
|
||||
// "code",
|
||||
// "ctrlKey",
|
||||
// "key",
|
||||
// "keyCode",
|
||||
// "location",
|
||||
// "metaKey",
|
||||
// "repeat",
|
||||
// "shiftKey",
|
||||
// "which",
|
||||
// ]
|
||||
|
||||
// document.getElementById("input").addEventListener("keydown", function(e) {
|
||||
// e.preventDefault();
|
||||
|
||||
// var ev = {};
|
||||
|
||||
// for (var i in targetProps) {
|
||||
// ev[targetProps[i]] = e[targetProps[i]];
|
||||
// }
|
||||
|
||||
// keyHistory.push([e.key, ev])
|
||||
|
||||
// document.getElementById("result").innerHTML = JSON.stringify(keyHistory);
|
||||
// })
|
||||
|
||||
// </script>
|
||||
|
||||
// <div id="result">
|
||||
// </div>
|
||||
// </html>
|
||||
|
||||
export const consoleScreenKeys = [
|
||||
{
|
||||
title: "Function Keys",
|
||||
keys: [
|
||||
[
|
||||
"F1",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "F1",
|
||||
ctrlKey: false,
|
||||
key: "F1",
|
||||
keyCode: 112,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 112,
|
||||
},
|
||||
],
|
||||
[
|
||||
"F2",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "F2",
|
||||
ctrlKey: false,
|
||||
key: "F2",
|
||||
keyCode: 113,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 113,
|
||||
},
|
||||
],
|
||||
[
|
||||
"F3",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "F3",
|
||||
ctrlKey: false,
|
||||
key: "F3",
|
||||
keyCode: 114,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 114,
|
||||
},
|
||||
],
|
||||
[
|
||||
"F4",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "F4",
|
||||
ctrlKey: false,
|
||||
key: "F4",
|
||||
keyCode: 115,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 115,
|
||||
},
|
||||
],
|
||||
[
|
||||
"F5",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "F5",
|
||||
ctrlKey: false,
|
||||
key: "F5",
|
||||
keyCode: 116,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 116,
|
||||
},
|
||||
],
|
||||
[
|
||||
"F6",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "F6",
|
||||
ctrlKey: false,
|
||||
key: "F6",
|
||||
keyCode: 117,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 117,
|
||||
},
|
||||
],
|
||||
[
|
||||
"F7",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "F7",
|
||||
ctrlKey: false,
|
||||
key: "F7",
|
||||
keyCode: 118,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 118,
|
||||
},
|
||||
],
|
||||
[
|
||||
"F8",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "F8",
|
||||
ctrlKey: false,
|
||||
key: "F8",
|
||||
keyCode: 119,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 119,
|
||||
},
|
||||
],
|
||||
[
|
||||
"F9",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "F9",
|
||||
ctrlKey: false,
|
||||
key: "F9",
|
||||
keyCode: 120,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 120,
|
||||
},
|
||||
],
|
||||
[
|
||||
"F10",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "F10",
|
||||
ctrlKey: false,
|
||||
key: "F10",
|
||||
keyCode: 121,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 121,
|
||||
},
|
||||
],
|
||||
[
|
||||
"F11",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "F11",
|
||||
ctrlKey: false,
|
||||
key: "F11",
|
||||
keyCode: 122,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 122,
|
||||
},
|
||||
],
|
||||
[
|
||||
"F12",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "F12",
|
||||
ctrlKey: false,
|
||||
key: "F12",
|
||||
keyCode: 123,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 123,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Misc Keys",
|
||||
keys: [
|
||||
[
|
||||
"Escape",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "Escape",
|
||||
ctrlKey: false,
|
||||
key: "Escape",
|
||||
keyCode: 27,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 27,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Tab",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "Tab",
|
||||
ctrlKey: false,
|
||||
key: "Tab",
|
||||
keyCode: 9,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 9,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Insert",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "Insert",
|
||||
ctrlKey: false,
|
||||
key: "Insert",
|
||||
keyCode: 45,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 45,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Delete",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "Delete",
|
||||
ctrlKey: false,
|
||||
key: "Delete",
|
||||
keyCode: 46,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 46,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Navigation Keys",
|
||||
keys: [
|
||||
[
|
||||
"Home",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "Home",
|
||||
ctrlKey: false,
|
||||
key: "Home",
|
||||
keyCode: 36,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 36,
|
||||
},
|
||||
],
|
||||
[
|
||||
"End",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "End",
|
||||
ctrlKey: false,
|
||||
key: "End",
|
||||
keyCode: 35,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 35,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Up " + String.fromCharCode(8593),
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "ArrowUp",
|
||||
ctrlKey: false,
|
||||
key: "ArrowUp",
|
||||
keyCode: 38,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 38,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Down " + String.fromCharCode(8595),
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "ArrowDown",
|
||||
ctrlKey: false,
|
||||
key: "ArrowDown",
|
||||
keyCode: 40,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 40,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Left " + String.fromCharCode(8592),
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "ArrowLeft",
|
||||
ctrlKey: false,
|
||||
key: "ArrowLeft",
|
||||
keyCode: 37,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 37,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Right " + String.fromCharCode(8594),
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "ArrowRight",
|
||||
ctrlKey: false,
|
||||
key: "ArrowRight",
|
||||
keyCode: 39,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 39,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Page Up " + String.fromCharCode(9652),
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "PageUp",
|
||||
ctrlKey: false,
|
||||
key: "PageUp",
|
||||
keyCode: 33,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 33,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Page Down " + String.fromCharCode(9662),
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "PageDown",
|
||||
ctrlKey: false,
|
||||
key: "PageDown",
|
||||
keyCode: 34,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 34,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Control Keys",
|
||||
keys: [
|
||||
[
|
||||
"Ctrl+C",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "KeyC",
|
||||
ctrlKey: true,
|
||||
key: "c",
|
||||
keyCode: 67,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 67,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Ctrl+Z",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "KeyZ",
|
||||
ctrlKey: true,
|
||||
key: "z",
|
||||
keyCode: 90,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 90,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Ctrl+R",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "KeyR",
|
||||
ctrlKey: true,
|
||||
key: "r",
|
||||
keyCode: 82,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 82,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Ctrl+L",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "KeyL",
|
||||
ctrlKey: true,
|
||||
key: "l",
|
||||
keyCode: 76,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 76,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Ctrl+A",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "KeyA",
|
||||
ctrlKey: true,
|
||||
key: "a",
|
||||
keyCode: 65,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 65,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Ctrl+E",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "KeyE",
|
||||
ctrlKey: true,
|
||||
key: "e",
|
||||
keyCode: 69,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 69,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Ctrl+W",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "KeyW",
|
||||
ctrlKey: true,
|
||||
key: "w",
|
||||
keyCode: 87,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 87,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Ctrl+U",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "KeyU",
|
||||
ctrlKey: true,
|
||||
key: "u",
|
||||
keyCode: 85,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 85,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Ctrl+K",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "KeyK",
|
||||
ctrlKey: true,
|
||||
key: "k",
|
||||
keyCode: 75,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 75,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Ctrl+D",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "KeyD",
|
||||
ctrlKey: true,
|
||||
key: "d",
|
||||
keyCode: 68,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 68,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Ctrl+Q",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "KeyQ",
|
||||
ctrlKey: true,
|
||||
key: "q",
|
||||
keyCode: 81,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 81,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Ctrl+X",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "KeyX",
|
||||
ctrlKey: true,
|
||||
key: "x",
|
||||
keyCode: 88,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 88,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Ctrl+T",
|
||||
{
|
||||
altKey: false,
|
||||
charCode: 0,
|
||||
code: "KeyT",
|
||||
ctrlKey: true,
|
||||
key: "t",
|
||||
keyCode: 84,
|
||||
location: 0,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
which: 84,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
75
widgets/screens.css
Normal file
75
widgets/screens.css
Normal file
@ -0,0 +1,75 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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.active {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#home-content > .screen {
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
flex-direction: column;
|
||||
font-size: 1em;
|
||||
overflow: hidden;
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
#home-content > .screen.screen-inactive {
|
||||
flex: 0 0 0;
|
||||
}
|
||||
|
||||
#home-content > .screen > .screen-error {
|
||||
display: block;
|
||||
padding: 10px;
|
||||
background: #b44;
|
||||
color: #fff;
|
||||
font-size: 0.75em;
|
||||
flex: 0 0;
|
||||
}
|
||||
|
||||
#home-content > .screen > .screen-error.screen-error-level-error {
|
||||
background: #b44;
|
||||
}
|
||||
|
||||
#home-content > .screen > .screen-error.screen-error-level-warning {
|
||||
background: #b82;
|
||||
}
|
||||
|
||||
#home-content > .screen > .screen-error.screen-error-level-info {
|
||||
background: #28b;
|
||||
}
|
||||
|
||||
#home-content > .screen > .screen-screen {
|
||||
flex: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#home-content > .screen > .screen-screen > .screen-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
107
widgets/screens.vue
Normal file
107
widgets/screens.vue
Normal file
@ -0,0 +1,107 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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 :class="{ active: screens.length > 0 }">
|
||||
<slot v-if="screens.length <= 0"></slot>
|
||||
|
||||
<div
|
||||
v-for="(screenInfo, idx) in screens"
|
||||
:key="screenInfo.id"
|
||||
:class="{ 'screen-inactive': screen !== idx }"
|
||||
class="screen"
|
||||
>
|
||||
<h1 style="display: none">Main Interface</h1>
|
||||
|
||||
<div
|
||||
v-if="screenInfo.indicator.message.length > 0"
|
||||
class="screen-error"
|
||||
:class="'screen-error-level-' + screenInfo.indicator.level"
|
||||
>
|
||||
{{ screenInfo.indicator.message }}
|
||||
</div>
|
||||
|
||||
<div class="screen-screen">
|
||||
<component
|
||||
:is="getComponent(screenInfo.ui)"
|
||||
:active="screen === idx"
|
||||
:control="screenInfo.control"
|
||||
:change="screenInfo.indicator"
|
||||
:toolbar="screenInfo.toolbar"
|
||||
:view-port="viewPort"
|
||||
:style="'background-color: ' + screenInfo.control.activeColor()"
|
||||
class="screen-content"
|
||||
@stopped="stopped(idx, $event)"
|
||||
@warning="warning(idx, $event)"
|
||||
@info="info(idx, $event)"
|
||||
@updated="updated(idx)"
|
||||
></component>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ConsoleScreen from "./screen_console.vue";
|
||||
|
||||
import "./screens.css";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ConsoleScreen,
|
||||
},
|
||||
props: {
|
||||
screen: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
screens: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
viewPort: {
|
||||
type: Object,
|
||||
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);
|
||||
},
|
||||
warning(index, msg) {
|
||||
this.$emit("warning", index, msg);
|
||||
},
|
||||
info(index, msg) {
|
||||
this.$emit("info", index, msg);
|
||||
},
|
||||
updated(index) {
|
||||
this.$emit("updated", index);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
221
widgets/status.css
Normal file
221
widgets/status.css
Normal file
@ -0,0 +1,221 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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-chart > .chart .expired {
|
||||
stroke: #3a3a3a;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
251
widgets/status.vue
Normal file
251
widgets/status.vue
Normal file
@ -0,0 +1,251 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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) {
|
||||
if (n < 0) {
|
||||
return "??";
|
||||
}
|
||||
|
||||
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>
|
||||
116
widgets/tab_list.vue
Normal file
116
widgets/tab_list.vue
Normal file
@ -0,0 +1,116 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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.message.length > 0 &&
|
||||
tabInfo.indicator.level === 'error',
|
||||
updated: tabInfo.indicator.updated && tab !== idx,
|
||||
}"
|
||||
:style="
|
||||
'background: ' +
|
||||
(tab === idx ? tabInfo.control.activeColor() : tabInfo.control.color())
|
||||
"
|
||||
@click.self="switchTab(idx)"
|
||||
>
|
||||
<span class="title" :title="tabInfo.name" @click="switchTab(idx)">
|
||||
<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: -1,
|
||||
},
|
||||
tabs: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
tabsClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
tab(newVal) {
|
||||
this.switchTabTo(newVal);
|
||||
},
|
||||
tabs(newVal) {
|
||||
if (newVal.length > this.tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.switchTabTo(newVal.length - 1);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
switchTabTo(index) {
|
||||
if (index < 0 || index >= this.tabs.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tab == index) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("current", index);
|
||||
},
|
||||
switchTab(index) {
|
||||
if (index < 0 || index >= this.tabs.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tab === index) {
|
||||
this.$emit("retap", index);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return this.switchTabTo(index);
|
||||
},
|
||||
closeAt(index) {
|
||||
this.$emit("close", index);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
148
widgets/tab_window.css
Normal file
148
widgets/tab_window.css
Normal file
@ -0,0 +1,148 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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;
|
||||
}
|
||||
80
widgets/tab_window.vue
Normal file
80
widgets/tab_window.vue
Normal file
@ -0,0 +1,80 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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)"
|
||||
@retap="$emit('retap', $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: -1,
|
||||
},
|
||||
tabs: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
tabsClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
tabs(newV) {
|
||||
if (newV.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("display", false);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
77
widgets/tabs.vue
Normal file
77
widgets/tabs.vue
Normal file
@ -0,0 +1,77 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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)"
|
||||
@retap="$emit('retap', $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: -1,
|
||||
},
|
||||
tabs: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
tabsClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
listTriggerClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
showList() {
|
||||
this.$emit("list", this.tabs);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
20
widgets/window.css
Normal file
20
widgets/window.css
Normal file
@ -0,0 +1,20 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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
widgets/window.vue
Normal file
77
widgets/window.vue
Normal file
@ -0,0 +1,77 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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>
|
||||
54
xhr.js
Normal file
54
xhr.js
Normal file
@ -0,0 +1,54 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.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/>.
|
||||
|
||||
function send(method, url, headers) {
|
||||
return new Promise((res, rej) => {
|
||||
let authReq = new XMLHttpRequest();
|
||||
|
||||
authReq.addEventListener("readystatechange", () => {
|
||||
if (authReq.readyState !== authReq.DONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
res(authReq);
|
||||
});
|
||||
|
||||
authReq.addEventListener("error", (e) => {
|
||||
rej(e);
|
||||
});
|
||||
|
||||
authReq.addEventListener("timeout", (e) => {
|
||||
rej(e);
|
||||
});
|
||||
|
||||
authReq.open(method, url, true);
|
||||
|
||||
for (let h in headers) {
|
||||
authReq.setRequestHeader(h, headers[h]);
|
||||
}
|
||||
|
||||
authReq.send();
|
||||
});
|
||||
}
|
||||
|
||||
export function get(url, headers) {
|
||||
return send("GET", url, headers);
|
||||
}
|
||||
|
||||
export function options(url, headers) {
|
||||
return send("OPTIONS", url, headers);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user