1
1
mirror of https://github.com/Eugeny/tabby.git synced 2024-11-30 13:04:27 +03:00

made zmodem xfers cancelable

This commit is contained in:
Eugene Pankov 2020-02-16 22:57:54 +01:00
parent 58d2590495
commit 2d357d0ed2
5 changed files with 139 additions and 66 deletions

View File

@ -38,7 +38,7 @@ export abstract class BaseTabComponent {
*/ */
color: string|null = null color: string|null = null
protected hasFocus = false hasFocus = false
/** /**
* Ping this if your recovery state has been changed and you want * Ping this if your recovery state has been changed and you want

View File

@ -1,4 +1,5 @@
import { Injectable, Inject, NgZone, EventEmitter } from '@angular/core' import { Injectable, Inject, NgZone, EventEmitter } from '@angular/core'
import { Observable, Subject } from 'rxjs'
import { HotkeyDescription, HotkeyProvider } from '../api/hotkeyProvider' import { HotkeyDescription, HotkeyProvider } from '../api/hotkeyProvider'
import { stringifyKeySequence } from './hotkeys.util' import { stringifyKeySequence } from './hotkeys.util'
import { ConfigService } from '../services/config.service' import { ConfigService } from '../services/config.service'
@ -20,8 +21,17 @@ interface EventBufferEntry {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class HotkeysService { export class HotkeysService {
key = new EventEmitter<KeyboardEvent>() key = new EventEmitter<KeyboardEvent>()
/** @hidden */
matchedHotkey = new EventEmitter<string>() matchedHotkey = new EventEmitter<string>()
/**
* Fired for each recognized hotkey
*/
get hotkey$ (): Observable<string> { return this._hotkey }
globalHotkey = new EventEmitter<void>() globalHotkey = new EventEmitter<void>()
private _hotkey = new Subject<string>()
private currentKeystrokes: EventBufferEntry[] = [] private currentKeystrokes: EventBufferEntry[] = []
private disabledLevel = 0 private disabledLevel = 0
private hotkeyDescriptions: HotkeyDescription[] = [] private hotkeyDescriptions: HotkeyDescription[] = []
@ -49,6 +59,9 @@ export class HotkeysService {
this.getHotkeyDescriptions().then(hotkeys => { this.getHotkeyDescriptions().then(hotkeys => {
this.hotkeyDescriptions = hotkeys this.hotkeyDescriptions = hotkeys
}) })
// deprecated
this.hotkey$.subscribe(h => this.matchedHotkey.emit(h))
} }
/** /**
@ -71,7 +84,7 @@ export class HotkeysService {
const matched = this.getCurrentFullyMatchedHotkey() const matched = this.getCurrentFullyMatchedHotkey()
if (matched) { if (matched) {
console.log('Matched hotkey', matched) console.log('Matched hotkey', matched)
this.matchedHotkey.emit(matched) this._hotkey.next(matched)
this.clearCurrentKeystrokes() this.clearCurrentKeystrokes()
} }
}) })

View File

@ -1,16 +1,35 @@
import { Subscription } from 'rxjs'
import { BaseTerminalTabComponent } from './baseTerminalTab.component' import { BaseTerminalTabComponent } from './baseTerminalTab.component'
/** /**
* Extend to automatically run actions on new terminals * Extend to automatically run actions on new terminals
*/ */
export abstract class TerminalDecorator { export abstract class TerminalDecorator {
private smartSubscriptions = new Map<BaseTerminalTabComponent, Subscription[]>()
/** /**
* Called when a new terminal tab starts * Called when a new terminal tab starts
*/ */
attach (terminal: BaseTerminalTabComponent): void { } // eslint-disable-line attach (terminal: BaseTerminalTabComponent): void { } // eslint-disable-line
/** /**
* Called before a terminal tab is destroyed * Called before a terminal tab is destroyed.
* Make sure to call super()
*/ */
detach (terminal: BaseTerminalTabComponent): void { } // eslint-disable-line detach (terminal: BaseTerminalTabComponent): void {
for (const s of this.smartSubscriptions.get(terminal) || []) {
s.unsubscribe()
}
this.smartSubscriptions.delete(terminal)
}
/**
* Automatically cancel @subscription once detached from @terminal
*/
protected subscribeUntilDetached (terminal: BaseTerminalTabComponent, subscription: Subscription) {
if (!this.smartSubscriptions.has(terminal)) {
this.smartSubscriptions.set(terminal, [])
}
this.smartSubscriptions.get(terminal)?.push(subscription)
}
} }

View File

@ -1,4 +1,3 @@
import { Subscription } from 'rxjs'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { TerminalDecorator } from '../api/decorator' import { TerminalDecorator } from '../api/decorator'
import { TerminalTabComponent } from '../components/terminalTab.component' import { TerminalTabComponent } from '../components/terminalTab.component'
@ -6,21 +5,17 @@ import { TerminalTabComponent } from '../components/terminalTab.component'
/** @hidden */ /** @hidden */
@Injectable() @Injectable()
export class PathDropDecorator extends TerminalDecorator { export class PathDropDecorator extends TerminalDecorator {
private subscriptions: Subscription[] = []
attach (terminal: TerminalTabComponent): void { attach (terminal: TerminalTabComponent): void {
setTimeout(() => { setTimeout(() => {
this.subscriptions = [ this.subscribeUntilDetached(terminal, terminal.frontend.dragOver$.subscribe(event => {
terminal.frontend.dragOver$.subscribe(event => { event.preventDefault()
event.preventDefault() }))
}), this.subscribeUntilDetached(terminal, terminal.frontend.drop$.subscribe(event => {
terminal.frontend.drop$.subscribe(event => { for (const file of event.dataTransfer!.files as any) {
for (const file of event.dataTransfer!.files as any) { this.injectPath(terminal, file.path)
this.injectPath(terminal, file.path) }
} event.preventDefault()
event.preventDefault() }))
}),
]
}) })
} }
@ -31,10 +26,4 @@ export class PathDropDecorator extends TerminalDecorator {
path = path.replace(/\\/g, '\\\\') path = path.replace(/\\/g, '\\\\')
terminal.sendInput(path + ' ') terminal.sendInput(path + ' ')
} }
detach (_terminal: TerminalTabComponent): void {
for (const s of this.subscriptions) {
s.unsubscribe()
}
}
} }

View File

@ -1,29 +1,33 @@
/* eslint-disable @typescript-eslint/camelcase */ /* eslint-disable @typescript-eslint/camelcase */
import colors from 'ansi-colors'
import * as ZModem from 'zmodem.js' import * as ZModem from 'zmodem.js'
import * as fs from 'fs' import * as fs from 'fs'
import * as path from 'path' import * as path from 'path'
import { Subscription } from 'rxjs' import { Observable } from 'rxjs'
import { filter } from 'rxjs/operators'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { TerminalDecorator } from '../api/decorator' import { TerminalDecorator } from '../api/decorator'
import { TerminalTabComponent } from '../components/terminalTab.component' import { TerminalTabComponent } from '../components/terminalTab.component'
import { LogService, Logger, ElectronService, HostAppService } from 'terminus-core' import { LogService, Logger, ElectronService, HostAppService, HotkeysService } from 'terminus-core'
const SPACER = ' ' const SPACER = ' '
/** @hidden */ /** @hidden */
@Injectable() @Injectable()
export class ZModemDecorator extends TerminalDecorator { export class ZModemDecorator extends TerminalDecorator {
private subscriptions: Subscription[] = []
private logger: Logger private logger: Logger
private activeSession: any = null private activeSession: any = null
private cancelEvent: Observable<any>
constructor ( constructor (
log: LogService, log: LogService,
hotkeys: HotkeysService,
private electron: ElectronService, private electron: ElectronService,
private hostApp: HostAppService, private hostApp: HostAppService,
) { ) {
super() super()
this.logger = log.create('zmodem') this.logger = log.create('zmodem')
this.cancelEvent = hotkeys.hotkey$.pipe(filter(x => x === 'ctrl-c'))
} }
attach (terminal: TerminalTabComponent): void { attach (terminal: TerminalTabComponent): void {
@ -47,27 +51,27 @@ export class ZModemDecorator extends TerminalDecorator {
}, },
}) })
setTimeout(() => { setTimeout(() => {
this.subscriptions = [ this.subscribeUntilDetached(terminal, terminal.session.binaryOutput$.subscribe(data => {
terminal.session.binaryOutput$.subscribe(data => { const chunkSize = 1024
const chunkSize = 1024 for (let i = 0; i <= Math.floor(data.length / chunkSize); i++) {
for (let i = 0; i <= Math.floor(data.length / chunkSize); i++) { try {
try { sentry.consume(data.subarray(i * chunkSize, (i + 1) * chunkSize))
sentry.consume(data.subarray(i * chunkSize, (i + 1) * chunkSize)) } catch (e) {
} catch (e) { this.logger.error('protocol error', e)
this.logger.error('protocol error', e) this.activeSession.abort()
this.activeSession.abort() this.activeSession = null
this.activeSession = null terminal.enablePassthrough = true
terminal.enablePassthrough = true return
return
}
} }
}), }
] }))
}) })
} }
async process (terminal, detection) { async process (terminal, detection) {
this.showMessage(terminal, '[Terminus] ZModem session started') this.showMessage(terminal, colors.bgBlue.black(' ZMODEM ') + ' Session started')
this.showMessage(terminal, '------------------------')
const zsession = detection.confirm() const zsession = detection.confirm()
this.activeSession = zsession this.activeSession = zsession
this.logger.info('new session', zsession) this.logger.info('new session', zsession)
@ -94,7 +98,7 @@ export class ZModemDecorator extends TerminalDecorator {
await zsession.close() await zsession.close()
} else { } else {
zsession.on('offer', xfer => { zsession.on('offer', xfer => {
this.receiveFile(terminal, xfer) this.receiveFile(terminal, xfer, zsession)
}) })
zsession.start() zsession.start()
@ -104,15 +108,12 @@ export class ZModemDecorator extends TerminalDecorator {
} }
} }
detach (_terminal: TerminalTabComponent): void { private async receiveFile (terminal, xfer, zsession) {
for (const s of this.subscriptions) { const details: {
s.unsubscribe() name: string,
} size: number,
} } = xfer.get_details()
this.showMessage(terminal, colors.bgYellow.black(' Offered ') + ' ' + details.name, true)
private async receiveFile (terminal, xfer) {
const details = xfer.get_details()
this.showMessage(terminal, `🟡 Offered ${details.name}`, true)
this.logger.info('offered', xfer) this.logger.info('offered', xfer)
const result = await this.electron.dialog.showSaveDialog( const result = await this.electron.dialog.showSaveDialog(
this.hostApp.getWindow(), this.hostApp.getWindow(),
@ -121,20 +122,48 @@ export class ZModemDecorator extends TerminalDecorator {
}, },
) )
if (!result.filePath) { if (!result.filePath) {
this.showMessage(terminal, `🔴 Rejected ${details.name}`) this.showMessage(terminal, colors.bgRed.black(' Rejected ') + ' ' + details.name)
xfer.skip() xfer.skip()
return return
} }
const stream = fs.createWriteStream(result.filePath) const stream = fs.createWriteStream(result.filePath)
let bytesSent = 0 let bytesSent = 0
await xfer.accept({ let canceled = false
on_input: chunk => { const cancelSubscription = this.cancelEvent.subscribe(() => {
stream.write(Buffer.from(chunk)) if (terminal.hasFocus) {
bytesSent += chunk.length try {
this.showMessage(terminal, `🟡 Receiving ${details.name}: ${Math.round(100 * bytesSent / details.size)}%`, true) zsession._skip()
}, } catch {}
canceled = true
}
}) })
this.showMessage(terminal, `✅ Received ${details.name}`)
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(),
])
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() stream.end()
} }
@ -149,23 +178,46 @@ export class ZModemDecorator extends TerminalDecorator {
bytes_remaining: stat.size, bytes_remaining: stat.size,
} }
this.logger.info('offering', offer) this.logger.info('offering', offer)
this.showMessage(terminal, `🟡 Offering ${offer.name}`, true) this.showMessage(terminal, colors.bgYellow.black(' Offered ') + ' ' + offer.name, true)
const xfer = await zsession.send_offer(offer) const xfer = await zsession.send_offer(offer)
if (xfer) { if (xfer) {
let bytesSent = 0 let bytesSent = 0
let canceled = false
const stream = fs.createReadStream(filePath) const stream = fs.createReadStream(filePath)
const cancelSubscription = this.cancelEvent.subscribe(() => {
if (terminal.hasFocus) {
canceled = true
}
})
stream.on('data', chunk => { stream.on('data', chunk => {
if (canceled) {
stream.close()
return
}
xfer.send(chunk) xfer.send(chunk)
bytesSent += chunk.length bytesSent += chunk.length
this.showMessage(terminal, `🟡 Sending ${offer.name}: ${Math.round(100 * bytesSent / offer.size)}%`, true) this.showMessage(terminal, colors.bgYellow.black(' ' + Math.round(100 * bytesSent / offer.size).toString().padStart(3, ' ') + '% ') + offer.name, true)
}) })
await new Promise(resolve => stream.on('end', resolve))
await Promise.race([
new Promise(resolve => stream.on('end', resolve)),
this.cancelEvent.toPromise(),
])
await xfer.end() await xfer.end()
if (canceled) {
this.showMessage(terminal, colors.bgRed.black(' Canceled ') + ' ' + offer.name)
} else {
this.showMessage(terminal, colors.bgGreen.black(' Sent ') + ' ' + offer.name)
}
stream.close() stream.close()
this.showMessage(terminal, `✅ Sent ${offer.name}`) cancelSubscription.unsubscribe()
} else { } else {
this.showMessage(terminal, `🔴 Other side rejected ${offer.name}`) this.showMessage(terminal, colors.bgRed.black(' Rejected ') + ' ' + offer.name)
this.logger.warn('rejected by the other side') this.logger.warn('rejected by the other side')
} }
} }