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

+402
View File
@@ -0,0 +1,402 @@
/**
* Pool Store - 连接池核心管理
* 负责: 连接 Map、LRU 淘汰、引用计数、创建/断开连接
*/
import { create } from 'zustand'
import type {
PooledConnection,
ConnectionConfig,
ConnectionState,
PoolClientConfig,
} from './types'
import type { CreateConnectionOptions, CreateConnectionResult, PoolStats } from './types'
// ============== Pool State ==============
interface PoolState {
// 连接池: connectionId -> PooledConnection
connections: Map<string, PooledConnection>
// 配置
config: PoolClientConfig
// Actions
setConfig: (config: Partial<PoolClientConfig>) => void
// 连接管理
createConnection: (
tabId: string,
config: ConnectionConfig,
options?: CreateConnectionOptions
) => Promise<CreateConnectionResult>
acquireConnection: (
tabId: string,
sessionId: string,
getSessionConfig: () => Promise<ConnectionConfig | null>
) => Promise<{ success: boolean; connectionId?: string; error?: string }>
resumeConnection: (connectionId: string) => Promise<boolean>
disconnectConnection: (connectionId: string, reason?: string) => Promise<void>
releaseConnection: (connectionId: string, tabId: string) => Promise<void>
// 连接查询
getConnection: (connectionId: string) => PooledConnection | undefined
getConnectionState: (connectionId: string) => ConnectionState | undefined
// LRU 淘汰
evictOldestBackgroundConnection: () => Promise<string | null>
shouldEvict: () => boolean
// 统计
getStats: () => PoolStats
// 断开所有
disconnectAll: () => Promise<void>
}
// ============== Pool Store Implementation ==============
export const usePoolStore = create<PoolState>((set, get) => ({
connections: new Map(),
config: {
maxConnections: 10,
maxBackgroundConnections: 5,
idleTimeoutMs: 5 * 60 * 1000,
suspendTimeoutMs: 30 * 60 * 1000,
keepaliveIntervalMs: 60 * 1000,
maxReconnectAttempts: 3,
},
setConfig: (newConfig) => {
set((state) => ({
config: { ...state.config, ...newConfig },
}))
},
createConnection: async (tabId, config, _options = {}): Promise<CreateConnectionResult> => {
console.log('=== POOL STORE createConnection ===')
console.log(' tabId:', tabId)
console.log(' config:', config.host, config.port, config.username)
const { connections, config: poolConfig } = get()
const now = Date.now()
// 1. 检查是否已存在相同配置的连接 (连接复用)
for (const [existingId, conn] of connections.entries()) {
if (
conn.config.host === config.host &&
conn.config.port === config.port &&
conn.config.username === config.username &&
conn.state !== 'disconnected' &&
conn.state !== 'error'
) {
// 复用已有连接
conn.refCount++
conn.tabIds.add(tabId)
conn.activeTabId = tabId
if (conn.state === 'suspended') {
// 需要恢复连接 - 简化处理:断开后重建
const resumed = await get().resumeConnection(existingId)
if (!resumed) {
conn.refCount--
conn.tabIds.delete(tabId)
continue // 尝试创建新连接
}
} else {
conn.state = 'connected'
}
set((state) => {
const newConnections = new Map(state.connections)
newConnections.set(existingId, conn)
return { connections: newConnections }
})
return { success: true, connectionId: existingId, isExisting: true }
}
}
// 2. 检查是否需要淘汰
if (connections.size >= poolConfig.maxConnections) {
const evictedId = await get().evictOldestBackgroundConnection()
if (!evictedId) {
return {
success: false,
error: `连接数达到上限 (${poolConfig.maxConnections}),请先关闭一些连接`,
}
}
}
// 3. 创建新连接
console.log('>>> Creating new SSH connection via electronAPI...')
try {
const result = await window.electronAPI?.sshConnect({
host: config.host,
port: config.port || 22,
username: config.username,
password: config.password,
privateKey: config.privateKey,
passphrase: config.passphrase,
})
console.log('>>> sshConnect result:', result)
if (!result?.success || !result.connectionId) {
console.log('>>> sshConnect FAILED:', result?.error)
return {
success: false,
error: result?.error || 'Failed to create connection',
}
}
const connectionId = result.connectionId
console.log('>>> Connection created, connectionId:', connectionId)
// 设置保活定时器
const keepaliveInterval = window.setInterval(() => {
const conn = get().connections.get(connectionId)
if (conn && (conn.state === 'connected' || conn.state === 'background')) {
window.electronAPI?.sshExec(connectionId, 'echo -n ""').catch(() => {
get().disconnectConnection(connectionId, 'keepalive failed')
})
}
}, poolConfig.keepaliveIntervalMs)
// 4. 创建连接池项
const pooledConnection: PooledConnection = {
connectionId,
config: {
host: config.host,
port: config.port || 22,
username: config.username,
password: config.password,
privateKey: config.privateKey,
passphrase: config.passphrase,
},
state: 'connected',
refCount: 1,
tabIds: new Set([tabId]),
activeTabId: tabId,
shellIds: new Set(),
sftpIds: new Set(),
stats: {
connectedAt: now,
lastActivityAt: now,
bytesTransferred: 0,
commandsExecuted: 0,
},
keepaliveInterval,
reconnectAttempts: 0,
maxReconnectAttempts: poolConfig.maxReconnectAttempts,
}
set((state) => {
const newConnections = new Map(state.connections)
newConnections.set(connectionId, pooledConnection)
return { connections: newConnections }
})
return { success: true, connectionId }
} catch (error: any) {
return {
success: false,
error: error.message || 'Unknown error creating connection',
}
}
},
// 辅助: 恢复连接 (简化实现)
resumeConnection: async (connectionId: string): Promise<boolean> => {
const conn = get().connections.get(connectionId)
if (!conn) return false
if (conn.state === 'connected' || conn.state === 'background') {
return true
}
try {
const result = await window.electronAPI?.sshConnect(conn.config)
if (!result?.success) return false
const newConnectionId = result.connectionId
if (!newConnectionId) return false
set((state) => {
const newConnections = new Map(state.connections)
newConnections.delete(connectionId)
const now = Date.now()
const keepaliveInterval = window.setInterval(() => {
window.electronAPI?.sshExec(newConnectionId, 'echo -n ""').catch(() => {})
}, state.config.keepaliveIntervalMs)
newConnections.set(newConnectionId, {
...conn,
connectionId: newConnectionId,
state: 'connected',
stats: {
...conn.stats,
lastActivityAt: now,
},
keepaliveInterval,
idleTimeout: undefined,
reconnectAttempts: 0,
})
return { connections: newConnections }
})
return true
} catch {
return false
}
},
acquireConnection: async (tabId, _sessionId, getSessionConfigFn) => {
const { connections } = get()
// 检查是否已有该标签的连接
for (const [connId, conn] of connections.entries()) {
if (conn.tabIds.has(tabId) && conn.state !== 'disconnected') {
return { success: true, connectionId: connId }
}
}
// 获取 session 配置并创建连接
const config = await getSessionConfigFn()
if (!config) {
return { success: false, error: '无法获取会话配置' }
}
return get().createConnection(tabId, config)
},
disconnectConnection: async (connectionId, _reason = 'manual') => {
const conn = get().connections.get(connectionId)
if (!conn) return
// 清除定时器
if (conn.keepaliveInterval) {
clearInterval(conn.keepaliveInterval)
}
if (conn.idleTimeout) {
clearTimeout(conn.idleTimeout)
}
try {
await window.electronAPI?.sshDisconnect(connectionId)
} catch {
// 忽略断开错误
}
set((state) => {
const newConnections = new Map(state.connections)
newConnections.delete(connectionId)
return { connections: newConnections }
})
},
releaseConnection: async (connectionId, tabId) => {
const conn = get().connections.get(connectionId)
if (!conn) return
conn.tabIds.delete(tabId)
conn.refCount = Math.max(0, conn.refCount - 1)
if (conn.activeTabId === tabId) {
conn.activeTabId = null
}
// 如果没有标签使用,断开连接
if (conn.refCount === 0 || conn.tabIds.size === 0) {
await get().disconnectConnection(connectionId, 'no refs')
return
}
set((state) => {
const newConnections = new Map(state.connections)
newConnections.set(connectionId, conn)
return { connections: newConnections }
})
},
getConnection: (connectionId) => {
return get().connections.get(connectionId)
},
getConnectionState: (connectionId) => {
return get().connections.get(connectionId)?.state
},
evictOldestBackgroundConnection: async () => {
const { connections } = get()
let oldestId: string | null = null
let oldestTime = Infinity
for (const [id, conn] of connections.entries()) {
if (conn.state === 'background' || conn.state === 'suspended') {
const lastActivity = conn.stats.lastBackgroundAt || conn.stats.lastActivityAt
if (lastActivity < oldestTime) {
oldestTime = lastActivity
oldestId = id
}
}
}
if (oldestId) {
await get().disconnectConnection(oldestId, 'evicted')
}
return oldestId
},
shouldEvict: () => {
const { connections, config } = get()
return connections.size >= config.maxConnections
},
getStats: (): PoolStats => {
const { connections } = get()
let connected = 0, background = 0, suspended = 0, connecting = 0, error = 0
connections.forEach((conn) => {
switch (conn.state) {
case 'connected': connected++; break
case 'background': background++; break
case 'suspended': suspended++; break
case 'connecting': connecting++; break
case 'error': error++; break
}
})
return { total: connections.size, connected, background, suspended, connecting, error }
},
disconnectAll: async () => {
const { connections } = get()
const promises: Promise<void>[] = []
connections.forEach((conn) => {
if (conn.keepaliveInterval) {
clearInterval(conn.keepaliveInterval)
}
if (conn.idleTimeout) {
clearTimeout(conn.idleTimeout)
}
promises.push(
window.electronAPI?.sshDisconnect(conn.connectionId)
.then(() => {})
.catch(() => {}) as Promise<void>
)
})
await Promise.all(promises)
set({ connections: new Map() })
},
}))