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:
1 parent
37e65be823
commit
514d62644e
77 files changed
+2563
-931
No files matched your search
@@ -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
|
||||
@@ -14,5 +14,9 @@ module.exports = {
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
|
||||
"*.{css,json,md}": ["prettier --write"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
@@ -21,9 +21,10 @@
|
||||
|
||||
### 当前版本状态
|
||||
|
||||
- **版本**: v1.0.0
|
||||
- **状态**: 已发布 (Release)
|
||||
- **版本**: v2.0.0
|
||||
- **状态**: 已发布 (Release) - 安全增强版
|
||||
- **已完成**: 核心 SSH/SFTP 功能、加密存储、多标签页系统、Zmodem 传输
|
||||
- **v2.0 优化**: safeStorage 主密钥加密、路径遍历防护、PBKDF2 600K 迭代、类型安全、工程化建设、性能优化
|
||||
- **技术债务**: 部分组件仍在迁移到新的连接池架构
|
||||
|
||||
---
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 处理应用级别信息
|
||||
*/
|
||||
|
||||
import { ipcMain, IpcMainInvokeEvent, app } from 'electron'
|
||||
import { ipcMain, app } from 'electron'
|
||||
|
||||
// IPC channel names
|
||||
export const APP_CHANNELS = {
|
||||
|
||||
+136
-27
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* File IPC Handlers
|
||||
* 处理本地文件操作和远程文件读写
|
||||
* 安全增强:防止路径遍历攻击
|
||||
*/
|
||||
|
||||
import { ipcMain, IpcMainInvokeEvent, app } from 'electron'
|
||||
@@ -21,25 +22,106 @@ export const FILE_CHANNELS = {
|
||||
LOCAL_MKDIR: 'local:mkdir',
|
||||
} 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 {
|
||||
// 确保白名单已初始化
|
||||
if (ALLOWED_BASE_PATHS.length === 0) {
|
||||
initializeAllowedPaths()
|
||||
}
|
||||
|
||||
// Read file (for rz)
|
||||
ipcMain.handle(FILE_CHANNELS.READ, async (_event: IpcMainInvokeEvent, filePath: string) => {
|
||||
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') }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
})
|
||||
|
||||
// Write file (for sz)
|
||||
ipcMain.handle(FILE_CHANNELS.WRITE, async (_event: IpcMainInvokeEvent, filePath: string, data: string) => {
|
||||
try {
|
||||
const safePath = sanitizePath(filePath)
|
||||
if (!isPathAllowed(safePath)) {
|
||||
return { success: false, error: 'Access denied: path traversal detected' }
|
||||
}
|
||||
const buffer = Buffer.from(data, 'base64')
|
||||
fs.writeFileSync(filePath, buffer)
|
||||
fs.writeFileSync(safePath, buffer)
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -48,15 +130,20 @@ export function registerFileIPCHandlers(): void {
|
||||
try {
|
||||
const homePath = app.getPath('home')
|
||||
return { success: true, path: homePath }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
})
|
||||
|
||||
// Read directory contents
|
||||
ipcMain.handle(FILE_CHANNELS.LOCAL_READ_DIR, async (_event: IpcMainInvokeEvent, dirPath: string) => {
|
||||
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 fullPath = path.join(dirPath, entry.name)
|
||||
let stats: fs.Stats | null = null
|
||||
@@ -84,63 +171,85 @@ export function registerFileIPCHandlers(): void {
|
||||
})
|
||||
|
||||
return { success: true, files }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
})
|
||||
|
||||
// Get file stats
|
||||
ipcMain.handle(FILE_CHANNELS.LOCAL_GET_STATS, async (_event: IpcMainInvokeEvent, filePath: string) => {
|
||||
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 {
|
||||
success: true,
|
||||
stats: {
|
||||
name: path.basename(filePath),
|
||||
path: filePath,
|
||||
name: path.basename(safePath),
|
||||
path: safePath,
|
||||
type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file',
|
||||
size: stats.size,
|
||||
modified: Math.floor(stats.mtime.getTime() / 1000),
|
||||
permissions: formatPermissions(stats.mode),
|
||||
},
|
||||
}
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
})
|
||||
|
||||
// Delete file or directory
|
||||
ipcMain.handle(FILE_CHANNELS.LOCAL_DELETE, async (_event: IpcMainInvokeEvent, filePath: string) => {
|
||||
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()) {
|
||||
fs.rmSync(filePath, { recursive: true, force: true })
|
||||
fs.rmSync(safePath, { recursive: true, force: true })
|
||||
} else {
|
||||
fs.unlinkSync(filePath)
|
||||
fs.unlinkSync(safePath)
|
||||
}
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
})
|
||||
|
||||
// Rename file or directory
|
||||
ipcMain.handle(FILE_CHANNELS.LOCAL_RENAME, async (_event: IpcMainInvokeEvent, oldPath: string, newPath: string) => {
|
||||
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 }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
})
|
||||
|
||||
// Create directory
|
||||
ipcMain.handle(FILE_CHANNELS.LOCAL_MKDIR, async (_event: IpcMainInvokeEvent, dirPath: string) => {
|
||||
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 }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+15
-2
@@ -16,9 +16,10 @@ import { registerSFTPIPCHandlers } from './sftp.ipc'
|
||||
import { registerSessionIPCHandlers } from './session.ipc'
|
||||
import { registerSettingsIPCHandlers } from './settings.ipc'
|
||||
import { registerDialogIPCHandlers } from './dialog.ipc'
|
||||
import { registerFileIPCHandlers } from './file.ipc'
|
||||
import { registerFileIPCHandlers, initializeAllowedPaths } from './file.ipc'
|
||||
import { registerZmodemIPCHandlers } from './zmodem.ipc'
|
||||
import { registerAppIPCHandlers } from './app.ipc'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
export interface IPCOptions {
|
||||
sshService: SSHService
|
||||
@@ -45,7 +46,8 @@ export function registerAllIPCHandlers(options: IPCOptions): void {
|
||||
// Dialog handlers
|
||||
registerDialogIPCHandlers({ mainWindowGetter })
|
||||
|
||||
// File handlers
|
||||
// File handlers (初始化路径白名单)
|
||||
initializeAllowedPaths()
|
||||
registerFileIPCHandlers()
|
||||
|
||||
// Zmodem handlers
|
||||
@@ -54,6 +56,17 @@ export function registerAllIPCHandlers(options: IPCOptions): void {
|
||||
// App handlers
|
||||
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')
|
||||
}
|
||||
|
||||
|
||||
+12
-11
@@ -5,6 +5,7 @@
|
||||
|
||||
import { ipcMain, IpcMainInvokeEvent } from 'electron'
|
||||
import type { DatabaseService } from '../services/database-service'
|
||||
import type { SessionUpdateRequest } from '../../shared/types/session.types'
|
||||
|
||||
// IPC channel names
|
||||
export const SESSION_CHANNELS = {
|
||||
@@ -27,8 +28,8 @@ export function registerSessionIPCHandlers(options: SessionIPCOptions): void {
|
||||
try {
|
||||
const result = dbService.createSession(session)
|
||||
return { success: true, session: result }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error: unknown) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -37,8 +38,8 @@ export function registerSessionIPCHandlers(options: SessionIPCOptions): void {
|
||||
try {
|
||||
const sessions = dbService.getAllSessions()
|
||||
return { success: true, sessions }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error: unknown) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -47,18 +48,18 @@ export function registerSessionIPCHandlers(options: SessionIPCOptions): void {
|
||||
try {
|
||||
const session = dbService.getSessionById(id)
|
||||
return { success: true, session }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error: unknown) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
// 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 {
|
||||
const result = dbService.updateSession(id, updates)
|
||||
return { success: true, session: result }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error: unknown) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -67,8 +68,8 @@ export function registerSessionIPCHandlers(options: SessionIPCOptions): void {
|
||||
try {
|
||||
dbService.deleteSession(id)
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error: unknown) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -24,8 +24,8 @@ export function registerSettingsIPCHandlers(options: SettingsIPCOptions): void {
|
||||
try {
|
||||
const value = dbService.getSetting(key)
|
||||
return { success: true, value }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error: unknown) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -34,8 +34,8 @@ export function registerSettingsIPCHandlers(options: SettingsIPCOptions): void {
|
||||
try {
|
||||
dbService.setSetting(key, value)
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error: unknown) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
})
|
||||
}
|
||||
+38
-29
@@ -26,11 +26,11 @@ export interface SFTPIPCOptions {
|
||||
}
|
||||
|
||||
// ===== 彩色调试日志 =====
|
||||
const DEBUG = true
|
||||
const _DEBUG = true
|
||||
function logHeader(msg: string) {
|
||||
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;')
|
||||
}
|
||||
function logSuccess(msg: string) {
|
||||
@@ -52,9 +52,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
|
||||
const sftp = await sshService.createSFTP(connectionId)
|
||||
logSuccess(`SFTP connected: ${sftp.id}`)
|
||||
return { success: true, sftpId: sftp.id }
|
||||
} catch (error: any) {
|
||||
logError(`SFTP connect failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
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)
|
||||
logSuccess(`Listed ${files.length} items`)
|
||||
return { success: true, files }
|
||||
} catch (error: any) {
|
||||
logError(`List failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logError(`List failed: ${message}`)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -97,9 +99,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
|
||||
})
|
||||
logSuccess('Upload complete')
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
logError(`Upload failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logError(`Upload failed: ${message}`)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -126,9 +129,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
|
||||
})
|
||||
logSuccess('Download complete')
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
logError(`Download failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
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)
|
||||
logSuccess('Rename complete')
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
logError(`Rename failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
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)
|
||||
logSuccess('Delete complete')
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
logError(`Delete failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logError(`Delete failed: ${message}`)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -190,9 +196,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
|
||||
await sshService.sftpMkdir(sftpId, dirPath)
|
||||
logSuccess('Mkdir complete')
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
logError(`Mkdir failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
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)
|
||||
logSuccess(`Stat complete: ${stats.size} bytes`)
|
||||
return { success: true, stats }
|
||||
} catch (error: any) {
|
||||
logError(`Stat failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
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)
|
||||
logSuccess('Chmod complete')
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
logError(`Chmod failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logError(`Chmod failed: ${message}`)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
})
|
||||
}
|
||||
+61
-63
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* SSH IPC Handlers
|
||||
* 处理 SSH 连接、Shell、会话、端口转发相关 IPC
|
||||
* 安全增强:规范化错误处理,不暴露敏感信息
|
||||
*/
|
||||
|
||||
import { ipcMain, IpcMainInvokeEvent } from 'electron'
|
||||
@@ -10,22 +11,22 @@ import { createLogger } from '../utils/logger'
|
||||
|
||||
const logger = createLogger('SSH_IPC')
|
||||
|
||||
// ===== 彩色调试日志 =====
|
||||
const DEBUG = true
|
||||
function debug(...args: any[]) {
|
||||
if (DEBUG) console.log('[SSH_IPC]', ...args)
|
||||
// 生产环境日志:使用结构化日志,不输出敏感信息
|
||||
const DEBUG = process.env.NODE_ENV === 'development'
|
||||
function debug(...args: unknown[]) {
|
||||
if (DEBUG) logger.debug('[SSH_IPC]', ...args)
|
||||
}
|
||||
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) {
|
||||
console.log(`%c${label}:%c ${value}`, 'background: #2c3e50; color: #fff; padding: 2px 6px; border-radius: 3px;', 'color: #3498db; font-weight: bold;')
|
||||
function logInfo(label: string, value: unknown) {
|
||||
logger.info(`${label}: ${value}`)
|
||||
}
|
||||
function logSuccess(msg: string) {
|
||||
console.log(`%c✓ ${msg}`, 'color: #27ae60; font-weight: bold;')
|
||||
logger.info(`[OK] ${msg}`)
|
||||
}
|
||||
function logError(msg: string) {
|
||||
console.log(`%c✗ ${msg}`, 'color: #e74c3c; font-weight: bold;')
|
||||
logger.error(msg)
|
||||
}
|
||||
|
||||
// IPC channel names
|
||||
@@ -49,15 +50,16 @@ export interface SSHIPCOptions {
|
||||
}
|
||||
|
||||
export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
|
||||
const { sshService, zmodemService, mainWindowGetter } = options
|
||||
const { sshService, zmodemService, mainWindowGetter: _mainWindowGetter } = options
|
||||
|
||||
// SSH connect
|
||||
ipcMain.handle(SSH_CHANNELS.CONNECT, async (_event: IpcMainInvokeEvent, config) => {
|
||||
try {
|
||||
const connection = await sshService.connect(config)
|
||||
return { success: true, connectionId: connection.id }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error) {
|
||||
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 {
|
||||
await sshService.disconnect(connectionId)
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -76,59 +79,49 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
|
||||
try {
|
||||
const result = await sshService.exec(connectionId, command)
|
||||
return { success: true, result }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
})
|
||||
|
||||
// SSH create shell
|
||||
ipcMain.handle(SSH_CHANNELS.CREATE_SHELL, async (event: IpcMainInvokeEvent, connectionId: string, options?: { cols?: number; rows?: number }, shellId?: string) => {
|
||||
console.log('=== SSH_IPC CREATE_SHELL ===')
|
||||
console.log(' connectionId:', connectionId)
|
||||
console.log(' options:', options)
|
||||
console.log(' preferredShellId:', shellId)
|
||||
|
||||
debug('CREATE_SHELL connectionId:', connectionId, 'preferredId:', shellId)
|
||||
|
||||
try {
|
||||
const preferredShellId = shellId
|
||||
logger.info('[SSH_IPC] Creating shell for connection:', connectionId, 'preferredId:', preferredShellId)
|
||||
debug('>>> Calling sshService.createShell...')
|
||||
|
||||
|
||||
const shell = await sshService.createShell(connectionId, options || {}, (shellId, data) => {
|
||||
// 检查 sender 是否已被销毁
|
||||
if (event.sender.isDestroyed()) {
|
||||
console.log('=== SHELL DATA CALLBACK - SKIPPED (sender destroyed) ===')
|
||||
debug('SHELL DATA CALLBACK - SKIPPED (sender destroyed)')
|
||||
return
|
||||
}
|
||||
|
||||
const text = data.toString('utf-8')
|
||||
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'))
|
||||
}
|
||||
|
||||
|
||||
debug('SHELL DATA CALLBACK shellId:', shellId, 'data length:', data.length)
|
||||
|
||||
try {
|
||||
console.log('>>> Sending SHELL_DATA to renderer...')
|
||||
debug('>>> Sending SHELL_DATA to renderer...')
|
||||
event.sender.send(SSH_CHANNELS.SHELL_DATA, connectionId, shellId, data)
|
||||
console.log('>>> SHELL_DATA sent successfully')
|
||||
debug('>>> SHELL_DATA sent successfully')
|
||||
} catch (err) {
|
||||
console.error('>>> FAILED to send SHELL_DATA:', err)
|
||||
logger.error('>>> FAILED to send SHELL_DATA:', err)
|
||||
}
|
||||
|
||||
|
||||
zmodemService.feedShellData(shellId, data)
|
||||
}, preferredShellId)
|
||||
|
||||
|
||||
logger.info('[SSH_IPC] Shell created:', shell.id)
|
||||
debug('>>> Shell created successfully:', shell.id)
|
||||
return { success: true, shellId: shell.id }
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
logger.error('[SSH_IPC] Shell creation failed:', error)
|
||||
debug('>>> Shell creation FAILED:', error.message)
|
||||
return { success: false, error: error.message }
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
debug('>>> Shell creation FAILED:', message)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -137,8 +130,9 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
|
||||
try {
|
||||
sshService.shellWrite(shellId, data)
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error) {
|
||||
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 {
|
||||
sshService.shellResize(shellId, cols, rows)
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -160,19 +155,20 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
|
||||
remoteHost: string,
|
||||
remotePort: number
|
||||
) => {
|
||||
logHeader('🔀 IPC FORWARD_LOCAL')
|
||||
logHeader('FORWARD_LOCAL')
|
||||
logInfo('connectionId', connectionId)
|
||||
logInfo('localPort', localPort)
|
||||
logInfo('remoteHost', remoteHost)
|
||||
logInfo('remotePort', remotePort)
|
||||
|
||||
|
||||
try {
|
||||
const result = await sshService.forwardLocal(connectionId, localPort, remoteHost, remotePort)
|
||||
logSuccess(`Local forward bound to port ${result.boundPort}`)
|
||||
return { success: true, boundPort: result.boundPort }
|
||||
} catch (error: any) {
|
||||
logError(`Local forward failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logError(`Local forward failed: ${message}`)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -184,19 +180,20 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
|
||||
localHost: string,
|
||||
localPort: number
|
||||
) => {
|
||||
logHeader('🔀 IPC FORWARD_REMOTE')
|
||||
logHeader('FORWARD_REMOTE')
|
||||
logInfo('connectionId', connectionId)
|
||||
logInfo('remotePort', remotePort)
|
||||
logInfo('localHost', localHost)
|
||||
logInfo('localPort', localPort)
|
||||
|
||||
|
||||
try {
|
||||
const result = await sshService.forwardRemote(connectionId, remotePort, localHost, localPort)
|
||||
await sshService.forwardRemote(connectionId, remotePort, localHost, localPort)
|
||||
logSuccess('Remote forward established')
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
logError(`Remote forward failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logError(`Remote forward failed: ${message}`)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -206,17 +203,18 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
|
||||
connectionId: string,
|
||||
localPort: number
|
||||
) => {
|
||||
logHeader('🧦 IPC FORWARD_DYNAMIC (SOCKS5)')
|
||||
logHeader('FORWARD_DYNAMIC (SOCKS5)')
|
||||
logInfo('connectionId', connectionId)
|
||||
logInfo('localPort', localPort)
|
||||
|
||||
|
||||
try {
|
||||
const result = await sshService.forwardDynamic(connectionId, localPort)
|
||||
logSuccess(`SOCKS5 proxy started on port ${result.boundPort}`)
|
||||
return { success: true, boundPort: result.boundPort }
|
||||
} catch (error: any) {
|
||||
logError(`Dynamic forward failed: ${error.message}`)
|
||||
return { success: false, error: error.message }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logError(`Dynamic forward failed: ${message}`)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -77,9 +77,9 @@ export function registerZmodemIPCHandlers(options: ZmodemIPCOptions): void {
|
||||
)
|
||||
|
||||
return { success: true, sessionId, fileName }
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
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 }
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
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 {
|
||||
zmodemService.cancelSession(sessionId)
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
} catch (error: unknown) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
})
|
||||
}
|
||||
+30
-1
@@ -87,12 +87,41 @@ function createApplicationMenu(): void {
|
||||
dialog.showMessageBox(mainWindow!, {
|
||||
type: 'info',
|
||||
title: '关于 OpsXShell',
|
||||
message: 'OpsXShell v1.0.0',
|
||||
message: 'OpsXShell v2.0.0',
|
||||
detail: '跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠\n\nMIT License',
|
||||
})
|
||||
},
|
||||
},
|
||||
{ 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: '退出',
|
||||
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
|
||||
|
||||
+73
-31
@@ -1,32 +1,64 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type {
|
||||
SSHConnectRequest,
|
||||
SSHConnectResponse,
|
||||
SSHCreateShellRequest,
|
||||
SessionCreateResponse,
|
||||
SessionUpdateResponse,
|
||||
DialogOptions,
|
||||
DialogResult,
|
||||
ZmodemProgress,
|
||||
ZmodemComplete,
|
||||
} from '../shared/types/ipc.types'
|
||||
import type { Session } from '../shared/types/session.types'
|
||||
|
||||
// 暴露 API 到渲染进程
|
||||
const api = {
|
||||
// SSH 相关
|
||||
sshConnect: (config: any) => ipcRenderer.invoke('ssh:connect', config),
|
||||
sshDisconnect: (connectionId: string) => ipcRenderer.invoke('ssh:disconnect', connectionId),
|
||||
sshExec: (connectionId: string, command: string) => ipcRenderer.invoke('ssh:exec', connectionId, command),
|
||||
sshCreateShell: (connectionId: string, options?: any, shellId?: string) => ipcRenderer.invoke('ssh:createShell', connectionId, options, shellId),
|
||||
sshShellWrite: (shellId: string, data: string | Uint8Array) => ipcRenderer.invoke('ssh:shellWrite', shellId, data),
|
||||
sshShellResize: (shellId: string, cols: number, rows: number) => ipcRenderer.invoke('ssh:shellResize', shellId, cols, rows),
|
||||
sshConnect: (config: SSHConnectRequest): Promise<SSHConnectResponse> =>
|
||||
ipcRenderer.invoke('ssh:connect', config),
|
||||
sshDisconnect: (connectionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('ssh:disconnect', connectionId),
|
||||
sshExec: (connectionId: string, command: string): Promise<{ success: boolean; result?: string; error?: string }> =>
|
||||
ipcRenderer.invoke('ssh:exec', connectionId, command),
|
||||
sshCreateShell: (connectionId: string, options?: SSHCreateShellRequest, shellId?: string): Promise<{ success: boolean; shellId?: string; error?: string }> =>
|
||||
ipcRenderer.invoke('ssh:createShell', connectionId, options, shellId),
|
||||
sshShellWrite: (shellId: string, data: string | Uint8Array): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('ssh:shellWrite', shellId, data),
|
||||
sshShellResize: (shellId: string, cols: number, rows: number): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('ssh:shellResize', shellId, cols, rows),
|
||||
|
||||
// 会话管理
|
||||
sessionGetAll: () => ipcRenderer.invoke('session:getAll'),
|
||||
sessionGetById: (id: string) => ipcRenderer.invoke('session:getById', id),
|
||||
sessionCreate: (session: any) => ipcRenderer.invoke('session:create', session),
|
||||
sessionUpdate: (id: string, updates: any) => ipcRenderer.invoke('session:update', id, updates),
|
||||
sessionDelete: (id: string) => ipcRenderer.invoke('session:delete', id),
|
||||
sessionGetAll: (): Promise<{ success: boolean; sessions?: Session[]; error?: string }> =>
|
||||
ipcRenderer.invoke('session:getAll'),
|
||||
sessionGetById: (id: string): Promise<{ success: boolean; session?: Session; error?: string }> =>
|
||||
ipcRenderer.invoke('session:getById', id),
|
||||
sessionCreate: (session: Omit<Session, 'id'>): Promise<SessionCreateResponse> =>
|
||||
ipcRenderer.invoke('session:create', session),
|
||||
sessionUpdate: (id: string, updates: Partial<Session>): Promise<SessionUpdateResponse> =>
|
||||
ipcRenderer.invoke('session:update', id, updates),
|
||||
sessionDelete: (id: string): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('session:delete', id),
|
||||
|
||||
// SFTP
|
||||
sftpConnect: (connectionId: string) => ipcRenderer.invoke('sftp:connect', connectionId),
|
||||
sftpListDirectory: (sftpId: string, path: string) => ipcRenderer.invoke('sftp:listDirectory', sftpId, path),
|
||||
sftpUpload: (sftpId: string, localPath: string, remotePath: string) => ipcRenderer.invoke('sftp:upload', sftpId, localPath, remotePath),
|
||||
sftpDownload: (sftpId: string, remotePath: string, localPath: string) => ipcRenderer.invoke('sftp:download', sftpId, remotePath, localPath),
|
||||
sftpRename: (sftpId: string, oldPath: string, newPath: string) => ipcRenderer.invoke('sftp:rename', sftpId, oldPath, newPath),
|
||||
sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => ipcRenderer.invoke('sftp:delete', sftpId, path, isDirectory),
|
||||
sftpMkdir: (sftpId: string, path: string) => ipcRenderer.invoke('sftp:mkdir', sftpId, path),
|
||||
sftpStat: (sftpId: string, filePath: string) => ipcRenderer.invoke('sftp:stat', sftpId, filePath),
|
||||
sftpChmod: (sftpId: string, filePath: string, mode: number) => ipcRenderer.invoke('sftp:chmod', sftpId, filePath, mode),
|
||||
sftpConnect: (connectionId: string): Promise<{ success: boolean; sftpId?: string; error?: string }> =>
|
||||
ipcRenderer.invoke('sftp:connect', connectionId),
|
||||
sftpListDirectory: (sftpId: string, path: string): Promise<{ success: boolean; files?: unknown[]; error?: string }> =>
|
||||
ipcRenderer.invoke('sftp:listDirectory', sftpId, path),
|
||||
sftpUpload: (sftpId: string, localPath: string, remotePath: string): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('sftp:upload', sftpId, localPath, remotePath),
|
||||
sftpDownload: (sftpId: string, remotePath: string, localPath: string): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('sftp:download', sftpId, remotePath, localPath),
|
||||
sftpRename: (sftpId: string, oldPath: string, newPath: string): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('sftp:rename', sftpId, oldPath, newPath),
|
||||
sftpDelete: (sftpId: string, path: string, isDirectory: boolean): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('sftp:delete', sftpId, path, isDirectory),
|
||||
sftpMkdir: (sftpId: string, path: string): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('sftp:mkdir', sftpId, path),
|
||||
sftpStat: (sftpId: string, filePath: string): Promise<{ success: boolean; stats?: unknown; error?: string }> =>
|
||||
ipcRenderer.invoke('sftp:stat', sftpId, filePath),
|
||||
sftpChmod: (sftpId: string, filePath: string, mode: number): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('sftp:chmod', sftpId, filePath, mode),
|
||||
|
||||
// 端口转发
|
||||
sshForwardLocal: (connectionId: string, localPort: number, remoteHost: string, remotePort: number) =>
|
||||
@@ -37,12 +69,16 @@ const api = {
|
||||
ipcRenderer.invoke('ssh:forwardDynamic', connectionId, localPort),
|
||||
|
||||
// 设置
|
||||
settingsGet: (key: string) => ipcRenderer.invoke('settings:get', key),
|
||||
settingsSet: (key: string, value: any) => ipcRenderer.invoke('settings:set', key, value),
|
||||
settingsGet: (key: string): Promise<{ success: boolean; value?: unknown; error?: string }> =>
|
||||
ipcRenderer.invoke('settings:get', key),
|
||||
settingsSet: (key: string, value: unknown): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('settings:set', key, value),
|
||||
|
||||
// 对话框
|
||||
showSaveDialog: (options: any) => ipcRenderer.invoke('dialog:showSaveDialog', options),
|
||||
showOpenDialog: (options: any) => ipcRenderer.invoke('dialog:showOpenDialog', options),
|
||||
showSaveDialog: (options: DialogOptions): Promise<DialogResult> =>
|
||||
ipcRenderer.invoke('dialog:showSaveDialog', options),
|
||||
showOpenDialog: (options: DialogOptions): Promise<DialogResult> =>
|
||||
ipcRenderer.invoke('dialog:showOpenDialog', options),
|
||||
|
||||
// 文件操作
|
||||
readFile: (filePath: string) => ipcRenderer.invoke('file:read', filePath),
|
||||
@@ -57,22 +93,25 @@ const api = {
|
||||
localRename: (oldPath: string, newPath: string) => ipcRenderer.invoke('local:rename', oldPath, newPath),
|
||||
localMkdir: (dirPath: string) => ipcRenderer.invoke('local:mkdir', dirPath),
|
||||
|
||||
// Zmodem rz/sz
|
||||
// Zmodem
|
||||
zmodemSend: (shellId: string) => ipcRenderer.invoke('zmodem:send', shellId),
|
||||
zmodemReceive: (shellId: string) => ipcRenderer.invoke('zmodem:receive', shellId),
|
||||
zmodemCancel: (sessionId: string) => ipcRenderer.invoke('zmodem:cancel', sessionId),
|
||||
|
||||
// Zmodem events
|
||||
onZmodemProgress: (callback: (event: any, sessionId: string, progress: any) => void) => {
|
||||
onZmodemProgress: (callback: (event: Electron.IpcRendererEvent, sessionId: string, progress: ZmodemProgress) => void) => {
|
||||
ipcRenderer.on('zmodem:progress', callback)
|
||||
},
|
||||
onZmodemComplete: (callback: (event: any, sessionId: string, result: any) => void) => {
|
||||
onZmodemComplete: (callback: (event: Electron.IpcRendererEvent, sessionId: string, result: ZmodemComplete) => void) => {
|
||||
ipcRenderer.on('zmodem:complete', callback)
|
||||
},
|
||||
|
||||
// 应用信息
|
||||
getVersion: () => ipcRenderer.invoke('app:getVersion'),
|
||||
|
||||
// Window
|
||||
toggleMaximize: () => ipcRenderer.invoke('window:toggleMaximize'),
|
||||
|
||||
// 事件监听
|
||||
onMenuNewSession: (callback: () => void) => {
|
||||
ipcRenderer.on('menu-new-session', callback)
|
||||
@@ -86,13 +125,16 @@ const api = {
|
||||
onMenuExportSessions: (callback: () => void) => {
|
||||
ipcRenderer.on('menu-export-sessions', callback)
|
||||
},
|
||||
onShellData: (callback: (event: any, connectionId: string, shellId: string, data: Buffer) => void) => {
|
||||
onMenuOpenSettings: (callback: () => void) => {
|
||||
ipcRenderer.on('menu-open-settings', callback)
|
||||
},
|
||||
onShellData: (callback: (event: Electron.IpcRendererEvent, connectionId: string, shellId: string, data: Buffer) => void) => {
|
||||
ipcRenderer.on('ssh:shellData', callback)
|
||||
},
|
||||
onSFTPProgress: (callback: (event: any, sftpId: string, progress: any) => void) => {
|
||||
onSFTPProgress: (callback: (event: Electron.IpcRendererEvent, sftpId: string, progress: { transferred: number; total: number; percentage: number }) => void) => {
|
||||
ipcRenderer.on('sftp:progress', callback)
|
||||
},
|
||||
removeListener: (channel: string, callback: (...args: any[]) => void) => {
|
||||
removeListener: (channel: string, callback: (...args: unknown[]) => void) => {
|
||||
ipcRenderer.removeListener(channel, callback)
|
||||
},
|
||||
removeAllListeners: (channel: string) => {
|
||||
|
||||
+100
-15
@@ -1,15 +1,31 @@
|
||||
import crypto from 'crypto'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm'
|
||||
const KEY_LENGTH = 32
|
||||
const IV_LENGTH = 16
|
||||
const TAG_LENGTH = 16
|
||||
const _TAG_LENGTH = 16
|
||||
const SALT_LENGTH = 32
|
||||
const ITERATIONS = 100000
|
||||
// OWASP 2023 建议至少 600,000 次(针对 SHA256)
|
||||
const ITERATIONS = 600000
|
||||
const CRYPTO_VERSION = 'v1'
|
||||
|
||||
// 从密码派生密钥
|
||||
function deriveKey(password: string, salt: Buffer): Buffer {
|
||||
return crypto.pbkdf2Sync(password, salt, ITERATIONS, KEY_LENGTH, 'sha256')
|
||||
// 异步 PBKDF2 避免阻塞事件循环
|
||||
const pbkdf2Async = promisify(crypto.pbkdf2)
|
||||
|
||||
/**
|
||||
* 从密码异步派生密钥
|
||||
* 使用异步版本避免阻塞 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')
|
||||
}
|
||||
|
||||
// 加密数据
|
||||
/**
|
||||
* 加密数据
|
||||
* 格式: version:salt:iv:tag:encrypted (全部 hex)
|
||||
* version 用于未来加密方案升级时的安全迁移
|
||||
*/
|
||||
export function encrypt(plaintext: string, password: string): string {
|
||||
const salt = crypto.randomBytes(SALT_LENGTH)
|
||||
const key = deriveKey(password, salt)
|
||||
const key = deriveKeySync(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()
|
||||
|
||||
// 格式: salt:iv:tag:encrypted (全部 hex)
|
||||
// 立即清零密钥内存
|
||||
key.fill(0)
|
||||
|
||||
// 格式: version:salt:iv:tag:encrypted (全部 hex)
|
||||
return [
|
||||
CRYPTO_VERSION,
|
||||
salt.toString('hex'),
|
||||
iv.toString('hex'),
|
||||
tag.toString('hex'),
|
||||
@@ -36,10 +60,50 @@ export function encrypt(plaintext: string, password: string): string {
|
||||
].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 {
|
||||
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) {
|
||||
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 encrypted = Buffer.from(encryptedHex, 'hex')
|
||||
|
||||
const key = deriveKey(password, salt)
|
||||
const key = deriveKeySync(password, salt)
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
decipher.setAuthTag(tag)
|
||||
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
|
||||
// 清零密钥内存
|
||||
key.fill(0)
|
||||
return decrypted.toString('utf8')
|
||||
} catch (error) {
|
||||
throw new Error('Decryption failed: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
// 简单加密(不带 salt,用于可逆加密)
|
||||
/**
|
||||
* 简单加密(使用主密钥,不经过 PBKDF2)
|
||||
* 用于内部数据加密,密钥长度必须正确
|
||||
*/
|
||||
export function simpleEncrypt(plaintext: string, key: string): string {
|
||||
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 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')
|
||||
}
|
||||
|
||||
// 简单解密
|
||||
/**
|
||||
* 简单解密
|
||||
*/
|
||||
export function simpleDecrypt(ciphertext: string, key: string): string {
|
||||
try {
|
||||
const [ivHex, tagHex, encryptedHex] = ciphertext.split(':')
|
||||
|
||||
if (!ivHex || !tagHex || !encryptedHex) {
|
||||
throw new Error('Invalid ciphertext format')
|
||||
}
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const tag = Buffer.from(tagHex, '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)
|
||||
decipher.setAuthTag(tag)
|
||||
|
||||
@@ -5,13 +5,14 @@ import fs from 'fs'
|
||||
import { simpleEncrypt, simpleDecrypt } from './crypto'
|
||||
import { keyService } from './key-service'
|
||||
import { createLogger } from '../utils/logger'
|
||||
import type { Session, SessionCreateRequest, SessionUpdateRequest } from '../../shared/types/session.types'
|
||||
|
||||
const logger = createLogger('DatabaseService')
|
||||
|
||||
// sql.js statement wrapper to match better-sqlite3 API
|
||||
interface StatementResult {
|
||||
id: number
|
||||
[key: string]: any
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
class PreparedStatement {
|
||||
@@ -23,16 +24,18 @@ class PreparedStatement {
|
||||
this.sql = sql
|
||||
}
|
||||
|
||||
run(...params: any[]): StatementResult {
|
||||
this.db.run(this.sql, params as any)
|
||||
return { id: 0 } as StatementResult
|
||||
run(...params: unknown[]): StatementResult {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.db.run(this.sql, params as any[])
|
||||
return { id: 0 }
|
||||
}
|
||||
|
||||
get(...params: any[]): any {
|
||||
get(...params: unknown[]): unknown {
|
||||
// 使用 exec 模式获取单行
|
||||
const stmt = this.db.prepare(this.sql)
|
||||
stmt.bind(params as any)
|
||||
let result: any = null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
stmt.bind(params as any[])
|
||||
let result: unknown = null
|
||||
if (stmt.step()) {
|
||||
result = stmt.getAsObject()
|
||||
}
|
||||
@@ -40,10 +43,11 @@ class PreparedStatement {
|
||||
return result
|
||||
}
|
||||
|
||||
all(...params: any[]): any[] {
|
||||
all(...params: unknown[]): unknown[] {
|
||||
const stmt = this.db.prepare(this.sql)
|
||||
stmt.bind(params as any)
|
||||
const results: any[] = []
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
stmt.bind(params as any[])
|
||||
const results: unknown[] = []
|
||||
while (stmt.step()) {
|
||||
results.push(stmt.getAsObject())
|
||||
}
|
||||
@@ -200,7 +204,6 @@ export class DatabaseService {
|
||||
}
|
||||
try {
|
||||
const decrypted = simpleDecrypt(value, this.masterKey)
|
||||
logger.info('Decrypted field length:', decrypted?.length || 0)
|
||||
return decrypted
|
||||
} catch (error) {
|
||||
logger.error('Decrypt failed:', error)
|
||||
@@ -209,10 +212,10 @@ export class DatabaseService {
|
||||
}
|
||||
|
||||
// 会话 CRUD 操作
|
||||
createSession(session: any): any {
|
||||
createSession(session: SessionCreateRequest): Session | null {
|
||||
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(`
|
||||
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 encryptedPassword = this.encryptField(session.password)
|
||||
logger.info('Encrypted password:', encryptedPassword ? `len=${encryptedPassword.length}` : 'null')
|
||||
|
||||
stmt.run(
|
||||
id,
|
||||
@@ -241,56 +243,56 @@ export class DatabaseService {
|
||||
return this.getSessionById(id)
|
||||
}
|
||||
|
||||
getAllSessions(): any[] {
|
||||
getAllSessions(): Session[] {
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
const stmt = this.prepare('SELECT * FROM sessions ORDER BY created_at DESC')
|
||||
const sessions = stmt.all()
|
||||
const sessions = stmt.all() as Record<string, unknown>[]
|
||||
|
||||
return sessions.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
port: s.port,
|
||||
username: s.username,
|
||||
authType: s.auth_type,
|
||||
password: this.decryptField(s.password),
|
||||
privateKey: s.private_key_path,
|
||||
passphrase: this.decryptField(s.passphrase_encrypted),
|
||||
group: s.group_name,
|
||||
tags: s.tags ? JSON.parse(s.tags) : [],
|
||||
return sessions.map((s) => ({
|
||||
id: s.id as string,
|
||||
name: s.name as string,
|
||||
host: s.host as string,
|
||||
port: s.port as number,
|
||||
username: s.username as string,
|
||||
authType: s.auth_type as Session['authType'],
|
||||
password: this.decryptField(s.password as string | null) || undefined,
|
||||
privateKey: s.private_key_path as string | undefined,
|
||||
passphrase: this.decryptField(s.passphrase_encrypted as string | null) || undefined,
|
||||
group: s.group_name as string | undefined,
|
||||
tags: s.tags ? JSON.parse(s.tags as string) as string[] : [],
|
||||
}))
|
||||
}
|
||||
|
||||
getSessionById(id: string): any {
|
||||
getSessionById(id: string): Session | null {
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
const stmt = this.prepare('SELECT * FROM sessions WHERE id = ?')
|
||||
const session = stmt.get(id) as any
|
||||
const session = stmt.get(id) as Record<string, unknown> | undefined
|
||||
|
||||
if (session) {
|
||||
return {
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
host: session.host,
|
||||
port: session.port,
|
||||
username: session.username,
|
||||
authType: session.auth_type,
|
||||
password: this.decryptField(session.password),
|
||||
privateKey: session.private_key_path,
|
||||
passphrase: this.decryptField(session.passphrase_encrypted),
|
||||
group: session.group_name,
|
||||
tags: session.tags ? JSON.parse(session.tags) : [],
|
||||
id: session.id as string,
|
||||
name: session.name as string,
|
||||
host: session.host as string,
|
||||
port: session.port as number,
|
||||
username: session.username as string,
|
||||
authType: session.auth_type as Session['authType'],
|
||||
password: this.decryptField(session.password as string | null) || undefined,
|
||||
privateKey: session.private_key_path as string | undefined,
|
||||
passphrase: this.decryptField(session.passphrase_encrypted as string | null) || undefined,
|
||||
group: session.group_name as string | undefined,
|
||||
tags: session.tags ? JSON.parse(session.tags as string) as string[] : [],
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
updateSession(id: string, updates: any): any {
|
||||
updateSession(id: string, updates: SessionUpdateRequest): Session | null {
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
const fields: string[] = []
|
||||
const values: any[] = []
|
||||
const values: unknown[] = []
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
fields.push('name = ?')
|
||||
@@ -320,6 +322,10 @@ export class DatabaseService {
|
||||
fields.push('passphrase_encrypted = ?')
|
||||
values.push(this.encryptField(updates.passphrase))
|
||||
}
|
||||
if (updates.privateKey !== undefined) {
|
||||
fields.push('private_key_path = ?')
|
||||
values.push(updates.privateKey || null)
|
||||
}
|
||||
if (updates.group !== undefined) {
|
||||
fields.push('group_name = ?')
|
||||
values.push(updates.group)
|
||||
@@ -350,15 +356,15 @@ export class DatabaseService {
|
||||
}
|
||||
|
||||
// 设置操作
|
||||
getSetting(key: string): any {
|
||||
getSetting(key: string): unknown {
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
const stmt = this.prepare('SELECT value FROM settings WHERE key = ?')
|
||||
const row: any = stmt.get(key)
|
||||
return row ? JSON.parse(row.value) : null
|
||||
const row = stmt.get(key) as Record<string, unknown> | undefined
|
||||
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')
|
||||
|
||||
const stmt = this.prepare(`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app } from 'electron'
|
||||
import { app, safeStorage } from 'electron'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import crypto from 'crypto'
|
||||
@@ -6,33 +6,81 @@ import { createLogger } from '../utils/logger'
|
||||
|
||||
const logger = createLogger('KeyService')
|
||||
|
||||
const KEY_FILE = 'encryption.key'
|
||||
const KEY_FILE = 'encryption.key.enc'
|
||||
const LEGACY_KEY_FILE = 'encryption.key'
|
||||
|
||||
export class KeyService {
|
||||
private key: string | null = null
|
||||
|
||||
// 获取或创建主密钥
|
||||
/**
|
||||
* 获取或创建主密钥
|
||||
* 使用 Electron safeStorage 加密存储,替代明文存储
|
||||
*/
|
||||
getMasterKey(): string {
|
||||
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)) {
|
||||
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
|
||||
} catch (error) {
|
||||
logger.error('Failed to read master key:', error)
|
||||
logger.error('Failed to decrypt master key, generating new one:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成新密钥
|
||||
// 2. 迁移旧的明文密钥(向后兼容)
|
||||
if (fs.existsSync(legacyKeyPath)) {
|
||||
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
|
||||
} catch (error) {
|
||||
logger.error('Failed to migrate legacy key:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 生成新密钥
|
||||
this.key = crypto.randomBytes(32).toString('hex')
|
||||
fs.writeFileSync(keyPath, this.key, { mode: 0o600 })
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除密钥(登出时)
|
||||
clearKey(): void {
|
||||
this.key = null
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
import { Client, ClientChannel } from 'ssh2'
|
||||
import { SFTPWrapper } from 'ssh2'
|
||||
import { Client, ClientChannel, SFTPWrapper, FileEntryWithStats } from 'ssh2'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { createLogger } from '../utils/logger'
|
||||
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 DEBUG = true
|
||||
function debug(...args: any[]) {
|
||||
if (DEBUG) console.log('[SSHService]', ...args)
|
||||
// 生产环境日志:使用结构化日志,不输出敏感信息
|
||||
const DEBUG = process.env.NODE_ENV === 'development'
|
||||
function debug(...args: unknown[]) {
|
||||
if (DEBUG) logger.debug('[SSHService]', ...args)
|
||||
}
|
||||
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) {
|
||||
console.log(`%c${label}:%c ${value}`, 'background: #2c3e50; color: #fff; padding: 2px 6px; border-radius: 3px;', 'color: #3498db; font-weight: bold;')
|
||||
function logInfo(label: string, value: unknown) {
|
||||
logger.info(`${label}: ${value}`)
|
||||
}
|
||||
function logSuccess(msg: string) {
|
||||
console.log(`%c✓ ${msg}`, 'color: #27ae60; font-weight: bold;')
|
||||
logger.info(`[OK] ${msg}`)
|
||||
}
|
||||
function logError(msg: string) {
|
||||
console.log(`%c✗ ${msg}`, 'color: #e74c3c; font-weight: bold;')
|
||||
logger.error(msg)
|
||||
}
|
||||
function logWarning(msg: string) {
|
||||
console.log(`%c⚠ ${msg}`, 'color: #f39c12; font-weight: bold;')
|
||||
function _logWarning(msg: string) {
|
||||
logger.warn(msg)
|
||||
}
|
||||
|
||||
export interface SSHConfig {
|
||||
@@ -63,11 +65,34 @@ export class SSHService {
|
||||
private shells: Map<string, ShellSession> = 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> {
|
||||
console.log('=== SSHService connect ===')
|
||||
console.log(' host:', config.host)
|
||||
console.log(' port:', config.port || 22)
|
||||
console.log(' username:', config.username)
|
||||
debug('SSHService connect', config.host, config.port || 22, config.username)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = new Client()
|
||||
@@ -105,13 +130,24 @@ export class SSHService {
|
||||
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...')
|
||||
client.connect({
|
||||
host: config.host,
|
||||
port: config.port || 22,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
privateKey: config.privateKey,
|
||||
privateKey: resolvedPrivateKey,
|
||||
passphrase: config.passphrase,
|
||||
agent: config.agent,
|
||||
readyTimeout: config.readyTimeout || 20000,
|
||||
@@ -190,14 +226,10 @@ export class SSHService {
|
||||
onData?: (shellId: string, data: Buffer) => void,
|
||||
preferredShellId?: string
|
||||
): Promise<ShellSession> {
|
||||
console.log('=== SSHService createShell ===')
|
||||
console.log(' connectionId:', connectionId)
|
||||
console.log(' options:', options)
|
||||
console.log(' preferredShellId:', preferredShellId)
|
||||
debug('SSHService createShell', connectionId, options, preferredShellId)
|
||||
|
||||
const connection = this.connections.get(connectionId)
|
||||
console.log(' connection found:', !!connection)
|
||||
console.log(' connection status:', connection?.status)
|
||||
debug('connection found:', !!connection, 'status:', connection?.status)
|
||||
|
||||
if (!connection || connection.status !== 'connected') {
|
||||
throw new Error('Connection not established')
|
||||
@@ -236,19 +268,8 @@ export class SSHService {
|
||||
if (onData) {
|
||||
debug('>>> Setting up channel.on(data) handler')
|
||||
channel.on('data', (data: Buffer) => {
|
||||
// Log first few chunks for debugging
|
||||
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...')
|
||||
debug('SSHService channel.on(data) shellId:', shellId, 'data length:', data.length)
|
||||
onData(shellId, data)
|
||||
debug('>>> onData callback returned')
|
||||
})
|
||||
} else {
|
||||
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')
|
||||
logInfo('sftpId', sftpId)
|
||||
logInfo('path', path)
|
||||
|
||||
@@ -10,17 +10,17 @@ const logger = createLogger('ZmodemService')
|
||||
interface ZmodemSendSession {
|
||||
type: 'send'
|
||||
set_sender(func: (octets: number[]) => void): void
|
||||
send_offer(params: { name: string; size: number; mtime?: number }): Promise<any>
|
||||
on(event: string, handler: (data: any) => void): void
|
||||
send_offer(params: { name: string; size: number; mtime?: number }): Promise<unknown>
|
||||
on(event: string, handler: (data: unknown) => void): void
|
||||
abort(): void
|
||||
close(): Promise<void>
|
||||
close(): Promise<unknown>
|
||||
}
|
||||
|
||||
interface ZmodemReceiveSession {
|
||||
type: 'receive'
|
||||
set_sender(func: (octets: number[]) => void): void
|
||||
start(): Promise<any>
|
||||
on(event: string, handler: (data: any) => void): void
|
||||
start(): Promise<unknown>
|
||||
on(event: string, handler: (data: unknown) => void): void
|
||||
abort(): void
|
||||
}
|
||||
|
||||
@@ -28,6 +28,20 @@ interface ZmodemSentry {
|
||||
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 {
|
||||
percentage: number
|
||||
transferred: number
|
||||
@@ -80,7 +94,7 @@ export class ZmodemService {
|
||||
id: sessionId,
|
||||
shellId,
|
||||
type: 'send',
|
||||
session: null as any,
|
||||
session: null as unknown as ZmodemSendSession,
|
||||
fileName,
|
||||
totalSize,
|
||||
transferred: 0
|
||||
@@ -94,13 +108,12 @@ export class ZmodemService {
|
||||
}
|
||||
|
||||
// Create Sentry to detect ZRINIT from server
|
||||
// @ts-ignore - zmodem-ts types are incomplete
|
||||
const sentry = new Zmodem.Sentry({
|
||||
to_terminal: (octets: number[]) => {
|
||||
// Echo to terminal
|
||||
writeFunc(new Uint8Array(octets))
|
||||
},
|
||||
on_detect: (detection: any) => {
|
||||
on_detect: (detection: ZmodemDetection) => {
|
||||
const detectedSession = detection.get_session()
|
||||
if (detectedSession && detectedSession.type === 'send') {
|
||||
// Server is ready for file transfer (received ZRINIT)
|
||||
@@ -119,9 +132,10 @@ export class ZmodemService {
|
||||
this.sessions.delete(sessionId)
|
||||
})
|
||||
|
||||
sendSession.on('error', (err: Error) => {
|
||||
logger.error('Zmodem send error:', err)
|
||||
onComplete?.(false, err.message)
|
||||
sendSession.on('error', (err: unknown) => {
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
logger.error('Zmodem send error:', error)
|
||||
onComplete?.(false, error.message)
|
||||
})
|
||||
|
||||
// Send the file
|
||||
@@ -181,9 +195,9 @@ export class ZmodemService {
|
||||
// Transfer was skipped or rejected
|
||||
onComplete?.(false, 'Transfer skipped or rejected')
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
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
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
for (const [, session] of this.sessions) {
|
||||
if (session.shellId === shellId && session.sentry) {
|
||||
// Feed to sentry for detection
|
||||
}
|
||||
@@ -222,7 +236,7 @@ export class ZmodemService {
|
||||
const fileName = path.basename(filePath)
|
||||
|
||||
// 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 sessionInfo: ZmodemSessionInfo = {
|
||||
@@ -248,17 +262,18 @@ export class ZmodemService {
|
||||
})
|
||||
|
||||
// Handle file offer
|
||||
session.on('offer', (offer: any) => {
|
||||
const details = offer.getDetails()
|
||||
session.on('offer', (offer: unknown) => {
|
||||
const zmodemOffer = offer as ZmodemOffer
|
||||
const details = zmodemOffer.getDetails()
|
||||
logger.info('Zmodem offer received:', details)
|
||||
|
||||
sessionInfo.fileName = details.name || fileName
|
||||
sessionInfo.totalSize = details.size || 0
|
||||
|
||||
// Accept the offer
|
||||
offer.accept({
|
||||
zmodemOffer.accept({
|
||||
on_input: 'spool_array'
|
||||
}).then((transfer: any) => {
|
||||
}).then((transfer) => {
|
||||
// Handle incoming data
|
||||
transfer.on('input', (data: Uint8Array) => {
|
||||
sessionInfo.transferred = (sessionInfo.transferred || 0) + data.length
|
||||
@@ -266,7 +281,7 @@ export class ZmodemService {
|
||||
// Write to file
|
||||
try {
|
||||
fs.writeFileSync(filePath, data, { flag: 'a' })
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
logger.error('Error writing Zmodem file:', err)
|
||||
}
|
||||
|
||||
@@ -302,9 +317,10 @@ export class ZmodemService {
|
||||
this.sessions.delete(sessionId)
|
||||
})
|
||||
|
||||
session.on('error', (err: Error) => {
|
||||
logger.error('Zmodem receive error:', err)
|
||||
onComplete?.(false, err.message)
|
||||
session.on('error', (err: unknown) => {
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
logger.error('Zmodem receive error:', error)
|
||||
onComplete?.(false, error.message)
|
||||
})
|
||||
|
||||
// Start receiving - this sends ZRINIT to signal we're ready
|
||||
@@ -324,10 +340,10 @@ export class ZmodemService {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (session) {
|
||||
try {
|
||||
const s = session.session as any
|
||||
const s = session.session as { abort?: () => void; close?: () => void }
|
||||
s.abort?.()
|
||||
s.close?.()
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
logger.error('Error canceling Zmodem session:', err)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "../dist-electron",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
+6
-2
@@ -1,11 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" class="dark" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<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>
|
||||
<style>
|
||||
html, body { background-color: #0f172a !important; }
|
||||
</style>
|
||||
<script src="/electron-api-mock.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+1063
-179
File diff suppressed because it is too large.
Load diff
+18
-4
@@ -1,36 +1,50 @@
|
||||
{
|
||||
"name": "opsxshell-gui",
|
||||
"version": "1.0.0",
|
||||
"description": "跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠",
|
||||
"version": "2.0.0",
|
||||
"description": "跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠 · v2.0 安全增强版",
|
||||
"main": "dist-electron/main.js",
|
||||
"author": "OpsXShell Team",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite & sleep 2 && electron dist-electron/main.js --inspect=9222",
|
||||
"build": "tsc && vite build && npm run build:electron",
|
||||
"dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron dist-electron/main.js --inspect=9222\"",
|
||||
"build": "tsc --noEmit && vite build && npm run build:electron",
|
||||
"build:electron": "tsc -p electron/tsconfig.json",
|
||||
"preview": "vite preview",
|
||||
"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:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"clean": "rimraf dist dist-electron release",
|
||||
"prepare": "husky install || true",
|
||||
"dist": "npm run build && electron-builder",
|
||||
"dist:win": "npm run build && electron-builder --win",
|
||||
"dist:mac": "npm run build && electron-builder --mac",
|
||||
"dist:linux": "npm run build && electron-builder --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.4.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"rimraf": "^6.1.3",
|
||||
"socksv5": "^0.0.6",
|
||||
"sql.js": "^1.11.0",
|
||||
"ssh2": "^1.15.0",
|
||||
"uuid": "^9.0.1",
|
||||
"wait-on": "^9.0.5",
|
||||
"zmodem-ts": "^1.0.8",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
export default {
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface SSHExecResponse {
|
||||
}
|
||||
|
||||
export interface SSHCreateShellRequest {
|
||||
connectionId: string
|
||||
connectionId?: string
|
||||
cols?: number
|
||||
rows?: number
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface SSHConnectionConfig {
|
||||
password?: string
|
||||
privateKey?: string
|
||||
passphrase?: string
|
||||
authType?: AuthType
|
||||
}
|
||||
|
||||
// 持久化会话
|
||||
|
||||
+71
-52
@@ -1,12 +1,8 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useEffect, useState, useCallback, lazy, Suspense } from 'react'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import TabBar from './components/TabBar'
|
||||
import TabContent from './components/TabContent'
|
||||
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 { useSessionStore, useCommandPaletteStore, type Session } from './stores'
|
||||
import { createLogger } from './utils/logger'
|
||||
@@ -16,6 +12,12 @@ import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||
import { useIsMobile } from './hooks/useMediaQuery'
|
||||
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')
|
||||
|
||||
function App() {
|
||||
@@ -63,7 +65,7 @@ function App() {
|
||||
}, [])
|
||||
|
||||
// 导出会话
|
||||
const handleExportSessions = async () => {
|
||||
const handleExportSessions = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.electronAPI?.showSaveDialog({
|
||||
title: '导出会话',
|
||||
@@ -91,10 +93,10 @@ function App() {
|
||||
logger.error('Export failed:', error)
|
||||
toast.error('导出会话失败')
|
||||
}
|
||||
}
|
||||
}, [sessions, toast])
|
||||
|
||||
// 导入会话
|
||||
const handleImportSessions = async () => {
|
||||
const handleImportSessions = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.electronAPI?.showOpenDialog({
|
||||
title: '导入会话',
|
||||
@@ -130,7 +132,7 @@ function App() {
|
||||
importSessions = data.sessions
|
||||
} else if (data.hosts && Array.isArray(data.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,
|
||||
host: h.host,
|
||||
port: h.port || 22,
|
||||
@@ -145,7 +147,7 @@ function App() {
|
||||
// 不是 JSON,尝试 SSH Config 格式
|
||||
const lines = fileResult.data.split('\n')
|
||||
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) {
|
||||
const trimmed = line.trim()
|
||||
@@ -185,9 +187,9 @@ function App() {
|
||||
try {
|
||||
await window.electronAPI?.sessionCreate({
|
||||
name: session.name || `${session.username}@${session.host}`,
|
||||
host: session.host,
|
||||
host: session.host!,
|
||||
port: session.port || 22,
|
||||
username: session.username,
|
||||
username: session.username!,
|
||||
authType: session.authType || 'password',
|
||||
password: session.password,
|
||||
privateKey: session.privateKey,
|
||||
@@ -210,7 +212,7 @@ function App() {
|
||||
logger.error('Import failed:', error)
|
||||
toast.error('导入会话失败')
|
||||
}
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for menu events from main process
|
||||
@@ -226,6 +228,8 @@ function App() {
|
||||
window.electronAPI.onMenuImportSessions(handleImportSessions)
|
||||
|
||||
window.electronAPI.onMenuExportSessions(handleExportSessions)
|
||||
|
||||
window.electronAPI.onMenuOpenSettings(handleOpenSettings)
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -234,14 +238,21 @@ function App() {
|
||||
window.electronAPI.removeAllListeners('menu-quick-connect')
|
||||
window.electronAPI.removeAllListeners('menu-import-sessions')
|
||||
window.electronAPI.removeAllListeners('menu-export-sessions')
|
||||
window.electronAPI.removeAllListeners('menu-open-settings')
|
||||
}
|
||||
}
|
||||
}, [sessions])
|
||||
}, [handleExportSessions, handleImportSessions, handleOpenSettings])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-dark-900 text-gray-200">
|
||||
{/* Title bar spacer for macOS */}
|
||||
<div className="h-8 bg-dark-900 drag"></div>
|
||||
<div className="flex flex-col h-screen bg-dark-900 text-gray-200" style={{ backgroundColor: '#0f172a' }}>
|
||||
{/* Title bar spacer for macOS — left area draggable, right area handles double-click */}
|
||||
<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 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
@@ -256,7 +267,7 @@ function App() {
|
||||
{/* Sidebar container with transition */}
|
||||
<div
|
||||
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
|
||||
? `fixed inset-y-0 left-0 z-50 top-8 ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`
|
||||
: sidebarCollapsed ? 'w-0 overflow-hidden' : 'w-64'
|
||||
@@ -282,7 +293,7 @@ function App() {
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="flex items-center">
|
||||
{isMobile && (
|
||||
@@ -319,44 +330,52 @@ function App() {
|
||||
<StatusBar />
|
||||
|
||||
{/* Connection management panel (self-contained via connectionPanelStore) */}
|
||||
<ConnectionPanel />
|
||||
<Suspense fallback={null}>
|
||||
<ConnectionPanel />
|
||||
</Suspense>
|
||||
|
||||
{/* Dialogs */}
|
||||
<SessionDialog
|
||||
isOpen={isSessionDialogOpen}
|
||||
onClose={() => {
|
||||
setIsSessionDialogOpen(false)
|
||||
setEditSession(null)
|
||||
}}
|
||||
editSession={editSession}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<SessionDialog
|
||||
isOpen={isSessionDialogOpen}
|
||||
onClose={() => {
|
||||
setIsSessionDialogOpen(false)
|
||||
setEditSession(null)
|
||||
}}
|
||||
editSession={editSession}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<QuickConnectDialog
|
||||
isOpen={isQuickConnectOpen}
|
||||
onClose={() => setIsQuickConnectOpen(false)}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<QuickConnectDialog
|
||||
isOpen={isQuickConnectOpen}
|
||||
onClose={() => setIsQuickConnectOpen(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
{/* Command Palette — isOpen/close managed via commandPaletteStore */}
|
||||
<CommandPalette
|
||||
onNewSession={() => {
|
||||
closeCommandPalette()
|
||||
setEditSession(null)
|
||||
setIsSessionDialogOpen(true)
|
||||
}}
|
||||
onQuickConnect={() => {
|
||||
closeCommandPalette()
|
||||
setIsQuickConnectOpen(true)
|
||||
}}
|
||||
onOpenSettings={() => {
|
||||
closeCommandPalette()
|
||||
handleOpenSettings()
|
||||
}}
|
||||
onEditSession={(session) => {
|
||||
closeCommandPalette()
|
||||
setEditSession(session)
|
||||
setIsSessionDialogOpen(true)
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<CommandPalette
|
||||
onNewSession={() => {
|
||||
closeCommandPalette()
|
||||
setEditSession(null)
|
||||
setIsSessionDialogOpen(true)
|
||||
}}
|
||||
onQuickConnect={() => {
|
||||
closeCommandPalette()
|
||||
setIsQuickConnectOpen(true)
|
||||
}}
|
||||
onOpenSettings={() => {
|
||||
closeCommandPalette()
|
||||
handleOpenSettings()
|
||||
}}
|
||||
onEditSession={(session) => {
|
||||
closeCommandPalette()
|
||||
setEditSession(session)
|
||||
setIsSessionDialogOpen(true)
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
|
||||
@@ -281,11 +281,11 @@ export default function ConnectionPanel() {
|
||||
{/* Panel */}
|
||||
<div
|
||||
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"
|
||||
>
|
||||
{/* 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">
|
||||
<Wifi className="w-4 h-4 text-accent-blue" />
|
||||
连接管理
|
||||
@@ -300,7 +300,7 @@ export default function ConnectionPanel() {
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
total={stats.total}
|
||||
connected={stats.connected}
|
||||
|
||||
@@ -132,8 +132,8 @@ export default function PortForwardView({ tabId, isActive: _isActive = true }: P
|
||||
} else {
|
||||
setError(result?.error || '启动失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || '启动失败')
|
||||
} catch (err: unknown) {
|
||||
setError((err instanceof Error ? err.message : String(err)) || '启动失败')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -331,7 +331,7 @@ export default function PortForwardView({ tabId, isActive: _isActive = true }: P
|
||||
].map((t) => (
|
||||
<button
|
||||
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 ${
|
||||
newRule.type === t.type
|
||||
? 'bg-accent-blue/20 border-accent-blue text-white'
|
||||
|
||||
@@ -52,8 +52,8 @@ export default function QuickConnectDialog({ isOpen, onClose }: QuickConnectDial
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
} catch (err: any) {
|
||||
setError(err.message || '连接失败')
|
||||
} catch (err: unknown) {
|
||||
setError((err instanceof Error ? err.message : String(err)) || '连接失败')
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 使用子组件拆分后的简化版本
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import {
|
||||
FolderOpen, Upload, Download, Trash2,
|
||||
Edit3, Copy, FileText, ShieldCheck,
|
||||
@@ -28,9 +28,10 @@ import type { FileItem, SortField, SortOrder } from '../../shared/types/sftp.typ
|
||||
interface SFTPViewProps {
|
||||
tabId: string
|
||||
isActive?: boolean
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPViewProps) {
|
||||
export default function SFTPView({ tabId, isActive: _isActive = true, sessionId }: SFTPViewProps) {
|
||||
const {
|
||||
sessions,
|
||||
localPath,
|
||||
@@ -83,6 +84,21 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
||||
const [chmodFile, setChmodFile] = useState<FileItem | null>(null)
|
||||
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
|
||||
const handleSort = useCallback((field: SortField) => {
|
||||
if (sortField === field) {
|
||||
|
||||
@@ -18,10 +18,10 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
username: '',
|
||||
username: 'root',
|
||||
authType: 'password' as AuthType,
|
||||
password: '',
|
||||
privateKey: '',
|
||||
privateKey: '~/.ssh/id_rsa',
|
||||
passphrase: '',
|
||||
group: 'default',
|
||||
tags: '',
|
||||
@@ -49,10 +49,10 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
username: '',
|
||||
username: 'root',
|
||||
authType: 'password',
|
||||
password: '',
|
||||
privateKey: '',
|
||||
privateKey: '~/.ssh/id_rsa',
|
||||
passphrase: '',
|
||||
group: 'default',
|
||||
tags: '',
|
||||
@@ -73,10 +73,10 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
name: formData.name,
|
||||
host: formData.host,
|
||||
port: formData.port,
|
||||
username: formData.username,
|
||||
username: formData.username.trim() || 'root',
|
||||
authType: formData.authType,
|
||||
password: formData.password,
|
||||
privateKey: formData.privateKey,
|
||||
privateKey: formData.authType === 'key' ? (formData.privateKey.trim() || '~/.ssh/id_rsa') : formData.privateKey,
|
||||
passphrase: formData.passphrase,
|
||||
group: formData.group,
|
||||
tags: formData.tags.split(',').map(t => t.trim()).filter(Boolean),
|
||||
|
||||
@@ -148,11 +148,11 @@ export default function SettingsView() {
|
||||
].map((theme) => (
|
||||
<button
|
||||
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 ${
|
||||
settings.theme === theme.value
|
||||
? '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}
|
||||
@@ -165,8 +165,8 @@ export default function SettingsView() {
|
||||
<SettingSectionTitle>语言</SettingSectionTitle>
|
||||
<select
|
||||
value={settings.language}
|
||||
onChange={(e) => setSetting('language', e.target.value as any)}
|
||||
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"
|
||||
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/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en-US">English</option>
|
||||
@@ -187,7 +187,7 @@ export default function SettingsView() {
|
||||
<select
|
||||
value={settings.fontFamily}
|
||||
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>Fira Code</option>
|
||||
@@ -200,7 +200,7 @@ export default function SettingsView() {
|
||||
<select
|
||||
value={settings.fontSize}
|
||||
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) => (
|
||||
<option key={size} value={size}>{size}px</option>
|
||||
@@ -216,11 +216,11 @@ export default function SettingsView() {
|
||||
{Object.entries(themes).map(([key, theme]) => (
|
||||
<button
|
||||
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 ${
|
||||
settings.terminalTheme === key
|
||||
? '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
|
||||
@@ -240,7 +240,7 @@ export default function SettingsView() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<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">
|
||||
<thead>
|
||||
<tr className="bg-dark-800/60 text-gray-400 text-left">
|
||||
@@ -253,7 +253,7 @@ export default function SettingsView() {
|
||||
{SHORTCUTS.map((s, i) => (
|
||||
<tr
|
||||
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">
|
||||
@@ -293,7 +293,7 @@ export default function SettingsView() {
|
||||
type="number"
|
||||
value={settings.defaultPort}
|
||||
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>
|
||||
|
||||
@@ -303,7 +303,7 @@ export default function SettingsView() {
|
||||
type="number"
|
||||
value={settings.timeout}
|
||||
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>
|
||||
|
||||
@@ -325,7 +325,7 @@ export default function SettingsView() {
|
||||
max={50}
|
||||
value={settings.maxConnections || 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>
|
||||
@@ -337,7 +337,7 @@ export default function SettingsView() {
|
||||
max={20}
|
||||
value={settings.maxBackgroundConnections || 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>
|
||||
@@ -352,7 +352,7 @@ export default function SettingsView() {
|
||||
max={120}
|
||||
value={Math.floor((settings.idleTimeoutMs || 5 * 60 * 1000) / 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>
|
||||
@@ -364,7 +364,7 @@ export default function SettingsView() {
|
||||
max={240}
|
||||
value={Math.floor((settings.suspendTimeoutMs || 30 * 60 * 1000) / 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>
|
||||
@@ -442,7 +442,7 @@ export default function SettingsView() {
|
||||
['Tailwind CSS', '3.3+'],
|
||||
['Lucide React', '0.294+'],
|
||||
].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-500 font-mono">{ver}</span>
|
||||
</div>
|
||||
@@ -475,9 +475,9 @@ export default function SettingsView() {
|
||||
return (
|
||||
<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">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500" />
|
||||
<input
|
||||
@@ -485,7 +485,7 @@ export default function SettingsView() {
|
||||
placeholder="搜索设置..."
|
||||
value={searchQuery}
|
||||
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 && (
|
||||
<button
|
||||
@@ -507,16 +507,16 @@ export default function SettingsView() {
|
||||
<button
|
||||
key={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
|
||||
? 'bg-accent-blue/15 text-white'
|
||||
: 'text-gray-400 hover:bg-dark-700/60 hover:text-gray-200'
|
||||
? 'bg-accent-blue text-white shadow-sm'
|
||||
: '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="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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -529,7 +529,7 @@ export default function SettingsView() {
|
||||
{/* ── 右侧内容区 ── */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* 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>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{currentMeta.description}</p>
|
||||
</div>
|
||||
@@ -558,7 +558,7 @@ function SettingCard({
|
||||
className={`rounded-xl p-5 ${
|
||||
highlight
|
||||
? '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}
|
||||
|
||||
+12
-11
@@ -22,7 +22,7 @@ interface 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 { addTab } = useTabStore()
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
||||
@@ -40,7 +40,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
||||
|
||||
// Sync width from settings on load
|
||||
useEffect(() => {
|
||||
if (settings.sidebarWidth && settings.sidebarWidth !== sidebarWidth) {
|
||||
if (settings.sidebarWidth) {
|
||||
setSidebarWidth(settings.sidebarWidth)
|
||||
}
|
||||
}, [settings.sidebarWidth])
|
||||
@@ -144,18 +144,19 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
||||
case 'edit':
|
||||
onEditSession(session)
|
||||
break
|
||||
case 'duplicate':
|
||||
case 'duplicate': {
|
||||
// Duplicate session
|
||||
const { id: _id, ...sessionWithoutId } = session
|
||||
window.electronAPI?.sessionCreate({
|
||||
...session,
|
||||
id: undefined,
|
||||
...sessionWithoutId,
|
||||
name: `${session.name} (副本)`,
|
||||
}).then((result) => {
|
||||
if (result?.success && result.session) {
|
||||
setSessions([...sessions, result.session])
|
||||
addSession(result.session as Session)
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'delete': {
|
||||
const confirmed = await confirm({
|
||||
title: '确认删除',
|
||||
@@ -177,12 +178,12 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
||||
<>
|
||||
<div
|
||||
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 }}
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||||
{/* 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="w-8 h-8 bg-accent-blue rounded-lg flex items-center justify-center">
|
||||
<Server className="w-5 h-5 text-white" />
|
||||
@@ -209,7 +210,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-3 border-b border-dark-700">
|
||||
<div className="p-3 border-b border-dark-700/20">
|
||||
<SearchBox
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
@@ -231,7 +232,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t border-dark-700">
|
||||
<div className="p-3 border-t border-dark-700/20">
|
||||
<button
|
||||
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"
|
||||
|
||||
@@ -163,7 +163,7 @@ export default function StatusBar() {
|
||||
: null
|
||||
|
||||
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 */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import React, { useState, useCallback, useRef } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useConnectionPool } from '../stores/connection'
|
||||
import { TabItem } from './tabbar/TabItem'
|
||||
import { TabContextMenu } from './tabbar/TabContextMenu'
|
||||
|
||||
export default function TabBar() {
|
||||
function TabBar() {
|
||||
const {
|
||||
tabs,
|
||||
activeTabId,
|
||||
@@ -58,7 +58,6 @@ export default function TabBar() {
|
||||
setDragFromIndex(index)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
// 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) {
|
||||
dragNodeRef.current = e.currentTarget
|
||||
}
|
||||
@@ -112,7 +111,7 @@ export default function TabBar() {
|
||||
const currentTab = menuState.tabId ? tabs.find(t => t.id === menuState.tabId) : null
|
||||
|
||||
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 */}
|
||||
{tabs.map((tab, index) => (
|
||||
<TabItem
|
||||
@@ -167,3 +166,5 @@ export default function TabBar() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(TabBar)
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import WelcomeView from './WelcomeView'
|
||||
import TerminalView from './TerminalView'
|
||||
@@ -11,8 +12,8 @@ interface TabContentProps {
|
||||
onQuickConnect?: () => void
|
||||
}
|
||||
|
||||
export default function TabContent({ activeTabId, onNewSession, onQuickConnect }: TabContentProps) {
|
||||
const { tabs } = useTabStore()
|
||||
function TabContent({ activeTabId, onNewSession, onQuickConnect }: TabContentProps) {
|
||||
const tabs = useTabStore((state) => state.tabs)
|
||||
|
||||
return (
|
||||
<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} />
|
||||
break
|
||||
case 'sftp':
|
||||
content = <SFTPView tabId={tab.id} isActive={isActive} />
|
||||
content = <SFTPView tabId={tab.id} isActive={isActive} sessionId={tab.sessionId} />
|
||||
break
|
||||
case 'portforward':
|
||||
content = <PortForwardView tabId={tab.id} isActive={isActive} />
|
||||
@@ -54,3 +55,5 @@ export default function TabContent({ activeTabId, onNewSession, onQuickConnect }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(TabContent)
|
||||
@@ -182,7 +182,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
|
||||
// 只注册一次监听器(防止严格模式下重复注册)
|
||||
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)
|
||||
}
|
||||
window.electronAPI?.onShellData(handler)
|
||||
@@ -192,6 +192,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
return () => {
|
||||
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()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []) // 只在挂载时执行
|
||||
|
||||
// 处理活跃状态变化
|
||||
@@ -234,15 +236,19 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
saveSnapshot()
|
||||
}
|
||||
}
|
||||
}, [isActive, tabId, saveSnapshot, setShowSuspendedOverlay])
|
||||
}, [isActive, tabId, saveSnapshot, setShowSuspendedOverlay, connectionIdRef, hasConnectedRef])
|
||||
|
||||
// 组件卸载时清理监听器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isConnectingRef.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)
|
||||
|
||||
@@ -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 { useTabStore } from '../stores/tabStore'
|
||||
import EmptyState from './common/EmptyState'
|
||||
@@ -24,17 +24,17 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center bg-dark-900 p-8">
|
||||
<div className="text-center mb-12">
|
||||
<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 pt-8">
|
||||
<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" />
|
||||
</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
|
||||
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">
|
||||
<Zap className="w-6 h-6 text-accent-blue" />
|
||||
@@ -47,7 +47,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
|
||||
<button
|
||||
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">
|
||||
<Plus className="w-6 h-6 text-white" />
|
||||
@@ -60,7 +60,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
|
||||
<button
|
||||
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">
|
||||
<FolderSync className="w-6 h-6 text-accent-purple" />
|
||||
@@ -73,7 +73,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
|
||||
<button
|
||||
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">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* Recent connections */}
|
||||
@@ -113,7 +100,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
<button
|
||||
key={session.id}
|
||||
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">
|
||||
<Globe className="w-5 h-5 text-accent-blue" />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { Component, type ReactNode } from 'react'
|
||||
import { createLogger } from '../../utils/logger'
|
||||
import { handleError } from '../../utils/error-handler'
|
||||
import DefaultFallback from './DefaultFallback'
|
||||
|
||||
const logger = createLogger('ErrorBoundary')
|
||||
|
||||
@@ -22,57 +22,6 @@ interface State {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误边界组件
|
||||
*
|
||||
|
||||
@@ -202,7 +202,7 @@ export function SessionTreeSkeleton({
|
||||
{Array.from({ length: groups }).map((_, gIdx) => {
|
||||
const count = sessionsPerGroup[0] + (gIdx % (sessionsPerGroup[1] - sessionsPerGroup[0] + 1))
|
||||
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">
|
||||
<Skeleton width={14} height={14} className="rounded-sm" />
|
||||
|
||||
@@ -67,19 +67,19 @@ function ToastItemView({ toast, onClose }: ToastItemProps) {
|
||||
}, [])
|
||||
|
||||
// 自动关闭
|
||||
const handleClose = useCallback(() => {
|
||||
setVisible(false)
|
||||
// 等待退场动画结束后再移除
|
||||
setTimeout(() => onClose(toast.id), 200)
|
||||
}, [onClose, toast.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (toast.duration <= 0) return
|
||||
const timer = setTimeout(() => {
|
||||
handleClose()
|
||||
}, toast.duration)
|
||||
return () => clearTimeout(timer)
|
||||
}, [toast.duration])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setVisible(false)
|
||||
// 等待退场动画结束后再移除
|
||||
setTimeout(() => onClose(toast.id), 200)
|
||||
}, [onClose, toast.id])
|
||||
}, [toast.duration, handleClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -138,7 +138,7 @@ export default function ChmodDialog({
|
||||
>
|
||||
<div className="space-y-4 min-w-[360px]">
|
||||
{/* 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" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-white font-medium truncate">{fileName}</p>
|
||||
|
||||
@@ -70,7 +70,7 @@ export function ConnectionPanel({
|
||||
// Disconnected State
|
||||
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>
|
||||
<button
|
||||
onClick={onShowSelector}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSe
|
||||
return (
|
||||
<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="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>
|
||||
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded">
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
|
||||
@@ -48,7 +48,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
{items.map((item, index) => (
|
||||
<div key={index}>
|
||||
{item.separator && index > 0 && (
|
||||
<div className="my-1 border-t border-dark-600" />
|
||||
<div className="my-1 border-t border-dark-600/30" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -121,7 +121,7 @@ export function FileListPanel({
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* 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">
|
||||
{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>
|
||||
@@ -151,12 +151,12 @@ export function FileListPanel({
|
||||
</div>
|
||||
|
||||
{/* 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 ? '/' : '/')} />
|
||||
</div>
|
||||
|
||||
{/* 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} />
|
||||
</div>
|
||||
|
||||
@@ -195,7 +195,7 @@ export function FileListPanel({
|
||||
</div>
|
||||
|
||||
{/* 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} 项`}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function FilePropertiesDialog({
|
||||
>
|
||||
<div className="space-y-4 min-w-[320px]">
|
||||
{/* 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">
|
||||
<TypeIcon className="w-8 h-8 text-accent-blue" />
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function FileRow({ file, isSelected, onSelect, onOpen, onContextMenu, onD
|
||||
onContextMenu={onContextMenu}
|
||||
draggable={file.type === 'file'}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function SortHeader({ onSort, sortField, sortOrder }: SortHeaderProps) {
|
||||
]
|
||||
|
||||
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 }) => (
|
||||
<button
|
||||
key={key}
|
||||
|
||||
@@ -16,7 +16,7 @@ interface ToolbarProps {
|
||||
|
||||
export function Toolbar({ isConnected, onUpload, onDownload, onRefresh, onNewFolder }: ToolbarProps) {
|
||||
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">
|
||||
<button
|
||||
onClick={onUpload}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove }: T
|
||||
const completedCount = tasks.filter(t => t.status === 'completed').length
|
||||
|
||||
return (
|
||||
<div className="bg-dark-800 border-t border-dark-700">
|
||||
<div className="bg-dark-800 border-t border-dark-700/30">
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
@@ -48,7 +48,7 @@ export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove }: T
|
||||
|
||||
{/* Transfer List */}
|
||||
{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) => (
|
||||
<TransferItem
|
||||
key={task.id}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function SessionTree({
|
||||
{Object.entries(grouped).map(([group, groupSessions]) => {
|
||||
const isExpanded = expandedGroups.has(group)
|
||||
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 */}
|
||||
<button
|
||||
onClick={() => onToggleGroup(group)}
|
||||
|
||||
@@ -201,13 +201,13 @@ export default function SnippetPanel() {
|
||||
return (
|
||||
<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" />
|
||||
<h2 className="text-sm font-semibold text-gray-200">命令片段</h2>
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="p-3 border-b border-dark-700">
|
||||
<div className="p-3 border-b border-dark-700/30">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
@@ -260,7 +260,7 @@ export default function SnippetPanel() {
|
||||
</div>
|
||||
|
||||
{/* 底部新建按钮 */}
|
||||
<div className="p-3 border-t border-dark-700">
|
||||
<div className="p-3 border-t border-dark-700/30">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
|
||||
@@ -60,11 +60,11 @@ export function TabItem({
|
||||
onDragEnd={onDragEnd}
|
||||
className={`
|
||||
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
|
||||
${isActive
|
||||
? 'bg-dark-800 text-gray-200'
|
||||
: 'bg-dark-900 text-gray-500 hover:bg-dark-800 hover:text-gray-400'
|
||||
? 'bg-dark-900 text-gray-200'
|
||||
: 'bg-dark-800 text-gray-500 hover:bg-dark-700 hover:text-gray-400'
|
||||
}
|
||||
${isDragging ? 'opacity-50' : 'opacity-100'}
|
||||
`}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react'
|
||||
import { Terminal as TerminalIcon } from 'lucide-react'
|
||||
import type { ConnectionState } from '../../stores/connection'
|
||||
|
||||
@@ -10,7 +11,7 @@ export interface TerminalOverlayProps {
|
||||
onResume: () => void
|
||||
}
|
||||
|
||||
export default function TerminalOverlay({
|
||||
function TerminalOverlay({
|
||||
connectionState,
|
||||
statusMessage,
|
||||
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'
|
||||
|
||||
export interface TerminalSearchBarProps {
|
||||
@@ -12,7 +12,7 @@ export interface TerminalSearchBarProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function TerminalSearchBar({
|
||||
function TerminalSearchBar({
|
||||
searchQuery,
|
||||
searchResultCount,
|
||||
searchResultIndex,
|
||||
@@ -72,3 +72,5 @@ export default function TerminalSearchBar({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(TerminalSearchBar)
|
||||
Vendored
+40
-86
@@ -1,93 +1,43 @@
|
||||
// Type declarations for Electron IPC API exposed via preload
|
||||
|
||||
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
|
||||
}
|
||||
// All types imported from shared/types for consistency
|
||||
|
||||
interface ElectronAPI {
|
||||
// SSH
|
||||
sshConnect: (config: SSHConfig) => Promise<IPCResponse>
|
||||
sshDisconnect: (connectionId: string) => Promise<IPCResponse>
|
||||
sshExec: (connectionId: string, command: string) => Promise<IPCResponse>
|
||||
sshCreateShell: (connectionId: string, options?: any, shellId?: string) => Promise<IPCResponse>
|
||||
sshShellWrite: (shellId: string, data: string) => Promise<IPCResponse>
|
||||
sshShellResize: (shellId: string, cols: number, rows: number) => Promise<IPCResponse>
|
||||
sshForwardLocal: (connectionId: string, localPort: number, remoteHost: string, remotePort: number) => Promise<IPCResponse>
|
||||
sshForwardRemote: (connectionId: string, remotePort: number, localHost: string, localPort: number) => Promise<IPCResponse>
|
||||
sshForwardDynamic: (connectionId: string, localPort: number) => Promise<IPCResponse>
|
||||
sshConnect: (config: import('../shared/types/ipc.types').SSHConnectRequest) => Promise<import('../shared/types/ipc.types').SSHConnectResponse>
|
||||
sshDisconnect: (connectionId: string) => Promise<{ success: boolean; error?: string }>
|
||||
sshExec: (connectionId: string, command: string) => Promise<{ success: boolean; result?: string; error?: string }>
|
||||
sshCreateShell: (connectionId: string, options?: import('../shared/types/ipc.types').SSHCreateShellRequest, shellId?: string) => Promise<{ success: boolean; shellId?: string; error?: string }>
|
||||
sshShellWrite: (shellId: string, data: string | Uint8Array) => Promise<{ success: boolean; error?: string }>
|
||||
sshShellResize: (shellId: string, cols: number, rows: number) => Promise<{ success: boolean; error?: string }>
|
||||
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<import('../shared/types/ipc.types').SSHForwardResponse>
|
||||
sshForwardDynamic: (connectionId: string, localPort: number) => Promise<import('../shared/types/ipc.types').SSHForwardResponse>
|
||||
|
||||
// Sessions
|
||||
sessionGetAll: () => Promise<IPCResponse>
|
||||
sessionGetById: (id: string) => Promise<IPCResponse>
|
||||
sessionCreate: (session: Partial<Session>) => Promise<IPCResponse>
|
||||
sessionUpdate: (id: string, updates: Partial<Session>) => Promise<IPCResponse>
|
||||
sessionDelete: (id: string) => Promise<IPCResponse>
|
||||
sessionGetAll: () => Promise<{ success: boolean; sessions?: import('../shared/types/session.types').Session[]; error?: string }>
|
||||
sessionGetById: (id: string) => Promise<{ success: boolean; session?: import('../shared/types/session.types').Session; error?: string }>
|
||||
sessionCreate: (session: Omit<import('../shared/types/session.types').Session, 'id'>) => Promise<import('../shared/types/ipc.types').SessionCreateResponse>
|
||||
sessionUpdate: (id: string, updates: Partial<import('../shared/types/session.types').Session>) => Promise<import('../shared/types/ipc.types').SessionUpdateResponse>
|
||||
sessionDelete: (id: string) => Promise<{ success: boolean; error?: string }>
|
||||
|
||||
// SFTP
|
||||
sftpConnect: (connectionId: string) => Promise<IPCResponse>
|
||||
sftpListDirectory: (sftpId: string, path: string) => Promise<IPCResponse>
|
||||
sftpUpload: (sftpId: string, localPath: string, remotePath: string) => Promise<IPCResponse>
|
||||
sftpDownload: (sftpId: string, remotePath: string, localPath: string) => Promise<IPCResponse>
|
||||
sftpRename: (sftpId: string, oldPath: string, newPath: string) => Promise<IPCResponse>
|
||||
sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => Promise<IPCResponse>
|
||||
sftpMkdir: (sftpId: string, path: string) => Promise<IPCResponse>
|
||||
sftpStat: (sftpId: string, filePath: string) => Promise<IPCResponse & {
|
||||
stats?: {
|
||||
size: number
|
||||
mode: number
|
||||
mtime: number
|
||||
atime: number
|
||||
isDirectory: boolean
|
||||
isFile: boolean
|
||||
isSymbolicLink: boolean
|
||||
}
|
||||
}>
|
||||
sftpChmod: (sftpId: string, filePath: string, mode: number) => Promise<IPCResponse>
|
||||
sftpConnect: (connectionId: string) => Promise<{ success: boolean; sftpId?: string; error?: string }>
|
||||
sftpListDirectory: (sftpId: string, path: string) => Promise<{ success: boolean; files?: unknown[]; error?: string }>
|
||||
sftpUpload: (sftpId: string, localPath: string, remotePath: string) => Promise<{ success: boolean; error?: string }>
|
||||
sftpDownload: (sftpId: string, remotePath: string, localPath: string) => Promise<{ success: boolean; error?: string }>
|
||||
sftpRename: (sftpId: string, oldPath: string, newPath: string) => Promise<{ success: boolean; error?: string }>
|
||||
sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => Promise<{ success: boolean; error?: string }>
|
||||
sftpMkdir: (sftpId: string, path: string) => Promise<{ success: boolean; error?: string }>
|
||||
sftpStat: (sftpId: string, filePath: string) => Promise<{ success: boolean; stats?: unknown; error?: string }>
|
||||
sftpChmod: (sftpId: string, filePath: string, mode: number) => Promise<{ success: boolean; error?: string }>
|
||||
|
||||
// Settings
|
||||
settingsGet: (key: string) => Promise<IPCResponse>
|
||||
settingsSet: (key: string, value: any) => Promise<IPCResponse>
|
||||
settingsGet: (key: string) => Promise<{ success: boolean; value?: unknown; error?: string }>
|
||||
settingsSet: (key: string, value: unknown) => Promise<{ success: boolean; error?: string }>
|
||||
|
||||
// Dialogs
|
||||
showSaveDialog: (options: any) => Promise<{ canceled: boolean; filePath?: string }>
|
||||
showOpenDialog: (options: any) => Promise<{ canceled: boolean; filePaths?: string[] }>
|
||||
showSaveDialog: (options: import('../shared/types/ipc.types').DialogOptions) => Promise<import('../shared/types/ipc.types').DialogResult>
|
||||
showOpenDialog: (options: import('../shared/types/ipc.types').DialogOptions) => Promise<import('../shared/types/ipc.types').DialogResult>
|
||||
|
||||
// File operations
|
||||
readFile: (filePath: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
@@ -96,23 +46,27 @@ interface ElectronAPI {
|
||||
// Local filesystem
|
||||
getHomePath: () => Promise<{ success: boolean; path?: string; error?: string }>
|
||||
getDownloadsPath: () => Promise<string>
|
||||
localReadDir: (dirPath: string) => Promise<IPCResponse>
|
||||
localGetStats: (filePath: string) => Promise<IPCResponse>
|
||||
localDelete: (filePath: string) => Promise<IPCResponse>
|
||||
localRename: (oldPath: string, newPath: string) => Promise<IPCResponse>
|
||||
localMkdir: (dirPath: string) => Promise<IPCResponse>
|
||||
localReadDir: (dirPath: string) => Promise<unknown>
|
||||
localGetStats: (filePath: string) => Promise<unknown>
|
||||
localDelete: (filePath: string) => Promise<unknown>
|
||||
localRename: (oldPath: string, newPath: string) => Promise<unknown>
|
||||
localMkdir: (dirPath: string) => Promise<unknown>
|
||||
|
||||
// App
|
||||
getVersion: () => Promise<string>
|
||||
|
||||
// Window
|
||||
toggleMaximize: () => Promise<void>
|
||||
|
||||
// Events
|
||||
onMenuNewSession: (callback: () => void) => void
|
||||
onMenuQuickConnect: (callback: () => void) => void
|
||||
onMenuImportSessions: (callback: () => void) => void
|
||||
onMenuExportSessions: (callback: () => void) => void
|
||||
onShellData: (callback: (event: any, connectionId: string, shellId: string, data: Buffer) => void) => void
|
||||
onSFTPProgress: (callback: (event: any, sftpId: string, progress: any) => void) => void
|
||||
removeListener: (channel: string, callback: (...args: any[]) => void) => void
|
||||
onMenuOpenSettings: (callback: () => void) => void
|
||||
onShellData: (callback: (event: Electron.IpcRendererEvent, connectionId: string, shellId: string, data: Buffer) => 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
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ export function useKeyboardShortcuts(): void {
|
||||
e.preventDefault()
|
||||
// Electron 渲染进程通过 IPC 请求全屏切换
|
||||
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
|
||||
window.electronAPI?.settingsSet('fullscreen', !isFullscreen)
|
||||
}).catch(() => {
|
||||
|
||||
+56
-39
@@ -4,7 +4,7 @@
|
||||
* 从 SFTPView 提取状态管理和业务逻辑
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useSessionStore } from '../stores'
|
||||
import { useTabStore, type ConnectionInstance } from '../stores/tabStore'
|
||||
import { useConnectionPool, getSessionConfig } from '../stores/connection'
|
||||
@@ -13,6 +13,14 @@ import type { FileItem, TransferTask, SortField, SortOrder } from '../../shared/
|
||||
|
||||
const logger = createLogger('useSFTP')
|
||||
|
||||
// 本地目录条目类型
|
||||
interface LocalDirEntry {
|
||||
name: string
|
||||
isDirectory: boolean
|
||||
size?: number
|
||||
mtime?: number
|
||||
}
|
||||
|
||||
export interface UseSFTPOptions {
|
||||
tabId: string
|
||||
}
|
||||
@@ -132,12 +140,13 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
} else {
|
||||
logger.error('Failed to get home path')
|
||||
}
|
||||
}).catch((err: any) => {
|
||||
logger.error(`getHomePath error: ${err?.message || err}`)
|
||||
}).catch((err: unknown) => {
|
||||
logger.error(`getHomePath error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
} else {
|
||||
logger.warn('electronAPI not available')
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Load local directory
|
||||
@@ -150,11 +159,11 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if (result.success && result.files) {
|
||||
const files: FileItem[] = result.files.map((f: any) => ({
|
||||
const files: FileItem[] = result.files.map((f: LocalDirEntry) => ({
|
||||
name: f.name,
|
||||
path: `${path}/${f.name}`,
|
||||
type: f.isDirectory ? 'directory' : 'file',
|
||||
@@ -167,8 +176,8 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
} else {
|
||||
logger.error(`Failed to load local directory: ${result.error}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error(`loadLocalDirectory error: ${err?.message}`, err)
|
||||
} catch (err: unknown) {
|
||||
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)
|
||||
|
||||
if (result.success && result.files) {
|
||||
const files: FileItem[] = result.files.map((f: any) => ({
|
||||
name: f.filename,
|
||||
path: `${path}/${f.filename}`.replace(/\/+/g, '/'),
|
||||
type: f.longname?.startsWith('d') ? 'directory' : 'file',
|
||||
size: f.attrs?.size || 0,
|
||||
modified: f.attrs?.mtime || Date.now() / 1000,
|
||||
permissions: f.longname?.substring(0, 10) || '-rw-r--r--',
|
||||
}))
|
||||
const files: FileItem[] = result.files.map((f: unknown) => {
|
||||
const entry = f as { filename?: string; longname?: string; attrs?: { size?: number; mtime?: number } }
|
||||
return {
|
||||
name: entry.filename || '',
|
||||
path: `${path}/${entry.filename || ''}`.replace(/\/+/g, '/'),
|
||||
type: entry.longname?.startsWith('d') ? 'directory' : 'file',
|
||||
size: entry.attrs?.size || 0,
|
||||
modified: entry.attrs?.mtime || Date.now() / 1000,
|
||||
permissions: entry.longname?.substring(0, 10) || '-rw-r--r--',
|
||||
}
|
||||
})
|
||||
setRemoteFiles(files)
|
||||
logger.info(`Loaded ${files.length} remote files`)
|
||||
} else {
|
||||
logger.error(`Failed to load remote directory: ${result.error}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error(`loadRemoteDirectory error: ${err?.message}`, err)
|
||||
} catch (err: unknown) {
|
||||
logger.error(`loadRemoteDirectory error: ${err instanceof Error ? err.message : String(err)}`, err)
|
||||
setError('Failed to load remote directory')
|
||||
}
|
||||
}, [sftpId])
|
||||
@@ -373,7 +385,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
setTransferQueue(prev => [...prev, ...newTasks])
|
||||
|
||||
// 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
|
||||
setTransferQueue(prev => {
|
||||
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 {
|
||||
for (const task of newTasks) {
|
||||
@@ -413,10 +425,10 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
))
|
||||
logger.error(`Upload failed: ${task.fileName} - ${result.error}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
logger.error('Upload failed', err)
|
||||
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
|
||||
loadRemoteDirectory(remotePath)
|
||||
} finally {
|
||||
window.electronAPI.removeListener?.('sftp:progress', progressHandler)
|
||||
window.electronAPI.removeListener?.('sftp:progress', progressHandler as (...args: unknown[]) => void)
|
||||
}
|
||||
}, [sftpId, localPath, remotePath, loadRemoteDirectory])
|
||||
|
||||
@@ -448,7 +460,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
setTransferQueue(prev => [...prev, ...newTasks])
|
||||
|
||||
// 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 => {
|
||||
const idx = prev.findIndex(t => t.type === 'download' && t.status === 'transferring')
|
||||
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 {
|
||||
for (const task of newTasks) {
|
||||
@@ -486,10 +498,10 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
))
|
||||
logger.error(`Download failed: ${task.fileName} - ${result.error}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
logger.error('Download failed', err)
|
||||
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
|
||||
loadLocalDirectory(localPath)
|
||||
} finally {
|
||||
window.electronAPI.removeListener?.('sftp:progress', progressHandler)
|
||||
window.electronAPI.removeListener?.('sftp:progress', progressHandler as (...args: unknown[]) => void)
|
||||
}
|
||||
}, [sftpId, remoteFiles, remoteSelected, localPath, loadLocalDirectory])
|
||||
|
||||
@@ -522,8 +534,8 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
logger.error(`Delete failed: ${file.name} - ${result.error}`)
|
||||
allSuccess = false
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error(`Delete error: ${file.name} - ${err?.message}`, err)
|
||||
} catch (err: unknown) {
|
||||
logger.error(`Delete error: ${file.name} - ${err instanceof Error ? err.message : String(err)}`, err)
|
||||
allSuccess = false
|
||||
}
|
||||
}
|
||||
@@ -556,8 +568,8 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
logger.error(`Rename failed: ${result.error}`)
|
||||
return false
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error(`Rename error: ${err?.message}`, err)
|
||||
} catch (err: unknown) {
|
||||
logger.error(`Rename error: ${err instanceof Error ? err.message : String(err)}`, err)
|
||||
return false
|
||||
}
|
||||
}, [sftpId, remotePath, loadRemoteDirectory])
|
||||
@@ -570,15 +582,15 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
}
|
||||
|
||||
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) {
|
||||
return result.stats
|
||||
} else {
|
||||
logger.error(`Stat failed: ${result.error}`)
|
||||
return null
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error(`Stat error: ${err?.message}`, err)
|
||||
} catch (err: unknown) {
|
||||
logger.error(`Stat error: ${err instanceof Error ? err.message : String(err)}`, err)
|
||||
return null
|
||||
}
|
||||
}, [sftpId])
|
||||
@@ -600,18 +612,23 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
logger.error(`Chmod failed: ${result.error}`)
|
||||
return false
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error(`Chmod error: ${err?.message}`, err)
|
||||
} catch (err: unknown) {
|
||||
logger.error(`Chmod error: ${err instanceof Error ? err.message : String(err)}`, err)
|
||||
return false
|
||||
}
|
||||
}, [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(() => {
|
||||
return () => {
|
||||
disconnect()
|
||||
const cid = connectionIdRef.current
|
||||
if (cid && window.electronAPI) {
|
||||
window.electronAPI.sshDisconnect(cid).catch(() => {})
|
||||
}
|
||||
}
|
||||
}, [disconnect])
|
||||
}, [])
|
||||
|
||||
return {
|
||||
sessions,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTabStore, type ConnectionInstance } from '../stores/tabStore'
|
||||
import { getSessionConfig, getConnectionStore, type ConnectionState } from '../stores/connection'
|
||||
import { useTerminalSnapshotStore } from '../stores/terminalSnapshotStore'
|
||||
import { createLogger } from '../utils/logger'
|
||||
import type { SSHConnectionConfig } from '../../shared/types/session.types'
|
||||
|
||||
const logger = createLogger('useTerminalConnection')
|
||||
|
||||
@@ -121,14 +122,8 @@ export function useTerminalConnection({
|
||||
|
||||
try {
|
||||
// 获取配置
|
||||
let config: {
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password?: string
|
||||
privateKey?: string
|
||||
passphrase?: string
|
||||
} | null = null
|
||||
let config: SSHConnectionConfig | null = null
|
||||
let authType: string | undefined
|
||||
|
||||
if (currentTab.connectionConfig) {
|
||||
config = {
|
||||
@@ -139,14 +134,22 @@ export function useTerminalConnection({
|
||||
privateKey: currentTab.connectionConfig.privateKey,
|
||||
passphrase: currentTab.connectionConfig.passphrase,
|
||||
}
|
||||
authType = currentTab.connectionConfig.authType
|
||||
} else if (currentTab.sessionId) {
|
||||
config = await getSessionConfig(currentTab.sessionId)
|
||||
authType = config?.authType
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
throw new Error('无可用连接信息')
|
||||
}
|
||||
|
||||
// 非密钥认证时,不传递 privateKey/passphrase,避免后端误解析
|
||||
if (authType && authType !== 'key') {
|
||||
delete config.privateKey
|
||||
delete config.passphrase
|
||||
}
|
||||
|
||||
// 创建连接
|
||||
const result = await getConnectionStore().createConnection(tabId, config)
|
||||
|
||||
@@ -205,16 +208,17 @@ export function useTerminalConnection({
|
||||
|
||||
xtermRef.current?.focus()
|
||||
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
setConnectionState('error')
|
||||
updateTabStatus(tabId, 'error')
|
||||
setStatusMessage(`错误: ${error.message}`)
|
||||
xtermRef.current?.writeln(`\r\n\x1b[31m错误: ${error.message}\x1b[0m`)
|
||||
setStatusMessage(`错误: ${errorMessage}`)
|
||||
xtermRef.current?.writeln(`\r\n\x1b[31m错误: ${errorMessage}\x1b[0m`)
|
||||
} finally {
|
||||
isConnectingRef.current = false
|
||||
hasConnectedRef.current = true
|
||||
}
|
||||
}, [tabId, updateTabConnectionInstance, updateTabStatus, getTabById, xtermRef])
|
||||
}, [tabId, updateTabConnectionInstance, updateTabStatus, updateTab, getTabById, xtermRef])
|
||||
|
||||
// 恢复挂起的连接
|
||||
const resumeConnection = useCallback(async () => {
|
||||
|
||||
+13
-12
@@ -6,10 +6,9 @@ import { darkColors, lightColors, type AppThemeMode, type ColorMap } from '../th
|
||||
* Resolves the *effective* theme ('dark' | 'light') from the user preference,
|
||||
* including the 'system' → prefers-color-scheme mapping.
|
||||
*/
|
||||
function resolveEffectiveTheme(preference: AppThemeMode): 'dark' | 'light' {
|
||||
if (preference !== 'system') return preference
|
||||
if (typeof window === 'undefined') return 'dark'
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
function resolveEffectiveTheme(_preference: AppThemeMode): 'dark' | 'light' {
|
||||
// 当前版本固定使用 dark 主题,后续版本开放主题切换
|
||||
return 'dark'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,26 +31,28 @@ function applyCSSVars(colors: ColorMap) {
|
||||
*/
|
||||
export function useTheme() {
|
||||
const theme = useSettingsStore((s) => s.settings.theme)
|
||||
const isLoaded = useSettingsStore((s) => s.isLoaded)
|
||||
|
||||
const effective = useMemo(() => resolveEffectiveTheme(theme), [theme])
|
||||
|
||||
// Apply theme to DOM whenever effective theme changes
|
||||
useEffect(() => {
|
||||
if (!isLoaded) return
|
||||
|
||||
const colors = effective === 'dark' ? darkColors : lightColors
|
||||
|
||||
// Set data attribute for potential direct CSS selectors
|
||||
document.documentElement.setAttribute('data-theme', effective)
|
||||
if (effective === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
|
||||
// Apply CSS custom properties
|
||||
applyCSSVars(colors)
|
||||
|
||||
// Update body bg & colour (mirrors index.css body rules but via vars)
|
||||
document.body.style.backgroundColor = 'var(--color-dark-900)'
|
||||
document.body.style.color = 'var(--color-text-primary)'
|
||||
}, [effective, isLoaded])
|
||||
document.body.style.backgroundColor = '#0f172a'
|
||||
document.body.style.color = '#e2e8f0'
|
||||
}, [effective])
|
||||
|
||||
// Listen for system colour-scheme changes when preference is 'system'
|
||||
useEffect(() => {
|
||||
@@ -64,8 +65,8 @@ export function useTheme() {
|
||||
const colors = e.matches ? darkColors : lightColors
|
||||
document.documentElement.setAttribute('data-theme', newEffective)
|
||||
applyCSSVars(colors)
|
||||
document.body.style.backgroundColor = 'var(--color-dark-900)'
|
||||
document.body.style.color = 'var(--color-text-primary)'
|
||||
document.body.style.backgroundColor = '#0f172a'
|
||||
document.body.style.color = '#e2e8f0'
|
||||
}
|
||||
|
||||
mql.addEventListener('change', handler)
|
||||
|
||||
+32
-28
@@ -17,16 +17,36 @@
|
||||
--color-dark-700: #334155;
|
||||
--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-secondary: #cbd5e1;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-text-faint: #64748b;
|
||||
--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-green: #10b981;
|
||||
--color-accent-purple: #8b5cf6;
|
||||
--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-thumb: #475569;
|
||||
@@ -35,45 +55,20 @@
|
||||
--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;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
html, body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--color-dark-900);
|
||||
color: var(--color-text-primary);
|
||||
background-color: #0f172a !important;
|
||||
color: #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -82,6 +77,15 @@ body {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Window chrome utilities */
|
||||
.drag {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
@@ -201,10 +201,10 @@ export const usePoolStore = create<PoolState>((set, get) => ({
|
||||
})
|
||||
|
||||
return { success: true, connectionId }
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Unknown error creating connection',
|
||||
error: error instanceof Error ? error.message : 'Unknown error creating connection',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ export async function getSessionConfig(sessionId: string): Promise<ConnectionCon
|
||||
password: session.password,
|
||||
privateKey: session.privateKey,
|
||||
passphrase: session.passphrase,
|
||||
authType: session.authType,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get session config:', error)
|
||||
|
||||
@@ -34,10 +34,19 @@ export const useSessionStore = create<SessionState>((set) => ({
|
||||
|
||||
setSessions: (sessions) => set({ sessions }),
|
||||
|
||||
addSession: (session) => {
|
||||
set((state) => ({
|
||||
sessions: [...state.sessions, 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) => ({
|
||||
sessions: [...state.sessions, session],
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
removeSession: (id) => {
|
||||
|
||||
+2
-11
@@ -1,22 +1,13 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { createLogger } from '../utils/logger'
|
||||
import type { SSHConnectionConfig } from '../../shared/types/session.types'
|
||||
|
||||
const logger = createLogger('TabStore')
|
||||
|
||||
export type TabType = 'welcome' | 'terminal' | 'sftp' | 'portforward' | 'settings'
|
||||
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 {
|
||||
connectionId: string // 实际的 SSH 连接 ID
|
||||
@@ -341,7 +332,7 @@ export const useTabStore = create<TabState>()(
|
||||
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,
|
||||
isActive: false,
|
||||
connectionStatus: 'disconnected' as ConnectionStatus,
|
||||
|
||||
@@ -30,6 +30,12 @@ export const darkColors: ColorMap = {
|
||||
'dark-700': '#334155',
|
||||
'dark-600': '#475569',
|
||||
|
||||
// Semantic surface tokens
|
||||
'surface-base': '#0f172a',
|
||||
'surface-raised': '#1e293b',
|
||||
'surface-overlay': '#334155',
|
||||
'surface-inset': '#475569',
|
||||
|
||||
// Text
|
||||
'text-primary': '#e2e8f0', // gray-200
|
||||
'text-secondary': '#cbd5e1', // gray-300
|
||||
@@ -37,11 +43,28 @@ export const darkColors: ColorMap = {
|
||||
'text-faint': '#64748b', // gray-500
|
||||
'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-blue': '#3b82f6',
|
||||
'accent-green': '#10b981',
|
||||
'accent-purple': '#8b5cf6',
|
||||
'accent-orange': '#f97316',
|
||||
'accent-red': '#ef4444',
|
||||
|
||||
// Feedback tokens
|
||||
'feedback-success': '#10b981',
|
||||
'feedback-warning': '#f59e0b',
|
||||
'feedback-error': '#ef4444',
|
||||
'feedback-info': '#3b82f6',
|
||||
|
||||
// UI chrome
|
||||
'scrollbar-track': '#1e293b',
|
||||
@@ -60,6 +83,12 @@ export const lightColors: ColorMap = {
|
||||
'dark-700': '#e5e7eb', // gray-200
|
||||
'dark-600': '#d1d5db', // gray-300
|
||||
|
||||
// Semantic surface tokens
|
||||
'surface-base': '#ffffff',
|
||||
'surface-raised': '#f3f4f6',
|
||||
'surface-overlay': '#e5e7eb',
|
||||
'surface-inset': '#d1d5db',
|
||||
|
||||
// Text
|
||||
'text-primary': '#1f2937', // gray-800
|
||||
'text-secondary': '#374151', // gray-700
|
||||
@@ -67,11 +96,28 @@ export const lightColors: ColorMap = {
|
||||
'text-faint': '#9ca3af', // gray-400
|
||||
'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-blue': '#2563eb',
|
||||
'accent-green': '#059669',
|
||||
'accent-purple': '#7c3aed',
|
||||
'accent-orange': '#ea580c',
|
||||
'accent-red': '#dc2626',
|
||||
|
||||
// Feedback tokens
|
||||
'feedback-success': '#059669',
|
||||
'feedback-warning': '#d97706',
|
||||
'feedback-error': '#dc2626',
|
||||
'feedback-info': '#2563eb',
|
||||
|
||||
// UI chrome
|
||||
'scrollbar-track': '#f3f4f6',
|
||||
|
||||
+66
-5
@@ -4,21 +4,82 @@ export default {
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
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: {
|
||||
900: 'var(--color-dark-900)',
|
||||
800: 'var(--color-dark-800)',
|
||||
700: 'var(--color-dark-700)',
|
||||
600: 'var(--color-dark-600)',
|
||||
900: 'var(--color-surface-base)',
|
||||
800: 'var(--color-surface-raised)',
|
||||
700: 'var(--color-surface-overlay)',
|
||||
600: 'var(--color-surface-inset)',
|
||||
},
|
||||
accent: {
|
||||
blue: 'var(--color-accent-blue)',
|
||||
green: 'var(--color-accent-green)',
|
||||
purple: 'var(--color-accent-purple)',
|
||||
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)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
@@ -64,7 +64,7 @@ const mockElectronAPI = {
|
||||
removeListener: vi.fn(),
|
||||
}
|
||||
|
||||
window.electronAPI = mockElectronAPI as any
|
||||
window.electronAPI = mockElectronAPI as unknown as typeof window.electronAPI
|
||||
|
||||
// Mock navigator
|
||||
Object.defineProperty(window, 'navigator', {
|
||||
@@ -98,7 +98,7 @@ Object.defineProperty(window, 'matchMedia', {
|
||||
// Suppress console errors in tests
|
||||
const originalError = console.error
|
||||
beforeAll(() => {
|
||||
console.error = (...args: any[]) => {
|
||||
console.error = (...args: unknown[]) => {
|
||||
if (
|
||||
typeof args[0] === 'string' &&
|
||||
(args[0].includes('Warning:') || args[0].includes('ReactDOM.render'))
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { useConnectionPool, useConnectionStore, getConnectionStore } from '../../src/stores/connectionPool'
|
||||
import { useConnectionPool, useConnectionStore, getConnectionStore } from '../../src/stores/connection'
|
||||
|
||||
describe('ConnectionPool Store', () => {
|
||||
describe('store 初始化', () => {
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('TabStore', () => {
|
||||
})
|
||||
|
||||
it('添加 settings 类型时如果已存在则激活而不是创建新的', () => {
|
||||
const { addTab, tabs } = useTabStore.getState()
|
||||
const { addTab } = useTabStore.getState()
|
||||
addTab({ title: 'Settings', type: 'settings' })
|
||||
addTab({ title: 'Settings 2', type: 'settings' })
|
||||
|
||||
@@ -86,12 +86,12 @@ describe('TabStore', () => {
|
||||
addTab({ title: 'Tab 1', type: 'terminal' })
|
||||
addTab({ title: 'Tab 2', type: 'sftp' })
|
||||
|
||||
const { tabs, activeTabId } = useTabStore.getState()
|
||||
const { tabs } = useTabStore.getState()
|
||||
const tabToClose = tabs.find(t => t.title === 'Tab 1')
|
||||
|
||||
if (tabToClose) {
|
||||
closeTab(tabToClose.id)
|
||||
const { tabs: newTabs, activeTabId: newActiveId } = useTabStore.getState()
|
||||
const { tabs: newTabs } = useTabStore.getState()
|
||||
expect(newTabs.find(t => t.isActive)?.title).toBe('Tab 2')
|
||||
}
|
||||
})
|
||||
@@ -112,7 +112,7 @@ describe('TabStore', () => {
|
||||
|
||||
describe('closeOtherTabs', () => {
|
||||
it('应该关闭除指定标签外的所有标签', () => {
|
||||
const { addTab, closeOtherTabs, tabs } = useTabStore.getState()
|
||||
const { addTab, closeOtherTabs } = useTabStore.getState()
|
||||
addTab({ title: 'Tab 1', type: 'terminal' })
|
||||
addTab({ title: 'Tab 2', type: 'sftp' })
|
||||
|
||||
|
||||
+22
-3
@@ -9,23 +9,42 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
cssCodeSplit: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vendor-core': ['react', 'react-dom', 'react-router-dom'],
|
||||
'vendor-xterm': ['@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-search', '@xterm/addon-webgl'],
|
||||
'vendor-react': ['react', 'react-dom'],
|
||||
'vendor-lucide': ['lucide-react'],
|
||||
'vendor-zustand': ['zustand'],
|
||||
'vendor-utils': ['zustand', 'lucide-react'],
|
||||
},
|
||||
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: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@shared': path.resolve(__dirname, './shared'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-search', '@xterm/addon-webgl', 'react', 'react-dom'],
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user