added a telnet client - fixes #760

上级 59de67ca
......@@ -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
......@@ -14,6 +14,7 @@ export interface Profile {
color?: string
disableDynamicTitle?: boolean
weight?: number
isBuiltin?: boolean
isTemplate?: boolean
}
......
......@@ -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
......@@ -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'] = []
}
......
......@@ -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()}`,
......
......@@ -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
}
......
......@@ -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'
}
}
{
"name": "tabby-serial",
"version": "1.0.144",
"description": "Serial connection manager for Tabby",
"description": "Serial connections for Tabby",
"keywords": [
"tabby-builtin-plugin"
],
......
{
"name": "tabby-ssh",
"version": "1.0.144",
"description": "SSH connection manager for Tabby",
"description": "SSH connections for Tabby",
"keywords": [
"tabby-builtin-plugin"
],
......
......@@ -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> {
......
......@@ -10,10 +10,8 @@ export class SSHConfigProvider extends ConfigProvider {
agentPath: null,
},
hotkeys: {
ssh: [
'Alt-S',
],
'restart-ssh-session': [],
'launch-winscp': [],
},
}
......
......@@ -31,6 +31,7 @@ export class SSHProfilesService extends ProfileProvider {
},
isBuiltin: true,
isTemplate: true,
weight: -1,
}]
}
......
{
"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": "*"
}
}
.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')
/* 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
}
.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
@import '../../../tabby-ssh/src/components/sshTab.component.scss';
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()
}
}
}
import { ConfigProvider } from 'tabby-core'
/** @hidden */
export class TelnetConfigProvider extends ConfigProvider {
defaults = {
hotkeys: {
'restart-telnet-session': [],
},
}
platformDefaults = { }
}
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
}
}
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
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',
},
}
}
}
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,
}
}
}
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
}
}
{
"extends": "../tsconfig.json",
"exclude": ["node_modules", "dist", "typings"],
"compilerOptions": {
"baseUrl": "src"
}
}
{
"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/*"]
}
}
}
const config = require('../webpack.plugin.config')
module.exports = config({
name: 'telnet',
dirname: __dirname
})
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@types/node@14.14.31":
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==
......@@ -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 {
......
......@@ -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' },
]
......
......@@ -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
......
......@@ -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',
})
......
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))
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册