feat: v0.3.0 版本 - SSH终端、SFTP、端口转发功能实现

核心功能:
- SSH终端: 密码/密钥认证、XTerm.js终端、多主题配色、IP高亮
- SFTP管理: 双栏文件管理、上传下载、传输队列
- 端口转发: 本地/远程转发、SOCKS5动态代理、规则管理

架构改进:
- 拆分大型组件 (SFTPView/TabBar/Sidebar)
- 添加统一日志和错误处理
- 修复React严格模式下的终端黑屏问题

UI更新:
- 新增端口转发视图
- 更新UI预览文档
- 左侧栏右键菜单支持SFTP快捷打开
This commit is contained in:
Logan.Li committed 2026-04-06 01:20:47 +08:00
1 parent cf1c054381
commit 484d850abd
105 files changed
+25330 -64

No files matched your search

+366
View File
@@ -0,0 +1,366 @@
import Zmodem from 'zmodem-ts'
import fs from 'fs'
import path from 'path'
import { app } from 'electron'
import { createLogger } from '../utils/logger'
const logger = createLogger('ZmodemService')
// Type definitions for Zmodem sessions (since zmodem-ts doesn't export them properly)
interface ZmodemSendSession {
type: 'send'
set_sender(func: (octets: number[]) => void): void
send_offer(params: { name: string; size: number; mtime?: number }): Promise<any>
on(event: string, handler: (data: any) => void): void
abort(): void
close(): Promise<void>
}
interface ZmodemReceiveSession {
type: 'receive'
set_sender(func: (octets: number[]) => void): void
start(): Promise<any>
on(event: string, handler: (data: any) => void): void
abort(): void
}
interface ZmodemSentry {
consume(octets: number[]): void
}
export interface ZmodemProgress {
percentage: number
transferred: number
total: number
fileName?: string
}
interface ZmodemSessionInfo {
id: string
shellId: string
type: 'send' | 'receive'
session: ZmodemSendSession | ZmodemReceiveSession
fileName?: string
filePath?: string
totalSize?: number
transferred?: number
sentry?: ZmodemSentry
onComplete?: (success: boolean, error?: string) => void
}
export class ZmodemService {
private sessions: Map<string, ZmodemSessionInfo> = new Map()
private shellDataCallbacks: Map<string, (data: Buffer) => void> = new Map()
constructor() {
app.on('before-quit', () => {
this.closeAll()
})
}
/**
* Send file to server via Zmodem (rz)
*
* Flow: Server types "rz" -> sends ZRQINIT/ZRINIT -> we send file
* We use Sentry to detect when server is ready (ZRINIT received)
*/
sendFile(
shellId: string,
fileName: string,
fileData: Buffer,
writeFunc: (data: Uint8Array) => void,
onProgress?: (progress: ZmodemProgress) => void,
onComplete?: (success: boolean, error?: string) => void
): string {
const sessionId = `zmodem-send-${Date.now()}`
const totalSize = fileData.length
// Create session info
const sessionInfo: ZmodemSessionInfo = {
id: sessionId,
shellId,
type: 'send',
session: null as any,
fileName,
totalSize,
transferred: 0
}
this.sessions.set(sessionId, sessionInfo)
// Store completion callback
if (onComplete) {
sessionInfo.onComplete = onComplete
}
// Create Sentry to detect ZRINIT from server
// @ts-ignore - zmodem-ts types are incomplete
const sentry = new Zmodem.Sentry({
to_terminal: (octets: number[]) => {
// Echo to terminal
writeFunc(new Uint8Array(octets))
},
on_detect: (detection: any) => {
const detectedSession = detection.get_session()
if (detectedSession && detectedSession.type === 'send') {
// Server is ready for file transfer (received ZRINIT)
const sendSession = detectedSession as ZmodemSendSession
// Update session reference
sessionInfo.session = sendSession
// Set up sender to write to shell
sendSession.set_sender((octets: number[]) => {
writeFunc(new Uint8Array(octets))
})
// Handle session end
sendSession.on('session_end', () => {
this.sessions.delete(sessionId)
})
sendSession.on('error', (err: Error) => {
logger.error('Zmodem send error:', err)
onComplete?.(false, err.message)
})
// Send the file
this.doSendFile(sessionId, sendSession, fileName, fileData, onProgress, onComplete)
}
},
on_retract: () => {
// Session was retracted
logger.info('Zmodem send session retracted')
this.sessions.delete(sessionId)
onComplete?.(false, 'Session retracted')
},
sender: (octets: number[]) => {
writeFunc(new Uint8Array(octets))
}
})
sessionInfo.sentry = sentry
// Store shell data callback to feed to Sentry
this.shellDataCallbacks.set(shellId, (data: Buffer) => {
sentry.consume(Array.from(data))
})
return sessionId
}
/**
* Internal method to send file after ZRINIT is received
*/
private async doSendFile(
sessionId: string,
session: ZmodemSendSession,
fileName: string,
fileData: Buffer,
onProgress?: (progress: ZmodemProgress) => void,
onComplete?: (success: boolean, error?: string) => void
): Promise<void> {
try {
// Send file offer
const result = await session.send_offer({
name: fileName,
size: fileData.length,
mtime: Date.now()
})
if (result) {
// Transfer completed successfully
onProgress?.({
percentage: 100,
transferred: fileData.length,
total: fileData.length,
fileName
})
onComplete?.(true)
} else {
// Transfer was skipped or rejected
onComplete?.(false, 'Transfer skipped or rejected')
}
} catch (err: any) {
logger.error('Zmodem send error:', err)
onComplete?.(false, err.message)
}
}
/**
* Feed shell data to Zmodem service for protocol detection
* This should be called when shell receives data
*/
feedShellData(shellId: string, data: Buffer): void {
const callback = this.shellDataCallbacks.get(shellId)
if (callback) {
callback(data)
}
// Also check all sessions for matching shellId
for (const [sessionId, session] of this.sessions) {
if (session.shellId === shellId && session.sentry) {
// Feed to sentry for detection
}
}
}
/**
* Receive file from server via Zmodem (sz)
*
* Flow: User clicks "sz" in UI -> we open receive session
* Server sends ZRQINIT -> we accept -> server sends file
*/
receiveFile(
shellId: string,
filePath: string,
writeFunc: (data: Uint8Array) => void,
onProgress?: (progress: ZmodemProgress) => void,
onComplete?: (success: boolean, error?: string) => void
): string {
const sessionId = `zmodem-receive-${Date.now()}`
const fileName = path.basename(filePath)
// Create receive session
// @ts-ignore - zmodem-ts types are incomplete
const session: ZmodemReceiveSession = new Zmodem.ReceiveSession()
const sessionInfo: ZmodemSessionInfo = {
id: sessionId,
shellId,
type: 'receive',
session,
filePath,
fileName,
totalSize: 0,
transferred: 0
}
this.sessions.set(sessionId, sessionInfo)
if (onComplete) {
sessionInfo.onComplete = onComplete
}
// Set up sender to write responses to shell
session.set_sender((octets: number[]) => {
writeFunc(new Uint8Array(octets))
})
// Handle file offer
session.on('offer', (offer: any) => {
const details = offer.getDetails()
logger.info('Zmodem offer received:', details)
sessionInfo.fileName = details.name || fileName
sessionInfo.totalSize = details.size || 0
// Accept the offer
offer.accept({
on_input: 'spool_array'
}).then((transfer: any) => {
// Handle incoming data
transfer.on('input', (data: Uint8Array) => {
sessionInfo.transferred = (sessionInfo.transferred || 0) + data.length
// Write to file
try {
fs.writeFileSync(filePath, data, { flag: 'a' })
} catch (err: any) {
logger.error('Error writing Zmodem file:', err)
}
const total = sessionInfo.totalSize || sessionInfo.transferred || 0
onProgress?.({
percentage: total > 0
? Math.round((sessionInfo.transferred! / total) * 100)
: 0,
transferred: sessionInfo.transferred!,
total: total,
fileName: sessionInfo.fileName
})
})
transfer.on('complete', () => {
logger.info('Zmodem receive complete')
onProgress?.({
percentage: 100,
transferred: sessionInfo.totalSize || sessionInfo.transferred || 0,
total: sessionInfo.totalSize || sessionInfo.transferred || 0,
fileName: sessionInfo.fileName
})
onComplete?.(true)
})
}).catch((err: Error) => {
logger.error('Zmodem accept error:', err)
onComplete?.(false, err.message)
})
})
// Handle session end
session.on('session_end', () => {
this.sessions.delete(sessionId)
})
session.on('error', (err: Error) => {
logger.error('Zmodem receive error:', err)
onComplete?.(false, err.message)
})
// Start receiving - this sends ZRINIT to signal we're ready
session.start().catch((err: Error) => {
logger.error('Zmodem start error:', err)
this.sessions.delete(sessionId)
onComplete?.(false, err.message)
})
return sessionId
}
/**
* Cancel a Zmodem session
*/
cancelSession(sessionId: string): void {
const session = this.sessions.get(sessionId)
if (session) {
try {
const s = session.session as any
s.abort?.()
s.close?.()
} catch (err: any) {
logger.error('Error canceling Zmodem session:', err)
}
// Clean up shell data callback
this.shellDataCallbacks.delete(session.shellId)
this.sessions.delete(sessionId)
}
}
/**
* Close all active sessions
*/
closeAll(): void {
for (const sessionId of this.sessions.keys()) {
this.cancelSession(sessionId)
}
}
/**
* Get active session IDs
*/
getActiveSessions(): string[] {
return Array.from(this.sessions.keys())
}
/**
* Get session by ID
*/
getSession(sessionId: string): ZmodemSessionInfo | undefined {
return this.sessions.get(sessionId)
}
}
// Singleton instance
export const zmodemService = new ZmodemService()