mirror of
https://github.com/Eugeny/tabby.git
synced 2025-10-04 22:14:55 +00:00
233 lines
8.2 KiB
TypeScript
233 lines
8.2 KiB
TypeScript
import colors from 'ansi-colors'
|
|
import * as ZModem from 'zmodem.js'
|
|
import * as fs from 'fs'
|
|
import * as path from 'path'
|
|
import { Observable } from 'rxjs'
|
|
import { filter } from 'rxjs/operators'
|
|
import { Injectable } from '@angular/core'
|
|
import { TerminalDecorator } from '../api/decorator'
|
|
import { TerminalTabComponent } from '../components/terminalTab.component'
|
|
import { LogService, Logger, ElectronService, HostAppService, HotkeysService } from 'terminus-core'
|
|
|
|
const SPACER = ' '
|
|
|
|
/** @hidden */
|
|
@Injectable()
|
|
export class ZModemDecorator extends TerminalDecorator {
|
|
private logger: Logger
|
|
private activeSession: any = null
|
|
private cancelEvent: Observable<any>
|
|
|
|
constructor (
|
|
log: LogService,
|
|
hotkeys: HotkeysService,
|
|
private electron: ElectronService,
|
|
private hostApp: HostAppService,
|
|
) {
|
|
super()
|
|
this.logger = log.create('zmodem')
|
|
this.cancelEvent = hotkeys.hotkey$.pipe(filter(x => x === 'ctrl-c'))
|
|
}
|
|
|
|
attach (terminal: TerminalTabComponent): void {
|
|
const sentry = new ZModem.Sentry({
|
|
to_terminal: data => {
|
|
if (!terminal.enablePassthrough) {
|
|
terminal.write(data)
|
|
}
|
|
},
|
|
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.subscribeUntilDetached(terminal, terminal.session!.binaryOutput$.subscribe(data => {
|
|
const chunkSize = 1024
|
|
for (let i = 0; i <= Math.floor(data.length / chunkSize); i++) {
|
|
try {
|
|
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
|
|
}
|
|
}
|
|
}))
|
|
})
|
|
}
|
|
|
|
private async process (terminal, detection): Promise<void> {
|
|
this.showMessage(terminal, colors.bgBlue.black(' ZMODEM ') + ' Session started')
|
|
this.showMessage(terminal, '------------------------')
|
|
|
|
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)
|
|
})
|
|
|
|
zsession.start()
|
|
|
|
await new Promise(resolve => zsession.on('session_end', resolve))
|
|
this.activeSession = null
|
|
}
|
|
}
|
|
|
|
private async receiveFile (terminal, xfer, zsession) {
|
|
const details: {
|
|
name: string,
|
|
size: number,
|
|
} = xfer.get_details()
|
|
this.showMessage(terminal, colors.bgYellow.black(' 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, colors.bgRed.black(' Rejected ') + ' ' + details.name)
|
|
xfer.skip()
|
|
return
|
|
}
|
|
|
|
const stream = fs.createWriteStream(result.filePath)
|
|
let bytesSent = 0
|
|
let canceled = false
|
|
const cancelSubscription = this.cancelEvent.subscribe(() => {
|
|
if (terminal.hasFocus) {
|
|
try {
|
|
zsession._skip()
|
|
} catch {}
|
|
canceled = true
|
|
}
|
|
})
|
|
|
|
try {
|
|
await Promise.race([
|
|
xfer.accept({
|
|
on_input: chunk => {
|
|
if (canceled) {
|
|
return
|
|
}
|
|
stream.write(Buffer.from(chunk))
|
|
bytesSent += chunk.length
|
|
this.showMessage(terminal, colors.bgYellow.black(' ' + Math.round(100 * bytesSent / details.size).toString().padStart(3, ' ') + '% ') + ' ' + details.name, true)
|
|
},
|
|
}),
|
|
this.cancelEvent.toPromise(),
|
|
])
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
if (canceled) {
|
|
this.showMessage(terminal, colors.bgRed.black(' Canceled ') + ' ' + details.name)
|
|
} else {
|
|
this.showMessage(terminal, colors.bgGreen.black(' Received ') + ' ' + details.name)
|
|
}
|
|
} catch {
|
|
this.showMessage(terminal, colors.bgRed.black(' Error ') + ' ' + details.name)
|
|
}
|
|
|
|
cancelSubscription.unsubscribe()
|
|
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, colors.bgYellow.black(' Offered ') + ' ' + offer.name, true)
|
|
|
|
const xfer = await zsession.send_offer(offer)
|
|
if (xfer) {
|
|
let bytesSent = 0
|
|
let canceled = false
|
|
const stream = fs.createReadStream(filePath)
|
|
const cancelSubscription = this.cancelEvent.subscribe(() => {
|
|
if (terminal.hasFocus) {
|
|
canceled = true
|
|
}
|
|
})
|
|
|
|
stream.on('data', chunk => {
|
|
if (canceled) {
|
|
stream.close()
|
|
return
|
|
}
|
|
xfer.send(chunk)
|
|
bytesSent += chunk.length
|
|
this.showMessage(terminal, colors.bgYellow.black(' ' + Math.round(100 * bytesSent / offer.size).toString().padStart(3, ' ') + '% ') + offer.name, true)
|
|
})
|
|
|
|
await Promise.race([
|
|
new Promise(resolve => stream.on('end', resolve)),
|
|
this.cancelEvent.toPromise(),
|
|
])
|
|
|
|
await xfer.end()
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
if (canceled) {
|
|
this.showMessage(terminal, colors.bgRed.black(' Canceled ') + ' ' + offer.name)
|
|
} else {
|
|
this.showMessage(terminal, colors.bgGreen.black(' Sent ') + ' ' + offer.name)
|
|
}
|
|
|
|
stream.close()
|
|
cancelSubscription.unsubscribe()
|
|
} else {
|
|
this.showMessage(terminal, colors.bgRed.black(' 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'))
|
|
}
|
|
}
|
|
}
|