diff --git a/app/lib/app.ts b/app/lib/app.ts index 91c2b9d5..9668cdba 100644 --- a/app/lib/app.ts +++ b/app/lib/app.ts @@ -5,13 +5,16 @@ import * as remote from '@electron/remote/main' import { loadConfig } from './config' import { Window, WindowOptions } from './window' import { pluginManager } from './pluginManager' +import { PTYManager } from './pty' export class Application { private tray?: Tray + private ptyManager = new PTYManager() private windows: Window[] = [] constructor () { remote.initialize() + this.ptyManager.init(this) ipcMain.on('app:config-change', (_event, config) => { this.broadcast('host:config-change', config) diff --git a/app/lib/bufferizedPTY.js b/app/lib/bufferizedPTY.js new file mode 100644 index 00000000..52783bde --- /dev/null +++ b/app/lib/bufferizedPTY.js @@ -0,0 +1,57 @@ +/** @hidden */ +module.exports = function patchPTYModule (mod) { + const oldSpawn = mod.spawn + if (mod.patched) { + return + } + mod.patched = true + mod.spawn = (file, args, opt) => { + let terminal = oldSpawn(file, args, opt) + let timeout = null + let buffer = Buffer.from('') + let lastFlush = 0 + let nextTimeout = 0 + + // Minimum prebuffering window (ms) if the input is non-stop flowing + const minWindow = 5 + + // Maximum buffering time (ms) until output must be flushed unconditionally + const maxWindow = 100 + + function flush () { + if (buffer.length) { + terminal.emit('data-buffered', buffer) + } + lastFlush = Date.now() + buffer = Buffer.from('') + } + + function reschedule () { + if (timeout) { + clearTimeout(timeout) + } + nextTimeout = Date.now() + minWindow + timeout = setTimeout(() => { + timeout = null + flush() + }, minWindow) + } + + terminal.on('data', data => { + if (typeof data === 'string') { + data = Buffer.from(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) { + // Extend the window if it's expiring + reschedule() + } + } + }) + return terminal + } +} diff --git a/app/lib/pty.ts b/app/lib/pty.ts new file mode 100644 index 00000000..20303ed1 --- /dev/null +++ b/app/lib/pty.ts @@ -0,0 +1,144 @@ +import * as nodePTY from '@terminus-term/node-pty' +import { v4 as uuidv4 } from 'uuid' +import { ipcMain } from 'electron' +import { Application } from './app' + +class PTYDataQueue { + private buffers: Buffer[] = [] + private delta = 0 + private maxChunk = 1024 + private maxDelta = 1024 * 50 + private flowPaused = false + + constructor (private pty: nodePTY.IPty, private onData: (data: Buffer) => void) { } + + push (data: Buffer) { + this.buffers.push(data) + this.maybeEmit() + } + + ack (length: number) { + this.delta -= length + this.maybeEmit() + } + + private maybeEmit () { + if (this.delta <= this.maxDelta && this.flowPaused) { + this.resume() + return + } + if (this.buffers.length > 0) { + if (this.delta > this.maxDelta && !this.flowPaused) { + this.pause() + return + } + + const buffersToSend = [] + let totalLength = 0 + while (totalLength < this.maxChunk && this.buffers.length) { + totalLength += this.buffers[0].length + buffersToSend.push(this.buffers.shift()) + } + let toSend = Buffer.concat(buffersToSend) + this.buffers.unshift(toSend.slice(this.maxChunk)) + toSend = toSend.slice(0, this.maxChunk) + this.onData(toSend) + this.delta += toSend.length + this.buffers = [] + } + } + + private pause () { + this.pty.pause() + this.flowPaused = true + } + + private resume () { + this.pty.resume() + this.flowPaused = false + this.maybeEmit() + } +} + +export class PTY { + private pty: nodePTY.IPty + private outputQueue: PTYDataQueue + + constructor (private id: string, private app: Application, ...args: any[]) { + this.pty = (nodePTY as any).spawn(...args) + for (const key of ['close', 'exit']) { + (this.pty as any).on(key, (...eventArgs) => this.emit(key, ...eventArgs)) + } + + this.outputQueue = new PTYDataQueue(this.pty, data => { + setImmediate(() => this.emit('data-buffered', data)) + }) + + this.pty.on('data', data => this.outputQueue.push(Buffer.from(data))) + } + + getPID (): number { + return this.pty.pid + } + + resize (columns: number, rows: number): void { + if ((this.pty as any)._writable) { + this.pty.resize(columns, rows) + } + } + + write (buffer: Buffer): void { + if ((this.pty as any)._writable) { + this.pty.write(buffer.toString()) + } + } + + ackData (length: number): void { + this.outputQueue.ack(length) + } + + kill (signal?: string): void { + this.pty.kill(signal) + } + + private emit (event: string, ...args: any[]) { + this.app.broadcast(`pty:${this.id}:${event}`, ...args) + } +} + +export class PTYManager { + private ptys: Record = {} + + init (app: Application): void { + //require('./bufferizedPTY')(nodePTY) // eslint-disable-line @typescript-eslint/no-var-requires + ipcMain.on('pty:spawn', (event, ...options) => { + const id = uuidv4().toString() + event.returnValue = id + this.ptys[id] = new PTY(id, app, ...options) + }) + + ipcMain.on('pty:exists', (event, id) => { + event.returnValue = !!this.ptys[id] + }) + + ipcMain.on('pty:get-pid', (event, id) => { + event.returnValue = this.ptys[id].getPID() + }) + + ipcMain.on('pty:resize', (_event, id, columns, rows) => { + this.ptys[id].resize(columns, rows) + }) + + ipcMain.on('pty:write', (_event, id, data) => { + this.ptys[id].write(Buffer.from(data)) + }) + + ipcMain.on('pty:kill', (_event, id, signal) => { + this.ptys[id].kill(signal) + }) + + ipcMain.on('pty:ack-data', (_event, id, length) => { + this.ptys[id].ackData(length) + }) + } +} diff --git a/app/webpack.main.config.js b/app/webpack.main.config.js index 7b171681..ecbc756a 100644 --- a/app/webpack.main.config.js +++ b/app/webpack.main.config.js @@ -46,6 +46,7 @@ module.exports = { 'source-map-support': 'commonjs source-map-support', 'windows-swca': 'commonjs windows-swca', 'windows-blurbehind': 'commonjs windows-blurbehind', + '@terminus-term/node-pty': 'commonjs @terminus-term/node-pty', }, plugins: [ new webpack.optimize.ModuleConcatenationPlugin(), diff --git a/package.json b/package.json index a70c5aa6..3da67529 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "build": "npm run build:typings && webpack --color --config app/webpack.main.config.js && webpack --color --config app/webpack.config.js && webpack --color --config terminus-core/webpack.config.js && webpack --color --config terminus-settings/webpack.config.js && webpack --color --config terminus-terminal/webpack.config.js && webpack --color --config terminus-plugin-manager/webpack.config.js && webpack --color --config terminus-community-color-schemes/webpack.config.js && webpack --color --config terminus-ssh/webpack.config.js && webpack --color --config terminus-serial/webpack.config.js", "build:typings": "node scripts/build-typings.js", "watch": "cross-env TERMINUS_DEV=1 webpack --progress --color --watch", - "start": "cross-env TERMINUS_DEV=1 electron app --debug", + "start": "cross-env TERMINUS_DEV=1 electron app --debug --inspect", "start:prod": "electron app --debug", "prod": "cross-env TERMINUS_DEV=1 electron app", "docs": "typedoc --out docs/api --tsconfig terminus-core/src/tsconfig.typings.json terminus-core/src/index.ts && typedoc --out docs/api/terminal --tsconfig terminus-terminal/tsconfig.typings.json terminus-terminal/src/index.ts && typedoc --out docs/api/settings --tsconfig terminus-settings/tsconfig.typings.json terminus-settings/src/index.ts", diff --git a/terminus-core/package.json b/terminus-core/package.json index 5f655b11..63b12aa6 100644 --- a/terminus-core/package.json +++ b/terminus-core/package.json @@ -23,6 +23,7 @@ "@types/winston": "^2.3.6", "axios": "^0.21.1", "bootstrap": "^4.1.3", + "clone-deep": "^4.0.1", "core-js": "^3.1.2", "deepmerge": "^4.1.1", "electron-updater": "^4.0.6", diff --git a/terminus-core/src/api/tabRecovery.ts b/terminus-core/src/api/tabRecovery.ts index a64b4563..4370baf7 100644 --- a/terminus-core/src/api/tabRecovery.ts +++ b/terminus-core/src/api/tabRecovery.ts @@ -1,3 +1,4 @@ +import deepClone from 'clone-deep' import { TabComponentType } from '../services/tabs.service' export interface RecoveredTab { @@ -35,10 +36,26 @@ export interface RecoveryToken { * ``` */ export abstract class TabRecoveryProvider { + /** + * @param recoveryToken a recovery token found in the saved tabs list + * @returns [[boolean]] whether this [[TabRecoveryProvider]] can recover a tab from this token + */ + abstract async applicableTo (recoveryToken: RecoveryToken): Promise + /** * @param recoveryToken a recovery token found in the saved tabs list * @returns [[RecoveredTab]] descriptor containing tab type and component inputs * or `null` if this token is from a different tab type or is not supported */ - abstract async recover (recoveryToken: RecoveryToken): Promise + abstract async recover (recoveryToken: RecoveryToken): Promise + + /** + * @param recoveryToken a recovery token found in the saved tabs list + * @returns [[RecoveryToken]] a new recovery token to create the duplicate tab from + * + * The default implementation just returns a deep copy of the original token + */ + duplicate (recoveryToken: RecoveryToken): RecoveryToken { + return deepClone(recoveryToken) + } } diff --git a/terminus-core/src/components/splitTab.component.ts b/terminus-core/src/components/splitTab.component.ts index b91bcab6..58839156 100644 --- a/terminus-core/src/components/splitTab.component.ts +++ b/terminus-core/src/components/splitTab.component.ts @@ -256,7 +256,7 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit /** @hidden */ async ngAfterViewInit (): Promise { if (this._recoveredState) { - await this.recoverContainer(this.root, this._recoveredState) + await this.recoverContainer(this.root, this._recoveredState, this._recoveredState.duplicate) this.layout() setTimeout(() => { if (this.hasFocus) { @@ -505,6 +505,9 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit if (tab.title) { this.setTitle(tab.title) } + tab.recoveryStateChangedHint$.subscribe(() => { + this.recoveryStateChangedHint.next() + }) tab.destroyed$.subscribe(() => { this.removeTab(tab) }) @@ -567,7 +570,7 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit }) } - private async recoverContainer (root: SplitContainer, state: any) { + private async recoverContainer (root: SplitContainer, state: any, duplicate = false) { const children: (SplitContainer | BaseTabComponent)[] = [] root.orientation = state.orientation root.ratios = state.ratios @@ -575,10 +578,10 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit for (const childState of state.children) { if (childState.type === 'app:split-tab') { const child = new SplitContainer() - await this.recoverContainer(child, childState) + await this.recoverContainer(child, childState, duplicate) children.push(child) } else { - const recovered = await this.tabRecovery.recoverTab(childState) + const recovered = await this.tabRecovery.recoverTab(childState, duplicate) if (recovered) { const tab = this.tabsService.create(recovered.type, recovered.options) children.push(tab) @@ -599,13 +602,21 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit /** @hidden */ @Injectable() export class SplitTabRecoveryProvider extends TabRecoveryProvider { - async recover (recoveryToken: RecoveryToken): Promise { - if (recoveryToken.type === 'app:split-tab') { - return { - type: SplitTabComponent, - options: { _recoveredState: recoveryToken }, - } + async applicableTo (recoveryToken: RecoveryToken): Promise { + return recoveryToken.type === 'app:split-tab' + } + + async recover (recoveryToken: RecoveryToken): Promise { + return { + type: SplitTabComponent, + options: { _recoveredState: recoveryToken }, + } + } + + duplicate (recoveryToken: RecoveryToken): RecoveryToken { + return { + ...recoveryToken, + duplicate: true, } - return null } } diff --git a/terminus-core/src/components/tabHeader.component.ts b/terminus-core/src/components/tabHeader.component.ts index 1c1c9183..99e62b51 100644 --- a/terminus-core/src/components/tabHeader.component.ts +++ b/terminus-core/src/components/tabHeader.component.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import type { MenuItemConstructorOptions } from 'electron' -import { Component, Input, Optional, Inject, HostBinding, HostListener, ViewChild, ElementRef } from '@angular/core' +import { Component, Input, Optional, Inject, HostBinding, HostListener, ViewChild, ElementRef, NgZone } from '@angular/core' import { SortableComponent } from 'ng2-dnd' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { TabContextMenuItemProvider } from '../api/tabContextMenuProvider' @@ -38,6 +38,7 @@ export class TabHeaderComponent { private hostApp: HostAppService, private ngbModal: NgbModal, private hotkeys: HotkeysService, + private zone: NgZone, @Inject(SortableComponent) private parentDraggable: SortableComponentProxy, @Optional() @Inject(TabContextMenuItemProvider) protected contextMenuProviders: TabContextMenuItemProvider[], ) { @@ -53,7 +54,9 @@ export class TabHeaderComponent { ngOnInit () { this.tab.progress$.subscribe(progress => { - this.progress = progress + this.zone.run(() => { + this.progress = progress + }) }) } diff --git a/terminus-core/src/services/tabRecovery.service.ts b/terminus-core/src/services/tabRecovery.service.ts index 68f0636c..96741094 100644 --- a/terminus-core/src/services/tabRecovery.service.ts +++ b/terminus-core/src/services/tabRecovery.service.ts @@ -40,16 +40,20 @@ export class TabRecoveryService { return token } - async recoverTab (token: RecoveryToken): Promise { + async recoverTab (token: RecoveryToken, duplicate = false): Promise { for (const provider of this.config.enabledServices(this.tabRecoveryProviders ?? [])) { try { - const tab = await provider.recover(token) - if (tab !== null) { - tab.options = tab.options || {} - tab.options.color = token.tabColor ?? null - tab.options.title = token.tabTitle || '' - return tab + if (!await provider.applicableTo(token)) { + continue } + if (duplicate) { + token = provider.duplicate(token) + } + const tab = await provider.recover(token) + tab.options = tab.options || {} + tab.options.color = token.tabColor ?? null + tab.options.title = token.tabTitle || '' + return tab } catch (error) { this.logger.warn('Tab recovery crashed:', token, provider, error) } diff --git a/terminus-core/src/services/tabs.service.ts b/terminus-core/src/services/tabs.service.ts index 1b184cdd..4c4403b8 100644 --- a/terminus-core/src/services/tabs.service.ts +++ b/terminus-core/src/services/tabs.service.ts @@ -34,7 +34,7 @@ export class TabsService { if (!token) { return null } - const dup = await this.tabRecovery.recoverTab(token) + const dup = await this.tabRecovery.recoverTab(token, true) if (dup) { return this.create(dup.type, dup.options) } diff --git a/terminus-core/yarn.lock b/terminus-core/yarn.lock index d545f50b..0e57a02f 100644 --- a/terminus-core/yarn.lock +++ b/terminus-core/yarn.lock @@ -80,6 +80,15 @@ builder-util-runtime@8.7.3: debug "^4.3.2" sax "^1.2.4" +clone-deep@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" + integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ== + dependencies: + is-plain-object "^2.0.4" + kind-of "^6.0.2" + shallow-clone "^3.0.0" + color-convert@^1.9.1: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -238,6 +247,13 @@ is-arrayish@^0.3.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== +is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== + dependencies: + isobject "^3.0.1" + is-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3" @@ -248,6 +264,11 @@ isarray@~1.0.0: resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= +isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= + js-yaml@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.0.0.tgz#f426bc0ff4b4051926cd588c71113183409a121f" @@ -264,6 +285,11 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +kind-of@^6.0.2: + version "6.0.3" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== + kuler@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" @@ -389,6 +415,13 @@ semver@^7.3.4: dependencies: lru-cache "^6.0.0" +shallow-clone@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" + integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA== + dependencies: + kind-of "^6.0.2" + shell-escape@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/shell-escape/-/shell-escape-0.2.0.tgz#68fd025eb0490b4f567a027f0bf22480b5f84133" diff --git a/terminus-serial/src/recoveryProvider.ts b/terminus-serial/src/recoveryProvider.ts index 3418f785..e47bc410 100644 --- a/terminus-serial/src/recoveryProvider.ts +++ b/terminus-serial/src/recoveryProvider.ts @@ -6,16 +6,17 @@ import { SerialTabComponent } from './components/serialTab.component' /** @hidden */ @Injectable() export class RecoveryProvider extends TabRecoveryProvider { - async recover (recoveryToken: RecoveryToken): Promise { - if (recoveryToken.type === 'app:serial-tab') { - return { - type: SerialTabComponent, - options: { - connection: recoveryToken.connection, - savedState: recoveryToken.savedState, - }, - } + async applicableTo (recoveryToken: RecoveryToken): Promise { + return recoveryToken.type === 'app:serial-tab' + } + + async recover (recoveryToken: RecoveryToken): Promise { + return { + type: SerialTabComponent, + options: { + connection: recoveryToken.connection, + savedState: recoveryToken.savedState, + }, } - return null } } diff --git a/terminus-ssh/src/recoveryProvider.ts b/terminus-ssh/src/recoveryProvider.ts index da147bd0..cc3b00fa 100644 --- a/terminus-ssh/src/recoveryProvider.ts +++ b/terminus-ssh/src/recoveryProvider.ts @@ -6,16 +6,17 @@ import { SSHTabComponent } from './components/sshTab.component' /** @hidden */ @Injectable() export class RecoveryProvider extends TabRecoveryProvider { - async recover (recoveryToken: RecoveryToken): Promise { - if (recoveryToken.type === 'app:ssh-tab') { - return { - type: SSHTabComponent, - options: { - connection: recoveryToken['connection'], - savedState: recoveryToken['savedState'], - }, - } + async applicableTo (recoveryToken: RecoveryToken): Promise { + return recoveryToken.type === 'app:ssh-tab' + } + + async recover (recoveryToken: RecoveryToken): Promise { + return { + type: SSHTabComponent, + options: { + connection: recoveryToken['connection'], + savedState: recoveryToken['savedState'], + }, } - return null } } diff --git a/terminus-terminal/src/api/baseTerminalTab.component.ts b/terminus-terminal/src/api/baseTerminalTab.component.ts index 9512bb9a..11fbffa9 100644 --- a/terminus-terminal/src/api/baseTerminalTab.component.ts +++ b/terminus-terminal/src/api/baseTerminalTab.component.ts @@ -32,6 +32,7 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit session: BaseSession|null = null savedState?: any + savedStateIsLive = false @Input() zoom = 0 @@ -226,8 +227,8 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit this.frontend = this.terminalContainersService.getFrontend(this.session) - this.frontend.ready$.subscribe(() => { - this.frontendIsReady = true + this.frontendReady$.pipe(first()).subscribe(() => { + this.onFrontendReady() }) this.frontend.resize$.pipe(first()).subscribe(async ({ columns, rows }) => { @@ -253,13 +254,6 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit this.alternateScreenActive = x }) - if (this.savedState) { - this.frontend.restoreState(this.savedState) - this.frontend.write('\r\n\r\n') - this.frontend.write(colors.bgWhite.black(' * ') + colors.bgBlackBright.white(' History restored ')) - this.frontend.write('\r\n\r\n') - } - setImmediate(async () => { if (this.hasFocus) { await this.frontend!.attach(this.content.nativeElement) @@ -298,6 +292,18 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit }) } + protected onFrontendReady (): void { + this.frontendIsReady = true + if (this.savedState) { + this.frontend!.restoreState(this.savedState) + if (!this.savedStateIsLive) { + this.frontend!.write('\r\n\r\n') + this.frontend!.write(colors.bgWhite.black(' * ') + colors.bgBlackBright.white(' History restored ')) + this.frontend!.write('\r\n\r\n') + } + } + } + async buildContextMenu (): Promise { let items: MenuItemConstructorOptions[] = [] for (const section of await Promise.all(this.contextMenuProviders.map(x => x.getItems(this)))) { @@ -594,10 +600,8 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit // this.session.output$.bufferTime(10).subscribe((datas) => { this.attachSessionHandler(this.session.output$.subscribe(data => { if (this.enablePassthrough) { - this.zone.run(() => { - this.output.next(data) - this.write(data) - }) + this.output.next(data) + this.write(data) } })) diff --git a/terminus-terminal/src/api/interfaces.ts b/terminus-terminal/src/api/interfaces.ts index aac1992d..8cdb6452 100644 --- a/terminus-terminal/src/api/interfaces.ts +++ b/terminus-terminal/src/api/interfaces.ts @@ -4,6 +4,7 @@ export interface ResizeEvent { } export interface SessionOptions { + restoreFromPTYID?: string name?: string command: string args?: string[] @@ -53,3 +54,9 @@ export interface Shell { hidden?: boolean } + +export interface ChildProcess { + pid: number + ppid: number + command: string +} diff --git a/terminus-terminal/src/bufferizedPTY.js b/terminus-terminal/src/bufferizedPTY.js deleted file mode 100644 index e9e69b9a..00000000 --- a/terminus-terminal/src/bufferizedPTY.js +++ /dev/null @@ -1,57 +0,0 @@ -/** @hidden */ -module.exports = function patchPTYModule (mod) { - const oldSpawn = mod.spawn - if (mod.patched) { - return - } - mod.patched = true - mod.spawn = (file, args, opt) => { - let terminal = oldSpawn(file, args, opt) - let timeout = null - let buffer = Buffer.from('') - let lastFlush = 0 - let nextTimeout = 0 - - // Minimum prebuffering window (ms) if the input is non-stop flowing - const minWindow = 10 - - // Maximum buffering time (ms) until output must be flushed unconditionally - const maxWindow = 100 - - function flush () { - if (buffer.length) { - terminal.emit('data-buffered', buffer) - } - lastFlush = Date.now() - buffer = Buffer.from('') - } - - function reschedule () { - if (timeout) { - clearTimeout(timeout) - } - nextTimeout = Date.now() + minWindow - timeout = setTimeout(() => { - timeout = null - flush() - }, minWindow) - } - - terminal.on('data', data => { - if (typeof data === 'string') { - data = Buffer.from(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) { - // Extend the window if it's expiring - reschedule() - } - } - }) - return terminal - } -} diff --git a/terminus-terminal/src/components/terminalTab.component.ts b/terminus-terminal/src/components/terminalTab.component.ts index 9a3682ee..bc49d571 100644 --- a/terminus-terminal/src/components/terminalTab.component.ts +++ b/terminus-terminal/src/components/terminalTab.component.ts @@ -1,6 +1,5 @@ import { Component, Input, Injector } from '@angular/core' import { Subscription } from 'rxjs' -import { first } from 'rxjs/operators' import { BaseTabProcess, WIN_BUILD_CONPTY_SUPPORTED, isWindowsBuild } from 'terminus-core' import { BaseTerminalTabComponent } from '../api/baseTerminalTab.component' import { SessionOptions } from '../api/interfaces' @@ -16,6 +15,7 @@ import { Session } from '../services/sessions.service' export class TerminalTabComponent extends BaseTerminalTabComponent { @Input() sessionOptions: SessionOptions private homeEndSubscription: Subscription + session: Session|null = null // eslint-disable-next-line @typescript-eslint/no-useless-constructor constructor ( @@ -44,13 +44,15 @@ export class TerminalTabComponent extends BaseTerminalTabComponent { } }) - this.frontendReady$.pipe(first()).subscribe(() => { - this.initializeSession(this.size.columns, this.size.rows) - }) - super.ngOnInit() } + protected onFrontendReady (): void { + this.initializeSession(this.size.columns, this.size.rows) + this.savedStateIsLive = this.sessionOptions.restoreFromPTYID === this.session?.getPTYID() + super.onFrontendReady() + } + initializeSession (columns: number, rows: number): void { this.sessions.addSession( this.session!, @@ -61,6 +63,7 @@ export class TerminalTabComponent extends BaseTerminalTabComponent { ) this.attachSessionHandlers(true) + this.recoveryStateChangedHint.next() } async getRecoveryToken (): Promise { @@ -70,6 +73,7 @@ export class TerminalTabComponent extends BaseTerminalTabComponent { sessionOptions: { ...this.sessionOptions, cwd: cwd ?? this.sessionOptions.cwd, + restoreFromPTYID: this.session?.getPTYID(), }, savedState: this.frontend?.saveState(), } diff --git a/terminus-terminal/src/config.ts b/terminus-terminal/src/config.ts index 6452ad8f..ee7a205b 100644 --- a/terminus-terminal/src/config.ts +++ b/terminus-terminal/src/config.ts @@ -75,6 +75,7 @@ export class TerminalConfigProvider extends ConfigProvider { caseSensitive: false, }, detectProgress: true, + scrollbackLines: 25000, }, } diff --git a/terminus-terminal/src/frontends/xtermFrontend.ts b/terminus-terminal/src/frontends/xtermFrontend.ts index e874e4f2..7b0e2c06 100644 --- a/terminus-terminal/src/frontends/xtermFrontend.ts +++ b/terminus-terminal/src/frontends/xtermFrontend.ts @@ -232,7 +232,7 @@ export class XTermFrontend extends Frontend { }[config.terminal.cursor] || config.terminal.cursor) this.xterm.setOption('cursorBlink', config.terminal.cursorBlink) this.xterm.setOption('macOptionIsMeta', config.terminal.altIsMeta) - this.xterm.setOption('scrollback', 100000) + this.xterm.setOption('scrollback', config.terminal.scrollbackLines) this.xterm.setOption('wordSeparator', config.terminal.wordSeparator) this.configuredFontSize = config.terminal.fontSize this.configuredLinePadding = config.terminal.linePadding diff --git a/terminus-terminal/src/recoveryProvider.ts b/terminus-terminal/src/recoveryProvider.ts index 47436cc2..61707c57 100644 --- a/terminus-terminal/src/recoveryProvider.ts +++ b/terminus-terminal/src/recoveryProvider.ts @@ -6,16 +6,27 @@ import { TerminalTabComponent } from './components/terminalTab.component' /** @hidden */ @Injectable() export class RecoveryProvider extends TabRecoveryProvider { - async recover (recoveryToken: RecoveryToken): Promise { - if (recoveryToken.type === 'app:terminal-tab') { - return { - type: TerminalTabComponent, - options: { - sessionOptions: recoveryToken.sessionOptions, - savedState: recoveryToken.savedState, - }, - } + async applicableTo (recoveryToken: RecoveryToken): Promise { + return recoveryToken.type === 'app:terminal-tab' + } + + async recover (recoveryToken: RecoveryToken): Promise { + return { + type: TerminalTabComponent, + options: { + sessionOptions: recoveryToken.sessionOptions, + savedState: recoveryToken.savedState, + }, + } + } + + duplicate (recoveryToken: RecoveryToken): RecoveryToken { + return { + ...recoveryToken, + sessionOptions: { + ...recoveryToken.sessionOptions, + restoreFromPTYID: null, + }, } - return null } } diff --git a/terminus-terminal/src/services/sessions.service.ts b/terminus-terminal/src/services/sessions.service.ts index 5ab9b083..4b89cb9e 100644 --- a/terminus-terminal/src/services/sessions.service.ts +++ b/terminus-terminal/src/services/sessions.service.ts @@ -1,13 +1,13 @@ import * as psNode from 'ps-node' import * as fs from 'mz/fs' import * as os from 'os' -import * as nodePTY from '@terminus-term/node-pty' +import { ipcRenderer } from 'electron' import { getWorkingDirectoryFromPID } from 'native-process-working-directory' import { Observable, Subject } from 'rxjs' import { first } from 'rxjs/operators' import { Injectable } from '@angular/core' import { Logger, LogService, ConfigService, WIN_BUILD_CONPTY_SUPPORTED, isWindowsBuild } from 'terminus-core' -import { SessionOptions } from '../api/interfaces' +import { SessionOptions, ChildProcess } from '../api/interfaces' /* eslint-disable block-scoped-var */ @@ -19,16 +19,72 @@ try { var windowsProcessTree = require('windows-process-tree') // eslint-disable-line @typescript-eslint/no-var-requires, no-var } catch { } -export interface ChildProcess { - pid: number - ppid: number - command: string -} - const windowsDirectoryRegex = /([a-zA-Z]:[^\:\[\]\?\"\<\>\|]+)/mi const OSC1337Prefix = Buffer.from('\x1b]1337;') const OSC1337Suffix = Buffer.from('\x07') +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class PTYProxy { + private id: string + private subscriptions: Map = new Map() + + static spawn (...options: any[]): PTYProxy { + return new PTYProxy(null, ...options) + } + + static restore (id: string): PTYProxy|null { + if (ipcRenderer.sendSync('pty:exists', id)) { + return new PTYProxy(id) + } + return null + } + + private constructor (id: string|null, ...options: any[]) { + if (id) { + this.id = id + } else { + this.id = ipcRenderer.sendSync('pty:spawn', ...options) + } + } + + getPTYID (): string { + return this.id + } + + getPID (): number { + return ipcRenderer.sendSync('pty:get-pid', this.id) + } + + subscribe (event: string, handler: (..._: any[]) => void): void { + const key = `pty:${this.id}:${event}` + const newHandler = (_event, ...args) => handler(...args) + this.subscriptions.set(key, newHandler) + ipcRenderer.on(key, newHandler) + } + + ackData (length: number): void { + ipcRenderer.send('pty:ack-data', this.id, length) + } + + unsubscribeAll (): void { + for (const k of this.subscriptions.keys()) { + ipcRenderer.off(k, this.subscriptions.get(k)) + } + } + + resize (columns: number, rows: number): void { + ipcRenderer.send('pty:resize', this.id, columns, rows) + } + + write (data: Buffer): void { + ipcRenderer.send('pty:write', this.id, data) + } + + kill (signal?: string): void { + ipcRenderer.send('pty:kill', this.id, signal) + } +} + /** * A session object for a [[BaseTerminalTabComponent]] * Extend this to implement custom I/O and process management for your terminal tab @@ -90,7 +146,7 @@ export abstract class BaseSession { /** @hidden */ export class Session extends BaseSession { - private pty: any + private pty: PTYProxy|null = null private pauseAfterExit = false private guessedCWD: string|null = null private reportedCWD: string @@ -103,47 +159,58 @@ export class Session extends BaseSession { start (options: SessionOptions): void { this.name = options.name ?? '' - const env = { - ...process.env, - TERM: 'xterm-256color', - TERM_PROGRAM: 'Terminus', - ...options.env, - ...this.config.store.terminal.environment || {}, + let pty: PTYProxy|null = null + + if (options.restoreFromPTYID) { + pty = PTYProxy.restore(options.restoreFromPTYID) + options.restoreFromPTYID = undefined } - if (process.platform === 'darwin' && !process.env.LC_ALL) { - const locale = process.env.LC_CTYPE ?? 'en_US.UTF-8' - Object.assign(env, { - LANG: locale, - LC_ALL: locale, - LC_MESSAGES: locale, - LC_NUMERIC: locale, - LC_COLLATE: locale, - LC_MONETARY: locale, + if (!pty) { + const env = { + ...process.env, + TERM: 'xterm-256color', + TERM_PROGRAM: 'Terminus', + ...options.env, + ...this.config.store.terminal.environment || {}, + } + + if (process.platform === 'darwin' && !process.env.LC_ALL) { + const locale = process.env.LC_CTYPE ?? 'en_US.UTF-8' + Object.assign(env, { + LANG: locale, + LC_ALL: locale, + LC_MESSAGES: locale, + LC_NUMERIC: locale, + LC_COLLATE: locale, + LC_MONETARY: locale, + }) + } + + let cwd = options.cwd ?? process.env.HOME + + if (!fs.existsSync(cwd)) { + console.warn('Ignoring non-existent CWD:', cwd) + cwd = undefined + } + + pty = PTYProxy.spawn(options.command, options.args ?? [], { + 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 + useConpty: (isWindowsBuild(WIN_BUILD_CONPTY_SUPPORTED) && this.config.store.terminal.useConPTY ? 1 : false) as any, }) + + this.guessedCWD = cwd ?? null } - let cwd = options.cwd ?? process.env.HOME + this.pty = pty - if (!fs.existsSync(cwd)) { - console.warn('Ignoring non-existent CWD:', cwd) - cwd = undefined - } - - this.pty = nodePTY.spawn(options.command, options.args ?? [], { - 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 - useConpty: (isWindowsBuild(WIN_BUILD_CONPTY_SUPPORTED) && this.config.store.terminal.useConPTY ? 1 : false) as any, - }) - - this.guessedCWD = cwd ?? null - - this.truePID = this.pty['pid'] + this.truePID = this.pty.getPID() setTimeout(async () => { // Retrieve any possible single children now that shell has fully started @@ -157,7 +224,10 @@ export class Session extends BaseSession { this.open = true - this.pty.on('data-buffered', (data: Buffer) => { + this.pty.subscribe('data-buffered', (array: Uint8Array) => { + this.pty!.ackData(array.length) + + let data = Buffer.from(array) data = this.processOSC1337(data) this.emitOutput(data) if (process.platform === 'win32') { @@ -165,7 +235,7 @@ export class Session extends BaseSession { } }) - this.pty.on('exit', () => { + this.pty.subscribe('exit', () => { if (this.pauseAfterExit) { return } else if (this.open) { @@ -173,7 +243,7 @@ export class Session extends BaseSession { } }) - this.pty.on('close', () => { + this.pty.subscribe('close', () => { if (this.pauseAfterExit) { this.emitOutput(Buffer.from('\r\nPress any key to close\r\n')) } else if (this.open) { @@ -182,26 +252,30 @@ export class Session extends BaseSession { }) this.pauseAfterExit = options.pauseAfterExit ?? false + + this.destroyed$.subscribe(() => this.pty!.unsubscribeAll()) + } + + getPTYID (): string|null { + return this.pty?.getPTYID() ?? null } resize (columns: number, rows: number): void { - if (this.pty._writable) { - this.pty.resize(columns, rows) - } + this.pty?.resize(columns, rows) } write (data: Buffer): void { if (this.open) { - if (this.pty._writable) { - this.pty.write(data) - } else { - this.destroy() - } + this.pty?.write(data) + // TODO if (this.pty._writable) { + // } else { + // this.destroy() + // } } } kill (signal?: string): void { - this.pty.kill(signal) + this.pty?.kill(signal) } async getChildProcesses (): Promise { @@ -245,7 +319,7 @@ export class Session extends BaseSession { this.kill('SIGTERM') setImmediate(() => { try { - process.kill(this.pty.pid, 0) + process.kill(this.pty!.getPID(), 0) // still alive setTimeout(() => { this.kill('SIGKILL') @@ -333,7 +407,6 @@ export class SessionsService { private constructor ( log: LogService, ) { - require('../bufferizedPTY')(nodePTY) // eslint-disable-line @typescript-eslint/no-var-requires this.logger = log.create('sessions') } diff --git a/webpack.plugin.config.js b/webpack.plugin.config.js index 8ff84479..e826effa 100644 --- a/webpack.plugin.config.js +++ b/webpack.plugin.config.js @@ -67,7 +67,6 @@ module.exports = options => { ], }, externals: [ - '@terminus-term/node-pty', 'child_process', 'electron-promise-ipc', 'electron',