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

+95
View File
@@ -0,0 +1,95 @@
/**
* Crypto Service Integration Tests
* 验证加密/解密核心逻辑的正确性和安全性
*/
import { describe, it, expect } from 'vitest'
import { encrypt, decrypt, encryptAsync, simpleEncrypt, simpleDecrypt } from '../../electron/services/crypto'
describe('Crypto Service', () => {
const testPassword = 'test-password-123'
const testData = 'Hello, OpsXShell! 你好,世界!'
const testKey = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
describe('encrypt / decrypt', () => {
it('应该能正确加密和解密数据', () => {
const encrypted = encrypt(testData, testPassword)
expect(encrypted).toBeTruthy()
expect(encrypted).toContain(':') // 格式: version:salt:iv:tag:ciphertext
const decrypted = decrypt(encrypted, testPassword)
expect(decrypted).toBe(testData)
})
it('密文应该包含版本标识', () => {
const encrypted = encrypt(testData, testPassword)
const parts = encrypted.split(':')
expect(parts.length).toBe(5)
expect(parts[0]).toBe('v1')
})
it('不同密码应该产生不同密文', () => {
const encrypted1 = encrypt(testData, testPassword)
const encrypted2 = encrypt(testData, 'different-password')
expect(encrypted1).not.toBe(encrypted2)
})
it('相同数据多次加密应该产生不同密文(随机 salt/iv)', () => {
const encrypted1 = encrypt(testData, testPassword)
const encrypted2 = encrypt(testData, testPassword)
expect(encrypted1).not.toBe(encrypted2)
// 但都能正确解密
expect(decrypt(encrypted1, testPassword)).toBe(testData)
expect(decrypt(encrypted2, testPassword)).toBe(testData)
})
it('错误密码应该抛出异常', () => {
const encrypted = encrypt(testData, testPassword)
expect(() => decrypt(encrypted, 'wrong-password')).toThrow('Decryption failed')
})
it('应该向后兼容旧格式密文(4部分,无版本标识)', () => {
// 构造一个旧格式密文: salt:iv:tag:ciphertext
const encrypted = encrypt(testData, testPassword)
const parts = encrypted.split(':')
const oldFormat = parts.slice(1).join(':') // 去掉版本标识
expect(decrypt(oldFormat, testPassword)).toBe(testData)
})
})
describe('encryptAsync / decrypt', () => {
it('异步加密应该产生可解密的结果', async () => {
const encrypted = await encryptAsync(testData, testPassword)
const decrypted = decrypt(encrypted, testPassword)
expect(decrypted).toBe(testData)
})
it('异步加密密文应该包含版本标识', async () => {
const encrypted = await encryptAsync(testData, testPassword)
const parts = encrypted.split(':')
expect(parts.length).toBe(5)
expect(parts[0]).toBe('v1')
})
})
describe('simpleEncrypt / simpleDecrypt', () => {
it('应该能正确加密和解密数据', () => {
const encrypted = simpleEncrypt(testData, testKey)
expect(encrypted).toBeTruthy()
const decrypted = simpleDecrypt(encrypted, testKey)
expect(decrypted).toBe(testData)
})
it('错误密钥应该抛出异常', () => {
const encrypted = simpleEncrypt(testData, testKey)
expect(() => simpleDecrypt(encrypted, 'wrong-key')).toThrow('Decryption failed')
})
it('无效密钥长度应该抛出异常', () => {
expect(() => simpleEncrypt(testData, 'short')).toThrow('Invalid key length')
})
})
})
+2 -2
View File
@@ -64,7 +64,7 @@ const mockElectronAPI = {
removeListener: vi.fn(),
}
window.electronAPI = mockElectronAPI as any
window.electronAPI = mockElectronAPI as unknown as typeof window.electronAPI
// Mock navigator
Object.defineProperty(window, 'navigator', {
@@ -98,7 +98,7 @@ Object.defineProperty(window, 'matchMedia', {
// Suppress console errors in tests
const originalError = console.error
beforeAll(() => {
console.error = (...args: any[]) => {
console.error = (...args: unknown[]) => {
if (
typeof args[0] === 'string' &&
(args[0].includes('Warning:') || args[0].includes('ReactDOM.render'))
+1 -1
View File
@@ -6,7 +6,7 @@
*/
import { describe, it, expect } from 'vitest'
import { useConnectionPool, useConnectionStore, getConnectionStore } from '../../src/stores/connectionPool'
import { useConnectionPool, useConnectionStore, getConnectionStore } from '../../src/stores/connection'
describe('ConnectionPool Store', () => {
describe('store 初始化', () => {
+4 -4
View File
@@ -44,7 +44,7 @@ describe('TabStore', () => {
})
it('添加 settings 类型时如果已存在则激活而不是创建新的', () => {
const { addTab, tabs } = useTabStore.getState()
const { addTab } = useTabStore.getState()
addTab({ title: 'Settings', type: 'settings' })
addTab({ title: 'Settings 2', type: 'settings' })
@@ -86,12 +86,12 @@ describe('TabStore', () => {
addTab({ title: 'Tab 1', type: 'terminal' })
addTab({ title: 'Tab 2', type: 'sftp' })
const { tabs, activeTabId } = useTabStore.getState()
const { tabs } = useTabStore.getState()
const tabToClose = tabs.find(t => t.title === 'Tab 1')
if (tabToClose) {
closeTab(tabToClose.id)
const { tabs: newTabs, activeTabId: newActiveId } = useTabStore.getState()
const { tabs: newTabs } = useTabStore.getState()
expect(newTabs.find(t => t.isActive)?.title).toBe('Tab 2')
}
})
@@ -112,7 +112,7 @@ describe('TabStore', () => {
describe('closeOtherTabs', () => {
it('应该关闭除指定标签外的所有标签', () => {
const { addTab, closeOtherTabs, tabs } = useTabStore.getState()
const { addTab, closeOtherTabs } = useTabStore.getState()
addTab({ title: 'Tab 1', type: 'terminal' })
addTab({ title: 'Tab 2', type: 'sftp' })