diff --git a/.gitignore b/.gitignore index f2d5761..7beb995 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,5 @@ build/harmony/rawfile src/client/electerm-react/ entry/src/main/resources/rawfile /src/client -/data \ No newline at end of file +/data +.workbuddy \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 34e88ec..f23fcc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "electerm", - "version": "4.15.179", + "version": "4.15.186", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "electerm", - "version": "4.15.179", + "version": "4.15.186", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@electerm/electerm-locales": "2.3.1", + "@electerm/electerm-locales": "2.3.5", "@electerm/electerm-themes": "^1.0.1", "@electerm/ftp-srv": "1.0.5", "@electerm/nedb": "2.0.0", @@ -494,9 +494,9 @@ } }, "node_modules/@electerm/electerm-locales": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@electerm/electerm-locales/-/electerm-locales-2.3.1.tgz", - "integrity": "sha512-GXlmbrb4LheH9UhCAnV7Tfsj3g5LI2PLN9RMr5h/HMNCvVijm6woQ8vYu8FheoW0+hnQ4gSJNghB5Fy8lFHMaA==", + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@electerm/electerm-locales/-/electerm-locales-2.3.5.tgz", + "integrity": "sha512-cWY0A74zoWiypfJUSB223rq5mRxRZ/o0EO/vbdjrCXbtP8pSCxY2vSYSYME4MZBSfZcg1GqgrjMo+/vZEgMFfw==", "license": "MIT" }, "node_modules/@electerm/electerm-react": { diff --git a/package.json b/package.json index 98f82de..99326e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "electerm", - "version": "4.15.179", + "version": "4.15.186", "description": "electerm — a free and open-source ssh/sftp/telnet/RDP/VNC/Spice/ftp client for HarmonyOS, built on the electerm codebase.", "main": "app.js", "bin": "npm/electerm", @@ -30,7 +30,7 @@ "url": "git+https://github.com/electerm/electerm-harmony.git" }, "author": { - "name": "ZHAO Xudong", + "name": "赵旭东", "email": "zxdong@gmail.com", "url": "https://github.com/zxdong262" }, @@ -87,7 +87,7 @@ "vite": "8.1.0" }, "dependencies": { - "@electerm/electerm-locales": "2.3.1", + "@electerm/electerm-locales": "2.3.5", "@electerm/electerm-themes": "^1.0.1", "@electerm/ftp-srv": "1.0.5", "@electerm/nedb": "2.0.0", diff --git a/scripts/build-app.sh b/scripts/build-app.sh index e903adc..49408b2 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -132,34 +132,341 @@ echo "==> Fixing permissions for SDK compatibility ..." # via AppGallery Connect. See docs/ENV_SETUP.md §2.6 for details. UNSUPPORTED_PERMS="SET_ABILITY_INSTANCE_INFO GET_FILE_ICON PRIVACY_WINDOW LOCK_WINDOW_CURSOR ACCESS_BIOMETRIC SYSTEM_FLOAT_WINDOW FILE_ACCESS_PERSIST PREPARE_APP_TERMINATE CUSTOM_SCREEN_CAPTURE" -# Fix entry module.json5 +# electerm-harmony is a terminal/SSH client — it has no legitimate use for +# geolocation, camera, microphone, or Bluetooth. The vendored web_engine +# template (a generic Electron-on-HarmonyOS runtime) declares these by +# default, which triggers AppGallery Connect's privacy-permission review +# ("检测当前APP申请了以下隐私权限"). Strip the declarations here so they are +# removed on every build, since web_engine/ is re-copied from the vendor +# template by prepare-electron-runtime.sh. +PRIVACY_UNUSED_PERMS="LOCATION APPROXIMATELY_LOCATION MICROPHONE CAMERA ACCESS_BLUETOOTH" + ENTRY_MODULE_JSON="${PROJECT_ROOT}/entry/src/main/module.json5" -if [ -f "${ENTRY_MODULE_JSON}" ]; then - for perm in ${UNSUPPORTED_PERMS}; do - if grep -q "ohos.permission.${perm}" "${ENTRY_MODULE_JSON}" 2>/dev/null; then - echo " entry: removing unsupported permission ohos.permission.${perm}" - perl -i -0pe "s/\\{[^{}]*ohos\\.permission\\.${perm}[^{}]*\\}[\\s,]*//g" "${ENTRY_MODULE_JSON}" - fi - done - echo " entry permissions cleaned" +WEB_ENGINE_MODULE_JSON="${WEB_ENGINE_DIR}/src/main/module.json5" + +# Use Python to remove permission entries from module.json5 files. +# The old perl regex [^{}]* could not match permission entries that contain +# nested objects (e.g., "usedScene": { "abilities": [...], "when": "..." }), +# so permissions with usedScene were silently left in the file and ended up +# in the compiled HAP, triggering AGC's privacy-permission review. +# This Python script uses a regex that handles one level of nested braces. +remove_module_perms() { + local file="${1}" + local label="${2}" + shift 2 + + if [ ! -f "${file}" ]; then + echo " (${label} module.json5 not found, skipping)" + return + fi + + python3 - "${file}" "${label}" "$@" <<'PYEOF' +import re, sys + +file_path = sys.argv[1] +label = sys.argv[2] +perms = sys.argv[3:] + +with open(file_path, 'r') as f: + content = f.read() + +for perm in perms: + if f'"ohos.permission.{perm}"' not in content: + continue + # Match a permission object, handling one level of nested braces + # (usedScene with abilities/when). The (?:\{[^{}]*\}[^{}]*)* group + # allows zero or more nested {...} blocks within the entry. + pattern = ( + r'\{[^{}]*"ohos\.permission\.' + re.escape(perm) + r'"' + r'[^{}]*(?:\{[^{}]*\}[^{}]*)*\}[\s,]*' + ) + new_content = re.sub(pattern, '', content) + if new_content != content: + print(f' {label}: removed ohos.permission.{perm}') + content = new_content + else: + print(f' {label}: WARNING — could not remove ohos.permission.{perm}') + +with open(file_path, 'w') as f: + f.write(content) +PYEOF +} + +remove_module_perms "${ENTRY_MODULE_JSON}" "entry" ${UNSUPPORTED_PERMS} +echo " entry permissions cleaned" + +remove_module_perms "${WEB_ENGINE_MODULE_JSON}" "web_engine" ${UNSUPPORTED_PERMS} +echo " web_engine permissions cleaned" + +# --- Remove unused privacy-sensitive permissions ----------------------------- + +echo "==> Removing unused privacy-sensitive permissions ..." + +remove_module_perms "${ENTRY_MODULE_JSON}" "entry" ${PRIVACY_UNUSED_PERMS} +remove_module_perms "${WEB_ENGINE_MODULE_JSON}" "web_engine" ${PRIVACY_UNUSED_PERMS} +echo " privacy-sensitive permissions cleaned" + +# --- Neutralize unused geolocation/bluetooth/camera adapters ---------------- + +echo "==> Neutralizing unused geolocation/bluetooth/camera adapters ..." + +# These adapters are not bound in web_engine's AdapterModule.ets (dead code +# from the vendor template) but their imports of @ohos.geoLocationManager / +# @ohos.bluetooth.* / @kit.CameraKit still get compiled into the HAP and are +# flagged by AGC's static privacy scan. Replace with minimal no-op stubs. +GEO_ADAPTER="${WEB_ENGINE_DIR}/src/main/ets/adapter/GeolocationAdapter.ets" +if [ -f "${GEO_ADAPTER}" ]; then + echo " Replacing GeolocationAdapter.ets with minimal stub" + cat > "${GEO_ADAPTER}" <<'GEOSTUB' +// GeolocationAdapter.ets — Stubbed: electerm-harmony does not use geolocation. +// Original file imported @ohos.geoLocationManager, which AGC's privacy scan +// flags. The adapter IS bound (in JsBindingMethod.ets → GeolocationAdapterBind), +// so the class and method signatures stay; only the @ohos.geoLocationManager +// implementation is gutted into no-ops. (The binding's `import type geoLocationManager` +// is type-only and erased at compile, so it does not reach the HAP.) +export class GeolocationAdapter { + startListening( + onLocationReport: (location: Object) => void, + onErrorReport: (errorCode: number) => void + ): void { + onErrorReport(-1); + } + stopListening(): void { + } +} +GEOSTUB +else + echo " (GeolocationAdapter.ets not found, skipping)" +fi + +BLUETOOTH_ADAPTER="${WEB_ENGINE_DIR}/src/main/ets/adapter/BluetoothAdapter.ets" +if [ -f "${BLUETOOTH_ADAPTER}" ]; then + echo " Replacing BluetoothAdapter.ets with minimal stub" + cat > "${BLUETOOTH_ADAPTER}" <<'BTSTUB' +// BluetoothAdapter.ets — Stubbed: electerm-harmony does not use Bluetooth. +// Original file imported @ohos.bluetooth.access / @ohos.bluetooth.connection, +// which AGC's privacy scan flags. The adapter IS bound (in JsBindingMethod.ets +// → BluetoothAdapterBind), so the class and method signatures stay; only the +// @ohos.bluetooth implementation is gutted into no-ops. +export class BluetoothAdapter { + isBluetoothSwitched(): boolean { + return false; + } + getAdapterName(): string { + return '--'; + } + setAdapterName(_name: string): boolean { + return false; + } + getBluetoothState(): number { + return -1; + } + enableBluetooth(): void { + } + disableBluetooth(): void { + } + startBluetoothStateMonitor(bluetoothStateCb: (state: number) => void): void { + bluetoothStateCb(-1); + } + stopBluetoothStateMonitor(): void { + } + getBluetoothScanMode(): number { + return -1; + } + setBluetoothScanMode(_mode: number, _duration: number): void { + } + startBluetoothDiscovery(): void { + } + stopBluetoothDiscovery(): void { + } + startDiscoveryMonitor(_deviceListCb: (deviceList: Array) => void): void { + } + stopDiscoveryMonitor(): void { + } + getRemoteDeviceName(_deviceId: string): string { + return ''; + } + getPairState(_deviceId: string): number { + return 0; + } + getRemoteDeviceClass(_deviceId: string): number { + return 0; + } + pairDevice(_deviceId: string, pairCb: (isSuccess: boolean) => void): void { + pairCb(false); + } + getPairedDevices(): Array { + return []; + } + setDevicePinCode(_deviceId: string, _pinCode: string, setPinCodeCb: (isSuccess: boolean) => void): boolean { + setPinCodeCb(false); + return false; + } + isBluetoothDiscovering(): boolean { + return false; + } + getPairedState(_deviceId: string): number { + return 0; + } +} +BTSTUB else - echo " (entry module.json5 not found, skipping)" + echo " (BluetoothAdapter.ets not found, skipping)" +fi + +BLE_ADAPTER="${WEB_ENGINE_DIR}/src/main/ets/adapter/BluetoothLowEnergyAdapter.ets" +if [ -f "${BLE_ADAPTER}" ]; then + echo " Replacing BluetoothLowEnergyAdapter.ets with minimal stub" + cat > "${BLE_ADAPTER}" <<'BLESTUB' +// BluetoothLowEnergyAdapter.ets — Stubbed: electerm-harmony does not use BLE. +// Original file imported @ohos.bluetooth.ble, which AGC's privacy scan flags. +// The adapter IS bound (in JsBindingMethod.ets → BluetoothLowEnergyAdapterBind), +// so the class and the ServiceInfo/DescriptorInfo/CharacteristicInfo type exports +// that the binding imports must stay; only the @ohos.bluetooth.ble implementation +// is gutted into no-ops. +export interface ServiceInfo { + identifier: string; + device_id: string; + service_uuid: string; + is_primary: boolean; +} + +export interface DescriptorInfo { + identifier: string; + device_id: string; + service_uuid: string; + characteristic_uuid: string; + descriptor_uuid: string; +} + +export interface CharacteristicInfo { + identifier: string; + device_id: string; + service_uuid: string; + properties: number; + characteristic_uuid: string; +} + +export class BluetoothLowEnergyAdapter { + startBluetoothDiscovery(): void { + } + stopBluetoothDiscovery(): void { + } + startDiscoveryMonitor(_onReceiveEvent: (data: ArrayBuffer, scanResult: Object) => void): void { + } + stopDiscoveryMonitor(): void { + } + createGattClientDevice(_deviceId: string, onGattChangeCallback: (state: number) => void): void { + onGattChangeCallback(-1); + } + disconnectGatt(_deviceId: string): void { + } + onAdvertisingStateChange(_onReceiveEvent: (data: Object) => void): void { + } + offAdvertisingStateChange(): void { + } + startAdvertising(_param: Object, _getAdvIdCallback: (advertising_id: number) => void): void { + } + stopAdvertising(_advertisingId: number): void { + } + startGattDiscovery(_deviceId: string, callback: (serviceInfos: Array) => void): void { + callback([]); + } + createCharacteristic(_serviceId: string, _deviceId: string, callback: (characteristicInfo: Array) => void): void { + callback([]); + } + createDescriptor(_characteristicId: string, _deviceId: string, callback: (descriptorInfo: Array) => void): void { + callback([]); + } + readCharacteristic(_id: string, _deviceId: string, callback: (error_code: number, value: ArrayBuffer) => void): void { + callback(-1, new ArrayBuffer(8)); + } + deprecatedWriteCharacteristic(_id: string, _deviceId: string, _value: ArrayBuffer, callback: (error_code: number) => void): void { + callback(-1); + } + writeCharacteristic(_id: string, _deviceId: string, _writeType: number, _value: ArrayBuffer, callback: (error_code: number) => void): void { + callback(-1); + } + readDescriptor(_id: string, _deviceId: string, callback: (error_code: number, value: ArrayBuffer) => void): void { + callback(-1, new ArrayBuffer(8)); + } + subscribeToNotifications(_id: string, _deviceId: string, callback: (value: ArrayBuffer, error_code: number) => void): void { + callback(new ArrayBuffer(8), -1); + } + unsubscribeFromNotifications(_id: string, _deviceId: string, callback: (error_code: number) => void): void { + callback(-1); + } + writeDescriptor(_id: string, _deviceId: string, _value: ArrayBuffer, callback: (error_code: number) => void): void { + callback(-1); + } +} +BLESTUB +else + echo " (BluetoothLowEnergyAdapter.ets not found, skipping)" +fi + +# MediaAdapter.ets imports @kit.CameraKit but never actually uses the camera +# module (dead import) — strip the import line so the CameraKit reference +# disappears from the compiled HAP. +MEDIA_ADAPTER="${WEB_ENGINE_DIR}/src/main/ets/adapter/MediaAdapter.ets" +if [ -f "${MEDIA_ADAPTER}" ] && grep -q "@kit.CameraKit" "${MEDIA_ADAPTER}" 2>/dev/null; then + echo " Removing unused @kit.CameraKit import from MediaAdapter.ets" + perl -i -ne "print unless /import \{ camera \} from '\@kit\.CameraKit';/" "${MEDIA_ADAPTER}" +fi + +# PermissionManagerAdapter.ets still lists location/microphone/camera/bluetooth +# permission groups in its needPermissions map. Remove them so runtime +# permission requests for these unused features can't be triggered either. +PERMISSION_MGR_ADAPTER="${WEB_ENGINE_DIR}/src/main/ets/adapter/PermissionManagerAdapter.ets" +if [ -f "${PERMISSION_MGR_ADAPTER}" ] && grep -q "'location'" "${PERMISSION_MGR_ADAPTER}" 2>/dev/null; then + echo " Removing location/microphone/camera/bluetooth entries from PermissionManagerAdapter needPermissions map" + perl -i -0pe "s/\\s*\\['(location|microphone|camera|bluetooth)',\\s*\\[[^\\]]*\\]\\],?//g" "${PERMISSION_MGR_ADAPTER}" +fi + +echo " unused geolocation/bluetooth/camera adapters neutralized" + +# --- Patch binding files for type compatibility with stubbed adapters -------- + +echo "==> Patching binding files for stubbed adapter type compatibility ..." + +# The adapter stubs use Object for callback parameters that the original +# SDK types (ble.ScanResult, geoLocationManager.Location, etc.) occupied. +# ArkTS enforces strict contravariance on function parameters, so the +# binding files' callbacks must also use Object — otherwise: +# "Argument of type '(x: ScanResult) => void' is not assignable to +# parameter of type '(x: Object) => void'" +# Patch the binding files to match the stubs, and remove the now-unused +# type-only SDK imports so they don't linger in the compiled HAP. + +BLE_BIND="${WEB_ENGINE_DIR}/src/main/ets/jsbindings/BluetoothLowEnergyAdapterBind.ets" +if [ -f "${BLE_BIND}" ]; then + echo " Patching BluetoothLowEnergyAdapterBind.ets callback types" + # Replace SDK-specific callback parameter types with Object to match stubs + perl -i -pe 's/ble\.ScanResult/Object/g' "${BLE_BIND}" + perl -i -pe 's/ble\.AdvertisingStateChangeInfo/Object/g' "${BLE_BIND}" + perl -i -pe 's/Array/Array/g' "${BLE_BIND}" + perl -i -pe 's/Array/Array/g' "${BLE_BIND}" + perl -i -pe 's/Array/Array/g' "${BLE_BIND}" + # Remove now-unused type-only imports (ble + adapter type exports) + perl -i -ne "print unless /import type ble from '\@ohos\.bluetooth\.ble';/" "${BLE_BIND}" + perl -i -0777 -pe "s/import type \{[^}]*\} from '\.\.\/adapter\/BluetoothLowEnergyAdapter';\n//" "${BLE_BIND}" + echo " BluetoothLowEnergyAdapterBind patched" +else + echo " (BluetoothLowEnergyAdapterBind.ets not found, skipping)" fi -# Fix web_engine module.json5 -WEB_ENGINE_MODULE_JSON="${WEB_ENGINE_DIR}/src/main/module.json5" -if [ -f "${WEB_ENGINE_MODULE_JSON}" ]; then - for perm in ${UNSUPPORTED_PERMS}; do - if grep -q "ohos.permission.${perm}" "${WEB_ENGINE_MODULE_JSON}" 2>/dev/null; then - echo " web_engine: removing unsupported permission ohos.permission.${perm}" - perl -i -0pe "s/\\{[^{}]*ohos\\.permission\\.${perm}[^{}]*\\}[\\s,]*//g" "${WEB_ENGINE_MODULE_JSON}" - fi - done - echo " web_engine permissions cleaned" +GEO_BIND="${WEB_ENGINE_DIR}/src/main/ets/jsbindings/GeolocationAdapterBind.ets" +if [ -f "${GEO_BIND}" ]; then + echo " Patching GeolocationAdapterBind.ets callback types" + perl -i -pe 's/geoLocationManager\.Location/Object/g' "${GEO_BIND}" + perl -i -ne "print unless /import type geoLocationManager from '\@ohos\.geoLocationManager';/" "${GEO_BIND}" + echo " GeolocationAdapterBind patched" else - echo " (web_engine module.json5 not found, skipping)" + echo " (GeolocationAdapterBind.ets not found, skipping)" fi +echo " binding files patched for stub compatibility" + # --- Patch NativeMessagingAdapter.ets for API compatibility ------------------ echo "==> Patching web_engine NativeMessagingAdapter for API compatibility ..." diff --git a/src/app/common/bookmark-zod-schemas.js b/src/app/common/bookmark-zod-schemas.js index b1eb0b7..5a4d1f1 100644 --- a/src/app/common/bookmark-zod-schemas.js +++ b/src/app/common/bookmark-zod-schemas.js @@ -98,6 +98,8 @@ const serialBookmarkSchema = { xany: z.boolean().optional().describe('XANY flow control'), txLineEnding: z.enum(['\r', '\n', '\r\n']).optional().describe('TX line ending appended on Enter: "\\r" (CR, default), "\\n" (LF), "\\r\\n" (CR+LF)'), rxLineEnding: z.enum(['none', 'lf_to_crlf', 'cr_to_crlf']).optional().describe('RX line ending conversion: "none" (pass-through, default), "lf_to_crlf" (LF→CRLF for LF-only devices), "cr_to_crlf" (CR→CRLF for CR-only devices)'), + closeSequence: z.string().optional().describe('Key sequence sent to the serial port when the user clicks "exit gracefully" in the terminal controls (e.g. to cleanly exit GNU screen before disconnecting a Bluetooth serial console). Supports \\n \\t \\r \\\\ and \\xHH hex bytes, default "\\x01ky" (Ctrl+A, k, y - GNU screen kill-window confirm)'), + closeSequenceDelay: z.number().optional().describe('Milliseconds to wait after sending closeSequence before actually closing the port, default 500'), description: z.string().optional().describe('Bookmark description') // runScripts: z.array(runScriptSchema).optional().describe('Run scripts after connected') } diff --git a/src/app/server/dispatch-center.js b/src/app/server/dispatch-center.js index 76e71ac..5608b95 100644 --- a/src/app/server/dispatch-center.js +++ b/src/app/server/dispatch-center.js @@ -6,6 +6,7 @@ const fs = require('./fs') const log = require('../common/log') const { Upgrade } = require('./download-upgrade') +const { transferKeys } = require('./transfer') const fetch = require('./fetch') const sync = require('./sync') const { @@ -59,7 +60,15 @@ const initWs = function (app) { await inst.init() } else if (action === 'upgrade-func') { const { id, func, args } = msg - globalState.getUpgradeInst(id)[func](...args) + const inst = globalState.getUpgradeInst(id) + if (!inst) { + return + } + if (!transferKeys.includes(func) || typeof inst[func] !== 'function') { + log.error('invalid upgrade function:', func) + return + } + inst[func](...args) } }) }) diff --git a/src/app/server/fs.js b/src/app/server/fs.js index 7b73fd4..debe309 100644 --- a/src/app/server/fs.js +++ b/src/app/server/fs.js @@ -6,6 +6,17 @@ const { fsExport: fs } = require('../lib/fs') function handleFs (ws, msg) { const { id, args, func } = msg + // only dispatch to fs helpers defined on the export itself, never to + // anything reached through the prototype chain + if (!Object.prototype.hasOwnProperty.call(fs, func) || typeof fs[func] !== 'function') { + return ws.s({ + id, + error: { + message: 'invalid fs function: ' + func, + stack: '' + } + }) + } fs[func](...args) .then(data => { ws.s({ diff --git a/src/app/server/session-server.js b/src/app/server/session-server.js index 12de59e..b85b977 100644 --- a/src/app/server/session-server.js +++ b/src/app/server/session-server.js @@ -135,12 +135,19 @@ function createSessionServer (type, wsPort, electermHost) { const dataBuffer = [] let sendTimeout = null + // Time of the last actual flush. Lets a chunk arriving after an idle gap + // (keystroke echo, command result) skip the coalescing delay entirely, + // so only chunks arriving inside an active burst (floods) pay the 10ms + // wait. Mirrors the client-side coalescing fast path. + let lastFlushTime = 0 + const flushIntervalMs = 10 const flushBufferedData = () => { if (!dataBuffer.length) { sendTimeout = null return } + lastFlushTime = Date.now() const combinedData = Buffer.concat(dataBuffer.splice(0).map(d => Buffer.isBuffer(d) ? d : Buffer.from(d))) term.writeLog(combinedData) @@ -249,8 +256,24 @@ function createSessionServer (type, wsPort, electermHost) { dataBuffer.push(chunk) + // Idle fast path: if nothing has been flushed within the coalescing + // window, this is the start of a new burst (or a lone interactive + // echo) rather than a continuation of a flood - send it right away + // instead of paying the fixed delay. Only chunks arriving while a + // burst is already in flight (elapsed < flushIntervalMs) get batched. + const elapsed = Date.now() - lastFlushTime + if (elapsed >= flushIntervalMs) { + if (sendTimeout) { + clearTimeout(sendTimeout) + sendTimeout = null + } + flushBufferedData() + return + } + + // If no timeout is pending, schedule a batched send if (!sendTimeout) { - sendTimeout = setTimeout(flushBufferedData, 10) + sendTimeout = setTimeout(flushBufferedData, flushIntervalMs - elapsed) } }) diff --git a/src/app/server/transfer.js b/src/app/server/transfer.js index 7d3ca97..e331377 100644 --- a/src/app/server/transfer.js +++ b/src/app/server/transfer.js @@ -27,6 +27,8 @@ class Transfer { const isd = type === 'download' this.src = isd ? sftp : fs this.dst = isd ? fs : sftp + this.sftp = sftp + this.ownsSftp = false this.sftpId = sftpId this.srcPath = isd ? remotePath : localPath this.dstPath = !isd ? remotePath : localPath @@ -60,6 +62,34 @@ class Transfer { } initTransfer = async (type) => { + // For regular file transfers (not folder transfers, not SSH FS fallback), + // create a separate SFTP channel on the same SSH connection so that + // directory listing and other SFTP operations remain responsive. + // Each transfer gets its own dedicated channel and closes it when done. + if ( + !this.isDirectory && + this.conn && + !this.shouldUseSsh2ScpTransfer() + ) { + try { + const separateSftp = await new Promise((resolve, reject) => { + this.conn.sftp((err, sftp) => { + if (err) { + return reject(err) + } + resolve(sftp) + }) + }) + this.sftp = separateSftp + this.ownsSftp = true + const isd = type === 'download' + this.src = isd ? separateSftp : fs + this.dst = isd ? fs : separateSftp + } catch (e) { + // Fallback to the shared SFTP channel (src/dst already set in constructor) + } + } + if (this.shouldUseFolderTransfer(type)) { return this.ssh2ScpFolderTransfer(type) } @@ -407,6 +437,10 @@ class Transfer { if (this.dst && this.dstHandle && this.dst.close) { this.dst.close(this.dstHandle, log.error) } + // Close the transfer-specific SFTP channel if we created one + if (this.ownsSftp && this.sftp && this.sftp.end) { + this.sftp.end() + } this.src = null this.dst = null this.srcHandle = null