mirror of
https://github.com/Eugeny/tabby
synced 2024-11-15 01:17:14 +00:00
zmodem support (fixes #693)
This commit is contained in:
parent
c11a10144e
commit
c40294628a
9 changed files with 251 additions and 34 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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
|
||||
|
|
176
terminus-terminal/src/zmodem.ts
Normal file
176
terminus-terminal/src/zmodem.ts
Normal 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'))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue