Files
opsxshell/electron/services/database-service.ts
T
Logan.Li 514d62644e 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
2026-04-22 11:15:41 +08:00

389 lines
11 KiB
TypeScript

import initSqlJs, { Database as SqlJsDatabase } from 'sql.js'
import path from 'path'
import { app } from 'electron'
import fs from 'fs'
import { simpleEncrypt, simpleDecrypt } from './crypto'
import { keyService } from './key-service'
import { createLogger } from '../utils/logger'
import type { Session, SessionCreateRequest, SessionUpdateRequest } from '../../shared/types/session.types'
const logger = createLogger('DatabaseService')
// sql.js statement wrapper to match better-sqlite3 API
interface StatementResult {
id: number
[key: string]: unknown
}
class PreparedStatement {
private db: SqlJsDatabase
private sql: string
constructor(db: SqlJsDatabase, sql: string) {
this.db = db
this.sql = sql
}
run(...params: unknown[]): StatementResult {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.db.run(this.sql, params as any[])
return { id: 0 }
}
get(...params: unknown[]): unknown {
// 使用 exec 模式获取单行
const stmt = this.db.prepare(this.sql)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stmt.bind(params as any[])
let result: unknown = null
if (stmt.step()) {
result = stmt.getAsObject()
}
stmt.free()
return result
}
all(...params: unknown[]): unknown[] {
const stmt = this.db.prepare(this.sql)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stmt.bind(params as any[])
const results: unknown[] = []
while (stmt.step()) {
results.push(stmt.getAsObject())
}
stmt.free()
return results
}
}
export class DatabaseService {
private db: SqlJsDatabase | null = null
private dbPath: string
private masterKey: string | null = null
private initialized = false
constructor() {
const userDataPath = app.getPath('userData')
this.dbPath = path.join(userDataPath, 'opsxshell.db')
}
async initialize(): Promise<void> {
try {
const dbDir = path.dirname(this.dbPath)
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true })
}
this.masterKey = keyService.getMasterKey()
// 初始化 sql.js (WASM, 纯 JS 无需原生编译)
const SQL = await initSqlJs()
// 如果已有数据库文件,加载它
if (fs.existsSync(this.dbPath)) {
const buffer = fs.readFileSync(this.dbPath)
this.db = new SQL.Database(buffer)
} else {
this.db = new SQL.Database()
}
this.createTables()
this.save()
this.initialized = true
logger.info('Database initialized at:', this.dbPath)
} catch (error) {
logger.error('Failed to initialize database:', error)
throw error
}
}
private createTables(): void {
if (!this.db) return
this.db.run(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER DEFAULT 22,
username TEXT NOT NULL,
auth_type TEXT CHECK(auth_type IN ('password', 'key', 'agent')),
password TEXT,
private_key_path TEXT,
passphrase_encrypted TEXT,
group_name TEXT DEFAULT 'default',
tags TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
// 迁移:如果 password 列不存在,添加它
try {
this.db.run(`ALTER TABLE sessions ADD COLUMN password TEXT`)
} catch (e) {
// 列可能已存在,忽略错误
}
this.db.run(`
CREATE TABLE IF NOT EXISTS connection_history (
id TEXT PRIMARY KEY,
session_id TEXT REFERENCES sessions(id),
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
disconnected_at DATETIME,
duration INTEGER,
status TEXT
)
`)
this.db.run(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
this.db.run(`
CREATE TABLE IF NOT EXISTS keys (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT CHECK(type IN ('rsa', 'ed25519', 'ecdsa')),
private_key_encrypted TEXT NOT NULL,
public_key TEXT,
passphrase_encrypted TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
this.db.run(`
CREATE TABLE IF NOT EXISTS port_forwards (
id TEXT PRIMARY KEY,
session_id TEXT REFERENCES sessions(id),
type TEXT CHECK(type IN ('local', 'remote', 'dynamic')),
local_host TEXT,
local_port INTEGER,
remote_host TEXT,
remote_port INTEGER,
is_active BOOLEAN DEFAULT FALSE
)
`)
}
// 持久化到磁盘
private save(): void {
if (!this.db) return
try {
const data = this.db.export()
const buffer = Buffer.from(data)
fs.writeFileSync(this.dbPath, buffer)
} catch (error) {
logger.error('Failed to save database:', error)
}
}
private prepare(sql: string): PreparedStatement {
if (!this.db) throw new Error('Database not initialized')
return new PreparedStatement(this.db, sql)
}
// 加密字段
private encryptField(value: string | null | undefined): string | null {
if (!value) return null
if (!this.masterKey) throw new Error('Master key not initialized')
return simpleEncrypt(value, this.masterKey)
}
// 解密字段
private decryptField(value: string | null | undefined): string | null {
if (!value) return null
if (!this.masterKey) {
logger.error('Decrypt failed: Master key not initialized')
return null
}
try {
const decrypted = simpleDecrypt(value, this.masterKey)
return decrypted
} catch (error) {
logger.error('Decrypt failed:', error)
return null
}
}
// 会话 CRUD 操作
createSession(session: SessionCreateRequest): Session | null {
if (!this.db) throw new Error('Database not initialized')
logger.info('Creating session:', session.name, session.host)
const stmt = this.prepare(`
INSERT INTO sessions (id, name, host, port, username, auth_type, password, private_key_path, passphrase_encrypted, group_name, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
const id = `session-${Date.now()}`
const encryptedPassword = this.encryptField(session.password)
stmt.run(
id,
session.name,
session.host,
session.port || 22,
session.username,
session.authType,
encryptedPassword,
session.privateKey || null,
this.encryptField(session.passphrase),
session.group || 'default',
session.tags ? JSON.stringify(session.tags) : null
)
this.save()
return this.getSessionById(id)
}
getAllSessions(): Session[] {
if (!this.db) throw new Error('Database not initialized')
const stmt = this.prepare('SELECT * FROM sessions ORDER BY created_at DESC')
const sessions = stmt.all() as Record<string, unknown>[]
return sessions.map((s) => ({
id: s.id as string,
name: s.name as string,
host: s.host as string,
port: s.port as number,
username: s.username as string,
authType: s.auth_type as Session['authType'],
password: this.decryptField(s.password as string | null) || undefined,
privateKey: s.private_key_path as string | undefined,
passphrase: this.decryptField(s.passphrase_encrypted as string | null) || undefined,
group: s.group_name as string | undefined,
tags: s.tags ? JSON.parse(s.tags as string) as string[] : [],
}))
}
getSessionById(id: string): Session | null {
if (!this.db) throw new Error('Database not initialized')
const stmt = this.prepare('SELECT * FROM sessions WHERE id = ?')
const session = stmt.get(id) as Record<string, unknown> | undefined
if (session) {
return {
id: session.id as string,
name: session.name as string,
host: session.host as string,
port: session.port as number,
username: session.username as string,
authType: session.auth_type as Session['authType'],
password: this.decryptField(session.password as string | null) || undefined,
privateKey: session.private_key_path as string | undefined,
passphrase: this.decryptField(session.passphrase_encrypted as string | null) || undefined,
group: session.group_name as string | undefined,
tags: session.tags ? JSON.parse(session.tags as string) as string[] : [],
}
}
return null
}
updateSession(id: string, updates: SessionUpdateRequest): Session | null {
if (!this.db) throw new Error('Database not initialized')
const fields: string[] = []
const values: unknown[] = []
if (updates.name !== undefined) {
fields.push('name = ?')
values.push(updates.name)
}
if (updates.host !== undefined) {
fields.push('host = ?')
values.push(updates.host)
}
if (updates.port !== undefined) {
fields.push('port = ?')
values.push(updates.port)
}
if (updates.username !== undefined) {
fields.push('username = ?')
values.push(updates.username)
}
if (updates.authType !== undefined) {
fields.push('auth_type = ?')
values.push(updates.authType)
}
if (updates.password !== undefined) {
fields.push('password = ?')
values.push(this.encryptField(updates.password))
}
if (updates.passphrase !== undefined) {
fields.push('passphrase_encrypted = ?')
values.push(this.encryptField(updates.passphrase))
}
if (updates.privateKey !== undefined) {
fields.push('private_key_path = ?')
values.push(updates.privateKey || null)
}
if (updates.group !== undefined) {
fields.push('group_name = ?')
values.push(updates.group)
}
if (updates.tags !== undefined) {
fields.push('tags = ?')
values.push(JSON.stringify(updates.tags))
}
fields.push('updated_at = CURRENT_TIMESTAMP')
values.push(id)
const stmt = this.prepare(`
UPDATE sessions SET ${fields.join(', ')} WHERE id = ?
`)
stmt.run(...values)
this.save()
return this.getSessionById(id)
}
deleteSession(id: string): void {
if (!this.db) throw new Error('Database not initialized')
const stmt = this.prepare('DELETE FROM sessions WHERE id = ?')
stmt.run(id)
this.save()
}
// 设置操作
getSetting(key: string): unknown {
if (!this.db) throw new Error('Database not initialized')
const stmt = this.prepare('SELECT value FROM settings WHERE key = ?')
const row = stmt.get(key) as Record<string, unknown> | undefined
return row ? JSON.parse(row.value as string) : null
}
setSetting(key: string, value: unknown): void {
if (!this.db) throw new Error('Database not initialized')
const stmt = this.prepare(`
INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = CURRENT_TIMESTAMP
`)
stmt.run(key, JSON.stringify(value))
this.save()
}
close(): void {
if (this.db) {
this.save()
this.db.close()
this.db = null
}
}
}