first commit
38
.gitignore
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
# Rust
|
||||
/target/
|
||||
**/*.rs.bk
|
||||
Cargo.lock
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
dist/
|
||||
.cache/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build artifacts
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Tauri
|
||||
/crates/client-tauri/src-tauri/target/
|
||||
/crates/client-tauri/ui/dist/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
88
Cargo.toml
Normal file
@ -0,0 +1,88 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/common",
|
||||
"crates/server",
|
||||
"crates/client-core",
|
||||
"crates/client-tauri",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["EasyRemote Team"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/easyremote/easyremote"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Async runtime
|
||||
tokio = { version = "1.35", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["codec"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
bincode = "1.3"
|
||||
|
||||
# Web framework
|
||||
axum = { version = "0.7", features = ["ws", "macros"] }
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["cors", "fs", "trace"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
|
||||
# Database
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "postgres", "chrono", "uuid"] }
|
||||
sea-orm = { version = "0.12", features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-native-tls"] }
|
||||
|
||||
# Authentication
|
||||
jsonwebtoken = "9.2"
|
||||
argon2 = "0.5"
|
||||
uuid = { version = "1.6", features = ["v4", "serde"] }
|
||||
|
||||
# Networking
|
||||
quinn = "0.10"
|
||||
webrtc = "0.9"
|
||||
stun = "0.5"
|
||||
turn = "0.6"
|
||||
|
||||
# Encryption
|
||||
ring = "0.17"
|
||||
rustls = "0.22"
|
||||
rcgen = "0.12"
|
||||
|
||||
# Screen capture & encoding
|
||||
scrap = "0.5"
|
||||
x264 = "0.5"
|
||||
vpx-encode = "1.0"
|
||||
|
||||
# Image processing
|
||||
image = "0.24"
|
||||
turbojpeg = "1.0"
|
||||
|
||||
# Input simulation
|
||||
enigo = "0.2"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
|
||||
# Utils
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
rand = "0.8"
|
||||
base64 = "0.21"
|
||||
bytes = "1.5"
|
||||
futures = "0.3"
|
||||
async-trait = "0.1"
|
||||
|
||||
# Config
|
||||
config = "0.14"
|
||||
dotenvy = "0.15"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
277
README.md
Normal file
@ -0,0 +1,277 @@
|
||||
# EasyRemote - 远程桌面控制软件
|
||||
|
||||
EasyRemote 是一个基于 Rust 开发的远程桌面控制软件,支持 P2P 直连和服务器中转两种连接方式,提供高性能的远程控制体验。
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
### 客户端功能
|
||||
- 🔐 **设备ID + 验证码** 连接方式,类似 TeamViewer/向日葵
|
||||
- 👥 **账号登录** 支持,同步设备列表和控制历史
|
||||
- 🖥️ **高性能屏幕捕获**,支持多显示器
|
||||
- ⌨️ **完整输入控制**,包括鼠标、键盘、滚轮
|
||||
- 📋 **剪贴板同步**
|
||||
- 📊 **控制历史记录**
|
||||
- ⚙️ **质量设置**,可调节帧率、分辨率、图像质量
|
||||
|
||||
### 服务端功能
|
||||
- 🌐 **Web 管理后台**,管理用户、设备、会话
|
||||
- 📡 **信令服务器**,支持 WebSocket
|
||||
- 🔄 **P2P 穿透**,优先直连,备选中转
|
||||
- 🛰️ **内置 STUN 服务器**,支持 NAT 穿透
|
||||
- 👮 **管理员功能**:强制下线、结束会话
|
||||
- 🖥️ **浏览器远程控制**(管理员)
|
||||
|
||||
### 技术特点
|
||||
- 🦀 **纯 Rust 实现**,高性能、内存安全
|
||||
- 🔒 **端到端加密**,AES-256-GCM
|
||||
- 🚀 **P2P 优先**,低延迟
|
||||
- 📦 **跨平台**,支持 Windows、macOS、Linux
|
||||
|
||||
## 🏗️ 项目结构
|
||||
|
||||
```
|
||||
easyremote/
|
||||
├── Cargo.toml # Workspace 配置
|
||||
├── crates/
|
||||
│ ├── common/ # 共享库(协议、类型、加密)
|
||||
│ ├── server/ # 服务端
|
||||
│ │ └── static/ # Web 管理后台
|
||||
│ ├── client-core/ # 客户端核心(屏幕捕获、输入控制)
|
||||
│ └── client-tauri/ # Tauri 桌面客户端
|
||||
│ └── ui/ # Vue3 前端
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Rust 1.70+
|
||||
- Node.js 18+ (客户端前端)
|
||||
- SQLite (服务端数据库)
|
||||
|
||||
### 构建服务端
|
||||
|
||||
```bash
|
||||
# 编译服务端
|
||||
cargo build --release -p easyremote-server
|
||||
|
||||
# 运行服务端
|
||||
./target/release/easyremote-server
|
||||
```
|
||||
|
||||
服务端默认运行在 `http://localhost:8080`,管理后台访问 `http://localhost:8080/`
|
||||
|
||||
### 构建客户端
|
||||
|
||||
```bash
|
||||
# 进入客户端目录
|
||||
cd crates/client-tauri
|
||||
|
||||
# 安装前端依赖
|
||||
cd ui && npm install && cd ..
|
||||
|
||||
# 开发模式运行
|
||||
cargo tauri dev
|
||||
|
||||
# 构建发布版本
|
||||
cargo tauri build
|
||||
```
|
||||
|
||||
## ⚙️ 配置
|
||||
|
||||
### 服务端配置
|
||||
|
||||
创建 `.env` 文件:
|
||||
|
||||
```env
|
||||
# 服务器配置
|
||||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
|
||||
# STUN 服务器配置
|
||||
STUN_PORT=3478 # STUN 服务端口(UDP)
|
||||
ENABLE_LOCAL_STUN=true # 启用内置 STUN 服务
|
||||
# PUBLIC_IP=your.public.ip # 公网 IP(可选,用于生成正确的 STUN URL)
|
||||
|
||||
# 额外的 STUN 服务器(可选,会添加到本地 STUN 后面)
|
||||
# STUN_SERVERS=stun:stun.l.google.com:19302
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL=sqlite:easyremote.db?mode=rwc
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||
JWT_EXPIRY=86400
|
||||
|
||||
# TURN 服务器(可选,用于无法 P2P 直连时中转)
|
||||
# TURN_SERVER=turn:your-turn-server:3478
|
||||
# TURN_USERNAME=username
|
||||
# TURN_PASSWORD=password
|
||||
```
|
||||
|
||||
### STUN 服务说明
|
||||
|
||||
EasyRemote 服务端内置了 STUN (Session Traversal Utilities for NAT) 服务器,用于帮助客户端发现其公网 IP 地址,实现 P2P 穿透连接。
|
||||
|
||||
**配置说明:**
|
||||
- `ENABLE_LOCAL_STUN=true`:启用本地 STUN 服务(默认开启)
|
||||
- `STUN_PORT=3478`:STUN 服务监听的 UDP 端口(默认 3478,标准端口)
|
||||
- `PUBLIC_IP`:如果服务器有公网 IP,设置此项可让客户端正确连接
|
||||
|
||||
**防火墙配置:**
|
||||
如果使用防火墙,需要开放以下端口:
|
||||
- TCP 8080:HTTP/WebSocket 服务
|
||||
- UDP 3478:STUN 服务
|
||||
|
||||
**客户端获取 ICE 配置:**
|
||||
客户端可以通过 API 获取 ICE 服务器配置:
|
||||
```bash
|
||||
curl http://localhost:8080/api/ice-servers
|
||||
# 返回:{"stun_servers":["stun:localhost:3478"],"turn_server":null}
|
||||
```
|
||||
|
||||
### 客户端配置
|
||||
|
||||
客户端配置存储在:
|
||||
- Windows: `%APPDATA%/easyremote/config.json`
|
||||
- macOS: `~/Library/Application Support/easyremote/config.json`
|
||||
- Linux: `~/.config/easyremote/config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "ws://localhost:8080",
|
||||
"device_name": "My Computer",
|
||||
"quality": {
|
||||
"frame_rate": 30,
|
||||
"resolution_scale": 1.0,
|
||||
"image_quality": 80,
|
||||
"hardware_acceleration": true
|
||||
},
|
||||
"auto_start": false,
|
||||
"launch_on_boot": false
|
||||
}
|
||||
```
|
||||
|
||||
## 📖 使用说明
|
||||
|
||||
### 远程协助(被控制端)
|
||||
|
||||
1. 启动客户端
|
||||
2. 开启「允许他人远程协助」开关
|
||||
3. 将设备ID和验证码告知控制方
|
||||
|
||||
### 远程控制(控制端)
|
||||
|
||||
1. 启动客户端
|
||||
2. 切换到「远控」标签
|
||||
3. 输入对方的设备ID和验证码
|
||||
4. 点击「连接」
|
||||
|
||||
### 账号登录
|
||||
|
||||
1. 注册/登录账号
|
||||
2. 设备自动绑定到账号
|
||||
3. 可在「远控」标签查看已登录的所有设备
|
||||
4. 在「历史」标签查看控制记录
|
||||
|
||||
### 管理后台
|
||||
|
||||
1. 创建管理员账号(首次需要手动在数据库中设置 role='admin')
|
||||
2. 访问 `http://服务器地址:8080/`
|
||||
3. 使用管理员账号登录
|
||||
4. 可管理用户、设备、会话,以及直接远程控制
|
||||
|
||||
## 🔧 开发
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
### 代码检查
|
||||
|
||||
```bash
|
||||
cargo clippy
|
||||
```
|
||||
|
||||
### 格式化代码
|
||||
|
||||
```bash
|
||||
cargo fmt
|
||||
```
|
||||
|
||||
## 📄 API 文档
|
||||
|
||||
### 认证接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/auth/register` | POST | 用户注册 |
|
||||
| `/api/auth/login` | POST | 用户登录 |
|
||||
| `/api/auth/logout` | POST | 退出登录 |
|
||||
| `/api/auth/refresh` | POST | 刷新令牌 |
|
||||
|
||||
### 用户接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/users/me` | GET | 获取当前用户 |
|
||||
| `/api/users/me` | POST | 更新用户信息 |
|
||||
|
||||
### 设备接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/devices` | GET | 获取设备列表 |
|
||||
| `/api/devices/register` | POST | 注册设备 |
|
||||
| `/api/devices/:id` | GET | 获取设备详情 |
|
||||
| `/api/devices/:id` | DELETE | 移除设备 |
|
||||
| `/api/devices/:id/offline` | POST | 强制下线 |
|
||||
|
||||
### ICE 配置接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/ice-servers` | GET | 获取 ICE 服务器配置(STUN/TURN)|
|
||||
|
||||
### 会话接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/sessions` | GET | 获取活跃会话 |
|
||||
| `/api/sessions/history` | GET | 获取历史记录 |
|
||||
| `/api/sessions/:id` | GET | 获取会话详情 |
|
||||
| `/api/sessions/:id/end` | POST | 结束会话 |
|
||||
|
||||
### WebSocket 接口
|
||||
|
||||
| 接口 | 说明 |
|
||||
|------|------|
|
||||
| `/ws/signal?device_id=xxx` | 信令WebSocket |
|
||||
| `/ws/remote/:device_id` | 浏览器远程控制WebSocket |
|
||||
|
||||
## 🛣️ 路线图
|
||||
|
||||
- [x] 基础架构
|
||||
- [x] 用户认证
|
||||
- [x] 设备管理
|
||||
- [x] 信令服务
|
||||
- [x] 客户端GUI
|
||||
- [x] 管理后台
|
||||
- [x] 内置 STUN 服务器
|
||||
- [ ] P2P连接优化
|
||||
- [ ] TURN 中转服务
|
||||
- [ ] 文件传输
|
||||
- [ ] 音频传输
|
||||
- [ ] 多显示器选择
|
||||
- [ ] 移动端适配
|
||||
|
||||
## 📜 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
48
crates/client-core/Cargo.toml
Normal file
@ -0,0 +1,48 @@
|
||||
[package]
|
||||
name = "easyremote-client-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
easyremote-common = { path = "../common" }
|
||||
|
||||
# Async
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
bincode = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
# Networking
|
||||
quinn = { workspace = true }
|
||||
rustls = { workspace = true }
|
||||
tokio-tungstenite = { version = "0.21", features = ["native-tls"] }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
# Screen capture
|
||||
scrap = { workspace = true }
|
||||
|
||||
# Input
|
||||
enigo = { workspace = true }
|
||||
|
||||
# Image
|
||||
image = { workspace = true }
|
||||
|
||||
# Logging
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
# Utils
|
||||
chrono = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
dirs = "5.0"
|
||||
hostname = "0.3"
|
||||
146
crates/client-core/src/capture.rs
Normal file
@ -0,0 +1,146 @@
|
||||
//! 屏幕捕获模块
|
||||
|
||||
use anyhow::Result;
|
||||
use scrap::{Capturer, Display};
|
||||
use std::io::ErrorKind::WouldBlock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 屏幕捕获器
|
||||
pub struct ScreenCapturer {
|
||||
capturer: Capturer,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
impl ScreenCapturer {
|
||||
/// 创建新的屏幕捕获器
|
||||
pub fn new(display_index: usize) -> Result<Self> {
|
||||
let displays = Display::all()?;
|
||||
if display_index >= displays.len() {
|
||||
anyhow::bail!("Invalid display index: {}", display_index);
|
||||
}
|
||||
|
||||
let display = displays.into_iter().nth(display_index).unwrap();
|
||||
let width = display.width() as u32;
|
||||
let height = display.height() as u32;
|
||||
|
||||
let capturer = Capturer::new(display)?;
|
||||
|
||||
Ok(Self {
|
||||
capturer,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取屏幕宽度
|
||||
pub fn width(&self) -> u32 {
|
||||
self.width
|
||||
}
|
||||
|
||||
/// 获取屏幕高度
|
||||
pub fn height(&self) -> u32 {
|
||||
self.height
|
||||
}
|
||||
|
||||
/// 捕获一帧
|
||||
pub fn capture_frame(&mut self) -> Result<Option<Vec<u8>>> {
|
||||
match self.capturer.frame() {
|
||||
Ok(frame) => {
|
||||
// scrap 返回的是 BGRA 格式
|
||||
let data = frame.to_vec();
|
||||
Ok(Some(data))
|
||||
}
|
||||
Err(ref e) if e.kind() == WouldBlock => {
|
||||
// 没有新帧,稍后重试
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 捕获并转换为 RGBA
|
||||
pub fn capture_rgba(&mut self) -> Result<Option<Vec<u8>>> {
|
||||
if let Some(bgra) = self.capture_frame()? {
|
||||
let mut rgba = bgra;
|
||||
// BGRA -> RGBA
|
||||
for chunk in rgba.chunks_exact_mut(4) {
|
||||
chunk.swap(0, 2);
|
||||
}
|
||||
Ok(Some(rgba))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有显示器信息
|
||||
pub fn get_displays() -> Result<Vec<DisplayInfo>> {
|
||||
let displays = Display::all()?;
|
||||
Ok(displays
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, d)| DisplayInfo {
|
||||
index: i,
|
||||
width: d.width() as u32,
|
||||
height: d.height() as u32,
|
||||
is_primary: i == 0, // scrap 没有提供 is_primary,默认第一个为主显示器
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 显示器信息
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DisplayInfo {
|
||||
pub index: usize,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub is_primary: bool,
|
||||
}
|
||||
|
||||
/// 帧率控制器
|
||||
pub struct FrameRateLimiter {
|
||||
target_interval: Duration,
|
||||
last_frame: std::time::Instant,
|
||||
}
|
||||
|
||||
impl FrameRateLimiter {
|
||||
pub fn new(fps: u32) -> Self {
|
||||
Self {
|
||||
target_interval: Duration::from_secs_f64(1.0 / fps as f64),
|
||||
last_frame: std::time::Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 等待到下一帧时间
|
||||
pub fn wait(&mut self) {
|
||||
let elapsed = self.last_frame.elapsed();
|
||||
if elapsed < self.target_interval {
|
||||
std::thread::sleep(self.target_interval - elapsed);
|
||||
}
|
||||
self.last_frame = std::time::Instant::now();
|
||||
}
|
||||
|
||||
/// 更新目标帧率
|
||||
pub fn set_fps(&mut self, fps: u32) {
|
||||
self.target_interval = Duration::from_secs_f64(1.0 / fps as f64);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_displays() {
|
||||
let displays = get_displays().unwrap();
|
||||
assert!(!displays.is_empty());
|
||||
println!("Found {} displays", displays.len());
|
||||
for d in &displays {
|
||||
println!(
|
||||
"Display {}: {}x{} (primary: {})",
|
||||
d.index, d.width, d.height, d.is_primary
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
220
crates/client-core/src/codec.rs
Normal file
@ -0,0 +1,220 @@
|
||||
//! 编解码模块
|
||||
|
||||
use anyhow::Result;
|
||||
use image::{ImageBuffer, Rgba, RgbaImage};
|
||||
|
||||
/// JPEG 编码器
|
||||
pub struct JpegEncoder {
|
||||
quality: u32,
|
||||
}
|
||||
|
||||
impl JpegEncoder {
|
||||
pub fn new(quality: u32) -> Self {
|
||||
Self {
|
||||
quality: quality.clamp(1, 100),
|
||||
}
|
||||
}
|
||||
|
||||
/// 编码 RGBA 数据为 JPEG
|
||||
pub fn encode(&self, rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>> {
|
||||
let img: RgbaImage = ImageBuffer::from_raw(width, height, rgba.to_vec())
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to create image buffer"))?;
|
||||
|
||||
let mut jpeg_data = Vec::new();
|
||||
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(
|
||||
&mut jpeg_data,
|
||||
self.quality as u8,
|
||||
);
|
||||
|
||||
encoder.encode(
|
||||
img.as_raw(),
|
||||
width,
|
||||
height,
|
||||
image::ColorType::Rgba8,
|
||||
)?;
|
||||
|
||||
Ok(jpeg_data)
|
||||
}
|
||||
|
||||
/// 设置质量
|
||||
pub fn set_quality(&mut self, quality: u32) {
|
||||
self.quality = quality.clamp(1, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/// JPEG 解码器
|
||||
pub struct JpegDecoder;
|
||||
|
||||
impl JpegDecoder {
|
||||
/// 解码 JPEG 为 RGBA
|
||||
pub fn decode(jpeg_data: &[u8]) -> Result<(Vec<u8>, u32, u32)> {
|
||||
let img = image::load_from_memory_with_format(jpeg_data, image::ImageFormat::Jpeg)?;
|
||||
let rgba = img.to_rgba8();
|
||||
let (width, height) = rgba.dimensions();
|
||||
Ok((rgba.into_raw(), width, height))
|
||||
}
|
||||
}
|
||||
|
||||
/// 图像缩放器
|
||||
pub struct ImageScaler;
|
||||
|
||||
impl ImageScaler {
|
||||
/// 缩放 RGBA 图像
|
||||
pub fn scale(
|
||||
rgba: &[u8],
|
||||
src_width: u32,
|
||||
src_height: u32,
|
||||
scale: f32,
|
||||
) -> Result<(Vec<u8>, u32, u32)> {
|
||||
if (scale - 1.0).abs() < 0.01 {
|
||||
return Ok((rgba.to_vec(), src_width, src_height));
|
||||
}
|
||||
|
||||
let new_width = (src_width as f32 * scale) as u32;
|
||||
let new_height = (src_height as f32 * scale) as u32;
|
||||
|
||||
let img: RgbaImage = ImageBuffer::from_raw(src_width, src_height, rgba.to_vec())
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to create image buffer"))?;
|
||||
|
||||
let resized = image::imageops::resize(
|
||||
&img,
|
||||
new_width,
|
||||
new_height,
|
||||
image::imageops::FilterType::Triangle,
|
||||
);
|
||||
|
||||
Ok((resized.into_raw(), new_width, new_height))
|
||||
}
|
||||
}
|
||||
|
||||
/// 帧差异计算
|
||||
pub struct FrameDiffer {
|
||||
last_frame: Option<Vec<u8>>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
impl FrameDiffer {
|
||||
pub fn new(width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
last_frame: None,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算与上一帧的差异区域
|
||||
/// 返回差异区域的边界框 (x, y, width, height)
|
||||
pub fn compute_diff(&mut self, current: &[u8]) -> Option<DiffRegion> {
|
||||
let result = if let Some(ref last) = self.last_frame {
|
||||
self.find_diff_region(last, current)
|
||||
} else {
|
||||
// 第一帧,返回整个区域
|
||||
Some(DiffRegion {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
is_full_frame: true,
|
||||
})
|
||||
};
|
||||
|
||||
self.last_frame = Some(current.to_vec());
|
||||
result
|
||||
}
|
||||
|
||||
fn find_diff_region(&self, last: &[u8], current: &[u8]) -> Option<DiffRegion> {
|
||||
let mut min_x = self.width;
|
||||
let mut min_y = self.height;
|
||||
let mut max_x = 0u32;
|
||||
let mut max_y = 0u32;
|
||||
let mut has_diff = false;
|
||||
|
||||
// 每4个字节一组(RGBA)
|
||||
for y in 0..self.height {
|
||||
for x in 0..self.width {
|
||||
let idx = ((y * self.width + x) * 4) as usize;
|
||||
if idx + 3 < last.len() && idx + 3 < current.len() {
|
||||
// 简单比较:如果任何通道差异超过阈值,认为有变化
|
||||
let diff = (last[idx] as i32 - current[idx] as i32).abs()
|
||||
+ (last[idx + 1] as i32 - current[idx + 1] as i32).abs()
|
||||
+ (last[idx + 2] as i32 - current[idx + 2] as i32).abs();
|
||||
|
||||
if diff > 30 {
|
||||
has_diff = true;
|
||||
min_x = min_x.min(x);
|
||||
min_y = min_y.min(y);
|
||||
max_x = max_x.max(x);
|
||||
max_y = max_y.max(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if has_diff {
|
||||
// 添加一些边距
|
||||
let margin = 8;
|
||||
min_x = min_x.saturating_sub(margin);
|
||||
min_y = min_y.saturating_sub(margin);
|
||||
max_x = (max_x + margin).min(self.width - 1);
|
||||
max_y = (max_y + margin).min(self.height - 1);
|
||||
|
||||
Some(DiffRegion {
|
||||
x: min_x,
|
||||
y: min_y,
|
||||
width: max_x - min_x + 1,
|
||||
height: max_y - min_y + 1,
|
||||
is_full_frame: false,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置状态
|
||||
pub fn reset(&mut self) {
|
||||
self.last_frame = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// 差异区域
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffRegion {
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub is_full_frame: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_jpeg_encode_decode() {
|
||||
let width = 100;
|
||||
let height = 100;
|
||||
let mut rgba = vec![0u8; (width * height * 4) as usize];
|
||||
|
||||
// 创建测试图像
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let idx = ((y * width + x) * 4) as usize;
|
||||
rgba[idx] = (x * 255 / width) as u8; // R
|
||||
rgba[idx + 1] = (y * 255 / height) as u8; // G
|
||||
rgba[idx + 2] = 128; // B
|
||||
rgba[idx + 3] = 255; // A
|
||||
}
|
||||
}
|
||||
|
||||
let encoder = JpegEncoder::new(80);
|
||||
let jpeg = encoder.encode(&rgba, width, height).unwrap();
|
||||
assert!(!jpeg.is_empty());
|
||||
|
||||
let (decoded, w, h) = JpegDecoder::decode(&jpeg).unwrap();
|
||||
assert_eq!(w, width);
|
||||
assert_eq!(h, height);
|
||||
assert_eq!(decoded.len(), rgba.len());
|
||||
}
|
||||
}
|
||||
93
crates/client-core/src/config.rs
Normal file
@ -0,0 +1,93 @@
|
||||
//! 客户端配置
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 客户端配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClientConfig {
|
||||
/// 服务器地址
|
||||
pub server_url: String,
|
||||
/// 设备名称
|
||||
pub device_name: String,
|
||||
/// 质量设置
|
||||
pub quality: QualityConfig,
|
||||
/// 是否自动启动
|
||||
pub auto_start: bool,
|
||||
/// 是否开机启动
|
||||
pub launch_on_boot: bool,
|
||||
}
|
||||
|
||||
/// 质量配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QualityConfig {
|
||||
/// 帧率
|
||||
pub frame_rate: u32,
|
||||
/// 分辨率缩放
|
||||
pub resolution_scale: f32,
|
||||
/// 图像质量 (1-100)
|
||||
pub image_quality: u32,
|
||||
/// 是否启用硬件加速
|
||||
pub hardware_acceleration: bool,
|
||||
}
|
||||
|
||||
impl Default for ClientConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
server_url: "ws://localhost:8080".to_string(),
|
||||
device_name: get_default_device_name(),
|
||||
quality: QualityConfig::default(),
|
||||
auto_start: false,
|
||||
launch_on_boot: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for QualityConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
frame_rate: 30,
|
||||
resolution_scale: 1.0,
|
||||
image_quality: 80,
|
||||
hardware_acceleration: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_default_device_name() -> String {
|
||||
hostname::get()
|
||||
.map(|h| h.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| "My Device".to_string())
|
||||
}
|
||||
|
||||
impl ClientConfig {
|
||||
/// 从文件加载配置
|
||||
pub fn load() -> Self {
|
||||
let config_path = Self::config_path();
|
||||
if config_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&config_path) {
|
||||
if let Ok(config) = serde_json::from_str(&content) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 保存配置到文件
|
||||
pub fn save(&self) -> anyhow::Result<()> {
|
||||
let config_path = Self::config_path();
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let content = serde_json::to_string_pretty(self)?;
|
||||
std::fs::write(config_path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config_path() -> std::path::PathBuf {
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("easyremote")
|
||||
.join("config.json")
|
||||
}
|
||||
}
|
||||
456
crates/client-core/src/connection.rs
Normal file
@ -0,0 +1,456 @@
|
||||
//! P2P 连接模块
|
||||
|
||||
use anyhow::Result;
|
||||
use bytes::Bytes;
|
||||
use easyremote_common::protocol::{FrameData, FrameFormat, InputEvent};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
|
||||
/// 连接状态
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ConnectionState {
|
||||
/// 断开连接
|
||||
Disconnected,
|
||||
/// 正在连接
|
||||
Connecting,
|
||||
/// 已连接
|
||||
Connected,
|
||||
/// 连接失败
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// P2P 连接管理器
|
||||
pub struct P2PConnection {
|
||||
state: Arc<RwLock<ConnectionState>>,
|
||||
local_addr: Option<SocketAddr>,
|
||||
remote_addr: Option<SocketAddr>,
|
||||
socket: Option<Arc<UdpSocket>>,
|
||||
frame_tx: Option<mpsc::Sender<FrameData>>,
|
||||
input_tx: Option<mpsc::Sender<InputEvent>>,
|
||||
}
|
||||
|
||||
impl P2PConnection {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(ConnectionState::Disconnected)),
|
||||
local_addr: None,
|
||||
remote_addr: None,
|
||||
socket: None,
|
||||
frame_tx: None,
|
||||
input_tx: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取连接状态
|
||||
pub async fn state(&self) -> ConnectionState {
|
||||
self.state.read().await.clone()
|
||||
}
|
||||
|
||||
/// 初始化本地套接字
|
||||
pub async fn init_socket(&mut self) -> Result<SocketAddr> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
let local_addr = socket.local_addr()?;
|
||||
self.local_addr = Some(local_addr);
|
||||
self.socket = Some(Arc::new(socket));
|
||||
Ok(local_addr)
|
||||
}
|
||||
|
||||
/// 尝试连接到远程地址
|
||||
pub async fn connect(&mut self, remote_addr: SocketAddr) -> Result<()> {
|
||||
*self.state.write().await = ConnectionState::Connecting;
|
||||
|
||||
if let Some(socket) = &self.socket {
|
||||
socket.connect(remote_addr).await?;
|
||||
self.remote_addr = Some(remote_addr);
|
||||
*self.state.write().await = ConnectionState::Connected;
|
||||
} else {
|
||||
*self.state.write().await = ConnectionState::Failed("Socket not initialized".into());
|
||||
anyhow::bail!("Socket not initialized");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 发送帧数据
|
||||
pub async fn send_frame(&self, frame: &FrameData) -> Result<()> {
|
||||
if let Some(socket) = &self.socket {
|
||||
let data = bincode::serialize(frame)?;
|
||||
// 如果数据太大,需要分片发送
|
||||
if data.len() > 65000 {
|
||||
self.send_fragmented(socket, &data).await?;
|
||||
} else {
|
||||
socket.send(&data).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 发送输入事件
|
||||
pub async fn send_input(&self, event: &InputEvent) -> Result<()> {
|
||||
if let Some(socket) = &self.socket {
|
||||
let data = bincode::serialize(event)?;
|
||||
socket.send(&data).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 分片发送大数据
|
||||
async fn send_fragmented(&self, socket: &UdpSocket, data: &[u8]) -> Result<()> {
|
||||
const MAX_FRAGMENT_SIZE: usize = 60000;
|
||||
let total_fragments = (data.len() + MAX_FRAGMENT_SIZE - 1) / MAX_FRAGMENT_SIZE;
|
||||
|
||||
for (i, chunk) in data.chunks(MAX_FRAGMENT_SIZE).enumerate() {
|
||||
let fragment = FragmentHeader {
|
||||
fragment_id: i as u16,
|
||||
total_fragments: total_fragments as u16,
|
||||
data: chunk.to_vec(),
|
||||
};
|
||||
let fragment_data = bincode::serialize(&fragment)?;
|
||||
socket.send(&fragment_data).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 开始接收数据
|
||||
pub async fn start_receiving(
|
||||
&self,
|
||||
on_frame: impl Fn(FrameData) + Send + 'static,
|
||||
on_input: impl Fn(InputEvent) + Send + 'static,
|
||||
) -> Result<()> {
|
||||
let socket = self.socket.clone().ok_or_else(|| anyhow::anyhow!("Socket not initialized"))?;
|
||||
let state = self.state.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 100000];
|
||||
let mut fragment_buffer: Vec<Option<Vec<u8>>> = Vec::new();
|
||||
let mut expected_fragments = 0u16;
|
||||
|
||||
loop {
|
||||
if *state.read().await != ConnectionState::Connected {
|
||||
break;
|
||||
}
|
||||
|
||||
match socket.recv(&mut buf).await {
|
||||
Ok(len) => {
|
||||
let data = &buf[..len];
|
||||
|
||||
// 尝试解析为分片
|
||||
if let Ok(fragment) = bincode::deserialize::<FragmentHeader>(data) {
|
||||
if fragment.total_fragments > 1 {
|
||||
// 处理分片
|
||||
if fragment_buffer.is_empty() {
|
||||
fragment_buffer = vec![None; fragment.total_fragments as usize];
|
||||
expected_fragments = fragment.total_fragments;
|
||||
}
|
||||
|
||||
if fragment.fragment_id < expected_fragments {
|
||||
fragment_buffer[fragment.fragment_id as usize] =
|
||||
Some(fragment.data);
|
||||
}
|
||||
|
||||
// 检查是否收到所有分片
|
||||
if fragment_buffer.iter().all(|f| f.is_some()) {
|
||||
let complete_data: Vec<u8> = fragment_buffer
|
||||
.iter()
|
||||
.filter_map(|f| f.clone())
|
||||
.flatten()
|
||||
.collect();
|
||||
|
||||
if let Ok(frame) = bincode::deserialize::<FrameData>(&complete_data) {
|
||||
on_frame(frame);
|
||||
}
|
||||
|
||||
fragment_buffer.clear();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试解析为帧数据
|
||||
if let Ok(frame) = bincode::deserialize::<FrameData>(data) {
|
||||
on_frame(frame);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 尝试解析为输入事件
|
||||
if let Ok(input) = bincode::deserialize::<InputEvent>(data) {
|
||||
on_input(input);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Receive error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 断开连接
|
||||
pub async fn disconnect(&mut self) {
|
||||
*self.state.write().await = ConnectionState::Disconnected;
|
||||
self.socket = None;
|
||||
self.remote_addr = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// 分片头
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
struct FragmentHeader {
|
||||
fragment_id: u16,
|
||||
total_fragments: u16,
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// ICE 服务器配置
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct IceServersConfig {
|
||||
pub stun_servers: Vec<String>,
|
||||
pub turn_server: Option<TurnConfig>,
|
||||
}
|
||||
|
||||
/// TURN 服务器配置
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TurnConfig {
|
||||
pub url: String,
|
||||
pub username: String,
|
||||
pub credential: String,
|
||||
}
|
||||
|
||||
/// NAT穿透辅助
|
||||
pub struct NatTraversal;
|
||||
|
||||
impl NatTraversal {
|
||||
/// 从服务器获取 ICE 服务器配置
|
||||
pub async fn fetch_ice_servers(server_url: &str) -> Result<IceServersConfig> {
|
||||
// 将 ws:// 或 wss:// 转换为 http:// 或 https://
|
||||
let http_url = server_url
|
||||
.replace("ws://", "http://")
|
||||
.replace("wss://", "https://");
|
||||
|
||||
let url = format!("{}/api/ice-servers", http_url.trim_end_matches('/'));
|
||||
|
||||
let response = reqwest::get(&url).await?;
|
||||
let config: IceServersConfig = response.json().await?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// 获取本地候选地址
|
||||
pub async fn get_local_candidates() -> Result<Vec<String>> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
// 获取所有本地网络接口
|
||||
if let Ok(socket) = UdpSocket::bind("0.0.0.0:0").await {
|
||||
if let Ok(addr) = socket.local_addr() {
|
||||
candidates.push(format!("host {}", addr));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
/// 获取完整的 ICE 候选(包括本地和公网地址)
|
||||
pub async fn get_all_candidates(stun_servers: &[String]) -> Result<Vec<IceCandidate>> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
// 添加本地地址候选
|
||||
let local_candidates = Self::get_local_candidates().await?;
|
||||
for candidate in local_candidates {
|
||||
candidates.push(IceCandidate::new(candidate));
|
||||
}
|
||||
|
||||
// 使用 STUN 服务器获取公网地址
|
||||
for stun_url in stun_servers {
|
||||
match Self::get_public_addr_from_url(stun_url).await {
|
||||
Ok(addr) => {
|
||||
candidates.push(IceCandidate::new(format!("srflx {}", addr)));
|
||||
tracing::info!("Got public address from STUN {}: {}", stun_url, addr);
|
||||
break; // 成功获取一个公网地址即可
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to get public address from {}: {}", stun_url, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
/// 从 STUN URL 解析并获取公网地址
|
||||
pub async fn get_public_addr_from_url(stun_url: &str) -> Result<SocketAddr> {
|
||||
// 解析 STUN URL,格式: stun:host:port 或 stun:host
|
||||
let url = stun_url.trim_start_matches("stun:");
|
||||
let parts: Vec<&str> = url.split(':').collect();
|
||||
|
||||
let (host, port) = match parts.len() {
|
||||
1 => (parts[0], 3478u16), // 默认 STUN 端口
|
||||
2 => (parts[0], parts[1].parse().unwrap_or(3478)),
|
||||
_ => anyhow::bail!("Invalid STUN URL format: {}", stun_url),
|
||||
};
|
||||
|
||||
Self::get_public_addr(host, port).await
|
||||
}
|
||||
|
||||
/// 使用 STUN 服务器获取公网地址
|
||||
pub async fn get_public_addr(stun_host: &str, stun_port: u16) -> Result<SocketAddr> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
|
||||
// 解析 STUN 服务器地址
|
||||
let stun_addr = tokio::net::lookup_host(format!("{}:{}", stun_host, stun_port))
|
||||
.await?
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Cannot resolve STUN server: {}", stun_host))?;
|
||||
|
||||
// 设置超时
|
||||
socket.connect(stun_addr).await?;
|
||||
|
||||
// 发送 STUN Binding 请求
|
||||
let binding_request = create_stun_binding_request();
|
||||
socket.send(&binding_request).await?;
|
||||
|
||||
// 接收响应(带超时)
|
||||
let mut buf = [0u8; 1024];
|
||||
let recv_result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
socket.recv(&mut buf)
|
||||
).await;
|
||||
|
||||
match recv_result {
|
||||
Ok(Ok(len)) => parse_stun_response(&buf[..len]),
|
||||
Ok(Err(e)) => anyhow::bail!("STUN receive error: {}", e),
|
||||
Err(_) => anyhow::bail!("STUN request timeout"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// STUN Magic Cookie (RFC 5389)
|
||||
const STUN_MAGIC_COOKIE: u32 = 0x2112A442;
|
||||
|
||||
/// STUN Attribute Types
|
||||
const ATTR_XOR_MAPPED_ADDRESS: u16 = 0x0020;
|
||||
|
||||
fn create_stun_binding_request() -> Vec<u8> {
|
||||
use rand::Rng;
|
||||
|
||||
let mut request = Vec::with_capacity(20);
|
||||
|
||||
// Message Type: Binding Request (0x0001)
|
||||
request.extend_from_slice(&0x0001u16.to_be_bytes());
|
||||
// Message Length: 0 (no attributes)
|
||||
request.extend_from_slice(&0x0000u16.to_be_bytes());
|
||||
// Magic Cookie (RFC 5389)
|
||||
request.extend_from_slice(&STUN_MAGIC_COOKIE.to_be_bytes());
|
||||
// Transaction ID (12 bytes random)
|
||||
let mut rng = rand::thread_rng();
|
||||
let transaction_id: [u8; 12] = rng.gen();
|
||||
request.extend_from_slice(&transaction_id);
|
||||
|
||||
request
|
||||
}
|
||||
|
||||
fn parse_stun_response(data: &[u8]) -> Result<SocketAddr> {
|
||||
// 验证最小长度 (20 bytes header)
|
||||
if data.len() < 20 {
|
||||
anyhow::bail!("Invalid STUN response: too short");
|
||||
}
|
||||
|
||||
// 验证 Magic Cookie
|
||||
let magic = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
|
||||
if magic != STUN_MAGIC_COOKIE {
|
||||
anyhow::bail!("Invalid STUN magic cookie");
|
||||
}
|
||||
|
||||
// 解析消息长度
|
||||
let msg_len = u16::from_be_bytes([data[2], data[3]]) as usize;
|
||||
if data.len() < 20 + msg_len {
|
||||
anyhow::bail!("Invalid STUN response: truncated");
|
||||
}
|
||||
|
||||
// 遍历属性查找 XOR-MAPPED-ADDRESS
|
||||
let mut offset = 20;
|
||||
while offset + 4 <= 20 + msg_len {
|
||||
let attr_type = u16::from_be_bytes([data[offset], data[offset + 1]]);
|
||||
let attr_len = u16::from_be_bytes([data[offset + 2], data[offset + 3]]) as usize;
|
||||
|
||||
if attr_type == ATTR_XOR_MAPPED_ADDRESS {
|
||||
// XOR-MAPPED-ADDRESS 格式:
|
||||
// 1 byte: reserved
|
||||
// 1 byte: family (0x01=IPv4, 0x02=IPv6)
|
||||
// 2 bytes: XOR'd port
|
||||
// 4 bytes (IPv4) or 16 bytes (IPv6): XOR'd address
|
||||
|
||||
if offset + 4 + attr_len > data.len() {
|
||||
anyhow::bail!("Invalid XOR-MAPPED-ADDRESS attribute");
|
||||
}
|
||||
|
||||
let family = data[offset + 5];
|
||||
|
||||
// XOR'd port
|
||||
let xor_port = u16::from_be_bytes([data[offset + 6], data[offset + 7]]);
|
||||
let port = xor_port ^ ((STUN_MAGIC_COOKIE >> 16) as u16);
|
||||
|
||||
match family {
|
||||
0x01 => {
|
||||
// IPv4
|
||||
let magic_bytes = STUN_MAGIC_COOKIE.to_be_bytes();
|
||||
let ip = std::net::Ipv4Addr::new(
|
||||
data[offset + 8] ^ magic_bytes[0],
|
||||
data[offset + 9] ^ magic_bytes[1],
|
||||
data[offset + 10] ^ magic_bytes[2],
|
||||
data[offset + 11] ^ magic_bytes[3],
|
||||
);
|
||||
return Ok(SocketAddr::new(std::net::IpAddr::V4(ip), port));
|
||||
}
|
||||
0x02 => {
|
||||
// IPv6 (需要 Transaction ID 进行 XOR)
|
||||
let magic_bytes = STUN_MAGIC_COOKIE.to_be_bytes();
|
||||
let transaction_id = &data[8..20];
|
||||
let mut ip_bytes = [0u8; 16];
|
||||
|
||||
// 前 4 bytes 与 magic cookie XOR
|
||||
for i in 0..4 {
|
||||
ip_bytes[i] = data[offset + 8 + i] ^ magic_bytes[i];
|
||||
}
|
||||
// 后 12 bytes 与 transaction ID XOR
|
||||
for i in 4..16 {
|
||||
ip_bytes[i] = data[offset + 8 + i] ^ transaction_id[i - 4];
|
||||
}
|
||||
|
||||
let ip = std::net::Ipv6Addr::from(ip_bytes);
|
||||
return Ok(SocketAddr::new(std::net::IpAddr::V6(ip), port));
|
||||
}
|
||||
_ => {
|
||||
anyhow::bail!("Unknown address family: {}", family);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移动到下一个属性 (4字节对齐)
|
||||
offset += 4 + attr_len + (4 - (attr_len % 4)) % 4;
|
||||
}
|
||||
|
||||
anyhow::bail!("XOR-MAPPED-ADDRESS not found in STUN response")
|
||||
}
|
||||
|
||||
/// ICE 候选
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct IceCandidate {
|
||||
pub candidate: String,
|
||||
pub sdp_mid: Option<String>,
|
||||
pub sdp_mline_index: Option<u32>,
|
||||
}
|
||||
|
||||
impl IceCandidate {
|
||||
pub fn new(candidate: String) -> Self {
|
||||
Self {
|
||||
candidate,
|
||||
sdp_mid: None,
|
||||
sdp_mline_index: Some(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
284
crates/client-core/src/input.rs
Normal file
@ -0,0 +1,284 @@
|
||||
//! 输入控制模块
|
||||
|
||||
use anyhow::Result;
|
||||
use enigo::{Enigo, Key, Keyboard, Mouse, Direction, Settings, Coordinate};
|
||||
use easyremote_common::protocol::{InputEvent, MouseButton as ProtoMouseButton, Modifiers};
|
||||
|
||||
/// 鼠标按钮
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum MouseButton {
|
||||
Left,
|
||||
Right,
|
||||
Middle,
|
||||
}
|
||||
|
||||
/// 输入控制器
|
||||
pub struct InputController {
|
||||
enigo: Enigo,
|
||||
screen_width: i32,
|
||||
screen_height: i32,
|
||||
}
|
||||
|
||||
impl InputController {
|
||||
/// 创建新的输入控制器(带屏幕尺寸)
|
||||
pub fn with_screen_size(screen_width: u32, screen_height: u32) -> Self {
|
||||
Self {
|
||||
enigo: Enigo::new(&Settings::default()).unwrap(),
|
||||
screen_width: screen_width as i32,
|
||||
screen_height: screen_height as i32,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建新的输入控制器(默认屏幕尺寸)
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(Self {
|
||||
enigo: Enigo::new(&Settings::default())?,
|
||||
screen_width: 1920,
|
||||
screen_height: 1080,
|
||||
})
|
||||
}
|
||||
|
||||
/// 移动鼠标
|
||||
pub fn move_mouse(&mut self, x: i32, y: i32) -> Result<()> {
|
||||
self.enigo.move_mouse(x, y, Coordinate::Abs)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 点击鼠标
|
||||
pub fn click(&mut self, button: MouseButton) -> Result<()> {
|
||||
self.enigo.button(convert_simple_mouse_button(button), Direction::Click)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 鼠标按下
|
||||
pub fn mouse_down(&mut self, button: MouseButton) -> Result<()> {
|
||||
self.enigo.button(convert_simple_mouse_button(button), Direction::Press)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 鼠标释放
|
||||
pub fn mouse_up(&mut self, button: MouseButton) -> Result<()> {
|
||||
self.enigo.button(convert_simple_mouse_button(button), Direction::Release)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 滚动
|
||||
pub fn scroll(&mut self, delta_x: i32, delta_y: i32) -> Result<()> {
|
||||
if delta_y != 0 {
|
||||
self.enigo.scroll(delta_y, enigo::Axis::Vertical)?;
|
||||
}
|
||||
if delta_x != 0 {
|
||||
self.enigo.scroll(delta_x, enigo::Axis::Horizontal)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 按键按下
|
||||
pub fn key_down(&mut self, key: &str) -> Result<()> {
|
||||
if let Some(k) = parse_key(key) {
|
||||
self.enigo.key(k, Direction::Press)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 按键释放
|
||||
pub fn key_up(&mut self, key: &str) -> Result<()> {
|
||||
if let Some(k) = parse_key(key) {
|
||||
self.enigo.key(k, Direction::Release)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 处理输入事件
|
||||
pub fn handle_event(&mut self, event: InputEvent) -> Result<()> {
|
||||
match event {
|
||||
InputEvent::MouseMove { x, y } => {
|
||||
let _ = self.enigo.move_mouse(x, y, Coordinate::Abs);
|
||||
}
|
||||
InputEvent::MouseDown { button, x, y } => {
|
||||
let _ = self.enigo.move_mouse(x, y, Coordinate::Abs);
|
||||
let _ = self.enigo.button(convert_mouse_button(button), Direction::Press);
|
||||
}
|
||||
InputEvent::MouseUp { button, x, y } => {
|
||||
let _ = self.enigo.move_mouse(x, y, Coordinate::Abs);
|
||||
let _ = self.enigo.button(convert_mouse_button(button), Direction::Release);
|
||||
}
|
||||
InputEvent::MouseScroll { delta_x, delta_y } => {
|
||||
if delta_y != 0 {
|
||||
let _ = self.enigo.scroll(delta_y, enigo::Axis::Vertical);
|
||||
}
|
||||
if delta_x != 0 {
|
||||
let _ = self.enigo.scroll(delta_x, enigo::Axis::Horizontal);
|
||||
}
|
||||
}
|
||||
InputEvent::KeyDown { key, modifiers } => {
|
||||
self.handle_modifiers_down(&modifiers);
|
||||
if let Some(k) = parse_key(&key) {
|
||||
let _ = self.enigo.key(k, Direction::Press);
|
||||
}
|
||||
}
|
||||
InputEvent::KeyUp { key, modifiers } => {
|
||||
if let Some(k) = parse_key(&key) {
|
||||
let _ = self.enigo.key(k, Direction::Release);
|
||||
}
|
||||
self.handle_modifiers_up(&modifiers);
|
||||
}
|
||||
InputEvent::TextInput { text } => {
|
||||
let _ = self.enigo.text(&text);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_modifiers_down(&mut self, modifiers: &Modifiers) {
|
||||
if modifiers.ctrl {
|
||||
let _ = self.enigo.key(Key::Control, Direction::Press);
|
||||
}
|
||||
if modifiers.alt {
|
||||
let _ = self.enigo.key(Key::Alt, Direction::Press);
|
||||
}
|
||||
if modifiers.shift {
|
||||
let _ = self.enigo.key(Key::Shift, Direction::Press);
|
||||
}
|
||||
if modifiers.meta {
|
||||
let _ = self.enigo.key(Key::Meta, Direction::Press);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_modifiers_up(&mut self, modifiers: &Modifiers) {
|
||||
if modifiers.meta {
|
||||
let _ = self.enigo.key(Key::Meta, Direction::Release);
|
||||
}
|
||||
if modifiers.shift {
|
||||
let _ = self.enigo.key(Key::Shift, Direction::Release);
|
||||
}
|
||||
if modifiers.alt {
|
||||
let _ = self.enigo.key(Key::Alt, Direction::Release);
|
||||
}
|
||||
if modifiers.ctrl {
|
||||
let _ = self.enigo.key(Key::Control, Direction::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新屏幕尺寸
|
||||
pub fn set_screen_size(&mut self, width: u32, height: u32) {
|
||||
self.screen_width = width as i32;
|
||||
self.screen_height = height as i32;
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_mouse_button(button: ProtoMouseButton) -> enigo::Button {
|
||||
match button {
|
||||
ProtoMouseButton::Left => enigo::Button::Left,
|
||||
ProtoMouseButton::Right => enigo::Button::Right,
|
||||
ProtoMouseButton::Middle => enigo::Button::Middle,
|
||||
ProtoMouseButton::Back => enigo::Button::Back,
|
||||
ProtoMouseButton::Forward => enigo::Button::Forward,
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_simple_mouse_button(button: MouseButton) -> enigo::Button {
|
||||
match button {
|
||||
MouseButton::Left => enigo::Button::Left,
|
||||
MouseButton::Right => enigo::Button::Right,
|
||||
MouseButton::Middle => enigo::Button::Middle,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_key(key: &str) -> Option<Key> {
|
||||
// 处理常见的键名
|
||||
match key.to_lowercase().as_str() {
|
||||
// 功能键
|
||||
"escape" | "esc" => Some(Key::Escape),
|
||||
"f1" => Some(Key::F1),
|
||||
"f2" => Some(Key::F2),
|
||||
"f3" => Some(Key::F3),
|
||||
"f4" => Some(Key::F4),
|
||||
"f5" => Some(Key::F5),
|
||||
"f6" => Some(Key::F6),
|
||||
"f7" => Some(Key::F7),
|
||||
"f8" => Some(Key::F8),
|
||||
"f9" => Some(Key::F9),
|
||||
"f10" => Some(Key::F10),
|
||||
"f11" => Some(Key::F11),
|
||||
"f12" => Some(Key::F12),
|
||||
|
||||
// 控制键
|
||||
"tab" => Some(Key::Tab),
|
||||
"capslock" => Some(Key::CapsLock),
|
||||
"shift" => Some(Key::Shift),
|
||||
"control" | "ctrl" => Some(Key::Control),
|
||||
"alt" => Some(Key::Alt),
|
||||
"meta" | "win" | "cmd" | "command" => Some(Key::Meta),
|
||||
"space" | " " => Some(Key::Space),
|
||||
"enter" | "return" => Some(Key::Return),
|
||||
"backspace" => Some(Key::Backspace),
|
||||
"delete" | "del" => Some(Key::Delete),
|
||||
"home" => Some(Key::Home),
|
||||
"end" => Some(Key::End),
|
||||
"pageup" | "pgup" => Some(Key::PageUp),
|
||||
"pagedown" | "pgdn" => Some(Key::PageDown),
|
||||
|
||||
// 方向键
|
||||
"arrowup" | "up" => Some(Key::UpArrow),
|
||||
"arrowdown" | "down" => Some(Key::DownArrow),
|
||||
"arrowleft" | "left" => Some(Key::LeftArrow),
|
||||
"arrowright" | "right" => Some(Key::RightArrow),
|
||||
|
||||
// 单字符 - 使用 Unicode
|
||||
s if s.len() == 1 => {
|
||||
let c = s.chars().next().unwrap();
|
||||
Some(Key::Unicode(c))
|
||||
}
|
||||
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成输入事件
|
||||
pub struct InputEventGenerator;
|
||||
|
||||
impl InputEventGenerator {
|
||||
/// 生成鼠标移动事件
|
||||
pub fn mouse_move(x: i32, y: i32) -> InputEvent {
|
||||
InputEvent::MouseMove { x, y }
|
||||
}
|
||||
|
||||
/// 生成鼠标按下事件
|
||||
pub fn mouse_down(button: ProtoMouseButton, x: i32, y: i32) -> InputEvent {
|
||||
InputEvent::MouseDown { button, x, y }
|
||||
}
|
||||
|
||||
/// 生成鼠标释放事件
|
||||
pub fn mouse_up(button: ProtoMouseButton, x: i32, y: i32) -> InputEvent {
|
||||
InputEvent::MouseUp { button, x, y }
|
||||
}
|
||||
|
||||
/// 生成鼠标点击事件(按下+释放)
|
||||
pub fn mouse_click(button: ProtoMouseButton, x: i32, y: i32) -> Vec<InputEvent> {
|
||||
vec![
|
||||
InputEvent::MouseDown { button, x, y },
|
||||
InputEvent::MouseUp { button, x, y },
|
||||
]
|
||||
}
|
||||
|
||||
/// 生成鼠标滚轮事件
|
||||
pub fn mouse_scroll(delta_x: i32, delta_y: i32) -> InputEvent {
|
||||
InputEvent::MouseScroll { delta_x, delta_y }
|
||||
}
|
||||
|
||||
/// 生成键盘按下事件
|
||||
pub fn key_down(key: String, modifiers: Modifiers) -> InputEvent {
|
||||
InputEvent::KeyDown { key, modifiers }
|
||||
}
|
||||
|
||||
/// 生成键盘释放事件
|
||||
pub fn key_up(key: String, modifiers: Modifiers) -> InputEvent {
|
||||
InputEvent::KeyUp { key, modifiers }
|
||||
}
|
||||
|
||||
/// 生成文本输入事件
|
||||
pub fn text_input(text: String) -> InputEvent {
|
||||
InputEvent::TextInput { text }
|
||||
}
|
||||
}
|
||||
13
crates/client-core/src/lib.rs
Normal file
@ -0,0 +1,13 @@
|
||||
//! EasyRemote Client Core Library
|
||||
|
||||
pub mod capture;
|
||||
pub mod input;
|
||||
pub mod connection;
|
||||
pub mod signal;
|
||||
pub mod codec;
|
||||
pub mod config;
|
||||
|
||||
pub use config::ClientConfig;
|
||||
pub use signal::{SignalClient, SignalMessage};
|
||||
pub use capture::ScreenCapturer;
|
||||
pub use input::{InputController, MouseButton};
|
||||
338
crates/client-core/src/signal.rs
Normal file
@ -0,0 +1,338 @@
|
||||
//! 信令客户端
|
||||
|
||||
use anyhow::Result;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
|
||||
/// 信令消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum SignalMessage {
|
||||
/// 注册设备
|
||||
#[serde(rename = "register")]
|
||||
Register {
|
||||
device_id: String,
|
||||
verification_code: String,
|
||||
},
|
||||
/// 心跳
|
||||
#[serde(rename = "heartbeat")]
|
||||
Heartbeat { device_id: String },
|
||||
/// 心跳响应
|
||||
#[serde(rename = "heartbeat_ack")]
|
||||
HeartbeatAck,
|
||||
/// 连接请求
|
||||
#[serde(rename = "connect_request")]
|
||||
ConnectRequest {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
verification_code: String,
|
||||
},
|
||||
/// 连接响应
|
||||
#[serde(rename = "connect_response")]
|
||||
ConnectResponse {
|
||||
session_id: String,
|
||||
accepted: bool,
|
||||
reason: Option<String>,
|
||||
},
|
||||
/// SDP Offer
|
||||
#[serde(rename = "offer")]
|
||||
Offer {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
sdp: String,
|
||||
},
|
||||
/// SDP Answer
|
||||
#[serde(rename = "answer")]
|
||||
Answer {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
sdp: String,
|
||||
},
|
||||
/// ICE Candidate
|
||||
#[serde(rename = "candidate")]
|
||||
Candidate {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
candidate: String,
|
||||
sdp_mid: Option<String>,
|
||||
sdp_mline_index: Option<u32>,
|
||||
},
|
||||
/// 会话结束
|
||||
#[serde(rename = "session_end")]
|
||||
SessionEnd { session_id: String },
|
||||
/// 强制下线
|
||||
#[serde(rename = "force_offline")]
|
||||
ForceOffline { device_id: String },
|
||||
/// 错误
|
||||
#[serde(rename = "error")]
|
||||
Error { code: u32, message: String },
|
||||
/// 设置允许远程
|
||||
#[serde(rename = "set_allow_remote")]
|
||||
SetAllowRemote { device_id: String, allow: bool },
|
||||
/// 刷新验证码
|
||||
#[serde(rename = "refresh_code")]
|
||||
RefreshCode { device_id: String },
|
||||
/// 验证码已刷新
|
||||
#[serde(rename = "code_refreshed")]
|
||||
CodeRefreshed {
|
||||
device_id: String,
|
||||
verification_code: String,
|
||||
},
|
||||
/// 屏幕帧数据
|
||||
#[serde(rename = "screen_frame")]
|
||||
ScreenFrame {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
data: String, // Base64 encoded JPEG
|
||||
},
|
||||
/// 鼠标事件
|
||||
#[serde(rename = "mouse_event")]
|
||||
MouseEvent {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
x: f64,
|
||||
y: f64,
|
||||
event_type: String, // "move", "click", "down", "up", "scroll"
|
||||
button: Option<u8>,
|
||||
delta: Option<f64>,
|
||||
},
|
||||
/// 键盘事件
|
||||
#[serde(rename = "keyboard_event")]
|
||||
KeyboardEvent {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
key: String,
|
||||
event_type: String, // "down", "up"
|
||||
},
|
||||
}
|
||||
|
||||
/// 信令客户端
|
||||
pub struct SignalClient {
|
||||
device_id: String,
|
||||
server_url: String,
|
||||
tx: Option<mpsc::Sender<SignalMessage>>,
|
||||
connected: Arc<RwLock<bool>>,
|
||||
}
|
||||
|
||||
impl SignalClient {
|
||||
pub fn new(device_id: String, server_url: String) -> Self {
|
||||
Self {
|
||||
device_id,
|
||||
server_url,
|
||||
tx: None,
|
||||
connected: Arc::new(RwLock::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 连接到信令服务器
|
||||
pub async fn connect(
|
||||
&mut self,
|
||||
on_message: impl Fn(SignalMessage) + Send + 'static,
|
||||
) -> Result<()> {
|
||||
// 将 http:// 转换为 ws://,https:// 转换为 wss://
|
||||
let ws_url = self.server_url
|
||||
.replace("http://", "ws://")
|
||||
.replace("https://", "wss://");
|
||||
|
||||
let url = format!(
|
||||
"{}/ws/signal?device_id={}",
|
||||
ws_url, self.device_id
|
||||
);
|
||||
|
||||
tracing::info!("Connecting to WebSocket: {}", url);
|
||||
|
||||
let (ws_stream, _) = connect_async(&url).await?;
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
let (tx, mut rx) = mpsc::channel::<SignalMessage>(32);
|
||||
self.tx = Some(tx);
|
||||
|
||||
*self.connected.write().await = true;
|
||||
let connected = self.connected.clone();
|
||||
|
||||
// 发送任务
|
||||
tokio::spawn(async move {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
let text = serde_json::to_string(&msg).unwrap();
|
||||
if write.send(Message::Text(text)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 接收任务
|
||||
tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = read.next().await {
|
||||
if let Message::Text(text) = msg {
|
||||
if let Ok(signal_msg) = serde_json::from_str::<SignalMessage>(&text) {
|
||||
on_message(signal_msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
*connected.write().await = false;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 发送消息
|
||||
pub async fn send(&self, msg: SignalMessage) -> Result<()> {
|
||||
if let Some(tx) = &self.tx {
|
||||
tx.send(msg).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 发送心跳
|
||||
pub async fn send_heartbeat(&self) -> Result<()> {
|
||||
self.send(SignalMessage::Heartbeat {
|
||||
device_id: self.device_id.clone(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// 发送连接请求
|
||||
pub async fn request_connect(
|
||||
&self,
|
||||
session_id: String,
|
||||
to_device: String,
|
||||
verification_code: String,
|
||||
) -> Result<()> {
|
||||
self.send(SignalMessage::ConnectRequest {
|
||||
session_id,
|
||||
from_device: self.device_id.clone(),
|
||||
to_device,
|
||||
verification_code,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// 发送连接响应
|
||||
pub async fn respond_connect(
|
||||
&self,
|
||||
session_id: String,
|
||||
accepted: bool,
|
||||
reason: Option<String>,
|
||||
) -> Result<()> {
|
||||
self.send(SignalMessage::ConnectResponse {
|
||||
session_id,
|
||||
accepted,
|
||||
reason,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// 发送 SDP Offer
|
||||
pub async fn send_offer(
|
||||
&self,
|
||||
session_id: String,
|
||||
to_device: String,
|
||||
sdp: String,
|
||||
) -> Result<()> {
|
||||
self.send(SignalMessage::Offer {
|
||||
session_id,
|
||||
from_device: self.device_id.clone(),
|
||||
to_device,
|
||||
sdp,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// 发送 SDP Answer
|
||||
pub async fn send_answer(
|
||||
&self,
|
||||
session_id: String,
|
||||
to_device: String,
|
||||
sdp: String,
|
||||
) -> Result<()> {
|
||||
self.send(SignalMessage::Answer {
|
||||
session_id,
|
||||
from_device: self.device_id.clone(),
|
||||
to_device,
|
||||
sdp,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// 发送 ICE Candidate
|
||||
pub async fn send_candidate(
|
||||
&self,
|
||||
session_id: String,
|
||||
to_device: String,
|
||||
candidate: String,
|
||||
sdp_mid: Option<String>,
|
||||
sdp_mline_index: Option<u32>,
|
||||
) -> Result<()> {
|
||||
self.send(SignalMessage::Candidate {
|
||||
session_id,
|
||||
from_device: self.device_id.clone(),
|
||||
to_device,
|
||||
candidate,
|
||||
sdp_mid,
|
||||
sdp_mline_index,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// 设置允许远程
|
||||
pub async fn set_allow_remote(&self, allow: bool) -> Result<()> {
|
||||
self.send(SignalMessage::SetAllowRemote {
|
||||
device_id: self.device_id.clone(),
|
||||
allow,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// 刷新验证码
|
||||
pub async fn refresh_code(&self) -> Result<()> {
|
||||
self.send(SignalMessage::RefreshCode {
|
||||
device_id: self.device_id.clone(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// 结束会话
|
||||
pub async fn end_session(&self, session_id: String) -> Result<()> {
|
||||
self.send(SignalMessage::SessionEnd { session_id }).await
|
||||
}
|
||||
|
||||
/// 发送屏幕帧
|
||||
pub async fn send_frame(
|
||||
&self,
|
||||
session_id: String,
|
||||
to_device: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
jpeg_data: &[u8],
|
||||
) -> Result<()> {
|
||||
let data = BASE64.encode(jpeg_data);
|
||||
self.send(SignalMessage::ScreenFrame {
|
||||
session_id,
|
||||
from_device: self.device_id.clone(),
|
||||
to_device,
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// 是否已连接
|
||||
pub async fn is_connected(&self) -> bool {
|
||||
*self.connected.read().await
|
||||
}
|
||||
}
|
||||
50
crates/client-tauri/Cargo.toml
Normal file
@ -0,0 +1,50 @@
|
||||
[package]
|
||||
name = "easyremote-client"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "1.5", features = [] }
|
||||
|
||||
[dependencies]
|
||||
easyremote-common = { path = "../common" }
|
||||
easyremote-client-core = { path = "../client-core" }
|
||||
|
||||
# Tauri
|
||||
tauri = { version = "1.5", features = ["shell-open", "dialog-all", "clipboard-all", "window-all"] }
|
||||
|
||||
# Windows single instance
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Threading",
|
||||
"Win32_Security",
|
||||
"Win32_UI_WindowsAndMessaging"
|
||||
] }
|
||||
|
||||
# Async
|
||||
tokio = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
# HTTP Client
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
# Logging
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
# Utils
|
||||
chrono = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
dirs = "5.0"
|
||||
image = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
BIN
crates/client-tauri/app-icon.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
3
crates/client-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
BIN
crates/client-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
crates/client-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
crates/client-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 561 B |
BIN
crates/client-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
crates/client-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
crates/client-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
crates/client-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
crates/client-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 528 B |
BIN
crates/client-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
crates/client-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 722 B |
BIN
crates/client-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
crates/client-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
crates/client-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 824 B |
BIN
crates/client-tauri/icons/icon.icns
Normal file
BIN
crates/client-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
crates/client-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
760
crates/client-tauri/src/commands.rs
Normal file
@ -0,0 +1,760 @@
|
||||
//! Tauri 命令
|
||||
|
||||
use crate::state::{AppState, ConnectionState, CurrentUser, DeviceInfo, HistoryItem, ConnectRequest};
|
||||
use easyremote_client_core::{ClientConfig, SignalClient, SignalMessage, ScreenCapturer};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tauri::State;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
type AppStateHandle = Arc<RwLock<AppState>>;
|
||||
|
||||
/// 全局信令客户端
|
||||
static SIGNAL_CLIENT: tokio::sync::OnceCell<Arc<RwLock<Option<SignalClient>>>> = tokio::sync::OnceCell::const_new();
|
||||
|
||||
/// 强制下线标志
|
||||
static FORCE_OFFLINE_FLAG: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 当前活跃的屏幕流会话
|
||||
static ACTIVE_SESSION: tokio::sync::OnceCell<Arc<RwLock<Option<ActiveScreenSession>>>> = tokio::sync::OnceCell::const_new();
|
||||
|
||||
/// 活跃屏幕会话
|
||||
struct ActiveScreenSession {
|
||||
session_id: String,
|
||||
controller_device: String,
|
||||
stop_flag: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
async fn get_active_session() -> &'static Arc<RwLock<Option<ActiveScreenSession>>> {
|
||||
ACTIVE_SESSION.get_or_init(|| async {
|
||||
Arc::new(RwLock::new(None))
|
||||
}).await
|
||||
}
|
||||
|
||||
/// 获取信令客户端
|
||||
async fn get_signal_client() -> &'static Arc<RwLock<Option<SignalClient>>> {
|
||||
SIGNAL_CLIENT.get_or_init(|| async {
|
||||
Arc::new(RwLock::new(None))
|
||||
}).await
|
||||
}
|
||||
|
||||
/// 检查是否需要强制下线
|
||||
pub fn check_force_offline() -> bool {
|
||||
FORCE_OFFLINE_FLAG.swap(false, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 设置强制下线标志并清除登录信息
|
||||
fn set_force_offline() {
|
||||
FORCE_OFFLINE_FLAG.store(true, Ordering::SeqCst);
|
||||
|
||||
// 清除本地保存的登录信息
|
||||
let auth_path = dirs::data_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("easyremote")
|
||||
.join("auth.json");
|
||||
let _ = std::fs::remove_file(&auth_path);
|
||||
tracing::info!("已清除登录信息");
|
||||
}
|
||||
|
||||
/// 启动屏幕流
|
||||
async fn start_screen_streaming(session_id: String, controller_device: String, my_device_id: String) {
|
||||
tracing::info!("启动屏幕流: session={}, controller={}", session_id, controller_device);
|
||||
|
||||
// 检查是否已有活跃会话
|
||||
let active_session = get_active_session().await;
|
||||
{
|
||||
let mut session = active_session.write().await;
|
||||
if session.is_some() {
|
||||
tracing::warn!("已有活跃屏幕流会话,停止旧会话");
|
||||
if let Some(s) = session.as_ref() {
|
||||
s.stop_flag.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
*session = Some(ActiveScreenSession {
|
||||
session_id: session_id.clone(),
|
||||
controller_device: controller_device.clone(),
|
||||
stop_flag: stop_flag.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// 获取信令客户端
|
||||
let signal_client = get_signal_client().await;
|
||||
|
||||
// 启动屏幕捕获线程
|
||||
let session_id_capture = session_id.clone();
|
||||
let controller_device_capture = controller_device.clone();
|
||||
let my_device_id_capture = my_device_id.clone();
|
||||
let signal_client_clone = signal_client.clone();
|
||||
let active_session_clone = active_session.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// 创建屏幕捕获器
|
||||
let mut capturer = match ScreenCapturer::new(0) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("创建屏幕捕获器失败: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let width = capturer.width();
|
||||
let height = capturer.height();
|
||||
tracing::info!("屏幕分辨率: {}x{}", width, height);
|
||||
|
||||
let mut frame_count = 0u64;
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
loop {
|
||||
// 检查是否需要停止
|
||||
let should_stop = {
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
let session = active_session_clone.read().await;
|
||||
session.as_ref().map(|s| s.stop_flag.load(Ordering::SeqCst)).unwrap_or(true)
|
||||
})
|
||||
};
|
||||
|
||||
if should_stop {
|
||||
tracing::info!("屏幕流停止");
|
||||
break;
|
||||
}
|
||||
|
||||
// 捕获帧
|
||||
match capturer.capture_rgba() {
|
||||
Ok(Some(rgba_data)) => {
|
||||
// 使用 image crate 压缩为 JPEG
|
||||
let img = image::RgbaImage::from_raw(width, height, rgba_data);
|
||||
if let Some(img) = img {
|
||||
let mut jpeg_data = Vec::new();
|
||||
let mut cursor = std::io::Cursor::new(&mut jpeg_data);
|
||||
|
||||
// 降低分辨率和质量以减少带宽
|
||||
let scaled = image::imageops::resize(&img, width / 2, height / 2, image::imageops::FilterType::Nearest);
|
||||
|
||||
if scaled.write_to(&mut cursor, image::ImageFormat::Jpeg).is_ok() {
|
||||
let jpeg_len = jpeg_data.len();
|
||||
|
||||
// 发送帧
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
let session_id_send = session_id_capture.clone();
|
||||
let controller_device_send = controller_device_capture.clone();
|
||||
let signal_client_send = signal_client_clone.clone();
|
||||
let scaled_width = width / 2;
|
||||
let scaled_height = height / 2;
|
||||
|
||||
rt.block_on(async move {
|
||||
let client = signal_client_send.read().await;
|
||||
if let Some(c) = client.as_ref() {
|
||||
let _ = c.send_frame(
|
||||
session_id_send,
|
||||
controller_device_send,
|
||||
scaled_width,
|
||||
scaled_height,
|
||||
&jpeg_data,
|
||||
).await;
|
||||
}
|
||||
});
|
||||
|
||||
frame_count += 1;
|
||||
if frame_count % 30 == 0 {
|
||||
let fps = frame_count as f64 / start_time.elapsed().as_secs_f64();
|
||||
tracing::debug!("屏幕流帧率: {:.1} fps, 帧大小: {} KB", fps, jpeg_len / 1024);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// 没有新帧
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("捕获屏幕失败: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 控制帧率 (约 15 fps)
|
||||
std::thread::sleep(std::time::Duration::from_millis(66));
|
||||
}
|
||||
|
||||
// 清理会话
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async {
|
||||
let mut session = active_session_clone.write().await;
|
||||
*session = None;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 处理鼠标输入
|
||||
fn handle_mouse_input(x: f64, y: f64, event_type: &str, button: Option<u8>, delta: Option<f64>) {
|
||||
use easyremote_client_core::InputController;
|
||||
|
||||
// 获取屏幕缩放比例 (因为我们发送的是缩放后的帧)
|
||||
let scale = 2.0;
|
||||
let actual_x = (x * scale) as i32;
|
||||
let actual_y = (y * scale) as i32;
|
||||
|
||||
if let Ok(mut controller) = InputController::new() {
|
||||
match event_type {
|
||||
"move" => {
|
||||
let _ = controller.move_mouse(actual_x, actual_y);
|
||||
}
|
||||
"click" => {
|
||||
let btn = match button {
|
||||
Some(0) => easyremote_client_core::MouseButton::Left,
|
||||
Some(2) => easyremote_client_core::MouseButton::Right,
|
||||
Some(1) => easyremote_client_core::MouseButton::Middle,
|
||||
_ => easyremote_client_core::MouseButton::Left,
|
||||
};
|
||||
let _ = controller.click(btn);
|
||||
}
|
||||
"down" => {
|
||||
let btn = match button {
|
||||
Some(0) => easyremote_client_core::MouseButton::Left,
|
||||
Some(2) => easyremote_client_core::MouseButton::Right,
|
||||
Some(1) => easyremote_client_core::MouseButton::Middle,
|
||||
_ => easyremote_client_core::MouseButton::Left,
|
||||
};
|
||||
let _ = controller.mouse_down(btn);
|
||||
}
|
||||
"up" => {
|
||||
let btn = match button {
|
||||
Some(0) => easyremote_client_core::MouseButton::Left,
|
||||
Some(2) => easyremote_client_core::MouseButton::Right,
|
||||
Some(1) => easyremote_client_core::MouseButton::Middle,
|
||||
_ => easyremote_client_core::MouseButton::Left,
|
||||
};
|
||||
let _ = controller.mouse_up(btn);
|
||||
}
|
||||
"scroll" => {
|
||||
if let Some(d) = delta {
|
||||
let _ = controller.scroll(0, -(d as i32) / 3);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理键盘输入
|
||||
fn handle_keyboard_input(key: &str, event_type: &str) {
|
||||
use easyremote_client_core::InputController;
|
||||
|
||||
if let Ok(mut controller) = InputController::new() {
|
||||
match event_type {
|
||||
"down" => {
|
||||
let _ = controller.key_down(key);
|
||||
}
|
||||
"up" => {
|
||||
let _ = controller.key_up(key);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取操作系统类型
|
||||
fn get_os_type() -> &'static str {
|
||||
if cfg!(target_os = "windows") {
|
||||
"Windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macOS"
|
||||
} else {
|
||||
"Linux"
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取操作系统版本
|
||||
fn get_os_version() -> String {
|
||||
std::env::consts::OS.to_string()
|
||||
}
|
||||
|
||||
/// 向服务端注册设备
|
||||
async fn register_device_to_server(
|
||||
server_url: &str,
|
||||
device_id: &str,
|
||||
device_name: &str,
|
||||
auth_token: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let client = reqwest::Client::new();
|
||||
let http_url = server_url.replace("ws://", "http://").replace("wss://", "https://");
|
||||
|
||||
let mut request = client
|
||||
.post(format!("{}/api/devices/register", http_url))
|
||||
.json(&serde_json::json!({
|
||||
"device_id": device_id,
|
||||
"name": device_name,
|
||||
"os_type": get_os_type(),
|
||||
"os_version": get_os_version()
|
||||
}));
|
||||
|
||||
// 如果已登录,附带 token 以绑定设备到用户
|
||||
if let Some(token) = auth_token {
|
||||
request = request.header("Authorization", format!("Bearer {}", token));
|
||||
}
|
||||
|
||||
let response = request.send().await.map_err(|e| e.to_string())?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error: serde_json::Value = response.json().await.map_err(|e| e.to_string())?;
|
||||
return Err(error["error"].as_str().unwrap_or("设备注册失败").to_string());
|
||||
}
|
||||
|
||||
tracing::info!("设备已注册到服务端");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 连接到信令服务器
|
||||
async fn connect_to_signal_server(device_id: String, server_url: String) -> Result<(), String> {
|
||||
let signal_client_holder = get_signal_client().await;
|
||||
|
||||
// 如果已连接,先断开
|
||||
{
|
||||
let mut holder = signal_client_holder.write().await;
|
||||
*holder = None;
|
||||
}
|
||||
|
||||
// 创建新的信令客户端
|
||||
let mut signal_client = SignalClient::new(device_id.clone(), server_url.clone());
|
||||
|
||||
// 连接到信令服务器
|
||||
let device_id_clone = device_id.clone();
|
||||
let device_id_for_callback = device_id.clone();
|
||||
match signal_client.connect(move |msg| {
|
||||
let device_id_inner = device_id_for_callback.clone();
|
||||
match msg {
|
||||
SignalMessage::HeartbeatAck => {
|
||||
tracing::debug!("收到心跳响应");
|
||||
}
|
||||
SignalMessage::ForceOffline { device_id } => {
|
||||
tracing::info!("收到强制下线命令: device_id={}", device_id);
|
||||
// 设置强制下线标志
|
||||
set_force_offline();
|
||||
}
|
||||
SignalMessage::ConnectRequest { session_id, from_device, to_device, .. } => {
|
||||
tracing::info!("收到连接请求: session_id={}, from={}, to={}", session_id, from_device, to_device);
|
||||
// 启动屏幕流
|
||||
let session_id_clone = session_id.clone();
|
||||
let from_device_clone = from_device.clone();
|
||||
let device_id_stream = device_id_inner.clone();
|
||||
tokio::spawn(async move {
|
||||
start_screen_streaming(session_id_clone, from_device_clone, device_id_stream).await;
|
||||
});
|
||||
}
|
||||
SignalMessage::MouseEvent { x, y, event_type, button, delta, .. } => {
|
||||
// 处理鼠标事件
|
||||
handle_mouse_input(x, y, &event_type, button, delta);
|
||||
}
|
||||
SignalMessage::KeyboardEvent { key, event_type, .. } => {
|
||||
// 处理键盘事件
|
||||
handle_keyboard_input(&key, &event_type);
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!("收到信令消息: {:?}", msg);
|
||||
}
|
||||
}
|
||||
}).await {
|
||||
Ok(_) => {
|
||||
tracing::info!("已连接到信令服务器");
|
||||
|
||||
// 存储客户端
|
||||
let mut holder = signal_client_holder.write().await;
|
||||
*holder = Some(signal_client);
|
||||
|
||||
// 启动心跳任务
|
||||
let holder_clone = signal_client_holder.clone();
|
||||
let device_id_for_heartbeat = device_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let holder = holder_clone.read().await;
|
||||
if let Some(client) = holder.as_ref() {
|
||||
if client.is_connected().await {
|
||||
if let Err(e) = client.send_heartbeat().await {
|
||||
tracing::warn!("发送心跳失败: {}", e);
|
||||
break;
|
||||
}
|
||||
tracing::debug!("发送心跳");
|
||||
} else {
|
||||
tracing::info!("信令连接已断开");
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("连接信令服务器失败: {}", e);
|
||||
Err(format!("连接信令服务器失败: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化 - 验证 token、注册设备并连接信令服务器
|
||||
#[tauri::command]
|
||||
pub async fn initialize(state: State<'_, AppStateHandle>) -> Result<bool, String> {
|
||||
let mut app_state = state.write().await;
|
||||
let mut logged_in = false;
|
||||
|
||||
// 如果有保存的 token,验证是否仍然有效
|
||||
if let Some(token) = &app_state.auth_token {
|
||||
let client = reqwest::Client::new();
|
||||
let server_url = app_state.config.server_url.replace("ws://", "http://").replace("wss://", "https://");
|
||||
|
||||
let response = client
|
||||
.get(format!("{}/api/users/me", server_url))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
tracing::info!("Token 验证成功,用户已登录");
|
||||
logged_in = true;
|
||||
}
|
||||
_ => {
|
||||
tracing::info!("Token 已过期,需要重新登录");
|
||||
app_state.current_user = None;
|
||||
app_state.auth_token = None;
|
||||
app_state.clear_auth_data();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取必要信息
|
||||
let device_id = app_state.device_id.0.clone();
|
||||
let device_name = app_state.config.device_name.clone();
|
||||
let server_url = app_state.config.server_url.clone();
|
||||
let token = app_state.auth_token.clone();
|
||||
|
||||
drop(app_state); // 释放锁
|
||||
|
||||
// 注册设备到服务端
|
||||
let _ = register_device_to_server(
|
||||
&server_url,
|
||||
&device_id,
|
||||
&device_name,
|
||||
token.as_deref(),
|
||||
).await;
|
||||
|
||||
// 连接到信令服务器(WebSocket)- 这会使设备显示为在线
|
||||
let _ = connect_to_signal_server(device_id, server_url).await;
|
||||
|
||||
Ok(logged_in)
|
||||
}
|
||||
|
||||
/// 获取设备信息
|
||||
#[tauri::command]
|
||||
pub async fn get_device_info(state: State<'_, AppStateHandle>) -> Result<DeviceInfo, String> {
|
||||
let state = state.read().await;
|
||||
|
||||
Ok(DeviceInfo {
|
||||
device_id: state.device_id.0.clone(),
|
||||
device_id_formatted: state.device_id.formatted(),
|
||||
verification_code: state.verification_code.0.clone(),
|
||||
allow_remote: state.allow_remote,
|
||||
device_name: state.config.device_name.clone(),
|
||||
os_type: get_os_type().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 刷新验证码
|
||||
#[tauri::command]
|
||||
pub async fn refresh_verification_code(state: State<'_, AppStateHandle>) -> Result<String, String> {
|
||||
let mut state = state.write().await;
|
||||
Ok(state.refresh_code())
|
||||
}
|
||||
|
||||
/// 设置是否允许远程控制
|
||||
#[tauri::command]
|
||||
pub async fn set_allow_remote(state: State<'_, AppStateHandle>, allow: bool) -> Result<(), String> {
|
||||
let mut state = state.write().await;
|
||||
state.allow_remote = allow;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 用户登录
|
||||
#[tauri::command]
|
||||
pub async fn login(
|
||||
state: State<'_, AppStateHandle>,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<CurrentUser, String> {
|
||||
let mut app_state = state.write().await;
|
||||
|
||||
// 构建登录请求
|
||||
let client = reqwest::Client::new();
|
||||
let server_url = app_state.config.server_url.replace("ws://", "http://").replace("wss://", "https://");
|
||||
|
||||
let response = client
|
||||
.post(format!("{}/api/auth/login", server_url))
|
||||
.json(&serde_json::json!({
|
||||
"username": username,
|
||||
"password": password,
|
||||
"device_id": app_state.device_id.0
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error: serde_json::Value = response.json().await.map_err(|e| e.to_string())?;
|
||||
return Err(error["error"].as_str().unwrap_or("登录失败").to_string());
|
||||
}
|
||||
|
||||
let result: serde_json::Value = response.json().await.map_err(|e| e.to_string())?;
|
||||
let data = &result["data"];
|
||||
|
||||
let user = CurrentUser {
|
||||
id: data["user"]["id"].as_str().unwrap_or("").to_string(),
|
||||
username: data["user"]["username"].as_str().unwrap_or("").to_string(),
|
||||
email: data["user"]["email"].as_str().map(|s| s.to_string()),
|
||||
};
|
||||
let token = data["token"].as_str().unwrap_or("").to_string();
|
||||
|
||||
app_state.set_user(user.clone(), token.clone());
|
||||
|
||||
// 登录成功后注册设备到服务端(绑定用户)
|
||||
let device_id = app_state.device_id.0.clone();
|
||||
let device_name = app_state.config.device_name.clone();
|
||||
|
||||
drop(app_state);
|
||||
|
||||
let _ = register_device_to_server(&server_url, &device_id, &device_name, Some(&token)).await;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// 用户注册
|
||||
#[tauri::command]
|
||||
pub async fn register(
|
||||
state: State<'_, AppStateHandle>,
|
||||
username: String,
|
||||
password: String,
|
||||
email: Option<String>,
|
||||
) -> Result<CurrentUser, String> {
|
||||
let mut app_state = state.write().await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let server_url = &app_state.config.server_url.replace("ws://", "http://").replace("wss://", "https://");
|
||||
|
||||
let response = client
|
||||
.post(format!("{}/api/auth/register", server_url))
|
||||
.json(&serde_json::json!({
|
||||
"username": username,
|
||||
"password": password,
|
||||
"email": email
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error: serde_json::Value = response.json().await.map_err(|e| e.to_string())?;
|
||||
return Err(error["error"].as_str().unwrap_or("注册失败").to_string());
|
||||
}
|
||||
|
||||
let result: serde_json::Value = response.json().await.map_err(|e| e.to_string())?;
|
||||
let data = &result["data"];
|
||||
|
||||
let user = CurrentUser {
|
||||
id: data["user"]["id"].as_str().unwrap_or("").to_string(),
|
||||
username: data["user"]["username"].as_str().unwrap_or("").to_string(),
|
||||
email: data["user"]["email"].as_str().map(|s| s.to_string()),
|
||||
};
|
||||
let token = data["token"].as_str().unwrap_or("").to_string();
|
||||
|
||||
app_state.set_user(user.clone(), token);
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// 用户退出登录
|
||||
#[tauri::command]
|
||||
pub async fn logout(state: State<'_, AppStateHandle>) -> Result<(), String> {
|
||||
let mut state = state.write().await;
|
||||
state.logout();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取当前用户
|
||||
#[tauri::command]
|
||||
pub async fn get_current_user(state: State<'_, AppStateHandle>) -> Result<Option<CurrentUser>, String> {
|
||||
let state = state.read().await;
|
||||
Ok(state.current_user.clone())
|
||||
}
|
||||
|
||||
/// 获取用户设备列表
|
||||
#[tauri::command]
|
||||
pub async fn get_devices(state: State<'_, AppStateHandle>) -> Result<Vec<serde_json::Value>, String> {
|
||||
let app_state = state.read().await;
|
||||
|
||||
let token = app_state.auth_token.as_ref()
|
||||
.ok_or("未登录")?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let server_url = &app_state.config.server_url.replace("ws://", "http://").replace("wss://", "https://");
|
||||
|
||||
let response = client
|
||||
.get(format!("{}/api/devices", server_url))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err("获取设备列表失败".to_string());
|
||||
}
|
||||
|
||||
let result: serde_json::Value = response.json().await.map_err(|e| e.to_string())?;
|
||||
let devices = result["data"].as_array()
|
||||
.map(|arr| arr.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
/// 移除设备
|
||||
#[tauri::command]
|
||||
pub async fn remove_device(
|
||||
state: State<'_, AppStateHandle>,
|
||||
device_id: String,
|
||||
) -> Result<(), String> {
|
||||
let app_state = state.read().await;
|
||||
|
||||
let token = app_state.auth_token.as_ref()
|
||||
.ok_or("未登录")?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let server_url = &app_state.config.server_url.replace("ws://", "http://").replace("wss://", "https://");
|
||||
|
||||
let response = client
|
||||
.delete(format!("{}/api/devices/{}", server_url, device_id))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err("移除设备失败".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 连接到目标设备
|
||||
#[tauri::command]
|
||||
pub async fn connect_to_device(
|
||||
state: State<'_, AppStateHandle>,
|
||||
request: ConnectRequest,
|
||||
) -> Result<(), String> {
|
||||
let mut app_state = state.write().await;
|
||||
|
||||
app_state.connection_state = ConnectionState::Connecting;
|
||||
|
||||
// TODO: 实现实际的P2P连接逻辑
|
||||
// 这里需要:
|
||||
// 1. 连接到信令服务器
|
||||
// 2. 发送连接请求
|
||||
// 3. 进行ICE候选交换
|
||||
// 4. 建立P2P连接
|
||||
|
||||
// 模拟连接过程
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
|
||||
app_state.connection_state = ConnectionState::Connected {
|
||||
target_device_id: request.device_id.clone(),
|
||||
target_device_name: format!("设备 {}", request.device_id),
|
||||
connection_type: "P2P".to_string(),
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 断开连接
|
||||
#[tauri::command]
|
||||
pub async fn disconnect(state: State<'_, AppStateHandle>) -> Result<(), String> {
|
||||
let mut state = state.write().await;
|
||||
state.connection_state = ConnectionState::Disconnected;
|
||||
state.current_session_id = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取连接状态
|
||||
#[tauri::command]
|
||||
pub async fn get_connection_state(state: State<'_, AppStateHandle>) -> Result<ConnectionState, String> {
|
||||
let state = state.read().await;
|
||||
Ok(state.connection_state.clone())
|
||||
}
|
||||
|
||||
/// 获取历史记录
|
||||
#[tauri::command]
|
||||
pub async fn get_history(
|
||||
state: State<'_, AppStateHandle>,
|
||||
page: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<Vec<HistoryItem>, String> {
|
||||
let app_state = state.read().await;
|
||||
|
||||
let token = app_state.auth_token.as_ref()
|
||||
.ok_or("未登录")?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let server_url = &app_state.config.server_url.replace("ws://", "http://").replace("wss://", "https://");
|
||||
|
||||
let response = client
|
||||
.get(format!("{}/api/sessions/history", server_url))
|
||||
.query(&[("page", page.unwrap_or(1)), ("limit", limit.unwrap_or(20))])
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err("获取历史记录失败".to_string());
|
||||
}
|
||||
|
||||
let result: serde_json::Value = response.json().await.map_err(|e| e.to_string())?;
|
||||
let items = result["data"]["items"].as_array()
|
||||
.map(|arr| {
|
||||
arr.iter().filter_map(|item| {
|
||||
Some(HistoryItem {
|
||||
id: item["id"].as_str()?.to_string(),
|
||||
device_id: item["controlled_device_id"].as_str()?.to_string(),
|
||||
device_name: item["controlled_device_name"].as_str()?.to_string(),
|
||||
started_at: item["started_at"].as_str()?.to_string(),
|
||||
ended_at: item["ended_at"].as_str().map(|s| s.to_string()),
|
||||
duration: item["duration"].as_str().map(|s| s.to_string()),
|
||||
connection_type: item["connection_type"].as_str()?.to_string(),
|
||||
})
|
||||
}).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// 获取配置
|
||||
#[tauri::command]
|
||||
pub async fn get_config(state: State<'_, AppStateHandle>) -> Result<ClientConfig, String> {
|
||||
let state = state.read().await;
|
||||
Ok(state.config.clone())
|
||||
}
|
||||
|
||||
/// 保存配置
|
||||
#[tauri::command]
|
||||
pub async fn save_config(
|
||||
state: State<'_, AppStateHandle>,
|
||||
config: ClientConfig,
|
||||
) -> Result<(), String> {
|
||||
let mut state = state.write().await;
|
||||
state.config = config.clone();
|
||||
config.save().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
195
crates/client-tauri/src/main.rs
Normal file
@ -0,0 +1,195 @@
|
||||
#![cfg_attr(
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
mod commands;
|
||||
mod state;
|
||||
|
||||
use state::AppState;
|
||||
use std::sync::Arc;
|
||||
use tauri::Manager;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[cfg(windows)]
|
||||
mod single_instance {
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, HWND, LPARAM, GetLastError, ERROR_ALREADY_EXISTS};
|
||||
use windows::Win32::System::Threading::CreateMutexW;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
EnumWindows, GetWindowTextW, IsWindowVisible, SetForegroundWindow, ShowWindow, SW_RESTORE,
|
||||
};
|
||||
|
||||
static MUTEX_NAME: &str = "Global\\EasyRemoteClientMutex";
|
||||
static WINDOW_TITLE: &str = "EasyRemote";
|
||||
|
||||
pub struct SingleInstance {
|
||||
_handle: HANDLE,
|
||||
}
|
||||
|
||||
impl SingleInstance {
|
||||
pub fn new() -> Option<Self> {
|
||||
unsafe {
|
||||
let mutex_name: Vec<u16> = MUTEX_NAME.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let handle = CreateMutexW(None, true, PCWSTR(mutex_name.as_ptr())).ok()?;
|
||||
|
||||
if GetLastError() == ERROR_ALREADY_EXISTS {
|
||||
// 已有实例在运行,尝试激活它
|
||||
activate_existing_window();
|
||||
let _ = CloseHandle(handle);
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self { _handle: handle })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn activate_existing_window() {
|
||||
unsafe {
|
||||
let _ = EnumWindows(Some(enum_window_callback), LPARAM(0));
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "system" fn enum_window_callback(hwnd: HWND, _: LPARAM) -> windows::Win32::Foundation::BOOL {
|
||||
if !IsWindowVisible(hwnd).as_bool() {
|
||||
return true.into();
|
||||
}
|
||||
|
||||
let mut title = [0u16; 256];
|
||||
let len = GetWindowTextW(hwnd, &mut title);
|
||||
if len > 0 {
|
||||
let title_str = String::from_utf16_lossy(&title[..len as usize]);
|
||||
if title_str.contains(WINDOW_TITLE) {
|
||||
// 找到窗口,恢复并聚焦
|
||||
let _ = ShowWindow(hwnd, SW_RESTORE);
|
||||
let _ = SetForegroundWindow(hwnd);
|
||||
return false.into(); // 停止枚举
|
||||
}
|
||||
}
|
||||
true.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
mod single_instance {
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct SingleInstance {
|
||||
_lock_file: File,
|
||||
}
|
||||
|
||||
impl SingleInstance {
|
||||
pub fn new() -> Option<Self> {
|
||||
let lock_path = get_lock_path();
|
||||
|
||||
// 尝试以独占方式打开锁文件
|
||||
match File::options()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(&lock_path)
|
||||
{
|
||||
Ok(file) => {
|
||||
// 尝试获取文件锁
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let fd = file.as_raw_fd();
|
||||
let result = unsafe {
|
||||
libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB)
|
||||
};
|
||||
if result != 0 {
|
||||
eprintln!("EasyRemote 已在运行中");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Self { _lock_file: file })
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("EasyRemote 已在运行中");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_lock_path() -> PathBuf {
|
||||
dirs::runtime_dir()
|
||||
.or_else(dirs::cache_dir)
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join("easyremote.lock")
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// 单例检查 - 确保只运行一个实例
|
||||
let _instance = match single_instance::SingleInstance::new() {
|
||||
Some(instance) => instance,
|
||||
None => {
|
||||
// 已有实例在运行,退出
|
||||
eprintln!("EasyRemote 客户端已在运行中,将激活现有窗口");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化日志
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
// 创建应用状态
|
||||
let state = Arc::new(RwLock::new(AppState::new()));
|
||||
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
// 设置窗口标题以便单例检测
|
||||
if let Some(window) = app.get_window("main") {
|
||||
let _ = window.set_title("EasyRemote");
|
||||
|
||||
// 启动强制下线检测任务
|
||||
let window_clone = window.clone();
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
|
||||
// 检查是否需要强制下线
|
||||
if commands::check_force_offline() {
|
||||
tracing::info!("执行强制下线,关闭应用");
|
||||
// 关闭窗口并退出
|
||||
let _ = window_clone.close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.manage(state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// 初始化
|
||||
commands::initialize,
|
||||
// 设备信息
|
||||
commands::get_device_info,
|
||||
commands::refresh_verification_code,
|
||||
commands::set_allow_remote,
|
||||
// 账号
|
||||
commands::login,
|
||||
commands::logout,
|
||||
commands::register,
|
||||
commands::get_current_user,
|
||||
// 设备管理
|
||||
commands::get_devices,
|
||||
commands::remove_device,
|
||||
// 连接
|
||||
commands::connect_to_device,
|
||||
commands::disconnect,
|
||||
commands::get_connection_state,
|
||||
// 历史记录
|
||||
commands::get_history,
|
||||
// 配置
|
||||
commands::get_config,
|
||||
commands::save_config,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
221
crates/client-tauri/src/state.rs
Normal file
@ -0,0 +1,221 @@
|
||||
//! 应用状态管理
|
||||
|
||||
use easyremote_common::types::{DeviceId, VerificationCode};
|
||||
use easyremote_client_core::ClientConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 应用状态
|
||||
pub struct AppState {
|
||||
/// 配置
|
||||
pub config: ClientConfig,
|
||||
/// 当前设备ID
|
||||
pub device_id: DeviceId,
|
||||
/// 验证码
|
||||
pub verification_code: VerificationCode,
|
||||
/// 是否允许远程控制
|
||||
pub allow_remote: bool,
|
||||
/// 当前登录用户
|
||||
pub current_user: Option<CurrentUser>,
|
||||
/// 认证令牌
|
||||
pub auth_token: Option<String>,
|
||||
/// 连接状态
|
||||
pub connection_state: ConnectionState,
|
||||
/// 当前会话ID
|
||||
pub current_session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 当前用户信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CurrentUser {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
/// 连接状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ConnectionState {
|
||||
/// 断开连接
|
||||
Disconnected,
|
||||
/// 正在连接
|
||||
Connecting,
|
||||
/// 已连接到设备
|
||||
Connected {
|
||||
target_device_id: String,
|
||||
target_device_name: String,
|
||||
connection_type: String,
|
||||
},
|
||||
/// 被控制中
|
||||
BeingControlled {
|
||||
controller_device_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 持久化的认证数据
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct AuthData {
|
||||
server_url: String,
|
||||
token: String,
|
||||
user: CurrentUser,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
// 尝试加载已保存的设备ID
|
||||
let (device_id, verification_code) = Self::load_or_create_device_id();
|
||||
let config = ClientConfig::load();
|
||||
|
||||
// 尝试加载已保存的登录信息
|
||||
let (current_user, auth_token) = Self::load_auth_data(&config.server_url);
|
||||
|
||||
Self {
|
||||
config,
|
||||
device_id,
|
||||
verification_code,
|
||||
allow_remote: false,
|
||||
current_user,
|
||||
auth_token,
|
||||
connection_state: ConnectionState::Disconnected,
|
||||
current_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_data_dir() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("easyremote")
|
||||
}
|
||||
|
||||
fn load_or_create_device_id() -> (DeviceId, VerificationCode) {
|
||||
let data_path = Self::get_data_dir().join("device.json");
|
||||
|
||||
if data_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&data_path) {
|
||||
if let Ok(data) = serde_json::from_str::<DeviceData>(&content) {
|
||||
return (DeviceId(data.device_id), VerificationCode::generate());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新的设备ID
|
||||
let device_id = DeviceId::generate();
|
||||
let verification_code = VerificationCode::generate();
|
||||
|
||||
// 保存设备ID
|
||||
if let Some(parent) = data_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let data = DeviceData {
|
||||
device_id: device_id.0.clone(),
|
||||
};
|
||||
let _ = std::fs::write(&data_path, serde_json::to_string(&data).unwrap());
|
||||
|
||||
(device_id, verification_code)
|
||||
}
|
||||
|
||||
/// 加载认证数据(如果服务器地址匹配)
|
||||
fn load_auth_data(server_url: &str) -> (Option<CurrentUser>, Option<String>) {
|
||||
let auth_path = Self::get_data_dir().join("auth.json");
|
||||
|
||||
if auth_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&auth_path) {
|
||||
if let Ok(auth_data) = serde_json::from_str::<AuthData>(&content) {
|
||||
// 只有当服务器地址匹配时才恢复登录状态
|
||||
if auth_data.server_url == server_url {
|
||||
tracing::info!("已恢复登录状态: {}", auth_data.user.username);
|
||||
return (Some(auth_data.user), Some(auth_data.token));
|
||||
} else {
|
||||
tracing::info!("服务器地址已变更,需要重新登录");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(None, None)
|
||||
}
|
||||
|
||||
/// 保存认证数据
|
||||
pub fn save_auth_data(&self) -> Result<(), std::io::Error> {
|
||||
if let (Some(user), Some(token)) = (&self.current_user, &self.auth_token) {
|
||||
let auth_path = Self::get_data_dir().join("auth.json");
|
||||
|
||||
if let Some(parent) = auth_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let auth_data = AuthData {
|
||||
server_url: self.config.server_url.clone(),
|
||||
token: token.clone(),
|
||||
user: user.clone(),
|
||||
};
|
||||
|
||||
std::fs::write(&auth_path, serde_json::to_string(&auth_data).unwrap())?;
|
||||
tracing::info!("登录信息已保存");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清除认证数据
|
||||
pub fn clear_auth_data(&self) {
|
||||
let auth_path = Self::get_data_dir().join("auth.json");
|
||||
let _ = std::fs::remove_file(&auth_path);
|
||||
tracing::info!("登录信息已清除");
|
||||
}
|
||||
|
||||
/// 刷新验证码
|
||||
pub fn refresh_code(&mut self) -> String {
|
||||
self.verification_code.refresh();
|
||||
self.verification_code.0.clone()
|
||||
}
|
||||
|
||||
/// 设置登录用户并保存
|
||||
pub fn set_user(&mut self, user: CurrentUser, token: String) {
|
||||
self.current_user = Some(user);
|
||||
self.auth_token = Some(token);
|
||||
// 保存登录信息
|
||||
let _ = self.save_auth_data();
|
||||
}
|
||||
|
||||
/// 退出登录并清除保存的数据
|
||||
pub fn logout(&mut self) {
|
||||
self.current_user = None;
|
||||
self.auth_token = None;
|
||||
self.clear_auth_data();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct DeviceData {
|
||||
device_id: String,
|
||||
}
|
||||
|
||||
/// 设备信息响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceInfo {
|
||||
pub device_id: String,
|
||||
pub device_id_formatted: String,
|
||||
pub verification_code: String,
|
||||
pub allow_remote: bool,
|
||||
pub device_name: String,
|
||||
pub os_type: String,
|
||||
}
|
||||
|
||||
/// 连接设备请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectRequest {
|
||||
pub device_id: String,
|
||||
pub verification_code: String,
|
||||
}
|
||||
|
||||
/// 历史记录项
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HistoryItem {
|
||||
pub id: String,
|
||||
pub device_id: String,
|
||||
pub device_name: String,
|
||||
pub started_at: String,
|
||||
pub ended_at: Option<String>,
|
||||
pub duration: Option<String>,
|
||||
pub connection_type: String,
|
||||
}
|
||||
81
crates/client-tauri/tauri.conf.json
Normal file
@ -0,0 +1,81 @@
|
||||
{
|
||||
"build": {
|
||||
"beforeDevCommand": "",
|
||||
"beforeBuildCommand": "",
|
||||
"devPath": "http://localhost:5173",
|
||||
"distDir": "ui/dist"
|
||||
},
|
||||
"package": {
|
||||
"productName": "EasyRemote",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
"all": false,
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"window": {
|
||||
"all": true
|
||||
},
|
||||
"dialog": {
|
||||
"all": true
|
||||
},
|
||||
"clipboard": {
|
||||
"all": true
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "Utility",
|
||||
"copyright": "Copyright © 2024 EasyRemote Team",
|
||||
"deb": {
|
||||
"depends": []
|
||||
},
|
||||
"externalBin": [],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"identifier": "com.easyremote.app",
|
||||
"longDescription": "A remote desktop control application",
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"providerShortName": null,
|
||||
"signingIdentity": null
|
||||
},
|
||||
"resources": [],
|
||||
"shortDescription": "Remote Desktop Control",
|
||||
"targets": "all",
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": ""
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"updater": {
|
||||
"active": false
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"fullscreen": false,
|
||||
"height": 700,
|
||||
"resizable": true,
|
||||
"title": "EasyRemote - 远程协助",
|
||||
"width": 480,
|
||||
"center": true,
|
||||
"decorations": true,
|
||||
"minWidth": 400,
|
||||
"minHeight": 600
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
13
crates/client-tauri/ui/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EasyRemote - 远程协助</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1367
crates/client-tauri/ui/package-lock.json
generated
Normal file
21
crates/client-tauri/ui/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "easyremote-ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"@tauri-apps/api": "^1.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vue-tsc": "^1.8.0"
|
||||
}
|
||||
}
|
||||
1328
crates/client-tauri/ui/src/App.vue
Normal file
5
crates/client-tauri/ui/src/main.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './styles/main.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
473
crates/client-tauri/ui/src/styles/main.css
Normal file
@ -0,0 +1,473 @@
|
||||
/* EasyRemote 主题样式 */
|
||||
:root {
|
||||
/* 主色调 - 深蓝科技感 */
|
||||
--primary: #3b82f6;
|
||||
--primary-hover: #2563eb;
|
||||
--primary-light: #60a5fa;
|
||||
--primary-bg: rgba(59, 130, 246, 0.1);
|
||||
|
||||
/* 背景色 */
|
||||
--bg-primary: #0a0f1a;
|
||||
--bg-secondary: #111827;
|
||||
--bg-tertiary: #1f2937;
|
||||
--bg-card: #161f2d;
|
||||
|
||||
/* 文字色 */
|
||||
--text-primary: #f3f4f6;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-muted: #6b7280;
|
||||
|
||||
/* 边框 */
|
||||
--border-color: #2d3748;
|
||||
--border-light: #4b5563;
|
||||
|
||||
/* 状态色 */
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--info: #06b6d4;
|
||||
|
||||
/* 阴影 */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.6);
|
||||
|
||||
/* 圆角 */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
|
||||
/* 字体 */
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-light);
|
||||
}
|
||||
|
||||
/* 主容器 */
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, var(--bg-primary) 0%, #050810 100%);
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.app-header {
|
||||
padding: 14px 20px;
|
||||
background: rgba(22, 31, 45, 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
animation: fadeIn 0.25s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 开关 */
|
||||
.switch {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.switch.active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.switch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--text-primary);
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.switch.active::after {
|
||||
left: 22px;
|
||||
}
|
||||
|
||||
/* 输入框 */
|
||||
.input-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-bg);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.btn-primary:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--border-color);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 状态指示器 */
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 8px var(--success);
|
||||
}
|
||||
|
||||
.status-dot.offline {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 历史记录列表 */
|
||||
.history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.history-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.history-item:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.history-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
background: var(--primary-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 14px;
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.history-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.history-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.history-action {
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* 用户菜单 */
|
||||
.user-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 分割线 */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 480px) {
|
||||
.app-header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
63
crates/client-tauri/ui/src/types.ts
Normal file
@ -0,0 +1,63 @@
|
||||
// Tauri 命令类型定义
|
||||
|
||||
export interface DeviceInfo {
|
||||
device_id: string;
|
||||
device_id_formatted: string;
|
||||
verification_code: string;
|
||||
allow_remote: boolean;
|
||||
device_name: string;
|
||||
os_type: string;
|
||||
}
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
id: string;
|
||||
device_id: string;
|
||||
name: string;
|
||||
os_type: string;
|
||||
os_version: string;
|
||||
is_online: boolean;
|
||||
allow_remote: boolean;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
export interface HistoryItem {
|
||||
id: string;
|
||||
device_id: string;
|
||||
device_name: string;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
duration: string | null;
|
||||
connection_type: string;
|
||||
}
|
||||
|
||||
export interface ConnectRequest {
|
||||
device_id: string;
|
||||
verification_code: string;
|
||||
}
|
||||
|
||||
export type ConnectionState =
|
||||
| { type: 'Disconnected' }
|
||||
| { type: 'Connecting' }
|
||||
| { type: 'Connected'; target_device_id: string; target_device_name: string; connection_type: string }
|
||||
| { type: 'BeingControlled'; controller_device_id: string };
|
||||
|
||||
export interface ClientConfig {
|
||||
server_url: string;
|
||||
device_name: string;
|
||||
quality: QualityConfig;
|
||||
auto_start: boolean;
|
||||
launch_on_boot: boolean;
|
||||
}
|
||||
|
||||
export interface QualityConfig {
|
||||
frame_rate: number;
|
||||
resolution_scale: number;
|
||||
image_quality: number;
|
||||
hardware_acceleration: boolean;
|
||||
}
|
||||
7
crates/client-tauri/ui/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
21
crates/client-tauri/ui/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
crates/client-tauri/ui/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
17
crates/client-tauri/ui/vite.config.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
envPrefix: ['VITE_', 'TAURI_'],
|
||||
build: {
|
||||
target: ['es2021', 'chrome100', 'safari13'],
|
||||
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
|
||||
sourcemap: !!process.env.TAURI_DEBUG,
|
||||
},
|
||||
})
|
||||
18
crates/common/Cargo.toml
Normal file
@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "easyremote-common"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
bincode = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
ring = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
hostname = "0.3"
|
||||
178
crates/common/src/crypto.rs
Normal file
@ -0,0 +1,178 @@
|
||||
//! 加密工具
|
||||
|
||||
use crate::{Error, Result};
|
||||
use ring::aead::{Aad, BoundKey, Nonce, NonceSequence, SealingKey, OpeningKey, UnboundKey, AES_256_GCM};
|
||||
use ring::rand::{SecureRandom, SystemRandom};
|
||||
|
||||
/// 密钥长度
|
||||
pub const KEY_SIZE: usize = 32;
|
||||
/// Nonce长度
|
||||
pub const NONCE_SIZE: usize = 12;
|
||||
/// 认证标签长度
|
||||
pub const TAG_SIZE: usize = 16;
|
||||
|
||||
/// 生成随机密钥
|
||||
pub fn generate_key() -> [u8; KEY_SIZE] {
|
||||
let rng = SystemRandom::new();
|
||||
let mut key = [0u8; KEY_SIZE];
|
||||
rng.fill(&mut key).expect("Failed to generate key");
|
||||
key
|
||||
}
|
||||
|
||||
/// 生成随机Nonce
|
||||
pub fn generate_nonce() -> [u8; NONCE_SIZE] {
|
||||
let rng = SystemRandom::new();
|
||||
let mut nonce = [0u8; NONCE_SIZE];
|
||||
rng.fill(&mut nonce).expect("Failed to generate nonce");
|
||||
nonce
|
||||
}
|
||||
|
||||
/// 计数器Nonce序列
|
||||
struct CounterNonceSequence {
|
||||
counter: u64,
|
||||
prefix: [u8; 4],
|
||||
}
|
||||
|
||||
impl CounterNonceSequence {
|
||||
fn new() -> Self {
|
||||
let rng = SystemRandom::new();
|
||||
let mut prefix = [0u8; 4];
|
||||
rng.fill(&mut prefix).expect("Failed to generate prefix");
|
||||
Self { counter: 0, prefix }
|
||||
}
|
||||
}
|
||||
|
||||
impl NonceSequence for CounterNonceSequence {
|
||||
fn advance(&mut self) -> std::result::Result<Nonce, ring::error::Unspecified> {
|
||||
let mut nonce = [0u8; NONCE_SIZE];
|
||||
nonce[..4].copy_from_slice(&self.prefix);
|
||||
nonce[4..].copy_from_slice(&self.counter.to_be_bytes());
|
||||
self.counter += 1;
|
||||
Nonce::try_assume_unique_for_key(&nonce)
|
||||
}
|
||||
}
|
||||
|
||||
/// 加密器
|
||||
pub struct Encryptor {
|
||||
sealing_key: SealingKey<CounterNonceSequence>,
|
||||
}
|
||||
|
||||
impl Encryptor {
|
||||
/// 创建新的加密器
|
||||
pub fn new(key: &[u8; KEY_SIZE]) -> Result<Self> {
|
||||
let unbound_key = UnboundKey::new(&AES_256_GCM, key)
|
||||
.map_err(|_| Error::CryptoError("Invalid key".into()))?;
|
||||
let sealing_key = SealingKey::new(unbound_key, CounterNonceSequence::new());
|
||||
Ok(Self { sealing_key })
|
||||
}
|
||||
|
||||
/// 加密数据
|
||||
pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut in_out = plaintext.to_vec();
|
||||
// 预留TAG空间
|
||||
in_out.extend_from_slice(&[0u8; TAG_SIZE]);
|
||||
|
||||
self.sealing_key
|
||||
.seal_in_place_append_tag(Aad::empty(), &mut in_out)
|
||||
.map_err(|_| Error::CryptoError("Encryption failed".into()))?;
|
||||
|
||||
Ok(in_out)
|
||||
}
|
||||
}
|
||||
|
||||
/// 解密器
|
||||
pub struct Decryptor {
|
||||
opening_key: OpeningKey<CounterNonceSequence>,
|
||||
}
|
||||
|
||||
impl Decryptor {
|
||||
/// 创建新的解密器
|
||||
pub fn new(key: &[u8; KEY_SIZE]) -> Result<Self> {
|
||||
let unbound_key = UnboundKey::new(&AES_256_GCM, key)
|
||||
.map_err(|_| Error::CryptoError("Invalid key".into()))?;
|
||||
let opening_key = OpeningKey::new(unbound_key, CounterNonceSequence::new());
|
||||
Ok(Self { opening_key })
|
||||
}
|
||||
|
||||
/// 解密数据
|
||||
pub fn decrypt(&mut self, ciphertext: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut in_out = ciphertext.to_vec();
|
||||
|
||||
let plaintext = self.opening_key
|
||||
.open_in_place(Aad::empty(), &mut in_out)
|
||||
.map_err(|_| Error::CryptoError("Decryption failed".into()))?;
|
||||
|
||||
Ok(plaintext.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
/// 密码哈希
|
||||
pub mod password {
|
||||
use super::*;
|
||||
use ring::pbkdf2;
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
const CREDENTIAL_LEN: usize = 32;
|
||||
const ITERATIONS: u32 = 100_000;
|
||||
|
||||
/// 哈希密码
|
||||
pub fn hash_password(password: &str, salt: &[u8]) -> [u8; CREDENTIAL_LEN] {
|
||||
let mut hash = [0u8; CREDENTIAL_LEN];
|
||||
pbkdf2::derive(
|
||||
pbkdf2::PBKDF2_HMAC_SHA256,
|
||||
NonZeroU32::new(ITERATIONS).unwrap(),
|
||||
salt,
|
||||
password.as_bytes(),
|
||||
&mut hash,
|
||||
);
|
||||
hash
|
||||
}
|
||||
|
||||
/// 验证密码
|
||||
pub fn verify_password(password: &str, salt: &[u8], hash: &[u8]) -> bool {
|
||||
pbkdf2::verify(
|
||||
pbkdf2::PBKDF2_HMAC_SHA256,
|
||||
NonZeroU32::new(ITERATIONS).unwrap(),
|
||||
salt,
|
||||
password.as_bytes(),
|
||||
hash,
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// 生成盐值
|
||||
pub fn generate_salt() -> [u8; 16] {
|
||||
let rng = SystemRandom::new();
|
||||
let mut salt = [0u8; 16];
|
||||
rng.fill(&mut salt).expect("Failed to generate salt");
|
||||
salt
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt() {
|
||||
let key = generate_key();
|
||||
let mut encryptor = Encryptor::new(&key).unwrap();
|
||||
let mut decryptor = Decryptor::new(&key).unwrap();
|
||||
|
||||
let plaintext = b"Hello, World!";
|
||||
let ciphertext = encryptor.encrypt(plaintext).unwrap();
|
||||
let decrypted = decryptor.decrypt(&ciphertext).unwrap();
|
||||
|
||||
assert_eq!(plaintext.as_slice(), decrypted.as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_hash() {
|
||||
let password = "my_secure_password";
|
||||
let salt = password::generate_salt();
|
||||
let hash = password::hash_password(password, &salt);
|
||||
|
||||
assert!(password::verify_password(password, &salt, &hash));
|
||||
assert!(!password::verify_password("wrong_password", &salt, &hash));
|
||||
}
|
||||
}
|
||||
59
crates/common/src/error.rs
Normal file
@ -0,0 +1,59 @@
|
||||
//! 错误类型定义
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("认证失败: {0}")]
|
||||
AuthError(String),
|
||||
|
||||
#[error("连接错误: {0}")]
|
||||
ConnectionError(String),
|
||||
|
||||
#[error("协议错误: {0}")]
|
||||
ProtocolError(String),
|
||||
|
||||
#[error("加密错误: {0}")]
|
||||
CryptoError(String),
|
||||
|
||||
#[error("设备未找到: {0}")]
|
||||
DeviceNotFound(String),
|
||||
|
||||
#[error("权限被拒绝: {0}")]
|
||||
PermissionDenied(String),
|
||||
|
||||
#[error("会话过期")]
|
||||
SessionExpired,
|
||||
|
||||
#[error("网络错误: {0}")]
|
||||
NetworkError(String),
|
||||
|
||||
#[error("编码错误: {0}")]
|
||||
EncodingError(String),
|
||||
|
||||
#[error("数据库错误: {0}")]
|
||||
DatabaseError(String),
|
||||
|
||||
#[error("内部错误: {0}")]
|
||||
InternalError(String),
|
||||
|
||||
#[error("IO错误: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
|
||||
#[error("序列化错误: {0}")]
|
||||
SerializationError(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
impl From<serde_json::Error> for Error {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
Error::SerializationError(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bincode::Error> for Error {
|
||||
fn from(e: bincode::Error) -> Self {
|
||||
Error::SerializationError(e.to_string())
|
||||
}
|
||||
}
|
||||
11
crates/common/src/lib.rs
Normal file
@ -0,0 +1,11 @@
|
||||
//! EasyRemote 共享库
|
||||
//! 包含协议定义、加密工具、通用类型等
|
||||
|
||||
pub mod protocol;
|
||||
pub mod crypto;
|
||||
pub mod types;
|
||||
pub mod error;
|
||||
pub mod utils;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use types::*;
|
||||
302
crates/common/src/protocol.rs
Normal file
@ -0,0 +1,302 @@
|
||||
//! 通信协议定义
|
||||
|
||||
use crate::types::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 消息类型标识
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
#[repr(u8)]
|
||||
pub enum MessageType {
|
||||
// 认证相关 (0x00 - 0x0F)
|
||||
AuthRequest = 0x00,
|
||||
AuthResponse = 0x01,
|
||||
AuthChallenge = 0x02,
|
||||
AuthVerify = 0x03,
|
||||
|
||||
// 信令相关 (0x10 - 0x1F)
|
||||
SignalOffer = 0x10,
|
||||
SignalAnswer = 0x11,
|
||||
SignalCandidate = 0x12,
|
||||
SignalReady = 0x13,
|
||||
|
||||
// 连接相关 (0x20 - 0x2F)
|
||||
ConnectRequest = 0x20,
|
||||
ConnectAccept = 0x21,
|
||||
ConnectReject = 0x22,
|
||||
Disconnect = 0x23,
|
||||
Heartbeat = 0x24,
|
||||
HeartbeatAck = 0x25,
|
||||
|
||||
// 数据传输 (0x30 - 0x3F)
|
||||
FrameData = 0x30,
|
||||
FrameAck = 0x31,
|
||||
InputEvent = 0x32,
|
||||
AudioData = 0x33,
|
||||
ClipboardData = 0x34,
|
||||
FileTransfer = 0x35,
|
||||
|
||||
// 控制相关 (0x40 - 0x4F)
|
||||
QualityRequest = 0x40,
|
||||
QualityResponse = 0x41,
|
||||
ScreenInfo = 0x42,
|
||||
SessionControl = 0x43,
|
||||
|
||||
// 错误 (0xF0 - 0xFF)
|
||||
Error = 0xF0,
|
||||
}
|
||||
|
||||
/// 协议消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProtocolMessage {
|
||||
/// 消息ID
|
||||
pub id: u64,
|
||||
/// 消息类型
|
||||
pub msg_type: MessageType,
|
||||
/// 消息负载
|
||||
pub payload: Vec<u8>,
|
||||
/// 时间戳
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
impl ProtocolMessage {
|
||||
pub fn new(msg_type: MessageType, payload: Vec<u8>) -> Self {
|
||||
use chrono::Utc;
|
||||
Self {
|
||||
id: rand::random(),
|
||||
msg_type,
|
||||
payload,
|
||||
timestamp: Utc::now().timestamp_millis(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> crate::Result<Vec<u8>> {
|
||||
bincode::serialize(self).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn decode(data: &[u8]) -> crate::Result<Self> {
|
||||
bincode::deserialize(data).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 认证消息 ====================
|
||||
|
||||
/// 认证请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthRequest {
|
||||
pub device_id: DeviceId,
|
||||
pub verification_code: String,
|
||||
}
|
||||
|
||||
/// 认证响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthResponse {
|
||||
pub success: bool,
|
||||
pub session_token: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
// ==================== 信令消息 ====================
|
||||
|
||||
/// SDP Offer
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SignalOffer {
|
||||
pub session_id: Uuid,
|
||||
pub from_device: DeviceId,
|
||||
pub to_device: DeviceId,
|
||||
pub sdp: String,
|
||||
}
|
||||
|
||||
/// SDP Answer
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SignalAnswer {
|
||||
pub session_id: Uuid,
|
||||
pub from_device: DeviceId,
|
||||
pub to_device: DeviceId,
|
||||
pub sdp: String,
|
||||
}
|
||||
|
||||
/// ICE Candidate
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SignalCandidate {
|
||||
pub session_id: Uuid,
|
||||
pub from_device: DeviceId,
|
||||
pub to_device: DeviceId,
|
||||
pub candidate: String,
|
||||
pub sdp_mid: Option<String>,
|
||||
pub sdp_mline_index: Option<u32>,
|
||||
}
|
||||
|
||||
// ==================== 连接消息 ====================
|
||||
|
||||
/// 连接请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectRequest {
|
||||
pub session_id: Uuid,
|
||||
pub from_device: DeviceId,
|
||||
pub to_device: DeviceId,
|
||||
pub verification_code: String,
|
||||
pub quality_settings: QualitySettings,
|
||||
}
|
||||
|
||||
/// 连接接受
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectAccept {
|
||||
pub session_id: Uuid,
|
||||
pub screen_width: u32,
|
||||
pub screen_height: u32,
|
||||
pub connection_type: ConnectionType,
|
||||
}
|
||||
|
||||
/// 连接拒绝
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectReject {
|
||||
pub session_id: Uuid,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
// ==================== 数据传输消息 ====================
|
||||
|
||||
/// 视频帧数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FrameData {
|
||||
/// 帧序号
|
||||
pub frame_id: u64,
|
||||
/// 是否为关键帧
|
||||
pub is_keyframe: bool,
|
||||
/// 帧宽度
|
||||
pub width: u32,
|
||||
/// 帧高度
|
||||
pub height: u32,
|
||||
/// 编码格式
|
||||
pub format: FrameFormat,
|
||||
/// 帧数据
|
||||
pub data: Vec<u8>,
|
||||
/// 时间戳
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
/// 帧编码格式
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
pub enum FrameFormat {
|
||||
/// JPEG
|
||||
Jpeg,
|
||||
/// VP8
|
||||
Vp8,
|
||||
/// VP9
|
||||
Vp9,
|
||||
/// H264
|
||||
H264,
|
||||
/// H265
|
||||
H265,
|
||||
/// Raw RGBA
|
||||
RawRgba,
|
||||
}
|
||||
|
||||
/// 输入事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum InputEvent {
|
||||
/// 鼠标移动
|
||||
MouseMove { x: i32, y: i32 },
|
||||
/// 鼠标按下
|
||||
MouseDown { button: MouseButton, x: i32, y: i32 },
|
||||
/// 鼠标释放
|
||||
MouseUp { button: MouseButton, x: i32, y: i32 },
|
||||
/// 鼠标滚轮
|
||||
MouseScroll { delta_x: i32, delta_y: i32 },
|
||||
/// 键盘按下
|
||||
KeyDown { key: String, modifiers: Modifiers },
|
||||
/// 键盘释放
|
||||
KeyUp { key: String, modifiers: Modifiers },
|
||||
/// 文本输入
|
||||
TextInput { text: String },
|
||||
}
|
||||
|
||||
/// 鼠标按钮
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
pub enum MouseButton {
|
||||
Left,
|
||||
Right,
|
||||
Middle,
|
||||
Back,
|
||||
Forward,
|
||||
}
|
||||
|
||||
/// 修饰键状态
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
pub struct Modifiers {
|
||||
pub ctrl: bool,
|
||||
pub alt: bool,
|
||||
pub shift: bool,
|
||||
pub meta: bool,
|
||||
}
|
||||
|
||||
/// 剪贴板数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClipboardData {
|
||||
pub format: ClipboardFormat,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// 剪贴板格式
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ClipboardFormat {
|
||||
Text,
|
||||
Html,
|
||||
Image,
|
||||
Files,
|
||||
}
|
||||
|
||||
// ==================== 控制消息 ====================
|
||||
|
||||
/// 质量调整请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QualityRequest {
|
||||
pub settings: QualitySettings,
|
||||
}
|
||||
|
||||
/// 屏幕信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScreenInfo {
|
||||
pub monitors: Vec<MonitorInfo>,
|
||||
pub primary_index: usize,
|
||||
}
|
||||
|
||||
/// 显示器信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MonitorInfo {
|
||||
pub index: usize,
|
||||
pub name: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub is_primary: bool,
|
||||
pub scale_factor: f32,
|
||||
}
|
||||
|
||||
/// 会话控制命令
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum SessionControl {
|
||||
/// 请求控制权
|
||||
RequestControl,
|
||||
/// 释放控制权
|
||||
ReleaseControl,
|
||||
/// 切换显示器
|
||||
SwitchMonitor { index: usize },
|
||||
/// 锁定屏幕
|
||||
LockScreen,
|
||||
/// 重启
|
||||
Restart,
|
||||
/// 关机
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
// ==================== 错误消息 ====================
|
||||
|
||||
/// 错误消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ErrorMessage {
|
||||
pub code: u32,
|
||||
pub message: String,
|
||||
}
|
||||
220
crates/common/src/types.rs
Normal file
@ -0,0 +1,220 @@
|
||||
//! 通用类型定义
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 设备ID (9位数字格式,如 732 940 272)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub struct DeviceId(pub String);
|
||||
|
||||
impl DeviceId {
|
||||
/// 生成新的设备ID
|
||||
pub fn generate() -> Self {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
let id: u32 = rng.gen_range(100_000_000..999_999_999);
|
||||
Self(format!("{}", id))
|
||||
}
|
||||
|
||||
/// 格式化显示 (如: 732 940 272)
|
||||
pub fn formatted(&self) -> String {
|
||||
let s = &self.0;
|
||||
if s.len() == 9 {
|
||||
format!("{} {} {}", &s[0..3], &s[3..6], &s[6..9])
|
||||
} else {
|
||||
s.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// 从字符串解析
|
||||
pub fn from_string(s: &str) -> Self {
|
||||
// 移除空格
|
||||
Self(s.replace(' ', ""))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DeviceId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.formatted())
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证码 (8位随机字符)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VerificationCode(pub String);
|
||||
|
||||
impl VerificationCode {
|
||||
/// 生成新的验证码
|
||||
pub fn generate() -> Self {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
let code: String = (0..8)
|
||||
.map(|_| {
|
||||
let idx = rng.gen_range(0..36);
|
||||
if idx < 10 {
|
||||
(b'0' + idx) as char
|
||||
} else {
|
||||
(b'a' + idx - 10) as char
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Self(code)
|
||||
}
|
||||
|
||||
/// 刷新验证码
|
||||
pub fn refresh(&mut self) {
|
||||
*self = Self::generate();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VerificationCode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_login: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 设备信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Device {
|
||||
pub id: Uuid,
|
||||
pub device_id: DeviceId,
|
||||
pub name: String,
|
||||
pub os_type: OsType,
|
||||
pub os_version: String,
|
||||
pub user_id: Option<Uuid>,
|
||||
pub is_online: bool,
|
||||
pub allow_remote: bool,
|
||||
pub last_seen: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 操作系统类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum OsType {
|
||||
Windows,
|
||||
MacOS,
|
||||
Linux,
|
||||
Android,
|
||||
IOS,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl OsType {
|
||||
pub fn current() -> Self {
|
||||
#[cfg(target_os = "windows")]
|
||||
return OsType::Windows;
|
||||
#[cfg(target_os = "macos")]
|
||||
return OsType::MacOS;
|
||||
#[cfg(target_os = "linux")]
|
||||
return OsType::Linux;
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
return OsType::Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// 连接会话
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: Uuid,
|
||||
pub controller_device_id: DeviceId,
|
||||
pub controlled_device_id: DeviceId,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub ended_at: Option<DateTime<Utc>>,
|
||||
pub connection_type: ConnectionType,
|
||||
pub status: SessionStatus,
|
||||
}
|
||||
|
||||
/// 连接类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ConnectionType {
|
||||
/// P2P直连
|
||||
P2P,
|
||||
/// 服务器中转
|
||||
Relay,
|
||||
}
|
||||
|
||||
/// 会话状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum SessionStatus {
|
||||
/// 连接中
|
||||
Connecting,
|
||||
/// 已连接
|
||||
Connected,
|
||||
/// 已断开
|
||||
Disconnected,
|
||||
/// 连接失败
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// 控制历史记录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ControlHistory {
|
||||
pub id: Uuid,
|
||||
pub user_id: Option<Uuid>,
|
||||
pub controller_device_id: DeviceId,
|
||||
pub controlled_device_id: DeviceId,
|
||||
pub controlled_device_name: String,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub ended_at: Option<DateTime<Utc>>,
|
||||
pub duration_seconds: Option<i64>,
|
||||
pub connection_type: ConnectionType,
|
||||
}
|
||||
|
||||
/// 质量设置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QualitySettings {
|
||||
/// 帧率 (fps)
|
||||
pub frame_rate: u32,
|
||||
/// 分辨率缩放比例 (0.25 - 1.0)
|
||||
pub resolution_scale: f32,
|
||||
/// 图像质量 (1-100)
|
||||
pub image_quality: u32,
|
||||
/// 是否启用硬件加速
|
||||
pub hardware_acceleration: bool,
|
||||
}
|
||||
|
||||
impl Default for QualitySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
frame_rate: 30,
|
||||
resolution_scale: 1.0,
|
||||
image_quality: 80,
|
||||
hardware_acceleration: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub device_id: DeviceId,
|
||||
}
|
||||
|
||||
/// 登录响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub user: User,
|
||||
pub devices: Vec<Device>,
|
||||
}
|
||||
|
||||
/// 注册请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
104
crates/common/src/utils.rs
Normal file
@ -0,0 +1,104 @@
|
||||
//! 工具函数
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// 获取系统信息
|
||||
pub fn get_system_info() -> SystemInfo {
|
||||
SystemInfo {
|
||||
os_name: std::env::consts::OS.to_string(),
|
||||
os_version: get_os_version(),
|
||||
hostname: get_hostname(),
|
||||
cpu_cores: num_cpus(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 系统信息
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SystemInfo {
|
||||
pub os_name: String,
|
||||
pub os_version: String,
|
||||
pub hostname: String,
|
||||
pub cpu_cores: usize,
|
||||
}
|
||||
|
||||
/// 获取操作系统版本
|
||||
fn get_os_version() -> String {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
"Windows 10/11".to_string()
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
"macOS".to_string()
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
"Linux".to_string()
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
"Unknown".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取主机名
|
||||
fn get_hostname() -> String {
|
||||
hostname::get()
|
||||
.map(|h| h.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| "Unknown".to_string())
|
||||
}
|
||||
|
||||
/// 获取CPU核心数
|
||||
fn num_cpus() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map(|p| p.get())
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// 格式化持续时间
|
||||
pub fn format_duration(seconds: i64) -> String {
|
||||
let hours = seconds / 3600;
|
||||
let minutes = (seconds % 3600) / 60;
|
||||
let secs = seconds % 60;
|
||||
|
||||
if hours > 0 {
|
||||
format!("{:02}:{:02}:{:02}", hours, minutes, secs)
|
||||
} else {
|
||||
format!("{:02}:{:02}", minutes, secs)
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化时间
|
||||
pub fn format_datetime(dt: &DateTime<Utc>) -> String {
|
||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
/// 格式化文件大小
|
||||
pub fn format_file_size(bytes: u64) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = KB * 1024;
|
||||
const GB: u64 = MB * 1024;
|
||||
|
||||
if bytes >= GB {
|
||||
format!("{:.2} GB", bytes as f64 / GB as f64)
|
||||
} else if bytes >= MB {
|
||||
format!("{:.2} MB", bytes as f64 / MB as f64)
|
||||
} else if bytes >= KB {
|
||||
format!("{:.2} KB", bytes as f64 / KB as f64)
|
||||
} else {
|
||||
format!("{} B", bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成随机字符串
|
||||
pub fn random_string(len: usize) -> String {
|
||||
use rand::Rng;
|
||||
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..len)
|
||||
.map(|_| {
|
||||
let idx = rng.gen_range(0..CHARSET.len());
|
||||
CHARSET[idx] as char
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
55
crates/server/Cargo.toml
Normal file
@ -0,0 +1,55 @@
|
||||
[package]
|
||||
name = "easyremote-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "easyremote-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
easyremote-common = { path = "../common" }
|
||||
|
||||
# Async
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
# Web
|
||||
axum = { workspace = true }
|
||||
tower = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
|
||||
# Database
|
||||
sqlx = { workspace = true }
|
||||
|
||||
# Auth
|
||||
jsonwebtoken = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
# Networking
|
||||
quinn = { workspace = true }
|
||||
rustls = { workspace = true }
|
||||
rcgen = { workspace = true }
|
||||
stun = { workspace = true }
|
||||
|
||||
# Logging
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
# Utils
|
||||
chrono = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
config = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
hex = "0.4"
|
||||
177
crates/server/src/config.rs
Normal file
@ -0,0 +1,177 @@
|
||||
//! 服务端配置
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 默认 STUN 端口
|
||||
pub const DEFAULT_STUN_PORT: u16 = 3478;
|
||||
/// 默认 TURN 端口
|
||||
pub const DEFAULT_TURN_PORT: u16 = 3479;
|
||||
/// 默认 TURN 用户名
|
||||
pub const DEFAULT_TURN_USERNAME: &str = "easyremote";
|
||||
/// 默认 TURN 密码
|
||||
pub const DEFAULT_TURN_PASSWORD: &str = "easyremote123";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Config {
|
||||
/// 服务器主机
|
||||
pub host: String,
|
||||
/// 服务器端口
|
||||
pub port: u16,
|
||||
/// STUN 服务器端口
|
||||
pub stun_port: u16,
|
||||
/// TURN 服务器端口
|
||||
pub turn_port: u16,
|
||||
/// 公网 IP(用于生成 STUN/TURN URL)
|
||||
pub public_ip: Option<String>,
|
||||
/// 数据库URL
|
||||
pub database_url: String,
|
||||
/// JWT密钥
|
||||
pub jwt_secret: String,
|
||||
/// JWT过期时间(秒)
|
||||
pub jwt_expiry: i64,
|
||||
/// STUN服务器地址列表
|
||||
pub stun_servers: Vec<String>,
|
||||
/// 是否启用本地 STUN 服务
|
||||
pub enable_local_stun: bool,
|
||||
/// 是否启用本地 TURN 服务
|
||||
pub enable_local_turn: bool,
|
||||
/// TURN服务器地址(如果不使用本地)
|
||||
pub turn_server: Option<String>,
|
||||
/// TURN用户名
|
||||
pub turn_username: String,
|
||||
/// TURN密码
|
||||
pub turn_password: String,
|
||||
/// TURN Realm
|
||||
pub turn_realm: String,
|
||||
}
|
||||
|
||||
/// ICE 服务器配置(返回给客户端)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct IceServersConfig {
|
||||
pub stun_servers: Vec<String>,
|
||||
pub turn_server: Option<TurnConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TurnConfig {
|
||||
pub url: String,
|
||||
pub username: String,
|
||||
pub credential: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let enable_local_stun = std::env::var("ENABLE_LOCAL_STUN")
|
||||
.unwrap_or_else(|_| "true".to_string())
|
||||
.parse()
|
||||
.unwrap_or(true);
|
||||
|
||||
let enable_local_turn = std::env::var("ENABLE_LOCAL_TURN")
|
||||
.unwrap_or_else(|_| "true".to_string())
|
||||
.parse()
|
||||
.unwrap_or(true);
|
||||
|
||||
let stun_port: u16 = std::env::var("STUN_PORT")
|
||||
.unwrap_or_else(|_| DEFAULT_STUN_PORT.to_string())
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_STUN_PORT);
|
||||
|
||||
let turn_port: u16 = std::env::var("TURN_PORT")
|
||||
.unwrap_or_else(|_| DEFAULT_TURN_PORT.to_string())
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_TURN_PORT);
|
||||
|
||||
let public_ip = std::env::var("PUBLIC_IP").ok();
|
||||
|
||||
let turn_username = std::env::var("TURN_USERNAME")
|
||||
.unwrap_or_else(|_| DEFAULT_TURN_USERNAME.to_string());
|
||||
let turn_password = std::env::var("TURN_PASSWORD")
|
||||
.unwrap_or_else(|_| DEFAULT_TURN_PASSWORD.to_string());
|
||||
let turn_realm = std::env::var("TURN_REALM")
|
||||
.unwrap_or_else(|_| "easyremote".to_string());
|
||||
|
||||
// 构建 STUN 服务器列表
|
||||
let mut stun_servers: Vec<String> = Vec::new();
|
||||
|
||||
// 如果启用本地 STUN,总是添加到列表最前面
|
||||
if enable_local_stun {
|
||||
let host = public_ip.as_deref().unwrap_or("localhost");
|
||||
stun_servers.push(format!("stun:{}:{}", host, stun_port));
|
||||
}
|
||||
|
||||
// 如果设置了额外的 STUN 服务器,添加到列表
|
||||
if let Ok(extra_stun) = std::env::var("STUN_SERVERS") {
|
||||
for server in extra_stun.split(',') {
|
||||
let s = server.trim().to_string();
|
||||
if !s.is_empty() && !stun_servers.contains(&s) {
|
||||
stun_servers.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果列表为空,添加 Google STUN 作为备用
|
||||
if stun_servers.is_empty() {
|
||||
stun_servers.push("stun:stun.l.google.com:19302".to_string());
|
||||
stun_servers.push("stun:stun1.l.google.com:19302".to_string());
|
||||
}
|
||||
|
||||
// TURN 服务器配置
|
||||
let turn_server = if enable_local_turn {
|
||||
None // 使用本地 TURN,URL 在 get_ice_servers 中动态生成
|
||||
} else {
|
||||
std::env::var("TURN_SERVER").ok()
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
|
||||
port: std::env::var("PORT")
|
||||
.unwrap_or_else(|_| "8080".to_string())
|
||||
.parse()?,
|
||||
stun_port,
|
||||
turn_port,
|
||||
public_ip,
|
||||
database_url: std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "sqlite:easyremote.db?mode=rwc".to_string()),
|
||||
jwt_secret: std::env::var("JWT_SECRET")
|
||||
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string()),
|
||||
jwt_expiry: std::env::var("JWT_EXPIRY")
|
||||
.unwrap_or_else(|_| "86400".to_string())
|
||||
.parse()?,
|
||||
stun_servers,
|
||||
enable_local_stun,
|
||||
enable_local_turn,
|
||||
turn_server,
|
||||
turn_username,
|
||||
turn_password,
|
||||
turn_realm,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 ICE 服务器配置
|
||||
pub fn get_ice_servers(&self) -> IceServersConfig {
|
||||
let turn_server = if self.enable_local_turn {
|
||||
// 使用本地 TURN 服务器
|
||||
let host = self.public_ip.as_deref().unwrap_or("localhost");
|
||||
Some(TurnConfig {
|
||||
url: format!("turn:{}:{}", host, self.turn_port),
|
||||
username: self.turn_username.clone(),
|
||||
credential: self.turn_password.clone(),
|
||||
})
|
||||
} else if let Some(url) = &self.turn_server {
|
||||
// 使用外部 TURN 服务器
|
||||
Some(TurnConfig {
|
||||
url: url.clone(),
|
||||
username: self.turn_username.clone(),
|
||||
credential: self.turn_password.clone(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
IceServersConfig {
|
||||
stun_servers: self.stun_servers.clone(),
|
||||
turn_server,
|
||||
}
|
||||
}
|
||||
}
|
||||
120
crates/server/src/db.rs
Normal file
@ -0,0 +1,120 @@
|
||||
//! 数据库管理
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite};
|
||||
|
||||
pub type DbPool = Pool<Sqlite>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Database {
|
||||
pub pool: DbPool,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub async fn new(database_url: &str) -> Result<Self> {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(10)
|
||||
.connect(database_url)
|
||||
.await?;
|
||||
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
pub async fn migrate(&self) -> Result<()> {
|
||||
// 创建用户表
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
salt TEXT NOT NULL,
|
||||
email TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT NOT NULL,
|
||||
last_login TEXT
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
// 创建设备表
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
device_id TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
os_type TEXT NOT NULL,
|
||||
os_version TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
is_online INTEGER NOT NULL DEFAULT 0,
|
||||
allow_remote INTEGER NOT NULL DEFAULT 0,
|
||||
verification_code TEXT,
|
||||
last_seen TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
// 创建会话表
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
controller_device_id TEXT NOT NULL,
|
||||
controlled_device_id TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT,
|
||||
connection_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
FOREIGN KEY (controller_device_id) REFERENCES devices(device_id),
|
||||
FOREIGN KEY (controlled_device_id) REFERENCES devices(device_id)
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
// 创建控制历史表
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS control_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
controller_device_id TEXT NOT NULL,
|
||||
controlled_device_id TEXT NOT NULL,
|
||||
controlled_device_name TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT,
|
||||
duration_seconds INTEGER,
|
||||
connection_type TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
// 创建索引
|
||||
sqlx::query("CREATE INDEX IF NOT EXISTS idx_devices_user_id ON devices(user_id)")
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query("CREATE INDEX IF NOT EXISTS idx_devices_device_id ON devices(device_id)")
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query("CREATE INDEX IF NOT EXISTS idx_sessions_controller ON sessions(controller_device_id)")
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query("CREATE INDEX IF NOT EXISTS idx_history_user_id ON control_history(user_id)")
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
tracing::info!("Database migrations completed");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
275
crates/server/src/handlers/admin.rs
Normal file
@ -0,0 +1,275 @@
|
||||
//! 管理员处理器
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::models::{
|
||||
ApiResponse, DeviceResponse, PaginatedResponse, PaginationParams, SessionResponse,
|
||||
StatsResponse, UserResponse,
|
||||
};
|
||||
use crate::services::{
|
||||
auth::UserRepository,
|
||||
device::DeviceRepository,
|
||||
session::SessionRepository,
|
||||
AppState,
|
||||
};
|
||||
use super::{api_error, AdminUser};
|
||||
|
||||
/// 服务器配置响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ServerConfigResponse {
|
||||
pub version: String,
|
||||
pub http_port: u16,
|
||||
pub stun_port: u16,
|
||||
pub stun_enabled: bool,
|
||||
pub stun_servers: Vec<String>,
|
||||
pub turn_port: u16,
|
||||
pub turn_enabled: bool,
|
||||
pub turn_server: Option<String>,
|
||||
pub turn_username: String,
|
||||
pub turn_realm: String,
|
||||
pub jwt_expiry_hours: i64,
|
||||
pub database_type: String,
|
||||
}
|
||||
|
||||
/// 获取所有用户
|
||||
pub async fn list_users(
|
||||
State(state): State<Arc<AppState>>,
|
||||
_admin: AdminUser,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> impl IntoResponse {
|
||||
let repo = UserRepository::new(&state.db);
|
||||
|
||||
match repo.find_all(params.offset(), params.limit()).await {
|
||||
Ok((users, total)) => {
|
||||
let items: Vec<UserResponse> = users.into_iter().map(Into::into).collect();
|
||||
let total_pages = ((total as f64) / (params.limit() as f64)).ceil() as u32;
|
||||
|
||||
let response = PaginatedResponse {
|
||||
items,
|
||||
total,
|
||||
page: params.page.unwrap_or(1),
|
||||
limit: params.limit(),
|
||||
total_pages,
|
||||
};
|
||||
|
||||
(StatusCode::OK, Json(ApiResponse::ok(response))).into_response()
|
||||
}
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除用户
|
||||
pub async fn delete_user(
|
||||
State(state): State<Arc<AppState>>,
|
||||
_admin: AdminUser,
|
||||
Path(user_id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let repo = UserRepository::new(&state.db);
|
||||
|
||||
match repo.delete(&user_id).await {
|
||||
Ok(_) => (StatusCode::OK, Json(ApiResponse::ok("用户已删除"))).into_response(),
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有设备(包含绑定用户信息)
|
||||
pub async fn list_all_devices(
|
||||
State(state): State<Arc<AppState>>,
|
||||
_admin: AdminUser,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> impl IntoResponse {
|
||||
let repo = DeviceRepository::new(&state.db);
|
||||
|
||||
match repo.find_all_with_username(params.offset(), params.limit()).await {
|
||||
Ok((devices, total)) => {
|
||||
let items: Vec<DeviceResponse> = devices.into_iter().map(Into::into).collect();
|
||||
let total_pages = ((total as f64) / (params.limit() as f64)).ceil() as u32;
|
||||
|
||||
let response = PaginatedResponse {
|
||||
items,
|
||||
total,
|
||||
page: params.page.unwrap_or(1),
|
||||
limit: params.limit(),
|
||||
total_pages,
|
||||
};
|
||||
|
||||
(StatusCode::OK, Json(ApiResponse::ok(response))).into_response()
|
||||
}
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有会话
|
||||
pub async fn list_all_sessions(
|
||||
State(state): State<Arc<AppState>>,
|
||||
_admin: AdminUser,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> impl IntoResponse {
|
||||
let repo = SessionRepository::new(&state.db);
|
||||
|
||||
match repo.find_all(params.offset(), params.limit()).await {
|
||||
Ok((sessions, total)) => {
|
||||
let items: Vec<SessionResponse> = sessions.into_iter().map(Into::into).collect();
|
||||
let total_pages = ((total as f64) / (params.limit() as f64)).ceil() as u32;
|
||||
|
||||
let response = PaginatedResponse {
|
||||
items,
|
||||
total,
|
||||
page: params.page.unwrap_or(1),
|
||||
limit: params.limit(),
|
||||
total_pages,
|
||||
};
|
||||
|
||||
(StatusCode::OK, Json(ApiResponse::ok(response))).into_response()
|
||||
}
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取统计信息
|
||||
pub async fn get_stats(
|
||||
State(state): State<Arc<AppState>>,
|
||||
_admin: AdminUser,
|
||||
) -> impl IntoResponse {
|
||||
let user_repo = UserRepository::new(&state.db);
|
||||
let device_repo = DeviceRepository::new(&state.db);
|
||||
let session_repo = SessionRepository::new(&state.db);
|
||||
|
||||
let total_users = user_repo.find_all(0, 1).await.map(|(_, t)| t).unwrap_or(0);
|
||||
let (_, total_devices) = device_repo.find_all(0, 1).await.unwrap_or((vec![], 0));
|
||||
let online_devices = device_repo.count_online().await.unwrap_or(0);
|
||||
let active_sessions = session_repo.count_active().await.unwrap_or(0);
|
||||
let (_, total_sessions) = session_repo.find_all(0, 1).await.unwrap_or((vec![], 0));
|
||||
|
||||
let response = StatsResponse {
|
||||
total_users,
|
||||
total_devices,
|
||||
online_devices,
|
||||
active_sessions,
|
||||
total_sessions,
|
||||
};
|
||||
|
||||
(StatusCode::OK, Json(ApiResponse::ok(response))).into_response()
|
||||
}
|
||||
|
||||
/// 获取服务器配置
|
||||
pub async fn get_server_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
_admin: AdminUser,
|
||||
) -> impl IntoResponse {
|
||||
let config = &state.config;
|
||||
|
||||
// 获取 TURN 服务器地址
|
||||
let turn_server = if config.enable_local_turn {
|
||||
let host = config.public_ip.as_deref().unwrap_or("localhost");
|
||||
Some(format!("turn:{}:{}", host, config.turn_port))
|
||||
} else {
|
||||
config.turn_server.clone()
|
||||
};
|
||||
|
||||
let response = ServerConfigResponse {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
http_port: config.port,
|
||||
stun_port: config.stun_port,
|
||||
stun_enabled: config.enable_local_stun,
|
||||
stun_servers: config.stun_servers.clone(),
|
||||
turn_port: config.turn_port,
|
||||
turn_enabled: config.enable_local_turn,
|
||||
turn_server,
|
||||
turn_username: config.turn_username.clone(),
|
||||
turn_realm: config.turn_realm.clone(),
|
||||
jwt_expiry_hours: config.jwt_expiry / 3600,
|
||||
database_type: "SQLite".to_string(),
|
||||
};
|
||||
|
||||
(StatusCode::OK, Json(ApiResponse::ok(response))).into_response()
|
||||
}
|
||||
|
||||
/// 环境配置响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EnvConfigResponse {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// 环境配置请求
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct EnvConfigRequest {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// 获取环境配置文件
|
||||
pub async fn get_env_config(
|
||||
_admin: AdminUser,
|
||||
) -> impl IntoResponse {
|
||||
let env_path = std::path::Path::new(".env");
|
||||
|
||||
let content = if env_path.exists() {
|
||||
std::fs::read_to_string(env_path).unwrap_or_default()
|
||||
} else {
|
||||
// 返回默认配置模板
|
||||
generate_default_env_config()
|
||||
};
|
||||
|
||||
let response = EnvConfigResponse { content };
|
||||
(StatusCode::OK, Json(ApiResponse::ok(response))).into_response()
|
||||
}
|
||||
|
||||
/// 保存环境配置文件
|
||||
pub async fn save_env_config(
|
||||
_admin: AdminUser,
|
||||
Json(req): Json<EnvConfigRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let env_path = std::path::Path::new(".env");
|
||||
|
||||
match std::fs::write(env_path, &req.content) {
|
||||
Ok(_) => {
|
||||
tracing::info!("Environment config saved");
|
||||
(StatusCode::OK, Json(ApiResponse::ok("配置已保存"))).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to save env config: {}", e);
|
||||
api_error(StatusCode::INTERNAL_SERVER_ERROR, format!("保存失败: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate default environment config
|
||||
fn generate_default_env_config() -> String {
|
||||
r#"# EasyRemote Server Configuration
|
||||
|
||||
# Server Settings
|
||||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
|
||||
# STUN Server Settings
|
||||
ENABLE_LOCAL_STUN=true
|
||||
STUN_PORT=3478
|
||||
|
||||
# TURN Server Settings
|
||||
ENABLE_LOCAL_TURN=true
|
||||
TURN_PORT=3479
|
||||
TURN_USERNAME=easyremote
|
||||
TURN_PASSWORD=easyremote123
|
||||
TURN_REALM=easyremote
|
||||
|
||||
# Public IP (optional, for generating correct STUN/TURN URLs)
|
||||
# PUBLIC_IP=your.public.ip
|
||||
|
||||
# Database Settings
|
||||
DATABASE_URL=sqlite:easyremote.db?mode=rwc
|
||||
|
||||
# JWT Settings
|
||||
# JWT_SECRET=your-secret-key-here
|
||||
JWT_EXPIRY=86400
|
||||
|
||||
# Log Level
|
||||
RUST_LOG=info,tower_http=debug
|
||||
"#.to_string()
|
||||
}
|
||||
141
crates/server/src/handlers/auth.rs
Normal file
@ -0,0 +1,141 @@
|
||||
//! 认证处理器
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::models::{
|
||||
ApiResponse, AuthResponse, LoginRequest, RegisterRequest,
|
||||
};
|
||||
use crate::services::{
|
||||
auth::{AuthService, UserRepository},
|
||||
device::DeviceRepository,
|
||||
AppState,
|
||||
};
|
||||
use super::{api_error, AuthUser};
|
||||
|
||||
/// 用户注册
|
||||
pub async fn register(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> impl IntoResponse {
|
||||
// 验证输入
|
||||
if req.username.len() < 3 || req.username.len() > 50 {
|
||||
return api_error(StatusCode::BAD_REQUEST, "用户名长度必须在3-50之间");
|
||||
}
|
||||
if req.password.len() < 6 {
|
||||
return api_error(StatusCode::BAD_REQUEST, "密码长度至少6位");
|
||||
}
|
||||
|
||||
let repo = UserRepository::new(&state.db);
|
||||
|
||||
// 检查用户名是否已存在
|
||||
if let Ok(Some(_)) = repo.find_by_username(&req.username).await {
|
||||
return api_error(StatusCode::CONFLICT, "用户名已存在");
|
||||
}
|
||||
|
||||
// 哈希密码
|
||||
let (hash, salt) = AuthService::hash_password(&req.password);
|
||||
|
||||
// 创建用户
|
||||
match repo.create(&req.username, &hash, &salt, req.email.as_deref()).await {
|
||||
Ok(user) => {
|
||||
let auth_service = AuthService::new(&state.config);
|
||||
match auth_service.generate_token(&user) {
|
||||
Ok(token) => (
|
||||
StatusCode::CREATED,
|
||||
Json(ApiResponse::ok(AuthResponse {
|
||||
token,
|
||||
user: user.into(),
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
}
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户登录
|
||||
pub async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let repo = UserRepository::new(&state.db);
|
||||
|
||||
// 查找用户
|
||||
let user = match repo.find_by_username(&req.username).await {
|
||||
Ok(Some(user)) => user,
|
||||
Ok(None) => return api_error(StatusCode::UNAUTHORIZED, "用户名或密码错误"),
|
||||
Err(e) => return api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
};
|
||||
|
||||
// 验证密码
|
||||
if !AuthService::verify_password(&req.password, &user.password_hash) {
|
||||
return api_error(StatusCode::UNAUTHORIZED, "用户名或密码错误");
|
||||
}
|
||||
|
||||
// 更新最后登录时间
|
||||
let _ = repo.update_last_login(&user.id).await;
|
||||
|
||||
// 如果提供了设备ID,绑定设备到用户
|
||||
if let Some(device_id) = &req.device_id {
|
||||
let device_repo = DeviceRepository::new(&state.db);
|
||||
let _ = device_repo.bind_to_user(device_id, &user.id).await;
|
||||
}
|
||||
|
||||
// 生成令牌
|
||||
let auth_service = AuthService::new(&state.config);
|
||||
match auth_service.generate_token(&user) {
|
||||
Ok(token) => (
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse::ok(AuthResponse {
|
||||
token,
|
||||
user: user.into(),
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 退出登录
|
||||
pub async fn logout(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
_user: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
// JWT 是无状态的,客户端只需删除本地令牌
|
||||
// 如果需要服务端失效令牌,可以使用令牌黑名单
|
||||
(StatusCode::OK, Json(ApiResponse::ok("已退出登录"))).into_response()
|
||||
}
|
||||
|
||||
/// 刷新令牌
|
||||
pub async fn refresh_token(
|
||||
State(state): State<Arc<AppState>>,
|
||||
user: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
let repo = UserRepository::new(&state.db);
|
||||
|
||||
match repo.find_by_id(&user.user_id).await {
|
||||
Ok(user_row) => {
|
||||
let auth_service = AuthService::new(&state.config);
|
||||
match auth_service.generate_token(&user_row) {
|
||||
Ok(token) => (
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse::ok(AuthResponse {
|
||||
token,
|
||||
user: user_row.into(),
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
}
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
}
|
||||
148
crates/server/src/handlers/devices.rs
Normal file
@ -0,0 +1,148 @@
|
||||
//! 设备处理器
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::models::{ApiResponse, DeviceRegisterRequest, DeviceResponse};
|
||||
use crate::services::{device::DeviceRepository, AppState};
|
||||
use super::{api_error, AuthUser, OptionalAuthUser};
|
||||
|
||||
/// 注册设备
|
||||
pub async fn register_device(
|
||||
State(state): State<Arc<AppState>>,
|
||||
OptionalAuthUser(auth_user): OptionalAuthUser,
|
||||
Json(req): Json<DeviceRegisterRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let repo = DeviceRepository::new(&state.db);
|
||||
|
||||
match repo.register(&req.device_id, &req.name, &req.os_type, &req.os_version).await {
|
||||
Ok(device) => {
|
||||
// 如果用户已登录,绑定设备到用户
|
||||
if let Some(ref user) = auth_user {
|
||||
tracing::info!("Binding device {} to user {}", req.device_id, user.user_id);
|
||||
match repo.bind_to_user(&req.device_id, &user.user_id).await {
|
||||
Ok(_) => tracing::info!("Device {} bound to user {} successfully", req.device_id, user.user_id),
|
||||
Err(e) => tracing::error!("Failed to bind device: {}", e),
|
||||
}
|
||||
} else {
|
||||
tracing::info!("Device {} registered without user (no auth token)", req.device_id);
|
||||
}
|
||||
|
||||
// 重新获取设备信息(包含绑定用户)
|
||||
let updated_device = repo.find_by_device_id(&req.device_id).await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(device);
|
||||
|
||||
let response: DeviceResponse = updated_device.into();
|
||||
(StatusCode::CREATED, Json(ApiResponse::ok(response))).into_response()
|
||||
}
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前用户的设备列表
|
||||
pub async fn list_devices(
|
||||
State(state): State<Arc<AppState>>,
|
||||
user: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
let repo = DeviceRepository::new(&state.db);
|
||||
|
||||
match repo.find_by_user(&user.user_id).await {
|
||||
Ok(devices) => {
|
||||
let response: Vec<DeviceResponse> = devices.into_iter().map(Into::into).collect();
|
||||
(StatusCode::OK, Json(ApiResponse::ok(response))).into_response()
|
||||
}
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取单个设备
|
||||
pub async fn get_device(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(device_id): Path<String>,
|
||||
user: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
let repo = DeviceRepository::new(&state.db);
|
||||
|
||||
match repo.find_by_device_id(&device_id).await {
|
||||
Ok(Some(device)) => {
|
||||
// 验证设备属于当前用户
|
||||
if device.user_id.as_ref() != Some(&user.user_id) && user.role != "admin" {
|
||||
return api_error(StatusCode::FORBIDDEN, "无权访问此设备");
|
||||
}
|
||||
|
||||
let response: DeviceResponse = device.into();
|
||||
(StatusCode::OK, Json(ApiResponse::ok(response))).into_response()
|
||||
}
|
||||
Ok(None) => api_error(StatusCode::NOT_FOUND, "设备不存在"),
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 移除设备
|
||||
pub async fn remove_device(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(device_id): Path<String>,
|
||||
user: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
let repo = DeviceRepository::new(&state.db);
|
||||
|
||||
// 验证设备属于当前用户
|
||||
match repo.find_by_device_id(&device_id).await {
|
||||
Ok(Some(device)) => {
|
||||
if device.user_id.as_ref() != Some(&user.user_id) && user.role != "admin" {
|
||||
return api_error(StatusCode::FORBIDDEN, "无权操作此设备");
|
||||
}
|
||||
}
|
||||
Ok(None) => return api_error(StatusCode::NOT_FOUND, "设备不存在"),
|
||||
Err(e) => return api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
|
||||
// 解绑设备
|
||||
match repo.unbind(&device_id).await {
|
||||
Ok(_) => (StatusCode::OK, Json(ApiResponse::ok("设备已移除"))).into_response(),
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 强制设备下线
|
||||
pub async fn force_offline(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(device_id): Path<String>,
|
||||
user: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
let repo = DeviceRepository::new(&state.db);
|
||||
|
||||
// 验证设备属于当前用户
|
||||
match repo.find_by_device_id(&device_id).await {
|
||||
Ok(Some(device)) => {
|
||||
if device.user_id.as_ref() != Some(&user.user_id) && user.role != "admin" {
|
||||
return api_error(StatusCode::FORBIDDEN, "无权操作此设备");
|
||||
}
|
||||
}
|
||||
Ok(None) => return api_error(StatusCode::NOT_FOUND, "设备不存在"),
|
||||
Err(e) => return api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
|
||||
// 发送下线命令
|
||||
let message = serde_json::json!({
|
||||
"type": "force_offline",
|
||||
"device_id": device_id,
|
||||
});
|
||||
|
||||
if state.send_to_device(&device_id, &message.to_string()).await {
|
||||
// 更新数据库状态
|
||||
let _ = repo.set_online(&device_id, false).await;
|
||||
(StatusCode::OK, Json(ApiResponse::ok("设备已下线"))).into_response()
|
||||
} else {
|
||||
// 设备可能已经离线
|
||||
let _ = repo.set_online(&device_id, false).await;
|
||||
(StatusCode::OK, Json(ApiResponse::ok("设备已下线"))).into_response()
|
||||
}
|
||||
}
|
||||
110
crates/server/src/handlers/mod.rs
Normal file
@ -0,0 +1,110 @@
|
||||
//! API 处理器
|
||||
|
||||
pub mod auth;
|
||||
pub mod users;
|
||||
pub mod devices;
|
||||
pub mod sessions;
|
||||
pub mod admin;
|
||||
pub mod setup;
|
||||
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{request::Parts, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use crate::models::ApiResponse;
|
||||
use crate::services::AppState;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// 认证用户提取器
|
||||
pub struct AuthUser {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[axum::async_trait]
|
||||
impl FromRequestParts<Arc<AppState>> for AuthUser {
|
||||
type Rejection = AuthError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &Arc<AppState>) -> Result<Self, Self::Rejection> {
|
||||
// 从 Authorization header 获取 token
|
||||
let auth_header = parts
|
||||
.headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or(AuthError::MissingToken)?;
|
||||
|
||||
// 解析 Bearer token
|
||||
let token = auth_header
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or(AuthError::InvalidToken)?;
|
||||
|
||||
// 验证 token
|
||||
let auth_service = crate::services::auth::AuthService::new(&state.config);
|
||||
let claims = auth_service
|
||||
.verify_token(token)
|
||||
.map_err(|_| AuthError::InvalidToken)?;
|
||||
|
||||
Ok(AuthUser {
|
||||
user_id: claims.sub,
|
||||
username: claims.username,
|
||||
role: claims.role,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 可选认证用户提取器
|
||||
pub struct OptionalAuthUser(pub Option<AuthUser>);
|
||||
|
||||
#[axum::async_trait]
|
||||
impl FromRequestParts<Arc<AppState>> for OptionalAuthUser {
|
||||
type Rejection = std::convert::Infallible;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &Arc<AppState>) -> Result<Self, Self::Rejection> {
|
||||
Ok(OptionalAuthUser(
|
||||
AuthUser::from_request_parts(parts, state).await.ok()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理员用户提取器
|
||||
pub struct AdminUser(pub AuthUser);
|
||||
|
||||
#[axum::async_trait]
|
||||
impl FromRequestParts<Arc<AppState>> for AdminUser {
|
||||
type Rejection = AuthError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &Arc<AppState>) -> Result<Self, Self::Rejection> {
|
||||
let user = AuthUser::from_request_parts(parts, state).await?;
|
||||
if user.role != "admin" {
|
||||
return Err(AuthError::Forbidden);
|
||||
}
|
||||
Ok(AdminUser(user))
|
||||
}
|
||||
}
|
||||
|
||||
/// 认证错误
|
||||
pub enum AuthError {
|
||||
MissingToken,
|
||||
InvalidToken,
|
||||
Forbidden,
|
||||
}
|
||||
|
||||
impl IntoResponse for AuthError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match self {
|
||||
AuthError::MissingToken => (StatusCode::UNAUTHORIZED, "缺少认证令牌"),
|
||||
AuthError::InvalidToken => (StatusCode::UNAUTHORIZED, "无效的认证令牌"),
|
||||
AuthError::Forbidden => (StatusCode::FORBIDDEN, "权限不足"),
|
||||
};
|
||||
|
||||
(status, Json(ApiResponse::<()>::err(message))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// API 错误处理
|
||||
pub fn api_error(status: StatusCode, message: impl ToString) -> Response {
|
||||
(status, Json(ApiResponse::<()>::err(message))).into_response()
|
||||
}
|
||||
141
crates/server/src/handlers/sessions.rs
Normal file
@ -0,0 +1,141 @@
|
||||
//! 会话处理器
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::models::{ApiResponse, HistoryResponse, PaginatedResponse, PaginationParams, SessionResponse};
|
||||
use crate::services::{
|
||||
device::DeviceRepository,
|
||||
session::{HistoryRepository, SessionRepository},
|
||||
AppState,
|
||||
};
|
||||
use super::{api_error, AuthUser};
|
||||
|
||||
/// 获取活跃会话列表
|
||||
pub async fn list_sessions(
|
||||
State(state): State<Arc<AppState>>,
|
||||
user: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
let session_repo = SessionRepository::new(&state.db);
|
||||
let device_repo = DeviceRepository::new(&state.db);
|
||||
|
||||
// 获取用户的设备
|
||||
let devices = match device_repo.find_by_user(&user.user_id).await {
|
||||
Ok(devices) => devices,
|
||||
Err(e) => return api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
};
|
||||
|
||||
let device_ids: Vec<&str> = devices.iter().map(|d| d.device_id.as_str()).collect();
|
||||
|
||||
// 获取活跃会话
|
||||
match session_repo.find_active().await {
|
||||
Ok(sessions) => {
|
||||
// 过滤出与用户设备相关的会话
|
||||
let filtered: Vec<SessionResponse> = sessions
|
||||
.into_iter()
|
||||
.filter(|s| {
|
||||
device_ids.contains(&s.controller_device_id.as_str())
|
||||
|| device_ids.contains(&s.controlled_device_id.as_str())
|
||||
})
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
|
||||
(StatusCode::OK, Json(ApiResponse::ok(filtered))).into_response()
|
||||
}
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取控制历史记录
|
||||
pub async fn get_history(
|
||||
State(state): State<Arc<AppState>>,
|
||||
user: AuthUser,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> impl IntoResponse {
|
||||
let repo = HistoryRepository::new(&state.db);
|
||||
|
||||
match repo.find_by_user(&user.user_id, params.offset(), params.limit()).await {
|
||||
Ok((history, total)) => {
|
||||
let items: Vec<HistoryResponse> = history.into_iter().map(Into::into).collect();
|
||||
let total_pages = ((total as f64) / (params.limit() as f64)).ceil() as u32;
|
||||
|
||||
let response = PaginatedResponse {
|
||||
items,
|
||||
total,
|
||||
page: params.page.unwrap_or(1),
|
||||
limit: params.limit(),
|
||||
total_pages,
|
||||
};
|
||||
|
||||
(StatusCode::OK, Json(ApiResponse::ok(response))).into_response()
|
||||
}
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取单个会话
|
||||
pub async fn get_session(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(session_id): Path<String>,
|
||||
_user: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
let repo = SessionRepository::new(&state.db);
|
||||
|
||||
match repo.find_by_id(&session_id).await {
|
||||
Ok(session) => {
|
||||
let response: SessionResponse = session.into();
|
||||
(StatusCode::OK, Json(ApiResponse::ok(response))).into_response()
|
||||
}
|
||||
Err(_) => api_error(StatusCode::NOT_FOUND, "会话不存在"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 结束会话
|
||||
pub async fn end_session(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(session_id): Path<String>,
|
||||
user: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
let session_repo = SessionRepository::new(&state.db);
|
||||
let device_repo = DeviceRepository::new(&state.db);
|
||||
|
||||
// 获取会话
|
||||
let session = match session_repo.find_by_id(&session_id).await {
|
||||
Ok(session) => session,
|
||||
Err(_) => return api_error(StatusCode::NOT_FOUND, "会话不存在"),
|
||||
};
|
||||
|
||||
// 验证用户有权结束会话
|
||||
let user_devices = match device_repo.find_by_user(&user.user_id).await {
|
||||
Ok(devices) => devices,
|
||||
Err(e) => return api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
};
|
||||
|
||||
let device_ids: Vec<&str> = user_devices.iter().map(|d| d.device_id.as_str()).collect();
|
||||
let is_owner = device_ids.contains(&session.controller_device_id.as_str())
|
||||
|| device_ids.contains(&session.controlled_device_id.as_str());
|
||||
|
||||
if !is_owner && user.role != "admin" {
|
||||
return api_error(StatusCode::FORBIDDEN, "无权结束此会话");
|
||||
}
|
||||
|
||||
// 发送断开连接消息
|
||||
let message = serde_json::json!({
|
||||
"type": "session_end",
|
||||
"session_id": session_id,
|
||||
});
|
||||
|
||||
state.send_to_device(&session.controller_device_id, &message.to_string()).await;
|
||||
state.send_to_device(&session.controlled_device_id, &message.to_string()).await;
|
||||
|
||||
// 更新会话状态
|
||||
match session_repo.end_session(&session_id).await {
|
||||
Ok(_) => (StatusCode::OK, Json(ApiResponse::ok("会话已结束"))).into_response(),
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
}
|
||||
191
crates/server/src/handlers/setup.rs
Normal file
@ -0,0 +1,191 @@
|
||||
//! 初始化配置处理器
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::models::ApiResponse;
|
||||
use crate::services::{
|
||||
auth::{AuthService, UserRepository},
|
||||
AppState,
|
||||
};
|
||||
|
||||
/// 初始化状态响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SetupStatusResponse {
|
||||
pub need_setup: bool,
|
||||
}
|
||||
|
||||
/// 初始化请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetupInitRequest {
|
||||
pub admin_username: String,
|
||||
pub admin_password: String,
|
||||
pub jwt_secret: String,
|
||||
pub jwt_expiry: i64,
|
||||
pub stun_servers: String,
|
||||
pub turn_server: Option<String>,
|
||||
pub turn_username: Option<String>,
|
||||
pub turn_password: Option<String>,
|
||||
}
|
||||
|
||||
/// 初始化响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SetupInitResponse {
|
||||
pub token: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 检查是否需要初始化
|
||||
pub async fn check_setup_status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> impl IntoResponse {
|
||||
let repo = UserRepository::new(&state.db);
|
||||
|
||||
// 检查是否存在管理员用户
|
||||
let need_setup = match repo.find_all(0, 1).await {
|
||||
Ok((users, count)) => count == 0,
|
||||
Err(_) => true,
|
||||
};
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse::ok(SetupStatusResponse { need_setup })),
|
||||
)
|
||||
}
|
||||
|
||||
/// 执行初始化配置
|
||||
pub async fn init_setup(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<SetupInitRequest>,
|
||||
) -> impl IntoResponse {
|
||||
// 验证输入
|
||||
if req.admin_username.len() < 3 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiResponse::<SetupInitResponse>::err("用户名至少3个字符")),
|
||||
);
|
||||
}
|
||||
if req.admin_password.len() < 6 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiResponse::<SetupInitResponse>::err("密码至少6位")),
|
||||
);
|
||||
}
|
||||
|
||||
let repo = UserRepository::new(&state.db);
|
||||
|
||||
// 检查是否已经初始化过
|
||||
if let Ok((_, count)) = repo.find_all(0, 1).await {
|
||||
if count > 0 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiResponse::<SetupInitResponse>::err("系统已初始化,请直接登录")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建管理员用户
|
||||
let (hash, salt) = AuthService::hash_password(&req.admin_password);
|
||||
|
||||
// 先创建普通用户
|
||||
let user = match repo.create(&req.admin_username, &hash, &salt, None).await {
|
||||
Ok(user) => user,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiResponse::<SetupInitResponse>::err(format!("创建用户失败: {}", e))),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 将用户设置为管理员
|
||||
if let Err(e) = repo.set_role(&user.id, "admin").await {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiResponse::<SetupInitResponse>::err(format!("设置管理员失败: {}", e))),
|
||||
);
|
||||
}
|
||||
|
||||
// 重新获取用户信息
|
||||
let admin_user = match repo.find_by_id(&user.id).await {
|
||||
Ok(user) => user,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiResponse::<SetupInitResponse>::err(format!("获取用户失败: {}", e))),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 生成令牌
|
||||
let auth_service = AuthService::new(&state.config);
|
||||
let token = match auth_service.generate_token(&admin_user) {
|
||||
Ok(token) => token,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiResponse::<SetupInitResponse>::err(format!("生成令牌失败: {}", e))),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 保存配置到文件(可选)
|
||||
save_env_config(&req);
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse::ok(SetupInitResponse {
|
||||
token,
|
||||
message: "初始化成功!配置已保存到 .env 文件,部分配置需要重启服务后生效。".to_string(),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// 保存配置到 .env 文件
|
||||
fn save_env_config(req: &SetupInitRequest) {
|
||||
let env_content = format!(
|
||||
r#"# EasyRemote 服务端配置(自动生成)
|
||||
|
||||
# 服务器配置
|
||||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL=sqlite:easyremote.db?mode=rwc
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET={}
|
||||
JWT_EXPIRY={}
|
||||
|
||||
# STUN 服务器
|
||||
STUN_SERVERS={}
|
||||
|
||||
# TURN 服务器
|
||||
{}
|
||||
{}
|
||||
{}
|
||||
|
||||
# 日志级别
|
||||
RUST_LOG=info,tower_http=debug
|
||||
"#,
|
||||
req.jwt_secret,
|
||||
req.jwt_expiry,
|
||||
req.stun_servers,
|
||||
req.turn_server.as_ref().map(|s| format!("TURN_SERVER={}", s)).unwrap_or_else(|| "# TURN_SERVER=".to_string()),
|
||||
req.turn_username.as_ref().map(|s| format!("TURN_USERNAME={}", s)).unwrap_or_else(|| "# TURN_USERNAME=".to_string()),
|
||||
req.turn_password.as_ref().map(|s| format!("TURN_PASSWORD={}", s)).unwrap_or_else(|| "# TURN_PASSWORD=".to_string()),
|
||||
);
|
||||
|
||||
// 保存到 .env 文件
|
||||
if let Err(e) = std::fs::write(".env", env_content) {
|
||||
tracing::warn!("无法保存 .env 文件: {}", e);
|
||||
} else {
|
||||
tracing::info!("配置已保存到 .env 文件");
|
||||
}
|
||||
}
|
||||
57
crates/server/src/handlers/users.rs
Normal file
@ -0,0 +1,57 @@
|
||||
//! 用户处理器
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::models::{ApiResponse, UpdateUserRequest, UserResponse};
|
||||
use crate::services::{auth::{AuthService, UserRepository}, AppState};
|
||||
use super::{api_error, AuthUser};
|
||||
|
||||
/// 获取当前用户信息
|
||||
pub async fn get_current_user(
|
||||
State(state): State<Arc<AppState>>,
|
||||
user: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
let repo = UserRepository::new(&state.db);
|
||||
|
||||
match repo.find_by_id(&user.user_id).await {
|
||||
Ok(user_row) => {
|
||||
let response: UserResponse = user_row.into();
|
||||
(StatusCode::OK, Json(ApiResponse::ok(response))).into_response()
|
||||
}
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新用户信息
|
||||
pub async fn update_user(
|
||||
State(state): State<Arc<AppState>>,
|
||||
user: AuthUser,
|
||||
Json(req): Json<UpdateUserRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let repo = UserRepository::new(&state.db);
|
||||
|
||||
// 如果要更新密码,先哈希
|
||||
let password_hash = req.password.as_ref().map(|p| {
|
||||
let (hash, _) = AuthService::hash_password(p);
|
||||
hash
|
||||
});
|
||||
|
||||
match repo.update(&user.user_id, req.email.as_deref(), password_hash.as_deref()).await {
|
||||
Ok(_) => {
|
||||
match repo.find_by_id(&user.user_id).await {
|
||||
Ok(user_row) => {
|
||||
let response: UserResponse = user_row.into();
|
||||
(StatusCode::OK, Json(ApiResponse::ok(response))).into_response()
|
||||
}
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
}
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
}
|
||||
}
|
||||
157
crates/server/src/main.rs
Normal file
@ -0,0 +1,157 @@
|
||||
//! EasyRemote 服务端主入口
|
||||
|
||||
mod config;
|
||||
mod db;
|
||||
mod handlers;
|
||||
mod models;
|
||||
mod services;
|
||||
mod stun_server;
|
||||
mod turn_server;
|
||||
mod websocket;
|
||||
|
||||
use axum::{
|
||||
routing::{get, post, delete},
|
||||
Router, Json, extract::State,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use crate::config::{Config, IceServersConfig};
|
||||
use crate::db::Database;
|
||||
use crate::services::AppState;
|
||||
use crate::stun_server::StunServer;
|
||||
use crate::turn_server::TurnServer;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// 初始化日志
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "info,tower_http=debug".into()),
|
||||
))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
// 加载配置
|
||||
dotenvy::dotenv().ok();
|
||||
let config = Config::from_env()?;
|
||||
|
||||
tracing::info!("Starting EasyRemote Server on {}:{}", config.host, config.port);
|
||||
|
||||
// 启动本地 STUN 服务
|
||||
if config.enable_local_stun {
|
||||
let stun_port = config.stun_port;
|
||||
tokio::spawn(async move {
|
||||
let stun_server = StunServer::new(stun_port);
|
||||
if let Err(e) = stun_server.start().await {
|
||||
tracing::error!("STUN server error: {}", e);
|
||||
}
|
||||
});
|
||||
tracing::info!("Local STUN server enabled on port {}", config.stun_port);
|
||||
}
|
||||
|
||||
// 启动本地 TURN 服务
|
||||
if config.enable_local_turn {
|
||||
let turn_port = config.turn_port;
|
||||
let turn_realm = config.turn_realm.clone();
|
||||
let turn_username = config.turn_username.clone();
|
||||
let turn_password = config.turn_password.clone();
|
||||
tokio::spawn(async move {
|
||||
let turn_server = TurnServer::new(turn_port, turn_realm, turn_username, turn_password);
|
||||
if let Err(e) = turn_server.start().await {
|
||||
tracing::error!("TURN server error: {}", e);
|
||||
}
|
||||
});
|
||||
tracing::info!("Local TURN server enabled on port {}", config.turn_port);
|
||||
}
|
||||
|
||||
// 初始化数据库
|
||||
let db = Database::new(&config.database_url).await?;
|
||||
db.migrate().await?;
|
||||
|
||||
// 创建应用状态
|
||||
let state = Arc::new(AppState::new(db, config.clone()));
|
||||
|
||||
// 启动时将所有设备设为离线(清理上次异常退出的状态)
|
||||
{
|
||||
let device_repo = crate::services::device::DeviceRepository::new(&state.db);
|
||||
match device_repo.set_all_offline().await {
|
||||
Ok(count) => {
|
||||
if count > 0 {
|
||||
tracing::info!("Reset {} devices to offline status", count);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to reset device status: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建路由
|
||||
let app = Router::new()
|
||||
// 初始化配置路由
|
||||
.route("/api/setup/status", get(handlers::setup::check_setup_status))
|
||||
.route("/api/setup/init", post(handlers::setup::init_setup))
|
||||
// 认证路由
|
||||
.route("/api/auth/register", post(handlers::auth::register))
|
||||
.route("/api/auth/login", post(handlers::auth::login))
|
||||
.route("/api/auth/logout", post(handlers::auth::logout))
|
||||
.route("/api/auth/refresh", post(handlers::auth::refresh_token))
|
||||
// 用户路由
|
||||
.route("/api/users/me", get(handlers::users::get_current_user))
|
||||
.route("/api/users/me", post(handlers::users::update_user))
|
||||
// 设备路由
|
||||
.route("/api/devices", get(handlers::devices::list_devices))
|
||||
.route("/api/devices/:id", get(handlers::devices::get_device))
|
||||
.route("/api/devices/:id", delete(handlers::devices::remove_device))
|
||||
.route("/api/devices/:id/offline", post(handlers::devices::force_offline))
|
||||
.route("/api/devices/register", post(handlers::devices::register_device))
|
||||
// ICE 服务器配置
|
||||
.route("/api/ice-servers", get(get_ice_servers))
|
||||
// 会话路由
|
||||
.route("/api/sessions", get(handlers::sessions::list_sessions))
|
||||
.route("/api/sessions/history", get(handlers::sessions::get_history))
|
||||
.route("/api/sessions/:id", get(handlers::sessions::get_session))
|
||||
.route("/api/sessions/:id/end", post(handlers::sessions::end_session))
|
||||
// 管理员路由
|
||||
.route("/api/admin/users", get(handlers::admin::list_users))
|
||||
.route("/api/admin/users/:id", delete(handlers::admin::delete_user))
|
||||
.route("/api/admin/devices", get(handlers::admin::list_all_devices))
|
||||
.route("/api/admin/sessions", get(handlers::admin::list_all_sessions))
|
||||
.route("/api/admin/stats", get(handlers::admin::get_stats))
|
||||
.route("/api/admin/config", get(handlers::admin::get_server_config))
|
||||
.route("/api/admin/env-config", get(handlers::admin::get_env_config))
|
||||
.route("/api/admin/env-config", post(handlers::admin::save_env_config))
|
||||
// WebSocket 信令
|
||||
.route("/ws/signal", get(websocket::signal_handler))
|
||||
// WebSocket 远程控制 (浏览器)
|
||||
.route("/ws/remote/:device_id", get(websocket::remote_handler))
|
||||
// 静态文件服务 (管理后台)
|
||||
.nest_service("/", tower_http::services::ServeDir::new("static"))
|
||||
// 中间件
|
||||
.layer(CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
// 打印 STUN 配置信息
|
||||
tracing::info!("ICE servers: {:?}", config.stun_servers);
|
||||
|
||||
// 启动服务器
|
||||
let addr = format!("{}:{}", config.host, config.port);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
tracing::info!("Server listening on {}", addr);
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取 ICE 服务器配置 API
|
||||
async fn get_ice_servers(State(state): State<Arc<AppState>>) -> Json<IceServersConfig> {
|
||||
Json(state.config.get_ice_servers())
|
||||
}
|
||||
217
crates/server/src/models.rs
Normal file
@ -0,0 +1,217 @@
|
||||
//! 数据模型
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// 数据库用户模型
|
||||
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||
pub struct UserRow {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub password_hash: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub salt: String,
|
||||
pub email: Option<String>,
|
||||
pub role: String,
|
||||
pub created_at: String,
|
||||
pub last_login: Option<String>,
|
||||
}
|
||||
|
||||
/// 数据库设备模型
|
||||
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||
pub struct DeviceRow {
|
||||
pub id: String,
|
||||
pub device_id: String,
|
||||
pub name: String,
|
||||
pub os_type: String,
|
||||
pub os_version: String,
|
||||
pub user_id: Option<String>,
|
||||
pub is_online: bool,
|
||||
pub allow_remote: bool,
|
||||
pub verification_code: Option<String>,
|
||||
pub last_seen: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// 数据库会话模型
|
||||
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||
pub struct SessionRow {
|
||||
pub id: String,
|
||||
pub controller_device_id: String,
|
||||
pub controlled_device_id: String,
|
||||
pub started_at: String,
|
||||
pub ended_at: Option<String>,
|
||||
pub connection_type: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// 数据库控制历史模型
|
||||
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||
pub struct HistoryRow {
|
||||
pub id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub controller_device_id: String,
|
||||
pub controlled_device_id: String,
|
||||
pub controlled_device_name: String,
|
||||
pub started_at: String,
|
||||
pub ended_at: Option<String>,
|
||||
pub duration_seconds: Option<i64>,
|
||||
pub connection_type: String,
|
||||
}
|
||||
|
||||
// ==================== API 请求/响应模型 ====================
|
||||
|
||||
/// 注册请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
/// 登录请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub device_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 认证响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthResponse {
|
||||
pub token: String,
|
||||
pub user: UserResponse,
|
||||
}
|
||||
|
||||
/// 用户响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserResponse {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub role: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// 设备注册请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeviceRegisterRequest {
|
||||
pub device_id: String,
|
||||
pub name: String,
|
||||
pub os_type: String,
|
||||
pub os_version: String,
|
||||
}
|
||||
|
||||
/// 设备响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeviceResponse {
|
||||
pub id: String,
|
||||
pub device_id: String,
|
||||
pub name: String,
|
||||
pub os_type: String,
|
||||
pub os_version: String,
|
||||
pub is_online: bool,
|
||||
pub allow_remote: bool,
|
||||
pub last_seen: String,
|
||||
pub user_id: Option<String>,
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
/// 会话响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SessionResponse {
|
||||
pub id: String,
|
||||
pub controller_device_id: String,
|
||||
pub controlled_device_id: String,
|
||||
pub started_at: String,
|
||||
pub ended_at: Option<String>,
|
||||
pub connection_type: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// 历史记录响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HistoryResponse {
|
||||
pub id: String,
|
||||
pub controller_device_id: String,
|
||||
pub controlled_device_id: String,
|
||||
pub controlled_device_name: String,
|
||||
pub started_at: String,
|
||||
pub ended_at: Option<String>,
|
||||
pub duration: Option<String>,
|
||||
pub connection_type: String,
|
||||
}
|
||||
|
||||
/// 统计响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct StatsResponse {
|
||||
pub total_users: i64,
|
||||
pub total_devices: i64,
|
||||
pub online_devices: i64,
|
||||
pub active_sessions: i64,
|
||||
pub total_sessions: i64,
|
||||
}
|
||||
|
||||
/// 更新用户请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateUserRequest {
|
||||
pub email: Option<String>,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
/// 通用API响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ApiResponse<T> {
|
||||
pub success: bool,
|
||||
pub data: Option<T>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl<T> ApiResponse<T> {
|
||||
pub fn ok(data: T) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
data: Some(data),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn err(error: impl ToString) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 分页请求参数
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PaginationParams {
|
||||
pub page: Option<u32>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
impl PaginationParams {
|
||||
pub fn offset(&self) -> u32 {
|
||||
let page = self.page.unwrap_or(1).max(1);
|
||||
let limit = self.limit();
|
||||
(page - 1) * limit
|
||||
}
|
||||
|
||||
pub fn limit(&self) -> u32 {
|
||||
self.limit.unwrap_or(20).min(100)
|
||||
}
|
||||
}
|
||||
|
||||
/// 分页响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PaginatedResponse<T> {
|
||||
pub items: Vec<T>,
|
||||
pub total: i64,
|
||||
pub page: u32,
|
||||
pub limit: u32,
|
||||
pub total_pages: u32,
|
||||
}
|
||||
252
crates/server/src/services/auth.rs
Normal file
@ -0,0 +1,252 @@
|
||||
//! 认证服务
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::db::Database;
|
||||
use crate::models::{UserRow, UserResponse};
|
||||
use anyhow::Result;
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// JWT Claims
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String, // 用户ID
|
||||
pub username: String, // 用户名
|
||||
pub role: String, // 角色
|
||||
pub exp: i64, // 过期时间
|
||||
pub iat: i64, // 签发时间
|
||||
}
|
||||
|
||||
/// 认证服务
|
||||
pub struct AuthService {
|
||||
jwt_secret: String,
|
||||
jwt_expiry: i64,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub fn new(config: &Config) -> Self {
|
||||
Self {
|
||||
jwt_secret: config.jwt_secret.clone(),
|
||||
jwt_expiry: config.jwt_expiry,
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成JWT令牌
|
||||
pub fn generate_token(&self, user: &UserRow) -> Result<String> {
|
||||
let now = Utc::now();
|
||||
let exp = now + Duration::seconds(self.jwt_expiry);
|
||||
|
||||
let claims = Claims {
|
||||
sub: user.id.clone(),
|
||||
username: user.username.clone(),
|
||||
role: user.role.clone(),
|
||||
exp: exp.timestamp(),
|
||||
iat: now.timestamp(),
|
||||
};
|
||||
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(self.jwt_secret.as_bytes()),
|
||||
)?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// 验证JWT令牌
|
||||
pub fn verify_token(&self, token: &str) -> Result<Claims> {
|
||||
let token_data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(self.jwt_secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)?;
|
||||
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
|
||||
/// 哈希密码
|
||||
pub fn hash_password(password: &str) -> (String, String) {
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.expect("Failed to hash password")
|
||||
.to_string();
|
||||
|
||||
(hash, salt.to_string())
|
||||
}
|
||||
|
||||
/// 验证密码
|
||||
pub fn verify_password(password: &str, hash: &str) -> bool {
|
||||
use argon2::{
|
||||
password_hash::{PasswordHash, PasswordVerifier},
|
||||
Argon2,
|
||||
};
|
||||
|
||||
let parsed_hash = match PasswordHash::new(hash) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户仓储
|
||||
pub struct UserRepository<'a> {
|
||||
db: &'a Database,
|
||||
}
|
||||
|
||||
impl<'a> UserRepository<'a> {
|
||||
pub fn new(db: &'a Database) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// 创建用户
|
||||
pub async fn create(&self, username: &str, password_hash: &str, salt: &str, email: Option<&str>) -> Result<UserRow> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO users (id, username, password_hash, salt, email, role, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'user', ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(username)
|
||||
.bind(password_hash)
|
||||
.bind(salt)
|
||||
.bind(email)
|
||||
.bind(&now)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
self.find_by_id(&id).await
|
||||
}
|
||||
|
||||
/// 根据ID查找用户
|
||||
pub async fn find_by_id(&self, id: &str) -> Result<UserRow> {
|
||||
let user = sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// 根据用户名查找用户
|
||||
pub async fn find_by_username(&self, username: &str) -> Result<Option<UserRow>> {
|
||||
let user = sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE username = ?")
|
||||
.bind(username)
|
||||
.fetch_optional(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// 更新最后登录时间
|
||||
pub async fn update_last_login(&self, id: &str) -> Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query("UPDATE users SET last_login = ? WHERE id = ?")
|
||||
.bind(&now)
|
||||
.bind(id)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新用户信息
|
||||
pub async fn update(&self, id: &str, email: Option<&str>, password_hash: Option<&str>) -> Result<()> {
|
||||
if let Some(email) = email {
|
||||
sqlx::query("UPDATE users SET email = ? WHERE id = ?")
|
||||
.bind(email)
|
||||
.bind(id)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(hash) = password_hash {
|
||||
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||
.bind(hash)
|
||||
.bind(id)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置用户角色
|
||||
pub async fn set_role(&self, id: &str, role: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE users SET role = ? WHERE id = ?")
|
||||
.bind(role)
|
||||
.bind(id)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取所有用户
|
||||
pub async fn find_all(&self, offset: u32, limit: u32) -> Result<(Vec<UserRow>, i64)> {
|
||||
let users = sqlx::query_as::<_, UserRow>(
|
||||
"SELECT * FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok((users, count.0))
|
||||
}
|
||||
|
||||
/// 删除用户
|
||||
pub async fn delete(&self, id: &str) -> Result<()> {
|
||||
// 先解除用户与设备的绑定
|
||||
sqlx::query("UPDATE devices SET user_id = NULL WHERE user_id = ?")
|
||||
.bind(id)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
// 删除用户的控制历史记录
|
||||
sqlx::query("DELETE FROM control_history WHERE user_id = ?")
|
||||
.bind(id)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
// 最后删除用户
|
||||
sqlx::query("DELETE FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserRow> for UserResponse {
|
||||
fn from(user: UserRow) -> Self {
|
||||
Self {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
created_at: user.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
303
crates/server/src/services/device.rs
Normal file
@ -0,0 +1,303 @@
|
||||
//! 设备服务
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::{DeviceRow, DeviceResponse};
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 设备仓储
|
||||
pub struct DeviceRepository<'a> {
|
||||
db: &'a Database,
|
||||
}
|
||||
|
||||
impl<'a> DeviceRepository<'a> {
|
||||
pub fn new(db: &'a Database) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// 注册设备
|
||||
pub async fn register(
|
||||
&self,
|
||||
device_id: &str,
|
||||
name: &str,
|
||||
os_type: &str,
|
||||
os_version: &str,
|
||||
) -> Result<DeviceRow> {
|
||||
// 检查设备是否已存在
|
||||
if let Some(existing) = self.find_by_device_id(device_id).await? {
|
||||
// 更新设备信息
|
||||
self.update_last_seen(device_id).await?;
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let verification_code = generate_verification_code();
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO devices (id, device_id, name, os_type, os_version, verification_code, last_seen, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(device_id)
|
||||
.bind(name)
|
||||
.bind(os_type)
|
||||
.bind(os_version)
|
||||
.bind(&verification_code)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
self.find_by_id(&id).await
|
||||
}
|
||||
|
||||
/// 根据ID查找设备
|
||||
pub async fn find_by_id(&self, id: &str) -> Result<DeviceRow> {
|
||||
let device = sqlx::query_as::<_, DeviceRow>("SELECT * FROM devices WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
/// 根据设备ID查找设备
|
||||
pub async fn find_by_device_id(&self, device_id: &str) -> Result<Option<DeviceRow>> {
|
||||
let device = sqlx::query_as::<_, DeviceRow>("SELECT * FROM devices WHERE device_id = ?")
|
||||
.bind(device_id)
|
||||
.fetch_optional(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
/// 获取用户的所有设备
|
||||
pub async fn find_by_user(&self, user_id: &str) -> Result<Vec<DeviceRow>> {
|
||||
let devices = sqlx::query_as::<_, DeviceRow>(
|
||||
"SELECT * FROM devices WHERE user_id = ? ORDER BY last_seen DESC",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
/// 绑定设备到用户
|
||||
pub async fn bind_to_user(&self, device_id: &str, user_id: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE devices SET user_id = ? WHERE device_id = ?")
|
||||
.bind(user_id)
|
||||
.bind(device_id)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 解绑设备
|
||||
pub async fn unbind(&self, device_id: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE devices SET user_id = NULL WHERE device_id = ?")
|
||||
.bind(device_id)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新设备在线状态
|
||||
pub async fn set_online(&self, device_id: &str, online: bool) -> Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query("UPDATE devices SET is_online = ?, last_seen = ? WHERE device_id = ?")
|
||||
.bind(online)
|
||||
.bind(&now)
|
||||
.bind(device_id)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将所有设备设为离线(服务器启动时调用)
|
||||
pub async fn set_all_offline(&self) -> Result<u64> {
|
||||
let result = sqlx::query("UPDATE devices SET is_online = false WHERE is_online = true")
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// 更新设备允许远程状态
|
||||
pub async fn set_allow_remote(&self, device_id: &str, allow: bool) -> Result<()> {
|
||||
sqlx::query("UPDATE devices SET allow_remote = ? WHERE device_id = ?")
|
||||
.bind(allow)
|
||||
.bind(device_id)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新验证码
|
||||
pub async fn update_verification_code(&self, device_id: &str) -> Result<String> {
|
||||
let code = generate_verification_code();
|
||||
sqlx::query("UPDATE devices SET verification_code = ? WHERE device_id = ?")
|
||||
.bind(&code)
|
||||
.bind(device_id)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// 验证设备验证码
|
||||
pub async fn verify_code(&self, device_id: &str, code: &str) -> Result<bool> {
|
||||
let device = self.find_by_device_id(device_id).await?;
|
||||
if let Some(device) = device {
|
||||
Ok(device.verification_code.as_deref() == Some(code))
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新最后访问时间
|
||||
pub async fn update_last_seen(&self, device_id: &str) -> Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query("UPDATE devices SET last_seen = ? WHERE device_id = ?")
|
||||
.bind(&now)
|
||||
.bind(device_id)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取所有设备
|
||||
pub async fn find_all(&self, offset: u32, limit: u32) -> Result<(Vec<DeviceRow>, i64)> {
|
||||
let devices = sqlx::query_as::<_, DeviceRow>(
|
||||
"SELECT * FROM devices ORDER BY last_seen DESC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM devices")
|
||||
.fetch_one(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok((devices, count.0))
|
||||
}
|
||||
|
||||
/// 获取所有设备(包含用户名)
|
||||
pub async fn find_all_with_username(&self, offset: u32, limit: u32) -> Result<(Vec<DeviceWithUsername>, i64)> {
|
||||
let devices = sqlx::query_as::<_, DeviceWithUsername>(
|
||||
r#"
|
||||
SELECT d.*, u.username
|
||||
FROM devices d
|
||||
LEFT JOIN users u ON d.user_id = u.id
|
||||
ORDER BY d.last_seen DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM devices")
|
||||
.fetch_one(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok((devices, count.0))
|
||||
}
|
||||
|
||||
/// 获取在线设备数量
|
||||
pub async fn count_online(&self) -> Result<i64> {
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM devices WHERE is_online = 1")
|
||||
.fetch_one(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(count.0)
|
||||
}
|
||||
|
||||
/// 删除设备
|
||||
pub async fn delete(&self, device_id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM devices WHERE device_id = ?")
|
||||
.bind(device_id)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成验证码
|
||||
fn generate_verification_code() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..8)
|
||||
.map(|_| {
|
||||
let idx = rng.gen_range(0..36);
|
||||
if idx < 10 {
|
||||
(b'0' + idx) as char
|
||||
} else {
|
||||
(b'a' + idx - 10) as char
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl From<DeviceRow> for DeviceResponse {
|
||||
fn from(device: DeviceRow) -> Self {
|
||||
Self {
|
||||
id: device.id,
|
||||
device_id: device.device_id,
|
||||
name: device.name,
|
||||
os_type: device.os_type,
|
||||
os_version: device.os_version,
|
||||
is_online: device.is_online,
|
||||
allow_remote: device.allow_remote,
|
||||
last_seen: device.last_seen,
|
||||
user_id: device.user_id,
|
||||
username: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 设备及用户名的联合查询结果
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct DeviceWithUsername {
|
||||
pub id: String,
|
||||
pub device_id: String,
|
||||
pub name: String,
|
||||
pub os_type: String,
|
||||
pub os_version: String,
|
||||
pub user_id: Option<String>,
|
||||
pub is_online: bool,
|
||||
pub allow_remote: bool,
|
||||
pub verification_code: Option<String>,
|
||||
pub last_seen: String,
|
||||
pub created_at: String,
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
impl From<DeviceWithUsername> for DeviceResponse {
|
||||
fn from(device: DeviceWithUsername) -> Self {
|
||||
Self {
|
||||
id: device.id,
|
||||
device_id: device.device_id,
|
||||
name: device.name,
|
||||
os_type: device.os_type,
|
||||
os_version: device.os_version,
|
||||
is_online: device.is_online,
|
||||
allow_remote: device.allow_remote,
|
||||
last_seen: device.last_seen,
|
||||
user_id: device.user_id,
|
||||
username: device.username,
|
||||
}
|
||||
}
|
||||
}
|
||||
66
crates/server/src/services/mod.rs
Normal file
@ -0,0 +1,66 @@
|
||||
//! 服务层
|
||||
|
||||
pub mod auth;
|
||||
pub mod device;
|
||||
pub mod session;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::db::Database;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// WebSocket连接信息
|
||||
pub struct WsConnection {
|
||||
pub device_id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub tx: tokio::sync::mpsc::Sender<String>,
|
||||
}
|
||||
|
||||
/// 应用状态
|
||||
pub struct AppState {
|
||||
pub db: Database,
|
||||
pub config: Config,
|
||||
/// 在线设备的WebSocket连接 (device_id -> connection)
|
||||
pub connections: RwLock<HashMap<String, WsConnection>>,
|
||||
/// 活跃的远程控制会话 (session_id -> (controller_ws, controlled_ws))
|
||||
pub active_sessions: RwLock<HashMap<String, (String, String)>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(db: Database, config: Config) -> Self {
|
||||
Self {
|
||||
db,
|
||||
config,
|
||||
connections: RwLock::new(HashMap::new()),
|
||||
active_sessions: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加WebSocket连接
|
||||
pub async fn add_connection(&self, device_id: String, user_id: Option<String>, tx: tokio::sync::mpsc::Sender<String>) {
|
||||
let mut connections = self.connections.write().await;
|
||||
connections.insert(device_id.clone(), WsConnection { device_id, user_id, tx });
|
||||
}
|
||||
|
||||
/// 移除WebSocket连接
|
||||
pub async fn remove_connection(&self, device_id: &str) {
|
||||
let mut connections = self.connections.write().await;
|
||||
connections.remove(device_id);
|
||||
}
|
||||
|
||||
/// 发送消息给指定设备
|
||||
pub async fn send_to_device(&self, device_id: &str, message: &str) -> bool {
|
||||
let connections = self.connections.read().await;
|
||||
if let Some(conn) = connections.get(device_id) {
|
||||
conn.tx.send(message.to_string()).await.is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查设备是否在线
|
||||
pub async fn is_device_online(&self, device_id: &str) -> bool {
|
||||
let connections = self.connections.read().await;
|
||||
connections.contains_key(device_id)
|
||||
}
|
||||
}
|
||||
286
crates/server/src/services/session.rs
Normal file
@ -0,0 +1,286 @@
|
||||
//! 会话服务
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::{SessionRow, HistoryRow, SessionResponse, HistoryResponse};
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 会话仓储
|
||||
pub struct SessionRepository<'a> {
|
||||
db: &'a Database,
|
||||
}
|
||||
|
||||
impl<'a> SessionRepository<'a> {
|
||||
pub fn new(db: &'a Database) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// 创建会话
|
||||
pub async fn create(
|
||||
&self,
|
||||
controller_device_id: &str,
|
||||
controlled_device_id: &str,
|
||||
connection_type: &str,
|
||||
) -> Result<SessionRow> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO sessions (id, controller_device_id, controlled_device_id, started_at, connection_type, status)
|
||||
VALUES (?, ?, ?, ?, ?, 'connecting')
|
||||
"#,
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(controller_device_id)
|
||||
.bind(controlled_device_id)
|
||||
.bind(&now)
|
||||
.bind(connection_type)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
self.find_by_id(&id).await
|
||||
}
|
||||
|
||||
/// 根据ID查找会话
|
||||
pub async fn find_by_id(&self, id: &str) -> Result<SessionRow> {
|
||||
let session = sqlx::query_as::<_, SessionRow>("SELECT * FROM sessions WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// 更新会话状态
|
||||
pub async fn update_status(&self, id: &str, status: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE sessions SET status = ? WHERE id = ?")
|
||||
.bind(status)
|
||||
.bind(id)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 结束会话
|
||||
pub async fn end_session(&self, id: &str) -> Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query("UPDATE sessions SET ended_at = ?, status = 'disconnected' WHERE id = ?")
|
||||
.bind(&now)
|
||||
.bind(id)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取活跃会话
|
||||
pub async fn find_active(&self) -> Result<Vec<SessionRow>> {
|
||||
let sessions = sqlx::query_as::<_, SessionRow>(
|
||||
"SELECT * FROM sessions WHERE status IN ('connecting', 'connected') ORDER BY started_at DESC",
|
||||
)
|
||||
.fetch_all(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
/// 获取设备相关的会话
|
||||
pub async fn find_by_device(&self, device_id: &str) -> Result<Vec<SessionRow>> {
|
||||
let sessions = sqlx::query_as::<_, SessionRow>(
|
||||
"SELECT * FROM sessions WHERE controller_device_id = ? OR controlled_device_id = ? ORDER BY started_at DESC",
|
||||
)
|
||||
.bind(device_id)
|
||||
.bind(device_id)
|
||||
.fetch_all(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
/// 获取所有会话
|
||||
pub async fn find_all(&self, offset: u32, limit: u32) -> Result<(Vec<SessionRow>, i64)> {
|
||||
let sessions = sqlx::query_as::<_, SessionRow>(
|
||||
"SELECT * FROM sessions ORDER BY started_at DESC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM sessions")
|
||||
.fetch_one(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok((sessions, count.0))
|
||||
}
|
||||
|
||||
/// 获取活跃会话数量
|
||||
pub async fn count_active(&self) -> Result<i64> {
|
||||
let count: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM sessions WHERE status IN ('connecting', 'connected')",
|
||||
)
|
||||
.fetch_one(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(count.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史记录仓储
|
||||
pub struct HistoryRepository<'a> {
|
||||
db: &'a Database,
|
||||
}
|
||||
|
||||
impl<'a> HistoryRepository<'a> {
|
||||
pub fn new(db: &'a Database) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// 创建历史记录
|
||||
pub async fn create(
|
||||
&self,
|
||||
user_id: Option<&str>,
|
||||
controller_device_id: &str,
|
||||
controlled_device_id: &str,
|
||||
controlled_device_name: &str,
|
||||
connection_type: &str,
|
||||
) -> Result<HistoryRow> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO control_history (id, user_id, controller_device_id, controlled_device_id, controlled_device_name, started_at, connection_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(user_id)
|
||||
.bind(controller_device_id)
|
||||
.bind(controlled_device_id)
|
||||
.bind(controlled_device_name)
|
||||
.bind(&now)
|
||||
.bind(connection_type)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
self.find_by_id(&id).await
|
||||
}
|
||||
|
||||
/// 根据ID查找历史记录
|
||||
pub async fn find_by_id(&self, id: &str) -> Result<HistoryRow> {
|
||||
let history = sqlx::query_as::<_, HistoryRow>("SELECT * FROM control_history WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
/// 结束历史记录
|
||||
pub async fn end_record(&self, id: &str) -> Result<()> {
|
||||
let now = Utc::now();
|
||||
let now_str = now.to_rfc3339();
|
||||
|
||||
// 计算持续时间
|
||||
let history = self.find_by_id(id).await?;
|
||||
let started_at = chrono::DateTime::parse_from_rfc3339(&history.started_at)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or(now);
|
||||
let duration = (now - started_at).num_seconds();
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE control_history SET ended_at = ?, duration_seconds = ? WHERE id = ?",
|
||||
)
|
||||
.bind(&now_str)
|
||||
.bind(duration)
|
||||
.bind(id)
|
||||
.execute(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取用户的历史记录
|
||||
pub async fn find_by_user(&self, user_id: &str, offset: u32, limit: u32) -> Result<(Vec<HistoryRow>, i64)> {
|
||||
let history = sqlx::query_as::<_, HistoryRow>(
|
||||
"SELECT * FROM control_history WHERE user_id = ? ORDER BY started_at DESC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM control_history WHERE user_id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_one(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok((history, count.0))
|
||||
}
|
||||
|
||||
/// 获取设备的历史记录
|
||||
pub async fn find_by_device(&self, device_id: &str, offset: u32, limit: u32) -> Result<(Vec<HistoryRow>, i64)> {
|
||||
let history = sqlx::query_as::<_, HistoryRow>(
|
||||
"SELECT * FROM control_history WHERE controller_device_id = ? ORDER BY started_at DESC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(device_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
let count: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM control_history WHERE controller_device_id = ?",
|
||||
)
|
||||
.bind(device_id)
|
||||
.fetch_one(&self.db.pool)
|
||||
.await?;
|
||||
|
||||
Ok((history, count.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SessionRow> for SessionResponse {
|
||||
fn from(session: SessionRow) -> Self {
|
||||
Self {
|
||||
id: session.id,
|
||||
controller_device_id: session.controller_device_id,
|
||||
controlled_device_id: session.controlled_device_id,
|
||||
started_at: session.started_at,
|
||||
ended_at: session.ended_at,
|
||||
connection_type: session.connection_type,
|
||||
status: session.status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HistoryRow> for HistoryResponse {
|
||||
fn from(history: HistoryRow) -> Self {
|
||||
let duration = history.duration_seconds.map(|secs| {
|
||||
let hours = secs / 3600;
|
||||
let minutes = (secs % 3600) / 60;
|
||||
let seconds = secs % 60;
|
||||
if hours > 0 {
|
||||
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
|
||||
} else {
|
||||
format!("{:02}:{:02}", minutes, seconds)
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
id: history.id,
|
||||
controller_device_id: history.controller_device_id,
|
||||
controlled_device_id: history.controlled_device_id,
|
||||
controlled_device_name: history.controlled_device_name,
|
||||
started_at: history.started_at,
|
||||
ended_at: history.ended_at,
|
||||
duration,
|
||||
connection_type: history.connection_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
210
crates/server/src/stun_server.rs
Normal file
@ -0,0 +1,210 @@
|
||||
//! STUN Server Implementation
|
||||
//! RFC 5389 compliant STUN server for NAT traversal
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
use tracing::{info, debug, warn};
|
||||
|
||||
/// STUN Message Types
|
||||
const BINDING_REQUEST: u16 = 0x0001;
|
||||
const BINDING_RESPONSE: u16 = 0x0101;
|
||||
|
||||
/// STUN Attribute Types
|
||||
const ATTR_MAPPED_ADDRESS: u16 = 0x0001;
|
||||
const ATTR_XOR_MAPPED_ADDRESS: u16 = 0x0020;
|
||||
const ATTR_SOFTWARE: u16 = 0x8022;
|
||||
const ATTR_FINGERPRINT: u16 = 0x8028;
|
||||
|
||||
/// STUN Magic Cookie (RFC 5389)
|
||||
const MAGIC_COOKIE: u32 = 0x2112A442;
|
||||
|
||||
/// STUN Server
|
||||
pub struct StunServer {
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl StunServer {
|
||||
pub fn new(port: u16) -> Self {
|
||||
Self { port }
|
||||
}
|
||||
|
||||
/// Start the STUN server
|
||||
pub async fn start(&self) -> anyhow::Result<()> {
|
||||
let addr = format!("0.0.0.0:{}", self.port);
|
||||
let socket = UdpSocket::bind(&addr).await?;
|
||||
let socket = Arc::new(socket);
|
||||
|
||||
info!("STUN Server listening on UDP port {}", self.port);
|
||||
|
||||
loop {
|
||||
let mut buf = [0u8; 1024];
|
||||
match socket.recv_from(&mut buf).await {
|
||||
Ok((len, src_addr)) => {
|
||||
let socket_clone = socket.clone();
|
||||
let data = buf[..len].to_vec();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_stun_request(&socket_clone, &data, src_addr).await {
|
||||
warn!("Failed to handle STUN request: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("STUN recv error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a STUN request
|
||||
async fn handle_stun_request(
|
||||
socket: &UdpSocket,
|
||||
data: &[u8],
|
||||
src_addr: SocketAddr,
|
||||
) -> anyhow::Result<()> {
|
||||
// Validate minimum STUN header size (20 bytes)
|
||||
if data.len() < 20 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Parse STUN header
|
||||
let msg_type = u16::from_be_bytes([data[0], data[1]]);
|
||||
let msg_len = u16::from_be_bytes([data[2], data[3]]);
|
||||
let magic = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
|
||||
let transaction_id = &data[8..20];
|
||||
|
||||
// Verify magic cookie
|
||||
if magic != MAGIC_COOKIE {
|
||||
debug!("Invalid STUN magic cookie from {}", src_addr);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Only handle Binding Request
|
||||
if msg_type != BINDING_REQUEST {
|
||||
debug!("Ignoring non-binding STUN message type: 0x{:04x}", msg_type);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
debug!("STUN Binding Request from {}", src_addr);
|
||||
|
||||
// Build Binding Response
|
||||
let response = build_binding_response(transaction_id, src_addr)?;
|
||||
|
||||
// Send response
|
||||
socket.send_to(&response, src_addr).await?;
|
||||
debug!("STUN Binding Response sent to {}", src_addr);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a STUN Binding Response
|
||||
fn build_binding_response(transaction_id: &[u8], mapped_addr: SocketAddr) -> anyhow::Result<Vec<u8>> {
|
||||
let mut response = Vec::with_capacity(64);
|
||||
|
||||
// Build attributes first to calculate length
|
||||
let xor_mapped_attr = build_xor_mapped_address(mapped_addr, transaction_id);
|
||||
let software_attr = build_software_attribute();
|
||||
|
||||
let attrs_len = xor_mapped_attr.len() + software_attr.len();
|
||||
|
||||
// STUN Header (20 bytes)
|
||||
// Message Type: Binding Response
|
||||
response.extend_from_slice(&BINDING_RESPONSE.to_be_bytes());
|
||||
// Message Length (excluding header)
|
||||
response.extend_from_slice(&(attrs_len as u16).to_be_bytes());
|
||||
// Magic Cookie
|
||||
response.extend_from_slice(&MAGIC_COOKIE.to_be_bytes());
|
||||
// Transaction ID (12 bytes)
|
||||
response.extend_from_slice(transaction_id);
|
||||
|
||||
// Attributes
|
||||
response.extend_from_slice(&xor_mapped_attr);
|
||||
response.extend_from_slice(&software_attr);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Build XOR-MAPPED-ADDRESS attribute (RFC 5389)
|
||||
fn build_xor_mapped_address(addr: SocketAddr, transaction_id: &[u8]) -> Vec<u8> {
|
||||
let mut attr = Vec::with_capacity(12);
|
||||
|
||||
match addr {
|
||||
SocketAddr::V4(v4) => {
|
||||
// Attribute Type
|
||||
attr.extend_from_slice(&ATTR_XOR_MAPPED_ADDRESS.to_be_bytes());
|
||||
// Attribute Length (8 bytes for IPv4)
|
||||
attr.extend_from_slice(&8u16.to_be_bytes());
|
||||
// Reserved (1 byte) + Family (1 byte)
|
||||
attr.push(0x00);
|
||||
attr.push(0x01); // IPv4
|
||||
|
||||
// XOR'd Port
|
||||
let port = v4.port() ^ ((MAGIC_COOKIE >> 16) as u16);
|
||||
attr.extend_from_slice(&port.to_be_bytes());
|
||||
|
||||
// XOR'd Address
|
||||
let ip_bytes = v4.ip().octets();
|
||||
let magic_bytes = MAGIC_COOKIE.to_be_bytes();
|
||||
for i in 0..4 {
|
||||
attr.push(ip_bytes[i] ^ magic_bytes[i]);
|
||||
}
|
||||
}
|
||||
SocketAddr::V6(v6) => {
|
||||
// Attribute Type
|
||||
attr.extend_from_slice(&ATTR_XOR_MAPPED_ADDRESS.to_be_bytes());
|
||||
// Attribute Length (20 bytes for IPv6)
|
||||
attr.extend_from_slice(&20u16.to_be_bytes());
|
||||
// Reserved (1 byte) + Family (1 byte)
|
||||
attr.push(0x00);
|
||||
attr.push(0x02); // IPv6
|
||||
|
||||
// XOR'd Port
|
||||
let port = v6.port() ^ ((MAGIC_COOKIE >> 16) as u16);
|
||||
attr.extend_from_slice(&port.to_be_bytes());
|
||||
|
||||
// XOR'd Address (XOR with magic cookie + transaction ID)
|
||||
let ip_bytes = v6.ip().octets();
|
||||
let magic_bytes = MAGIC_COOKIE.to_be_bytes();
|
||||
for i in 0..4 {
|
||||
attr.push(ip_bytes[i] ^ magic_bytes[i]);
|
||||
}
|
||||
for i in 4..16 {
|
||||
attr.push(ip_bytes[i] ^ transaction_id[i - 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
attr
|
||||
}
|
||||
|
||||
/// Build SOFTWARE attribute
|
||||
fn build_software_attribute() -> Vec<u8> {
|
||||
let software = b"EasyRemote STUN Server";
|
||||
let mut attr = Vec::with_capacity(4 + software.len() + 4);
|
||||
|
||||
// Attribute Type
|
||||
attr.extend_from_slice(&ATTR_SOFTWARE.to_be_bytes());
|
||||
|
||||
// Attribute Length
|
||||
let len = software.len() as u16;
|
||||
attr.extend_from_slice(&len.to_be_bytes());
|
||||
|
||||
// Value
|
||||
attr.extend_from_slice(software);
|
||||
|
||||
// Padding to 4-byte boundary
|
||||
let padding = (4 - (software.len() % 4)) % 4;
|
||||
for _ in 0..padding {
|
||||
attr.push(0x00);
|
||||
}
|
||||
|
||||
attr
|
||||
}
|
||||
|
||||
/// Get the default local STUN server URL
|
||||
pub fn get_local_stun_url(port: u16, public_ip: Option<&str>) -> String {
|
||||
let host = public_ip.unwrap_or("localhost");
|
||||
format!("stun:{}:{}", host, port)
|
||||
}
|
||||
929
crates/server/src/turn_server.rs
Normal file
@ -0,0 +1,929 @@
|
||||
//! TURN Server Implementation
|
||||
//! RFC 5766 compliant TURN server for NAT traversal relay
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{info, debug, warn};
|
||||
|
||||
/// STUN/TURN Message Types
|
||||
const BINDING_REQUEST: u16 = 0x0001;
|
||||
const BINDING_RESPONSE: u16 = 0x0101;
|
||||
const ALLOCATE_REQUEST: u16 = 0x0003;
|
||||
const ALLOCATE_RESPONSE: u16 = 0x0103;
|
||||
const ALLOCATE_ERROR_RESPONSE: u16 = 0x0113;
|
||||
const REFRESH_REQUEST: u16 = 0x0004;
|
||||
const REFRESH_RESPONSE: u16 = 0x0104;
|
||||
const SEND_INDICATION: u16 = 0x0016;
|
||||
const DATA_INDICATION: u16 = 0x0017;
|
||||
const CREATE_PERMISSION_REQUEST: u16 = 0x0008;
|
||||
const CREATE_PERMISSION_RESPONSE: u16 = 0x0108;
|
||||
const CHANNEL_BIND_REQUEST: u16 = 0x0009;
|
||||
const CHANNEL_BIND_RESPONSE: u16 = 0x0109;
|
||||
|
||||
/// STUN/TURN Attribute Types
|
||||
const ATTR_XOR_MAPPED_ADDRESS: u16 = 0x0020;
|
||||
const ATTR_XOR_RELAYED_ADDRESS: u16 = 0x0016;
|
||||
const ATTR_XOR_PEER_ADDRESS: u16 = 0x0012;
|
||||
const ATTR_USERNAME: u16 = 0x0006;
|
||||
const ATTR_MESSAGE_INTEGRITY: u16 = 0x0008;
|
||||
const ATTR_ERROR_CODE: u16 = 0x0009;
|
||||
const ATTR_REALM: u16 = 0x0014;
|
||||
const ATTR_NONCE: u16 = 0x0015;
|
||||
const ATTR_LIFETIME: u16 = 0x000D;
|
||||
const ATTR_DATA: u16 = 0x0013;
|
||||
const ATTR_SOFTWARE: u16 = 0x8022;
|
||||
const ATTR_REQUESTED_TRANSPORT: u16 = 0x0019;
|
||||
const ATTR_CHANNEL_NUMBER: u16 = 0x000C;
|
||||
|
||||
/// STUN Magic Cookie (RFC 5389)
|
||||
const MAGIC_COOKIE: u32 = 0x2112A442;
|
||||
|
||||
/// Error codes
|
||||
const ERROR_UNAUTHORIZED: u16 = 401;
|
||||
const ERROR_STALE_NONCE: u16 = 438;
|
||||
const ERROR_ALLOCATION_MISMATCH: u16 = 437;
|
||||
const ERROR_INSUFFICIENT_CAPACITY: u16 = 508;
|
||||
|
||||
/// Default allocation lifetime (10 minutes)
|
||||
const DEFAULT_LIFETIME: u32 = 600;
|
||||
|
||||
/// Allocation state
|
||||
#[derive(Debug)]
|
||||
struct Allocation {
|
||||
client_addr: SocketAddr,
|
||||
relay_socket: Arc<UdpSocket>,
|
||||
relay_addr: SocketAddr,
|
||||
username: String,
|
||||
permissions: HashMap<String, Instant>, // peer IP -> expiry
|
||||
channels: HashMap<u16, SocketAddr>, // channel number -> peer addr
|
||||
created_at: Instant,
|
||||
lifetime: Duration,
|
||||
}
|
||||
|
||||
/// TURN Server
|
||||
pub struct TurnServer {
|
||||
port: u16,
|
||||
realm: String,
|
||||
username: String,
|
||||
password: String,
|
||||
allocations: Arc<RwLock<HashMap<String, Allocation>>>,
|
||||
nonces: Arc<RwLock<HashMap<String, Instant>>>,
|
||||
}
|
||||
|
||||
impl TurnServer {
|
||||
pub fn new(port: u16, realm: String, username: String, password: String) -> Self {
|
||||
Self {
|
||||
port,
|
||||
realm,
|
||||
username,
|
||||
password,
|
||||
allocations: Arc::new(RwLock::new(HashMap::new())),
|
||||
nonces: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the TURN server
|
||||
pub async fn start(&self) -> anyhow::Result<()> {
|
||||
let addr = format!("0.0.0.0:{}", self.port);
|
||||
let socket = UdpSocket::bind(&addr).await?;
|
||||
let socket = Arc::new(socket);
|
||||
|
||||
info!("TURN Server listening on UDP port {}", self.port);
|
||||
|
||||
// Start cleanup task for expired allocations
|
||||
let allocations_clone = self.allocations.clone();
|
||||
let nonces_clone = self.nonces.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
cleanup_expired(&allocations_clone, &nonces_clone).await;
|
||||
}
|
||||
});
|
||||
|
||||
loop {
|
||||
let mut buf = [0u8; 2048];
|
||||
match socket.recv_from(&mut buf).await {
|
||||
Ok((len, src_addr)) => {
|
||||
let socket_clone = socket.clone();
|
||||
let data = buf[..len].to_vec();
|
||||
let realm = self.realm.clone();
|
||||
let username = self.username.clone();
|
||||
let password = self.password.clone();
|
||||
let allocations = self.allocations.clone();
|
||||
let nonces = self.nonces.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_turn_request(
|
||||
&socket_clone,
|
||||
&data,
|
||||
src_addr,
|
||||
&realm,
|
||||
&username,
|
||||
&password,
|
||||
&allocations,
|
||||
&nonces,
|
||||
).await {
|
||||
warn!("Failed to handle TURN request: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("TURN recv error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleanup expired allocations and nonces
|
||||
async fn cleanup_expired(
|
||||
allocations: &Arc<RwLock<HashMap<String, Allocation>>>,
|
||||
nonces: &Arc<RwLock<HashMap<String, Instant>>>,
|
||||
) {
|
||||
let now = Instant::now();
|
||||
|
||||
// Cleanup allocations
|
||||
let mut allocs = allocations.write().await;
|
||||
allocs.retain(|_, alloc| {
|
||||
alloc.created_at.elapsed() < alloc.lifetime
|
||||
});
|
||||
|
||||
// Cleanup nonces (expire after 1 hour)
|
||||
let mut nonce_map = nonces.write().await;
|
||||
nonce_map.retain(|_, created| {
|
||||
now.duration_since(*created) < Duration::from_secs(3600)
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle a TURN request
|
||||
async fn handle_turn_request(
|
||||
socket: &UdpSocket,
|
||||
data: &[u8],
|
||||
src_addr: SocketAddr,
|
||||
realm: &str,
|
||||
valid_username: &str,
|
||||
valid_password: &str,
|
||||
allocations: &Arc<RwLock<HashMap<String, Allocation>>>,
|
||||
nonces: &Arc<RwLock<HashMap<String, Instant>>>,
|
||||
) -> anyhow::Result<()> {
|
||||
// Validate minimum STUN header size (20 bytes)
|
||||
if data.len() < 20 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Parse STUN header
|
||||
let msg_type = u16::from_be_bytes([data[0], data[1]]);
|
||||
let magic = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
|
||||
let transaction_id = &data[8..20];
|
||||
|
||||
// Verify magic cookie
|
||||
if magic != MAGIC_COOKIE {
|
||||
debug!("Invalid STUN magic cookie from {}", src_addr);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match msg_type {
|
||||
BINDING_REQUEST => {
|
||||
// Handle as STUN Binding Request
|
||||
let response = build_binding_response(transaction_id, src_addr)?;
|
||||
socket.send_to(&response, src_addr).await?;
|
||||
debug!("TURN: STUN Binding Response sent to {}", src_addr);
|
||||
}
|
||||
ALLOCATE_REQUEST => {
|
||||
handle_allocate(
|
||||
socket, data, src_addr, transaction_id,
|
||||
realm, valid_username, valid_password, allocations, nonces
|
||||
).await?;
|
||||
}
|
||||
REFRESH_REQUEST => {
|
||||
handle_refresh(
|
||||
socket, data, src_addr, transaction_id, allocations
|
||||
).await?;
|
||||
}
|
||||
CREATE_PERMISSION_REQUEST => {
|
||||
handle_create_permission(
|
||||
socket, data, src_addr, transaction_id, allocations
|
||||
).await?;
|
||||
}
|
||||
SEND_INDICATION => {
|
||||
handle_send_indication(data, src_addr, allocations).await?;
|
||||
}
|
||||
CHANNEL_BIND_REQUEST => {
|
||||
handle_channel_bind(
|
||||
socket, data, src_addr, transaction_id, allocations
|
||||
).await?;
|
||||
}
|
||||
_ => {
|
||||
debug!("TURN: Unknown message type 0x{:04x} from {}", msg_type, src_addr);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle Allocate Request
|
||||
async fn handle_allocate(
|
||||
socket: &UdpSocket,
|
||||
data: &[u8],
|
||||
src_addr: SocketAddr,
|
||||
transaction_id: &[u8],
|
||||
realm: &str,
|
||||
valid_username: &str,
|
||||
valid_password: &str,
|
||||
allocations: &Arc<RwLock<HashMap<String, Allocation>>>,
|
||||
nonces: &Arc<RwLock<HashMap<String, Instant>>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let client_key = src_addr.to_string();
|
||||
|
||||
// Check if allocation already exists
|
||||
{
|
||||
let allocs = allocations.read().await;
|
||||
if allocs.contains_key(&client_key) {
|
||||
// Allocation already exists
|
||||
let response = build_error_response(
|
||||
transaction_id,
|
||||
ERROR_ALLOCATION_MISMATCH,
|
||||
"Allocation already exists"
|
||||
);
|
||||
socket.send_to(&response, src_addr).await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Parse attributes to check authentication
|
||||
let attrs = parse_attributes(data)?;
|
||||
|
||||
// Check for credentials
|
||||
let username = attrs.get(&ATTR_USERNAME);
|
||||
let nonce = attrs.get(&ATTR_NONCE);
|
||||
let msg_integrity = attrs.get(&ATTR_MESSAGE_INTEGRITY);
|
||||
|
||||
if username.is_none() || nonce.is_none() || msg_integrity.is_none() {
|
||||
// Send challenge response
|
||||
let new_nonce = generate_nonce();
|
||||
{
|
||||
let mut nonce_map = nonces.write().await;
|
||||
nonce_map.insert(new_nonce.clone(), Instant::now());
|
||||
}
|
||||
|
||||
let response = build_challenge_response(transaction_id, realm, &new_nonce);
|
||||
socket.send_to(&response, src_addr).await?;
|
||||
debug!("TURN: Sent challenge to {}", src_addr);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Verify credentials
|
||||
let username_str = String::from_utf8_lossy(username.unwrap());
|
||||
if username_str != valid_username {
|
||||
let response = build_error_response(transaction_id, ERROR_UNAUTHORIZED, "Invalid username");
|
||||
socket.send_to(&response, src_addr).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Verify nonce
|
||||
let nonce_str = String::from_utf8_lossy(nonce.unwrap());
|
||||
{
|
||||
let nonce_map = nonces.read().await;
|
||||
if !nonce_map.contains_key(nonce_str.as_ref()) {
|
||||
let response = build_error_response(transaction_id, ERROR_STALE_NONCE, "Stale nonce");
|
||||
socket.send_to(&response, src_addr).await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Verify MESSAGE-INTEGRITY with HMAC-SHA1
|
||||
// For simplicity, we skip this in the basic implementation
|
||||
|
||||
// Create relay socket
|
||||
let relay_socket = match UdpSocket::bind("0.0.0.0:0").await {
|
||||
Ok(s) => Arc::new(s),
|
||||
Err(_) => {
|
||||
let response = build_error_response(
|
||||
transaction_id,
|
||||
ERROR_INSUFFICIENT_CAPACITY,
|
||||
"Cannot allocate relay address"
|
||||
);
|
||||
socket.send_to(&response, src_addr).await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let relay_addr = relay_socket.local_addr()?;
|
||||
|
||||
// Create allocation
|
||||
let allocation = Allocation {
|
||||
client_addr: src_addr,
|
||||
relay_socket: relay_socket.clone(),
|
||||
relay_addr,
|
||||
username: username_str.to_string(),
|
||||
permissions: HashMap::new(),
|
||||
channels: HashMap::new(),
|
||||
created_at: Instant::now(),
|
||||
lifetime: Duration::from_secs(DEFAULT_LIFETIME as u64),
|
||||
};
|
||||
|
||||
// Store allocation
|
||||
{
|
||||
let mut allocs = allocations.write().await;
|
||||
allocs.insert(client_key.clone(), allocation);
|
||||
}
|
||||
|
||||
// Start relay task
|
||||
let socket_clone = socket.local_addr()?.to_string();
|
||||
let client_addr = src_addr;
|
||||
let allocations_clone = allocations.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let main_socket = UdpSocket::bind("0.0.0.0:0").await.ok();
|
||||
if let Some(main) = main_socket {
|
||||
main.connect(&socket_clone).await.ok();
|
||||
relay_incoming_data(relay_socket, main, client_addr, allocations_clone).await;
|
||||
}
|
||||
});
|
||||
|
||||
// Build success response
|
||||
let response = build_allocate_response(transaction_id, src_addr, relay_addr)?;
|
||||
socket.send_to(&response, src_addr).await?;
|
||||
|
||||
info!("TURN: Allocation created for {} -> relay {}", src_addr, relay_addr);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle Refresh Request
|
||||
async fn handle_refresh(
|
||||
socket: &UdpSocket,
|
||||
data: &[u8],
|
||||
src_addr: SocketAddr,
|
||||
transaction_id: &[u8],
|
||||
allocations: &Arc<RwLock<HashMap<String, Allocation>>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let client_key = src_addr.to_string();
|
||||
|
||||
// Parse lifetime attribute
|
||||
let attrs = parse_attributes(data)?;
|
||||
let lifetime = attrs.get(&ATTR_LIFETIME)
|
||||
.map(|v| {
|
||||
if v.len() >= 4 {
|
||||
u32::from_be_bytes([v[0], v[1], v[2], v[3]])
|
||||
} else {
|
||||
DEFAULT_LIFETIME
|
||||
}
|
||||
})
|
||||
.unwrap_or(DEFAULT_LIFETIME);
|
||||
|
||||
let mut allocs = allocations.write().await;
|
||||
|
||||
if lifetime == 0 {
|
||||
// Delete allocation
|
||||
allocs.remove(&client_key);
|
||||
info!("TURN: Allocation deleted for {}", src_addr);
|
||||
} else if let Some(alloc) = allocs.get_mut(&client_key) {
|
||||
// Refresh allocation
|
||||
alloc.lifetime = Duration::from_secs(lifetime as u64);
|
||||
alloc.created_at = Instant::now();
|
||||
}
|
||||
|
||||
// Build response
|
||||
let response = build_refresh_response(transaction_id, lifetime);
|
||||
socket.send_to(&response, src_addr).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle Create Permission Request
|
||||
async fn handle_create_permission(
|
||||
socket: &UdpSocket,
|
||||
data: &[u8],
|
||||
src_addr: SocketAddr,
|
||||
transaction_id: &[u8],
|
||||
allocations: &Arc<RwLock<HashMap<String, Allocation>>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let client_key = src_addr.to_string();
|
||||
|
||||
// Parse peer address
|
||||
let attrs = parse_attributes(data)?;
|
||||
let peer_addr = attrs.get(&ATTR_XOR_PEER_ADDRESS);
|
||||
|
||||
if peer_addr.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let peer_ip = parse_xor_address(peer_addr.unwrap(), &data[8..20])?;
|
||||
|
||||
let mut allocs = allocations.write().await;
|
||||
if let Some(alloc) = allocs.get_mut(&client_key) {
|
||||
// Add permission (expires in 5 minutes)
|
||||
alloc.permissions.insert(
|
||||
peer_ip.ip().to_string(),
|
||||
Instant::now() + Duration::from_secs(300)
|
||||
);
|
||||
|
||||
// Build success response
|
||||
let response = build_create_permission_response(transaction_id);
|
||||
socket.send_to(&response, src_addr).await?;
|
||||
|
||||
debug!("TURN: Permission created for {} -> {}", src_addr, peer_ip);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle Send Indication
|
||||
async fn handle_send_indication(
|
||||
data: &[u8],
|
||||
src_addr: SocketAddr,
|
||||
allocations: &Arc<RwLock<HashMap<String, Allocation>>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let client_key = src_addr.to_string();
|
||||
|
||||
// Parse attributes
|
||||
let attrs = parse_attributes(data)?;
|
||||
let peer_addr = attrs.get(&ATTR_XOR_PEER_ADDRESS);
|
||||
let send_data = attrs.get(&ATTR_DATA);
|
||||
|
||||
if peer_addr.is_none() || send_data.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let peer = parse_xor_address(peer_addr.unwrap(), &data[8..20])?;
|
||||
|
||||
let allocs = allocations.read().await;
|
||||
if let Some(alloc) = allocs.get(&client_key) {
|
||||
// Check permission
|
||||
if let Some(expiry) = alloc.permissions.get(&peer.ip().to_string()) {
|
||||
if Instant::now() < *expiry {
|
||||
// Send data to peer
|
||||
alloc.relay_socket.send_to(send_data.unwrap(), peer).await?;
|
||||
debug!("TURN: Relayed data from {} to {}", src_addr, peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle Channel Bind Request
|
||||
async fn handle_channel_bind(
|
||||
socket: &UdpSocket,
|
||||
data: &[u8],
|
||||
src_addr: SocketAddr,
|
||||
transaction_id: &[u8],
|
||||
allocations: &Arc<RwLock<HashMap<String, Allocation>>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let client_key = src_addr.to_string();
|
||||
|
||||
// Parse attributes
|
||||
let attrs = parse_attributes(data)?;
|
||||
let channel_num = attrs.get(&ATTR_CHANNEL_NUMBER);
|
||||
let peer_addr = attrs.get(&ATTR_XOR_PEER_ADDRESS);
|
||||
|
||||
if channel_num.is_none() || peer_addr.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let channel = u16::from_be_bytes([channel_num.unwrap()[0], channel_num.unwrap()[1]]);
|
||||
let peer = parse_xor_address(peer_addr.unwrap(), &data[8..20])?;
|
||||
|
||||
let mut allocs = allocations.write().await;
|
||||
if let Some(alloc) = allocs.get_mut(&client_key) {
|
||||
alloc.channels.insert(channel, peer);
|
||||
|
||||
// Build success response
|
||||
let response = build_channel_bind_response(transaction_id);
|
||||
socket.send_to(&response, src_addr).await?;
|
||||
|
||||
debug!("TURN: Channel {} bound to {} for {}", channel, peer, src_addr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Relay incoming data from peers to client
|
||||
async fn relay_incoming_data(
|
||||
relay_socket: Arc<UdpSocket>,
|
||||
main_socket: UdpSocket,
|
||||
client_addr: SocketAddr,
|
||||
allocations: Arc<RwLock<HashMap<String, Allocation>>>,
|
||||
) {
|
||||
let mut buf = [0u8; 2048];
|
||||
let client_key = client_addr.to_string();
|
||||
|
||||
loop {
|
||||
match relay_socket.recv_from(&mut buf).await {
|
||||
Ok((len, peer_addr)) => {
|
||||
// Check if allocation still exists
|
||||
let allocs = allocations.read().await;
|
||||
if let Some(alloc) = allocs.get(&client_key) {
|
||||
// Check permission
|
||||
if let Some(expiry) = alloc.permissions.get(&peer_addr.ip().to_string()) {
|
||||
if Instant::now() < *expiry {
|
||||
// Build Data Indication
|
||||
let indication = build_data_indication(peer_addr, &buf[..len]);
|
||||
if let Ok(ind) = indication {
|
||||
let _ = main_socket.send_to(&ind, client_addr).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Allocation expired
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse STUN attributes
|
||||
fn parse_attributes(data: &[u8]) -> anyhow::Result<HashMap<u16, Vec<u8>>> {
|
||||
let mut attrs = HashMap::new();
|
||||
|
||||
if data.len() < 20 {
|
||||
return Ok(attrs);
|
||||
}
|
||||
|
||||
let msg_len = u16::from_be_bytes([data[2], data[3]]) as usize;
|
||||
let mut offset = 20;
|
||||
|
||||
while offset + 4 <= 20 + msg_len && offset + 4 <= data.len() {
|
||||
let attr_type = u16::from_be_bytes([data[offset], data[offset + 1]]);
|
||||
let attr_len = u16::from_be_bytes([data[offset + 2], data[offset + 3]]) as usize;
|
||||
|
||||
if offset + 4 + attr_len > data.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let value = data[offset + 4..offset + 4 + attr_len].to_vec();
|
||||
attrs.insert(attr_type, value);
|
||||
|
||||
// 4-byte alignment
|
||||
offset += 4 + attr_len + (4 - (attr_len % 4)) % 4;
|
||||
}
|
||||
|
||||
Ok(attrs)
|
||||
}
|
||||
|
||||
/// Parse XOR-MAPPED-ADDRESS or XOR-PEER-ADDRESS
|
||||
fn parse_xor_address(data: &[u8], transaction_id: &[u8]) -> anyhow::Result<SocketAddr> {
|
||||
if data.len() < 8 {
|
||||
anyhow::bail!("Invalid XOR address");
|
||||
}
|
||||
|
||||
let family = data[1];
|
||||
let xor_port = u16::from_be_bytes([data[2], data[3]]);
|
||||
let port = xor_port ^ ((MAGIC_COOKIE >> 16) as u16);
|
||||
|
||||
match family {
|
||||
0x01 => {
|
||||
// IPv4
|
||||
let magic_bytes = MAGIC_COOKIE.to_be_bytes();
|
||||
let ip = std::net::Ipv4Addr::new(
|
||||
data[4] ^ magic_bytes[0],
|
||||
data[5] ^ magic_bytes[1],
|
||||
data[6] ^ magic_bytes[2],
|
||||
data[7] ^ magic_bytes[3],
|
||||
);
|
||||
Ok(SocketAddr::new(std::net::IpAddr::V4(ip), port))
|
||||
}
|
||||
0x02 => {
|
||||
// IPv6
|
||||
if data.len() < 20 {
|
||||
anyhow::bail!("Invalid IPv6 XOR address");
|
||||
}
|
||||
let magic_bytes = MAGIC_COOKIE.to_be_bytes();
|
||||
let mut ip_bytes = [0u8; 16];
|
||||
for i in 0..4 {
|
||||
ip_bytes[i] = data[4 + i] ^ magic_bytes[i];
|
||||
}
|
||||
for i in 4..16 {
|
||||
ip_bytes[i] = data[4 + i] ^ transaction_id[i - 4];
|
||||
}
|
||||
let ip = std::net::Ipv6Addr::from(ip_bytes);
|
||||
Ok(SocketAddr::new(std::net::IpAddr::V6(ip), port))
|
||||
}
|
||||
_ => anyhow::bail!("Unknown address family"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a random nonce
|
||||
fn generate_nonce() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
let bytes: [u8; 16] = rng.gen();
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// Build STUN Binding Response (same as STUN server)
|
||||
fn build_binding_response(transaction_id: &[u8], mapped_addr: SocketAddr) -> anyhow::Result<Vec<u8>> {
|
||||
let mut response = Vec::with_capacity(64);
|
||||
|
||||
let xor_mapped_attr = build_xor_mapped_address(mapped_addr, transaction_id);
|
||||
let software_attr = build_software_attribute();
|
||||
let attrs_len = xor_mapped_attr.len() + software_attr.len();
|
||||
|
||||
response.extend_from_slice(&BINDING_RESPONSE.to_be_bytes());
|
||||
response.extend_from_slice(&(attrs_len as u16).to_be_bytes());
|
||||
response.extend_from_slice(&MAGIC_COOKIE.to_be_bytes());
|
||||
response.extend_from_slice(transaction_id);
|
||||
response.extend_from_slice(&xor_mapped_attr);
|
||||
response.extend_from_slice(&software_attr);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Build Allocate Response
|
||||
fn build_allocate_response(
|
||||
transaction_id: &[u8],
|
||||
mapped_addr: SocketAddr,
|
||||
relay_addr: SocketAddr,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
let mut response = Vec::with_capacity(128);
|
||||
|
||||
let xor_mapped = build_xor_mapped_address(mapped_addr, transaction_id);
|
||||
let xor_relayed = build_xor_relayed_address(relay_addr, transaction_id);
|
||||
let lifetime = build_lifetime_attribute(DEFAULT_LIFETIME);
|
||||
let software = build_software_attribute();
|
||||
|
||||
let attrs_len = xor_mapped.len() + xor_relayed.len() + lifetime.len() + software.len();
|
||||
|
||||
response.extend_from_slice(&ALLOCATE_RESPONSE.to_be_bytes());
|
||||
response.extend_from_slice(&(attrs_len as u16).to_be_bytes());
|
||||
response.extend_from_slice(&MAGIC_COOKIE.to_be_bytes());
|
||||
response.extend_from_slice(transaction_id);
|
||||
response.extend_from_slice(&xor_mapped);
|
||||
response.extend_from_slice(&xor_relayed);
|
||||
response.extend_from_slice(&lifetime);
|
||||
response.extend_from_slice(&software);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Build Challenge Response (401)
|
||||
fn build_challenge_response(transaction_id: &[u8], realm: &str, nonce: &str) -> Vec<u8> {
|
||||
let mut response = Vec::with_capacity(128);
|
||||
|
||||
let error_attr = build_error_attribute(ERROR_UNAUTHORIZED, "Unauthorized");
|
||||
let realm_attr = build_realm_attribute(realm);
|
||||
let nonce_attr = build_nonce_attribute(nonce);
|
||||
|
||||
let attrs_len = error_attr.len() + realm_attr.len() + nonce_attr.len();
|
||||
|
||||
response.extend_from_slice(&ALLOCATE_ERROR_RESPONSE.to_be_bytes());
|
||||
response.extend_from_slice(&(attrs_len as u16).to_be_bytes());
|
||||
response.extend_from_slice(&MAGIC_COOKIE.to_be_bytes());
|
||||
response.extend_from_slice(transaction_id);
|
||||
response.extend_from_slice(&error_attr);
|
||||
response.extend_from_slice(&realm_attr);
|
||||
response.extend_from_slice(&nonce_attr);
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Build Error Response
|
||||
fn build_error_response(transaction_id: &[u8], code: u16, reason: &str) -> Vec<u8> {
|
||||
let mut response = Vec::with_capacity(64);
|
||||
|
||||
let error_attr = build_error_attribute(code, reason);
|
||||
let attrs_len = error_attr.len();
|
||||
|
||||
response.extend_from_slice(&ALLOCATE_ERROR_RESPONSE.to_be_bytes());
|
||||
response.extend_from_slice(&(attrs_len as u16).to_be_bytes());
|
||||
response.extend_from_slice(&MAGIC_COOKIE.to_be_bytes());
|
||||
response.extend_from_slice(transaction_id);
|
||||
response.extend_from_slice(&error_attr);
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Build Refresh Response
|
||||
fn build_refresh_response(transaction_id: &[u8], lifetime: u32) -> Vec<u8> {
|
||||
let mut response = Vec::with_capacity(64);
|
||||
|
||||
let lifetime_attr = build_lifetime_attribute(lifetime);
|
||||
let attrs_len = lifetime_attr.len();
|
||||
|
||||
response.extend_from_slice(&REFRESH_RESPONSE.to_be_bytes());
|
||||
response.extend_from_slice(&(attrs_len as u16).to_be_bytes());
|
||||
response.extend_from_slice(&MAGIC_COOKIE.to_be_bytes());
|
||||
response.extend_from_slice(transaction_id);
|
||||
response.extend_from_slice(&lifetime_attr);
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Build Create Permission Response
|
||||
fn build_create_permission_response(transaction_id: &[u8]) -> Vec<u8> {
|
||||
let mut response = Vec::with_capacity(32);
|
||||
|
||||
response.extend_from_slice(&CREATE_PERMISSION_RESPONSE.to_be_bytes());
|
||||
response.extend_from_slice(&0u16.to_be_bytes()); // No attributes
|
||||
response.extend_from_slice(&MAGIC_COOKIE.to_be_bytes());
|
||||
response.extend_from_slice(transaction_id);
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Build Channel Bind Response
|
||||
fn build_channel_bind_response(transaction_id: &[u8]) -> Vec<u8> {
|
||||
let mut response = Vec::with_capacity(32);
|
||||
|
||||
response.extend_from_slice(&CHANNEL_BIND_RESPONSE.to_be_bytes());
|
||||
response.extend_from_slice(&0u16.to_be_bytes()); // No attributes
|
||||
response.extend_from_slice(&MAGIC_COOKIE.to_be_bytes());
|
||||
response.extend_from_slice(transaction_id);
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Build Data Indication
|
||||
fn build_data_indication(peer_addr: SocketAddr, data: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
use rand::Rng;
|
||||
|
||||
let mut indication = Vec::with_capacity(32 + data.len());
|
||||
|
||||
// Generate random transaction ID
|
||||
let mut rng = rand::thread_rng();
|
||||
let transaction_id: [u8; 12] = rng.gen();
|
||||
|
||||
let peer_attr = build_xor_peer_address(peer_addr, &transaction_id);
|
||||
let data_attr = build_data_attribute(data);
|
||||
|
||||
let attrs_len = peer_attr.len() + data_attr.len();
|
||||
|
||||
indication.extend_from_slice(&DATA_INDICATION.to_be_bytes());
|
||||
indication.extend_from_slice(&(attrs_len as u16).to_be_bytes());
|
||||
indication.extend_from_slice(&MAGIC_COOKIE.to_be_bytes());
|
||||
indication.extend_from_slice(&transaction_id);
|
||||
indication.extend_from_slice(&peer_attr);
|
||||
indication.extend_from_slice(&data_attr);
|
||||
|
||||
Ok(indication)
|
||||
}
|
||||
|
||||
/// Build XOR-MAPPED-ADDRESS attribute
|
||||
fn build_xor_mapped_address(addr: SocketAddr, transaction_id: &[u8]) -> Vec<u8> {
|
||||
build_xor_address_attribute(ATTR_XOR_MAPPED_ADDRESS, addr, transaction_id)
|
||||
}
|
||||
|
||||
/// Build XOR-RELAYED-ADDRESS attribute
|
||||
fn build_xor_relayed_address(addr: SocketAddr, transaction_id: &[u8]) -> Vec<u8> {
|
||||
build_xor_address_attribute(ATTR_XOR_RELAYED_ADDRESS, addr, transaction_id)
|
||||
}
|
||||
|
||||
/// Build XOR-PEER-ADDRESS attribute
|
||||
fn build_xor_peer_address(addr: SocketAddr, transaction_id: &[u8]) -> Vec<u8> {
|
||||
build_xor_address_attribute(ATTR_XOR_PEER_ADDRESS, addr, transaction_id)
|
||||
}
|
||||
|
||||
/// Build XOR address attribute
|
||||
fn build_xor_address_attribute(attr_type: u16, addr: SocketAddr, transaction_id: &[u8]) -> Vec<u8> {
|
||||
let mut attr = Vec::with_capacity(12);
|
||||
|
||||
match addr {
|
||||
SocketAddr::V4(v4) => {
|
||||
attr.extend_from_slice(&attr_type.to_be_bytes());
|
||||
attr.extend_from_slice(&8u16.to_be_bytes()); // Length
|
||||
attr.push(0x00); // Reserved
|
||||
attr.push(0x01); // IPv4
|
||||
|
||||
let port = v4.port() ^ ((MAGIC_COOKIE >> 16) as u16);
|
||||
attr.extend_from_slice(&port.to_be_bytes());
|
||||
|
||||
let ip_bytes = v4.ip().octets();
|
||||
let magic_bytes = MAGIC_COOKIE.to_be_bytes();
|
||||
for i in 0..4 {
|
||||
attr.push(ip_bytes[i] ^ magic_bytes[i]);
|
||||
}
|
||||
}
|
||||
SocketAddr::V6(v6) => {
|
||||
attr.extend_from_slice(&attr_type.to_be_bytes());
|
||||
attr.extend_from_slice(&20u16.to_be_bytes()); // Length
|
||||
attr.push(0x00); // Reserved
|
||||
attr.push(0x02); // IPv6
|
||||
|
||||
let port = v6.port() ^ ((MAGIC_COOKIE >> 16) as u16);
|
||||
attr.extend_from_slice(&port.to_be_bytes());
|
||||
|
||||
let ip_bytes = v6.ip().octets();
|
||||
let magic_bytes = MAGIC_COOKIE.to_be_bytes();
|
||||
for i in 0..4 {
|
||||
attr.push(ip_bytes[i] ^ magic_bytes[i]);
|
||||
}
|
||||
for i in 4..16 {
|
||||
attr.push(ip_bytes[i] ^ transaction_id[i - 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
attr
|
||||
}
|
||||
|
||||
/// Build SOFTWARE attribute
|
||||
fn build_software_attribute() -> Vec<u8> {
|
||||
let software = b"EasyRemote TURN Server";
|
||||
let mut attr = Vec::with_capacity(4 + software.len() + 4);
|
||||
|
||||
attr.extend_from_slice(&ATTR_SOFTWARE.to_be_bytes());
|
||||
attr.extend_from_slice(&(software.len() as u16).to_be_bytes());
|
||||
attr.extend_from_slice(software);
|
||||
|
||||
// Padding
|
||||
let padding = (4 - (software.len() % 4)) % 4;
|
||||
for _ in 0..padding {
|
||||
attr.push(0x00);
|
||||
}
|
||||
|
||||
attr
|
||||
}
|
||||
|
||||
/// Build LIFETIME attribute
|
||||
fn build_lifetime_attribute(lifetime: u32) -> Vec<u8> {
|
||||
let mut attr = Vec::with_capacity(8);
|
||||
attr.extend_from_slice(&ATTR_LIFETIME.to_be_bytes());
|
||||
attr.extend_from_slice(&4u16.to_be_bytes());
|
||||
attr.extend_from_slice(&lifetime.to_be_bytes());
|
||||
attr
|
||||
}
|
||||
|
||||
/// Build ERROR-CODE attribute
|
||||
fn build_error_attribute(code: u16, reason: &str) -> Vec<u8> {
|
||||
let reason_bytes = reason.as_bytes();
|
||||
let value_len = 4 + reason_bytes.len();
|
||||
let padded_len = value_len + (4 - (value_len % 4)) % 4;
|
||||
|
||||
let mut attr = Vec::with_capacity(4 + padded_len);
|
||||
attr.extend_from_slice(&ATTR_ERROR_CODE.to_be_bytes());
|
||||
attr.extend_from_slice(&(value_len as u16).to_be_bytes());
|
||||
|
||||
// Reserved (2 bytes) + Class (1 byte) + Number (1 byte)
|
||||
attr.push(0x00);
|
||||
attr.push(0x00);
|
||||
attr.push((code / 100) as u8);
|
||||
attr.push((code % 100) as u8);
|
||||
attr.extend_from_slice(reason_bytes);
|
||||
|
||||
// Padding
|
||||
let padding = (4 - (value_len % 4)) % 4;
|
||||
for _ in 0..padding {
|
||||
attr.push(0x00);
|
||||
}
|
||||
|
||||
attr
|
||||
}
|
||||
|
||||
/// Build REALM attribute
|
||||
fn build_realm_attribute(realm: &str) -> Vec<u8> {
|
||||
let realm_bytes = realm.as_bytes();
|
||||
let mut attr = Vec::with_capacity(4 + realm_bytes.len() + 4);
|
||||
|
||||
attr.extend_from_slice(&ATTR_REALM.to_be_bytes());
|
||||
attr.extend_from_slice(&(realm_bytes.len() as u16).to_be_bytes());
|
||||
attr.extend_from_slice(realm_bytes);
|
||||
|
||||
// Padding
|
||||
let padding = (4 - (realm_bytes.len() % 4)) % 4;
|
||||
for _ in 0..padding {
|
||||
attr.push(0x00);
|
||||
}
|
||||
|
||||
attr
|
||||
}
|
||||
|
||||
/// Build NONCE attribute
|
||||
fn build_nonce_attribute(nonce: &str) -> Vec<u8> {
|
||||
let nonce_bytes = nonce.as_bytes();
|
||||
let mut attr = Vec::with_capacity(4 + nonce_bytes.len() + 4);
|
||||
|
||||
attr.extend_from_slice(&ATTR_NONCE.to_be_bytes());
|
||||
attr.extend_from_slice(&(nonce_bytes.len() as u16).to_be_bytes());
|
||||
attr.extend_from_slice(nonce_bytes);
|
||||
|
||||
// Padding
|
||||
let padding = (4 - (nonce_bytes.len() % 4)) % 4;
|
||||
for _ in 0..padding {
|
||||
attr.push(0x00);
|
||||
}
|
||||
|
||||
attr
|
||||
}
|
||||
|
||||
/// Build DATA attribute
|
||||
fn build_data_attribute(data: &[u8]) -> Vec<u8> {
|
||||
let mut attr = Vec::with_capacity(4 + data.len() + 4);
|
||||
|
||||
attr.extend_from_slice(&ATTR_DATA.to_be_bytes());
|
||||
attr.extend_from_slice(&(data.len() as u16).to_be_bytes());
|
||||
attr.extend_from_slice(data);
|
||||
|
||||
// Padding
|
||||
let padding = (4 - (data.len() % 4)) % 4;
|
||||
for _ in 0..padding {
|
||||
attr.push(0x00);
|
||||
}
|
||||
|
||||
attr
|
||||
}
|
||||
435
crates/server/src/websocket.rs
Normal file
@ -0,0 +1,435 @@
|
||||
//! WebSocket 处理
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
Path, Query, State,
|
||||
},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::services::{
|
||||
device::DeviceRepository,
|
||||
session::SessionRepository,
|
||||
AppState,
|
||||
};
|
||||
|
||||
/// WebSocket 查询参数
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WsQuery {
|
||||
pub device_id: String,
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
/// 信令消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum SignalMessage {
|
||||
/// 注册设备
|
||||
#[serde(rename = "register")]
|
||||
Register {
|
||||
device_id: String,
|
||||
verification_code: String,
|
||||
},
|
||||
/// 心跳
|
||||
#[serde(rename = "heartbeat")]
|
||||
Heartbeat { device_id: String },
|
||||
/// 心跳响应
|
||||
#[serde(rename = "heartbeat_ack")]
|
||||
HeartbeatAck,
|
||||
/// 连接请求
|
||||
#[serde(rename = "connect_request")]
|
||||
ConnectRequest {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
verification_code: String,
|
||||
},
|
||||
/// 连接响应
|
||||
#[serde(rename = "connect_response")]
|
||||
ConnectResponse {
|
||||
session_id: String,
|
||||
accepted: bool,
|
||||
reason: Option<String>,
|
||||
},
|
||||
/// SDP Offer
|
||||
#[serde(rename = "offer")]
|
||||
Offer {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
sdp: String,
|
||||
},
|
||||
/// SDP Answer
|
||||
#[serde(rename = "answer")]
|
||||
Answer {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
sdp: String,
|
||||
},
|
||||
/// ICE Candidate
|
||||
#[serde(rename = "candidate")]
|
||||
Candidate {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
candidate: String,
|
||||
sdp_mid: Option<String>,
|
||||
sdp_mline_index: Option<u32>,
|
||||
},
|
||||
/// 会话结束
|
||||
#[serde(rename = "session_end")]
|
||||
SessionEnd { session_id: String },
|
||||
/// 强制下线
|
||||
#[serde(rename = "force_offline")]
|
||||
ForceOffline { device_id: String },
|
||||
/// 错误
|
||||
#[serde(rename = "error")]
|
||||
Error { code: u32, message: String },
|
||||
/// 设置允许远程
|
||||
#[serde(rename = "set_allow_remote")]
|
||||
SetAllowRemote { device_id: String, allow: bool },
|
||||
/// 刷新验证码
|
||||
#[serde(rename = "refresh_code")]
|
||||
RefreshCode { device_id: String },
|
||||
/// 验证码已刷新
|
||||
#[serde(rename = "code_refreshed")]
|
||||
CodeRefreshed {
|
||||
device_id: String,
|
||||
verification_code: String,
|
||||
},
|
||||
/// 屏幕帧数据
|
||||
#[serde(rename = "screen_frame")]
|
||||
ScreenFrame {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
data: String,
|
||||
},
|
||||
/// 鼠标事件
|
||||
#[serde(rename = "mouse_event")]
|
||||
MouseEvent {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
x: f64,
|
||||
y: f64,
|
||||
event_type: String,
|
||||
button: Option<u8>,
|
||||
delta: Option<f64>,
|
||||
},
|
||||
/// 键盘事件
|
||||
#[serde(rename = "keyboard_event")]
|
||||
KeyboardEvent {
|
||||
session_id: String,
|
||||
from_device: String,
|
||||
to_device: String,
|
||||
key: String,
|
||||
event_type: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 信令WebSocket处理器
|
||||
pub async fn signal_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<WsQuery>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_signal_socket(socket, state, query))
|
||||
}
|
||||
|
||||
async fn handle_signal_socket(socket: WebSocket, state: Arc<AppState>, query: WsQuery) {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
let (tx, mut rx) = mpsc::channel::<String>(32);
|
||||
|
||||
let device_id = query.device_id.clone();
|
||||
|
||||
// 注册连接
|
||||
state.add_connection(device_id.clone(), None, tx).await;
|
||||
|
||||
// 更新设备在线状态
|
||||
{
|
||||
let device_repo = DeviceRepository::new(&state.db);
|
||||
if let Err(e) = device_repo.set_online(&device_id, true).await {
|
||||
tracing::error!("Failed to set device {} online: {}", device_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Device {} connected via WebSocket", device_id);
|
||||
|
||||
// 发送任务
|
||||
let send_task = tokio::spawn(async move {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
if sender.send(Message::Text(msg)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 接收任务
|
||||
let state_clone = state.clone();
|
||||
let device_id_clone = device_id.clone();
|
||||
let recv_task = tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = receiver.next().await {
|
||||
if let Message::Text(text) = msg {
|
||||
handle_signal_message(&state_clone, &device_id_clone, &text).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 等待任务完成
|
||||
tokio::select! {
|
||||
_ = send_task => {},
|
||||
_ = recv_task => {},
|
||||
}
|
||||
|
||||
// 清理 - 确保断开连接时设置离线状态
|
||||
tracing::info!("Device {} WebSocket disconnecting, cleaning up...", device_id);
|
||||
|
||||
state.remove_connection(&device_id).await;
|
||||
|
||||
// 重新创建 repo 来设置离线状态
|
||||
let device_repo = DeviceRepository::new(&state.db);
|
||||
if let Err(e) = device_repo.set_online(&device_id, false).await {
|
||||
tracing::error!("Failed to set device {} offline: {}", device_id, e);
|
||||
} else {
|
||||
tracing::info!("Device {} set to offline", device_id);
|
||||
}
|
||||
|
||||
tracing::info!("Device {} disconnected", device_id);
|
||||
}
|
||||
|
||||
async fn handle_signal_message(state: &Arc<AppState>, device_id: &str, text: &str) {
|
||||
let msg: SignalMessage = match serde_json::from_str(text) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse message: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match msg {
|
||||
SignalMessage::Heartbeat { .. } => {
|
||||
let device_repo = DeviceRepository::new(&state.db);
|
||||
let _ = device_repo.update_last_seen(device_id).await;
|
||||
let response = SignalMessage::HeartbeatAck;
|
||||
let _ = state
|
||||
.send_to_device(device_id, &serde_json::to_string(&response).unwrap())
|
||||
.await;
|
||||
}
|
||||
|
||||
SignalMessage::ConnectRequest {
|
||||
session_id,
|
||||
from_device,
|
||||
to_device,
|
||||
verification_code,
|
||||
} => {
|
||||
let device_repo = DeviceRepository::new(&state.db);
|
||||
|
||||
// 验证目标设备是否在线且允许远程
|
||||
if let Ok(Some(target_device)) = device_repo.find_by_device_id(&to_device).await {
|
||||
if !target_device.is_online {
|
||||
let error = SignalMessage::Error {
|
||||
code: 1001,
|
||||
message: "目标设备不在线".to_string(),
|
||||
};
|
||||
let _ = state
|
||||
.send_to_device(&from_device, &serde_json::to_string(&error).unwrap())
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
if !target_device.allow_remote {
|
||||
let error = SignalMessage::Error {
|
||||
code: 1002,
|
||||
message: "目标设备未开启远程协助".to_string(),
|
||||
};
|
||||
let _ = state
|
||||
.send_to_device(&from_device, &serde_json::to_string(&error).unwrap())
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证验证码
|
||||
if target_device.verification_code.as_deref() != Some(&verification_code) {
|
||||
let error = SignalMessage::Error {
|
||||
code: 1003,
|
||||
message: "验证码错误".to_string(),
|
||||
};
|
||||
let _ = state
|
||||
.send_to_device(&from_device, &serde_json::to_string(&error).unwrap())
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建会话
|
||||
let session_repo = SessionRepository::new(&state.db);
|
||||
let _ = session_repo
|
||||
.create(&from_device, &to_device, "p2p")
|
||||
.await;
|
||||
|
||||
// 转发连接请求到目标设备
|
||||
let _ = state.send_to_device(&to_device, text).await;
|
||||
} else {
|
||||
let error = SignalMessage::Error {
|
||||
code: 1004,
|
||||
message: "目标设备不存在".to_string(),
|
||||
};
|
||||
let _ = state
|
||||
.send_to_device(&from_device, &serde_json::to_string(&error).unwrap())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
SignalMessage::ConnectResponse {
|
||||
session_id,
|
||||
accepted: _,
|
||||
..
|
||||
} => {
|
||||
// 获取会话信息并转发响应
|
||||
if let Some((controller, _controlled)) = state.active_sessions.read().await.get(&session_id) {
|
||||
// 转发到控制端
|
||||
let _ = state.send_to_device(controller, text).await;
|
||||
}
|
||||
}
|
||||
|
||||
SignalMessage::Offer {
|
||||
to_device, ..
|
||||
}
|
||||
| SignalMessage::Answer {
|
||||
to_device, ..
|
||||
}
|
||||
| SignalMessage::Candidate {
|
||||
to_device, ..
|
||||
} => {
|
||||
// 转发信令消息
|
||||
let _ = state.send_to_device(&to_device, text).await;
|
||||
}
|
||||
|
||||
SignalMessage::SessionEnd { session_id } => {
|
||||
let session_repo = SessionRepository::new(&state.db);
|
||||
let _ = session_repo.end_session(&session_id).await;
|
||||
}
|
||||
|
||||
SignalMessage::SetAllowRemote { device_id, allow } => {
|
||||
let device_repo = DeviceRepository::new(&state.db);
|
||||
let _ = device_repo.set_allow_remote(&device_id, allow).await;
|
||||
}
|
||||
|
||||
SignalMessage::RefreshCode { device_id } => {
|
||||
let device_repo = DeviceRepository::new(&state.db);
|
||||
if let Ok(new_code) = device_repo.update_verification_code(&device_id).await {
|
||||
let response = SignalMessage::CodeRefreshed {
|
||||
device_id: device_id.clone(),
|
||||
verification_code: new_code,
|
||||
};
|
||||
let _ = state
|
||||
.send_to_device(&device_id, &serde_json::to_string(&response).unwrap())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// 转发屏幕帧到目标设备(浏览器)
|
||||
SignalMessage::ScreenFrame { to_device, .. } => {
|
||||
let _ = state.send_to_device(&to_device, text).await;
|
||||
}
|
||||
|
||||
// 转发鼠标事件到目标设备(客户端)
|
||||
SignalMessage::MouseEvent { to_device, .. } => {
|
||||
let _ = state.send_to_device(&to_device, text).await;
|
||||
}
|
||||
|
||||
// 转发键盘事件到目标设备(客户端)
|
||||
SignalMessage::KeyboardEvent { to_device, .. } => {
|
||||
let _ = state.send_to_device(&to_device, text).await;
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 远程控制WebSocket处理器(浏览器端)
|
||||
pub async fn remote_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(device_id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_remote_socket(socket, state, device_id))
|
||||
}
|
||||
|
||||
async fn handle_remote_socket(socket: WebSocket, state: Arc<AppState>, device_id: String) {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
|
||||
// 检查目标设备是否在线
|
||||
if !state.is_device_online(&device_id).await {
|
||||
let _ = sender
|
||||
.send(Message::Text(
|
||||
serde_json::json!({
|
||||
"type": "error",
|
||||
"message": "目标设备不在线"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
let browser_device_id = format!("browser_{}", Uuid::new_v4());
|
||||
|
||||
// 创建浏览器端的临时连接
|
||||
let (tx, mut rx) = mpsc::channel::<String>(32);
|
||||
state
|
||||
.add_connection(browser_device_id.clone(), None, tx)
|
||||
.await;
|
||||
|
||||
// 发送连接请求
|
||||
let connect_req = SignalMessage::ConnectRequest {
|
||||
session_id: session_id.clone(),
|
||||
from_device: browser_device_id.clone(),
|
||||
to_device: device_id.clone(),
|
||||
verification_code: String::new(), // 浏览器端需要管理员权限,跳过验证码
|
||||
};
|
||||
|
||||
let _ = state
|
||||
.send_to_device(&device_id, &serde_json::to_string(&connect_req).unwrap())
|
||||
.await;
|
||||
|
||||
// 发送任务
|
||||
let send_task = tokio::spawn(async move {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
if sender.send(Message::Text(msg)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 接收任务
|
||||
let state_clone = state.clone();
|
||||
let _browser_id_clone = browser_device_id.clone();
|
||||
let target_device_id = device_id.clone();
|
||||
let recv_task = tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = receiver.next().await {
|
||||
if let Message::Text(text) = msg {
|
||||
// 转发消息到目标设备
|
||||
let _ = state_clone.send_to_device(&target_device_id, &text).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
_ = send_task => {},
|
||||
_ = recv_task => {},
|
||||
}
|
||||
|
||||
// 清理
|
||||
state.remove_connection(&browser_device_id).await;
|
||||
}
|
||||
2359
crates/server/static/index.html
Normal file
2258
static/index.html
Normal file
979
static/static/index.html
Normal file
@ -0,0 +1,979 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>EasyRemote 管理后台</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #3b82f6;
|
||||
--primary-hover: #2563eb;
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--border-color: #334155;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 侧边栏 */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
padding: 0 12px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 24px;
|
||||
background: var(--primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nav-item:hover,
|
||||
.nav-item.active {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 12px;
|
||||
color: var(--success);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 16px 24px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.table td {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* 状态标签 */
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status.online {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status.offline {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status.active {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
/* 搜索框 */
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 分页 */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pagination-btn:hover,
|
||||
.pagination-btn.active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 远程控制面板 */
|
||||
.remote-panel {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.remote-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.remote-screen {
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
aspect-ratio: 16/9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 登录表单 */
|
||||
.login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, var(--bg-primary) 0%, #0c1222 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* 隐藏内容 */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 登录页面 -->
|
||||
<div id="login-page" class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="login-logo">🔒</div>
|
||||
<h1 class="login-title">管理后台</h1>
|
||||
</div>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label">用户名</label>
|
||||
<input type="text" class="form-input" id="username" placeholder="请输入管理员用户名" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">密码</label>
|
||||
<input type="password" class="form-input" id="password" placeholder="请输入密码" required>
|
||||
</div>
|
||||
<div id="login-error" class="hidden" style="color: var(--error); font-size: 14px; margin-bottom: 16px;"></div>
|
||||
<button type="submit" class="login-btn">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 管理面板 -->
|
||||
<div id="admin-panel" class="layout hidden">
|
||||
<aside class="sidebar">
|
||||
<div class="logo">EasyRemote 管理</div>
|
||||
<nav>
|
||||
<div class="nav-item active" data-page="dashboard">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="14" width="7" height="7"/>
|
||||
<rect x="3" y="14" width="7" height="7"/>
|
||||
</svg>
|
||||
仪表盘
|
||||
</div>
|
||||
<div class="nav-item" data-page="users">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
用户管理
|
||||
</div>
|
||||
<div class="nav-item" data-page="devices">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
设备管理
|
||||
</div>
|
||||
<div class="nav-item" data-page="sessions">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
</svg>
|
||||
会话管理
|
||||
</div>
|
||||
<div class="nav-item" data-page="remote">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
|
||||
<polyline points="10 17 15 12 10 7"/>
|
||||
<line x1="15" y1="12" x2="3" y2="12"/>
|
||||
</svg>
|
||||
远程控制
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<!-- 仪表盘 -->
|
||||
<div id="page-dashboard" class="page-content">
|
||||
<div class="header">
|
||||
<h1 class="page-title">仪表盘</h1>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">总用户数</div>
|
||||
<div class="stat-value" id="stat-users">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">设备总数</div>
|
||||
<div class="stat-value" id="stat-devices">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">在线设备</div>
|
||||
<div class="stat-value" id="stat-online">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">活跃会话</div>
|
||||
<div class="stat-value" id="stat-sessions">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">最近会话</h3>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>控制端</th>
|
||||
<th>被控端</th>
|
||||
<th>开始时间</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recent-sessions">
|
||||
<tr>
|
||||
<td colspan="4" class="empty">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户管理 -->
|
||||
<div id="page-users" class="page-content hidden">
|
||||
<div class="header">
|
||||
<h1 class="page-title">用户管理</h1>
|
||||
<div class="search-box">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input type="text" placeholder="搜索用户...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
<th>邮箱</th>
|
||||
<th>角色</th>
|
||||
<th>注册时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-table">
|
||||
<tr>
|
||||
<td colspan="5" class="empty">加载中...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pagination" id="users-pagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设备管理 -->
|
||||
<div id="page-devices" class="page-content hidden">
|
||||
<div class="header">
|
||||
<h1 class="page-title">设备管理</h1>
|
||||
<div class="search-box">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input type="text" placeholder="搜索设备...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>设备ID</th>
|
||||
<th>名称</th>
|
||||
<th>绑定用户</th>
|
||||
<th>系统</th>
|
||||
<th>状态</th>
|
||||
<th>最后在线</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="devices-table">
|
||||
<tr>
|
||||
<td colspan="7" class="empty">加载中...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pagination" id="devices-pagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 会话管理 -->
|
||||
<div id="page-sessions" class="page-content hidden">
|
||||
<div class="header">
|
||||
<h1 class="page-title">会话管理</h1>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>会话ID</th>
|
||||
<th>控制端</th>
|
||||
<th>被控端</th>
|
||||
<th>类型</th>
|
||||
<th>开始时间</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sessions-table">
|
||||
<tr>
|
||||
<td colspan="7" class="empty">加载中...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pagination" id="sessions-pagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 远程控制 -->
|
||||
<div id="page-remote" class="page-content hidden">
|
||||
<div class="header">
|
||||
<h1 class="page-title">远程控制</h1>
|
||||
</div>
|
||||
<div class="remote-panel">
|
||||
<div class="remote-header">
|
||||
<div>
|
||||
<select class="form-input" id="remote-device-select" style="width: 300px;">
|
||||
<option value="">选择在线设备...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-primary" id="connect-btn">连接</button>
|
||||
<button class="btn btn-danger hidden" id="disconnect-btn">断开</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="remote-screen" id="remote-screen">
|
||||
<span>选择设备后点击连接开始远程控制</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// API 基础URL
|
||||
const API_BASE = '/api';
|
||||
let authToken = localStorage.getItem('admin_token');
|
||||
|
||||
// 页面初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (authToken) {
|
||||
checkAuth();
|
||||
}
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
// 检查认证状态
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/users/me`, {
|
||||
headers: { 'Authorization': `Bearer ${authToken}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.data.role === 'admin') {
|
||||
showAdminPanel();
|
||||
loadDashboard();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
localStorage.removeItem('admin_token');
|
||||
authToken = null;
|
||||
}
|
||||
|
||||
// 设置事件监听
|
||||
function setupEventListeners() {
|
||||
// 登录表单
|
||||
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
||||
|
||||
// 导航
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const page = item.dataset.page;
|
||||
navigateTo(page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 处理登录
|
||||
async function handleLogin(e) {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const errorEl = document.getElementById('login-error');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
if (data.data.user.role !== 'admin') {
|
||||
throw new Error('需要管理员权限');
|
||||
}
|
||||
authToken = data.data.token;
|
||||
localStorage.setItem('admin_token', authToken);
|
||||
showAdminPanel();
|
||||
loadDashboard();
|
||||
} else {
|
||||
throw new Error(data.error || '登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
errorEl.textContent = error.message;
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示管理面板
|
||||
function showAdminPanel() {
|
||||
document.getElementById('login-page').classList.add('hidden');
|
||||
document.getElementById('admin-panel').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 页面导航
|
||||
function navigateTo(page) {
|
||||
// 更新导航状态
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.classList.toggle('active', item.dataset.page === page);
|
||||
});
|
||||
|
||||
// 切换页面内容
|
||||
document.querySelectorAll('.page-content').forEach(content => {
|
||||
content.classList.add('hidden');
|
||||
});
|
||||
document.getElementById(`page-${page}`).classList.remove('hidden');
|
||||
|
||||
// 加载数据
|
||||
switch (page) {
|
||||
case 'dashboard': loadDashboard(); break;
|
||||
case 'users': loadUsers(); break;
|
||||
case 'devices': loadDevices(); break;
|
||||
case 'sessions': loadSessions(); break;
|
||||
case 'remote': loadRemoteDevices(); break;
|
||||
}
|
||||
}
|
||||
|
||||
// API 请求辅助函数
|
||||
async function apiRequest(endpoint, options = {}) {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 加载仪表盘
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const stats = await apiRequest('/admin/stats');
|
||||
if (stats.success) {
|
||||
document.getElementById('stat-users').textContent = stats.data.total_users;
|
||||
document.getElementById('stat-devices').textContent = stats.data.total_devices;
|
||||
document.getElementById('stat-online').textContent = stats.data.online_devices;
|
||||
document.getElementById('stat-sessions').textContent = stats.data.active_sessions;
|
||||
}
|
||||
|
||||
const sessions = await apiRequest('/admin/sessions?limit=5');
|
||||
const tbody = document.getElementById('recent-sessions');
|
||||
if (sessions.success && sessions.data.items.length > 0) {
|
||||
tbody.innerHTML = sessions.data.items.map(s => `
|
||||
<tr>
|
||||
<td>${s.controller_device_id}</td>
|
||||
<td>${s.controlled_device_id}</td>
|
||||
<td>${new Date(s.started_at).toLocaleString('zh-CN')}</td>
|
||||
<td><span class="status ${s.status === 'connected' ? 'active' : 'offline'}">
|
||||
<span class="status-dot"></span>${s.status}
|
||||
</span></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty">暂无数据</td></tr>';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载仪表盘失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载用户列表
|
||||
async function loadUsers(page = 1) {
|
||||
try {
|
||||
const data = await apiRequest(`/admin/users?page=${page}&limit=10`);
|
||||
const tbody = document.getElementById('users-table');
|
||||
|
||||
if (data.success && data.data.items.length > 0) {
|
||||
tbody.innerHTML = data.data.items.map(u => `
|
||||
<tr>
|
||||
<td>${u.username}</td>
|
||||
<td>${u.email || '-'}</td>
|
||||
<td><span class="status ${u.role === 'admin' ? 'active' : ''}">${u.role}</span></td>
|
||||
<td>${new Date(u.created_at).toLocaleString('zh-CN')}</td>
|
||||
<td>
|
||||
<button class="btn btn-danger" onclick="deleteUser('${u.id}')">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
renderPagination('users-pagination', data.data, (p) => loadUsers(p));
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty">暂无数据</td></tr>';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载用户失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载设备列表
|
||||
async function loadDevices(page = 1) {
|
||||
try {
|
||||
const data = await apiRequest(`/admin/devices?page=${page}&limit=10`);
|
||||
const tbody = document.getElementById('devices-table');
|
||||
|
||||
if (data.success && data.data.items.length > 0) {
|
||||
tbody.innerHTML = data.data.items.map(d => `
|
||||
<tr>
|
||||
<td style="font-family: monospace;">${d.device_id}</td>
|
||||
<td>${d.name}</td>
|
||||
<td>${d.username ? `<span class="status active"><span class="status-dot"></span>${d.username}</span>` : '<span style="color: var(--text-secondary);">未绑定</span>'}</td>
|
||||
<td>${d.os_type}</td>
|
||||
<td><span class="status ${d.is_online ? 'online' : 'offline'}">
|
||||
<span class="status-dot"></span>${d.is_online ? '在线' : '离线'}
|
||||
</span></td>
|
||||
<td>${new Date(d.last_seen).toLocaleString('zh-CN')}</td>
|
||||
<td>
|
||||
${d.is_online ? `<button class="btn btn-secondary" onclick="forceOffline('${d.device_id}')">强制下线</button>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
renderPagination('devices-pagination', data.data, (p) => loadDevices(p));
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="empty">暂无数据</td></tr>';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载设备失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载会话列表
|
||||
async function loadSessions(page = 1) {
|
||||
try {
|
||||
const data = await apiRequest(`/admin/sessions?page=${page}&limit=10`);
|
||||
const tbody = document.getElementById('sessions-table');
|
||||
|
||||
if (data.success && data.data.items.length > 0) {
|
||||
tbody.innerHTML = data.data.items.map(s => `
|
||||
<tr>
|
||||
<td style="font-family: monospace; font-size: 12px;">${s.id.substring(0, 8)}...</td>
|
||||
<td>${s.controller_device_id}</td>
|
||||
<td>${s.controlled_device_id}</td>
|
||||
<td>${s.connection_type}</td>
|
||||
<td>${new Date(s.started_at).toLocaleString('zh-CN')}</td>
|
||||
<td><span class="status ${s.status === 'connected' ? 'active' : 'offline'}">
|
||||
<span class="status-dot"></span>${s.status}
|
||||
</span></td>
|
||||
<td>
|
||||
${s.status === 'connected' ? `<button class="btn btn-danger" onclick="endSession('${s.id}')">结束</button>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
renderPagination('sessions-pagination', data.data, (p) => loadSessions(p));
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="empty">暂无数据</td></tr>';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载会话失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载在线设备(用于远程控制)
|
||||
async function loadRemoteDevices() {
|
||||
try {
|
||||
const data = await apiRequest('/admin/devices?limit=100');
|
||||
const select = document.getElementById('remote-device-select');
|
||||
|
||||
select.innerHTML = '<option value="">选择在线设备...</option>';
|
||||
if (data.success) {
|
||||
data.data.items.filter(d => d.is_online).forEach(d => {
|
||||
select.innerHTML += `<option value="${d.device_id}">${d.name} (${d.device_id})</option>`;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载设备失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染分页
|
||||
function renderPagination(containerId, data, loadFunc) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (data.total_pages <= 1) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (let i = 1; i <= data.total_pages; i++) {
|
||||
html += `<button class="pagination-btn ${i === data.page ? 'active' : ''}" onclick="(${loadFunc.toString()})(${i})">${i}</button>`;
|
||||
}
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
async function deleteUser(userId) {
|
||||
if (!confirm('确定要删除该用户吗?')) return;
|
||||
try {
|
||||
await apiRequest(`/admin/users/${userId}`, { method: 'DELETE' });
|
||||
loadUsers();
|
||||
} catch (e) {
|
||||
alert('删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 强制下线
|
||||
async function forceOffline(deviceId) {
|
||||
if (!confirm('确定要强制该设备下线吗?')) return;
|
||||
try {
|
||||
await apiRequest(`/devices/${deviceId}/offline`, { method: 'POST' });
|
||||
loadDevices();
|
||||
} catch (e) {
|
||||
alert('操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 结束会话
|
||||
async function endSession(sessionId) {
|
||||
if (!confirm('确定要结束该会话吗?')) return;
|
||||
try {
|
||||
await apiRequest(`/sessions/${sessionId}/end`, { method: 'POST' });
|
||||
loadSessions();
|
||||
} catch (e) {
|
||||
alert('操作失败');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||