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
+2519 -887

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
+4
View File
@@ -14,5 +14,9 @@ module.exports = {
'warn', 'warn',
{ allowConstantExport: true }, { allowConstantExport: true },
], ],
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
}, },
} }
+81
View File
@@ -0,0 +1,81 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
lint-and-typecheck:
name: Lint & TypeCheck
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: TypeCheck
run: npm run typecheck
- name: Format Check
run: npm run format:check
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test
- name: Upload coverage
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
build:
name: Build
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
+4
View File
@@ -0,0 +1,4 @@
{
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{css,json,md}": ["prettier --write"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5",
"plugins": ["prettier-plugin-tailwindcss"]
}
+3 -2
View File
@@ -21,9 +21,10 @@
### 当前版本状态 ### 当前版本状态
- **版本**: v1.0.0 - **版本**: v2.0.0
- **状态**: 已发布 (Release) - **状态**: 已发布 (Release) - 安全增强版
- **已完成**: 核心 SSH/SFTP 功能、加密存储、多标签页系统、Zmodem 传输 - **已完成**: 核心 SSH/SFTP 功能、加密存储、多标签页系统、Zmodem 传输
- **v2.0 优化**: safeStorage 主密钥加密、路径遍历防护、PBKDF2 600K 迭代、类型安全、工程化建设、性能优化
- **技术债务**: 部分组件仍在迁移到新的连接池架构 - **技术债务**: 部分组件仍在迁移到新的连接池架构
--- ---
+1 -1
View File
@@ -3,7 +3,7 @@
* 处理应用级别信息 * 处理应用级别信息
*/ */
import { ipcMain, IpcMainInvokeEvent, app } from 'electron' import { ipcMain, app } from 'electron'
// IPC channel names // IPC channel names
export const APP_CHANNELS = { export const APP_CHANNELS = {
+136 -27
View File
@@ -1,6 +1,7 @@
/** /**
* File IPC Handlers * File IPC Handlers
* 处理本地文件操作和远程文件读写 * 处理本地文件操作和远程文件读写
* 安全增强:防止路径遍历攻击
*/ */
import { ipcMain, IpcMainInvokeEvent, app } from 'electron' import { ipcMain, IpcMainInvokeEvent, app } from 'electron'
@@ -21,25 +22,106 @@ export const FILE_CHANNELS = {
LOCAL_MKDIR: 'local:mkdir', LOCAL_MKDIR: 'local:mkdir',
} as const } as const
// 允许的文件操作基础目录白名单
const ALLOWED_BASE_PATHS: string[] = []
/**
* 初始化允许的路径白名单
* 防止路径遍历攻击
*/
export function initializeAllowedPaths(): void {
ALLOWED_BASE_PATHS.push(
app.getPath('home'),
app.getPath('downloads'),
app.getPath('documents'),
app.getPath('desktop'),
app.getPath('temp'),
app.getPath('userData'),
)
// 添加平台特定的常用目录
if (process.platform === 'darwin') {
ALLOWED_BASE_PATHS.push('/tmp', '/var/tmp')
} else if (process.platform === 'linux') {
ALLOWED_BASE_PATHS.push('/tmp', '/var/tmp', '/home')
} else if (process.platform === 'win32') {
ALLOWED_BASE_PATHS.push(process.env.LOCALAPPDATA || '', process.env.TEMP || '')
}
}
/**
* 验证文件路径是否在允许的范围内
* 防止路径遍历攻击(如 ../../../etc/passwd)
*/
function isPathAllowed(filePath: string): boolean {
// 规范化路径(解析 .. 和 .)
const resolved = path.resolve(filePath)
// 检查是否在允许的基目录下
const isUnderAllowedPath = ALLOWED_BASE_PATHS.some((base) => {
if (!base) return false
const relative = path.relative(base, resolved)
return !relative.startsWith('..') && !path.isAbsolute(relative)
})
if (isUnderAllowedPath) return true
// 如果路径存在,检查其实际位置(处理符号链接)
try {
const real = fs.realpathSync(resolved)
return ALLOWED_BASE_PATHS.some((base) => {
if (!base) return false
const relative = path.relative(base, real)
return !relative.startsWith('..') && !path.isAbsolute(relative)
})
} catch {
return false
}
}
/**
* 安全地规范化路径
*/
function sanitizePath(inputPath: string): string {
// 移除 null 字节
const cleaned = inputPath.replace(/\0/g, '')
// 规范化路径
return path.resolve(cleaned)
}
export function registerFileIPCHandlers(): void { export function registerFileIPCHandlers(): void {
// 确保白名单已初始化
if (ALLOWED_BASE_PATHS.length === 0) {
initializeAllowedPaths()
}
// Read file (for rz) // Read file (for rz)
ipcMain.handle(FILE_CHANNELS.READ, async (_event: IpcMainInvokeEvent, filePath: string) => { ipcMain.handle(FILE_CHANNELS.READ, async (_event: IpcMainInvokeEvent, filePath: string) => {
try { try {
const data = fs.readFileSync(filePath) const safePath = sanitizePath(filePath)
if (!isPathAllowed(safePath)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
const data = fs.readFileSync(safePath)
return { success: true, data: data.toString('base64') } return { success: true, data: data.toString('base64') }
} catch (error: any) { } catch (error) {
return { success: false, error: error.message } const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
} }
}) })
// Write file (for sz) // Write file (for sz)
ipcMain.handle(FILE_CHANNELS.WRITE, async (_event: IpcMainInvokeEvent, filePath: string, data: string) => { ipcMain.handle(FILE_CHANNELS.WRITE, async (_event: IpcMainInvokeEvent, filePath: string, data: string) => {
try { try {
const safePath = sanitizePath(filePath)
if (!isPathAllowed(safePath)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
const buffer = Buffer.from(data, 'base64') const buffer = Buffer.from(data, 'base64')
fs.writeFileSync(filePath, buffer) fs.writeFileSync(safePath, buffer)
return { success: true } return { success: true }
} catch (error: any) { } catch (error) {
return { success: false, error: error.message } const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
} }
}) })
@@ -48,15 +130,20 @@ export function registerFileIPCHandlers(): void {
try { try {
const homePath = app.getPath('home') const homePath = app.getPath('home')
return { success: true, path: homePath } return { success: true, path: homePath }
} catch (error: any) { } catch (error) {
return { success: false, error: error.message } const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
} }
}) })
// Read directory contents // Read directory contents
ipcMain.handle(FILE_CHANNELS.LOCAL_READ_DIR, async (_event: IpcMainInvokeEvent, dirPath: string) => { ipcMain.handle(FILE_CHANNELS.LOCAL_READ_DIR, async (_event: IpcMainInvokeEvent, dirPath: string) => {
try { try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true }) const safePath = sanitizePath(dirPath)
if (!isPathAllowed(safePath)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
const entries = fs.readdirSync(safePath, { withFileTypes: true })
const files = entries.map((entry) => { const files = entries.map((entry) => {
const fullPath = path.join(dirPath, entry.name) const fullPath = path.join(dirPath, entry.name)
let stats: fs.Stats | null = null let stats: fs.Stats | null = null
@@ -84,63 +171,85 @@ export function registerFileIPCHandlers(): void {
}) })
return { success: true, files } return { success: true, files }
} catch (error: any) { } catch (error) {
return { success: false, error: error.message } const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
} }
}) })
// Get file stats // Get file stats
ipcMain.handle(FILE_CHANNELS.LOCAL_GET_STATS, async (_event: IpcMainInvokeEvent, filePath: string) => { ipcMain.handle(FILE_CHANNELS.LOCAL_GET_STATS, async (_event: IpcMainInvokeEvent, filePath: string) => {
try { try {
const stats = fs.lstatSync(filePath) const safePath = sanitizePath(filePath)
if (!isPathAllowed(safePath)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
const stats = fs.lstatSync(safePath)
return { return {
success: true, success: true,
stats: { stats: {
name: path.basename(filePath), name: path.basename(safePath),
path: filePath, path: safePath,
type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file', type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file',
size: stats.size, size: stats.size,
modified: Math.floor(stats.mtime.getTime() / 1000), modified: Math.floor(stats.mtime.getTime() / 1000),
permissions: formatPermissions(stats.mode), permissions: formatPermissions(stats.mode),
}, },
} }
} catch (error: any) { } catch (error) {
return { success: false, error: error.message } const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
} }
}) })
// Delete file or directory // Delete file or directory
ipcMain.handle(FILE_CHANNELS.LOCAL_DELETE, async (_event: IpcMainInvokeEvent, filePath: string) => { ipcMain.handle(FILE_CHANNELS.LOCAL_DELETE, async (_event: IpcMainInvokeEvent, filePath: string) => {
try { try {
const stats = fs.lstatSync(filePath) const safePath = sanitizePath(filePath)
if (!isPathAllowed(safePath)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
const stats = fs.lstatSync(safePath)
if (stats.isDirectory()) { if (stats.isDirectory()) {
fs.rmSync(filePath, { recursive: true, force: true }) fs.rmSync(safePath, { recursive: true, force: true })
} else { } else {
fs.unlinkSync(filePath) fs.unlinkSync(safePath)
} }
return { success: true } return { success: true }
} catch (error: any) { } catch (error) {
return { success: false, error: error.message } const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
} }
}) })
// Rename file or directory // Rename file or directory
ipcMain.handle(FILE_CHANNELS.LOCAL_RENAME, async (_event: IpcMainInvokeEvent, oldPath: string, newPath: string) => { ipcMain.handle(FILE_CHANNELS.LOCAL_RENAME, async (_event: IpcMainInvokeEvent, oldPath: string, newPath: string) => {
try { try {
fs.renameSync(oldPath, newPath) const safeOld = sanitizePath(oldPath)
const safeNew = sanitizePath(newPath)
if (!isPathAllowed(safeOld) || !isPathAllowed(safeNew)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
fs.renameSync(safeOld, safeNew)
return { success: true } return { success: true }
} catch (error: any) { } catch (error) {
return { success: false, error: error.message } const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
} }
}) })
// Create directory // Create directory
ipcMain.handle(FILE_CHANNELS.LOCAL_MKDIR, async (_event: IpcMainInvokeEvent, dirPath: string) => { ipcMain.handle(FILE_CHANNELS.LOCAL_MKDIR, async (_event: IpcMainInvokeEvent, dirPath: string) => {
try { try {
fs.mkdirSync(dirPath, { recursive: true }) const safePath = sanitizePath(dirPath)
if (!isPathAllowed(safePath)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
fs.mkdirSync(safePath, { recursive: true })
return { success: true } return { success: true }
} catch (error: any) { } catch (error) {
return { success: false, error: error.message } const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
} }
}) })
} }
+15 -2
View File
@@ -16,9 +16,10 @@ import { registerSFTPIPCHandlers } from './sftp.ipc'
import { registerSessionIPCHandlers } from './session.ipc' import { registerSessionIPCHandlers } from './session.ipc'
import { registerSettingsIPCHandlers } from './settings.ipc' import { registerSettingsIPCHandlers } from './settings.ipc'
import { registerDialogIPCHandlers } from './dialog.ipc' import { registerDialogIPCHandlers } from './dialog.ipc'
import { registerFileIPCHandlers } from './file.ipc' import { registerFileIPCHandlers, initializeAllowedPaths } from './file.ipc'
import { registerZmodemIPCHandlers } from './zmodem.ipc' import { registerZmodemIPCHandlers } from './zmodem.ipc'
import { registerAppIPCHandlers } from './app.ipc' import { registerAppIPCHandlers } from './app.ipc'
import { ipcMain } from 'electron'
export interface IPCOptions { export interface IPCOptions {
sshService: SSHService sshService: SSHService
@@ -45,7 +46,8 @@ export function registerAllIPCHandlers(options: IPCOptions): void {
// Dialog handlers // Dialog handlers
registerDialogIPCHandlers({ mainWindowGetter }) registerDialogIPCHandlers({ mainWindowGetter })
// File handlers // File handlers (初始化路径白名单)
initializeAllowedPaths()
registerFileIPCHandlers() registerFileIPCHandlers()
// Zmodem handlers // Zmodem handlers
@@ -54,6 +56,17 @@ export function registerAllIPCHandlers(options: IPCOptions): void {
// App handlers // App handlers
registerAppIPCHandlers() registerAppIPCHandlers()
// Window handlers
ipcMain.handle('window:toggleMaximize', () => {
const win = mainWindowGetter()
if (!win) return
if (win.isMaximized()) {
win.unmaximize()
} else {
win.maximize()
}
})
logger.info('[IPC] All handlers registered') logger.info('[IPC] All handlers registered')
} }
+12 -11
View File
@@ -5,6 +5,7 @@
import { ipcMain, IpcMainInvokeEvent } from 'electron' import { ipcMain, IpcMainInvokeEvent } from 'electron'
import type { DatabaseService } from '../services/database-service' import type { DatabaseService } from '../services/database-service'
import type { SessionUpdateRequest } from '../../shared/types/session.types'
// IPC channel names // IPC channel names
export const SESSION_CHANNELS = { export const SESSION_CHANNELS = {
@@ -27,8 +28,8 @@ export function registerSessionIPCHandlers(options: SessionIPCOptions): void {
try { try {
const result = dbService.createSession(session) const result = dbService.createSession(session)
return { success: true, session: result } return { success: true, session: result }
} catch (error: any) { } catch (error: unknown) {
return { success: false, error: error.message } return { success: false, error: error instanceof Error ? error.message : String(error) }
} }
}) })
@@ -37,8 +38,8 @@ export function registerSessionIPCHandlers(options: SessionIPCOptions): void {
try { try {
const sessions = dbService.getAllSessions() const sessions = dbService.getAllSessions()
return { success: true, sessions } return { success: true, sessions }
} catch (error: any) { } catch (error: unknown) {
return { success: false, error: error.message } return { success: false, error: error instanceof Error ? error.message : String(error) }
} }
}) })
@@ -47,18 +48,18 @@ export function registerSessionIPCHandlers(options: SessionIPCOptions): void {
try { try {
const session = dbService.getSessionById(id) const session = dbService.getSessionById(id)
return { success: true, session } return { success: true, session }
} catch (error: any) { } catch (error: unknown) {
return { success: false, error: error.message } return { success: false, error: error instanceof Error ? error.message : String(error) }
} }
}) })
// Update session // Update session
ipcMain.handle(SESSION_CHANNELS.UPDATE, async (_event: IpcMainInvokeEvent, id: string, updates: any) => { ipcMain.handle(SESSION_CHANNELS.UPDATE, async (_event: IpcMainInvokeEvent, id: string, updates: SessionUpdateRequest) => {
try { try {
const result = dbService.updateSession(id, updates) const result = dbService.updateSession(id, updates)
return { success: true, session: result } return { success: true, session: result }
} catch (error: any) { } catch (error: unknown) {
return { success: false, error: error.message } return { success: false, error: error instanceof Error ? error.message : String(error) }
} }
}) })
@@ -67,8 +68,8 @@ export function registerSessionIPCHandlers(options: SessionIPCOptions): void {
try { try {
dbService.deleteSession(id) dbService.deleteSession(id)
return { success: true } return { success: true }
} catch (error: any) { } catch (error: unknown) {
return { success: false, error: error.message } return { success: false, error: error instanceof Error ? error.message : String(error) }
} }
}) })
} }
+4 -4
View File
@@ -24,8 +24,8 @@ export function registerSettingsIPCHandlers(options: SettingsIPCOptions): void {
try { try {
const value = dbService.getSetting(key) const value = dbService.getSetting(key)
return { success: true, value } return { success: true, value }
} catch (error: any) { } catch (error: unknown) {
return { success: false, error: error.message } return { success: false, error: error instanceof Error ? error.message : String(error) }
} }
}) })
@@ -34,8 +34,8 @@ export function registerSettingsIPCHandlers(options: SettingsIPCOptions): void {
try { try {
dbService.setSetting(key, value) dbService.setSetting(key, value)
return { success: true } return { success: true }
} catch (error: any) { } catch (error: unknown) {
return { success: false, error: error.message } return { success: false, error: error instanceof Error ? error.message : String(error) }
} }
}) })
} }
+38 -29
View File
@@ -26,11 +26,11 @@ export interface SFTPIPCOptions {
} }
// ===== 彩色调试日志 ===== // ===== 彩色调试日志 =====
const DEBUG = true const _DEBUG = true
function logHeader(msg: string) { function logHeader(msg: string) {
console.log(`%c${msg}`, 'background: #16a085; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;') console.log(`%c${msg}`, 'background: #16a085; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;')
} }
function logInfo(label: string, value: any) { function logInfo(label: string, value: unknown) {
console.log(`%c${label}:%c ${value}`, 'background: #2c3e50; color: #fff; padding: 2px 6px; border-radius: 3px;', 'color: #1abc9c; font-weight: bold;') 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) { function logSuccess(msg: string) {
@@ -52,9 +52,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
const sftp = await sshService.createSFTP(connectionId) const sftp = await sshService.createSFTP(connectionId)
logSuccess(`SFTP connected: ${sftp.id}`) logSuccess(`SFTP connected: ${sftp.id}`)
return { success: true, sftpId: sftp.id } return { success: true, sftpId: sftp.id }
} catch (error: any) { } catch (error: unknown) {
logError(`SFTP connect failed: ${error.message}`) const message = error instanceof Error ? error.message : String(error)
return { success: false, error: error.message } logError(`SFTP connect failed: ${message}`)
return { success: false, error: message }
} }
}) })
@@ -68,9 +69,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
const files = await sshService.sftpListDirectory(sftpId, dirPath) const files = await sshService.sftpListDirectory(sftpId, dirPath)
logSuccess(`Listed ${files.length} items`) logSuccess(`Listed ${files.length} items`)
return { success: true, files } return { success: true, files }
} catch (error: any) { } catch (error: unknown) {
logError(`List failed: ${error.message}`) const message = error instanceof Error ? error.message : String(error)
return { success: false, error: error.message } logError(`List failed: ${message}`)
return { success: false, error: message }
} }
}) })
@@ -97,9 +99,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
}) })
logSuccess('Upload complete') logSuccess('Upload complete')
return { success: true } return { success: true }
} catch (error: any) { } catch (error: unknown) {
logError(`Upload failed: ${error.message}`) const message = error instanceof Error ? error.message : String(error)
return { success: false, error: error.message } logError(`Upload failed: ${message}`)
return { success: false, error: message }
} }
}) })
@@ -126,9 +129,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
}) })
logSuccess('Download complete') logSuccess('Download complete')
return { success: true } return { success: true }
} catch (error: any) { } catch (error: unknown) {
logError(`Download failed: ${error.message}`) const message = error instanceof Error ? error.message : String(error)
return { success: false, error: error.message } logError(`Download failed: ${message}`)
return { success: false, error: message }
} }
}) })
@@ -148,9 +152,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
await sshService.sftpRename(sftpId, oldPath, newPath) await sshService.sftpRename(sftpId, oldPath, newPath)
logSuccess('Rename complete') logSuccess('Rename complete')
return { success: true } return { success: true }
} catch (error: any) { } catch (error: unknown) {
logError(`Rename failed: ${error.message}`) const message = error instanceof Error ? error.message : String(error)
return { success: false, error: error.message } logError(`Rename failed: ${message}`)
return { success: false, error: message }
} }
}) })
@@ -170,9 +175,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
await sshService.sftpDelete(sftpId, filePath, isDirectory) await sshService.sftpDelete(sftpId, filePath, isDirectory)
logSuccess('Delete complete') logSuccess('Delete complete')
return { success: true } return { success: true }
} catch (error: any) { } catch (error: unknown) {
logError(`Delete failed: ${error.message}`) const message = error instanceof Error ? error.message : String(error)
return { success: false, error: error.message } logError(`Delete failed: ${message}`)
return { success: false, error: message }
} }
}) })
@@ -190,9 +196,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
await sshService.sftpMkdir(sftpId, dirPath) await sshService.sftpMkdir(sftpId, dirPath)
logSuccess('Mkdir complete') logSuccess('Mkdir complete')
return { success: true } return { success: true }
} catch (error: any) { } catch (error: unknown) {
logError(`Mkdir failed: ${error.message}`) const message = error instanceof Error ? error.message : String(error)
return { success: false, error: error.message } logError(`Mkdir failed: ${message}`)
return { success: false, error: message }
} }
}) })
@@ -210,9 +217,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
const stats = await sshService.sftpStat(sftpId, filePath) const stats = await sshService.sftpStat(sftpId, filePath)
logSuccess(`Stat complete: ${stats.size} bytes`) logSuccess(`Stat complete: ${stats.size} bytes`)
return { success: true, stats } return { success: true, stats }
} catch (error: any) { } catch (error: unknown) {
logError(`Stat failed: ${error.message}`) const message = error instanceof Error ? error.message : String(error)
return { success: false, error: error.message } logError(`Stat failed: ${message}`)
return { success: false, error: message }
} }
}) })
@@ -232,9 +240,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
await sshService.sftpChmod(sftpId, filePath, mode) await sshService.sftpChmod(sftpId, filePath, mode)
logSuccess('Chmod complete') logSuccess('Chmod complete')
return { success: true } return { success: true }
} catch (error: any) { } catch (error: unknown) {
logError(`Chmod failed: ${error.message}`) const message = error instanceof Error ? error.message : String(error)
return { success: false, error: error.message } logError(`Chmod failed: ${message}`)
return { success: false, error: message }
} }
}) })
} }
+52 -54
View File
@@ -1,6 +1,7 @@
/** /**
* SSH IPC Handlers * SSH IPC Handlers
* 处理 SSH 连接、Shell、会话、端口转发相关 IPC * 处理 SSH 连接、Shell、会话、端口转发相关 IPC
* 安全增强:规范化错误处理,不暴露敏感信息
*/ */
import { ipcMain, IpcMainInvokeEvent } from 'electron' import { ipcMain, IpcMainInvokeEvent } from 'electron'
@@ -10,22 +11,22 @@ import { createLogger } from '../utils/logger'
const logger = createLogger('SSH_IPC') const logger = createLogger('SSH_IPC')
// ===== 彩色调试日志 ===== // 生产环境日志:使用结构化日志,不输出敏感信息
const DEBUG = true const DEBUG = process.env.NODE_ENV === 'development'
function debug(...args: any[]) { function debug(...args: unknown[]) {
if (DEBUG) console.log('[SSH_IPC]', ...args) if (DEBUG) logger.debug('[SSH_IPC]', ...args)
} }
function logHeader(msg: string) { function logHeader(msg: string) {
console.log(`%c${msg}`, 'background: #9b59b6; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;') logger.info(`[${msg}]`)
} }
function logInfo(label: string, value: any) { function logInfo(label: string, value: unknown) {
console.log(`%c${label}:%c ${value}`, 'background: #2c3e50; color: #fff; padding: 2px 6px; border-radius: 3px;', 'color: #3498db; font-weight: bold;') logger.info(`${label}: ${value}`)
} }
function logSuccess(msg: string) { function logSuccess(msg: string) {
console.log(`%c✓ ${msg}`, 'color: #27ae60; font-weight: bold;') logger.info(`[OK] ${msg}`)
} }
function logError(msg: string) { function logError(msg: string) {
console.log(`%c✗ ${msg}`, 'color: #e74c3c; font-weight: bold;') logger.error(msg)
} }
// IPC channel names // IPC channel names
@@ -49,15 +50,16 @@ export interface SSHIPCOptions {
} }
export function registerSSHIPCHandlers(options: SSHIPCOptions): void { export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
const { sshService, zmodemService, mainWindowGetter } = options const { sshService, zmodemService, mainWindowGetter: _mainWindowGetter } = options
// SSH connect // SSH connect
ipcMain.handle(SSH_CHANNELS.CONNECT, async (_event: IpcMainInvokeEvent, config) => { ipcMain.handle(SSH_CHANNELS.CONNECT, async (_event: IpcMainInvokeEvent, config) => {
try { try {
const connection = await sshService.connect(config) const connection = await sshService.connect(config)
return { success: true, connectionId: connection.id } return { success: true, connectionId: connection.id }
} catch (error: any) { } catch (error) {
return { success: false, error: error.message } const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
} }
}) })
@@ -66,8 +68,9 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
try { try {
await sshService.disconnect(connectionId) await sshService.disconnect(connectionId)
return { success: true } return { success: true }
} catch (error: any) { } catch (error) {
return { success: false, error: error.message } const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
} }
}) })
@@ -76,17 +79,15 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
try { try {
const result = await sshService.exec(connectionId, command) const result = await sshService.exec(connectionId, command)
return { success: true, result } return { success: true, result }
} catch (error: any) { } catch (error) {
return { success: false, error: error.message } const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
} }
}) })
// SSH create shell // SSH create shell
ipcMain.handle(SSH_CHANNELS.CREATE_SHELL, async (event: IpcMainInvokeEvent, connectionId: string, options?: { cols?: number; rows?: number }, shellId?: string) => { ipcMain.handle(SSH_CHANNELS.CREATE_SHELL, async (event: IpcMainInvokeEvent, connectionId: string, options?: { cols?: number; rows?: number }, shellId?: string) => {
console.log('=== SSH_IPC CREATE_SHELL ===') debug('CREATE_SHELL connectionId:', connectionId, 'preferredId:', shellId)
console.log(' connectionId:', connectionId)
console.log(' options:', options)
console.log(' preferredShellId:', shellId)
try { try {
const preferredShellId = shellId const preferredShellId = shellId
@@ -96,27 +97,18 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
const shell = await sshService.createShell(connectionId, options || {}, (shellId, data) => { const shell = await sshService.createShell(connectionId, options || {}, (shellId, data) => {
// 检查 sender 是否已被销毁 // 检查 sender 是否已被销毁
if (event.sender.isDestroyed()) { if (event.sender.isDestroyed()) {
console.log('=== SHELL DATA CALLBACK - SKIPPED (sender destroyed) ===') debug('SHELL DATA CALLBACK - SKIPPED (sender destroyed)')
return return
} }
const text = data.toString('utf-8') debug('SHELL DATA CALLBACK shellId:', shellId, 'data length:', data.length)
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 { try {
console.log('>>> Sending SHELL_DATA to renderer...') debug('>>> Sending SHELL_DATA to renderer...')
event.sender.send(SSH_CHANNELS.SHELL_DATA, connectionId, shellId, data) event.sender.send(SSH_CHANNELS.SHELL_DATA, connectionId, shellId, data)
console.log('>>> SHELL_DATA sent successfully') debug('>>> SHELL_DATA sent successfully')
} catch (err) { } catch (err) {
console.error('>>> FAILED to send SHELL_DATA:', err) logger.error('>>> FAILED to send SHELL_DATA:', err)
} }
zmodemService.feedShellData(shellId, data) zmodemService.feedShellData(shellId, data)
@@ -125,10 +117,11 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
logger.info('[SSH_IPC] Shell created:', shell.id) logger.info('[SSH_IPC] Shell created:', shell.id)
debug('>>> Shell created successfully:', shell.id) debug('>>> Shell created successfully:', shell.id)
return { success: true, shellId: shell.id } return { success: true, shellId: shell.id }
} catch (error: any) { } catch (error) {
logger.error('[SSH_IPC] Shell creation failed:', error) logger.error('[SSH_IPC] Shell creation failed:', error)
debug('>>> Shell creation FAILED:', error.message) const message = error instanceof Error ? error.message : String(error)
return { success: false, error: error.message } debug('>>> Shell creation FAILED:', message)
return { success: false, error: message }
} }
}) })
@@ -137,8 +130,9 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
try { try {
sshService.shellWrite(shellId, data) sshService.shellWrite(shellId, data)
return { success: true } return { success: true }
} catch (error: any) { } catch (error) {
return { success: false, error: error.message } const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
} }
}) })
@@ -147,8 +141,9 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
try { try {
sshService.shellResize(shellId, cols, rows) sshService.shellResize(shellId, cols, rows)
return { success: true } return { success: true }
} catch (error: any) { } catch (error) {
return { success: false, error: error.message } const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
} }
}) })
@@ -160,7 +155,7 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
remoteHost: string, remoteHost: string,
remotePort: number remotePort: number
) => { ) => {
logHeader('🔀 IPC FORWARD_LOCAL') logHeader('FORWARD_LOCAL')
logInfo('connectionId', connectionId) logInfo('connectionId', connectionId)
logInfo('localPort', localPort) logInfo('localPort', localPort)
logInfo('remoteHost', remoteHost) logInfo('remoteHost', remoteHost)
@@ -170,9 +165,10 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
const result = await sshService.forwardLocal(connectionId, localPort, remoteHost, remotePort) const result = await sshService.forwardLocal(connectionId, localPort, remoteHost, remotePort)
logSuccess(`Local forward bound to port ${result.boundPort}`) logSuccess(`Local forward bound to port ${result.boundPort}`)
return { success: true, boundPort: result.boundPort } return { success: true, boundPort: result.boundPort }
} catch (error: any) { } catch (error) {
logError(`Local forward failed: ${error.message}`) const message = error instanceof Error ? error.message : String(error)
return { success: false, error: error.message } logError(`Local forward failed: ${message}`)
return { success: false, error: message }
} }
}) })
@@ -184,19 +180,20 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
localHost: string, localHost: string,
localPort: number localPort: number
) => { ) => {
logHeader('🔀 IPC FORWARD_REMOTE') logHeader('FORWARD_REMOTE')
logInfo('connectionId', connectionId) logInfo('connectionId', connectionId)
logInfo('remotePort', remotePort) logInfo('remotePort', remotePort)
logInfo('localHost', localHost) logInfo('localHost', localHost)
logInfo('localPort', localPort) logInfo('localPort', localPort)
try { try {
const result = await sshService.forwardRemote(connectionId, remotePort, localHost, localPort) await sshService.forwardRemote(connectionId, remotePort, localHost, localPort)
logSuccess('Remote forward established') logSuccess('Remote forward established')
return { success: true } return { success: true }
} catch (error: any) { } catch (error) {
logError(`Remote forward failed: ${error.message}`) const message = error instanceof Error ? error.message : String(error)
return { success: false, error: error.message } logError(`Remote forward failed: ${message}`)
return { success: false, error: message }
} }
}) })
@@ -206,7 +203,7 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
connectionId: string, connectionId: string,
localPort: number localPort: number
) => { ) => {
logHeader('🧦 IPC FORWARD_DYNAMIC (SOCKS5)') logHeader('FORWARD_DYNAMIC (SOCKS5)')
logInfo('connectionId', connectionId) logInfo('connectionId', connectionId)
logInfo('localPort', localPort) logInfo('localPort', localPort)
@@ -214,9 +211,10 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
const result = await sshService.forwardDynamic(connectionId, localPort) const result = await sshService.forwardDynamic(connectionId, localPort)
logSuccess(`SOCKS5 proxy started on port ${result.boundPort}`) logSuccess(`SOCKS5 proxy started on port ${result.boundPort}`)
return { success: true, boundPort: result.boundPort } return { success: true, boundPort: result.boundPort }
} catch (error: any) { } catch (error) {
logError(`Dynamic forward failed: ${error.message}`) const message = error instanceof Error ? error.message : String(error)
return { success: false, error: error.message } logError(`Dynamic forward failed: ${message}`)
return { success: false, error: message }
} }
}) })
} }
+6 -6
View File
@@ -77,9 +77,9 @@ export function registerZmodemIPCHandlers(options: ZmodemIPCOptions): void {
) )
return { success: true, sessionId, fileName } return { success: true, sessionId, fileName }
} catch (error: any) { } catch (error: unknown) {
logger.error('Zmodem send error:', error) logger.error('Zmodem send error:', error)
return { success: false, error: error.message } return { success: false, error: error instanceof Error ? error.message : String(error) }
} }
}) })
@@ -123,9 +123,9 @@ export function registerZmodemIPCHandlers(options: ZmodemIPCOptions): void {
) )
return { success: true, sessionId } return { success: true, sessionId }
} catch (error: any) { } catch (error: unknown) {
logger.error('Zmodem receive error:', error) logger.error('Zmodem receive error:', error)
return { success: false, error: error.message } return { success: false, error: error instanceof Error ? error.message : String(error) }
} }
}) })
@@ -134,8 +134,8 @@ export function registerZmodemIPCHandlers(options: ZmodemIPCOptions): void {
try { try {
zmodemService.cancelSession(sessionId) zmodemService.cancelSession(sessionId)
return { success: true } return { success: true }
} catch (error: any) { } catch (error: unknown) {
return { success: false, error: error.message } return { success: false, error: error instanceof Error ? error.message : String(error) }
} }
}) })
} }
+30 -1
View File
@@ -87,12 +87,41 @@ function createApplicationMenu(): void {
dialog.showMessageBox(mainWindow!, { dialog.showMessageBox(mainWindow!, {
type: 'info', type: 'info',
title: '关于 OpsXShell', title: '关于 OpsXShell',
message: 'OpsXShell v1.0.0', message: 'OpsXShell v2.0.0',
detail: '跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠\n\nMIT License', detail: '跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠\n\nMIT License',
}) })
}, },
}, },
{ type: 'separator' }, { type: 'separator' },
{
label: '设置',
accelerator: process.platform === 'darwin' ? 'Cmd+,' : 'Ctrl+,',
click: () => {
mainWindow?.webContents.send('menu-open-settings')
},
},
{ type: 'separator' },
{
label: '服务',
role: 'services',
submenu: [],
},
{ type: 'separator' },
{
label: `隐藏 ${app.name}`,
accelerator: 'Cmd+H',
role: 'hide',
},
{
label: '隐藏其他',
accelerator: 'Cmd+Alt+H',
role: 'hideOthers',
},
{
label: '全部显示',
role: 'unhide',
},
{ type: 'separator' },
{ {
label: '退出', label: '退出',
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q', accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
+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 到渲染进程 // 暴露 API 到渲染进程
const api = { const api = {
// SSH 相关 // SSH 相关
sshConnect: (config: any) => ipcRenderer.invoke('ssh:connect', config), sshConnect: (config: SSHConnectRequest): Promise<SSHConnectResponse> =>
sshDisconnect: (connectionId: string) => ipcRenderer.invoke('ssh:disconnect', connectionId), ipcRenderer.invoke('ssh:connect', config),
sshExec: (connectionId: string, command: string) => ipcRenderer.invoke('ssh:exec', connectionId, command), sshDisconnect: (connectionId: string): Promise<{ success: boolean; error?: string }> =>
sshCreateShell: (connectionId: string, options?: any, shellId?: string) => ipcRenderer.invoke('ssh:createShell', connectionId, options, shellId), ipcRenderer.invoke('ssh:disconnect', connectionId),
sshShellWrite: (shellId: string, data: string | Uint8Array) => ipcRenderer.invoke('ssh:shellWrite', shellId, data), sshExec: (connectionId: string, command: string): Promise<{ success: boolean; result?: string; error?: string }> =>
sshShellResize: (shellId: string, cols: number, rows: number) => ipcRenderer.invoke('ssh:shellResize', shellId, cols, rows), 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'), sessionGetAll: (): Promise<{ success: boolean; sessions?: Session[]; error?: string }> =>
sessionGetById: (id: string) => ipcRenderer.invoke('session:getById', id), ipcRenderer.invoke('session:getAll'),
sessionCreate: (session: any) => ipcRenderer.invoke('session:create', session), sessionGetById: (id: string): Promise<{ success: boolean; session?: Session; error?: string }> =>
sessionUpdate: (id: string, updates: any) => ipcRenderer.invoke('session:update', id, updates), ipcRenderer.invoke('session:getById', id),
sessionDelete: (id: string) => ipcRenderer.invoke('session:delete', 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 // SFTP
sftpConnect: (connectionId: string) => ipcRenderer.invoke('sftp:connect', connectionId), sftpConnect: (connectionId: string): Promise<{ success: boolean; sftpId?: string; error?: string }> =>
sftpListDirectory: (sftpId: string, path: string) => ipcRenderer.invoke('sftp:listDirectory', sftpId, path), ipcRenderer.invoke('sftp:connect', connectionId),
sftpUpload: (sftpId: string, localPath: string, remotePath: string) => ipcRenderer.invoke('sftp:upload', sftpId, localPath, remotePath), sftpListDirectory: (sftpId: string, path: string): Promise<{ success: boolean; files?: unknown[]; error?: string }> =>
sftpDownload: (sftpId: string, remotePath: string, localPath: string) => ipcRenderer.invoke('sftp:download', sftpId, remotePath, localPath), ipcRenderer.invoke('sftp:listDirectory', sftpId, path),
sftpRename: (sftpId: string, oldPath: string, newPath: string) => ipcRenderer.invoke('sftp:rename', sftpId, oldPath, newPath), sftpUpload: (sftpId: string, localPath: string, remotePath: string): Promise<{ success: boolean; error?: string }> =>
sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => ipcRenderer.invoke('sftp:delete', sftpId, path, isDirectory), ipcRenderer.invoke('sftp:upload', sftpId, localPath, remotePath),
sftpMkdir: (sftpId: string, path: string) => ipcRenderer.invoke('sftp:mkdir', sftpId, path), sftpDownload: (sftpId: string, remotePath: string, localPath: string): Promise<{ success: boolean; error?: string }> =>
sftpStat: (sftpId: string, filePath: string) => ipcRenderer.invoke('sftp:stat', sftpId, filePath), ipcRenderer.invoke('sftp:download', sftpId, remotePath, localPath),
sftpChmod: (sftpId: string, filePath: string, mode: number) => ipcRenderer.invoke('sftp:chmod', sftpId, filePath, mode), 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) => sshForwardLocal: (connectionId: string, localPort: number, remoteHost: string, remotePort: number) =>
@@ -37,12 +69,16 @@ const api = {
ipcRenderer.invoke('ssh:forwardDynamic', connectionId, localPort), ipcRenderer.invoke('ssh:forwardDynamic', connectionId, localPort),
// 设置 // 设置
settingsGet: (key: string) => ipcRenderer.invoke('settings:get', key), settingsGet: (key: string): Promise<{ success: boolean; value?: unknown; error?: string }> =>
settingsSet: (key: string, value: any) => ipcRenderer.invoke('settings:set', key, value), 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), showSaveDialog: (options: DialogOptions): Promise<DialogResult> =>
showOpenDialog: (options: any) => ipcRenderer.invoke('dialog:showOpenDialog', options), ipcRenderer.invoke('dialog:showSaveDialog', options),
showOpenDialog: (options: DialogOptions): Promise<DialogResult> =>
ipcRenderer.invoke('dialog:showOpenDialog', options),
// 文件操作 // 文件操作
readFile: (filePath: string) => ipcRenderer.invoke('file:read', filePath), 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), localRename: (oldPath: string, newPath: string) => ipcRenderer.invoke('local:rename', oldPath, newPath),
localMkdir: (dirPath: string) => ipcRenderer.invoke('local:mkdir', dirPath), localMkdir: (dirPath: string) => ipcRenderer.invoke('local:mkdir', dirPath),
// Zmodem rz/sz // Zmodem
zmodemSend: (shellId: string) => ipcRenderer.invoke('zmodem:send', shellId), zmodemSend: (shellId: string) => ipcRenderer.invoke('zmodem:send', shellId),
zmodemReceive: (shellId: string) => ipcRenderer.invoke('zmodem:receive', shellId), zmodemReceive: (shellId: string) => ipcRenderer.invoke('zmodem:receive', shellId),
zmodemCancel: (sessionId: string) => ipcRenderer.invoke('zmodem:cancel', sessionId), zmodemCancel: (sessionId: string) => ipcRenderer.invoke('zmodem:cancel', sessionId),
// Zmodem events // 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) 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) ipcRenderer.on('zmodem:complete', callback)
}, },
// 应用信息 // 应用信息
getVersion: () => ipcRenderer.invoke('app:getVersion'), getVersion: () => ipcRenderer.invoke('app:getVersion'),
// Window
toggleMaximize: () => ipcRenderer.invoke('window:toggleMaximize'),
// 事件监听 // 事件监听
onMenuNewSession: (callback: () => void) => { onMenuNewSession: (callback: () => void) => {
ipcRenderer.on('menu-new-session', callback) ipcRenderer.on('menu-new-session', callback)
@@ -86,13 +125,16 @@ const api = {
onMenuExportSessions: (callback: () => void) => { onMenuExportSessions: (callback: () => void) => {
ipcRenderer.on('menu-export-sessions', callback) 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) 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) ipcRenderer.on('sftp:progress', callback)
}, },
removeListener: (channel: string, callback: (...args: any[]) => void) => { removeListener: (channel: string, callback: (...args: unknown[]) => void) => {
ipcRenderer.removeListener(channel, callback) ipcRenderer.removeListener(channel, callback)
}, },
removeAllListeners: (channel: string) => { removeAllListeners: (channel: string) => {
+100 -15
View File
@@ -1,15 +1,31 @@
import crypto from 'crypto' import crypto from 'crypto'
import { promisify } from 'util'
const ALGORITHM = 'aes-256-gcm' const ALGORITHM = 'aes-256-gcm'
const KEY_LENGTH = 32 const KEY_LENGTH = 32
const IV_LENGTH = 16 const IV_LENGTH = 16
const TAG_LENGTH = 16 const _TAG_LENGTH = 16
const SALT_LENGTH = 32 const SALT_LENGTH = 32
const ITERATIONS = 100000 // OWASP 2023 建议至少 600,000 次(针对 SHA256)
const ITERATIONS = 600000
const CRYPTO_VERSION = 'v1'
// 从密码派生密钥 // 异步 PBKDF2 避免阻塞事件循环
function deriveKey(password: string, salt: Buffer): Buffer { const pbkdf2Async = promisify(crypto.pbkdf2)
return crypto.pbkdf2Sync(password, salt, ITERATIONS, KEY_LENGTH, 'sha256')
/**
* 从密码异步派生密钥
* 使用异步版本避免阻塞 Electron 主进程事件循环
*/
async function deriveKeyAsync(password: string, salt: Buffer): Promise<Buffer> {
return pbkdf2Async(password, salt, ITERATIONS, KEY_LENGTH, 'sha512')
}
/**
* 从密码同步派生密钥(仅在启动等必要时使用)
*/
function deriveKeySync(password: string, salt: Buffer): Buffer {
return crypto.pbkdf2Sync(password, salt, ITERATIONS, KEY_LENGTH, 'sha512')
} }
// 生成随机密钥(用于主密钥) // 生成随机密钥(用于主密钥)
@@ -17,18 +33,26 @@ export function generateMasterKey(): string {
return crypto.randomBytes(KEY_LENGTH).toString('hex') return crypto.randomBytes(KEY_LENGTH).toString('hex')
} }
// 加密数据 /**
* 加密数据
* 格式: version:salt:iv:tag:encrypted (全部 hex)
* version 用于未来加密方案升级时的安全迁移
*/
export function encrypt(plaintext: string, password: string): string { export function encrypt(plaintext: string, password: string): string {
const salt = crypto.randomBytes(SALT_LENGTH) const salt = crypto.randomBytes(SALT_LENGTH)
const key = deriveKey(password, salt) const key = deriveKeySync(password, salt)
const iv = crypto.randomBytes(IV_LENGTH) const iv = crypto.randomBytes(IV_LENGTH)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv) const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag() const tag = cipher.getAuthTag()
// 格式: salt:iv:tag:encrypted (全部 hex) // 立即清零密钥内存
key.fill(0)
// 格式: version:salt:iv:tag:encrypted (全部 hex)
return [ return [
CRYPTO_VERSION,
salt.toString('hex'), salt.toString('hex'),
iv.toString('hex'), iv.toString('hex'),
tag.toString('hex'), tag.toString('hex'),
@@ -36,10 +60,50 @@ export function encrypt(plaintext: string, password: string): string {
].join(':') ].join(':')
} }
// 解密数据 /**
* 异步加密(推荐,不阻塞事件循环)
*/
export async function encryptAsync(plaintext: string, password: string): Promise<string> {
const salt = crypto.randomBytes(SALT_LENGTH)
const key = await deriveKeyAsync(password, salt)
const iv = crypto.randomBytes(IV_LENGTH)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
// 立即清零密钥内存
key.fill(0)
return [
CRYPTO_VERSION,
salt.toString('hex'),
iv.toString('hex'),
tag.toString('hex'),
encrypted.toString('hex'),
].join(':')
}
/**
* 解密数据
* 支持带版本标识的新格式和不带版本标识的旧格式(向后兼容)
*/
export function decrypt(ciphertext: string, password: string): string { export function decrypt(ciphertext: string, password: string): string {
try { try {
const [saltHex, ivHex, tagHex, encryptedHex] = ciphertext.split(':') const parts = ciphertext.split(':')
// 判断格式:新格式有5部分(含version),旧格式有4部分
let saltHex: string, ivHex: string, tagHex: string, encryptedHex: string
if (parts.length === 5) {
// 新格式: version:salt:iv:tag:encrypted
[, saltHex, ivHex, tagHex, encryptedHex] = parts
} else if (parts.length === 4) {
// 旧格式: salt:iv:tag:encrypted(向后兼容)
[saltHex, ivHex, tagHex, encryptedHex] = parts
} else {
throw new Error('Invalid ciphertext format')
}
if (!saltHex || !ivHex || !tagHex || !encryptedHex) { if (!saltHex || !ivHex || !tagHex || !encryptedHex) {
throw new Error('Invalid ciphertext format') throw new Error('Invalid ciphertext format')
@@ -50,22 +114,32 @@ export function decrypt(ciphertext: string, password: string): string {
const tag = Buffer.from(tagHex, 'hex') const tag = Buffer.from(tagHex, 'hex')
const encrypted = Buffer.from(encryptedHex, 'hex') const encrypted = Buffer.from(encryptedHex, 'hex')
const key = deriveKey(password, salt) const key = deriveKeySync(password, salt)
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(tag) decipher.setAuthTag(tag)
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]) const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
// 清零密钥内存
key.fill(0)
return decrypted.toString('utf8') return decrypted.toString('utf8')
} catch (error) { } catch (error) {
throw new Error('Decryption failed: ' + (error as Error).message) throw new Error('Decryption failed: ' + (error as Error).message)
} }
} }
// 简单加密(不带 salt,用于可逆加密) /**
* 简单加密(使用主密钥,不经过 PBKDF2)
* 用于内部数据加密,密钥长度必须正确
*/
export function simpleEncrypt(plaintext: string, key: string): string { export function simpleEncrypt(plaintext: string, key: string): string {
const iv = crypto.randomBytes(IV_LENGTH) const iv = crypto.randomBytes(IV_LENGTH)
const keyBuffer = Buffer.from(key, 'hex').slice(0, KEY_LENGTH) const keyBuffer = Buffer.from(key, 'hex')
// 验证密钥长度
if (keyBuffer.length !== KEY_LENGTH) {
throw new Error(`Invalid key length: expected ${KEY_LENGTH} bytes, got ${keyBuffer.length}`)
}
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv) const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
@@ -74,15 +148,26 @@ export function simpleEncrypt(plaintext: string, key: string): string {
return iv.toString('hex') + ':' + tag.toString('hex') + ':' + encrypted.toString('hex') return iv.toString('hex') + ':' + tag.toString('hex') + ':' + encrypted.toString('hex')
} }
// 简单解密 /**
* 简单解密
*/
export function simpleDecrypt(ciphertext: string, key: string): string { export function simpleDecrypt(ciphertext: string, key: string): string {
try { try {
const [ivHex, tagHex, encryptedHex] = ciphertext.split(':') const [ivHex, tagHex, encryptedHex] = ciphertext.split(':')
if (!ivHex || !tagHex || !encryptedHex) {
throw new Error('Invalid ciphertext format')
}
const iv = Buffer.from(ivHex, 'hex') const iv = Buffer.from(ivHex, 'hex')
const tag = Buffer.from(tagHex, 'hex') const tag = Buffer.from(tagHex, 'hex')
const encrypted = Buffer.from(encryptedHex, 'hex') const encrypted = Buffer.from(encryptedHex, 'hex')
const keyBuffer = Buffer.from(key, 'hex').slice(0, KEY_LENGTH) const keyBuffer = Buffer.from(key, 'hex')
// 验证密钥长度
if (keyBuffer.length !== KEY_LENGTH) {
throw new Error(`Invalid key length: expected ${KEY_LENGTH} bytes, got ${keyBuffer.length}`)
}
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv) const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv)
decipher.setAuthTag(tag) decipher.setAuthTag(tag)
+53 -47
View File
@@ -5,13 +5,14 @@ import fs from 'fs'
import { simpleEncrypt, simpleDecrypt } from './crypto' import { simpleEncrypt, simpleDecrypt } from './crypto'
import { keyService } from './key-service' import { keyService } from './key-service'
import { createLogger } from '../utils/logger' import { createLogger } from '../utils/logger'
import type { Session, SessionCreateRequest, SessionUpdateRequest } from '../../shared/types/session.types'
const logger = createLogger('DatabaseService') const logger = createLogger('DatabaseService')
// sql.js statement wrapper to match better-sqlite3 API // sql.js statement wrapper to match better-sqlite3 API
interface StatementResult { interface StatementResult {
id: number id: number
[key: string]: any [key: string]: unknown
} }
class PreparedStatement { class PreparedStatement {
@@ -23,16 +24,18 @@ class PreparedStatement {
this.sql = sql this.sql = sql
} }
run(...params: any[]): StatementResult { run(...params: unknown[]): StatementResult {
this.db.run(this.sql, params as any) // eslint-disable-next-line @typescript-eslint/no-explicit-any
return { id: 0 } as StatementResult this.db.run(this.sql, params as any[])
return { id: 0 }
} }
get(...params: any[]): any { get(...params: unknown[]): unknown {
// 使用 exec 模式获取单行 // 使用 exec 模式获取单行
const stmt = this.db.prepare(this.sql) const stmt = this.db.prepare(this.sql)
stmt.bind(params as any) // eslint-disable-next-line @typescript-eslint/no-explicit-any
let result: any = null stmt.bind(params as any[])
let result: unknown = null
if (stmt.step()) { if (stmt.step()) {
result = stmt.getAsObject() result = stmt.getAsObject()
} }
@@ -40,10 +43,11 @@ class PreparedStatement {
return result return result
} }
all(...params: any[]): any[] { all(...params: unknown[]): unknown[] {
const stmt = this.db.prepare(this.sql) const stmt = this.db.prepare(this.sql)
stmt.bind(params as any) // eslint-disable-next-line @typescript-eslint/no-explicit-any
const results: any[] = [] stmt.bind(params as any[])
const results: unknown[] = []
while (stmt.step()) { while (stmt.step()) {
results.push(stmt.getAsObject()) results.push(stmt.getAsObject())
} }
@@ -200,7 +204,6 @@ export class DatabaseService {
} }
try { try {
const decrypted = simpleDecrypt(value, this.masterKey) const decrypted = simpleDecrypt(value, this.masterKey)
logger.info('Decrypted field length:', decrypted?.length || 0)
return decrypted return decrypted
} catch (error) { } catch (error) {
logger.error('Decrypt failed:', error) logger.error('Decrypt failed:', error)
@@ -209,10 +212,10 @@ export class DatabaseService {
} }
// 会话 CRUD 操作 // 会话 CRUD 操作
createSession(session: any): any { createSession(session: SessionCreateRequest): Session | null {
if (!this.db) throw new Error('Database not initialized') if (!this.db) throw new Error('Database not initialized')
logger.info('Creating session with password:', session.password ? `len=${session.password.length}` : 'null/undefined') logger.info('Creating session:', session.name, session.host)
const stmt = this.prepare(` const stmt = this.prepare(`
INSERT INTO sessions (id, name, host, port, username, auth_type, password, private_key_path, passphrase_encrypted, group_name, tags) INSERT INTO sessions (id, name, host, port, username, auth_type, password, private_key_path, passphrase_encrypted, group_name, tags)
@@ -221,7 +224,6 @@ export class DatabaseService {
const id = `session-${Date.now()}` const id = `session-${Date.now()}`
const encryptedPassword = this.encryptField(session.password) const encryptedPassword = this.encryptField(session.password)
logger.info('Encrypted password:', encryptedPassword ? `len=${encryptedPassword.length}` : 'null')
stmt.run( stmt.run(
id, id,
@@ -241,56 +243,56 @@ export class DatabaseService {
return this.getSessionById(id) return this.getSessionById(id)
} }
getAllSessions(): any[] { getAllSessions(): Session[] {
if (!this.db) throw new Error('Database not initialized') if (!this.db) throw new Error('Database not initialized')
const stmt = this.prepare('SELECT * FROM sessions ORDER BY created_at DESC') const stmt = this.prepare('SELECT * FROM sessions ORDER BY created_at DESC')
const sessions = stmt.all() const sessions = stmt.all() as Record<string, unknown>[]
return sessions.map((s: any) => ({ return sessions.map((s) => ({
id: s.id, id: s.id as string,
name: s.name, name: s.name as string,
host: s.host, host: s.host as string,
port: s.port, port: s.port as number,
username: s.username, username: s.username as string,
authType: s.auth_type, authType: s.auth_type as Session['authType'],
password: this.decryptField(s.password), password: this.decryptField(s.password as string | null) || undefined,
privateKey: s.private_key_path, privateKey: s.private_key_path as string | undefined,
passphrase: this.decryptField(s.passphrase_encrypted), passphrase: this.decryptField(s.passphrase_encrypted as string | null) || undefined,
group: s.group_name, group: s.group_name as string | undefined,
tags: s.tags ? JSON.parse(s.tags) : [], tags: s.tags ? JSON.parse(s.tags as string) as string[] : [],
})) }))
} }
getSessionById(id: string): any { getSessionById(id: string): Session | null {
if (!this.db) throw new Error('Database not initialized') if (!this.db) throw new Error('Database not initialized')
const stmt = this.prepare('SELECT * FROM sessions WHERE id = ?') const stmt = this.prepare('SELECT * FROM sessions WHERE id = ?')
const session = stmt.get(id) as any const session = stmt.get(id) as Record<string, unknown> | undefined
if (session) { if (session) {
return { return {
id: session.id, id: session.id as string,
name: session.name, name: session.name as string,
host: session.host, host: session.host as string,
port: session.port, port: session.port as number,
username: session.username, username: session.username as string,
authType: session.auth_type, authType: session.auth_type as Session['authType'],
password: this.decryptField(session.password), password: this.decryptField(session.password as string | null) || undefined,
privateKey: session.private_key_path, privateKey: session.private_key_path as string | undefined,
passphrase: this.decryptField(session.passphrase_encrypted), passphrase: this.decryptField(session.passphrase_encrypted as string | null) || undefined,
group: session.group_name, group: session.group_name as string | undefined,
tags: session.tags ? JSON.parse(session.tags) : [], tags: session.tags ? JSON.parse(session.tags as string) as string[] : [],
} }
} }
return null return null
} }
updateSession(id: string, updates: any): any { updateSession(id: string, updates: SessionUpdateRequest): Session | null {
if (!this.db) throw new Error('Database not initialized') if (!this.db) throw new Error('Database not initialized')
const fields: string[] = [] const fields: string[] = []
const values: any[] = [] const values: unknown[] = []
if (updates.name !== undefined) { if (updates.name !== undefined) {
fields.push('name = ?') fields.push('name = ?')
@@ -320,6 +322,10 @@ export class DatabaseService {
fields.push('passphrase_encrypted = ?') fields.push('passphrase_encrypted = ?')
values.push(this.encryptField(updates.passphrase)) values.push(this.encryptField(updates.passphrase))
} }
if (updates.privateKey !== undefined) {
fields.push('private_key_path = ?')
values.push(updates.privateKey || null)
}
if (updates.group !== undefined) { if (updates.group !== undefined) {
fields.push('group_name = ?') fields.push('group_name = ?')
values.push(updates.group) values.push(updates.group)
@@ -350,15 +356,15 @@ export class DatabaseService {
} }
// 设置操作 // 设置操作
getSetting(key: string): any { getSetting(key: string): unknown {
if (!this.db) throw new Error('Database not initialized') if (!this.db) throw new Error('Database not initialized')
const stmt = this.prepare('SELECT value FROM settings WHERE key = ?') const stmt = this.prepare('SELECT value FROM settings WHERE key = ?')
const row: any = stmt.get(key) const row = stmt.get(key) as Record<string, unknown> | undefined
return row ? JSON.parse(row.value) : null return row ? JSON.parse(row.value as string) : null
} }
setSetting(key: string, value: any): void { setSetting(key: string, value: unknown): void {
if (!this.db) throw new Error('Database not initialized') if (!this.db) throw new Error('Database not initialized')
const stmt = this.prepare(` const stmt = this.prepare(`
+58 -10
View File
@@ -1,4 +1,4 @@
import { app } from 'electron' import { app, safeStorage } from 'electron'
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs'
import crypto from 'crypto' import crypto from 'crypto'
@@ -6,31 +6,79 @@ import { createLogger } from '../utils/logger'
const logger = createLogger('KeyService') const logger = createLogger('KeyService')
const KEY_FILE = 'encryption.key' const KEY_FILE = 'encryption.key.enc'
const LEGACY_KEY_FILE = 'encryption.key'
export class KeyService { export class KeyService {
private key: string | null = null private key: string | null = null
// 获取或创建主密钥 /**
* 获取或创建主密钥
* 使用 Electron safeStorage 加密存储,替代明文存储
*/
getMasterKey(): string { getMasterKey(): string {
if (this.key) return this.key if (this.key) return this.key
const keyPath = path.join(app.getPath('userData'), KEY_FILE) const userDataPath = app.getPath('userData')
const keyPath = path.join(userDataPath, KEY_FILE)
const legacyKeyPath = path.join(userDataPath, LEGACY_KEY_FILE)
// 尝试读取现有密钥 // 1. 尝试读取新的加密格式密钥
if (fs.existsSync(keyPath)) { if (fs.existsSync(keyPath)) {
try { try {
this.key = fs.readFileSync(keyPath, 'utf8').trim() const encrypted = fs.readFileSync(keyPath)
this.key = safeStorage.decryptString(encrypted)
logger.info('Master key loaded from encrypted storage')
return this.key return this.key
} catch (error) { } catch (error) {
logger.error('Failed to read master key:', error) logger.error('Failed to decrypt master key, generating new one:', error)
} }
} }
// 生成新密钥 // 2. 迁移旧的明文密钥(向后兼容)
this.key = crypto.randomBytes(32).toString('hex') if (fs.existsSync(legacyKeyPath)) {
fs.writeFileSync(keyPath, this.key, { mode: 0o600 }) try {
this.key = fs.readFileSync(legacyKeyPath, 'utf8').trim()
// 迁移到加密存储
this.saveEncryptedKey(this.key)
// 安全删除旧文件(覆盖后删除)
this.secureDelete(legacyKeyPath)
logger.info('Migrated master key from plaintext to encrypted storage')
return this.key return this.key
} catch (error) {
logger.error('Failed to migrate legacy key:', error)
}
}
// 3. 生成新密钥
this.key = crypto.randomBytes(32).toString('hex')
this.saveEncryptedKey(this.key)
logger.info('Generated new master key with encrypted storage')
return this.key
}
// 使用 safeStorage 加密保存密钥
private saveEncryptedKey(key: string): void {
const keyPath = path.join(app.getPath('userData'), KEY_FILE)
const encrypted = safeStorage.encryptString(key)
fs.writeFileSync(keyPath, encrypted, { mode: 0o600 })
}
// 安全删除文件(先覆盖再删除)
private secureDelete(filePath: string): void {
try {
const stats = fs.statSync(filePath)
const size = stats.size
// 用随机数据覆盖
const randomData = crypto.randomBytes(size)
fs.writeFileSync(filePath, randomData)
// 再用零覆盖
fs.writeFileSync(filePath, Buffer.alloc(size, 0))
// 最后删除
fs.unlinkSync(filePath)
} catch (error) {
logger.error('Failed to securely delete file:', error)
}
} }
// 清除密钥(登出时) // 清除密钥(登出时)
+58 -37
View File
@@ -1,30 +1,32 @@
import { Client, ClientChannel } from 'ssh2' import { Client, ClientChannel, SFTPWrapper, FileEntryWithStats } from 'ssh2'
import { SFTPWrapper } from 'ssh2'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '../utils/logger' import { createLogger } from '../utils/logger'
import * as net from 'net' import * as net from 'net'
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
const logger = createLogger('SSHService') const logger = createLogger('SSHService')
// ===== 彩色调试日志 ===== // 生产环境日志:使用结构化日志,不输出敏感信息
const DEBUG = true const DEBUG = process.env.NODE_ENV === 'development'
function debug(...args: any[]) { function debug(...args: unknown[]) {
if (DEBUG) console.log('[SSHService]', ...args) if (DEBUG) logger.debug('[SSHService]', ...args)
} }
function logHeader(msg: string) { function logHeader(msg: string) {
console.log(`%c${msg}`, 'background: #e74c3c; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;') logger.info(`[${msg}]`)
} }
function logInfo(label: string, value: any) { function logInfo(label: string, value: unknown) {
console.log(`%c${label}:%c ${value}`, 'background: #2c3e50; color: #fff; padding: 2px 6px; border-radius: 3px;', 'color: #3498db; font-weight: bold;') logger.info(`${label}: ${value}`)
} }
function logSuccess(msg: string) { function logSuccess(msg: string) {
console.log(`%c✓ ${msg}`, 'color: #27ae60; font-weight: bold;') logger.info(`[OK] ${msg}`)
} }
function logError(msg: string) { function logError(msg: string) {
console.log(`%c✗ ${msg}`, 'color: #e74c3c; font-weight: bold;') logger.error(msg)
} }
function logWarning(msg: string) { function _logWarning(msg: string) {
console.log(`%c⚠ ${msg}`, 'color: #f39c12; font-weight: bold;') logger.warn(msg)
} }
export interface SSHConfig { export interface SSHConfig {
@@ -63,11 +65,34 @@ export class SSHService {
private shells: Map<string, ShellSession> = new Map() private shells: Map<string, ShellSession> = new Map()
private sftps: Map<string, SFTPSession> = new Map() private sftps: Map<string, SFTPSession> = new Map()
private resolvePrivateKey(privateKey?: string): string | Buffer | undefined {
if (!privateKey) return undefined
// 如果已经是 PEM 格式内容,直接返回
if (privateKey.includes('-----BEGIN')) {
return privateKey
}
// 展开 ~ 为用户主目录
let keyPath = privateKey
if (keyPath.startsWith('~')) {
keyPath = path.join(os.homedir(), keyPath.slice(1))
}
// 当作文件路径读取
try {
const content = fs.readFileSync(keyPath)
debug('>>> Private key loaded from:', keyPath)
return content
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`Failed to read private key file: ${keyPath} - ${msg}`)
throw new Error(`私钥文件读取失败: ${keyPath}`)
}
}
async connect(config: SSHConfig): Promise<Connection> { async connect(config: SSHConfig): Promise<Connection> {
console.log('=== SSHService connect ===') debug('SSHService connect', config.host, config.port || 22, config.username)
console.log(' host:', config.host)
console.log(' port:', config.port || 22)
console.log(' username:', config.username)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const client = new Client() const client = new Client()
@@ -105,13 +130,24 @@ export class SSHService {
this.connections.delete(connectionId) this.connections.delete(connectionId)
}) })
// 解析私钥(路径 → 内容)
let resolvedPrivateKey: string | Buffer | undefined
try {
resolvedPrivateKey = this.resolvePrivateKey(config.privateKey)
} catch (err) {
connection.status = 'error'
this.connections.delete(connectionId)
reject(err)
return
}
debug('>>> Starting SSH client.connect...') debug('>>> Starting SSH client.connect...')
client.connect({ client.connect({
host: config.host, host: config.host,
port: config.port || 22, port: config.port || 22,
username: config.username, username: config.username,
password: config.password, password: config.password,
privateKey: config.privateKey, privateKey: resolvedPrivateKey,
passphrase: config.passphrase, passphrase: config.passphrase,
agent: config.agent, agent: config.agent,
readyTimeout: config.readyTimeout || 20000, readyTimeout: config.readyTimeout || 20000,
@@ -190,14 +226,10 @@ export class SSHService {
onData?: (shellId: string, data: Buffer) => void, onData?: (shellId: string, data: Buffer) => void,
preferredShellId?: string preferredShellId?: string
): Promise<ShellSession> { ): Promise<ShellSession> {
console.log('=== SSHService createShell ===') debug('SSHService createShell', connectionId, options, preferredShellId)
console.log(' connectionId:', connectionId)
console.log(' options:', options)
console.log(' preferredShellId:', preferredShellId)
const connection = this.connections.get(connectionId) const connection = this.connections.get(connectionId)
console.log(' connection found:', !!connection) debug('connection found:', !!connection, 'status:', connection?.status)
console.log(' connection status:', connection?.status)
if (!connection || connection.status !== 'connected') { if (!connection || connection.status !== 'connected') {
throw new Error('Connection not established') throw new Error('Connection not established')
@@ -236,19 +268,8 @@ export class SSHService {
if (onData) { if (onData) {
debug('>>> Setting up channel.on(data) handler') debug('>>> Setting up channel.on(data) handler')
channel.on('data', (data: Buffer) => { channel.on('data', (data: Buffer) => {
// Log first few chunks for debugging debug('SSHService channel.on(data) shellId:', shellId, 'data length:', data.length)
const text = data.toString('utf-8')
console.log('=== SSHService channel.on(data) ===')
console.log(' shellId:', shellId)
console.log(' data length:', data.length)
console.log(' data preview:', text.substring(0, 100).replace(/\n/g, '\\n'))
if (text.includes('Last login') || text.includes('Welcome')) {
logger.info('[SSHService] Shell data (first 200 chars):', text.substring(0, 200))
}
debug('>>> Calling onData callback...')
onData(shellId, data) onData(shellId, data)
debug('>>> onData callback returned')
}) })
} else { } else {
debug('>>> WARNING: onData callback is undefined!') debug('>>> WARNING: onData callback is undefined!')
@@ -321,7 +342,7 @@ export class SSHService {
}) })
} }
async sftpListDirectory(sftpId: string, path: string): Promise<any[]> { async sftpListDirectory(sftpId: string, path: string): Promise<FileEntryWithStats[]> {
logHeader('📁 SFTP LIST DIRECTORY') logHeader('📁 SFTP LIST DIRECTORY')
logInfo('sftpId', sftpId) logInfo('sftpId', sftpId)
logInfo('path', path) logInfo('path', path)
+41 -25
View File
@@ -10,17 +10,17 @@ const logger = createLogger('ZmodemService')
interface ZmodemSendSession { interface ZmodemSendSession {
type: 'send' type: 'send'
set_sender(func: (octets: number[]) => void): void set_sender(func: (octets: number[]) => void): void
send_offer(params: { name: string; size: number; mtime?: number }): Promise<any> send_offer(params: { name: string; size: number; mtime?: number }): Promise<unknown>
on(event: string, handler: (data: any) => void): void on(event: string, handler: (data: unknown) => void): void
abort(): void abort(): void
close(): Promise<void> close(): Promise<unknown>
} }
interface ZmodemReceiveSession { interface ZmodemReceiveSession {
type: 'receive' type: 'receive'
set_sender(func: (octets: number[]) => void): void set_sender(func: (octets: number[]) => void): void
start(): Promise<any> start(): Promise<unknown>
on(event: string, handler: (data: any) => void): void on(event: string, handler: (data: unknown) => void): void
abort(): void abort(): void
} }
@@ -28,6 +28,20 @@ interface ZmodemSentry {
consume(octets: number[]): void consume(octets: number[]): void
} }
interface ZmodemDetection {
get_session(): { type: string } & ZmodemSendSession
}
interface ZmodemOffer {
getDetails(): { name: string; size: number }
accept(options: { on_input: string }): Promise<ZmodemTransfer>
}
interface ZmodemTransfer {
on(event: 'input', handler: (data: Uint8Array) => void): void
on(event: 'complete', handler: () => void): void
}
export interface ZmodemProgress { export interface ZmodemProgress {
percentage: number percentage: number
transferred: number transferred: number
@@ -80,7 +94,7 @@ export class ZmodemService {
id: sessionId, id: sessionId,
shellId, shellId,
type: 'send', type: 'send',
session: null as any, session: null as unknown as ZmodemSendSession,
fileName, fileName,
totalSize, totalSize,
transferred: 0 transferred: 0
@@ -94,13 +108,12 @@ export class ZmodemService {
} }
// Create Sentry to detect ZRINIT from server // Create Sentry to detect ZRINIT from server
// @ts-ignore - zmodem-ts types are incomplete
const sentry = new Zmodem.Sentry({ const sentry = new Zmodem.Sentry({
to_terminal: (octets: number[]) => { to_terminal: (octets: number[]) => {
// Echo to terminal // Echo to terminal
writeFunc(new Uint8Array(octets)) writeFunc(new Uint8Array(octets))
}, },
on_detect: (detection: any) => { on_detect: (detection: ZmodemDetection) => {
const detectedSession = detection.get_session() const detectedSession = detection.get_session()
if (detectedSession && detectedSession.type === 'send') { if (detectedSession && detectedSession.type === 'send') {
// Server is ready for file transfer (received ZRINIT) // Server is ready for file transfer (received ZRINIT)
@@ -119,9 +132,10 @@ export class ZmodemService {
this.sessions.delete(sessionId) this.sessions.delete(sessionId)
}) })
sendSession.on('error', (err: Error) => { sendSession.on('error', (err: unknown) => {
logger.error('Zmodem send error:', err) const error = err instanceof Error ? err : new Error(String(err))
onComplete?.(false, err.message) logger.error('Zmodem send error:', error)
onComplete?.(false, error.message)
}) })
// Send the file // Send the file
@@ -181,9 +195,9 @@ export class ZmodemService {
// Transfer was skipped or rejected // Transfer was skipped or rejected
onComplete?.(false, 'Transfer skipped or rejected') onComplete?.(false, 'Transfer skipped or rejected')
} }
} catch (err: any) { } catch (err: unknown) {
logger.error('Zmodem send error:', err) logger.error('Zmodem send error:', err)
onComplete?.(false, err.message) onComplete?.(false, err instanceof Error ? err.message : String(err))
} }
} }
@@ -198,7 +212,7 @@ export class ZmodemService {
} }
// Also check all sessions for matching shellId // Also check all sessions for matching shellId
for (const [sessionId, session] of this.sessions) { for (const [, session] of this.sessions) {
if (session.shellId === shellId && session.sentry) { if (session.shellId === shellId && session.sentry) {
// Feed to sentry for detection // Feed to sentry for detection
} }
@@ -222,7 +236,7 @@ export class ZmodemService {
const fileName = path.basename(filePath) const fileName = path.basename(filePath)
// Create receive session // Create receive session
// @ts-ignore - zmodem-ts types are incomplete // @ts-expect-error - zmodem-ts types are incomplete
const session: ZmodemReceiveSession = new Zmodem.ReceiveSession() const session: ZmodemReceiveSession = new Zmodem.ReceiveSession()
const sessionInfo: ZmodemSessionInfo = { const sessionInfo: ZmodemSessionInfo = {
@@ -248,17 +262,18 @@ export class ZmodemService {
}) })
// Handle file offer // Handle file offer
session.on('offer', (offer: any) => { session.on('offer', (offer: unknown) => {
const details = offer.getDetails() const zmodemOffer = offer as ZmodemOffer
const details = zmodemOffer.getDetails()
logger.info('Zmodem offer received:', details) logger.info('Zmodem offer received:', details)
sessionInfo.fileName = details.name || fileName sessionInfo.fileName = details.name || fileName
sessionInfo.totalSize = details.size || 0 sessionInfo.totalSize = details.size || 0
// Accept the offer // Accept the offer
offer.accept({ zmodemOffer.accept({
on_input: 'spool_array' on_input: 'spool_array'
}).then((transfer: any) => { }).then((transfer) => {
// Handle incoming data // Handle incoming data
transfer.on('input', (data: Uint8Array) => { transfer.on('input', (data: Uint8Array) => {
sessionInfo.transferred = (sessionInfo.transferred || 0) + data.length sessionInfo.transferred = (sessionInfo.transferred || 0) + data.length
@@ -266,7 +281,7 @@ export class ZmodemService {
// Write to file // Write to file
try { try {
fs.writeFileSync(filePath, data, { flag: 'a' }) fs.writeFileSync(filePath, data, { flag: 'a' })
} catch (err: any) { } catch (err: unknown) {
logger.error('Error writing Zmodem file:', err) logger.error('Error writing Zmodem file:', err)
} }
@@ -302,9 +317,10 @@ export class ZmodemService {
this.sessions.delete(sessionId) this.sessions.delete(sessionId)
}) })
session.on('error', (err: Error) => { session.on('error', (err: unknown) => {
logger.error('Zmodem receive error:', err) const error = err instanceof Error ? err : new Error(String(err))
onComplete?.(false, err.message) logger.error('Zmodem receive error:', error)
onComplete?.(false, error.message)
}) })
// Start receiving - this sends ZRINIT to signal we're ready // Start receiving - this sends ZRINIT to signal we're ready
@@ -324,10 +340,10 @@ export class ZmodemService {
const session = this.sessions.get(sessionId) const session = this.sessions.get(sessionId)
if (session) { if (session) {
try { try {
const s = session.session as any const s = session.session as { abort?: () => void; close?: () => void }
s.abort?.() s.abort?.()
s.close?.() s.close?.()
} catch (err: any) { } catch (err: unknown) {
logger.error('Error canceling Zmodem session:', err) logger.error('Error canceling Zmodem session:', err)
} }
-1
View File
@@ -4,7 +4,6 @@
"module": "commonjs", "module": "commonjs",
"lib": ["ES2020"], "lib": ["ES2020"],
"outDir": "../dist-electron", "outDir": "../dist-electron",
"rootDir": ".",
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
+6 -2
View File
@@ -1,11 +1,15 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN" class="dark" data-theme="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws://localhost:*;"> <meta name="color-scheme" content="dark" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws://localhost:*;">
<title>OpsXShell · 跨平台 SSH/SFTP 终端工具</title> <title>OpsXShell · 跨平台 SSH/SFTP 终端工具</title>
<style>
html, body { background-color: #0f172a !important; }
</style>
<script src="/electron-api-mock.js"></script> <script src="/electron-api-mock.js"></script>
</head> </head>
<body> <body>
+1063 -179
View File
File diff suppressed because it is too large. Load diff
+18 -4
View File
@@ -1,36 +1,50 @@
{ {
"name": "opsxshell-gui", "name": "opsxshell-gui",
"version": "1.0.0", "version": "2.0.0",
"description": "跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠", "description": "跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠 · v2.0 安全增强版",
"main": "dist-electron/main.js", "main": "dist-electron/main.js",
"author": "OpsXShell Team", "author": "OpsXShell Team",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "vite & sleep 2 && electron dist-electron/main.js --inspect=9222", "dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron dist-electron/main.js --inspect=9222\"",
"build": "tsc && vite build && npm run build:electron", "build": "tsc --noEmit && vite build && npm run build:electron",
"build:electron": "tsc -p electron/tsconfig.json", "build:electron": "tsc -p electron/tsconfig.json",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --ext ts,tsx --fix",
"format": "prettier --write \"**/*.{ts,tsx,css,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,css,json,md}\"",
"typecheck": "tsc --noEmit && tsc --noEmit -p electron/tsconfig.json",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"clean": "rimraf dist dist-electron release",
"prepare": "husky install || true",
"dist": "npm run build && electron-builder", "dist": "npm run build && electron-builder",
"dist:win": "npm run build && electron-builder --win", "dist:win": "npm run build && electron-builder --win",
"dist:mac": "npm run build && electron-builder --mac", "dist:mac": "npm run build && electron-builder --mac",
"dist:linux": "npm run build && electron-builder --linux" "dist:linux": "npm run build && electron-builder --linux"
}, },
"dependencies": { "dependencies": {
"@vitest/coverage-v8": "^4.1.4",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.16.0", "@xterm/addon-search": "^0.16.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"concurrently": "^9.2.1",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.7.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.21.0", "react-router-dom": "^6.21.0",
"rimraf": "^6.1.3",
"socksv5": "^0.0.6", "socksv5": "^0.0.6",
"sql.js": "^1.11.0", "sql.js": "^1.11.0",
"ssh2": "^1.15.0", "ssh2": "^1.15.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"wait-on": "^9.0.5",
"zmodem-ts": "^1.0.8", "zmodem-ts": "^1.0.8",
"zustand": "^4.4.7" "zustand": "^4.4.7"
}, },
+1 -1
View File
@@ -1,4 +1,4 @@
export default { module.exports = {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
+1 -1
View File
@@ -34,7 +34,7 @@ export interface SSHExecResponse {
} }
export interface SSHCreateShellRequest { export interface SSHCreateShellRequest {
connectionId: string connectionId?: string
cols?: number cols?: number
rows?: number rows?: number
} }
+1
View File
@@ -14,6 +14,7 @@ export interface SSHConnectionConfig {
password?: string password?: string
privateKey?: string privateKey?: string
passphrase?: string passphrase?: string
authType?: AuthType
} }
// 持久化会话 // 持久化会话
+38 -19
View File
@@ -1,12 +1,8 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback, lazy, Suspense } from 'react'
import Sidebar from './components/Sidebar' import Sidebar from './components/Sidebar'
import TabBar from './components/TabBar' import TabBar from './components/TabBar'
import TabContent from './components/TabContent' import TabContent from './components/TabContent'
import StatusBar from './components/StatusBar' import StatusBar from './components/StatusBar'
import ConnectionPanel from './components/ConnectionPanel'
import SessionDialog from './components/SessionDialog'
import QuickConnectDialog from './components/QuickConnectDialog'
import { CommandPalette } from './components/common/CommandPalette'
import { useTabStore } from './stores/tabStore' import { useTabStore } from './stores/tabStore'
import { useSessionStore, useCommandPaletteStore, type Session } from './stores' import { useSessionStore, useCommandPaletteStore, type Session } from './stores'
import { createLogger } from './utils/logger' import { createLogger } from './utils/logger'
@@ -16,6 +12,12 @@ import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { useIsMobile } from './hooks/useMediaQuery' import { useIsMobile } from './hooks/useMediaQuery'
import { useTheme } from './hooks/useTheme' import { useTheme } from './hooks/useTheme'
// Lazy load heavy components that are not always visible
const ConnectionPanel = lazy(() => import('./components/ConnectionPanel'))
const SessionDialog = lazy(() => import('./components/SessionDialog'))
const QuickConnectDialog = lazy(() => import('./components/QuickConnectDialog'))
const CommandPalette = lazy(() => import('./components/common/CommandPalette').then(m => ({ default: m.CommandPalette })))
const logger = createLogger('App') const logger = createLogger('App')
function App() { function App() {
@@ -63,7 +65,7 @@ function App() {
}, []) }, [])
// 导出会话 // 导出会话
const handleExportSessions = async () => { const handleExportSessions = useCallback(async () => {
try { try {
const result = await window.electronAPI?.showSaveDialog({ const result = await window.electronAPI?.showSaveDialog({
title: '导出会话', title: '导出会话',
@@ -91,10 +93,10 @@ function App() {
logger.error('Export failed:', error) logger.error('Export failed:', error)
toast.error('导出会话失败') toast.error('导出会话失败')
} }
} }, [sessions, toast])
// 导入会话 // 导入会话
const handleImportSessions = async () => { const handleImportSessions = useCallback(async () => {
try { try {
const result = await window.electronAPI?.showOpenDialog({ const result = await window.electronAPI?.showOpenDialog({
title: '导入会话', title: '导入会话',
@@ -130,7 +132,7 @@ function App() {
importSessions = data.sessions importSessions = data.sessions
} else if (data.hosts && Array.isArray(data.hosts)) { } else if (data.hosts && Array.isArray(data.hosts)) {
// SSH config 格式 { hosts: [...] } // SSH config 格式 { hosts: [...] }
importSessions = data.hosts.map((h: any) => ({ importSessions = data.hosts.map((h: { name?: string; host?: string; port?: number; user?: string; password?: string; privateKey?: string; group?: string }) => ({
name: h.name || h.host, name: h.name || h.host,
host: h.host, host: h.host,
port: h.port || 22, port: h.port || 22,
@@ -145,7 +147,7 @@ function App() {
// 不是 JSON,尝试 SSH Config 格式 // 不是 JSON,尝试 SSH Config 格式
const lines = fileResult.data.split('\n') const lines = fileResult.data.split('\n')
const hosts: Partial<Session>[] = [] const hosts: Partial<Session>[] = []
let currentHost: any = null let currentHost: { name?: string; host?: string; port?: number; username?: string; authType?: 'password' | 'key'; privateKey?: string } | null = null
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim() const trimmed = line.trim()
@@ -185,9 +187,9 @@ function App() {
try { try {
await window.electronAPI?.sessionCreate({ await window.electronAPI?.sessionCreate({
name: session.name || `${session.username}@${session.host}`, name: session.name || `${session.username}@${session.host}`,
host: session.host, host: session.host!,
port: session.port || 22, port: session.port || 22,
username: session.username, username: session.username!,
authType: session.authType || 'password', authType: session.authType || 'password',
password: session.password, password: session.password,
privateKey: session.privateKey, privateKey: session.privateKey,
@@ -210,7 +212,7 @@ function App() {
logger.error('Import failed:', error) logger.error('Import failed:', error)
toast.error('导入会话失败') toast.error('导入会话失败')
} }
} }, [toast])
useEffect(() => { useEffect(() => {
// Listen for menu events from main process // Listen for menu events from main process
@@ -226,6 +228,8 @@ function App() {
window.electronAPI.onMenuImportSessions(handleImportSessions) window.electronAPI.onMenuImportSessions(handleImportSessions)
window.electronAPI.onMenuExportSessions(handleExportSessions) window.electronAPI.onMenuExportSessions(handleExportSessions)
window.electronAPI.onMenuOpenSettings(handleOpenSettings)
} }
return () => { return () => {
@@ -234,14 +238,21 @@ function App() {
window.electronAPI.removeAllListeners('menu-quick-connect') window.electronAPI.removeAllListeners('menu-quick-connect')
window.electronAPI.removeAllListeners('menu-import-sessions') window.electronAPI.removeAllListeners('menu-import-sessions')
window.electronAPI.removeAllListeners('menu-export-sessions') window.electronAPI.removeAllListeners('menu-export-sessions')
window.electronAPI.removeAllListeners('menu-open-settings')
} }
} }
}, [sessions]) }, [handleExportSessions, handleImportSessions, handleOpenSettings])
return ( return (
<div className="flex flex-col h-screen bg-dark-900 text-gray-200"> <div className="flex flex-col h-screen bg-dark-900 text-gray-200" style={{ backgroundColor: '#0f172a' }}>
{/* Title bar spacer for macOS */} {/* Title bar spacer for macOS — left area draggable, right area handles double-click */}
<div className="h-8 bg-dark-900 drag"></div> <div className="h-8 bg-dark-900 flex">
<div className="w-20 drag" />
<div
className="flex-1 no-drag"
onDoubleClick={() => window.electronAPI?.toggleMaximize()}
/>
</div>
{/* Main content */} {/* Main content */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
@@ -256,7 +267,7 @@ function App() {
{/* Sidebar container with transition */} {/* Sidebar container with transition */}
<div <div
className={` className={`
transition-all duration-300 ease-in-out flex-shrink-0 transition-all duration-300 ease-in-out flex-shrink-0 h-full bg-dark-800
${isMobile ${isMobile
? `fixed inset-y-0 left-0 z-50 top-8 ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}` ? `fixed inset-y-0 left-0 z-50 top-8 ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`
: sidebarCollapsed ? 'w-0 overflow-hidden' : 'w-64' : sidebarCollapsed ? 'w-0 overflow-hidden' : 'w-64'
@@ -282,7 +293,7 @@ function App() {
</div> </div>
{/* Main area */} {/* Main area */}
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0 bg-dark-800">
{/* Tab bar with mobile sidebar toggle */} {/* Tab bar with mobile sidebar toggle */}
<div className="flex items-center"> <div className="flex items-center">
{isMobile && ( {isMobile && (
@@ -319,9 +330,12 @@ function App() {
<StatusBar /> <StatusBar />
{/* Connection management panel (self-contained via connectionPanelStore) */} {/* Connection management panel (self-contained via connectionPanelStore) */}
<Suspense fallback={null}>
<ConnectionPanel /> <ConnectionPanel />
</Suspense>
{/* Dialogs */} {/* Dialogs */}
<Suspense fallback={null}>
<SessionDialog <SessionDialog
isOpen={isSessionDialogOpen} isOpen={isSessionDialogOpen}
onClose={() => { onClose={() => {
@@ -330,13 +344,17 @@ function App() {
}} }}
editSession={editSession} editSession={editSession}
/> />
</Suspense>
<Suspense fallback={null}>
<QuickConnectDialog <QuickConnectDialog
isOpen={isQuickConnectOpen} isOpen={isQuickConnectOpen}
onClose={() => setIsQuickConnectOpen(false)} onClose={() => setIsQuickConnectOpen(false)}
/> />
</Suspense>
{/* Command Palette — isOpen/close managed via commandPaletteStore */} {/* Command Palette — isOpen/close managed via commandPaletteStore */}
<Suspense fallback={null}>
<CommandPalette <CommandPalette
onNewSession={() => { onNewSession={() => {
closeCommandPalette() closeCommandPalette()
@@ -357,6 +375,7 @@ function App() {
setIsSessionDialogOpen(true) setIsSessionDialogOpen(true)
}} }}
/> />
</Suspense>
{/* Toast Notifications */} {/* Toast Notifications */}
<ToastContainer toasts={toasts} onRemove={removeToast} /> <ToastContainer toasts={toasts} onRemove={removeToast} />
+3 -3
View File
@@ -281,11 +281,11 @@ export default function ConnectionPanel() {
{/* Panel */} {/* Panel */}
<div <div
className="fixed top-0 right-0 bottom-0 w-[360px] z-50 className="fixed top-0 right-0 bottom-0 w-[360px] z-50
bg-dark-800/95 backdrop-blur border-l border-dark-700 bg-dark-800/95 backdrop-blur border-l border-dark-700/30
flex flex-col shadow-xl animate-slide-in-right" flex flex-col shadow-xl animate-slide-in-right"
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 h-12 border-b border-dark-700 shrink-0"> <div className="flex items-center justify-between px-4 h-12 border-b border-dark-700/30 shrink-0">
<h2 className="text-sm font-semibold text-gray-200 flex items-center gap-2"> <h2 className="text-sm font-semibold text-gray-200 flex items-center gap-2">
<Wifi className="w-4 h-4 text-accent-blue" /> <Wifi className="w-4 h-4 text-accent-blue" />
连接管理 连接管理
@@ -300,7 +300,7 @@ export default function ConnectionPanel() {
</div> </div>
{/* Stats Overview */} {/* Stats Overview */}
<div className="px-4 py-3 border-b border-dark-700 shrink-0"> <div className="px-4 py-3 border-b border-dark-700/30 shrink-0">
<StatsOverview <StatsOverview
total={stats.total} total={stats.total}
connected={stats.connected} connected={stats.connected}
+3 -3
View File
@@ -132,8 +132,8 @@ export default function PortForwardView({ tabId, isActive: _isActive = true }: P
} else { } else {
setError(result?.error || '启动失败') setError(result?.error || '启动失败')
} }
} catch (err: any) { } catch (err: unknown) {
setError(err.message || '启动失败') setError((err instanceof Error ? err.message : String(err)) || '启动失败')
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -331,7 +331,7 @@ export default function PortForwardView({ tabId, isActive: _isActive = true }: P
].map((t) => ( ].map((t) => (
<button <button
key={t.type} key={t.type}
onClick={() => setNewRule({ ...newRule, type: t.type as any })} onClick={() => setNewRule({ ...newRule, type: t.type as 'local' | 'remote' | 'dynamic' })}
className={`p-3 rounded-lg border text-left transition-colors ${ className={`p-3 rounded-lg border text-left transition-colors ${
newRule.type === t.type newRule.type === t.type
? 'bg-accent-blue/20 border-accent-blue text-white' ? 'bg-accent-blue/20 border-accent-blue text-white'
+2 -2
View File
@@ -52,8 +52,8 @@ export default function QuickConnectDialog({ isOpen, onClose }: QuickConnectDial
username: '', username: '',
password: '', password: '',
}) })
} catch (err: any) { } catch (err: unknown) {
setError(err.message || '连接失败') setError((err instanceof Error ? err.message : String(err)) || '连接失败')
} finally { } finally {
setIsConnecting(false) setIsConnecting(false)
} }
+18 -2
View File
@@ -4,7 +4,7 @@
* 使用子组件拆分后的简化版本 * 使用子组件拆分后的简化版本
*/ */
import { useState, useCallback } from 'react' import { useState, useCallback, useEffect, useRef } from 'react'
import { import {
FolderOpen, Upload, Download, Trash2, FolderOpen, Upload, Download, Trash2,
Edit3, Copy, FileText, ShieldCheck, Edit3, Copy, FileText, ShieldCheck,
@@ -28,9 +28,10 @@ import type { FileItem, SortField, SortOrder } from '../../shared/types/sftp.typ
interface SFTPViewProps { interface SFTPViewProps {
tabId: string tabId: string
isActive?: boolean isActive?: boolean
sessionId?: string
} }
export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPViewProps) { export default function SFTPView({ tabId, isActive: _isActive = true, sessionId }: SFTPViewProps) {
const { const {
sessions, sessions,
localPath, localPath,
@@ -83,6 +84,21 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
const [chmodFile, setChmodFile] = useState<FileItem | null>(null) const [chmodFile, setChmodFile] = useState<FileItem | null>(null)
const [chmodInitialMode, setChmodInitialMode] = useState(0o644) const [chmodInitialMode, setChmodInitialMode] = useState(0o644)
// Auto-connect when sessionId is provided and not connected
const autoConnectAttempted = useRef(false)
const connectRef = useRef(connect)
connectRef.current = connect
const isConnectedRef = useRef(isConnected)
isConnectedRef.current = isConnected
const isConnectingRef = useRef(isConnecting)
isConnectingRef.current = isConnecting
useEffect(() => {
if (sessionId && !isConnectedRef.current && !isConnectingRef.current && !autoConnectAttempted.current) {
autoConnectAttempted.current = true
connectRef.current(sessionId)
}
}, [sessionId])
// Sort handler // Sort handler
const handleSort = useCallback((field: SortField) => { const handleSort = useCallback((field: SortField) => {
if (sortField === field) { if (sortField === field) {
+6 -6
View File
@@ -18,10 +18,10 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
name: '', name: '',
host: '', host: '',
port: 22, port: 22,
username: '', username: 'root',
authType: 'password' as AuthType, authType: 'password' as AuthType,
password: '', password: '',
privateKey: '', privateKey: '~/.ssh/id_rsa',
passphrase: '', passphrase: '',
group: 'default', group: 'default',
tags: '', tags: '',
@@ -49,10 +49,10 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
name: '', name: '',
host: '', host: '',
port: 22, port: 22,
username: '', username: 'root',
authType: 'password', authType: 'password',
password: '', password: '',
privateKey: '', privateKey: '~/.ssh/id_rsa',
passphrase: '', passphrase: '',
group: 'default', group: 'default',
tags: '', tags: '',
@@ -73,10 +73,10 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
name: formData.name, name: formData.name,
host: formData.host, host: formData.host,
port: formData.port, port: formData.port,
username: formData.username, username: formData.username.trim() || 'root',
authType: formData.authType, authType: formData.authType,
password: formData.password, password: formData.password,
privateKey: formData.privateKey, privateKey: formData.authType === 'key' ? (formData.privateKey.trim() || '~/.ssh/id_rsa') : formData.privateKey,
passphrase: formData.passphrase, passphrase: formData.passphrase,
group: formData.group, group: formData.group,
tags: formData.tags.split(',').map(t => t.trim()).filter(Boolean), tags: formData.tags.split(',').map(t => t.trim()).filter(Boolean),
+27 -27
View File
@@ -148,11 +148,11 @@ export default function SettingsView() {
].map((theme) => ( ].map((theme) => (
<button <button
key={theme.value} key={theme.value}
onClick={() => setSetting('theme', theme.value as any)} onClick={() => setSetting('theme', theme.value as 'dark' | 'light' | 'system')}
className={`p-3 rounded-lg border text-sm transition-colors ${ className={`p-3 rounded-lg border text-sm transition-colors ${
settings.theme === theme.value settings.theme === theme.value
? 'border-accent-blue bg-accent-blue/20 text-accent-blue' ? 'border-accent-blue bg-accent-blue/20 text-accent-blue'
: 'border-dark-700 hover:border-dark-600 text-gray-300' : 'border-dark-700/30 hover:border-dark-600 text-gray-300'
}`} }`}
> >
{theme.label} {theme.label}
@@ -165,8 +165,8 @@ export default function SettingsView() {
<SettingSectionTitle>语言</SettingSectionTitle> <SettingSectionTitle>语言</SettingSectionTitle>
<select <select
value={settings.language} value={settings.language}
onChange={(e) => setSetting('language', e.target.value as any)} onChange={(e) => setSetting('language', e.target.value as 'zh-CN' | 'en-US')}
className="w-full mt-3 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue" className="w-full mt-3 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
> >
<option value="zh-CN">简体中文</option> <option value="zh-CN">简体中文</option>
<option value="en-US">English</option> <option value="en-US">English</option>
@@ -187,7 +187,7 @@ export default function SettingsView() {
<select <select
value={settings.fontFamily} value={settings.fontFamily}
onChange={(e) => setSetting('fontFamily', e.target.value)} onChange={(e) => setSetting('fontFamily', e.target.value)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue" className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
> >
<option>JetBrains Mono</option> <option>JetBrains Mono</option>
<option>Fira Code</option> <option>Fira Code</option>
@@ -200,7 +200,7 @@ export default function SettingsView() {
<select <select
value={settings.fontSize} value={settings.fontSize}
onChange={(e) => setSetting('fontSize', parseInt(e.target.value))} onChange={(e) => setSetting('fontSize', parseInt(e.target.value))}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue" className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
> >
{[12, 13, 14, 15, 16, 18, 20].map((size) => ( {[12, 13, 14, 15, 16, 18, 20].map((size) => (
<option key={size} value={size}>{size}px</option> <option key={size} value={size}>{size}px</option>
@@ -216,11 +216,11 @@ export default function SettingsView() {
{Object.entries(themes).map(([key, theme]) => ( {Object.entries(themes).map(([key, theme]) => (
<button <button
key={key} key={key}
onClick={() => setSetting('terminalTheme', key as any)} onClick={() => setSetting('terminalTheme', key as 'dracula' | 'monokai' | 'solarized' | 'one-dark')}
className={`p-3 rounded-lg border text-sm transition-colors ${ className={`p-3 rounded-lg border text-sm transition-colors ${
settings.terminalTheme === key settings.terminalTheme === key
? 'border-accent-blue bg-accent-blue/20 text-accent-blue' ? 'border-accent-blue bg-accent-blue/20 text-accent-blue'
: 'border-dark-700 hover:border-dark-600 text-gray-300' : 'border-dark-700/30 hover:border-dark-600 text-gray-300'
}`} }`}
> >
<div <div
@@ -240,7 +240,7 @@ export default function SettingsView() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<SettingCard> <SettingCard>
<div className="overflow-hidden rounded-lg border border-dark-700"> <div className="overflow-hidden rounded-lg border border-dark-700/30">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="bg-dark-800/60 text-gray-400 text-left"> <tr className="bg-dark-800/60 text-gray-400 text-left">
@@ -253,7 +253,7 @@ export default function SettingsView() {
{SHORTCUTS.map((s, i) => ( {SHORTCUTS.map((s, i) => (
<tr <tr
key={i} key={i}
className="border-t border-dark-700/60 hover:bg-dark-800/40 transition-colors" className="border-t border-dark-700/20 hover:bg-dark-800/40 transition-colors"
> >
<td className="px-4 py-2.5 text-gray-200">{s.action}</td> <td className="px-4 py-2.5 text-gray-200">{s.action}</td>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
@@ -293,7 +293,7 @@ export default function SettingsView() {
type="number" type="number"
value={settings.defaultPort} value={settings.defaultPort}
onChange={(e) => setSetting('defaultPort', parseInt(e.target.value) || 22)} onChange={(e) => setSetting('defaultPort', parseInt(e.target.value) || 22)}
className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue" className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
/> />
</SettingCard> </SettingCard>
@@ -303,7 +303,7 @@ export default function SettingsView() {
type="number" type="number"
value={settings.timeout} value={settings.timeout}
onChange={(e) => setSetting('timeout', parseInt(e.target.value) || 30)} onChange={(e) => setSetting('timeout', parseInt(e.target.value) || 30)}
className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue" className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
/> />
</SettingCard> </SettingCard>
@@ -325,7 +325,7 @@ export default function SettingsView() {
max={50} max={50}
value={settings.maxConnections || 10} value={settings.maxConnections || 10}
onChange={(e) => setSetting('maxConnections', parseInt(e.target.value) || 10)} onChange={(e) => setSetting('maxConnections', parseInt(e.target.value) || 10)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue" className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
/> />
<div className="text-xs text-gray-600 mt-1">最大并发 SSH 连接数</div> <div className="text-xs text-gray-600 mt-1">最大并发 SSH 连接数</div>
</div> </div>
@@ -337,7 +337,7 @@ export default function SettingsView() {
max={20} max={20}
value={settings.maxBackgroundConnections || 5} value={settings.maxBackgroundConnections || 5}
onChange={(e) => setSetting('maxBackgroundConnections', parseInt(e.target.value) || 5)} onChange={(e) => setSetting('maxBackgroundConnections', parseInt(e.target.value) || 5)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue" className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
/> />
<div className="text-xs text-gray-600 mt-1">非活跃标签保持连接数</div> <div className="text-xs text-gray-600 mt-1">非活跃标签保持连接数</div>
</div> </div>
@@ -352,7 +352,7 @@ export default function SettingsView() {
max={120} max={120}
value={Math.floor((settings.idleTimeoutMs || 5 * 60 * 1000) / 60000)} value={Math.floor((settings.idleTimeoutMs || 5 * 60 * 1000) / 60000)}
onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 60000)} onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 60000)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue" className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
/> />
<div className="text-xs text-gray-600 mt-1">无操作后进入挂起</div> <div className="text-xs text-gray-600 mt-1">无操作后进入挂起</div>
</div> </div>
@@ -364,7 +364,7 @@ export default function SettingsView() {
max={240} max={240}
value={Math.floor((settings.suspendTimeoutMs || 30 * 60 * 1000) / 60000)} value={Math.floor((settings.suspendTimeoutMs || 30 * 60 * 1000) / 60000)}
onChange={(e) => setSetting('suspendTimeoutMs', (parseInt(e.target.value) || 30) * 60000)} onChange={(e) => setSetting('suspendTimeoutMs', (parseInt(e.target.value) || 30) * 60000)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue" className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
/> />
<div className="text-xs text-gray-600 mt-1">挂起后自动断开</div> <div className="text-xs text-gray-600 mt-1">挂起后自动断开</div>
</div> </div>
@@ -442,7 +442,7 @@ export default function SettingsView() {
['Tailwind CSS', '3.3+'], ['Tailwind CSS', '3.3+'],
['Lucide React', '0.294+'], ['Lucide React', '0.294+'],
].map(([name, ver]) => ( ].map(([name, ver]) => (
<div key={name} className="flex justify-between py-1 border-b border-dark-700/40"> <div key={name} className="flex justify-between py-1 border-b border-dark-700/20">
<span className="text-gray-400">{name}</span> <span className="text-gray-400">{name}</span>
<span className="text-gray-500 font-mono">{ver}</span> <span className="text-gray-500 font-mono">{ver}</span>
</div> </div>
@@ -475,9 +475,9 @@ export default function SettingsView() {
return ( return (
<div className="h-full flex bg-dark-900"> <div className="h-full flex bg-dark-900">
{/* ── 左侧导航 ── */} {/* ── 左侧导航 ── */}
<div className="w-56 flex-shrink-0 bg-dark-800/50 border-r border-dark-700 flex flex-col"> <div className="w-56 flex-shrink-0 bg-dark-800 flex flex-col">
{/* 搜索框 */} {/* 搜索框 */}
<div className="p-3 border-b border-dark-700"> <div className="p-3 border-b border-dark-700/20">
<div className="relative"> <div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500" />
<input <input
@@ -485,7 +485,7 @@ export default function SettingsView() {
placeholder="搜索设置..." placeholder="搜索设置..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-8 py-1.5 text-sm bg-dark-900 border border-dark-700 rounded-lg text-gray-300 placeholder-gray-600 focus:outline-none focus:border-accent-blue/60" className="w-full pl-8 pr-8 py-1.5 text-sm bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 placeholder-gray-600 focus:outline-none focus:border-accent-blue/60"
/> />
{searchQuery && ( {searchQuery && (
<button <button
@@ -507,16 +507,16 @@ export default function SettingsView() {
<button <button
key={tab.id} key={tab.id}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all ${
active active
? 'bg-accent-blue/15 text-white' ? 'bg-accent-blue text-white shadow-sm'
: 'text-gray-400 hover:bg-dark-700/60 hover:text-gray-200' : 'text-gray-400 hover:bg-dark-700/50 hover:text-gray-200'
}`} }`}
> >
<Icon className={`w-4 h-4 flex-shrink-0 ${active ? 'text-accent-blue' : ''}`} /> <Icon className={`w-4 h-4 flex-shrink-0 ${active ? 'text-white' : ''}`} />
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-medium truncate">{tab.label}</div> <div className="text-sm font-medium truncate">{tab.label}</div>
<div className={`text-xs truncate ${active ? 'text-gray-400' : 'text-gray-600'}`}> <div className={`text-xs truncate ${active ? 'text-white/70' : 'text-gray-600'}`}>
{tab.description} {tab.description}
</div> </div>
</div> </div>
@@ -529,7 +529,7 @@ export default function SettingsView() {
{/* ── 右侧内容区 ── */} {/* ── 右侧内容区 ── */}
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
{/* Header bar */} {/* Header bar */}
<div className="flex-shrink-0 px-8 pt-6 pb-4 border-b border-dark-700/50"> <div className="flex-shrink-0 px-8 pt-6 pb-4 border-b border-dark-700/20">
<h1 className="text-xl font-semibold text-gray-100">{currentMeta.label}</h1> <h1 className="text-xl font-semibold text-gray-100">{currentMeta.label}</h1>
<p className="text-sm text-gray-500 mt-0.5">{currentMeta.description}</p> <p className="text-sm text-gray-500 mt-0.5">{currentMeta.description}</p>
</div> </div>
@@ -558,7 +558,7 @@ function SettingCard({
className={`rounded-xl p-5 ${ className={`rounded-xl p-5 ${
highlight highlight
? 'bg-dark-800/80 border border-accent-blue/20' ? 'bg-dark-800/80 border border-accent-blue/20'
: 'bg-dark-800/50 border border-dark-700/50' : 'bg-dark-800/50 border border-dark-700/30'
}`} }`}
> >
{children} {children}
+12 -11
View File
@@ -22,7 +22,7 @@ interface SidebarProps {
} }
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }: SidebarProps) { export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }: SidebarProps) {
const { sessions, setSessions, removeSession } = useSessionStore() const { sessions, setSessions, addSession, removeSession } = useSessionStore()
const { settings, setSetting } = useSettingsStore() const { settings, setSetting } = useSettingsStore()
const { addTab } = useTabStore() const { addTab } = useTabStore()
const { confirm, ConfirmDialogComponent } = useConfirmDialog() const { confirm, ConfirmDialogComponent } = useConfirmDialog()
@@ -40,7 +40,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
// Sync width from settings on load // Sync width from settings on load
useEffect(() => { useEffect(() => {
if (settings.sidebarWidth && settings.sidebarWidth !== sidebarWidth) { if (settings.sidebarWidth) {
setSidebarWidth(settings.sidebarWidth) setSidebarWidth(settings.sidebarWidth)
} }
}, [settings.sidebarWidth]) }, [settings.sidebarWidth])
@@ -144,18 +144,19 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
case 'edit': case 'edit':
onEditSession(session) onEditSession(session)
break break
case 'duplicate': case 'duplicate': {
// Duplicate session // Duplicate session
const { id: _id, ...sessionWithoutId } = session
window.electronAPI?.sessionCreate({ window.electronAPI?.sessionCreate({
...session, ...sessionWithoutId,
id: undefined,
name: `${session.name} (副本)`, name: `${session.name} (副本)`,
}).then((result) => { }).then((result) => {
if (result?.success && result.session) { if (result?.success && result.session) {
setSessions([...sessions, result.session]) addSession(result.session as Session)
} }
}) })
break break
}
case 'delete': { case 'delete': {
const confirmed = await confirm({ const confirmed = await confirm({
title: '确认删除', title: '确认删除',
@@ -177,12 +178,12 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
<> <>
<div <div
ref={sidebarRef} ref={sidebarRef}
className="relative flex flex-shrink-0 bg-dark-800 border-r border-dark-700" className="relative flex flex-col h-full bg-dark-800"
style={{ width: sidebarWidth }} style={{ width: sidebarWidth }}
> >
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0 overflow-hidden">
{/* Header */} {/* Header */}
<div className="p-4 border-b border-dark-700"> <div className="p-4 border-b border-dark-700/20">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<div className="w-8 h-8 bg-accent-blue rounded-lg flex items-center justify-center"> <div className="w-8 h-8 bg-accent-blue rounded-lg flex items-center justify-center">
<Server className="w-5 h-5 text-white" /> <Server className="w-5 h-5 text-white" />
@@ -209,7 +210,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
</div> </div>
{/* Search */} {/* Search */}
<div className="p-3 border-b border-dark-700"> <div className="p-3 border-b border-dark-700/20">
<SearchBox <SearchBox
value={searchTerm} value={searchTerm}
onChange={setSearchTerm} onChange={setSearchTerm}
@@ -231,7 +232,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
/> />
{/* Footer */} {/* Footer */}
<div className="p-3 border-t border-dark-700"> <div className="p-3 border-t border-dark-700/20">
<button <button
onClick={() => addTab({ title: '设置', type: 'settings' })} onClick={() => addTab({ title: '设置', type: 'settings' })}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:bg-dark-700 rounded transition-colors" className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:bg-dark-700 rounded transition-colors"
+1 -1
View File
@@ -163,7 +163,7 @@ export default function StatusBar() {
: null : null
return ( return (
<div className="h-7 bg-dark-800 border-t border-dark-700 flex items-center px-3 text-xs text-white select-none"> <div className="h-7 bg-dark-800 flex items-center px-3 text-xs text-white select-none">
{/* Left: Connection status area — clickable to toggle ConnectionPanel */} {/* Left: Connection status area — clickable to toggle ConnectionPanel */}
<button <button
type="button" type="button"
+5 -4
View File
@@ -3,13 +3,13 @@
*/ */
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import { useState, useCallback, useRef } from 'react' import React, { useState, useCallback, useRef } from 'react'
import { useTabStore } from '../stores/tabStore' import { useTabStore } from '../stores/tabStore'
import { useConnectionPool } from '../stores/connection' import { useConnectionPool } from '../stores/connection'
import { TabItem } from './tabbar/TabItem' import { TabItem } from './tabbar/TabItem'
import { TabContextMenu } from './tabbar/TabContextMenu' import { TabContextMenu } from './tabbar/TabContextMenu'
export default function TabBar() { function TabBar() {
const { const {
tabs, tabs,
activeTabId, activeTabId,
@@ -58,7 +58,6 @@ export default function TabBar() {
setDragFromIndex(index) setDragFromIndex(index)
e.dataTransfer.effectAllowed = 'move' e.dataTransfer.effectAllowed = 'move'
// Set a transparent drag image so we rely on the ghost // Set a transparent drag image so we rely on the ghost
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (e.currentTarget instanceof HTMLDivElement) { if (e.currentTarget instanceof HTMLDivElement) {
dragNodeRef.current = e.currentTarget dragNodeRef.current = e.currentTarget
} }
@@ -112,7 +111,7 @@ export default function TabBar() {
const currentTab = menuState.tabId ? tabs.find(t => t.id === menuState.tabId) : null const currentTab = menuState.tabId ? tabs.find(t => t.id === menuState.tabId) : null
return ( return (
<div className="flex items-center bg-dark-900 border-b border-dark-700 overflow-x-auto"> <div className="flex items-center bg-dark-800 overflow-x-auto">
{/* Tab List */} {/* Tab List */}
{tabs.map((tab, index) => ( {tabs.map((tab, index) => (
<TabItem <TabItem
@@ -167,3 +166,5 @@ export default function TabBar() {
</div> </div>
) )
} }
export default React.memo(TabBar)
+6 -3
View File
@@ -1,3 +1,4 @@
import React from 'react'
import { useTabStore } from '../stores/tabStore' import { useTabStore } from '../stores/tabStore'
import WelcomeView from './WelcomeView' import WelcomeView from './WelcomeView'
import TerminalView from './TerminalView' import TerminalView from './TerminalView'
@@ -11,8 +12,8 @@ interface TabContentProps {
onQuickConnect?: () => void onQuickConnect?: () => void
} }
export default function TabContent({ activeTabId, onNewSession, onQuickConnect }: TabContentProps) { function TabContent({ activeTabId, onNewSession, onQuickConnect }: TabContentProps) {
const { tabs } = useTabStore() const tabs = useTabStore((state) => state.tabs)
return ( return (
<div className="relative w-full h-full"> <div className="relative w-full h-full">
@@ -27,7 +28,7 @@ export default function TabContent({ activeTabId, onNewSession, onQuickConnect }
content = <TerminalView tabId={tab.id} isActive={isActive} /> content = <TerminalView tabId={tab.id} isActive={isActive} />
break break
case 'sftp': case 'sftp':
content = <SFTPView tabId={tab.id} isActive={isActive} /> content = <SFTPView tabId={tab.id} isActive={isActive} sessionId={tab.sessionId} />
break break
case 'portforward': case 'portforward':
content = <PortForwardView tabId={tab.id} isActive={isActive} /> content = <PortForwardView tabId={tab.id} isActive={isActive} />
@@ -54,3 +55,5 @@ export default function TabContent({ activeTabId, onNewSession, onQuickConnect }
</div> </div>
) )
} }
export default React.memo(TabContent)
+9 -3
View File
@@ -182,7 +182,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
// 只注册一次监听器(防止严格模式下重复注册) // 只注册一次监听器(防止严格模式下重复注册)
if (!listenerRegisteredRef.current) { if (!listenerRegisteredRef.current) {
const handler = (event: any, connectionId: string, shellId: string, data: Buffer) => { const handler = (event: unknown, connectionId: string, shellId: string, data: Buffer) => {
handleShellDataRef.current?.(event, connectionId, shellId, data) handleShellDataRef.current?.(event, connectionId, shellId, data)
} }
window.electronAPI?.onShellData(handler) window.electronAPI?.onShellData(handler)
@@ -192,6 +192,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
return () => { return () => {
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // 只在挂载时运行 }, []) // 只在挂载时运行
// 初始连接 // 初始连接
@@ -210,6 +211,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
connect() connect()
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // 只在挂载时执行 }, []) // 只在挂载时执行
// 处理活跃状态变化 // 处理活跃状态变化
@@ -234,15 +236,19 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
saveSnapshot() saveSnapshot()
} }
} }
}, [isActive, tabId, saveSnapshot, setShowSuspendedOverlay]) }, [isActive, tabId, saveSnapshot, setShowSuspendedOverlay, connectionIdRef, hasConnectedRef])
// 组件卸载时清理监听器 // 组件卸载时清理监听器
useEffect(() => { useEffect(() => {
return () => { return () => {
isConnectingRef.current = false isConnectingRef.current = false
listenerRegisteredRef.current = false listenerRegisteredRef.current = false
window.electronAPI?.removeAllListeners('ssh:shellData') // 精确移除当前组件注册的监听器,避免影响其他终端
if (handleShellDataRef.current) {
window.electronAPI?.removeListener('ssh:shellData', handleShellDataRef.current as (...args: unknown[]) => void)
} }
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
const currentTab = getTabById(tabId) const currentTab = getTabById(tabId)
+9 -22
View File
@@ -1,4 +1,4 @@
import { Terminal, FolderSync, ArrowRightLeft, Globe, Settings, Plus, Zap, Clock } from 'lucide-react' import { Terminal, FolderSync, ArrowRightLeft, Globe, Plus, Zap, Clock } from 'lucide-react'
import { useSessionStore } from '../stores' import { useSessionStore } from '../stores'
import { useTabStore } from '../stores/tabStore' import { useTabStore } from '../stores/tabStore'
import EmptyState from './common/EmptyState' import EmptyState from './common/EmptyState'
@@ -24,17 +24,17 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
} }
return ( return (
<div className="h-full flex flex-col items-center justify-center bg-dark-900 p-8"> <div className="h-full flex flex-col items-center justify-start bg-dark-900 p-8 overflow-y-auto">
<div className="text-center mb-12"> <div className="text-center mb-12 pt-8">
<div className="w-20 h-20 bg-dark-700 rounded-2xl flex items-center justify-center mx-auto shadow-lg"> <div className="w-20 h-20 bg-dark-700 rounded-2xl flex items-center justify-center mx-auto shadow-lg">
<Terminal className="w-10 h-10 text-white" /> <Terminal className="w-10 h-10 text-white" />
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 w-full max-w-2xl mb-12"> <div className="grid grid-cols-2 gap-4 w-full max-w-2xl mb-12">
<button <button
onClick={onQuickConnect} onClick={onQuickConnect}
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-accent-blue transition-all group" className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700/30 hover:border-accent-blue transition-all group"
> >
<div className="w-12 h-12 bg-accent-blue/20 rounded-lg flex items-center justify-center group-hover:bg-accent-blue/30 transition-colors"> <div className="w-12 h-12 bg-accent-blue/20 rounded-lg flex items-center justify-center group-hover:bg-accent-blue/30 transition-colors">
<Zap className="w-6 h-6 text-accent-blue" /> <Zap className="w-6 h-6 text-accent-blue" />
@@ -47,7 +47,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
<button <button
onClick={onNewSession} onClick={onNewSession}
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-accent-green transition-all group" className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700/30 hover:border-accent-green transition-all group"
> >
<div className="w-12 h-12 bg-accent-green/20 rounded-lg flex items-center justify-center group-hover:bg-accent-green/30 transition-colors"> <div className="w-12 h-12 bg-accent-green/20 rounded-lg flex items-center justify-center group-hover:bg-accent-green/30 transition-colors">
<Plus className="w-6 h-6 text-white" /> <Plus className="w-6 h-6 text-white" />
@@ -60,7 +60,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
<button <button
onClick={() => addTab({ title: 'SFTP', type: 'sftp' })} onClick={() => addTab({ title: 'SFTP', type: 'sftp' })}
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-accent-purple transition-all group" className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700/30 hover:border-accent-purple transition-all group"
> >
<div className="w-12 h-12 bg-accent-purple/20 rounded-lg flex items-center justify-center group-hover:bg-accent-purple/30 transition-colors"> <div className="w-12 h-12 bg-accent-purple/20 rounded-lg flex items-center justify-center group-hover:bg-accent-purple/30 transition-colors">
<FolderSync className="w-6 h-6 text-accent-purple" /> <FolderSync className="w-6 h-6 text-accent-purple" />
@@ -73,7 +73,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
<button <button
onClick={() => addTab({ title: '端口转发', type: 'portforward' })} onClick={() => addTab({ title: '端口转发', type: 'portforward' })}
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-accent-orange transition-all group" className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700/30 hover:border-accent-orange transition-all group"
> >
<div className="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center group-hover:bg-orange-500/30 transition-colors"> <div className="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center group-hover:bg-orange-500/30 transition-colors">
<ArrowRightLeft className="w-6 h-6 text-orange-400" /> <ArrowRightLeft className="w-6 h-6 text-orange-400" />
@@ -83,19 +83,6 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
<div className="text-sm text-gray-500">本地/远程/SOCKS5 代理</div> <div className="text-sm text-gray-500">本地/远程/SOCKS5 代理</div>
</div> </div>
</button> </button>
<button
onClick={() => addTab({ title: '设置', type: 'settings' })}
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-gray-500 transition-all group"
>
<div className="w-12 h-12 bg-gray-700/50 rounded-lg flex items-center justify-center group-hover:bg-gray-700 transition-colors">
<Settings className="w-6 h-6 text-gray-400" />
</div>
<div className="text-left">
<div className="font-semibold text-gray-200">设置</div>
<div className="text-sm text-gray-500">配置应用偏好</div>
</div>
</button>
</div> </div>
{/* Recent connections */} {/* Recent connections */}
@@ -113,7 +100,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
<button <button
key={session.id} key={session.id}
onClick={() => handleSessionClick(session)} onClick={() => handleSessionClick(session)}
className="w-full flex items-center justify-between p-4 bg-dark-800 hover:bg-dark-700 rounded-lg border border-dark-700 hover:border-accent-blue/50 transition-colors" className="w-full flex items-center justify-between p-4 bg-dark-800 hover:bg-dark-700 rounded-lg border border-dark-700/30 hover:border-accent-blue/50 transition-colors"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Globe className="w-5 h-5 text-accent-blue" /> <Globe className="w-5 h-5 text-accent-blue" />
+54
View File
@@ -0,0 +1,54 @@
import { handleError } from '../../utils/error-handler'
interface DefaultFallbackProps {
error: Error
onReset: () => void
}
export default function DefaultFallback({ error, onReset }: DefaultFallbackProps) {
const appError = handleError(error)
return (
<div className="min-h-screen flex items-center justify-center bg-dark-900 p-4">
<div className="max-w-md w-full bg-dark-800 rounded-xl p-6 border border-dark-700">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-gray-200">出错了</h2>
<p className="text-sm text-gray-500">应用遇到意外问题</p>
</div>
</div>
<div className="bg-dark-900 rounded-lg p-4 mb-4">
<p className="text-sm text-gray-300 mb-2">{appError.message}</p>
{appError.recoverable && (
<p className="text-xs text-green-400">此错误可以恢复,请尝试刷新页面</p>
)}
</div>
<div className="flex gap-3">
<button
onClick={onReset}
className="flex-1 px-4 py-2 bg-accent-blue hover:bg-blue-600 text-white rounded-lg transition-colors text-sm font-medium"
>
刷新页面
</button>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-gray-300 rounded-lg transition-colors text-sm"
>
重启应用
</button>
</div>
<p className="mt-4 text-xs text-gray-600 text-center">
如果问题持续存在,请检查日志或联系支持
</p>
</div>
</div>
)
}
+1 -52
View File
@@ -7,7 +7,7 @@
import { Component, type ReactNode } from 'react' import { Component, type ReactNode } from 'react'
import { createLogger } from '../../utils/logger' import { createLogger } from '../../utils/logger'
import { handleError } from '../../utils/error-handler' import DefaultFallback from './DefaultFallback'
const logger = createLogger('ErrorBoundary') const logger = createLogger('ErrorBoundary')
@@ -22,57 +22,6 @@ interface State {
error: Error | null error: Error | null
} }
/**
* 默认错误_fallback UI
*/
function DefaultFallback({ error, onReset }: { error: Error; onReset: () => void }) {
const appError = handleError(error)
return (
<div className="min-h-screen flex items-center justify-center bg-dark-900 p-4">
<div className="max-w-md w-full bg-dark-800 rounded-xl p-6 border border-dark-700">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-gray-200">出错了</h2>
<p className="text-sm text-gray-500">应用遇到意外问题</p>
</div>
</div>
<div className="bg-dark-900 rounded-lg p-4 mb-4">
<p className="text-sm text-gray-300 mb-2">{appError.message}</p>
{appError.recoverable && (
<p className="text-xs text-green-400">此错误可以恢复,请尝试刷新页面</p>
)}
</div>
<div className="flex gap-3">
<button
onClick={onReset}
className="flex-1 px-4 py-2 bg-accent-blue hover:bg-blue-600 text-white rounded-lg transition-colors text-sm font-medium"
>
刷新页面
</button>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-gray-300 rounded-lg transition-colors text-sm"
>
重启应用
</button>
</div>
<p className="mt-4 text-xs text-gray-600 text-center">
如果问题持续存在,请检查日志或联系支持
</p>
</div>
</div>
)
}
/** /**
* 错误边界组件 * 错误边界组件
* *
+1 -1
View File
@@ -202,7 +202,7 @@ export function SessionTreeSkeleton({
{Array.from({ length: groups }).map((_, gIdx) => { {Array.from({ length: groups }).map((_, gIdx) => {
const count = sessionsPerGroup[0] + (gIdx % (sessionsPerGroup[1] - sessionsPerGroup[0] + 1)) const count = sessionsPerGroup[0] + (gIdx % (sessionsPerGroup[1] - sessionsPerGroup[0] + 1))
return ( return (
<div key={gIdx} className="border-b border-dark-700 last:border-b-0"> <div key={gIdx} className="border-b border-dark-700/30 last:border-b-0">
{/* 分组头 */} {/* 分组头 */}
<div className="flex items-center gap-2 px-4 py-2"> <div className="flex items-center gap-2 px-4 py-2">
<Skeleton width={14} height={14} className="rounded-sm" /> <Skeleton width={14} height={14} className="rounded-sm" />
+7 -7
View File
@@ -67,19 +67,19 @@ function ToastItemView({ toast, onClose }: ToastItemProps) {
}, []) }, [])
// 自动关闭 // 自动关闭
const handleClose = useCallback(() => {
setVisible(false)
// 等待退场动画结束后再移除
setTimeout(() => onClose(toast.id), 200)
}, [onClose, toast.id])
useEffect(() => { useEffect(() => {
if (toast.duration <= 0) return if (toast.duration <= 0) return
const timer = setTimeout(() => { const timer = setTimeout(() => {
handleClose() handleClose()
}, toast.duration) }, toast.duration)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [toast.duration]) }, [toast.duration, handleClose])
const handleClose = useCallback(() => {
setVisible(false)
// 等待退场动画结束后再移除
setTimeout(() => onClose(toast.id), 200)
}, [onClose, toast.id])
return ( return (
<div <div
+1 -1
View File
@@ -138,7 +138,7 @@ export default function ChmodDialog({
> >
<div className="space-y-4 min-w-[360px]"> <div className="space-y-4 min-w-[360px]">
{/* File info */} {/* File info */}
<div className="flex items-center gap-3 pb-3 border-b border-dark-600"> <div className="flex items-center gap-3 pb-3 border-b border-dark-600/30">
<Shield className="w-5 h-5 text-accent-blue" /> <Shield className="w-5 h-5 text-accent-blue" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-white font-medium truncate">{fileName}</p> <p className="text-white font-medium truncate">{fileName}</p>
+1 -1
View File
@@ -70,7 +70,7 @@ export function ConnectionPanel({
// Disconnected State // Disconnected State
return ( return (
<> <>
<div className="flex items-center justify-between px-4 py-2 bg-dark-800 border-b border-dark-700"> <div className="flex items-center justify-between px-4 py-2 bg-dark-800 border-b border-dark-700/30">
<span className="text-sm text-gray-400">未连接</span> <span className="text-sm text-gray-400">未连接</span>
<button <button
onClick={onShowSelector} onClick={onShowSelector}
+1 -1
View File
@@ -15,7 +15,7 @@ export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSe
return ( return (
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-40"> <div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-40">
<div className="bg-dark-800 rounded-xl border border-dark-700 shadow-2xl w-full max-w-md overflow-hidden"> <div className="bg-dark-800 rounded-xl border border-dark-700 shadow-2xl w-full max-w-md overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700"> <div className="flex items-center justify-between px-6 py-4 border-b border-dark-700/30">
<h3 className="text-lg font-medium text-white">选择服务器</h3> <h3 className="text-lg font-medium text-white">选择服务器</h3>
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded"> <button onClick={onClose} className="p-1 hover:bg-dark-700 rounded">
<X className="w-5 h-5 text-gray-400" /> <X className="w-5 h-5 text-gray-400" />
+1 -1
View File
@@ -48,7 +48,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
{items.map((item, index) => ( {items.map((item, index) => (
<div key={index}> <div key={index}>
{item.separator && index > 0 && ( {item.separator && index > 0 && (
<div className="my-1 border-t border-dark-600" /> <div className="my-1 border-t border-dark-600/30" />
)} )}
<button <button
onClick={() => { onClick={() => {
+4 -4
View File
@@ -121,7 +121,7 @@ export function FileListPanel({
onDrop={handleDrop} onDrop={handleDrop}
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-dark-700 border-b border-dark-600"> <div className="flex items-center justify-between px-4 py-2 bg-dark-700 border-b border-dark-600/30">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isLocal ? <HardDrive className="w-4 h-4 text-accent-blue" /> : <Folder className="w-4 h-4 text-accent-green" />} {isLocal ? <HardDrive className="w-4 h-4 text-accent-blue" /> : <Folder className="w-4 h-4 text-accent-green" />}
<span className="text-sm font-medium">{title}</span> <span className="text-sm font-medium">{title}</span>
@@ -151,12 +151,12 @@ export function FileListPanel({
</div> </div>
{/* Breadcrumb */} {/* Breadcrumb */}
<div className="px-4 py-2 border-b border-dark-700"> <div className="px-4 py-2 border-b border-dark-700/20">
<Breadcrumb path={path} onNavigate={onNavigate} onHome={() => onNavigate(isLocal ? '/' : '/')} /> <Breadcrumb path={path} onNavigate={onNavigate} onHome={() => onNavigate(isLocal ? '/' : '/')} />
</div> </div>
{/* Column Headers */} {/* Column Headers */}
<div className="flex items-center px-4 py-2 border-b border-dark-700 text-xs text-gray-400"> <div className="flex items-center px-4 py-2 border-b border-dark-700/20 text-xs text-gray-400">
<SortHeader sortField={sortField} sortOrder={sortOrder} onSort={onSort} /> <SortHeader sortField={sortField} sortOrder={sortOrder} onSort={onSort} />
</div> </div>
@@ -195,7 +195,7 @@ export function FileListPanel({
</div> </div>
{/* Status Bar */} {/* Status Bar */}
<div className="px-4 py-2 border-t border-dark-700 text-xs text-gray-400"> <div className="px-4 py-2 border-t border-dark-700/20 text-xs text-gray-400">
{loading ? '加载中...' : selected.size > 0 ? `已选择 ${selected.size} 项` : `共 ${files.length} 项`} {loading ? '加载中...' : selected.size > 0 ? `已选择 ${selected.size} 项` : `共 ${files.length} 项`}
</div> </div>
+1 -1
View File
@@ -123,7 +123,7 @@ export default function FilePropertiesDialog({
> >
<div className="space-y-4 min-w-[320px]"> <div className="space-y-4 min-w-[320px]">
{/* File icon + name header */} {/* File icon + name header */}
<div className="flex items-center gap-3 pb-3 border-b border-dark-600"> <div className="flex items-center gap-3 pb-3 border-b border-dark-600/30">
<div className="p-2 bg-dark-700 rounded-lg"> <div className="p-2 bg-dark-700 rounded-lg">
<TypeIcon className="w-8 h-8 text-accent-blue" /> <TypeIcon className="w-8 h-8 text-accent-blue" />
</div> </div>
+1 -1
View File
@@ -23,7 +23,7 @@ export function FileRow({ file, isSelected, onSelect, onOpen, onContextMenu, onD
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
draggable={file.type === 'file'} draggable={file.type === 'file'}
onDragStart={onDragStart} onDragStart={onDragStart}
className={`h-10 flex items-center px-4 cursor-pointer border-b border-dark-700/50 transition-colors group ${ className={`h-10 flex items-center px-4 cursor-pointer border-b border-dark-700/20 transition-colors group ${
isSelected ? 'bg-accent-blue/20 hover:bg-accent-blue/25' : 'hover:bg-dark-700/50' isSelected ? 'bg-accent-blue/20 hover:bg-accent-blue/25' : 'hover:bg-dark-700/50'
}`} }`}
> >
+1 -1
View File
@@ -20,7 +20,7 @@ export function SortHeader({ onSort, sortField, sortOrder }: SortHeaderProps) {
] ]
return ( return (
<div className="h-9 flex items-center px-4 bg-dark-700/50 text-xs text-gray-500 border-b border-dark-700 select-none"> <div className="h-9 flex items-center px-4 bg-dark-700/50 text-xs text-gray-500 border-b border-dark-700/30 select-none">
{fields.map(({ key, label, width, align }) => ( {fields.map(({ key, label, width, align }) => (
<button <button
key={key} key={key}
+1 -1
View File
@@ -16,7 +16,7 @@ interface ToolbarProps {
export function Toolbar({ isConnected, onUpload, onDownload, onRefresh, onNewFolder }: ToolbarProps) { export function Toolbar({ isConnected, onUpload, onDownload, onRefresh, onNewFolder }: ToolbarProps) {
return ( return (
<div className="flex items-center justify-between px-4 py-2 bg-dark-800 border-b border-dark-700"> <div className="flex items-center justify-between px-4 py-2 bg-dark-800 border-b border-dark-700/30">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={onUpload} onClick={onUpload}
+2 -2
View File
@@ -21,7 +21,7 @@ export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove }: T
const completedCount = tasks.filter(t => t.status === 'completed').length const completedCount = tasks.filter(t => t.status === 'completed').length
return ( return (
<div className="bg-dark-800 border-t border-dark-700"> <div className="bg-dark-800 border-t border-dark-700/30">
{/* Header */} {/* Header */}
<button <button
onClick={onToggle} onClick={onToggle}
@@ -48,7 +48,7 @@ export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove }: T
{/* Transfer List */} {/* Transfer List */}
{isOpen && tasks.length > 0 && ( {isOpen && tasks.length > 0 && (
<div className="max-h-48 overflow-y-auto border-t border-dark-700"> <div className="max-h-48 overflow-y-auto border-t border-dark-700/30">
{tasks.map((task) => ( {tasks.map((task) => (
<TransferItem <TransferItem
key={task.id} key={task.id}
+1 -1
View File
@@ -62,7 +62,7 @@ export function SessionTree({
{Object.entries(grouped).map(([group, groupSessions]) => { {Object.entries(grouped).map(([group, groupSessions]) => {
const isExpanded = expandedGroups.has(group) const isExpanded = expandedGroups.has(group)
return ( return (
<div key={group} className="border-b border-dark-700 last:border-b-0"> <div key={group} className="border-b border-dark-700/20 last:border-b-0">
{/* Group Header */} {/* Group Header */}
<button <button
onClick={() => onToggleGroup(group)} onClick={() => onToggleGroup(group)}
+3 -3
View File
@@ -201,13 +201,13 @@ export default function SnippetPanel() {
return ( return (
<div className="flex flex-col h-full bg-dark-800"> <div className="flex flex-col h-full bg-dark-800">
{/* 头部 */} {/* 头部 */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-dark-700"> <div className="flex items-center gap-2 px-4 py-3 border-b border-dark-700/30">
<Code2 className="w-5 h-5 text-accent-blue flex-shrink-0" /> <Code2 className="w-5 h-5 text-accent-blue flex-shrink-0" />
<h2 className="text-sm font-semibold text-gray-200">命令片段</h2> <h2 className="text-sm font-semibold text-gray-200">命令片段</h2>
</div> </div>
{/* 搜索框 */} {/* 搜索框 */}
<div className="p-3 border-b border-dark-700"> <div className="p-3 border-b border-dark-700/30">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input <input
@@ -260,7 +260,7 @@ export default function SnippetPanel() {
</div> </div>
{/* 底部新建按钮 */} {/* 底部新建按钮 */}
<div className="p-3 border-t border-dark-700"> <div className="p-3 border-t border-dark-700/30">
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"
+3 -3
View File
@@ -60,11 +60,11 @@ export function TabItem({
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
className={` className={`
group flex items-center gap-2 px-3 py-2 min-w-[120px] max-w-[200px] group flex items-center gap-2 px-3 py-2 min-w-[120px] max-w-[200px]
border-r border-dark-700 cursor-pointer select-none border-r border-dark-700/20 cursor-pointer select-none
transition-all duration-150 relative transition-all duration-150 relative
${isActive ${isActive
? 'bg-dark-800 text-gray-200' ? 'bg-dark-900 text-gray-200'
: 'bg-dark-900 text-gray-500 hover:bg-dark-800 hover:text-gray-400' : 'bg-dark-800 text-gray-500 hover:bg-dark-700 hover:text-gray-400'
} }
${isDragging ? 'opacity-50' : 'opacity-100'} ${isDragging ? 'opacity-50' : 'opacity-100'}
`} `}
+4 -1
View File
@@ -1,3 +1,4 @@
import { memo } from 'react'
import { Terminal as TerminalIcon } from 'lucide-react' import { Terminal as TerminalIcon } from 'lucide-react'
import type { ConnectionState } from '../../stores/connection' import type { ConnectionState } from '../../stores/connection'
@@ -10,7 +11,7 @@ export interface TerminalOverlayProps {
onResume: () => void onResume: () => void
} }
export default function TerminalOverlay({ function TerminalOverlay({
connectionState, connectionState,
statusMessage, statusMessage,
showSuspendedOverlay, showSuspendedOverlay,
@@ -96,3 +97,5 @@ export default function TerminalOverlay({
</> </>
) )
} }
export default memo(TerminalOverlay)
@@ -1,4 +1,4 @@
import React from 'react' import React, { memo } from 'react'
import { Search, ChevronUp, ChevronDown, X } from 'lucide-react' import { Search, ChevronUp, ChevronDown, X } from 'lucide-react'
export interface TerminalSearchBarProps { export interface TerminalSearchBarProps {
@@ -12,7 +12,7 @@ export interface TerminalSearchBarProps {
onClose: () => void onClose: () => void
} }
export default function TerminalSearchBar({ function TerminalSearchBar({
searchQuery, searchQuery,
searchResultCount, searchResultCount,
searchResultIndex, searchResultIndex,
@@ -72,3 +72,5 @@ export default function TerminalSearchBar({
</div> </div>
) )
} }
export default memo(TerminalSearchBar)
+40 -86
View File
@@ -1,93 +1,43 @@
// Type declarations for Electron IPC API exposed via preload // Type declarations for Electron IPC API exposed via preload
// All types imported from shared/types for consistency
interface SSHConfig {
host: string
port?: number
username: string
password?: string
privateKey?: string
passphrase?: string
agent?: string
readyTimeout?: number
keepaliveInterval?: number
}
interface Session {
id: string
name: string
host: string
port: number
username: string
authType: 'password' | 'key' | 'agent'
password?: string
privateKey?: string
passphrase?: string
group?: string
tags?: string[]
}
interface IPCResponse<T = any> {
success: boolean
error?: string
connectionId?: string
shellId?: string
sftpId?: string
sessions?: Session[]
session?: Session
files?: any[]
result?: any
value?: any
path?: string
boundPort?: number
}
interface ElectronAPI { interface ElectronAPI {
// SSH // SSH
sshConnect: (config: SSHConfig) => Promise<IPCResponse> sshConnect: (config: import('../shared/types/ipc.types').SSHConnectRequest) => Promise<import('../shared/types/ipc.types').SSHConnectResponse>
sshDisconnect: (connectionId: string) => Promise<IPCResponse> sshDisconnect: (connectionId: string) => Promise<{ success: boolean; error?: string }>
sshExec: (connectionId: string, command: string) => Promise<IPCResponse> sshExec: (connectionId: string, command: string) => Promise<{ success: boolean; result?: string; error?: string }>
sshCreateShell: (connectionId: string, options?: any, shellId?: string) => Promise<IPCResponse> sshCreateShell: (connectionId: string, options?: import('../shared/types/ipc.types').SSHCreateShellRequest, shellId?: string) => Promise<{ success: boolean; shellId?: string; error?: string }>
sshShellWrite: (shellId: string, data: string) => Promise<IPCResponse> sshShellWrite: (shellId: string, data: string | Uint8Array) => Promise<{ success: boolean; error?: string }>
sshShellResize: (shellId: string, cols: number, rows: number) => Promise<IPCResponse> sshShellResize: (shellId: string, cols: number, rows: number) => Promise<{ success: boolean; error?: string }>
sshForwardLocal: (connectionId: string, localPort: number, remoteHost: string, remotePort: number) => Promise<IPCResponse> sshForwardLocal: (connectionId: string, localPort: number, remoteHost: string, remotePort: number) => Promise<import('../shared/types/ipc.types').SSHForwardResponse>
sshForwardRemote: (connectionId: string, remotePort: number, localHost: string, localPort: number) => Promise<IPCResponse> sshForwardRemote: (connectionId: string, remotePort: number, localHost: string, localPort: number) => Promise<import('../shared/types/ipc.types').SSHForwardResponse>
sshForwardDynamic: (connectionId: string, localPort: number) => Promise<IPCResponse> sshForwardDynamic: (connectionId: string, localPort: number) => Promise<import('../shared/types/ipc.types').SSHForwardResponse>
// Sessions // Sessions
sessionGetAll: () => Promise<IPCResponse> sessionGetAll: () => Promise<{ success: boolean; sessions?: import('../shared/types/session.types').Session[]; error?: string }>
sessionGetById: (id: string) => Promise<IPCResponse> sessionGetById: (id: string) => Promise<{ success: boolean; session?: import('../shared/types/session.types').Session; error?: string }>
sessionCreate: (session: Partial<Session>) => Promise<IPCResponse> sessionCreate: (session: Omit<import('../shared/types/session.types').Session, 'id'>) => Promise<import('../shared/types/ipc.types').SessionCreateResponse>
sessionUpdate: (id: string, updates: Partial<Session>) => Promise<IPCResponse> sessionUpdate: (id: string, updates: Partial<import('../shared/types/session.types').Session>) => Promise<import('../shared/types/ipc.types').SessionUpdateResponse>
sessionDelete: (id: string) => Promise<IPCResponse> sessionDelete: (id: string) => Promise<{ success: boolean; error?: string }>
// SFTP // SFTP
sftpConnect: (connectionId: string) => Promise<IPCResponse> sftpConnect: (connectionId: string) => Promise<{ success: boolean; sftpId?: string; error?: string }>
sftpListDirectory: (sftpId: string, path: string) => Promise<IPCResponse> sftpListDirectory: (sftpId: string, path: string) => Promise<{ success: boolean; files?: unknown[]; error?: string }>
sftpUpload: (sftpId: string, localPath: string, remotePath: string) => Promise<IPCResponse> sftpUpload: (sftpId: string, localPath: string, remotePath: string) => Promise<{ success: boolean; error?: string }>
sftpDownload: (sftpId: string, remotePath: string, localPath: string) => Promise<IPCResponse> sftpDownload: (sftpId: string, remotePath: string, localPath: string) => Promise<{ success: boolean; error?: string }>
sftpRename: (sftpId: string, oldPath: string, newPath: string) => Promise<IPCResponse> sftpRename: (sftpId: string, oldPath: string, newPath: string) => Promise<{ success: boolean; error?: string }>
sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => Promise<IPCResponse> sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => Promise<{ success: boolean; error?: string }>
sftpMkdir: (sftpId: string, path: string) => Promise<IPCResponse> sftpMkdir: (sftpId: string, path: string) => Promise<{ success: boolean; error?: string }>
sftpStat: (sftpId: string, filePath: string) => Promise<IPCResponse & { sftpStat: (sftpId: string, filePath: string) => Promise<{ success: boolean; stats?: unknown; error?: string }>
stats?: { sftpChmod: (sftpId: string, filePath: string, mode: number) => Promise<{ success: boolean; error?: string }>
size: number
mode: number
mtime: number
atime: number
isDirectory: boolean
isFile: boolean
isSymbolicLink: boolean
}
}>
sftpChmod: (sftpId: string, filePath: string, mode: number) => Promise<IPCResponse>
// Settings // Settings
settingsGet: (key: string) => Promise<IPCResponse> settingsGet: (key: string) => Promise<{ success: boolean; value?: unknown; error?: string }>
settingsSet: (key: string, value: any) => Promise<IPCResponse> settingsSet: (key: string, value: unknown) => Promise<{ success: boolean; error?: string }>
// Dialogs // Dialogs
showSaveDialog: (options: any) => Promise<{ canceled: boolean; filePath?: string }> showSaveDialog: (options: import('../shared/types/ipc.types').DialogOptions) => Promise<import('../shared/types/ipc.types').DialogResult>
showOpenDialog: (options: any) => Promise<{ canceled: boolean; filePaths?: string[] }> showOpenDialog: (options: import('../shared/types/ipc.types').DialogOptions) => Promise<import('../shared/types/ipc.types').DialogResult>
// File operations // File operations
readFile: (filePath: string) => Promise<{ success: boolean; data?: string; error?: string }> readFile: (filePath: string) => Promise<{ success: boolean; data?: string; error?: string }>
@@ -96,23 +46,27 @@ interface ElectronAPI {
// Local filesystem // Local filesystem
getHomePath: () => Promise<{ success: boolean; path?: string; error?: string }> getHomePath: () => Promise<{ success: boolean; path?: string; error?: string }>
getDownloadsPath: () => Promise<string> getDownloadsPath: () => Promise<string>
localReadDir: (dirPath: string) => Promise<IPCResponse> localReadDir: (dirPath: string) => Promise<unknown>
localGetStats: (filePath: string) => Promise<IPCResponse> localGetStats: (filePath: string) => Promise<unknown>
localDelete: (filePath: string) => Promise<IPCResponse> localDelete: (filePath: string) => Promise<unknown>
localRename: (oldPath: string, newPath: string) => Promise<IPCResponse> localRename: (oldPath: string, newPath: string) => Promise<unknown>
localMkdir: (dirPath: string) => Promise<IPCResponse> localMkdir: (dirPath: string) => Promise<unknown>
// App // App
getVersion: () => Promise<string> getVersion: () => Promise<string>
// Window
toggleMaximize: () => Promise<void>
// Events // Events
onMenuNewSession: (callback: () => void) => void onMenuNewSession: (callback: () => void) => void
onMenuQuickConnect: (callback: () => void) => void onMenuQuickConnect: (callback: () => void) => void
onMenuImportSessions: (callback: () => void) => void onMenuImportSessions: (callback: () => void) => void
onMenuExportSessions: (callback: () => void) => void onMenuExportSessions: (callback: () => void) => void
onShellData: (callback: (event: any, connectionId: string, shellId: string, data: Buffer) => void) => void onMenuOpenSettings: (callback: () => void) => void
onSFTPProgress: (callback: (event: any, sftpId: string, progress: any) => void) => void onShellData: (callback: (event: Electron.IpcRendererEvent, connectionId: string, shellId: string, data: Buffer) => void) => void
removeListener: (channel: string, callback: (...args: any[]) => void) => void onSFTPProgress: (callback: (event: Electron.IpcRendererEvent, sftpId: string, progress: { transferred: number; total: number; percentage: number }) => void) => void
removeListener: (channel: string, callback: (...args: unknown[]) => void) => void
removeAllListeners: (channel: string) => void removeAllListeners: (channel: string) => void
} }
+1 -1
View File
@@ -89,7 +89,7 @@ export function useKeyboardShortcuts(): void {
e.preventDefault() e.preventDefault()
// Electron 渲染进程通过 IPC 请求全屏切换 // Electron 渲染进程通过 IPC 请求全屏切换
if (window.electronAPI?.settingsSet) { if (window.electronAPI?.settingsSet) {
window.electronAPI.settingsGet('fullscreen').then((res: any) => { window.electronAPI.settingsGet('fullscreen').then((res: { success: boolean; value?: unknown }) => {
const isFullscreen = res?.value === true const isFullscreen = res?.value === true
window.electronAPI?.settingsSet('fullscreen', !isFullscreen) window.electronAPI?.settingsSet('fullscreen', !isFullscreen)
}).catch(() => { }).catch(() => {
+56 -39
View File
@@ -4,7 +4,7 @@
* 从 SFTPView 提取状态管理和业务逻辑 * 从 SFTPView 提取状态管理和业务逻辑
*/ */
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { useSessionStore } from '../stores' import { useSessionStore } from '../stores'
import { useTabStore, type ConnectionInstance } from '../stores/tabStore' import { useTabStore, type ConnectionInstance } from '../stores/tabStore'
import { useConnectionPool, getSessionConfig } from '../stores/connection' import { useConnectionPool, getSessionConfig } from '../stores/connection'
@@ -13,6 +13,14 @@ import type { FileItem, TransferTask, SortField, SortOrder } from '../../shared/
const logger = createLogger('useSFTP') const logger = createLogger('useSFTP')
// 本地目录条目类型
interface LocalDirEntry {
name: string
isDirectory: boolean
size?: number
mtime?: number
}
export interface UseSFTPOptions { export interface UseSFTPOptions {
tabId: string tabId: string
} }
@@ -132,12 +140,13 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
} else { } else {
logger.error('Failed to get home path') logger.error('Failed to get home path')
} }
}).catch((err: any) => { }).catch((err: unknown) => {
logger.error(`getHomePath error: ${err?.message || err}`) logger.error(`getHomePath error: ${err instanceof Error ? err.message : String(err)}`)
}) })
} else { } else {
logger.warn('electronAPI not available') logger.warn('electronAPI not available')
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
// Load local directory // Load local directory
@@ -150,11 +159,11 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
} }
try { try {
const result = await window.electronAPI.localReadDir(path) const result = await window.electronAPI.localReadDir(path) as { success: boolean; files?: LocalDirEntry[]; error?: string }
logger.debug('localReadDir result', result) logger.debug('localReadDir result', result)
if (result.success && result.files) { if (result.success && result.files) {
const files: FileItem[] = result.files.map((f: any) => ({ const files: FileItem[] = result.files.map((f: LocalDirEntry) => ({
name: f.name, name: f.name,
path: `${path}/${f.name}`, path: `${path}/${f.name}`,
type: f.isDirectory ? 'directory' : 'file', type: f.isDirectory ? 'directory' : 'file',
@@ -167,8 +176,8 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
} else { } else {
logger.error(`Failed to load local directory: ${result.error}`) logger.error(`Failed to load local directory: ${result.error}`)
} }
} catch (err: any) { } catch (err: unknown) {
logger.error(`loadLocalDirectory error: ${err?.message}`, err) logger.error(`loadLocalDirectory error: ${err instanceof Error ? err.message : String(err)}`, err)
} }
}, []) }, [])
@@ -186,21 +195,24 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
logger.debug('sftpListDirectory result', result) logger.debug('sftpListDirectory result', result)
if (result.success && result.files) { if (result.success && result.files) {
const files: FileItem[] = result.files.map((f: any) => ({ const files: FileItem[] = result.files.map((f: unknown) => {
name: f.filename, const entry = f as { filename?: string; longname?: string; attrs?: { size?: number; mtime?: number } }
path: `${path}/${f.filename}`.replace(/\/+/g, '/'), return {
type: f.longname?.startsWith('d') ? 'directory' : 'file', name: entry.filename || '',
size: f.attrs?.size || 0, path: `${path}/${entry.filename || ''}`.replace(/\/+/g, '/'),
modified: f.attrs?.mtime || Date.now() / 1000, type: entry.longname?.startsWith('d') ? 'directory' : 'file',
permissions: f.longname?.substring(0, 10) || '-rw-r--r--', size: entry.attrs?.size || 0,
})) modified: entry.attrs?.mtime || Date.now() / 1000,
permissions: entry.longname?.substring(0, 10) || '-rw-r--r--',
}
})
setRemoteFiles(files) setRemoteFiles(files)
logger.info(`Loaded ${files.length} remote files`) logger.info(`Loaded ${files.length} remote files`)
} else { } else {
logger.error(`Failed to load remote directory: ${result.error}`) logger.error(`Failed to load remote directory: ${result.error}`)
} }
} catch (err: any) { } catch (err: unknown) {
logger.error(`loadRemoteDirectory error: ${err?.message}`, err) logger.error(`loadRemoteDirectory error: ${err instanceof Error ? err.message : String(err)}`, err)
setError('Failed to load remote directory') setError('Failed to load remote directory')
} }
}, [sftpId]) }, [sftpId])
@@ -373,7 +385,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
setTransferQueue(prev => [...prev, ...newTasks]) setTransferQueue(prev => [...prev, ...newTasks])
// Set up progress listener scoped to this batch // Set up progress listener scoped to this batch
const progressHandler = (_event: any, _sftpId: string, progress: { transferred: number; total: number; percentage: number }) => { const progressHandler = (_event: unknown, _sftpId: string, progress: { transferred: number; total: number; percentage: number }) => {
// Update the first task that is currently transferring // Update the first task that is currently transferring
setTransferQueue(prev => { setTransferQueue(prev => {
const idx = prev.findIndex(t => t.type === 'upload' && t.status === 'transferring') const idx = prev.findIndex(t => t.type === 'upload' && t.status === 'transferring')
@@ -387,7 +399,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
}) })
} }
window.electronAPI.onSFTPProgress(progressHandler) window.electronAPI.onSFTPProgress(progressHandler as (...args: unknown[]) => void)
try { try {
for (const task of newTasks) { for (const task of newTasks) {
@@ -413,10 +425,10 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
)) ))
logger.error(`Upload failed: ${task.fileName} - ${result.error}`) logger.error(`Upload failed: ${task.fileName} - ${result.error}`)
} }
} catch (err: any) { } catch (err: unknown) {
logger.error('Upload failed', err) logger.error('Upload failed', err)
setTransferQueue(prev => prev.map(t => setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'failed', error: err?.message || 'Upload failed' } : t t.id === task.id ? { ...t, status: 'failed', error: err instanceof Error ? err.message : 'Upload failed' } : t
)) ))
} }
} }
@@ -424,7 +436,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
// Refresh remote directory after all uploads // Refresh remote directory after all uploads
loadRemoteDirectory(remotePath) loadRemoteDirectory(remotePath)
} finally { } finally {
window.electronAPI.removeListener?.('sftp:progress', progressHandler) window.electronAPI.removeListener?.('sftp:progress', progressHandler as (...args: unknown[]) => void)
} }
}, [sftpId, localPath, remotePath, loadRemoteDirectory]) }, [sftpId, localPath, remotePath, loadRemoteDirectory])
@@ -448,7 +460,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
setTransferQueue(prev => [...prev, ...newTasks]) setTransferQueue(prev => [...prev, ...newTasks])
// Set up progress listener // Set up progress listener
const progressHandler = (_event: any, _sftpId: string, progress: { transferred: number; total: number; percentage: number }) => { const progressHandler = (_event: unknown, _sftpId: string, progress: { transferred: number; total: number; percentage: number }) => {
setTransferQueue(prev => { setTransferQueue(prev => {
const idx = prev.findIndex(t => t.type === 'download' && t.status === 'transferring') const idx = prev.findIndex(t => t.type === 'download' && t.status === 'transferring')
if (idx === -1) return prev if (idx === -1) return prev
@@ -461,7 +473,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
}) })
} }
window.electronAPI.onSFTPProgress(progressHandler) window.electronAPI.onSFTPProgress(progressHandler as (...args: unknown[]) => void)
try { try {
for (const task of newTasks) { for (const task of newTasks) {
@@ -486,10 +498,10 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
)) ))
logger.error(`Download failed: ${task.fileName} - ${result.error}`) logger.error(`Download failed: ${task.fileName} - ${result.error}`)
} }
} catch (err: any) { } catch (err: unknown) {
logger.error('Download failed', err) logger.error('Download failed', err)
setTransferQueue(prev => prev.map(t => setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'failed', error: err?.message || 'Download failed' } : t t.id === task.id ? { ...t, status: 'failed', error: err instanceof Error ? err.message : 'Download failed' } : t
)) ))
} }
} }
@@ -497,7 +509,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
// Refresh local directory after all downloads // Refresh local directory after all downloads
loadLocalDirectory(localPath) loadLocalDirectory(localPath)
} finally { } finally {
window.electronAPI.removeListener?.('sftp:progress', progressHandler) window.electronAPI.removeListener?.('sftp:progress', progressHandler as (...args: unknown[]) => void)
} }
}, [sftpId, remoteFiles, remoteSelected, localPath, loadLocalDirectory]) }, [sftpId, remoteFiles, remoteSelected, localPath, loadLocalDirectory])
@@ -522,8 +534,8 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
logger.error(`Delete failed: ${file.name} - ${result.error}`) logger.error(`Delete failed: ${file.name} - ${result.error}`)
allSuccess = false allSuccess = false
} }
} catch (err: any) { } catch (err: unknown) {
logger.error(`Delete error: ${file.name} - ${err?.message}`, err) logger.error(`Delete error: ${file.name} - ${err instanceof Error ? err.message : String(err)}`, err)
allSuccess = false allSuccess = false
} }
} }
@@ -556,8 +568,8 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
logger.error(`Rename failed: ${result.error}`) logger.error(`Rename failed: ${result.error}`)
return false return false
} }
} catch (err: any) { } catch (err: unknown) {
logger.error(`Rename error: ${err?.message}`, err) logger.error(`Rename error: ${err instanceof Error ? err.message : String(err)}`, err)
return false return false
} }
}, [sftpId, remotePath, loadRemoteDirectory]) }, [sftpId, remotePath, loadRemoteDirectory])
@@ -570,15 +582,15 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
} }
try { try {
const result = await window.electronAPI.sftpStat(sftpId, filePath) const result = await window.electronAPI.sftpStat(sftpId, filePath) as { success: boolean; stats?: { size: number; mode: number; mtime: number; atime: number; isDirectory: boolean; isFile: boolean; isSymbolicLink: boolean }; error?: string }
if (result.success && result.stats) { if (result.success && result.stats) {
return result.stats return result.stats
} else { } else {
logger.error(`Stat failed: ${result.error}`) logger.error(`Stat failed: ${result.error}`)
return null return null
} }
} catch (err: any) { } catch (err: unknown) {
logger.error(`Stat error: ${err?.message}`, err) logger.error(`Stat error: ${err instanceof Error ? err.message : String(err)}`, err)
return null return null
} }
}, [sftpId]) }, [sftpId])
@@ -600,18 +612,23 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
logger.error(`Chmod failed: ${result.error}`) logger.error(`Chmod failed: ${result.error}`)
return false return false
} }
} catch (err: any) { } catch (err: unknown) {
logger.error(`Chmod error: ${err?.message}`, err) logger.error(`Chmod error: ${err instanceof Error ? err.message : String(err)}`, err)
return false return false
} }
}, [sftpId, remotePath, loadRemoteDirectory]) }, [sftpId, remotePath, loadRemoteDirectory])
// Cleanup on unmount // Cleanup on unmount — use refs to avoid re-running when connectionId changes
const connectionIdRef = useRef(connectionId)
connectionIdRef.current = connectionId
useEffect(() => { useEffect(() => {
return () => { return () => {
disconnect() const cid = connectionIdRef.current
if (cid && window.electronAPI) {
window.electronAPI.sshDisconnect(cid).catch(() => {})
} }
}, [disconnect]) }
}, [])
return { return {
sessions, sessions,
+16 -12
View File
@@ -4,6 +4,7 @@ import { useTabStore, type ConnectionInstance } from '../stores/tabStore'
import { getSessionConfig, getConnectionStore, type ConnectionState } from '../stores/connection' import { getSessionConfig, getConnectionStore, type ConnectionState } from '../stores/connection'
import { useTerminalSnapshotStore } from '../stores/terminalSnapshotStore' import { useTerminalSnapshotStore } from '../stores/terminalSnapshotStore'
import { createLogger } from '../utils/logger' import { createLogger } from '../utils/logger'
import type { SSHConnectionConfig } from '../../shared/types/session.types'
const logger = createLogger('useTerminalConnection') const logger = createLogger('useTerminalConnection')
@@ -121,14 +122,8 @@ export function useTerminalConnection({
try { try {
// 获取配置 // 获取配置
let config: { let config: SSHConnectionConfig | null = null
host: string let authType: string | undefined
port: number
username: string
password?: string
privateKey?: string
passphrase?: string
} | null = null
if (currentTab.connectionConfig) { if (currentTab.connectionConfig) {
config = { config = {
@@ -139,14 +134,22 @@ export function useTerminalConnection({
privateKey: currentTab.connectionConfig.privateKey, privateKey: currentTab.connectionConfig.privateKey,
passphrase: currentTab.connectionConfig.passphrase, passphrase: currentTab.connectionConfig.passphrase,
} }
authType = currentTab.connectionConfig.authType
} else if (currentTab.sessionId) { } else if (currentTab.sessionId) {
config = await getSessionConfig(currentTab.sessionId) config = await getSessionConfig(currentTab.sessionId)
authType = config?.authType
} }
if (!config) { if (!config) {
throw new Error('无可用连接信息') throw new Error('无可用连接信息')
} }
// 非密钥认证时,不传递 privateKey/passphrase,避免后端误解析
if (authType && authType !== 'key') {
delete config.privateKey
delete config.passphrase
}
// 创建连接 // 创建连接
const result = await getConnectionStore().createConnection(tabId, config) const result = await getConnectionStore().createConnection(tabId, config)
@@ -205,16 +208,17 @@ export function useTerminalConnection({
xtermRef.current?.focus() xtermRef.current?.focus()
} catch (error: any) { } catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
setConnectionState('error') setConnectionState('error')
updateTabStatus(tabId, 'error') updateTabStatus(tabId, 'error')
setStatusMessage(`错误: ${error.message}`) setStatusMessage(`错误: ${errorMessage}`)
xtermRef.current?.writeln(`\r\n\x1b[31m错误: ${error.message}\x1b[0m`) xtermRef.current?.writeln(`\r\n\x1b[31m错误: ${errorMessage}\x1b[0m`)
} finally { } finally {
isConnectingRef.current = false isConnectingRef.current = false
hasConnectedRef.current = true hasConnectedRef.current = true
} }
}, [tabId, updateTabConnectionInstance, updateTabStatus, getTabById, xtermRef]) }, [tabId, updateTabConnectionInstance, updateTabStatus, updateTab, getTabById, xtermRef])
// 恢复挂起的连接 // 恢复挂起的连接
const resumeConnection = useCallback(async () => { const resumeConnection = useCallback(async () => {
+13 -12
View File
@@ -6,10 +6,9 @@ import { darkColors, lightColors, type AppThemeMode, type ColorMap } from '../th
* Resolves the *effective* theme ('dark' | 'light') from the user preference, * Resolves the *effective* theme ('dark' | 'light') from the user preference,
* including the 'system' → prefers-color-scheme mapping. * including the 'system' → prefers-color-scheme mapping.
*/ */
function resolveEffectiveTheme(preference: AppThemeMode): 'dark' | 'light' { function resolveEffectiveTheme(_preference: AppThemeMode): 'dark' | 'light' {
if (preference !== 'system') return preference // 当前版本固定使用 dark 主题,后续版本开放主题切换
if (typeof window === 'undefined') return 'dark' return 'dark'
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
} }
/** /**
@@ -32,26 +31,28 @@ function applyCSSVars(colors: ColorMap) {
*/ */
export function useTheme() { export function useTheme() {
const theme = useSettingsStore((s) => s.settings.theme) const theme = useSettingsStore((s) => s.settings.theme)
const isLoaded = useSettingsStore((s) => s.isLoaded)
const effective = useMemo(() => resolveEffectiveTheme(theme), [theme]) const effective = useMemo(() => resolveEffectiveTheme(theme), [theme])
// Apply theme to DOM whenever effective theme changes // Apply theme to DOM whenever effective theme changes
useEffect(() => { useEffect(() => {
if (!isLoaded) return
const colors = effective === 'dark' ? darkColors : lightColors const colors = effective === 'dark' ? darkColors : lightColors
// Set data attribute for potential direct CSS selectors // Set data attribute for potential direct CSS selectors
document.documentElement.setAttribute('data-theme', effective) document.documentElement.setAttribute('data-theme', effective)
if (effective === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
// Apply CSS custom properties // Apply CSS custom properties
applyCSSVars(colors) applyCSSVars(colors)
// Update body bg & colour (mirrors index.css body rules but via vars) // Update body bg & colour (mirrors index.css body rules but via vars)
document.body.style.backgroundColor = 'var(--color-dark-900)' document.body.style.backgroundColor = '#0f172a'
document.body.style.color = 'var(--color-text-primary)' document.body.style.color = '#e2e8f0'
}, [effective, isLoaded]) }, [effective])
// Listen for system colour-scheme changes when preference is 'system' // Listen for system colour-scheme changes when preference is 'system'
useEffect(() => { useEffect(() => {
@@ -64,8 +65,8 @@ export function useTheme() {
const colors = e.matches ? darkColors : lightColors const colors = e.matches ? darkColors : lightColors
document.documentElement.setAttribute('data-theme', newEffective) document.documentElement.setAttribute('data-theme', newEffective)
applyCSSVars(colors) applyCSSVars(colors)
document.body.style.backgroundColor = 'var(--color-dark-900)' document.body.style.backgroundColor = '#0f172a'
document.body.style.color = 'var(--color-text-primary)' document.body.style.color = '#e2e8f0'
} }
mql.addEventListener('change', handler) mql.addEventListener('change', handler)
+32 -28
View File
@@ -17,16 +17,36 @@
--color-dark-700: #334155; --color-dark-700: #334155;
--color-dark-600: #475569; --color-dark-600: #475569;
/* Semantic tokens (dark default) */
--color-surface-base: #0f172a;
--color-surface-raised: #1e293b;
--color-surface-overlay: #334155;
--color-surface-inset: #475569;
--color-text-primary: #e2e8f0; --color-text-primary: #e2e8f0;
--color-text-secondary: #cbd5e1; --color-text-secondary: #cbd5e1;
--color-text-muted: #94a3b8; --color-text-muted: #94a3b8;
--color-text-faint: #64748b; --color-text-faint: #64748b;
--color-text-dim: #475569; --color-text-dim: #475569;
--color-content-primary: #e2e8f0;
--color-content-secondary: #cbd5e1;
--color-content-tertiary: #94a3b8;
--color-content-inverse: #0f172a;
--color-border: #334155;
--color-border-strong: #475569;
--color-accent-blue: #3b82f6; --color-accent-blue: #3b82f6;
--color-accent-green: #10b981; --color-accent-green: #10b981;
--color-accent-purple: #8b5cf6; --color-accent-purple: #8b5cf6;
--color-accent-orange: #f97316; --color-accent-orange: #f97316;
--color-accent-red: #ef4444;
--color-feedback-success: #10b981;
--color-feedback-warning: #f59e0b;
--color-feedback-error: #ef4444;
--color-feedback-info: #3b82f6;
--color-scrollbar-track: #1e293b; --color-scrollbar-track: #1e293b;
--color-scrollbar-thumb: #475569; --color-scrollbar-thumb: #475569;
@@ -35,45 +55,20 @@
--color-terminal-bg: #0f172a; --color-terminal-bg: #0f172a;
} }
/* ---- Light theme ---- */
:root[data-theme='light'] {
--color-dark-900: #ffffff;
--color-dark-800: #f3f4f6;
--color-dark-700: #e5e7eb;
--color-dark-600: #d1d5db;
--color-text-primary: #1f2937;
--color-text-secondary: #374151;
--color-text-muted: #6b7280;
--color-text-faint: #9ca3af;
--color-text-dim: #d1d5db;
--color-accent-blue: #2563eb;
--color-accent-green: #059669;
--color-accent-purple: #7c3aed;
--color-accent-orange: #ea580c;
--color-scrollbar-track: #f3f4f6;
--color-scrollbar-thumb: #d1d5db;
--color-scrollbar-thumb-h: #9ca3af;
--color-terminal-bg: #ffffff;
}
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
body { html, body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif; sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background-color: var(--color-dark-900); background-color: #0f172a !important;
color: var(--color-text-primary); color: #e2e8f0;
overflow: hidden; overflow: hidden;
} }
@@ -82,6 +77,15 @@ body {
height: 100vh; height: 100vh;
} }
/* Window chrome utilities */
.drag {
-webkit-app-region: drag;
}
.no-drag {
-webkit-app-region: no-drag;
}
/* Custom scrollbar */ /* Custom scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
+2 -2
View File
@@ -201,10 +201,10 @@ export const usePoolStore = create<PoolState>((set, get) => ({
}) })
return { success: true, connectionId } return { success: true, connectionId }
} catch (error: any) { } catch (error: unknown) {
return { return {
success: false, success: false,
error: error.message || 'Unknown error creating connection', error: error instanceof Error ? error.message : 'Unknown error creating connection',
} }
} }
}, },
+1
View File
@@ -26,6 +26,7 @@ export async function getSessionConfig(sessionId: string): Promise<ConnectionCon
password: session.password, password: session.password,
privateKey: session.privateKey, privateKey: session.privateKey,
passphrase: session.passphrase, passphrase: session.passphrase,
authType: session.authType,
} }
} catch (error) { } catch (error) {
logger.error('Failed to get session config:', error) logger.error('Failed to get session config:', error)
+10 -1
View File
@@ -34,10 +34,19 @@ export const useSessionStore = create<SessionState>((set) => ({
setSessions: (sessions) => set({ sessions }), setSessions: (sessions) => set({ sessions }),
addSession: (session) => { addSession: async (session) => {
// 同步到数据库
const result = await window.electronAPI?.sessionCreate(session)
if (result?.success && result.session) {
set((state) => ({
sessions: [...state.sessions, result.session!],
}))
} else {
// 失败时仍然添加到本地状态,避免 UI 卡住
set((state) => ({ set((state) => ({
sessions: [...state.sessions, session], sessions: [...state.sessions, session],
})) }))
}
}, },
removeSession: (id) => { removeSession: (id) => {
+2 -11
View File
@@ -1,22 +1,13 @@
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware' import { persist } from 'zustand/middleware'
import { createLogger } from '../utils/logger' import { createLogger } from '../utils/logger'
import type { SSHConnectionConfig } from '../../shared/types/session.types'
const logger = createLogger('TabStore') const logger = createLogger('TabStore')
export type TabType = 'welcome' | 'terminal' | 'sftp' | 'portforward' | 'settings' export type TabType = 'welcome' | 'terminal' | 'sftp' | 'portforward' | 'settings'
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error' export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
// SSH 连接配置(用于快速连接)
export interface SSHConnectionConfig {
host: string
port: number
username: string
password?: string
privateKey?: string
passphrase?: string
}
// 连接实例信息 // 连接实例信息
export interface ConnectionInstance { export interface ConnectionInstance {
connectionId: string // 实际的 SSH 连接 ID connectionId: string // 实际的 SSH 连接 ID
@@ -341,7 +332,7 @@ export const useTabStore = create<TabState>()(
logger.info('Restoring tabs from persisted state:', persisted.tabs.length, 'tabs') logger.info('Restoring tabs from persisted state:', persisted.tabs.length, 'tabs')
// 将持久化的标签转换为运行时标签 // 将持久化的标签转换为运行时标签
const restoredTabs: Tab[] = persisted.tabs.map((tab: any) => ({ const restoredTabs: Tab[] = persisted.tabs.map((tab: Tab) => ({
...tab, ...tab,
isActive: false, isActive: false,
connectionStatus: 'disconnected' as ConnectionStatus, connectionStatus: 'disconnected' as ConnectionStatus,
+46
View File
@@ -30,6 +30,12 @@ export const darkColors: ColorMap = {
'dark-700': '#334155', 'dark-700': '#334155',
'dark-600': '#475569', 'dark-600': '#475569',
// Semantic surface tokens
'surface-base': '#0f172a',
'surface-raised': '#1e293b',
'surface-overlay': '#334155',
'surface-inset': '#475569',
// Text // Text
'text-primary': '#e2e8f0', // gray-200 'text-primary': '#e2e8f0', // gray-200
'text-secondary': '#cbd5e1', // gray-300 'text-secondary': '#cbd5e1', // gray-300
@@ -37,11 +43,28 @@ export const darkColors: ColorMap = {
'text-faint': '#64748b', // gray-500 'text-faint': '#64748b', // gray-500
'text-dim': '#475569', // gray-600 'text-dim': '#475569', // gray-600
// Content tokens
'content-primary': '#e2e8f0',
'content-secondary': '#cbd5e1',
'content-tertiary': '#94a3b8',
'content-inverse': '#0f172a',
// Border tokens
'border': '#334155',
'border-strong': '#475569',
// Accent // Accent
'accent-blue': '#3b82f6', 'accent-blue': '#3b82f6',
'accent-green': '#10b981', 'accent-green': '#10b981',
'accent-purple': '#8b5cf6', 'accent-purple': '#8b5cf6',
'accent-orange': '#f97316', 'accent-orange': '#f97316',
'accent-red': '#ef4444',
// Feedback tokens
'feedback-success': '#10b981',
'feedback-warning': '#f59e0b',
'feedback-error': '#ef4444',
'feedback-info': '#3b82f6',
// UI chrome // UI chrome
'scrollbar-track': '#1e293b', 'scrollbar-track': '#1e293b',
@@ -60,6 +83,12 @@ export const lightColors: ColorMap = {
'dark-700': '#e5e7eb', // gray-200 'dark-700': '#e5e7eb', // gray-200
'dark-600': '#d1d5db', // gray-300 'dark-600': '#d1d5db', // gray-300
// Semantic surface tokens
'surface-base': '#ffffff',
'surface-raised': '#f3f4f6',
'surface-overlay': '#e5e7eb',
'surface-inset': '#d1d5db',
// Text // Text
'text-primary': '#1f2937', // gray-800 'text-primary': '#1f2937', // gray-800
'text-secondary': '#374151', // gray-700 'text-secondary': '#374151', // gray-700
@@ -67,11 +96,28 @@ export const lightColors: ColorMap = {
'text-faint': '#9ca3af', // gray-400 'text-faint': '#9ca3af', // gray-400
'text-dim': '#d1d5db', // gray-300 'text-dim': '#d1d5db', // gray-300
// Content tokens
'content-primary': '#1f2937',
'content-secondary': '#374151',
'content-tertiary': '#6b7280',
'content-inverse': '#ffffff',
// Border tokens
'border': '#e5e7eb',
'border-strong': '#d1d5db',
// Accent (kept the same hue family, slightly adjusted for light bg) // Accent (kept the same hue family, slightly adjusted for light bg)
'accent-blue': '#2563eb', 'accent-blue': '#2563eb',
'accent-green': '#059669', 'accent-green': '#059669',
'accent-purple': '#7c3aed', 'accent-purple': '#7c3aed',
'accent-orange': '#ea580c', 'accent-orange': '#ea580c',
'accent-red': '#dc2626',
// Feedback tokens
'feedback-success': '#059669',
'feedback-warning': '#d97706',
'feedback-error': '#dc2626',
'feedback-info': '#2563eb',
// UI chrome // UI chrome
'scrollbar-track': '#f3f4f6', 'scrollbar-track': '#f3f4f6',
+66 -5
View File
@@ -4,21 +4,82 @@ export default {
"./index.html", "./index.html",
"./src/**/*.{js,ts,jsx,tsx}", "./src/**/*.{js,ts,jsx,tsx}",
], ],
darkMode: 'class',
theme: { theme: {
extend: { extend: {
colors: { colors: {
// Background surfaces — driven by CSS custom properties set per theme // Semantic color tokens — driven by CSS custom properties set per theme
surface: {
base: 'var(--color-surface-base)',
raised: 'var(--color-surface-raised)',
overlay: 'var(--color-surface-overlay)',
inset: 'var(--color-surface-inset)',
},
// Legacy dark-* aliases for backward compatibility
dark: { dark: {
900: 'var(--color-dark-900)', 900: 'var(--color-surface-base)',
800: 'var(--color-dark-800)', 800: 'var(--color-surface-raised)',
700: 'var(--color-dark-700)', 700: 'var(--color-surface-overlay)',
600: 'var(--color-dark-600)', 600: 'var(--color-surface-inset)',
}, },
accent: { accent: {
blue: 'var(--color-accent-blue)', blue: 'var(--color-accent-blue)',
green: 'var(--color-accent-green)', green: 'var(--color-accent-green)',
purple: 'var(--color-accent-purple)', purple: 'var(--color-accent-purple)',
orange: 'var(--color-accent-orange)', orange: 'var(--color-accent-orange)',
red: 'var(--color-accent-red)',
},
content: {
primary: 'var(--color-content-primary)',
secondary: 'var(--color-content-secondary)',
tertiary: 'var(--color-content-tertiary)',
inverse: 'var(--color-content-inverse)',
},
border: {
DEFAULT: 'var(--color-border)',
strong: 'var(--color-border-strong)',
},
},
fontFamily: {
sans: [
'Inter',
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'"Noto Sans SC"',
'"PingFang SC"',
'"Microsoft YaHei"',
'sans-serif',
],
mono: [
'Menlo',
'Monaco',
'Consolas',
'"Liberation Mono"',
'"Courier New"',
'monospace',
],
},
animation: {
'fade-in': 'fadeIn 0.15s ease-out',
'slide-up': 'slideUp 0.2s ease-out',
'scale-in': 'scaleIn 0.15s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(8px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
scaleIn: {
'0%': { opacity: '0', transform: 'scale(0.96)' },
'100%': { opacity: '1', transform: 'scale(1)' },
}, },
}, },
}, },
+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(), removeListener: vi.fn(),
} }
window.electronAPI = mockElectronAPI as any window.electronAPI = mockElectronAPI as unknown as typeof window.electronAPI
// Mock navigator // Mock navigator
Object.defineProperty(window, 'navigator', { Object.defineProperty(window, 'navigator', {
@@ -98,7 +98,7 @@ Object.defineProperty(window, 'matchMedia', {
// Suppress console errors in tests // Suppress console errors in tests
const originalError = console.error const originalError = console.error
beforeAll(() => { beforeAll(() => {
console.error = (...args: any[]) => { console.error = (...args: unknown[]) => {
if ( if (
typeof args[0] === 'string' && typeof args[0] === 'string' &&
(args[0].includes('Warning:') || args[0].includes('ReactDOM.render')) (args[0].includes('Warning:') || args[0].includes('ReactDOM.render'))
+1 -1
View File
@@ -6,7 +6,7 @@
*/ */
import { describe, it, expect } from 'vitest' 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('ConnectionPool Store', () => {
describe('store 初始化', () => { describe('store 初始化', () => {
+4 -4
View File
@@ -44,7 +44,7 @@ describe('TabStore', () => {
}) })
it('添加 settings 类型时如果已存在则激活而不是创建新的', () => { it('添加 settings 类型时如果已存在则激活而不是创建新的', () => {
const { addTab, tabs } = useTabStore.getState() const { addTab } = useTabStore.getState()
addTab({ title: 'Settings', type: 'settings' }) addTab({ title: 'Settings', type: 'settings' })
addTab({ title: 'Settings 2', type: 'settings' }) addTab({ title: 'Settings 2', type: 'settings' })
@@ -86,12 +86,12 @@ describe('TabStore', () => {
addTab({ title: 'Tab 1', type: 'terminal' }) addTab({ title: 'Tab 1', type: 'terminal' })
addTab({ title: 'Tab 2', type: 'sftp' }) addTab({ title: 'Tab 2', type: 'sftp' })
const { tabs, activeTabId } = useTabStore.getState() const { tabs } = useTabStore.getState()
const tabToClose = tabs.find(t => t.title === 'Tab 1') const tabToClose = tabs.find(t => t.title === 'Tab 1')
if (tabToClose) { if (tabToClose) {
closeTab(tabToClose.id) 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') expect(newTabs.find(t => t.isActive)?.title).toBe('Tab 2')
} }
}) })
@@ -112,7 +112,7 @@ describe('TabStore', () => {
describe('closeOtherTabs', () => { describe('closeOtherTabs', () => {
it('应该关闭除指定标签外的所有标签', () => { it('应该关闭除指定标签外的所有标签', () => {
const { addTab, closeOtherTabs, tabs } = useTabStore.getState() const { addTab, closeOtherTabs } = useTabStore.getState()
addTab({ title: 'Tab 1', type: 'terminal' }) addTab({ title: 'Tab 1', type: 'terminal' })
addTab({ title: 'Tab 2', type: 'sftp' }) addTab({ title: 'Tab 2', type: 'sftp' })
+22 -3
View File
@@ -9,23 +9,42 @@ export default defineConfig({
build: { build: {
outDir: 'dist', outDir: 'dist',
emptyOutDir: true, emptyOutDir: true,
sourcemap: true,
cssCodeSplit: true,
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: { manualChunks: {
'vendor-core': ['react', 'react-dom', 'react-router-dom'],
'vendor-xterm': ['@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-search', '@xterm/addon-webgl'], 'vendor-xterm': ['@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-search', '@xterm/addon-webgl'],
'vendor-react': ['react', 'react-dom'], 'vendor-utils': ['zustand', 'lucide-react'],
'vendor-lucide': ['lucide-react'], },
'vendor-zustand': ['zustand'], chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: (assetInfo) => {
const info = assetInfo.name || ''
if (/\.(png|jpe?g|gif|svg|webp|ico)$/i.test(info)) {
return 'assets/images/[name]-[hash][extname]'
}
if (/\.css$/i.test(info)) {
return 'assets/css/[name]-[hash][extname]'
}
return 'assets/[name]-[hash][extname]'
}, },
}, },
}, },
assetsInlineLimit: 4096,
}, },
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
'@shared': path.resolve(__dirname, './shared'),
}, },
}, },
server: { server: {
port: 5173, port: 5173,
strictPort: true,
},
optimizeDeps: {
include: ['@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-search', '@xterm/addon-webgl', 'react', 'react-dom'],
}, },
}) })