From c40294628a0a628ebc9cc695ac05b60602caf159 Mon Sep 17 00:00:00 2001 From: Eugene Pankov Date: Tue, 31 Dec 2019 20:10:37 +0100 Subject: [PATCH] zmodem support (fixes #693) --- terminus-ssh/src/api.ts | 2 +- .../src/components/sshTab.component.pug | 6 +- terminus-terminal/package.json | 3 +- .../src/api/baseTerminalTab.component.ts | 17 +- terminus-terminal/src/bufferizedPTY.js | 10 +- terminus-terminal/src/index.ts | 2 + .../src/services/sessions.service.ts | 44 +++-- terminus-terminal/src/zmodem.ts | 176 ++++++++++++++++++ terminus-terminal/yarn.lock | 25 +++ 9 files changed, 251 insertions(+), 34 deletions(-) create mode 100644 terminus-terminal/src/zmodem.ts diff --git a/terminus-ssh/src/api.ts b/terminus-ssh/src/api.ts index 4931b3aa..62abba41 100644 --- a/terminus-ssh/src/api.ts +++ b/terminus-ssh/src/api.ts @@ -112,7 +112,7 @@ export class SSHSession extends BaseSession { this.shell.on('data', data => { const dataString = data.toString() - this.emitOutput(dataString) + this.emitOutput(data) if (this.scripts) { let found = false diff --git a/terminus-ssh/src/components/sshTab.component.pug b/terminus-ssh/src/components/sshTab.component.pug index e4cfa3c6..0bb90c35 100644 --- a/terminus-ssh/src/components/sshTab.component.pug +++ b/terminus-ssh/src/components/sshTab.component.pug @@ -2,9 +2,9 @@ .btn.btn-outline-secondary.reveal-button i.fas.fa-ellipsis-h .toolbar - i.fas.fa-circle.text-success.mr-2(*ngIf='session.open') - i.fas.fa-circle.text-danger.mr-2(*ngIf='!session.open') - strong.mr-auto {{session.connection.user}}@{{session.connection.host}}:{{session.connection.port}} + i.fas.fa-circle.text-success.mr-2(*ngIf='session && session.open') + i.fas.fa-circle.text-danger.mr-2(*ngIf='!session || !session.open') + strong.mr-auto(*ngIf='session') {{session.connection.user}}@{{session.connection.host}}:{{session.connection.port}} button.btn.btn-secondary((click)='showPortForwarding()') i.fas.fa-plug span Ports diff --git a/terminus-terminal/package.json b/terminus-terminal/package.json index c630d3c1..3d9a0be2 100644 --- a/terminus-terminal/package.json +++ b/terminus-terminal/package.json @@ -31,7 +31,8 @@ "xterm-addon-fit": "^0.4.0-beta2", "xterm-addon-ligatures": "^0.2.1", "xterm-addon-search": "^0.4.0", - "xterm-addon-webgl": "^0.4.0" + "xterm-addon-webgl": "^0.4.0", + "zmodem.js": "^0.1.9" }, "peerDependencies": { "@angular/animations": "^7", diff --git a/terminus-terminal/src/api/baseTerminalTab.component.ts b/terminus-terminal/src/api/baseTerminalTab.component.ts index 5b388bcf..406a8a0b 100644 --- a/terminus-terminal/src/api/baseTerminalTab.component.ts +++ b/terminus-terminal/src/api/baseTerminalTab.component.ts @@ -56,6 +56,11 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit frontendReady = new Subject() size: ResizeEvent + /** + * Enables normall passthrough from session output to terminal input + */ + enablePassthrough = true + protected logger: Logger protected output = new Subject() private sessionCloseSubscription: Subscription @@ -248,7 +253,7 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit const percentage = percentageMatch[3] ? parseFloat(percentageMatch[2]) : parseInt(percentageMatch[2]) if (percentage > 0 && percentage <= 100) { this.setProgress(percentage) - this.logger.debug('Detected progress:', percentage) + // this.logger.debug('Detected progress:', percentage) } } else { this.setProgress(null) @@ -410,10 +415,12 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit protected attachSessionHandlers () { // this.session.output$.bufferTime(10).subscribe((datas) => { this.session.output$.subscribe(data => { - this.zone.run(() => { - this.output.next(data) - this.write(data) - }) + if (this.enablePassthrough) { + this.zone.run(() => { + this.output.next(data) + this.write(data) + }) + } }) this.sessionCloseSubscription = this.session.closed$.subscribe(() => { diff --git a/terminus-terminal/src/bufferizedPTY.js b/terminus-terminal/src/bufferizedPTY.js index b2e1ee7d..3be1d206 100644 --- a/terminus-terminal/src/bufferizedPTY.js +++ b/terminus-terminal/src/bufferizedPTY.js @@ -8,7 +8,7 @@ module.exports = function patchPTYModule (mod) { mod.spawn = (file, args, opt) => { let terminal = oldSpawn(file, args, opt) let timeout = null - let buffer = '' + let buffer = Buffer.from('') let lastFlush = 0 let nextTimeout = 0 @@ -19,11 +19,11 @@ module.exports = function patchPTYModule (mod) { const maxWindow = 100 function flush () { - if (buffer) { + if (buffer.length) { terminal.emit('data-buffered', buffer) } lastFlush = Date.now() - buffer = '' + buffer = Buffer.from('') } function reschedule () { @@ -38,12 +38,12 @@ module.exports = function patchPTYModule (mod) { } terminal.on('data', data => { - buffer += data + buffer = Buffer.concat([buffer, data]) if (Date.now() - lastFlush > maxWindow) { // Taking too much time buffering, flush to keep things interactive flush() } else { - if (Date.now() > nextTimeout - (maxWindow / 10)) { + if (Date.now() > nextTimeout - maxWindow / 10) { // Extend the window if it's expiring reschedule() } diff --git a/terminus-terminal/src/index.ts b/terminus-terminal/src/index.ts index 3af8fe7b..03eb55d2 100644 --- a/terminus-terminal/src/index.ts +++ b/terminus-terminal/src/index.ts @@ -37,6 +37,7 @@ import { TerminalHotkeyProvider } from './hotkeys' import { HyperColorSchemes } from './colorSchemes' import { NewTabContextMenu, CopyPasteContextMenu } from './contextMenu' import { SaveAsProfileContextMenu } from './tabContextMenu' +import { ZModemDecorator } from './zmodem' import { CmderShellProvider } from './shells/cmder' import { CustomShellProvider } from './shells/custom' @@ -76,6 +77,7 @@ import { XTermFrontend, XTermWebGLFrontend } from './frontends/xtermFrontend' { provide: HotkeyProvider, useClass: TerminalHotkeyProvider, multi: true }, { provide: TerminalColorSchemeProvider, useClass: HyperColorSchemes, multi: true }, { provide: TerminalDecorator, useClass: PathDropDecorator, multi: true }, + { provide: TerminalDecorator, useClass: ZModemDecorator, multi: true }, { provide: ShellProvider, useClass: WindowsDefaultShellProvider, multi: true }, { provide: ShellProvider, useClass: MacOSDefaultShellProvider, multi: true }, diff --git a/terminus-terminal/src/services/sessions.service.ts b/terminus-terminal/src/services/sessions.service.ts index 410e39c0..2055a44f 100644 --- a/terminus-terminal/src/services/sessions.service.ts +++ b/terminus-terminal/src/services/sessions.service.ts @@ -30,8 +30,8 @@ export interface ChildProcess { const windowsDirectoryRegex = /([a-zA-Z]:[^\:\[\]\?\"\<\>\|]+)/mi const catalinaDataVolumePrefix = '/System/Volumes/Data' -const OSC1337Prefix = '\x1b]1337;' -const OSC1337Suffix = '\x07' +const OSC1337Prefix = Buffer.from('\x1b]1337;') +const OSC1337Suffix = Buffer.from('\x07') /** * A session object for a [[BaseTerminalTabComponent]] @@ -42,27 +42,31 @@ export abstract class BaseSession { name: string truePID: number protected output = new Subject() + protected binaryOutput = new Subject() protected closed = new Subject() protected destroyed = new Subject() - private initialDataBuffer = '' + private initialDataBuffer = Buffer.from('') private initialDataBufferReleased = false get output$ (): Observable { return this.output } + get binaryOutput$ (): Observable { return this.binaryOutput } get closed$ (): Observable { return this.closed } get destroyed$ (): Observable { return this.destroyed } - emitOutput (data: string) { + emitOutput (data: Buffer) { if (!this.initialDataBufferReleased) { - this.initialDataBuffer += data + this.initialDataBuffer = Buffer.concat([this.initialDataBuffer, data]) } else { - this.output.next(data) + this.output.next(data.toString()) + this.binaryOutput.next(data) } } releaseInitialDataBuffer () { this.initialDataBufferReleased = true - this.output.next(this.initialDataBuffer) - this.initialDataBuffer = '' + this.output.next(this.initialDataBuffer.toString()) + this.binaryOutput.next(this.initialDataBuffer) + this.initialDataBuffer = Buffer.from('') } async destroy (): Promise { @@ -71,6 +75,7 @@ export abstract class BaseSession { this.closed.next() this.destroyed.next() this.output.complete() + this.binaryOutput.complete() await this.gracefullyKillProcess() } } @@ -129,6 +134,7 @@ export class Session extends BaseSession { name: 'xterm-256color', cols: options.width || 80, rows: options.height || 30, + encoding: null, cwd, env: env, // `1` instead of `true` forces ConPTY even if unstable @@ -150,11 +156,11 @@ export class Session extends BaseSession { this.open = true - this.pty.on('data-buffered', data => { + this.pty.on('data-buffered', (data: Buffer) => { data = this.processOSC1337(data) this.emitOutput(data) if (process.platform === 'win32') { - this.guessWindowsCWD(data) + this.guessWindowsCWD(data.toString()) } }) @@ -168,7 +174,7 @@ export class Session extends BaseSession { this.pty.on('close', () => { if (this.pauseAfterExit) { - this.emitOutput('\r\nPress any key to close\r\n') + this.emitOutput(Buffer.from('\r\nPress any key to close\r\n')) } else if (this.open) { this.destroy() } @@ -177,19 +183,19 @@ export class Session extends BaseSession { this.pauseAfterExit = options.pauseAfterExit || false } - processOSC1337 (data: string) { + processOSC1337 (data: Buffer) { if (data.includes(OSC1337Prefix)) { - const preData = data.substring(0, data.indexOf(OSC1337Prefix)) - let params = data.substring(data.indexOf(OSC1337Prefix) + OSC1337Prefix.length) - const postData = params.substring(params.indexOf(OSC1337Suffix) + OSC1337Suffix.length) - params = params.substring(0, params.indexOf(OSC1337Suffix)) + const preData = data.subarray(0, data.indexOf(OSC1337Prefix)) + let params = data.subarray(data.indexOf(OSC1337Prefix) + OSC1337Prefix.length) + const postData = params.subarray(params.indexOf(OSC1337Suffix) + OSC1337Suffix.length) + const paramString = params.subarray(0, params.indexOf(OSC1337Suffix)).toString() - if (params.startsWith('CurrentDir=')) { - this.reportedCWD = params.split('=')[1] + if (paramString.startsWith('CurrentDir=')) { + this.reportedCWD = paramString.split('=')[1] if (this.reportedCWD.startsWith('~')) { this.reportedCWD = os.homedir() + this.reportedCWD.substring(1) } - data = preData + postData + data = Buffer.concat([preData, postData]) } } return data diff --git a/terminus-terminal/src/zmodem.ts b/terminus-terminal/src/zmodem.ts new file mode 100644 index 00000000..4a6d25c2 --- /dev/null +++ b/terminus-terminal/src/zmodem.ts @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import * as ZModem from 'zmodem.js' +import * as fs from 'fs' +import * as path from 'path' +import { Subscription } from 'rxjs' +import { Injectable } from '@angular/core' +import { TerminalDecorator } from './api/decorator' +import { TerminalTabComponent } from './components/terminalTab.component' +import { LogService, Logger, ElectronService, HostAppService } from 'terminus-core' + +const SPACER = ' ' + +/** @hidden */ +@Injectable() +export class ZModemDecorator extends TerminalDecorator { + private subscriptions: Subscription[] = [] + private logger: Logger + private sentry + private activeSession: any = null + + constructor ( + log: LogService, + private electron: ElectronService, + private hostApp: HostAppService, + ) { + super() + this.logger = log.create('zmodem') + } + + attach (terminal: TerminalTabComponent): void { + this.sentry = new ZModem.Sentry({ + to_terminal: () => null, + sender: data => terminal.session.write(Buffer.from(data)), + on_detect: async detection => { + try { + terminal.enablePassthrough = false + await this.process(terminal, detection) + } finally { + terminal.enablePassthrough = true + } + }, + on_retract: () => { + this.showMessage(terminal, 'transfer cancelled') + }, + }) + setTimeout(() => { + this.subscriptions = [ + terminal.session.binaryOutput$.subscribe(data => { + const chunkSize = 1024 + for (let i = 0; i <= Math.floor(data.length / chunkSize); i++) { + try { + this.sentry.consume(data.subarray(i * chunkSize, (i + 1) * chunkSize)) + } catch (e) { + this.logger.error('protocol error', e) + this.activeSession.abort() + this.activeSession = null + terminal.enablePassthrough = true + return + } + } + }), + ] + }) + } + + async process (terminal, detection) { + this.showMessage(terminal, '[Terminus] ZModem session started') + const zsession = detection.confirm() + this.activeSession = zsession + this.logger.info('new session', zsession) + + if (zsession.type === 'send') { + const result = await this.electron.dialog.showOpenDialog( + this.hostApp.getWindow(), + { + buttonLabel: 'Send', + properties: ['multiSelections', 'openFile', 'treatPackageAsDirectory'], + }, + ) + if (result.canceled) { + zsession.close() + return + } + + let filesRemaining = result.filePaths.length + for (const filePath of result.filePaths) { + await this.sendFile(terminal, zsession, filePath, filesRemaining) + filesRemaining-- + } + this.activeSession = null + await zsession.close() + } else { + zsession.on('offer', xfer => { + this.receiveFile(terminal, xfer) + }) + + zsession.start() + + await new Promise(resolve => zsession.on('session_end', resolve)) + this.activeSession = null + } + } + + detach (_terminal: TerminalTabComponent): void { + for (const s of this.subscriptions) { + s.unsubscribe() + } + } + + private async receiveFile (terminal, xfer) { + const details = xfer.get_details() + this.showMessage(terminal, `🟡 Offered ${details.name}`, true) + this.logger.info('offered', xfer) + const result = await this.electron.dialog.showSaveDialog( + this.hostApp.getWindow(), + { + defaultPath: details.name, + }, + ) + if (!result.filePath) { + this.showMessage(terminal, `🔴 Rejected ${details.name}`) + xfer.skip() + return + } + const stream = fs.createWriteStream(result.filePath) + let bytesSent = 0 + await xfer.accept({ + on_input: chunk => { + stream.write(Buffer.from(chunk)) + bytesSent += chunk.length + this.showMessage(terminal, `🟡 Receiving ${details.name}: ${Math.round(100 * bytesSent / details.size)}%`, true) + }, + }) + this.showMessage(terminal, `✅ Received ${details.name}`) + stream.end() + } + + private async sendFile (terminal, zsession, filePath, filesRemaining) { + const stat = fs.statSync(filePath) + const offer = { + name: path.basename(filePath), + size: stat.size, + mode: stat.mode, + mtime: Math.floor(stat.mtimeMs / 1000), + files_remaining: filesRemaining, + bytes_remaining: stat.size, + } + this.logger.info('offering', offer) + this.showMessage(terminal, `🟡 Offering ${offer.name}`, true) + + const xfer = await zsession.send_offer(offer) + if (xfer) { + let bytesSent = 0 + const stream = fs.createReadStream(filePath) + stream.on('data', chunk => { + xfer.send(chunk) + bytesSent += chunk.length + this.showMessage(terminal, `🟡 Sending ${offer.name}: ${Math.round(100 * bytesSent / offer.size)}%`, true) + }) + await new Promise(resolve => stream.on('end', resolve)) + await xfer.end() + stream.close() + this.showMessage(terminal, `✅ Sent ${offer.name}`) + } else { + this.showMessage(terminal, `🔴 Other side rejected ${offer.name}`) + this.logger.warn('rejected by the other side') + } + } + + private showMessage (terminal, msg: string, overwrite = false) { + terminal.write(Buffer.from(`\r${msg}${SPACER}`)) + if (!overwrite) { + terminal.write(Buffer.from('\r\n')) + } + } +} diff --git a/terminus-terminal/yarn.lock b/terminus-terminal/yarn.lock index 2f138890..5b754fd6 100644 --- a/terminus-terminal/yarn.lock +++ b/terminus-terminal/yarn.lock @@ -22,6 +22,14 @@ connected-domain@^1.0.0: resolved "https://registry.yarnpkg.com/connected-domain/-/connected-domain-1.0.0.tgz#bfe77238c74be453a79f0cb6058deeb4f2358e93" integrity sha1-v+dyOMdL5FOnnwy2BY3utPI1jpM= +crc-32@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.0.tgz#cb2db6e29b88508e32d9dd0ec1693e7b41a18208" + integrity sha512-1uBwHxF+Y/4yF5G48fwnKq6QsIXheor3ZLPT80yGBV1oEUwpPojlEhQbWKVw1VwcTQyMGHK1/XMmTjmlsmTTGA== + dependencies: + exit-on-epipe "~1.0.1" + printj "~1.1.0" + dataurl@0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/dataurl/-/dataurl-0.1.0.tgz#1f4734feddec05ffe445747978d86759c4b33199" @@ -46,6 +54,11 @@ define-properties@^1.1.2: dependencies: object-keys "^1.0.12" +exit-on-epipe@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz#0bdd92e87d5285d267daa8171d0eb06159689692" + integrity sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw== + font-finder@^1.0.3, font-finder@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/font-finder/-/font-finder-1.0.4.tgz#2ca944954dd8d0e1b5bdc4c596cc08607761d89b" @@ -141,6 +154,11 @@ opentype.js@^0.8.0: dependencies: tiny-inflate "^1.0.2" +printj@~1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/printj/-/printj-1.1.2.tgz#d90deb2975a8b9f600fb3a1c94e3f4c53c78a222" + integrity sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ== + promise-stream-reader@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/promise-stream-reader/-/promise-stream-reader-1.0.1.tgz#4e793a79c9d49a73ccd947c6da9c127f12923649" @@ -245,3 +263,10 @@ yallist@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= + +zmodem.js@^0.1.9: + version "0.1.9" + resolved "https://registry.yarnpkg.com/zmodem.js/-/zmodem.js-0.1.9.tgz#8dda36d45091bbdf263819f961d3c1a20223daf7" + integrity sha512-xixLjW1eML0uiWULsXDInyfwNW9mqESzz7ra+2MWHNG2F5JINEkE5vzF5MigpPcLvrYoHdnehPcJwQZlDph3hQ== + dependencies: + crc-32 "^1.1.1"