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 => { this.shell.on('data', data => {
const dataString = data.toString() const dataString = data.toString()
this.emitOutput(dataString) this.emitOutput(data)
if (this.scripts) { if (this.scripts) {
let found = false let found = false

View File

@ -2,9 +2,9 @@
.btn.btn-outline-secondary.reveal-button .btn.btn-outline-secondary.reveal-button
i.fas.fa-ellipsis-h i.fas.fa-ellipsis-h
.toolbar .toolbar
i.fas.fa-circle.text-success.mr-2(*ngIf='session.open') i.fas.fa-circle.text-success.mr-2(*ngIf='session && session.open')
i.fas.fa-circle.text-danger.mr-2(*ngIf='!session.open') i.fas.fa-circle.text-danger.mr-2(*ngIf='!session || !session.open')
strong.mr-auto {{session.connection.user}}@{{session.connection.host}}:{{session.connection.port}} strong.mr-auto(*ngIf='session') {{session.connection.user}}@{{session.connection.host}}:{{session.connection.port}}
button.btn.btn-secondary((click)='showPortForwarding()') button.btn.btn-secondary((click)='showPortForwarding()')
i.fas.fa-plug i.fas.fa-plug
span Ports span Ports

View File

@ -31,7 +31,8 @@
"xterm-addon-fit": "^0.4.0-beta2", "xterm-addon-fit": "^0.4.0-beta2",
"xterm-addon-ligatures": "^0.2.1", "xterm-addon-ligatures": "^0.2.1",
"xterm-addon-search": "^0.4.0", "xterm-addon-search": "^0.4.0",
"xterm-addon-webgl": "^0.4.0" "xterm-addon-webgl": "^0.4.0",
"zmodem.js": "^0.1.9"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/animations": "^7", "@angular/animations": "^7",

View File

@ -56,6 +56,11 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit
frontendReady = new Subject<void>() frontendReady = new Subject<void>()
size: ResizeEvent size: ResizeEvent
/**
* Enables normall passthrough from session output to terminal input
*/
enablePassthrough = true
protected logger: Logger protected logger: Logger
protected output = new Subject<string>() protected output = new Subject<string>()
private sessionCloseSubscription: Subscription private sessionCloseSubscription: Subscription
@ -248,7 +253,7 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit
const percentage = percentageMatch[3] ? parseFloat(percentageMatch[2]) : parseInt(percentageMatch[2]) const percentage = percentageMatch[3] ? parseFloat(percentageMatch[2]) : parseInt(percentageMatch[2])
if (percentage > 0 && percentage <= 100) { if (percentage > 0 && percentage <= 100) {
this.setProgress(percentage) this.setProgress(percentage)
this.logger.debug('Detected progress:', percentage) // this.logger.debug('Detected progress:', percentage)
} }
} else { } else {
this.setProgress(null) this.setProgress(null)
@ -410,10 +415,12 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit
protected attachSessionHandlers () { protected attachSessionHandlers () {
// this.session.output$.bufferTime(10).subscribe((datas) => { // this.session.output$.bufferTime(10).subscribe((datas) => {
this.session.output$.subscribe(data => { this.session.output$.subscribe(data => {
this.zone.run(() => { if (this.enablePassthrough) {
this.output.next(data) this.zone.run(() => {
this.write(data) this.output.next(data)
}) this.write(data)
})
}
}) })
this.sessionCloseSubscription = this.session.closed$.subscribe(() => { this.sessionCloseSubscription = this.session.closed$.subscribe(() => {

View File

@ -8,7 +8,7 @@ module.exports = function patchPTYModule (mod) {
mod.spawn = (file, args, opt) => { mod.spawn = (file, args, opt) => {
let terminal = oldSpawn(file, args, opt) let terminal = oldSpawn(file, args, opt)
let timeout = null let timeout = null
let buffer = '' let buffer = Buffer.from('')
let lastFlush = 0 let lastFlush = 0
let nextTimeout = 0 let nextTimeout = 0
@ -19,11 +19,11 @@ module.exports = function patchPTYModule (mod) {
const maxWindow = 100 const maxWindow = 100
function flush () { function flush () {
if (buffer) { if (buffer.length) {
terminal.emit('data-buffered', buffer) terminal.emit('data-buffered', buffer)
} }
lastFlush = Date.now() lastFlush = Date.now()
buffer = '' buffer = Buffer.from('')
} }
function reschedule () { function reschedule () {
@ -38,12 +38,12 @@ module.exports = function patchPTYModule (mod) {
} }
terminal.on('data', data => { terminal.on('data', data => {
buffer += data buffer = Buffer.concat([buffer, data])
if (Date.now() - lastFlush > maxWindow) { if (Date.now() - lastFlush > maxWindow) {
// Taking too much time buffering, flush to keep things interactive // Taking too much time buffering, flush to keep things interactive
flush() flush()
} else { } else {
if (Date.now() > nextTimeout - (maxWindow / 10)) { if (Date.now() > nextTimeout - maxWindow / 10) {
// Extend the window if it's expiring // Extend the window if it's expiring
reschedule() reschedule()
} }

View File

@ -37,6 +37,7 @@ import { TerminalHotkeyProvider } from './hotkeys'
import { HyperColorSchemes } from './colorSchemes' import { HyperColorSchemes } from './colorSchemes'
import { NewTabContextMenu, CopyPasteContextMenu } from './contextMenu' import { NewTabContextMenu, CopyPasteContextMenu } from './contextMenu'
import { SaveAsProfileContextMenu } from './tabContextMenu' import { SaveAsProfileContextMenu } from './tabContextMenu'
import { ZModemDecorator } from './zmodem'
import { CmderShellProvider } from './shells/cmder' import { CmderShellProvider } from './shells/cmder'
import { CustomShellProvider } from './shells/custom' import { CustomShellProvider } from './shells/custom'
@ -76,6 +77,7 @@ import { XTermFrontend, XTermWebGLFrontend } from './frontends/xtermFrontend'
{ provide: HotkeyProvider, useClass: TerminalHotkeyProvider, multi: true }, { provide: HotkeyProvider, useClass: TerminalHotkeyProvider, multi: true },
{ provide: TerminalColorSchemeProvider, useClass: HyperColorSchemes, multi: true }, { provide: TerminalColorSchemeProvider, useClass: HyperColorSchemes, multi: true },
{ provide: TerminalDecorator, useClass: PathDropDecorator, multi: true }, { provide: TerminalDecorator, useClass: PathDropDecorator, multi: true },
{ provide: TerminalDecorator, useClass: ZModemDecorator, multi: true },
{ provide: ShellProvider, useClass: WindowsDefaultShellProvider, multi: true }, { provide: ShellProvider, useClass: WindowsDefaultShellProvider, multi: true },
{ provide: ShellProvider, useClass: MacOSDefaultShellProvider, multi: true }, { provide: ShellProvider, useClass: MacOSDefaultShellProvider, multi: true },

View File

@ -30,8 +30,8 @@ export interface ChildProcess {
const windowsDirectoryRegex = /([a-zA-Z]:[^\:\[\]\?\"\<\>\|]+)/mi const windowsDirectoryRegex = /([a-zA-Z]:[^\:\[\]\?\"\<\>\|]+)/mi
const catalinaDataVolumePrefix = '/System/Volumes/Data' const catalinaDataVolumePrefix = '/System/Volumes/Data'
const OSC1337Prefix = '\x1b]1337;' const OSC1337Prefix = Buffer.from('\x1b]1337;')
const OSC1337Suffix = '\x07' const OSC1337Suffix = Buffer.from('\x07')
/** /**
* A session object for a [[BaseTerminalTabComponent]] * A session object for a [[BaseTerminalTabComponent]]
@ -42,27 +42,31 @@ export abstract class BaseSession {
name: string name: string
truePID: number truePID: number
protected output = new Subject<string>() protected output = new Subject<string>()
protected binaryOutput = new Subject<Buffer>()
protected closed = new Subject<void>() protected closed = new Subject<void>()
protected destroyed = new Subject<void>() protected destroyed = new Subject<void>()
private initialDataBuffer = '' private initialDataBuffer = Buffer.from('')
private initialDataBufferReleased = false private initialDataBufferReleased = false
get output$ (): Observable<string> { return this.output } get output$ (): Observable<string> { return this.output }
get binaryOutput$ (): Observable<Buffer> { return this.binaryOutput }
get closed$ (): Observable<void> { return this.closed } get closed$ (): Observable<void> { return this.closed }
get destroyed$ (): Observable<void> { return this.destroyed } get destroyed$ (): Observable<void> { return this.destroyed }
emitOutput (data: string) { emitOutput (data: Buffer) {
if (!this.initialDataBufferReleased) { if (!this.initialDataBufferReleased) {
this.initialDataBuffer += data this.initialDataBuffer = Buffer.concat([this.initialDataBuffer, data])
} else { } else {
this.output.next(data) this.output.next(data.toString())
this.binaryOutput.next(data)
} }
} }
releaseInitialDataBuffer () { releaseInitialDataBuffer () {
this.initialDataBufferReleased = true this.initialDataBufferReleased = true
this.output.next(this.initialDataBuffer) this.output.next(this.initialDataBuffer.toString())
this.initialDataBuffer = '' this.binaryOutput.next(this.initialDataBuffer)
this.initialDataBuffer = Buffer.from('')
} }
async destroy (): Promise<void> { async destroy (): Promise<void> {
@ -71,6 +75,7 @@ export abstract class BaseSession {
this.closed.next() this.closed.next()
this.destroyed.next() this.destroyed.next()
this.output.complete() this.output.complete()
this.binaryOutput.complete()
await this.gracefullyKillProcess() await this.gracefullyKillProcess()
} }
} }
@ -129,6 +134,7 @@ export class Session extends BaseSession {
name: 'xterm-256color', name: 'xterm-256color',
cols: options.width || 80, cols: options.width || 80,
rows: options.height || 30, rows: options.height || 30,
encoding: null,
cwd, cwd,
env: env, env: env,
// `1` instead of `true` forces ConPTY even if unstable // `1` instead of `true` forces ConPTY even if unstable
@ -150,11 +156,11 @@ export class Session extends BaseSession {
this.open = true this.open = true
this.pty.on('data-buffered', data => { this.pty.on('data-buffered', (data: Buffer) => {
data = this.processOSC1337(data) data = this.processOSC1337(data)
this.emitOutput(data) this.emitOutput(data)
if (process.platform === 'win32') { if (process.platform === 'win32') {
this.guessWindowsCWD(data) this.guessWindowsCWD(data.toString())
} }
}) })
@ -168,7 +174,7 @@ export class Session extends BaseSession {
this.pty.on('close', () => { this.pty.on('close', () => {
if (this.pauseAfterExit) { 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) { } else if (this.open) {
this.destroy() this.destroy()
} }
@ -177,19 +183,19 @@ export class Session extends BaseSession {
this.pauseAfterExit = options.pauseAfterExit || false this.pauseAfterExit = options.pauseAfterExit || false
} }
processOSC1337 (data: string) { processOSC1337 (data: Buffer) {
if (data.includes(OSC1337Prefix)) { if (data.includes(OSC1337Prefix)) {
const preData = data.substring(0, data.indexOf(OSC1337Prefix)) const preData = data.subarray(0, data.indexOf(OSC1337Prefix))
let params = data.substring(data.indexOf(OSC1337Prefix) + OSC1337Prefix.length) let params = data.subarray(data.indexOf(OSC1337Prefix) + OSC1337Prefix.length)
const postData = params.substring(params.indexOf(OSC1337Suffix) + OSC1337Suffix.length) const postData = params.subarray(params.indexOf(OSC1337Suffix) + OSC1337Suffix.length)
params = params.substring(0, params.indexOf(OSC1337Suffix)) const paramString = params.subarray(0, params.indexOf(OSC1337Suffix)).toString()
if (params.startsWith('CurrentDir=')) { if (paramString.startsWith('CurrentDir=')) {
this.reportedCWD = params.split('=')[1] this.reportedCWD = paramString.split('=')[1]
if (this.reportedCWD.startsWith('~')) { if (this.reportedCWD.startsWith('~')) {
this.reportedCWD = os.homedir() + this.reportedCWD.substring(1) this.reportedCWD = os.homedir() + this.reportedCWD.substring(1)
} }
data = preData + postData data = Buffer.concat([preData, postData])
} }
} }
return data 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" resolved "https://registry.yarnpkg.com/connected-domain/-/connected-domain-1.0.0.tgz#bfe77238c74be453a79f0cb6058deeb4f2358e93"
integrity sha1-v+dyOMdL5FOnnwy2BY3utPI1jpM= 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: dataurl@0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/dataurl/-/dataurl-0.1.0.tgz#1f4734feddec05ffe445747978d86759c4b33199" resolved "https://registry.yarnpkg.com/dataurl/-/dataurl-0.1.0.tgz#1f4734feddec05ffe445747978d86759c4b33199"
@ -46,6 +54,11 @@ define-properties@^1.1.2:
dependencies: dependencies:
object-keys "^1.0.12" 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: font-finder@^1.0.3, font-finder@^1.0.4:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/font-finder/-/font-finder-1.0.4.tgz#2ca944954dd8d0e1b5bdc4c596cc08607761d89b" resolved "https://registry.yarnpkg.com/font-finder/-/font-finder-1.0.4.tgz#2ca944954dd8d0e1b5bdc4c596cc08607761d89b"
@ -141,6 +154,11 @@ opentype.js@^0.8.0:
dependencies: dependencies:
tiny-inflate "^1.0.2" 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: promise-stream-reader@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/promise-stream-reader/-/promise-stream-reader-1.0.1.tgz#4e793a79c9d49a73ccd947c6da9c127f12923649" 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" version "2.1.2"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= 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"