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,24 @@
|
||||
/**
|
||||
* App IPC Handlers
|
||||
* 处理应用级别信息
|
||||
*/
|
||||
|
||||
import { ipcMain, IpcMainInvokeEvent, app } from 'electron'
|
||||
|
||||
// IPC channel names
|
||||
export const APP_CHANNELS = {
|
||||
GET_VERSION: 'app:getVersion',
|
||||
GET_DOWNLOADS_PATH: 'app:getDownloadsPath',
|
||||
} as const
|
||||
|
||||
export function registerAppIPCHandlers(): void {
|
||||
// Get app version
|
||||
ipcMain.handle(APP_CHANNELS.GET_VERSION, () => {
|
||||
return app.getVersion()
|
||||
})
|
||||
|
||||
// Get downloads directory path
|
||||
ipcMain.handle(APP_CHANNELS.GET_DOWNLOADS_PATH, () => {
|
||||
return app.getPath('downloads')
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Dialog IPC Handlers
|
||||
* 处理系统对话框
|
||||
*/
|
||||
|
||||
import { ipcMain, IpcMainInvokeEvent, dialog } from 'electron'
|
||||
|
||||
// IPC channel names
|
||||
export const DIALOG_CHANNELS = {
|
||||
SHOW_SAVE: 'dialog:showSaveDialog',
|
||||
SHOW_OPEN: 'dialog:showOpenDialog',
|
||||
} as const
|
||||
|
||||
export interface DialogIPCOptions {
|
||||
mainWindowGetter: () => Electron.BrowserWindow | null
|
||||
}
|
||||
|
||||
export function registerDialogIPCHandlers(options: DialogIPCOptions): void {
|
||||
const { mainWindowGetter } = options
|
||||
|
||||
// Show save dialog
|
||||
ipcMain.handle(DIALOG_CHANNELS.SHOW_SAVE, async (_event: IpcMainInvokeEvent, options: Electron.SaveDialogOptions) => {
|
||||
const mainWindow = mainWindowGetter()
|
||||
if (!mainWindow) {
|
||||
return { canceled: true }
|
||||
}
|
||||
return dialog.showSaveDialog(mainWindow, options)
|
||||
})
|
||||
|
||||
// Show open dialog
|
||||
ipcMain.handle(DIALOG_CHANNELS.SHOW_OPEN, async (_event: IpcMainInvokeEvent, options: Electron.OpenDialogOptions) => {
|
||||
const mainWindow = mainWindowGetter()
|
||||
if (!mainWindow) {
|
||||
return { canceled: true }
|
||||
}
|
||||
return dialog.showOpenDialog(mainWindow, options)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* File IPC Handlers
|
||||
* 处理本地文件操作和远程文件读写
|
||||
*/
|
||||
|
||||
import { ipcMain, IpcMainInvokeEvent, app } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
// IPC channel names
|
||||
export const FILE_CHANNELS = {
|
||||
// Remote file operations (for zmodem)
|
||||
READ: 'file:read',
|
||||
WRITE: 'file:write',
|
||||
// Local file system operations
|
||||
LOCAL_GET_HOME: 'local:getHome',
|
||||
LOCAL_READ_DIR: 'local:readDir',
|
||||
LOCAL_GET_STATS: 'local:getStats',
|
||||
LOCAL_DELETE: 'local:delete',
|
||||
LOCAL_RENAME: 'local:rename',
|
||||
LOCAL_MKDIR: 'local:mkdir',
|
||||
} as const
|
||||
|
||||
export function registerFileIPCHandlers(): void {
|
||||
// Read file (for rz)
|
||||
ipcMain.handle(FILE_CHANNELS.READ, async (_event: IpcMainInvokeEvent, filePath: string) => {
|
||||
try {
|
||||
const data = fs.readFileSync(filePath)
|
||||
return { success: true, data: data.toString('base64') }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Write file (for sz)
|
||||
ipcMain.handle(FILE_CHANNELS.WRITE, async (_event: IpcMainInvokeEvent, filePath: string, data: string) => {
|
||||
try {
|
||||
const buffer = Buffer.from(data, 'base64')
|
||||
fs.writeFileSync(filePath, buffer)
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Get user home directory
|
||||
ipcMain.handle(FILE_CHANNELS.LOCAL_GET_HOME, async () => {
|
||||
try {
|
||||
const homePath = app.getPath('home')
|
||||
return { success: true, path: homePath }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Read directory contents
|
||||
ipcMain.handle(FILE_CHANNELS.LOCAL_READ_DIR, async (_event: IpcMainInvokeEvent, dirPath: string) => {
|
||||
try {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
||||
const files = entries.map((entry) => {
|
||||
const fullPath = path.join(dirPath, entry.name)
|
||||
let stats: fs.Stats | null = null
|
||||
try {
|
||||
stats = fs.lstatSync(fullPath)
|
||||
} catch {
|
||||
// Skip files we can't stat
|
||||
}
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
path: fullPath,
|
||||
type: entry.isDirectory() ? 'directory' : entry.isSymbolicLink() ? 'symlink' : 'file',
|
||||
size: stats?.size || 0,
|
||||
modified: stats?.mtime ? Math.floor(stats.mtime.getTime() / 1000) : 0,
|
||||
permissions: formatPermissions(stats?.mode || 0),
|
||||
}
|
||||
})
|
||||
|
||||
// Sort: directories first, then by name
|
||||
files.sort((a, b) => {
|
||||
if (a.type !== 'file' && b.type === 'file') return -1
|
||||
if (a.type === 'file' && b.type !== 'file') return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return { success: true, files }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Get file stats
|
||||
ipcMain.handle(FILE_CHANNELS.LOCAL_GET_STATS, async (_event: IpcMainInvokeEvent, filePath: string) => {
|
||||
try {
|
||||
const stats = fs.lstatSync(filePath)
|
||||
return {
|
||||
success: true,
|
||||
stats: {
|
||||
name: path.basename(filePath),
|
||||
path: filePath,
|
||||
type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file',
|
||||
size: stats.size,
|
||||
modified: Math.floor(stats.mtime.getTime() / 1000),
|
||||
permissions: formatPermissions(stats.mode),
|
||||
},
|
||||
}
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Delete file or directory
|
||||
ipcMain.handle(FILE_CHANNELS.LOCAL_DELETE, async (_event: IpcMainInvokeEvent, filePath: string) => {
|
||||
try {
|
||||
const stats = fs.lstatSync(filePath)
|
||||
if (stats.isDirectory()) {
|
||||
fs.rmSync(filePath, { recursive: true, force: true })
|
||||
} else {
|
||||
fs.unlinkSync(filePath)
|
||||
}
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Rename file or directory
|
||||
ipcMain.handle(FILE_CHANNELS.LOCAL_RENAME, async (_event: IpcMainInvokeEvent, oldPath: string, newPath: string) => {
|
||||
try {
|
||||
fs.renameSync(oldPath, newPath)
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Create directory
|
||||
ipcMain.handle(FILE_CHANNELS.LOCAL_MKDIR, async (_event: IpcMainInvokeEvent, dirPath: string) => {
|
||||
try {
|
||||
fs.mkdirSync(dirPath, { recursive: true })
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to format Unix permissions
|
||||
function formatPermissions(mode: number): string {
|
||||
const perms: string[] = []
|
||||
const types = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx']
|
||||
|
||||
// Owner
|
||||
perms.push(types[(mode >> 6) & 7])
|
||||
// Group
|
||||
perms.push(types[(mode >> 3) & 7])
|
||||
// Other
|
||||
perms.push(types[mode & 7])
|
||||
|
||||
return perms.join('')
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* IPC Handler Registration
|
||||
* 统一注册所有 IPC 处理器
|
||||
*/
|
||||
|
||||
import type { SSHService } from '../services/ssh-service'
|
||||
import type { DatabaseService } from '../services/database-service'
|
||||
import type { ZmodemService } from '../services/zmodem-service'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { createLogger } from '../utils/logger'
|
||||
|
||||
const logger = createLogger('IPC')
|
||||
|
||||
import { registerSSHIPCHandlers } from './ssh.ipc'
|
||||
import { registerSFTPIPCHandlers } from './sftp.ipc'
|
||||
import { registerSessionIPCHandlers } from './session.ipc'
|
||||
import { registerSettingsIPCHandlers } from './settings.ipc'
|
||||
import { registerDialogIPCHandlers } from './dialog.ipc'
|
||||
import { registerFileIPCHandlers } from './file.ipc'
|
||||
import { registerZmodemIPCHandlers } from './zmodem.ipc'
|
||||
import { registerAppIPCHandlers } from './app.ipc'
|
||||
|
||||
export interface IPCOptions {
|
||||
sshService: SSHService
|
||||
databaseService: DatabaseService
|
||||
zmodemService: ZmodemService
|
||||
mainWindowGetter: () => BrowserWindow | null
|
||||
}
|
||||
|
||||
export function registerAllIPCHandlers(options: IPCOptions): void {
|
||||
const { sshService, databaseService, zmodemService, mainWindowGetter } = options
|
||||
|
||||
// SSH handlers
|
||||
registerSSHIPCHandlers({ sshService, zmodemService, mainWindowGetter })
|
||||
|
||||
// SFTP handlers
|
||||
registerSFTPIPCHandlers({ sshService, mainWindowGetter })
|
||||
|
||||
// Session handlers
|
||||
registerSessionIPCHandlers({ dbService: databaseService })
|
||||
|
||||
// Settings handlers
|
||||
registerSettingsIPCHandlers({ dbService: databaseService })
|
||||
|
||||
// Dialog handlers
|
||||
registerDialogIPCHandlers({ mainWindowGetter })
|
||||
|
||||
// File handlers
|
||||
registerFileIPCHandlers()
|
||||
|
||||
// Zmodem handlers
|
||||
registerZmodemIPCHandlers({ sshService, zmodemService, mainWindowGetter })
|
||||
|
||||
// App handlers
|
||||
registerAppIPCHandlers()
|
||||
|
||||
logger.info('[IPC] All handlers registered')
|
||||
}
|
||||
|
||||
// Re-export channel constants for external use
|
||||
export { SSH_CHANNELS } from './ssh.ipc'
|
||||
export { SFTP_CHANNELS } from './sftp.ipc'
|
||||
export { SESSION_CHANNELS } from './session.ipc'
|
||||
export { SETTINGS_CHANNELS } from './settings.ipc'
|
||||
export { DIALOG_CHANNELS } from './dialog.ipc'
|
||||
export { FILE_CHANNELS } from './file.ipc'
|
||||
export { ZMODEM_CHANNELS } from './zmodem.ipc'
|
||||
export { APP_CHANNELS } from './app.ipc'
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Session IPC Handlers
|
||||
* 处理会话的 CRUD 操作
|
||||
*/
|
||||
|
||||
import { ipcMain, IpcMainInvokeEvent } from 'electron'
|
||||
import type { DatabaseService } from '../services/database-service'
|
||||
|
||||
// IPC channel names
|
||||
export const SESSION_CHANNELS = {
|
||||
CREATE: 'session:create',
|
||||
GET_ALL: 'session:getAll',
|
||||
GET_BY_ID: 'session:getById',
|
||||
UPDATE: 'session:update',
|
||||
DELETE: 'session:delete',
|
||||
} as const
|
||||
|
||||
export interface SessionIPCOptions {
|
||||
dbService: DatabaseService
|
||||
}
|
||||
|
||||
export function registerSessionIPCHandlers(options: SessionIPCOptions): void {
|
||||
const { dbService } = options
|
||||
|
||||
// Create session
|
||||
ipcMain.handle(SESSION_CHANNELS.CREATE, async (_event: IpcMainInvokeEvent, session) => {
|
||||
try {
|
||||
const result = dbService.createSession(session)
|
||||
return { success: true, session: result }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Get all sessions
|
||||
ipcMain.handle(SESSION_CHANNELS.GET_ALL, async () => {
|
||||
try {
|
||||
const sessions = dbService.getAllSessions()
|
||||
return { success: true, sessions }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Get session by ID
|
||||
ipcMain.handle(SESSION_CHANNELS.GET_BY_ID, async (_event: IpcMainInvokeEvent, id: string) => {
|
||||
try {
|
||||
const session = dbService.getSessionById(id)
|
||||
return { success: true, session }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Update session
|
||||
ipcMain.handle(SESSION_CHANNELS.UPDATE, async (_event: IpcMainInvokeEvent, id: string, updates: any) => {
|
||||
try {
|
||||
const result = dbService.updateSession(id, updates)
|
||||
return { success: true, session: result }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Delete session
|
||||
ipcMain.handle(SESSION_CHANNELS.DELETE, async (_event: IpcMainInvokeEvent, id: string) => {
|
||||
try {
|
||||
dbService.deleteSession(id)
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Settings IPC Handlers
|
||||
* 处理应用设置读写
|
||||
*/
|
||||
|
||||
import { ipcMain, IpcMainInvokeEvent } from 'electron'
|
||||
import type { DatabaseService } from '../services/database-service'
|
||||
|
||||
// IPC channel names
|
||||
export const SETTINGS_CHANNELS = {
|
||||
GET: 'settings:get',
|
||||
SET: 'settings:set',
|
||||
} as const
|
||||
|
||||
export interface SettingsIPCOptions {
|
||||
dbService: DatabaseService
|
||||
}
|
||||
|
||||
export function registerSettingsIPCHandlers(options: SettingsIPCOptions): void {
|
||||
const { dbService } = options
|
||||
|
||||
// Get setting
|
||||
ipcMain.handle(SETTINGS_CHANNELS.GET, async (_event: IpcMainInvokeEvent, key: string) => {
|
||||
try {
|
||||
const value = dbService.getSetting(key)
|
||||
return { success: true, value }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Set setting
|
||||
ipcMain.handle(SETTINGS_CHANNELS.SET, async (_event: IpcMainInvokeEvent, key: string, value: unknown) => {
|
||||
try {
|
||||
dbService.setSetting(key, value)
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* SFTP IPC Handlers
|
||||
* 处理 SFTP 连接和文件操作相关 IPC
|
||||
*/
|
||||
|
||||
import { ipcMain, IpcMainInvokeEvent } from 'electron'
|
||||
import type { SSHService } from '../services/ssh-service'
|
||||
|
||||
// IPC channel names
|
||||
export const SFTP_CHANNELS = {
|
||||
CONNECT: 'sftp:connect',
|
||||
LIST_DIRECTORY: 'sftp:listDirectory',
|
||||
UPLOAD: 'sftp:upload',
|
||||
DOWNLOAD: 'sftp:download',
|
||||
RENAME: 'sftp:rename',
|
||||
DELETE: 'sftp:delete',
|
||||
MKDIR: 'sftp:mkdir',
|
||||
PROGRESS: 'sftp:progress',
|
||||
} as const
|
||||
|
||||
export interface SFTPIPCOptions {
|
||||
sshService: SSHService
|
||||
mainWindowGetter: () => Electron.BrowserWindow | null
|
||||
}
|
||||
|
||||
// ===== 彩色调试日志 =====
|
||||
const DEBUG = true
|
||||
function logHeader(msg: string) {
|
||||
console.log(`%c${msg}`, 'background: #16a085; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;')
|
||||
}
|
||||
function logInfo(label: string, value: any) {
|
||||
console.log(`%c${label}:%c ${value}`, 'background: #2c3e50; color: #fff; padding: 2px 6px; border-radius: 3px;', 'color: #1abc9c; font-weight: bold;')
|
||||
}
|
||||
function logSuccess(msg: string) {
|
||||
console.log(`%c✓ ${msg}`, 'color: #27ae60; font-weight: bold;')
|
||||
}
|
||||
function logError(msg: string) {
|
||||
console.log(`%c✗ ${msg}`, 'color: #e74c3c; font-weight: bold;')
|
||||
}
|
||||
|
||||
export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
|
||||
const { sshService, mainWindowGetter } = options
|
||||
|
||||
// SFTP connect
|
||||
ipcMain.handle(SFTP_CHANNELS.CONNECT, async (_event: IpcMainInvokeEvent, connectionId: string) => {
|
||||
logHeader('📁 SFTP IPC CONNECT')
|
||||
logInfo('connectionId', connectionId)
|
||||
|
||||
try {
|
||||
const sftp = await sshService.createSFTP(connectionId)
|
||||
logSuccess(`SFTP connected: ${sftp.id}`)
|
||||
return { success: true, sftpId: sftp.id }
|
||||
} catch (error: any) {
|
||||
logError(`SFTP connect failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// SFTP list directory
|
||||
ipcMain.handle(SFTP_CHANNELS.LIST_DIRECTORY, async (_event: IpcMainInvokeEvent, sftpId: string, dirPath: string) => {
|
||||
logHeader('📁 SFTP IPC LIST')
|
||||
logInfo('sftpId', sftpId)
|
||||
logInfo('dirPath', dirPath)
|
||||
|
||||
try {
|
||||
const files = await sshService.sftpListDirectory(sftpId, dirPath)
|
||||
logSuccess(`Listed ${files.length} items`)
|
||||
return { success: true, files }
|
||||
} catch (error: any) {
|
||||
logError(`List failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// SFTP upload file
|
||||
ipcMain.handle(SFTP_CHANNELS.UPLOAD, async (
|
||||
_event: IpcMainInvokeEvent,
|
||||
sftpId: string,
|
||||
localPath: string,
|
||||
remotePath: string
|
||||
) => {
|
||||
logHeader('📤 SFTP IPC UPLOAD')
|
||||
logInfo('sftpId', sftpId)
|
||||
logInfo('localPath', localPath)
|
||||
logInfo('remotePath', remotePath)
|
||||
|
||||
try {
|
||||
const mainWindow = mainWindowGetter()
|
||||
await sshService.sftpUpload(sftpId, localPath, remotePath, (transferred, total) => {
|
||||
mainWindow?.webContents.send(SFTP_CHANNELS.PROGRESS, sftpId, {
|
||||
transferred,
|
||||
total,
|
||||
percentage: Math.round((transferred / total) * 100),
|
||||
})
|
||||
})
|
||||
logSuccess('Upload complete')
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
logError(`Upload failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// SFTP download file
|
||||
ipcMain.handle(SFTP_CHANNELS.DOWNLOAD, async (
|
||||
_event: IpcMainInvokeEvent,
|
||||
sftpId: string,
|
||||
remotePath: string,
|
||||
localPath: string
|
||||
) => {
|
||||
logHeader('📥 SFTP IPC DOWNLOAD')
|
||||
logInfo('sftpId', sftpId)
|
||||
logInfo('remotePath', remotePath)
|
||||
logInfo('localPath', localPath)
|
||||
|
||||
try {
|
||||
const mainWindow = mainWindowGetter()
|
||||
await sshService.sftpDownload(sftpId, remotePath, localPath, (transferred, total) => {
|
||||
mainWindow?.webContents.send(SFTP_CHANNELS.PROGRESS, sftpId, {
|
||||
transferred,
|
||||
total,
|
||||
percentage: Math.round((transferred / total) * 100),
|
||||
})
|
||||
})
|
||||
logSuccess('Download complete')
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
logError(`Download failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// SFTP rename
|
||||
ipcMain.handle(SFTP_CHANNELS.RENAME, async (
|
||||
_event: IpcMainInvokeEvent,
|
||||
sftpId: string,
|
||||
oldPath: string,
|
||||
newPath: string
|
||||
) => {
|
||||
logHeader('📁 SFTP IPC RENAME')
|
||||
logInfo('sftpId', sftpId)
|
||||
logInfo('oldPath', oldPath)
|
||||
logInfo('newPath', newPath)
|
||||
|
||||
try {
|
||||
await sshService.sftpRename(sftpId, oldPath, newPath)
|
||||
logSuccess('Rename complete')
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
logError(`Rename failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// SFTP delete
|
||||
ipcMain.handle(SFTP_CHANNELS.DELETE, async (
|
||||
_event: IpcMainInvokeEvent,
|
||||
sftpId: string,
|
||||
filePath: string,
|
||||
isDirectory: boolean
|
||||
) => {
|
||||
logHeader('📁 SFTP IPC DELETE')
|
||||
logInfo('sftpId', sftpId)
|
||||
logInfo('filePath', filePath)
|
||||
logInfo('isDirectory', isDirectory)
|
||||
|
||||
try {
|
||||
await sshService.sftpDelete(sftpId, filePath, isDirectory)
|
||||
logSuccess('Delete complete')
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
logError(`Delete failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// SFTP mkdir
|
||||
ipcMain.handle(SFTP_CHANNELS.MKDIR, async (
|
||||
_event: IpcMainInvokeEvent,
|
||||
sftpId: string,
|
||||
dirPath: string
|
||||
) => {
|
||||
logHeader('📁 SFTP IPC MKDIR')
|
||||
logInfo('sftpId', sftpId)
|
||||
logInfo('dirPath', dirPath)
|
||||
|
||||
try {
|
||||
await sshService.sftpMkdir(sftpId, dirPath)
|
||||
logSuccess('Mkdir complete')
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
logError(`Mkdir failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* SSH IPC Handlers
|
||||
* 处理 SSH 连接、Shell、会话、端口转发相关 IPC
|
||||
*/
|
||||
|
||||
import { ipcMain, IpcMainInvokeEvent } from 'electron'
|
||||
import type { SSHService } from '../services/ssh-service'
|
||||
import type { ZmodemService } from '../services/zmodem-service'
|
||||
import { createLogger } from '../utils/logger'
|
||||
|
||||
const logger = createLogger('SSH_IPC')
|
||||
|
||||
// ===== 彩色调试日志 =====
|
||||
const DEBUG = true
|
||||
function debug(...args: any[]) {
|
||||
if (DEBUG) console.log('[SSH_IPC]', ...args)
|
||||
}
|
||||
function logHeader(msg: string) {
|
||||
console.log(`%c${msg}`, 'background: #9b59b6; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;')
|
||||
}
|
||||
function logInfo(label: string, value: any) {
|
||||
console.log(`%c${label}:%c ${value}`, 'background: #2c3e50; color: #fff; padding: 2px 6px; border-radius: 3px;', 'color: #3498db; font-weight: bold;')
|
||||
}
|
||||
function logSuccess(msg: string) {
|
||||
console.log(`%c✓ ${msg}`, 'color: #27ae60; font-weight: bold;')
|
||||
}
|
||||
function logError(msg: string) {
|
||||
console.log(`%c✗ ${msg}`, 'color: #e74c3c; font-weight: bold;')
|
||||
}
|
||||
|
||||
// IPC channel names
|
||||
export const SSH_CHANNELS = {
|
||||
CONNECT: 'ssh:connect',
|
||||
DISCONNECT: 'ssh:disconnect',
|
||||
EXEC: 'ssh:exec',
|
||||
CREATE_SHELL: 'ssh:createShell',
|
||||
SHELL_WRITE: 'ssh:shellWrite',
|
||||
SHELL_RESIZE: 'ssh:shellResize',
|
||||
FORWARD_LOCAL: 'ssh:forwardLocal',
|
||||
FORWARD_REMOTE: 'ssh:forwardRemote',
|
||||
FORWARD_DYNAMIC: 'ssh:forwardDynamic',
|
||||
SHELL_DATA: 'ssh:shellData',
|
||||
} as const
|
||||
|
||||
export interface SSHIPCOptions {
|
||||
sshService: SSHService
|
||||
zmodemService: ZmodemService
|
||||
mainWindowGetter: () => Electron.BrowserWindow | null
|
||||
}
|
||||
|
||||
export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
|
||||
const { sshService, zmodemService, mainWindowGetter } = options
|
||||
|
||||
// SSH connect
|
||||
ipcMain.handle(SSH_CHANNELS.CONNECT, async (_event: IpcMainInvokeEvent, config) => {
|
||||
try {
|
||||
const connection = await sshService.connect(config)
|
||||
return { success: true, connectionId: connection.id }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// SSH disconnect
|
||||
ipcMain.handle(SSH_CHANNELS.DISCONNECT, async (_event: IpcMainInvokeEvent, connectionId: string) => {
|
||||
try {
|
||||
await sshService.disconnect(connectionId)
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// SSH exec command
|
||||
ipcMain.handle(SSH_CHANNELS.EXEC, async (_event: IpcMainInvokeEvent, connectionId: string, command: string) => {
|
||||
try {
|
||||
const result = await sshService.exec(connectionId, command)
|
||||
return { success: true, result }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// SSH create shell
|
||||
ipcMain.handle(SSH_CHANNELS.CREATE_SHELL, async (event: IpcMainInvokeEvent, connectionId: string, options?: { cols?: number; rows?: number }, shellId?: string) => {
|
||||
console.log('=== SSH_IPC CREATE_SHELL ===')
|
||||
console.log(' connectionId:', connectionId)
|
||||
console.log(' options:', options)
|
||||
console.log(' preferredShellId:', shellId)
|
||||
|
||||
try {
|
||||
const preferredShellId = shellId
|
||||
logger.info('[SSH_IPC] Creating shell for connection:', connectionId, 'preferredId:', preferredShellId)
|
||||
debug('>>> Calling sshService.createShell...')
|
||||
|
||||
const shell = await sshService.createShell(connectionId, options || {}, (shellId, data) => {
|
||||
// 检查 sender 是否已被销毁
|
||||
if (event.sender.isDestroyed()) {
|
||||
console.log('=== SHELL DATA CALLBACK - SKIPPED (sender destroyed) ===')
|
||||
return
|
||||
}
|
||||
|
||||
const text = data.toString('utf-8')
|
||||
console.log('=== SHELL DATA CALLBACK ===')
|
||||
console.log(' shellId:', shellId)
|
||||
console.log(' data length:', data.length)
|
||||
console.log(' data preview:', text.substring(0, 100).replace(/\n/g, '\\n'))
|
||||
|
||||
// 打印所有收到的数据前100字符用于调试
|
||||
if (text.length > 0) {
|
||||
logger.info(`[SSH_IPC] Shell data from ${shellId}:`, text.substring(0, 100).replace(/\n/g, '\\n'))
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('>>> Sending SHELL_DATA to renderer...')
|
||||
event.sender.send(SSH_CHANNELS.SHELL_DATA, connectionId, shellId, data)
|
||||
console.log('>>> SHELL_DATA sent successfully')
|
||||
} catch (err) {
|
||||
console.error('>>> FAILED to send SHELL_DATA:', err)
|
||||
}
|
||||
|
||||
zmodemService.feedShellData(shellId, data)
|
||||
}, preferredShellId)
|
||||
|
||||
logger.info('[SSH_IPC] Shell created:', shell.id)
|
||||
debug('>>> Shell created successfully:', shell.id)
|
||||
return { success: true, shellId: shell.id }
|
||||
} catch (error: any) {
|
||||
logger.error('[SSH_IPC] Shell creation failed:', error)
|
||||
debug('>>> Shell creation FAILED:', error.message)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// SSH shell write
|
||||
ipcMain.handle(SSH_CHANNELS.SHELL_WRITE, async (_event: IpcMainInvokeEvent, shellId: string, data: string) => {
|
||||
try {
|
||||
sshService.shellWrite(shellId, data)
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// SSH shell resize
|
||||
ipcMain.handle(SSH_CHANNELS.SHELL_RESIZE, async (_event: IpcMainInvokeEvent, shellId: string, cols: number, rows: number) => {
|
||||
try {
|
||||
sshService.shellResize(shellId, cols, rows)
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// SSH local port forward
|
||||
ipcMain.handle(SSH_CHANNELS.FORWARD_LOCAL, async (
|
||||
_event: IpcMainInvokeEvent,
|
||||
connectionId: string,
|
||||
localPort: number,
|
||||
remoteHost: string,
|
||||
remotePort: number
|
||||
) => {
|
||||
logHeader('🔀 IPC FORWARD_LOCAL')
|
||||
logInfo('connectionId', connectionId)
|
||||
logInfo('localPort', localPort)
|
||||
logInfo('remoteHost', remoteHost)
|
||||
logInfo('remotePort', remotePort)
|
||||
|
||||
try {
|
||||
const result = await sshService.forwardLocal(connectionId, localPort, remoteHost, remotePort)
|
||||
logSuccess(`Local forward bound to port ${result.boundPort}`)
|
||||
return { success: true, boundPort: result.boundPort }
|
||||
} catch (error: any) {
|
||||
logError(`Local forward failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// SSH remote port forward
|
||||
ipcMain.handle(SSH_CHANNELS.FORWARD_REMOTE, async (
|
||||
_event: IpcMainInvokeEvent,
|
||||
connectionId: string,
|
||||
remotePort: number,
|
||||
localHost: string,
|
||||
localPort: number
|
||||
) => {
|
||||
logHeader('🔀 IPC FORWARD_REMOTE')
|
||||
logInfo('connectionId', connectionId)
|
||||
logInfo('remotePort', remotePort)
|
||||
logInfo('localHost', localHost)
|
||||
logInfo('localPort', localPort)
|
||||
|
||||
try {
|
||||
const result = await sshService.forwardRemote(connectionId, remotePort, localHost, localPort)
|
||||
logSuccess('Remote forward established')
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
logError(`Remote forward failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// SSH dynamic port forward (SOCKS5)
|
||||
ipcMain.handle(SSH_CHANNELS.FORWARD_DYNAMIC, async (
|
||||
_event: IpcMainInvokeEvent,
|
||||
connectionId: string,
|
||||
localPort: number
|
||||
) => {
|
||||
logHeader('🧦 IPC FORWARD_DYNAMIC (SOCKS5)')
|
||||
logInfo('connectionId', connectionId)
|
||||
logInfo('localPort', localPort)
|
||||
|
||||
try {
|
||||
const result = await sshService.forwardDynamic(connectionId, localPort)
|
||||
logSuccess(`SOCKS5 proxy started on port ${result.boundPort}`)
|
||||
return { success: true, boundPort: result.boundPort }
|
||||
} catch (error: any) {
|
||||
logError(`Dynamic forward failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Zmodem IPC Handlers
|
||||
* 处理 rz/sz 文件传输
|
||||
*/
|
||||
|
||||
import { ipcMain, IpcMainInvokeEvent, app, dialog } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import type { SSHService } from '../services/ssh-service'
|
||||
import type { ZmodemService } from '../services/zmodem-service'
|
||||
import { createLogger } from '../utils/logger'
|
||||
|
||||
const logger = createLogger('Zmodem_IPC')
|
||||
|
||||
// IPC channel names
|
||||
export const ZMODEM_CHANNELS = {
|
||||
SEND: 'zmodem:send',
|
||||
RECEIVE: 'zmodem:receive',
|
||||
CANCEL: 'zmodem:cancel',
|
||||
PROGRESS: 'zmodem:progress',
|
||||
COMPLETE: 'zmodem:complete',
|
||||
} as const
|
||||
|
||||
export interface ZmodemIPCOptions {
|
||||
sshService: SSHService
|
||||
zmodemService: ZmodemService
|
||||
mainWindowGetter: () => Electron.BrowserWindow | null
|
||||
}
|
||||
|
||||
export function registerZmodemIPCHandlers(options: ZmodemIPCOptions): void {
|
||||
const { sshService, zmodemService, mainWindowGetter } = options
|
||||
|
||||
// Zmodem send (rz) - send file to server
|
||||
ipcMain.handle(ZMODEM_CHANNELS.SEND, async (_event: IpcMainInvokeEvent, shellId: string) => {
|
||||
try {
|
||||
const mainWindow = mainWindowGetter()
|
||||
if (!mainWindow) {
|
||||
return { success: false, error: 'Window not available' }
|
||||
}
|
||||
|
||||
// Open file dialog to select file
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
title: '选择要发送的文件',
|
||||
properties: ['openFile'],
|
||||
})
|
||||
|
||||
if (result.canceled || !result.filePaths.length) {
|
||||
return { success: false, canceled: true }
|
||||
}
|
||||
|
||||
const filePath = result.filePaths[0]
|
||||
const fileName = path.basename(filePath)
|
||||
|
||||
// Read file
|
||||
const fileData = fs.readFileSync(filePath)
|
||||
|
||||
// Get shell write function
|
||||
const shell = sshService.getShell(shellId)
|
||||
if (!shell) {
|
||||
return { success: false, error: 'Shell not found' }
|
||||
}
|
||||
|
||||
// Send file via Zmodem protocol
|
||||
const sessionId = zmodemService.sendFile(
|
||||
shellId,
|
||||
fileName,
|
||||
fileData,
|
||||
(data) => {
|
||||
shell.channel.write(data)
|
||||
},
|
||||
(progress) => {
|
||||
mainWindow.webContents.send(ZMODEM_CHANNELS.PROGRESS, sessionId, progress)
|
||||
},
|
||||
(success, error) => {
|
||||
mainWindow.webContents.send(ZMODEM_CHANNELS.COMPLETE, sessionId, { success, error })
|
||||
}
|
||||
)
|
||||
|
||||
return { success: true, sessionId, fileName }
|
||||
} catch (error: any) {
|
||||
logger.error('Zmodem send error:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Zmodem receive (sz) - receive file from server
|
||||
ipcMain.handle(ZMODEM_CHANNELS.RECEIVE, async (_event: IpcMainInvokeEvent, shellId: string) => {
|
||||
try {
|
||||
const mainWindow = mainWindowGetter()
|
||||
if (!mainWindow) {
|
||||
return { success: false, error: 'Window not available' }
|
||||
}
|
||||
|
||||
// Get shell to monitor for incoming data
|
||||
const shell = sshService.getShell(shellId)
|
||||
if (!shell) {
|
||||
return { success: false, error: 'Shell not found' }
|
||||
}
|
||||
|
||||
// Open save dialog to select destination
|
||||
const result = await dialog.showSaveDialog(mainWindow, {
|
||||
title: '保存文件',
|
||||
defaultPath: app.getPath('downloads'),
|
||||
})
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { success: false, canceled: true }
|
||||
}
|
||||
|
||||
// Start Zmodem receive session
|
||||
const sessionId = zmodemService.receiveFile(
|
||||
shellId,
|
||||
result.filePath,
|
||||
(data) => {
|
||||
shell.channel.write(data)
|
||||
},
|
||||
(progress) => {
|
||||
mainWindow.webContents.send(ZMODEM_CHANNELS.PROGRESS, sessionId, progress)
|
||||
},
|
||||
(success, error) => {
|
||||
mainWindow.webContents.send(ZMODEM_CHANNELS.COMPLETE, sessionId, { success, error })
|
||||
}
|
||||
)
|
||||
|
||||
return { success: true, sessionId }
|
||||
} catch (error: any) {
|
||||
logger.error('Zmodem receive error:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Zmodem cancel transfer
|
||||
ipcMain.handle(ZMODEM_CHANNELS.CANCEL, async (_event: IpcMainInvokeEvent, sessionId: string) => {
|
||||
try {
|
||||
zmodemService.cancelSession(sessionId)
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user