Files
tabby/terminus-terminal/src/services/sessions.service.ts
2021-01-02 20:31:26 +01:00

356 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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 { exec } from 'mz/child_process'
import { SessionOptions } from '../api/interfaces'
/* eslint-disable block-scoped-var */
try {
var macOSNativeProcessList = require('macos-native-processlist') // eslint-disable-line @typescript-eslint/no-var-requires, no-var
} catch { }
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 catalinaDataVolumePrefix = '/System/Volumes/Data'
const OSC1337Prefix = Buffer.from('\x1b]1337;')
const OSC1337Suffix = Buffer.from('\x07')
/**
* A session object for a [[BaseTerminalTabComponent]]
* Extend this to implement custom I/O and process management for your terminal tab
*/
export abstract class BaseSession {
open: boolean
name: string
truePID: number
protected output = new Subject<string>()
protected binaryOutput = new Subject<Buffer>()
protected closed = new Subject<void>()
protected destroyed = new Subject<void>()
private initialDataBuffer = Buffer.from('')
private initialDataBufferReleased = false
get output$ (): Observable<string> { return this.output }
get binaryOutput$ (): Observable<Buffer> { return this.binaryOutput }
get closed$ (): Observable<void> { return this.closed }
get destroyed$ (): Observable<void> { return this.destroyed }
emitOutput (data: Buffer): void {
if (!this.initialDataBufferReleased) {
this.initialDataBuffer = Buffer.concat([this.initialDataBuffer, data])
} else {
this.output.next(data.toString())
this.binaryOutput.next(data)
}
}
releaseInitialDataBuffer (): void {
this.initialDataBufferReleased = true
this.output.next(this.initialDataBuffer.toString())
this.binaryOutput.next(this.initialDataBuffer)
this.initialDataBuffer = Buffer.from('')
}
async destroy (): Promise<void> {
if (this.open) {
this.open = false
this.closed.next()
this.destroyed.next()
this.closed.complete()
this.destroyed.complete()
this.output.complete()
this.binaryOutput.complete()
await this.gracefullyKillProcess()
}
}
abstract start (options: SessionOptions): void
abstract resize (columns: number, rows: number): void
abstract write (data: Buffer): void
abstract kill (signal?: string): void
abstract async getChildProcesses (): Promise<ChildProcess[]>
abstract async gracefullyKillProcess (): Promise<void>
abstract async getWorkingDirectory (): Promise<string|null>
}
/** @hidden */
export class Session extends BaseSession {
private pty: any
private pauseAfterExit = false
private guessedCWD: string|null = null
private reportedCWD: string
constructor (private config: ConfigService) {
super()
}
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 || {},
}
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
}
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']
setTimeout(async () => {
// Retrieve any possible single children now that shell has fully started
let processes = await this.getChildProcesses()
while (processes.length === 1) {
this.truePID = processes[0].pid
processes = await this.getChildProcesses()
}
}, 2000)
this.open = true
this.pty.on('data-buffered', (data: Buffer) => {
data = this.processOSC1337(data)
this.emitOutput(data)
if (process.platform === 'win32') {
this.guessWindowsCWD(data.toString())
}
})
this.pty.on('exit', () => {
if (this.pauseAfterExit) {
return
} else if (this.open) {
this.destroy()
}
})
this.pty.on('close', () => {
if (this.pauseAfterExit) {
this.emitOutput(Buffer.from('\r\nPress any key to close\r\n'))
} else if (this.open) {
this.destroy()
}
})
this.pauseAfterExit = options.pauseAfterExit ?? false
}
resize (columns: number, rows: number): void {
if (this.pty._writable) {
this.pty.resize(columns, rows)
}
}
write (data: Buffer): void {
if (this.open) {
if (this.pty._writable) {
this.pty.write(data)
} else {
this.destroy()
}
}
}
kill (signal?: string): void {
this.pty.kill(signal)
}
async getChildProcesses (): Promise<ChildProcess[]> {
if (!this.truePID) {
return []
}
if (process.platform === 'darwin') {
const processes = await macOSNativeProcessList.getProcessList()
return processes.filter(x => x.ppid === this.truePID).map(p => ({
pid: p.pid,
ppid: p.ppid,
command: p.name,
}))
}
if (process.platform === 'win32') {
return new Promise<ChildProcess[]>(resolve => {
windowsProcessTree.getProcessTree(this.truePID, tree => {
resolve(tree ? tree.children.map(child => ({
pid: child.pid,
ppid: tree.pid,
command: child.name,
})) : [])
})
})
}
return new Promise<ChildProcess[]>((resolve, reject) => {
psNode.lookup({ ppid: this.truePID }, (err, processes) => {
if (err) {
return reject(err)
}
resolve(processes as ChildProcess[])
})
})
}
async gracefullyKillProcess (): Promise<void> {
if (process.platform === 'win32') {
this.kill()
} else {
await new Promise((resolve) => {
this.kill('SIGTERM')
setImmediate(() => {
try {
process.kill(this.pty.pid, 0)
// still alive
setTimeout(() => {
this.kill('SIGKILL')
resolve()
}, 1000)
} catch {
resolve()
}
})
})
}
}
async getWorkingDirectory (): Promise<string|null> {
if (this.reportedCWD) {
return this.reportedCWD
}
if (!this.truePID) {
return null
}
if (process.platform === 'darwin') {
let lines: string[] = []
try {
lines = (await exec(`lsof -p ${this.truePID} -Fn`))[0].toString().split('\n')
} catch (e) {
return null
}
let cwd = lines[lines[1] === 'fcwd' ? 2 : 1].substring(1)
if (cwd.startsWith(catalinaDataVolumePrefix)) {
cwd = cwd.substring(catalinaDataVolumePrefix.length)
}
return cwd
}
if (process.platform === 'linux') {
try {
const cwd = await fs.readlink(`/proc/${this.truePID}/cwd`)
return cwd
} catch (exc) {
console.error(exc)
return null
}
}
if (process.platform === 'win32') {
if (!this.guessedCWD) {
return null
}
try {
await fs.access(this.guessedCWD)
} catch (e) {
return null
}
return this.guessedCWD
}
return null
}
private guessWindowsCWD (data: string) {
const match = windowsDirectoryRegex.exec(data)
if (match) {
this.guessedCWD = match[0]
}
}
private processOSC1337 (data: Buffer) {
if (data.includes(OSC1337Prefix)) {
const preData = data.subarray(0, data.indexOf(OSC1337Prefix))
const params = data.subarray(data.indexOf(OSC1337Prefix) + OSC1337Prefix.length)
const postData = params.subarray(params.indexOf(OSC1337Suffix) + OSC1337Suffix.length)
const paramString = params.subarray(0, params.indexOf(OSC1337Suffix)).toString()
if (paramString.startsWith('CurrentDir=')) {
this.reportedCWD = paramString.split('=')[1]
if (this.reportedCWD.startsWith('~')) {
this.reportedCWD = os.homedir() + this.reportedCWD.substring(1)
}
data = Buffer.concat([preData, postData])
}
}
return data
}
}
/** @hidden */
@Injectable({ providedIn: 'root' })
export class SessionsService {
sessions = new Map<string, BaseSession>()
logger: Logger
private lastID = 0
private constructor (
log: LogService,
) {
require('../bufferizedPTY')(nodePTY) // eslint-disable-line @typescript-eslint/no-var-requires
this.logger = log.create('sessions')
}
addSession (session: BaseSession, options: SessionOptions): BaseSession {
this.lastID++
options.name = `session-${this.lastID}`
session.start(options)
session.destroyed$.pipe(first()).subscribe(() => {
this.sessions.delete(session.name)
})
this.sessions.set(session.name, session)
return session
}
}