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:
parent
58d2590495
commit
2d357d0ed2
@ -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
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user