moved login scripts processing into tabby-terminal

This commit is contained in:
Eugene Pankov
2021-07-05 23:56:38 +02:00
parent 461cd2bec7
commit bf762cc4c7
16 changed files with 270 additions and 344 deletions

View File

@@ -2,7 +2,7 @@ import * as psNode from 'ps-node'
import * as fs from 'mz/fs' import * as fs from 'mz/fs'
import * as os from 'os' import * as os from 'os'
import { Injector } from '@angular/core' import { Injector } from '@angular/core'
import { HostAppService, ConfigService, WIN_BUILD_CONPTY_SUPPORTED, isWindowsBuild, Platform, BootstrapData, BOOTSTRAP_DATA } from 'tabby-core' import { HostAppService, ConfigService, WIN_BUILD_CONPTY_SUPPORTED, isWindowsBuild, Platform, BootstrapData, BOOTSTRAP_DATA, LogService } from 'tabby-core'
import { BaseSession } from 'tabby-terminal' import { BaseSession } from 'tabby-terminal'
import { ipcRenderer } from 'electron' import { ipcRenderer } from 'electron'
import { getWorkingDirectoryFromPID } from 'native-process-working-directory' import { getWorkingDirectoryFromPID } from 'native-process-working-directory'
@@ -97,7 +97,7 @@ export class Session extends BaseSession {
private bootstrapData: BootstrapData private bootstrapData: BootstrapData
constructor (injector: Injector) { constructor (injector: Injector) {
super() super(injector.get(LogService).create('local'))
this.config = injector.get(ConfigService) this.config = injector.get(ConfigService)
this.hostApp = injector.get(HostAppService) this.hostApp = injector.get(HostAppService)
this.bootstrapData = injector.get(BOOTSTRAP_DATA) this.bootstrapData = injector.get(BOOTSTRAP_DATA)

View File

@@ -1,22 +1,15 @@
import stripAnsi from 'strip-ansi' import stripAnsi from 'strip-ansi'
import SerialPort from 'serialport' import SerialPort from 'serialport'
import { Logger, LogService, NotificationsService, Profile } from 'tabby-core' import { LogService, NotificationsService, Profile } from 'tabby-core'
import { Subject, Observable } from 'rxjs' import { Subject, Observable } from 'rxjs'
import { Injector, NgZone } from '@angular/core' import { Injector, NgZone } from '@angular/core'
import { BaseSession, StreamProcessingOptions, TerminalStreamProcessor } from 'tabby-terminal' import { BaseSession, LoginScriptsOptions, StreamProcessingOptions, TerminalStreamProcessor } from 'tabby-terminal'
export interface LoginScript {
expect: string
send: string
isRegex?: boolean
optional?: boolean
}
export interface SerialProfile extends Profile { export interface SerialProfile extends Profile {
options: SerialProfileOptions options: SerialProfileOptions
} }
export interface SerialProfileOptions extends StreamProcessingOptions { export interface SerialProfileOptions extends StreamProcessingOptions, LoginScriptsOptions {
port: string port: string
baudrate?: number baudrate?: number
databits?: number databits?: number
@@ -26,7 +19,6 @@ export interface SerialProfileOptions extends StreamProcessingOptions {
xon?: boolean xon?: boolean
xoff?: boolean xoff?: boolean
xany?: boolean xany?: boolean
scripts?: LoginScript[]
color?: string color?: string
} }
@@ -40,9 +32,7 @@ export interface SerialPortInfo {
} }
export class SerialSession extends BaseSession { export class SerialSession extends BaseSession {
scripts?: LoginScript[]
serial: SerialPort serial: SerialPort
logger: Logger
get serviceMessage$ (): Observable<string> { return this.serviceMessage } get serviceMessage$ (): Observable<string> { return this.serviceMessage }
private serviceMessage = new Subject<string>() private serviceMessage = new Subject<string>()
@@ -51,62 +41,20 @@ export class SerialSession extends BaseSession {
private notifications: NotificationsService private notifications: NotificationsService
constructor (injector: Injector, public profile: SerialProfile) { constructor (injector: Injector, public profile: SerialProfile) {
super() super(injector.get(LogService).create(`serial-${profile.options.port}`))
this.logger = injector.get(LogService).create(`serial-${profile.options.port}`)
this.zone = injector.get(NgZone) this.zone = injector.get(NgZone)
this.notifications = injector.get(NotificationsService) this.notifications = injector.get(NotificationsService)
this.scripts = profile.options.scripts ?? []
this.streamProcessor = new TerminalStreamProcessor(profile.options) this.streamProcessor = new TerminalStreamProcessor(profile.options)
this.streamProcessor.outputToSession$.subscribe(data => { this.streamProcessor.outputToSession$.subscribe(data => {
this.serial?.write(data.toString()) this.serial?.write(data.toString())
}) })
this.streamProcessor.outputToTerminal$.subscribe(data => { this.streamProcessor.outputToTerminal$.subscribe(data => {
this.emitOutput(data) this.emitOutput(data)
this.loginScriptProcessor?.feedFromSession(data)
const dataString = data.toString()
if (this.scripts) {
let found = false
for (const script of this.scripts) {
let match = false
let cmd = ''
if (script.isRegex) {
const re = new RegExp(script.expect, 'g')
if (re.test(dataString)) {
cmd = dataString.replace(re, script.send)
match = true
found = true
}
} else {
if (dataString.includes(script.expect)) {
cmd = script.send
match = true
found = true
}
}
if (match) {
this.logger.info('Executing script: "' + cmd + '"')
this.serial.write(cmd + '\n')
this.scripts = this.scripts.filter(x => x !== script)
} else {
if (script.optional) {
this.logger.debug('Skip optional script: ' + script.expect)
found = true
this.scripts = this.scripts.filter(x => x !== script)
} else {
break
}
}
}
if (found) {
this.executeUnconditionalScripts()
}
}
}) })
this.setLoginScriptsOptions(profile.options)
} }
async start (): Promise<void> { async start (): Promise<void> {
@@ -151,6 +99,7 @@ export class SerialSession extends BaseSession {
}) })
this.open = true this.open = true
setTimeout(() => this.streamProcessor.start())
this.serial.on('readable', () => { this.serial.on('readable', () => {
this.streamProcessor.feedFromSession(this.serial.read()) this.streamProcessor.feedFromSession(this.serial.read())
@@ -163,7 +112,7 @@ export class SerialSession extends BaseSession {
} }
}) })
this.executeUnconditionalScripts() this.loginScriptProcessor?.executeUnconditionalScripts()
} }
write (data: Buffer): void { write (data: Buffer): void {
@@ -205,18 +154,4 @@ export class SerialSession extends BaseSession {
async getWorkingDirectory (): Promise<string|null> { async getWorkingDirectory (): Promise<string|null> {
return null return null
} }
private executeUnconditionalScripts () {
if (this.scripts) {
for (const script of this.scripts) {
if (!script.expect) {
console.log('Executing script:', script.send)
this.serial.write(script.send + '\n')
this.scripts = this.scripts.filter(x => x !== script)
} else {
break
}
}
}
}
} }

View File

@@ -80,43 +80,6 @@ ul.nav-tabs(ngbNav, #nav='ngbNav')
li(ngbNavItem) li(ngbNavItem)
a(ngbNavLink) Login scripts a(ngbNavLink) Login scripts
ng-template(ngbNavContent) ng-template(ngbNavContent)
table(*ngIf='profile.options.scripts.length > 0') login-scripts-settings([options]='profile.options')
tr
th String to expect
th String to be sent
th.pl-2 Regex
th.pl-2 Optional
th.pl-2 Actions
tr(*ngFor='let script of profile.options.scripts')
td.pr-2
input.form-control(
type='text',
[(ngModel)]='script.expect'
)
td
input.form-control(
type='text',
[(ngModel)]='script.send'
)
td.pl-2
checkbox(
[(ngModel)]='script.isRegex',
)
td.pl-2
checkbox(
[(ngModel)]='script.optional',
)
td.pl-2
.input-group.flex-nowrap
button.btn.btn-outline-info.ml-0((click)='moveScriptUp(script)')
i.fas.fa-arrow-up
button.btn.btn-outline-info.ml-0((click)='moveScriptDown(script)')
i.fas.fa-arrow-down
button.btn.btn-outline-danger.ml-0((click)='deleteScript(script)')
i.fas.fa-trash
button.btn.btn-outline-info.mt-2((click)='addScript()')
i.fas.fa-plus
span New item
div([ngbNavOutlet]='nav') div([ngbNavOutlet]='nav')

View File

@@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators' import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators'
import { PlatformService, ProfileSettingsComponent } from 'tabby-core' import { ProfileSettingsComponent } from 'tabby-core'
import { LoginScript, SerialPortInfo, BAUD_RATES, SerialProfile } from '../api' import { SerialPortInfo, BAUD_RATES, SerialProfile } from '../api'
import { SerialService } from '../services/serial.service' import { SerialService } from '../services/serial.service'
/** @hidden */ /** @hidden */
@@ -14,7 +14,6 @@ export class SerialProfileSettingsComponent implements ProfileSettingsComponent
foundPorts: SerialPortInfo[] foundPorts: SerialPortInfo[]
constructor ( constructor (
private platform: PlatformService,
private serial: SerialService, private serial: SerialService,
) { } ) { }
@@ -40,50 +39,6 @@ export class SerialProfileSettingsComponent implements ProfileSettingsComponent
} }
async ngOnInit () { async ngOnInit () {
this.profile.options.scripts = this.profile.options.scripts ?? []
this.foundPorts = await this.serial.listPorts() this.foundPorts = await this.serial.listPorts()
} }
moveScriptUp (script: LoginScript) {
if (!this.profile.options.scripts) {
this.profile.options.scripts = []
}
const index = this.profile.options.scripts.indexOf(script)
if (index > 0) {
this.profile.options.scripts.splice(index, 1)
this.profile.options.scripts.splice(index - 1, 0, script)
}
}
moveScriptDown (script: LoginScript) {
if (!this.profile.options.scripts) {
this.profile.options.scripts = []
}
const index = this.profile.options.scripts.indexOf(script)
if (index >= 0 && index < this.profile.options.scripts.length - 1) {
this.profile.options.scripts.splice(index, 1)
this.profile.options.scripts.splice(index + 1, 0, script)
}
}
async deleteScript (script: LoginScript) {
if (this.profile.options.scripts && (await this.platform.showMessageBox(
{
type: 'warning',
message: 'Delete this script?',
detail: script.expect,
buttons: ['Keep', 'Delete'],
defaultId: 1,
}
)).response === 1) {
this.profile.options.scripts = this.profile.options.scripts.filter(x => x !== script)
}
}
addScript () {
if (!this.profile.options.scripts) {
this.profile.options.scripts = []
}
this.profile.options.scripts.push({ expect: '', send: '' })
}
} }

View File

@@ -10,8 +10,8 @@ import stripAnsi from 'strip-ansi'
import socksv5 from 'socksv5' import socksv5 from 'socksv5'
import { Injector, NgZone } from '@angular/core' import { Injector, NgZone } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { ConfigService, FileProvidersService, HostAppService, Logger, NotificationsService, Platform, PlatformService, wrapPromise, PromptModalComponent, Profile, LogService } from 'tabby-core' import { ConfigService, FileProvidersService, HostAppService, NotificationsService, Platform, PlatformService, wrapPromise, PromptModalComponent, Profile, LogService } from 'tabby-core'
import { BaseSession } from 'tabby-terminal' import { BaseSession, LoginScriptsOptions } from 'tabby-terminal'
import { Server, Socket, createServer, createConnection } from 'net' import { Server, Socket, createServer, createConnection } from 'net'
import { Client, ClientChannel, SFTPWrapper } from 'ssh2' import { Client, ClientChannel, SFTPWrapper } from 'ssh2'
import type { FileEntry, Stats } from 'ssh2-streams' import type { FileEntry, Stats } from 'ssh2-streams'
@@ -22,13 +22,6 @@ import { promisify } from 'util'
const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent' const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent'
export interface LoginScript {
expect: string
send: string
isRegex?: boolean
optional?: boolean
}
export enum SSHAlgorithmType { export enum SSHAlgorithmType {
HMAC = 'hmac', HMAC = 'hmac',
KEX = 'kex', KEX = 'kex',
@@ -40,14 +33,13 @@ export interface SSHProfile extends Profile {
options: SSHProfileOptions options: SSHProfileOptions
} }
export interface SSHProfileOptions { export interface SSHProfileOptions extends LoginScriptsOptions {
host: string host: string
port?: number port?: number
user: string user: string
auth?: null|'password'|'publicKey'|'agent'|'keyboardInteractive' auth?: null|'password'|'publicKey'|'agent'|'keyboardInteractive'
password?: string password?: string
privateKeys?: string[] privateKeys?: string[]
scripts?: LoginScript[]
keepaliveInterval?: number keepaliveInterval?: number
keepaliveCountMax?: number keepaliveCountMax?: number
readyTimeout?: number readyTimeout?: number
@@ -255,12 +247,10 @@ export class SFTPSession {
} }
export class SSHSession extends BaseSession { export class SSHSession extends BaseSession {
scripts?: LoginScript[]
shell?: ClientChannel shell?: ClientChannel
ssh: Client ssh: Client
sftp?: SFTPWrapper sftp?: SFTPWrapper
forwardedPorts: ForwardedPort[] = [] forwardedPorts: ForwardedPort[] = []
logger: Logger
jumpStream: any jumpStream: any
proxyCommandStream: ProxyCommandStream|null = null proxyCommandStream: ProxyCommandStream|null = null
savedPassword?: string savedPassword?: string
@@ -286,8 +276,7 @@ export class SSHSession extends BaseSession {
injector: Injector, injector: Injector,
public profile: SSHProfile, public profile: SSHProfile,
) { ) {
super() super(injector.get(LogService).create(`ssh-${profile.options.host}-${profile.options.port}`))
this.logger = injector.get(LogService).create(`ssh-${profile.options.host}-${profile.options.port}`)
this.passwordStorage = injector.get(PasswordStorageService) this.passwordStorage = injector.get(PasswordStorageService)
this.ngbModal = injector.get(NgbModal) this.ngbModal = injector.get(NgbModal)
@@ -298,7 +287,6 @@ export class SSHSession extends BaseSession {
this.fileProviders = injector.get(FileProvidersService) this.fileProviders = injector.get(FileProvidersService)
this.config = injector.get(ConfigService) this.config = injector.get(ConfigService)
this.scripts = profile.options.scripts ?? []
this.destroyed$.subscribe(() => { this.destroyed$.subscribe(() => {
for (const port of this.forwardedPorts) { for (const port of this.forwardedPorts) {
if (port.type === PortForwardType.Local) { if (port.type === PortForwardType.Local) {
@@ -306,6 +294,8 @@ export class SSHSession extends BaseSession {
} }
} }
}) })
this.setLoginScriptsOptions(profile.options)
} }
async init (): Promise<void> { async init (): Promise<void> {
@@ -389,6 +379,8 @@ export class SSHSession extends BaseSession {
return return
} }
this.loginScriptProcessor?.executeUnconditionalScripts()
this.shell.on('greeting', greeting => { this.shell.on('greeting', greeting => {
this.emitServiceMessage(`Shell greeting: ${greeting}`) this.emitServiceMessage(`Shell greeting: ${greeting}`)
}) })
@@ -398,48 +390,7 @@ export class SSHSession extends BaseSession {
}) })
this.shell.on('data', data => { this.shell.on('data', data => {
const dataString = data.toString()
this.emitOutput(data) this.emitOutput(data)
if (this.scripts) {
let found = false
for (const script of this.scripts) {
let match = false
let cmd = ''
if (script.isRegex) {
const re = new RegExp(script.expect, 'g')
if (dataString.match(re)) {
cmd = dataString.replace(re, script.send)
match = true
found = true
}
} else {
if (dataString.includes(script.expect)) {
cmd = script.send
match = true
found = true
}
}
if (match) {
this.logger.info('Executing script: "' + cmd + '"')
this.shell?.write(cmd + '\n')
this.scripts = this.scripts.filter(x => x !== script)
} else {
if (script.optional) {
this.logger.debug('Skip optional script: ' + script.expect)
found = true
this.scripts = this.scripts.filter(x => x !== script)
} else {
break
}
}
}
if (found) {
this.executeUnconditionalScripts()
}
}
}) })
this.shell.on('end', () => { this.shell.on('end', () => {
@@ -513,8 +464,6 @@ export class SSHSession extends BaseSession {
}) })
}) })
}) })
this.executeUnconditionalScripts()
} }
emitServiceMessage (msg: string): void { emitServiceMessage (msg: string): void {
@@ -714,20 +663,6 @@ export class SSHSession extends BaseSession {
}) })
} }
private executeUnconditionalScripts () {
if (this.scripts) {
for (const script of this.scripts) {
if (!script.expect) {
console.log('Executing script:', script.send)
this.shell?.write(script.send + '\n')
this.scripts = this.scripts.filter(x => x !== script)
} else {
break
}
}
}
}
async loadPrivateKey (privateKeyContents?: Buffer): Promise<string|null> { async loadPrivateKey (privateKeyContents?: Buffer): Promise<string|null> {
if (!privateKeyContents) { if (!privateKeyContents) {
const userKeyPath = path.join(process.env.HOME!, '.ssh', 'id_rsa') const userKeyPath = path.join(process.env.HOME!, '.ssh', 'id_rsa')

View File

@@ -189,43 +189,6 @@ ul.nav-tabs(ngbNav, #nav='ngbNav')
li(ngbNavItem) li(ngbNavItem)
a(ngbNavLink) Login scripts a(ngbNavLink) Login scripts
ng-template(ngbNavContent) ng-template(ngbNavContent)
table(*ngIf='profile.options.scripts.length > 0') login-scripts-settings([options]='profile.options')
tr
th String to expect
th String to be sent
th.pl-2 Regex
th.pl-2 Optional
th.pl-2 Actions
tr(*ngFor='let script of profile.options.scripts')
td.pr-2
input.form-control(
type='text',
[(ngModel)]='script.expect'
)
td
input.form-control(
type='text',
[(ngModel)]='script.send'
)
td.pl-2
checkbox(
[(ngModel)]='script.isRegex',
)
td.pl-2
checkbox(
[(ngModel)]='script.optional',
)
td.pl-2
.input-group.flex-nowrap
button.btn.btn-outline-info.ml-0((click)='moveScriptUp(script)')
i.fas.fa-arrow-up
button.btn.btn-outline-info.ml-0((click)='moveScriptDown(script)')
i.fas.fa-arrow-down
button.btn.btn-outline-danger.ml-0((click)='deleteScript(script)')
i.fas.fa-trash
button.btn.btn-outline-info.mt-2((click)='addScript()')
i.fas.fa-plus
span New item
div([ngbNavOutlet]='nav') div([ngbNavOutlet]='nav')

View File

@@ -2,9 +2,9 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { ConfigService, PlatformService, FileProvidersService, Platform, HostAppService, PromptModalComponent } from 'tabby-core' import { ConfigService, FileProvidersService, Platform, HostAppService, PromptModalComponent } from 'tabby-core'
import { PasswordStorageService } from '../services/passwordStorage.service' import { PasswordStorageService } from '../services/passwordStorage.service'
import { LoginScript, ForwardedPortConfig, SSHAlgorithmType, ALGORITHM_BLACKLIST, SSHProfile } from '../api' import { ForwardedPortConfig, SSHAlgorithmType, ALGORITHM_BLACKLIST, SSHProfile } from '../api'
import * as ALGORITHMS from 'ssh2/lib/protocol/constants' import * as ALGORITHMS from 'ssh2/lib/protocol/constants'
/** @hidden */ /** @hidden */
@@ -23,9 +23,8 @@ export class SSHProfileSettingsComponent {
jumpHosts: SSHProfile[] jumpHosts: SSHProfile[]
constructor ( constructor (
public config: ConfigService,
public hostApp: HostAppService, public hostApp: HostAppService,
private platform: PlatformService, private config: ConfigService,
private passwordStorage: PasswordStorageService, private passwordStorage: PasswordStorageService,
private ngbModal: NgbModal, private ngbModal: NgbModal,
private fileProviders: FileProvidersService, private fileProviders: FileProvidersService,
@@ -63,7 +62,6 @@ export class SSHProfileSettingsComponent {
} }
} }
this.profile.options.scripts = this.profile.options.scripts ?? []
this.profile.options.auth = this.profile.options.auth ?? null this.profile.options.auth = this.profile.options.auth ?? null
this.profile.options.privateKeys ??= [] this.profile.options.privateKeys ??= []
@@ -116,49 +114,6 @@ export class SSHProfileSettingsComponent {
} }
} }
moveScriptUp (script: LoginScript) {
if (!this.profile.options.scripts) {
this.profile.options.scripts = []
}
const index = this.profile.options.scripts.indexOf(script)
if (index > 0) {
this.profile.options.scripts.splice(index, 1)
this.profile.options.scripts.splice(index - 1, 0, script)
}
}
moveScriptDown (script: LoginScript) {
if (!this.profile.options.scripts) {
this.profile.options.scripts = []
}
const index = this.profile.options.scripts.indexOf(script)
if (index >= 0 && index < this.profile.options.scripts.length - 1) {
this.profile.options.scripts.splice(index, 1)
this.profile.options.scripts.splice(index + 1, 0, script)
}
}
async deleteScript (script: LoginScript) {
if (this.profile.options.scripts && (await this.platform.showMessageBox(
{
type: 'warning',
message: 'Delete this script?',
detail: script.expect,
buttons: ['Keep', 'Delete'],
defaultId: 1,
}
)).response === 1) {
this.profile.options.scripts = this.profile.options.scripts.filter(x => x !== script)
}
}
addScript () {
if (!this.profile.options.scripts) {
this.profile.options.scripts = []
}
this.profile.options.scripts.push({ expect: '', send: '' })
}
onForwardAdded (fw: ForwardedPortConfig) { onForwardAdded (fw: ForwardedPortConfig) {
this.profile.options.forwardedPorts = this.profile.options.forwardedPorts ?? [] this.profile.options.forwardedPorts = this.profile.options.forwardedPorts ?? []
this.profile.options.forwardedPorts.push(fw) this.profile.options.forwardedPorts.push(fw)

View File

@@ -1,16 +1,27 @@
.form-group ul.nav-tabs(ngbNav, #nav='ngbNav')
label Host li(ngbNavItem)
input.form-control( a(ngbNavLink) General
type='text', ng-template(ngbNavContent)
[(ngModel)]='profile.options.host', .form-group
) label Host
input.form-control(
type='text',
[(ngModel)]='profile.options.host',
)
.form-group .form-group
label Port label Port
input.form-control( input.form-control(
type='number', type='number',
placeholder='22', placeholder='22',
[(ngModel)]='profile.options.port', [(ngModel)]='profile.options.port',
) )
stream-processing-settings([options]='profile.options') stream-processing-settings([options]='profile.options')
li(ngbNavItem)
a(ngbNavLink) Login scripts
ng-template(ngbNavContent)
login-scripts-settings([options]='profile.options')
div([ngbNavOutlet]='nav')

View File

@@ -20,7 +20,7 @@ export class TelnetProfilesService extends ProfileProvider {
options: { options: {
host: '', host: '',
port: 23, port: 23,
inputMode: 'local-echo', inputMode: 'readline',
outputMode: null, outputMode: null,
inputNewlines: null, inputNewlines: null,
outputNewlines: 'crlf', outputNewlines: 'crlf',
@@ -58,7 +58,7 @@ export class TelnetProfilesService extends ProfileProvider {
options: { options: {
host, host,
port, port,
inputMode: 'local-echo', inputMode: 'readline',
outputNewlines: 'crlf', outputNewlines: 'crlf',
}, },
} }

View File

@@ -2,8 +2,8 @@ import { Socket } from 'net'
import colors from 'ansi-colors' import colors from 'ansi-colors'
import stripAnsi from 'strip-ansi' import stripAnsi from 'strip-ansi'
import { Injector } from '@angular/core' import { Injector } from '@angular/core'
import { Logger, Profile, LogService } from 'tabby-core' import { Profile, LogService } from 'tabby-core'
import { BaseSession, StreamProcessingOptions, TerminalStreamProcessor } from 'tabby-terminal' import { BaseSession, LoginScriptsOptions, StreamProcessingOptions, TerminalStreamProcessor } from 'tabby-terminal'
import { Subject, Observable } from 'rxjs' import { Subject, Observable } from 'rxjs'
@@ -11,13 +11,12 @@ export interface TelnetProfile extends Profile {
options: TelnetProfileOptions options: TelnetProfileOptions
} }
export interface TelnetProfileOptions extends StreamProcessingOptions { export interface TelnetProfileOptions extends StreamProcessingOptions, LoginScriptsOptions {
host: string host: string
port?: number port?: number
} }
export class TelnetSession extends BaseSession { export class TelnetSession extends BaseSession {
logger: Logger
get serviceMessage$ (): Observable<string> { return this.serviceMessage } get serviceMessage$ (): Observable<string> { return this.serviceMessage }
private serviceMessage = new Subject<string>() private serviceMessage = new Subject<string>()
@@ -28,8 +27,7 @@ export class TelnetSession extends BaseSession {
injector: Injector, injector: Injector,
public profile: TelnetProfile, public profile: TelnetProfile,
) { ) {
super() super(injector.get(LogService).create(`telnet-${profile.options.host}-${profile.options.port}`))
this.logger = injector.get(LogService).create(`telnet-${profile.options.host}-${profile.options.port}`)
this.streamProcessor = new TerminalStreamProcessor(profile.options) this.streamProcessor = new TerminalStreamProcessor(profile.options)
this.streamProcessor.outputToSession$.subscribe(data => { this.streamProcessor.outputToSession$.subscribe(data => {
this.socket.write(data) this.socket.write(data)
@@ -37,6 +35,7 @@ export class TelnetSession extends BaseSession {
this.streamProcessor.outputToTerminal$.subscribe(data => { this.streamProcessor.outputToTerminal$.subscribe(data => {
this.emitOutput(data) this.emitOutput(data)
}) })
this.setLoginScriptsOptions(profile.options)
} }
async start (): Promise<void> { async start (): Promise<void> {
@@ -57,6 +56,8 @@ export class TelnetSession extends BaseSession {
this.socket.connect(this.profile.options.port ?? 23, this.profile.options.host, () => { this.socket.connect(this.profile.options.port ?? 23, this.profile.options.host, () => {
this.emitServiceMessage('Connected') this.emitServiceMessage('Connected')
this.open = true this.open = true
setTimeout(() => this.streamProcessor.start())
this.loginScriptProcessor?.executeUnconditionalScripts()
resolve() resolve()
}) })
}) })

View File

@@ -0,0 +1,86 @@
import { Subject, Observable } from 'rxjs'
import { Logger } from 'tabby-core'
export interface LoginScript {
expect: string
send: string
isRegex?: boolean
optional?: boolean
}
export interface LoginScriptsOptions {
scripts?: LoginScript[]
}
export class LoginScriptProcessor {
get outputToSession$ (): Observable<Buffer> { return this.outputToSession }
private outputToSession = new Subject<Buffer>()
private remainingScripts: LoginScript[] = []
constructor (
private logger: Logger,
options: LoginScriptsOptions
) {
this.remainingScripts = options.scripts ?? []
}
feedFromSession (data: Buffer): boolean {
const dataString = data.toString()
let found = false
for (const script of this.remainingScripts) {
if (!script.expect) {
continue
}
let match = false
let cmd = ''
if (script.isRegex) {
const re = new RegExp(script.expect, 'g')
if (re.exec(dataString)) {
cmd = dataString.replace(re, script.send)
match = true
found = true
}
} else {
if (dataString.includes(script.expect)) {
cmd = script.send
match = true
found = true
}
}
if (match) {
this.logger.info('Executing script: "' + cmd + '"')
this.outputToSession.next(Buffer.from(cmd + '\n'))
this.remainingScripts = this.remainingScripts.filter(x => x !== script)
} else {
if (script.optional) {
this.logger.debug('Skip optional script: ' + script.expect)
found = true
this.remainingScripts = this.remainingScripts.filter(x => x !== script)
} else {
break
}
}
}
return found
}
close (): void {
this.outputToSession.complete()
}
executeUnconditionalScripts (): void {
for (const script of this.remainingScripts) {
if (!script.expect) {
this.logger.info('Executing script:', script.send)
this.outputToSession.next(Buffer.from(script.send + '\n'))
this.remainingScripts = this.remainingScripts.filter(x => x !== script)
} else {
break
}
}
}
}

View File

@@ -26,9 +26,10 @@ export class TerminalStreamProcessor {
protected outputToTerminal = new Subject<Buffer>() protected outputToTerminal = new Subject<Buffer>()
private inputReadline: ReadLine private inputReadline: ReadLine
private inputPromptVisible = true private inputPromptVisible = false
private inputReadlineInStream: Readable & Writable private inputReadlineInStream: Readable & Writable
private inputReadlineOutStream: Readable & Writable private inputReadlineOutStream: Readable & Writable
private started = false
constructor (private options: StreamProcessingOptions) { constructor (private options: StreamProcessingOptions) {
this.inputReadlineInStream = new PassThrough() this.inputReadlineInStream = new PassThrough()
@@ -46,7 +47,16 @@ export class TerminalStreamProcessor {
this.onTerminalInput(Buffer.from(line + '\n')) this.onTerminalInput(Buffer.from(line + '\n'))
this.resetInputPrompt() this.resetInputPrompt()
}) })
this.outputToTerminal$.pipe(debounce(() => interval(500))).subscribe(() => this.onOutputSettled()) this.outputToTerminal$.pipe(debounce(() => interval(500))).subscribe(() => {
if (this.started) {
this.onOutputSettled()
}
})
}
start (): void {
this.started = true
this.onOutputSettled()
} }
feedFromSession (data: Buffer): void { feedFromSession (data: Buffer): void {

View File

@@ -0,0 +1,38 @@
table(*ngIf='options.scripts.length > 0')
tr
th String to expect
th String to be sent
th.pl-2 Regex
th.pl-2 Optional
th.pl-2 Actions
tr(*ngFor='let script of options.scripts')
td.pr-2
input.form-control(
type='text',
[(ngModel)]='script.expect'
)
td
input.form-control(
type='text',
[(ngModel)]='script.send'
)
td.pl-2
checkbox(
[(ngModel)]='script.isRegex',
)
td.pl-2
checkbox(
[(ngModel)]='script.optional',
)
td.pl-2
.input-group.flex-nowrap
button.btn.btn-outline-info.ml-0((click)='moveScriptUp(script)')
i.fas.fa-arrow-up
button.btn.btn-outline-info.ml-0((click)='moveScriptDown(script)')
i.fas.fa-arrow-down
button.btn.btn-outline-danger.ml-0((click)='deleteScript(script)')
i.fas.fa-trash
button.btn.btn-outline-info.mt-2((click)='addScript()')
i.fas.fa-plus
span New item

View File

@@ -0,0 +1,56 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Component, Input } from '@angular/core'
import { PlatformService } from 'tabby-core'
import { LoginScript, LoginScriptsOptions } from '../api/loginScriptProcessing'
/** @hidden */
@Component({
selector: 'login-scripts-settings',
template: require('./loginScriptsSettings.component.pug'),
})
export class LoginScriptsSettingsComponent {
@Input() options: LoginScriptsOptions
constructor (
private platform: PlatformService,
) { }
ngOnInit () {
this.options.scripts ??= []
}
moveScriptUp (script: LoginScript) {
const index = this.options.scripts!.indexOf(script)
if (index > 0) {
this.options.scripts!.splice(index, 1)
this.options.scripts!.splice(index - 1, 0, script)
}
}
moveScriptDown (script: LoginScript) {
const index = this.options.scripts!.indexOf(script)
if (index >= 0 && index < this.options.scripts!.length - 1) {
this.options.scripts!.splice(index, 1)
this.options.scripts!.splice(index + 1, 0, script)
}
}
async deleteScript (script: LoginScript) {
if ((await this.platform.showMessageBox(
{
type: 'warning',
message: 'Delete this script?',
detail: script.expect,
buttons: ['Keep', 'Delete'],
defaultId: 1,
}
)).response === 1) {
this.options.scripts = this.options.scripts!.filter(x => x !== script)
}
}
addScript () {
this.options.scripts!.push({ expect: '', send: '' })
}
}

View File

@@ -14,6 +14,7 @@ import { ColorPickerComponent } from './components/colorPicker.component'
import { ColorSchemePreviewComponent } from './components/colorSchemePreview.component' import { ColorSchemePreviewComponent } from './components/colorSchemePreview.component'
import { SearchPanelComponent } from './components/searchPanel.component' import { SearchPanelComponent } from './components/searchPanel.component'
import { StreamProcessingSettingsComponent } from './components/streamProcessingSettings.component' import { StreamProcessingSettingsComponent } from './components/streamProcessingSettings.component'
import { LoginScriptsSettingsComponent } from './components/loginScriptsSettings.component'
import { TerminalFrontendService } from './services/terminalFrontend.service' import { TerminalFrontendService } from './services/terminalFrontend.service'
@@ -72,11 +73,13 @@ import { TerminalCLIHandler } from './cli'
TerminalSettingsTabComponent, TerminalSettingsTabComponent,
SearchPanelComponent, SearchPanelComponent,
StreamProcessingSettingsComponent, StreamProcessingSettingsComponent,
LoginScriptsSettingsComponent,
] as any[], ] as any[],
exports: [ exports: [
ColorPickerComponent, ColorPickerComponent,
SearchPanelComponent, SearchPanelComponent,
StreamProcessingSettingsComponent, StreamProcessingSettingsComponent,
LoginScriptsSettingsComponent,
], ],
}) })
export default class TerminalModule { // eslint-disable-line @typescript-eslint/no-extraneous-class export default class TerminalModule { // eslint-disable-line @typescript-eslint/no-extraneous-class
@@ -115,4 +118,5 @@ export { Frontend, XTermFrontend, XTermWebGLFrontend, HTermFrontend }
export { BaseTerminalTabComponent } from './api/baseTerminalTab.component' export { BaseTerminalTabComponent } from './api/baseTerminalTab.component'
export * from './api/interfaces' export * from './api/interfaces'
export * from './api/streamProcessing' export * from './api/streamProcessing'
export * from './api/loginScriptProcessing'
export * from './session' export * from './session'

View File

@@ -1,4 +1,6 @@
import { Observable, Subject } from 'rxjs' import { Observable, Subject } from 'rxjs'
import { Logger } from 'tabby-core'
import { LoginScriptProcessor, LoginScriptsOptions } from './api/loginScriptProcessing'
/** /**
* A session object for a [[BaseTerminalTabComponent]] * A session object for a [[BaseTerminalTabComponent]]
@@ -12,6 +14,7 @@ export abstract class BaseSession {
protected binaryOutput = new Subject<Buffer>() protected binaryOutput = new Subject<Buffer>()
protected closed = new Subject<void>() protected closed = new Subject<void>()
protected destroyed = new Subject<void>() protected destroyed = new Subject<void>()
protected loginScriptProcessor: LoginScriptProcessor | null = null
private initialDataBuffer = Buffer.from('') private initialDataBuffer = Buffer.from('')
private initialDataBufferReleased = false private initialDataBufferReleased = false
@@ -20,12 +23,15 @@ export abstract class BaseSession {
get closed$ (): Observable<void> { return this.closed } get closed$ (): Observable<void> { return this.closed }
get destroyed$ (): Observable<void> { return this.destroyed } get destroyed$ (): Observable<void> { return this.destroyed }
constructor (protected logger: Logger) { }
emitOutput (data: Buffer): void { emitOutput (data: Buffer): void {
if (!this.initialDataBufferReleased) { if (!this.initialDataBufferReleased) {
this.initialDataBuffer = Buffer.concat([this.initialDataBuffer, data]) this.initialDataBuffer = Buffer.concat([this.initialDataBuffer, data])
} else { } else {
this.output.next(data.toString()) this.output.next(data.toString())
this.binaryOutput.next(data) this.binaryOutput.next(data)
this.loginScriptProcessor?.feedFromSession(data)
} }
} }
@@ -36,9 +42,17 @@ export abstract class BaseSession {
this.initialDataBuffer = Buffer.from('') this.initialDataBuffer = Buffer.from('')
} }
setLoginScriptsOptions (options: LoginScriptsOptions): void {
this.loginScriptProcessor?.close()
this.loginScriptProcessor = new LoginScriptProcessor(this.logger, options)
this.loginScriptProcessor.outputToSession$.subscribe(data => this.write(data))
}
async destroy (): Promise<void> { async destroy (): Promise<void> {
if (this.open) { if (this.open) {
this.logger.info('Destroying')
this.open = false this.open = false
this.loginScriptProcessor?.close()
this.closed.next() this.closed.next()
this.destroyed.next() this.destroyed.next()
await this.gracefullyKillProcess() await this.gracefullyKillProcess()