diff --git a/tabby-core/src/components/transfersMenu.component.scss b/tabby-core/src/components/transfersMenu.component.scss index fd95e608..0fc72e6a 100644 --- a/tabby-core/src/components/transfersMenu.component.scss +++ b/tabby-core/src/components/transfersMenu.component.scss @@ -22,6 +22,7 @@ min-width: 0; margin-right: auto; margin-bottom: 3px; + width: 100%; label { margin: 0; diff --git a/tabby-electron/package.json b/tabby-electron/package.json index 4e449d00..1358f13a 100644 --- a/tabby-electron/package.json +++ b/tabby-electron/package.json @@ -20,7 +20,8 @@ "@angular/core": "^9.1.9" }, "devDependencies": { - "winston": "^3.3.3", - "electron-promise-ipc": "^2.2.4" + "electron-promise-ipc": "^2.2.4", + "tmp-promise": "^3.0.2", + "winston": "^3.3.3" } } diff --git a/tabby-electron/src/index.ts b/tabby-electron/src/index.ts index 01ce6cc0..e13704f0 100644 --- a/tabby-electron/src/index.ts +++ b/tabby-electron/src/index.ts @@ -1,6 +1,7 @@ import { NgModule } from '@angular/core' import { PlatformService, LogService, UpdaterService, DockingService, HostAppService, ThemesService, Platform, AppService, ConfigService, WIN_BUILD_FLUENT_BG_SUPPORTED, isWindowsBuild, HostWindowService, HotkeyProvider, ConfigProvider, FileProvider } from 'tabby-core' import { TerminalColorSchemeProvider } from 'tabby-terminal' +import { SFTPContextMenuItemProvider } from 'tabby-ssh' import { HyperColorSchemes } from './colorSchemes' import { ElectronPlatformService } from './services/platform.service' @@ -14,11 +15,12 @@ import { ElectronHostAppService } from './services/hostApp.service' import { ElectronService } from './services/electron.service' import { ElectronHotkeyProvider } from './hotkeys' import { ElectronConfigProvider } from './config' +import { EditSFTPContextMenu } from './sftpContextMenu' @NgModule({ providers: [ { provide: TerminalColorSchemeProvider, useClass: HyperColorSchemes, multi: true }, - { provide: PlatformService, useClass: ElectronPlatformService }, + { provide: PlatformService, useExisting: ElectronPlatformService }, { provide: HostWindowService, useExisting: ElectronHostWindow }, { provide: HostAppService, useExisting: ElectronHostAppService }, { provide: LogService, useClass: ElectronLogService }, @@ -27,6 +29,7 @@ import { ElectronConfigProvider } from './config' { provide: HotkeyProvider, useClass: ElectronHotkeyProvider, multi: true }, { provide: ConfigProvider, useClass: ElectronConfigProvider, multi: true }, { provide: FileProvider, useClass: ElectronFileProvider, multi: true }, + { provide: SFTPContextMenuItemProvider, useClass: EditSFTPContextMenu, multi: true }, ], }) export default class ElectronModule { diff --git a/tabby-electron/src/services/platform.service.ts b/tabby-electron/src/services/platform.service.ts index 5de96b4c..a2cb4e7e 100644 --- a/tabby-electron/src/services/platform.service.ts +++ b/tabby-electron/src/services/platform.service.ts @@ -20,7 +20,7 @@ try { var wnr = require('windows-native-registry') } catch { } -@Injectable() +@Injectable({ providedIn: 'root' }) export class ElectronPlatformService extends PlatformService { supportsWindowControls = true private configPath: string @@ -189,7 +189,7 @@ export class ElectronPlatformService extends PlatformService { this.electron.app.exit(0) } - async startUpload (options?: FileUploadOptions): Promise { + async startUpload (options?: FileUploadOptions, paths?: string[]): Promise { options ??= { multiple: false } const properties: any[] = ['openFile', 'treatPackageAsDirectory'] @@ -197,18 +197,21 @@ export class ElectronPlatformService extends PlatformService { properties.push('multiSelections') } - const result = await this.electron.dialog.showOpenDialog( - this.hostWindow.getWindow(), - { - buttonLabel: 'Select', - properties, - }, - ) - if (result.canceled) { - return [] + if (!paths) { + const result = await this.electron.dialog.showOpenDialog( + this.hostWindow.getWindow(), + { + buttonLabel: 'Select', + properties, + }, + ) + if (result.canceled) { + return [] + } + paths = result.filePaths } - return Promise.all(result.filePaths.map(async p => { + return Promise.all(paths.map(async p => { const transfer = new ElectronFileUpload(p, this.electron) await wrapPromise(this.zone, transfer.open()) this.fileTransferStarted.next(transfer) @@ -216,17 +219,20 @@ export class ElectronPlatformService extends PlatformService { })) } - async startDownload (name: string, mode: number, size: number): Promise { - const result = await this.electron.dialog.showSaveDialog( - this.hostWindow.getWindow(), - { - defaultPath: name, - }, - ) - if (!result.filePath) { - return null + async startDownload (name: string, mode: number, size: number, filePath?: string): Promise { + if (!filePath) { + const result = await this.electron.dialog.showSaveDialog( + this.hostWindow.getWindow(), + { + defaultPath: name, + }, + ) + if (!result.filePath) { + return null + } + filePath = result.filePath } - const transfer = new ElectronFileDownload(result.filePath, mode, size, this.electron) + const transfer = new ElectronFileDownload(filePath, mode, size, this.electron) await wrapPromise(this.zone, transfer.open()) this.fileTransferStarted.next(transfer) return transfer diff --git a/tabby-electron/src/sftpContextMenu.ts b/tabby-electron/src/sftpContextMenu.ts new file mode 100644 index 00000000..26203c3e --- /dev/null +++ b/tabby-electron/src/sftpContextMenu.ts @@ -0,0 +1,59 @@ +import * as tmp from 'tmp-promise' +import * as path from 'path' +import * as fs from 'fs' +import { Subject, debounceTime, debounce } from 'rxjs' +import { Injectable } from '@angular/core' +import { MenuItemOptions } from 'tabby-core' +import { SFTPFile, SFTPPanelComponent, SFTPContextMenuItemProvider, SFTPSession } from 'tabby-ssh' +import { ElectronPlatformService } from './services/platform.service' + + +/** @hidden */ +@Injectable() +export class EditSFTPContextMenu extends SFTPContextMenuItemProvider { + weight = 0 + + constructor ( + private platform: ElectronPlatformService, + ) { + super() + } + + async getItems (item: SFTPFile, panel: SFTPPanelComponent): Promise { + if (item.isDirectory) { + return [] + } + return [ + { + click: () => this.edit(item, panel.sftp), + label: 'Edit locally', + }, + ] + } + + private async edit (item: SFTPFile, sftp: SFTPSession) { + const tempDir = (await tmp.dir({ unsafeCleanup: true })).path + const tempPath = path.join(tempDir, item.name) + const transfer = await this.platform.startDownload(item.name, item.mode, item.size, tempPath) + if (!transfer) { + return + } + await sftp.download(item.fullPath, transfer) + this.platform.openPath(tempPath) + + const events = new Subject() + const watcher = fs.watch(tempPath, event => events.next(event)) + events.pipe(debounceTime(1000), debounce(async event => { + if (event === 'rename') { + watcher.close() + } + const upload = await this.platform.startUpload({ multiple: false }, [tempPath]) + if (!upload.length) { + return + } + sftp.upload(item.fullPath, upload[0]) + })).subscribe() + watcher.on('close', () => events.complete()) + sftp.closed$.subscribe(() => watcher.close()) + } +} diff --git a/tabby-electron/yarn.lock b/tabby-electron/yarn.lock index f13b92e7..a44e471b 100644 --- a/tabby-electron/yarn.lock +++ b/tabby-electron/yarn.lock @@ -16,6 +16,19 @@ async@^3.1.0: resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720" integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw== +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + call-bind@^1.0.0, call-bind@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" @@ -70,6 +83,11 @@ colorspace@1.1.x: color "3.0.x" text-hex "1.0.x" +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -143,6 +161,11 @@ fn.name@1.x.x: resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -157,6 +180,18 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.1: has "^1.0.3" has-symbols "^1.0.1" +glob@^7.1.3: + version "7.1.7" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90" + integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + has-bigints@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" @@ -174,7 +209,15 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" -inherits@^2.0.3, inherits@~2.0.3: +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@^2.0.3, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -267,6 +310,13 @@ logform@^2.2.0: ms "^2.1.1" triple-beam "^1.3.0" +minimatch@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== + dependencies: + brace-expansion "^1.1.7" + ms@^2.1.1: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" @@ -302,6 +352,13 @@ object.entries@^1.1.3: es-abstract "^1.18.0-next.1" has "^1.0.3" +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + dependencies: + wrappy "1" + one-time@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45" @@ -309,6 +366,11 @@ one-time@^1.0.0: dependencies: fn.name "1.x.x" +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -336,6 +398,13 @@ readable-stream@^3.4.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +rimraf@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" @@ -400,6 +469,20 @@ text-hex@1.0.x: resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== +tmp-promise@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/tmp-promise/-/tmp-promise-3.0.2.tgz#6e933782abff8b00c3119d63589ca1fb9caaa62a" + integrity sha512-OyCLAKU1HzBjL6Ev3gxUeraJNlbNingmi8IrHHEsYH8LTmEuhvYfqvhn2F/je+mjf4N58UmZ96OMEy1JanSCpA== + dependencies: + tmp "^0.2.0" + +tmp@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" + integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ== + dependencies: + rimraf "^3.0.0" + triple-beam@^1.2.0, triple-beam@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9" @@ -463,3 +546,8 @@ winston@^3.3.3: stack-trace "0.0.x" triple-beam "^1.3.0" winston-transport "^4.4.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= diff --git a/tabby-ssh/src/algorithms.ts b/tabby-ssh/src/algorithms.ts index 4a69d93b..0223dcdc 100644 --- a/tabby-ssh/src/algorithms.ts +++ b/tabby-ssh/src/algorithms.ts @@ -1,5 +1,5 @@ -import { ALGORITHM_BLACKLIST, SSHAlgorithmType } from './api' import * as ALGORITHMS from 'ssh2/lib/protocol/constants' +import { ALGORITHM_BLACKLIST, SSHAlgorithmType } from './api' export const supportedAlgorithms: Record = {} diff --git a/tabby-ssh/src/api/contextMenu.ts b/tabby-ssh/src/api/contextMenu.ts new file mode 100644 index 00000000..26f5854a --- /dev/null +++ b/tabby-ssh/src/api/contextMenu.ts @@ -0,0 +1,12 @@ +import { MenuItemOptions } from 'tabby-core' +import { SFTPFile } from '../session/sftp' +import { SFTPPanelComponent } from '../components/sftpPanel.component' + +/** + * Extend to add items to the SFTPPanel context menu + */ +export abstract class SFTPContextMenuItemProvider { + weight = 0 + + abstract getItems (item: SFTPFile, panel: SFTPPanelComponent): Promise +} diff --git a/tabby-ssh/src/api/index.ts b/tabby-ssh/src/api/index.ts new file mode 100644 index 00000000..d71080a4 --- /dev/null +++ b/tabby-ssh/src/api/index.ts @@ -0,0 +1,2 @@ +export * from './contextMenu' +export * from './interfaces' diff --git a/tabby-ssh/src/api/interfaces.ts b/tabby-ssh/src/api/interfaces.ts new file mode 100644 index 00000000..39622302 --- /dev/null +++ b/tabby-ssh/src/api/interfaces.ts @@ -0,0 +1,53 @@ +import { Profile } from 'tabby-core' +import { LoginScriptsOptions } from 'tabby-terminal' + +export enum SSHAlgorithmType { + HMAC = 'hmac', + KEX = 'kex', + CIPHER = 'cipher', + HOSTKEY = 'serverHostKey', +} + +export interface SSHProfile extends Profile { + options: SSHProfileOptions +} + +export interface SSHProfileOptions extends LoginScriptsOptions { + host: string + port?: number + user: string + auth?: null|'password'|'publicKey'|'agent'|'keyboardInteractive' + password?: string + privateKeys?: string[] + keepaliveInterval?: number + keepaliveCountMax?: number + readyTimeout?: number + x11?: boolean + skipBanner?: boolean + jumpHost?: string + agentForward?: boolean + warnOnClose?: boolean + algorithms?: Record + proxyCommand?: string + forwardedPorts?: ForwardedPortConfig[] +} + +export enum PortForwardType { + Local = 'Local', + Remote = 'Remote', + Dynamic = 'Dynamic', +} + +export interface ForwardedPortConfig { + type: PortForwardType + host: string + port: number + targetAddress: string + targetPort: number +} + +export const ALGORITHM_BLACKLIST = [ + // cause native crashes in node crypto, use EC instead + 'diffie-hellman-group-exchange-sha256', + 'diffie-hellman-group-exchange-sha1', +] diff --git a/tabby-ssh/src/components/sftpPanel.component.ts b/tabby-ssh/src/components/sftpPanel.component.ts index 8d3c7dd8..a58b6314 100644 --- a/tabby-ssh/src/components/sftpPanel.component.ts +++ b/tabby-ssh/src/components/sftpPanel.component.ts @@ -1,18 +1,16 @@ -import { Component, Input, Output, EventEmitter } from '@angular/core' -import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { SSHSession } from '../api' -import { SFTPSession, SFTPFile } from '../session/sftp' -import { posix as path } from 'path' import * as C from 'constants' -import { FileUpload, PlatformService } from 'tabby-core' -import { SFTPDeleteModalComponent } from './sftpDeleteModal.component' +import { posix as path } from 'path' +import { Component, Input, Output, EventEmitter, Inject, Optional } from '@angular/core' +import { FileUpload, MenuItemOptions, PlatformService } from 'tabby-core' +import { SFTPSession, SFTPFile } from '../session/sftp' +import { SSHSession } from '../session/ssh' +import { SFTPContextMenuItemProvider } from '../api' interface PathSegment { name: string path: string } -/** @hidden */ @Component({ selector: 'sftp-panel', template: require('./sftpPanel.component.pug'), @@ -29,8 +27,10 @@ export class SFTPPanelComponent { constructor ( private platform: PlatformService, - private ngbModal: NgbModal, - ) { } + @Optional() @Inject(SFTPContextMenuItemProvider) protected contextMenuProviders: SFTPContextMenuItemProvider[], + ) { + this.contextMenuProviders.sort((a, b) => a.weight - b.weight) + } async ngOnInit (): Promise { this.sftp = await this.session.openSFTP() @@ -96,28 +96,10 @@ export class SFTPPanelComponent { } async uploadOne (transfer: FileUpload): Promise { - const itemPath = path.join(this.path, transfer.getName()) - const tempPath = itemPath + '.tabby-upload' + this.sftp.upload(path.join(this.path, transfer.getName()), transfer) const savedPath = this.path - try { - const handle = await this.sftp.open(tempPath, 'w') - while (true) { - const chunk = await transfer.read() - if (!chunk.length) { - break - } - await handle.write(chunk) - } - handle.close() - await this.sftp.rename(tempPath, itemPath) - transfer.close() - if (this.path === savedPath) { - await this.navigate(this.path) - } - } catch (e) { - transfer.cancel() - this.sftp.unlink(tempPath) - throw e + if (this.path === savedPath) { + await this.navigate(this.path) } } @@ -126,21 +108,7 @@ export class SFTPPanelComponent { if (!transfer) { return } - try { - const handle = await this.sftp.open(itemPath, 'r') - while (true) { - const chunk = await handle.read() - if (!chunk.length) { - break - } - await transfer.write(chunk) - } - transfer.close() - handle.close() - } catch (e) { - transfer.cancel() - throw e - } + this.sftp.download(itemPath, transfer) } getModeString (item: SFTPFile): string { @@ -159,31 +127,18 @@ export class SFTPPanelComponent { return result } - showContextMenu (item: SFTPFile, event: MouseEvent): void { - event.preventDefault() - this.platform.popupContextMenu([ - { - click: async () => { - if ((await this.platform.showMessageBox({ - type: 'warning', - message: `Delete ${item.fullPath}?`, - defaultId: 0, - buttons: ['Delete', 'Cancel'], - })).response === 0) { - await this.deleteItem(item) - this.navigate(this.path) - } - }, - label: 'Delete', - }, - ], event) + async buildContextMenu (item: SFTPFile): Promise { + let items: MenuItemOptions[] = [] + for (const section of await Promise.all(this.contextMenuProviders.map(x => x.getItems(item, this)))) { + items.push({ type: 'separator' }) + items = items.concat(section) + } + return items.slice(1) } - async deleteItem (item: SFTPFile): Promise { - const modal = this.ngbModal.open(SFTPDeleteModalComponent) - modal.componentInstance.item = item - modal.componentInstance.sftp = this.sftp - await modal.result + async showContextMenu (item: SFTPFile, event: MouseEvent): Promise { + event.preventDefault() + this.platform.popupContextMenu(await this.buildContextMenu(item), event) } close (): void { diff --git a/tabby-ssh/src/components/sshPortForwardingModal.component.ts b/tabby-ssh/src/components/sshPortForwardingModal.component.ts index 5ed631f6..b8c99c3c 100644 --- a/tabby-ssh/src/components/sshPortForwardingModal.component.ts +++ b/tabby-ssh/src/components/sshPortForwardingModal.component.ts @@ -1,6 +1,8 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { Component, Input } from '@angular/core' -import { ForwardedPort, ForwardedPortConfig, SSHSession } from '../api' +import { ForwardedPort } from '../session/forwards' +import { SSHSession } from '../session/ssh' +import { ForwardedPortConfig } from '../api' /** @hidden */ @Component({ diff --git a/tabby-ssh/src/components/sshTab.component.ts b/tabby-ssh/src/components/sshTab.component.ts index a0c55797..7c9fc734 100644 --- a/tabby-ssh/src/components/sshTab.component.ts +++ b/tabby-ssh/src/components/sshTab.component.ts @@ -5,8 +5,9 @@ import { first } from 'rxjs' import { Platform, RecoveryToken } from 'tabby-core' import { BaseTerminalTabComponent } from 'tabby-terminal' import { SSHService } from '../services/ssh.service' -import { SSHProfile, SSHSession } from '../api' +import { SSHSession } from '../session/ssh' import { SSHPortForwardingModalComponent } from './sshPortForwardingModal.component' +import { SSHProfile } from '../api' /** @hidden */ diff --git a/tabby-ssh/src/index.ts b/tabby-ssh/src/index.ts index 11ee7235..9005fc72 100644 --- a/tabby-ssh/src/index.ts +++ b/tabby-ssh/src/index.ts @@ -22,6 +22,8 @@ import { RecoveryProvider } from './recoveryProvider' import { SSHHotkeyProvider } from './hotkeys' import { SFTPContextMenu } from './tabContextMenu' import { SSHProfilesService } from './profiles' +import { SFTPContextMenuItemProvider } from './api/contextMenu' +import { CommonSFTPContextMenu } from './sftpContextMenu' /** @hidden */ @NgModule({ @@ -41,6 +43,7 @@ import { SSHProfilesService } from './profiles' { provide: HotkeyProvider, useClass: SSHHotkeyProvider, multi: true }, { provide: TabContextMenuItemProvider, useClass: SFTPContextMenu, multi: true }, { provide: ProfileProvider, useExisting: SSHProfilesService, multi: true }, + { provide: SFTPContextMenuItemProvider, useClass: CommonSFTPContextMenu, multi: true }, ], entryComponents: [ SSHProfileSettingsComponent, @@ -105,3 +108,7 @@ export default class SSHModule { await this.selector.show('Select an SSH profile', options) } } + +export * from './api' +export { SFTPFile, SFTPSession } from './session/sftp' +export { SFTPPanelComponent } diff --git a/tabby-ssh/src/profiles.ts b/tabby-ssh/src/profiles.ts index b444bd7f..fe607dfd 100644 --- a/tabby-ssh/src/profiles.ts +++ b/tabby-ssh/src/profiles.ts @@ -1,10 +1,10 @@ import { Injectable } from '@angular/core' import { ProfileProvider, NewTabParameters, PartialProfile } from 'tabby-core' +import * as ALGORITHMS from 'ssh2/lib/protocol/constants' import { SSHProfileSettingsComponent } from './components/sshProfileSettings.component' import { SSHTabComponent } from './components/sshTab.component' import { PasswordStorageService } from './services/passwordStorage.service' import { ALGORITHM_BLACKLIST, SSHAlgorithmType, SSHProfile } from './api' -import * as ALGORITHMS from 'ssh2/lib/protocol/constants' @Injectable({ providedIn: 'root' }) diff --git a/tabby-ssh/src/services/passwordStorage.service.ts b/tabby-ssh/src/services/passwordStorage.service.ts index 7722b6e8..2850ee22 100644 --- a/tabby-ssh/src/services/passwordStorage.service.ts +++ b/tabby-ssh/src/services/passwordStorage.service.ts @@ -1,7 +1,7 @@ import * as keytar from 'keytar' import { Injectable } from '@angular/core' -import { SSHProfile } from '../api' import { VaultService } from 'tabby-core' +import { SSHProfile } from '../api' export const VAULT_SECRET_TYPE_PASSWORD = 'ssh:password' export const VAULT_SECRET_TYPE_PASSPHRASE = 'ssh:key-passphrase' diff --git a/tabby-ssh/src/services/ssh.service.ts b/tabby-ssh/src/services/ssh.service.ts index ad89b0ef..e6b6e3fb 100644 --- a/tabby-ssh/src/services/ssh.service.ts +++ b/tabby-ssh/src/services/ssh.service.ts @@ -4,11 +4,13 @@ import { Injectable, NgZone } from '@angular/core' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { Client } from 'ssh2' import { exec } from 'child_process' +import { ChildProcess } from 'node:child_process' import { Subject, Observable } from 'rxjs' import { Logger, LogService, ConfigService, NotificationsService, HostAppService, Platform, PlatformService, PromptModalComponent } from 'tabby-core' -import { ALGORITHM_BLACKLIST, ForwardedPort, SSHAlgorithmType, SSHProfile, SSHSession } from '../api' +import { SSHSession } from '../session/ssh' +import { ForwardedPort } from '../session/forwards' +import { ALGORITHM_BLACKLIST, SSHAlgorithmType, SSHProfile } from '../api' import { PasswordStorageService } from './passwordStorage.service' -import { ChildProcess } from 'node:child_process' @Injectable({ providedIn: 'root' }) export class SSHService { diff --git a/tabby-ssh/src/session/forwards.ts b/tabby-ssh/src/session/forwards.ts new file mode 100644 index 00000000..5f2333f9 --- /dev/null +++ b/tabby-ssh/src/session/forwards.ts @@ -0,0 +1,64 @@ +import socksv5 from 'socksv5' +import { Server, Socket, createServer } from 'net' + +import { ForwardedPortConfig, PortForwardType } from '../api' + +export class ForwardedPort implements ForwardedPortConfig { + type: PortForwardType + host = '127.0.0.1' + port: number + targetAddress: string + targetPort: number + + private listener: Server|null = null + + async startLocalListener (callback: (accept: () => Socket, reject: () => void, sourceAddress: string|null, sourcePort: number|null, targetAddress: string, targetPort: number) => void): Promise { + if (this.type === PortForwardType.Local) { + const listener = this.listener = createServer(s => callback( + () => s, + () => s.destroy(), + s.remoteAddress ?? null, + s.remotePort ?? null, + this.targetAddress, + this.targetPort, + )) + return new Promise((resolve, reject) => { + listener.listen(this.port, this.host) + listener.on('error', reject) + listener.on('listening', resolve) + }) + } else if (this.type === PortForwardType.Dynamic) { + return new Promise((resolve, reject) => { + this.listener = socksv5.createServer((info, acceptConnection, rejectConnection) => { + callback( + () => acceptConnection(true), + () => rejectConnection(), + null, + null, + info.dstAddr, + info.dstPort, + ) + }) as Server + this.listener.on('error', reject) + this.listener.listen(this.port, this.host, resolve) + this.listener['useAuth'](socksv5.auth.None()) + }) + } else { + throw new Error('Invalid forward type for a local listener') + } + } + + stopLocalListener (): void { + this.listener?.close() + } + + toString (): string { + if (this.type === PortForwardType.Local) { + return `(local) ${this.host}:${this.port} → (remote) ${this.targetAddress}:${this.targetPort}` + } if (this.type === PortForwardType.Remote) { + return `(remote) ${this.host}:${this.port} → (local) ${this.targetAddress}:${this.targetPort}` + } else { + return `(dynamic) ${this.host}:${this.port}` + } + } +} diff --git a/tabby-ssh/src/session/sftp.ts b/tabby-ssh/src/session/sftp.ts index d440e53c..dfaf7b7b 100644 --- a/tabby-ssh/src/session/sftp.ts +++ b/tabby-ssh/src/session/sftp.ts @@ -1,8 +1,9 @@ import * as C from 'constants' // eslint-disable-next-line @typescript-eslint/no-duplicate-imports, no-duplicate-imports +import { Subject, Observable } from 'rxjs' import { posix as posixPath } from 'path' -import { NgZone } from '@angular/core' -import { wrapPromise } from 'tabby-core' +import { Injector, NgZone } from '@angular/core' +import { FileDownload, FileUpload, Logger, LogService, wrapPromise } from 'tabby-core' import { SFTPWrapper } from 'ssh2' import { promisify } from 'util' @@ -70,9 +71,22 @@ export class SFTPFileHandle { } export class SFTPSession { - constructor (private sftp: SFTPWrapper, private zone: NgZone) { } + get closed$ (): Observable { return this.closed } + private closed = new Subject() + private zone: NgZone + private logger: Logger + + constructor (private sftp: SFTPWrapper, injector: Injector) { + this.zone = injector.get(NgZone) + this.logger = injector.get(LogService).create('sftp') + sftp.on('close', () => { + this.closed.next() + this.closed.complete() + }) + } async readdir (p: string): Promise { + this.logger.debug('readdir', p) const entries = await wrapPromise(this.zone, promisify(f => this.sftp.readdir(p, f))()) return entries.map(entry => this._makeFile( posixPath.join(p, entry.filename), entry, @@ -80,10 +94,12 @@ export class SFTPSession { } readlink (p: string): Promise { + this.logger.debug('readlink', p) return wrapPromise(this.zone, promisify(f => this.sftp.readlink(p, f))()) } async stat (p: string): Promise { + this.logger.debug('stat', p) const stats = await wrapPromise(this.zone, promisify(f => this.sftp.stat(p, f))()) return { name: posixPath.basename(p), @@ -97,22 +113,68 @@ export class SFTPSession { } async open (p: string, mode: string): Promise { + this.logger.debug('open', p) const handle = await wrapPromise(this.zone, promisify(f => this.sftp.open(p, mode, f))()) return new SFTPFileHandle(this.sftp, handle, this.zone) } async rmdir (p: string): Promise { + this.logger.debug('rmdir', p) await promisify((f: any) => this.sftp.rmdir(p, f))() } async rename (oldPath: string, newPath: string): Promise { + this.logger.debug('rename', oldPath, newPath) await promisify((f: any) => this.sftp.rename(oldPath, newPath, f))() } async unlink (p: string): Promise { + this.logger.debug('unlink', p) await promisify((f: any) => this.sftp.unlink(p, f))() } + async upload (path: string, transfer: FileUpload): Promise { + this.logger.info('Uploading into', path) + const tempPath = path + '.tabby-upload' + try { + const handle = await this.open(tempPath, 'w') + while (true) { + const chunk = await transfer.read() + if (!chunk.length) { + break + } + await handle.write(chunk) + } + handle.close() + await this.unlink(path) + await this.rename(tempPath, path) + transfer.close() + } catch (e) { + transfer.cancel() + this.unlink(tempPath) + throw e + } + } + + async download (path: string, transfer: FileDownload): Promise { + this.logger.info('Downloading', path) + try { + const handle = await this.open(path, 'r') + while (true) { + const chunk = await handle.read() + if (!chunk.length) { + break + } + await transfer.write(chunk) + } + transfer.close() + handle.close() + } catch (e) { + transfer.cancel() + throw e + } + } + private _makeFile (p: string, entry: FileEntry): SFTPFile { return { fullPath: p, diff --git a/tabby-ssh/src/api.ts b/tabby-ssh/src/session/ssh.ts similarity index 83% rename from tabby-ssh/src/api.ts rename to tabby-ssh/src/session/ssh.ts index 114eec2c..01d03b0f 100644 --- a/tabby-ssh/src/api.ts +++ b/tabby-ssh/src/session/ssh.ts @@ -5,126 +5,22 @@ import * as path from 'path' import * as sshpk from 'sshpk' import colors from 'ansi-colors' import stripAnsi from 'strip-ansi' -import socksv5 from 'socksv5' import { Injector, NgZone } from '@angular/core' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { ConfigService, FileProvidersService, HostAppService, NotificationsService, Platform, PlatformService, wrapPromise, PromptModalComponent, Profile, LogService } from 'tabby-core' -import { BaseSession, LoginScriptsOptions } from 'tabby-terminal' -import { Server, Socket, createServer, createConnection } from 'net' +import { ConfigService, FileProvidersService, HostAppService, NotificationsService, Platform, PlatformService, wrapPromise, PromptModalComponent, LogService } from 'tabby-core' +import { BaseSession } from 'tabby-terminal' +import { Socket, createConnection } from 'net' import { Client, ClientChannel, SFTPWrapper } from 'ssh2' import { Subject, Observable } from 'rxjs' -import { ProxyCommandStream } from './services/ssh.service' -import { PasswordStorageService } from './services/passwordStorage.service' +import { ProxyCommandStream } from '../services/ssh.service' +import { PasswordStorageService } from '../services/passwordStorage.service' import { promisify } from 'util' -import { SFTPSession } from './session/sftp' +import { SFTPSession } from './sftp' +import { PortForwardType, SSHProfile } from '../api/interfaces' +import { ForwardedPort } from './forwards' const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent' -export enum SSHAlgorithmType { - HMAC = 'hmac', - KEX = 'kex', - CIPHER = 'cipher', - HOSTKEY = 'serverHostKey', -} - -export interface SSHProfile extends Profile { - options: SSHProfileOptions -} - -export interface SSHProfileOptions extends LoginScriptsOptions { - host: string - port?: number - user: string - auth?: null|'password'|'publicKey'|'agent'|'keyboardInteractive' - password?: string - privateKeys?: string[] - keepaliveInterval?: number - keepaliveCountMax?: number - readyTimeout?: number - x11?: boolean - skipBanner?: boolean - jumpHost?: string - agentForward?: boolean - warnOnClose?: boolean - algorithms?: Record - proxyCommand?: string - forwardedPorts?: ForwardedPortConfig[] -} - -export enum PortForwardType { - Local = 'Local', - Remote = 'Remote', - Dynamic = 'Dynamic', -} - -export interface ForwardedPortConfig { - type: PortForwardType - host: string - port: number - targetAddress: string - targetPort: number -} - -export class ForwardedPort implements ForwardedPortConfig { - type: PortForwardType - host = '127.0.0.1' - port: number - targetAddress: string - targetPort: number - - private listener: Server|null = null - - async startLocalListener (callback: (accept: () => Socket, reject: () => void, sourceAddress: string|null, sourcePort: number|null, targetAddress: string, targetPort: number) => void): Promise { - if (this.type === PortForwardType.Local) { - const listener = this.listener = createServer(s => callback( - () => s, - () => s.destroy(), - s.remoteAddress ?? null, - s.remotePort ?? null, - this.targetAddress, - this.targetPort, - )) - return new Promise((resolve, reject) => { - listener.listen(this.port, this.host) - listener.on('error', reject) - listener.on('listening', resolve) - }) - } else if (this.type === PortForwardType.Dynamic) { - return new Promise((resolve, reject) => { - this.listener = socksv5.createServer((info, acceptConnection, rejectConnection) => { - callback( - () => acceptConnection(true), - () => rejectConnection(), - null, - null, - info.dstAddr, - info.dstPort, - ) - }) as Server - this.listener.on('error', reject) - this.listener.listen(this.port, this.host, resolve) - this.listener['useAuth'](socksv5.auth.None()) - }) - } else { - throw new Error('Invalid forward type for a local listener') - } - } - - stopLocalListener (): void { - this.listener?.close() - } - - toString (): string { - if (this.type === PortForwardType.Local) { - return `(local) ${this.host}:${this.port} → (remote) ${this.targetAddress}:${this.targetPort}` - } if (this.type === PortForwardType.Remote) { - return `(remote) ${this.host}:${this.port} → (local) ${this.targetAddress}:${this.targetPort}` - } else { - return `(dynamic) ${this.host}:${this.port}` - } - } -} - interface AuthMethod { type: 'none'|'publickey'|'agent'|'password'|'keyboard-interactive'|'hostbased' name?: string @@ -158,7 +54,7 @@ export class SSHSession extends BaseSession { private config: ConfigService constructor ( - injector: Injector, + private injector: Injector, public profile: SSHProfile, ) { super(injector.get(LogService).create(`ssh-${profile.options.host}-${profile.options.port}`)) @@ -241,7 +137,7 @@ export class SSHSession extends BaseSession { if (!this.sftp) { this.sftp = await wrapPromise(this.zone, promisify(f => this.ssh.sftp(f))()) } - return new SFTPSession(this.sftp, this.zone) + return new SFTPSession(this.sftp, this.injector) } async start (): Promise { @@ -613,9 +509,3 @@ export class SSHSession extends BaseSession { } } } - -export const ALGORITHM_BLACKLIST = [ - // cause native crashes in node crypto, use EC instead - 'diffie-hellman-group-exchange-sha256', - 'diffie-hellman-group-exchange-sha1', -] diff --git a/tabby-ssh/src/sftpContextMenu.ts b/tabby-ssh/src/sftpContextMenu.ts new file mode 100644 index 00000000..85f17ffc --- /dev/null +++ b/tabby-ssh/src/sftpContextMenu.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@angular/core' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { MenuItemOptions, PlatformService } from 'tabby-core' +import { SFTPSession, SFTPFile } from './session/sftp' +import { SFTPContextMenuItemProvider } from './api' +import { SFTPDeleteModalComponent } from './components/sftpDeleteModal.component' +import { SFTPPanelComponent } from './components/sftpPanel.component' + + +/** @hidden */ +@Injectable() +export class CommonSFTPContextMenu extends SFTPContextMenuItemProvider { + weight = 10 + + constructor ( + private platform: PlatformService, + private ngbModal: NgbModal, + ) { + super() + } + + async getItems (item: SFTPFile, panel: SFTPPanelComponent): Promise { + return [ + { + click: async () => { + if ((await this.platform.showMessageBox({ + type: 'warning', + message: `Delete ${item.fullPath}?`, + defaultId: 0, + buttons: ['Delete', 'Cancel'], + })).response === 0) { + await this.deleteItem(item, panel.sftp) + panel.navigate(panel.path) + } + }, + label: 'Delete', + }, + ] + } + + async deleteItem (item: SFTPFile, session: SFTPSession): Promise { + const modal = this.ngbModal.open(SFTPDeleteModalComponent) + modal.componentInstance.item = item + modal.componentInstance.sftp = session + await modal.result + } +}