fork repo
Some checks failed
backend / cross (aarch64) (push) Failing after 11m25s
backend / cross (arm) (push) Failing after 7m16s
backend / cross (armhf) (push) Failing after 2m5s
backend / cross (i686) (push) Failing after 31s
backend / cross (mips) (push) Failing after 31s
backend / cross (mips64) (push) Failing after 31s
backend / cross (mips64el) (push) Failing after 2m49s
backend / cross (s390x) (push) Failing after 6m45s
backend / cross (mipsel) (push) Failing after 16m40s
backend / cross (win32) (push) Failing after 7m0s
backend / cross (x86_64) (push) Failing after 17m57s
frontend / build (push) Failing after 5m51s

This commit is contained in:
2025-09-02 20:02:46 +08:00
parent 0cb803226d
commit 6c23e008c7
93 changed files with 45370 additions and 2581 deletions

14
html/.editorconfig Normal file
View File

@@ -0,0 +1,14 @@
root = true
[*]
charset = utf-8
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[{*.json, *.scss}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false

2
html/.eslintignore Normal file
View File

@@ -0,0 +1,2 @@
dist/
src/

20
html/.eslintrc.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "./node_modules/gts/",
"overrides": [
{
"files": ["**/*.ts", "**/*.tsx"],
"parserOptions": {
"jsxPragma": "h"
},
"rules": {
"@typescript-eslint/no-duplicate-enum-values": "off"
}
},
{
"files": ["gulpfile.js", "webpack.config.js"],
"rules": {
"node/no-unpublished-require": "off"
}
}
]
}

9
html/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules
dist
*.log
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

6
html/.prettierrc.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
...require('gts/.prettierrc.json'),
"bracketSpacing": true,
"tabWidth": 4,
"printWidth": 120,
}

View File

@@ -0,0 +1,34 @@
diff --git a/src/zsession.js b/src/zsession.js
index 5f0b8f9d8afa6fba0acd6dd0477afa186f7aad9a..c7ea98e0f08c97d63d321f784a5dd8bf66888743 100644
--- a/src/zsession.js
+++ b/src/zsession.js
@@ -548,20 +548,17 @@ Zmodem.Session.Receive = class ZmodemReceiveSession extends Zmodem.Session {
if (this._got_ZFIN) {
if (this._input_buffer.length < 2) return;
- //if its OO, then set this._bytes_after_OO
- if (Zmodem.ZMLIB.find_subarray(this._input_buffer, OVER_AND_OUT) === 0) {
+ if (Zmodem.ZMLIB.find_subarray(this._input_buffer, OVER_AND_OUT) !== 0) {
+ console.warn( "PROTOCOL: Only thing after ZFIN should be “OO” (79,79), not: " + this._input_buffer.join() );
+ }
- //This doubles as an indication that the session has ended.
- //We need to set this right away so that handlers like
- //"session_end" will have access to it.
- this._bytes_after_OO = _trim_OO(this._bytes_being_consumed.slice(0));
- this._on_session_end();
+ //This doubles as an indication that the session has ended.
+ //We need to set this right away so that handlers like
+ //"session_end" will have access to it.
+ this._bytes_after_OO = _trim_OO(this._bytes_being_consumed.slice(0));
+ this._on_session_end();
- return;
- }
- else {
- throw( "PROTOCOL: Only thing after ZFIN should be “OO” (79,79), not: " + this._input_buffer.join() );
- }
+ return;
}
var parsed;

1
html/.yarnrc.yml Normal file
View File

@@ -0,0 +1 @@
nodeLinker: node-modules

14
html/README.md Normal file
View File

@@ -0,0 +1,14 @@
## Prerequisites
> **NOTE:** yarn v2 is required.
Install [Yarn](https://yarnpkg.com/getting-started/install), and run: `yarn install`.
## Development
1. Start ttyd: `ttyd bash`
2. Start the dev server: `yarn run start`
## Publish
Run `yarn run build`, this will compile the inlined html to `../src/html.h`.

68
html/gulpfile.js Normal file
View File

@@ -0,0 +1,68 @@
const { src, dest, task, series } = require('gulp');
const clean = require('gulp-clean');
const gzip = require('gulp-gzip');
const inlineSource = require('gulp-inline-source');
const rename = require('gulp-rename');
const through2 = require('through2');
const genHeader = (size, buf, len) => {
let idx = 0;
let data = 'unsigned char index_html[] = {\n ';
for (const value of buf) {
idx++;
const current = value < 0 ? value + 256 : value;
data += '0x';
data += (current >>> 4).toString(16);
data += (current & 0xf).toString(16);
if (idx === len) {
data += '\n';
} else {
data += idx % 12 === 0 ? ',\n ' : ', ';
}
}
data += '};\n';
data += `unsigned int index_html_len = ${len};\n`;
data += `unsigned int index_html_size = ${size};\n`;
return data;
};
let fileSize = 0;
task('clean', () => {
return src('dist', { read: false, allowEmpty: true }).pipe(clean());
});
task('inline', () => {
const options = {
compress: false,
};
return src('dist/index.html').pipe(inlineSource(options)).pipe(rename('inline.html')).pipe(dest('dist/'));
});
task(
'default',
series('inline', () => {
return src('dist/inline.html')
.pipe(
through2.obj((file, enc, cb) => {
fileSize = file.contents.length;
return cb(null, file);
})
)
.pipe(gzip())
.pipe(
through2.obj((file, enc, cb) => {
const buf = file.contents;
file.contents = Buffer.from(genHeader(fileSize, buf, buf.length));
return cb(null, file);
})
)
.pipe(rename('html.h'))
.pipe(dest('../src/'));
})
);

15602
html/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

74
html/package.json Normal file
View File

@@ -0,0 +1,74 @@
{
"private": true,
"name": "ttyd",
"version": "1.0.0",
"description": "Share your terminal over the web",
"repository": {
"url": "git@github.com:tsl0922/ttyd.git",
"type": "git"
},
"author": "Shuanglei Tao <tsl0922@gmail.com>",
"license": "MIT",
"scripts": {
"prestart": "gulp clean",
"start": "NODE_ENV=development && webpack serve",
"build": "NODE_ENV=production webpack && gulp",
"inline": "NODE_ENV=production webpack && gulp inline",
"check": "gts check",
"fix": "gts fix"
},
"engines": {
"node": ">=12"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^7.1.1",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^6.10.0",
"css-minimizer-webpack-plugin": "^6.0.0",
"eslint": "^8.57.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-webpack-plugin": "^4.0.1",
"gts": "^5.2.0",
"gulp": "^4.0.2",
"gulp-clean": "^0.4.0",
"gulp-gzip": "^1.4.2",
"gulp-inline-source": "^4.0.0",
"gulp-rename": "^2.0.0",
"html-webpack-plugin": "^5.6.0",
"mini-css-extract-plugin": "^2.8.1",
"sass": "^1.71.1",
"sass-loader": "^14.1.1",
"scssfmt": "^1.0.7",
"style-loader": "^3.3.4",
"terser-webpack-plugin": "^5.3.10",
"through2": "^4.0.2",
"ts-loader": "^9.5.1",
"typescript": "^5.3.3",
"util": "^0.12.5",
"webpack": "^5.90.3",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.2",
"webpack-merge": "^5.10.0"
},
"dependencies": {
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-image": "^0.8.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"decko": "^1.2.0",
"file-saver": "^2.0.5",
"preact": "^10.19.6",
"trzsz": "^1.1.5",
"whatwg-fetch": "^3.6.20",
"zmodem.js": "^0.1.10"
},
"resolutions": {
"zmodem.js@^0.1.10": "patch:zmodem.js@npm%3A0.1.10#./.yarn/patches/zmodem.js-npm-0.1.10-e5537fa2ed.patch"
},
"packageManager": "yarn@3.6.3"
}

View File

@@ -0,0 +1,70 @@
import { h, Component } from 'preact';
import { Terminal } from './terminal';
import type { ITerminalOptions, ITheme } from '@xterm/xterm';
import type { ClientOptions, FlowControl } from './terminal/xterm';
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const path = window.location.pathname.replace(/[/]+$/, '');
const wsUrl = [protocol, '//', window.location.host, path,'/ws'].join('');
const tokenUrl = [window.location.protocol, '//', window.location.host, path,'/token'].join('');
const clientOptions = {
rendererType: 'webgl',
disableLeaveAlert: false,
disableResizeOverlay: false,
enableZmodem: false,
enableTrzsz: false,
enableSixel: false,
closeOnDisconnect: false,
isWindows: false,
unicodeVersion: '11',
} as ClientOptions;
const termOptions = {
fontSize: 13,
fontFamily: 'Consolas,Liberation Mono,Menlo,Courier,monospace',
cursorBlink: true,
theme: {
foreground: '#d2d2d2',
background: '#2b2b2b',
cursor: '#adadad',
black: '#000000',
red: '#d81e00',
green: '#5ea702',
yellow: '#cfae00',
blue: '#427ab3',
magenta: '#89658e',
cyan: '#00a7aa',
white: '#dbded8',
brightBlack: '#686a66',
brightRed: '#f54235',
brightGreen: '#99e343',
brightYellow: '#fdeb61',
brightBlue: '#84b0d8',
brightMagenta: '#bc94b7',
brightCyan: '#37e6e8',
brightWhite: '#f1f1f0',
} as ITheme,
allowProposedApi: true,
} as ITerminalOptions;
const flowControl = {
limit: 100000,
highWater: 10,
lowWater: 4,
} as FlowControl;
export class App extends Component {
render() {
return (
<Terminal
id="terminal-container"
wsUrl={wsUrl}
tokenUrl={tokenUrl}
clientOptions={clientOptions}
termOptions={termOptions}
flowControl={flowControl}
/>
);
}
}

View File

@@ -0,0 +1,27 @@
import { h, Component, ComponentChildren } from 'preact';
import './modal.scss';
interface Props {
show: boolean;
children: ComponentChildren;
}
export class Modal extends Component<Props> {
constructor(props: Props) {
super(props);
}
render({ show, children }: Props) {
return (
show && (
<div className="modal">
<div className="modal-background" />
<div className="modal-content">
<div className="box">{children}</div>
</div>
</div>
)
);
}
}

View File

@@ -0,0 +1,81 @@
.modal {
bottom: 0;
left: 0;
right: 0;
top: 0;
align-items: center;
display: flex;
overflow: hidden;
position: fixed;
z-index: 40;
}
.modal-background {
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
background-color: #4a4a4acc;
}
.modal-content {
margin: 0 20px;
max-height: calc(100vh - 160px);
overflow: auto;
position: relative;
width: 100%;
.box {
background-color: #fff;
color: #4a4a4a;
display: block;
padding: 1.25rem;
}
header {
font-weight: bold;
text-align: center;
padding-bottom: 10px;
margin-bottom: 10px;
border-bottom: 1px solid #ddd;
}
.file-input {
height: .01em;
left: 0;
outline: none;
position: absolute;
top: 0;
width: .01em;
}
.file-cta {
cursor: pointer;
background-color: #f5f5f5;
color: #6200ee;
outline: none;
align-items: center;
box-shadow: none;
display: inline-flex;
height: 2.25em;
justify-content: flex-start;
line-height: 1.5;
position: relative;
vertical-align: top;
border-color: #dbdbdb;
border-radius: 3px;
font-size: 1em;
font-weight: 500;
padding: calc(.375em - 1px) 1em;
white-space: nowrap;
}
}
@media print, screen and (min-width: 769px) {
.modal-content {
margin: 0 auto;
max-height: calc(100vh - 40px);
width: 640px;
}
}

View File

@@ -0,0 +1,152 @@
import { bind } from 'decko';
import { Component, h } from 'preact';
import { Xterm, XtermOptions } from './xterm';
import '@xterm/xterm/css/xterm.css';
import { Modal } from '../modal';
interface Props extends XtermOptions {
id: string;
}
interface State {
modal: boolean;
}
export class Terminal extends Component<Props, State> {
private container: HTMLElement;
private xterm: Xterm;
private intervalID: NodeJS.Timeout;
private currentDevcontainer = {
title: 'Devcontainer Info',
detail: 'No Devcontainer Created yet',
port: '',
ip:'',
steps: [
// {
// summary: '',
// duration: '',
// status: '',
// logs:{
// },
// }
],
};
constructor(props: Props) {
super();
this.xterm = new Xterm(props, this.showModal);
}
async componentDidMount() {
await this.xterm.refreshToken();
const options = new URLSearchParams(decodeURIComponent(window.location.search));
const params = new URLSearchParams({
repo: options.get('repoid') as string,
user: options.get('userid') as string,
});
fetch('http://' +
options.get('domain') +
':'+
options.get('port') +
'/' +
options.get('user') +
'/' +
options.get('repo') +
'/devcontainer/status?' +
params
)
.then(response => response.json())
.then(data => {
if (data.status !== '-1') {
if (options.get('type') === 'docker') {
this.xterm.open(this.container);
this.xterm.connect();
} else {
this.intervalID = setInterval(this.loadOutput, 3000);
this.xterm.open(this.container);
this.xterm.changeUrl(this.currentDevcontainer.ip, this.currentDevcontainer.port)
this.xterm.changeStatus(true);
this.xterm.connect();
}
}
})
.catch(error => {
console.error('Error:', error);
});
}
componentWillUnmount() {
this.xterm.dispose();
}
render({ id }: Props, { modal }: State) {
return (
<div id={id} ref={c => (this.container = c as HTMLElement)}>
<Modal show={modal}>
<label class="file-label">
<input onChange={this.sendFile} class="file-input" type="file" multiple />
<span class="file-cta">Choose files</span>
</label>
</Modal>
</div>
);
}
@bind
showModal() {
this.setState({ modal: true });
}
@bind
sendFile(event: Event) {
this.setState({ modal: false });
const files = (event.target as HTMLInputElement).files;
if (files) this.xterm.sendFile(files);
}
@bind
private loadOutput() {
const options = new URLSearchParams(decodeURIComponent(window.location.search));
const params = new URLSearchParams({
repo: options.get('repoid') as string,
user: options.get('userid') as string,
});
fetch(
'http://' + options.get('domain') + ':'+ options.get('port') +'/' +
options.get('user') +
'/' +
options.get('repo') +
'/devcontainer/output?' +
params
)
.then(response => response.json())
.then(job => {
if (!job) {
clearInterval(this.intervalID);
this.intervalID = null as any;
return;
}
if(this.currentDevcontainer.steps.length < job.currentDevcontainer.steps.length){
for(let i = this.currentDevcontainer.steps.length; i < job.currentDevcontainer.steps.length; i++) {
this.xterm.writeData(job.currentDevcontainer.steps[i].summary);
this.xterm.writeData('\r\n');
for(let j = 0; j < job.currentDevcontainer.steps[i].logs.length; j++) {
this.xterm.writeData(job.currentDevcontainer.steps[i].logs[j].message.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'));
this.xterm.writeData('\r\n');
}
}
}
this.currentDevcontainer = job.currentDevcontainer;
if (this.currentDevcontainer.detail === '4' && this.intervalID) {
clearInterval(this.intervalID);
this.intervalID = null as any;
}
})
.catch(error => {
console.error('Error:', error);
});
}
}

View File

@@ -0,0 +1,73 @@
// ported from hterm.Terminal.prototype.showOverlay
// https://chromium.googlesource.com/apps/libapps/+/master/hterm/js/hterm_terminal.js
import { bind } from 'decko';
import { ITerminalAddon, Terminal } from '@xterm/xterm';
export class OverlayAddon implements ITerminalAddon {
private terminal: Terminal;
private overlayNode: HTMLElement;
private overlayTimeout?: number;
constructor() {
this.overlayNode = document.createElement('div');
this.overlayNode.style.cssText = `border-radius: 15px;
font-size: xx-large;
opacity: 0.75;
padding: 0.2em 0.5em 0.2em 0.5em;
position: absolute;
-webkit-user-select: none;
-webkit-transition: opacity 180ms ease-in;
-moz-user-select: none;
-moz-transition: opacity 180ms ease-in;`;
this.overlayNode.addEventListener(
'mousedown',
e => {
e.preventDefault();
e.stopPropagation();
},
true
);
}
activate(terminal: Terminal): void {
this.terminal = terminal;
}
dispose(): void {}
@bind
showOverlay(msg: string, timeout?: number): void {
const { terminal, overlayNode } = this;
if (!terminal.element) return;
overlayNode.style.color = '#101010';
overlayNode.style.backgroundColor = '#f0f0f0';
overlayNode.textContent = msg;
overlayNode.style.opacity = '0.75';
if (!overlayNode.parentNode) {
terminal.element.appendChild(overlayNode);
}
const divSize = terminal.element.getBoundingClientRect();
const overlaySize = overlayNode.getBoundingClientRect();
overlayNode.style.top = (divSize.height - overlaySize.height) / 2 + 'px';
overlayNode.style.left = (divSize.width - overlaySize.width) / 2 + 'px';
if (this.overlayTimeout) clearTimeout(this.overlayTimeout);
if (!timeout) return;
this.overlayTimeout = window.setTimeout(() => {
overlayNode.style.opacity = '0';
this.overlayTimeout = window.setTimeout(() => {
if (overlayNode.parentNode) {
overlayNode.parentNode.removeChild(overlayNode);
}
this.overlayTimeout = undefined;
overlayNode.style.opacity = '0.75';
}, 200);
}, timeout || 1500);
}
}

View File

@@ -0,0 +1,182 @@
import { bind } from 'decko';
import { saveAs } from 'file-saver';
import { IDisposable, ITerminalAddon, Terminal } from '@xterm/xterm';
import * as Zmodem from 'zmodem.js/src/zmodem_browser';
import { TrzszFilter } from 'trzsz';
export interface ZmodeOptions {
zmodem: boolean;
trzsz: boolean;
windows: boolean;
trzszDragInitTimeout: number;
onSend: () => void;
sender: (data: string | Uint8Array) => void;
writer: (data: string | Uint8Array) => void;
}
export class ZmodemAddon implements ITerminalAddon {
private disposables: IDisposable[] = [];
private terminal: Terminal;
private sentry: Zmodem.Sentry;
private session: Zmodem.Session;
private denier: () => void;
private trzszFilter: TrzszFilter;
constructor(private options: ZmodeOptions) {}
activate(terminal: Terminal) {
this.terminal = terminal;
if (this.options.zmodem) this.zmodemInit();
if (this.options.trzsz) this.trzszInit();
}
dispose() {
for (const d of this.disposables) {
d.dispose();
}
this.disposables.length = 0;
}
consume(data: ArrayBuffer) {
try {
if (this.options.trzsz) {
this.trzszFilter.processServerOutput(data);
} else {
this.sentry.consume(data);
}
} catch (e) {
console.error('[ttyd] zmodem consume: ', e);
this.reset();
}
}
@bind
private reset() {
this.terminal.options.disableStdin = false;
this.terminal.focus();
}
private addDisposableListener(target: EventTarget, type: string, listener: EventListener) {
target.addEventListener(type, listener);
this.disposables.push({ dispose: () => target.removeEventListener(type, listener) });
}
@bind
private trzszInit() {
const { terminal } = this;
const { sender, writer, zmodem } = this.options;
this.trzszFilter = new TrzszFilter({
writeToTerminal: data => {
if (!this.trzszFilter.isTransferringFiles() && zmodem) {
this.sentry.consume(data);
} else {
writer(typeof data === 'string' ? data : new Uint8Array(data as ArrayBuffer));
}
},
sendToServer: data => sender(data),
terminalColumns: terminal.cols,
isWindowsShell: this.options.windows,
dragInitTimeout: this.options.trzszDragInitTimeout,
});
const element = terminal.element as EventTarget;
this.addDisposableListener(element, 'dragover', event => event.preventDefault());
this.addDisposableListener(element, 'drop', event => {
event.preventDefault();
this.trzszFilter
.uploadFiles((event as DragEvent).dataTransfer?.items as DataTransferItemList)
.then(() => console.log('[ttyd] upload success'))
.catch(err => console.log('[ttyd] upload failed: ' + err));
});
this.disposables.push(terminal.onResize(size => this.trzszFilter.setTerminalColumns(size.cols)));
}
@bind
private zmodemInit() {
const { sender, writer } = this.options;
const { terminal, reset, zmodemDetect } = this;
this.session = null;
this.sentry = new Zmodem.Sentry({
to_terminal: octets => writer(new Uint8Array(octets)),
sender: octets => sender(new Uint8Array(octets)),
on_retract: () => reset(),
on_detect: detection => zmodemDetect(detection),
});
this.disposables.push(
terminal.onKey(e => {
const event = e.domEvent;
if (event.ctrlKey && event.key === 'c') {
if (this.denier) this.denier();
}
})
);
}
@bind
private zmodemDetect(detection: Zmodem.Detection): void {
const { terminal, receiveFile } = this;
terminal.options.disableStdin = true;
this.denier = () => detection.deny();
this.session = detection.confirm();
this.session.on('session_end', () => this.reset());
if (this.session.type === 'send') {
this.options.onSend();
} else {
receiveFile();
}
}
@bind
public sendFile(files: FileList) {
const { session, writeProgress } = this;
Zmodem.Browser.send_files(session, files, {
on_progress: (_, offer) => writeProgress(offer),
})
.then(() => session.close())
.catch(() => this.reset());
}
@bind
private receiveFile() {
const { session, writeProgress } = this;
session.on('offer', offer => {
offer.on('input', () => writeProgress(offer));
offer
.accept()
.then(payloads => {
const blob = new Blob(payloads, { type: 'application/octet-stream' });
saveAs(blob, offer.get_details().name);
})
.catch(() => this.reset());
});
session.start();
}
@bind
private writeProgress(offer: Zmodem.Offer) {
const { bytesHuman } = this;
const file = offer.get_details();
const name = file.name;
const size = file.size;
const offset = offer.get_offset();
const percent = ((100 * offset) / size).toFixed(2);
this.options.writer(`${name} ${percent}% ${bytesHuman(offset, 2)}/${bytesHuman(size, 2)}\r`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private bytesHuman(bytes: any, precision: number): string {
if (!/^([-+])?|(\.\d+)(\d+(\.\d+)?|(\d+\.)|Infinity)$/.test(bytes)) {
return '-';
}
if (bytes === 0) return '0';
if (typeof precision === 'undefined') precision = 1;
const units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
const num = Math.floor(Math.log(bytes) / Math.log(1024));
const value = (bytes / Math.pow(1024, Math.floor(num))).toFixed(precision);
return `${value} ${units[num]}`;
}
}

View File

@@ -0,0 +1,652 @@
import { bind } from 'decko';
import type { IDisposable, ITerminalOptions } from '@xterm/xterm';
import { Terminal } from '@xterm/xterm';
import { CanvasAddon } from '@xterm/addon-canvas';
import { ClipboardAddon } from '@xterm/addon-clipboard';
import { WebglAddon } from '@xterm/addon-webgl';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { ImageAddon } from '@xterm/addon-image';
import { Unicode11Addon } from '@xterm/addon-unicode11';
import { OverlayAddon } from './addons/overlay';
import { ZmodemAddon } from './addons/zmodem';
import '@xterm/xterm/css/xterm.css';
interface TtydTerminal extends Terminal {
fit(): void;
}
declare global {
interface Window {
term: TtydTerminal;
}
}
enum Command {
// server side
OUTPUT = '0',
SET_WINDOW_TITLE = '1',
SET_PREFERENCES = '2',
// client side
INPUT = '0',
RESIZE_TERMINAL = '1',
PAUSE = '2',
RESUME = '3',
}
type Preferences = ITerminalOptions & ClientOptions;
export type RendererType = 'dom' | 'canvas' | 'webgl';
export interface ClientOptions {
rendererType: RendererType;
disableLeaveAlert: boolean;
disableResizeOverlay: boolean;
enableZmodem: boolean;
enableTrzsz: boolean;
enableSixel: boolean;
titleFixed?: string;
isWindows: boolean;
trzszDragInitTimeout: number;
unicodeVersion: string;
closeOnDisconnect: boolean;
}
export interface FlowControl {
limit: number;
highWater: number;
lowWater: number;
}
export interface XtermOptions {
wsUrl: string;
tokenUrl: string;
flowControl: FlowControl;
clientOptions: ClientOptions;
termOptions: ITerminalOptions;
}
function toDisposable(f: () => void): IDisposable {
return { dispose: f };
}
function addEventListener(target: EventTarget, type: string, listener: EventListener): IDisposable {
target.addEventListener(type, listener);
return toDisposable(() => target.removeEventListener(type, listener));
}
export class Xterm {
private disposables: IDisposable[] = [];
private textEncoder = new TextEncoder();
private textDecoder = new TextDecoder();
private written = 0;
private pending = 0;
private terminal: Terminal;
private fitAddon = new FitAddon();
private overlayAddon = new OverlayAddon();
private clipboardAddon = new ClipboardAddon();
private webLinksAddon = new WebLinksAddon();
private webglAddon?: WebglAddon;
private canvasAddon?: CanvasAddon;
private zmodemAddon?: ZmodemAddon;
private socket?: WebSocket;
private token: string;
private opened = false;
private title?: string;
private titleFixed?: string;
private resizeOverlay = true;
private reconnect = true;
private doReconnect = true;
private closeOnDisconnect = false;
private intervalID: NodeJS.Timeout;
private writeFunc = (data: ArrayBuffer) => this.writeData(new Uint8Array(data));
private status = false;
private titleStatus = false;
private checkStatus = false;
private connectStatus = false;
private beforeCommand?: string;
constructor(
private options: XtermOptions,
private sendCb: () => void
) {}
dispose() {
for (const d of this.disposables) {
d.dispose();
}
this.disposables.length = 0;
}
@bind
private register<T extends IDisposable>(d: T): T {
this.disposables.push(d);
return d;
}
@bind
public sendFile(files: FileList) {
this.zmodemAddon?.sendFile(files);
}
@bind
public async refreshToken() {
try {
const resp = await fetch(this.options.tokenUrl);
if (resp.ok) {
const json = await resp.json();
this.token = json.token;
}
} catch (e) {
console.error(`[ttyd] fetch ${this.options.tokenUrl}: `, e);
}
}
@bind
private onWindowUnload(event: BeforeUnloadEvent) {
event.preventDefault();
if (this.socket?.readyState === WebSocket.OPEN) {
const message = 'Close terminal? this will also terminate the command.';
event.returnValue = message;
return message;
}
return undefined;
}
@bind
public open(parent: HTMLElement) {
this.terminal = new Terminal(this.options.termOptions);
const { terminal, fitAddon, overlayAddon, clipboardAddon, webLinksAddon } = this;
window.term = terminal as TtydTerminal;
window.term.fit = () => {
this.fitAddon.fit();
};
terminal.loadAddon(fitAddon);
terminal.loadAddon(overlayAddon);
terminal.loadAddon(clipboardAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(parent);
fitAddon.fit();
}
@bind
private initListeners() {
const { terminal, fitAddon, overlayAddon, register, sendData } = this;
register(
terminal.onTitleChange(data => {
if (data && data !== '' && !this.titleFixed) {
document.title = data + ' | ' + this.title;
}
})
);
register(
terminal.onData(data =>
{
if (this.status) {
sendData(data);
} else {
this.writeData('\b \b');
}
})
);
register(terminal.onBinary(data => sendData(Uint8Array.from(data, v => v.charCodeAt(0)))));
register(
terminal.onResize(({ cols, rows }) => {
const msg = JSON.stringify({ columns: cols, rows: rows });
this.socket?.send(this.textEncoder.encode(Command.RESIZE_TERMINAL + msg));
if (this.resizeOverlay) overlayAddon.showOverlay(`${cols}x${rows}`, 300);
})
);
register(
terminal.onSelectionChange(() => {
if (this.terminal.getSelection() === '') return;
try {
document.execCommand('copy');
} catch (e) {
return;
}
this.overlayAddon?.showOverlay('\u2702', 200);
})
);
register(addEventListener(window, 'resize', () => fitAddon.fit()));
register(addEventListener(window, 'beforeunload', this.onWindowUnload));
}
@bind
public writeData(data: string | Uint8Array) {
const { terminal, textEncoder } = this;
const { limit, highWater, lowWater } = this.options.flowControl;
this.written += data.length;
if (this.written > limit) {
terminal.write(data, () => {
this.pending = Math.max(this.pending - 1, 0);
if (this.pending < lowWater) {
this.socket?.send(textEncoder.encode(Command.RESUME));
}
});
this.pending++;
this.written = 0;
if (this.pending > highWater) {
this.socket?.send(textEncoder.encode(Command.PAUSE));
}
} else {
terminal.write(data);
}
}
@bind
public sendData(data: string | Uint8Array) {
const { socket, textEncoder } = this;
if (socket?.readyState !== WebSocket.OPEN) return;
if (typeof data === 'string') {
const payload = new Uint8Array(data.length * 3 + 1);
payload[0] = Command.INPUT.charCodeAt(0);
const stats = textEncoder.encodeInto(data, payload.subarray(1));
socket.send(payload.subarray(0, (stats.written as number) + 1));
} else {
const payload = new Uint8Array(data.length + 1);
payload[0] = Command.INPUT.charCodeAt(0);
payload.set(data, 1);
socket.send(payload);
}
}
@bind
public changeUrl(ip: string, port: string) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this.options.wsUrl = [protocol, '//' + ip + ':' + port +'/ws', window.location.search].join('');
this.options.tokenUrl = [window.location.protocol, '//' + ip + ':' + port +'/token'].join('');
}
@bind
public changeStatus(v: boolean){
this.status = v;
}
@bind
public connect() {
this.socket = new WebSocket(this.options.wsUrl, ['tty']);
const { socket, register } = this;
socket.binaryType = 'arraybuffer';
register(addEventListener(socket, 'open', this.onSocketOpen));
register(addEventListener(socket, 'message', this.onSocketData as EventListener));
register(addEventListener(socket, 'close', this.onSocketClose as EventListener));
register(addEventListener(socket, 'error', () => (this.doReconnect = false)));
const options = new URLSearchParams(decodeURIComponent(window.location.search));
if (options.get('type') === 'docker') {
this.intervalID = setInterval(this.loadCommand, 3000);
}
}
@bind
private onSocketOpen() {
console.log('[ttyd] websocket connection opened');
const { textEncoder, terminal, overlayAddon } = this;
const msg = JSON.stringify({ AuthToken: this.token, columns: terminal.cols, rows: terminal.rows });
this.socket?.send(textEncoder.encode(msg));
if (this.opened) {
terminal.reset();
terminal.options.disableStdin = false;
overlayAddon.showOverlay('Reconnected', 300);
} else {
this.opened = true;
}
this.doReconnect = this.reconnect;
this.initListeners();
terminal.focus();
}
@bind
private onSocketClose(event: CloseEvent) {
console.log(`[ttyd] websocket connection closed with code: ${event.code}`);
const { refreshToken, connect, doReconnect, overlayAddon } = this;
overlayAddon.showOverlay('Connection Closed');
this.dispose();
// 1000: CLOSE_NORMAL
if (event.code !== 1000 && doReconnect) {
overlayAddon.showOverlay('Reconnecting...');
refreshToken().then(connect);
} else if (this.closeOnDisconnect) {
window.close();
} else {
const { terminal } = this;
const keyDispose = terminal.onKey(e => {
const event = e.domEvent;
if (event.key === 'Enter') {
keyDispose.dispose();
overlayAddon.showOverlay('Reconnecting...');
refreshToken().then(connect);
}
});
overlayAddon.showOverlay('Press ⏎ to Reconnect');
}
}
@bind
private loadCommand() {
const options = new URLSearchParams(decodeURIComponent(window.location.search));
const params = new URLSearchParams({
repo: options.get('repoid') as string,
user: options.get('userid') as string,
});
fetch(
'http://' + options.get('domain') + ':'+ options.get('port') +'/' +
options.get('user') +
'/' +
options.get('repo') +
'/devcontainer/command?' +
params
)
.then(response => response.json())
.then(data => {
if (data.status !== '4' && data.status !== '0') {
this.sendData(data.command);
} else {
clearInterval(this.intervalID);
if (data.status === '4') {
fetch(
'http://' + options.get('domain') + ':'+ options.get('port') +'/' +
options.get('user') +
'/' +
options.get('repo') +
'/devcontainer/command?' +
params
)
.then(response => response.json())
.then(data => {
this.sendData(data.command);
})
.catch(error => {
console.error('Error:', error);
});
}
}
})
.catch(error => {
console.error('Error:', error);
});
}
@bind
private parseOptsFromUrlQuery(query: string): Preferences {
const { terminal } = this;
const { clientOptions } = this.options;
const prefs = {} as Preferences;
const queryObj = Array.from(new URLSearchParams(query) as unknown as Iterable<[string, string]>);
for (const [k, queryVal] of queryObj) {
let v = clientOptions[k];
if (v === undefined) v = terminal.options[k];
switch (typeof v) {
case 'boolean':
prefs[k] = queryVal === 'true' || queryVal === '1';
break;
case 'number':
case 'bigint':
prefs[k] = Number.parseInt(queryVal, 10);
break;
case 'string':
prefs[k] = queryVal;
break;
case 'object':
prefs[k] = JSON.parse(queryVal);
break;
default:
console.warn(`[ttyd] maybe unknown option: ${k}=${queryVal}, treating as string`);
prefs[k] = queryVal;
break;
}
}
return prefs;
}
@bind
private onSocketData(event: MessageEvent) {
const { textDecoder } = this;
const rawData = event.data as ArrayBuffer;
const cmd = String.fromCharCode(new Uint8Array(rawData)[0]);
const data = rawData.slice(1);
switch (cmd) {
case Command.OUTPUT:
const options = new URLSearchParams(decodeURIComponent(window.location.search));
if (options.get('type') === 'docker') {
if (
this.status === false &&
textDecoder.decode(data).replace(/\s/g, '').includes('Successfully connected to the container'.replace(/\s/g, ''))
) {
if(this.connectStatus == true){
this.status = true;
}
this.connectStatus = true;
}
if (this.checkStatus) {
if (textDecoder.decode(data).replace(/\s/g, '').includes('You have out the container. Please refresh the terminal to reconnect.'.replace(/\s/g, ''))) {
this.checkStatus = false;
this.status = false;
this.connectStatus = false;
}
if(textDecoder.decode(data).includes('\x1b')){
this.checkStatus = false;
}
}
if (this.titleStatus && textDecoder.decode(data).includes('\x1b')) {
this.titleStatus = false;
this.checkStatus = true;
this.sendData(
`echo $WEB_TERMINAL\n`
);
}
if (
!(this.status === false && (textDecoder.decode(data).includes('\x1b') || textDecoder.decode(data).replace(/\s/g, '').includes('docker'))) &&
this.titleStatus !== true &&
this.checkStatus !== true
){
this.writeFunc(data);
}
if (textDecoder.decode(data).replace(/\s/g, '').includes('exit')) {
this.titleStatus = true;
}
} else {
this.writeFunc(data);
}
break;
case Command.SET_WINDOW_TITLE:
console.log('SET_WINDOW_TITLESET_WINDOW_TITLE');
this.title = textDecoder.decode(data);
document.title = this.title;
break;
case Command.SET_PREFERENCES:
console.log('SET_PREFERENCESSET_PREFERENCESSET_PREFERENCES');
this.applyPreferences({
...this.options.clientOptions,
...JSON.parse(textDecoder.decode(data)),
...this.parseOptsFromUrlQuery(window.location.search),
} as Preferences);
break;
default:
console.warn(`[ttyd] unknown command: ${cmd}`);
break;
}
}
@bind
private applyPreferences(prefs: Preferences) {
const { terminal, fitAddon, register } = this;
if (prefs.enableZmodem || prefs.enableTrzsz) {
this.zmodemAddon = new ZmodemAddon({
zmodem: prefs.enableZmodem,
trzsz: prefs.enableTrzsz,
windows: prefs.isWindows,
trzszDragInitTimeout: prefs.trzszDragInitTimeout,
onSend: this.sendCb,
sender: this.sendData,
writer: this.writeData,
});
this.writeFunc = data => this.zmodemAddon?.consume(data);
terminal.loadAddon(register(this.zmodemAddon));
}
for (const [key, value] of Object.entries(prefs)) {
switch (key) {
case 'rendererType':
this.setRendererType(value);
break;
case 'disableLeaveAlert':
if (value) {
window.removeEventListener('beforeunload', this.onWindowUnload);
console.log('[ttyd] Leave site alert disabled');
}
break;
case 'disableResizeOverlay':
if (value) {
console.log('[ttyd] Resize overlay disabled');
this.resizeOverlay = false;
}
break;
case 'disableReconnect':
if (value) {
console.log('[ttyd] Reconnect disabled');
this.reconnect = false;
this.doReconnect = false;
}
break;
case 'enableZmodem':
if (value) console.log('[ttyd] Zmodem enabled');
break;
case 'enableTrzsz':
if (value) console.log('[ttyd] trzsz enabled');
break;
case 'trzszDragInitTimeout':
if (value) console.log(`[ttyd] trzsz drag init timeout: ${value}`);
break;
case 'enableSixel':
if (value) {
terminal.loadAddon(register(new ImageAddon()));
console.log('[ttyd] Sixel enabled');
}
break;
case 'closeOnDisconnect':
if (value) {
console.log('[ttyd] close on disconnect enabled (Reconnect disabled)');
this.closeOnDisconnect = true;
this.reconnect = false;
this.doReconnect = false;
}
break;
case 'titleFixed':
if (!value || value === '') return;
console.log(`[ttyd] setting fixed title: ${value}`);
this.titleFixed = value;
document.title = value;
break;
case 'isWindows':
if (value) console.log('[ttyd] is windows');
break;
case 'unicodeVersion':
switch (value) {
case 6:
case '6':
console.log('[ttyd] setting Unicode version: 6');
break;
case 11:
case '11':
default:
console.log('[ttyd] setting Unicode version: 11');
terminal.loadAddon(new Unicode11Addon());
terminal.unicode.activeVersion = '11';
break;
}
break;
default:
console.log(`[ttyd] option: ${key}=${JSON.stringify(value)}`);
if (terminal.options[key] instanceof Object) {
terminal.options[key] = Object.assign({}, terminal.options[key], value);
} else {
terminal.options[key] = value;
}
if (key.indexOf('font') === 0) fitAddon.fit();
break;
}
}
}
@bind
private setRendererType(value: RendererType) {
const { terminal } = this;
const disposeCanvasRenderer = () => {
try {
this.canvasAddon?.dispose();
} catch {
// ignore
}
this.canvasAddon = undefined;
};
const disposeWebglRenderer = () => {
try {
this.webglAddon?.dispose();
} catch {
// ignore
}
this.webglAddon = undefined;
};
const enableCanvasRenderer = () => {
if (this.canvasAddon) return;
this.canvasAddon = new CanvasAddon();
disposeWebglRenderer();
try {
this.terminal.loadAddon(this.canvasAddon);
console.log('[ttyd] canvas renderer loaded');
} catch (e) {
console.log('[ttyd] canvas renderer could not be loaded, falling back to dom renderer', e);
disposeCanvasRenderer();
}
};
const enableWebglRenderer = () => {
if (this.webglAddon) return;
this.webglAddon = new WebglAddon();
disposeCanvasRenderer();
try {
this.webglAddon.onContextLoss(() => {
this.webglAddon?.dispose();
});
terminal.loadAddon(this.webglAddon);
console.log('[ttyd] WebGL renderer loaded');
} catch (e) {
console.log('[ttyd] WebGL renderer could not be loaded, falling back to canvas renderer', e);
disposeWebglRenderer();
enableCanvasRenderer();
}
};
switch (value) {
case 'canvas':
enableCanvasRenderer();
break;
case 'webgl':
enableWebglRenderer();
break;
case 'dom':
disposeWebglRenderer();
disposeCanvasRenderer();
console.log('[ttyd] dom renderer loaded');
break;
default:
break;
}
}
}

BIN
html/src/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

9
html/src/index.tsx Normal file
View File

@@ -0,0 +1,9 @@
if (process.env.NODE_ENV === 'development') {
require('preact/debug');
}
import 'whatwg-fetch';
import { h, render } from 'preact';
import { App } from './components/app';
import './style/index.scss';
render(<App />, document.body);

18
html/src/style/index.scss Normal file
View File

@@ -0,0 +1,18 @@
html,
body {
height: 100%;
min-height: 100%;
margin: 0;
overflow: hidden;
}
#terminal-container {
width: auto;
height: 100%;
margin: 0 auto;
padding: 0;
.terminal {
padding: 5px;
height: calc(100% - 10px);
}
}

18
html/src/template.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="mobile-web-app-capable" content="yes">
<title><%= htmlWebpackPlugin.options.title %></title>
<link inline rel="icon" type="image/png" href="favicon.png">
<% for (const css in htmlWebpackPlugin.files.css) { %>
<link inline rel="stylesheet" type="text/css" href="<%= htmlWebpackPlugin.files.css[css] %>">
<% } %>
</head>
<body>
<% for (const js in htmlWebpackPlugin.files.js) { %>
<script inline type="text/javascript" src="<%= htmlWebpackPlugin.files.js[js] %>"></script>
<% } %>
</body>
</html>

19
html/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "./node_modules/gts/tsconfig-google.json",
"compilerOptions": {
"moduleResolution": "node",
"esModuleInterop": true,
"jsx": "react",
"jsxFactory": "h",
"allowJs": true,
"noImplicitAny": false,
"declaration": false,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"lib": ["es2019", "dom"],
},
"include": [
"src/**/*.tsx",
"src/**/*.ts"
]
}

101
html/webpack.config.js Normal file
View File

@@ -0,0 +1,101 @@
const path = require('path');
const { merge } = require('webpack-merge');
const ESLintPlugin = require('eslint-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const devMode = process.env.NODE_ENV !== 'production';
const baseConfig = {
context: path.resolve(__dirname, 'src'),
entry: {
app: './index.tsx',
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: devMode ? '[name].js' : '[name].[contenthash].js',
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.s?[ac]ss$/,
use: [devMode ? 'style-loader' : MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
plugins: [
new ESLintPlugin({
context: path.resolve(__dirname, '.'),
extensions: ['js', 'jsx', 'ts', 'tsx'],
}),
new CopyWebpackPlugin({
patterns: [{ from: './favicon.png', to: '.' }],
}),
new MiniCssExtractPlugin({
filename: devMode ? '[name].css' : '[name].[contenthash].css',
chunkFilename: devMode ? '[id].css' : '[id].[contenthash].css',
}),
new HtmlWebpackPlugin({
inject: false,
minify: {
removeComments: true,
collapseWhitespace: true,
},
title: 'ttyd - Terminal',
template: './template.html',
}),
],
performance: {
hints: false,
},
};
const devConfig = {
mode: 'development',
devServer: {
static: path.join(__dirname, 'dist'),
compress: true,
port: 9000,
client: {
overlay: {
errors: true,
warnings: false,
},
},
proxy: [
{
context: ['/token', '/ws'],
target: 'http://localhost:7681',
ws: true,
},
],
webSocketServer: {
type: 'sockjs',
options: {
path: '/sockjs-node',
},
},
},
devtool: 'inline-source-map',
};
const prodConfig = {
mode: 'production',
optimization: {
minimizer: [new TerserPlugin(), new CssMinimizerPlugin()],
},
devtool: 'source-map',
};
module.exports = merge(baseConfig, devMode ? devConfig : prodConfig);

8066
html/yarn.lock Normal file

File diff suppressed because it is too large Load Diff