diff options
Diffstat (limited to '')
-rw-r--r-- | html/src/components/terminal/xterm/addons/overlay.ts | 73 | ||||
-rw-r--r-- | html/src/components/terminal/xterm/addons/zmodem.ts | 180 |
2 files changed, 253 insertions, 0 deletions
diff --git a/html/src/components/terminal/xterm/addons/overlay.ts b/html/src/components/terminal/xterm/addons/overlay.ts new file mode 100644 index 0000000..6fa5a92 --- /dev/null +++ b/html/src/components/terminal/xterm/addons/overlay.ts @@ -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'; + +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); + } +} diff --git a/html/src/components/terminal/xterm/addons/zmodem.ts b/html/src/components/terminal/xterm/addons/zmodem.ts new file mode 100644 index 0000000..8571f68 --- /dev/null +++ b/html/src/components/terminal/xterm/addons/zmodem.ts @@ -0,0 +1,180 @@ +import { bind } from 'decko'; +import { saveAs } from 'file-saver'; +import { IDisposable, ITerminalAddon, Terminal } from 'xterm'; +import * as Zmodem from 'zmodem.js/src/zmodem_browser'; +import { TrzszFilter } from 'trzsz'; + +export interface ZmodeOptions { + zmodem: boolean; + trzsz: boolean; + windows: boolean; + 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, + }); + 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]}`; + } +} |