mirror of
https://github.com/Eugeny/tabby.git
synced 2025-07-03 09:59:58 +00:00
remote pty
This commit is contained in:
parent
80c781a8ca
commit
174a1bcca7
@ -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
app/lib/bufferizedPTY.js
Normal file
57
app/lib/bufferizedPTY.js
Normal file
@ -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
app/lib/pty.ts
Normal file
144
app/lib/pty.ts
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
@ -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(),
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}))
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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(),
|
||||
}
|
||||
|
@ -75,6 +75,7 @@ export class TerminalConfigProvider extends ConfigProvider {
|
||||
caseSensitive: false,
|
||||
},
|
||||
detectProgress: true,
|
||||
scrollbackLines: 25000,
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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 (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<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')
|
||||
}
|
||||
|
||||
|
@ -67,7 +67,6 @@ module.exports = options => {
|
||||
],
|
||||
},
|
||||
externals: [
|
||||
'@terminus-term/node-pty',
|
||||
'child_process',
|
||||
'electron-promise-ipc',
|
||||
'electron',
|
||||
|
Loading…
x
Reference in New Issue
Block a user