Browse Source

added a telnet client - fixes #760

Eugene Pankov 4 years ago
parent
commit
827345d899
33 changed files with 591 additions and 70 deletions
  1. 15 14
      scripts/vars.js
  2. 1 0
      tabby-core/src/api/profileProvider.ts
  3. 0 12
      tabby-core/src/components/welcomeTab.component.pug
  4. 0 10
      tabby-core/src/components/welcomeTab.component.ts
  5. 1 1
      tabby-core/src/services/config.service.ts
  6. 1 1
      tabby-core/src/services/profiles.service.ts
  7. 2 0
      tabby-local/src/components/profilesSettingsTab.component.ts
  8. 1 1
      tabby-serial/package.json
  9. 1 1
      tabby-ssh/package.json
  10. 0 6
      tabby-ssh/src/components/sshTab.component.ts
  11. 1 3
      tabby-ssh/src/config.ts
  12. 1 0
      tabby-ssh/src/profiles.ts
  13. 35 0
      tabby-telnet/package.json
  14. 16 0
      tabby-telnet/src/components/telnetProfileSettings.component.pug
  15. 13 0
      tabby-telnet/src/components/telnetProfileSettings.component.ts
  16. 10 0
      tabby-telnet/src/components/telnetTab.component.pug
  17. 1 0
      tabby-telnet/src/components/telnetTab.component.scss
  18. 156 0
      tabby-telnet/src/components/telnetTab.component.ts
  19. 12 0
      tabby-telnet/src/config.ts
  20. 17 0
      tabby-telnet/src/hotkeys.ts
  21. 44 0
      tabby-telnet/src/index.ts
  22. 71 0
      tabby-telnet/src/profiles.ts
  23. 29 0
      tabby-telnet/src/recoveryProvider.ts
  24. 102 0
      tabby-telnet/src/session.ts
  25. 7 0
      tabby-telnet/tsconfig.json
  26. 15 0
      tabby-telnet/tsconfig.typings.json
  27. 5 0
      tabby-telnet/webpack.config.js
  28. 13 0
      tabby-telnet/yarn.lock
  29. 4 1
      tabby-terminal/src/api/streamProcessing.ts
  30. 1 0
      tabby-terminal/src/components/streamProcessingSettings.component.ts
  31. 4 4
      tabby-terminal/src/session.ts
  32. 0 1
      tabby-web/src/platform.ts
  33. 12 15
      webpack.config.js

+ 15 - 14
scripts/vars.js

@@ -5,28 +5,29 @@ const childProcess = require('child_process')
 
 const electronInfo = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../node_modules/electron/package.json')))
 
-exports.version = childProcess.execSync('git describe --tags', {encoding:'utf-8'})
+exports.version = childProcess.execSync('git describe --tags', { encoding:'utf-8' })
 exports.version = exports.version.substring(1).trim()
 exports.version = exports.version.replace('-', '-c')
 
 if (exports.version.includes('-c')) {
-  exports.version = semver.inc(exports.version, 'prepatch').replace('-0', '-nightly.0')
+    exports.version = semver.inc(exports.version, 'prepatch').replace('-0', '-nightly.0')
 }
 
 exports.builtinPlugins = [
-  'tabby-core',
-  'tabby-settings',
-  'tabby-terminal',
-  'tabby-electron',
-  'tabby-local',
-  'tabby-web',
-  'tabby-community-color-schemes',
-  'tabby-plugin-manager',
-  'tabby-ssh',
-  'tabby-serial',
+    'tabby-core',
+    'tabby-settings',
+    'tabby-terminal',
+    'tabby-electron',
+    'tabby-local',
+    'tabby-web',
+    'tabby-community-color-schemes',
+    'tabby-plugin-manager',
+    'tabby-ssh',
+    'tabby-serial',
+    'tabby-telnet',
 ]
 exports.bundledModules = [
-  '@angular',
-  '@ng-bootstrap',
+    '@angular',
+    '@ng-bootstrap',
 ]
 exports.electronVersion = electronInfo.version

+ 1 - 0
tabby-core/src/api/profileProvider.ts

@@ -14,6 +14,7 @@ export interface Profile {
     color?: string
     disableDynamicTitle?: boolean
 
+    weight?: number
     isBuiltin?: boolean
     isTemplate?: boolean
 }

+ 0 - 12
tabby-core/src/components/welcomeTab.component.pug

@@ -19,18 +19,6 @@
             .description Toggles the Tabby window visibility
         toggle([(ngModel)]='enableGlobalHotkey')
 
-    .form-line
-        .header
-            .title Enable #[strong SSH] plugin
-            .description Adds an SSH connection manager UI to Tabby
-        toggle([(ngModel)]='enableSSH')
-
-    .form-line
-        .header
-            .title Enable #[strong Serial] plugin
-            .description Allows attaching Tabby to serial ports
-        toggle([(ngModel)]='enableSerial')
-
 
     .text-center.mt-5
         button.btn.btn-primary((click)='closeAndDisable()') Close and never show again

+ 0 - 10
tabby-core/src/components/welcomeTab.component.ts

@@ -11,8 +11,6 @@ import { HostWindowService } from '../api/hostWindow'
     styles: [require('./welcomeTab.component.scss')],
 })
 export class WelcomeTabComponent extends BaseTabComponent {
-    enableSSH = false
-    enableSerial = false
     enableGlobalHotkey = true
 
     constructor (
@@ -21,19 +19,11 @@ export class WelcomeTabComponent extends BaseTabComponent {
     ) {
         super()
         this.setTitle('Welcome')
-        this.enableSSH = !config.store.pluginBlacklist.includes('ssh')
-        this.enableSerial = !config.store.pluginBlacklist.includes('serial')
     }
 
     closeAndDisable () {
         this.config.store.enableWelcomeTab = false
         this.config.store.pluginBlacklist = []
-        if (!this.enableSSH) {
-            this.config.store.pluginBlacklist.push('ssh')
-        }
-        if (!this.enableSerial) {
-            this.config.store.pluginBlacklist.push('serial')
-        }
         if (!this.enableGlobalHotkey) {
             this.config.store.hotkeys['toggle-window'] = []
         }

+ 1 - 1
tabby-core/src/services/config.service.ts

@@ -284,7 +284,7 @@ export class ConfigService {
             config.version = 2
         }
         if (config.version < 3) {
-            delete config.ssh.recentConnections
+            delete config.ssh?.recentConnections
             for (const c of config.ssh?.connections ?? []) {
                 const p = {
                     id: `ssh:${uuidv4()}`,

+ 1 - 1
tabby-core/src/services/profiles.service.ts

@@ -18,9 +18,9 @@ export class ProfilesService {
         if (params) {
             const tab = this.app.openNewTab(params)
             ;(this.app.getParentTab(tab) ?? tab).color = profile.color ?? null
+            tab.setTitle(profile.name)
             if (profile.disableDynamicTitle) {
                 tab['enableDynamicTitle'] = false
-                tab.setTitle(profile.name)
             }
             return tab
         }

+ 2 - 0
tabby-local/src/components/profilesSettingsTab.component.ts

@@ -51,6 +51,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
     async newProfile (base?: Profile): Promise<void> {
         if (!base) {
             const profiles = [...this.templateProfiles, ...this.builtinProfiles, ...this.profiles]
+            profiles.sort((a, b) => (a.weight ?? 0) - (b.weight ?? 0))
             base = await this.selector.show(
                 'Select a base profile to use as a template',
                 profiles.map(p => ({
@@ -196,6 +197,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
         return {
             ssh: 'secondary',
             serial: 'success',
+            telnet: 'info',
         }[this.profilesService.providerForProfile(profile)?.id ?? ''] ?? 'warning'
     }
 }

+ 1 - 1
tabby-serial/package.json

@@ -1,7 +1,7 @@
 {
   "name": "tabby-serial",
   "version": "1.0.144",
-  "description": "Serial connection manager for Tabby",
+  "description": "Serial connections for Tabby",
   "keywords": [
     "tabby-builtin-plugin"
   ],

+ 1 - 1
tabby-ssh/package.json

@@ -1,7 +1,7 @@
 {
   "name": "tabby-ssh",
   "version": "1.0.144",
-  "description": "SSH connection manager for Tabby",
+  "description": "SSH connections for Tabby",
   "keywords": [
     "tabby-builtin-plugin"
   ],

+ 0 - 6
tabby-ssh/src/components/sshTab.component.ts

@@ -49,8 +49,6 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
 
         this.logger = this.log.create('terminalTab')
 
-        this.enableDynamicTitle = !this.profile.disableDynamicTitle
-
         this.subscribeUntilDestroyed(this.hotkeys.matchedHotkey, hotkey => {
             if (!this.hasFocus) {
                 return
@@ -82,10 +80,6 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
         })
 
         super.ngOnInit()
-
-        setImmediate(() => {
-            this.setTitle(this.profile!.name)
-        })
     }
 
     async setupOneSession (session: SSHSession): Promise<void> {

+ 1 - 3
tabby-ssh/src/config.ts

@@ -10,10 +10,8 @@ export class SSHConfigProvider extends ConfigProvider {
             agentPath: null,
         },
         hotkeys: {
-            ssh: [
-                'Alt-S',
-            ],
             'restart-ssh-session': [],
+            'launch-winscp': [],
         },
     }
 

+ 1 - 0
tabby-ssh/src/profiles.ts

@@ -31,6 +31,7 @@ export class SSHProfilesService extends ProfileProvider {
             },
             isBuiltin: true,
             isTemplate: true,
+            weight: -1,
         }]
     }
 

+ 35 - 0
tabby-telnet/package.json

@@ -0,0 +1,35 @@
+{
+  "name": "tabby-telnet",
+  "version": "1.0.144",
+  "description": "Telnet/socket connections for Tabby",
+  "keywords": [
+    "tabby-builtin-plugin"
+  ],
+  "main": "dist/index.js",
+  "typings": "typings/index.d.ts",
+  "scripts": {
+    "build": "webpack --progress --color",
+    "watch": "webpack --progress --color --watch"
+  },
+  "files": [
+    "typings"
+  ],
+  "author": "Eugene Pankov",
+  "license": "MIT",
+  "devDependencies": {
+    "@types/node": "14.14.31",
+    "cli-spinner": "^0.2.10"
+  },
+  "peerDependencies": {
+    "@angular/animations": "^9.1.9",
+    "@angular/common": "^9.1.11",
+    "@angular/core": "^9.1.9",
+    "@angular/forms": "^9.1.11",
+    "@angular/platform-browser": "^9.1.11",
+    "@ng-bootstrap/ng-bootstrap": "^6.1.0",
+    "rxjs": "^6.5.5",
+    "tabby-core": "*",
+    "tabby-settings": "*",
+    "tabby-terminal": "*"
+  }
+}

+ 16 - 0
tabby-telnet/src/components/telnetProfileSettings.component.pug

@@ -0,0 +1,16 @@
+.form-group
+    label Host
+    input.form-control(
+        type='text',
+        [(ngModel)]='profile.options.host',
+    )
+
+.form-group
+    label Port
+    input.form-control(
+        type='number',
+        placeholder='22',
+        [(ngModel)]='profile.options.port',
+    )
+
+stream-processing-settings([options]='profile.options')

+ 13 - 0
tabby-telnet/src/components/telnetProfileSettings.component.ts

@@ -0,0 +1,13 @@
+/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
+import { Component } from '@angular/core'
+
+import { ProfileSettingsComponent } from 'tabby-core'
+import { TelnetProfile } from '../session'
+
+/** @hidden */
+@Component({
+    template: require('./telnetProfileSettings.component.pug'),
+})
+export class TelnetProfileSettingsComponent implements ProfileSettingsComponent {
+    profile: TelnetProfile
+}

+ 10 - 0
tabby-telnet/src/components/telnetTab.component.pug

@@ -0,0 +1,10 @@
+.tab-toolbar([class.show]='!session || !session.open')
+    .btn.btn-outline-secondary.reveal-button
+        i.fas.fa-ellipsis-h
+    .toolbar
+        i.fas.fa-circle.text-success.mr-2(*ngIf='session && session.open')
+        i.fas.fa-circle.text-danger.mr-2(*ngIf='!session || !session.open')
+        strong.mr-auto {{profile.options.host}}:{{profile.options.port}}
+
+        button.btn.btn-secondary.mr-2((click)='reconnect()', [class.btn-info]='!session || !session.open')
+            span Reconnect

+ 1 - 0
tabby-telnet/src/components/telnetTab.component.scss

@@ -0,0 +1 @@
+@import '../../../tabby-ssh/src/components/sshTab.component.scss';

+ 156 - 0
tabby-telnet/src/components/telnetTab.component.ts

@@ -0,0 +1,156 @@
+import colors from 'ansi-colors'
+import { Spinner } from 'cli-spinner'
+import { Component, Injector } from '@angular/core'
+import { first } from 'rxjs/operators'
+import { Platform, RecoveryToken } from 'tabby-core'
+import { BaseTerminalTabComponent } from 'tabby-terminal'
+import { TelnetProfile, TelnetSession } from '../session'
+
+
+/** @hidden */
+@Component({
+    selector: 'telnet-tab',
+    template: `${BaseTerminalTabComponent.template} ${require('./telnetTab.component.pug')}`,
+    styles: [require('./telnetTab.component.scss'), ...BaseTerminalTabComponent.styles],
+    animations: BaseTerminalTabComponent.animations,
+})
+export class TelnetTabComponent extends BaseTerminalTabComponent {
+    Platform = Platform
+    profile?: TelnetProfile
+    session: TelnetSession|null = null
+    private reconnectOffered = false
+    private spinner = new Spinner({
+        text: 'Connecting',
+        stream: {
+            write: x => this.write(x),
+        },
+    })
+    private spinnerActive = false
+
+    // eslint-disable-next-line @typescript-eslint/no-useless-constructor
+    constructor (
+        injector: Injector,
+    ) {
+        super(injector)
+    }
+
+    ngOnInit (): void {
+        if (!this.profile) {
+            throw new Error('Profile not set')
+        }
+
+        this.logger = this.log.create('telnetTab')
+
+        this.subscribeUntilDestroyed(this.hotkeys.matchedHotkey, hotkey => {
+            if (this.hasFocus && hotkey === 'restart-telnet-session') {
+                this.reconnect()
+            }
+        })
+
+        this.frontendReady$.pipe(first()).subscribe(() => {
+            this.initializeSession()
+        })
+
+        super.ngOnInit()
+    }
+
+    protected attachSessionHandlers (): void {
+        const session = this.session!
+        this.attachSessionHandler(session.destroyed$, () => {
+            if (this.frontend) {
+                // Session was closed abruptly
+                if (!this.reconnectOffered) {
+                    this.reconnectOffered = true
+                    this.write('Press any key to reconnect\r\n')
+                    this.input$.pipe(first()).subscribe(() => {
+                        if (!this.session?.open && this.reconnectOffered) {
+                            this.reconnect()
+                        }
+                    })
+                }
+            }
+        })
+        super.attachSessionHandlers()
+    }
+
+    async initializeSession (): Promise<void> {
+        this.reconnectOffered = false
+        if (!this.profile) {
+            this.logger.error('No Telnet connection info supplied')
+            return
+        }
+
+        const session = new TelnetSession(this.injector, this.profile)
+        this.setSession(session)
+
+        try {
+            this.startSpinner()
+
+            this.attachSessionHandler(session.serviceMessage$, msg => {
+                this.pauseSpinner(() => {
+                    this.write(`\r${colors.black.bgWhite(' Telnet ')} ${msg}\r\n`)
+                    session.resize(this.size.columns, this.size.rows)
+                })
+            })
+
+            try {
+                await session.start()
+                this.stopSpinner()
+            } catch (e) {
+                this.stopSpinner()
+                this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n')
+                return
+            }
+        } catch (e) {
+            this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n')
+        }
+    }
+
+    async getRecoveryToken (): Promise<RecoveryToken> {
+        return {
+            type: 'app:telnet-tab',
+            profile: this.profile,
+            savedState: this.frontend?.saveState(),
+        }
+    }
+
+    async reconnect (): Promise<void> {
+        this.session?.destroy()
+        await this.initializeSession()
+        this.session?.releaseInitialDataBuffer()
+    }
+
+    async canClose (): Promise<boolean> {
+        if (!this.session?.open) {
+            return true
+        }
+        return (await this.platform.showMessageBox(
+            {
+                type: 'warning',
+                message: `Disconnect from ${this.profile?.options.host}?`,
+                buttons: ['Cancel', 'Disconnect'],
+                defaultId: 1,
+            }
+        )).response === 1
+    }
+
+    private startSpinner () {
+        this.spinner.setSpinnerString(6)
+        this.spinner.start()
+        this.spinnerActive = true
+    }
+
+    private stopSpinner () {
+        this.spinner.stop(true)
+        this.spinnerActive = false
+    }
+
+    private pauseSpinner (work: () => void) {
+        const wasActive = this.spinnerActive
+        this.stopSpinner()
+        work()
+        if (wasActive) {
+            this.startSpinner()
+        }
+    }
+}

+ 12 - 0
tabby-telnet/src/config.ts

@@ -0,0 +1,12 @@
+import { ConfigProvider } from 'tabby-core'
+
+/** @hidden */
+export class TelnetConfigProvider extends ConfigProvider {
+    defaults = {
+        hotkeys: {
+            'restart-telnet-session': [],
+        },
+    }
+
+    platformDefaults = { }
+}

+ 17 - 0
tabby-telnet/src/hotkeys.ts

@@ -0,0 +1,17 @@
+import { Injectable } from '@angular/core'
+import { HotkeyDescription, HotkeyProvider } from 'tabby-core'
+
+/** @hidden */
+@Injectable()
+export class TelnetHotkeyProvider extends HotkeyProvider {
+    hotkeys: HotkeyDescription[] = [
+        {
+            id: 'restart-telnet-session',
+            name: 'Restart current Telnet session',
+        },
+    ]
+
+    async provide (): Promise<HotkeyDescription[]> {
+        return this.hotkeys
+    }
+}

+ 44 - 0
tabby-telnet/src/index.ts

@@ -0,0 +1,44 @@
+import { NgModule } from '@angular/core'
+import { CommonModule } from '@angular/common'
+import { FormsModule } from '@angular/forms'
+import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { ToastrModule } from 'ngx-toastr'
+import { NgxFilesizeModule } from 'ngx-filesize'
+import TabbyCoreModule, { ConfigProvider, TabRecoveryProvider, HotkeyProvider, ProfileProvider } from 'tabby-core'
+import TabbyTerminalModule from 'tabby-terminal'
+
+import { TelnetProfileSettingsComponent } from './components/telnetProfileSettings.component'
+import { TelnetTabComponent } from './components/telnetTab.component'
+
+import { TelnetConfigProvider } from './config'
+import { RecoveryProvider } from './recoveryProvider'
+import { TelnetHotkeyProvider } from './hotkeys'
+import { TelnetProfilesService } from './profiles'
+
+/** @hidden */
+@NgModule({
+    imports: [
+        NgbModule,
+        NgxFilesizeModule,
+        CommonModule,
+        FormsModule,
+        ToastrModule,
+        TabbyCoreModule,
+        TabbyTerminalModule,
+    ],
+    providers: [
+        { provide: ConfigProvider, useClass: TelnetConfigProvider, multi: true },
+        { provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true },
+        { provide: HotkeyProvider, useClass: TelnetHotkeyProvider, multi: true },
+        { provide: ProfileProvider, useClass: TelnetProfilesService, multi: true },
+    ],
+    entryComponents: [
+        TelnetProfileSettingsComponent,
+        TelnetTabComponent,
+    ],
+    declarations: [
+        TelnetProfileSettingsComponent,
+        TelnetTabComponent,
+    ],
+})
+export default class TelnetModule { } // eslint-disable-line @typescript-eslint/no-extraneous-class

+ 71 - 0
tabby-telnet/src/profiles.ts

@@ -0,0 +1,71 @@
+import { Injectable } from '@angular/core'
+import { ProfileProvider, Profile, NewTabParameters } from 'tabby-core'
+import { TelnetProfileSettingsComponent } from './components/telnetProfileSettings.component'
+import { TelnetTabComponent } from './components/telnetTab.component'
+import { TelnetProfile } from './session'
+
+@Injectable({ providedIn: 'root' })
+export class TelnetProfilesService extends ProfileProvider {
+    id = 'telnet'
+    name = 'Telnet'
+    supportsQuickConnect = true
+    settingsComponent = TelnetProfileSettingsComponent
+
+    async getBuiltinProfiles (): Promise<TelnetProfile[]> {
+        return [{
+            id: `telnet:template`,
+            type: 'telnet',
+            name: 'Telnet/socket connection',
+            icon: 'fas fa-network-wired',
+            options: {
+                host: '',
+                port: 23,
+                inputMode: 'local-echo',
+                outputMode: null,
+                inputNewlines: null,
+                outputNewlines: 'crlf',
+            },
+            isBuiltin: true,
+            isTemplate: true,
+        }]
+    }
+
+    async getNewTabParameters (profile: Profile): Promise<NewTabParameters<TelnetTabComponent>> {
+        return {
+            type: TelnetTabComponent,
+            inputs: { profile },
+        }
+    }
+
+    getDescription (profile: TelnetProfile): string {
+        return profile.options.host ? `${profile.options.host}:${profile.options.port}` : ''
+    }
+
+    quickConnect (query: string): TelnetProfile|null {
+        if (!query.startsWith('telnet:')) {
+            return null
+        }
+        query = query.substring('telnet:'.length)
+
+        let host = query
+        let port = 23
+        if (host.includes('[')) {
+            port = parseInt(host.split(']')[1].substring(1))
+            host = host.split(']')[0].substring(1)
+        } else if (host.includes(':')) {
+            port = parseInt(host.split(/:/g)[1])
+            host = host.split(':')[0]
+        }
+
+        return {
+            name: query,
+            type: 'telnet',
+            options: {
+                host,
+                port,
+                inputMode: 'local-echo',
+                outputNewlines: 'crlf',
+            },
+        }
+    }
+}

+ 29 - 0
tabby-telnet/src/recoveryProvider.ts

@@ -0,0 +1,29 @@
+import { Injectable } from '@angular/core'
+import { TabRecoveryProvider, NewTabParameters, RecoveryToken } from 'tabby-core'
+
+import { TelnetTabComponent } from './components/telnetTab.component'
+
+/** @hidden */
+@Injectable()
+export class RecoveryProvider extends TabRecoveryProvider<TelnetTabComponent> {
+    async applicableTo (recoveryToken: RecoveryToken): Promise<boolean> {
+        return recoveryToken.type === 'app:telnet-tab'
+    }
+
+    async recover (recoveryToken: RecoveryToken): Promise<NewTabParameters<TelnetTabComponent>> {
+        return {
+            type: TelnetTabComponent,
+            inputs: {
+                profile: recoveryToken['profile'],
+                savedState: recoveryToken['savedState'],
+            },
+        }
+    }
+
+    duplicate (recoveryToken: RecoveryToken): RecoveryToken {
+        return {
+            ...recoveryToken,
+            savedState: null,
+        }
+    }
+}

+ 102 - 0
tabby-telnet/src/session.ts

@@ -0,0 +1,102 @@
+import { Socket } from 'net'
+import colors from 'ansi-colors'
+import stripAnsi from 'strip-ansi'
+import { Injector } from '@angular/core'
+import { Logger, Profile, LogService } from 'tabby-core'
+import { BaseSession, StreamProcessingOptions, TerminalStreamProcessor } from 'tabby-terminal'
+import { Subject, Observable } from 'rxjs'
+
+
+export interface TelnetProfile extends Profile {
+    options: TelnetProfileOptions
+}
+
+export interface TelnetProfileOptions extends StreamProcessingOptions {
+    host: string
+    port?: number
+}
+
+export class TelnetSession extends BaseSession {
+    logger: Logger
+    get serviceMessage$ (): Observable<string> { return this.serviceMessage }
+
+    private serviceMessage = new Subject<string>()
+    private socket: Socket
+    private streamProcessor: TerminalStreamProcessor
+
+    constructor (
+        injector: Injector,
+        public profile: TelnetProfile,
+    ) {
+        super()
+        this.logger = injector.get(LogService).create(`telnet-${profile.options.host}-${profile.options.port}`)
+        this.streamProcessor = new TerminalStreamProcessor(profile.options)
+        this.streamProcessor.outputToSession$.subscribe(data => {
+            this.socket.write(data)
+        })
+        this.streamProcessor.outputToTerminal$.subscribe(data => {
+            this.emitOutput(data)
+        })
+    }
+
+    async start (): Promise<void> {
+        this.socket = new Socket()
+        this.emitServiceMessage(`Connecting to ${this.profile.options.host}`)
+
+        return new Promise((resolve, reject) => {
+            this.socket.on('error', err => {
+                this.emitServiceMessage(colors.bgRed.black(' X ') + ` Socket error: ${err as any}`)
+                reject()
+                this.destroy()
+            })
+            this.socket.on('close', () => {
+                this.emitServiceMessage('Connection closed')
+                this.destroy()
+            })
+            this.socket.on('data', data => this.streamProcessor.feedFromSession(data))
+            this.socket.connect(this.profile.options.port ?? 23, this.profile.options.host, () => {
+                this.emitServiceMessage('Connected')
+                this.open = true
+                resolve()
+            })
+        })
+    }
+
+    emitServiceMessage (msg: string): void {
+        this.serviceMessage.next(msg)
+        this.logger.info(stripAnsi(msg))
+    }
+
+    // eslint-disable-next-line @typescript-eslint/no-empty-function
+    resize (_w: number, _h: number): void { }
+
+    write (data: Buffer): void {
+        this.streamProcessor.feedFromTerminal(data)
+    }
+
+    kill (_signal?: string): void {
+        this.socket.destroy()
+    }
+
+    async destroy (): Promise<void> {
+        this.serviceMessage.complete()
+        this.kill()
+        await super.destroy()
+    }
+
+    async getChildProcesses (): Promise<any[]> {
+        return []
+    }
+
+    async gracefullyKillProcess (): Promise<void> {
+        this.kill()
+    }
+
+    supportsWorkingDirectory (): boolean {
+        return false
+    }
+
+    async getWorkingDirectory (): Promise<string|null> {
+        return null
+    }
+}

+ 7 - 0
tabby-telnet/tsconfig.json

@@ -0,0 +1,7 @@
+{
+  "extends": "../tsconfig.json",
+  "exclude": ["node_modules", "dist", "typings"],
+  "compilerOptions": {
+    "baseUrl": "src"
+  }
+}

+ 15 - 0
tabby-telnet/tsconfig.typings.json

@@ -0,0 +1,15 @@
+{
+  "extends": "../tsconfig.json",
+  "exclude": ["node_modules", "dist", "typings"],
+  "include": ["src"],
+  "compilerOptions": {
+    "baseUrl": "src",
+    "emitDeclarationOnly": true,
+    "declaration": true,
+    "declarationDir": "./typings",
+    "paths": {
+      "tabby-*": ["../../tabby-*"],
+      "*": ["../../app/node_modules/*"]
+    }
+  }
+}

+ 5 - 0
tabby-telnet/webpack.config.js

@@ -0,0 +1,5 @@
+const config = require('../webpack.plugin.config')
+module.exports = config({
+    name: 'telnet',
+    dirname: __dirname
+})

+ 13 - 0
tabby-telnet/yarn.lock

@@ -0,0 +1,13 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@types/[email protected]":
+  version "14.14.31"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.31.tgz#72286bd33d137aa0d152d47ec7c1762563d34055"
+  integrity sha512-vFHy/ezP5qI0rFgJ7aQnjDXwAMrG0KqqIH7tQG5PPv3BWBayOPIQNBjVc/P6hhdZfMx51REc6tfDNXHUio893g==
+
+cli-spinner@^0.2.10:
+  version "0.2.10"
+  resolved "https://registry.yarnpkg.com/cli-spinner/-/cli-spinner-0.2.10.tgz#f7d617a36f5c47a7bc6353c697fc9338ff782a47"
+  integrity sha512-U0sSQ+JJvSLi1pAYuJykwiA8Dsr15uHEy85iCJ6A+0DjVxivr3d+N2Wjvodeg89uP5K6TswFkKBfAD7B3YSn/Q==

+ 4 - 1
tabby-terminal/src/api/streamProcessing.ts

@@ -7,7 +7,7 @@ import { debounce } from 'rxjs/operators'
 import { PassThrough, Readable, Writable } from 'stream'
 import { ReadLine, createInterface as createReadline, clearLine } from 'readline'
 
-export type InputMode = null | 'readline' | 'readline-hex' // eslint-disable-line @typescript-eslint/no-type-alias
+export type InputMode = null | 'local-echo' | 'readline' | 'readline-hex' // eslint-disable-line @typescript-eslint/no-type-alias
 export type OutputMode = null | 'hex' // eslint-disable-line @typescript-eslint/no-type-alias
 export type NewlineMode = null | 'cr' | 'lf' | 'crlf' // eslint-disable-line @typescript-eslint/no-type-alias
 
@@ -76,6 +76,9 @@ export class TerminalStreamProcessor {
     }
 
     feedFromTerminal (data: Buffer): void {
+        if (this.options.inputMode === 'local-echo') {
+            this.outputToTerminal.next(this.replaceNewlines(data, 'crlf'))
+        }
         if (this.options.inputMode?.startsWith('readline')) {
             this.inputReadlineInStream.write(data)
         } else {

+ 1 - 0
tabby-terminal/src/components/streamProcessingSettings.component.ts

@@ -12,6 +12,7 @@ export class StreamProcessingSettingsComponent {
 
     inputModes = [
         { key: null, name: 'Normal', description: 'Input is sent as you type' },
+        { key: 'local-echo', name: 'Local echo', description: 'Immediately echoes your input locally' },
         { key: 'readline', name: 'Line by line', description: 'Line editor, input is sent after you press Enter' },
         { key: 'readline-hex', name: 'Hexadecimal', description: 'Send bytes by typing in hex values' },
     ]

+ 4 - 4
tabby-terminal/src/session.ts

@@ -42,12 +42,12 @@ export abstract class BaseSession {
             this.open = false
             this.closed.next()
             this.destroyed.next()
-            this.closed.complete()
-            this.destroyed.complete()
-            this.output.complete()
-            this.binaryOutput.complete()
             await this.gracefullyKillProcess()
         }
+        this.closed.complete()
+        this.destroyed.complete()
+        this.output.complete()
+        this.binaryOutput.complete()
     }
 
     abstract start (options: unknown): void

+ 0 - 1
tabby-web/src/platform.ts

@@ -91,7 +91,6 @@ export class WebPlatformService extends PlatformService {
     }
 
     async showMessageBox (options: MessageBoxOptions): Promise<MessageBoxResult> {
-        console.log(options)
         const modal = this.ngbModal.open(MessageBoxModalComponent, {
             backdrop: 'static',
         })

+ 12 - 15
webpack.config.js

@@ -1,16 +1,13 @@
-module.exports = [
-    require('./app/webpack.config.js'),
-    require('./app/webpack.main.config.js'),
-    require('./tabby-core/webpack.config.js'),
-    require('./tabby-electron/webpack.config.js'),
-    require('./tabby-web/webpack.config.js'),
-    require('./tabby-settings/webpack.config.js'),
-    require('./tabby-terminal/webpack.config.js'),
-    require('./tabby-local/webpack.config.js'),
-    require('./tabby-community-color-schemes/webpack.config.js'),
-    require('./tabby-plugin-manager/webpack.config.js'),
-    require('./tabby-ssh/webpack.config.js'),
-    require('./tabby-serial/webpack.config.js'),
-    require('./tabby-web/webpack.config.js'),
-    require('./web/webpack.config.js'),
+const log = require('npmlog')
+const { builtinPlugins } = require('./scripts/vars')
+
+const paths = [
+    './app/webpack.config.js',
+    './app/webpack.main.config.js',
+    './web/webpack.config.js',
+    ...builtinPlugins.map(x => `./${x}/webpack.config.js`),
 ]
+
+paths.forEach(x => log.info(`Using config: ${x}`))
+
+module.exports = paths.map(x => require(x))