feat: release OpsXShell 2.1.0 native Tauri edition

This commit is contained in:
Logan.Li committed 2026-07-30 23:32:51 +08:00
1 parent e9a0d21758
commit a3dba0c6ed
311 files changed
+31748 -14897

No files matched your search

+24
View File
@@ -58,6 +58,30 @@ jobs:
name: coverage-report
path: coverage/
tauri-foundation:
name: Tauri Foundation Acceptance
runs-on: macos-14
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: Install dependencies
run: npm ci
- name: Run foundation acceptance
run: npm run acceptance:quick
build:
name: Build
runs-on: ${{ matrix.os }}
+104
View File
@@ -0,0 +1,104 @@
name: Tauri Release Builds
permissions:
contents: read
on:
workflow_dispatch:
push:
tags:
- 'tauri-v*'
jobs:
bundle:
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- name: macos-arm64
os: macos-14
args: --target aarch64-apple-darwin
- name: windows-x64
os: windows-latest
args: ''
env:
REQUIRE_SIGNING: ${{ startsWith(github.ref, 'refs/tags/tauri-v') }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_BASE64 }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.name == 'macos-arm64' && 'aarch64-apple-darwin' || '' }}
- run: npm ci
- name: Validate release signing configuration
shell: bash
run: node scripts/release-preflight.mjs ${{ runner.os == 'macOS' && 'macos' || 'windows' }}
- name: Import Windows signing certificate
if: runner.os == 'Windows' && env.WINDOWS_CERTIFICATE_BASE64 != ''
shell: pwsh
run: |
$pfxPath = Join-Path $env:RUNNER_TEMP 'opsxshell-signing.pfx'
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_BASE64))
$password = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force
$certificate = Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation Cert:\CurrentUser\My -Password $password
$config = @{ bundle = @{ windows = @{
certificateThumbprint = $certificate.Thumbprint
digestAlgorithm = 'sha256'
timestampUrl = 'http://timestamp.digicert.com'
} } } | ConvertTo-Json -Depth 4
Set-Content -Path src-tauri/tauri.signing.conf.json -Value $config
- name: Import Apple Developer ID certificate
if: runner.os == 'macOS' && env.APPLE_CERTIFICATE != ''
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ env.APPLE_CERTIFICATE }}
p12-password: ${{ env.APPLE_CERTIFICATE_PASSWORD }}
- name: Configure Apple Developer ID signing
if: runner.os == 'macOS' && env.APPLE_CERTIFICATE != ''
shell: bash
run: |
node -e "require('fs').writeFileSync('src-tauri/tauri.signing.conf.json', JSON.stringify({ bundle: { macOS: { signingIdentity: process.env.APPLE_SIGNING_IDENTITY } } }))"
- name: Build native package
if: (runner.os != 'Windows' || env.WINDOWS_CERTIFICATE_BASE64 == '') && (runner.os != 'macOS' || env.APPLE_CERTIFICATE == '')
run: npm run build:tauri -- ${{ matrix.args }}
- name: Build signed Windows package
if: runner.os == 'Windows' && env.WINDOWS_CERTIFICATE_BASE64 != ''
run: npm run build:tauri -- --config src-tauri/tauri.signing.conf.json
- name: Build signed macOS package
if: runner.os == 'macOS' && env.APPLE_CERTIFICATE != ''
run: npm run build:tauri -- ${{ matrix.args }} --config src-tauri/tauri.signing.conf.json
- name: Verify native package
shell: bash
run: node scripts/verify-release.mjs ${{ runner.os == 'macOS' && 'macos' || 'windows' }}
- name: Upload package
uses: actions/upload-artifact@v4
with:
name: opsxshell-${{ matrix.name }}
path: |
src-tauri/target/${{ matrix.name == 'macos-arm64' && 'aarch64-apple-darwin/release/bundle/**' || 'release/bundle/**' }}
if-no-files-found: error
+2 -3
View File
@@ -5,7 +5,6 @@ node_modules
# Build outputs
dist
dist-electron
release
*.tsbuildinfo
@@ -41,8 +40,8 @@ lerna-debug.log*
coverage
.nyc_output
# Electron
out/
# Tauri / Rust
src-tauri/target/
# Database
*.db
-2
View File
@@ -1,3 +1 @@
registry=https://registry.npmmirror.com
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
+32 -770
View File
@@ -1,782 +1,44 @@
# AGENTS.md - OpsXShell 项目开发指南
# OpsXShell 开发指南
本文件为 AI 编程助手提供项目开发指导。
OpsXShell 是基于 Tauri 2、Rust、React 和 TypeScript 的轻量 SSH/SFTP 运维终端。产品强调便捷、人性化、操作简单实用,不加入审计、审批或堡垒机功能。
---
## 架构
## 项目概述
- `src/`:React 界面、Zustand 状态和 XTerm.js 终端。
- `src/platform/`:唯一的 Tauri 命令与事件适配入口。
- `src/platform/sftp.ts`:固定 `localPath`/`remotePath` 方向,区分远程文本读写与文件传输。
- `src/utils/terminalPipeline.ts`:终端输入、UTF-8 输出、背压和错误边界。
- `src-tauri/src/commands/`:精简命令边界和统一响应。
- `src-tauri/src/services/`:SSH、SFTP、隧道、PTY、串口、存储和凭据服务。转发 Listener 必须由 `TunnelManager` 统一持有和取消。
- `src-tauri/src/services/ssh/`:认证、known_hosts、代理/跳板、路径和 SFTP,禁止重新堆回 `ssh.rs`。
- `src-tauri/src/services/tunnel/`:运行模型、SOCKS5 协议和 Relay;运行态只能由 `TunnelManager` 持有。
- `shared/types/`:渲染层共享 TypeScript 契约。
- `test/`:Vitest 基础测试与验收。
**OpsXShell** 是跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠 · 基于 Electron + React + TypeScript 构建。
React 不直接访问 SSH、文件系统或加密实现。新增原生能力时先定义 Rust command,再更新 `src/desktop.d.ts`、`src/platform/desktop.ts` 和 `test/setup.ts`。
### 核心功能
## 规则
- 🔐 **SSH 终端** - 支持密码、密钥、SSH Agent 多种认证方式
- 📁 **SFTP 文件管理** - 双栏可视化文件管理,拖拽上传下载
- 🔄 **端口转发** - 本地/远程端口转发支持
- 📑 **多标签页** - 类浏览器标签管理,支持会话恢复
- 🎨 **现代 UI** - 基于 Tailwind CSS 的深色主题界面
- 🔒 **安全可靠** - 敏感数据使用 AES-256-GCM 加密存储
- 🌐 **跨平台** - 支持 Windows、macOS、Linux
- ⚡ **Zmodem 支持** - 集成 rz/sz 文件传输协议
- 未由 Rust 后端实现的能力不能在界面中显示为可用。
- 会话密码、私钥口令和代理密码必须由 Rust 加密后落盘。
- 不保存终端命令历史;批量命令结果仅保留在当前窗口。
- 主机密钥使用 `~/.ssh/known_hosts` 校验。
- 主密钥位于 `~/.opsxshsell/master.key`,Unix 目录权限 `0700`、文件权限 `0600`。
- 保持常用流程简短,高级配置默认收起。
- 端口转发规则可持久化,运行中状态不得持久化;界面的停止操作必须关闭真实原生 Listener。
- 复制转发规则不得复制运行态;运行中的规则修改前必须先停止 Listener。
- SFTP read/write 不得作为 upload/download 的别名,本地路径与远程路径不得使用位置含糊的调用。
- 大模型新配置必须先用临时凭据完成真实测试,测试期间不得写 settings 或 secrets;连接字段变化后保存必须重新锁定。
- AI 远程执行只能复用当前活跃 SSH connection,每次执行前显式确认;不建立独立 podexec/Kubernetes 凭据边界,也不保存命令或工具输出。
- `ssh://` 禁止携带密码或静默直连;冷启动与运行中唤起都必须通过原生有界队列交付给快速连接确认。
### 当前版本状态
- **版本**: v2.0.0
- **状态**: 已发布 (Release) - 安全增强版
- **已完成**: 核心 SSH/SFTP 功能、加密存储、多标签页系统、Zmodem 传输
- **v2.0 优化**: safeStorage 主密钥加密、路径遍历防护、PBKDF2 600K 迭代、类型安全、工程化建设、性能优化
- **技术债务**: 部分组件仍在迁移到新的连接池架构
---
## 技术栈
| 层级 | 技术 | 版本 |
|------|------|------|
| 框架 | Electron | 28+ |
| 前端框架 | React | 18 |
| 语言 | TypeScript | 5 |
| 构建工具 | Vite | 5 |
| 状态管理 | Zustand | 4.4+ |
| 终端仿真 | XTerm.js | 5.5+ |
| SSH/SFTP | ssh2 | 1.15+ |
| Zmodem | zmodem-ts | 1.0.8 |
| 数据库 | better-sqlite3 | 9.4+ |
| 样式 | Tailwind CSS | 3.3+ |
| 图标 | Lucide React | 0.294+ |
| 测试 | Vitest | 4.1+ |
---
## 项目结构
```
opsxshell/
├── electron/ # Electron 主进程
│ ├── main.ts # 主进程入口 - 窗口创建、应用生命周期、菜单
│ ├── preload.ts # 上下文桥接,暴露 API 到渲染进程
│ ├── tsconfig.json # 主进程 TS 配置 (CommonJS)
│ ├── ipc/ # IPC 处理器(按功能模块组织)
│ │ ├── index.ts # IPC 注册入口
│ │ ├── ssh.ipc.ts # SSH 连接、Shell、端口转发
│ │ ├── sftp.ipc.ts # SFTP 文件操作
│ │ ├── session.ipc.ts # 会话 CRUD
│ │ ├── settings.ipc.ts # 应用设置
│ │ ├── dialog.ipc.ts # 文件对话框
│ │ ├── file.ipc.ts # 文件系统操作
│ │ ├── zmodem.ipc.ts # Zmodem 传输
│ │ └── app.ipc.ts # 应用信息
│ └── services/ # 主进程服务
│ ├── ssh-service.ts # SSH 连接、Shell 会话、SFTP 操作
│ ├── database-service.ts # SQLite 持久化存储
│ ├── crypto.ts # AES-256-GCM 加密工具
│ ├── key-service.ts # 主密钥管理
│ └── zmodem-service.ts # Zmodem 文件传输服务
├── src/ # 渲染进程 (React)
│ ├── components/ # UI 组件
│ │ ├── index.ts # 🆕 统一导出
│ │ ├── App.tsx # 根组件
│ │ ├── SessionDialog.tsx # 会话编辑对话框
│ │ ├── QuickConnectDialog.tsx # 快速连接对话框
│ │ ├── WelcomeView.tsx # 欢迎页面
│ │ │
│ │ ├── layout/ # 🆕 布局组件
│ │ │ ├── Sidebar.tsx # 侧边栏
│ │ │ ├── TabBar.tsx # 标签栏
│ │ │ ├── TabContent.tsx # 标签内容
│ │ │ └── StatusBar.tsx # 状态栏
│ │ │
│ │ ├── sftp/ # SFTP 模块
│ │ │ ├── index.ts
│ │ │ ├── SFTPView.tsx # SFTP 主视图
│ │ │ ├── Breadcrumb.tsx
│ │ │ ├── ConnectionSelector.tsx
│ │ │ ├── ContextMenu.tsx
│ │ │ ├── FileRow.tsx
│ │ │ ├── SortHeader.tsx
│ │ │ ├── TransferItem.tsx
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ │
│ │ ├── terminal/ # 终端模块
│ │ │ ├── index.ts
│ │ │ ├── TerminalView.tsx # 终端视图
│ │ │ └── types.ts
│ │ │
│ │ ├── settings/ # 🆕 设置模块
│ │ │ └── SettingsView.tsx # 设置页面
│ │ │
│ │ └── common/ # 🆕 通用组件
│ │ ├── index.ts
│ │ ├── Button.tsx # 按钮
│ │ ├── Modal.tsx # 模态框
│ │ ├── Loading.tsx # 加载状态
│ │ └── ErrorBoundary.tsx # 错误边界
│ ├── stores/ # Zustand 状态管理
│ │ ├── index.ts # 🆕 统一导出
│ │ ├── tabStore.ts # 标签页状态管理
│ │ ├── settingsStore.ts # 应用设置
│ │ ├── terminalSnapshotStore.ts # 终端快照
│ │ └── connection/ # 连接管理架构
│ │ ├── index.ts # 统一导出
│ │ ├── types.ts # 类型定义
│ │ ├── poolStore.ts # 连接池核心
│ │ ├── lifecycleStore.ts # 连接生命周期
│ │ ├── storeHooks.ts # Store Hooks
│ │ └── sessionHelper.ts # 会话配置获取
│ ├── themes/ # 主题配置
│ │ └── terminal.ts # 终端主题
│ │
│ ├── hooks/ # 🆕 自定义 Hooks
│ │ ├── index.ts # 统一导出
│ │ ├── useTerminal.ts # 终端管理
│ │ ├── useSFTP.ts # SFTP 操作
│ │ ├── useConnection.ts # 连接管理
│ │ └── useSettings.ts # 设置操作
│ │
│ ├── constants/ # 🆕 常量管理
│ │ ├── index.ts # 统一导出
│ │ ├── terminal.ts # 终端常量
│ │ ├── sftp.ts # SFTP 常量
│ │ └── keybindings.ts # 快捷键
│ │
│ ├── utils/ # 工具函数
│ │ ├── index.ts # 统一导出
│ │ ├── logger.ts # 统一日志系统
│ │ ├── error-handler.ts # 统一错误处理
│ │ ├── validators.ts # 输入校验
│ │ └── formatters.ts # 格式化工具
│ │
│ ├── electron.d.ts # Electron API 类型声明
│ ├── main.tsx # React 渲染入口
│ └── index.css # 全局样式(Tailwind + 自定义样式)
├── shared/ # 共享类型定义(主进程/渲染进程共用)
│ └── types/
│ ├── session.types.ts # 会话相关类型
│ ├── connection.types.ts # 连接相关类型
│ ├── sftp.types.ts # SFTP 类型
│ ├── ipc.types.ts # IPC 类型
│ └── index.ts # 统一导出
├── test/ # 测试文件
│ ├── setup.ts # 测试环境配置(mock Electron API)
│ ├── stores/ # Store 测试
│ │ ├── tabStore.test.ts
│ │ └── connectionPool.test.ts
│ └── components/sftp/ # 组件测试
│ └── utils.test.ts
├── dist/ # 前端构建输出 (Vite)
├── dist-electron/ # 主进程构建输出
├── package.json # 项目配置 + electron-builder 配置
├── vite.config.ts # Vite 配置
├── vitest.config.ts # Vitest 测试配置
├── tailwind.config.js # Tailwind CSS 配置
├── tsconfig.json # 渲染进程 TS 配置
├── tsconfig.node.json # Node 类型配置
├── index.html # 入口 HTML(含 CSP 配置)
└── .eslintrc.cjs # ESLint 配置
```
---
## 架构详解
### Electron 双进程模型
```
┌─────────────────────────────────────────────────────────────┐
│ 主进程 (Node.js) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ ssh-service │ │ database │ │ key-service │ │
│ │ (ssh2) │ │ (SQLite) │ │ (加密密钥管理) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │zmodem-service│ │ IPC │ │
│ │ (zmodem-ts) │ │ Handlers │ │
│ └─────────────┘ └──────┬──────┘ │
│ │ │
│ ipcMain.handle() / event.sender.send() │
│ │ │
│ ┌──────────────┴──────────────┐ │
│ │ contextBridge │ │
│ │ (electron/preload.ts) │ │
│ └──────────────┬──────────────┘ │
└──────────────────────────┼──────────────────────────────────┘
│ IPC (安全隔离)
┌──────────────────────────┼──────────────────────────────────┐
│ 渲染进程 (Chromium) │
│ ┌──────────────┴──────────────┐ │
│ │ window.electronAPI │ │
│ └──────────────┬──────────────┘ │
│ │ │
│ ┌─────────────┐ ┌──────┴──────┐ ┌─────────────────────┐ │
│ │ React UI │ │ Zustand │ │ XTerm.js 终端 │ │
│ │ 组件 │ │ Stores │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### IPC 通信接口
渲染进程通过 `window.electronAPI` 与主进程通信。完整类型定义在 `src/electron.d.ts`。
#### 核心 IPC 通道
```typescript
// SSH 操作
const { success, connectionId, error } = await window.electronAPI.sshConnect(config)
const { success, shellId } = await window.electronAPI.sshCreateShell(connectionId, { cols, rows })
await window.electronAPI.sshShellWrite(shellId, data)
await window.electronAPI.sshShellResize(shellId, cols, rows)
await window.electronAPI.sshDisconnect(connectionId)
// Shell 数据推送(主进程 → 渲染进程)
window.electronAPI.onShellData((event, connectionId, shellId, data: Buffer) => {
terminal.write(data)
})
// SFTP 操作
const { sftpId } = await window.electronAPI.sftpConnect(connectionId)
const { files } = await window.electronAPI.sftpListDirectory(sftpId, path)
await window.electronAPI.sftpUpload(sftpId, localPath, remotePath)
// 会话管理
const { sessions } = await window.electronAPI.sessionGetAll()
const { session } = await window.electronAPI.sessionCreate(sessionData)
await window.electronAPI.sessionUpdate(id, updates)
await window.electronAPI.sessionDelete(id)
```
### 数据流
1. **SSH 连接流程**:
```
QuickConnectDialog/SessionDialog
↓
window.electronAPI.sshConnect()
↓
ipcMain.handle('ssh:connect')
↓
ssh-service.connect()
↓
ssh2.Client.connect()
```
2. **Shell 数据流**:
```
ssh2 ClientChannel (data事件)
↓
event.sender.send('ssh:shellData')
↓
ipcRenderer.on('ssh:shellData')
↓
window.electronAPI.onShellData()
↓
XTerm.write(data)
```
3. **会话持久化**:
```
connectionStore.addSession()
↓
window.electronAPI.sessionCreate()
↓
database-service.createSession()
↓
SQLite (密码加密存储)
```
---
## 构建和运行命令
### 环境要求
- Node.js 18+
- npm 9+ 或 yarn 1.22+
### 开发命令
## 命令
```bash
# 安装依赖
npm install
# 启动开发服务器(Vite + Electron)
npm run dev
# 说明:会先启动 Vite dev server (端口 5173),然后启动 Electron
# 运行 ESLint 检查
npm run lint
# 运行测试
npm run test # 运行所有测试
npm run test:watch # 监视模式
npm run test:coverage # 生成覆盖率报告
npm run acceptance:quick
npm run acceptance:full
npx tauri build --bundles app
```
### 生产构建
```bash
# 构建生产版本(前端 + Electron 主进程)
npm run build
# 构建步骤分解:
# 1. tsc - 检查渲染进程 TypeScript
# 2. vite build - 构建前端到 dist/
# 3. npm run build:electron - 编译主进程到 dist-electron/
```
### 打包分发
```bash
# 打包当前平台
npm run dist
# 平台特定打包
npm run dist:win # Windows (NSIS)
npm run dist:mac # macOS (DMG, x64 + arm64)
npm run dist:linux # Linux (AppImage)
# 输出目录: release/
```
---
## 代码规范
### 文件组织
- **组件**: `src/components/*.tsx` - PascalCase 命名
- **子组件目录**: `src/components/{feature}/*.tsx` - 功能相关子组件
- **Stores**: `src/stores/*Store.ts` 或 `src/stores/{feature}/store.ts` - camelCase
- **服务**: `electron/services/*-service.ts` - kebab-case
- **IPC 处理器**: `electron/ipc/{feature}.ipc.ts` - kebab-case
- **类型**: 共享类型定义在 `shared/types/*.types.ts`
### 命名约定
```typescript
// 组件: PascalCase
export default function TerminalView() {}
// Hooks: camelCase + use 前缀
const useConnectionStore = create<ConnectionState>(...)
// 接口: PascalCase
interface SSHConfig { ... }
interface TabState { ... }
// 常量: UPPER_SNAKE_CASE (局部) 或 camelCase (模块级)
const ALGORITHM = 'aes-256-gcm'
const keyService = new KeyService()
```
### 类型安全
- 所有 IPC 接口必须在 `src/electron.d.ts` 中声明
- 共享类型定义在 `shared/types/` 目录
- 使用严格的 TypeScript 配置 (`strict: true`)
- Zustand Store 必须定义 State 接口
```typescript
// 示例:完整的类型定义
interface Tab {
id: string
title: string
type: 'welcome' | 'terminal' | 'sftp' | 'settings'
sessionId?: string
connectionConfig?: SSHConnectionConfig
isActive: boolean
}
interface TabState {
tabs: Tab[]
activeTabId: string | null
addTab: (tab: Omit<Tab, 'id' | 'isActive'>) => void
// ...
}
```
### IPC 错误处理
所有 IPC 处理器必须返回统一的响应格式:
```typescript
// 主进程
ipcMain.handle('xxx:action', async (event, params) => {
try {
const result = await doSomething(params)
return { success: true, data: result }
} catch (error: any) {
return { success: false, error: error.message }
}
})
// 渲染进程
const result = await window.electronAPI.xxxAction(params)
if (result.success) {
// 处理成功
} else {
// 处理错误: result.error
}
```
---
## 安全规范
### ✅ 必须遵守
1. **始终启用 contextIsolation**
```typescript
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, // ✅ 必须
nodeIntegration: false, // ✅ 必须
}
```
2. **通过 preload 暴露 API**
```typescript
// preload.ts - 唯一使用 node 能力的地方
contextBridge.exposeInMainWorld('electronAPI', api)
```
3. **敏感数据加密**
- 所有密码使用 `simpleEncrypt/simpleDecrypt` 加密存储
- 主密钥存储在 `app.getPath('userData')/encryption.key`
- 密钥文件权限设置为 `0o600`
4. **资源清理**
```typescript
app.on('before-quit', () => {
sshService.disconnectAll()
dbService?.close()
})
```
5. **CSP 配置**
```html
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';">
```
### ❌ 禁止行为
1. ❌ 在渲染进程直接使用 `require('ssh2')` 或其他 Node 模块
2. ❌ 暴露 `nodeIntegration: true` 或 `contextIsolation: false`
3. ❌ 明文存储密码或密钥
4. ❌ 忽略 SSH 连接关闭(会导致资源泄漏)
5. ❌ 事件监听不清理(组件卸载时调用 `removeAllListeners`)
---
## 日志与错误处理
### 统一日志系统
使用 `src/utils/logger.ts` 替代 `console.log`:
```typescript
import { createLogger } from '../utils/logger'
const logger = createLogger('ModuleName')
// 使用
logger.debug('调试信息', { detail: 'xxx' })
logger.info('操作成功', { id: 'xxx' })
logger.warn('警告信息')
logger.error('操作失败', error)
```
**日志级别**:
- `DEBUG` - 开发调试信息(生产环境不输出)
- `INFO` - 关键操作记录
- `WARN` - 警告信息
- `ERROR` - 错误信息
### 统一错误处理
使用 `src/utils/error-handler.ts`:
```typescript
import { handleError, createError, ErrorCode } from '../utils/error-handler'
// 1. 转换未知错误
try {
await riskyOperation()
} catch (error) {
const appError = handleError(error, 'context')
// appError.message - 用户友好消息
// appError.recoverable - 是否可恢复
// appError.action - 建议操作
}
// 2. 创建特定错误
const error = createError(ErrorCode.CONNECTION_TIMEOUT, 'detail')
```
**错误码分类**:
- `1xxx` - 连接错误
- `11xx` - SSH 错误
- `12xx` - SFTP 错误
- `13xx` - 会话错误
- `14xx` - 验证错误
- `9xxx` - 系统错误
---
## 数据库架构
SQLite 数据库存储在 `app.getPath('userData')/opsxshell.db`:
### 表结构
```sql
-- 会话表
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER DEFAULT 22,
username TEXT NOT NULL,
auth_type TEXT CHECK(auth_type IN ('password', 'key', 'agent')),
password TEXT, -- 加密存储
private_key_path TEXT, -- 私钥路径(不加密)
passphrase_encrypted TEXT, -- 私钥密码(加密)
group_name TEXT DEFAULT 'default',
tags TEXT, -- JSON 数组
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 连接历史表
CREATE TABLE connection_history (
id TEXT PRIMARY KEY,
session_id TEXT REFERENCES sessions(id),
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
disconnected_at DATETIME,
duration INTEGER,
status TEXT
);
-- 设置表
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT, -- JSON 格式
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 密钥表
CREATE TABLE keys (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT CHECK(type IN ('rsa', 'ed25519', 'ecdsa')),
private_key_encrypted TEXT NOT NULL,
public_key TEXT,
passphrase_encrypted TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 端口转发规则表
CREATE TABLE port_forwards (
id TEXT PRIMARY KEY,
session_id TEXT REFERENCES sessions(id),
type TEXT CHECK(type IN ('local', 'remote', 'dynamic')),
local_host TEXT,
local_port INTEGER,
remote_host TEXT,
remote_port INTEGER,
is_active BOOLEAN DEFAULT FALSE
);
```
---
## 测试策略
### 测试框架
- **测试运行器**: Vitest
- **测试环境**: jsdom
- **断言库**: @testing-library/jest-dom
- **React 测试**: @testing-library/react
### 测试文件位置
```
test/
├── setup.ts # 测试环境配置
├── stores/ # Store 测试
│ ├── tabStore.test.ts
│ └── connectionPool.test.ts
└── components/ # 组件测试
└── sftp/
└── utils.test.ts
```
### 编写测试
```typescript
import { describe, it, expect, beforeEach } from 'vitest'
import { useTabStore } from '../../src/stores/tabStore'
describe('TabStore', () => {
beforeEach(() => {
// Reset store to initial state
useTabStore.setState({
tabs: [{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }],
activeTabId: 'welcome',
})
})
it('应该添加新的终端标签', () => {
const { addTab } = useTabStore.getState()
addTab({ title: 'Terminal', type: 'terminal' })
const { tabs } = useTabStore.getState()
expect(tabs).toHaveLength(2)
})
})
```
### Mock Electron API
测试环境中已配置完整的 Electron API mock,详见 `test/setup.ts`。
---
## 开发指南
### 添加新的 IPC 接口
1. **在 `src/electron.d.ts` 添加类型声明**:
```typescript
interface ElectronAPI {
newMethod: (param: string) => Promise<IPCResponse>
}
```
2. **在 `electron/preload.ts` 暴露 API**:
```typescript
const api = {
newMethod: (param: string) => ipcRenderer.invoke('xxx:newMethod', param),
}
```
3. **在 `electron/ipc/` 添加处理器**:
```typescript
// electron/ipc/feature.ipc.ts
export const FEATURE_CHANNELS = {
NEW_METHOD: 'xxx:newMethod',
} as const
export function registerFeatureIPCHandlers(options: Options): void {
ipcMain.handle(FEATURE_CHANNELS.NEW_METHOD, async (event, param) => {
try {
const result = await doSomething(param)
return { success: true, data: result }
} catch (error: any) {
return { success: false, error: error.message }
}
})
}
```
4. **在 `electron/ipc/index.ts` 注册处理器**:
```typescript
import { registerFeatureIPCHandlers } from './feature.ipc'
export function registerAllIPCHandlers(options: IPCOptions): void {
registerFeatureIPCHandlers({ ... })
}
```
### 添加新的 UI 组件
1. 在 `src/components/` 创建组件文件(PascalCase)
2. 如果需要状态,在 `src/stores/` 创建对应的 Store
3. 在 `TabContent.tsx` 添加渲染逻辑(如果是新标签类型)
### 添加终端主题
1. 在 `src/themes/terminal.ts` 添加主题配置:
```typescript
'my-theme': {
name: 'My Theme',
background: '#xxxxxx',
foreground: '#xxxxxx',
// ... 所有必需的颜色
}
```
2. 在 `src/stores/settingsStore.ts` 的 `terminalTheme` 类型中添加新主题
---
## 连接池架构(新)
项目正在迁移到新的连接池架构,位于 `src/stores/connection/`。
### 连接状态
```typescript
type ConnectionState =
| 'disconnected' // 初始/已关闭
| 'connecting' // 正在连接
| 'connected' // 活跃连接
| 'background' // 后台保持
| 'suspended' // 挂起(长时间未操作)
| 'error' // 连接错误
| 'reconnecting' // 正在重连
```
### 使用新的连接管理
```typescript
import { useConnectionStore, getSessionConfig } from '../stores/connection'
// 在组件中使用
const connectionStore = useConnectionStore()
// 创建连接
const result = await connectionStore.createConnection(tabId, config)
// 断开连接
await connectionStore.disconnectConnection(connectionId, 'reason')
```
### 旧版兼容
`src/stores/connectionPool.ts` 提供向后兼容的导出,旧代码仍可继续使用:
```typescript
import { useConnectionPool } from '../stores/connectionPool'
// 实际指向新的实现
```
---
## 常见问题
| 问题 | 解决方案 |
|------|----------|
| 开发时 Electron 无法加载页面 | 确保 Vite 开发服务器在端口 5173 运行 |
| SSH 连接失败 | 检查主机、端口、认证方式配置;查看主进程控制台日志 |
| XTerm.js 显示异常 | 确保容器有明确宽高,调用 `fitAddon.fit()` |
| 数据库初始化失败 | 检查应用数据目录权限 (`app.getPath('userData')`) |
| 加密解密失败 | 检查主密钥文件是否存在且权限正确 |
| 构建后应用白屏 | 检查 `vite.config.ts` 中 `base: './'` 配置 |
| better-sqlite3 构建失败 | 运行 `npm run rebuild` 或 `npx electron-rebuild` |
---
## 参考资源
- [Electron 文档](https://www.electronjs.org/docs)
- [React 文档](https://react.dev)
- [XTerm.js 文档](https://xtermjs.org)
- [ssh2 文档](https://github.com/mscdex/ssh2)
- [Zustand 文档](https://zustand.docs.pmnd.rs)
- [Tailwind CSS 文档](https://tailwindcss.com/docs)
- [better-sqlite3 文档](https://github.com/WiseLibs/better-sqlite3)
- [zmodem-ts 文档](https://github.com/FGasper/zmodem-ts)
平台安装包必须在目标系统实机验收。没有签名凭据时不得宣称完成签名、公证或自动更新发布。
+3 -245
View File
@@ -1,247 +1,5 @@
# CLAUDE.md
# OpsXShell 项目说明
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
请使用中文沟通。项目当前架构为 Tauri 2 + Rust + React + TypeScript,开发规则和命令以 [AGENTS.md](./AGENTS.md) 为准。
## 语言偏好
**请使用中文与用户交互。** 所有交流使用中文,代码注释和文档可使用中文或英文。
---
## 项目概述
**OpsXShell** - 跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠 · 操作便捷人性化 · 基于 Electron + React + TypeScript 构建。支持终端仿真、SFTP、端口转发、会话管理。
### 技术栈
- **UI 框架**: Electron 28 + React 18 + TypeScript 5
- **构建工具**: Vite 5
- **状态管理**: Zustand
- **终端仿真**: XTerm.js + WebGL
- **SSH/SFTP**: ssh2
- **数据库**: better-sqlite3
- **样式**: Tailwind CSS
- **测试**: Vitest
- **支持平台**: macOS, Windows, Linux
---
## 常用命令
### 安装和运行
```bash
npm install # 安装依赖
npm run dev # 启动开发服务器 + Electron
```
### 构建和打包
```bash
npm run build # 构建生产版本(前端 + Electron)
npm run dist # 打包分发(当前平台)
npm run dist:mac # 打包 macOS
npm run dist:win # 打包 Windows
npm run dist:linux # 打包 Linux
```
### 代码检查
```bash
npm run lint # 运行 ESLint
```
### 测试
```bash
npm test # 运行所有测试
npm run test:watch # 监听模式
npm run test:coverage #覆盖率报告
```
---
## 项目架构
### 进程模型
Electron 双进程架构:
- **主进程** (`electron/`): 窗口管理、IPC 处理器、本地操作
- **渲染进程** (`src/`): React UI、状态管理、XTerm.js 终端
### 目录结构
```
electron/
├── main.ts # 主进程入口
├── preload.ts # 上下文桥接 (window.electronAPI)
├── services/
│ ├── ssh-service.ts # SSH/SFTP 操作 (ssh2)
│ └── database-service.ts # SQLite 操作
└── ipc/ # IPC 处理器分层
├── ssh.ipc.ts # SSH 连接/Shell/端口转发
├── sftp.ipc.ts # SFTP 操作/传输
├── session.ipc.ts # 会话 CRUD
├── settings.ipc.ts # 设置读写
├── dialog.ipc.ts # 文件对话框
├── file.ipc.ts # 本地文件操作
├── zmodem.ipc.ts # rz/sz 传输
└── app.ipc.ts # 应用信息
src/
├── components/ # React 组件
│ ├── TerminalView.tsx # 终端仿真 (XTerm.js + WebGL)
│ ├── SFTPView.tsx # SFTP 双面板文件管理
│ ├── sftp/ # SFTP 子组件
│ │ ├── Breadcrumb.tsx
│ │ ├── SortHeader.tsx
│ │ ├── FileRow.tsx
│ │ ├── ContextMenu.tsx
│ │ ├── TransferItem.tsx
│ │ └── ConnectionSelector.tsx
│ └── ...
├── stores/ # Zustand 状态管理
│ ├── tabStore.ts # 标签页管理
│ ├── connectionStore.ts # 连接状态
│ └── connection/ # 连接池分层
│ ├── poolStore.ts # 连接池核心 (创建/获取/释放)
│ ├── lifecycleStore.ts # 状态机/超时
│ └── storeHooks.ts # 组合接口
└── themes/ # 终端主题
shared/
└── types/ # 跨进程共享类型
├── session.types.ts # Session, AuthType
├── connection.types.ts # PooledConnection, ConnectionState
├── sftp.types.ts # FileItem, TransferTask
└── ipc.types.ts # IPC 通道常量
test/ # Vitest 测试
├── setup.ts # 测试环境配置 (Electron API mock)
├── stores/ # Store 单元测试
└── components/ # 组件工具函数测试
```
### IPC 通信
渲染进程通过 `window.electronAPI` 与主进程通信。所有 IPC 处理器已分层到 `electron/ipc/` 目录。
**主要通道**:
- `ssh:connect`, `ssh:disconnect`, `ssh:createShell`, `ssh:shellWrite`, `ssh:shellResize`
- `sftp:connect`, `sftp:listDirectory`, `sftp:upload`, `sftp:download`, `sftp:mkdir`
- `session:create`, `session:update`, `session:delete`
- `local:readDir`, `local:mkdir`, `local:delete`, `local:rename`
事件推送: Shell 输出通过 `ssh:shellData`;SFTP 进度通过 `sftp:progress`。
### 数据库架构
SQLite(`~/.opsxshell/`):
- **sessions**: SSH 会话配置
- **connection_history**: 连接历史
- **settings**: 应用设置
- **keys**: SSH 密钥
- **port_forwards**: 端口转发规则
### 连接池架构
连接池已拆分为两个 Store:
1. **poolStore** - 连接 Map、LRU 淘汰、引用计数、创建/断开
2. **lifecycleStore** - 状态机(disconnected/connecting/connected/background/suspended)、超时管理
---
## 设计模式参考
### IPC 通信
```typescript
// electron/ipc/ssh.ipc.ts
ipcMain.handle('ssh:connect', async (_event, config) => {
try {
const result = await sshService.connect(config)
return { success: true, connectionId: result.id }
} catch (error: any) {
return { success: false, error: error.message }
}
})
```
### 事件推送
```typescript
// 主进程发送
mainWindow?.webContents.send('ssh:shellData', shellId, data)
// 渲染进程监听
window.electronAPI.onShellData((_event, shellId, data) => {
terminal.write(data)
})
```
### Zustand Store
```typescript
// 使用组合模式
export const useConnectionStore = () => {
const pool = usePoolStore()
const lifecycle = useLifecycleStore()
return { ...pool, ...lifecycle }
}
```
---
## 开发注意事项
### ✅ 推荐做法
1. 使用 contextBridge 暴露 API,保持 `contextIsolation: true`
2. IPC 返回 `{ success: false, error: error.message }` 格式
3. 在 `app.on('before-quit')` 清理资源
4. 组件卸载时清理事件监听 (`removeAllListeners`)
5. 添加新 IPC 处理器时:在 `electron/ipc/` 创建/修改对应文件 + 在 `electron/preload.ts` 暴露 + 在 `src/electron.d.ts` 添加类型
### ❌ 避免做法
1. 渲染进程直接使用 `require('ssh2')` - 必须通过 IPC
2. 暴露 `nodeIntegration: true`
3. SSH 连接未关闭导致资源泄漏
4. 硬编码数据库路径
5. 忽略 Shell 窗口大小变化
---
## 添加新功能指南
### 添加 IPC 处理器
1. 在 `electron/ipc/` 对应模块添加 handler
2. 在 `electron/preload.ts` 添加暴露方法
3. 在 `src/electron.d.ts` 添加类型声明
### 添加 UI 组件
1. 在 `src/components/` 创建组件
2. 如需状态管理,在 `src/stores/` 添加/扩展 Store
3. 在 `TabContent.tsx` 添加渲染逻辑
### 添加测试
1. 在 `test/stores/` 或 `test/components/` 创建 `.test.ts` 文件
2. 使用 `test/setup.ts` 中的 mock 配置
3. 运行 `npm test` 验证
---
## 参考资源
- [Electron](https://www.electronjs.org/docs)
- [React](https://react.dev)
- [XTerm.js](https://xtermjs.org)
- [ssh2](https://github.com/mscdex/ssh2)
- [Zustand](https://zustand.docs.pmnd.rs)
- [Vitest](https://vitest.dev)
核心产品范围:SSH、SFTP、HTTP/SOCKS5 代理、端口转发、串口、本地终端、多标签、分屏和批量命令。不要恢复旧桌面构建链、终端命令历史或堡垒机审计能力。
-336
View File
@@ -1,336 +0,0 @@
# PROJECT_EVALUATION.md - 项目评估和质量指标
本文档定义 OpsXShell 的质量标准、评估指标和改进目标。
---
## 版本历史
| 日期 | 版本 | 评分 | 主要变更 |
|------|------|------|----------|
| 2026-04-04 | v0.1.0 | 56/100 | 初始骨架 |
| 2026-04-06 | v0.10.0 | 78/100 | 核心功能实现发布 |
| 2026-04-18 | 代码审计 | **58/100** | 独立代码质量审计 |
| 2026-04-18 | UI/UX审计 | **48/100** | UI 视觉 + 功能交互深度审计 |
> **评分说明**: 代码审计聚焦代码质量/测试/架构/安全;UI/UX 审计聚焦视觉设计、交互体验、功能便捷性。
---
## UI/UX 审计评估 (2026-04-18)
### 整体评分: **48/100**
| 维度 | 分数 | 评级 | 说明 |
|------|------|------|------|
| **视觉设计** | 65/100 | 🟡 中等 | 深色主题配色专业,但设计系统执行不严格 |
| **交互体验** | 40/100 | 🔴 差 | 大量半成品功能,反馈系统缺失 |
| **功能便捷性** | 45/100 | 🔴 差 | 快捷键薄弱,核心操作路径长 |
| **状态处理** | 35/100 | 🔴 差 | 多组件缺空状态/加载/错误三态 |
| **响应式设计** | 10/100 | 🔴 极差 | 完全缺失 |
```
视觉设计 ████████████████░░░░░░░░░ 65% 配色专业但执行不严格
交互体验 ██████████░░░░░░░░░░░░░░░ 40% alert/prompt泛滥,无Toast
功能便捷性 ███████████░░░░░░░░░░░░░░ 45% 快捷键仅4个,无终端搜索
状态处理 ████████░░░░░░░░░░░░░░░░░ 35% 多组件缺三态
响应式设计 ██░░░░░░░░░░░░░░░░░░░░░░░ 10% 完全缺失
```
### 竞品对标
| 维度 | OpsXShell | Termius | Tabby |
|------|-----------|---------|-------|
| 整体配色 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 布局结构 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 图标一致性 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 动画/过渡 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 响应式 | ⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| 状态处理 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 设计系统 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
**OpsXShell 视觉设计达到 Termius 的 70-75% 水平,但交互体验仅 40-50%。**
---
## 视觉设计详细评估
### ✅ 做得好的
| 组件 | 亮点 |
|------|------|
| **WelcomeView** | 全项目最佳 — 2×2快捷操作网格,独立配色,图标容器渐变悬停,`rounded-xl` 统一圆角 |
| **Modal** | `backdrop-blur-sm` 毛玻璃 + `shadow-2xl` 立体感 + ESC关闭 |
| **Button** | 4变体 × 3尺寸 = 12组合,disabled 态完整,loading spinner |
| **TabBar** | 连接状态圆点(黄/绿/红),`min-w/max-w` 宽度限制,悬停显示关闭按钮 |
| **TransferPanel** | 折叠动画 + 进度条 `transition-all duration-300` + 状态标签胶囊 |
| **配色系统** | `dark-900→800→700→600` 四级灰度 + `accent-blue/green/purple` 三色搭配 |
### 🔴 高优先级视觉问题
| # | 文件 | 问题 | 修复方案 |
|---|------|------|----------|
| V-1 | `StatusBar.tsx:33` | `bg-accent-blue` 状态栏过于刺眼 | → `bg-dark-800 border-t border-dark-700` |
| V-2 | `SFTPView.tsx:96-131` | 右键菜单用 emoji `📂📤📥🗑️` | → Lucide 图标组件 |
| V-3 | `FileRow.tsx:27` | 选中色 `cyan-500` 不在设计系统 | → `accent-blue/20` |
| V-4 | `TerminalView.tsx:601` | 恢复按钮 `bg-cyan-600` 不在设计系统 | → `accent-blue` |
| V-5 | `FileRow.tsx:32` | 目录图标 `sky-400` 不在设计系统 | → `accent-blue` |
### 🟡 中优先级视觉问题
| # | 文件 | 问题 |
|---|------|------|
| V-6 | `Breadcrumb.tsx:33` | `text-slate-600` 应统一为 `text-dark-600` |
| V-7 | `TransferItem.tsx:28` | `bg-slate-700` 应统一为 `bg-dark-700` |
| V-8 | `WelcomeView.tsx:26` | `grid-cols-2` 硬编码,应加 `grid-cols-1 sm:grid-cols-2` |
| V-9 | `StatusBar.tsx:36` | `Wifi` 图标语义不当,应用 `CheckCircle` |
| V-10 | `tailwind.config.js` | 缺少 `accent-orange` 定义,`orange-400` 散落 |
---
## 交互体验详细评估
### 🔴 Critical — 严重影响可用性
| # | 文件 | 问题 | 影响 |
|---|------|------|------|
| I-1 | `App.tsx:46,49,128,159,168` | 导入导出全用 `alert()` | 体验极差,与暗色主题不协调 |
| I-2 | `SFTPView.tsx:173` | 新建文件夹用 `prompt()` | 原生弹窗破坏沉浸感 |
| I-3 | `PortForwardView.tsx:150` | 删除用 `confirm()` | 同上 |
| I-4 | `SFTPView.tsx:103` | 本地上传右键菜单 action **空函数** | 功能完全不可用 |
| I-5 | `SFTPView.tsx:129-131` | 远程删除右键菜单 action **空函数** | 功能完全不可用 |
| I-6 | `useSFTP.ts:399` | 上传实现是模拟代码 | SFTP 上传不可用 |
| I-7 | `WelcomeView.tsx:12-16` | 最近连接列表是硬编码假数据 | 显示虚假信息误导用户 |
| I-8 | `SessionDialog.tsx:265-270` | 私钥"浏览"按钮 **无事件** | 点击无反应 |
| I-9 | 全局 | **无 Toast 通知系统** | 用户操作无任何非阻塞反馈 |
| I-10 | 全局 | **无终端搜索** (Ctrl+Shift+F) | 基础功能缺失 |
| I-11 | `tabStore.ts` | 标签页无持久化,重启全部丢失 | 会话恢复名存实亡 |
| I-12 | `StatusBar.tsx:33-56` | 状态栏永远显示"就绪" | 未接入实际连接状态 |
### 🟡 Warning — 显著影响效率
| # | 文件 | 问题 |
|---|------|------|
| I-13 | 快捷键仅 4 个 | 缺 Ctrl+Tab 切标签、Ctrl+1~9、F11 全屏、Ctrl+Shift+F 搜索 |
| I-14 | `WelcomeView.tsx:120` | 提示 Ctrl+T 但 **未注册** |
| I-15 | `TabBar.tsx` | 无标签拖拽排序 |
| I-16 | `QuickConnectDialog.tsx:12-17` | 不支持密钥认证,仅密码 |
| I-17 | `SettingsView.tsx:79` | light 主题选项无效(仅 dark) |
| I-18 | `SettingsView.tsx:96-101` | 语言切换无效(无 i18n) |
| I-19 | `SFTPView.tsx:208` | 左右面板共享 contextMenu 状态可能冲突 |
| I-20 | `Sidebar.tsx` | 会话分组不可折叠/展开 |
---
## 功能便捷性评分
### SSH 连接
| 场景 | 步骤 | 评分 | 竞品对比 |
|------|------|------|----------|
| 已保存会话 | 1步(双击) | 8/10 | ✅ 与 Termius 持平 |
| 快速连接 Ctrl+K | 2-4步 | 7/10 | ⚠️ 不支持 `user@host:port` 单行输入 |
| 密钥认证 | — | 3/10 | ❌ 浏览按钮无效,需手动输入路径 |
### SFTP 文件操作
| 操作 | 步骤 | 评分 | 问题 |
|------|------|------|------|
| 导航目录 | 1步 | 8/10 | ✅ |
| 上传文件 | 3步 | 3/10 | ❌ 不可用(模拟实现) |
| 下载文件 | 2步 | 7/10 | ✅ |
| 新建文件夹 | 2步 | 5/10 | ⚠️ 用 `prompt()` |
| 拖拽上传 | — | 0/10 | ❌ 缺失 |
| 文件预览 | — | 0/10 | ❌ 缺失 |
### 标签页管理
| 操作 | 方式 | 评分 | 竞品差距 |
|------|------|------|----------|
| 创建 | +按钮/Ctrl+N | 8/10 | — |
| 切换 | 点击标签 | 6/10 | ❌ 无 Ctrl+Tab |
| 关闭 | ×/Ctrl+W | 7/10 | — |
| 拖拽排序 | — | 0/10 | ❌ 缺失 |
| 会话恢复 | — | 0/10 | ❌ 重启丢失 |
### 快捷键覆盖
| 类别 | 已有 | 缺失 | 覆盖率 |
|------|------|------|--------|
| 应用级 | 4个 (N/K/Q/R) | F11全屏, Ctrl+Shift+N新标签 | 30% |
| 标签级 | 1个 (Ctrl+W) | Ctrl+Tab, Ctrl+1~9, Ctrl+Shift+T恢复 | 10% |
| 终端级 | 0 | Ctrl+Shift+F搜索, Ctrl+Shift+C/V, Ctrl+/-缩放 | 0% |
| 自定义 | ❌ | 全部 | 0% |
---
## 功能完成度矩阵
|| 功能 | 状态 | 完成度 | 备注 |
|------|------|--------|------|
| SSH 密码连接 | ✅ | 90% | |
| SSH 密钥连接 | ✅ | 80% | |
| SSH Agent 认证 | ✅ | 80% | |
| XTerm.js 终端 | ✅ | 85% | |
| 终端多主题 | ✅ | 90% | |
| IP 高亮 | ✅ | 95% | |
| SFTP 双栏浏览 | ✅ | 80% | |
| SFTP 上传 | ✅ | 75% | |
| SFTP 下载 | ✅ | 75% | |
| SFTP 右键菜单 | 🟡 | 60% | |
| SFTP 拖拽上传 | ✅ | 80% | |
| SFTP 文件预览 | ❌ | 0% | |
| 端口转发 | ✅ | 85% | |
| 标签页管理 | ✅ | 85% | |
| 会话管理 | ✅ | 75% | |
| 会话恢复 | ✅ | 85% | |
| 快捷键系统 | ✅ | 90% | v1.4.0: 自定义快捷键+冲突检测 |
| Toast 通知 | ✅ | 90% | |
| 终端搜索 | ✅ | 85% | |
| 设置系统 | ✅ | 70% | light/i18n 待实现 |
| 状态栏 | ✅ | 80% | |
| 欢迎页 | ✅ | 80% | |
| 命令片段库 | ✅ | 85% | |
| 命令面板 | ✅ | 80% | |
| 连接面板 | ✅ | 80% | |
| 主题系统 | ✅ | 90% | Dark/Light/System |
| 终端分屏 | 🚧 | 0% | v1.4.0 计划 |
| i18n 国际化 | 🚧 | 5% | v1.4.0 计划 |
| 代理设置 | ✅ | 85% | v1.4.0: HTTP/SOCKS5 代理+按会话配置 |
| 多窗口支持 | 🚧 | 0% | v1.4.0 计划 |
---
## SWOT 分析
### Strengths (优势)
- 深色主题四级灰度配色系统专业(基于 Tailwind Slate)
- WelcomeView 快捷操作卡片设计出色
- 基础组件质量高(Button/Modal/Loading/ErrorBoundary)
- 连接池管理 + 终端快照恢复(独有亮点)
- AES-256-GCM 加密实现专业
### Weaknesses (劣势)
- **大量半成品功能**: SFTP 上传/删除/右键菜单为空函数
- **反馈系统缺失**: 无 Toast,大量 alert/prompt/confirm
- **快捷键极弱**: 仅 4 个,无终端搜索、无标签切换
- **调试代码未清理**: `DEBUG=true` + 50+ console.log
- **设计系统执行不严格**: cyan/sky/slate 颜色散落
### Opportunities (机会)
- Toast 通知系统建立后,全局交互体验可跃升
- xterm SearchAddon 可快速集成终端搜索
- 快捷键自定义框架一次建设,长期受益
- 响应式设计补全后可在小屏/平板使用
### Threats (威胁)
- 竞品 Termius/Tabby 功能成熟度远超
- 半成品功能影响用户第一印象
- 无会话恢复 = 重启丢工作,企业用户不可接受
---
## 优化方向与目标
### Phase 1: 基础可用 (v1.1.0) — 2 周
**目标**: 解决所有 Critical 问题,产品具备基础可用性
**评分目标**: UI/UX 48→65
| # | 任务 | 类型 | 预计 | 影响 |
|---|------|------|------|------|
| 1 | **Toast 通知系统** (组件 + 全局 hook) | 新增 | 2天 | 全局交互提升 |
| 2 | **替换所有 alert/prompt/confirm** 为自定义 UI | 修复 | 2天 | 消除原生弹窗 |
| 3 | **SFTP 上传/删除真实实现** | 修复 | 3天 | 核心功能可用 |
| 4 | **终端搜索** (xterm SearchAddon) | 新增 | 1天 | 基础功能补全 |
| 5 | **会话恢复** (标签持久化到 SQLite) | 新增 | 3天 | 重启不丢工作 |
| 6 | **修复私钥浏览按钮** | 修复 | 0.5天 | 密钥认证可用 |
| 7 | **清理生产调试日志** | 修复 | 1天 | 代码质量 |
| 8 | **设计系统统一** (cyan→accent-blue 等) | 修复 | 1天 | 视觉一致性 |
| 9 | **StatusBar 接入实际状态** | 修复 | 0.5天 | 信息准确 |
| 10 | **WelcomeView 真实最近连接** | 修复 | 1天 | 去除假数据 |
### Phase 2: 体验升级 (v1.2.0) — 2 周
**目标**: 快捷键、视觉打磨、交互优化
**评分目标**: UI/UX 65→78
| # | 任务 | 类型 | 预计 |
|---|------|------|------|
| 1 | Ctrl+Tab / Ctrl+1~9 标签切换 | 新增 | 1天 |
| 2 | 标签拖拽排序 | 新增 | 2天 |
| 3 | SFTP 拖拽上传 | 新增 | 2天 |
| 4 | SFTP 右键菜单完善(权限编辑/属性) | 完善 | 1天 |
| 5 | 侧边栏会话分组折叠/展开 | 完善 | 1天 |
| 6 | Sidebar 宽度可拖拽调整 | 新增 | 1天 |
| 7 | 响应式布局基础(窄窗口折叠侧边栏) | 新增 | 2天 |
| 8 | Loading/Skeleton 加载态补全 | 新增 | 1天 |
| 9 | 空状态插画/提示补全 | 新增 | 1天 |
| 10 | 终端组件拆分重构 | 重构 | 3天 |
### Phase 3: 专业功能 (v1.3.0) — 3 周
**目标**: 快捷键自定义、终端分屏、i18n
**评分目标**: UI/UX 78→88
| # | 任务 | 类型 | 预计 |
|---|------|------|------|
| 1 | 快捷键自定义系统 | 新增 | 3天 |
| 2 | 终端分屏(水平/垂直) | 新增 | 5天 |
| 3 | i18n 国际化 | 新增 | 5天 |
| 4 | Light 主题实现 | 新增 | 3天 |
| 5 | Snippets 命令片段库 | 新增 | 3天 |
| 6 | 代理设置 | 新增 | 2天 |
| 7 | 多窗口支持 | 新增 | 5天 |
---
## 里程碑总览
```
v1.1.0 基础可用 (2 周) → UI/UX 48→65 解决所有Critical问题
v1.2.0 体验升级 (2 周) → UI/UX 65→78 快捷键+视觉打磨
v1.3.0 专业功能 (3 周) → UI/UX 78→88 分屏+i18n+自定义快捷键
总计: ~7 周 → UI/UX 综合评分 48→88
```
### 成功指标
| 指标 | v1.0 当前 | v1.3 目标 |
|------|-----------|-----------|
| UI/UX 综合评分 | 48/100 | 88/100 |
| 有效快捷键数 | 4 | 30+ |
| alert/prompt/confirm | 8 处 | 0 处 |
| 半成品功能 | 8 个 | 0 个 |
| Toast 通知覆盖 | 0% | 100% |
| 终端搜索 | ❌ | ✅ |
| 会话恢复 | ❌ | ✅ |
| 响应式 | ❌ | ✅ |
| 快捷键可自定义 | ❌ | ✅ |
---
## 代码质量评估(代码审计结论,保留参考)
### 综合评分: 58/100
| 维度 | 分数 | 说明 |
|------|------|------|
| 功能完成度 | 82/100 | SSH/SFTP/端口转发主体完成 |
| 代码质量 | 45/100 | 50+ console.log, DEBUG=true 残留 |
| 测试覆盖 | 15/100 | 核心模块零测试 |
| 架构设计 | 72/100 | IPC 分层好,Store 有耦合 |
| 安全合规 | 55/100 | 加密优秀,传输链路有风险 |
| 依赖管理 | 50/100 | Electron v28 过旧 |
| 构建配置 | 75/100 | 基本合理 |
### 代码统计
| 目录 | 文件数 | 代码行数 | 占比 |
|------|--------|----------|------|
| `electron/` | 17 | 2,862 | 25.3% |
| `src/` | 55 | 7,480 | 66.0% |
| `shared/` | 5 | 441 | 3.9% |
| `test/` | 4 | 475 | 4.2% |
| **总计** | **81** | **11,258** | **100%** |
+72 -126
View File
@@ -1,155 +1,101 @@
# OpsXShell GUI
# OpsXShell
跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠
轻量原生的跨平台 SSH / SFTP 运维终端。产品目标是让常用远程操作更直接,不做审计平台,也不依赖插件才能完成基础工作。
## 功能特性
## 已支持
- 🔐 **SSH 终端** - 支持密码、密钥、SSH Agent 认证,XTerm.js 高性能终端
- 📁 **SFTP 文件管理** - 双栏可视化文件管理,拖拽上传下载,传输队列
- 🔄 **端口转发** - 本地/远程/动态 SOCKS5 代理
- 📑 **多标签页** - 类浏览器标签管理,连接池复用,会话恢复
- 🎨 **现代 UI** - 多主题系统 (Dark/Light/System),命令面板,命令片段库
- ⌨️ **快捷键自定义** - 可重映射所有快捷键,实时冲突检测
- 🛡️ **代理支持** - HTTP/SOCKS5 代理,按会话独立配置
- 🔒 **安全可靠** - AES-256-GCM 加密存储,PBKDF2 600K 迭代密钥派生
- 🌐 **跨平台** - 支持 Windows、macOS、Linux
- SSH 终端:密码、私钥、SSH Agent 自动尝试、主机指纹校验
- 会话管理:分组、标签、快速连接、多标签、终端分屏
- 批量命令:选中多个会话执行同一命令,结果仅保留在当前窗口
- SFTP:双栏文件管理、断点续传、完整后原子替换、重命名、权限、远程文本编辑
- 端口转发:本地、远程、动态 SOCKS5;规则可编辑和复制,与运行实例分离,支持真实启停和状态统计
- 连接代理:HTTP CONNECT、SOCKS5,可选用户名密码
- OpenSSH 配置:导入 `~/.ssh/config` 常用字段;界面支持单跳选择,Rust 后端支持最多 8 跳
- 系统链接:`ssh://user@host:port` 唤起已有或新启动的 OpsXShell,并进入预填充快速连接确认
- 本地终端与串口:系统 Shell、串口设备与波特率配置
- 终端 AI 助手:OpenAI/Responses/Anthropic 兼容 Provider、new-api 与 Ollama;支持建议、填入及经确认的当前 SSH 会话单次执行
- 本地安全:AES-256-GCM 加密会话密码、密钥口令和模型 API Key,不保存终端命令或 AI 对话历史
## 产品边界
OpsXShell 聚焦 SSH、SFTP、代理、转发、串口和多终端工作区。审计、录像、审批等堡垒机能力不在产品范围内。
当前仍需补齐的专业能力:
- Agent Forwarding、OpenSSH 证书与 FIDO2
- 断网重连、休眠恢复和大量标签切换的持续压测
- SFTP 内容校验和、显式冲突选择和任务恢复
- Windows 实机验收、自动更新、代码签名、macOS 公证和崩溃诊断
## 技术栈
| 层级 | 技术 |
|------|------|
| 框架 | Electron 28+ |
| 前端 | React 18 + TypeScript 5 |
| 构建 | Vite 5 |
| 状态管理 | Zustand 4.4+ |
| 样式 | Tailwind CSS 3.3+ |
| 终端 | XTerm.js 5.5+ |
| SSH/SFTP | ssh2 1.15+ |
| 图标 | Lucide React |
| 数据库 | sql.js (SQLite WASM) 1.11+ |
| 测试 | Vitest 4.1+ |
| 层级 | 技术 |
| ---------- | -------------------------------- |
| 桌面运行时 | Tauri 2 |
| 原生后端 | Rust 2021 |
| SSH / SFTP | `ssh2 0.9` / libssh2 |
| 本地终端 | `portable-pty 0.9` |
| 串口 | `serialport 4.8` |
| 前端 | React 18 + TypeScript 5 + Vite 5 |
| 状态管理 | Zustand 4.4+ |
| 终端 | XTerm.js 5.5+ |
| 测试 | Vitest + Rust test |
## 快速开始
会话和设置保存在系统应用数据目录的 `opsxshell-data.json`。主密钥固定保存在 `~/.opsxshsell/master.key`,Unix 权限为目录 `0700`、文件 `0600`。
### 环境要求
## 开发
- Node.js 18+
- npm 9+ 或 yarn 1.22+
- Git
### 安装
环境要求:Node.js 18+、npm 9+、Rust 1.77+。
```bash
# 安装依赖
npm install
# 启动开发服务器
npm run dev
# 构建应用
npm run build
# 打包分发
npm run dist
```
### 平台特定打包
常用检查与构建:
```bash
# Windows
npm run dist:win
# macOS
npm run dist:mac
# Linux
npm run dist:linux
npm run acceptance:quick
npm run acceptance:full
npm run build:tauri
```
## 项目结构
平台打包必须在对应系统执行。macOS 在 macOS 构建,Windows x86_64 在 Windows 构建。
## 目录
```text
src/ React 界面与状态管理
src/platform/ Tauri 命令和事件适配层
src/platform/sftp.ts 本地/远程路径强类型传输边界
src/utils/terminalPipeline.ts 终端输入、输出和背压管线
shared/types/ 前后端共享 TypeScript 契约
src-tauri/src/commands/ 精简的 Tauri 命令边界
src-tauri/src/domain/ 原生领域模型
src-tauri/src/services/ SSH、SFTP、隧道、PTY、串口、AI、存储服务
src-tauri/src/services/ssh/ 认证、known_hosts、代理/跳板和 SFTP 子模块
src-tauri/src/services/tunnel/ 转发运行模型、SOCKS5 协议和双向 Relay
test/ 前端测试与基础验收
scripts/ 构建和验收脚本
```
opsxshell-gui/
├── electron/ # Electron 主进程
│ ├── main.ts # 主进程入口
│ ├── preload.ts # 预加载脚本
│ └── tsconfig.json # 主进程 TS 配置
├── src/ # 渲染进程 (React)
│ ├── components/ # 组件
│ │ ├── Sidebar.tsx # 侧边栏
│ │ ├── TabBar.tsx # 标签栏
│ │ ├── TabContent.tsx # 标签内容
│ │ ├── StatusBar.tsx # 状态栏
│ │ ├── WelcomeView.tsx # 欢迎页面
│ │ ├── TerminalView.tsx # 终端视图
│ │ ├── SFTPView.tsx # SFTP 视图
│ │ ├── SettingsView.tsx # 设置视图
│ │ ├── SessionDialog.tsx # 会话对话框
│ │ └── QuickConnectDialog.tsx # 快速连接对话框
│ ├── stores/ # 状态管理
│ │ ├── tabStore.ts # 标签状态
│ │ └── connectionStore.ts # 连接状态
│ ├── App.tsx # 根组件
│ ├── main.tsx # 渲染入口
│ └── index.css # 全局样式
├── package.json # 项目配置
├── vite.config.ts # Vite 配置
├── tailwind.config.js # Tailwind 配置
└── tsconfig.json # TypeScript 配置
```
## 界面预览
📱 **在线预览**: [docs/ui-preview.html](./docs/ui-preview.html)
在浏览器中打开 `docs/ui-preview.html` 文件可查看高清界面预览(支持切换终端/SFTP视图):
### 终端视图
- 🎨 **深色主题** - 基于 Tailwind CSS 的现代化深色界面
- 📑 **标签页管理** - 悬停显示更多操作(`...`)和关闭(`×`)按钮
- 🖥️ **终端区域** - 清晰显示 SSH 连接状态和命令提示符
- 📊 **状态栏** - 实时显示连接状态、延迟和功能入口
### SFTP 双栏视图
- 📂 **双栏文件管理器** - 左侧本地目录,右侧远程服务器目录
- 🧭 **面包屑导航** - 清晰的路径导航和返回/前进按钮
- 📋 **文件列表** - 显示名称、修改时间、大小、类型/权限
- ⚡ **传输队列** - 实时显示上传/下载进度
- 🔍 **Filter/Actions** - 筛选和操作下拉菜单
> 提示:双击 HTML 文件即可在浏览器中查看完整效果,支持终端和 SFTP 视图切换。
## 快捷键
| 快捷键 | 功能 |
|--------|------|
| `Cmd/Ctrl + N` | 新建会话 |
| `Cmd/Ctrl + K` | 快速连接 |
| `Cmd/Ctrl + T` | 新建标签 |
| `Cmd/Ctrl + W` | 关闭标签 |
| `Cmd/Ctrl + Tab` | 切换标签 |
| `F11` | 全屏模式 |
| 快捷键 | 功能 |
| -------------------------- | -------------------- |
| `Cmd/Ctrl + N` | 新建会话 |
| `Cmd/Ctrl + K` | 快速连接 |
| `Cmd/Ctrl + D` | 终端分屏 |
| `Cmd/Ctrl + W` | 关闭标签 |
| `Cmd + Option + I` / `F12` | 开发构建打开调试工具 |
## 开发计划
## 文档
- [x] 基础项目框架
- [x] Electron 主进程
- [x] React 前端界面
- [x] 多标签页系统
- [x] 会话管理
- [x] SSH 连接集成 (node-ssh2)
- [x] 终端模拟器 (XTerm.js)
- [x] SFTP 文件传输
- [x] 端口转发
- [x] 数据持久化 (SQLite)
- [x] 架构模块化重构
- [ ] 智能命令补全
- [ ] 断点续传
- [使用指南](docs/user-guide.md)
- [架构说明](docs/architecture.md)
- [测试与验收](docs/testing-acceptance.md)
- [发布指南](docs/release.md)
## 许可证
MIT License - 开源免费使用
---
Made with ❤️ by OpsXShell Team
MIT
+26 -165
View File
@@ -1,172 +1,33 @@
# ROADMAP.md - OpsXShell 开发路线图
# OpsXShell Roadmap
## 版本规划总览
## 已完成基线
```
v1.1.0 基础可用 ─── 2 周 ──→ UI/UX 48→65, 代码 58→68
v1.2.0 体验升级 ─── 2 周 ──→ UI/UX 65→78, 代码 68→75
v1.3.0 测试覆盖 ─── 2 周 ──→ 测试 15→50%, 代码 75→80
v1.4.0 专业功能 ─── 2 周 ──→ UI/UX 78→88, 功能完成度 90+
v1.5.0 安全升级 ─── 1 周 ──→ 安全 55→85, 代码 80→85
```
- Rust `TunnelManager`:本地、远程和动态 SOCKS5 转发拥有独立 ID、真实启停、连接清理和运行状态
- 转发规则按保存会话持久化,不持久化运行中状态
- SOCKS5 一键启动、代理地址复制、活动连接数和上下行字节统计
- 转发规则的创建、编辑、复制、删除与运行实例完全分离
- `TerminalPipeline` 统一输入错误、UTF-8 分片解码、输出背压和流量统计
- SFTP 本地/远程路径使用不同类型;read/write 与 upload/download 使用不同接口
- `ssh.rs` 已拆出认证、known_hosts、代理/ProxyJump、路径和 SFTP 模块
- `tunnel.rs` 已拆出运行模型、SOCKS5 协议与 Relay 模块
- 注册 `ssh://` 系统链接,支持冷启动队列和运行中单实例唤起
---
## 当前重点
## v1.1.0 基础可用 (2 周) — ✅ 已完成
- SSH/SFTP/PTY/串口稳定性和断网恢复
- 隧道专用 SSH 连接复用、保活、断线重建和可选自动启动
- 继续抽离 SSH connection/shell 生命周期,并为隧道增加专用连接 Multiplexer
- 完成 Windows 安装包的 `ssh://` 注册与运行中单实例实机验收
- 从 URL、IP 和远程路径识别中抽取安全点击策略
- Agent Forwarding、OpenSSH 证书和 FIDO2
- SFTP 内容校验和、显式冲突选择及任务恢复
- Windows x86_64 实机安装与功能验收
> **目标**: 解决所有 Critical UI/UX 问题,建立基础反馈系统,产品具备日常使用价值
## 发布闭环
### ✅ 全部完成 (提交: 53274e2 + d344900)
- macOS Developer ID 签名与公证
- Windows Authenticode 签名
- 带签名清单和回滚策略的自动更新
- 可由用户主动导出的脱敏崩溃诊断包
| # | 任务 | 状态 | 提交 |
|---|------|------|------|
| 1 | Toast 通知系统 | ✅ | d344900 |
| 2 | 替换所有 alert/prompt/confirm | ✅ | d344900 |
| 3 | SFTP 上传/删除真实实现 | ✅ | d344900 |
| 4 | 终端搜索 (SearchAddon) | ✅ | d344900 |
| 5 | 会话恢复 (标签持久化) | ✅ | d344900 |
| 6 | 修复私钥浏览按钮 | ✅ | 53274e2 |
| 7 | 清理生产调试日志 | ✅ | 53274e2 |
| 8 | 设计系统统一颜色 | ✅ | 53274e2 |
| 9 | StatusBar 接入实际状态 | ✅ | 53274e2 |
| 10 | WelcomeView 真实最近连接 | ✅ | 53274e2 |
| 11 | better-sqlite3 → sql.js 迁移 | ✅ | 53274e2 |
### 里程碑
- [x] SFTP 功能可用 (上传+删除+右键菜单)
- [x] Toast 系统上线 (零 alert)
- [x] 终端搜索可用
- [x] 会话恢复可用
---
## v1.2.0 体验升级 (2 周) — ✅ 已完成
> **目标**: 快捷键补全、视觉打磨、交互优化,接近 Termius 体验水平
### ✅ 全部完成 (提交: caba5f4)
### 🟡 P1 — 重要
| # | 任务 | 类型 | 预计 | 验收标准 |
|---|------|------|------|----------|
| 1 | Ctrl+Tab / Ctrl+1~9 标签切换 | 新增 | 1天 | Ctrl+Tab 前后切换,Ctrl+1~9 跳转指定标签 |
| 2 | 标签拖拽排序 | 新增 | 2天 | 拖拽标签头重排序,拖出标签栏可弹出新窗口(可选) |
| 3 | SFTP 拖拽上传 | 新增 | 2天 | 从系统文件管理器拖拽文件到远程面板触发上传 |
| 4 | SFTP 右键菜单完善 | 完善 | 1天 | 新增:权限编辑、属性查看、重命名、复制路径 |
| 5 | 侧边栏会话分组折叠 | 完善 | 1天 | 点击分组头折叠/展开,带动画 |
| 6 | Sidebar 宽度可拖拽调整 | 新增 | 1天 | 拖拽分隔条调整宽度,记忆到 settings |
| 7 | 响应式布局基础 | 新增 | 2天 | 窄窗口(<800px)自动折叠侧边栏,SFTP 垂直堆叠 |
| 8 | Loading/Skeleton 加载态 | 新增 | 1天 | FileListPanel 加载时显示骨架屏,SessionTree 同理 |
| 9 | 空状态设计 | 新增 | 1天 | 每个组件空状态有引导文案+操作按钮 |
| 10 | TerminalView 组件拆分 | 重构 | 3天 | 645行拆为 TerminalCore + TerminalOverlay + TerminalSearch |
### 视觉优化清单
| # | 文件 | 改进 |
|---|------|------|
| V-1 | StatusBar.tsx | `bg-accent-blue` → `bg-dark-800 border-t` |
| V-2 | SFTPView.tsx | emoji 图标 → Lucide 组件 |
| V-3 | TabBar.tsx | 添加活跃标签底部指示线 |
| V-4 | App.tsx | Sidebar 折叠/展开过渡动画 |
| V-5 | WelcomeView.tsx | 响应式 grid-cols-1 sm:grid-cols-2 |
---
## v1.3.0 专业体验 (2 周) — ✅ 已完成
> **目标**: 主题系统、命令面板、Snippets、UI 打磨,达到专业级体验
### ✅ 全部完成 (提交: 1f2de03 + 924a02b)
|| # | 任务 | 状态 | 提交 |
|---|------|------|------|
| 1 | 主题系统 Dark/Light/System | ✅ | 1f2de03 |
| 2 | Vite 代码分割 | ✅ | 1f2de03 |
| 3 | 设置面板视觉重构 | ✅ | 1f2de03 |
| 4 | 命令片段库 Snippets | ✅ | 1f2de03 |
| 5 | 全局命令面板 | ✅ | 1f2de03 |
| 6 | 连接状态面板 | ✅ | 1f2de03 |
| 7 | UI 打磨 — 全局细节优化 | ✅ | 924a02b |
---
## v1.4.0 专业功能 (2 周) — 🚧 进行中
> **目标**: 快捷键自定义、终端分屏、i18n、代理、多窗口,达到专业级水平
|| # | 任务 | 类型 | 预计 | 验收标准 | 状态 |
|---|------|------|------|----------|------|
| 1 | 快捷键自定义系统 | 新增 | 3天 | 设置页快捷键 Tab,可重映射所有快捷键,冲突检测 | ✅ |
| 2 | 终端分屏 | 新增 | 5天 | 水平/垂直分割标签页,可拖拽调整分割比例 | 🚧 |
| 3 | i18n 国际化 | 新增 | 5天 | zh-CN + en-US,所有 UI 文字抽离到 locale 文件 | 🚧 |
|| 4 | 代理设置 | 新增 | 2天 | HTTP/SOCKS5 代理配置,按会话独立设置 | ✅ |
| 5 | 多窗口支持 | 新增 | 5天 | 右键标签"在新窗口打开",窗口位置记忆 | 🚧 |
> **注**: Light 主题、Snippets 命令片段库已在 v1.3.0 提前完成
---
## v1.5.0 安全升级 (1 周)
> **目标**: 安全评分从 55 提升到 85
| # | 任务 | 类型 | 预计 |
|---|------|------|------|
| 1 | IPC 传输加密 (密码不明文) | 修复 | 2天 |
| 2 | Electron 升级 v28→v33+ | 升级 | 2天 |
| 3 | CSP 策略强化 | 新增 | 1天 |
| 4 | 依赖安全审计 + 更新 | 修复 | 1天 |
---
## 长期愿景 (v2.0+)
| 功能 | 描述 |
|------|------|
| 云同步 | 会话/设置跨设备同步 |
| AI 助手 | 终端命令建议、错误诊断 |
| 团队协作 | 共享会话、权限管理 |
| 插件系统 | 第三方扩展支持 |
| 脚本录制 | 操作录制+回放 |
---
## 技术栈
|| 类别 | 技术 | 版本 |
|------|------|------|
| 框架 | Electron | 28+ (升级至 33+) |
| UI | React | 18 |
| 语言 | TypeScript | 5 |
| 构建 | Vite | 5 |
| 状态管理 | Zustand | 4 |
| 终端 | XTerm.js | 5 |
| SSH | ssh2 | 1.15 |
| 样式 | Tailwind CSS | 3 |
| 图标 | Lucide React | — |
| 数据库 | sql.js (SQLite WASM) | 1.11+ |
| 加密 | AES-256-GCM (Node crypto) | — |
## 架构设计
```
┌──────────────────────────────────────────────┐
│ Electron Main │
│ ┌─────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ Window │ │ IPC │ │ Services │ │
│ │ Manager │ │ Handlers │ │ SSH/SFTP/DB │ │
│ └─────────┘ └──────────┘ └───────────────┘ │
├──────────────────────────────────────────────┤
│ React Renderer │
│ ┌─────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ Views │ │ Stores │ │ Hooks │ │
│ │Term/SFTP│ │Zustand x5│ │useTerm/useSFTP│ │
│ └─────────┘ └──────────┘ └───────────────┘ │
│ ┌─────────────────────────────────────────┐ │
│ │ Components (Button/Modal/...) │ │
│ └─────────────────────────────────────────┘ │
├──────────────────────────────────────────────┤
│ Shared (types/constants) │
└──────────────────────────────────────────────┘
```
审计、录像、审批和命令历史不在产品范围内。
-407
View File
@@ -1,407 +0,0 @@
# TDD_PLAN.md - 测试驱动开发计划
本文档规划 OpsXShell 的测试策略,覆盖从单元测试到 E2E 测试的完整体系。
---
## 当前状态
||| 指标 | 当前值 | 目标值 ||
||------|--------|--------|--
|| 测试文件数 | 6 | 15+ ||
|| 测试用例数 | 96 | 150+ ||
|| 估算覆盖率 | ~25% | ≥50% ||
|| 核心模块覆盖 | 60% | ≥80% ||
### 已有测试
||| 测试文件 | 用例数 | 覆盖模块 | 质量 ||
||----------|--------|----------|------|
|| `test/stores/tabStore.test.ts` | 14 | tabStore | ✅ 完整 ||
|| `test/stores/connectionPool.test.ts` | 8 | poolStore | 🟡 仅接口验证 ||
|| `test/components/sftp/utils.test.ts` | 16 | sftp/utils | ✅ 完整 ||
|| `test/services/crypto.test.ts` | 11 | crypto | ✅ 完整 ||
|| `test/stores/keybindingStore.test.ts` | 23 | keybindingStore | ✅ 完整 ||
|| `test/utils/proxy.test.ts` | 24 | proxy utils | ✅ 完整 ||
---
## 测试策略
```
v1.3.0 测试计划
├── P0: 核心安全模块(crypto, ssh-service)
├── P1: 数据层(database, error-handler, stores)
├── P1: IPC 通信层(8 个 IPC Handler)
├── P2: UI 层(TerminalView, useSFTP hooks)
└── P3: E2E 集成(Playwright)
```
### 原则
1. **安全先行**: 加密/解密模块必须 100% 测试覆盖
2. **核心优先**: SSH 连接逻辑是应用核心,优先覆盖
3. **可 mock 性**: 所有外部依赖(ssh2, better-sqlite3, electron)必须可 mock
4. **独立性**: 每个测试独立运行,不依赖外部状态
5. **快速反馈**: 单元测试 < 5s,集成测试 < 30s
---
## 测试环境配置
### 已有配置
```typescript
// vitest.config.ts
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
thresholds: {
statements: 50, // 当前远未达到
branches: 40,
functions: 50,
lines: 50,
},
},
},
})
```
### Mock 策略
```typescript
// test/setup.ts 已有:
// - window.electronAPI 完整 mock
// - localStorage mock
// 需要新增:
// - ssh2 Client mock
// - better-sqlite3 mock
// - zmodem-ts mock
```
---
## P0: 核心模块测试(第一批)
### 1. crypto.ts 测试 — 加密/解密正确性
**文件**: `test/services/crypto.test.ts`
**用例目标**: 15+
```typescript
describe('Crypto Service', () => {
describe('encrypt / decrypt', () => {
it('应正确加密并解密明文字符串')
it('应正确加密并解密空字符串')
it('应正确加密并解密特殊字符和 Unicode')
it('每次加密应生成不同的密文(随机 IV)')
it('密文篡改后解密应失败(Auth Tag 验证)')
it('错误密钥解密应失败')
})
describe('deriveKey', () => {
it('相同密码和 salt 应派生相同密钥')
it('不同 salt 应派生不同密钥')
it('应使用 100,000 次 PBKDF2 迭代')
})
describe('generateSalt / generateIV', () => {
it('salt 应为 32 字节随机值')
it('IV 应为 12 字节随机值(GCM 推荐)')
it('每次生成的值应不同')
})
})
```
### 2. ssh-service.ts 测试 — SSH 连接逻辑
**文件**: `test/services/ssh-service.test.ts`
**用例目标**: 20+
**Mock**: ssh2 Client
```typescript
// Mock ssh2
vi.mock('ssh2', () => ({
Client: vi.fn().mockImplementation(() => ({
connect: vi.fn(),
on: vi.fn(),
end: vi.fn(),
shell: vi.fn(),
forwardOut: vi.fn(),
forwardIn: vi.fn(),
sftp: vi.fn(),
})),
}))
describe('SSH Service', () => {
describe('connect', () => {
it('应使用密码认证连接')
it('应使用私钥认证连接')
it('应使用 SSH Agent 认证连接')
it('连接失败应返回错误')
it('连接超时应返回超时错误')
it('重复连接同一主机应复用')
})
describe('createShell', () => {
it('应创建交互式 Shell')
it('应传入终端尺寸 cols/rows')
it('Shell 创建失败应返回错误')
})
describe('disconnect', () => {
it('应正确关闭连接')
it('应清理所有 Shell 会话')
it('应清理端口转发')
})
describe('portForward', () => {
it('应创建本地端口转发')
it('应创建远程端口转发')
it('应创建 SOCKS5 动态代理')
it('端口被占用应返回错误')
})
describe('connectionPool', () => {
it('不应超过最大连接数限制')
it('空闲连接应被自动回收')
it('LRU 淘汰策略应正确工作')
})
})
```
---
## P1: 数据层 + Store 测试(第二批)
### 3. database-service.ts 测试
**文件**: `test/services/database.test.ts`
**用例目标**: 15+
```typescript
describe('Database Service', () => {
describe('Session CRUD', () => {
it('应创建新会话并返回 ID')
it('应获取所有会话列表')
it('应根据 ID 获取单个会话')
it('应更新会话字段')
it('应删除会话')
it('密码字段应加密存储')
it('读取时密码字段应解密')
})
describe('Settings', () => {
it('应保存和读取设置')
it('未设置的键应返回默认值')
it('应支持复杂对象类型设置')
})
describe('数据完整性', () => {
it('数据库文件不存在应自动创建')
it('Schema 版本升级应自动迁移')
})
})
```
### 4. error-handler.ts 测试
**文件**: `test/utils/error-handler.test.ts`
**用例目标**: 10+
```typescript
describe('Error Handler', () => {
describe('inferErrorCode', () => {
it('应从 ECONNREFUSED 推断连接错误')
it('应从 ETIMEDOUT 推断超时错误')
it('应从 auth 推断认证错误')
it('应从 ENOENT 推断文件不存在错误')
it('未知错误应返回通用错误码')
})
describe('withErrorHandling', () => {
it('应包装异步函数并捕获错误')
it('应返回统一错误格式')
})
})
```
### 5. poolStore 测试增强
**文件**: `test/stores/connectionPool.test.ts`
**用例目标**: 15+ (新增 8)
```typescript
describe('Connection Pool Store', () => {
// 已有: 基本接口验证
// 新增:
it('应拒绝超过最大连接数的新连接')
it('LRU 淘汰应移除最久未使用的连接')
it('后台连接超过最大数应断开最旧')
it('活跃连接不应被 LRU 淘汰')
it('连接状态机转换应完整覆盖')
it('挂起超时 30 分钟应彻底断开')
it('保活心跳应重置空闲计时器')
})
```
### 6. lifecycleStore 测试
**文件**: `test/stores/lifecycleStore.test.ts`
**用例目标**: 10+
---
## P1: IPC Handler 测试(第三批)
### 7. SSH IPC 测试
**文件**: `test/ipc/ssh.ipc.test.ts`
**用例目标**: 8+
```typescript
describe('SSH IPC Handlers', () => {
it('ssh:connect 应调用 sshService.connect')
it('ssh:disconnect 应调用 sshService.disconnect')
it('ssh:create-shell 应调用 sshService.createShell')
it('ssh:shell-write 应调用 sshService.shellWrite')
it('ssh:shell-resize 应调用 sshService.shellResize')
it('ssh:forward-local 应调用 sshService.forwardLocal')
it('ssh:forward-remote 应调用 sshService.forwardRemote')
it('无效参数应返回错误')
})
```
### 8. SFTP IPC 测试
**文件**: `test/ipc/sftp.ipc.test.ts`
**用例目标**: 8+
### 9. Session IPC 测试
**文件**: `test/ipc/session.ipc.test.ts`
**用例目标**: 6+
### 10. Settings IPC 测试
**文件**: `test/ipc/settings.ipc.test.ts`
**用例目标**: 4+
### 11. 其他 IPC 测试
**文件**: `test/ipc/{dialog,file,zmodem,app}.ipc.test.ts`
**用例目标**: 每个文件 3-5+
---
## P2: UI 层测试(第四批)
### 12. TerminalView 组件测试
**文件**: `test/components/TerminalView.test.tsx`
**用例目标**: 10+
```typescript
// Mock xterm
vi.mock('@xterm/xterm', () => ({
Terminal: vi.fn().mockImplementation(() => ({
onData: vi.fn(), onResize: vi.fn(), open: vi.fn(),
write: vi.fn(), dispose: vi.fn(), cols: 80, rows: 24,
})),
}))
describe('TerminalView', () => {
it('应渲染终端容器')
it('应显示连接中状态')
it('连接成功应初始化 XTerm')
it('连接失败应显示错误信息')
it('Shell 数据应写入终端')
it('终端输入应发送到 Shell')
it('窗口调整应 resize 终端')
it('组件卸载应释放资源')
})
```
### 13. useSFTP Hooks 测试
**文件**: `test/hooks/useSFTP.test.ts`
**用例目标**: 15+
### 14. 其他组件测试
- `test/components/Sidebar.test.tsx` — 5+
- `test/components/SessionDialog.test.tsx` — 5+
- `test/components/SFTPView.test.tsx` — 5+
---
## P3: E2E 集成测试(远期)
### Playwright 配置
```typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './test/e2e',
timeout: 30000,
use: { baseURL: 'http://localhost:5173' },
})
```
### E2E 用例
- 快速连接 → SSH 终端出现
- 侧边栏会话 → 点击 → 终端连接
- SFTP → 选择服务器 → 浏览目录
- 标签页 → 创建 → 切换 → 关闭
---
## 测试执行
### 命令
```bash
npm test # 运行所有测试
npm test -- --coverage # 带覆盖率报告
npm test -- test/services/ # 只跑 services 测试
npm test -- --reporter=verbose # 详细输出
npm test -- --watch # 监听模式
```
### CI 集成(规划)
```yaml
# .github/workflows/test.yml
- name: Test
run: npm test -- --coverage
- name: Coverage Check
run: |
if [ $(npm test -- --coverage --reporter=json 2>/dev/null | jq '.total.lines.pct') -lt 50 ]; then
echo "Coverage below 50%"
exit 1
fi
```
---
## 覆盖率追踪
|| 版本 | 总覆盖率 | 核心模块 | 用例数 | 日期 |
|------|----------|----------|--------|------|
| v1.0.0 | ~10% | 0% | 34 | 2026-04-06 |
| v1.1.0 | ~12% | 0% | 34 | — |
| v1.2.0 | ~15% | 0% | 40 | — |
| v1.3.0 | ~20% | 60% | 49 | 2026-04-24 |
| v1.4.0-batch1 | ~25% | 60% | 72 | 2026-04-24 |
| v1.4.0-proxy | ~25% | 65% | 96 | 2026-04-25 |
| v1.3.0-batch2 | ~35% | 60% | 104 | — |
| v1.3.0-batch3 | ~45% | 70% | 143 | — |
| v1.3.0-final | **≥50%** | **≥80%** | **150+** | — |
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<rect x="64" y="64" width="896" height="896" rx="224" fill="#101820"/>
<rect x="116" y="116" width="792" height="792" rx="180" fill="#17232d"/>
<path d="M258 326 454 512 258 698" fill="none" stroke="#f2f7f8" stroke-width="92" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M500 698h244" fill="none" stroke="#55aef2" stroke-width="92" stroke-linecap="round"/>
<circle cx="750" cy="308" r="52" fill="#42d392"/>
</svg>

After

Width:  |  Height:  |  Size: 502 B

+12
View File
@@ -0,0 +1,12 @@
# OpsXShell 文档
文档按使用、开发、验收和发布分开维护,避免把规划能力写成已支持功能。
| 文档 | 内容 |
| ----------------------------------- | -------------------------------------------- |
| [使用指南](./user-guide.md) | SSH、SFTP、代理、串口、AI 助手和数据安全 |
| [架构说明](./architecture.md) | Tauri/Rust 分层、原生服务、AI 与安全边界 |
| [测试验收](./testing-acceptance.md) | 本地门禁、真实 SSH 探针和发版验收 |
| [发布指南](./release.md) | macOS/Windows 构建、签名、产物和临时文件清理 |
项目定位、功能概览和开发命令见仓库根目录的 [README](../README.md),未完成事项只在 [ROADMAP](../ROADMAP.md) 维护。
+148
View File
@@ -0,0 +1,148 @@
# 架构说明
## 产品边界
OpsXShell 是轻量原生的 SSH/SFTP 运维终端,核心范围是连接、终端、文件传输、端口转发、代理、串口和多会话操作。审计、录像、审批和堡垒机能力不在客户端范围内。
## 目录与职责
```text
src/ React 界面、工作区和 Zustand 状态
src/platform/ Tauri command/event 适配层
shared/types/ 渲染层 TypeScript 契约
src-tauri/src/commands/ 参数边界和统一响应
src-tauri/src/domain/ Rust 领域模型
src-tauri/src/services/ SSH、SFTP、隧道、PTY、串口、AI、存储和安全策略
```
React 不直接访问 SSH、文件系统或加密实现。Rust 服务持有连接、Shell、SFTP 和串口资源,渲染层只保存资源 ID 与界面状态。新增原生接口时必须同步更新:
1. Rust command 和 `src-tauri/src/lib.rs` 注册
2. `src/desktop.d.ts` 类型契约
3. `src/platform/desktop.ts` 适配实现
4. `test/setup.ts` 测试替身
Rust SSH 服务保留 `ssh.rs` 作为连接与 Shell 门面,已将独立边界拆出:
```text
services/
├── ssh.rs 连接池、Shell 与稳定公共 API
├── ssh/
│ ├── auth.rs 密码、私钥、Agent 与默认密钥认证
│ ├── known_hosts.rs OpenSSH 主机密钥策略
│ ├── proxy.rs HTTP/SOCKS5 连接代理与 ProxyJump
│ ├── path.rs 本机路径展开
│ └── sftp.rs 远程文件系统与断点传输
├── tunnel.rs TunnelManager 与启动/停止编排
└── tunnel/
├── model.rs 运行实例、计数器和取消令牌
├── socks5.rs SOCKS5 服务端协议
└── relay.rs TCP/libssh2 双向流转发
```
模块化只约束内部职责,不引入插件框架,也不把 libssh2 资源暴露到 command 层。后续 connection/shell 拆分继续保持现有 Tauri command 契约不变。
## 原生服务
| 能力 | 实现 |
| -------------------- | ------------------------------------------------ |
| SSH/SFTP | `ssh2 0.9` / libssh2 |
| HTTP/SOCKS5 连接代理 | Rust TCP 握手后交给 libssh2 |
| ProxyJump | libssh2 direct-tcpip + loopback relay,最多 8 跳 |
| 本地/远程/动态转发 | libssh2 channel + Rust TCP relay |
| 本地终端 | `portable-pty` |
| 串口 | `serialport` |
| 会话与设置 | 应用数据目录内原子写入 JSON |
| 凭据 | AES-256-GCM + `~/.opsxshsell/master.key` |
| 主机信任 | OpenSSH `~/.ssh/known_hosts` |
| 终端 AI | Rust `reqwest` + OpenAI/Anthropic 兼容协议 |
## 终端 AI 边界
AI 是可选的终端助手。XTerm 只把当前焦点分屏的选区或用户明确勾选的当前屏幕交给 Rust;Rust 先脱敏常见密码、Token、Authorization Header、AWS Key 和私钥,再调用 Provider。需要实际处理时采用受控的单工具循环,不允许模型文本直接触发执行。
```text
XTerm 当前焦点分屏
-> 选区优先 / 显式当前屏幕
-> Rust 本地脱敏
-> AiService (OpenAI Chat / Responses / Anthropic)
-> 建议文本
-> 复制,或单行命令填入 XTerm(不发送回车)
-> 执行任务:唯一单行命令 + 用户确认
-> 当前 connectionId 的 ssh_exec
-> 真实输出脱敏后交给 AI 总结
```
- Provider 配置和模型路由保存在 `ai-providers-v1`,不包含 API Key。
- API Key 使用 `ai-provider:<provider-id>:api-key` 作用域写入现有 AES-256-GCM secrets。
- 模型可覆盖 Provider 协议和 Endpoint,以支持同一个上游模型的按量和 Plan 路由。
- models.dev 目录仅由 Rust 访问三个固定 HTTPS 地址,最多读取 24 MiB;它用于维护和扩展预设,不是应用启动依赖。
- new-api、One API 和其他聚合网关通过 OpenAI Compatible Provider 接入。
- AI 对话、提示词、终端上下文和回答只存在于当前面板内存中;关闭面板即清空,不写入应用设置。
- 新 Provider 的测试命令接收临时 API Key 并发出最小真实完成请求;测试过程不调用凭据存储。只有当前连接字段快照测试成功,界面才允许保存。
- 模型返回内容按不可信文本处理。普通模式只有唯一单行代码块可“填入终端”,多行脚本只能复制。
- 执行模式只接受唯一单行命令,目标固定为当前活跃 SSH connection;每次调用 `ssh_exec` 前显示目标、命令和风险确认。执行结果只保留在当前面板。
- 不创建独立 Kubernetes 客户端或 podexec。集群运维命令沿当前 SSH 会话执行,避免引入本机 kubeconfig 与第二套目标/凭据边界。
## 端口转发生命周期
转发规则和运行实例是两种不同状态:
- `ForwardRule` 由 React 管理并按保存会话 ID 写入设置,只保存类型、端口和目标。
- `ForwardStatus` 由 Rust `TunnelManager` 管理,只在当前进程有效,包含监听端口、开始时间、连接数和流量。
- 每个运行实例有唯一 `forwardId` 和取消标志。停止、删除正在运行的规则或断开 SSH 连接时,Rust 必须关闭真实 Listener。
- 规则支持编辑和复制。复制只产生新的规则 ID,不复制运行态;运行中的规则必须先停止真实 Listener 才能编辑。
- 应用重启后不得根据上次 UI 状态显示“运行中”,必须以 `ssh_forward_list` 返回的原生状态为准。
当前动态 SOCKS5 默认仅绑定 `127.0.0.1`,不对局域网暴露。客户端仅支持 SOCKS5 CONNECT 和无认证方式。
## SFTP 写入策略
渲染层通过 `src/platform/sftp.ts` 固定路径方向:
- `localPath` 永远表示本机文件系统路径,`remotePath` 永远表示 SFTP 服务器路径。
- `uploadRemoteFile` 固定为本地源到远程目标;`downloadRemoteFile` 固定为远程源到本地目标。
- `readRemoteTextFile`/`writeRemoteTextFile` 只操作远程编辑器内存内容,不等同于下载/上传。
- SFTP ID 只能由已连接的 SSH connection 创建,连接失效后不得复用旧 SFTP ID。
上传和下载使用 `.opsx-part` 临时文件:
1. 检查临时文件已有长度。
2. 从相同偏移继续读取和写入。
3. 完成后校验源与目标字节数。
4. 校验通过后替换正式目标。
SSH 传输本身受协议 MAC 保护;当前应用额外执行大小校验,但尚未执行文件内容哈希。
## 终端输入输出管线
`TerminalPipeline` 是 XTerm 和 Shell API 之间的轻量边界,单终端与分屏共用:
- 输入:活动状态更新、Shell 路由、同步输入以及异步写入错误收敛。
- 输出:IPC 数据归一化、流式 UTF-8 解码、关键字变换、每帧限量写入和积压上限。
- 终端视图只处理布局、焦点与生命周期;Zmodem、OSC 和登录脚本后续作为显式管线阶段增加,不继续堆进工作区组件。
## ssh:// 深链接
系统协议由 Tauri deep-link 插件注册,single-instance 插件负责把后续启动参数交回现有进程。原生层只接受 `ssh://`,拒绝 URL 中的密码、路径、查询参数、片段、控制字符和异常用户名。
深链接不会静默连接任意服务器:Rust 将解析后的 `{ username, host, port }` 放入有界内存队列并通知前端;前端原子取走队列,打开预填充的快速连接窗口供用户确认。冷启动时通知可能早于 WebView,队列仍会保留;运行中唤起则聚焦现有主窗口。
## 安全规则
- 敏感凭据必须在 Rust 层加密后落盘。
- 不持久化终端命令历史或批量命令输出。
- 不持久化 AI 提示词、回答或终端上下文;API Key 不得返回 WebView 或写入普通 settings。
- 未实现能力不能在界面中显示为可用。
- 自动更新必须具备签名公钥、HTTPS 更新源和回滚策略,否则保持关闭。
- 应用不能静默绕过 changed host key 或 known_hosts 不匹配。
## 体积预算
| 产物 | 上限 |
| -------------- | ----------------------------- |
| macOS DMG | 25 MB |
| Windows 安装包 | 15 MB,不含 WebView2 引导程序 |
| Linux AppImage | 35 MB |
Release 使用 LTO、单 codegen unit、`panic = abort` 和符号剥离。平台功能必须在对应系统实机验收。
+88
View File
@@ -0,0 +1,88 @@
# 发布指南
## 构建命令
```bash
# 当前平台完整安装包
npm run build:tauri
# 仅 macOS App
npx tauri build --bundles app
# macOS arm64
npm run dist:mac
# Windows x86_64(必须在 Windows 或配置完整交叉编译环境)
npm run dist:win
```
macOS App 位于:
```text
src-tauri/target/release/bundle/macos/OpsXShell.app
```
指定 `aarch64-apple-darwin` target 时,产物位于对应 target 的 `release/bundle` 下。
## 临时 DMG
Tauri 调用 `hdiutil` 制作 DMG 时,可能在异常中断后留下:
```text
rw.<进程号>.OpsXShell_<版本>_aarch64.dmg
```
这些 `rw.*.dmg` 是可写临时镜像,不是发布包。确认没有正在运行的 Tauri/hdiutil 打包任务后可以删除。正式包没有 `rw.<数字>.` 前缀。
如果 DMG 脚本报错 `resource busy`,先检查是否正从临时挂载目录运行 OpsXShell:
```bash
hdiutil info
lsof +D /Volumes/<临时卷名>
```
先正常退出该 OpsXShell 进程,再卸载对应的临时卷并重新构建。不要在应用仍从卷内运行时直接删除 `rw.*.dmg`。
## 签名和公证
标签 `tauri-v*` 会触发 `.github/workflows/tauri-release.yml`。正式标签构建要求签名变量齐全。
macOS:
- `APPLE_CERTIFICATE`
- `APPLE_CERTIFICATE_PASSWORD`
- `APPLE_SIGNING_IDENTITY`
- `APPLE_ID`
- `APPLE_PASSWORD`
- `APPLE_TEAM_ID`
Windows:
- `WINDOWS_CERTIFICATE_BASE64`
- `WINDOWS_CERTIFICATE_PASSWORD`
缺少凭据时只能生成开发构建,不能标记为正式签名或已公证版本。
## 产物验证
```bash
npm run release:preflight:mac
npm run release:verify:mac
npm run release:preflight:win
npm run release:verify:win
```
验证脚本检查架构、体积和安装包存在性。正式标签还会验证 macOS 签名/Gatekeeper 或 Windows Authenticode 签名。
安装包还必须包含 `ssh://` 协议注册。macOS 检查 `CFBundleURLTypes`,Windows 安装后检查当前用户 `Software\\Classes\\ssh` 并实际执行一次协议唤起。只验证配置文件不足以证明单实例事件交付正常。
## 发布前检查
1. `package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`Cargo.lock` 和 `tauri.conf.json` 版本一致。
2. `npm run acceptance:full` 通过。
3. 目标平台构建和签名通过。
4. 安装后完成输入框复制/粘贴、SFTP 双向拖拽、普通区域右键菜单、SSH/SFTP/代理/跳板和 `ssh://` 单实例唤起验收。
5. 大模型新配置必须先测试后保存;AI“执行任务”必须显示当前 SSH 目标并在确认后只执行一次。
5. 检查安装包尺寸,没有上传 `rw.*.dmg`、日志、测试密钥或用户数据。
6. 保留上一稳定版本及回滚说明。
+114
View File
@@ -0,0 +1,114 @@
# 测试与验收
## 本地门禁
日常开发执行:
```bash
npm run acceptance:quick
```
依次检查界面类型、测试 API 契约、全部 Vitest 测试、Rust 格式和 Rust 编译。
提交或发包前执行:
```bash
npm run acceptance:full
```
完整门禁额外执行 ESLint、Clippy 零警告、Rust 测试、前端生产构建和发布脚本测试。任何步骤失败都会立即返回非零状态。
## 覆盖重点
- 应用壳在原生 API 可用和不可用时都能稳定渲染
- 会话新建、密码/私钥认证、代理和跳板配置
- SSH Config 解析和会话导出真实写盘
- 批量打开终端、SFTP 和批量命令
- Tauri 事件监听注册/销毁竞态
- SSH 连接关闭后的状态回收
- HTTP CONNECT、SOCKS5 和断点复制
- 转发规则不持久化运行状态,复制产生新 ID;原生隧道可启动、查询、停止并在 SSH 断开时清理
- SFTP 本地/远程路径参数方向、原生路径上传、选中文件下载、双栏双向拖拽、目录落点、远程文本读写和错误反馈
- 终端分屏、剪贴板、快捷键、分片 UTF-8 解码、高输出背压和异步输入错误
- `ssh://` 安全解析、冷启动队列、IPv6 格式和快速连接预填充
- AI Provider 的 OpenAI Chat、Responses、Anthropic 响应解析和 HTTP Endpoint 限制
- models.dev 多种 JSON 形态归一化、目录源部分失败降级和 new-api `/models` 列表
- API Key 只进入 Rust secrets,不出现在 `ai-providers-v1`、日志或 WebView 返回值中
- 终端上下文选区优先、当前屏幕显式授权、本地脱敏、单行填入和多行拒绝
- 新 Provider 测试前不落盘,真实测试成功后才启用保存;任一连接字段变化会使验证快照失效
- AI 执行任务固定当前 SSH connection,确认前不得调用 `ssh_exec`,真实输出回传分析且不进入历史
- macOS 原生“编辑”菜单以及所有输入框的复制、剪切、粘贴、全选、撤销;应用快捷键不得覆盖这些系统操作
- 普通界面右键不出现 WebView 的 Reload/Inspect Element;输入框仍保留系统编辑菜单,自定义右键不受影响
测试替身必须完整实现 `DesktopApiContract`。新增或删除桌面接口后,`typecheck:test` 必须能发现未同步的契约。
## AI 助手验收
自动验收不得使用真实个人 API Key。可以用本机 mock OpenAI Compatible 服务验证以下边界:
1. Provider 没有 Key 时返回明确错误;保存 Key 后普通设置 JSON 中搜索不到明文。
2. HTTPS 为默认要求,任意公网 HTTP Endpoint 被拒绝;只有显式允许后才能访问可信本机 HTTP 服务。
3. OpenAI Chat、OpenAI Responses 和 Anthropic 三种响应都能提取文本,错误响应显示 HTTP 状态和供应商消息。
4. `Authorization: Bearer`、密码字段、AWS Key 和私钥在请求发出前被替换,并显示脱敏计数。
5. 有选区时即使勾选当前屏幕也只发送选区;无选区且未勾选时只发送用户问题。
6. 单行命令填入后终端不得出现 `\r` 或 `\n`;多行代码块不显示填入按钮。
7. 关闭再打开 AI 面板,之前的提示词和回答不存在。
8. models.dev 三个源全部失败时不影响已保存 Provider;部分成功时仍能生成可导入目录。
9. 新增 Provider 后测试前“保存”禁用;测试实际发送“你是什么模型?请简短回答。”,期间 settings 与 secrets 均无写入;只有非空成功回答显示 HTTP 200,随后保存才写入加密 Key。
10. 切换 Endpoint、模型、协议或 API Key 后“保存”立即重新禁用。
11. “执行任务”只接受唯一单行命令;确认对话框打开前和用户取消后 `ssh_exec` 调用次数必须为 0。
12. 确认后只向当前 connectionId 执行一次;真实输出交给模型总结,关闭面板后命令与输出消失。
## 真实 SSH/SFTP 探针
真实探针默认标记为 ignored,必须显式提供测试服务器:
```bash
OPSX_TEST_SSH_HOST=example.com \
OPSX_TEST_SSH_PORT=22 \
OPSX_TEST_SSH_USER=root \
cargo test --manifest-path src-tauri/Cargo.toml \
real_ssh_exec_and_sftp_probe -- --ignored --nocapture
```
该探针会执行 SSH 命令、创建 SFTP 通道,并在远端 `/tmp` 完成一个临时文件的上传、下载、逐字节比对和清理。只有列目录成功不算 SFTP 基础传输验收通过。
ProxyJump 探针额外设置 `OPSX_TEST_SSH_JUMP_HOST`、`OPSX_TEST_SSH_JUMP_PORT`;使用 `OPSX_TEST_SSH_JUMP_COUNT=2` 可验证两级链。
动态 SOCKS5 真实探针使用同一组 SSH 环境变量:
```bash
OPSX_TEST_SSH_HOST=example.com \
OPSX_TEST_SSH_PORT=22 \
OPSX_TEST_SSH_USER=root \
cargo test --manifest-path src-tauri/Cargo.toml \
real_dynamic_forward_probe -- --ignored --nocapture
```
它会创建随机本地 SOCKS5 监听,通过 SSH `direct-tcpip` 再连回测试主机,读取 `SSH-` 协议头,最后停止监听并确认运行状态已清理。
## 端口转发验收
Rust 单元测试会用随机本地端口验证动态监听器的唯一 ID、运行查询、重复启动拒绝和真实停止。实机验收还必须包含:
1. 在保存会话上一键启动 `127.0.0.1:1080`,用 `curl --proxy socks5h://127.0.0.1:1080 https://example.com` 验证。
2. 确认连接数和流量会变化,复制出的代理地址正确。
3. 停止后确认端口无法再连接,不能只检查 UI 颜色。
4. 重启应用后规则仍在,但状态必须为未运行。
5. 断开 SSH 主连接后,关联的本地、远程和 SOCKS5 监听器全部清理。
6. 复制规则后确认新规则 ID 独立且状态为未运行;编辑运行中的规则必须先停止原 Listener。
## ssh:// 验收
macOS 安装版先检查 `Info.plist` 包含 `ssh` URL scheme,然后分别验证冷启动和运行中唤起:
```bash
plutil -p OpsXShell.app/Contents/Info.plist | grep -A8 CFBundleURLTypes
open -a OpsXShell 'ssh://root@opsbase.cn:55555'
```
必须确认只存在一个 OpsXShell 进程、原窗口被聚焦、快速连接目标为 `root@opsbase.cn:55555`。`ssh://root:password@host` 和包含远程路径的链接必须被拒绝。Windows 需要在安装后的注册表和浏览器唤起场景重复验证,macOS 结果不能代替 Windows 验收。
## 发版验收
候选发布必须在目标系统完成:安装、首次启动、窗口操作、SSH、SFTP、代理、跳板、串口枚举、休眠恢复、卸载及重新安装。CI 构建成功不能替代 Windows 或 macOS 实机验收。
-680
View File
@@ -1,680 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpsXShell - 功能预览与架构设计</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
body { font-family: 'Inter', sans-serif; }
.terminal-text { font-family: 'Menlo', 'Monaco', 'Courier New', monospace; }
.tab-active {
background: #1e293b;
border-top: 2px solid #22d3ee;
}
.preview-container {
aspect-ratio: 16/10;
}
.feature-done {
border-left: 4px solid #22c55e;
}
.feature-progress {
border-left: 4px solid #f59e0b;
}
.feature-todo {
border-left: 4px solid #6b7280;
}
</style>
</head>
<body class="bg-slate-950 min-h-screen p-8">
<div class="max-w-7xl mx-auto space-y-8">
<!-- Header -->
<div class="text-center mb-8">
<h1 class="text-4xl font-bold text-white mb-3">OpsXShell 功能架构</h1>
<p class="text-slate-400 text-lg">跨平台 SSH/SFTP 终端工具 · 专业级远程连接解决方案</p>
<div class="flex justify-center gap-4 mt-6">
<span class="flex items-center gap-2 text-sm text-green-400">
<div class="w-3 h-3 rounded-full bg-green-500"></div>
已实现
</span>
<span class="flex items-center gap-2 text-sm text-yellow-400">
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
进行中
</span>
<span class="flex items-center gap-2 text-sm text-gray-400">
<div class="w-3 h-3 rounded-full bg-gray-500"></div>
规划中
</span>
</div>
</div>
<!-- Tab Switcher -->
<div class="flex justify-center gap-2 mb-6 flex-wrap">
<button onclick="showView('terminal')" id="tab-terminal" class="px-6 py-2 rounded-lg text-sm font-medium transition-colors bg-cyan-600 text-white">
<i class="fas fa-terminal mr-2"></i>SSH 终端
</button>
<button onclick="showView('sftp')" id="tab-sftp" class="px-6 py-2 rounded-lg text-sm font-medium transition-colors bg-slate-800 text-slate-300 hover:bg-slate-700">
<i class="fas fa-folder-open mr-2"></i>SFTP 管理
</button>
<button onclick="showView('portforward')" id="tab-portforward" class="px-6 py-2 rounded-lg text-sm font-medium transition-colors bg-slate-800 text-slate-300 hover:bg-slate-700">
<i class="fas fa-network-wired mr-2"></i>端口转发
</button>
<button onclick="showView('features')" id="tab-features" class="px-6 py-2 rounded-lg text-sm font-medium transition-colors bg-slate-800 text-slate-300 hover:bg-slate-700">
<i class="fas fa-list-check mr-2"></i>功能清单
</button>
</div>
<!-- Terminal View -->
<div id="view-terminal" class="preview-container">
<div class="bg-slate-900 rounded-2xl border border-slate-800 overflow-hidden shadow-2xl shadow-cyan-500/10 h-full">
<!-- Title Bar -->
<div class="bg-gradient-to-r from-slate-800 to-slate-700 px-4 py-3 flex items-center justify-between border-b border-slate-700">
<div class="flex items-center gap-3">
<div class="flex gap-2">
<div class="w-3 h-3 rounded-full bg-red-500"></div>
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<div class="w-3 h-3 rounded-full bg-green-500"></div>
</div>
<div class="flex items-center gap-2 ml-4">
<i class="fas fa-terminal text-cyan-400"></i>
<span class="text-slate-200 font-medium">OpsXShell</span>
</div>
</div>
</div>
<!-- Main Content -->
<div class="flex h-[calc(100%-48px)]">
<!-- Sidebar -->
<div class="w-60 bg-slate-950 border-r border-slate-800 p-3 flex flex-col">
<div class="flex items-center gap-2 mb-4 px-2 py-2 bg-slate-800 rounded-lg border border-slate-700">
<i class="fas fa-search text-slate-500 text-xs"></i>
<span class="text-slate-500 text-xs">搜索会话...</span>
</div>
<div class="space-y-1 flex-1">
<div class="text-xs text-slate-500 font-semibold px-2 mb-2">生产环境</div>
<div class="flex items-center gap-2 px-2 py-2.5 bg-cyan-500/20 rounded-lg border border-cyan-500/30 cursor-pointer">
<div class="w-2 h-2 rounded-full bg-green-500"></div>
<i class="fas fa-server text-cyan-400 text-xs"></i>
<span class="text-slate-200 text-sm font-medium">Web-Prod-01</span>
</div>
<div class="flex items-center gap-2 px-2 py-2 hover:bg-slate-800 rounded-lg cursor-pointer transition-colors">
<div class="w-2 h-2 rounded-full bg-green-500"></div>
<i class="fas fa-database text-slate-500 text-xs"></i>
<span class="text-slate-400 text-sm">DB-Master</span>
</div>
</div>
<button class="mt-3 w-full bg-cyan-600 hover:bg-cyan-500 text-white text-xs py-2.5 rounded-lg flex items-center justify-center gap-2 transition-colors">
<i class="fas fa-plus"></i> 新建会话
</button>
</div>
<!-- Right Panel -->
<div class="flex-1 flex flex-col bg-slate-950">
<!-- Tab Bar -->
<div class="flex items-center bg-slate-900 border-b border-slate-800 px-2">
<div class="flex items-center gap-1 py-1 flex-1">
<div class="tab-active flex items-center gap-2 px-4 py-2 rounded-t-lg cursor-pointer group">
<i class="fas fa-terminal text-cyan-400 text-xs"></i>
<span class="text-slate-200 text-sm">Web-Prod-01</span>
<div class="flex items-center gap-1 ml-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button class="p-1 hover:bg-slate-600 rounded" title="更多">
<i class="fas fa-ellipsis-h text-slate-400 text-xs"></i>
</button>
<button class="p-1 hover:bg-slate-600 rounded" title="关闭">
<i class="fas fa-times text-slate-400 text-xs"></i>
</button>
</div>
</div>
<div class="flex items-center gap-2 px-4 py-2 hover:bg-slate-800 rounded-t-lg cursor-pointer transition-colors">
<i class="fas fa-folder text-yellow-500 text-xs"></i>
<span class="text-slate-400 text-sm">SFTP</span>
</div>
</div>
<button class="p-2 text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded-lg transition-colors">
<i class="fas fa-plus text-xs"></i>
</button>
</div>
<!-- Terminal Area -->
<div class="flex-1 p-5 terminal-text text-sm overflow-auto" style="background: #0d1117;">
<div class="text-green-400">➜ ~</div>
<div class="text-slate-300 mt-2">ssh root@192.168.1.100</div>
<div class="text-slate-500 mt-2">Connecting to 192.168.1.100...</div>
<div class="text-green-400 mt-2">✓ Connected to CentOS 7.9</div>
<div class="mt-2 text-slate-400">Last login: Mon Jan 15 10:30:25 2026 from <span class="text-green-400">172.16.2.1</span></div>
<div class="text-cyan-400 mt-3 flex items-center gap-2">
<span>[root@localhost ~]#</span>
<span class="animate-pulse">_</span>
</div>
</div>
<!-- Status Bar -->
<div class="bg-slate-900 border-t border-slate-800 px-4 py-2.5 flex items-center justify-between text-xs">
<div class="flex items-center gap-6">
<span class="text-green-500 flex items-center gap-1.5">
<i class="fas fa-circle text-[8px]"></i>已连接
</span>
<span class="text-cyan-400 flex items-center gap-1.5">
<i class="fas fa-wifi"></i>12ms
</span>
</div>
<div class="flex items-center gap-6 text-slate-500">
<button class="hover:text-slate-300 flex items-center gap-1.5 transition-colors">
<i class="fas fa-exchange-alt"></i>端口转发
</button>
<button class="hover:text-slate-300 flex items-center gap-1.5 transition-colors">
<i class="fas fa-folder-open"></i>SFTP
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- SFTP View -->
<div id="view-sftp" class="preview-container hidden">
<div class="bg-slate-900 rounded-2xl border border-slate-800 overflow-hidden shadow-2xl shadow-cyan-500/10 h-full flex flex-col">
<!-- Title Bar -->
<div class="bg-gradient-to-r from-slate-800 to-slate-700 px-4 py-3 flex items-center justify-between border-b border-slate-700">
<div class="flex items-center gap-3">
<div class="flex gap-2">
<div class="w-3 h-3 rounded-full bg-red-500"></div>
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<div class="w-3 h-3 rounded-full bg-green-500"></div>
</div>
<div class="flex items-center gap-2 ml-4">
<i class="fas fa-terminal text-cyan-400"></i>
<span class="text-slate-200 font-medium">OpsXShell</span>
</div>
</div>
</div>
<!-- Main Content -->
<div class="flex-1 flex flex-col min-h-0">
<!-- Toolbar -->
<div class="h-12 flex items-center justify-between px-4 border-b border-slate-800 bg-slate-900">
<div class="flex items-center gap-2">
<span class="text-sm text-slate-400">SFTP - root@192.168.1.100</span>
<span class="px-2 py-0.5 text-xs rounded bg-green-500/20 text-green-400">已连接</span>
</div>
<div class="flex items-center gap-2">
<button class="flex items-center gap-2 px-4 py-1.5 bg-cyan-600 hover:bg-cyan-500 rounded-lg text-sm text-white transition-colors">
<i class="fas fa-download text-xs"></i>下载
</button>
<button class="flex items-center gap-2 px-4 py-1.5 bg-green-600 hover:bg-green-500 rounded-lg text-sm text-white transition-colors">
<i class="fas fa-upload text-xs"></i>上传
</button>
<button class="p-2 text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded-lg transition-colors">
<i class="fas fa-sync-alt text-xs"></i>
</button>
</div>
</div>
<!-- Dual Pane File Manager -->
<div class="flex-1 flex min-h-0">
<!-- Local Panel -->
<div class="flex-1 flex flex-col border-r border-slate-800">
<div class="h-12 flex items-center justify-between px-4 border-b border-slate-800 bg-slate-800/50">
<div class="flex items-center gap-3">
<i class="fas fa-hdd text-cyan-400"></i>
<span class="font-medium text-slate-200">本地</span>
</div>
</div>
<div class="h-10 flex items-center gap-2 px-4 border-b border-slate-800">
<div class="flex items-center gap-1 text-sm text-slate-300">
<i class="fas fa-home text-slate-400"></i>
<span class="text-slate-500">›</span>
<span>Users</span>
<span class="text-slate-500">›</span>
<span>admin</span>
</div>
</div>
<div class="h-9 flex items-center px-4 bg-slate-800/30 text-xs text-slate-500 border-b border-slate-800">
<div class="flex-1 min-w-0">名称</div>
<div class="w-32 text-right">修改时间</div>
<div class="w-24 text-right">大小</div>
</div>
<div class="flex-1 overflow-y-auto">
<div class="h-10 flex items-center px-4 hover:bg-slate-800/50 cursor-pointer border-b border-slate-800/50">
<div class="flex-1 flex items-center gap-2 min-w-0">
<i class="fas fa-folder text-sky-400 w-4"></i>
<span class="text-sm text-slate-300">Desktop</span>
</div>
<div class="w-32 text-right text-xs text-slate-500">2026/01/15</div>
<div class="w-24 text-right text-xs text-slate-500">--</div>
</div>
<div class="h-10 flex items-center px-4 hover:bg-slate-800/50 cursor-pointer border-b border-slate-800/50">
<div class="flex-1 flex items-center gap-2 min-w-0">
<i class="fas fa-folder text-sky-400 w-4"></i>
<span class="text-sm text-slate-300">Documents</span>
</div>
<div class="w-32 text-right text-xs text-slate-500">2026/01/14</div>
<div class="w-24 text-right text-xs text-slate-500">--</div>
</div>
<div class="h-10 flex items-center px-4 hover:bg-slate-800/50 cursor-pointer border-b border-slate-800/50 bg-cyan-500/10">
<div class="flex-1 flex items-center gap-2 min-w-0">
<i class="fas fa-file text-slate-400 w-4"></i>
<span class="text-sm text-slate-300">deploy.sh</span>
</div>
<div class="w-32 text-right text-xs text-slate-500">2026/01/15</div>
<div class="w-24 text-right text-xs text-slate-500">2.4 KB</div>
</div>
</div>
</div>
<!-- Remote Panel -->
<div class="flex-1 flex flex-col">
<div class="h-12 flex items-center justify-between px-4 border-b border-slate-800 bg-slate-800/50">
<div class="flex items-center gap-3">
<i class="fas fa-desktop text-cyan-400"></i>
<span class="font-medium text-slate-200">192.168.1.100</span>
</div>
</div>
<div class="h-10 flex items-center gap-2 px-4 border-b border-slate-800">
<div class="flex items-center gap-1 text-sm text-slate-300">
<i class="fas fa-hdd text-slate-400"></i>
<span class="text-slate-500">›</span>
<span>root</span>
</div>
</div>
<div class="h-9 flex items-center px-4 bg-slate-800/30 text-xs text-slate-500 border-b border-slate-800">
<div class="flex-1 min-w-0">名称</div>
<div class="w-32 text-right">修改时间</div>
<div class="w-24 text-right">大小</div>
</div>
<div class="flex-1 overflow-y-auto">
<div class="h-10 flex items-center px-4 hover:bg-slate-800/50 cursor-pointer border-b border-slate-800/50">
<div class="flex-1 flex items-center gap-2 min-w-0">
<i class="fas fa-folder text-sky-400 w-4"></i>
<span class="text-sm text-slate-300">..</span>
</div>
</div>
<div class="h-10 flex items-center px-4 hover:bg-slate-800/50 cursor-pointer border-b border-slate-800/50">
<div class="flex-1 flex items-center gap-2 min-w-0">
<i class="fas fa-folder text-sky-400 w-4"></i>
<span class="text-sm text-slate-300">logs</span>
</div>
<div class="w-32 text-right text-xs text-slate-500">2026/01/15</div>
<div class="w-24 text-right text-xs text-slate-500">--</div>
</div>
<div class="h-10 flex items-center px-4 hover:bg-slate-800/50 cursor-pointer border-b border-slate-800/50">
<div class="flex-1 flex items-center gap-2 min-w-0">
<i class="fas fa-file text-slate-400 w-4"></i>
<span class="text-sm text-slate-300">nginx.conf</span>
</div>
<div class="w-32 text-right text-xs text-slate-500">2026/01/14</div>
<div class="w-24 text-right text-xs text-slate-500">1.2 KB</div>
</div>
</div>
</div>
</div>
<!-- Transfer Panel -->
<div class="h-40 border-t border-slate-800 bg-slate-900">
<div class="h-10 flex items-center justify-between px-4 bg-slate-800/50">
<div class="flex items-center gap-2">
<i class="fas fa-chevron-down text-slate-400 text-xs"></i>
<span class="text-sm text-slate-300">传输队列</span>
<span class="px-2 py-0.5 text-xs bg-cyan-500/20 text-cyan-400 rounded">2</span>
</div>
</div>
<div class="p-2 space-y-1">
<div class="flex items-center gap-3 px-3 py-2 bg-slate-800/30 rounded">
<i class="fas fa-download text-cyan-400 w-4"></i>
<div class="flex-1 min-w-0">
<div class="text-sm text-slate-300">nginx.conf</div>
<div class="h-1.5 bg-slate-700 rounded-full mt-1 overflow-hidden">
<div class="h-full bg-cyan-400" style="width: 75%"></div>
</div>
</div>
<div class="text-xs text-slate-500">75%</div>
</div>
<div class="flex items-center gap-3 px-3 py-2 bg-slate-800/30 rounded">
<i class="fas fa-upload text-green-400 w-4"></i>
<div class="flex-1 min-w-0">
<div class="text-sm text-slate-300">deploy.sh</div>
<div class="h-1.5 bg-slate-700 rounded-full mt-1 overflow-hidden">
<div class="h-full bg-green-400" style="width: 30%"></div>
</div>
</div>
<div class="text-xs text-slate-500">30%</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Port Forward View -->
<div id="view-portforward" class="preview-container hidden">
<div class="bg-slate-900 rounded-2xl border border-slate-800 overflow-hidden shadow-2xl shadow-cyan-500/10 h-full flex flex-col">
<!-- Title Bar -->
<div class="bg-gradient-to-r from-slate-800 to-slate-700 px-4 py-3 flex items-center justify-between border-b border-slate-700">
<div class="flex items-center gap-3">
<div class="flex gap-2">
<div class="w-3 h-3 rounded-full bg-red-500"></div>
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<div class="w-3 h-3 rounded-full bg-green-500"></div>
</div>
<div class="flex items-center gap-2 ml-4">
<i class="fas fa-terminal text-cyan-400"></i>
<span class="text-slate-200 font-medium">OpsXShell</span>
</div>
</div>
</div>
<!-- Main Content -->
<div class="flex-1 flex flex-col min-h-0">
<!-- Header -->
<div class="h-14 flex items-center justify-between px-6 border-b border-slate-800 bg-slate-900">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-orange-500/20 rounded-lg flex items-center justify-center">
<i class="fas fa-exchange-alt text-orange-400"></i>
</div>
<div>
<div class="font-semibold text-white">端口转发</div>
<div class="text-sm text-gray-500">已连接: root@192.168.1.100</div>
</div>
</div>
<button class="flex items-center gap-2 px-4 py-2 bg-orange-600 hover:bg-orange-500 rounded-lg text-sm font-medium text-white transition-colors">
<i class="fas fa-plus mr-1"></i>添加规则
</button>
</div>
<!-- Rules List -->
<div class="flex-1 overflow-y-auto p-6">
<!-- Active Rule - Local Forward -->
<div class="p-4 rounded-lg border border-green-500/30 bg-green-500/10 mb-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center text-blue-400">
<i class="fas fa-arrow-right"></i>
</div>
<div>
<div class="flex items-center gap-2">
<span class="font-medium text-white">MySQL 本地转发</span>
<span class="text-xs px-2 py-0.5 rounded bg-blue-500/20 text-blue-400">本地转发</span>
<span class="text-xs px-2 py-0.5 rounded bg-green-500/20 text-green-400">运行中</span>
</div>
<div class="text-sm text-gray-400 mt-1">
127.0.0.1:3306 → 192.168.1.100:3306
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button class="p-2 text-yellow-400 hover:bg-yellow-400/10 rounded" title="停止">
<i class="fas fa-stop"></i>
</button>
<button class="p-2 text-red-400 hover:bg-red-400/10 rounded" title="删除">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
<!-- Active Rule - Dynamic SOCKS5 -->
<div class="p-4 rounded-lg border border-green-500/30 bg-green-500/10 mb-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center text-orange-400">
<i class="fas fa-globe"></i>
</div>
<div>
<div class="flex items-center gap-2">
<span class="font-medium text-white">SOCKS5 代理</span>
<span class="text-xs px-2 py-0.5 rounded bg-orange-500/20 text-orange-400">动态 SOCKS5</span>
<span class="text-xs px-2 py-0.5 rounded bg-green-500/20 text-green-400">运行中</span>
</div>
<div class="text-sm text-gray-400 mt-1">
SOCKS5 代理: 127.0.0.1:1080
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button class="p-2 text-yellow-400 hover:bg-yellow-400/10 rounded" title="停止">
<i class="fas fa-stop"></i>
</button>
<button class="p-2 text-red-400 hover:bg-red-400/10 rounded" title="删除">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
<!-- Inactive Rule - Remote Forward -->
<div class="p-4 rounded-lg border border-slate-700 bg-slate-800">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center text-purple-400">
<i class="fas fa-arrow-left"></i>
</div>
<div>
<div class="flex items-center gap-2">
<span class="font-medium text-white">Redis 远程转发</span>
<span class="text-xs px-2 py-0.5 rounded bg-purple-500/20 text-purple-400">远程转发</span>
</div>
<div class="text-sm text-gray-400 mt-1">
远程 6379 → 本地 127.0.0.1:6379
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button class="p-2 text-green-400 hover:bg-green-400/10 rounded" title="启动">
<i class="fas fa-play"></i>
</button>
<button class="p-2 text-red-400 hover:bg-red-400/10 rounded" title="删除">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Info Panel -->
<div class="h-36 border-t border-slate-800 bg-slate-800/50 p-4">
<div class="text-sm text-slate-400">
<p class="mb-2"><i class="fas fa-info-circle mr-2 text-cyan-400"></i><strong class="text-slate-300">端口转发类型说明:</strong></p>
<ul class="space-y-1 ml-6 text-xs">
<li>• <strong class="text-blue-400">本地转发</strong>: 将本地端口映射到远程服务器 (访问内网MySQL/Redis等)</li>
<li>• <strong class="text-purple-400">远程转发</strong>: 将远程端口映射到本地 (让外网访问本地服务)</li>
<li>• <strong class="text-orange-400">动态 SOCKS5</strong>: 创建 SOCKS5 代理服务器 (浏览器代理/科学上网)</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Features List View -->
<div id="view-features" class="preview-container hidden">
<div class="bg-slate-900 rounded-2xl border border-slate-800 overflow-hidden shadow-2xl h-full p-8 overflow-y-auto">
<h2 class="text-2xl font-bold text-white mb-6 flex items-center gap-3">
<i class="fas fa-list-check text-cyan-400"></i>
功能实现清单
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- SSH Terminal -->
<div class="feature-done bg-slate-800/50 rounded-xl p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-cyan-500/20 rounded-lg flex items-center justify-center">
<i class="fas fa-terminal text-cyan-400"></i>
</div>
<h3 class="text-lg font-semibold text-white">SSH 终端</h3>
</div>
<ul class="space-y-3 text-sm">
<li class="flex items-center gap-2 text-green-400">
<i class="fas fa-check-circle"></i>
<span>密码认证</span>
</li>
<li class="flex items-center gap-2 text-green-400">
<i class="fas fa-check-circle"></i>
<span>密钥认证 (RSA/ED25519)</span>
</li>
<li class="flex items-center gap-2 text-green-400">
<i class="fas fa-check-circle"></i>
<span>XTerm.js 高性能终端</span>
</li>
<li class="flex items-center gap-2 text-green-400">
<i class="fas fa-check-circle"></i>
<span>多主题配色 (Dracula/Monokai/Solarized/One Dark)</span>
</li>
<li class="flex items-center gap-2 text-green-400">
<i class="fas fa-check-circle"></i>
<span>IP 地址高亮显示</span>
</li>
<li class="flex items-center gap-2 text-yellow-400">
<i class="fas fa-clock"></i>
<span>智能命令补全 (规划中)</span>
</li>
</ul>
</div>
<!-- SFTP -->
<div class="feature-done bg-slate-800/50 rounded-xl p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-green-500/20 rounded-lg flex items-center justify-center">
<i class="fas fa-folder-open text-green-400"></i>
</div>
<h3 class="text-lg font-semibold text-white">SFTP 管理</h3>
</div>
<ul class="space-y-3 text-sm">
<li class="flex items-center gap-2 text-green-400">
<i class="fas fa-check-circle"></i>
<span>双栏文件管理器</span>
</li>
<li class="flex items-center gap-2 text-green-400">
<i class="fas fa-check-circle"></i>
<span>上传/下载文件</span>
</li>
<li class="flex items-center gap-2 text-green-400">
<i class="fas fa-check-circle"></i>
<span>传输队列管理</span>
</li>
<li class="flex items-center gap-2 text-green-400">
<i class="fas fa-check-circle"></i>
<span>传输进度显示</span>
</li>
<li class="flex items-center gap-2 text-yellow-400">
<i class="fas fa-clock"></i>
<span>拖拽上传下载 (进行中)</span>
</li>
<li class="flex items-center gap-2 text-gray-400">
<i class="fas fa-circle text-[8px]"></i>
<span>断点续传 (规划中)</span>
</li>
</ul>
</div>
<!-- Port Forward -->
<div class="feature-done bg-slate-800/50 rounded-xl p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-orange-500/20 rounded-lg flex items-center justify-center">
<i class="fas fa-network-wired text-orange-400"></i>
</div>
<h3 class="text-lg font-semibold text-white">端口转发</h3>
</div>
<ul class="space-y-3 text-sm">
<li class="flex items-center gap-2 text-green-400">
<i class="fas fa-check-circle"></i>
<span>本地端口转发</span>
</li>
<li class="flex items-center gap-2 text-green-400">
<i class="fas fa-check-circle"></i>
<span>远程端口转发</span>
</li>
<li class="flex items-center gap-2 text-green-400">
<i class="fas fa-check-circle"></i>
<span>动态 SOCKS5 代理</span>
</li>
<li class="flex items-center gap-2 text-green-400">
<i class="fas fa-check-circle"></i>
<span>转发规则管理</span>
</li>
<li class="flex items-center gap-2 text-green-400">
<i class="fas fa-check-circle"></i>
<span>规则持久化存储</span>
</li>
</ul>
</div>
</div>
<!-- Architecture Info -->
<div class="mt-8 p-6 bg-slate-800/30 rounded-xl border border-slate-700">
<h3 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<i class="fas fa-sitemap text-cyan-400"></i>
技术架构
</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div class="text-center p-4 bg-slate-800 rounded-lg">
<div class="text-cyan-400 font-semibold mb-1">Electron 28+</div>
<div class="text-slate-500 text-xs">跨平台桌面框架</div>
</div>
<div class="text-center p-4 bg-slate-800 rounded-lg">
<div class="text-cyan-400 font-semibold mb-1">React 18</div>
<div class="text-slate-500 text-xs">UI 组件库</div>
</div>
<div class="text-center p-4 bg-slate-800 rounded-lg">
<div class="text-cyan-400 font-semibold mb-1">TypeScript 5</div>
<div class="text-slate-500 text-xs">类型安全</div>
</div>
<div class="text-center p-4 bg-slate-800 rounded-lg">
<div class="text-cyan-400 font-semibold mb-1">ssh2</div>
<div class="text-slate-500 text-xs">SSH/SFTP 协议</div>
</div>
<div class="text-center p-4 bg-slate-800 rounded-lg">
<div class="text-cyan-400 font-semibold mb-1">XTerm.js</div>
<div class="text-slate-500 text-xs">终端仿真</div>
</div>
<div class="text-center p-4 bg-slate-800 rounded-lg">
<div class="text-cyan-400 font-semibold mb-1">Zustand</div>
<div class="text-slate-500 text-xs">状态管理</div>
</div>
<div class="text-center p-4 bg-slate-800 rounded-lg">
<div class="text-cyan-400 font-semibold mb-1">Tailwind CSS</div>
<div class="text-slate-500 text-xs">样式框架</div>
</div>
<div class="text-center p-4 bg-slate-800 rounded-lg">
<div class="text-cyan-400 font-semibold mb-1">SQLite</div>
<div class="text-slate-500 text-xs">数据持久化</div>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="text-center text-slate-500 text-sm mt-8">
<p>OpsXShell v0.3.0 | 专业级 SSH/SFTP 客户端</p>
</div>
</div>
<script>
function showView(view) {
// Hide all views
const views = ['terminal', 'sftp', 'portforward', 'features'];
views.forEach(v => {
document.getElementById('view-' + v).classList.add('hidden');
});
// Reset all tab styles
views.forEach(tab => {
const el = document.getElementById('tab-' + tab);
if (el) {
el.classList.remove('bg-cyan-600', 'text-white');
el.classList.add('bg-slate-800', 'text-slate-300');
}
});
// Show selected view
document.getElementById('view-' + view).classList.remove('hidden');
document.getElementById('tab-' + view).classList.remove('bg-slate-800', 'text-slate-300');
document.getElementById('tab-' + view).classList.add('bg-cyan-600', 'text-white');
}
</script>
</body>
</html>
+118
View File
@@ -0,0 +1,118 @@
# 使用指南
## 快速连接
按 `Cmd/Ctrl + K`,输入 `user@host` 或 `user@host:port` 后连接。默认端口为 `22`。快速连接适合临时使用,不会自动保存会话。
需要长期使用时新建保存会话,选择密码或私钥认证,并按需设置分组、标签和启动命令。未填写密码或私钥的导入会话会尝试本机 SSH Agent 和常见默认密钥。
安装版支持系统链接,例如:
```text
ssh://root@opsbase.cn:55555
```
点击后会唤起 OpsXShell,并打开已填好目标的快速连接窗口。为了避免网页静默连接陌生服务器,仍需用户确认连接;链接中禁止携带密码、远程路径、查询参数或片段。
## 代理与跳板
会话的高级选项支持:
- HTTP CONNECT 代理
- SOCKS5 代理
- 可选代理用户名和密码
- 选择一个已保存会话作为跳板机
代理和跳板互斥,选择其中一个会清除另一个,避免连接路径含糊。界面保持单跳操作;Rust 连接层可处理已有配置中的最多 8 跳链。
## 端口转发与 SOCKS5
端口转发页面支持本地转发、远程转发和动态 SOCKS5。普通用户可直接点击“启动 SOCKS5”,默认创建:
```text
socks5://127.0.0.1:1080
```
- 启动后卡片显示真实绑定端口、活动连接数和上下行流量。
- 复制按钮复制 `socks5://127.0.0.1:<端口>`。需要远程 DNS 解析的命令行工具应使用 `socks5h`,例如 `curl --proxy socks5h://127.0.0.1:1080 https://example.com`。
- “停止”会关闭 Rust Listener,不是只改界面图标。删除运行中的规则会先停止再删除。
- “复制规则”会创建独立的新规则,便于只修改端口或目标;不会复制原规则的运行状态。
- 运行中的规则选择“编辑”时,会先提示停止真实监听,再允许保存新配置。
- 保存的是规则,不是“运行中”状态;应用重启后需要用户再次启动。
- SOCKS5 默认仅监听本机,当前不提供局域网开放和客户端密码认证。
## 导入 OpenSSH 配置
从应用菜单选择“文件 > 导入会话”,打开 `~/.ssh/config`。当前解析以下常用字段:
- `Host`
- `HostName`
- `User`
- `Port`
- `IdentityFile`
- 单个 `ProxyJump` 别名
包含 `*`、`?` 或 `!` 的 Host 模式不会生成会话。导入后请检查目标、用户名、密钥路径和跳板关系再连接。
会话导出为 JSON,不包含密码、私钥口令或代理密码;私钥路径会保留,便于在同一台电脑恢复配置。
## SFTP
SFTP 使用双栏文件管理器。上传和下载先写入 `.opsx-part` 临时文件,中断后再次传输同一路径会从已有字节继续。传输完成并通过大小校验后才替换正式文件,因此失败传输不会伪装成完整文件。
- 上传:在左侧本地栏选中文件后点击“上传”;未选择文件时点击“上传”会打开系统原生文件选择器。
- 下载:在右侧远程栏选中文件后点击“下载”。下载按钮在未选择远程文件时保持禁用。
- 文件右键菜单也提供对应的“上传”或“下载”操作。
- 可直接把左侧本地文件拖到右侧远程栏上传,也可把右侧远程文件拖到左侧本地栏下载;拖到目录行时以该目录为目标,并显示落点高亮。
- 当前拖拽只支持单个文件,不递归传输目录;同侧拖拽不会执行移动或重命名。
- 远程文本编辑的“打开/保存”直接读写远程文件,不会在本地创建同名文件,也不等同于上传或下载。
- 传输失败会在传输队列和界面通知中显示 Rust/SFTP 返回的具体原因。
当前目标文件采用覆盖语义。内容哈希校验和显式冲突选择仍在路线图中。
## 终端与批量命令
- `Cmd/Ctrl + D`:分屏当前终端
- `Cmd/Ctrl + W`:关闭当前标签
- 会话侧栏进入批量模式后,可同时打开终端、SFTP,或在多个主机执行同一条命令
批量命令结果只保留在当前弹窗。OpsXShell 不持久化终端命令历史。
## 本地终端与串口
快速启动入口可打开系统默认 Shell。串口入口会列出系统设备,可设置波特率、数据位、停止位和校验方式后连接终端设备。
## 大模型设置与终端助手
在“设置 → 大模型”中添加 Provider。内置预设包含 DeepSeek、Z.AI/GLM、MiniMax、Kimi、Qwen、OpenAI、Anthropic、OpenRouter、Ollama 和 new-api;也可以从空白配置开始。
1. 选择供应商预设或“自定义”。新配置只在当前表单中暂存。
2. 选择模型,核对 Endpoint,输入 API Key。协议和 HTTP 开关位于默认收起的“高级设置”。
3. 点击“测试连接”。Rust 使用当前表单实际向目标模型发送“你是什么模型?请简短回答。”,但不会保存 Provider 或 API Key。只有服务返回成功状态且回答非空时界面才显示 `HTTP 200`。
4. 测试成功后“保存”按钮才会启用。Endpoint、模型、协议或凭据变化后,测试结果立即失效,必须重新测试。
5. 保存时 API Key 进入 Rust 加密存储,普通设置 JSON 不包含明文,界面重新打开后也不会回显。
预设模型参考 models.dev 与各供应商兼容接口维护。new-api、One API、OpenRouter 等网关使用“自定义”或相应预设,填写自己的 `/v1` Endpoint 和实际模型 ID。
终端工具栏点击“AI”打开助手:
- 终端已有选区时,只发送选区。
- 没有选区时,默认不发送任何终端内容;勾选“无选区时附带当前屏幕”才会读取当前焦点分屏的可见行。
- Rust 会在联网前做常见敏感信息脱敏,面板会显示发送行数和脱敏数量。
- AI 结果只提供复制;当回答中恰好有一个单行 shell 代码块时,额外提供“填入终端”。填入不会附加回车,仍需人工检查和执行。
- 多行脚本不允许直接填入。关闭面板后问题、上下文和回答全部清空。
- “执行任务”固定复用当前已连接的 SSH 会话。AI 先准备唯一的单行远程命令,界面显示目标与命令;用户再次确认后才调用原生 `ssh-exec` 执行一次。
- 执行后的真实输出会回给 AI 生成简短结论。每次后续命令都必须重新确认,命令与输出不写入历史。
- 不提供独立 podexec。远程服务器已有的 `kubectl`、`helm`、`docker` 等工具都通过同一 SSH 执行链使用,避免额外管理本机 kubeconfig 和执行目标。
云模型会把所选上下文发给相应供应商;有严格数据边界时使用本机 Ollama,并仅在可信本机上开启 HTTP。OpsXShell 不保存 AI 对话、终端上下文或长期记忆。
## 本地数据
设置“关于”页会显示当前系统的实际数据路径。会话密码、私钥口令、代理密码和大模型 API Key 使用 AES-256-GCM 加密。主密钥位于:
```text
~/.opsxshsell/master.key
```
Unix 系统中目录权限为 `0700`,密钥文件权限为 `0600`。主机信任记录使用 `~/.ssh/known_hosts`。
-24
View File
@@ -1,24 +0,0 @@
/**
* App IPC Handlers
* 处理应用级别信息
*/
import { ipcMain, app } from 'electron'
// IPC channel names
export const APP_CHANNELS = {
GET_VERSION: 'app:getVersion',
GET_DOWNLOADS_PATH: 'app:getDownloadsPath',
} as const
export function registerAppIPCHandlers(): void {
// Get app version
ipcMain.handle(APP_CHANNELS.GET_VERSION, () => {
return app.getVersion()
})
// Get downloads directory path
ipcMain.handle(APP_CHANNELS.GET_DOWNLOADS_PATH, () => {
return app.getPath('downloads')
})
}
-38
View File
@@ -1,38 +0,0 @@
/**
* Dialog IPC Handlers
* 处理系统对话框
*/
import { ipcMain, IpcMainInvokeEvent, dialog } from 'electron'
// IPC channel names
export const DIALOG_CHANNELS = {
SHOW_SAVE: 'dialog:showSaveDialog',
SHOW_OPEN: 'dialog:showOpenDialog',
} as const
export interface DialogIPCOptions {
mainWindowGetter: () => Electron.BrowserWindow | null
}
export function registerDialogIPCHandlers(options: DialogIPCOptions): void {
const { mainWindowGetter } = options
// Show save dialog
ipcMain.handle(DIALOG_CHANNELS.SHOW_SAVE, async (_event: IpcMainInvokeEvent, options: Electron.SaveDialogOptions) => {
const mainWindow = mainWindowGetter()
if (!mainWindow) {
return { canceled: true }
}
return dialog.showSaveDialog(mainWindow, options)
})
// Show open dialog
ipcMain.handle(DIALOG_CHANNELS.SHOW_OPEN, async (_event: IpcMainInvokeEvent, options: Electron.OpenDialogOptions) => {
const mainWindow = mainWindowGetter()
if (!mainWindow) {
return { canceled: true }
}
return dialog.showOpenDialog(mainWindow, options)
})
}
-270
View File
@@ -1,270 +0,0 @@
/**
* File IPC Handlers
* 处理本地文件操作和远程文件读写
* 安全增强:防止路径遍历攻击
*/
import { ipcMain, IpcMainInvokeEvent, app } from 'electron'
import fs from 'fs'
import path from 'path'
// IPC channel names
export const FILE_CHANNELS = {
// Remote file operations (for zmodem)
READ: 'file:read',
WRITE: 'file:write',
// Local file system operations
LOCAL_GET_HOME: 'local:getHome',
LOCAL_READ_DIR: 'local:readDir',
LOCAL_GET_STATS: 'local:getStats',
LOCAL_DELETE: 'local:delete',
LOCAL_RENAME: 'local:rename',
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 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) {
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(safePath, buffer)
return { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
// Get user home directory
ipcMain.handle(FILE_CHANNELS.LOCAL_GET_HOME, async () => {
try {
const homePath = app.getPath('home')
return { success: true, path: homePath }
} 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 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
try {
stats = fs.lstatSync(fullPath)
} catch {
// Skip files we can't stat
}
return {
name: entry.name,
path: fullPath,
type: entry.isDirectory() ? 'directory' : entry.isSymbolicLink() ? 'symlink' : 'file',
size: stats?.size || 0,
modified: stats?.mtime ? Math.floor(stats.mtime.getTime() / 1000) : 0,
permissions: formatPermissions(stats?.mode || 0),
}
})
// Sort: directories first, then by name
files.sort((a, b) => {
if (a.type !== 'file' && b.type === 'file') return -1
if (a.type === 'file' && b.type !== 'file') return 1
return a.name.localeCompare(b.name)
})
return { success: true, files }
} 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 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(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) {
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 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(safePath, { recursive: true, force: true })
} else {
fs.unlinkSync(safePath)
}
return { success: true }
} 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 {
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) {
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 {
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) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
}
// Helper function to format Unix permissions
function formatPermissions(mode: number): string {
const perms: string[] = []
const types = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx']
// Owner
perms.push(types[(mode >> 6) & 7])
// Group
perms.push(types[(mode >> 3) & 7])
// Other
perms.push(types[mode & 7])
return perms.join('')
}
-81
View File
@@ -1,81 +0,0 @@
/**
* IPC Handler Registration
* 统一注册所有 IPC 处理器
*/
import type { SSHService } from '../services/ssh-service'
import type { DatabaseService } from '../services/database-service'
import type { ZmodemService } from '../services/zmodem-service'
import type { BrowserWindow } from 'electron'
import { createLogger } from '../utils/logger'
const logger = createLogger('IPC')
import { registerSSHIPCHandlers } from './ssh.ipc'
import { registerSFTPIPCHandlers } from './sftp.ipc'
import { registerSessionIPCHandlers } from './session.ipc'
import { registerSettingsIPCHandlers } from './settings.ipc'
import { registerDialogIPCHandlers } from './dialog.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
databaseService: DatabaseService
zmodemService: ZmodemService
mainWindowGetter: () => BrowserWindow | null
}
export function registerAllIPCHandlers(options: IPCOptions): void {
const { sshService, databaseService, zmodemService, mainWindowGetter } = options
// SSH handlers
registerSSHIPCHandlers({ sshService, zmodemService, mainWindowGetter })
// SFTP handlers
registerSFTPIPCHandlers({ sshService, mainWindowGetter })
// Session handlers
registerSessionIPCHandlers({ dbService: databaseService })
// Settings handlers
registerSettingsIPCHandlers({ dbService: databaseService })
// Dialog handlers
registerDialogIPCHandlers({ mainWindowGetter })
// File handlers (初始化路径白名单)
initializeAllowedPaths()
registerFileIPCHandlers()
// Zmodem handlers
registerZmodemIPCHandlers({ sshService, zmodemService, mainWindowGetter })
// 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')
}
// Re-export channel constants for external use
export { SSH_CHANNELS } from './ssh.ipc'
export { SFTP_CHANNELS } from './sftp.ipc'
export { SESSION_CHANNELS } from './session.ipc'
export { SETTINGS_CHANNELS } from './settings.ipc'
export { DIALOG_CHANNELS } from './dialog.ipc'
export { FILE_CHANNELS } from './file.ipc'
export { ZMODEM_CHANNELS } from './zmodem.ipc'
export { APP_CHANNELS } from './app.ipc'
-75
View File
@@ -1,75 +0,0 @@
/**
* Session IPC Handlers
* 处理会话的 CRUD 操作
*/
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 = {
CREATE: 'session:create',
GET_ALL: 'session:getAll',
GET_BY_ID: 'session:getById',
UPDATE: 'session:update',
DELETE: 'session:delete',
} as const
export interface SessionIPCOptions {
dbService: DatabaseService
}
export function registerSessionIPCHandlers(options: SessionIPCOptions): void {
const { dbService } = options
// Create session
ipcMain.handle(SESSION_CHANNELS.CREATE, async (_event: IpcMainInvokeEvent, session) => {
try {
const result = dbService.createSession(session)
return { success: true, session: result }
} catch (error: unknown) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
// Get all sessions
ipcMain.handle(SESSION_CHANNELS.GET_ALL, async () => {
try {
const sessions = dbService.getAllSessions()
return { success: true, sessions }
} catch (error: unknown) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
// Get session by ID
ipcMain.handle(SESSION_CHANNELS.GET_BY_ID, async (_event: IpcMainInvokeEvent, id: string) => {
try {
const session = dbService.getSessionById(id)
return { success: true, session }
} 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: SessionUpdateRequest) => {
try {
const result = dbService.updateSession(id, updates)
return { success: true, session: result }
} catch (error: unknown) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
// Delete session
ipcMain.handle(SESSION_CHANNELS.DELETE, async (_event: IpcMainInvokeEvent, id: string) => {
try {
dbService.deleteSession(id)
return { success: true }
} catch (error: unknown) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
}
-41
View File
@@ -1,41 +0,0 @@
/**
* Settings IPC Handlers
* 处理应用设置读写
*/
import { ipcMain, IpcMainInvokeEvent } from 'electron'
import type { DatabaseService } from '../services/database-service'
// IPC channel names
export const SETTINGS_CHANNELS = {
GET: 'settings:get',
SET: 'settings:set',
} as const
export interface SettingsIPCOptions {
dbService: DatabaseService
}
export function registerSettingsIPCHandlers(options: SettingsIPCOptions): void {
const { dbService } = options
// Get setting
ipcMain.handle(SETTINGS_CHANNELS.GET, async (_event: IpcMainInvokeEvent, key: string) => {
try {
const value = dbService.getSetting(key)
return { success: true, value }
} catch (error: unknown) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
// Set setting
ipcMain.handle(SETTINGS_CHANNELS.SET, async (_event: IpcMainInvokeEvent, key: string, value: unknown) => {
try {
dbService.setSetting(key, value)
return { success: true }
} catch (error: unknown) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
}
-249
View File
@@ -1,249 +0,0 @@
/**
* SFTP IPC Handlers
* 处理 SFTP 连接和文件操作相关 IPC
*/
import { ipcMain, IpcMainInvokeEvent } from 'electron'
import type { SSHService } from '../services/ssh-service'
// IPC channel names
export const SFTP_CHANNELS = {
CONNECT: 'sftp:connect',
LIST_DIRECTORY: 'sftp:listDirectory',
UPLOAD: 'sftp:upload',
DOWNLOAD: 'sftp:download',
RENAME: 'sftp:rename',
DELETE: 'sftp:delete',
MKDIR: 'sftp:mkdir',
STAT: 'sftp:stat',
CHMOD: 'sftp:chmod',
PROGRESS: 'sftp:progress',
} as const
export interface SFTPIPCOptions {
sshService: SSHService
mainWindowGetter: () => Electron.BrowserWindow | null
}
// ===== 彩色调试日志 =====
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: 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) {
console.log(`%c✓ ${msg}`, 'color: #27ae60; font-weight: bold;')
}
function logError(msg: string) {
console.log(`%c✗ ${msg}`, 'color: #e74c3c; font-weight: bold;')
}
export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
const { sshService, mainWindowGetter } = options
// SFTP connect
ipcMain.handle(SFTP_CHANNELS.CONNECT, async (_event: IpcMainInvokeEvent, connectionId: string) => {
logHeader('📁 SFTP IPC CONNECT')
logInfo('connectionId', connectionId)
try {
const sftp = await sshService.createSFTP(connectionId)
logSuccess(`SFTP connected: ${sftp.id}`)
return { success: true, sftpId: sftp.id }
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logError(`SFTP connect failed: ${message}`)
return { success: false, error: message }
}
})
// SFTP list directory
ipcMain.handle(SFTP_CHANNELS.LIST_DIRECTORY, async (_event: IpcMainInvokeEvent, sftpId: string, dirPath: string) => {
logHeader('📁 SFTP IPC LIST')
logInfo('sftpId', sftpId)
logInfo('dirPath', dirPath)
try {
const files = await sshService.sftpListDirectory(sftpId, dirPath)
logSuccess(`Listed ${files.length} items`)
return { success: true, files }
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logError(`List failed: ${message}`)
return { success: false, error: message }
}
})
// SFTP upload file
ipcMain.handle(SFTP_CHANNELS.UPLOAD, async (
_event: IpcMainInvokeEvent,
sftpId: string,
localPath: string,
remotePath: string
) => {
logHeader('📤 SFTP IPC UPLOAD')
logInfo('sftpId', sftpId)
logInfo('localPath', localPath)
logInfo('remotePath', remotePath)
try {
const mainWindow = mainWindowGetter()
await sshService.sftpUpload(sftpId, localPath, remotePath, (transferred, total) => {
mainWindow?.webContents.send(SFTP_CHANNELS.PROGRESS, sftpId, {
transferred,
total,
percentage: Math.round((transferred / total) * 100),
})
})
logSuccess('Upload complete')
return { success: true }
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logError(`Upload failed: ${message}`)
return { success: false, error: message }
}
})
// SFTP download file
ipcMain.handle(SFTP_CHANNELS.DOWNLOAD, async (
_event: IpcMainInvokeEvent,
sftpId: string,
remotePath: string,
localPath: string
) => {
logHeader('📥 SFTP IPC DOWNLOAD')
logInfo('sftpId', sftpId)
logInfo('remotePath', remotePath)
logInfo('localPath', localPath)
try {
const mainWindow = mainWindowGetter()
await sshService.sftpDownload(sftpId, remotePath, localPath, (transferred, total) => {
mainWindow?.webContents.send(SFTP_CHANNELS.PROGRESS, sftpId, {
transferred,
total,
percentage: Math.round((transferred / total) * 100),
})
})
logSuccess('Download complete')
return { success: true }
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logError(`Download failed: ${message}`)
return { success: false, error: message }
}
})
// SFTP rename
ipcMain.handle(SFTP_CHANNELS.RENAME, async (
_event: IpcMainInvokeEvent,
sftpId: string,
oldPath: string,
newPath: string
) => {
logHeader('📁 SFTP IPC RENAME')
logInfo('sftpId', sftpId)
logInfo('oldPath', oldPath)
logInfo('newPath', newPath)
try {
await sshService.sftpRename(sftpId, oldPath, newPath)
logSuccess('Rename complete')
return { success: true }
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logError(`Rename failed: ${message}`)
return { success: false, error: message }
}
})
// SFTP delete
ipcMain.handle(SFTP_CHANNELS.DELETE, async (
_event: IpcMainInvokeEvent,
sftpId: string,
filePath: string,
isDirectory: boolean
) => {
logHeader('📁 SFTP IPC DELETE')
logInfo('sftpId', sftpId)
logInfo('filePath', filePath)
logInfo('isDirectory', isDirectory)
try {
await sshService.sftpDelete(sftpId, filePath, isDirectory)
logSuccess('Delete complete')
return { success: true }
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logError(`Delete failed: ${message}`)
return { success: false, error: message }
}
})
// SFTP mkdir
ipcMain.handle(SFTP_CHANNELS.MKDIR, async (
_event: IpcMainInvokeEvent,
sftpId: string,
dirPath: string
) => {
logHeader('📁 SFTP IPC MKDIR')
logInfo('sftpId', sftpId)
logInfo('dirPath', dirPath)
try {
await sshService.sftpMkdir(sftpId, dirPath)
logSuccess('Mkdir complete')
return { success: true }
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logError(`Mkdir failed: ${message}`)
return { success: false, error: message }
}
})
// SFTP stat
ipcMain.handle(SFTP_CHANNELS.STAT, async (
_event: IpcMainInvokeEvent,
sftpId: string,
filePath: string
) => {
logHeader('📋 SFTP IPC STAT')
logInfo('sftpId', sftpId)
logInfo('filePath', filePath)
try {
const stats = await sshService.sftpStat(sftpId, filePath)
logSuccess(`Stat complete: ${stats.size} bytes`)
return { success: true, stats }
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logError(`Stat failed: ${message}`)
return { success: false, error: message }
}
})
// SFTP chmod
ipcMain.handle(SFTP_CHANNELS.CHMOD, async (
_event: IpcMainInvokeEvent,
sftpId: string,
filePath: string,
mode: number
) => {
logHeader('🔒 SFTP IPC CHMOD')
logInfo('sftpId', sftpId)
logInfo('filePath', filePath)
logInfo('mode', mode.toString(8))
try {
await sshService.sftpChmod(sftpId, filePath, mode)
logSuccess('Chmod complete')
return { success: true }
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logError(`Chmod failed: ${message}`)
return { success: false, error: message }
}
})
}
-220
View File
@@ -1,220 +0,0 @@
/**
* SSH IPC Handlers
* 处理 SSH 连接、Shell、会话、端口转发相关 IPC
* 安全增强:规范化错误处理,不暴露敏感信息
*/
import { ipcMain, IpcMainInvokeEvent } from 'electron'
import type { SSHService } from '../services/ssh-service'
import type { ZmodemService } from '../services/zmodem-service'
import { createLogger } from '../utils/logger'
const logger = createLogger('SSH_IPC')
// 生产环境日志:使用结构化日志,不输出敏感信息
const DEBUG = process.env.NODE_ENV === 'development'
function debug(...args: unknown[]) {
if (DEBUG) logger.debug('[SSH_IPC]', ...args)
}
function logHeader(msg: string) {
logger.info(`[${msg}]`)
}
function logInfo(label: string, value: unknown) {
logger.info(`${label}: ${value}`)
}
function logSuccess(msg: string) {
logger.info(`[OK] ${msg}`)
}
function logError(msg: string) {
logger.error(msg)
}
// IPC channel names
export const SSH_CHANNELS = {
CONNECT: 'ssh:connect',
DISCONNECT: 'ssh:disconnect',
EXEC: 'ssh:exec',
CREATE_SHELL: 'ssh:createShell',
SHELL_WRITE: 'ssh:shellWrite',
SHELL_RESIZE: 'ssh:shellResize',
FORWARD_LOCAL: 'ssh:forwardLocal',
FORWARD_REMOTE: 'ssh:forwardRemote',
FORWARD_DYNAMIC: 'ssh:forwardDynamic',
SHELL_DATA: 'ssh:shellData',
} as const
export interface SSHIPCOptions {
sshService: SSHService
zmodemService: ZmodemService
mainWindowGetter: () => Electron.BrowserWindow | null
}
export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
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) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
// SSH disconnect
ipcMain.handle(SSH_CHANNELS.DISCONNECT, async (_event: IpcMainInvokeEvent, connectionId: string) => {
try {
await sshService.disconnect(connectionId)
return { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
// SSH exec command
ipcMain.handle(SSH_CHANNELS.EXEC, async (_event: IpcMainInvokeEvent, connectionId: string, command: string) => {
try {
const result = await sshService.exec(connectionId, command)
return { success: true, result }
} 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) => {
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()) {
debug('SHELL DATA CALLBACK - SKIPPED (sender destroyed)')
return
}
debug('SHELL DATA CALLBACK shellId:', shellId, 'data length:', data.length)
try {
debug('>>> Sending SHELL_DATA to renderer...')
event.sender.send(SSH_CHANNELS.SHELL_DATA, connectionId, shellId, data)
debug('>>> SHELL_DATA sent successfully')
} catch (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) {
logger.error('[SSH_IPC] Shell creation failed:', error)
const message = error instanceof Error ? error.message : String(error)
debug('>>> Shell creation FAILED:', message)
return { success: false, error: message }
}
})
// SSH shell write
ipcMain.handle(SSH_CHANNELS.SHELL_WRITE, async (_event: IpcMainInvokeEvent, shellId: string, data: string) => {
try {
sshService.shellWrite(shellId, data)
return { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
// SSH shell resize
ipcMain.handle(SSH_CHANNELS.SHELL_RESIZE, async (_event: IpcMainInvokeEvent, shellId: string, cols: number, rows: number) => {
try {
sshService.shellResize(shellId, cols, rows)
return { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
// SSH local port forward
ipcMain.handle(SSH_CHANNELS.FORWARD_LOCAL, async (
_event: IpcMainInvokeEvent,
connectionId: string,
localPort: number,
remoteHost: string,
remotePort: number
) => {
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) {
const message = error instanceof Error ? error.message : String(error)
logError(`Local forward failed: ${message}`)
return { success: false, error: message }
}
})
// SSH remote port forward
ipcMain.handle(SSH_CHANNELS.FORWARD_REMOTE, async (
_event: IpcMainInvokeEvent,
connectionId: string,
remotePort: number,
localHost: string,
localPort: number
) => {
logHeader('FORWARD_REMOTE')
logInfo('connectionId', connectionId)
logInfo('remotePort', remotePort)
logInfo('localHost', localHost)
logInfo('localPort', localPort)
try {
await sshService.forwardRemote(connectionId, remotePort, localHost, localPort)
logSuccess('Remote forward established')
return { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logError(`Remote forward failed: ${message}`)
return { success: false, error: message }
}
})
// SSH dynamic port forward (SOCKS5)
ipcMain.handle(SSH_CHANNELS.FORWARD_DYNAMIC, async (
_event: IpcMainInvokeEvent,
connectionId: string,
localPort: number
) => {
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) {
const message = error instanceof Error ? error.message : String(error)
logError(`Dynamic forward failed: ${message}`)
return { success: false, error: message }
}
})
}
-141
View File
@@ -1,141 +0,0 @@
/**
* Zmodem IPC Handlers
* 处理 rz/sz 文件传输
*/
import { ipcMain, IpcMainInvokeEvent, app, dialog } from 'electron'
import fs from 'fs'
import path from 'path'
import type { SSHService } from '../services/ssh-service'
import type { ZmodemService } from '../services/zmodem-service'
import { createLogger } from '../utils/logger'
const logger = createLogger('Zmodem_IPC')
// IPC channel names
export const ZMODEM_CHANNELS = {
SEND: 'zmodem:send',
RECEIVE: 'zmodem:receive',
CANCEL: 'zmodem:cancel',
PROGRESS: 'zmodem:progress',
COMPLETE: 'zmodem:complete',
} as const
export interface ZmodemIPCOptions {
sshService: SSHService
zmodemService: ZmodemService
mainWindowGetter: () => Electron.BrowserWindow | null
}
export function registerZmodemIPCHandlers(options: ZmodemIPCOptions): void {
const { sshService, zmodemService, mainWindowGetter } = options
// Zmodem send (rz) - send file to server
ipcMain.handle(ZMODEM_CHANNELS.SEND, async (_event: IpcMainInvokeEvent, shellId: string) => {
try {
const mainWindow = mainWindowGetter()
if (!mainWindow) {
return { success: false, error: 'Window not available' }
}
// Open file dialog to select file
const result = await dialog.showOpenDialog(mainWindow, {
title: '选择要发送的文件',
properties: ['openFile'],
})
if (result.canceled || !result.filePaths.length) {
return { success: false, canceled: true }
}
const filePath = result.filePaths[0]
const fileName = path.basename(filePath)
// Read file
const fileData = fs.readFileSync(filePath)
// Get shell write function
const shell = sshService.getShell(shellId)
if (!shell) {
return { success: false, error: 'Shell not found' }
}
// Send file via Zmodem protocol
const sessionId = zmodemService.sendFile(
shellId,
fileName,
fileData,
(data) => {
shell.channel.write(data)
},
(progress) => {
mainWindow.webContents.send(ZMODEM_CHANNELS.PROGRESS, sessionId, progress)
},
(success, error) => {
mainWindow.webContents.send(ZMODEM_CHANNELS.COMPLETE, sessionId, { success, error })
}
)
return { success: true, sessionId, fileName }
} catch (error: unknown) {
logger.error('Zmodem send error:', error)
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
// Zmodem receive (sz) - receive file from server
ipcMain.handle(ZMODEM_CHANNELS.RECEIVE, async (_event: IpcMainInvokeEvent, shellId: string) => {
try {
const mainWindow = mainWindowGetter()
if (!mainWindow) {
return { success: false, error: 'Window not available' }
}
// Get shell to monitor for incoming data
const shell = sshService.getShell(shellId)
if (!shell) {
return { success: false, error: 'Shell not found' }
}
// Open save dialog to select destination
const result = await dialog.showSaveDialog(mainWindow, {
title: '保存文件',
defaultPath: app.getPath('downloads'),
})
if (result.canceled || !result.filePath) {
return { success: false, canceled: true }
}
// Start Zmodem receive session
const sessionId = zmodemService.receiveFile(
shellId,
result.filePath,
(data) => {
shell.channel.write(data)
},
(progress) => {
mainWindow.webContents.send(ZMODEM_CHANNELS.PROGRESS, sessionId, progress)
},
(success, error) => {
mainWindow.webContents.send(ZMODEM_CHANNELS.COMPLETE, sessionId, { success, error })
}
)
return { success: true, sessionId }
} catch (error: unknown) {
logger.error('Zmodem receive error:', error)
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
// Zmodem cancel transfer
ipcMain.handle(ZMODEM_CHANNELS.CANCEL, async (_event: IpcMainInvokeEvent, sessionId: string) => {
try {
zmodemService.cancelSession(sessionId)
return { success: true }
} catch (error: unknown) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
}
-280
View File
@@ -1,280 +0,0 @@
/**
* OpsXShell - Main Process Entry
* 跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠
*/
import { app, BrowserWindow, Menu, shell, dialog } from 'electron'
import path from 'path'
import { createLogger } from './utils/logger'
const logger = createLogger('Main')
// Services
import { SSHService } from './services/ssh-service'
import { DatabaseService } from './services/database-service'
import { keyService } from './services/key-service'
import { ZmodemService } from './services/zmodem-service'
// IPC Handlers
import { registerAllIPCHandlers } from './ipc'
// ============== Global State ==============
let mainWindow: BrowserWindow | null = null
// Services (initialized after app ready)
let sshService: SSHService
let zmodemService: ZmodemService
let dbService: DatabaseService | null = null
// ============== Window Management ==============
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 1000,
minHeight: 600,
titleBarStyle: 'hiddenInset',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
show: false,
backgroundColor: '#0f172a',
})
// Load the app
if (process.env.VITE_DEV_SERVER_URL) {
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL)
// DevTools 通过快捷键 Cmd+Option+I 打开,不自动调用
} else {
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'))
}
// Show window when ready
mainWindow.once('ready-to-show', () => {
mainWindow?.show()
})
// Handle window closed
mainWindow.on('closed', () => {
mainWindow = null
})
// Open external links in browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
// Set up application menu
createApplicationMenu()
}
// ============== Application Menu ==============
function createApplicationMenu(): void {
const template: Electron.MenuItemConstructorOptions[] = [
{
label: 'OpsXShell',
submenu: [
{
label: '关于 OpsXShell',
click: () => {
dialog.showMessageBox(mainWindow!, {
type: 'info',
title: '关于 OpsXShell',
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',
click: () => {
app.quit()
},
},
],
},
{
label: '会话',
submenu: [
{
label: '新建会话',
accelerator: 'CmdOrCtrl+N',
click: () => {
mainWindow?.webContents.send('menu-new-session')
},
},
{
label: '快速连接',
accelerator: 'CmdOrCtrl+K',
click: () => {
mainWindow?.webContents.send('menu-quick-connect')
},
},
{ type: 'separator' },
{
label: '导入会话',
click: () => {
mainWindow?.webContents.send('menu-import-sessions')
},
},
{
label: '导出会话',
click: () => {
mainWindow?.webContents.send('menu-export-sessions')
},
},
],
},
{
label: '编辑',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
{
label: '视图',
submenu: [
{
label: '重新加载',
accelerator: 'CmdOrCtrl+R',
click: () => {
mainWindow?.webContents.reload()
},
},
{
label: '切换开发者工具',
accelerator: process.platform === 'darwin' ? 'Alt+Cmd+I' : 'F12',
click: () => {
mainWindow?.webContents.toggleDevTools()
},
},
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: '窗口',
submenu: [
{ role: 'minimize' },
{ role: 'close' },
],
},
{
label: '帮助',
submenu: [
{
label: '文档',
click: () => {
shell.openExternal('https://github.com/opsxshell/docs')
},
},
{
label: '报告问题',
click: () => {
shell.openExternal('https://github.com/opsxshell/issues')
},
},
],
},
]
const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
}
// ============== App Lifecycle ==============
app.whenReady().then(async () => {
// Initialize encryption key first
keyService.getMasterKey()
logger.info('[App] Encryption key initialized')
// Initialize services
sshService = new SSHService()
zmodemService = new ZmodemService()
dbService = new DatabaseService()
await dbService.initialize()
// Register IPC handlers
registerAllIPCHandlers({
sshService,
databaseService: dbService,
zmodemService,
mainWindowGetter: () => mainWindow,
})
logger.info('[App] IPC handlers registered')
// Create main window
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
// Close all SSH connections
sshService?.disconnectAll()
// Close database
dbService?.close()
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('before-quit', () => {
sshService?.disconnectAll()
dbService?.close()
})
-145
View File
@@ -1,145 +0,0 @@
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: 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: (): 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): 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) =>
ipcRenderer.invoke('ssh:forwardLocal', connectionId, localPort, remoteHost, remotePort),
sshForwardRemote: (connectionId: string, remotePort: number, localHost: string, localPort: number) =>
ipcRenderer.invoke('ssh:forwardRemote', connectionId, remotePort, localHost, localPort),
sshForwardDynamic: (connectionId: string, localPort: number) =>
ipcRenderer.invoke('ssh:forwardDynamic', connectionId, localPort),
// 设置
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: 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),
writeFile: (filePath: string, data: string) => ipcRenderer.invoke('file:write', filePath, data),
// 本地文件系统
getHomePath: () => ipcRenderer.invoke('local:getHome'),
getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'),
localReadDir: (dirPath: string) => ipcRenderer.invoke('local:readDir', dirPath),
localGetStats: (filePath: string) => ipcRenderer.invoke('local:getStats', filePath),
localDelete: (filePath: string) => ipcRenderer.invoke('local:delete', filePath),
localRename: (oldPath: string, newPath: string) => ipcRenderer.invoke('local:rename', oldPath, newPath),
localMkdir: (dirPath: string) => ipcRenderer.invoke('local:mkdir', dirPath),
// 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: Electron.IpcRendererEvent, sessionId: string, progress: ZmodemProgress) => void) => {
ipcRenderer.on('zmodem:progress', callback)
},
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)
},
onMenuQuickConnect: (callback: () => void) => {
ipcRenderer.on('menu-quick-connect', callback)
},
onMenuImportSessions: (callback: () => void) => {
ipcRenderer.on('menu-import-sessions', callback)
},
onMenuExportSessions: (callback: () => void) => {
ipcRenderer.on('menu-export-sessions', callback)
},
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: Electron.IpcRendererEvent, sftpId: string, progress: { transferred: number; total: number; percentage: number }) => void) => {
ipcRenderer.on('sftp:progress', callback)
},
removeListener: (channel: string, callback: (...args: unknown[]) => void) => {
ipcRenderer.removeListener(channel, callback)
},
removeAllListeners: (channel: string) => {
ipcRenderer.removeAllListeners(channel)
}
}
contextBridge.exposeInMainWorld('electronAPI', api)
-180
View File
@@ -1,180 +0,0 @@
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 SALT_LENGTH = 32
// OWASP 2023 建议至少 600,000 次(针对 SHA256)
const ITERATIONS = 600000
const CRYPTO_VERSION = 'v1'
// 异步 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')
}
// 生成随机密钥(用于主密钥)
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 = 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()
// 立即清零密钥内存
key.fill(0)
// 格式: version:salt:iv:tag:encrypted (全部 hex)
return [
CRYPTO_VERSION,
salt.toString('hex'),
iv.toString('hex'),
tag.toString('hex'),
encrypted.toString('hex'),
].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 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')
}
const salt = Buffer.from(saltHex, 'hex')
const iv = Buffer.from(ivHex, 'hex')
const tag = Buffer.from(tagHex, 'hex')
const encrypted = Buffer.from(encryptedHex, 'hex')
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)
}
}
/**
* 简单加密(使用主密钥,不经过 PBKDF2)
* 用于内部数据加密,密钥长度必须正确
*/
export function simpleEncrypt(plaintext: string, key: string): string {
const iv = crypto.randomBytes(IV_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()])
const tag = cipher.getAuthTag()
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')
// 验证密钥长度
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)
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
return decrypted.toString('utf8')
} catch (error) {
throw new Error('Decryption failed: ' + (error as Error).message)
}
}
-402
View File
@@ -1,402 +0,0 @@
import initSqlJs, { Database as SqlJsDatabase } from 'sql.js'
import path from 'path'
import { app } from 'electron'
import fs from 'fs'
import { simpleEncrypt, simpleDecrypt } from './crypto'
import { keyService } from './key-service'
import { createLogger } from '../utils/logger'
import type { Session, SessionCreateRequest, SessionUpdateRequest } from '../../shared/types/session.types'
const logger = createLogger('DatabaseService')
// sql.js statement wrapper to match better-sqlite3 API
interface StatementResult {
id: number
[key: string]: unknown
}
class PreparedStatement {
private db: SqlJsDatabase
private sql: string
constructor(db: SqlJsDatabase, sql: string) {
this.db = db
this.sql = sql
}
run(...params: unknown[]): StatementResult {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.db.run(this.sql, params as any[])
return { id: 0 }
}
get(...params: unknown[]): unknown {
// 使用 exec 模式获取单行
const stmt = this.db.prepare(this.sql)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stmt.bind(params as any[])
let result: unknown = null
if (stmt.step()) {
result = stmt.getAsObject()
}
stmt.free()
return result
}
all(...params: unknown[]): unknown[] {
const stmt = this.db.prepare(this.sql)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stmt.bind(params as any[])
const results: unknown[] = []
while (stmt.step()) {
results.push(stmt.getAsObject())
}
stmt.free()
return results
}
}
export class DatabaseService {
private db: SqlJsDatabase | null = null
private dbPath: string
private masterKey: string | null = null
private initialized = false
constructor() {
const userDataPath = app.getPath('userData')
this.dbPath = path.join(userDataPath, 'opsxshell.db')
}
async initialize(): Promise<void> {
try {
const dbDir = path.dirname(this.dbPath)
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true })
}
this.masterKey = keyService.getMasterKey()
// 初始化 sql.js (WASM, 纯 JS 无需原生编译)
const SQL = await initSqlJs()
// 如果已有数据库文件,加载它
if (fs.existsSync(this.dbPath)) {
const buffer = fs.readFileSync(this.dbPath)
this.db = new SQL.Database(buffer)
} else {
this.db = new SQL.Database()
}
this.createTables()
this.save()
this.initialized = true
logger.info('Database initialized at:', this.dbPath)
} catch (error) {
logger.error('Failed to initialize database:', error)
throw error
}
}
private createTables(): void {
if (!this.db) return
this.db.run(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER DEFAULT 22,
username TEXT NOT NULL,
auth_type TEXT CHECK(auth_type IN ('password', 'key', 'agent')),
password TEXT,
private_key_path TEXT,
passphrase_encrypted TEXT,
group_name TEXT DEFAULT 'default',
tags TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
// 迁移:如果 password 列不存在,添加它
try {
this.db.run(`ALTER TABLE sessions ADD COLUMN password TEXT`)
} catch (e) {
// 列可能已存在,忽略错误
}
// 迁移:添加 proxy 列 (JSON 格式存储代理配置)
try {
this.db.run(`ALTER TABLE sessions ADD COLUMN proxy TEXT`)
} catch (e) {
// 列可能已存在,忽略错误
}
this.db.run(`
CREATE TABLE IF NOT EXISTS connection_history (
id TEXT PRIMARY KEY,
session_id TEXT REFERENCES sessions(id),
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
disconnected_at DATETIME,
duration INTEGER,
status TEXT
)
`)
this.db.run(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
this.db.run(`
CREATE TABLE IF NOT EXISTS keys (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT CHECK(type IN ('rsa', 'ed25519', 'ecdsa')),
private_key_encrypted TEXT NOT NULL,
public_key TEXT,
passphrase_encrypted TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
this.db.run(`
CREATE TABLE IF NOT EXISTS port_forwards (
id TEXT PRIMARY KEY,
session_id TEXT REFERENCES sessions(id),
type TEXT CHECK(type IN ('local', 'remote', 'dynamic')),
local_host TEXT,
local_port INTEGER,
remote_host TEXT,
remote_port INTEGER,
is_active BOOLEAN DEFAULT FALSE
)
`)
}
// 持久化到磁盘
private save(): void {
if (!this.db) return
try {
const data = this.db.export()
const buffer = Buffer.from(data)
fs.writeFileSync(this.dbPath, buffer)
} catch (error) {
logger.error('Failed to save database:', error)
}
}
private prepare(sql: string): PreparedStatement {
if (!this.db) throw new Error('Database not initialized')
return new PreparedStatement(this.db, sql)
}
// 加密字段
private encryptField(value: string | null | undefined): string | null {
if (!value) return null
if (!this.masterKey) throw new Error('Master key not initialized')
return simpleEncrypt(value, this.masterKey)
}
// 解密字段
private decryptField(value: string | null | undefined): string | null {
if (!value) return null
if (!this.masterKey) {
logger.error('Decrypt failed: Master key not initialized')
return null
}
try {
const decrypted = simpleDecrypt(value, this.masterKey)
return decrypted
} catch (error) {
logger.error('Decrypt failed:', error)
return null
}
}
// 会话 CRUD 操作
createSession(session: SessionCreateRequest): Session | null {
if (!this.db) throw new Error('Database not initialized')
logger.info('Creating session:', session.name, session.host)
const stmt = this.prepare(`
INSERT INTO sessions (id, name, host, port, username, auth_type, password, private_key_path, passphrase_encrypted, group_name, tags, proxy)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
const id = `session-${Date.now()}`
const encryptedPassword = this.encryptField(session.password)
stmt.run(
id,
session.name,
session.host,
session.port || 22,
session.username,
session.authType,
encryptedPassword,
session.privateKey || null,
this.encryptField(session.passphrase),
session.group || 'default',
session.tags ? JSON.stringify(session.tags) : null,
session.proxy ? JSON.stringify(session.proxy) : null
)
this.save()
return this.getSessionById(id)
}
getAllSessions(): Session[] {
if (!this.db) throw new Error('Database not initialized')
const stmt = this.prepare('SELECT * FROM sessions ORDER BY created_at DESC')
const sessions = stmt.all() as Record<string, unknown>[]
return sessions.map((s) => ({
id: s.id as string,
name: s.name as string,
host: s.host as string,
port: s.port as number,
username: s.username as string,
authType: s.auth_type as Session['authType'],
password: this.decryptField(s.password as string | null) || undefined,
privateKey: s.private_key_path as string | undefined,
passphrase: this.decryptField(s.passphrase_encrypted as string | null) || undefined,
proxy: s.proxy ? JSON.parse(s.proxy as string) as Session['proxy'] : undefined,
group: s.group_name as string | undefined,
tags: s.tags ? JSON.parse(s.tags as string) as string[] : [],
}))
}
getSessionById(id: string): Session | null {
if (!this.db) throw new Error('Database not initialized')
const stmt = this.prepare('SELECT * FROM sessions WHERE id = ?')
const session = stmt.get(id) as Record<string, unknown> | undefined
if (session) {
return {
id: session.id as string,
name: session.name as string,
host: session.host as string,
port: session.port as number,
username: session.username as string,
authType: session.auth_type as Session['authType'],
password: this.decryptField(session.password as string | null) || undefined,
privateKey: session.private_key_path as string | undefined,
passphrase: this.decryptField(session.passphrase_encrypted as string | null) || undefined,
proxy: session.proxy ? JSON.parse(session.proxy as string) as Session['proxy'] : undefined,
group: session.group_name as string | undefined,
tags: session.tags ? JSON.parse(session.tags as string) as string[] : [],
}
}
return null
}
updateSession(id: string, updates: SessionUpdateRequest): Session | null {
if (!this.db) throw new Error('Database not initialized')
const fields: string[] = []
const values: unknown[] = []
if (updates.name !== undefined) {
fields.push('name = ?')
values.push(updates.name)
}
if (updates.host !== undefined) {
fields.push('host = ?')
values.push(updates.host)
}
if (updates.port !== undefined) {
fields.push('port = ?')
values.push(updates.port)
}
if (updates.username !== undefined) {
fields.push('username = ?')
values.push(updates.username)
}
if (updates.authType !== undefined) {
fields.push('auth_type = ?')
values.push(updates.authType)
}
if (updates.password !== undefined) {
fields.push('password = ?')
values.push(this.encryptField(updates.password))
}
if (updates.passphrase !== undefined) {
fields.push('passphrase_encrypted = ?')
values.push(this.encryptField(updates.passphrase))
}
if (updates.privateKey !== undefined) {
fields.push('private_key_path = ?')
values.push(updates.privateKey || null)
}
if (updates.group !== undefined) {
fields.push('group_name = ?')
values.push(updates.group)
}
if (updates.tags !== undefined) {
fields.push('tags = ?')
values.push(JSON.stringify(updates.tags))
}
if (updates.proxy !== undefined) {
fields.push('proxy = ?')
values.push(updates.proxy ? JSON.stringify(updates.proxy) : null)
}
fields.push('updated_at = CURRENT_TIMESTAMP')
values.push(id)
const stmt = this.prepare(`
UPDATE sessions SET ${fields.join(', ')} WHERE id = ?
`)
stmt.run(...values)
this.save()
return this.getSessionById(id)
}
deleteSession(id: string): void {
if (!this.db) throw new Error('Database not initialized')
const stmt = this.prepare('DELETE FROM sessions WHERE id = ?')
stmt.run(id)
this.save()
}
// 设置操作
getSetting(key: string): unknown {
if (!this.db) throw new Error('Database not initialized')
const stmt = this.prepare('SELECT value FROM settings WHERE key = ?')
const row = stmt.get(key) as Record<string, unknown> | undefined
return row ? JSON.parse(row.value as string) : null
}
setSetting(key: string, value: unknown): void {
if (!this.db) throw new Error('Database not initialized')
const stmt = this.prepare(`
INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = CURRENT_TIMESTAMP
`)
stmt.run(key, JSON.stringify(value))
this.save()
}
close(): void {
if (this.db) {
this.save()
this.db.close()
this.db = null
}
}
}
-91
View File
@@ -1,91 +0,0 @@
import { app, safeStorage } from 'electron'
import path from 'path'
import fs from 'fs'
import crypto from 'crypto'
import { createLogger } from '../utils/logger'
const logger = createLogger('KeyService')
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 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 {
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 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')
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
}
}
// 全局单例
export const keyService = new KeyService()
-997
View File
@@ -1,997 +0,0 @@
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'
import type { ProxyConfig } from '../../shared/types/session.types'
const logger = createLogger('SSHService')
// 生产环境日志:使用结构化日志,不输出敏感信息
const DEBUG = process.env.NODE_ENV === 'development'
function debug(...args: unknown[]) {
if (DEBUG) logger.debug('[SSHService]', ...args)
}
function logHeader(msg: string) {
logger.info(`[${msg}]`)
}
function logInfo(label: string, value: unknown) {
logger.info(`${label}: ${value}`)
}
function logSuccess(msg: string) {
logger.info(`[OK] ${msg}`)
}
function logError(msg: string) {
logger.error(msg)
}
function _logWarning(msg: string) {
logger.warn(msg)
}
export interface SSHConfig {
host: string
port?: number
username: string
password?: string
privateKey?: string
passphrase?: string
agent?: string
readyTimeout?: number
keepaliveInterval?: number
proxy?: ProxyConfig
}
export interface Connection {
id: string
client: Client
config: SSHConfig
status: 'connecting' | 'connected' | 'disconnected' | 'error'
}
export interface ShellSession {
id: string
connectionId: string
channel: ClientChannel
}
export interface SFTPSession {
id: string
connectionId: string
sftp: SFTPWrapper
}
export class SSHService {
private connections: Map<string, Connection> = new Map()
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> {
debug('SSHService connect', config.host, config.port || 22, config.username)
return new Promise((resolve, reject) => {
const client = new Client()
const connectionId = uuidv4()
debug('>>> Generated connectionId:', connectionId)
const connection: Connection = {
id: connectionId,
client,
config,
status: 'connecting'
}
this.connections.set(connectionId, connection)
debug('>>> Connection stored, total connections:', this.connections.size)
client.on('ready', () => {
connection.status = 'connected'
logger.info(`SSH connection ${connectionId} established`)
debug('>>> SSH client ready, connectionId:', connectionId)
resolve(connection)
})
client.on('error', (err) => {
connection.status = 'error'
logger.error(`SSH connection ${connectionId} error:`, err)
debug('>>> SSH client ERROR:', err.message)
reject(err)
})
client.on('close', () => {
connection.status = 'disconnected'
logger.info(`SSH connection ${connectionId} closed`)
debug('>>> SSH client closed, connectionId:', connectionId)
this.connections.delete(connectionId)
})
// 解析私钥(路径 → 内容)
let resolvedPrivateKey: string | Buffer | undefined
try {
resolvedPrivateKey = this.resolvePrivateKey(config.privateKey)
} catch (err) {
connection.status = 'error'
this.connections.delete(connectionId)
reject(err)
return
}
const sshConnectOpts = {
host: config.host,
port: config.port || 22,
username: config.username,
password: config.password,
privateKey: resolvedPrivateKey,
passphrase: config.passphrase,
agent: config.agent,
readyTimeout: config.readyTimeout || 20000,
keepaliveInterval: config.keepaliveInterval || 60000,
}
// 如果配置了代理,先通过代理建立隧道
if (config.proxy && config.proxy.type !== 'none' && config.proxy.host) {
debug('>>> Connecting through proxy:', config.proxy.type, config.proxy.host, config.proxy.port)
this.connectThroughProxy(config.proxy, config.host, config.port || 22)
.then((socket) => {
debug('>>> Proxy tunnel established, starting SSH handshake')
client.connect({
...sshConnectOpts,
sock: socket,
})
})
.catch((err) => {
connection.status = 'error'
this.connections.delete(connectionId)
logger.error(`Proxy connection failed for ${connectionId}:`, err)
reject(new Error(`代理连接失败: ${err.message}`))
})
} else {
debug('>>> Starting SSH client.connect (direct)...')
client.connect(sshConnectOpts)
}
})
}
/**
* 通过代理建立 TCP 隧道
* 支持 HTTP CONNECT 和 SOCKS5
*/
private connectThroughProxy(
proxy: ProxyConfig,
targetHost: string,
targetPort: number
): Promise<net.Socket> {
if (proxy.type === 'http') {
return this.connectThroughHttpProxy(proxy, targetHost, targetPort)
} else if (proxy.type === 'socks5') {
return this.connectThroughSocks5Proxy(proxy, targetHost, targetPort)
}
return Promise.reject(new Error(`不支持的代理类型: ${proxy.type}`))
}
/**
* HTTP CONNECT 代理隧道
*/
private connectThroughHttpProxy(
proxy: ProxyConfig,
targetHost: string,
targetPort: number
): Promise<net.Socket> {
return new Promise((resolve, reject) => {
const socket = new net.Socket()
const connectTimeout = setTimeout(() => {
socket.destroy()
reject(new Error('代理连接超时'))
}, 15000)
socket.connect(proxy.port, proxy.host, () => {
clearTimeout(connectTimeout)
debug('>>> Connected to HTTP proxy:', proxy.host, proxy.port)
// 构建 CONNECT 请求
let connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n`
// 代理认证
if (proxy.username) {
const credentials = Buffer.from(`${proxy.username}:${proxy.password || ''}`).toString('base64')
connectRequest += `Proxy-Authorization: Basic ${credentials}\r\n`
}
connectRequest += '\r\n'
socket.write(connectRequest)
let responseBuffer = ''
const onData = (data: Buffer) => {
responseBuffer += data.toString()
// 检查是否收到完整 HTTP 响应头
const headerEnd = responseBuffer.indexOf('\r\n\r\n')
if (headerEnd === -1) return
socket.removeListener('data', onData)
const header = responseBuffer.substring(0, headerEnd)
// 解析状态码
const statusMatch = header.match(/^HTTP\/\d\.\d\s+(\d+)\s+(.*)/)
if (!statusMatch) {
socket.destroy()
reject(new Error('代理返回无效响应'))
return
}
const statusCode = parseInt(statusMatch[1], 10)
if (statusCode >= 200 && statusCode < 300) {
debug('>>> HTTP proxy tunnel established')
resolve(socket)
} else {
socket.destroy()
reject(new Error(`代理拒绝连接: ${statusCode} ${statusMatch[2]}`))
}
}
socket.on('data', onData)
socket.on('error', (err) => {
clearTimeout(connectTimeout)
reject(new Error(`代理连接错误: ${err.message}`))
})
})
socket.on('error', (err) => {
clearTimeout(connectTimeout)
reject(new Error(`无法连接代理服务器: ${err.message}`))
})
})
}
/**
* SOCKS5 代理隧道
*/
private connectThroughSocks5Proxy(
proxy: ProxyConfig,
targetHost: string,
targetPort: number
): Promise<net.Socket> {
return new Promise((resolve, reject) => {
const socket = new net.Socket()
const connectTimeout = setTimeout(() => {
socket.destroy()
reject(new Error('SOCKS5 代理连接超时'))
}, 15000)
socket.connect(proxy.port, proxy.host, () => {
clearTimeout(connectTimeout)
debug('>>> Connected to SOCKS5 proxy:', proxy.host, proxy.port)
// SOCKS5 握手阶段 1: 认证方法协商
let authMethods: Buffer
if (proxy.username) {
// 支持无认证(0x00)和用户名密码认证(0x02)
authMethods = Buffer.from([0x05, 0x02, 0x00, 0x02])
} else {
// 仅支持无认证
authMethods = Buffer.from([0x05, 0x01, 0x00])
}
socket.write(authMethods)
const handleHandshake = (data: Buffer) => {
if (data.length < 2) {
socket.destroy()
reject(new Error('SOCKS5 握手响应无效'))
return
}
const version = data[0]
const method = data[1]
if (version !== 0x05) {
socket.destroy()
reject(new Error('不是 SOCKS5 代理'))
return
}
if (method === 0x02 && proxy.username) {
// 用户名密码认证
socket.removeListener('data', handleHandshake)
const userBuf = Buffer.from(proxy.username)
const passBuf = Buffer.from(proxy.password || '')
const authBuf = Buffer.alloc(3 + userBuf.length + passBuf.length)
authBuf[0] = 0x01 // 认证协商版本
authBuf[1] = userBuf.length
userBuf.copy(authBuf, 2)
authBuf[2 + userBuf.length] = passBuf.length
passBuf.copy(authBuf, 3 + userBuf.length)
socket.write(authBuf)
socket.once('data', (authResp: Buffer) => {
if (authResp.length < 2 || authResp[1] !== 0x00) {
socket.destroy()
reject(new Error('SOCKS5 认证失败'))
return
}
sendConnectRequest()
})
} else if (method === 0x00) {
// 无需认证
socket.removeListener('data', handleHandshake)
sendConnectRequest()
} else {
socket.destroy()
reject(new Error('SOCKS5 不支持此认证方法'))
return
}
}
const sendConnectRequest = () => {
// SOCKS5 CONNECT 请求
const hostBuf = Buffer.from(targetHost)
// VER(1) + CMD(1) + RSV(1) + ATYP(1) + DOMAIN_LEN(1) + DOMAIN + PORT(2)
const connectBuf = Buffer.alloc(4 + 1 + hostBuf.length + 2)
connectBuf[0] = 0x05 // SOCKS5
connectBuf[1] = 0x01 // CONNECT
connectBuf[2] = 0x00 // 保留
connectBuf[3] = 0x03 // 域名类型
connectBuf[4] = hostBuf.length
hostBuf.copy(connectBuf, 5)
connectBuf.writeUInt16BE(targetPort, 5 + hostBuf.length)
socket.write(connectBuf)
socket.once('data', (connectResp: Buffer) => {
if (connectResp.length < 4 || connectResp[1] !== 0x00) {
const errorCode = connectResp.length >= 2 ? connectResp[1] : -1
const errorMessages: Record<number, string> = {
0x01: 'SOCKS5 服务器错误',
0x02: 'SOCKS5 连接不允许',
0x03: 'SOCKS5 网络不可达',
0x04: 'SOCKS5 主机不可达',
0x05: 'SOCKS5 连接拒绝',
0x06: 'SOCKS5 TTL 过期',
0x07: 'SOCKS5 不支持的命令',
0x08: 'SOCKS5 地址类型不支持',
}
socket.destroy()
reject(new Error(errorMessages[errorCode] || `SOCKS5 连接失败 (${errorCode})`))
return
}
debug('>>> SOCKS5 proxy tunnel established')
resolve(socket)
})
}
socket.on('data', handleHandshake)
socket.on('error', (err) => {
reject(new Error(`SOCKS5 代理错误: ${err.message}`))
})
})
socket.on('error', (err) => {
clearTimeout(connectTimeout)
reject(new Error(`无法连接 SOCKS5 代理: ${err.message}`))
})
})
}
async disconnect(connectionId: string): Promise<void> {
const connection = this.connections.get(connectionId)
if (connection) {
connection.client.end()
this.connections.delete(connectionId)
// Clean up associated shells
for (const [shellId, shell] of this.shells.entries()) {
if (shell.connectionId === connectionId) {
shell.channel.close()
this.shells.delete(shellId)
}
}
// Clean up associated SFTP sessions
for (const [sftpId, sftp] of this.sftps.entries()) {
if (sftp.connectionId === connectionId) {
this.sftps.delete(sftpId)
}
}
}
}
disconnectAll(): void {
for (const connectionId of this.connections.keys()) {
this.disconnect(connectionId)
}
}
async exec(connectionId: string, command: string): Promise<string> {
const connection = this.connections.get(connectionId)
if (!connection || connection.status !== 'connected') {
throw new Error('Connection not established')
}
return new Promise((resolve, reject) => {
let stdout = ''
let stderr = ''
connection.client.exec(command, (err, stream) => {
if (err) {
reject(err)
return
}
stream.on('close', (code: number) => {
if (code !== 0) {
reject(new Error(`Command failed with code ${code}: ${stderr}`))
} else {
resolve(stdout)
}
})
stream.on('data', (data: Buffer) => {
stdout += data.toString()
})
stream.stderr.on('data', (data: Buffer) => {
stderr += data.toString()
})
})
})
}
async createShell(
connectionId: string,
options: { cols?: number; rows?: number; term?: string } = {},
onData?: (shellId: string, data: Buffer) => void,
preferredShellId?: string
): Promise<ShellSession> {
debug('SSHService createShell', connectionId, options, preferredShellId)
const connection = this.connections.get(connectionId)
debug('connection found:', !!connection, 'status:', connection?.status)
if (!connection || connection.status !== 'connected') {
throw new Error('Connection not established')
}
return new Promise((resolve, reject) => {
debug('>>> Calling client.shell...')
connection.client.shell({
cols: options.cols || 80,
rows: options.rows || 24,
term: options.term || 'xterm-256color'
}, (err, channel) => {
if (err) {
debug('>>> client.shell ERROR:', err.message)
reject(err)
return
}
debug('>>> client.shell success, channel created')
// 使用前端提供的 shellId 或生成新的
const shellId = preferredShellId || uuidv4()
debug('>>> Using shellId:', shellId)
const shell: ShellSession = {
id: shellId,
connectionId,
channel
}
// Store shell BEFORE setting up callbacks
this.shells.set(shellId, shell)
debug('>>> Shell stored, total shells:', this.shells.size)
// Set up data callback AFTER shell is stored
if (onData) {
debug('>>> Setting up channel.on(data) handler')
channel.on('data', (data: Buffer) => {
debug('SSHService channel.on(data) shellId:', shellId, 'data length:', data.length)
onData(shellId, data)
})
} else {
debug('>>> WARNING: onData callback is undefined!')
}
channel.on('close', () => {
debug('>>> Channel closed, shellId:', shellId)
this.shells.delete(shellId)
})
debug('>>> Resolving shell:', shellId)
resolve(shell)
})
})
}
shellWrite(shellId: string, data: string | Uint8Array): void {
const shell = this.shells.get(shellId)
if (shell) {
shell.channel.write(data)
}
}
shellResize(shellId: string, cols: number, rows: number): void {
const shell = this.shells.get(shellId)
if (shell) {
shell.channel.setWindow(rows, cols, 0, 0)
}
}
getShell(shellId: string): ShellSession | undefined {
return this.shells.get(shellId)
}
getConnection(connectionId: string): Connection | undefined {
return this.connections.get(connectionId)
}
// ===== SFTP 操作 =====
async createSFTP(connectionId: string): Promise<SFTPSession> {
logHeader('📁 SFTP CREATE')
logInfo('connectionId', connectionId)
const connection = this.connections.get(connectionId)
if (!connection || connection.status !== 'connected') {
logError('Connection not established')
throw new Error('Connection not established')
}
return new Promise((resolve, reject) => {
debug('>>> Calling client.sftp()...')
connection.client.sftp((err, sftp) => {
if (err) {
logError(`SFTP creation failed: ${err.message}`)
reject(err)
return
}
const sftpId = uuidv4()
const session: SFTPSession = {
id: sftpId,
connectionId,
sftp
}
this.sftps.set(sftpId, session)
logSuccess(`SFTP session created: ${sftpId}`)
resolve(session)
})
})
}
async sftpListDirectory(sftpId: string, path: string): Promise<FileEntryWithStats[]> {
logHeader('📁 SFTP LIST DIRECTORY')
logInfo('sftpId', sftpId)
logInfo('path', path)
const session = this.sftps.get(sftpId)
if (!session) {
logError('SFTP session not found')
throw new Error('SFTP session not found')
}
return new Promise((resolve, reject) => {
session.sftp.readdir(path, (err, list) => {
if (err) {
logError(`List directory failed: ${err.message}`)
reject(err)
return
}
logSuccess(`Listed ${list.length} items in ${path}`)
resolve(list)
})
})
}
async sftpUpload(
sftpId: string,
localPath: string,
remotePath: string,
onProgress?: (transferred: number, total: number) => void
): Promise<void> {
logHeader('📤 SFTP UPLOAD')
logInfo('sftpId', sftpId)
logInfo('localPath', localPath)
logInfo('remotePath', remotePath)
const session = this.sftps.get(sftpId)
if (!session) {
logError('SFTP session not found')
throw new Error('SFTP session not found')
}
return new Promise((resolve, reject) => {
session.sftp.fastPut(localPath, remotePath, {
step: (transferred: number, _chunk: number, total: number) => {
debug(`Upload progress: ${transferred}/${total}`)
if (onProgress) {
onProgress(transferred, total)
}
}
}, (err) => {
if (err) {
logError(`Upload failed: ${err.message}`)
reject(err)
return
}
logSuccess(`Upload complete: ${remotePath}`)
resolve()
})
})
}
async sftpDownload(
sftpId: string,
remotePath: string,
localPath: string,
onProgress?: (transferred: number, total: number) => void
): Promise<void> {
logHeader('📥 SFTP DOWNLOAD')
logInfo('sftpId', sftpId)
logInfo('remotePath', remotePath)
logInfo('localPath', localPath)
const session = this.sftps.get(sftpId)
if (!session) {
logError('SFTP session not found')
throw new Error('SFTP session not found')
}
return new Promise((resolve, reject) => {
session.sftp.fastGet(remotePath, localPath, {
step: (transferred: number, _chunk: number, total: number) => {
debug(`Download progress: ${transferred}/${total}`)
if (onProgress) {
onProgress(transferred, total)
}
}
}, (err) => {
if (err) {
logError(`Download failed: ${err.message}`)
reject(err)
return
}
logSuccess(`Download complete: ${remotePath}`)
resolve()
})
})
}
async sftpRename(sftpId: string, oldPath: string, newPath: string): Promise<void> {
const session = this.sftps.get(sftpId)
if (!session) {
throw new Error('SFTP session not found')
}
return new Promise((resolve, reject) => {
session.sftp.rename(oldPath, newPath, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
async sftpDelete(sftpId: string, path: string, isDirectory: boolean): Promise<void> {
const session = this.sftps.get(sftpId)
if (!session) {
throw new Error('SFTP session not found')
}
return new Promise((resolve, reject) => {
if (isDirectory) {
session.sftp.rmdir(path, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
} else {
session.sftp.unlink(path, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
}
})
}
async sftpMkdir(sftpId: string, path: string): Promise<void> {
const session = this.sftps.get(sftpId)
if (!session) {
throw new Error('SFTP session not found')
}
return new Promise((resolve, reject) => {
session.sftp.mkdir(path, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
async sftpStat(sftpId: string, filePath: string): Promise<{
size: number
mode: number
mtime: number
atime: number
isDirectory: boolean
isFile: boolean
isSymbolicLink: boolean
}> {
const session = this.sftps.get(sftpId)
if (!session) {
throw new Error('SFTP session not found')
}
return new Promise((resolve, reject) => {
session.sftp.stat(filePath, (err, stats) => {
if (err) {
reject(err)
return
}
resolve({
size: stats.size,
mode: stats.mode,
mtime: stats.mtime,
atime: stats.atime,
isDirectory: stats.isDirectory(),
isFile: stats.isFile(),
isSymbolicLink: stats.isSymbolicLink(),
})
})
})
}
async sftpChmod(sftpId: string, filePath: string, mode: number): Promise<void> {
const session = this.sftps.get(sftpId)
if (!session) {
throw new Error('SFTP session not found')
}
return new Promise((resolve, reject) => {
session.sftp.chmod(filePath, mode, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
// 端口转发
// ===== 本地端口转发 =====
async forwardLocal(
connectionId: string,
localPort: number,
remoteHost: string,
remotePort: number
): Promise<{ success: boolean; boundPort: number }> {
logHeader('🔀 LOCAL PORT FORWARD')
logInfo('connectionId', connectionId)
logInfo('localPort', localPort)
logInfo('remoteHost', remoteHost)
logInfo('remotePort', remotePort)
const connection = this.connections.get(connectionId)
if (!connection || connection.status !== 'connected') {
logError('Connection not established')
throw new Error('Connection not established')
}
return new Promise((resolve, reject) => {
debug('>>> Creating local server on port', localPort)
const server = net.createServer((socket) => {
debug('>>> Local connection received, forwarding to', remoteHost, remotePort)
connection.client.forwardOut('127.0.0.1', 0, remoteHost, remotePort, (err, stream) => {
if (err) {
logError(`ForwardOut failed: ${err.message}`)
socket.end()
return
}
socket.pipe(stream).pipe(socket)
debug('>>> Pipeline established')
})
})
server.listen(localPort, '127.0.0.1', () => {
const actualPort = (server.address() as net.AddressInfo).port
logSuccess(`Local forward established: 127.0.0.1:${actualPort} -> ${remoteHost}:${remotePort}`)
resolve({ success: true, boundPort: actualPort })
})
server.on('error', (err) => {
logError(`Server error: ${err.message}`)
reject(err)
})
})
}
// ===== 远程端口转发 =====
async forwardRemote(
connectionId: string,
remotePort: number,
localHost: string,
localPort: number
): Promise<{ success: boolean }> {
logHeader('🔀 REMOTE PORT FORWARD')
logInfo('connectionId', connectionId)
logInfo('remotePort', remotePort)
logInfo('localHost', localHost)
logInfo('localPort', localPort)
const connection = this.connections.get(connectionId)
if (!connection || connection.status !== 'connected') {
logError('Connection not established')
throw new Error('Connection not established')
}
return new Promise((resolve, reject) => {
connection.client.forwardIn('127.0.0.1', remotePort, (err) => {
if (err) {
logError(`forwardIn failed: ${err.message}`)
reject(err)
return
}
logSuccess(`Remote forward established: remote:${remotePort} -> ${localHost}:${localPort}`)
resolve({ success: true })
})
})
}
// ===== 动态端口转发 (SOCKS5) =====
private socksServers: Map<string, net.Server> = new Map()
async forwardDynamic(
connectionId: string,
localPort: number
): Promise<{ success: boolean; boundPort: number }> {
logHeader('🧦 SOCKS5 DYNAMIC FORWARD')
logInfo('connectionId', connectionId)
logInfo('localPort', localPort)
const connection = this.connections.get(connectionId)
if (!connection || connection.status !== 'connected') {
logError('Connection not established')
throw new Error('Connection not established')
}
return new Promise((resolve, reject) => {
const server = net.createServer((socket) => {
debug('>>> SOCKS5 client connected')
socket.once('data', (data) => {
// 解析 SOCKS5 握手
if (data.length < 3 || data[0] !== 0x05) {
logError('Invalid SOCKS5 handshake')
socket.end()
return
}
// 发送握手响应 (无认证)
socket.write(Buffer.from([0x05, 0x00]))
socket.once('data', (data) => {
// 解析 SOCKS5 请求
const req = Buffer.from(data)
if (req.length < 10 || req[0] !== 0x05) {
logError('Invalid SOCKS5 request')
socket.end()
return
}
const cmd = req[1]
const atyp = req[3]
if (cmd !== 0x01) { // CONNECT only
socket.write(Buffer.from([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]))
socket.end()
return
}
let targetHost: string
let targetPort: number
let headerLen: number
if (atyp === 0x01) { // IPv4
targetHost = `${req[4]}.${req[5]}.${req[6]}.${req[7]}`
targetPort = req.readUInt16BE(8)
headerLen = 10
} else if (atyp === 0x03) { // Domain
const domainLen = req[4]
targetHost = req.slice(5, 5 + domainLen).toString('ascii')
targetPort = req.readUInt16BE(5 + domainLen)
headerLen = 5 + domainLen + 2
} else {
socket.write(Buffer.from([0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]))
socket.end()
return
}
logInfo('SOCKS5 target', `${targetHost}:${targetPort}`)
// 通过 SSH 连接目标
connection.client.forwardOut('127.0.0.1', 0, targetHost, targetPort, (err, stream) => {
if (err) {
logError(`SOCKS5 forwardOut failed: ${err.message}`)
socket.write(Buffer.from([0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0]))
socket.end()
return
}
// 发送成功响应
socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]))
// 建立双向管道
stream.pipe(socket)
socket.pipe(stream)
// 如果有残留数据,发送给 stream
if (req.length > headerLen) {
stream.write(req.slice(headerLen))
}
logSuccess(`SOCKS5 tunnel established to ${targetHost}:${targetPort}`)
})
})
})
})
server.listen(localPort, '127.0.0.1', () => {
const actualPort = (server.address() as net.AddressInfo).port
this.socksServers.set(`${connectionId}:${actualPort}`, server)
logSuccess(`SOCKS5 proxy started on 127.0.0.1:${actualPort}`)
resolve({ success: true, boundPort: actualPort })
})
server.on('error', (err) => {
logError(`SOCKS5 server error: ${err.message}`)
reject(err)
})
})
}
// 停止 SOCKS5 代理
stopDynamicForward(connectionId: string, localPort: number): void {
const key = `${connectionId}:${localPort}`
const server = this.socksServers.get(key)
if (server) {
server.close()
this.socksServers.delete(key)
logSuccess(`SOCKS5 proxy stopped on port ${localPort}`)
}
}
}
-382
View File
@@ -1,382 +0,0 @@
import Zmodem from 'zmodem-ts'
import fs from 'fs'
import path from 'path'
import { app } from 'electron'
import { createLogger } from '../utils/logger'
const logger = createLogger('ZmodemService')
// Type definitions for Zmodem sessions (since zmodem-ts doesn't export them properly)
interface ZmodemSendSession {
type: 'send'
set_sender(func: (octets: number[]) => 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<unknown>
}
interface ZmodemReceiveSession {
type: 'receive'
set_sender(func: (octets: number[]) => void): void
start(): Promise<unknown>
on(event: string, handler: (data: unknown) => void): void
abort(): void
}
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
total: number
fileName?: string
}
interface ZmodemSessionInfo {
id: string
shellId: string
type: 'send' | 'receive'
session: ZmodemSendSession | ZmodemReceiveSession
fileName?: string
filePath?: string
totalSize?: number
transferred?: number
sentry?: ZmodemSentry
onComplete?: (success: boolean, error?: string) => void
}
export class ZmodemService {
private sessions: Map<string, ZmodemSessionInfo> = new Map()
private shellDataCallbacks: Map<string, (data: Buffer) => void> = new Map()
constructor() {
app.on('before-quit', () => {
this.closeAll()
})
}
/**
* Send file to server via Zmodem (rz)
*
* Flow: Server types "rz" -> sends ZRQINIT/ZRINIT -> we send file
* We use Sentry to detect when server is ready (ZRINIT received)
*/
sendFile(
shellId: string,
fileName: string,
fileData: Buffer,
writeFunc: (data: Uint8Array) => void,
onProgress?: (progress: ZmodemProgress) => void,
onComplete?: (success: boolean, error?: string) => void
): string {
const sessionId = `zmodem-send-${Date.now()}`
const totalSize = fileData.length
// Create session info
const sessionInfo: ZmodemSessionInfo = {
id: sessionId,
shellId,
type: 'send',
session: null as unknown as ZmodemSendSession,
fileName,
totalSize,
transferred: 0
}
this.sessions.set(sessionId, sessionInfo)
// Store completion callback
if (onComplete) {
sessionInfo.onComplete = onComplete
}
// Create Sentry to detect ZRINIT from server
const sentry = new Zmodem.Sentry({
to_terminal: (octets: number[]) => {
// Echo to terminal
writeFunc(new Uint8Array(octets))
},
on_detect: (detection: ZmodemDetection) => {
const detectedSession = detection.get_session()
if (detectedSession && detectedSession.type === 'send') {
// Server is ready for file transfer (received ZRINIT)
const sendSession = detectedSession as ZmodemSendSession
// Update session reference
sessionInfo.session = sendSession
// Set up sender to write to shell
sendSession.set_sender((octets: number[]) => {
writeFunc(new Uint8Array(octets))
})
// Handle session end
sendSession.on('session_end', () => {
this.sessions.delete(sessionId)
})
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
this.doSendFile(sessionId, sendSession, fileName, fileData, onProgress, onComplete)
}
},
on_retract: () => {
// Session was retracted
logger.info('Zmodem send session retracted')
this.sessions.delete(sessionId)
onComplete?.(false, 'Session retracted')
},
sender: (octets: number[]) => {
writeFunc(new Uint8Array(octets))
}
})
sessionInfo.sentry = sentry
// Store shell data callback to feed to Sentry
this.shellDataCallbacks.set(shellId, (data: Buffer) => {
sentry.consume(Array.from(data))
})
return sessionId
}
/**
* Internal method to send file after ZRINIT is received
*/
private async doSendFile(
sessionId: string,
session: ZmodemSendSession,
fileName: string,
fileData: Buffer,
onProgress?: (progress: ZmodemProgress) => void,
onComplete?: (success: boolean, error?: string) => void
): Promise<void> {
try {
// Send file offer
const result = await session.send_offer({
name: fileName,
size: fileData.length,
mtime: Date.now()
})
if (result) {
// Transfer completed successfully
onProgress?.({
percentage: 100,
transferred: fileData.length,
total: fileData.length,
fileName
})
onComplete?.(true)
} else {
// Transfer was skipped or rejected
onComplete?.(false, 'Transfer skipped or rejected')
}
} catch (err: unknown) {
logger.error('Zmodem send error:', err)
onComplete?.(false, err instanceof Error ? err.message : String(err))
}
}
/**
* Feed shell data to Zmodem service for protocol detection
* This should be called when shell receives data
*/
feedShellData(shellId: string, data: Buffer): void {
const callback = this.shellDataCallbacks.get(shellId)
if (callback) {
callback(data)
}
// Also check all sessions for matching shellId
for (const [, session] of this.sessions) {
if (session.shellId === shellId && session.sentry) {
// Feed to sentry for detection
}
}
}
/**
* Receive file from server via Zmodem (sz)
*
* Flow: User clicks "sz" in UI -> we open receive session
* Server sends ZRQINIT -> we accept -> server sends file
*/
receiveFile(
shellId: string,
filePath: string,
writeFunc: (data: Uint8Array) => void,
onProgress?: (progress: ZmodemProgress) => void,
onComplete?: (success: boolean, error?: string) => void
): string {
const sessionId = `zmodem-receive-${Date.now()}`
const fileName = path.basename(filePath)
// Create receive session
// @ts-expect-error - zmodem-ts types are incomplete
const session: ZmodemReceiveSession = new Zmodem.ReceiveSession()
const sessionInfo: ZmodemSessionInfo = {
id: sessionId,
shellId,
type: 'receive',
session,
filePath,
fileName,
totalSize: 0,
transferred: 0
}
this.sessions.set(sessionId, sessionInfo)
if (onComplete) {
sessionInfo.onComplete = onComplete
}
// Set up sender to write responses to shell
session.set_sender((octets: number[]) => {
writeFunc(new Uint8Array(octets))
})
// Handle file offer
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
zmodemOffer.accept({
on_input: 'spool_array'
}).then((transfer) => {
// Handle incoming data
transfer.on('input', (data: Uint8Array) => {
sessionInfo.transferred = (sessionInfo.transferred || 0) + data.length
// Write to file
try {
fs.writeFileSync(filePath, data, { flag: 'a' })
} catch (err: unknown) {
logger.error('Error writing Zmodem file:', err)
}
const total = sessionInfo.totalSize || sessionInfo.transferred || 0
onProgress?.({
percentage: total > 0
? Math.round((sessionInfo.transferred! / total) * 100)
: 0,
transferred: sessionInfo.transferred!,
total: total,
fileName: sessionInfo.fileName
})
})
transfer.on('complete', () => {
logger.info('Zmodem receive complete')
onProgress?.({
percentage: 100,
transferred: sessionInfo.totalSize || sessionInfo.transferred || 0,
total: sessionInfo.totalSize || sessionInfo.transferred || 0,
fileName: sessionInfo.fileName
})
onComplete?.(true)
})
}).catch((err: Error) => {
logger.error('Zmodem accept error:', err)
onComplete?.(false, err.message)
})
})
// Handle session end
session.on('session_end', () => {
this.sessions.delete(sessionId)
})
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
session.start().catch((err: Error) => {
logger.error('Zmodem start error:', err)
this.sessions.delete(sessionId)
onComplete?.(false, err.message)
})
return sessionId
}
/**
* Cancel a Zmodem session
*/
cancelSession(sessionId: string): void {
const session = this.sessions.get(sessionId)
if (session) {
try {
const s = session.session as { abort?: () => void; close?: () => void }
s.abort?.()
s.close?.()
} catch (err: unknown) {
logger.error('Error canceling Zmodem session:', err)
}
// Clean up shell data callback
this.shellDataCallbacks.delete(session.shellId)
this.sessions.delete(sessionId)
}
}
/**
* Close all active sessions
*/
closeAll(): void {
for (const sessionId of this.sessions.keys()) {
this.cancelSession(sessionId)
}
}
/**
* Get active session IDs
*/
getActiveSessions(): string[] {
return Array.from(this.sessions.keys())
}
/**
* Get session by ID
*/
getSession(sessionId: string): ZmodemSessionInfo | undefined {
return this.sessions.get(sessionId)
}
}
// Singleton instance
export const zmodemService = new ZmodemService()
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "../dist-electron",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node"
},
"include": ["./**/*"],
"exclude": ["node_modules"]
}
-46
View File
@@ -1,46 +0,0 @@
/**
* Electron 主进程日志工具
*
* 简化版,直接输出到控制台
*/
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
}
const LOG_LEVEL = process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.DEBUG
function shouldLog(level: LogLevel): boolean {
return level >= LOG_LEVEL
}
function formatMessage(level: string, module: string, message: string): string {
const timestamp = new Date().toISOString()
return `[${timestamp}] [${level}] [${module}] ${message}`
}
export function createLogger(module: string) {
return {
debug: (message: string, ...args: unknown[]) => {
if (!shouldLog(LogLevel.DEBUG)) return
console.debug(formatMessage('DEBUG', module, message), ...args)
},
info: (message: string, ...args: unknown[]) => {
if (!shouldLog(LogLevel.INFO)) return
console.info(formatMessage('INFO', module, message), ...args)
},
warn: (message: string, ...args: unknown[]) => {
if (!shouldLog(LogLevel.WARN)) return
console.warn(formatMessage('WARN', module, message), ...args)
},
error: (message: string, ...args: unknown[]) => {
if (!shouldLog(LogLevel.ERROR)) return
console.error(formatMessage('ERROR', module, message), ...args)
},
}
}
export const logger = createLogger('Main')
-2
View File
@@ -5,12 +5,10 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
<div id="root"></div>
+309 -3995
View File
File diff suppressed because it is too large. Load diff
+30 -69
View File
@@ -1,69 +1,76 @@
{
"name": "opsxshell-gui",
"version": "2.0.0",
"description": "跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠 · v2.0 安全增强版",
"main": "dist-electron/main.js",
"version": "2.1.0",
"description": "轻量原生的跨平台 SSH/SFTP 运维终端 · Tauri 2 + Rust",
"author": "OpsXShell Team",
"license": "MIT",
"scripts": {
"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",
"dev": "tauri dev",
"dev:ui": "vite",
"dev:tauri": "tauri dev",
"build": "tauri build",
"build:ui": "tsc --noEmit && vite build",
"build:tauri": "tauri build",
"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",
"typecheck": "npm run typecheck:ui && npm run typecheck:test",
"typecheck:ui": "tsc --noEmit",
"typecheck:test": "tsc --noEmit -p tsconfig.test.json",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"clean": "rimraf dist dist-electron release",
"test:release": "node --test scripts/release-tools.test.mjs",
"acceptance": "npm run acceptance:quick",
"acceptance:quick": "node scripts/acceptance.mjs quick",
"acceptance:full": "node scripts/acceptance.mjs full",
"clean": "rimraf dist src-tauri/target/release 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"
"dist": "tauri build",
"dist:win": "tauri build --target x86_64-pc-windows-msvc",
"dist:mac": "tauri build --target aarch64-apple-darwin",
"release:preflight:mac": "node scripts/release-preflight.mjs macos",
"release:preflight:win": "node scripts/release-preflight.mjs windows",
"release:verify:mac": "node scripts/verify-release.mjs macos",
"release:verify:win": "node scripts/verify-release.mjs windows",
"dist:linux": "tauri build --target x86_64-unknown-linux-gnu"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@tauri-apps/api": "^2.11.1",
"@vitest/coverage-v8": "^4.1.4",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"concurrently": "^9.2.1",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"lucide-react": "^0.294.0",
"monaco-editor": "^0.55.1",
"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"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.20.1",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@types/sql.js": "^1.4.11",
"@types/ssh2": "^1.11.18",
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"@xterm/addon-webgl": "^0.19.0",
"autoprefixer": "^10.4.16",
"electron": "^28.3.3",
"electron-builder": "^24.9.1",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
@@ -72,52 +79,6 @@
"tailwindcss": "^3.3.6",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vite-plugin-electron": "^0.28.0",
"vite-plugin-electron-renderer": "^0.14.5",
"vitest": "^4.1.2"
},
"build": {
"appId": "com.opsxshell.app",
"productName": "OpsXShell",
"directories": {
"output": "release"
},
"files": [
"dist",
"dist-electron"
],
"mac": {
"category": "public.app-category.utilities",
"target": [
{
"target": "dmg",
"arch": [
"x64",
"arm64"
]
}
]
},
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
]
},
"linux": {
"target": [
{
"target": "AppImage",
"arch": [
"x64"
]
}
],
"category": "Utility"
}
}
}
-89
View File
@@ -1,89 +0,0 @@
// 浏览器环境中模拟 electron API
if (typeof window !== 'undefined' && !window.electronAPI) {
window.electronAPI = {
// SSH 相关
sshConnect: async (config) => ({
success: true,
connectionId: 'mock-' + Date.now()
}),
sshDisconnect: async () => ({ success: true }),
sshExec: async () => ({ success: true, result: '' }),
sshCreateShell: async () => ({
success: true,
shellId: 'shell-' + Date.now()
}),
sshShellWrite: async () => ({ success: true }),
sshShellResize: async () => ({ success: true }),
// 会话管理
sessionGetAll: async () => ({
success: true,
sessions: [
{ id: '1', name: '本地开发服务器', host: '192.168.1.100', port: 22, username: 'root', authType: 'password' },
{ id: '2', name: '阿里云生产', host: '47.100.1.1', port: 22, username: 'admin', authType: 'key' },
{ id: '3', name: '腾讯云测试', host: '119.29.1.1', port: 2222, username: 'ubuntu', authType: 'password' },
]
}),
sessionGetById: async (id) => ({
success: true,
session: {
id,
name: '测试服务器',
host: '192.168.1.100',
port: 22,
username: 'root',
authType: 'password'
}
}),
sessionCreate: async (session) => ({
success: true,
session: { ...session, id: 'new-' + Date.now() }
}),
sessionUpdate: async (id, updates) => ({
success: true,
session: { id, ...updates }
}),
sessionDelete: async () => ({ success: true }),
// SFTP
sftpConnect: async () => ({
success: true,
sftpId: 'sftp-' + Date.now()
}),
sftpListDirectory: async () => ({
success: true,
files: [
{ filename: 'documents', longname: 'drwxr-xr-x', attrs: { mode: 16877, size: 4096 } },
{ filename: 'file.txt', longname: '-rw-r--r--', attrs: { mode: 33188, size: 1024 } },
]
}),
sftpUpload: async () => ({ success: true }),
sftpDownload: async () => ({ success: true }),
// 端口转发
sshForwardLocal: async () => ({ success: true, boundPort: 1080 }),
sshForwardRemote: async () => ({ success: true }),
sshForwardDynamic: async () => ({ success: true, boundPort: 1080 }),
// 设置
settingsGet: async () => ({ success: true, value: null }),
settingsSet: async () => ({ success: true }),
// 对话框
showSaveDialog: async () => ({ canceled: false, filePath: '/mock/path' }),
showOpenDialog: async () => ({ canceled: false, filePaths: ['/mock/path'] }),
// 应用信息
getVersion: async () => '1.0.0',
// 事件监听
onMenuNewSession: () => {},
onMenuQuickConnect: () => {},
onMenuImportSessions: () => {},
onMenuExportSessions: () => {},
onShellData: () => {},
onSFTPProgress: () => {},
removeAllListeners: () => {},
};
console.log('[Mock] electronAPI loaded for browser preview');
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -4
View File
@@ -16,7 +16,6 @@ echo -e "${CYAN}🦐 启动 OpsXShell...${NC}"
# 1. 清理旧进程
echo -e "${YELLOW}🧹 清理旧进程...${NC}"
pkill -f "vite" 2>/dev/null || true
pkill -f "electron" 2>/dev/null || true
pkill -f "opsxshell" 2>/dev/null || true
sleep 1
@@ -26,9 +25,8 @@ if [ ! -d "node_modules" ]; then
npm install
fi
# 3. 启动 Vite + Electron (npm run dev 已包含 electron 启动)
# 3. 启动 Tauri 开发应用
echo -e "${GREEN}⚡ 启动 OpsXShell...${NC}"
export VITE_DEV_SERVER_URL=http://localhost:5173
npm run dev > "$LOG_FILE" 2>&1 &
VITE_PID=$!
echo $VITE_PID > "$PID_FILE"
@@ -37,7 +35,6 @@ echo $VITE_PID > "$PID_FILE"
sleep 3
echo ""
echo -e "${CYAN}📝 注意: IMKCFRunLoopWakeUpReliable 是 macOS Electron 已知问题,不影响功能${NC}"
echo -e "${GREEN}🎉 OpsXShell 已启动!${NC}"
echo -e "${CYAN}📄 日志: tail -f ${LOG_FILE}${NC}"
echo -e "${CYAN}🔍 DevTools: 按 Cmd+Option+I (macOS) 或 F12${NC}"
+57
View File
@@ -0,0 +1,57 @@
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
const mode = process.argv[2] ?? 'quick'
if (!['quick', 'full'].includes(mode)) {
console.error(`Unknown acceptance mode: ${mode}`)
process.exit(2)
}
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
const cargoHomeBinary = join(homedir(), '.cargo', 'bin', process.platform === 'win32' ? 'cargo.exe' : 'cargo')
const cargo = process.env.CARGO || (existsSync(cargoHomeBinary) ? cargoHomeBinary : 'cargo')
const manifest = 'src-tauri/Cargo.toml'
const quickSteps = [
['Renderer type contract', npm, ['run', 'typecheck:ui']],
['Test and mock contracts', npm, ['run', 'typecheck:test']],
['Unit and foundation smoke tests', npm, ['test']],
['Rust formatting', cargo, ['fmt', '--manifest-path', manifest, '--', '--check']],
['Rust compile check', cargo, ['check', '--manifest-path', manifest]],
]
const fullSteps = [
['TypeScript and React lint', npm, ['run', 'lint']],
...quickSteps,
['Rust lint with warnings denied', cargo, ['clippy', '--manifest-path', manifest, '--all-targets', '--', '-D', 'warnings']],
['Rust tests', cargo, ['test', '--manifest-path', manifest]],
['Renderer production build', npm, ['run', 'build:ui']],
['Release tooling tests', npm, ['run', 'test:release']],
]
const steps = mode === 'full' ? fullSteps : quickSteps
const startedAt = Date.now()
for (const [label, command, args] of steps) {
console.log(`\n[acceptance] ${label}`)
const result = spawnSync(command, args, {
cwd: process.cwd(),
env: process.env,
stdio: 'inherit',
})
if (result.error) {
console.error(`[acceptance] Unable to run ${command}: ${result.error.message}`)
process.exit(1)
}
if (result.status !== 0) {
console.error(`[acceptance] FAILED: ${label}`)
process.exit(result.status ?? 1)
}
}
const elapsedSeconds = ((Date.now() - startedAt) / 1000).toFixed(1)
console.log(`\n[acceptance] PASS (${mode}, ${elapsedSeconds}s)`)
+16
View File
@@ -0,0 +1,16 @@
import { missingSigningVariables } from './release-tools.mjs'
const platform = process.argv[2] ?? (process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : '')
if (!platform) {
console.error('[release] Only macOS and Windows release targets are supported')
process.exit(2)
}
const requireSigning = process.env.REQUIRE_SIGNING === 'true'
const missing = missingSigningVariables(platform)
if (requireSigning && missing.length > 0) {
console.error(`[release] ${platform} signing is required but these variables are missing: ${missing.join(', ')}`)
process.exit(1)
}
console.log(`[release] ${platform} preflight passed (${requireSigning ? 'signed release required' : missing.length ? 'unsigned development build' : 'signing configured'})`)
+39
View File
@@ -0,0 +1,39 @@
import { readFileSync } from 'node:fs'
export const REQUIRED_SIGNING_ENV = {
macos: [
'APPLE_CERTIFICATE',
'APPLE_CERTIFICATE_PASSWORD',
'APPLE_SIGNING_IDENTITY',
'APPLE_ID',
'APPLE_PASSWORD',
'APPLE_TEAM_ID',
],
windows: ['WINDOWS_CERTIFICATE_BASE64', 'WINDOWS_CERTIFICATE_PASSWORD'],
}
export function missingSigningVariables(platform, env = process.env) {
const required = REQUIRED_SIGNING_ENV[platform]
if (!required) throw new Error(`Unsupported release platform: ${platform}`)
return required.filter(name => !env[name]?.trim())
}
export function readPeMachine(path) {
const buffer = readFileSync(path)
if (buffer.length < 64 || buffer.readUInt16LE(0) !== 0x5a4d) {
throw new Error(`${path} is not a PE executable`)
}
const peOffset = buffer.readUInt32LE(0x3c)
if (peOffset + 6 > buffer.length || buffer.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0') {
throw new Error(`${path} has an invalid PE header`)
}
return buffer.readUInt16LE(peOffset + 4)
}
export function assertWithinSize(path, maxBytes) {
const size = readFileSync(path).byteLength
if (size > maxBytes) {
throw new Error(`${path} is ${(size / 1024 / 1024).toFixed(1)} MB; limit is ${(maxBytes / 1024 / 1024).toFixed(1)} MB`)
}
return size
}
+25
View File
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { assertWithinSize, missingSigningVariables, readPeMachine, REQUIRED_SIGNING_ENV } from './release-tools.mjs'
test('signing preflight reports every missing secret', () => {
assert.deepEqual(missingSigningVariables('windows', {}), REQUIRED_SIGNING_ENV.windows)
const env = Object.fromEntries(REQUIRED_SIGNING_ENV.macos.map(key => [key, 'configured']))
assert.deepEqual(missingSigningVariables('macos', env), [])
})
test('PE reader accepts x86_64 and rejects malformed files', () => {
const dir = mkdtempSync(join(tmpdir(), 'opsxshell-release-'))
const pe = Buffer.alloc(128)
pe.writeUInt16LE(0x5a4d, 0)
pe.writeUInt32LE(64, 0x3c)
pe.write('PE\0\0', 64, 'ascii')
pe.writeUInt16LE(0x8664, 68)
const path = join(dir, 'app.exe')
writeFileSync(path, pe)
assert.equal(readPeMachine(path), 0x8664)
assert.throws(() => assertWithinSize(path, 64), /limit is/)
})
+62
View File
@@ -0,0 +1,62 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { existsSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { assertWithinSize, readPeMachine } from './release-tools.mjs'
const platform = process.argv[2] ?? (process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : '')
const root = process.argv[3] ?? (platform === 'macos'
? 'src-tauri/target/aarch64-apple-darwin/release/bundle'
: 'src-tauri/target/release/bundle')
const requireSigning = process.env.REQUIRE_SIGNING === 'true'
const maxBinaryBytes = Number(process.env.MAX_RELEASE_BINARY_MB ?? 40) * 1024 * 1024
const maxInstallerBytes = Number(process.env.MAX_RELEASE_INSTALLER_MB ?? 40) * 1024 * 1024
function walk(dir) {
if (!existsSync(dir)) return []
return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const path = join(dir, entry.name)
return entry.isDirectory() ? walk(path) : [path]
})
}
function fail(message) {
console.error(`[release] ${message}`)
process.exit(1)
}
const files = walk(root)
if (files.length === 0) fail(`No release artifacts found under ${root}`)
if (platform === 'macos') {
const appExecutable = files.find(path => /\.app\/Contents\/MacOS\/[^/]+$/.test(path))
const dmg = files.find(path => path.includes('/dmg/') && path.endsWith('.dmg'))
if (!appExecutable || !dmg) fail('macOS release must contain an .app executable and a .dmg')
assertWithinSize(appExecutable, maxBinaryBytes)
assertWithinSize(dmg, maxInstallerBytes)
const architectures = execFileSync('lipo', ['-archs', appExecutable], { encoding: 'utf8' }).trim().split(/\s+/)
if (!architectures.includes('arm64')) fail(`Expected arm64 app, found: ${architectures.join(', ')}`)
if (requireSigning) {
execFileSync('codesign', ['--verify', '--deep', '--strict', appExecutable.slice(0, appExecutable.indexOf('.app/') + 4)], { stdio: 'inherit' })
const inspection = spawnSync('codesign', ['-dv', '--verbose=4', appExecutable.slice(0, appExecutable.indexOf('.app/') + 4)], { encoding: 'utf8' })
if (inspection.status !== 0) fail(`Unable to inspect macOS signature: ${inspection.stderr}`)
const detail = `${inspection.stdout}\n${inspection.stderr}`
if (/Signature=adhoc/.test(detail)) fail('macOS application is only ad-hoc signed')
execFileSync('spctl', ['--assess', '--type', 'execute', '--verbose=2', appExecutable.slice(0, appExecutable.indexOf('.app/') + 4)], { stdio: 'inherit' })
}
console.log(`[release] macOS arm64 artifacts verified: ${root}`)
} else if (platform === 'windows') {
const executables = files.filter(path => path.toLowerCase().endsWith('.exe'))
const installers = files.filter(path => /\.(?:msi|exe)$/i.test(path))
if (executables.length === 0 || installers.length === 0) fail('Windows release must contain an x64 executable or installer')
for (const executable of executables) {
assertWithinSize(executable, maxBinaryBytes)
if (readPeMachine(executable) !== 0x8664) fail(`${executable} is not an x86_64 PE executable`)
if (requireSigning) {
execFileSync('powershell', ['-NoProfile', '-Command', `$s = Get-AuthenticodeSignature -LiteralPath '${executable.replaceAll("'", "''")}'; if ($s.Status -ne 'Valid') { throw \"Invalid signature: $($s.Status)\" }`], { stdio: 'inherit' })
}
}
for (const installer of installers) assertWithinSize(installer, maxInstallerBytes)
console.log(`[release] Windows x64 artifacts verified: ${root}`)
} else {
fail(`Unsupported platform: ${platform}`)
}
+93
View File
@@ -0,0 +1,93 @@
export type AiProtocol = 'openai-chat' | 'openai-responses' | 'anthropic'
export type AiAuthStyle = 'bearer' | 'x-api-key' | 'both' | 'none'
export type AiBillingMode = 'payg' | 'plan' | 'local'
export type AiModelCapability = 'chat' | 'reasoning' | 'plan'
export interface AiModelConfig {
/** Stable UI identity. Multiple routes may point at the same upstream model id. */
id: string
/** Exact model id sent to the provider. */
model: string
label: string
capability: AiModelCapability
billing: AiBillingMode
/** Optional per-model route overrides for subscription/plan endpoints. */
protocol?: AiProtocol
baseUrl?: string
}
export interface AiProviderConfig {
id: string
name: string
presetId?: string
protocol: AiProtocol
authStyle: AiAuthStyle
baseUrl: string
allowInsecureHttp: boolean
models: AiModelConfig[]
defaultModelId: string
enabled: boolean
/** Runtime-only flag. The API key itself never leaves the Rust process after saving. */
hasApiKey?: boolean
}
export type AiAssistantAction = 'explain' | 'command' | 'diagnose' | 'execute'
export interface AiTerminalContext {
selection: string
viewport: string
}
export interface AiCompleteRequest {
provider: AiProviderConfig
model: AiModelConfig
action: AiAssistantAction
prompt: string
context: string
}
export interface AiCompleteResponse {
success: boolean
text?: string
model?: string
redactedCount?: number
error?: string
}
export interface AiModelListResponse {
success: boolean
models?: string[]
error?: string
}
/** Connection test credentials are transient and are never persisted by the test command. */
export interface AiProviderTestRequest {
provider: AiProviderConfig
model: AiModelConfig
apiKey?: string
}
export interface AiProviderTestResponse {
success: boolean
status?: number
model?: string
latencyMs?: number
answer?: string
error?: string
}
export interface AiCatalogDocument {
source: string
data: unknown
}
export interface AiCatalogFetchResponse {
success: boolean
documents?: AiCatalogDocument[]
errors?: string[]
error?: string
}
+3
View File
@@ -8,3 +8,6 @@ export * from './session.types'
export * from './connection.types'
export * from './sftp.types'
export * from './ipc.types'
export * from './known-hosts.types'
export * from './serial.types'
export * from './ai.types'
+28 -2
View File
@@ -3,7 +3,7 @@
* 定义所有 IPC 请求/响应的类型
*/
import type { Session, ProxyConfig } from './session.types'
import type { Session, ProxyConfig, JumpHostConfig } from './session.types'
// ============== SSH IPC ==============
@@ -15,6 +15,10 @@ export interface SSHConnectRequest {
privateKey?: string
passphrase?: string
proxy?: ProxyConfig
jumpHost?: JumpHostConfig
jumpHosts?: JumpHostConfig[]
timeoutSeconds?: number
keepAlive?: boolean
}
export interface SSHConnectResponse {
@@ -30,7 +34,7 @@ export interface SSHExecRequest {
export interface SSHExecResponse {
success: boolean
output?: string
result?: string
error?: string
}
@@ -80,10 +84,32 @@ export interface SSHForwardDynamicRequest {
export interface SSHForwardResponse {
success: boolean
forwardId?: string
boundPort?: number
error?: string
}
export interface SSHForwardStatus {
forwardId: string
connectionId: string
kind: 'local' | 'remote' | 'dynamic'
bindHost: string
boundPort: number
targetHost?: string
targetPort?: number
startedAt: number
running: boolean
activeConnections: number
bytesUp: number
bytesDown: number
}
export interface SSHForwardListResponse {
success: boolean
tunnels?: SSHForwardStatus[]
error?: string
}
// ============== Session IPC ==============
export interface SessionCreateResponse {
+35
View File
@@ -0,0 +1,35 @@
/**
* Known Hosts types - host key trust store
*/
export type HostKeyTrustDecision = 'accept-once' | 'accept-always' | 'reject'
export interface KnownHostEntry {
/** host:port */
host: string
port: number
/** e.g. ssh-ed25519, ssh-rsa, ecdsa-sha2-nistp256 */
keyType: string
/** base64-encoded public key blob (OpenSSH wire format body) */
publicKey: string
/** SHA256 fingerprint (base64, no prefix) */
fingerprint: string
firstSeenAt: number
lastSeenAt: number
}
export interface HostKeyPromptPayload {
requestId: string
host: string
port: number
keyType: string
fingerprint: string
/** true when stored key differs from presented key */
isChanged: boolean
previousFingerprint?: string
}
export interface HostKeyPromptResponse {
requestId: string
decision: HostKeyTrustDecision
}
+26
View File
@@ -0,0 +1,26 @@
export type SerialParity = 'none' | 'odd' | 'even'
export type SerialFlowControl = 'none' | 'software' | 'hardware'
export interface SerialConfig {
path: string
baudRate: number
dataBits: 5 | 6 | 7 | 8
stopBits: 1 | 2
parity: SerialParity
flowControl: SerialFlowControl
}
export interface SerialPortSummary {
path: string
portType: string
}
export interface SerialDataEvent {
sessionId: string
data: number[]
}
export interface SerialErrorEvent {
sessionId: string
message: string
}
+42
View File
@@ -18,6 +18,19 @@ export interface ProxyConfig {
password?: string
}
/** Jump host / bastion credentials (ProxyJump-style) */
export interface JumpHostConfig {
host: string
port?: number
username: string
password?: string
privateKey?: string
passphrase?: string
authType?: AuthType
/** Optional proxy used only when reaching the jump host */
proxy?: ProxyConfig
}
// 会话配置 (用于快速连接)
export interface SSHConnectionConfig {
host: string
@@ -28,6 +41,20 @@ export interface SSHConnectionConfig {
passphrase?: string
authType?: AuthType
proxy?: ProxyConfig
/** 连接成功后自动发送到 shell 的命令(可含片段变量) */
startupCommand?: string
/** 单跳板(兼容);多跳请用 jumpHosts */
jumpHost?: JumpHostConfig
/** 有序跳板链:第一跳 → … → 最后一跳 → 目标 */
jumpHosts?: JumpHostConfig[]
/** TCP connect and SSH handshake timeout in seconds. */
timeoutSeconds?: number
/** Enable SSH protocol keepalive for this connection. */
keepAlive?: boolean
/** 单跳板会话 ID(兼容) */
jumpSessionId?: string
/** 有序跳板会话 ID 链(优先于 jumpSessionId) */
jumpSessionIds?: string[]
}
// 持久化会话
@@ -42,6 +69,16 @@ export interface Session {
privateKey?: string // 私钥路径或内容
passphrase?: string // 私钥密码
proxy?: ProxyConfig // 代理配置
/** 连接成功后自动执行的启动命令 */
startupCommand?: string
/** 单跳板会话 ID(兼容) */
jumpSessionId?: string
/** 有序跳板会话 ID 链 JSON 持久化 */
jumpSessionIds?: string[]
/** 内联单跳板(兼容) */
jumpHost?: JumpHostConfig
/** 内联多跳板链 */
jumpHosts?: JumpHostConfig[]
group?: string
tags?: string[]
createdAt?: number
@@ -59,6 +96,11 @@ export interface SessionCreateRequest {
privateKey?: string
passphrase?: string
proxy?: ProxyConfig
startupCommand?: string
jumpSessionId?: string
jumpSessionIds?: string[]
jumpHost?: JumpHostConfig
jumpHosts?: JumpHostConfig[]
group?: string
tags?: string[]
}
+6084
View File
File diff suppressed because it is too large. Load diff
+48
View File
@@ -0,0 +1,48 @@
[package]
name = "opsxshell"
version = "2.1.0"
description = "OpsXShell native remote operations runtime"
authors = ["OpsXShell Team"]
edition = "2021"
rust-version = "1.77"
[lib]
name = "opsxshell_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.4", features = [] }
[dependencies]
anyhow = "1.0"
aes-gcm = "0.10"
base64 = "0.22"
dirs = "6.0"
open = "5.3"
rfd = "0.15"
rand = "0.8"
regex = "1.11"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
ssh2 = { version = "0.9", features = ["vendored-openssl"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.10"
portable-pty = "0.9"
serialport = { version = "4.8", default-features = false }
tauri = { version = "2.8", features = [] }
tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = "2"
thiserror = "2.0"
tokio = { version = "1.43", features = ["rt-multi-thread", "sync", "time"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1.11", features = ["v4"] }
arboard = { version = "3.6.1", default-features = false }
url = "2.5"
[profile.release]
codegen-units = 1
lto = "thin"
opt-level = "s"
panic = "abort"
strip = "symbols"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop-default",
"description": "Minimum capabilities for the OpsXShell main window",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-start-dragging"
]
}
+1
View File
@@ -0,0 +1 @@
{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-reLine truncated
+1
View File
@@ -0,0 +1 @@
{"desktop-default":{"identifier":"desktop-default","description":"Minimum capabilities for the OpsXShell main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging"]}}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 805 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 999 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#101826"/>
<path d="M132 154l108 102-108 102M276 358h106" fill="none" stroke="#60a5fa" stroke-linecap="round" stroke-linejoin="round" stroke-width="38"/>
<circle cx="395" cy="154" r="24" fill="#34d399"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 B

Loaded 100 of 311 files, more files were not shown because too many files have changed in this diff. Show more