convert wetty from submodule to normal directory
This commit is contained in:
5
wetty/src/client/dev.ts
Normal file
5
wetty/src/client/dev.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
caches.keys().then(cacheNames => {
|
||||
cacheNames.forEach(cacheName => {
|
||||
caches.delete(cacheName);
|
||||
});
|
||||
});
|
||||
72
wetty/src/client/wetty.ts
Normal file
72
wetty/src/client/wetty.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { dom, library } from '@fortawesome/fontawesome-svg-core';
|
||||
import { faCogs, faKeyboard } from '@fortawesome/free-solid-svg-icons';
|
||||
import _ from 'lodash';
|
||||
|
||||
import '../assets/scss/styles.scss';
|
||||
|
||||
import { disconnect } from './wetty/disconnect';
|
||||
import { overlay } from './wetty/disconnect/elements';
|
||||
import { verifyPrompt } from './wetty/disconnect/verify';
|
||||
import { FileDownloader } from './wetty/download';
|
||||
import { mobileKeyboard } from './wetty/mobile';
|
||||
import { socket } from './wetty/socket';
|
||||
import { terminal, Term } from './wetty/term';
|
||||
|
||||
// Setup for fontawesome
|
||||
library.add(faCogs);
|
||||
library.add(faKeyboard);
|
||||
dom.watch();
|
||||
|
||||
function onResize(term: Term): () => void {
|
||||
return function resize() {
|
||||
term.resizeTerm();
|
||||
};
|
||||
}
|
||||
|
||||
const term = terminal(socket);
|
||||
if (_.isUndefined(term)) {
|
||||
disconnect('终端初始化失败:未找到 #terminal 容器');
|
||||
} else {
|
||||
window.addEventListener('beforeunload', verifyPrompt, false);
|
||||
window.addEventListener('resize', onResize(term), false);
|
||||
|
||||
term.resizeTerm();
|
||||
term.focus();
|
||||
mobileKeyboard();
|
||||
const fileDownloader = new FileDownloader();
|
||||
|
||||
socket
|
||||
.on('connect', () => {
|
||||
if (!_.isNull(overlay)) overlay.style.display = 'none';
|
||||
})
|
||||
.on('data', (data: string) => {
|
||||
const remainingData = fileDownloader.buffer(data);
|
||||
if (remainingData) {
|
||||
term.write(remainingData);
|
||||
}
|
||||
})
|
||||
.on('login', () => {
|
||||
if (!_.isNull(overlay)) overlay.style.display = 'none';
|
||||
term.writeln('');
|
||||
term.resizeTerm();
|
||||
term.focus();
|
||||
})
|
||||
.on('logout', disconnect)
|
||||
.on('disconnect', disconnect)
|
||||
.on('error', (err: string | null) => {
|
||||
if (err) disconnect(err);
|
||||
});
|
||||
|
||||
term.onData((data: string) => {
|
||||
socket.emit('input', data);
|
||||
});
|
||||
term.onResize((size: { cols: number; rows: number }) => {
|
||||
socket.emit('resize', size);
|
||||
});
|
||||
|
||||
socket.connect().catch((error: unknown) => {
|
||||
const message =
|
||||
error instanceof Error ? error.message : '连接网关失败,请检查配置';
|
||||
disconnect(message);
|
||||
});
|
||||
}
|
||||
11
wetty/src/client/wetty/disconnect.ts
Normal file
11
wetty/src/client/wetty/disconnect.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import _ from 'lodash';
|
||||
import { overlay } from './disconnect/elements';
|
||||
import { verifyPrompt } from './disconnect/verify';
|
||||
|
||||
export function disconnect(reason: string): void {
|
||||
if (_.isNull(overlay)) return;
|
||||
overlay.style.display = 'block';
|
||||
const msg = document.getElementById('msg');
|
||||
if (!_.isUndefined(reason) && !_.isNull(msg)) msg.innerHTML = reason;
|
||||
window.removeEventListener('beforeunload', verifyPrompt, false);
|
||||
}
|
||||
5
wetty/src/client/wetty/disconnect/elements.ts
Normal file
5
wetty/src/client/wetty/disconnect/elements.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const overlay = document.getElementById('overlay');
|
||||
export const terminal = document.getElementById('terminal');
|
||||
export const editor = document.querySelector(
|
||||
'#options .editor',
|
||||
) as HTMLIFrameElement;
|
||||
4
wetty/src/client/wetty/disconnect/verify.ts
Normal file
4
wetty/src/client/wetty/disconnect/verify.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function verifyPrompt(e: { returnValue: string }): string {
|
||||
e.returnValue = 'Are you sure?';
|
||||
return e.returnValue;
|
||||
}
|
||||
236
wetty/src/client/wetty/download.spec.ts
Normal file
236
wetty/src/client/wetty/download.spec.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { expect } from 'chai';
|
||||
import 'mocha';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import * as sinon from 'sinon';
|
||||
|
||||
import { FileDownloader } from './download';
|
||||
|
||||
const noop = (): void => {}; // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
describe('FileDownloader', () => {
|
||||
const FILE_BEGIN = 'BEGIN';
|
||||
const FILE_END = 'END';
|
||||
let fileDownloader: FileDownloader;
|
||||
|
||||
beforeEach(() => {
|
||||
const { window } = new JSDOM(`...`);
|
||||
global.document = window.document;
|
||||
fileDownloader = new FileDownloader(noop, FILE_BEGIN, FILE_END);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
it('should return data before file markers', () => {
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
expect(
|
||||
fileDownloader.buffer(`DATA AT THE LEFT${FILE_BEGIN}FILE${FILE_END}`),
|
||||
).to.equal('DATA AT THE LEFT');
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
|
||||
});
|
||||
|
||||
it('should return data after file markers', () => {
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
expect(
|
||||
fileDownloader.buffer(`${FILE_BEGIN}FILE${FILE_END}DATA AT THE RIGHT`),
|
||||
).to.equal('DATA AT THE RIGHT');
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
|
||||
});
|
||||
|
||||
it('should return data before and after file markers', () => {
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
expect(
|
||||
fileDownloader.buffer(
|
||||
`DATA AT THE LEFT${FILE_BEGIN}FILE${FILE_END}DATA AT THE RIGHT`,
|
||||
),
|
||||
).to.equal('DATA AT THE LEFTDATA AT THE RIGHT');
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
|
||||
});
|
||||
|
||||
it('should return data before a beginning marker found', () => {
|
||||
sinon.stub(fileDownloader, 'onCompleteFileCallback');
|
||||
expect(fileDownloader.buffer(`DATA AT THE LEFT${FILE_BEGIN}FILE`)).to.equal(
|
||||
'DATA AT THE LEFT',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return data after an ending marker found', () => {
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
expect(fileDownloader.buffer(`${FILE_BEGIN}FI`)).to.equal('');
|
||||
expect(fileDownloader.buffer(`LE${FILE_END}DATA AT THE RIGHT`)).to.equal(
|
||||
'DATA AT THE RIGHT',
|
||||
);
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
|
||||
});
|
||||
|
||||
it('should buffer across incomplete file begin marker sequence on two calls', () => {
|
||||
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
|
||||
expect(fileDownloader.buffer('BEG')).to.equal('');
|
||||
expect(fileDownloader.buffer('INFILEEND')).to.equal('');
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
|
||||
});
|
||||
|
||||
it('should buffer across incomplete file begin marker sequence on n calls', () => {
|
||||
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
|
||||
expect(fileDownloader.buffer('B')).to.equal('');
|
||||
expect(fileDownloader.buffer('E')).to.equal('');
|
||||
expect(fileDownloader.buffer('G')).to.equal('');
|
||||
expect(fileDownloader.buffer('I')).to.equal('');
|
||||
expect(fileDownloader.buffer('NFILEEND')).to.equal('');
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
|
||||
});
|
||||
|
||||
it('should buffer across incomplete file begin marker sequence with data on the left and right on multiple calls', () => {
|
||||
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
|
||||
expect(fileDownloader.buffer('DATA AT THE LEFTB')).to.equal(
|
||||
'DATA AT THE LEFT',
|
||||
);
|
||||
expect(fileDownloader.buffer('E')).to.equal('');
|
||||
expect(fileDownloader.buffer('G')).to.equal('');
|
||||
expect(fileDownloader.buffer('I')).to.equal('');
|
||||
expect(fileDownloader.buffer('NFILEENDDATA AT THE RIGHT')).to.equal(
|
||||
'DATA AT THE RIGHT',
|
||||
);
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
|
||||
});
|
||||
|
||||
it('should buffer across incomplete file begin marker sequence then handle false positive', () => {
|
||||
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
|
||||
expect(fileDownloader.buffer('DATA AT THE LEFTB')).to.equal(
|
||||
'DATA AT THE LEFT',
|
||||
);
|
||||
expect(fileDownloader.buffer('E')).to.equal('');
|
||||
expect(fileDownloader.buffer('G')).to.equal('');
|
||||
// This isn't part of the file_begin marker and should trigger the partial
|
||||
// file begin marker to be returned with the normal data
|
||||
expect(fileDownloader.buffer('ZDATA AT THE RIGHT')).to.equal(
|
||||
'BEGZDATA AT THE RIGHT',
|
||||
);
|
||||
expect(onCompleteFileCallbackStub.called).to.be.false;
|
||||
});
|
||||
|
||||
it('should buffer across incomplete file end marker sequence on two calls', () => {
|
||||
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
|
||||
const mockFilePart1 = 'DATA AT THE LEFTBEGINFILEE';
|
||||
const mockFilePart2 = 'NDDATA AT THE RIGHT';
|
||||
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
expect(fileDownloader.buffer(mockFilePart1)).to.equal('DATA AT THE LEFT');
|
||||
expect(fileDownloader.buffer(mockFilePart2)).to.equal('DATA AT THE RIGHT');
|
||||
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
|
||||
});
|
||||
|
||||
it('should buffer across incomplete file end and file begin marker sequence with data on the left and right on multiple calls', () => {
|
||||
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
|
||||
expect(fileDownloader.buffer('DATA AT THE LEFTBE')).to.equal(
|
||||
'DATA AT THE LEFT',
|
||||
);
|
||||
expect(fileDownloader.buffer('G')).to.equal('');
|
||||
expect(fileDownloader.buffer('I')).to.equal('');
|
||||
expect(fileDownloader.buffer('NFILEE')).to.equal('');
|
||||
expect(fileDownloader.buffer('N')).to.equal('');
|
||||
expect(fileDownloader.buffer('DDATA AT THE RIGHT')).to.equal(
|
||||
'DATA AT THE RIGHT',
|
||||
);
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
|
||||
});
|
||||
|
||||
it('should be able to handle multiple files', () => {
|
||||
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
|
||||
expect(
|
||||
fileDownloader.buffer(
|
||||
'DATA AT THE LEFT' +
|
||||
'BEGIN' +
|
||||
'FILE1' +
|
||||
'END' +
|
||||
'SECOND DATA' +
|
||||
'BEGIN',
|
||||
),
|
||||
).to.equal('DATA AT THE LEFTSECOND DATA');
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE1');
|
||||
|
||||
expect(fileDownloader.buffer('FILE2')).to.equal('');
|
||||
expect(fileDownloader.buffer('E')).to.equal('');
|
||||
expect(fileDownloader.buffer('NDRIGHT')).to.equal('RIGHT');
|
||||
expect(onCompleteFileCallbackStub.calledTwice).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(1).args[0]).to.equal('FILE2');
|
||||
});
|
||||
|
||||
it('should be able to handle multiple files with an ending marker', () => {
|
||||
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
|
||||
expect(fileDownloader.buffer('DATA AT THE LEFTBEGINFILE1EN')).to.equal(
|
||||
'DATA AT THE LEFT',
|
||||
);
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.false;
|
||||
expect(fileDownloader.buffer('DSECOND DATABEGINFILE2EN')).to.equal(
|
||||
'SECOND DATA',
|
||||
);
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE1');
|
||||
expect(fileDownloader.buffer('D')).to.equal('');
|
||||
expect(onCompleteFileCallbackStub.calledTwice).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(1).args[0]).to.equal('FILE2');
|
||||
});
|
||||
});
|
||||
169
wetty/src/client/wetty/download.ts
Normal file
169
wetty/src/client/wetty/download.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import fileType from 'file-type';
|
||||
import Toastify from 'toastify-js';
|
||||
|
||||
const DEFAULT_FILE_BEGIN = '\u001b[5i';
|
||||
const DEFAULT_FILE_END = '\u001b[4i';
|
||||
|
||||
type OnCompleteFile = (bufferCharacters: string) => void;
|
||||
|
||||
function onCompleteFile(bufferCharacters: string): void {
|
||||
let fileNameBase64;
|
||||
let fileCharacters = bufferCharacters;
|
||||
if (bufferCharacters.includes(":")) {
|
||||
[fileNameBase64, fileCharacters] = bufferCharacters.split(":");
|
||||
}
|
||||
// Try to decode it as base64, if it fails we assume it's not base64
|
||||
try {
|
||||
fileCharacters = window.atob(fileCharacters);
|
||||
} catch (err) {
|
||||
// Assuming it's not base64...
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(fileCharacters.length);
|
||||
for (let i = 0; i < fileCharacters.length; i += 1) {
|
||||
bytes[i] = fileCharacters.charCodeAt(i);
|
||||
}
|
||||
|
||||
let mimeType = 'application/octet-stream';
|
||||
let fileExt = '';
|
||||
const typeData = fileType(bytes);
|
||||
if (typeData) {
|
||||
mimeType = typeData.mime;
|
||||
fileExt = typeData.ext;
|
||||
}
|
||||
// Check if the buffer is ASCII
|
||||
// Ref: https://stackoverflow.com/a/14313213
|
||||
// eslint-disable-next-line no-control-regex
|
||||
else if (/^[\x00-\xFF]*$/.test(fileCharacters)) {
|
||||
mimeType = 'text/plain';
|
||||
fileExt = 'txt';
|
||||
}
|
||||
let fileName;
|
||||
try {
|
||||
if (fileNameBase64 !== undefined) {
|
||||
fileName = window.atob(fileNameBase64);
|
||||
}
|
||||
} catch (err) {
|
||||
// Filename wasn't base64-encoded so let's ignore it
|
||||
}
|
||||
|
||||
if (fileName === undefined) {
|
||||
fileName = `file-${new Date()
|
||||
.toISOString()
|
||||
.split('.')[0]
|
||||
.replace(/-/g, '')
|
||||
.replace('T', '')
|
||||
.replace(/:/g, '')}${fileExt ? `.${fileExt}` : ''}`;
|
||||
}
|
||||
|
||||
const blob = new Blob([new Uint8Array(bytes.buffer)], {
|
||||
type: mimeType,
|
||||
});
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
Toastify({
|
||||
text: `Download ready: <a href="${blobUrl}" target="_blank" download="${fileName}">${fileName}</a>`,
|
||||
duration: 10000,
|
||||
newWindow: true,
|
||||
gravity: 'bottom',
|
||||
position: 'right',
|
||||
backgroundColor: '#fff',
|
||||
stopOnFocus: true,
|
||||
escapeMarkup: false,
|
||||
}).showToast();
|
||||
}
|
||||
|
||||
export class FileDownloader {
|
||||
fileBuffer: string[];
|
||||
fileBegin: string;
|
||||
fileEnd: string;
|
||||
partialFileBegin: string;
|
||||
onCompleteFileCallback: OnCompleteFile;
|
||||
|
||||
constructor(
|
||||
onCompleteFileCallback: OnCompleteFile = onCompleteFile,
|
||||
fileBegin: string = DEFAULT_FILE_BEGIN,
|
||||
fileEnd: string = DEFAULT_FILE_END,
|
||||
) {
|
||||
this.fileBuffer = [];
|
||||
this.fileBegin = fileBegin;
|
||||
this.fileEnd = fileEnd;
|
||||
this.partialFileBegin = '';
|
||||
this.onCompleteFileCallback = onCompleteFileCallback;
|
||||
}
|
||||
|
||||
bufferCharacter(character: string): string {
|
||||
// If we are not currently buffering a file.
|
||||
if (this.fileBuffer.length === 0) {
|
||||
// If we are not currently expecting the rest of the fileBegin sequences.
|
||||
if (this.partialFileBegin.length === 0) {
|
||||
// If the character is the first character of fileBegin we know to start
|
||||
// expecting the rest of the fileBegin sequence.
|
||||
if (character === this.fileBegin[0]) {
|
||||
this.partialFileBegin = character;
|
||||
return '';
|
||||
}
|
||||
// Otherwise, we just return the character for printing to the terminal.
|
||||
|
||||
return character;
|
||||
}
|
||||
// We're currently in the state of buffering a beginner marker...
|
||||
|
||||
const nextExpectedCharacter =
|
||||
this.fileBegin[this.partialFileBegin.length];
|
||||
// If the next character *is* the next character in the fileBegin sequence.
|
||||
if (character === nextExpectedCharacter) {
|
||||
this.partialFileBegin += character;
|
||||
// Do we now have the complete fileBegin sequence.
|
||||
if (this.partialFileBegin === this.fileBegin) {
|
||||
this.partialFileBegin = '';
|
||||
this.fileBuffer = this.fileBuffer.concat(this.fileBegin.split(''));
|
||||
return '';
|
||||
}
|
||||
// Otherwise, we just wait until the next character.
|
||||
|
||||
return '';
|
||||
}
|
||||
// If the next expected character wasn't found for the fileBegin sequence,
|
||||
// we need to return all the data that was bufferd in the partialFileBegin
|
||||
// back to the terminal.
|
||||
|
||||
const dataToReturn = this.partialFileBegin + character;
|
||||
this.partialFileBegin = '';
|
||||
return dataToReturn;
|
||||
}
|
||||
// If we are currently in the state of buffering a file.
|
||||
|
||||
this.fileBuffer.push(character);
|
||||
// If we now have an entire fileEnd marker, we have a complete file!
|
||||
if (
|
||||
this.fileBuffer.length >= this.fileBegin.length + this.fileEnd.length &&
|
||||
this.fileBuffer.slice(-this.fileEnd.length).join('') === this.fileEnd
|
||||
) {
|
||||
this.onCompleteFileCallback(
|
||||
this.fileBuffer
|
||||
.slice(
|
||||
this.fileBegin.length,
|
||||
this.fileBuffer.length - this.fileEnd.length,
|
||||
)
|
||||
.join(''),
|
||||
);
|
||||
this.fileBuffer = [];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
buffer(data: string): string {
|
||||
// This is a optimization to quickly return if we know for
|
||||
// sure we don't need to loop over each character.
|
||||
if (
|
||||
this.fileBuffer.length === 0 &&
|
||||
this.partialFileBegin.length === 0 &&
|
||||
data.indexOf(this.fileBegin[0]) === -1
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
return data.split('').map(this.bufferCharacter.bind(this)).join('');
|
||||
}
|
||||
}
|
||||
24
wetty/src/client/wetty/flowcontrol.ts
Normal file
24
wetty/src/client/wetty/flowcontrol.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Flow control client side.
|
||||
* For low impact on overall throughput simply commits every `ackBytes`
|
||||
* (default 2^18).
|
||||
*/
|
||||
export class FlowControlClient {
|
||||
public counter = 0;
|
||||
public ackBytes = 262144;
|
||||
|
||||
constructor(ackBytes?: number) {
|
||||
if (ackBytes) {
|
||||
this.ackBytes = ackBytes;
|
||||
}
|
||||
}
|
||||
|
||||
public needsCommit(length: number): boolean {
|
||||
this.counter += length;
|
||||
if (this.counter >= this.ackBytes) {
|
||||
this.counter -= this.ackBytes;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
14
wetty/src/client/wetty/mobile.ts
Normal file
14
wetty/src/client/wetty/mobile.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
export function mobileKeyboard(): void {
|
||||
const [screen] = Array.from(document.getElementsByClassName('xterm-screen'));
|
||||
if (_.isNull(screen)) return;
|
||||
screen.setAttribute('contenteditable', 'true');
|
||||
screen.setAttribute('spellcheck', 'false');
|
||||
screen.setAttribute('autocorrect', 'false');
|
||||
screen.setAttribute('autocomplete', 'false');
|
||||
screen.setAttribute('autocapitalize', 'false');
|
||||
/*
|
||||
term.scrollPort_.screen_.setAttribute('contenteditable', 'false');
|
||||
*/
|
||||
}
|
||||
118
wetty/src/client/wetty/runtimeConfig.ts
Normal file
118
wetty/src/client/wetty/runtimeConfig.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
export type GatewayAuthType = 'password' | 'privateKey' | 'certificate';
|
||||
|
||||
export interface TerminalServerConfig {
|
||||
id: string;
|
||||
name?: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
transportMode?: string;
|
||||
authType: GatewayAuthType;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
passphrase?: string;
|
||||
certificate?: string;
|
||||
knownHostFingerprint?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export interface TerminalRuntimeConfig {
|
||||
gatewayUrl?: string;
|
||||
gatewayToken?: string;
|
||||
selectedServerId?: string;
|
||||
servers: TerminalServerConfig[];
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' ? value : undefined;
|
||||
}
|
||||
|
||||
function parseServer(input: unknown): TerminalServerConfig | undefined {
|
||||
if (!isObject(input)) return undefined;
|
||||
|
||||
const id = asString(input.id);
|
||||
const host = asString(input.host);
|
||||
const username = asString(input.username);
|
||||
const authType = asString(input.authType) as GatewayAuthType | undefined;
|
||||
const port = asNumber(input.port);
|
||||
|
||||
if (!id || !host || !username || !authType || !port) return undefined;
|
||||
if (!['password', 'privateKey', 'certificate'].includes(authType)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
authType,
|
||||
name: asString(input.name),
|
||||
transportMode: asString(input.transportMode),
|
||||
password: asString(input.password),
|
||||
privateKey: asString(input.privateKey),
|
||||
passphrase: asString(input.passphrase),
|
||||
certificate: asString(input.certificate),
|
||||
knownHostFingerprint: asString(input.knownHostFingerprint),
|
||||
cols: asNumber(input.cols),
|
||||
rows: asNumber(input.rows),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载终端运行配置:
|
||||
* 1) 统一从项目根目录 `terminal.config.json` 读取;
|
||||
* 2) 使用 `no-store` 避免开发阶段被浏览器缓存;
|
||||
* 3) 只返回通过最小字段校验的服务器条目,减少运行时协议错误。
|
||||
*/
|
||||
export async function loadRuntimeConfig(): Promise<TerminalRuntimeConfig> {
|
||||
const response = await fetch('/terminal.config.json', {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`读取 terminal.config.json 失败: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as unknown;
|
||||
if (!isObject(data)) {
|
||||
throw new Error('terminal.config.json 格式错误: 顶层必须是对象');
|
||||
}
|
||||
|
||||
const serversInput = Array.isArray(data.servers) ? data.servers : [];
|
||||
const servers = serversInput
|
||||
.map(parseServer)
|
||||
.filter((item): item is TerminalServerConfig => Boolean(item));
|
||||
|
||||
if (servers.length === 0) {
|
||||
throw new Error('terminal.config.json 格式错误: servers 不能为空');
|
||||
}
|
||||
|
||||
return {
|
||||
gatewayUrl: asString(data.gatewayUrl),
|
||||
gatewayToken: asString(data.gatewayToken),
|
||||
selectedServerId: asString(data.selectedServerId),
|
||||
servers,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectServer(
|
||||
config: TerminalRuntimeConfig,
|
||||
): TerminalServerConfig {
|
||||
if (config.selectedServerId) {
|
||||
const selected = config.servers.find(
|
||||
(server) => server.id === config.selectedServerId,
|
||||
);
|
||||
if (selected) return selected;
|
||||
}
|
||||
|
||||
return config.servers[0];
|
||||
}
|
||||
273
wetty/src/client/wetty/socket.ts
Normal file
273
wetty/src/client/wetty/socket.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { loadRuntimeConfig, selectServer } from './runtimeConfig';
|
||||
|
||||
type SocketEvent =
|
||||
| 'connect'
|
||||
| 'login'
|
||||
| 'data'
|
||||
| 'logout'
|
||||
| 'disconnect'
|
||||
| 'error';
|
||||
|
||||
type EventPayloadMap = {
|
||||
connect: [];
|
||||
login: [];
|
||||
data: [string];
|
||||
logout: [string];
|
||||
disconnect: [string];
|
||||
error: [string];
|
||||
};
|
||||
|
||||
type EventHandler<K extends SocketEvent> = (...args: EventPayloadMap[K]) => void;
|
||||
|
||||
type EmitEvent = 'input' | 'resize' | 'commit';
|
||||
|
||||
interface ResizePayload {
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
interface GatewayFrame {
|
||||
type: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface GatewaySocketOptions {
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface TermSocketLike {
|
||||
emit(event: EmitEvent, payload: string | number | ResizePayload): void;
|
||||
}
|
||||
|
||||
export class GatewaySocket {
|
||||
private ws: WebSocket | null = null;
|
||||
private options: GatewaySocketOptions = { cols: 80, rows: 24 };
|
||||
private initialized = false;
|
||||
private handlers = new Map<SocketEvent, Set<(...args: unknown[]) => void>>();
|
||||
|
||||
public on<K extends SocketEvent>(event: K, handler: EventHandler<K>): this {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, new Set<(...args: unknown[]) => void>());
|
||||
}
|
||||
this.handlers.get(event)?.add(handler as (...args: unknown[]) => void);
|
||||
return this;
|
||||
}
|
||||
|
||||
public emit(
|
||||
event: EmitEvent,
|
||||
payload: string | number | ResizePayload,
|
||||
): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
if (!this.initialized && event !== 'resize') return;
|
||||
|
||||
if (event === 'input' && typeof payload === 'string') {
|
||||
this.sendFrame({
|
||||
type: 'stdin',
|
||||
payload: { data: payload, meta: { source: 'keyboard' } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event === 'resize' &&
|
||||
typeof payload === 'object' &&
|
||||
payload !== null &&
|
||||
typeof (payload as ResizePayload).cols === 'number' &&
|
||||
typeof (payload as ResizePayload).rows === 'number'
|
||||
) {
|
||||
const size = payload as ResizePayload;
|
||||
this.options = { cols: size.cols, rows: size.rows };
|
||||
this.sendFrame({
|
||||
type: 'resize',
|
||||
payload: size as unknown as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
try {
|
||||
const runtime = await loadRuntimeConfig();
|
||||
const selectedServer = selectServer(runtime);
|
||||
this.options = {
|
||||
cols: selectedServer.cols ?? 80,
|
||||
rows: selectedServer.rows ?? 24,
|
||||
};
|
||||
|
||||
const wsUrl = GatewaySocket.buildGatewayWsUrl(
|
||||
runtime.gatewayUrl,
|
||||
runtime.gatewayToken,
|
||||
);
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.addEventListener('open', () => {
|
||||
this.fire('connect');
|
||||
this.sendFrame({
|
||||
type: 'init',
|
||||
payload: this.buildInitPayload(selectedServer),
|
||||
});
|
||||
});
|
||||
|
||||
this.ws.addEventListener('message', (event) => {
|
||||
this.handleMessage(event.data);
|
||||
});
|
||||
|
||||
this.ws.addEventListener('close', (event) => {
|
||||
const reason = event.reason || `连接已关闭 (code=${event.code})`;
|
||||
if (this.initialized) {
|
||||
this.fire('logout', reason);
|
||||
}
|
||||
this.fire('disconnect', reason);
|
||||
this.initialized = false;
|
||||
this.ws = null;
|
||||
});
|
||||
|
||||
this.ws.addEventListener('error', () => {
|
||||
this.fire('error', '网关连接异常');
|
||||
});
|
||||
} catch (error) {
|
||||
this.fire(
|
||||
'error',
|
||||
error instanceof Error ? error.message : '初始化网关连接失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static buildGatewayWsUrl(
|
||||
rawUrl: string | undefined,
|
||||
token?: string,
|
||||
): string {
|
||||
const defaultProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const defaultUrl = `${defaultProtocol}//${window.location.hostname}:8787`;
|
||||
const normalized = rawUrl && rawUrl.trim() ? rawUrl : defaultUrl;
|
||||
|
||||
const url = new URL(normalized);
|
||||
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
|
||||
url.protocol = defaultProtocol;
|
||||
}
|
||||
url.pathname = '/ws/terminal';
|
||||
if (token) {
|
||||
url.searchParams.set('token', token);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 gateway init 帧:
|
||||
* 1) 认证信息按 authType 映射到协议要求字段;
|
||||
* 2) `clientSessionKey` 固定化,支持网关侧短时断线续接;
|
||||
* 3) 初始窗口大小从配置注入,后续再由 xterm resize 覆盖。
|
||||
*/
|
||||
private buildInitPayload(
|
||||
server: Awaited<ReturnType<typeof selectServer>>,
|
||||
): Record<string, unknown> {
|
||||
const clientSessionKey = `${server.id}:${server.username}@${server.host}:${server.port}`;
|
||||
const payload: Record<string, unknown> = {
|
||||
host: server.host,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
clientSessionKey,
|
||||
pty: {
|
||||
cols: this.options.cols,
|
||||
rows: this.options.rows,
|
||||
},
|
||||
};
|
||||
|
||||
if (server.knownHostFingerprint) {
|
||||
payload.knownHostFingerprint = server.knownHostFingerprint;
|
||||
}
|
||||
|
||||
if (server.authType === 'password') {
|
||||
payload.credential = {
|
||||
type: 'password',
|
||||
password: server.password || '',
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (server.authType === 'privateKey') {
|
||||
payload.credential = {
|
||||
type: 'privateKey',
|
||||
privateKey: server.privateKey || '',
|
||||
...server.passphrase ? { passphrase: server.passphrase } : {},
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
|
||||
payload.credential = {
|
||||
type: 'certificate',
|
||||
privateKey: server.privateKey || '',
|
||||
certificate: server.certificate || '',
|
||||
...server.passphrase ? { passphrase: server.passphrase } : {},
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
|
||||
private handleMessage(raw: string | Blob | ArrayBuffer): void {
|
||||
let data = '';
|
||||
|
||||
if (typeof raw === 'string') {
|
||||
data = raw;
|
||||
} else if (raw instanceof ArrayBuffer) {
|
||||
data = new TextDecoder().decode(raw);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
let frame: GatewayFrame;
|
||||
try {
|
||||
frame = JSON.parse(data) as GatewayFrame;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!frame || typeof frame.type !== 'string') return;
|
||||
|
||||
if (frame.type === 'stdout' || frame.type === 'stderr') {
|
||||
const text = `${frame.payload?.data || ''}`;
|
||||
if (text) this.fire('data', text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === 'error') {
|
||||
const msg = `${frame.payload?.message || '网关返回错误'}`;
|
||||
this.fire('error', msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === 'control') {
|
||||
const action = `${frame.payload?.action || ''}`;
|
||||
if (action === 'ping') {
|
||||
this.sendFrame({ type: 'control', payload: { action: 'pong' } });
|
||||
return;
|
||||
}
|
||||
if (action === 'connected') {
|
||||
if (!this.initialized) {
|
||||
this.initialized = true;
|
||||
this.fire('login');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === 'disconnect') {
|
||||
const reason = `${frame.payload?.reason || '连接已断开'}`;
|
||||
this.fire('logout', reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sendFrame(frame: GatewayFrame): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(JSON.stringify(frame));
|
||||
}
|
||||
|
||||
private fire<K extends SocketEvent>(
|
||||
event: K,
|
||||
...args: EventPayloadMap[K]
|
||||
): void {
|
||||
this.handlers.get(event)?.forEach((handler) => {
|
||||
handler(...args);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const socket = new GatewaySocket();
|
||||
236
wetty/src/client/wetty/term.ts
Normal file
236
wetty/src/client/wetty/term.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { ImageAddon } from '@xterm/addon-image';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { terminal as termElement } from './disconnect/elements';
|
||||
import { configureTerm } from './term/confiruragtion';
|
||||
import { loadOptions } from './term/load';
|
||||
import type { TermSocketLike } from './socket';
|
||||
import type { Options } from './term/options';
|
||||
|
||||
export class Term extends Terminal {
|
||||
socket: TermSocketLike;
|
||||
fitAddon: FitAddon;
|
||||
loadOptions: () => Options;
|
||||
|
||||
constructor(socket: TermSocketLike) {
|
||||
super({ allowProposedApi: true });
|
||||
this.socket = socket;
|
||||
this.fitAddon = new FitAddon();
|
||||
this.loadAddon(this.fitAddon);
|
||||
this.loadAddon(new WebLinksAddon());
|
||||
this.loadAddon(new ImageAddon());
|
||||
this.loadOptions = loadOptions;
|
||||
}
|
||||
|
||||
resizeTerm(): void {
|
||||
this.refresh(0, this.rows - 1);
|
||||
if (this.shouldFitTerm) this.fitAddon.fit();
|
||||
this.socket.emit('resize', { cols: this.cols, rows: this.rows });
|
||||
}
|
||||
|
||||
get shouldFitTerm(): boolean {
|
||||
return this.loadOptions().wettyFitTerminal ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
const ctrlButton = document.getElementById('onscreen-ctrl');
|
||||
let ctrlFlag = false; // This indicates whether the CTRL key is pressed or not
|
||||
|
||||
/**
|
||||
* Toggles the state of the `ctrlFlag` variable and updates the visual state
|
||||
* of the `ctrlButton` element accordingly. If `ctrlFlag` is set to `true`,
|
||||
* the `active` class is added to the `ctrlButton`; otherwise, it is removed.
|
||||
* After toggling, the terminal (`wetty_term`) is focused if it exists.
|
||||
*/
|
||||
const toggleCTRL = (): void => {
|
||||
ctrlFlag = !ctrlFlag;
|
||||
if (ctrlButton) {
|
||||
if (ctrlFlag) {
|
||||
ctrlButton.classList.add('active');
|
||||
} else {
|
||||
ctrlButton.classList.remove('active');
|
||||
}
|
||||
}
|
||||
window.wetty_term?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates a backspace key press by sending the backspace character
|
||||
* (ASCII code 127) to the terminal. This function is intended to be used
|
||||
* in conjunction with the `simulateCTRLAndKey` function to handle
|
||||
* keyboard shortcuts.
|
||||
*
|
||||
*/
|
||||
const simulateBackspace = (): void => {
|
||||
window.wetty_term?.input('\x7F', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates a CTRL + key press by sending the corresponding character
|
||||
* (converted from the key's ASCII code) to the terminal. This function
|
||||
* is intended to be used in conjunction with the `toggleCTRL` function
|
||||
* to handle keyboard shortcuts.
|
||||
*
|
||||
* @param key - The key that was pressed, which will be converted to
|
||||
* its corresponding character code.
|
||||
*/
|
||||
const simulateCTRLAndKey = (key: string): void => {
|
||||
window.wetty_term?.input(`${String.fromCharCode(key.toUpperCase().charCodeAt(0) - 64)}`, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the keydown event for the CTRL key. When the CTRL key is pressed,
|
||||
* it sets the `ctrlFlag` variable to true and updates the visual state of
|
||||
* the `ctrlButton` element. If the CTRL key is released, it sets `ctrlFlag`
|
||||
* to false and updates the visual state of the `ctrlButton` element.
|
||||
*
|
||||
* @param e - The keyboard event object.
|
||||
*/
|
||||
document.addEventListener('keyup', (e) => {
|
||||
if (ctrlFlag) {
|
||||
// if key is a character
|
||||
if (e.key.length === 1 && e.key.match(/^[a-zA-Z0-9]$/)) {
|
||||
simulateCTRLAndKey(e.key);
|
||||
// delayed backspace is needed to remove the character added to the terminal
|
||||
// when CTRL + key is pressed.
|
||||
// this is a workaround because e.preventDefault() cannot be used.
|
||||
_.debounce(() => {
|
||||
simulateBackspace();
|
||||
}, 100)();
|
||||
}
|
||||
toggleCTRL();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Simulates pressing the ESC key by sending the ESC character (ASCII code 27)
|
||||
* to the terminal. If the CTRL key is active, it toggles the CTRL state off.
|
||||
* After sending the ESC character, the terminal is focused.
|
||||
*/
|
||||
const pressESC = (): void => {
|
||||
if (ctrlFlag) {
|
||||
toggleCTRL();
|
||||
}
|
||||
window.wetty_term?.input('\x1B', false);
|
||||
window.wetty_term?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates pressing the UP arrow key by sending the UP character (ASCII code 65)
|
||||
* to the terminal. If the CTRL key is active, it toggles the CTRL state off.
|
||||
* After sending the UP character, the terminal is focused.
|
||||
*/
|
||||
const pressUP = (): void => {
|
||||
if (ctrlFlag) {
|
||||
toggleCTRL();
|
||||
}
|
||||
window.wetty_term?.input('\x1B[A', false);
|
||||
window.wetty_term?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates pressing the DOWN arrow key by sending the DOWN character (ASCII code 66)
|
||||
* to the terminal. If the CTRL key is active, it toggles the CTRL state off.
|
||||
* After sending the DOWN character, the terminal is focused.
|
||||
*/
|
||||
const pressDOWN = (): void => {
|
||||
if (ctrlFlag) {
|
||||
toggleCTRL();
|
||||
}
|
||||
window.wetty_term?.input('\x1B[B', false);
|
||||
window.wetty_term?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates pressing the TAB key by sending the TAB character (ASCII code 9)
|
||||
* to the terminal. If the CTRL key is active, it toggles the CTRL state off.
|
||||
* After sending the TAB character, the terminal is focused.
|
||||
*/
|
||||
const pressTAB = (): void => {
|
||||
if (ctrlFlag) {
|
||||
toggleCTRL();
|
||||
}
|
||||
window.wetty_term?.input('\x09', false);
|
||||
window.wetty_term?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates pressing the LEFT arrow key by sending the LEFT character (ASCII code 68)
|
||||
* to the terminal. If the CTRL key is active, it toggles the CTRL state off.
|
||||
* After sending the LEFT character, the terminal is focused.
|
||||
*/
|
||||
const pressLEFT = (): void => {
|
||||
if (ctrlFlag) {
|
||||
toggleCTRL();
|
||||
}
|
||||
window.wetty_term?.input('\x1B[D', false);
|
||||
window.wetty_term?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates pressing the RIGHT arrow key by sending the RIGHT character (ASCII code 67)
|
||||
* to the terminal. If the CTRL key is active, it toggles the CTRL state off.
|
||||
* After sending the RIGHT character, the terminal is focused.
|
||||
*/
|
||||
const pressRIGHT = (): void => {
|
||||
if (ctrlFlag) {
|
||||
toggleCTRL();
|
||||
}
|
||||
window.wetty_term?.input('\x1B[C', false);
|
||||
window.wetty_term?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the visibility of the onscreen buttons by adding or removing
|
||||
* the 'active' class to the element with the ID 'onscreen-buttons'.
|
||||
*/
|
||||
const toggleFunctions = (): void => {
|
||||
const element = document.querySelector('div#functions > div.onscreen-buttons')
|
||||
if (element?.classList.contains('active')) {
|
||||
element?.classList.remove('active');
|
||||
} else {
|
||||
element?.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
wetty_term?: Term;
|
||||
wetty_close_config?: () => void;
|
||||
wetty_save_config?: (newConfig: Options) => void;
|
||||
clipboardData: DataTransfer;
|
||||
loadOptions: (conf: Options) => void;
|
||||
toggleFunctions?: () => void;
|
||||
toggleCTRL? : () => void;
|
||||
pressESC?: () => void;
|
||||
pressUP?: () => void;
|
||||
pressDOWN?: () => void;
|
||||
pressTAB?: () => void;
|
||||
pressLEFT?: () => void;
|
||||
pressRIGHT?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
export function terminal(socket: TermSocketLike): Term | undefined {
|
||||
const term = new Term(socket);
|
||||
if (_.isNull(termElement)) return undefined;
|
||||
termElement.innerHTML = '';
|
||||
term.open(termElement);
|
||||
configureTerm(term);
|
||||
window.onresize = function onResize() {
|
||||
term.resizeTerm();
|
||||
};
|
||||
window.wetty_term = term;
|
||||
window.toggleFunctions = toggleFunctions;
|
||||
window.toggleCTRL = toggleCTRL;
|
||||
window.pressESC = pressESC;
|
||||
window.pressUP = pressUP;
|
||||
window.pressDOWN = pressDOWN;
|
||||
window.pressTAB = pressTAB;
|
||||
window.pressLEFT = pressLEFT;
|
||||
window.pressRIGHT = pressRIGHT;
|
||||
return term;
|
||||
}
|
||||
77
wetty/src/client/wetty/term/confiruragtion.ts
Normal file
77
wetty/src/client/wetty/term/confiruragtion.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { editor } from '../disconnect/elements';
|
||||
import { copySelected, copyShortcut } from './confiruragtion/clipboard';
|
||||
import { onInput } from './confiruragtion/editor';
|
||||
import { loadOptions } from './load';
|
||||
import type { Options } from './options';
|
||||
import type { Term } from '../term';
|
||||
|
||||
export function configureTerm(term: Term): void {
|
||||
const options = loadOptions();
|
||||
try {
|
||||
term.options = options.xterm;
|
||||
} catch {
|
||||
/* Do nothing */
|
||||
}
|
||||
|
||||
const toggle = document.querySelector('#options .toggler');
|
||||
const optionsElem = document.getElementById('options');
|
||||
if (editor == null || toggle == null || optionsElem == null) {
|
||||
throw new Error("Couldn't initialize configuration menu");
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前配置同步给 iframe 配置面板,并注入回调钩子。
|
||||
* 返回值表示 iframe 是否已准备好接收配置(loadOptions 已可调用)。
|
||||
*/
|
||||
const syncEditor = (): boolean => {
|
||||
const editorWindow = editor.contentWindow;
|
||||
if (!editorWindow || typeof editorWindow.loadOptions !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
editorWindow.loadOptions(loadOptions());
|
||||
editorWindow.wetty_close_config = () => {
|
||||
optionsElem?.classList.toggle('opened');
|
||||
};
|
||||
editorWindow.wetty_save_config = (newConfig: Options) => {
|
||||
onInput(term, newConfig);
|
||||
};
|
||||
return true;
|
||||
};
|
||||
|
||||
function editorOnLoad() {
|
||||
if (syncEditor()) return;
|
||||
|
||||
// 某些浏览器/开发模式下,iframe 的脚本初始化会略晚于 load 事件。
|
||||
setTimeout(() => {
|
||||
syncEditor();
|
||||
}, 50);
|
||||
}
|
||||
if (
|
||||
(
|
||||
editor.contentDocument ||
|
||||
(editor.contentWindow?.document ?? {
|
||||
readyState: '',
|
||||
})
|
||||
).readyState === 'complete'
|
||||
) {
|
||||
editorOnLoad();
|
||||
}
|
||||
editor.addEventListener('load', editorOnLoad);
|
||||
|
||||
toggle.addEventListener('click', e => {
|
||||
syncEditor();
|
||||
optionsElem.classList.toggle('opened');
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
term.attachCustomKeyEventHandler(copyShortcut);
|
||||
|
||||
document.addEventListener(
|
||||
'mouseup',
|
||||
() => {
|
||||
if (term.hasSelection()) copySelected(term.getSelection());
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
40
wetty/src/client/wetty/term/confiruragtion/clipboard.ts
Normal file
40
wetty/src/client/wetty/term/confiruragtion/clipboard.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
Copy text selection to clipboard on double click or select
|
||||
@param text - the selected text to copy
|
||||
@returns boolean to indicate success or failure
|
||||
*/
|
||||
export function copySelected(text: string): boolean {
|
||||
if (window.clipboardData?.setData) {
|
||||
window.clipboardData.setData('Text', text);
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
document.queryCommandSupported &&
|
||||
document.queryCommandSupported('copy')
|
||||
) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.textContent = text;
|
||||
textarea.style.position = 'fixed';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
return true;
|
||||
} catch (ex) {
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function copyShortcut(e: KeyboardEvent): boolean {
|
||||
// Ctrl + Shift + C
|
||||
if (e.ctrlKey && e.shiftKey && e.keyCode === 67) {
|
||||
e.preventDefault();
|
||||
document.execCommand('copy');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
23
wetty/src/client/wetty/term/confiruragtion/editor.ts
Normal file
23
wetty/src/client/wetty/term/confiruragtion/editor.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { editor } from '../../disconnect/elements';
|
||||
import type { Term } from '../../term';
|
||||
import type { Options } from '../options';
|
||||
|
||||
export const onInput = (term: Term, updated: Options) => {
|
||||
try {
|
||||
const updatedConf = JSON.stringify(updated, null, 2);
|
||||
if (localStorage.options === updatedConf) return;
|
||||
term.options = updated.xterm;
|
||||
if (
|
||||
!updated.wettyFitTerminal &&
|
||||
updated.xterm.cols != null &&
|
||||
updated.xterm.rows != null
|
||||
)
|
||||
term.resize(updated.xterm.cols, updated.xterm.rows);
|
||||
term.resizeTerm();
|
||||
editor.classList.remove('error');
|
||||
localStorage.options = updatedConf;
|
||||
} catch (e) {
|
||||
console.error('Configuration Error', e); // eslint-disable-line no-console
|
||||
editor.classList.add('error');
|
||||
}
|
||||
};
|
||||
25
wetty/src/client/wetty/term/load.ts
Normal file
25
wetty/src/client/wetty/term/load.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import _ from 'lodash';
|
||||
import type { XTerm, Options } from './options';
|
||||
|
||||
export const defaultOptions: Options = {
|
||||
xterm: { fontSize: 14 },
|
||||
wettyVoid: 0,
|
||||
wettyFitTerminal: true,
|
||||
};
|
||||
|
||||
export function loadOptions(): Options {
|
||||
try {
|
||||
let options = _.isUndefined(localStorage.options)
|
||||
? defaultOptions
|
||||
: JSON.parse(localStorage.options);
|
||||
// Convert old options to new options
|
||||
if (!('xterm' in options)) {
|
||||
const xterm = options;
|
||||
options = defaultOptions;
|
||||
options.xterm = xterm as unknown as XTerm;
|
||||
}
|
||||
return options;
|
||||
} catch {
|
||||
return defaultOptions;
|
||||
}
|
||||
}
|
||||
11
wetty/src/client/wetty/term/options.ts
Normal file
11
wetty/src/client/wetty/term/options.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type XTerm = {
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
fontSize: number;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
export interface Options {
|
||||
xterm: XTerm;
|
||||
wettyFitTerminal: boolean;
|
||||
wettyVoid: number;
|
||||
}
|
||||
Reference in New Issue
Block a user