first commit: 项目初始化

main
蒋尚宏 3 weeks ago
commit 7bf0eb370a

39
.gitignore vendored

@ -0,0 +1,39 @@
# Dependencies
node_modules/
# Build output
dist/
src-tauri/target/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Environment
.env
.env.local
.env.*.local
# Tauri
src-tauri/target/
src-tauri/WixTools/
src-tauri/icons/*.png
src-tauri/icons/*.ico
src-tauri/icons/*.icns
# Database
*.db
*.sqlite
# Certificates (generated)
src-tauri/certs/

@ -0,0 +1,123 @@
# Flash Send
跨平台局域网文件传输与聊天应用,基于 Tauri 2 + Vue 3 + Rust 构建。
## 功能特性
- 🔍 **局域网设备发现** - UDP 广播自动发现同一网络内的设备
- 💬 **实时聊天** - WebSocket 实现的低延迟文本消息
- 📁 **文件传输** - HTTPS 加密传输,支持大文件
- 🔒 **安全通信** - TLS 加密所有通信,自签名证书
- 💾 **历史记录** - SQLite 本地存储聊天和传输记录
- 🎨 **现代 UI** - 扁平化设计,参考微信/LocalSend 风格
## 技术栈
### 后端 (Rust)
- **Tauri 2** - 跨平台桌面应用框架
- **Tokio** - 异步运行时
- **Rustls** - TLS 加密
- **Axum** - HTTP 服务
- **tokio-tungstenite** - WebSocket
- **rusqlite** - SQLite 数据库
### 前端 (TypeScript)
- **Vue 3** - 响应式 UI 框架
- **Pinia** - 状态管理
- **Vue Router** - 路由管理
- **TailwindCSS** - 样式框架
- **Lucide Icons** - 图标库
## 项目结构
```
flash-send/
├── src/ # Vue 前端源码
│ ├── pages/ # 页面组件
│ ├── components/ # UI 组件
│ ├── stores/ # Pinia 状态管理
│ ├── api/ # Tauri API 封装
│ ├── hooks/ # Vue Hooks
│ ├── types/ # TypeScript 类型
│ └── router/ # 路由配置
├── src-tauri/ # Rust 后端源码
│ └── src/
│ ├── discovery/ # UDP 设备发现
│ ├── websocket/ # WebSocket 聊天
│ ├── http/ # HTTP 文件传输
│ ├── tls/ # TLS 加密
│ ├── database/ # SQLite 数据库
│ ├── models/ # 数据模型
│ ├── commands/ # Tauri 命令
│ └── utils/ # 工具函数
└── public/ # 静态资源
```
## 开发环境
### 前置要求
- Node.js 18+
- Rust 1.70+
- pnpm 或 npm
### 安装依赖
```bash
# 安装前端依赖
npm install
# Rust 依赖会在首次构建时自动安装
```
### 开发模式
```bash
npm run tauri dev
```
### 构建发布
```bash
npm run tauri build
```
## 通信协议
### UDP 设备发现 (端口 53317)
```json
{
"version": 1,
"device": {
"deviceId": "uuid",
"deviceName": "设备名",
"ip": "192.168.1.100",
"wsPort": 53318,
"httpPort": 53319,
"capabilities": ["text", "image", "file"]
},
"packetType": "announce|query|response|goodbye"
}
```
### WebSocket 消息 (端口 53318)
```json
{
"id": "uuid",
"type": "text|image|event|ack|ping|pong",
"content": "消息内容",
"from": "发送者设备ID",
"to": "接收者设备ID",
"timestamp": 1700000000,
"status": "pending|sent|delivered|read|failed"
}
```
### HTTP 文件传输 (端口 53319)
- `POST /upload` - 上传文件
- `GET /file/:id` - 下载文件
- `GET /file/:id/info` - 获取文件信息
- `GET /health` - 健康检查
## 许可证
MIT License

@ -0,0 +1,102 @@
# Flash Send 项目文档
欢迎阅读 Flash Send 项目文档!本文档将帮助你快速了解和熟悉这个项目。
## 项目简介
**Flash Send** 是一款基于 Tauri 2 + Vue 3 + Rust 构建的跨平台局域网文件传输与聊天应用。它允许同一局域网内的设备进行:
- 🔍 **设备发现** - 自动发现局域网内的其他 Flash Send 设备
- 💬 **即时聊天** - 与其他设备进行实时文字聊天
- 📁 **文件传输** - 安全快速地传输文件
- 🔒 **端到端加密** - 使用 TLS 加密保护数据传输安全
## 技术栈
| 层级 | 技术 |
|------|------|
| 前端框架 | Vue 3 + TypeScript |
| 状态管理 | Pinia |
| UI 框架 | TailwindCSS |
| 图标库 | Lucide Vue |
| 路由 | Vue Router |
| 桌面框架 | Tauri 2 |
| 后端语言 | Rust |
| 异步运行时 | Tokio |
| HTTP 服务 | Axum |
| WebSocket | tokio-tungstenite |
| 数据库 | SQLite (rusqlite) |
| TLS 加密 | rustls + rcgen |
## 文档目录
| 文档 | 说明 |
|------|------|
| [02-架构设计](./02-架构设计.md) | 整体架构、模块划分、数据流 |
| [03-后端结构](./03-后端结构.md) | Rust 后端代码结构和模块说明 |
| [04-前端结构](./04-前端结构.md) | Vue 前端代码结构和组件说明 |
| [05-API文档](./05-API文档.md) | Tauri 命令接口详细说明 |
| [06-通信协议](./06-通信协议.md) | 设备发现、WebSocket、HTTP 协议 |
| [07-数据库设计](./07-数据库设计.md) | 数据表结构和操作 |
| [08-开发指南](./08-开发指南.md) | 环境搭建、开发流程、构建部署 |
## 快速开始
### 环境要求
- Node.js >= 18
- Rust >= 1.70
- pnpm / npm / yarn
### 安装依赖
```bash
# 安装前端依赖
npm install
# Rust 依赖会在构建时自动安装
```
### 开发模式
```bash
npm run tauri dev
```
### 构建生产版本
```bash
npm run tauri build
```
## 核心功能
### 1. 设备发现
通过 UDP 广播(端口 53317自动发现局域网内的设备无需手动配置。
### 2. 即时聊天
使用 WebSocket端口 53318进行实时双向通信支持文本消息。
### 3. 文件传输
通过 HTTPS端口 53319进行加密文件传输支持
- 大文件分块传输
- 实时进度显示
- 取消传输
- 传输历史记录
### 4. 安全特性
- 自签名 TLS 证书
- 端到端加密传输
- 本地数据持久化
## 端口说明
| 端口 | 协议 | 用途 |
|------|------|------|
| 53317 | UDP | 设备发现广播 |
| 53318 | WebSocket | 即时聊天 |
| 53319 | HTTPS | 文件传输 |
## 许可证
MIT License

@ -0,0 +1,258 @@
# 架构设计
本文档介绍 Flash Send 的整体架构设计、模块划分和数据流。
## 整体架构
```
┌─────────────────────────────────────────────────────────────────┐
│ Flash Send │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Frontend (Vue 3) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ DeviceList│ │ChatWindow│ │FileTransfer│ │ Settings │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ Pinia Stores │ │ │
│ │ │ deviceStore | chatStore | settingsStore │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ Tauri API Layer │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ invoke / listen │
│ │ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Backend (Rust) │ │
│ │ ┌──────────────────────────────────────────────────┐ │ │
│ │ │ Tauri Commands │ │ │
│ │ │ discovery | chat | file | config │ │ │
│ │ └──────────────────────────────────────────────────┘ │ │
│ │ ┌──────────────────────────────────────────────────┐ │ │
│ │ │ AppState │ │ │
│ │ │ local_device | device_manager | connections │ │ │
│ │ └──────────────────────────────────────────────────┘ │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │ │
│ │ │ Discovery │ │ WebSocket │ │ HTTP │ │ │
│ │ │ (UDP) │ │ (WS) │ │ (HTTPS) │ │ │
│ │ └────────────┘ └────────────┘ └────────────────────┘ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ Database (SQLite) │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## 模块划分
### 前端模块 (src/)
```
src/
├── api/ # Tauri 命令封装
│ └── index.ts # 统一 API 导出
├── components/ # 可复用组件
│ ├── Sidebar.vue # 侧边导航栏
│ ├── DeviceCard.vue # 设备卡片
│ ├── MessageBubble.vue# 消息气泡
│ ├── ChatInput.vue # 聊天输入框
│ ├── FileProgress.vue # 文件传输进度
│ └── ...
├── pages/ # 页面组件
│ ├── DeviceList.vue # 设备列表页
│ ├── ChatWindow.vue # 聊天窗口页
│ ├── FileTransfer.vue # 文件传输页
│ └── Settings.vue # 设置页
├── stores/ # Pinia 状态管理
│ ├── deviceStore.ts # 设备状态
│ ├── chatStore.ts # 聊天状态
│ └── settingsStore.ts # 设置状态
├── hooks/ # 组合式函数
│ └── useEventListener.ts
├── types/ # TypeScript 类型定义
│ └── index.ts
└── router/ # Vue Router 路由
└── index.ts
```
### 后端模块 (src-tauri/src/)
```
src-tauri/src/
├── commands/ # Tauri 命令处理
│ ├── mod.rs # 命令注册
│ ├── discovery_commands.rs # 设备发现命令
│ ├── chat_commands.rs # 聊天命令
│ ├── file_commands.rs # 文件传输命令
│ └── config_commands.rs # 配置命令
├── discovery/ # UDP 设备发现
│ ├── mod.rs
│ ├── service.rs # 广播服务
│ └── manager.rs # 设备管理器
├── websocket/ # WebSocket 聊天
│ ├── mod.rs
│ ├── server.rs # WS 服务端
│ ├── client.rs # WS 客户端
│ ├── connection.rs # 连接管理
│ └── handler.rs # 消息处理
├── http/ # HTTP 文件传输
│ ├── mod.rs
│ ├── server.rs # HTTP 服务端
│ ├── client.rs # HTTP 客户端
│ └── handlers.rs # 请求处理器
├── tls/ # TLS 加密
│ ├── mod.rs
│ ├── certificate.rs # 证书生成
│ └── config.rs # TLS 配置
├── database/ # SQLite 数据库
│ ├── mod.rs
│ ├── schema.rs # 表结构定义
│ └── repository.rs # 数据操作
├── models/ # 数据模型
│ ├── mod.rs
│ ├── device.rs # 设备信息
│ ├── message.rs # 聊天消息
│ ├── file_transfer.rs # 文件传输
│ └── events.rs # 事件定义
├── utils/ # 工具函数
│ ├── mod.rs
│ ├── error.rs # 错误处理
│ └── config.rs # 应用配置
├── state.rs # 全局状态
├── main.rs # 程序入口
└── lib.rs # 库入口
```
## 数据流
### 1. 设备发现流程
```
┌──────────────┐ UDP Broadcast ┌──────────────┐
│ Device A │ ─────────────────────────────▶│ Device B │
│ │ Port 53317 │ │
│ │◀───────────────────────────── │ │
└──────────────┘ Response └──────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ DeviceManager│ │ DeviceManager│
│ Add Device │ │ Add Device │
└──────────────┘ └──────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Frontend │ │ Frontend │
│ device:found │ │ device:found │
└──────────────┘ └──────────────┘
```
### 2. 聊天消息流程
```
┌──────────────┐ ┌──────────────┐
│ Frontend A │ │ Frontend B │
│ send_message │ │ │
└──────────────┘ └──────────────┘
│ ▲
▼ │
┌──────────────┐ WebSocket ┌──────────────┐
│ WS Client A │ ─────────────────────────────▶│ WS Server B │
│ Port 53318 │ │ │
└──────────────┘ └──────────────┘
┌──────────────┐
│ Database │
│ save_message │
└──────────────┘
┌──────────────┐
│ Frontend │
│ chat:message │
└──────────────┘
```
### 3. 文件传输流程
```
┌──────────────┐ ┌──────────────┐
│ Sender │ │ Receiver │
│ select_file │ │ │
└──────────────┘ └──────────────┘
│ ▲
▼ │
┌──────────────┐ HTTPS POST ┌──────────────┐
│ HTTP Client │ ─────────────────────────────▶│ HTTP Server │
│ /upload │ Port 53319 │ │
│ multipart │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │
└──────────────┘ Progress Events └──────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Database │ │ Database │
│save_transfer │ │save_transfer │
└──────────────┘ └──────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Frontend │ │ Frontend │
│file:progress │ │file:progress │
└──────────────┘ └──────────────┘
```
## 状态管理
### AppState (Rust)
全局单例,管理所有运行时状态:
```rust
pub struct AppStateInner {
pub local_device: RwLock<DeviceInfo>, // 本机设备信息
pub device_manager: DeviceManager, // 设备管理器
pub discovery_service: RwLock<Option<DiscoveryService>>,
pub ws_server: RwLock<Option<WsServer>>,
pub ws_client: RwLock<Option<WsClient>>,
pub http_server: RwLock<Option<HttpServer>>,
pub http_client: HttpClient,
pub connection_manager: ConnectionManager, // WebSocket 连接
pub app_data_dir: PathBuf, // 数据目录
pub transfer_cancellation_tokens: RwLock<HashMap<String, CancellationToken>>,
}
```
### Pinia Stores (Vue)
- **deviceStore**: 管理在线设备列表
- **chatStore**: 管理聊天消息、文件传输状态
- **settingsStore**: 管理应用设置(主题、设备名等)
## 事件系统
后端通过 Tauri 事件向前端推送实时更新:
| 事件名 | 说明 | 数据 |
|--------|------|------|
| `device:found` | 发现新设备 | DeviceInfo |
| `device:lost` | 设备离线 | device_id |
| `chat:message` | 收到聊天消息 | ChatMessage |
| `file:progress` | 文件传输进度 | TransferProgressEvent |
## 安全设计
### TLS 加密
1. 应用启动时生成自签名证书RSA 2048
2. 证书存储在应用数据目录
3. 所有 HTTP 传输使用 TLS 加密
4. 客户端跳过证书验证(局域网内互信)
### 数据存储
- SQLite 数据库存储在用户数据目录
- 聊天记录、文件传输记录本地持久化
- 设备信息定期清理过期数据

@ -0,0 +1,537 @@
# 后端结构
本文档详细介绍 Rust 后端的代码结构、模块功能和核心实现。
## 目录结构
```
src-tauri/src/
├── commands/ # Tauri 命令(前端调用入口)
├── discovery/ # UDP 设备发现
├── websocket/ # WebSocket 聊天
├── http/ # HTTP 文件传输
├── tls/ # TLS 加密
├── database/ # SQLite 数据库
├── models/ # 数据模型
├── utils/ # 工具函数
├── state.rs # 全局状态
├── main.rs # 程序入口
└── lib.rs # 库入口
```
## 核心模块详解
### 1. Commands 模块
Tauri 命令是前端与后端通信的桥梁。
#### discovery_commands.rs
```rust
// 启动设备发现服务
#[tauri::command]
pub async fn start_discovery(app: AppHandle) -> CommandResult<()>;
// 停止设备发现服务
#[tauri::command]
pub async fn stop_discovery() -> CommandResult<()>;
// 获取在线设备列表
#[tauri::command]
pub async fn get_online_devices() -> CommandResult<Vec<DeviceInfo>>;
// 获取本机设备信息
#[tauri::command]
pub fn get_local_device() -> CommandResult<DeviceInfo>;
```
#### chat_commands.rs
```rust
// 启动 WebSocket 服务
#[tauri::command]
pub async fn start_ws_server(app: AppHandle) -> CommandResult<()>;
// 连接到指定设备
#[tauri::command]
pub async fn connect_to_device(device_id: String) -> CommandResult<()>;
// 发送聊天消息
#[tauri::command]
pub async fn send_chat_message(
device_id: String,
content: String,
message_type: String,
) -> CommandResult<ChatMessage>;
// 获取聊天历史
#[tauri::command]
pub async fn get_chat_history(
device_id: String,
limit: Option<usize>,
offset: Option<usize>,
) -> CommandResult<Vec<ChatMessage>>;
```
#### file_commands.rs
```rust
// 启动 HTTP 文件服务
#[tauri::command]
pub async fn start_http_server(app: AppHandle) -> CommandResult<()>;
// 选择要发送的文件
#[tauri::command]
pub async fn select_file(app: AppHandle) -> CommandResult<Option<FileMetadata>>;
// 发送文件
#[tauri::command]
pub async fn send_file(
app: AppHandle,
device_id: String,
file_id: String,
file_path: String,
) -> CommandResult<FileTransfer>;
// 取消传输
#[tauri::command]
pub async fn cancel_transfer(file_id: String) -> CommandResult<()>;
// 获取传输历史
#[tauri::command]
pub async fn get_transfer_history(
device_id: String,
limit: Option<usize>,
) -> CommandResult<Vec<FileTransfer>>;
// 打开文件位置
#[tauri::command]
pub async fn open_file_location(path: String) -> CommandResult<()>;
```
#### config_commands.rs
```rust
// 获取应用配置
#[tauri::command]
pub fn get_app_config() -> CommandResult<AppConfig>;
// 更新设备名称
#[tauri::command]
pub async fn update_device_name(name: String) -> CommandResult<()>;
// 更新下载目录
#[tauri::command]
pub fn update_download_dir(path: String) -> CommandResult<()>;
```
### 2. Discovery 模块
UDP 广播设备发现服务。
#### service.rs
```rust
pub struct DiscoveryService {
socket: Arc<UdpSocket>,
local_device: DeviceInfo,
running: Arc<AtomicBool>,
}
impl DiscoveryService {
// 创建服务
pub async fn new(local_device: DeviceInfo, port: u16) -> Result<Self>;
// 启动广播和监听
pub async fn start(&self, app: AppHandle);
// 停止服务
pub fn stop(&self);
// 发送广播
async fn broadcast(&self);
// 处理收到的消息
async fn handle_message(&self, data: &[u8], addr: SocketAddr, app: &AppHandle);
}
```
#### manager.rs
```rust
pub struct DeviceManager {
devices: DashMap<String, DeviceInfo>,
}
impl DeviceManager {
// 添加/更新设备
pub fn add_or_update(&self, device: DeviceInfo);
// 移除设备
pub fn remove(&self, device_id: &str);
// 获取设备
pub fn get(&self, device_id: &str) -> Option<DeviceInfo>;
// 获取所有设备
pub fn get_all(&self) -> Vec<DeviceInfo>;
// 清理过期设备
pub fn cleanup_stale(&self, max_age: Duration) -> Vec<String>;
}
```
### 3. WebSocket 模块
WebSocket 实时聊天服务。
#### server.rs
```rust
pub struct WsServer {
port: u16,
running: Arc<AtomicBool>,
}
impl WsServer {
// 启动服务
pub async fn start(&self, app: AppHandle, connection_manager: ConnectionManager);
// 处理新连接
async fn handle_connection(
stream: TcpStream,
addr: SocketAddr,
app: AppHandle,
connection_manager: ConnectionManager,
);
}
```
#### client.rs
```rust
pub struct WsClient;
impl WsClient {
// 连接到设备
pub async fn connect(
device: &DeviceInfo,
local_device: &DeviceInfo,
connection_manager: &ConnectionManager,
) -> Result<()>;
// 发送消息
pub async fn send_message(
connection_manager: &ConnectionManager,
device_id: &str,
message: &str,
) -> Result<()>;
}
```
#### connection.rs
```rust
pub struct ConnectionManager {
// device_id -> write half of WebSocket
connections: DashMap<String, SplitSink<WebSocketStream<TcpStream>, Message>>,
}
impl ConnectionManager {
// 添加连接
pub fn add(&self, device_id: String, sink: SplitSink<...>);
// 移除连接
pub fn remove(&self, device_id: &str);
// 发送消息
pub async fn send(&self, device_id: &str, message: &str) -> Result<()>;
// 检查连接状态
pub fn is_connected(&self, device_id: &str) -> bool;
}
```
### 4. HTTP 模块
HTTPS 文件传输服务。
#### server.rs
```rust
pub struct HttpServer {
port: u16,
}
impl HttpServer {
// 启动服务
pub async fn start(&self, app: AppHandle, download_dir: PathBuf) -> Result<()>;
// 创建路由
fn create_router(app: AppHandle, download_dir: PathBuf) -> Router;
}
```
#### handlers.rs
```rust
// 文件上传处理
pub async fn upload_handler(
State(state): State<AppState>,
mut multipart: Multipart,
) -> impl IntoResponse;
// 文件下载处理
pub async fn download_handler(
Path(file_id): Path<String>,
State(state): State<AppState>,
) -> impl IntoResponse;
```
#### client.rs
```rust
pub struct HttpClient {
app_handle: Arc<RwLock<Option<AppHandle>>>,
}
impl HttpClient {
// 上传文件(支持取消)
pub async fn upload_file_with_cancel(
&self,
device: &DeviceInfo,
file_path: &PathBuf,
file_id: &str,
from_device: &str,
cancel_token: CancellationToken,
) -> Result<()>;
// 发送进度事件
fn emit_progress(&self, file_id: &str, progress: f64, ...);
}
```
### 5. TLS 模块
TLS 证书生成和配置。
#### certificate.rs
```rust
pub struct CertificateManager;
impl CertificateManager {
// 加载或生成证书
pub fn load_or_generate(data_dir: &Path) -> Result<(Vec<Certificate>, PrivateKey)>;
// 生成自签名证书
fn generate_self_signed() -> Result<(Certificate, PrivateKey)>;
}
```
#### config.rs
```rust
// 构建服务端 TLS 配置
pub fn build_server_config(certs: Vec<Certificate>, key: PrivateKey) -> Result<ServerConfig>;
// 构建客户端 TLS 配置(跳过证书验证)
pub fn build_client_config() -> Result<ClientConfig>;
```
### 6. Database 模块
SQLite 数据库操作。
#### schema.rs
```rust
// 初始化数据库表结构
pub fn init_database(conn: &Connection) -> Result<()>;
// 表结构:
// - chat_messages: 聊天消息
// - file_transfers: 文件传输记录
// - known_devices: 已知设备
// - app_settings: 应用设置
```
#### repository.rs
```rust
pub struct Database;
impl Database {
// 消息操作
pub fn save_message(message: &ChatMessage) -> Result<()>;
pub fn get_chat_history(device_id: &str, limit: usize, offset: usize) -> Result<Vec<ChatMessage>>;
// 传输操作
pub fn save_file_transfer(transfer: &FileTransfer) -> Result<()>;
pub fn update_transfer_progress(file_id: &str, progress: f64, ...) -> Result<()>;
pub fn get_transfer_history(device_id: &str, ...) -> Result<Vec<FileTransfer>>;
// 设备操作
pub fn save_known_device(device: &DeviceInfo) -> Result<()>;
pub fn get_known_devices() -> Result<Vec<DeviceInfo>>;
// 设置操作
pub fn get_setting(key: &str) -> Result<Option<String>>;
pub fn set_setting(key: &str, value: &str) -> Result<()>;
}
```
### 7. Models 模块
数据模型定义。
#### device.rs
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceInfo {
pub device_id: String, // UUID
pub device_name: String, // 显示名称
pub ip: String, // IP 地址
pub ws_port: u16, // WebSocket 端口
pub http_port: u16, // HTTP 端口
pub last_seen: i64, // 最后在线时间
}
```
#### message.rs
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub id: String,
pub from_device: String,
pub to_device: String,
pub content: String,
pub message_type: MessageType,
pub timestamp: i64,
pub is_read: bool,
}
```
#### file_transfer.rs
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileTransfer {
pub file_id: String,
pub name: String,
pub size: u64,
pub progress: f64,
pub status: TransferStatus,
pub mime_type: Option<String>,
pub from_device: String,
pub to_device: String,
pub local_path: Option<String>,
pub created_at: i64,
pub completed_at: Option<i64>,
pub transferred_bytes: u64,
}
pub enum TransferStatus {
Pending,
Transferring,
Completed,
Failed,
Cancelled,
}
```
#### events.rs
```rust
// 事件名称常量
pub mod event_names {
pub const DEVICE_FOUND: &str = "device:found";
pub const DEVICE_LOST: &str = "device:lost";
pub const CHAT_MESSAGE: &str = "chat:message";
pub const FILE_PROGRESS: &str = "file:progress";
}
// 传输进度事件
#[derive(Debug, Clone, Serialize)]
pub struct TransferProgressEvent {
pub file_id: String,
pub progress: f64,
pub transferred_bytes: u64,
pub total_bytes: u64,
pub status: TransferStatus,
pub file_name: Option<String>,
pub local_path: Option<String>,
}
```
### 8. State 模块
全局应用状态管理。
```rust
// 全局单例
static APP_STATE: OnceCell<Arc<AppStateInner>> = OnceCell::new();
pub struct AppStateInner {
pub local_device: RwLock<DeviceInfo>,
pub device_manager: DeviceManager,
pub discovery_service: RwLock<Option<DiscoveryService>>,
pub ws_server: RwLock<Option<WsServer>>,
pub ws_client: RwLock<Option<WsClient>>,
pub http_server: RwLock<Option<HttpServer>>,
pub http_client: HttpClient,
pub connection_manager: ConnectionManager,
pub app_data_dir: PathBuf,
pub transfer_cancellation_tokens: RwLock<HashMap<String, CancellationToken>>,
}
pub struct AppState;
impl AppState {
pub fn init(app_data_dir: PathBuf) -> Result<()>;
pub fn get() -> Arc<AppStateInner>;
}
```
## 错误处理
使用 `thiserror` 定义统一的错误类型:
```rust
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Database error: {0}")]
Database(#[from] rusqlite::Error),
#[error("WebSocket error: {0}")]
WebSocket(String),
#[error("TLS error: {0}")]
Tls(String),
#[error("File transfer error: {0}")]
FileTransfer(String),
#[error("{0}")]
General(String),
}
```
## 依赖关系
```
main.rs
└── lib.rs
├── commands/
│ ├── discovery_commands ──▶ discovery/, state
│ ├── chat_commands ──────▶ websocket/, database/, state
│ ├── file_commands ──────▶ http/, database/, state
│ └── config_commands ────▶ utils/, database/
├── state ──────────────────▶ models/, discovery/, websocket/, http/
└── database/ ──────────────▶ models/
```

@ -0,0 +1,552 @@
# 前端结构
本文档详细介绍 Vue 3 前端的代码结构、组件功能和状态管理。
## 目录结构
```
src/
├── api/ # Tauri 命令封装
│ └── index.ts
├── components/ # 可复用组件
│ ├── Sidebar.vue
│ ├── DeviceCard.vue
│ ├── MessageBubble.vue
│ ├── ChatInput.vue
│ ├── FileProgress.vue
│ └── LoadingSpinner.vue
├── pages/ # 页面组件
│ ├── DeviceList.vue
│ ├── ChatWindow.vue
│ ├── FileTransfer.vue
│ └── Settings.vue
├── stores/ # Pinia 状态管理
│ ├── deviceStore.ts
│ ├── chatStore.ts
│ └── settingsStore.ts
├── hooks/ # 组合式函数
│ ├── useEventListener.ts
│ └── useTheme.ts
├── types/ # TypeScript 类型
│ └── index.ts
├── router/ # Vue Router
│ └── index.ts
├── styles/ # 全局样式
│ └── main.css
├── App.vue # 根组件
└── main.ts # 入口文件
```
## 页面组件
### DeviceList.vue
设备列表页面,展示在线设备。
**功能**
- 显示所有在线设备
- 点击设备进入聊天
- 自动刷新设备列表
**模板结构**
```vue
<template>
<div class="device-list">
<header>在线设备</header>
<div v-if="loading">
<LoadingSpinner />
</div>
<div v-else-if="devices.length === 0">
<EmptyState message="暂无在线设备" />
</div>
<div v-else class="device-grid">
<DeviceCard
v-for="device in devices"
:key="device.deviceId"
:device="device"
@click="goToChat(device)"
/>
</div>
</div>
</template>
```
### ChatWindow.vue
聊天窗口页面,与指定设备聊天。
**功能**
- 显示聊天历史
- 发送/接收消息
- 发送文件
- 显示文件传输进度
- 点击文件消息打开文件位置
**关键逻辑**
```typescript
// 过滤当前设备的传输(仅显示进行中)
const deviceTransfers = computed(() => {
const result: FileTransfer[] = []
for (const [, transfer] of chatStore.transfers) {
const isRelated =
(transfer.fromDevice === localDeviceId.value && transfer.toDevice === deviceId.value) ||
(transfer.fromDevice === deviceId.value && transfer.toDevice === localDeviceId.value)
const isInProgress = transfer.status === 'pending' || transfer.status === 'transferring'
if (isRelated && isInProgress) {
result.push(transfer)
}
}
return result
})
// 点击文件消息打开位置
async function handleFileClick(fileName: string) {
// 查找传输记录,获取 localPath
await chatStore.openFileLocation(transfer.localPath)
}
```
### FileTransfer.vue
文件传输页面,展示所有传输记录。
**功能**
- 显示所有传输(进行中/已完成)
- 取消传输
- 打开文件位置
### Settings.vue
设置页面。
**功能**
- 修改设备名称
- 切换主题(系统/浅色/深色)
- 修改下载目录
## 组件详解
### Sidebar.vue
侧边导航栏。
```vue
<template>
<aside class="sidebar">
<nav>
<router-link to="/devices">
<MonitorSmartphone />
<span>设备</span>
</router-link>
<router-link to="/transfer">
<FolderSync />
<span>传输</span>
</router-link>
<router-link to="/settings">
<Settings />
<span>设置</span>
</router-link>
</nav>
<div class="device-info">
<span>{{ deviceName }}</span>
</div>
</aside>
</template>
```
### DeviceCard.vue
设备卡片组件。
**Props**
```typescript
interface Props {
device: DeviceInfo
}
```
**显示内容**
- 设备名称
- IP 地址
- 在线状态指示
### MessageBubble.vue
消息气泡组件。
**Props**
```typescript
interface Props {
message: ChatMessage
isOwn: boolean // 是否是自己发送的
}
```
**事件**
```typescript
const emit = defineEmits<{
'file-click': [fileName: string]
}>()
```
**支持的消息类型**
- `text`: 文本消息
- `image`: 图片消息
- `file`: 文件消息(可点击)
### ChatInput.vue
聊天输入框组件。
**事件**
```typescript
const emit = defineEmits<{
'send': [content: string]
'send-file': []
}>()
```
**功能**
- 文本输入
- Enter 发送
- 文件选择按钮
### FileProgress.vue
文件传输进度组件。
**Props**
```typescript
interface Props {
transfer: FileTransfer
isReceiver: boolean // 是否是接收方
}
```
**显示内容**
- 文件名
- 进度条
- 传输速度/状态
- 操作按钮(取消/打开位置)
### LoadingSpinner.vue
加载动画组件。
## 状态管理
### deviceStore.ts
管理设备相关状态。
```typescript
export const useDeviceStore = defineStore('device', () => {
// 状态
const devices = ref<Map<string, DeviceInfo>>(new Map())
const localDevice = ref<DeviceInfo | null>(null)
const loading = ref(false)
// 计算属性
const deviceList = computed(() => Array.from(devices.value.values()))
// 方法
async function startDiscovery()
async function stopDiscovery()
async function refreshDevices()
function addDevice(device: DeviceInfo)
function removeDevice(deviceId: string)
return { devices, localDevice, loading, deviceList, ... }
})
```
### chatStore.ts
管理聊天和文件传输状态。
```typescript
export const useChatStore = defineStore('chat', () => {
// 状态
const messages = ref<Map<string, ChatMessage[]>>(new Map()) // deviceId -> messages
const transfers = ref<Map<string, FileTransfer>>(new Map()) // fileId -> transfer
const currentDevice = ref<string | null>(null)
const pendingFile = ref<FileMetadata | null>(null)
// 方法
async function loadHistory(deviceId: string)
async function sendMessage(deviceId: string, content: string)
async function sendFile(deviceId: string, file: FileMetadata)
function updateTransferProgress(event: TransferProgressEvent)
async function loadTransferHistory(deviceId: string, force?: boolean)
async function openFileLocation(path: string)
async function cancelTransfer(fileId: string)
return { messages, transfers, ... }
})
```
### settingsStore.ts
管理应用设置。
```typescript
export const useSettingsStore = defineStore('settings', () => {
// 状态
const theme = ref<'system' | 'light' | 'dark'>('system')
const deviceName = ref('')
const downloadDir = ref('')
// 方法
async function loadSettings()
async function updateTheme(newTheme: Theme)
async function updateDeviceName(name: string)
async function updateDownloadDir(path: string)
return { theme, deviceName, downloadDir, ... }
})
```
## API 封装
### api/index.ts
```typescript
import { invoke } from '@tauri-apps/api/core'
// 设备发现 API
export const discoveryApi = {
start: () => invoke('start_discovery'),
stop: () => invoke('stop_discovery'),
getDevices: () => invoke<DeviceInfo[]>('get_online_devices'),
getLocalDevice: () => invoke<DeviceInfo>('get_local_device'),
}
// 聊天 API
export const chatApi = {
startServer: () => invoke('start_ws_server'),
connect: (deviceId: string) => invoke('connect_to_device', { deviceId }),
send: (deviceId: string, content: string, messageType?: string) =>
invoke<ChatMessage>('send_chat_message', { deviceId, content, messageType }),
getHistory: (deviceId: string, limit?: number, offset?: number) =>
invoke<ChatMessage[]>('get_chat_history', { deviceId, limit, offset }),
}
// 文件传输 API
export const fileApi = {
startServer: () => invoke('start_http_server'),
selectFile: () => invoke<FileMetadata | null>('select_file'),
send: (deviceId: string, fileId: string, filePath: string) =>
invoke<FileTransfer>('send_file', { deviceId, fileId, filePath }),
getHistory: (deviceId: string, limit?: number) =>
invoke<FileTransfer[]>('get_transfer_history', { deviceId, limit }),
openLocation: (path: string) => invoke('open_file_location', { path }),
cancelTransfer: (fileId: string) => invoke('cancel_transfer', { fileId }),
}
// 配置 API
export const configApi = {
get: () => invoke<AppConfig>('get_app_config'),
updateDeviceName: (name: string) => invoke('update_device_name', { name }),
updateDownloadDir: (path: string) => invoke('update_download_dir', { path }),
}
```
## 事件监听
### hooks/useEventListener.ts
```typescript
import { listen, UnlistenFn } from '@tauri-apps/api/event'
import { onMounted, onUnmounted } from 'vue'
export function useEventListener<T>(
eventName: string,
handler: (payload: T) => void
) {
let unlisten: UnlistenFn | null = null
onMounted(async () => {
unlisten = await listen<T>(eventName, (event) => {
handler(event.payload)
})
})
onUnmounted(() => {
unlisten?.()
})
}
```
**使用示例**
```typescript
// 在组件中监听设备发现事件
useEventListener<DeviceInfo>('device:found', (device) => {
deviceStore.addDevice(device)
})
// 监听文件传输进度
useEventListener<TransferProgressEvent>('file:progress', (event) => {
chatStore.updateTransferProgress(event)
})
```
## 路由配置
### router/index.ts
```typescript
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
redirect: '/devices'
},
{
path: '/devices',
name: 'devices',
component: () => import('../pages/DeviceList.vue')
},
{
path: '/chat/:deviceId',
name: 'chat',
component: () => import('../pages/ChatWindow.vue'),
props: true
},
{
path: '/transfer',
name: 'transfer',
component: () => import('../pages/FileTransfer.vue')
},
{
path: '/settings',
name: 'settings',
component: () => import('../pages/Settings.vue')
}
]
export const router = createRouter({
history: createWebHistory(),
routes
})
```
## 类型定义
### types/index.ts
```typescript
// 设备信息
export interface DeviceInfo {
deviceId: string
deviceName: string
ip: string
wsPort: number
httpPort: number
lastSeen: number
}
// 聊天消息
export interface ChatMessage {
id: string
fromDevice: string
toDevice: string
content: string
messageType: MessageType
timestamp: number
isRead: boolean
}
export type MessageType = 'text' | 'image' | 'file' | 'system'
// 文件传输
export interface FileTransfer {
fileId: string
name: string
size: number
progress: number
status: TransferStatus
mimeType?: string
fromDevice: string
toDevice: string
localPath?: string
createdAt: number
completedAt?: number
transferredBytes: number
}
export type TransferStatus = 'pending' | 'transferring' | 'completed' | 'failed' | 'cancelled'
// 传输进度事件
export interface TransferProgressEvent {
fileId: string
progress: number
transferredBytes: number
totalBytes: number
status: TransferStatus
fileName?: string
localPath?: string
}
// 文件元数据
export interface FileMetadata {
name: string
path: string
size: number
mimeType?: string
}
// 应用配置
export interface AppConfig {
deviceId: string
deviceName: string
downloadDir: string
wsPort: number
httpPort: number
}
```
## 样式系统
使用 TailwindCSS 进行样式管理。
### tailwind.config.js
```javascript
module.exports = {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
darkMode: 'class', // 支持 class 模式的深色主题
theme: {
extend: {
colors: {
primary: {...},
secondary: {...},
}
}
},
plugins: []
}
```
### 深色模式
通过在 `<html>` 标签添加 `dark` class 来切换深色模式:
```typescript
// settingsStore.ts
function applyTheme(theme: Theme) {
if (theme === 'dark' ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
```
**组件中使用**
```vue
<div class="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
<!-- 内容 -->
</div>
```

@ -0,0 +1,555 @@
# API 文档
本文档详细介绍 Flash Send 的 Tauri 命令 API 和事件系统。
## Tauri 命令
所有命令通过 `@tauri-apps/api/core``invoke` 函数调用。
### 设备发现
#### start_discovery
启动设备发现服务,开始广播本机信息并监听其他设备。
```typescript
invoke('start_discovery')
```
**返回**: `Promise<void>`
---
#### stop_discovery
停止设备发现服务。
```typescript
invoke('stop_discovery')
```
**返回**: `Promise<void>`
---
#### get_online_devices
获取当前在线的设备列表。
```typescript
invoke<DeviceInfo[]>('get_online_devices')
```
**返回**: `Promise<DeviceInfo[]>`
```typescript
interface DeviceInfo {
deviceId: string // 设备唯一标识
deviceName: string // 设备名称
ip: string // IP 地址
wsPort: number // WebSocket 端口
httpPort: number // HTTP 端口
lastSeen: number // 最后在线时间戳
}
```
---
#### get_local_device
获取本机设备信息。
```typescript
invoke<DeviceInfo>('get_local_device')
```
**返回**: `Promise<DeviceInfo>`
---
### 聊天通信
#### start_ws_server
启动 WebSocket 服务端,接收其他设备的连接。
```typescript
invoke('start_ws_server')
```
**返回**: `Promise<void>`
---
#### connect_to_device
连接到指定设备的 WebSocket 服务。
```typescript
invoke('connect_to_device', { deviceId: string })
```
**参数**:
| 参数 | 类型 | 说明 |
|------|------|------|
| deviceId | string | 目标设备 ID |
**返回**: `Promise<void>`
---
#### send_chat_message
发送聊天消息。
```typescript
invoke<ChatMessage>('send_chat_message', {
deviceId: string,
content: string,
messageType?: string
})
```
**参数**:
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| deviceId | string | - | 目标设备 ID |
| content | string | - | 消息内容 |
| messageType | string | 'text' | 消息类型text/image/file |
**返回**: `Promise<ChatMessage>`
```typescript
interface ChatMessage {
id: string
fromDevice: string
toDevice: string
content: string
messageType: 'text' | 'image' | 'file' | 'system'
timestamp: number
isRead: boolean
}
```
---
#### get_chat_history
获取与指定设备的聊天历史。
```typescript
invoke<ChatMessage[]>('get_chat_history', {
deviceId: string,
limit?: number,
offset?: number
})
```
**参数**:
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| deviceId | string | - | 目标设备 ID |
| limit | number | 50 | 返回条数限制 |
| offset | number | 0 | 偏移量 |
**返回**: `Promise<ChatMessage[]>`
---
#### delete_chat_history
删除与指定设备的聊天历史。
```typescript
invoke('delete_chat_history', { deviceId: string })
```
**参数**:
| 参数 | 类型 | 说明 |
|------|------|------|
| deviceId | string | 目标设备 ID |
**返回**: `Promise<void>`
---
### 文件传输
#### start_http_server
启动 HTTPS 文件服务,接收文件上传。
```typescript
invoke('start_http_server')
```
**返回**: `Promise<void>`
---
#### select_file
打开文件选择对话框。
```typescript
invoke<FileMetadata | null>('select_file')
```
**返回**: `Promise<FileMetadata | null>`
```typescript
interface FileMetadata {
name: string // 文件名
path: string // 文件完整路径
size: number // 文件大小(字节)
mimeType?: string // MIME 类型
}
```
---
#### send_file
发送文件到指定设备。
```typescript
invoke<FileTransfer>('send_file', {
deviceId: string,
fileId: string,
filePath: string
})
```
**参数**:
| 参数 | 类型 | 说明 |
|------|------|------|
| deviceId | string | 目标设备 ID |
| fileId | string | 文件传输 IDUUID |
| filePath | string | 本地文件路径 |
**返回**: `Promise<FileTransfer>`
```typescript
interface FileTransfer {
fileId: string
name: string
size: number
progress: number // 0.0 - 1.0
status: TransferStatus
mimeType?: string
fromDevice: string
toDevice: string
localPath?: string
createdAt: number
completedAt?: number
transferredBytes: number
}
type TransferStatus = 'pending' | 'transferring' | 'completed' | 'failed' | 'cancelled'
```
---
#### cancel_transfer
取消文件传输。
```typescript
invoke('cancel_transfer', { fileId: string })
```
**参数**:
| 参数 | 类型 | 说明 |
|------|------|------|
| fileId | string | 传输 ID |
**返回**: `Promise<void>`
---
#### get_transfer_history
获取与指定设备的文件传输历史。
```typescript
invoke<FileTransfer[]>('get_transfer_history', {
deviceId: string,
limit?: number
})
```
**参数**:
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| deviceId | string | - | 目标设备 ID |
| limit | number | 50 | 返回条数限制 |
**返回**: `Promise<FileTransfer[]>`
---
#### open_file_location
在系统文件管理器中打开文件所在位置。
```typescript
invoke('open_file_location', { path: string })
```
**参数**:
| 参数 | 类型 | 说明 |
|------|------|------|
| path | string | 文件路径 |
**返回**: `Promise<void>`
**平台行为**:
- **Windows**: `explorer /select, <path>`
- **macOS**: `open -R <path>`
- **Linux**: `xdg-open <dir>`
---
### 配置管理
#### get_app_config
获取应用配置。
```typescript
invoke<AppConfig>('get_app_config')
```
**返回**: `Promise<AppConfig>`
```typescript
interface AppConfig {
deviceId: string // 本机设备 ID
deviceName: string // 设备名称
downloadDir: string // 下载目录
wsPort: number // WebSocket 端口
httpPort: number // HTTP 端口
}
```
---
#### update_device_name
更新设备名称。
```typescript
invoke('update_device_name', { name: string })
```
**参数**:
| 参数 | 类型 | 说明 |
|------|------|------|
| name | string | 新的设备名称 |
**返回**: `Promise<void>`
---
#### update_download_dir
更新文件下载目录。
```typescript
invoke('update_download_dir', { path: string })
```
**参数**:
| 参数 | 类型 | 说明 |
|------|------|------|
| path | string | 新的下载目录路径 |
**返回**: `Promise<void>`
---
## 事件系统
通过 `@tauri-apps/api/event``listen` 函数监听后端事件。
### device:found
发现新设备时触发。
```typescript
import { listen } from '@tauri-apps/api/event'
listen<DeviceInfo>('device:found', (event) => {
console.log('发现设备:', event.payload)
})
```
**Payload**: `DeviceInfo`
---
### device:lost
设备离线时触发。
```typescript
listen<string>('device:lost', (event) => {
console.log('设备离线:', event.payload) // device_id
})
```
**Payload**: `string` (device_id)
---
### chat:message
收到聊天消息时触发。
```typescript
listen<ChatMessage>('chat:message', (event) => {
console.log('收到消息:', event.payload)
})
```
**Payload**: `ChatMessage`
---
### file:progress
文件传输进度更新时触发。
```typescript
listen<TransferProgressEvent>('file:progress', (event) => {
console.log('传输进度:', event.payload)
})
```
**Payload**: `TransferProgressEvent`
```typescript
interface TransferProgressEvent {
fileId: string // 传输 ID
progress: number // 进度 0.0 - 1.0
transferredBytes: number // 已传输字节数
totalBytes: number // 总字节数
status: TransferStatus // 传输状态
fileName?: string // 文件名(接收方)
localPath?: string // 本地路径(接收方)
}
```
---
## 错误处理
所有命令在出错时会抛出 `CommandError`
```typescript
interface CommandError {
code: string // 错误代码
message: string // 错误信息
}
```
**常见错误代码**:
| 代码 | 说明 |
|------|------|
| `DB_ERROR` | 数据库操作错误 |
| `IO_ERROR` | 文件 IO 错误 |
| `WS_ERROR` | WebSocket 错误 |
| `DEVICE_NOT_FOUND` | 设备不存在 |
| `NOT_CONNECTED` | 未建立连接 |
| `TRANSFER_ERROR` | 传输错误 |
**使用示例**
```typescript
try {
await invoke('send_chat_message', { deviceId, content })
} catch (error) {
const err = error as CommandError
if (err.code === 'NOT_CONNECTED') {
// 尝试重新连接
await invoke('connect_to_device', { deviceId })
}
}
```
---
## 完整示例
### 设备发现与聊天
```typescript
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
// 1. 监听设备发现事件
const unlistenDevice = await listen<DeviceInfo>('device:found', (event) => {
console.log('发现设备:', event.payload.deviceName)
})
// 2. 监听消息事件
const unlistenMessage = await listen<ChatMessage>('chat:message', (event) => {
console.log('收到消息:', event.payload.content)
})
// 3. 启动服务
await invoke('start_ws_server')
await invoke('start_discovery')
// 4. 获取在线设备
const devices = await invoke<DeviceInfo[]>('get_online_devices')
// 5. 连接设备并发送消息
if (devices.length > 0) {
const device = devices[0]
await invoke('connect_to_device', { deviceId: device.deviceId })
await invoke('send_chat_message', {
deviceId: device.deviceId,
content: 'Hello!'
})
}
// 清理
unlistenDevice()
unlistenMessage()
await invoke('stop_discovery')
```
### 文件传输
```typescript
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
// 1. 监听传输进度
const unlisten = await listen<TransferProgressEvent>('file:progress', (event) => {
const { fileId, progress, status } = event.payload
console.log(`${fileId}: ${(progress * 100).toFixed(1)}% - ${status}`)
})
// 2. 启动文件服务
await invoke('start_http_server')
// 3. 选择文件
const file = await invoke<FileMetadata | null>('select_file')
if (!file) return
// 4. 发送文件
const fileId = crypto.randomUUID()
const transfer = await invoke<FileTransfer>('send_file', {
deviceId: targetDeviceId,
fileId,
filePath: file.path
})
console.log('开始传输:', transfer.name)
// 清理
unlisten()
```

@ -0,0 +1,352 @@
# 通信协议
本文档详细介绍 Flash Send 使用的网络通信协议。
## 概述
Flash Send 使用三种协议进行通信:
| 协议 | 端口 | 用途 | 加密 |
|------|------|------|------|
| UDP | 53317 | 设备发现 | 无 |
| WebSocket | 53318 | 即时聊天 | 无(局域网) |
| HTTPS | 53319 | 文件传输 | TLS |
## 设备发现协议
### 广播机制
使用 UDP 广播在局域网内发现设备。
**广播地址**: `255.255.255.255:53317`
**广播频率**: 每 3 秒一次
### 消息格式
所有消息使用 JSON 格式,以换行符 `\n` 结尾。
#### 广播消息 (Announce)
设备向局域网广播自身信息:
```json
{
"type": "announce",
"device": {
"device_id": "550e8400-e29b-41d4-a716-446655440000",
"device_name": "My Computer",
"ip": "192.168.1.100",
"ws_port": 53318,
"http_port": 53319,
"last_seen": 1701388800
}
}
```
#### 响应消息 (Response)
收到广播后,设备回复自身信息:
```json
{
"type": "response",
"device": {
"device_id": "661e9500-f30c-52e5-b827-557766551111",
"device_name": "Other PC",
"ip": "192.168.1.101",
"ws_port": 53318,
"http_port": 53319,
"last_seen": 1701388805
}
}
```
### 设备状态
- **在线**: 每 3 秒收到广播/响应
- **离线**: 超过 15 秒未收到任何消息
### 流程图
```
┌─────────────┐ ┌─────────────┐
│ Device A │ │ Device B │
└──────┬──────┘ └──────┬──────┘
│ │
│ UDP Broadcast (announce) │
│ 255.255.255.255:53317 │
│ ─────────────────────────────────────────▶ │
│ │
│ UDP Response │
│ ◀───────────────────────────────────────── │
│ │
│ Device A adds Device B to list │
│ │
│ Device B adds │
│ Device A to list │
│ │
```
---
## WebSocket 聊天协议
### 连接建立
1. 客户端连接到目标设备的 WebSocket 服务
2. 发送握手消息
3. 服务端确认后,连接建立
**连接地址**: `ws://{ip}:{ws_port}`
### 消息格式
所有消息使用 JSON 格式。
#### 握手消息 (Handshake)
客户端首先发送握手消息:
```json
{
"type": "handshake",
"device_id": "550e8400-e29b-41d4-a716-446655440000",
"device_name": "My Computer"
}
```
#### 聊天消息 (Chat)
```json
{
"type": "chat",
"message": {
"id": "msg-uuid-12345",
"from_device": "550e8400-e29b-41d4-a716-446655440000",
"to_device": "661e9500-f30c-52e5-b827-557766551111",
"content": "Hello, World!",
"message_type": "text",
"timestamp": 1701388800000
}
}
```
**message_type 枚举**:
- `text`: 普通文本
- `image`: 图片content 为 base64 或路径)
- `file`: 文件content 为文件名)
- `system`: 系统消息
#### 确认消息 (Ack)
```json
{
"type": "ack",
"message_id": "msg-uuid-12345"
}
```
#### 心跳消息 (Ping/Pong)
```json
{
"type": "ping"
}
```
```json
{
"type": "pong"
}
```
### 连接管理
- **心跳间隔**: 30 秒
- **超时断开**: 90 秒无响应
- **自动重连**: 断开后尝试重连
### 流程图
```
┌─────────────┐ ┌─────────────┐
│ Client │ │ Server │
└──────┬──────┘ └──────┬──────┘
│ │
│ WebSocket Connect │
│ ─────────────────────────────────────────▶ │
│ │
│ Handshake │
│ ─────────────────────────────────────────▶ │
│ │
│ Connection OK │
│ ◀───────────────────────────────────────── │
│ │
│ Chat Message │
│ ─────────────────────────────────────────▶ │
│ │
│ Ack │
│ ◀───────────────────────────────────────── │
│ │
│ Ping │
│ ─────────────────────────────────────────▶ │
│ │
│ Pong │
│ ◀───────────────────────────────────────── │
│ │
```
---
## HTTPS 文件传输协议
### TLS 配置
- **证书类型**: 自签名 RSA 2048
- **证书有效期**: 365 天
- **客户端验证**: 跳过(局域网内互信)
### 端点
#### POST /upload
上传文件到服务端。
**Content-Type**: `multipart/form-data`
**表单字段**:
| 字段 | 类型 | 说明 |
|------|------|------|
| file_id | string | 文件传输 ID |
| from_device | string | 发送方设备 ID |
| file | binary | 文件内容 |
**请求示例**:
```http
POST /upload HTTP/1.1
Host: 192.168.1.100:53319
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Length: 12345
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file_id"
550e8400-e29b-41d4-a716-446655440000
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="from_device"
661e9500-f30c-52e5-b827-557766551111
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="document.pdf"
Content-Type: application/pdf
<binary data>
------WebKitFormBoundary7MA4YWxkTrZu0gW--
```
**响应**:
```http
HTTP/1.1 200 OK
Content-Type: application/json
{
"success": true,
"file_id": "550e8400-e29b-41d4-a716-446655440000",
"file_name": "document.pdf",
"file_size": 12345
}
```
#### GET /download/{file_id}
从服务端下载文件(预留接口)。
### 传输流程
```
┌─────────────┐ ┌─────────────┐
│ Sender │ │ Receiver │
└──────┬──────┘ └──────┬──────┘
│ │
│ 1. Create FileTransfer record │
│ (status: pending) │
│ │
│ 2. TLS Handshake │
│ ─────────────────────────────────────────▶ │
│ │
│ 3. POST /upload (multipart) │
│ ─────────────────────────────────────────▶ │
│ [chunk 1] │
│ emit progress event │
│ ─────────────────────────────────────────▶ │
│ [chunk 2] │ 4. Save to disk
│ emit progress event │ emit progress event
│ ─────────────────────────────────────────▶ │
│ [chunk n] │
│ emit progress event │
│ │
│ HTTP 200 OK │
│ ◀───────────────────────────────────────── │
│ │
│ 5. Update status: completed │ 5. Update status: completed
│ emit final progress event │ emit final progress event
│ │
```
### 分块传输
大文件使用分块传输,每块大小为 **64KB**
**进度计算**:
```
progress = transferred_bytes / total_bytes
```
**进度事件**:
- 每发送一个块后触发
- 包含当前进度、已传输字节数、总字节数
### 取消机制
1. 发送方调用 `cancel_transfer`
2. 触发 CancellationToken
3. 上传循环检测到取消,中断传输
4. 更新状态为 `cancelled`
5. 发送进度事件通知前端
---
## 安全考虑
### 局域网信任模型
Flash Send 假设局域网内的设备是可信的:
- UDP 广播不加密(仅设备发现)
- WebSocket 不加密(聊天内容)
- HTTP 使用 TLS文件传输
### TLS 证书
- 应用首次启动时生成自签名证书
- 证书存储在用户数据目录
- 客户端跳过证书验证(`dangerous_configuration`
### 建议
如果需要更高安全性:
1. 添加设备配对/授权机制
2. 使用端到端加密
3. 添加消息签名验证
---
## 端口冲突处理
如果默认端口被占用:
1. **检测**: 启动时检查端口可用性
2. **提示**: 通知用户端口冲突
3. **配置**: 允许用户在设置中修改端口
**端口范围建议**: 53317 - 53399

@ -0,0 +1,449 @@
# 数据库设计
本文档介绍 Flash Send 的 SQLite 数据库设计。
## 概述
Flash Send 使用 SQLite 作为本地数据库,存储:
- 聊天消息记录
- 文件传输记录
- 已知设备信息
- 应用设置
**数据库文件位置**: `{app_data_dir}/flash_send.db`
## 表结构
### chat_messages
存储聊天消息。
```sql
CREATE TABLE IF NOT EXISTS chat_messages (
id TEXT PRIMARY KEY, -- 消息唯一 ID (UUID)
from_device TEXT NOT NULL, -- 发送方设备 ID
to_device TEXT NOT NULL, -- 接收方设备 ID
content TEXT NOT NULL, -- 消息内容
message_type TEXT NOT NULL, -- 消息类型 (text/image/file/system)
timestamp INTEGER NOT NULL, -- 时间戳 (毫秒)
is_read INTEGER DEFAULT 0 -- 是否已读 (0/1)
);
-- 索引:按设备和时间查询
CREATE INDEX IF NOT EXISTS idx_messages_devices
ON chat_messages(from_device, to_device);
CREATE INDEX IF NOT EXISTS idx_messages_timestamp
ON chat_messages(timestamp);
```
**字段说明**:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | TEXT | 消息 UUID |
| from_device | TEXT | 发送方设备 ID |
| to_device | TEXT | 接收方设备 ID |
| content | TEXT | 消息内容 |
| message_type | TEXT | text/image/file/system |
| timestamp | INTEGER | Unix 时间戳(毫秒) |
| is_read | INTEGER | 0=未读, 1=已读 |
---
### file_transfers
存储文件传输记录。
```sql
CREATE TABLE IF NOT EXISTS file_transfers (
file_id TEXT PRIMARY KEY, -- 传输唯一 ID (UUID)
name TEXT NOT NULL, -- 文件名
size INTEGER NOT NULL, -- 文件大小 (字节)
progress REAL DEFAULT 0, -- 传输进度 (0.0-1.0)
status TEXT NOT NULL, -- 传输状态 (JSON)
mime_type TEXT, -- MIME 类型
from_device TEXT NOT NULL, -- 发送方设备 ID
to_device TEXT NOT NULL, -- 接收方设备 ID
local_path TEXT, -- 本地文件路径
created_at INTEGER NOT NULL, -- 创建时间
completed_at INTEGER, -- 完成时间
transferred_bytes INTEGER DEFAULT 0 -- 已传输字节数
);
-- 索引:按设备查询
CREATE INDEX IF NOT EXISTS idx_transfers_devices
ON file_transfers(from_device, to_device);
CREATE INDEX IF NOT EXISTS idx_transfers_created
ON file_transfers(created_at);
```
**字段说明**:
| 字段 | 类型 | 说明 |
|------|------|------|
| file_id | TEXT | 传输 UUID |
| name | TEXT | 文件名 |
| size | INTEGER | 文件大小(字节) |
| progress | REAL | 进度 0.0-1.0 |
| status | TEXT | JSON: "pending"/"transferring"/"completed"/"failed"/"cancelled" |
| mime_type | TEXT | MIME 类型 |
| from_device | TEXT | 发送方设备 ID |
| to_device | TEXT | 接收方设备 ID |
| local_path | TEXT | 本地存储路径 |
| created_at | INTEGER | 创建时间戳 |
| completed_at | INTEGER | 完成时间戳 |
| transferred_bytes | INTEGER | 已传输字节数 |
**status 字段格式**:
```json
"\"pending\""
"\"transferring\""
"\"completed\""
"\"failed\""
"\"cancelled\""
```
---
### known_devices
存储已知设备信息。
```sql
CREATE TABLE IF NOT EXISTS known_devices (
device_id TEXT PRIMARY KEY, -- 设备唯一 ID
device_name TEXT NOT NULL, -- 设备名称
ip TEXT, -- 最后已知 IP
ws_port INTEGER, -- WebSocket 端口
http_port INTEGER, -- HTTP 端口
last_seen INTEGER -- 最后在线时间
);
```
**字段说明**:
| 字段 | 类型 | 说明 |
|------|------|------|
| device_id | TEXT | 设备 UUID |
| device_name | TEXT | 显示名称 |
| ip | TEXT | IP 地址 |
| ws_port | INTEGER | WebSocket 端口 |
| http_port | INTEGER | HTTP 端口 |
| last_seen | INTEGER | 最后在线时间戳 |
---
### app_settings
存储应用设置。
```sql
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY, -- 设置键名
value TEXT -- 设置值
);
```
**常用设置键**:
| 键名 | 说明 | 示例值 |
|------|------|--------|
| theme | 主题模式 | system/light/dark |
| device_name | 设备名称 | My Computer |
| download_dir | 下载目录 | C:\Downloads |
| language | 语言 | zh-CN |
---
## 数据操作
### 消息操作
#### 保存消息
```rust
pub fn save_message(message: &ChatMessage) -> Result<()> {
conn.execute(
"INSERT INTO chat_messages
(id, from_device, to_device, content, message_type, timestamp, is_read)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
message.id,
message.from_device,
message.to_device,
message.content,
message.message_type.to_string(),
message.timestamp,
message.is_read as i32,
],
)?;
Ok(())
}
```
#### 获取聊天历史
```rust
pub fn get_chat_history(
device_id: &str,
local_device_id: &str,
limit: usize,
offset: usize,
) -> Result<Vec<ChatMessage>> {
let mut stmt = conn.prepare(
"SELECT id, from_device, to_device, content, message_type, timestamp, is_read
FROM chat_messages
WHERE (from_device = ?1 AND to_device = ?2)
OR (from_device = ?2 AND to_device = ?1)
ORDER BY timestamp DESC
LIMIT ?3 OFFSET ?4"
)?;
// ...
}
```
#### 删除聊天历史
```rust
pub fn delete_chat_history(device_id: &str, local_device_id: &str) -> Result<()> {
conn.execute(
"DELETE FROM chat_messages
WHERE (from_device = ?1 AND to_device = ?2)
OR (from_device = ?2 AND to_device = ?1)",
params![device_id, local_device_id],
)?;
Ok(())
}
```
---
### 传输操作
#### 保存传输记录
```rust
pub fn save_file_transfer(transfer: &FileTransfer) -> Result<()> {
conn.execute(
"INSERT INTO file_transfers
(file_id, name, size, progress, status, mime_type,
from_device, to_device, local_path, created_at, completed_at, transferred_bytes)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
ON CONFLICT(file_id) DO UPDATE SET
progress = ?4, status = ?5, local_path = ?9,
completed_at = ?11, transferred_bytes = ?12",
params![...],
)?;
Ok(())
}
```
#### 更新传输进度
```rust
pub fn update_transfer_progress(
file_id: &str,
progress: f64,
transferred_bytes: u64,
status: &str,
) -> Result<()> {
let completed_at = if status == "completed" || status == "failed" {
Some(chrono::Utc::now().timestamp())
} else {
None
};
conn.execute(
"UPDATE file_transfers
SET progress = ?1, transferred_bytes = ?2, status = ?3, completed_at = ?4
WHERE file_id = ?5",
params![progress, transferred_bytes, status, completed_at, file_id],
)?;
Ok(())
}
```
#### 获取传输历史
```rust
pub fn get_transfer_history(
device_id: &str,
local_device_id: &str,
limit: usize,
) -> Result<Vec<FileTransfer>> {
let mut stmt = conn.prepare(
"SELECT file_id, name, size, progress, status, mime_type,
from_device, to_device, local_path, created_at, completed_at, transferred_bytes
FROM file_transfers
WHERE (from_device = ?1 AND to_device = ?2)
OR (from_device = ?2 AND to_device = ?1)
ORDER BY created_at DESC
LIMIT ?3"
)?;
// ...
}
```
---
### 设备操作
#### 保存已知设备
```rust
pub fn save_known_device(device: &DeviceInfo) -> Result<()> {
conn.execute(
"INSERT INTO known_devices
(device_id, device_name, ip, ws_port, http_port, last_seen)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(device_id) DO UPDATE SET
device_name = ?2, ip = ?3, ws_port = ?4, http_port = ?5, last_seen = ?6",
params![...],
)?;
Ok(())
}
```
---
### 设置操作
#### 获取设置
```rust
pub fn get_setting(key: &str) -> Result<Option<String>> {
let mut stmt = conn.prepare(
"SELECT value FROM app_settings WHERE key = ?1"
)?;
stmt.query_row(params![key], |row| row.get(0)).optional()
}
```
#### 保存设置
```rust
pub fn set_setting(key: &str, value: &str) -> Result<()> {
conn.execute(
"INSERT INTO app_settings (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = ?2",
params![key, value],
)?;
Ok(())
}
```
---
## 数据库迁移
### 迁移机制
使用版本号管理数据库迁移:
```rust
pub fn init_database(conn: &Connection) -> Result<()> {
// 创建版本表
conn.execute(
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER)",
[],
)?;
let version: i32 = conn
.query_row("SELECT version FROM schema_version", [], |row| row.get(0))
.unwrap_or(0);
// 根据版本执行迁移
if version < 1 {
// 创建初始表结构
create_initial_tables(conn)?;
update_version(conn, 1)?;
}
if version < 2 {
// 添加 transferred_bytes 字段
add_transferred_bytes_column(conn)?;
update_version(conn, 2)?;
}
Ok(())
}
```
### 迁移示例
添加 `transferred_bytes` 字段:
```rust
fn add_transferred_bytes_column(conn: &Connection) -> Result<()> {
// 检查字段是否存在
let columns: Vec<String> = conn
.prepare("PRAGMA table_info(file_transfers)")?
.query_map([], |row| row.get::<_, String>(1))?
.collect::<Result<Vec<_>, _>>()?;
if !columns.contains(&"transferred_bytes".to_string()) {
conn.execute(
"ALTER TABLE file_transfers ADD COLUMN transferred_bytes INTEGER DEFAULT 0",
[],
)?;
}
Ok(())
}
```
---
## 数据清理
### 自动清理策略
- **消息**: 保留最近 1000 条/设备
- **传输记录**: 保留最近 100 条/设备
- **离线设备**: 30 天未见自动删除
### 手动清理
```rust
// 清理指定设备的所有数据
pub fn cleanup_device_data(device_id: &str) -> Result<()> {
conn.execute("DELETE FROM chat_messages WHERE from_device = ?1 OR to_device = ?1", [device_id])?;
conn.execute("DELETE FROM file_transfers WHERE from_device = ?1 OR to_device = ?1", [device_id])?;
conn.execute("DELETE FROM known_devices WHERE device_id = ?1", [device_id])?;
Ok(())
}
```
---
## 性能优化
### 索引策略
- 按设备 ID 查询:`idx_messages_devices`, `idx_transfers_devices`
- 按时间排序:`idx_messages_timestamp`, `idx_transfers_created`
### 连接池
使用全局单例连接,通过 `parking_lot::Mutex` 保护:
```rust
static DB_CONN: OnceCell<Mutex<Connection>> = OnceCell::new();
pub fn conn() -> MutexGuard<'static, Connection> {
DB_CONN.get().expect("Database not initialized").lock()
}
```
### 事务使用
批量操作使用事务提升性能:
```rust
conn.execute("BEGIN TRANSACTION", [])?;
for message in messages {
save_message(&message)?;
}
conn.execute("COMMIT", [])?;
```

@ -0,0 +1,492 @@
# 开发指南
本文档介绍 Flash Send 的开发环境搭建、开发流程和构建部署。
## 环境要求
### 系统要求
| 平台 | 最低版本 |
|------|----------|
| Windows | Windows 10 |
| macOS | 10.15+ |
| Linux | Ubuntu 18.04+ |
### 开发工具
| 工具 | 版本要求 | 用途 |
|------|----------|------|
| Node.js | >= 18.0 | 前端构建 |
| Rust | >= 1.70 | 后端开发 |
| pnpm/npm/yarn | 最新版 | 包管理 |
### 推荐 IDE
- **VS Code** + 插件:
- Vue - Official
- rust-analyzer
- Tailwind CSS IntelliSense
- Tauri
- **RustRover** / **WebStorm**
---
## 环境搭建
### 1. 克隆项目
```bash
git clone https://github.com/your-repo/flash-send.git
cd flash-send
```
### 2. 安装 Node.js 依赖
```bash
npm install
# 或
pnpm install
# 或
yarn install
```
### 3. 安装 Rust
```bash
# Windows (使用 rustup-init.exe)
# https://rustup.rs/
# macOS / Linux
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# 验证安装
rustc --version
cargo --version
```
### 4. 安装 Tauri CLI
```bash
npm install -g @tauri-apps/cli
# 或
cargo install tauri-cli
```
### 5. 系统依赖 (Linux)
```bash
# Ubuntu/Debian
sudo apt update
sudo apt install libwebkit2gtk-4.0-dev \
build-essential \
curl \
wget \
file \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev
```
---
## 开发命令
### 启动开发服务器
```bash
npm run tauri dev
```
这会同时启动:
- Vite 开发服务器(前端热重载)
- Tauri 开发窗口(后端实时编译)
### 仅启动前端
```bash
npm run dev
```
### 类型检查
```bash
# 前端 TypeScript
npm run build # 包含 vue-tsc --noEmit
# 后端 Rust
cd src-tauri
cargo check
```
### 代码格式化
```bash
# 前端
npx prettier --write src/
# 后端
cd src-tauri
cargo fmt
```
### 代码检查
```bash
# 后端
cd src-tauri
cargo clippy
```
---
## 项目结构说明
```
flash-send/
├── src/ # Vue 前端源码
│ ├── api/ # Tauri API 封装
│ ├── components/ # Vue 组件
│ ├── pages/ # 页面组件
│ ├── stores/ # Pinia 状态
│ ├── hooks/ # 组合式函数
│ ├── types/ # TypeScript 类型
│ ├── router/ # Vue Router
│ ├── styles/ # 全局样式
│ ├── App.vue # 根组件
│ └── main.ts # 入口文件
├── src-tauri/ # Rust 后端源码
│ ├── src/
│ │ ├── commands/ # Tauri 命令
│ │ ├── discovery/ # 设备发现
│ │ ├── websocket/ # WebSocket
│ │ ├── http/ # HTTP 传输
│ │ ├── tls/ # TLS 加密
│ │ ├── database/ # SQLite
│ │ ├── models/ # 数据模型
│ │ ├── utils/ # 工具函数
│ │ ├── state.rs # 全局状态
│ │ ├── main.rs # 程序入口
│ │ └── lib.rs # 库入口
│ ├── Cargo.toml # Rust 依赖
│ └── tauri.conf.json # Tauri 配置
├── document/ # 项目文档
├── public/ # 静态资源
├── index.html # HTML 入口
├── package.json # Node 依赖
├── vite.config.ts # Vite 配置
├── tailwind.config.js # Tailwind 配置
└── tsconfig.json # TypeScript 配置
```
---
## 开发流程
### 1. 新增 Tauri 命令
**步骤 a**: 在 `src-tauri/src/commands/` 创建或编辑命令文件
```rust
// src-tauri/src/commands/my_commands.rs
#[tauri::command]
pub async fn my_command(param: String) -> CommandResult<String> {
// 实现逻辑
Ok(format!("Hello, {}!", param))
}
```
**步骤 b**: 在 `mod.rs` 注册命令
```rust
// src-tauri/src/commands/mod.rs
pub fn get_handlers() -> impl Fn(tauri::Builder<Wry>) -> tauri::Builder<Wry> {
|builder| {
builder.invoke_handler(tauri::generate_handler![
// ... 其他命令
my_command,
])
}
}
```
**步骤 c**: 在前端调用
```typescript
// src/api/index.ts
export const myApi = {
myCommand: (param: string) => invoke<string>('my_command', { param }),
}
```
### 2. 新增前端页面
**步骤 a**: 创建页面组件
```vue
<!-- src/pages/MyPage.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const data = ref('')
onMounted(async () => {
// 初始化
})
</script>
<template>
<div class="p-4">
<h1>My Page</h1>
</div>
</template>
```
**步骤 b**: 添加路由
```typescript
// src/router/index.ts
{
path: '/my-page',
name: 'myPage',
component: () => import('../pages/MyPage.vue')
}
```
### 3. 新增后端事件
**步骤 a**: 定义事件
```rust
// src-tauri/src/models/events.rs
pub mod event_names {
pub const MY_EVENT: &str = "my:event";
}
```
**步骤 b**: 发送事件
```rust
use tauri::Emitter;
app.emit("my:event", payload)?;
```
**步骤 c**: 前端监听
```typescript
import { listen } from '@tauri-apps/api/event'
const unlisten = await listen('my:event', (event) => {
console.log(event.payload)
})
```
---
## 调试技巧
### 前端调试
1. **Chrome DevTools**: `Ctrl+Shift+I``F12`
2. **Vue DevTools**: 安装浏览器扩展
3. **Console 日志**: 使用 `console.log()`
### 后端调试
1. **日志输出**:
```rust
log::info!("Debug message: {:?}", value);
log::error!("Error: {}", err);
```
2. **环境变量**:
```bash
RUST_LOG=debug npm run tauri dev
```
3. **查看后端日志**: 在终端中查看 Tauri 输出
### 网络调试
1. **WebSocket**: 使用浏览器 DevTools Network 面板
2. **HTTP**: 使用 Postman 或 curl 测试
3. **UDP**: 使用 Wireshark 抓包
---
## 构建发布
### 构建生产版本
```bash
npm run tauri build
```
**输出目录**: `src-tauri/target/release/bundle/`
### 构建产物
| 平台 | 格式 |
|------|------|
| Windows | `.msi`, `.exe` (NSIS) |
| macOS | `.dmg`, `.app` |
| Linux | `.deb`, `.AppImage` |
### 配置构建
编辑 `src-tauri/tauri.conf.json`:
```json
{
"productName": "Flash Send",
"version": "1.0.0",
"identifier": "com.flashsend.app",
"bundle": {
"active": true,
"targets": "all",
"icon": ["icons/icon.ico", "icons/icon.png"],
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
}
}
}
```
### 代码签名
#### Windows
```json
{
"bundle": {
"windows": {
"certificateThumbprint": "YOUR_CERT_THUMBPRINT",
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com"
}
}
}
```
#### macOS
```bash
export APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (XXXXXXXXXX)"
npm run tauri build
```
---
## 测试
### 单元测试 (Rust)
```bash
cd src-tauri
cargo test
```
### 集成测试
1. 启动两个实例(不同机器或虚拟机)
2. 测试设备发现
3. 测试聊天功能
4. 测试文件传输
### 测试清单
- [ ] 设备发现正常
- [ ] WebSocket 连接稳定
- [ ] 消息收发正常
- [ ] 文件传输完整
- [ ] 取消传输有效
- [ ] 深色模式正常
- [ ] 数据库持久化
- [ ] 重启后数据恢复
---
## 常见问题
### Q: 编译时出现 OpenSSL 错误
**A**: 安装 OpenSSL 开发库
```bash
# Ubuntu
sudo apt install libssl-dev
# macOS
brew install openssl
```
### Q: Windows 上编译失败
**A**: 安装 Visual Studio Build Tools
下载地址: https://visualstudio.microsoft.com/visual-cpp-build-tools/
### Q: 设备无法发现
**A**: 检查防火墙设置,确保 UDP 53317 端口开放
### Q: WebSocket 连接失败
**A**: 检查防火墙设置,确保 TCP 53318 端口开放
### Q: 文件传输失败
**A**:
1. 检查防火墙设置,确保 TCP 53319 端口开放
2. 检查磁盘空间是否充足
3. 检查下载目录权限
---
## 贡献指南
### 提交规范
使用 Conventional Commits:
```
feat: 添加新功能
fix: 修复 bug
docs: 更新文档
style: 代码格式调整
refactor: 代码重构
test: 添加测试
chore: 构建/工具变更
```
### 分支策略
- `main`: 稳定版本
- `develop`: 开发分支
- `feature/*`: 功能分支
- `fix/*`: 修复分支
### PR 流程
1. Fork 项目
2. 创建功能分支
3. 提交变更
4. 创建 Pull Request
5. 代码审查
6. 合并到 develop
---
## 参考资源
- [Tauri 官方文档](https://tauri.app/v2/guides/)
- [Vue 3 文档](https://vuejs.org/)
- [Rust 官方文档](https://doc.rust-lang.org/)
- [TailwindCSS 文档](https://tailwindcss.com/docs)
- [Pinia 文档](https://pinia.vuejs.org/)

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/flash-send.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flash Send - 局域网传输与聊天</title>
</head>
<body class="bg-surface-50">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2778
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,32 @@
{
"name": "flash-send",
"version": "1.0.0",
"description": "跨平台局域网文件传输与聊天应用",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"pinia": "^2.1.7",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"lucide-vue-next": "^0.344.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@vitejs/plugin-vue": "^5.0.4",
"typescript": "^5.4.2",
"vite": "^5.1.6",
"vue-tsc": "^2.0.6",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1"
}
}

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0ea5e9;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0284c7;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
<path d="M30 25 L70 50 L30 75 Z" fill="white" opacity="0.9"/>
<path d="M45 35 L75 50 L45 65 Z" fill="white" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 507 B

@ -0,0 +1,12 @@
@echo off
REM 运行第二个 Flash Send 实例用于测试
REM 使用不同的端口和实例名称
set FLASH_SEND_PORT_OFFSET=100
set FLASH_SEND_INSTANCE=测试2
echo 启动 Flash Send 实例 2...
echo 端口偏移: +100 (UDP:53417, WS:53418, HTTP:53419)
cd /d %~dp0..
cargo run --manifest-path src-tauri/Cargo.toml

@ -0,0 +1,24 @@
# 运行第二个 Flash Send 实例用于测试
# 使用不同的端口和实例名称
$env:FLASH_SEND_PORT_OFFSET = "100"
$env:FLASH_SEND_INSTANCE = "Test2"
Write-Host "启动 Flash Send 实例 2..." -ForegroundColor Green
Write-Host "UDP:53317 (共用) | WS:53418 | HTTP:53419" -ForegroundColor Cyan
$exePath = "$PSScriptRoot\..\src-tauri\target\release\flash-send-instance2.exe"
# 如果副本不存在则复制
if (-not (Test-Path $exePath)) {
$srcPath = "$PSScriptRoot\..\src-tauri\target\release\flash-send.exe"
if (Test-Path $srcPath) {
Copy-Item $srcPath $exePath
Write-Host "已复制可执行文件" -ForegroundColor Yellow
} else {
Write-Host "错误: 请先编译主程序 (cargo build --release)" -ForegroundColor Red
exit 1
}
}
& $exePath

@ -0,0 +1,3 @@
# Windows 静态链接 MSVC 运行时,无需用户安装 VC++ Redistributable
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]

6305
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,86 @@
[package]
name = "flash-send"
version = "1.0.0"
description = "跨平台局域网文件传输与聊天应用"
authors = ["Flash Send Team"]
edition = "2021"
rust-version = "1.70"
[lib]
name = "flash_send_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
# Tauri 核心
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-shell = "2"
# 异步运行时
tokio = { version = "1", features = ["full"] }
# 序列化
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# 数据库
rusqlite = { version = "0.31", features = ["bundled"] }
# TLS 加密
rustls = { version = "0.21", features = ["dangerous_configuration"] }
rustls-pemfile = "1"
rcgen = "0.11"
tokio-rustls = "0.24"
# WebSocket
tokio-tungstenite = { version = "0.20", features = ["rustls-tls-native-roots"] }
futures-util = "0.3"
# HTTP 服务
axum = { version = "0.6", features = ["multipart", "tokio"] }
axum-server = { version = "0.5", features = ["tls-rustls"] }
tower = "0.4"
tower-http = { version = "0.4", features = ["cors", "fs"] }
hyper = "0.14"
# 网络工具
local-ip-address = "0.6"
socket2 = { version = "0.5", features = ["all"] }
# UUID 生成
uuid = { version = "1", features = ["v4", "serde"] }
# 时间处理
chrono = { version = "0.4", features = ["serde"] }
# 日志
log = "0.4"
env_logger = "0.11"
# 错误处理
thiserror = "1"
anyhow = "1"
# 文件处理
mime_guess = "2"
tokio-util = { version = "0.7", features = ["io"] }
# 并发工具
parking_lot = "0.12"
dashmap = "5"
once_cell = "1"
# 系统信息
hostname = "0.4"
dirs = "5"
# 时间处理 (for rcgen)
time = "0.3"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

@ -0,0 +1,14 @@
{
"$schema": "https://schema.tauri.app/config/2/schema.json",
"identifier": "default",
"description": "Default capability for Flash Send",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"fs:default",
"shell:default",
"core:event:default",
"core:window:default"
]
}

File diff suppressed because one or more lines are too long

@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capability for Flash Send","local":true,"windows":["main"],"permissions":["core:default","dialog:default","fs:default","shell:default","core:event:default","core:window:default"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -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: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 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.

After

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

@ -0,0 +1,269 @@
//! 聊天相关命令
use tauri::{AppHandle, Emitter};
use crate::database::Database;
use crate::models::{ChatMessage, event_names};
use crate::state::AppState;
use crate::utils::{CommandError, CommandResult, HEARTBEAT_INTERVAL_MS};
use crate::websocket::{HeartbeatManager, WsClient, WsConnectionEvent, WsServer};
/// 启动 WebSocket 服务端
#[tauri::command]
pub async fn start_websocket_server(app: AppHandle) -> CommandResult<()> {
let state = AppState::get();
let local_device = state.local_device.read().clone();
// 创建并启动 WS 服务端
let mut server = WsServer::new(
local_device.ws_port,
local_device.device_id.clone(),
state.connection_manager.clone(),
);
server.start().await.map_err(|e| CommandError {
code: "WS_SERVER_ERROR".to_string(),
message: e.to_string(),
})?;
*state.ws_server.write() = Some(server);
// 创建 WS 客户端
let client = WsClient::new(
local_device.device_id.clone(),
state.connection_manager.clone(),
);
*state.ws_client.write() = Some(client);
// 启动心跳管理
HeartbeatManager::start(
state.connection_manager.clone(),
local_device.device_id.clone(),
HEARTBEAT_INTERVAL_MS,
);
// 监听连接事件
let mut rx = state.connection_manager.subscribe();
let app_handle = app.clone();
tokio::spawn(async move {
while let Ok(event) = rx.recv().await {
match event {
WsConnectionEvent::Connected { device_id } => {
let _ = app_handle.emit(event_names::WEBSOCKET_CONNECTED, serde_json::json!({
"deviceId": device_id
}));
}
WsConnectionEvent::Disconnected { device_id, reason } => {
let _ = app_handle.emit(event_names::WEBSOCKET_DISCONNECTED, serde_json::json!({
"deviceId": device_id,
"reason": reason
}));
}
WsConnectionEvent::MessageReceived { message } => {
let _ = app_handle.emit(event_names::MESSAGE_RECEIVED, &message);
}
}
}
});
log::info!("WebSocket server started on port {}", local_device.ws_port);
Ok(())
}
/// 停止 WebSocket 服务端
#[tauri::command]
pub async fn stop_websocket_server() -> CommandResult<()> {
let state = AppState::get();
// 关闭所有连接
state.connection_manager.close_all().await;
// 停止服务端
if let Some(mut server) = state.ws_server.write().take() {
server.stop();
}
*state.ws_client.write() = None;
log::info!("WebSocket server stopped");
Ok(())
}
/// 连接到指定设备
#[tauri::command]
pub async fn connect_to_peer(device_id: String) -> CommandResult<()> {
let state = AppState::get();
// 获取设备信息
let device = state
.device_manager
.get_device(&device_id)
.ok_or_else(|| CommandError {
code: "DEVICE_NOT_FOUND".to_string(),
message: format!("Device {} not found", device_id),
})?;
// 检查是否已连接 (同步检查,立即释放锁)
{
let client_guard = state.ws_client.read();
if let Some(client) = client_guard.as_ref() {
if client.is_connected(&device_id) || state.connection_manager.is_connected(&device_id) {
return Ok(());
}
} else if state.connection_manager.is_connected(&device_id) {
return Ok(());
}
}
// 克隆客户端引用以便异步使用
let ws_client = {
let client_guard = state.ws_client.read();
client_guard.as_ref().cloned()
};
let client = ws_client.ok_or_else(|| CommandError {
code: "WS_NOT_STARTED".to_string(),
message: "WebSocket client not started".to_string(),
})?;
client.connect(&device).await.map_err(|e| CommandError {
code: "CONNECTION_ERROR".to_string(),
message: e.to_string(),
})?;
// 更新设备在线状态
state.device_manager.set_device_online(&device_id, true);
log::info!("Connected to device {}", device_id);
Ok(())
}
/// 断开与指定设备的连接
#[tauri::command]
pub async fn disconnect_from_peer(device_id: String) -> CommandResult<()> {
let state = AppState::get();
// 克隆客户端引用
let ws_client = {
let client_guard = state.ws_client.read();
client_guard.as_ref().cloned()
};
if let Some(client) = ws_client {
client.disconnect(&device_id).await;
}
state.device_manager.set_device_online(&device_id, false);
log::info!("Disconnected from device {}", device_id);
Ok(())
}
/// 发送聊天消息
#[tauri::command]
pub async fn send_chat_message(
device_id: String,
content: String,
message_type: Option<String>,
) -> CommandResult<ChatMessage> {
let state = AppState::get();
// 获取本地设备 ID (立即释放锁)
let local_device_id = {
let local_device = state.local_device.read();
local_device.device_id.clone()
};
// 创建消息
let message = match message_type.as_deref() {
Some("image") => ChatMessage::new_image(
local_device_id,
device_id.clone(),
content,
),
_ => ChatMessage::new_text(
local_device_id,
device_id.clone(),
content,
),
};
// 保存到数据库
Database::save_message(&message).map_err(|e| CommandError {
code: "DB_ERROR".to_string(),
message: e.to_string(),
})?;
// 获取客户端 (立即释放锁)
let ws_client = {
let client_guard = state.ws_client.read();
client_guard.as_ref().cloned()
};
let mut sent = false;
// 尝试通过客户端发送(主动连接的情况)
if let Some(client) = &ws_client {
if client.is_connected(&device_id) {
client.send_to(&device_id, &message).await.map_err(|e| CommandError {
code: "SEND_ERROR".to_string(),
message: e,
})?;
sent = true;
}
}
// 如果客户端没有发送,尝试通过服务端连接发送(被动连接的情况)
if !sent {
state
.connection_manager
.send_to(&device_id, &message)
.await
.map_err(|e| CommandError {
code: "SEND_ERROR".to_string(),
message: e,
})?;
}
Ok(message)
}
/// 获取聊天历史
#[tauri::command]
pub async fn get_chat_history(
device_id: String,
limit: Option<usize>,
offset: Option<usize>,
) -> CommandResult<Vec<ChatMessage>> {
let state = AppState::get();
let local_device = state.local_device.read();
let messages = Database::get_chat_history(
&device_id,
&local_device.device_id,
limit.unwrap_or(50),
offset.unwrap_or(0),
)
.map_err(|e| CommandError {
code: "DB_ERROR".to_string(),
message: e.to_string(),
})?;
Ok(messages)
}
/// 删除聊天历史
#[tauri::command]
pub async fn delete_chat_history(device_id: String) -> CommandResult<usize> {
let state = AppState::get();
let local_device = state.local_device.read();
let count = Database::delete_chat_history(&device_id, &local_device.device_id)
.map_err(|e| CommandError {
code: "DB_ERROR".to_string(),
message: e.to_string(),
})?;
Ok(count)
}

@ -0,0 +1,50 @@
//! 配置相关命令
use crate::models::DeviceInfo;
use crate::state::AppState;
use crate::utils::{AppConfig, CommandResult};
/// 获取应用配置
#[tauri::command]
pub async fn get_app_config() -> CommandResult<AppConfig> {
Ok(AppConfig::get())
}
/// 更新设备名称
#[tauri::command]
pub async fn update_device_name(name: String) -> CommandResult<()> {
AppConfig::set_device_name(name.clone());
// 更新本机设备信息
let state = AppState::get();
let mut device = state.local_device.write();
device.device_name = name;
Ok(())
}
/// 更新下载目录
#[tauri::command]
pub async fn update_download_dir(dir: String) -> CommandResult<()> {
// 验证目录是否存在
let path = std::path::PathBuf::from(&dir);
if !path.exists() {
std::fs::create_dir_all(&path).map_err(|e| crate::utils::CommandError {
code: "IO_ERROR".to_string(),
message: e.to_string(),
})?;
}
AppConfig::set_download_dir(dir);
Ok(())
}
/// 获取本机设备信息
#[tauri::command]
pub async fn get_local_device_info() -> CommandResult<DeviceInfo> {
let state = AppState::get();
state.update_local_ip();
let device = state.local_device.read().clone();
Ok(device)
}

@ -0,0 +1,88 @@
//! 设备发现相关命令
use tauri::{AppHandle, Emitter};
use crate::discovery::{DiscoveryEvent, DiscoveryService};
use crate::models::{DeviceInfo, event_names};
use crate::state::AppState;
use crate::utils::CommandResult;
/// 启动设备发现服务
#[tauri::command]
pub async fn start_discovery_service(app: AppHandle) -> CommandResult<()> {
let state = AppState::get();
// 更新本机 IP
state.update_local_ip();
let local_device = state.local_device.read().clone();
// 创建并启动发现服务
let mut discovery = DiscoveryService::new(local_device);
let mut rx = discovery.subscribe();
discovery.start().await.map_err(|e| crate::utils::CommandError {
code: "DISCOVERY_ERROR".to_string(),
message: e.to_string(),
})?;
// 存储服务实例
*state.discovery_service.write() = Some(discovery);
// 启动设备超时检查
state.device_manager.start_timeout_checker();
// 监听发现事件并转发到前端
let app_handle = app.clone();
let device_manager = state.device_manager.clone();
tokio::spawn(async move {
while let Ok(event) = rx.recv().await {
match event {
DiscoveryEvent::DeviceFound(device) => {
device_manager.upsert_device(device.clone());
let _ = app_handle.emit(event_names::DEVICE_FOUND, &device);
}
DiscoveryEvent::DeviceLost(device_id) => {
device_manager.remove_device(&device_id);
let _ = app_handle.emit(event_names::DEVICE_LOST, serde_json::json!({
"deviceId": device_id
}));
}
_ => {}
}
}
});
log::info!("Discovery service started");
Ok(())
}
/// 停止设备发现服务
#[tauri::command]
pub async fn stop_discovery_service() -> CommandResult<()> {
let state = AppState::get();
// 取出 discovery 服务,释放锁后再操作
let discovery_opt = state.discovery_service.write().take();
if let Some(mut discovery) = discovery_opt {
// 发送离线通知
let _ = discovery.send_goodbye().await;
discovery.stop();
}
// 清空设备列表
state.device_manager.clear();
log::info!("Discovery service stopped");
Ok(())
}
/// 获取已发现的设备列表
#[tauri::command]
pub async fn get_discovered_devices() -> CommandResult<Vec<DeviceInfo>> {
let state = AppState::get();
let devices = state.device_manager.get_all_devices();
Ok(devices)
}

@ -0,0 +1,328 @@
//! 文件传输相关命令
use std::path::PathBuf;
use tauri::{AppHandle, Emitter};
use tauri_plugin_dialog::DialogExt;
use tokio_util::sync::CancellationToken;
use crate::database::Database;
use crate::http::HttpServer;
use crate::models::{event_names, FileMetadata, FileTransfer, TransferStatus};
use crate::state::AppState;
use crate::utils::{AppConfig, CommandError, CommandResult};
/// 启动 HTTP 文件服务
#[tauri::command]
pub async fn start_http_server(app: AppHandle) -> CommandResult<()> {
let state = AppState::get();
let local_device = state.local_device.read().clone();
let config = AppConfig::get();
// 创建并启动 HTTP 服务
let mut server = HttpServer::new(
local_device.http_port,
PathBuf::from(&config.download_dir),
local_device.device_id.clone(),
);
server.start().await.map_err(|e| CommandError {
code: "HTTP_SERVER_ERROR".to_string(),
message: e.to_string(),
})?;
// 监听进度事件
let mut rx = server.state().subscribe_progress();
let app_handle = app.clone();
tokio::spawn(async move {
while let Ok(event) = rx.recv().await {
let _ = app_handle.emit(event_names::TRANSFER_PROGRESS, &event);
// 文件接收完成时发送事件
if event.status == TransferStatus::Completed {
let _ = app_handle.emit(event_names::FILE_RECEIVED, serde_json::json!({
"fileId": event.file_id,
"progress": event.progress,
"status": "completed"
}));
}
}
});
*state.http_server.write() = Some(server);
log::info!("HTTP server started on port {}", local_device.http_port);
Ok(())
}
/// 停止 HTTP 文件服务
#[tauri::command]
pub async fn stop_http_server() -> CommandResult<()> {
let state = AppState::get();
if let Some(mut server) = state.http_server.write().take() {
server.stop();
}
log::info!("HTTP server stopped");
Ok(())
}
/// 选择要发送的文件
#[tauri::command]
pub async fn select_file_to_send(app: AppHandle) -> CommandResult<Option<FileMetadata>> {
let file_path = app
.dialog()
.file()
.blocking_pick_file();
match file_path {
Some(file_path) => {
let path = file_path.into_path().map_err(|e| CommandError {
code: "PATH_ERROR".to_string(),
message: format!("Invalid file path: {}", e),
})?;
let file_name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let metadata = std::fs::metadata(&path).map_err(|e| CommandError {
code: "IO_ERROR".to_string(),
message: e.to_string(),
})?;
let mime_type = mime_guess::from_path(&path)
.first_or_octet_stream()
.to_string();
let file_id = uuid::Uuid::new_v4().to_string();
// 注册文件供下载
let state = AppState::get();
if let Some(server) = state.http_server.read().as_ref() {
server.register_file(
file_id.clone(),
path.clone(),
file_name.clone(),
metadata.len(),
mime_type.clone(),
);
}
Ok(Some(FileMetadata {
file_id,
name: file_name,
size: metadata.len(),
mime_type,
thumbnail: None,
path: Some(path.to_string_lossy().to_string()),
}))
}
None => Ok(None),
}
}
/// 发送文件到指定设备
#[tauri::command]
pub async fn send_file(
app: AppHandle,
device_id: String,
file_id: String,
file_path: String,
) -> CommandResult<FileTransfer> {
let state = AppState::get();
let local_device = state.local_device.read().clone();
// 获取目标设备
let device = state
.device_manager
.get_device(&device_id)
.ok_or_else(|| CommandError {
code: "DEVICE_NOT_FOUND".to_string(),
message: format!("Device {} not found", device_id),
})?;
log::info!("send_file called with file_id={}, file_path={}", file_id, file_path);
// 优先从已注册的文件中获取路径select_file_to_send 已经注册了文件)
let (path, file_name, file_size, mime_type) = if let Some(server) = state.http_server.read().as_ref() {
let server_state = server.state();
if let Some(registered_path) = server_state.files.get(&file_id) {
let p = registered_path.clone();
let name = p.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let size = std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0);
let mime = mime_guess::from_path(&p).first_or_octet_stream().to_string();
log::info!("Using registered file path: {:?}", p);
(p, name, size, mime)
} else {
// 回退到前端传递的路径
let p = PathBuf::from(&file_path);
let name = p.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let size = std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0);
let mime = mime_guess::from_path(&p).first_or_octet_stream().to_string();
log::info!("Using provided file path: {:?}", p);
(p, name, size, mime)
}
} else {
let p = PathBuf::from(&file_path);
let name = p.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let size = std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0);
let mime = mime_guess::from_path(&p).first_or_octet_stream().to_string();
(p, name, size, mime)
};
log::info!("Resolved file_name={}, file_size={}", file_name, file_size);
// 创建传输记录
let transfer = FileTransfer::new_outgoing(
file_name,
file_size,
mime_type,
local_device.device_id.clone(),
device_id.clone(),
path.to_string_lossy().to_string(),
);
// 保存到数据库
Database::save_file_transfer(&transfer).map_err(|e| CommandError {
code: "DB_ERROR".to_string(),
message: e.to_string(),
})?;
// 设置 AppHandle 用于发送进度事件
state.http_client.set_app_handle(app.clone());
// 创建取消 token
let cancel_token = CancellationToken::new();
{
let mut tokens = state.transfer_cancellation_tokens.write();
tokens.insert(transfer.file_id.clone(), cancel_token.clone());
}
// 执行上传(使用 transfer.file_id 保持一致)
let transfer_clone = transfer.clone();
let transfer_file_id = transfer.file_id.clone();
let transfer_file_id_for_cleanup = transfer.file_id.clone();
let http_client = state.http_client.clone();
let device_clone = device.clone();
let local_device_id = local_device.device_id.clone();
let state_clone = state.clone();
tokio::spawn(async move {
let result = http_client
.upload_file(&device_clone, &path, &transfer_file_id, &local_device_id, cancel_token)
.await;
// 清理取消 token
{
let mut tokens = state_clone.transfer_cancellation_tokens.write();
tokens.remove(&transfer_file_id_for_cleanup);
}
if let Err(e) = result {
log::error!("File upload failed: {}", e);
}
});
Ok(transfer_clone)
}
/// 取消文件传输
#[tauri::command]
pub async fn cancel_transfer(file_id: String) -> CommandResult<()> {
log::info!("Cancelling transfer: {}", file_id);
let state = AppState::get();
// 触发取消 token
{
let tokens = state.transfer_cancellation_tokens.read();
if let Some(token) = tokens.get(&file_id) {
token.cancel();
log::info!("Cancelled transfer token for: {}", file_id);
}
}
// 更新数据库中的传输状态
Database::update_transfer_status(&file_id, "cancelled").map_err(|e| CommandError {
code: "DB_ERROR".to_string(),
message: e.to_string(),
})?;
Ok(())
}
/// 获取文件传输历史
#[tauri::command]
pub async fn get_transfer_history(
device_id: String,
limit: Option<usize>,
) -> CommandResult<Vec<FileTransfer>> {
let state = AppState::get();
let local_device = state.local_device.read();
let transfers = Database::get_transfer_history(
&device_id,
&local_device.device_id,
limit.unwrap_or(50),
)
.map_err(|e| CommandError {
code: "DB_ERROR".to_string(),
message: e.to_string(),
})?;
Ok(transfers)
}
/// 在文件管理器中打开文件位置
#[tauri::command]
pub async fn open_file_location(path: String) -> CommandResult<()> {
let path = PathBuf::from(&path);
// 获取父目录
let _dir = path.parent().unwrap_or(&path);
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
.arg("/select,")
.arg(&path)
.spawn()
.map_err(|e| CommandError {
code: "IO_ERROR".to_string(),
message: e.to_string(),
})?;
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg("-R")
.arg(&path)
.spawn()
.map_err(|e| CommandError {
code: "IO_ERROR".to_string(),
message: e.to_string(),
})?;
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(dir)
.spawn()
.map_err(|e| CommandError {
code: "IO_ERROR".to_string(),
message: e.to_string(),
})?;
}
Ok(())
}

@ -0,0 +1,46 @@
//! Tauri 命令模块
//! 定义所有前端可调用的命令
mod discovery_commands;
mod chat_commands;
mod file_commands;
mod config_commands;
pub use discovery_commands::*;
pub use chat_commands::*;
pub use file_commands::*;
pub use config_commands::*;
use tauri::ipc::Invoke;
/// 注册所有命令
pub fn get_handlers() -> impl Fn(Invoke) -> bool {
tauri::generate_handler![
// 发现服务命令
start_discovery_service,
stop_discovery_service,
get_discovered_devices,
// WebSocket 命令
start_websocket_server,
stop_websocket_server,
connect_to_peer,
disconnect_from_peer,
// 聊天命令
send_chat_message,
get_chat_history,
delete_chat_history,
// 文件传输命令
start_http_server,
stop_http_server,
select_file_to_send,
send_file,
cancel_transfer,
get_transfer_history,
open_file_location,
// 配置命令
get_app_config,
update_device_name,
update_download_dir,
get_local_device_info,
]
}

@ -0,0 +1,8 @@
//! 数据库模块
//! 使用 SQLite 存储聊天记录和文件传输历史
mod schema;
mod repository;
pub use schema::*;
pub use repository::*;

@ -0,0 +1,338 @@
//! 数据库操作封装
use once_cell::sync::OnceCell;
use parking_lot::Mutex;
use rusqlite::{params, Connection, OptionalExtension};
use std::path::PathBuf;
use crate::models::{ChatMessage, FileTransfer, MessageStatus, MessageType, TransferStatus, DeviceInfo};
use crate::utils::AppError;
use super::init_database;
/// 全局数据库连接
static DB_CONNECTION: OnceCell<Mutex<Connection>> = OnceCell::new();
/// 数据库仓库
pub struct Database;
impl Database {
/// 初始化数据库连接
pub fn init(app_data_dir: PathBuf) -> Result<(), AppError> {
// 确保目录存在
std::fs::create_dir_all(&app_data_dir)?;
let db_path = app_data_dir.join("flash_send.db");
let conn = Connection::open(&db_path)?;
// 启用外键约束
conn.execute("PRAGMA foreign_keys = ON", [])?;
// 初始化表结构
init_database(&conn)?;
DB_CONNECTION
.set(Mutex::new(conn))
.map_err(|_| AppError::Database(rusqlite::Error::InvalidPath(db_path)))?;
log::info!("Database initialized at {:?}", app_data_dir);
Ok(())
}
/// 获取数据库连接
fn conn() -> &'static Mutex<Connection> {
DB_CONNECTION.get().expect("Database not initialized")
}
// ==================== 聊天消息操作 ====================
/// 保存聊天消息
pub fn save_message(message: &ChatMessage) -> Result<(), AppError> {
let conn = Self::conn().lock();
conn.execute(
r#"
INSERT INTO chat_messages (id, message_type, content, from_device, to_device, timestamp, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(id) DO UPDATE SET status = ?7
"#,
params![
message.id,
serde_json::to_string(&message.message_type)?,
message.content,
message.from,
message.to,
message.timestamp,
serde_json::to_string(&message.status)?,
],
)?;
Ok(())
}
/// 更新消息状态
pub fn update_message_status(message_id: &str, status: MessageStatus) -> Result<(), AppError> {
let conn = Self::conn().lock();
conn.execute(
"UPDATE chat_messages SET status = ?1 WHERE id = ?2",
params![serde_json::to_string(&status)?, message_id],
)?;
Ok(())
}
/// 获取与指定设备的聊天历史
pub fn get_chat_history(
device_id: &str,
local_device_id: &str,
limit: usize,
offset: usize,
) -> Result<Vec<ChatMessage>, AppError> {
let conn = Self::conn().lock();
let mut stmt = conn.prepare(
r#"
SELECT id, message_type, content, from_device, to_device, timestamp, status
FROM chat_messages
WHERE (from_device = ?1 AND to_device = ?2) OR (from_device = ?2 AND to_device = ?1)
ORDER BY timestamp DESC
LIMIT ?3 OFFSET ?4
"#,
)?;
let messages = stmt
.query_map(params![device_id, local_device_id, limit, offset], |row| {
let message_type_str: String = row.get(1)?;
let status_str: String = row.get(6)?;
Ok(ChatMessage {
id: row.get(0)?,
message_type: serde_json::from_str(&message_type_str).unwrap_or(MessageType::Text),
content: row.get(2)?,
from: row.get(3)?,
to: row.get(4)?,
timestamp: row.get(5)?,
status: serde_json::from_str(&status_str).unwrap_or(MessageStatus::Pending),
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(messages)
}
/// 删除与指定设备的所有聊天记录
pub fn delete_chat_history(device_id: &str, local_device_id: &str) -> Result<usize, AppError> {
let conn = Self::conn().lock();
let count = conn.execute(
r#"
DELETE FROM chat_messages
WHERE (from_device = ?1 AND to_device = ?2) OR (from_device = ?2 AND to_device = ?1)
"#,
params![device_id, local_device_id],
)?;
Ok(count)
}
// ==================== 文件传输操作 ====================
/// 保存文件传输记录
pub fn save_file_transfer(transfer: &FileTransfer) -> Result<(), AppError> {
let conn = Self::conn().lock();
conn.execute(
r#"
INSERT INTO file_transfers
(file_id, name, size, progress, status, mime_type, from_device, to_device, local_path, created_at, completed_at, transferred_bytes)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
ON CONFLICT(file_id) DO UPDATE SET
progress = ?4, status = ?5, local_path = ?9, completed_at = ?11, transferred_bytes = ?12
"#,
params![
transfer.file_id,
transfer.name,
transfer.size as i64,
transfer.progress,
serde_json::to_string(&transfer.status)?,
transfer.mime_type,
transfer.from_device,
transfer.to_device,
transfer.local_path,
transfer.created_at,
transfer.completed_at,
transfer.transferred_bytes as i64,
],
)?;
Ok(())
}
/// 更新文件传输状态
pub fn update_transfer_status(file_id: &str, status: &str) -> Result<(), AppError> {
let conn = Self::conn().lock();
let completed_at = chrono::Utc::now().timestamp();
conn.execute(
"UPDATE file_transfers SET status = ?1, completed_at = ?2 WHERE file_id = ?3",
params![format!("\"{}\"", status), completed_at, file_id],
)?;
Ok(())
}
/// 更新文件传输进度和状态
pub fn update_transfer_progress(file_id: &str, progress: f64, transferred_bytes: u64, status: &str) -> Result<(), AppError> {
let conn = Self::conn().lock();
let completed_at = if status == "completed" || status == "failed" {
Some(chrono::Utc::now().timestamp())
} else {
None
};
conn.execute(
"UPDATE file_transfers SET progress = ?1, transferred_bytes = ?2, status = ?3, completed_at = ?4 WHERE file_id = ?5",
params![progress, transferred_bytes as i64, format!("\"{}\"", status), completed_at, file_id],
)?;
Ok(())
}
/// 获取文件传输记录
pub fn get_file_transfer(file_id: &str) -> Result<Option<FileTransfer>, AppError> {
let conn = Self::conn().lock();
let mut stmt = conn.prepare(
r#"
SELECT file_id, name, size, progress, status, mime_type,
from_device, to_device, local_path, created_at, completed_at, transferred_bytes
FROM file_transfers WHERE file_id = ?1
"#,
)?;
let transfer = stmt
.query_row(params![file_id], |row| {
let status_str: String = row.get(4)?;
Ok(FileTransfer {
file_id: row.get(0)?,
name: row.get(1)?,
size: row.get::<_, i64>(2)? as u64,
progress: row.get(3)?,
status: serde_json::from_str(&status_str).unwrap_or(TransferStatus::Pending),
mime_type: row.get(5)?,
from_device: row.get(6)?,
to_device: row.get(7)?,
local_path: row.get(8)?,
created_at: row.get(9)?,
completed_at: row.get(10)?,
transferred_bytes: row.get::<_, i64>(11).unwrap_or(0) as u64,
})
})
.optional()?;
Ok(transfer)
}
/// 获取与指定设备的文件传输历史
pub fn get_transfer_history(
device_id: &str,
local_device_id: &str,
limit: usize,
) -> Result<Vec<FileTransfer>, AppError> {
let conn = Self::conn().lock();
let mut stmt = conn.prepare(
r#"
SELECT file_id, name, size, progress, status, mime_type,
from_device, to_device, local_path, created_at, completed_at, transferred_bytes
FROM file_transfers
WHERE (from_device = ?1 AND to_device = ?2) OR (from_device = ?2 AND to_device = ?1)
ORDER BY created_at DESC
LIMIT ?3
"#,
)?;
let transfers = stmt
.query_map(params![device_id, local_device_id, limit], |row| {
let status_str: String = row.get(4)?;
Ok(FileTransfer {
file_id: row.get(0)?,
name: row.get(1)?,
size: row.get::<_, i64>(2)? as u64,
progress: row.get(3)?,
status: serde_json::from_str(&status_str).unwrap_or(TransferStatus::Pending),
mime_type: row.get(5)?,
from_device: row.get(6)?,
to_device: row.get(7)?,
local_path: row.get(8)?,
created_at: row.get(9)?,
completed_at: row.get(10)?,
transferred_bytes: row.get::<_, i64>(11).unwrap_or(0) as u64,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(transfers)
}
// ==================== 设备信息操作 ====================
/// 保存已知设备
pub fn save_known_device(device: &DeviceInfo) -> Result<(), AppError> {
let conn = Self::conn().lock();
conn.execute(
r#"
INSERT INTO known_devices (device_id, device_name, ip, ws_port, http_port, last_seen)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(device_id) DO UPDATE SET
device_name = ?2, ip = ?3, ws_port = ?4, http_port = ?5, last_seen = ?6
"#,
params![
device.device_id,
device.device_name,
device.ip,
device.ws_port,
device.http_port,
device.last_seen,
],
)?;
Ok(())
}
/// 获取所有已知设备
pub fn get_known_devices() -> Result<Vec<DeviceInfo>, AppError> {
let conn = Self::conn().lock();
let mut stmt = conn.prepare(
"SELECT device_id, device_name, ip, ws_port, http_port, last_seen FROM known_devices",
)?;
let devices = stmt
.query_map([], |row| {
Ok(DeviceInfo {
device_id: row.get(0)?,
device_name: row.get(1)?,
ip: row.get(2)?,
ws_port: row.get(3)?,
http_port: row.get(4)?,
online: false,
capabilities: vec![],
last_seen: row.get(5)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(devices)
}
// ==================== 配置操作 ====================
/// 保存配置项
pub fn save_config(key: &str, value: &str) -> Result<(), AppError> {
let conn = Self::conn().lock();
conn.execute(
"INSERT INTO app_config (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = ?2",
params![key, value],
)?;
Ok(())
}
/// 获取配置项
pub fn get_config(key: &str) -> Result<Option<String>, AppError> {
let conn = Self::conn().lock();
let value: Option<String> = conn
.query_row(
"SELECT value FROM app_config WHERE key = ?1",
params![key],
|row| row.get(0),
)
.optional()?;
Ok(value)
}
}

@ -0,0 +1,115 @@
//! 数据库表结构定义
use rusqlite::Connection;
use crate::utils::AppError;
/// 初始化数据库表结构
pub fn init_database(conn: &Connection) -> Result<(), AppError> {
// 创建聊天消息表
conn.execute(
r#"
CREATE TABLE IF NOT EXISTS chat_messages (
id TEXT PRIMARY KEY,
message_type TEXT NOT NULL,
content TEXT NOT NULL,
from_device TEXT NOT NULL,
to_device TEXT NOT NULL,
timestamp INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
)
"#,
[],
)?;
// 创建聊天消息索引
conn.execute(
r#"
CREATE INDEX IF NOT EXISTS idx_messages_devices
ON chat_messages(from_device, to_device)
"#,
[],
)?;
conn.execute(
r#"
CREATE INDEX IF NOT EXISTS idx_messages_timestamp
ON chat_messages(timestamp DESC)
"#,
[],
)?;
// 创建文件传输记录表
conn.execute(
r#"
CREATE TABLE IF NOT EXISTS file_transfers (
file_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
size INTEGER NOT NULL,
progress REAL NOT NULL DEFAULT 0.0,
status TEXT NOT NULL DEFAULT 'pending',
mime_type TEXT NOT NULL,
from_device TEXT NOT NULL,
to_device TEXT NOT NULL,
local_path TEXT,
created_at INTEGER NOT NULL,
completed_at INTEGER,
transferred_bytes INTEGER NOT NULL DEFAULT 0
)
"#,
[],
)?;
// 迁移:为旧表添加 transferred_bytes 字段(如果不存在)
let _ = conn.execute(
"ALTER TABLE file_transfers ADD COLUMN transferred_bytes INTEGER NOT NULL DEFAULT 0",
[],
);
// 创建文件传输索引
conn.execute(
r#"
CREATE INDEX IF NOT EXISTS idx_transfers_devices
ON file_transfers(from_device, to_device)
"#,
[],
)?;
conn.execute(
r#"
CREATE INDEX IF NOT EXISTS idx_transfers_status
ON file_transfers(status)
"#,
[],
)?;
// 创建设备信息表 (用于持久化已知设备)
conn.execute(
r#"
CREATE TABLE IF NOT EXISTS known_devices (
device_id TEXT PRIMARY KEY,
device_name TEXT NOT NULL,
ip TEXT NOT NULL,
ws_port INTEGER NOT NULL,
http_port INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
)
"#,
[],
)?;
// 创建应用配置表
conn.execute(
r#"
CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
"#,
[],
)?;
log::info!("Database schema initialized successfully");
Ok(())
}

@ -0,0 +1,131 @@
//! 设备管理器
//! 管理已发现的设备列表,处理设备超时
use dashmap::DashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::broadcast;
use crate::models::DeviceInfo;
use crate::utils::DEVICE_TIMEOUT_MS;
use super::DiscoveryEvent;
/// 设备管理器
#[derive(Clone)]
pub struct DeviceManager {
/// 已发现的设备 (device_id -> DeviceInfo)
devices: Arc<DashMap<String, DeviceInfo>>,
/// 事件发送器
event_tx: broadcast::Sender<DiscoveryEvent>,
}
impl DeviceManager {
/// 创建新的设备管理器
pub fn new() -> Self {
let (event_tx, _) = broadcast::channel(100);
Self {
devices: Arc::new(DashMap::new()),
event_tx,
}
}
/// 获取事件订阅
pub fn subscribe(&self) -> broadcast::Receiver<DiscoveryEvent> {
self.event_tx.subscribe()
}
/// 添加或更新设备
pub fn upsert_device(&self, mut device: DeviceInfo) {
device.touch();
let device_id = device.device_id.clone();
let is_new = !self.devices.contains_key(&device_id);
self.devices.insert(device_id, device.clone());
if is_new {
let _ = self.event_tx.send(DiscoveryEvent::DeviceFound(device));
}
}
/// 移除设备
pub fn remove_device(&self, device_id: &str) {
if self.devices.remove(device_id).is_some() {
let _ = self.event_tx.send(DiscoveryEvent::DeviceLost(device_id.to_string()));
}
}
/// 获取设备信息
pub fn get_device(&self, device_id: &str) -> Option<DeviceInfo> {
self.devices.get(device_id).map(|d| d.clone())
}
/// 获取所有在线设备
pub fn get_all_devices(&self) -> Vec<DeviceInfo> {
self.devices.iter().map(|d| d.clone()).collect()
}
/// 获取在线设备数量
pub fn device_count(&self) -> usize {
self.devices.len()
}
/// 启动超时检查任务
pub fn start_timeout_checker(&self) {
let devices = self.devices.clone();
let event_tx = self.event_tx.clone();
let timeout = Duration::from_millis(DEVICE_TIMEOUT_MS);
tokio::spawn(async move {
let check_interval = Duration::from_secs(5);
loop {
tokio::time::sleep(check_interval).await;
let now = chrono::Utc::now().timestamp();
let timeout_secs = timeout.as_secs() as i64;
let mut expired_ids = Vec::new();
for entry in devices.iter() {
if now - entry.last_seen > timeout_secs {
expired_ids.push(entry.device_id.clone());
}
}
for device_id in expired_ids {
if devices.remove(&device_id).is_some() {
log::info!("Device {} timed out", device_id);
let _ = event_tx.send(DiscoveryEvent::DeviceLost(device_id));
}
}
}
});
}
/// 设置设备在线状态
pub fn set_device_online(&self, device_id: &str, online: bool) {
if let Some(mut device) = self.devices.get_mut(device_id) {
device.online = online;
if online {
device.touch();
}
}
}
/// 清空所有设备
pub fn clear(&self) {
let device_ids: Vec<String> = self.devices.iter().map(|d| d.device_id.clone()).collect();
self.devices.clear();
for device_id in device_ids {
let _ = self.event_tx.send(DiscoveryEvent::DeviceLost(device_id));
}
}
}
impl Default for DeviceManager {
fn default() -> Self {
Self::new()
}
}

@ -0,0 +1,8 @@
//! 局域网设备发现模块
//! 使用 UDP 广播/组播实现设备自动发现
mod service;
mod manager;
pub use service::*;
pub use manager::*;

@ -0,0 +1,191 @@
//! UDP 广播服务实现
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::Arc;
use std::time::Duration;
use socket2::{Domain, Protocol, Socket, Type};
use tokio::net::UdpSocket;
use tokio::sync::broadcast;
use crate::models::{DeviceInfo, DiscoveryPacket, DiscoveryPacketType};
use crate::utils::{AppError, DISCOVERY_INTERVAL_MS, DEFAULT_UDP_PORT};
/// 广播地址
const BROADCAST_ADDR: Ipv4Addr = Ipv4Addr::new(255, 255, 255, 255);
/// 发现服务状态
#[derive(Debug, Clone)]
pub enum DiscoveryEvent {
/// 发现新设备
DeviceFound(DeviceInfo),
/// 设备离线
DeviceLost(String),
/// 服务启动
Started,
/// 服务停止
Stopped,
}
/// UDP 发现服务
pub struct DiscoveryService {
/// 本机设备信息
local_device: DeviceInfo,
/// UDP 端口
port: u16,
/// 事件发送器
event_tx: broadcast::Sender<DiscoveryEvent>,
/// 停止信号
stop_tx: Option<tokio::sync::oneshot::Sender<()>>,
}
impl DiscoveryService {
/// 创建新的发现服务
pub fn new(local_device: DeviceInfo) -> Self {
let (event_tx, _) = broadcast::channel(100);
Self {
port: local_device.ws_port.saturating_sub(1).max(DEFAULT_UDP_PORT),
local_device,
event_tx,
stop_tx: None,
}
}
/// 获取事件接收器
pub fn subscribe(&self) -> broadcast::Receiver<DiscoveryEvent> {
self.event_tx.subscribe()
}
/// 启动发现服务
pub async fn start(&mut self) -> Result<(), AppError> {
let port = self.port;
let local_device = self.local_device.clone();
let event_tx = self.event_tx.clone();
// 创建停止信号
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
self.stop_tx = Some(stop_tx);
// 使用 socket2 创建支持端口复用的 UDP 套接字
let bind_addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port);
let socket2_socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
socket2_socket.set_reuse_address(true)?;
#[cfg(unix)]
socket2_socket.set_reuse_port(true)?;
socket2_socket.set_broadcast(true)?;
socket2_socket.set_nonblocking(true)?;
socket2_socket.bind(&bind_addr.into())?;
let std_socket: std::net::UdpSocket = socket2_socket.into();
let socket = UdpSocket::from_std(std_socket)?;
log::info!("Discovery service started on port {}", port);
let _ = event_tx.send(DiscoveryEvent::Started);
let socket = Arc::new(socket);
// 启动接收任务
let recv_socket = socket.clone();
let recv_event_tx = event_tx.clone();
let local_id = local_device.device_id.clone();
let recv_handle = tokio::spawn(async move {
Self::receive_loop(recv_socket, recv_event_tx, local_id).await;
});
// 启动广播任务
let broadcast_socket = socket.clone();
let broadcast_device = local_device.clone();
let broadcast_handle = tokio::spawn(async move {
Self::broadcast_loop(broadcast_socket, broadcast_device, port).await;
});
// 等待停止信号
tokio::spawn(async move {
let _ = stop_rx.await;
recv_handle.abort();
broadcast_handle.abort();
log::info!("Discovery service stopped");
});
Ok(())
}
/// 停止发现服务
pub fn stop(&mut self) {
if let Some(stop_tx) = self.stop_tx.take() {
let _ = stop_tx.send(());
let _ = self.event_tx.send(DiscoveryEvent::Stopped);
}
}
/// 接收循环
async fn receive_loop(
socket: Arc<UdpSocket>,
event_tx: broadcast::Sender<DiscoveryEvent>,
local_device_id: String,
) {
let mut buf = [0u8; 4096];
loop {
match socket.recv_from(&mut buf).await {
Ok((len, src)) => {
if let Ok(packet) = serde_json::from_slice::<DiscoveryPacket>(&buf[..len]) {
// 忽略自己的广播
if packet.device.device_id == local_device_id {
continue;
}
match packet.packet_type {
DiscoveryPacketType::Announce | DiscoveryPacketType::Response => {
log::debug!("Discovered device: {} at {}", packet.device.device_name, src);
let _ = event_tx.send(DiscoveryEvent::DeviceFound(packet.device));
}
DiscoveryPacketType::Goodbye => {
log::debug!("Device offline: {}", packet.device.device_id);
let _ = event_tx.send(DiscoveryEvent::DeviceLost(packet.device.device_id));
}
DiscoveryPacketType::Query => {
// 收到查询请求,不在这里处理 (由 broadcast_loop 定期广播)
}
}
}
}
Err(e) => {
log::error!("UDP receive error: {}", e);
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
}
/// 广播循环
async fn broadcast_loop(socket: Arc<UdpSocket>, device: DeviceInfo, port: u16) {
let broadcast_addr = SocketAddr::V4(SocketAddrV4::new(BROADCAST_ADDR, port));
let interval = Duration::from_millis(DISCOVERY_INTERVAL_MS);
loop {
let packet = DiscoveryPacket::announce(device.clone());
if let Ok(data) = serde_json::to_vec(&packet) {
if let Err(e) = socket.send_to(&data, broadcast_addr).await {
log::error!("Failed to broadcast: {}", e);
}
}
tokio::time::sleep(interval).await;
}
}
/// 发送离线通知
pub async fn send_goodbye(&self) -> Result<(), AppError> {
let bind_addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0);
let socket = UdpSocket::bind(bind_addr).await?;
socket.set_broadcast(true)?;
let broadcast_addr = SocketAddr::V4(SocketAddrV4::new(BROADCAST_ADDR, self.port));
let packet = DiscoveryPacket::goodbye(self.local_device.clone());
let data = serde_json::to_vec(&packet)?;
socket.send_to(&data, broadcast_addr).await?;
Ok(())
}
}

@ -0,0 +1,168 @@
//! HTTP 文件传输客户端
//! 用于向其他设备上传/下载文件
use std::path::PathBuf;
use std::sync::Arc;
use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tauri::{AppHandle, Emitter};
use tokio_util::sync::CancellationToken;
use crate::models::{DeviceInfo, TransferProgressEvent, TransferStatus};
use crate::utils::{AppError, FILE_CHUNK_SIZE};
use crate::models::event_names;
/// HTTP 传输客户端
#[derive(Clone)]
pub struct HttpClient {
/// Tauri AppHandle (可选,用于发送事件)
app_handle: Arc<std::sync::RwLock<Option<AppHandle>>>,
}
impl HttpClient {
/// 创建新的客户端
pub fn new() -> Self {
Self {
app_handle: Arc::new(std::sync::RwLock::new(None)),
}
}
/// 设置 AppHandle
pub fn set_app_handle(&self, handle: AppHandle) {
if let Ok(mut guard) = self.app_handle.write() {
*guard = Some(handle);
}
}
/// 上传文件到指定设备(支持取消)
pub async fn upload_file(
&self,
device: &DeviceInfo,
file_path: &PathBuf,
file_id: &str,
from_device: &str,
cancel_token: CancellationToken,
) -> Result<(), AppError> {
use tokio::net::TcpStream;
use tokio::io::{AsyncBufReadExt, BufReader};
let file_name = file_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let addr = format!("{}:{}", device.ip, device.http_port);
let stream = TcpStream::connect(&addr).await?;
// TLS 握手
let connector = crate::tls::client::build_connector()?;
let ip_addr: std::net::IpAddr = device.ip.parse()
.map_err(|_| AppError::Tls("Invalid IP address".to_string()))?;
let server_name = rustls::ServerName::IpAddress(ip_addr);
let mut tls_stream = connector.connect(server_name, stream).await
.map_err(|e| AppError::Tls(format!("TLS error: {}", e)))?;
// 读取文件内容
let mut file = File::open(file_path).await?;
let file_size = file.metadata().await?.len();
let mut file_content = Vec::with_capacity(file_size as usize);
file.read_to_end(&mut file_content).await?;
// 构建 multipart 请求体
let boundary = format!("----WebKitFormBoundary{}", uuid::Uuid::new_v4().to_string().replace("-", "")[..16].to_string());
let mut body = Vec::new();
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
body.extend_from_slice(format!("Content-Disposition: form-data; name=\"file_id\"\r\n\r\n{}\r\n", file_id).as_bytes());
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
body.extend_from_slice(format!("Content-Disposition: form-data; name=\"from_device\"\r\n\r\n{}\r\n", from_device).as_bytes());
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
body.extend_from_slice(format!("Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n", file_name).as_bytes());
body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
body.extend_from_slice(&file_content);
body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes());
let request = format!(
"POST /upload HTTP/1.1\r\n\
Host: {}\r\n\
Content-Type: multipart/form-data; boundary={}\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\
\r\n",
addr,
boundary,
body.len()
);
tls_stream.write_all(request.as_bytes()).await?;
// 分块发送请求体,同时检查取消状态
let mut sent = 0usize;
for chunk in body.chunks(FILE_CHUNK_SIZE) {
// 检查是否已取消
if cancel_token.is_cancelled() {
self.emit_progress(file_id, 0.0, sent as u64, file_size, TransferStatus::Cancelled);
if let Err(e) = crate::database::Database::update_transfer_progress(file_id, 0.0, sent as u64, "cancelled") {
log::error!("Failed to update transfer in database: {}", e);
}
log::info!("File upload cancelled: {}", file_name);
return Err(AppError::FileTransfer("Upload cancelled".to_string()));
}
tls_stream.write_all(chunk).await?;
sent += chunk.len();
let progress = sent as f64 / body.len() as f64;
self.emit_progress(file_id, progress, sent as u64, file_size, TransferStatus::Transferring);
}
tls_stream.flush().await?;
// 读取响应
let mut reader = BufReader::new(tls_stream);
let mut response_line = String::new();
reader.read_line(&mut response_line).await?;
if response_line.contains("200") {
self.emit_progress(file_id, 1.0, file_size, file_size, TransferStatus::Completed);
if let Err(e) = crate::database::Database::update_transfer_progress(file_id, 1.0, file_size, "completed") {
log::error!("Failed to update transfer in database: {}", e);
}
log::info!("File uploaded successfully: {}", file_name);
Ok(())
} else {
self.emit_progress(file_id, 0.0, 0, file_size, TransferStatus::Failed);
if let Err(e) = crate::database::Database::update_transfer_progress(file_id, 0.0, 0, "failed") {
log::error!("Failed to update transfer in database: {}", e);
}
Err(AppError::FileTransfer(format!("Upload failed: {}", response_line)))
}
}
/// 发送进度事件
fn emit_progress(&self, file_id: &str, progress: f64, transferred: u64, total: u64, status: TransferStatus) {
let event = TransferProgressEvent {
file_id: file_id.to_string(),
progress,
transferred_bytes: transferred,
total_bytes: total,
status,
file_name: None, // 发送方不需要文件名
local_path: None, // 发送方不需要本地路径
};
// 直接通过 AppHandle 发送事件
if let Ok(guard) = self.app_handle.read() {
if let Some(ref handle) = *guard {
let _ = handle.emit(event_names::TRANSFER_PROGRESS, &event);
}
}
}
}
impl Default for HttpClient {
fn default() -> Self {
Self::new()
}
}

@ -0,0 +1,285 @@
//! HTTP 请求处理器
use axum::{
body::StreamBody,
extract::{Multipart, Path, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
Json,
};
use dashmap::DashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use tokio::sync::broadcast;
use tokio_util::io::ReaderStream;
use crate::models::{FileMetadata, TransferProgressEvent, TransferStatus};
/// 应用状态
#[derive(Clone)]
pub struct AppState {
/// 待下载文件映射 (file_id -> 文件路径)
pub files: Arc<DashMap<String, PathBuf>>,
/// 文件元数据映射
pub metadata: Arc<DashMap<String, FileMetadata>>,
/// 进度事件发送器
pub progress_tx: broadcast::Sender<TransferProgressEvent>,
/// 下载目录
pub download_dir: PathBuf,
/// 本机设备 ID
pub local_device_id: String,
}
impl AppState {
/// 创建新的应用状态
pub fn new(download_dir: PathBuf, local_device_id: String) -> Self {
let (progress_tx, _) = broadcast::channel(100);
Self {
files: Arc::new(DashMap::new()),
metadata: Arc::new(DashMap::new()),
progress_tx,
download_dir,
local_device_id,
}
}
/// 注册待下载文件
pub fn register_file(&self, file_id: String, path: PathBuf, metadata: FileMetadata) {
self.files.insert(file_id.clone(), path);
self.metadata.insert(file_id, metadata);
}
/// 移除文件注册
pub fn unregister_file(&self, file_id: &str) {
self.files.remove(file_id);
self.metadata.remove(file_id);
}
/// 获取进度事件订阅
pub fn subscribe_progress(&self) -> broadcast::Receiver<TransferProgressEvent> {
self.progress_tx.subscribe()
}
}
/// 上传文件处理
/// POST /upload
pub async fn upload_handler(
State(state): State<AppState>,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let mut file_id: Option<String> = None;
let mut file_name: Option<String> = None;
let mut _from_device: Option<String> = None;
let mut saved_path: Option<PathBuf> = None;
let mut total_size: u64 = 0;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Multipart error: {}", e)))?
{
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"file_id" => {
file_id = Some(
field
.text()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?,
);
}
"file_name" => {
file_name = Some(
field
.text()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?,
);
}
"from_device" => {
_from_device = Some(
field
.text()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?,
);
}
"file" => {
let fname = file_name.clone().unwrap_or_else(|| {
field
.file_name()
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".to_string())
});
// 生成保存路径
let save_path = state.download_dir.join(&fname);
let mut file = File::create(&save_path)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 读取并写入文件内容
let data = field
.bytes()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
total_size = data.len() as u64;
file.write_all(&data)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
file.flush()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
saved_path = Some(save_path);
file_name = Some(fname);
}
_ => {}
}
}
let file_id = file_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let file_name = file_name.ok_or((StatusCode::BAD_REQUEST, "Missing file name".to_string()))?;
let saved_path =
saved_path.ok_or((StatusCode::BAD_REQUEST, "No file uploaded".to_string()))?;
let from_device = _from_device.unwrap_or_default();
// 创建并保存传输记录(接收方)
let transfer = crate::models::FileTransfer {
file_id: file_id.clone(),
name: file_name.clone(),
size: total_size,
progress: 1.0,
status: TransferStatus::Completed,
mime_type: mime_guess::from_path(&saved_path).first_or_octet_stream().to_string(),
from_device: from_device.clone(),
to_device: state.local_device_id.clone(),
local_path: Some(saved_path.to_string_lossy().to_string()),
created_at: chrono::Utc::now().timestamp(),
completed_at: Some(chrono::Utc::now().timestamp()),
transferred_bytes: total_size,
};
if let Err(e) = crate::database::Database::save_file_transfer(&transfer) {
log::error!("Failed to save transfer record: {}", e);
}
// 发送完成事件(包含文件名和路径,供前端显示)
let progress_event = TransferProgressEvent {
file_id: file_id.clone(),
progress: 1.0,
transferred_bytes: total_size,
total_bytes: total_size,
status: TransferStatus::Completed,
file_name: Some(file_name.clone()),
local_path: Some(saved_path.to_string_lossy().to_string()),
};
let _ = state.progress_tx.send(progress_event);
log::info!("File received: {} ({} bytes) from {}", file_name, total_size, from_device);
Ok(Json(serde_json::json!({
"success": true,
"fileId": file_id,
"fileName": file_name,
"size": total_size,
"path": saved_path.to_string_lossy()
})))
}
/// 下载文件处理
/// GET /file/:id
pub async fn download_handler(
State(state): State<AppState>,
Path(file_id): Path<String>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
// 查找文件路径
let file_path = state
.files
.get(&file_id)
.map(|e| e.clone())
.ok_or((StatusCode::NOT_FOUND, "File not found".to_string()))?;
// 获取元数据
let metadata = state.metadata.get(&file_id);
// 打开文件
let file = File::open(&file_path)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let file_size = file
.metadata()
.await
.map(|m| m.len())
.unwrap_or(0);
// 确定 MIME 类型
let mime_type = metadata
.as_ref()
.map(|m| m.mime_type.clone())
.unwrap_or_else(|| {
mime_guess::from_path(&file_path)
.first_or_octet_stream()
.to_string()
});
// 确定文件名
let file_name = metadata
.as_ref()
.map(|m| m.name.clone())
.unwrap_or_else(|| {
file_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "download".to_string())
});
// 创建流式响应
let stream = ReaderStream::new(file);
let body = StreamBody::new(stream);
let response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime_type)
.header(header::CONTENT_LENGTH, file_size)
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", file_name),
)
.body(body)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
log::info!("File download started: {}", file_name);
Ok(response)
}
/// 获取文件元数据
/// GET /file/:id/info
pub async fn file_info_handler(
State(state): State<AppState>,
Path(file_id): Path<String>,
) -> Result<Json<FileMetadata>, (StatusCode, String)> {
let metadata = state
.metadata
.get(&file_id)
.map(|e| e.clone())
.ok_or((StatusCode::NOT_FOUND, "File not found".to_string()))?;
Ok(Json(metadata))
}
/// 健康检查
/// GET /health
pub async fn health_handler() -> Json<serde_json::Value> {
Json(serde_json::json!({
"status": "ok",
"timestamp": chrono::Utc::now().timestamp()
}))
}

@ -0,0 +1,10 @@
//! HTTP 文件传输模块
//! 提供文件上传和下载服务
mod server;
mod handlers;
mod client;
pub use server::*;
pub use handlers::*;
pub use client::*;

@ -0,0 +1,150 @@
//! HTTP 文件服务端
use axum::{
extract::DefaultBodyLimit,
routing::{get, post},
Router,
};
use axum_server::tls_rustls::RustlsConfig;
use std::net::SocketAddr;
use std::path::PathBuf;
use tower_http::cors::{Any, CorsLayer};
use crate::tls::CertificateManager;
use crate::utils::AppError;
use super::handlers::{
download_handler, file_info_handler, health_handler, upload_handler, AppState,
};
/// HTTP 文件服务端
pub struct HttpServer {
/// 监听端口
port: u16,
/// 应用状态
state: AppState,
/// 停止信号
stop_tx: Option<tokio::sync::oneshot::Sender<()>>,
}
impl HttpServer {
/// 创建新的 HTTP 服务端
pub fn new(port: u16, download_dir: PathBuf, local_device_id: String) -> Self {
Self {
port,
state: AppState::new(download_dir, local_device_id),
stop_tx: None,
}
}
/// 获取应用状态
pub fn state(&self) -> &AppState {
&self.state
}
/// 获取可克隆的状态
pub fn state_clone(&self) -> AppState {
self.state.clone()
}
/// 启动服务
pub async fn start(&mut self) -> Result<(), AppError> {
// 获取 TLS 证书
let cert_bundle = CertificateManager::get()
.ok_or_else(|| AppError::Tls("Certificate not initialized".to_string()))?;
// 创建临时证书文件用于 axum-server
let temp_dir = std::env::temp_dir().join("flash-send-certs");
std::fs::create_dir_all(&temp_dir)?;
let cert_path = temp_dir.join("cert.pem");
let key_path = temp_dir.join("key.pem");
std::fs::write(&cert_path, &cert_bundle.cert_pem)?;
std::fs::write(&key_path, &cert_bundle.key_pem)?;
// 配置 TLS
let tls_config = RustlsConfig::from_pem_file(&cert_path, &key_path)
.await
.map_err(|e| AppError::Tls(format!("Failed to load TLS config: {}", e)))?;
// CORS 配置
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
// 创建路由
let app = Router::new()
.route("/health", get(health_handler))
.route("/upload", post(upload_handler))
.route("/file/:id", get(download_handler))
.route("/file/:id/info", get(file_info_handler))
.layer(cors)
.layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024)) // 10GB 限制
.with_state(self.state.clone());
// 绑定地址
let addr = SocketAddr::from(([0, 0, 0, 0], self.port));
log::info!("HTTP server started on https://0.0.0.0:{}", self.port);
// 创建停止信号
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
self.stop_tx = Some(stop_tx);
// 启动服务器
tokio::spawn(async move {
let server = axum_server::bind_rustls(addr, tls_config)
.serve(app.into_make_service());
tokio::select! {
result = server => {
if let Err(e) = result {
log::error!("HTTP server error: {}", e);
}
}
_ = stop_rx => {
log::info!("HTTP server stopping");
}
}
});
Ok(())
}
/// 停止服务
pub fn stop(&mut self) {
if let Some(stop_tx) = self.stop_tx.take() {
let _ = stop_tx.send(());
}
}
/// 注册文件供下载
pub fn register_file(
&self,
file_id: String,
path: PathBuf,
name: String,
size: u64,
mime_type: String,
) {
use crate::models::FileMetadata;
let metadata = FileMetadata {
file_id: file_id.clone(),
name,
size,
mime_type,
thumbnail: None,
path: Some(path.to_string_lossy().to_string()),
};
self.state.register_file(file_id, path, metadata);
}
/// 移除文件注册
pub fn unregister_file(&self, file_id: &str) {
self.state.unregister_file(file_id);
}
}

@ -0,0 +1,16 @@
//! Flash Send 主库
//! 跨平台局域网文件传输与聊天应用
pub mod database;
pub mod discovery;
pub mod http;
pub mod models;
pub mod tls;
pub mod utils;
pub mod websocket;
mod commands;
mod state;
pub use commands::*;
pub use state::*;

@ -0,0 +1,70 @@
//! Flash Send 应用入口
//! 跨平台局域网文件传输与聊天应用
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use flash_send_lib::{
database::Database,
tls::CertificateManager,
utils::{AppConfig, configure_firewall},
AppState,
get_handlers,
};
use std::path::PathBuf;
use tauri::Manager;
fn main() {
// 初始化日志
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.format_timestamp_millis()
.init();
log::info!("Starting Flash Send application");
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_shell::init())
.setup(|app| {
let app_data_dir = app
.path()
.app_data_dir()
.unwrap_or_else(|_| PathBuf::from("."));
log::info!("App data directory: {:?}", app_data_dir);
// 初始化应用状态
AppState::init(app_data_dir.clone())
.expect("Failed to initialize app state");
// 初始化数据库
Database::init(app_data_dir.clone())
.expect("Failed to initialize database");
// 获取本机 IP 并初始化 TLS 证书
let config = AppConfig::get();
let ip_addresses = local_ip_address::local_ip()
.map(|ip| vec![ip.to_string()])
.unwrap_or_default();
CertificateManager::init(&app_data_dir, &config.device_id, ip_addresses)
.expect("Failed to initialize TLS certificate");
// 配置防火墙规则Windows
configure_firewall(
"Flash Send",
config.udp_port,
config.ws_port,
config.http_port,
);
log::info!("Application initialized successfully");
log::info!("Device ID: {}", config.device_id);
log::info!("Device Name: {}", config.device_name);
Ok(())
})
.invoke_handler(get_handlers())
.run(tauri::generate_context!())
.expect("Error while running Flash Send application");
}

@ -0,0 +1,135 @@
//! 设备信息数据结构
use serde::{Deserialize, Serialize};
/// 设备能力枚举
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DeviceCapability {
/// 文本消息
Text,
/// 图片消息
Image,
/// 文件传输
File,
}
/// 设备信息结构
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceInfo {
/// 设备唯一标识符 (UUID)
pub device_id: String,
/// 设备名称
pub device_name: String,
/// IP 地址
pub ip: String,
/// WebSocket 端口
pub ws_port: u16,
/// HTTP 端口
pub http_port: u16,
/// 是否在线
pub online: bool,
/// 设备能力列表
pub capabilities: Vec<DeviceCapability>,
/// 最后活跃时间戳 (Unix timestamp)
#[serde(default)]
pub last_seen: i64,
}
impl DeviceInfo {
/// 创建新的设备信息
pub fn new(
device_id: String,
device_name: String,
ip: String,
ws_port: u16,
http_port: u16,
) -> Self {
Self {
device_id,
device_name,
ip,
ws_port,
http_port,
online: true,
capabilities: vec![
DeviceCapability::Text,
DeviceCapability::Image,
DeviceCapability::File,
],
last_seen: chrono::Utc::now().timestamp(),
}
}
/// 获取 WebSocket 地址
pub fn ws_addr(&self) -> String {
format!("wss://{}:{}", self.ip, self.ws_port)
}
/// 获取 HTTP 地址
pub fn http_addr(&self) -> String {
format!("https://{}:{}", self.ip, self.http_port)
}
/// 更新最后活跃时间
pub fn touch(&mut self) {
self.last_seen = chrono::Utc::now().timestamp();
self.online = true;
}
}
/// UDP 广播发现数据包
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DiscoveryPacket {
/// 协议版本
pub version: u8,
/// 设备信息
pub device: DeviceInfo,
/// 数据包类型
pub packet_type: DiscoveryPacketType,
}
/// 发现数据包类型
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DiscoveryPacketType {
/// 广播通告
Announce,
/// 查询请求
Query,
/// 查询响应
Response,
/// 离线通知
Goodbye,
}
impl DiscoveryPacket {
/// 创建通告数据包
pub fn announce(device: DeviceInfo) -> Self {
Self {
version: 1,
device,
packet_type: DiscoveryPacketType::Announce,
}
}
/// 创建离线数据包
pub fn goodbye(device: DeviceInfo) -> Self {
Self {
version: 1,
device,
packet_type: DiscoveryPacketType::Goodbye,
}
}
/// 创建查询数据包
pub fn query(device: DeviceInfo) -> Self {
Self {
version: 1,
device,
packet_type: DiscoveryPacketType::Query,
}
}
}

@ -0,0 +1,108 @@
//! 前后端事件定义
//! 定义从后端发送到前端的所有事件
use serde::{Deserialize, Serialize};
use super::{DeviceInfo, ChatMessage, FileTransfer};
/// 事件名称常量
pub mod event_names {
/// 发现新设备
pub const DEVICE_FOUND: &str = "device_found";
/// 设备离线
pub const DEVICE_LOST: &str = "device_lost";
/// 收到消息
pub const MESSAGE_RECEIVED: &str = "message_received";
/// 收到文件
pub const FILE_RECEIVED: &str = "file_received";
/// 传输进度更新
pub const TRANSFER_PROGRESS: &str = "transfer_progress";
/// WebSocket 已连接
pub const WEBSOCKET_CONNECTED: &str = "websocket_connected";
/// WebSocket 已断开
pub const WEBSOCKET_DISCONNECTED: &str = "websocket_disconnected";
/// 消息状态更新
pub const MESSAGE_STATUS_UPDATED: &str = "message_status_updated";
}
/// 设备发现事件
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceFoundEvent {
/// 发现的设备信息
pub device: DeviceInfo,
}
/// 设备离线事件
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceLostEvent {
/// 离线设备 ID
pub device_id: String,
}
/// 消息接收事件
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MessageReceivedEvent {
/// 接收到的消息
pub message: ChatMessage,
}
/// 文件接收事件
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileReceivedEvent {
/// 文件传输信息
pub transfer: FileTransfer,
}
/// 传输进度事件
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TransferProgressEvent {
/// 文件 ID
pub file_id: String,
/// 当前进度 (0.0 - 1.0)
pub progress: f64,
/// 已传输字节数
pub transferred_bytes: u64,
/// 总字节数
pub total_bytes: u64,
/// 传输状态
pub status: super::TransferStatus,
/// 文件名(可选,用于接收方显示)
#[serde(skip_serializing_if = "Option::is_none")]
pub file_name: Option<String>,
/// 本地路径(可选,用于接收方)
#[serde(skip_serializing_if = "Option::is_none")]
pub local_path: Option<String>,
}
/// WebSocket 连接事件
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebSocketConnectedEvent {
/// 连接的设备 ID
pub device_id: String,
}
/// WebSocket 断开事件
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebSocketDisconnectedEvent {
/// 断开的设备 ID
pub device_id: String,
/// 断开原因
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
/// 消息状态更新事件
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MessageStatusUpdatedEvent {
/// 消息 ID
pub message_id: String,
/// 新状态
pub status: super::MessageStatus,
}

@ -0,0 +1,167 @@
//! 文件传输数据结构
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// 文件传输状态
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TransferStatus {
/// 等待中
Pending,
/// 传输中
Transferring,
/// 已完成
Completed,
/// 失败
Failed,
/// 已取消
Cancelled,
}
/// 文件传输信息
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTransfer {
/// 文件传输唯一标识符
pub file_id: String,
/// 文件名
pub name: String,
/// 文件大小 (字节)
pub size: u64,
/// 传输进度 (0.0 - 1.0)
pub progress: f64,
/// 传输状态
pub status: TransferStatus,
/// MIME 类型
pub mime_type: String,
/// 发送者设备 ID
pub from_device: String,
/// 接收者设备 ID
pub to_device: String,
/// 本地文件路径 (发送时为源路径,接收时为保存路径)
#[serde(skip_serializing_if = "Option::is_none")]
pub local_path: Option<String>,
/// 创建时间戳
pub created_at: i64,
/// 完成时间戳
#[serde(skip_serializing_if = "Option::is_none")]
pub completed_at: Option<i64>,
/// 已传输字节数
pub transferred_bytes: u64,
}
impl FileTransfer {
/// 创建新的文件传输记录 (发送)
pub fn new_outgoing(
name: String,
size: u64,
mime_type: String,
from_device: String,
to_device: String,
local_path: String,
) -> Self {
Self {
file_id: Uuid::new_v4().to_string(),
name,
size,
progress: 0.0,
status: TransferStatus::Pending,
mime_type,
from_device,
to_device,
local_path: Some(local_path),
created_at: chrono::Utc::now().timestamp(),
completed_at: None,
transferred_bytes: 0,
}
}
/// 创建新的文件传输记录 (接收)
pub fn new_incoming(
file_id: String,
name: String,
size: u64,
mime_type: String,
from_device: String,
to_device: String,
) -> Self {
Self {
file_id,
name,
size,
progress: 0.0,
status: TransferStatus::Pending,
mime_type,
from_device,
to_device,
local_path: None,
created_at: chrono::Utc::now().timestamp(),
completed_at: None,
transferred_bytes: 0,
}
}
/// 更新传输进度
pub fn update_progress(&mut self, transferred_bytes: u64) {
self.transferred_bytes = transferred_bytes;
if self.size > 0 {
self.progress = transferred_bytes as f64 / self.size as f64;
}
if self.progress >= 1.0 {
self.progress = 1.0;
self.status = TransferStatus::Completed;
self.completed_at = Some(chrono::Utc::now().timestamp());
} else {
self.status = TransferStatus::Transferring;
}
}
/// 标记失败
pub fn mark_failed(&mut self) {
self.status = TransferStatus::Failed;
self.completed_at = Some(chrono::Utc::now().timestamp());
}
/// 标记取消
pub fn mark_cancelled(&mut self) {
self.status = TransferStatus::Cancelled;
self.completed_at = Some(chrono::Utc::now().timestamp());
}
}
/// 文件上传请求
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileUploadRequest {
/// 文件 ID
pub file_id: String,
/// 文件名
pub file_name: String,
/// 文件大小
pub file_size: u64,
/// MIME 类型
pub mime_type: String,
/// 发送者设备 ID
pub from_device: String,
}
/// 文件元数据
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileMetadata {
/// 文件 ID
pub file_id: String,
/// 文件名
pub name: String,
/// 文件大小
pub size: u64,
/// MIME 类型
pub mime_type: String,
/// 缩略图 (base64, 可选)
#[serde(skip_serializing_if = "Option::is_none")]
pub thumbnail: Option<String>,
/// 文件路径
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}

@ -0,0 +1,153 @@
//! 聊天消息数据结构
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// 消息类型枚举
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum MessageType {
/// 文本消息
Text,
/// 图片消息 (base64 指针)
Image,
/// 系统事件 (上线/离线等)
Event,
/// 消息确认回执
Ack,
/// 心跳 ping
Ping,
/// 心跳 pong
Pong,
}
/// 聊天消息结构
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChatMessage {
/// 消息唯一标识符
pub id: String,
/// 消息类型
#[serde(rename = "type")]
pub message_type: MessageType,
/// 消息内容
pub content: String,
/// 发送者设备 ID
pub from: String,
/// 接收者设备 ID
pub to: String,
/// 时间戳 (Unix timestamp)
pub timestamp: i64,
/// 消息状态
#[serde(default)]
pub status: MessageStatus,
}
/// 消息状态
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum MessageStatus {
/// 待发送
#[default]
Pending,
/// 已发送
Sent,
/// 已送达
Delivered,
/// 已读
Read,
/// 发送失败
Failed,
}
impl ChatMessage {
/// 创建新的文本消息
pub fn new_text(from: String, to: String, content: String) -> Self {
Self {
id: Uuid::new_v4().to_string(),
message_type: MessageType::Text,
content,
from,
to,
timestamp: chrono::Utc::now().timestamp(),
status: MessageStatus::Pending,
}
}
/// 创建图片消息
pub fn new_image(from: String, to: String, image_data: String) -> Self {
Self {
id: Uuid::new_v4().to_string(),
message_type: MessageType::Image,
content: image_data,
from,
to,
timestamp: chrono::Utc::now().timestamp(),
status: MessageStatus::Pending,
}
}
/// 创建事件消息
pub fn new_event(from: String, to: String, event: &str) -> Self {
Self {
id: Uuid::new_v4().to_string(),
message_type: MessageType::Event,
content: event.to_string(),
from,
to,
timestamp: chrono::Utc::now().timestamp(),
status: MessageStatus::Sent,
}
}
/// 创建消息确认
pub fn new_ack(from: String, to: String, message_id: &str) -> Self {
Self {
id: Uuid::new_v4().to_string(),
message_type: MessageType::Ack,
content: message_id.to_string(),
from,
to,
timestamp: chrono::Utc::now().timestamp(),
status: MessageStatus::Sent,
}
}
/// 创建 ping 消息
pub fn new_ping(from: String) -> Self {
Self {
id: Uuid::new_v4().to_string(),
message_type: MessageType::Ping,
content: String::new(),
from,
to: String::new(),
timestamp: chrono::Utc::now().timestamp(),
status: MessageStatus::Sent,
}
}
/// 创建 pong 消息
pub fn new_pong(from: String, to: String) -> Self {
Self {
id: Uuid::new_v4().to_string(),
message_type: MessageType::Pong,
content: String::new(),
from,
to,
timestamp: chrono::Utc::now().timestamp(),
status: MessageStatus::Sent,
}
}
}
/// WebSocket 连接状态
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionState {
/// 对端设备 ID
pub device_id: String,
/// 是否已连接
pub connected: bool,
/// 最后心跳时间
pub last_heartbeat: i64,
}

@ -0,0 +1,12 @@
//! 数据模型模块
//! 定义应用中使用的所有数据结构
mod device;
mod message;
mod file_transfer;
mod events;
pub use device::*;
pub use message::*;
pub use file_transfer::*;
pub use events::*;

@ -0,0 +1,108 @@
//! 应用状态管理
//! 集中管理所有服务实例
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use crate::discovery::{DeviceManager, DiscoveryService};
use crate::http::{HttpClient, HttpServer};
use crate::models::DeviceInfo;
use crate::utils::{AppConfig, AppError};
use crate::websocket::{ConnectionManager, WsClient, WsServer};
/// 全局应用状态
static APP_STATE: OnceCell<Arc<AppStateInner>> = OnceCell::new();
/// 应用状态内部结构
pub struct AppStateInner {
/// 本机设备信息
pub local_device: RwLock<DeviceInfo>,
/// 设备管理器
pub device_manager: DeviceManager,
/// 发现服务
pub discovery_service: RwLock<Option<DiscoveryService>>,
/// WebSocket 服务端
pub ws_server: RwLock<Option<WsServer>>,
/// WebSocket 客户端
pub ws_client: RwLock<Option<WsClient>>,
/// HTTP 服务端
pub http_server: RwLock<Option<HttpServer>>,
/// HTTP 客户端
pub http_client: HttpClient,
/// 连接管理器
pub connection_manager: ConnectionManager,
/// 应用数据目录
pub app_data_dir: PathBuf,
/// 文件传输取消 tokensfile_id -> CancellationToken
pub transfer_cancellation_tokens: RwLock<HashMap<String, CancellationToken>>,
}
impl AppStateInner {
/// 创建新的应用状态
pub fn new(app_data_dir: PathBuf) -> Self {
let config = AppConfig::get();
let local_device = DeviceInfo::new(
config.device_id.clone(),
config.device_name.clone(),
String::new(), // IP 将在启动时确定
config.ws_port,
config.http_port,
);
Self {
local_device: RwLock::new(local_device),
device_manager: DeviceManager::new(),
discovery_service: RwLock::new(None),
ws_server: RwLock::new(None),
ws_client: RwLock::new(None),
http_server: RwLock::new(None),
http_client: HttpClient::new(),
connection_manager: ConnectionManager::new(),
app_data_dir,
transfer_cancellation_tokens: RwLock::new(HashMap::new()),
}
}
/// 获取本机 IP 地址
pub fn get_local_ip() -> Option<String> {
local_ip_address::local_ip()
.ok()
.map(|ip| ip.to_string())
}
/// 更新本机 IP
pub fn update_local_ip(&self) {
if let Some(ip) = Self::get_local_ip() {
let mut device = self.local_device.write();
device.ip = ip;
}
}
}
/// 应用状态管理
pub struct AppState;
impl AppState {
/// 初始化应用状态
pub fn init(app_data_dir: PathBuf) -> Result<(), AppError> {
let state = Arc::new(AppStateInner::new(app_data_dir));
APP_STATE
.set(state)
.map_err(|_| AppError::General("App state already initialized".to_string()))?;
Ok(())
}
/// 获取应用状态
pub fn get() -> Arc<AppStateInner> {
APP_STATE.get().expect("App state not initialized").clone()
}
/// 检查是否已初始化
pub fn is_initialized() -> bool {
APP_STATE.get().is_some()
}
}

@ -0,0 +1,132 @@
//! 证书生成与管理 (rcgen 0.11)
use once_cell::sync::OnceCell;
use rcgen::{Certificate, CertificateParams, DistinguishedName, DnType, SanType};
use std::path::PathBuf;
use std::sync::Arc;
use crate::utils::AppError;
/// 证书和私钥
pub struct CertificateBundle {
/// PEM 格式的证书
pub cert_pem: String,
/// PEM 格式的私钥
pub key_pem: String,
}
/// 全局证书存储
static CERTIFICATE: OnceCell<Arc<CertificateBundle>> = OnceCell::new();
impl CertificateBundle {
/// 生成新的自签名证书
pub fn generate(device_id: &str, ip_addresses: Vec<String>) -> Result<Self, AppError> {
// 配置证书参数
let mut params = CertificateParams::default();
// 设置证书主题
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, format!("FlashSend-{}", &device_id[..8.min(device_id.len())]));
dn.push(DnType::OrganizationName, "Flash Send");
params.distinguished_name = dn;
// 设置 SAN (Subject Alternative Names)
let mut san_list = vec![
SanType::DnsName("localhost".to_string()),
];
// 添加 IP 地址
for ip in ip_addresses {
if let Ok(ip_addr) = ip.parse() {
san_list.push(SanType::IpAddress(ip_addr));
}
}
// 添加本地回环地址
san_list.push(SanType::IpAddress("127.0.0.1".parse().unwrap()));
params.subject_alt_names = san_list;
// 生成证书
let cert = Certificate::from_params(params)
.map_err(|e| AppError::Tls(format!("Failed to generate certificate: {}", e)))?;
Ok(Self {
cert_pem: cert.serialize_pem()
.map_err(|e| AppError::Tls(format!("Failed to serialize cert: {}", e)))?,
key_pem: cert.serialize_private_key_pem(),
})
}
/// 从文件加载证书
pub fn load_from_files(cert_path: &PathBuf, key_path: &PathBuf) -> Result<Self, AppError> {
let cert_pem = std::fs::read_to_string(cert_path)?;
let key_pem = std::fs::read_to_string(key_path)?;
Ok(Self { cert_pem, key_pem })
}
/// 保存证书到文件
pub fn save_to_files(&self, cert_path: &PathBuf, key_path: &PathBuf) -> Result<(), AppError> {
std::fs::write(cert_path, &self.cert_pem)?;
std::fs::write(key_path, &self.key_pem)?;
log::info!("Certificate saved to {:?}", cert_path);
Ok(())
}
}
/// 证书管理器
pub struct CertificateManager;
impl CertificateManager {
/// 初始化证书 (加载已有或生成新证书)
pub fn init(
app_data_dir: &PathBuf,
device_id: &str,
ip_addresses: Vec<String>,
) -> Result<Arc<CertificateBundle>, AppError> {
let cert_dir = app_data_dir.join("certs");
std::fs::create_dir_all(&cert_dir)?;
let cert_path = cert_dir.join("cert.pem");
let key_path = cert_dir.join("key.pem");
let bundle = if cert_path.exists() && key_path.exists() {
log::info!("Loading existing certificate");
CertificateBundle::load_from_files(&cert_path, &key_path)?
} else {
log::info!("Generating new self-signed certificate");
let bundle = CertificateBundle::generate(device_id, ip_addresses)?;
bundle.save_to_files(&cert_path, &key_path)?;
bundle
};
let arc_bundle = Arc::new(bundle);
CERTIFICATE
.set(arc_bundle.clone())
.map_err(|_| AppError::Tls("Certificate already initialized".to_string()))?;
Ok(arc_bundle)
}
/// 获取当前证书
pub fn get() -> Option<Arc<CertificateBundle>> {
CERTIFICATE.get().cloned()
}
/// 强制重新生成证书
pub fn regenerate(
app_data_dir: &PathBuf,
device_id: &str,
ip_addresses: Vec<String>,
) -> Result<CertificateBundle, AppError> {
let cert_dir = app_data_dir.join("certs");
std::fs::create_dir_all(&cert_dir)?;
let cert_path = cert_dir.join("cert.pem");
let key_path = cert_dir.join("key.pem");
let bundle = CertificateBundle::generate(device_id, ip_addresses)?;
bundle.save_to_files(&cert_path, &key_path)?;
Ok(bundle)
}
}

@ -0,0 +1,119 @@
//! TLS 配置构建器 (rustls 0.21)
use rustls::{Certificate, PrivateKey, ServerConfig};
use rustls_pemfile::{certs, pkcs8_private_keys, rsa_private_keys};
use std::io::BufReader;
use std::sync::Arc;
use tokio_rustls::TlsAcceptor;
use super::CertificateBundle;
use crate::utils::AppError;
/// TLS 配置构建器
pub struct TlsConfigBuilder;
impl TlsConfigBuilder {
/// 从证书包构建服务端 TLS 配置
pub fn build_server_config(bundle: &CertificateBundle) -> Result<Arc<ServerConfig>, AppError> {
// 解析证书
let cert_chain = Self::parse_certificates(&bundle.cert_pem)?;
// 解析私钥
let private_key = Self::parse_private_key(&bundle.key_pem)?;
// 构建 ServerConfig
let config = ServerConfig::builder()
.with_safe_defaults()
.with_no_client_auth()
.with_single_cert(cert_chain, private_key)
.map_err(|e| AppError::Tls(format!("Failed to build TLS config: {}", e)))?;
Ok(Arc::new(config))
}
/// 创建 TLS Acceptor (用于接受 TLS 连接)
pub fn build_acceptor(bundle: &CertificateBundle) -> Result<TlsAcceptor, AppError> {
let config = Self::build_server_config(bundle)?;
Ok(TlsAcceptor::from(config))
}
/// 解析 PEM 格式的证书链
fn parse_certificates(pem: &str) -> Result<Vec<Certificate>, AppError> {
let mut reader = BufReader::new(pem.as_bytes());
let certs_raw = certs(&mut reader)
.map_err(|e| AppError::Tls(format!("Failed to parse certificate: {}", e)))?;
if certs_raw.is_empty() {
return Err(AppError::Tls("No certificates found in PEM".to_string()));
}
Ok(certs_raw.into_iter().map(Certificate).collect())
}
/// 解析 PEM 格式的私钥
fn parse_private_key(pem: &str) -> Result<PrivateKey, AppError> {
let mut reader = BufReader::new(pem.as_bytes());
// 尝试 PKCS8 格式
let keys = pkcs8_private_keys(&mut reader)
.map_err(|e| AppError::Tls(format!("Failed to parse private key: {}", e)))?;
if let Some(key) = keys.into_iter().next() {
return Ok(PrivateKey(key));
}
// 尝试 RSA 格式
let mut reader = BufReader::new(pem.as_bytes());
let keys = rsa_private_keys(&mut reader)
.map_err(|e| AppError::Tls(format!("Failed to parse RSA key: {}", e)))?;
keys.into_iter()
.next()
.map(PrivateKey)
.ok_or_else(|| AppError::Tls("No private key found in PEM".to_string()))
}
}
/// 创建跳过证书验证的客户端配置 (用于自签名证书)
pub mod client {
use rustls::client::{ServerCertVerified, ServerCertVerifier};
use rustls::{Certificate, ClientConfig, ServerName};
use std::sync::Arc;
use std::time::SystemTime;
use tokio_rustls::TlsConnector;
use crate::utils::AppError;
/// 跳过验证的证书验证器 (仅用于自签名证书场景)
struct SkipServerVerification;
impl ServerCertVerifier for SkipServerVerification {
fn verify_server_cert(
&self,
_end_entity: &Certificate,
_intermediates: &[Certificate],
_server_name: &ServerName,
_scts: &mut dyn Iterator<Item = &[u8]>,
_ocsp_response: &[u8],
_now: SystemTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
}
/// 构建客户端 TLS 配置 (跳过证书验证)
pub fn build_client_config() -> Result<Arc<ClientConfig>, AppError> {
let config = ClientConfig::builder()
.with_safe_defaults()
.with_custom_certificate_verifier(Arc::new(SkipServerVerification))
.with_no_client_auth();
Ok(Arc::new(config))
}
/// 创建 TLS Connector
pub fn build_connector() -> Result<TlsConnector, AppError> {
let config = build_client_config()?;
Ok(TlsConnector::from(config))
}
}

@ -0,0 +1,8 @@
//! TLS 加密模块
//! 使用 rustls + rcgen 实现自签名证书管理
mod certificate;
mod config;
pub use certificate::*;
pub use config::*;

@ -0,0 +1,130 @@
//! 应用配置模块
use once_cell::sync::Lazy;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// 默认 UDP 广播端口
pub const DEFAULT_UDP_PORT: u16 = 53317;
/// 默认 WebSocket 端口
pub const DEFAULT_WS_PORT: u16 = 53318;
/// 默认 HTTP 端口
pub const DEFAULT_HTTP_PORT: u16 = 53319;
/// UDP 广播间隔 (毫秒)
pub const DISCOVERY_INTERVAL_MS: u64 = 3000;
/// 设备离线超时 (毫秒)
pub const DEVICE_TIMEOUT_MS: u64 = 15000;
/// 心跳间隔 (毫秒)
pub const HEARTBEAT_INTERVAL_MS: u64 = 5000;
/// 心跳超时 (毫秒)
pub const HEARTBEAT_TIMEOUT_MS: u64 = 15000;
/// 文件分块大小 (字节)
pub const FILE_CHUNK_SIZE: usize = 64 * 1024; // 64KB
/// 应用配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppConfig {
/// 设备唯一标识符
pub device_id: String,
/// 设备名称
pub device_name: String,
/// UDP 广播端口
pub udp_port: u16,
/// WebSocket 服务端口
pub ws_port: u16,
/// HTTP 服务端口
pub http_port: u16,
/// 文件下载保存目录
pub download_dir: String,
/// 是否自动接受文件
pub auto_accept_files: bool,
/// 最大文件大小限制 (字节, 0 表示不限制)
pub max_file_size: u64,
}
impl Default for AppConfig {
fn default() -> Self {
// 从环境变量读取端口偏移量 (用于测试多实例)
// 设置 FLASH_SEND_PORT_OFFSET=100 可运行第二个实例
let port_offset: u16 = std::env::var("FLASH_SEND_PORT_OFFSET")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// 从环境变量读取实例名称后缀
let instance_suffix = std::env::var("FLASH_SEND_INSTANCE")
.ok()
.map(|s| format!(" ({})", s))
.unwrap_or_default();
// 获取主机名作为默认设备名
let device_name = hostname::get()
.map(|h| format!("{}{}", h.to_string_lossy(), instance_suffix))
.unwrap_or_else(|_| format!("Unknown Device{}", instance_suffix));
// 获取默认下载目录
let download_dir = dirs::download_dir()
.or_else(dirs::home_dir)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| ".".to_string());
// 如果有端口偏移生成不同的设备ID用于多实例测试
let device_id = if port_offset > 0 {
// 为测试实例生成固定但不同的 UUID
format!("test-instance-{:04}", port_offset)
} else {
Uuid::new_v4().to_string()
};
Self {
device_id,
device_name,
udp_port: DEFAULT_UDP_PORT, // UDP广播端口必须固定所有实例使用同一端口才能互相发现
ws_port: DEFAULT_WS_PORT + port_offset,
http_port: DEFAULT_HTTP_PORT + port_offset,
download_dir,
auto_accept_files: false,
max_file_size: 0,
}
}
}
/// 全局配置单例
pub static APP_CONFIG: Lazy<RwLock<AppConfig>> = Lazy::new(|| {
RwLock::new(AppConfig::default())
});
impl AppConfig {
/// 获取当前配置副本
pub fn get() -> AppConfig {
APP_CONFIG.read().clone()
}
/// 更新配置
pub fn update<F>(f: F)
where
F: FnOnce(&mut AppConfig),
{
let mut config = APP_CONFIG.write();
f(&mut config);
}
/// 设置设备名称
pub fn set_device_name(name: String) {
Self::update(|c| c.device_name = name);
}
/// 设置下载目录
pub fn set_download_dir(dir: String) {
Self::update(|c| c.download_dir = dir);
}
}

@ -0,0 +1,105 @@
//! 错误处理模块
use serde::Serialize;
use thiserror::Error;
/// 应用级错误类型
#[derive(Debug, Error)]
pub enum AppError {
/// IO 错误
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
/// 数据库错误
#[error("Database error: {0}")]
Database(#[from] rusqlite::Error),
/// 序列化错误
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
/// TLS 错误
#[error("TLS error: {0}")]
Tls(String),
/// WebSocket 错误
#[error("WebSocket error: {0}")]
WebSocket(String),
/// 网络错误
#[error("Network error: {0}")]
Network(String),
/// 文件传输错误
#[error("File transfer error: {0}")]
FileTransfer(String),
/// 设备未找到
#[error("Device not found: {0}")]
DeviceNotFound(String),
/// 连接错误
#[error("Connection error: {0}")]
Connection(String),
/// 配置错误
#[error("Configuration error: {0}")]
Config(String),
/// 通用错误
#[error("{0}")]
General(String),
}
/// Tauri 命令返回的错误包装
/// 实现 Serialize 以便在前端使用
#[derive(Debug, Serialize)]
pub struct CommandError {
/// 错误代码
pub code: String,
/// 错误消息
pub message: String,
}
impl From<AppError> for CommandError {
fn from(err: AppError) -> Self {
let (code, message) = match &err {
AppError::Io(_) => ("IO_ERROR", err.to_string()),
AppError::Database(_) => ("DB_ERROR", err.to_string()),
AppError::Serialization(_) => ("SERIALIZATION_ERROR", err.to_string()),
AppError::Tls(_) => ("TLS_ERROR", err.to_string()),
AppError::WebSocket(_) => ("WS_ERROR", err.to_string()),
AppError::Network(_) => ("NETWORK_ERROR", err.to_string()),
AppError::FileTransfer(_) => ("FILE_TRANSFER_ERROR", err.to_string()),
AppError::DeviceNotFound(_) => ("DEVICE_NOT_FOUND", err.to_string()),
AppError::Connection(_) => ("CONNECTION_ERROR", err.to_string()),
AppError::Config(_) => ("CONFIG_ERROR", err.to_string()),
AppError::General(_) => ("GENERAL_ERROR", err.to_string()),
};
Self {
code: code.to_string(),
message,
}
}
}
impl From<std::io::Error> for CommandError {
fn from(err: std::io::Error) -> Self {
AppError::Io(err).into()
}
}
impl From<rusqlite::Error> for CommandError {
fn from(err: rusqlite::Error) -> Self {
AppError::Database(err).into()
}
}
impl From<serde_json::Error> for CommandError {
fn from(err: serde_json::Error) -> Self {
AppError::Serialization(err).into()
}
}
/// Tauri 命令返回类型
pub type CommandResult<T> = Result<T, CommandError>;

@ -0,0 +1,78 @@
//! Windows 防火墙自动配置
//!
//! 在应用启动时自动添加防火墙规则,避免用户手动配置
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use std::process::Command;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
/// 检查并配置 Windows 防火墙规则
/// 如果规则不存在,则静默添加(需要管理员权限时会自动请求)
#[cfg(target_os = "windows")]
pub fn configure_firewall(app_name: &str, udp_port: u16, ws_port: u16, http_port: u16) {
// 检查规则是否已存在
let check_result = Command::new("netsh")
.args(["advfirewall", "firewall", "show", "rule", &format!("name={}", app_name)])
.creation_flags(CREATE_NO_WINDOW)
.output();
if let Ok(output) = check_result {
let output_str = String::from_utf8_lossy(&output.stdout);
if output_str.contains(app_name) {
log::info!("Firewall rules already configured");
return;
}
}
log::info!("Configuring firewall rules...");
// 添加 UDP 规则(设备发现)
let _ = Command::new("netsh")
.args([
"advfirewall", "firewall", "add", "rule",
&format!("name={} UDP", app_name),
"dir=in",
"action=allow",
"protocol=UDP",
&format!("localport={}", udp_port),
])
.creation_flags(CREATE_NO_WINDOW)
.output();
// 添加 WebSocket TCP 规则
let _ = Command::new("netsh")
.args([
"advfirewall", "firewall", "add", "rule",
&format!("name={} WebSocket", app_name),
"dir=in",
"action=allow",
"protocol=TCP",
&format!("localport={}", ws_port),
])
.creation_flags(CREATE_NO_WINDOW)
.output();
// 添加 HTTP TCP 规则
let _ = Command::new("netsh")
.args([
"advfirewall", "firewall", "add", "rule",
&format!("name={} HTTP", app_name),
"dir=in",
"action=allow",
"protocol=TCP",
&format!("localport={}", http_port),
])
.creation_flags(CREATE_NO_WINDOW)
.output();
log::info!("Firewall rules configuration attempted");
}
#[cfg(not(target_os = "windows"))]
pub fn configure_firewall(_app_name: &str, _udp_port: u16, _ws_port: u16, _http_port: u16) {
// macOS 和 Linux 通常不需要手动配置防火墙
log::info!("Firewall configuration not needed on this platform");
}

@ -0,0 +1,9 @@
//! 工具模块
mod error;
mod config;
mod firewall;
pub use error::*;
pub use config::*;
pub use firewall::*;

@ -0,0 +1,171 @@
//! WebSocket 客户端
//! 用于主动连接其他设备
//!
//! 注意: 客户端使用独立的连接管理,因为 TLS 流类型与服务端不同
use dashmap::DashMap;
use futures_util::stream::SplitSink;
use futures_util::{SinkExt, StreamExt};
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio_rustls::client::TlsStream;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::{client_async, WebSocketStream};
use crate::models::{ChatMessage, DeviceInfo};
use crate::tls::client as tls_client;
use crate::utils::AppError;
use super::{ConnectionManager, MessageHandler};
/// 客户端 WebSocket 写入端类型
type ClientWsWriter = SplitSink<WebSocketStream<TlsStream<TcpStream>>, Message>;
type ClientWsWriterArc = Arc<tokio::sync::Mutex<ClientWsWriter>>;
/// WebSocket 客户端
#[derive(Clone)]
pub struct WsClient {
/// 本机设备 ID
local_device_id: String,
/// 连接管理器 (用于事件通知)
connection_manager: ConnectionManager,
/// 客户端连接存储
client_connections: Arc<DashMap<String, ClientWsWriterArc>>,
}
impl WsClient {
/// 创建新的客户端
pub fn new(local_device_id: String, connection_manager: ConnectionManager) -> Self {
Self {
local_device_id,
connection_manager,
client_connections: Arc::new(DashMap::new()),
}
}
/// 连接到指定设备
pub async fn connect(&self, device: &DeviceInfo) -> Result<(), AppError> {
let addr = format!("{}:{}", device.ip, device.ws_port);
let ws_url = format!("wss://{}", addr);
log::info!("Connecting to {} at {}", device.device_name, ws_url);
// 建立 TCP 连接
let tcp_stream = TcpStream::connect(&addr)
.await
.map_err(|e| AppError::Connection(format!("TCP connection failed: {}", e)))?;
// TLS 握手
let tls_connector = tls_client::build_connector()?;
let server_name = rustls::ServerName::try_from(device.ip.as_str())
.map_err(|_| AppError::Tls("Invalid server name".to_string()))?;
let tls_stream = tls_connector
.connect(server_name, tcp_stream)
.await
.map_err(|e| AppError::Tls(format!("TLS handshake failed: {}", e)))?;
// WebSocket 握手
let (ws_stream, _) = client_async(&ws_url, tls_stream)
.await
.map_err(|e| AppError::WebSocket(format!("WebSocket handshake failed: {}", e)))?;
let (writer, mut reader) = ws_stream.split();
let writer_arc = Arc::new(tokio::sync::Mutex::new(writer));
// 存储连接
let device_id = device.device_id.clone();
self.client_connections.insert(device_id.clone(), writer_arc.clone());
// 发送初始消息以标识自己
let hello = ChatMessage::new_event(
self.local_device_id.clone(),
device.device_id.clone(),
"online",
);
{
let data = serde_json::to_string(&hello)
.map_err(|e| AppError::Serialization(e))?;
let mut w = writer_arc.lock().await;
w.send(Message::Text(data)).await
.map_err(|e| AppError::WebSocket(format!("Send failed: {}", e)))?;
}
log::info!("Connected to device {}", device.device_name);
// 启动读取任务
let connection_manager = self.connection_manager.clone();
let local_device_id = self.local_device_id.clone();
let peer_device_id = device.device_id.clone();
let client_connections = self.client_connections.clone();
tokio::spawn(async move {
while let Some(msg_result) = reader.next().await {
match msg_result {
Ok(msg) => {
if msg.is_text() {
let text = msg.to_text().unwrap_or("");
if let Ok(message) = serde_json::from_str::<ChatMessage>(text) {
MessageHandler::handle_message(
message,
&connection_manager,
&local_device_id,
).await;
}
} else if msg.is_close() {
break;
}
}
Err(e) => {
log::error!("WebSocket read error: {}", e);
break;
}
}
}
client_connections.remove(&peer_device_id);
log::info!("Disconnected from device {}", peer_device_id);
});
Ok(())
}
/// 向设备发送消息
pub async fn send_to(&self, device_id: &str, message: &ChatMessage) -> Result<(), String> {
if let Some(writer) = self.client_connections.get(device_id) {
let data = serde_json::to_string(message)
.map_err(|e| format!("Serialization error: {}", e))?;
let mut w = writer.lock().await;
w.send(Message::Text(data)).await
.map_err(|e| format!("Send error: {}", e))?;
Ok(())
} else {
Err(format!("Device {} not connected", device_id))
}
}
/// 检查是否已连接
pub fn is_connected(&self, device_id: &str) -> bool {
self.client_connections.contains_key(device_id)
}
/// 断开与指定设备的连接
pub async fn disconnect(&self, device_id: &str) {
// 发送离线通知
let goodbye = ChatMessage::new_event(
self.local_device_id.clone(),
device_id.to_string(),
"offline",
);
let _ = self.send_to(device_id, &goodbye).await;
self.client_connections.remove(device_id);
}
/// 获取连接管理器引用
pub fn connection_manager(&self) -> &ConnectionManager {
&self.connection_manager
}
}

@ -0,0 +1,156 @@
//! WebSocket 连接管理
use dashmap::DashMap;
use futures_util::stream::SplitSink;
use futures_util::SinkExt;
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio::sync::broadcast;
use tokio_rustls::server::TlsStream;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::WebSocketStream;
use crate::models::ChatMessage;
/// WebSocket 连接事件
#[derive(Debug, Clone)]
pub enum WsConnectionEvent {
/// 新连接建立
Connected { device_id: String },
/// 连接断开
Disconnected { device_id: String, reason: Option<String> },
/// 收到消息
MessageReceived { message: ChatMessage },
}
/// WebSocket 写入端类型别名 (服务端TLS连接)
pub type WsWriter = SplitSink<WebSocketStream<TlsStream<TcpStream>>, Message>;
/// 包装后的写入端类型
pub type WsWriterArc = Arc<tokio::sync::Mutex<WsWriter>>;
/// 连接管理器
pub struct ConnectionManager {
/// 活跃连接 (device_id -> writer)
connections: Arc<DashMap<String, WsWriterArc>>,
/// 事件发送器
event_tx: broadcast::Sender<WsConnectionEvent>,
}
impl ConnectionManager {
/// 创建新的连接管理器
pub fn new() -> Self {
let (event_tx, _) = broadcast::channel(100);
Self {
connections: Arc::new(DashMap::new()),
event_tx,
}
}
/// 获取事件订阅
pub fn subscribe(&self) -> broadcast::Receiver<WsConnectionEvent> {
self.event_tx.subscribe()
}
/// 注册新连接
pub fn register(&self, device_id: String, writer: WsWriter) {
let writer = Arc::new(tokio::sync::Mutex::new(writer));
self.connections.insert(device_id.clone(), writer);
let _ = self.event_tx.send(WsConnectionEvent::Connected { device_id });
}
/// 注册新连接 (使用已包装的 Arc)
pub fn register_arc(&self, device_id: String, writer: WsWriterArc) {
self.connections.insert(device_id.clone(), writer);
let _ = self.event_tx.send(WsConnectionEvent::Connected { device_id });
}
/// 移除连接
pub fn unregister(&self, device_id: &str, reason: Option<String>) {
if self.connections.remove(device_id).is_some() {
let _ = self.event_tx.send(WsConnectionEvent::Disconnected {
device_id: device_id.to_string(),
reason,
});
}
}
/// 检查设备是否已连接
pub fn is_connected(&self, device_id: &str) -> bool {
self.connections.contains_key(device_id)
}
/// 获取所有已连接的设备 ID
pub fn connected_devices(&self) -> Vec<String> {
self.connections.iter().map(|e| e.key().clone()).collect()
}
/// 向指定设备发送消息
pub async fn send_to(&self, device_id: &str, message: &ChatMessage) -> Result<(), String> {
if let Some(writer) = self.connections.get(device_id) {
let data = serde_json::to_string(message)
.map_err(|e| format!("Serialization error: {}", e))?;
let mut writer = writer.lock().await;
writer
.send(Message::Text(data))
.await
.map_err(|e| format!("Send error: {}", e))?;
Ok(())
} else {
Err(format!("Device {} not connected", device_id))
}
}
/// 广播消息到所有连接
pub async fn broadcast(&self, message: &ChatMessage) {
let data = match serde_json::to_string(message) {
Ok(d) => d,
Err(e) => {
log::error!("Failed to serialize message: {}", e);
return;
}
};
for entry in self.connections.iter() {
let mut writer = entry.value().lock().await;
if let Err(e) = writer.send(Message::Text(data.clone())).await {
log::error!("Failed to send to {}: {}", entry.key(), e);
}
}
}
/// 发送消息接收事件
pub fn emit_message_received(&self, message: ChatMessage) {
let _ = self.event_tx.send(WsConnectionEvent::MessageReceived { message });
}
/// 获取连接数
pub fn connection_count(&self) -> usize {
self.connections.len()
}
/// 关闭所有连接
pub async fn close_all(&self) {
for entry in self.connections.iter() {
let mut writer = entry.value().lock().await;
let _ = writer.close().await;
}
self.connections.clear();
}
}
impl Default for ConnectionManager {
fn default() -> Self {
Self::new()
}
}
impl Clone for ConnectionManager {
fn clone(&self) -> Self {
Self {
connections: self.connections.clone(),
event_tx: self.event_tx.clone(),
}
}
}

@ -0,0 +1,96 @@
//! WebSocket 消息处理器
use crate::database::Database;
use crate::models::{ChatMessage, MessageStatus, MessageType};
use super::ConnectionManager;
/// 消息处理器
pub struct MessageHandler;
impl MessageHandler {
/// 处理收到的消息
pub async fn handle_message(
message: ChatMessage,
connection_manager: &ConnectionManager,
local_device_id: &str,
) {
match message.message_type {
MessageType::Text | MessageType::Image => {
// 保存消息到数据库
if let Err(e) = Database::save_message(&message) {
log::error!("Failed to save message: {}", e);
}
// 发送确认回执
let ack = ChatMessage::new_ack(
local_device_id.to_string(),
message.from.clone(),
&message.id,
);
if let Err(e) = connection_manager.send_to(&message.from, &ack).await {
log::error!("Failed to send ack: {}", e);
}
// 触发消息接收事件
connection_manager.emit_message_received(message);
}
MessageType::Event => {
// 处理事件消息 (如在线/离线通知)
log::info!("Received event: {} from {}", message.content, message.from);
connection_manager.emit_message_received(message);
}
MessageType::Ack => {
// 处理确认回执,更新消息状态
let message_id = &message.content;
if let Err(e) = Database::update_message_status(message_id, MessageStatus::Delivered) {
log::error!("Failed to update message status: {}", e);
}
}
MessageType::Ping => {
// 收到 ping回复 pong
let pong = ChatMessage::new_pong(
local_device_id.to_string(),
message.from.clone(),
);
if let Err(e) = connection_manager.send_to(&message.from, &pong).await {
log::error!("Failed to send pong: {}", e);
}
}
MessageType::Pong => {
// 收到 pong更新心跳时间
log::debug!("Received pong from {}", message.from);
}
}
}
}
/// 心跳管理器
pub struct HeartbeatManager;
impl HeartbeatManager {
/// 启动心跳任务
pub fn start(
connection_manager: ConnectionManager,
local_device_id: String,
interval_ms: u64,
) {
tokio::spawn(async move {
let interval = std::time::Duration::from_millis(interval_ms);
loop {
tokio::time::sleep(interval).await;
let devices = connection_manager.connected_devices();
for device_id in devices {
let ping = ChatMessage::new_ping(local_device_id.clone());
if let Err(e) = connection_manager.send_to(&device_id, &ping).await {
log::warn!("Failed to send heartbeat to {}: {}", device_id, e);
}
}
}
});
}
}

@ -0,0 +1,12 @@
//! WebSocket 聊天服务模块
//! 实现实时文本消息、心跳、在线状态管理
mod server;
mod client;
mod connection;
mod handler;
pub use server::*;
pub use client::*;
pub use connection::*;
pub use handler::*;

@ -0,0 +1,178 @@
//! WebSocket 服务端
use futures_util::StreamExt;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;
use tokio_tungstenite::accept_async;
use crate::models::ChatMessage;
use crate::tls::{CertificateManager, TlsConfigBuilder};
use crate::utils::AppError;
use super::{ConnectionManager, MessageHandler};
/// WebSocket 服务端
pub struct WsServer {
/// 监听端口
port: u16,
/// 本机设备 ID
local_device_id: String,
/// 连接管理器
connection_manager: ConnectionManager,
/// 停止信号
stop_tx: Option<tokio::sync::oneshot::Sender<()>>,
}
impl WsServer {
/// 创建新的 WebSocket 服务端
pub fn new(port: u16, local_device_id: String, connection_manager: ConnectionManager) -> Self {
Self {
port,
local_device_id,
connection_manager,
stop_tx: None,
}
}
/// 启动服务
pub async fn start(&mut self) -> Result<(), AppError> {
// 获取 TLS 配置
let cert_bundle = CertificateManager::get()
.ok_or_else(|| AppError::Tls("Certificate not initialized".to_string()))?;
let tls_acceptor = TlsConfigBuilder::build_acceptor(&cert_bundle)?;
// 绑定端口
let addr = SocketAddr::from(([0, 0, 0, 0], self.port));
let listener = TcpListener::bind(addr).await?;
log::info!("WebSocket server started on port {}", self.port);
// 创建停止信号
let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel();
self.stop_tx = Some(stop_tx);
let connection_manager = self.connection_manager.clone();
let local_device_id = self.local_device_id.clone();
tokio::spawn(async move {
loop {
tokio::select! {
result = listener.accept() => {
match result {
Ok((stream, peer_addr)) => {
let tls_acceptor = tls_acceptor.clone();
let conn_mgr = connection_manager.clone();
let device_id = local_device_id.clone();
tokio::spawn(async move {
if let Err(e) = Self::handle_connection(
stream,
peer_addr,
tls_acceptor,
conn_mgr,
device_id,
).await {
log::error!("Connection error from {}: {}", peer_addr, e);
}
});
}
Err(e) => {
log::error!("Accept error: {}", e);
}
}
}
_ = &mut stop_rx => {
log::info!("WebSocket server stopping");
break;
}
}
}
});
Ok(())
}
/// 处理单个连接
async fn handle_connection(
stream: tokio::net::TcpStream,
peer_addr: SocketAddr,
tls_acceptor: TlsAcceptor,
connection_manager: ConnectionManager,
local_device_id: String,
) -> Result<(), AppError> {
log::info!("New connection from {}", peer_addr);
// TLS 握手
let tls_stream = tls_acceptor
.accept(stream)
.await
.map_err(|e| AppError::Tls(format!("TLS handshake failed: {}", e)))?;
// WebSocket 握手
let ws_stream = accept_async(tls_stream)
.await
.map_err(|e| AppError::WebSocket(format!("WebSocket handshake failed: {}", e)))?;
let (write_half, mut read_half) = ws_stream.split();
// 使用 Arc<Mutex> 包装 writer 以便在读取第一条消息后注册
let writer = Arc::new(tokio::sync::Mutex::new(write_half));
let mut peer_device_id: Option<String> = None;
let mut registered = false;
while let Some(msg_result) = read_half.next().await {
match msg_result {
Ok(msg) => {
if msg.is_text() {
let text = msg.to_text().unwrap_or("");
if let Ok(message) = serde_json::from_str::<ChatMessage>(text) {
if !registered {
// 第一条消息,注册连接
peer_device_id = Some(message.from.clone());
connection_manager.register_arc(message.from.clone(), writer.clone());
registered = true;
log::info!("Device {} connected", message.from);
}
// 处理消息
MessageHandler::handle_message(
message,
&connection_manager,
&local_device_id,
).await;
}
} else if msg.is_close() {
break;
}
}
Err(e) => {
log::error!("WebSocket error: {}", e);
break;
}
}
}
// 连接关闭,移除注册
if let Some(device_id) = peer_device_id {
connection_manager.unregister(&device_id, Some("Connection closed".to_string()));
log::info!("Device {} disconnected", device_id);
}
Ok(())
}
/// 停止服务
pub fn stop(&mut self) {
if let Some(stop_tx) = self.stop_tx.take() {
let _ = stop_tx.send(());
}
}
/// 获取连接管理器
pub fn connection_manager(&self) -> &ConnectionManager {
&self.connection_manager
}
}

@ -0,0 +1,41 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Flash Send",
"version": "1.0.0",
"identifier": "com.flashsend.app",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "Flash Send",
"width": 1200,
"height": 800,
"resizable": true,
"fullscreen": false,
"minWidth": 800,
"minHeight": 600
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {}
}

@ -0,0 +1,97 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { listen, UnlistenFn } from '@tauri-apps/api/event'
import { useDeviceStore } from '@/stores/deviceStore'
import { useChatStore } from '@/stores/chatStore'
import { EventNames } from '@/types'
import type { DeviceInfo, ChatMessage, TransferProgressEvent } from '@/types'
import Sidebar from '@/components/Sidebar.vue'
const deviceStore = useDeviceStore()
const chatStore = useChatStore()
let unlistenFns: UnlistenFn[] = []
//
function initTheme() {
const theme = localStorage.getItem('theme') || 'system'
const root = document.documentElement
if (theme === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
root.classList.toggle('dark', isDark)
} else {
root.classList.toggle('dark', theme === 'dark')
}
}
onMounted(async () => {
//
initTheme()
//
await deviceStore.initialize()
//
unlistenFns.push(
await listen<DeviceInfo>(EventNames.DEVICE_FOUND, (event) => {
deviceStore.addDevice(event.payload)
})
)
// 线
unlistenFns.push(
await listen<{ deviceId: string }>(EventNames.DEVICE_LOST, (event) => {
deviceStore.removeDevice(event.payload.deviceId)
})
)
//
unlistenFns.push(
await listen<ChatMessage>(EventNames.MESSAGE_RECEIVED, (event) => {
chatStore.receiveMessage(event.payload)
})
)
// WebSocket
unlistenFns.push(
await listen<{ deviceId: string }>(EventNames.WEBSOCKET_CONNECTED, (event) => {
deviceStore.setDeviceConnected(event.payload.deviceId, true)
})
)
// WebSocket
unlistenFns.push(
await listen<{ deviceId: string }>(EventNames.WEBSOCKET_DISCONNECTED, (event) => {
deviceStore.setDeviceConnected(event.payload.deviceId, false)
})
)
//
unlistenFns.push(
await listen<TransferProgressEvent>(EventNames.TRANSFER_PROGRESS, (event) => {
chatStore.updateTransferProgress(event.payload)
})
)
})
onUnmounted(() => {
//
unlistenFns.forEach(fn => fn())
})
</script>
<template>
<div class="flex h-screen bg-surface-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<!-- 侧边栏 -->
<Sidebar />
<!-- 主内容区 -->
<main class="flex-1 overflow-hidden">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</div>
</template>

@ -0,0 +1,96 @@
/**
* API
* Tauri
*/
import { invoke } from '@tauri-apps/api/core'
import type {
DeviceInfo,
ChatMessage,
FileTransfer,
FileMetadata,
AppConfig,
} from '@/types'
/** 发现服务 API */
export const discoveryApi = {
/** 启动设备发现服务 */
start: () => invoke('start_discovery_service'),
/** 停止设备发现服务 */
stop: () => invoke('stop_discovery_service'),
/** 获取已发现的设备列表 */
getDevices: () => invoke<DeviceInfo[]>('get_discovered_devices'),
}
/** WebSocket API */
export const websocketApi = {
/** 启动 WebSocket 服务端 */
startServer: () => invoke('start_websocket_server'),
/** 停止 WebSocket 服务端 */
stopServer: () => invoke('stop_websocket_server'),
/** 连接到指定设备 */
connect: (deviceId: string) => invoke('connect_to_peer', { deviceId }),
/** 断开与指定设备的连接 */
disconnect: (deviceId: string) => invoke('disconnect_from_peer', { deviceId }),
}
/** 聊天 API */
export const chatApi = {
/** 发送聊天消息 */
sendMessage: (deviceId: string, content: string, messageType?: string) =>
invoke<ChatMessage>('send_chat_message', { deviceId, content, messageType }),
/** 获取聊天历史 */
getHistory: (deviceId: string, limit?: number, offset?: number) =>
invoke<ChatMessage[]>('get_chat_history', { deviceId, limit, offset }),
/** 删除聊天历史 */
deleteHistory: (deviceId: string) =>
invoke<number>('delete_chat_history', { deviceId }),
}
/** 文件传输 API */
export const fileApi = {
/** 启动 HTTP 服务端 */
startServer: () => invoke('start_http_server'),
/** 停止 HTTP 服务端 */
stopServer: () => invoke('stop_http_server'),
/** 选择要发送的文件 */
selectFile: () => invoke<FileMetadata | null>('select_file_to_send'),
/** 发送文件 */
sendFile: (deviceId: string, fileId: string, filePath: string) =>
invoke<FileTransfer>('send_file', { deviceId, fileId, filePath }),
/** 获取传输历史 */
getHistory: (deviceId: string, limit?: number) =>
invoke<FileTransfer[]>('get_transfer_history', { deviceId, limit }),
/** 打开文件位置 */
openLocation: (path: string) => invoke('open_file_location', { path }),
/** 取消传输 */
cancelTransfer: (fileId: string) => invoke('cancel_transfer', { fileId }),
}
/** 配置 API */
export const configApi = {
/** 获取应用配置 */
get: () => invoke<AppConfig>('get_app_config'),
/** 更新设备名称 */
updateDeviceName: (name: string) => invoke('update_device_name', { name }),
/** 更新下载目录 */
updateDownloadDir: (dir: string) => invoke('update_download_dir', { dir }),
/** 获取本机设备信息 */
getLocalDevice: () => invoke<DeviceInfo>('get_local_device_info'),
}

@ -0,0 +1,87 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Send, Paperclip, Image } from 'lucide-vue-next'
const emit = defineEmits<{
(e: 'send', content: string): void
(e: 'selectFile'): void
(e: 'selectImage'): void
}>()
const inputText = ref('')
const inputRef = ref<HTMLTextAreaElement | null>(null)
//
function sendMessage() {
const content = inputText.value.trim()
if (!content) return
emit('send', content)
inputText.value = ''
//
inputRef.value?.focus()
}
//
function handleKeydown(e: KeyboardEvent) {
// Enter Shift+Enter
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}
//
function autoResize(e: Event) {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
target.style.height = Math.min(target.scrollHeight, 120) + 'px'
}
</script>
<template>
<div class="bg-white dark:bg-gray-800 border-t border-surface-200 dark:border-gray-700 p-4">
<div class="flex items-end gap-3">
<!-- 附件按钮 -->
<div class="flex gap-1">
<button
@click="emit('selectFile')"
class="p-2 rounded-lg hover:bg-surface-100 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 transition-colors"
title="发送文件"
>
<Paperclip :size="20" />
</button>
<button
@click="emit('selectImage')"
class="p-2 rounded-lg hover:bg-surface-100 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 transition-colors"
title="发送图片"
>
<Image :size="20" />
</button>
</div>
<!-- 输入框 -->
<div class="flex-1 relative">
<textarea
ref="inputRef"
v-model="inputText"
@keydown="handleKeydown"
@input="autoResize"
placeholder="输入消息... (Enter 发送, Shift+Enter 换行)"
rows="1"
class="w-full px-4 py-2 bg-surface-50 dark:bg-gray-700 border border-surface-200 dark:border-gray-600 rounded-xl resize-none focus:outline-none focus:border-primary-400 transition-colors text-gray-900 dark:text-white placeholder-gray-400"
/>
</div>
<!-- 发送按钮 -->
<button
@click="sendMessage"
:disabled="!inputText.trim()"
class="p-3 rounded-xl bg-primary-500 text-white disabled:opacity-50 disabled:cursor-not-allowed hover:bg-primary-600 transition-colors"
>
<Send :size="20" />
</button>
</div>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save