diff --git a/app/lib/pty.ts b/app/lib/pty.ts index 6aab8edaab..c671d5124d 100644 --- a/app/lib/pty.ts +++ b/app/lib/pty.ts @@ -4,7 +4,6 @@ import { ipcMain } from 'electron' import { Application } from './app' import { UTF8Splitter } from './utfSplitter' import { Subject, debounceTime } from 'rxjs' -import { StringDecoder } from './stringDecoder' class PTYDataQueue { private buffers: Buffer[] = [] @@ -91,7 +90,6 @@ class PTYDataQueue { export class PTY { private pty: nodePTY.IPty private outputQueue: PTYDataQueue - private decoder = new StringDecoder() exited = false constructor (private id: string, private app: Application, ...args: any[]) { @@ -101,7 +99,7 @@ export class PTY { } this.outputQueue = new PTYDataQueue(this.pty, data => { - setImmediate(() => this.emit('data', this.decoder.write(data))) + setImmediate(() => this.emit('data', data)) }) this.pty.onData(data => this.outputQueue.push(Buffer.from(data))) diff --git a/app/lib/stringDecoder.ts b/app/lib/stringDecoder.ts deleted file mode 100644 index 21492b148b..0000000000 --- a/app/lib/stringDecoder.ts +++ /dev/null @@ -1,105 +0,0 @@ -// based on Joyent's StringDecoder -// https://github.com/nodejs/string_decoder/blob/master/lib/string_decoder.js - -export class StringDecoder { - lastNeed: number - lastTotal: number - lastChar: Buffer - - constructor () { - this.lastNeed = 0 - this.lastTotal = 0 - this.lastChar = Buffer.allocUnsafe(4) - } - - write (buf: Buffer): Buffer { - if (buf.length === 0) { - return buf - } - let r: Buffer|undefined = undefined - let i = 0 - if (this.lastNeed) { - r = this.fillLast(buf) - if (r === undefined) { - return Buffer.from('') - } - i = this.lastNeed - this.lastNeed = 0 - } - if (i < buf.length) { - return r ? Buffer.concat([r, this.text(buf, i)]) : this.text(buf, i) - } - return r - } - - // For UTF-8, a replacement character is added when ending on a partial - // character. - end (buf?: Buffer): Buffer { - const r = buf?.length ? this.write(buf) : Buffer.from('') - if (this.lastNeed) { - console.log('end', r) - return Buffer.concat([r, Buffer.from('\ufffd')]) - } - return r - } - - // Returns all complete UTF-8 characters in a Buffer. If the Buffer ended on a - // partial character, the character's bytes are buffered until the required - // number of bytes are available. - private text (buf: Buffer, i: number) { - const total = this.utf8CheckIncomplete(buf, i) - if (!this.lastNeed) { - return buf.slice(i) - } - this.lastTotal = total - const end = buf.length - (total - this.lastNeed) - buf.copy(this.lastChar, 0, end) - return buf.slice(i, end) - } - - // Attempts to complete a partial non-UTF-8 character using bytes from a Buffer - private fillLast (buf: Buffer): Buffer|undefined { - if (this.lastNeed <= buf.length) { - buf.copy(this.lastChar, this.lastTotal - this.lastNeed, 0, this.lastNeed) - return this.lastChar.slice(0, this.lastTotal) - } - buf.copy(this.lastChar, this.lastTotal - this.lastNeed, 0, buf.length) - this.lastNeed -= buf.length - return undefined - } - - // Checks the type of a UTF-8 byte, whether it's ASCII, a leading byte, or a - // continuation byte. If an invalid byte is detected, -2 is returned. - private utf8CheckByte (byte) { - if (byte <= 0x7F) {return 0} else if (byte >> 5 === 0x06) {return 2} else if (byte >> 4 === 0x0E) {return 3} else if (byte >> 3 === 0x1E) {return 4} - return byte >> 6 === 0x02 ? -1 : -2 - } - - // Checks at most 3 bytes at the end of a Buffer in order to detect an - // incomplete multi-byte UTF-8 character. The total number of bytes (2, 3, or 4) - // needed to complete the UTF-8 character (if applicable) are returned. - private utf8CheckIncomplete (buf, i) { - let j = buf.length - 1 - if (j < i) {return 0} - let nb = this.utf8CheckByte(buf[j]) - if (nb >= 0) { - if (nb > 0) {this.lastNeed = nb - 1} - return nb - } - if (--j < i || nb === -2) {return 0} - nb = this.utf8CheckByte(buf[j]) - if (nb >= 0) { - if (nb > 0) {this.lastNeed = nb - 2} - return nb - } - if (--j < i || nb === -2) {return 0} - nb = this.utf8CheckByte(buf[j]) - if (nb >= 0) { - if (nb > 0) { - if (nb === 2) {nb = 0} else {this.lastNeed = nb - 3} - } - return nb - } - return 0 - } -} diff --git a/tabby-terminal/src/api/baseTerminalTab.component.ts b/tabby-terminal/src/api/baseTerminalTab.component.ts index 1f7fd02921..369bcd7f36 100644 --- a/tabby-terminal/src/api/baseTerminalTab.component.ts +++ b/tabby-terminal/src/api/baseTerminalTab.component.ts @@ -123,6 +123,7 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit protected logger: Logger protected output = new Subject() + protected binaryOutput = new Subject() protected sessionChanged = new Subject() private bellPlayer: HTMLAudioElement private termContainerSubscriptions = new SubscriptionContainer() @@ -153,6 +154,7 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit } get output$ (): Observable { return this.output } + get binaryOutput$ (): Observable { return this.binaryOutput } get resize$ (): Observable { if (!this.frontend) { @@ -369,7 +371,7 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit this.configure() setTimeout(() => { - this.output.subscribe(() => { + this.binaryOutput$.subscribe(() => { this.displayActivity() }) }, 1000) @@ -564,6 +566,7 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit } }) this.output.complete() + this.binaryOutput.complete() this.frontendReady.complete() super.destroy() @@ -741,6 +744,12 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit } }) + this.attachSessionHandler(this.session.binaryOutput$, data => { + if (this.enablePassthrough) { + this.binaryOutput.next(data) + } + }) + if (destroyOnSessionClose) { this.attachSessionHandler(this.session.closed$, () => { this.destroy() diff --git a/tabby-terminal/src/api/middleware.ts b/tabby-terminal/src/api/middleware.ts index 5edcc98bb3..480bd6b4f1 100644 --- a/tabby-terminal/src/api/middleware.ts +++ b/tabby-terminal/src/api/middleware.ts @@ -22,7 +22,7 @@ export class SessionMiddleware { } } -export class SesssionMiddlewareStack extends SessionMiddleware { +export class SessionMiddlewareStack extends SessionMiddleware { private stack: SessionMiddleware[] = [] private subs = new SubscriptionContainer() diff --git a/tabby-terminal/src/features/zmodem.ts b/tabby-terminal/src/features/zmodem.ts index 3b9ca5baba..a485a32e8b 100644 --- a/tabby-terminal/src/features/zmodem.ts +++ b/tabby-terminal/src/features/zmodem.ts @@ -4,13 +4,14 @@ import { Observable, filter, first } from 'rxjs' import { Injectable } from '@angular/core' import { TerminalDecorator } from '../api/decorator' import { BaseTerminalTabComponent } from '../api/baseTerminalTab.component' +import { SessionMiddleware } from '../api/middleware' import { LogService, Logger, HotkeysService, PlatformService, FileUpload } from 'tabby-core' const SPACER = ' ' -/** @hidden */ -@Injectable() -export class ZModemDecorator extends TerminalDecorator { +class ZModemMiddleware extends SessionMiddleware { + private sentry: ZModem.Sentry + private isActive = false private logger: Logger private activeSession: any = null private cancelEvent: Observable @@ -21,65 +22,52 @@ export class ZModemDecorator extends TerminalDecorator { private platform: PlatformService, ) { super() - this.logger = log.create('zmodem') - this.cancelEvent = hotkeys.hotkey$.pipe(filter(x => x === 'ctrl-c')) - } + this.cancelEvent = this.outputToSession$.pipe(filter(x => x.length === 1 && x[0] === 3)) - attach (terminal: BaseTerminalTabComponent): void { - let isActive = false - const sentry = new ZModem.Sentry({ + this.logger = log.create('zmodem') + this.sentry = new ZModem.Sentry({ to_terminal: data => { - if (isActive) { - terminal.write(data) + if (this.isActive) { + this.outputToTerminal.next(Buffer.from(data)) } }, - sender: data => terminal.session!.feedFromTerminal(Buffer.from(data)), + sender: data => this.outputToSession.next(Buffer.from(data)), on_detect: async detection => { try { - terminal.enablePassthrough = false - isActive = true - await this.process(terminal, detection) + this.isActive = true + await this.process(detection) } finally { - terminal.enablePassthrough = true - isActive = false + this.isActive = false } }, on_retract: () => { - this.showMessage(terminal, 'transfer cancelled') + this.showMessage('transfer cancelled') }, }) - setTimeout(() => { - this.attachToSession(sentry, terminal) - this.subscribeUntilDetached(terminal, terminal.sessionChanged$.subscribe(() => { - this.attachToSession(sentry, terminal) - })) - }) } - private attachToSession (sentry, terminal) { - if (!terminal.session) { - return - } - 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(Buffer.from(data.slice(i * chunkSize, (i + 1) * chunkSize))) - } catch (e) { - this.showMessage(terminal, colors.bgRed.black(' Error ') + ' ' + e) - this.logger.error('protocol error', e) - this.activeSession.abort() - this.activeSession = null - terminal.enablePassthrough = true - return - } + feedFromSession (data: Buffer): void { + const chunkSize = 1024 + for (let i = 0; i <= Math.floor(data.length / chunkSize); i++) { + try { + this.sentry.consume(Buffer.from(data.slice(i * chunkSize, (i + 1) * chunkSize))) + } catch (e) { + this.showMessage(colors.bgRed.black(' Error ') + ' ' + e) + this.logger.error('protocol error', e) + this.activeSession.abort() + this.activeSession = null + this.isActive = false + return } - })) + } + if (!this.isActive) { + this.outputToTerminal.next(data) + } } - private async process (terminal, detection): Promise { - this.showMessage(terminal, colors.bgBlue.black(' ZMODEM ') + ' Session started') - this.showMessage(terminal, '------------------------') + private async process (detection): Promise { + this.showMessage(colors.bgBlue.black(' ZMODEM ') + ' Session started') + this.showMessage('------------------------') const zsession = detection.confirm() this.activeSession = zsession @@ -90,7 +78,7 @@ export class ZModemDecorator extends TerminalDecorator { let filesRemaining = transfers.length let sizeRemaining = transfers.reduce((a, b) => a + b.getSize(), 0) for (const transfer of transfers) { - await this.sendFile(terminal, zsession, transfer, filesRemaining, sizeRemaining) + await this.sendFile(zsession, transfer, filesRemaining, sizeRemaining) filesRemaining-- sizeRemaining -= transfer.getSize() } @@ -98,7 +86,7 @@ export class ZModemDecorator extends TerminalDecorator { await zsession.close() } else { zsession.on('offer', xfer => { - this.receiveFile(terminal, xfer, zsession) + this.receiveFile(xfer, zsession) }) zsession.start() @@ -108,29 +96,27 @@ export class ZModemDecorator extends TerminalDecorator { } } - private async receiveFile (terminal, xfer, zsession) { + private async receiveFile (xfer, zsession) { const details: { name: string, size: number, } = xfer.get_details() - this.showMessage(terminal, colors.bgYellow.black(' Offered ') + ' ' + details.name, true) + this.showMessage(colors.bgYellow.black(' Offered ') + ' ' + details.name, true) this.logger.info('offered', xfer) const transfer = await this.platform.startDownload(details.name, 0o644, details.size) if (!transfer) { - this.showMessage(terminal, colors.bgRed.black(' Rejected ') + ' ' + details.name) + this.showMessage(colors.bgRed.black(' Rejected ') + ' ' + details.name) xfer.skip() return } let canceled = false const cancelSubscription = this.cancelEvent.subscribe(() => { - if (terminal.hasFocus) { - try { - zsession._skip() - } catch {} - canceled = true - } + try { + zsession._skip() + } catch {} + canceled = true }) try { @@ -141,7 +127,7 @@ export class ZModemDecorator extends TerminalDecorator { return } transfer.write(Buffer.from(chunk)) - this.showMessage(terminal, colors.bgYellow.black(' ' + Math.round(100 * transfer.getCompletedBytes() / details.size).toString().padStart(3, ' ') + '% ') + ' ' + details.name, true) + this.showMessage(colors.bgYellow.black(' ' + Math.round(100 * transfer.getCompletedBytes() / details.size).toString().padStart(3, ' ') + '% ') + ' ' + details.name, true) }, }), this.cancelEvent.pipe(first()).toPromise(), @@ -150,19 +136,19 @@ export class ZModemDecorator extends TerminalDecorator { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (canceled) { transfer.cancel() - this.showMessage(terminal, colors.bgRed.black(' Canceled ') + ' ' + details.name) + this.showMessage(colors.bgRed.black(' Canceled ') + ' ' + details.name) } else { transfer.close() - this.showMessage(terminal, colors.bgGreen.black(' Received ') + ' ' + details.name) + this.showMessage(colors.bgGreen.black(' Received ') + ' ' + details.name) } } catch { - this.showMessage(terminal, colors.bgRed.black(' Error ') + ' ' + details.name) + this.showMessage(colors.bgRed.black(' Error ') + ' ' + details.name) } cancelSubscription.unsubscribe() } - private async sendFile (terminal, zsession, transfer: FileUpload, filesRemaining, sizeRemaining) { + private async sendFile (zsession, transfer: FileUpload, filesRemaining, sizeRemaining) { const offer = { name: transfer.getName(), size: transfer.getSize(), @@ -171,15 +157,13 @@ export class ZModemDecorator extends TerminalDecorator { bytes_remaining: sizeRemaining, } this.logger.info('offering', offer) - this.showMessage(terminal, colors.bgYellow.black(' Offered ') + ' ' + offer.name, true) + this.showMessage(colors.bgYellow.black(' Offered ') + ' ' + offer.name, true) const xfer = await zsession.send_offer(offer) if (xfer) { let canceled = false const cancelSubscription = this.cancelEvent.subscribe(() => { - if (terminal.hasFocus) { - canceled = true - } + canceled = true }) while (true) { @@ -190,7 +174,7 @@ export class ZModemDecorator extends TerminalDecorator { } await xfer.send(chunk) - this.showMessage(terminal, colors.bgYellow.black(' ' + Math.round(100 * transfer.getCompletedBytes() / offer.size).toString().padStart(3, ' ') + '% ') + offer.name, true) + this.showMessage(colors.bgYellow.black(' ' + Math.round(100 * transfer.getCompletedBytes() / offer.size).toString().padStart(3, ' ') + '% ') + offer.name, true) } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -204,23 +188,51 @@ export class ZModemDecorator extends TerminalDecorator { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (canceled) { - this.showMessage(terminal, colors.bgRed.black(' Canceled ') + ' ' + offer.name) + this.showMessage(colors.bgRed.black(' Canceled ') + ' ' + offer.name) } else { - this.showMessage(terminal, colors.bgGreen.black(' Sent ') + ' ' + offer.name) + this.showMessage(colors.bgGreen.black(' Sent ') + ' ' + offer.name) } cancelSubscription.unsubscribe() } else { transfer.cancel() - this.showMessage(terminal, colors.bgRed.black(' Rejected ') + ' ' + offer.name) + this.showMessage(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}`)) + private showMessage (msg: string, overwrite = false) { + this.outputToTerminal.next(Buffer.from(`\r${msg}${SPACER}`)) if (!overwrite) { - terminal.write(Buffer.from('\r\n')) + this.outputToTerminal.next(Buffer.from('\r\n')) + } + } +} + +/** @hidden */ +@Injectable() +export class ZModemDecorator extends TerminalDecorator { + constructor ( + private log: LogService, + private hotkeys: HotkeysService, + private platform: PlatformService, + ) { + super() + } + + attach (terminal: BaseTerminalTabComponent): void { + setTimeout(() => { + this.attachToSession(terminal) + this.subscribeUntilDetached(terminal, terminal.sessionChanged$.subscribe(() => { + this.attachToSession(terminal) + })) + }) + } + + private attachToSession (terminal: BaseTerminalTabComponent) { + if (!terminal.session) { + return } + terminal.session.middleware.unshift(new ZModemMiddleware(this.log, this.hotkeys, this.platform)) } } diff --git a/tabby-terminal/src/session.ts b/tabby-terminal/src/session.ts index 2d2e872660..1a842bca9f 100644 --- a/tabby-terminal/src/session.ts +++ b/tabby-terminal/src/session.ts @@ -2,7 +2,7 @@ import { Observable, Subject } from 'rxjs' import { Logger } from 'tabby-core' import { LoginScriptProcessor, LoginScriptsOptions } from './middleware/loginScriptProcessing' import { OSCProcessor } from './middleware/oscProcessing' -import { SesssionMiddlewareStack } from './api/middleware' +import { SessionMiddlewareStack } from './api/middleware' /** * A session object for a [[BaseTerminalTabComponent]] @@ -11,8 +11,8 @@ import { SesssionMiddlewareStack } from './api/middleware' export abstract class BaseSession { open: boolean truePID?: number - oscProcessor = new OSCProcessor() - protected readonly middleware = new SesssionMiddlewareStack() + readonly oscProcessor = new OSCProcessor() + readonly middleware = new SessionMiddlewareStack() protected output = new Subject() protected binaryOutput = new Subject() protected closed = new Subject()