remote pty

This commit is contained in:
Eugene Pankov
2021-04-04 20:07:57 +02:00
parent 80c781a8ca
commit 174a1bcca7
23 changed files with 506 additions and 188 deletions

View File

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

View File

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

View File

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

View File

@@ -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(),
}

View File

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

View File

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

View File

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

View File

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