Browse Source

remote pty

Eugene Pankov 4 years ago
parent
commit
174a1bcca7

+ 3 - 0
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)

+ 57 - 0
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
+    }
+}

+ 144 - 0
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<string, PTY> = {}
+
+    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)
+        })
+    }
+}

+ 1 - 0
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(),

+ 1 - 1
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",

+ 1 - 0
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",

+ 18 - 1
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<boolean>
+
     /**
      * @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<RecoveredTab|null>
+    abstract async recover (recoveryToken: RecoveryToken): Promise<RecoveredTab>
+
+    /**
+     * @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)
+    }
 }

+ 22 - 11
terminus-core/src/components/splitTab.component.ts

@@ -256,7 +256,7 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit
     /** @hidden */
     async ngAfterViewInit (): Promise<void> {
         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<RecoveredTab|null> {
-        if (recoveryToken.type === 'app:split-tab') {
-            return {
-                type: SplitTabComponent,
-                options: { _recoveredState: recoveryToken },
-            }
+    async applicableTo (recoveryToken: RecoveryToken): Promise<boolean> {
+        return recoveryToken.type === 'app:split-tab'
+    }
+
+    async recover (recoveryToken: RecoveryToken): Promise<RecoveredTab> {
+        return {
+            type: SplitTabComponent,
+            options: { _recoveredState: recoveryToken },
+        }
+    }
+
+    duplicate (recoveryToken: RecoveryToken): RecoveryToken {
+        return {
+            ...recoveryToken,
+            duplicate: true,
         }
-        return null
     }
 }

+ 5 - 2
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
+            })
         })
     }
 

+ 11 - 7
terminus-core/src/services/tabRecovery.service.ts

@@ -40,16 +40,20 @@ export class TabRecoveryService {
         return token
     }
 
-    async recoverTab (token: RecoveryToken): Promise<RecoveredTab|null> {
+    async recoverTab (token: RecoveryToken, duplicate = false): Promise<RecoveredTab|null> {
         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)
             }

+ 1 - 1
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)
         }

+ 33 - 0
terminus-core/yarn.lock

@@ -80,6 +80,15 @@ [email protected]:
     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"

+ 11 - 10
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<RecoveredTab|null> {
-        if (recoveryToken.type === 'app:serial-tab') {
-            return {
-                type: SerialTabComponent,
-                options: {
-                    connection: recoveryToken.connection,
-                    savedState: recoveryToken.savedState,
-                },
-            }
+    async applicableTo (recoveryToken: RecoveryToken): Promise<boolean> {
+        return recoveryToken.type === 'app:serial-tab'
+    }
+
+    async recover (recoveryToken: RecoveryToken): Promise<RecoveredTab> {
+        return {
+            type: SerialTabComponent,
+            options: {
+                connection: recoveryToken.connection,
+                savedState: recoveryToken.savedState,
+            },
         }
-        return null
     }
 }

+ 11 - 10
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<RecoveredTab|null> {
-        if (recoveryToken.type === 'app:ssh-tab') {
-            return {
-                type: SSHTabComponent,
-                options: {
-                    connection: recoveryToken['connection'],
-                    savedState: recoveryToken['savedState'],
-                },
-            }
+    async applicableTo (recoveryToken: RecoveryToken): Promise<boolean> {
+        return recoveryToken.type === 'app:ssh-tab'
+    }
+
+    async recover (recoveryToken: RecoveryToken): Promise<RecoveredTab> {
+        return {
+            type: SSHTabComponent,
+            options: {
+                connection: recoveryToken['connection'],
+                savedState: recoveryToken['savedState'],
+            },
         }
-        return null
     }
 }

+ 17 - 13
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<MenuItemConstructorOptions[]> {
         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)
             }
         }))
 

+ 7 - 0
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
+}

+ 0 - 57
terminus-terminal/src/bufferizedPTY.js

@@ -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
-  }
-}

+ 9 - 5
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<any> {
@@ -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(),
         }

+ 1 - 0
terminus-terminal/src/config.ts

@@ -75,6 +75,7 @@ export class TerminalConfigProvider extends ConfigProvider {
                 caseSensitive: false,
             },
             detectProgress: true,
+            scrollbackLines: 25000,
         },
     }
 

+ 1 - 1
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

+ 21 - 10
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<RecoveredTab|null> {
-        if (recoveryToken.type === 'app:terminal-tab') {
-            return {
-                type: TerminalTabComponent,
-                options: {
-                    sessionOptions: recoveryToken.sessionOptions,
-                    savedState: recoveryToken.savedState,
-                },
-            }
+    async applicableTo (recoveryToken: RecoveryToken): Promise<boolean> {
+        return recoveryToken.type === 'app:terminal-tab'
+    }
+
+    async recover (recoveryToken: RecoveryToken): Promise<RecoveredTab> {
+        return {
+            type: TerminalTabComponent,
+            options: {
+                sessionOptions: recoveryToken.sessionOptions,
+                savedState: recoveryToken.savedState,
+            },
+        }
+    }
+
+    duplicate (recoveryToken: RecoveryToken): RecoveryToken {
+        return {
+            ...recoveryToken,
+            sessionOptions: {
+                ...recoveryToken.sessionOptions,
+                restoreFromPTYID: null,
+            },
         }
-        return null
     }
 }

+ 130 - 57
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<string, any> = 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 (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 (options.restoreFromPTYID) {
+            pty = PTYProxy.restore(options.restoreFromPTYID)
+            options.restoreFromPTYID = undefined
         }
 
-        let cwd = options.cwd ?? process.env.HOME
+        if (!pty) {
+            const env = {
+                ...process.env,
+                TERM: 'xterm-256color',
+                TERM_PROGRAM: 'Terminus',
+                ...options.env,
+                ...this.config.store.terminal.environment || {},
+            }
 
-        if (!fs.existsSync(cwd)) {
-            console.warn('Ignoring non-existent CWD:', cwd)
-            cwd = 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,
+                })
+            }
 
-        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,
-        })
+            let cwd = options.cwd ?? process.env.HOME
 
-        this.guessedCWD = cwd ?? null
+            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.truePID = this.pty['pid']
+            this.guessedCWD = cwd ?? null
+        }
+
+        this.pty = pty
+
+        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<ChildProcess[]> {
@@ -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')
     }
 

+ 0 - 1
webpack.plugin.config.js

@@ -67,7 +67,6 @@ module.exports = options => {
             ],
         },
         externals: [
-            '@terminus-term/node-pty',
             'child_process',
             'electron-promise-ipc',
             'electron',