v2.0.0: 主题系统+SSH私钥解析修复+会话持久化+连接池优化+UI打磨

核心修复:
- ssh-service.ts: 新增 resolvePrivateKey(),将私钥文件路径(~/.ssh/id_rsa)解析为内容后传给 ssh2,
  解决 'Cannot parse privateKey: Unsupported key format' 错误
- useTerminalConnection.ts + sessionHelper.ts: 根据 authType 过滤字段,password/agent 认证不再传 privateKey
- sessionStore.ts: addSession() 现在先调用 sessionCreate() 持久化到 SQLite,解决恢复标签'无可用连接信息'问题
- database-service.ts: updateSession() 补充 private_key_path 字段持久化
- SessionDialog.tsx: 默认 username='root'、privateKey='~/.ssh/id_rsa'

UI/UX:
- 语义化主题系统(app-theme.ts),强制深色模式,全局无边框设计
- macOS 标题栏支持双击最大化(window:toggleMaximize IPC)
- macOS 菜单栏('OpsXShell' 应用菜单 + Settings Cmd+,)
- WelcomeView 4卡片布局,移除 Settings 按钮
- TabBar/TabItem 背景色统一(bg-dark-800/900),消除白色边框线

架构优化:
- SFTP 生命周期修复(useSFTP.ts useRef + autoConnectAttempted guard)
- 连接池竞态修复,支持快速多标签连接
- IPC 接口规范化,安全错误处理
- Vite 代码分割优化

质量: lint零错误 | tsc零错误 | build成功 | 49 tests pass
This commit is contained in:
Logan.Li committed 2026-04-22 11:15:41 +08:00
1 parent 37e65be823
commit 514d62644e
77 files changed
+2563 -931

No files matched your search

+73 -31
View File
@@ -1,32 +1,64 @@
const { contextBridge, ipcRenderer } = require('electron')
import { contextBridge, ipcRenderer } from 'electron'
import type {
SSHConnectRequest,
SSHConnectResponse,
SSHCreateShellRequest,
SessionCreateResponse,
SessionUpdateResponse,
DialogOptions,
DialogResult,
ZmodemProgress,
ZmodemComplete,
} from '../shared/types/ipc.types'
import type { Session } from '../shared/types/session.types'
// 暴露 API 到渲染进程
const api = {
// SSH 相关
sshConnect: (config: any) => ipcRenderer.invoke('ssh:connect', config),
sshDisconnect: (connectionId: string) => ipcRenderer.invoke('ssh:disconnect', connectionId),
sshExec: (connectionId: string, command: string) => ipcRenderer.invoke('ssh:exec', connectionId, command),
sshCreateShell: (connectionId: string, options?: any, shellId?: string) => ipcRenderer.invoke('ssh:createShell', connectionId, options, shellId),
sshShellWrite: (shellId: string, data: string | Uint8Array) => ipcRenderer.invoke('ssh:shellWrite', shellId, data),
sshShellResize: (shellId: string, cols: number, rows: number) => ipcRenderer.invoke('ssh:shellResize', shellId, cols, rows),
sshConnect: (config: SSHConnectRequest): Promise<SSHConnectResponse> =>
ipcRenderer.invoke('ssh:connect', config),
sshDisconnect: (connectionId: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('ssh:disconnect', connectionId),
sshExec: (connectionId: string, command: string): Promise<{ success: boolean; result?: string; error?: string }> =>
ipcRenderer.invoke('ssh:exec', connectionId, command),
sshCreateShell: (connectionId: string, options?: SSHCreateShellRequest, shellId?: string): Promise<{ success: boolean; shellId?: string; error?: string }> =>
ipcRenderer.invoke('ssh:createShell', connectionId, options, shellId),
sshShellWrite: (shellId: string, data: string | Uint8Array): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('ssh:shellWrite', shellId, data),
sshShellResize: (shellId: string, cols: number, rows: number): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('ssh:shellResize', shellId, cols, rows),
// 会话管理
sessionGetAll: () => ipcRenderer.invoke('session:getAll'),
sessionGetById: (id: string) => ipcRenderer.invoke('session:getById', id),
sessionCreate: (session: any) => ipcRenderer.invoke('session:create', session),
sessionUpdate: (id: string, updates: any) => ipcRenderer.invoke('session:update', id, updates),
sessionDelete: (id: string) => ipcRenderer.invoke('session:delete', id),
sessionGetAll: (): Promise<{ success: boolean; sessions?: Session[]; error?: string }> =>
ipcRenderer.invoke('session:getAll'),
sessionGetById: (id: string): Promise<{ success: boolean; session?: Session; error?: string }> =>
ipcRenderer.invoke('session:getById', id),
sessionCreate: (session: Omit<Session, 'id'>): Promise<SessionCreateResponse> =>
ipcRenderer.invoke('session:create', session),
sessionUpdate: (id: string, updates: Partial<Session>): Promise<SessionUpdateResponse> =>
ipcRenderer.invoke('session:update', id, updates),
sessionDelete: (id: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('session:delete', id),
// SFTP
sftpConnect: (connectionId: string) => ipcRenderer.invoke('sftp:connect', connectionId),
sftpListDirectory: (sftpId: string, path: string) => ipcRenderer.invoke('sftp:listDirectory', sftpId, path),
sftpUpload: (sftpId: string, localPath: string, remotePath: string) => ipcRenderer.invoke('sftp:upload', sftpId, localPath, remotePath),
sftpDownload: (sftpId: string, remotePath: string, localPath: string) => ipcRenderer.invoke('sftp:download', sftpId, remotePath, localPath),
sftpRename: (sftpId: string, oldPath: string, newPath: string) => ipcRenderer.invoke('sftp:rename', sftpId, oldPath, newPath),
sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => ipcRenderer.invoke('sftp:delete', sftpId, path, isDirectory),
sftpMkdir: (sftpId: string, path: string) => ipcRenderer.invoke('sftp:mkdir', sftpId, path),
sftpStat: (sftpId: string, filePath: string) => ipcRenderer.invoke('sftp:stat', sftpId, filePath),
sftpChmod: (sftpId: string, filePath: string, mode: number) => ipcRenderer.invoke('sftp:chmod', sftpId, filePath, mode),
sftpConnect: (connectionId: string): Promise<{ success: boolean; sftpId?: string; error?: string }> =>
ipcRenderer.invoke('sftp:connect', connectionId),
sftpListDirectory: (sftpId: string, path: string): Promise<{ success: boolean; files?: unknown[]; error?: string }> =>
ipcRenderer.invoke('sftp:listDirectory', sftpId, path),
sftpUpload: (sftpId: string, localPath: string, remotePath: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('sftp:upload', sftpId, localPath, remotePath),
sftpDownload: (sftpId: string, remotePath: string, localPath: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('sftp:download', sftpId, remotePath, localPath),
sftpRename: (sftpId: string, oldPath: string, newPath: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('sftp:rename', sftpId, oldPath, newPath),
sftpDelete: (sftpId: string, path: string, isDirectory: boolean): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('sftp:delete', sftpId, path, isDirectory),
sftpMkdir: (sftpId: string, path: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('sftp:mkdir', sftpId, path),
sftpStat: (sftpId: string, filePath: string): Promise<{ success: boolean; stats?: unknown; error?: string }> =>
ipcRenderer.invoke('sftp:stat', sftpId, filePath),
sftpChmod: (sftpId: string, filePath: string, mode: number): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('sftp:chmod', sftpId, filePath, mode),
// 端口转发
sshForwardLocal: (connectionId: string, localPort: number, remoteHost: string, remotePort: number) =>
@@ -37,12 +69,16 @@ const api = {
ipcRenderer.invoke('ssh:forwardDynamic', connectionId, localPort),
// 设置
settingsGet: (key: string) => ipcRenderer.invoke('settings:get', key),
settingsSet: (key: string, value: any) => ipcRenderer.invoke('settings:set', key, value),
settingsGet: (key: string): Promise<{ success: boolean; value?: unknown; error?: string }> =>
ipcRenderer.invoke('settings:get', key),
settingsSet: (key: string, value: unknown): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('settings:set', key, value),
// 对话框
showSaveDialog: (options: any) => ipcRenderer.invoke('dialog:showSaveDialog', options),
showOpenDialog: (options: any) => ipcRenderer.invoke('dialog:showOpenDialog', options),
showSaveDialog: (options: DialogOptions): Promise<DialogResult> =>
ipcRenderer.invoke('dialog:showSaveDialog', options),
showOpenDialog: (options: DialogOptions): Promise<DialogResult> =>
ipcRenderer.invoke('dialog:showOpenDialog', options),
// 文件操作
readFile: (filePath: string) => ipcRenderer.invoke('file:read', filePath),
@@ -57,22 +93,25 @@ const api = {
localRename: (oldPath: string, newPath: string) => ipcRenderer.invoke('local:rename', oldPath, newPath),
localMkdir: (dirPath: string) => ipcRenderer.invoke('local:mkdir', dirPath),
// Zmodem rz/sz
// Zmodem
zmodemSend: (shellId: string) => ipcRenderer.invoke('zmodem:send', shellId),
zmodemReceive: (shellId: string) => ipcRenderer.invoke('zmodem:receive', shellId),
zmodemCancel: (sessionId: string) => ipcRenderer.invoke('zmodem:cancel', sessionId),
// Zmodem events
onZmodemProgress: (callback: (event: any, sessionId: string, progress: any) => void) => {
onZmodemProgress: (callback: (event: Electron.IpcRendererEvent, sessionId: string, progress: ZmodemProgress) => void) => {
ipcRenderer.on('zmodem:progress', callback)
},
onZmodemComplete: (callback: (event: any, sessionId: string, result: any) => void) => {
onZmodemComplete: (callback: (event: Electron.IpcRendererEvent, sessionId: string, result: ZmodemComplete) => void) => {
ipcRenderer.on('zmodem:complete', callback)
},
// 应用信息
getVersion: () => ipcRenderer.invoke('app:getVersion'),
// Window
toggleMaximize: () => ipcRenderer.invoke('window:toggleMaximize'),
// 事件监听
onMenuNewSession: (callback: () => void) => {
ipcRenderer.on('menu-new-session', callback)
@@ -86,13 +125,16 @@ const api = {
onMenuExportSessions: (callback: () => void) => {
ipcRenderer.on('menu-export-sessions', callback)
},
onShellData: (callback: (event: any, connectionId: string, shellId: string, data: Buffer) => void) => {
onMenuOpenSettings: (callback: () => void) => {
ipcRenderer.on('menu-open-settings', callback)
},
onShellData: (callback: (event: Electron.IpcRendererEvent, connectionId: string, shellId: string, data: Buffer) => void) => {
ipcRenderer.on('ssh:shellData', callback)
},
onSFTPProgress: (callback: (event: any, sftpId: string, progress: any) => void) => {
onSFTPProgress: (callback: (event: Electron.IpcRendererEvent, sftpId: string, progress: { transferred: number; total: number; percentage: number }) => void) => {
ipcRenderer.on('sftp:progress', callback)
},
removeListener: (channel: string, callback: (...args: any[]) => void) => {
removeListener: (channel: string, callback: (...args: unknown[]) => void) => {
ipcRenderer.removeListener(channel, callback)
},
removeAllListeners: (channel: string) => {