feat: v0.3.0 版本 - SSH终端、SFTP、端口转发功能实现
核心功能: - SSH终端: 密码/密钥认证、XTerm.js终端、多主题配色、IP高亮 - SFTP管理: 双栏文件管理、上传下载、传输队列 - 端口转发: 本地/远程转发、SOCKS5动态代理、规则管理 架构改进: - 拆分大型组件 (SFTPView/TabBar/Sidebar) - 添加统一日志和错误处理 - 修复React严格模式下的终端黑屏问题 UI更新: - 新增端口转发视图 - 更新UI预览文档 - 左侧栏右键菜单支持SFTP快捷打开
This commit is contained in:
1 parent
cf1c054381
commit
484d850abd
105 files changed
+25330
-64
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user