zmodem support (fixes #693)

This commit is contained in:
Eugene Pankov 2019-12-31 20:10:37 +01:00
parent c11a10144e
commit c40294628a
9 changed files with 251 additions and 34 deletions

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -56,6 +56,11 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit
frontendReady = new Subject<void>()
size: ResizeEvent
/**
* Enables normall passthrough from session output to terminal input
*/
enablePassthrough = true
protected logger: Logger
protected output = new Subject<string>()
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(() => {

View file

@ -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()
}

View file

@ -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 },

View file

@ -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<string>()
protected binaryOutput = new Subject<Buffer>()
protected closed = new Subject<void>()
protected destroyed = new Subject<void>()
private initialDataBuffer = ''
private initialDataBuffer = Buffer.from('')
private initialDataBufferReleased = false
get output$ (): Observable<string> { return this.output }
get binaryOutput$ (): Observable<Buffer> { return this.binaryOutput }
get closed$ (): Observable<void> { return this.closed }
get destroyed$ (): Observable<void> { 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<void> {
@ -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

View file

@ -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'))
}
}
}

View file

@ -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"