From cc6054b3f7a07e2313700d4299d9036eade6b6a4 Mon Sep 17 00:00:00 2001 From: Ethanfly Date: Sun, 4 Jan 2026 18:15:07 +0800 Subject: [PATCH] first commit --- .gitignore | 38 + Cargo.toml | 88 + README.md | 277 ++ crates/client-core/Cargo.toml | 48 + crates/client-core/src/capture.rs | 146 + crates/client-core/src/codec.rs | 220 ++ crates/client-core/src/config.rs | 93 + crates/client-core/src/connection.rs | 456 ++++ crates/client-core/src/input.rs | 284 ++ crates/client-core/src/lib.rs | 13 + crates/client-core/src/signal.rs | 338 +++ crates/client-tauri/Cargo.toml | 50 + crates/client-tauri/app-icon.png | Bin 0 -> 9115 bytes crates/client-tauri/build.rs | 3 + crates/client-tauri/icons/128x128.png | Bin 0 -> 1891 bytes crates/client-tauri/icons/128x128@2x.png | Bin 0 -> 3503 bytes crates/client-tauri/icons/32x32.png | Bin 0 -> 561 bytes .../client-tauri/icons/Square107x107Logo.png | Bin 0 -> 1608 bytes .../client-tauri/icons/Square142x142Logo.png | Bin 0 -> 2127 bytes .../client-tauri/icons/Square150x150Logo.png | Bin 0 -> 2198 bytes .../client-tauri/icons/Square284x284Logo.png | Bin 0 -> 3822 bytes crates/client-tauri/icons/Square30x30Logo.png | Bin 0 -> 528 bytes .../client-tauri/icons/Square310x310Logo.png | Bin 0 -> 4155 bytes crates/client-tauri/icons/Square44x44Logo.png | Bin 0 -> 722 bytes crates/client-tauri/icons/Square71x71Logo.png | Bin 0 -> 1121 bytes crates/client-tauri/icons/Square89x89Logo.png | Bin 0 -> 1333 bytes crates/client-tauri/icons/StoreLogo.png | Bin 0 -> 824 bytes crates/client-tauri/icons/icon.icns | Bin 0 -> 32520 bytes crates/client-tauri/icons/icon.ico | Bin 0 -> 7012 bytes crates/client-tauri/icons/icon.png | Bin 0 -> 6332 bytes crates/client-tauri/src/commands.rs | 760 ++++++ crates/client-tauri/src/main.rs | 195 ++ crates/client-tauri/src/state.rs | 221 ++ crates/client-tauri/tauri.conf.json | 81 + crates/client-tauri/ui/index.html | 13 + crates/client-tauri/ui/package-lock.json | 1367 ++++++++++ crates/client-tauri/ui/package.json | 21 + crates/client-tauri/ui/src/App.vue | 1328 ++++++++++ crates/client-tauri/ui/src/main.ts | 5 + crates/client-tauri/ui/src/styles/main.css | 473 ++++ crates/client-tauri/ui/src/types.ts | 63 + crates/client-tauri/ui/src/vite-env.d.ts | 7 + crates/client-tauri/ui/tsconfig.json | 21 + crates/client-tauri/ui/tsconfig.node.json | 11 + crates/client-tauri/ui/vite.config.ts | 17 + crates/common/Cargo.toml | 18 + crates/common/src/crypto.rs | 178 ++ crates/common/src/error.rs | 59 + crates/common/src/lib.rs | 11 + crates/common/src/protocol.rs | 302 +++ crates/common/src/types.rs | 220 ++ crates/common/src/utils.rs | 104 + crates/server/Cargo.toml | 55 + crates/server/src/config.rs | 177 ++ crates/server/src/db.rs | 120 + crates/server/src/handlers/admin.rs | 275 ++ crates/server/src/handlers/auth.rs | 141 + crates/server/src/handlers/devices.rs | 148 ++ crates/server/src/handlers/mod.rs | 110 + crates/server/src/handlers/sessions.rs | 141 + crates/server/src/handlers/setup.rs | 191 ++ crates/server/src/handlers/users.rs | 57 + crates/server/src/main.rs | 157 ++ crates/server/src/models.rs | 217 ++ crates/server/src/services/auth.rs | 252 ++ crates/server/src/services/device.rs | 303 +++ crates/server/src/services/mod.rs | 66 + crates/server/src/services/session.rs | 286 ++ crates/server/src/stun_server.rs | 210 ++ crates/server/src/turn_server.rs | 929 +++++++ crates/server/src/websocket.rs | 435 +++ crates/server/static/index.html | 2359 +++++++++++++++++ static/index.html | 2258 ++++++++++++++++ static/static/index.html | 979 +++++++ 74 files changed, 17395 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 crates/client-core/Cargo.toml create mode 100644 crates/client-core/src/capture.rs create mode 100644 crates/client-core/src/codec.rs create mode 100644 crates/client-core/src/config.rs create mode 100644 crates/client-core/src/connection.rs create mode 100644 crates/client-core/src/input.rs create mode 100644 crates/client-core/src/lib.rs create mode 100644 crates/client-core/src/signal.rs create mode 100644 crates/client-tauri/Cargo.toml create mode 100644 crates/client-tauri/app-icon.png create mode 100644 crates/client-tauri/build.rs create mode 100644 crates/client-tauri/icons/128x128.png create mode 100644 crates/client-tauri/icons/128x128@2x.png create mode 100644 crates/client-tauri/icons/32x32.png create mode 100644 crates/client-tauri/icons/Square107x107Logo.png create mode 100644 crates/client-tauri/icons/Square142x142Logo.png create mode 100644 crates/client-tauri/icons/Square150x150Logo.png create mode 100644 crates/client-tauri/icons/Square284x284Logo.png create mode 100644 crates/client-tauri/icons/Square30x30Logo.png create mode 100644 crates/client-tauri/icons/Square310x310Logo.png create mode 100644 crates/client-tauri/icons/Square44x44Logo.png create mode 100644 crates/client-tauri/icons/Square71x71Logo.png create mode 100644 crates/client-tauri/icons/Square89x89Logo.png create mode 100644 crates/client-tauri/icons/StoreLogo.png create mode 100644 crates/client-tauri/icons/icon.icns create mode 100644 crates/client-tauri/icons/icon.ico create mode 100644 crates/client-tauri/icons/icon.png create mode 100644 crates/client-tauri/src/commands.rs create mode 100644 crates/client-tauri/src/main.rs create mode 100644 crates/client-tauri/src/state.rs create mode 100644 crates/client-tauri/tauri.conf.json create mode 100644 crates/client-tauri/ui/index.html create mode 100644 crates/client-tauri/ui/package-lock.json create mode 100644 crates/client-tauri/ui/package.json create mode 100644 crates/client-tauri/ui/src/App.vue create mode 100644 crates/client-tauri/ui/src/main.ts create mode 100644 crates/client-tauri/ui/src/styles/main.css create mode 100644 crates/client-tauri/ui/src/types.ts create mode 100644 crates/client-tauri/ui/src/vite-env.d.ts create mode 100644 crates/client-tauri/ui/tsconfig.json create mode 100644 crates/client-tauri/ui/tsconfig.node.json create mode 100644 crates/client-tauri/ui/vite.config.ts create mode 100644 crates/common/Cargo.toml create mode 100644 crates/common/src/crypto.rs create mode 100644 crates/common/src/error.rs create mode 100644 crates/common/src/lib.rs create mode 100644 crates/common/src/protocol.rs create mode 100644 crates/common/src/types.rs create mode 100644 crates/common/src/utils.rs create mode 100644 crates/server/Cargo.toml create mode 100644 crates/server/src/config.rs create mode 100644 crates/server/src/db.rs create mode 100644 crates/server/src/handlers/admin.rs create mode 100644 crates/server/src/handlers/auth.rs create mode 100644 crates/server/src/handlers/devices.rs create mode 100644 crates/server/src/handlers/mod.rs create mode 100644 crates/server/src/handlers/sessions.rs create mode 100644 crates/server/src/handlers/setup.rs create mode 100644 crates/server/src/handlers/users.rs create mode 100644 crates/server/src/main.rs create mode 100644 crates/server/src/models.rs create mode 100644 crates/server/src/services/auth.rs create mode 100644 crates/server/src/services/device.rs create mode 100644 crates/server/src/services/mod.rs create mode 100644 crates/server/src/services/session.rs create mode 100644 crates/server/src/stun_server.rs create mode 100644 crates/server/src/turn_server.rs create mode 100644 crates/server/src/websocket.rs create mode 100644 crates/server/static/index.html create mode 100644 static/index.html create mode 100644 static/static/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5dd1c2c --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..72ff266 --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f39b17b --- /dev/null +++ b/README.md @@ -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! diff --git a/crates/client-core/Cargo.toml b/crates/client-core/Cargo.toml new file mode 100644 index 0000000..19e1757 --- /dev/null +++ b/crates/client-core/Cargo.toml @@ -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" diff --git a/crates/client-core/src/capture.rs b/crates/client-core/src/capture.rs new file mode 100644 index 0000000..fa280db --- /dev/null +++ b/crates/client-core/src/capture.rs @@ -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 { + 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>> { + 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>> { + 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> { + 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 + ); + } + } +} diff --git a/crates/client-core/src/codec.rs b/crates/client-core/src/codec.rs new file mode 100644 index 0000000..bffc825 --- /dev/null +++ b/crates/client-core/src/codec.rs @@ -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> { + 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, 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, 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>, + 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 { + 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 { + 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()); + } +} diff --git a/crates/client-core/src/config.rs b/crates/client-core/src/config.rs new file mode 100644 index 0000000..204a0d0 --- /dev/null +++ b/crates/client-core/src/config.rs @@ -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") + } +} diff --git a/crates/client-core/src/connection.rs b/crates/client-core/src/connection.rs new file mode 100644 index 0000000..1698ad5 --- /dev/null +++ b/crates/client-core/src/connection.rs @@ -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>, + local_addr: Option, + remote_addr: Option, + socket: Option>, + frame_tx: Option>, + input_tx: Option>, +} + +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 { + 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>> = 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::(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 = fragment_buffer + .iter() + .filter_map(|f| f.clone()) + .flatten() + .collect(); + + if let Ok(frame) = bincode::deserialize::(&complete_data) { + on_frame(frame); + } + + fragment_buffer.clear(); + } + continue; + } + } + + // 尝试解析为帧数据 + if let Ok(frame) = bincode::deserialize::(data) { + on_frame(frame); + continue; + } + + // 尝试解析为输入事件 + if let Ok(input) = bincode::deserialize::(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, +} + +/// ICE 服务器配置 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct IceServersConfig { + pub stun_servers: Vec, + pub turn_server: Option, +} + +/// 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 { + // 将 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> { + 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> { + 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 { + // 解析 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 { + 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 { + 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 { + // 验证最小长度 (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, + pub sdp_mline_index: Option, +} + +impl IceCandidate { + pub fn new(candidate: String) -> Self { + Self { + candidate, + sdp_mid: None, + sdp_mline_index: Some(0), + } + } +} diff --git a/crates/client-core/src/input.rs b/crates/client-core/src/input.rs new file mode 100644 index 0000000..3877a84 --- /dev/null +++ b/crates/client-core/src/input.rs @@ -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 { + 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 { + // 处理常见的键名 + 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 { + 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 } + } +} diff --git a/crates/client-core/src/lib.rs b/crates/client-core/src/lib.rs new file mode 100644 index 0000000..3320437 --- /dev/null +++ b/crates/client-core/src/lib.rs @@ -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}; \ No newline at end of file diff --git a/crates/client-core/src/signal.rs b/crates/client-core/src/signal.rs new file mode 100644 index 0000000..e84a0f4 --- /dev/null +++ b/crates/client-core/src/signal.rs @@ -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, + }, + /// 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, + sdp_mline_index: Option, + }, + /// 会话结束 + #[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, + delta: Option, + }, + /// 键盘事件 + #[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>, + connected: Arc>, +} + +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::(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::(&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, + ) -> 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, + sdp_mline_index: Option, + ) -> 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 + } +} diff --git a/crates/client-tauri/Cargo.toml b/crates/client-tauri/Cargo.toml new file mode 100644 index 0000000..936bd18 --- /dev/null +++ b/crates/client-tauri/Cargo.toml @@ -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"] diff --git a/crates/client-tauri/app-icon.png b/crates/client-tauri/app-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e16bb738551cdd865f00c07b488fc0d89fdfc043 GIT binary patch literal 9115 zcmeHNdr(x@8UL1-0wJ`qO==W1WfGF%u@*+gI0zQ_6I%pQCrKQAuk=@> zFTt6sE5E}3RgtqL2jJ|H*ts2%=-<)RuN1xu@c3o?M{z~-hcbY~wVQKuezsqIe>mrG z<>s2)YwAzBf4_52+!If~x9QX;Z!J_Dz506m=np)v*W6Yv@0jgec;H(QN{}*-bv}6k zy}__bfnPC8@$-#r{4|Ay;n#P*g`X!(_z98NS$KX8er79V5t%W$VB~(u#^XR97nlWk zS|SqU!vdFrd{!b7qN1+Vln>9;KmZYHPgaeo<3SrH8Cb1t)*7@7-kwN$U)8)=FwB}eB7*wHTfvDt=kzTFuws< z+vjcJGDrLN)(8yDtfG{B#jObU%u@k=?ovfLX_nk)4HaNFhm?2|T)HSHZPRB3x;GUHSaBJHTSsB)Cxu@ZAW@`P4QcQc($&t@<74% z#zdIH4kE;72M1k6V}^d-2FEcsqjvju^Y^OVqhBv;W+R*_D06?m!~L=P=TrQ3txD;L zG-29LAfa1-M&IXd{gqR}TAcB&y1L|#hVAxa8^Wi+f3!#uTg2!=td z&7Mz(&^dyQ-oWJ-$REER`!@L_Dhpeg@>yODYC2i{H&Di6oR3LiDB+7DU5^sJDALXm zl<@y4Qb!MtbRAts!lnBzz2f*YR2}tqR+YG8ohxbA8mn*w(gLX@Nywx5Fp^wJVZ*5| zo8mZb$Uvd?zeBRzOlR4sBgG(LavLcvDXe#QU8b}Xgv(W~Sq15YbbXg0NrlWD8+Y93 z;N}Cpc8zbliJIwpc~6=n+~gMr*V|0f3RD)OUruv=m=woBo=aELff}XN@EP9<`&T0h zj*fJ2nHR62#<6_aWM*R?HT0@f_HXZ_oR1GY?44VHNi`WKSU%B8jguqIzT2D1sc}N& z2A(tf(2&BUi!P_rLkfG_w|9@w4KrDkR%+6TO4P;73&2%S%dc>Xr`;nNH4_UH-w#60O>lbfhUEo!bC&VdDNSi2psD;VZflmL` z`1!ec@UVd%iMAmPVWvQ-?(nA>UxWH!nQHyKKjH*77#P;~*1g@##${u6f>U_6rF$+5 z>gh?xjGatz#F~caned=EYlpA}3>mbnPdm%&VP<>4K|I1Y-Xb;I5^xe92*!U5x-<&U z6m8^T*O-|N7a0mnz;O?eT~5pPTxbwBz|8`xm1~=b=(q%Sk-+Gaqqc*<+9a@E0;2~* z?P&sQpkXQ-fxS!{wGr3`8m6hn@n1MVQyfQl^3vSZA0ncSG)*evFDbgf=&5fdFx;)d zv#Cdf;bINK#ISJfguE{TE~QWBC=-Ey0x)k;&haqzy&r|{gR-D~_EAz+9;6z6k$l#V4P z3w;PYI7NJB>6-Xg(>OD?3K%mTTM|j50q>x}dN0rs2l4a@(igW+-53ijT@~m5MEsNR z(oN#Epi+F3fYn_#{Tn3bQEWhXa}Ca8~CMO!%vIw-ZgQD>k${K zvgxn+Emde`OSTz1*;KT8y@)tiV?~SXoJg0&gGyb{v?U| z9!}_g`M8ZNc{gz2!H75?(K&>-nvfxO+&=dtwByD}*P~s!8!vxXMC7+6$c;A4+(%OJ z|M;Q?3sW^Z!6geRf5$jmC7i$c0blscqJ z*OA~$q>f%B;+^D}3*?x)Nd}}v`td~~enrZ%oru39ajDKt#C=lahXlz=b=HzK!yze> ze$jcO^y6=dxJ9Bf{qA*62a`+WJAC;LA2pSHiJgr}kT0<@3-Tp4B0;{y#-$)%Vj~is zUxTUmCT?+MEAo#DP^rm3!$2hb|9*w?i-qt(YvH9A6wiEt8+-H1`MGCaD*ffZ0Wp~! Ag8%>k literal 0 HcmV?d00001 diff --git a/crates/client-tauri/build.rs b/crates/client-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/crates/client-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/crates/client-tauri/icons/128x128.png b/crates/client-tauri/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..4e52c99461ac05333a0104cc6449c073a1143d2c GIT binary patch literal 1891 zcmciD=|9^E0tWD3gd(Jkx~gp)byHe*R1qEG7?YR|>I_32Md_%DquPX`%EvV=L&Be!&GA4xLm_7&002N;T^z4} zTl{|_b>dqy?0y~x07(N^M+eWOcblZi0tF|9*Fm_+f{F!Sq_#uNKh;gDUr2*2+|pkK z!KRbAucc3^s+))uRj+=^$=$(A%7;A8Ih!K~MoH_TkPhGmI>b@Jp)~9P2Is%bX3Oc>i)bZrrQvT4%#yHEM_6DL_Tpa~2WDDAUdc7w)p^;Drk{u@Q!ScSQXiEVWoF4DyS8Z6WIyl1a)>WY7d+wv>I#K7t)*(+Nl|$$m&d+gQlO7u zk%$(SV1?WMe2+62Dl{TgNw8m9a`pI*meq};)fUT+Kq-?FO_z(WK3 zl#d3bKE39bzE_f16ly%GGi*Y^#A7`rE5zx;T;Ve?HaP}Cg-ANDRA$&)v1irb`l~ag zw>B@Aq!^am2Us06wVVi$^`pN0CEvmp3>3H7%#tt@6-2Q5s?4vuVcezZvh1=A@E;9 zAG~gyo6*ji<7nBWWswS*#q>3!YWk#{-3oa@56I>>OAZ+?*eXGo)lUiw3;(+%Vrp)+* zHMijUjC0`*K{vjCBTXeSzOG-&j!z&PwZ)Q04xc$~M!Fx5@PbnNs&Vo-c+^OGaVqQu zMI`~AjXLx|#he}z-7b7{m0NryMzmwp_w}Sthukg`9l_YmoSIRv!R?%FczPm=&tiUKS4jVY0=1GxELvZrHuxc zFR%Dq6bu_@?TTo}S?P7T;DxR$*1=~HQ~hXQD)oa7S69BTxAS&xj%%1n@?u-xd#Hpn zCo1mw;@7-ET0=a6-Lu9EFSeRAyQT{u)k10{G;!6cpB$s7vgD*+Gl@UW>34t{Z^etv zqh^FhoMh}6?%MjG(8(*#m9c=J#o8ag&(K!x^Byo_8UHDa%)Ip5sp8r=%l?=dZ9;oC zC;0g~37=VA(OJt#^YK;hXH)sN;Z?0(bFG5gx}M1ueyDFjuP#Mj70 n)NCeAygB{*6p$8$R2#7+Y3MfTG5kV!1 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 literal 0 HcmV?d00001 diff --git a/crates/client-tauri/icons/Square284x284Logo.png b/crates/client-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..6bfbdca0a5ffa4cf1112011ed0d62ceb9abdf8cb GIT binary patch literal 3822 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 literal 0 HcmV?d00001 diff --git a/crates/client-tauri/icons/Square44x44Logo.png b/crates/client-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a3699ed054fc8233b2550f13fe24ff5d7721672a GIT binary patch literal 722 zcmV;@0xkWCP)hf$JtMJ!n!Ax;EwBZ) zz!um7TVM-pfx8OK*ss5r^!%qyYwc3My91oi%0kFhObUk6E`2 zQZVN;^yVGJvLzcM*cxqO=0}23j-eQkJ(kVQl&cc+IyLarCxJ(=Yvg`LF$?%^J&qe36O)U0^+D+0Z|-xkrQbz9 z%XAzwtUn2jfmLXzc4D}IQMwX>RUjAclyT)j1ZUYkI$Xp;l<50i+kG7}Z$1lTJ!X`A zsTEe?@R3~ZI!vpp#-7u1wWPteO)mP191qD8XU0PE0Q6;2EK&oHJ+0#AvnomfhoFj3 zq~2el-nm<4iq{yYj`m_NCFs6VV{E?y-$&yQZlzMA2M^%csFwqHE$a2hvF_uK#BWhlM57)|mT}X@sGE!JA zUa3ihRncyvm+RCIm+xhvld%y#s-K>V^;jil$AES4(|V4}e(M2M#vG^J<^J*K^|EwBZ)z!um7TVM-pf&b_B6Yb3bpdV+c8~^|S07*qoM6N<$ Eg2}*C!~g&Q literal 0 HcmV?d00001 diff --git a/crates/client-tauri/icons/Square71x71Logo.png b/crates/client-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..615792229e66e37f4e6b6104ac0d3fe9035f3091 GIT binary patch literal 1121 zcmV-n1fKheP)L} zP!?fk8QBkuKrti5ANyl=otfJ?cV>27-PE92nmPwBv*W#Ych2XY^F8;j8kcTNOJJ*N z1|wiN42R(`9EQVi7!Jc>IBX1u;V>MA!*Cc5!(liKhvBd>9EQVi7|#CyM~HO{lImy8 z@xt$SX?zX-6d5WU*9BN~j17C=Kl=NnUSU`Gt{MOhh9*sMhfGZ_~KMhoz9 z(uRa7LXDtsB1h2b12oiYuyga;AMt6@z=$ZQhuMMS*P7FEFabBaGBEJIpx}lTL7}A; zt+eBmpZeK{B7B~*A#RlQ0{P*+fOq4IDorQ_3%1uuQPTa&QW2#2DM!$b?lM)%T|AY7 z;jcyfjCJbT-vp;b$hX0^_UJSMT&NL3h7JNOADqm4)%M$rFrdb zX*i7Ox)Gyt>qZ^Mz9H~fIO(N9jjY<@l0PKG<}aKxV_sybhGPmCO|w~9nk%Cy$kpuR z-Z*UAq(jUogCOC~79>0z%CFE3>f6$wD@`#ma;Ty;@6(}ix2gJn%)2l#<5av~B;io` zJ%kmAS~cq)w!rbVCI~T#ruMcCUX;j6U9nM{ZuMkT6-=5D6y%Bz!PFsbn3{D|1OyK2 zs5J{Y7xiW3zJ~he@-FUagaSZTM1Z72eIu4C=?OF3y-v;2>cNl&kA^)#Ug{9JO-^b7 z9KVl1T~N@V-D z#h~R-0y247I(9@at%A)SUe6s%z==I3>W?TL2_%LGZT?0lZ0LNRFC8k$)LuxI_dyE? z;&yKquC}Jt{0mJfwRfVlGefFaMwncv%&PFj%l1tMw(4VPT*p98y$-~;K1eqo5Z%8( z7=0rJXx<-J>3I2cJzVdc4{dEjfkUfEC+9EUZMZ*}hoS=&hsfpQCZ@eAM)yxpTgLG| zhv`R02s%1QA5apb>>fg#&AJL4L2szjFnXk_h<`eGL|Ff%O%G3BKahiBjR-Sz?<)uz zm&ft8e_l$Nm0}$nH%O#|xpi?7y|Wdqb`0O0w*BiF>-v!u0HN2eyAmWt3st?~e)*V4 zzzLjoelsqWAj{Pw%?S}m&EQ1c7!Jc>I1Gp3FdT-%a2O85VPiN9hv6_BhQn|e4#QzM n42O;3FdT-%a2O85iLU(w2cxf3$y|`b00000NkvXXu0mjfYX1To literal 0 HcmV?d00001 diff --git a/crates/client-tauri/icons/Square89x89Logo.png b/crates/client-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b3a8eeef2b1fdf24927ecac069e88d974d7fbaee GIT binary patch literal 1333 zcmZ9MZ9EeQ9LE<+<)M6LsVVZ*NjdAYlFdAuST@X&=M|B#Qj##cqz8EjZFa&UVi6oO?&ZFxaYnD6b#IU z0<1?C$=5s!il-}eCVgx1%}J1#S3a`cZr)$>vDE~?^qo@Fc`51}z5`_#@SFJ`@HF+m z8J;gIFLr$?QL`agDj>{PNdh!jmduDl; zR6uk3(r~OVzW`5)?xqg~vcvqp_G=Z&_zZ$bHuS+zNGeA`@8LOPHtgm7O$`Rf9`$bH zS;2}*U}ZWbO&L_BB@K*%5NpU=Td&IKM-S*l9qQ0Bn5-XdS_~+07x|R(?IOKu=z@r; z)4Xg}nJLr0TKev(8*3;FlC6X9@uJRbd!QQeL7@O-sb0dxDL^FtxTq_bMyLB5k|Z%3kT z-7ex~EsMALr|`%vl(O5@?0SA=FI2Ago84|x@TJM!IQX$V{*Lp~Et-jbm|IDROrjN9l)wTV&{g%f;LPK+?u6#Gle9@WcJXcVkP)xUaR{NdQOYfZ)Z|- z>4;l8T-*G{pyRlPRKSs7r}!;``D?xiDYefX|9-jBx$&az3WQ1n%xva0KuCxA9NWja zpw_O@V>rgSjE~WK7m$l0@|0xEHm5kT;&h0caMMi1ED*Jz=xq+l8Q`X!hb>vH_#mt? z?iHmli+UA!cVtET2uZICy2B}Hxh;Ixq1U7>qs2D3hr4R$9`zrRe%V7*ZRpe5C-e*r z;_8`)GEUX>HAyJeIJGM-3oj$EM5OCSsmV<3obiOjJpTIL%KfK+(bRw{0%3ABBW!zv?tGVNKNO>laHj31E9J)L=N1Hiqbhuy3c4 z;T>uDOS50Z60B7$>BR#Cqo<6!(2S2=h%Zk5ok&C)H8{dvW-Cp;nPac5W8mT8y^X#N?RP44CqLKmEE z>#E~IivZ;DMw&p3AW)J!KoMhKOM_cV_I3FrFFWO*6Yu=08S%jwm4`;g-SeQwGh~P;*dC-G` zK}8QLf*?u2=s^up$O8@go|jcU+hzq7*@mpdE@(38sU`KR{_5+n;_T)51S=rKN`NpR z2E>3E5CdXB42S_SAO^&M{J$X9Z}yWv<|u1T zk4Bgl&QYi5>G=?MUsf@*5W#iyU~C;dMO1LXv$0`K2Hgc4<$8j8kgh60a>p5}*kxn= z>I_!-7D}~5<`wAqz+e{VM+)#P;}>QZ0~cVq-1w#f1wo1lTmXc zGJs2ekx#&-x!_!m3Y`#3RcB1JK{CxM4{giJ)f$)XEMjIpB7ijM#7-Yac6g`-EG=7t z@JW7{4QamgW<(oANnwJd@nWj+qad56Z|++RAvh;Znq7=Aw-`%<;9w(Qx$(So%(Yag z0^WTIar;?C4tB(JJR{U~L7Iu0gZ%t$1J@pwadMxJbI0-+-Q#1D8Zb+6jzEW8OMx_h zvP=eg1^Rk2=(1lTHI-uT<9YWD(gI;uG;p} zJHD;?qGS7gPqKSNConl3NEmjgQZ2?6vg`eW>AZMO`w$$K&X?G1y?mpy3#orQT0z-v z8HA-%BOB&o)eX*{Rr~B}zH8}-LsMEwDiuT>eQ*KUA61%?kA>pP5>aupO^{>0>FX@ zxv#27O?j3Q06?v*bn77i#1Kdf$Vs6OBrC5!0O#<^x8&8`hnL4j?&&KtA|6EECXV=y z<6t`oVqRvHHo0`w81!^_tM?i5!awxNYz*!8E+^$rAaOXtP*<0~orFFxSV94i(-E%h z2*MQwV1y6&aRtPMW0o8X10)8=`av)W$ClyPCLY_wV>LK-4G7lozr-~#zUfwO!~$nJ z*qItwL%!=O>pB588KBv?9F-B->v2QI(!`+^La)N*j)x#LPGt;dwIqF#-5roJh{ZgHggmJ#Dc^Ht7oi1zE2Hk&Ae6 z@CW<5MGkaP0B|zUchkM7<-5f}-9ZFkxd}DY4(KMs=>2&dXAWWRv;=lOf@PDvu1l_5-Cu{-)=N$V~cH)&uI3J**P0nuRcPc~y|!bnNi4vAhW zLx8bxAQJTqx&>eZ4JIfCk%^)ehAPz{)Z|lABgpT^e2^4O`V!>p#n9g%;V-Y$69@D00xv4#75NJLK%dAm<(g4@U9Az{@)X=oLA@1QRG{4F=$)0s$2Q zp_KlhLLk%)0_rt{BDZ6KCIUHKkYHnAkkUI4_&UiBamJEhw`eZGfx~GCg$jd)a{7Z3 zJ2?F}P?expiUj#mg)EiB`*VsA2o?$2>u>?mF%|;l5`=k9LQ?}-6=J}g3IT#Zj+z`Z zP9R6Vf)Zd-2(@At;ov1ftC@qd0azk*WqwJ3Veq)mnQzCqyY8=89)6C;$A)hVh$t+1#EK{>fq*}183%DlpCCrTPN%>h9l>ZoQ~c>R z1PAT}(ouM50BwR)W7vun07Mazz_kM6lVH*oQZJweJ?R3jKP{h~0>(0UuOr-MxhYhG zDjky`w9qEV#@|fIU$*o=G_uDPsAFD19Qd>19t*?&cwxYBA1gP(#2pJPJAn%b6yf^M z-tlq8KLrfOEIDQgL09P53D;xA`5$IQG4)T!qQqTqunrI&kMeERTX{E({h<8eHOSV} z|8xEjAP7&9916FpqiR&5Ue=SgvAhtv!9Lh^_i7-?%!m zhD*XmUv_;~Yw?~8^i_`b?Tz$h-49(;V1j@Y1DFAkF`z>Ii-MPs28{7pcUMO%+?s{k z%}66`zykTn)nHXy1T=@d|M{gzhFsftv#JD|FXe2(5Y5kij(Zxq%VmaLo?uyLl*bp(tbnYmkcVZraS1HmDVZjY02Fb%r zjVm17PGc3W+UVw{?^xCR9@gsMk@krtzr+Cljxrk_nO=Jz{(+w%Q8x;76Mrmx zLRRH2f99q_;#&H}@Q?uBfu2IQu!!voH#=E^-{wvrtW!fOF0(fAIRTw>i4G3+cBDeJ z?mg?l7Z$HutDk0^&!4#WJhC;@BRnvP>C^86nLeq+>W-MIhpr&&T)<7rt@}O;RbO-( z+d`kSaFE5@?*m~I%>Jdg>z|lD@XMz;r;Pkq(0ybzFWNo$xcP^UR>sfaA<9t?DbB~y zc6(!_8pKv5I&C{2ZiE`{sB}iPW;=$PR(Kz_bQyPMZ__O#sQ0T<7@0r zhL1k}`n6tR{)Fj-CTo-H?X^6a@R{?E z7DA`uBvIxkyRxZGSY(lAI359x)_2NVE(UD^;Vg)=G3R@^9S$7hd!w>5c^!^^26U5a z(LA)1gs+LwhA62Njec6)-jtwnk^D4;=ob0hH)vv!r*^0m|ahc8o42a`6VcO%bbm}g)Q z@LpO&KwIV8f1)*i*Qcu-2h@Pn`?ERmyc~f3{*6Ttfdc)n|7;Nm1`Yn#8wFeLy;!J` z|KHyz(2Fv2Gr+7PcU8K~Eya@<-!^lG%-mrQiK{#lUvww>t~so750jlms~vh;^CyvP za}xS(hLy6;^f_AJ8yt+6PpV%vxv_zur&m_G6p*H*L7r6GzNB9zQ{Ut5dOfMbMU~qL zySuOM;##ciVcF@5#V_OW$#@;FRYf+|Q-D4GHWd7CK>HI>QBaH?0PQ1Ox&-Z*azG(G z1+9sJB}6t~l{;ReMUGeWvnj zFd0|$WdFXrB?Z{2VLnu|32#-{p3}0Q9mjAtWq43!zbm{p7eBr5U6c504~wkqQ<*D< zb5lhngV}8F=E<{su4zkbuhIl%)>$?yfPzd>fBf_;b?fvjf8WV&VTSu*zI>$kYPRvK zZb;AUc=BMp&;457nggtN-=rjtc&u@XzUkMwNSkqwPx^;*M-)GUi|?9}Dpfgk;O`-;4Bf_w|@aP?|_2Y#shodWj24jbaGmM z#JYTlY0nPxsV0e(+6jS-wn~=|Q`cBUd1E!s z_f4Pj@^8)9RGM4huw{L;XFJ@OTaX7QC5OQB_ z{)&x5`Tg!{TJ%85^~;L=is-agsRLCv!J3&1xypwl0rw%3{mJW{SG0^O)K{a~;jh95 zDxlil(;xbA6UaG4<<479VOTqfz+;r|!6JsyN_tVgg1eOAa(K^neF> zo;`b9^E%3aXKu-Qm;<=Gp8n8Du{U+5>P8}x(^&Q5i$ zeeKWE$F{Eb*RF3`bQ_)DQuX0`_rs1{q!=Wq*UxG&@yB03FL(k`P7Z!p$zE*AHMBo$ z^^kNmad)Jw4O>VWRG-kG$`R^bE?vDy`epAoBry}nb!|z{T)Ehc1m>lnKKn9csnKfR z#ONTmUA)&=1le2v9Fy*$S9RR>%X-*!8SUOZwXs*cPP%`*C9oOvH_VP4LK)`2X=6y@!KdcH zCoxa!$ImlYApm&t)e&P{s3q&VSy)sji7Tla+2l@CJIwc)PD?Q#e1;^j0z7${Pjh)X zs3~xI=DN5lZa6Q+X~R_CN5a_}NZkV9BNg`nx7Ykicu}Ryr>@`jQ_d(udwZjBSSABN zT5XKJQ+?tqWE9kP(4ZLO04QgZG@qrp0529|5}Q&9BD!CPe$g&K>Wcyb@vyc(e<+gkMM^H1Cx?McqEF4)Q0}1> z)n@u6bayGYRZ%)U(|8WAT5LR$F21z)&=lkN zVc&@%$hz?CA=1HF+YmYASo!l)Uu?qa@w4yul&Ex&2Ia=~)$Iol z(56ar14M{~@%$#sg2-uw)3+jVntw^}pp3mtA%;LF-f6&0t7@g#ut0#N!Gw%Q{!`4!b)Dmp`sE}ixnDla+!!OyPO z;hAk{H5T^@V~w1oRdK}&11A2djW5>o`P5A8EgcVauP-965v_mJiyUaXW&!3PlXa)R z%Q9c`^9r?A9@@x#`Hpo{jpRwkeVxuTz68DZr3=FBb1&_)p-;RxomfMS)R0Ojq z_-I{Ox)WnR($m4ElFC-lz0uey)JE5JCKX%nKA<33k#FQmg>!~{aSOZC!c!*%zVLJP zaMR+pXCu*U=g&o~;w%?ZY3Ko_y`v$fZEF%gE6&POK3=awlUKPExl+WyjuDpdUaMbD zN}6Wzmt%#pb1C1pTt9bAB6sQxhs5@Jo@$VrN`=z3A% z_w)0r?Z9<`@uTHjkKNn;a+{D&$E{1@tsFy)q$5(9;|0cQ9~dpN$;lNAhvz2z9HIvZ z-5!4*q+|Uw^U@R+hx~YcLQeYXftJViJs&C@w4NgF@QfU5U)e0?gU2dEF2|bLKo`GMT)2OK6I$< zKWC3MSU~Mzr(zd}@09nyPkn+)#Tl9u*twuhH}vz}TW&nxL%(sH(u&Yp#2#UHFzcS8 z{{mjt(M9z|5v4+bhy<@z(B?sR0Uf0ZN{$1MAxISApz1E9KRcHh-HKh zO1lT|@0Oxr)VW)Z_bS2tW~;Xs-n%IVN5y?h@Yw#w?eDTQR?ll>=hQZuw5p#k>ykfk zFeCRf*5^#Bm$>%j>FtEvxD0vG>&22+66zf9^ZV?ZY%kV&tg+pz`#E+?Eq}gdK_DSj z{4(2qX>nUqokNc7Px}l?|0*I4&_WI#J|*@4QTqrc#oehBJcO3lAhJv}ku#LBtC@B` zA`@b1+}=6D#_m5CFt}`AZBeSf0r@to_g0(mURzwqGm47&HLD#5ZC$kes`1*|R9xf? za8D8AZ?cwMZgMhsj!W~JTqgTu9{N<-%5>}Ps0p*-Rl^mGN*@E?yN{`Vs7xn8NLs@_LW9PsGkvO`3;I`>#r5>wEuvDCwn79Oa)NS&-SNe)Qwl z9>YQE;nb81@zr*A_?H7(*2@EiC1zYG#+t9bG6EJvrx!O#Jfadm76^j?77YAx?0_WSstd2_) z_=ME!5AnK51FLGO3~LAWa@jOA10mP)i9t7y5Q0s6IX)lK@gU3=_paD2;;-hu&onU9 zTDj<^2*{?Z= zd$ArosmaN--8;J8#U*Qbi@SZoK&Wlf8O%U0FGzjVp{$tt+_CX`WMB~lvs>LoE_k6P zt~+b$OtA2PnNX++FX`c+?B0hf;CvPuewt}mj*UG}w&EA$)4sloUgQfsxPX#`7H-ur zHQY1LS+&l|9~kUDw;b-9Kz{sD+fh;J)DOgrKQxf7gbwAaI+217_}slQdBF?D)*~*y z?PM_EJie9T3we_>E%McT9jwI21#Wb!3mX240S;cB5E`dF?Ky2~B;mTT^ZDHwFYR(h zObs2t=ri~AY-O;>2<2Q%v&|GBSyD%u9%i;dHdovPWq4+*HFx*TtNn)<>n_%~cjw+= z9p&dP2pAZzv^(;oJ^y4>>1s4oKAO~Wji`nWT6?w=>$g;K-TP#w#fFq~YGN!^YfVgT z>DgM)iZaHFveR`YZ@%1lcH-k~pr?~lx%i{+nJNM#hf5iEx8uAIb@eP)notv?yp#3k zhpy~O*Jx-ac;xbnV{$0K#z#`$S5@H@1!dyl&%dir+|p0{#vpnZhFl+)_ueq18n%5? zIU%iem1K0g`>`AK2Pb0iL}c|~y=ARoRZ@cKBGTzm@*u_PO*T1wyoC6J;t^A);fI)m z@tXR+*;TbNin_h&uxaewgf`pVRLB#~gcLoL{MB_;V)qyw0TLB^29`D;bfV`fEymEY zFDg!9bK@12i0wGAJm0bK33w89n?v?zW~LoK{&6)(*pnlVvOpXd{5Gd-uhp@qGqVR( zP>k@T{V5M-95_b<@bK04L(JAV&@w1cKQCv;gR1RlV^A`kxeJS(^_bnB%0Q*Z3J*0v zepY8Y8jg+4Kqc^@%MFTkBWz#pnxkr)?=mQv_uqw`h%Ln%)tLP8xx~sxM!e~UFG2pF zMI~c*k~DIOa&OUIn^$jypTgLlu<8`oTuB=`H(7NT-G-gXDjg$B=Ys>KC(uCQuSg^; zbB$K}vtWbJlKVzjd}Oa|&B`_HiD%4nk?q*oqf!^lYcxF3Hs0;PvL|VYx`R;)6Ze=y21@_uu<>hWbie z4$G~1Yh{q9y^0wfa&#&8l8{omKAYhOYMtDrOq7y<|Lgw@1)e*A~8)KI@Fn{Kon_$Bpn2`Kw=dvX+eI2Ofw% zU4I@F+bmHh>BtW}1J6L)4jyzLWpAepI%fA}_RN$OcTi#M8jHWixiRJWN9Ma!Ql?71 zx4579Eb-@!iOH^U4lozMj5uBEB%3SNlp~g)Qzc@}(6f1>Cv4C5d`|@HiJ4T8CS3De zs=v<73VO@xKi?TS-ab6uRz2QJ{-2{H-FTwc2BhDL59JB(^Zg@-VIvG>HQ~&7GrU=Y z88G*?ATcwyFt@OXF@rtUN9mE68tdulKuHT43xm*+k+D&mjPc>|3}f>MGg=GnbbQ#$ zXv6Qn{WI}_1{UEEN+)?QDJ@R3z280>?{8op4)xPb!guw5)@j~b#3vY9L_ipw)Fo9l zb(pEAS8<#}jQbY}YZhraA_|L!`d}SnrqK-`|E-Ncf z%*>37$)ZJCrAPt-x4E^sxQ|`g_=BKY+Qjc;_m=i?NBGU9 zsAZz1t)m6hA_;^#+Jnl35e^OxSN5?B>wg-+RyRKGh!1{Qxo$bGZJ&VCl+=_FU%;+4U20-ODo^&Tlhl-gRg}i-`d~m{Z+t} zh81Ui)VByQBQ4$RiRkyv(<^l9 z+?z@T&ztni;WWPYuZ0TOGYQq}qHJ$lkLLqeGYzsoF$-_|*nOCD==GJ!aR(8GSEkog z_<#3pr$jLBbir*{uy;c4Oa+U=B`xKEv{BklF)WF4h7wNlP$8nRcDlmh=KDv{TO5%q zVWuzK!IjqtKZ|kQ*{lht1k)!CkAC3s=8p>wcQ zPz7TJoYKSe_^}J$ES#8EyVB+(!(WI-Qt5IspgAWmEtU(ADtHKgDzFurCR25ukMpf* z(%2<3cw%#YUt53oer#3_JbUZ**E@QGk4z_5X&>=C7-zL6tMiMA_P0@r@_Wf#TIWWe zWxz3>jZ$G}Fqn~4&Huu!cehhV$}o`reX#QEX%~Tzn63J!Tvu-nQtbRvo*P^m=XOjF zpby@q3SRZ4|7xwA+O&60?%DRpeIhGBod#P02?^C4U+-~Z+a{Q#5`=>L#Hb5^5tjB}hQjbY zPkB0Xw(&yYJN&#XjJbC`Mxv%Kg!5&ubsyy?rm*K~^=e>7yKZ6qsW7^fH?(N~dy)FN z!adxi>txnX4z=-vBCdl-ar26~`Q7|0o?Wu{V=Vir<_DgXt$&Otaw$FE;?9RIM!Ni7 zn=ebo6<;(rXF$4z`0vXRz2Z4dqo=*2k@QA#@cYHSB$kd7ttn6Ip89K3ZgmB%@Hd}` z4EAz;&vAXHPXtkSlTP9!yyV^6-=Ydelhk%Z5j2Ot7Yt0Vmy|X9PPtk}w<%*vt~ych zROt?h+W5G)M(e`D9o5M&-VLt$0#yvD&Fk~g-%qo*khJs#QXxuK>fYw)YDOhe>eO+o zHn&`&D~3t=bbSx>+Az3zdBhkdX3Y_Y!i zmXyGK;Y{^lbo4`$5vem;Dyw9wCBMJ8U)D>>QK2AfL;v2|eNtk|`f0}Vl7q*Y;FB+> zwS*#hBu0{xM8$|0ZS}VAGpm(STbiEU-$_7A;~g%EYaQkp`RO$uH08c^#GM` z<}VkvQqG&bu~YQ8)PpenKvXla^0n4}-XZ!@yxk?3Dp9w3&yqVIMYoP4ibM46W~EIr zDtCiJe@hkY6i;1A(5@5_xOV|mMrsVi8mts;D zQM`EHV48ZR7nV#7L$UX+i>Y2)lVv^s`b=7%*xFXHwaS?5qclCaq3NtV%})dNJ|Z0T zc-Dm*QM?E6PB>jstDzvGF(XR+REQ7s=w&m6~klbJ|u`&s|v$b2j{yxcP)UZFYtGUhfpkD)_3%BC+y}mnOzFHUHz(};2! z)AAwjL;%etZ*gLv$=r9M&1dG0@dy8N7V<_~ciSmSlN*j^1%e!%bNKS@a+gz%7~EK&Hv7eR(p40nab^utz}{D|q5p!jjcS)(i#dcR$u@kbD)&~?=**Ic z9KE>WJ3qbMaP1TNy|7F+0$5zmKWJdI`;nMXE6cR2za2aqJrl4AMx=k-Tk z>!Ok}jD^1x?AEP}n|vT*b0ubDwQkX4Q?qinpkz~!(PXo?F%;3^5kZdRZ1@hiy}Vw` z;>=Gx*jbjL!_tYtUeDzSX;9)jbyU}4oH#M5eRcmEf8YkL4j<^35k@prA|+{jR-(IF z6?A+4;#%WosH!Zo4GU2=7kWOHUo}ciqPXzXK7H)yWo=!SqwjS2%$7xrc$e@EZh&Aj zc}H^5?d|!+Th`ryCn=ixk{%536v93}afA;l^c);6JU480yD?jPa9hJ(xnW46&Q1d(nkrCGx_!ryUV^+MnkZOnUnv1*$hPTzu{1>^MDWYmkdZ;IYGcx&shmlw=ZEw%d& z0TloJw8S8K*zvwSQrnr?{`_T8(^SQ5*VY?`-}xR+Dz>rb486`4gCG{cg0NHAwA zhj3vhgr%oMMhtg8J+|55{E&1-Uz=Q_lqz|fBTVAmvU_Jc{eQ<=@voMmA}rJF`L(Dv z9K(x$ztjo|LqEb%{?AJ-^mP;f_Sg4k=sRXvK#g4g{{0ySFZIn?o7VBKg%7U1Sx$O8 zoqLNi`t!aol*zbgDPt?utsy+Knmd^&2(v^j^v}4|`s5m62ZP-}Boh zNg5`P|KuBU;|bmHu$`NwtDEbLJFY9)(ye->CdV^H(i%UZc5T_!)naF;XY;UgQ+igi zi$@OIJ9G=YQnz3LuF%)EJ|+gn%rL+Voy7?}F8)8~1|5PiBXVo?{hXtjxl#rvm*M+JJ3(vr7bWixFWS2E1QF7hM{8!d`m zq#_Li&C%z_N;Xz2-(3;c*Fn#3Ep{vRsg_&_r@FndpjW)nFrnJ&fZcu=e>ZGEB{iQ) zabr-%dTfF(H$*a_I?gV*T-f(y41-iziZXPEqHxz?a|K(HWlRrdHMGlL) zm@BO7RoI6^Gj;nkNV7yZBs==UDwm5y`KjZ#HF4FK_al z$-d<@eM6fV5fMXjy)@DG&KhN(GOtW1dTPJul6PFKSZwp4)$a&Qx-Ng+!-Hq^wi)`V zff=$ZwYeAMA74a`w+ljrCp~ zXMMNvtL60I8x)=#1aKDO~{~*9Mi-r@Q9T z8F>jY@C(Va`b8cS-WOBjl+NjiG)g*X=QM{oG*-nVvCiq8Y^H^we3NXs9-ayLJr{E3 zb@J25wcS>p8F+v^FrGXd{MzU&{jF+OoPn3x$j8s zr&XSE+-j{rWWuzjS92G+-gi8gq0XYkkH*v^dRMNfSau*6kagrJcxCjug{MH5XNe(i z+tXxOz1CKZRMp=rwBc2=QXM2~nPkwJb{>Z9os3o$;#MZueY&y2ZI+6iFTaux%CH}$ zzBlKSL|Iqf9xcV<7HaJd+>UN z6gNt{^16@3Q5ze{xXV`G=dI$Sb z`JRD~c)Jph6ss2d6)t>le+C!kRL{_X7bFK)?|RY+PIPUB1EiDd{6Vq{s zl*=8gG?n*yCAHeobgBQy6W?cEdGz6=ctfEc`P?S$vi`;EJp(?wlL3dD zTRAz|x72ZPi2Xk<)@$M2j@VT_4w^D7}-M*phQ9{8@(^YwOu)kFJt-wA7HT zF|SzkKn}-rpYW~Q=$3lE@tQ&sJ4_YpO^KPtkR6q&8Nex9oB!; z^WD?n^X&XA*YDNLvw4*hOZ4!%#m)UOjUk^ShhfeO#H1nj!u9tpphKgnxW=!C?3O`X z%8VV6dvdN_ud0zKnkYx^w9o7y>UwVXFujEEc+Bd1je1YUiY55_Hd>hK@LgU3qwX{4w;R-+*?JfYNA8CO;ra~ zS^GP<(?)f?X++*W^Z6NZMVenal7fCSh(|VWB9mOFXK6m~L(2fY$Aj({hZ?@O%il7& zk~sIL?rc^Y2u!BpM)N+gTpx1q4|&$UVf@?#TDz1dgXDDcK14g`E`{gtPU6Ru zYR!LsbE-T7n8GCS$O4K`hL-8;*ol^3d8h?Zd8Np`YB`B3*X>x)!;DTu8@C|)jP}&T zAg#OdMD3~sA=FI}5Vf49Okejpbry>C`~+}|n=y?7x<4bzZ)v6V>}(kL-aBVfiGLSg zdE#YM1D+^}9iz}OoK+PYXgA~L+0I8dY#BRLFB@9rrK!5Sno$LR(0kzay_ihgP{VZ! z=1ig)Q4F4k4Tl*n%$MnYzWz~;dT_CS*zVN2O~^HTU1K9n;b7J(CCi?ZfnAk_QkhMt z`G@b%A3R_?OZz?-#hqH)T!WH!9Z*b_YIALWz4NFoqfh8r#zY7L%l9U!dr|cHgv;QV zqF^;ou;z--ibPH}-4ku0(*=$t0G>srz2eD<)%m0smL2~|t8mlMrq@6)ZX4fZePl24 z4Q$8*V!HgIO@|}RY#&|?`Yf;M9~%??@f-lYha8g(;}?(jSc85NKoAZDUqU}oaS?!q zER&ub%7 literal 0 HcmV?d00001 diff --git a/crates/client-tauri/icons/icon.ico b/crates/client-tauri/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..2caf0fbe684a17e38fc56d1094fa5cf0d58e30af GIT binary patch literal 7012 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 + +/// 全局信令客户端 +static SIGNAL_CLIENT: tokio::sync::OnceCell>>> = tokio::sync::OnceCell::const_new(); + +/// 强制下线标志 +static FORCE_OFFLINE_FLAG: AtomicBool = AtomicBool::new(false); + +/// 当前活跃的屏幕流会话 +static ACTIVE_SESSION: tokio::sync::OnceCell>>> = tokio::sync::OnceCell::const_new(); + +/// 活跃屏幕会话 +struct ActiveScreenSession { + session_id: String, + controller_device: String, + stop_flag: Arc, +} + +async fn get_active_session() -> &'static Arc>> { + ACTIVE_SESSION.get_or_init(|| async { + Arc::new(RwLock::new(None)) + }).await +} + +/// 获取信令客户端 +async fn get_signal_client() -> &'static Arc>> { + 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, delta: Option) { + 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 { + 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 { + 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 { + 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 { + 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, +) -> Result { + 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, String> { + let state = state.read().await; + Ok(state.current_user.clone()) +} + +/// 获取用户设备列表 +#[tauri::command] +pub async fn get_devices(state: State<'_, AppStateHandle>) -> 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 + .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 { + let state = state.read().await; + Ok(state.connection_state.clone()) +} + +/// 获取历史记录 +#[tauri::command] +pub async fn get_history( + state: State<'_, AppStateHandle>, + page: Option, + limit: Option, +) -> 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 + .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 { + 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(()) +} diff --git a/crates/client-tauri/src/main.rs b/crates/client-tauri/src/main.rs new file mode 100644 index 0000000..705ea30 --- /dev/null +++ b/crates/client-tauri/src/main.rs @@ -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 { + unsafe { + let mutex_name: Vec = 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 { + 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"); +} diff --git a/crates/client-tauri/src/state.rs b/crates/client-tauri/src/state.rs new file mode 100644 index 0000000..927d1c6 --- /dev/null +++ b/crates/client-tauri/src/state.rs @@ -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, + /// 认证令牌 + pub auth_token: Option, + /// 连接状态 + pub connection_state: ConnectionState, + /// 当前会话ID + pub current_session_id: Option, +} + +/// 当前用户信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CurrentUser { + pub id: String, + pub username: String, + pub email: Option, +} + +/// 连接状态 +#[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::(&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, Option) { + 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::(&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, + pub duration: Option, + pub connection_type: String, +} diff --git a/crates/client-tauri/tauri.conf.json b/crates/client-tauri/tauri.conf.json new file mode 100644 index 0000000..c9100dc --- /dev/null +++ b/crates/client-tauri/tauri.conf.json @@ -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 + } + ] + } +} diff --git a/crates/client-tauri/ui/index.html b/crates/client-tauri/ui/index.html new file mode 100644 index 0000000..2cd349a --- /dev/null +++ b/crates/client-tauri/ui/index.html @@ -0,0 +1,13 @@ + + + + + + + EasyRemote - 远程协助 + + +
+ + + diff --git a/crates/client-tauri/ui/package-lock.json b/crates/client-tauri/ui/package-lock.json new file mode 100644 index 0000000..2985882 --- /dev/null +++ b/crates/client-tauri/ui/package-lock.json @@ -0,0 +1,1367 @@ +{ + "name": "easyremote-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "easyremote-ui", + "version": "0.1.0", + "dependencies": { + "@tauri-apps/api": "^1.5.0", + "vue": "^3.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.3.0", + "vite": "^5.0.0", + "vue-tsc": "^1.8.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tauri-apps/api": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@tauri-apps/api/-/api-1.6.0.tgz", + "integrity": "sha512-rqI++FWClU5I2UBp4HXFvl+sBWkdigBkxnpJDQUWttNyG7IZP4FwQGhTNL5EOw0vI8i6eSAJ5frLqO7n7jbJdg==", + "license": "Apache-2.0 OR MIT", + "engines": { + "node": ">= 14.6.0", + "npm": ">= 6.6.0", + "yarn": ">= 1.19.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-1.11.1.tgz", + "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "1.11.1" + } + }, + "node_modules/@volar/source-map": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-1.11.1.tgz", + "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "muggle-string": "^0.3.1" + } + }, + "node_modules/@volar/typescript": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-1.11.1.tgz", + "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "1.11.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.26.tgz", + "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.26", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", + "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", + "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.26", + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz", + "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/language-core": { + "version": "1.8.27", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-1.8.27.tgz", + "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "~1.11.1", + "@volar/source-map": "~1.11.1", + "@vue/compiler-dom": "^3.3.0", + "@vue/shared": "^3.3.0", + "computeds": "^0.0.1", + "minimatch": "^9.0.3", + "muggle-string": "^0.3.1", + "path-browserify": "^1.0.1", + "vue-template-compiler": "^2.7.14" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.26.tgz", + "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.26.tgz", + "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz", + "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/runtime-core": "3.5.26", + "@vue/shared": "3.5.26", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.26.tgz", + "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "vue": "3.5.26" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.26.tgz", + "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/computeds": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/computeds/-/computeds-0.0.1.tgz", + "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.3.1.tgz", + "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.26", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.26.tgz", + "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-sfc": "3.5.26", + "@vue/runtime-dom": "3.5.26", + "@vue/server-renderer": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-template-compiler": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", + "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/vue-tsc": { + "version": "1.8.27", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-1.8.27.tgz", + "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "~1.11.1", + "@vue/language-core": "1.8.27", + "semver": "^7.5.4" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": "*" + } + } + } +} diff --git a/crates/client-tauri/ui/package.json b/crates/client-tauri/ui/package.json new file mode 100644 index 0000000..c1730bf --- /dev/null +++ b/crates/client-tauri/ui/package.json @@ -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" + } +} diff --git a/crates/client-tauri/ui/src/App.vue b/crates/client-tauri/ui/src/App.vue new file mode 100644 index 0000000..429e88e --- /dev/null +++ b/crates/client-tauri/ui/src/App.vue @@ -0,0 +1,1328 @@ + + + + + diff --git a/crates/client-tauri/ui/src/main.ts b/crates/client-tauri/ui/src/main.ts new file mode 100644 index 0000000..97128ba --- /dev/null +++ b/crates/client-tauri/ui/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './styles/main.css' + +createApp(App).mount('#app') diff --git a/crates/client-tauri/ui/src/styles/main.css b/crates/client-tauri/ui/src/styles/main.css new file mode 100644 index 0000000..432e7d5 --- /dev/null +++ b/crates/client-tauri/ui/src/styles/main.css @@ -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; +} diff --git a/crates/client-tauri/ui/src/types.ts b/crates/client-tauri/ui/src/types.ts new file mode 100644 index 0000000..bf434c6 --- /dev/null +++ b/crates/client-tauri/ui/src/types.ts @@ -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; +} diff --git a/crates/client-tauri/ui/src/vite-env.d.ts b/crates/client-tauri/ui/src/vite-env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/crates/client-tauri/ui/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/crates/client-tauri/ui/tsconfig.json b/crates/client-tauri/ui/tsconfig.json new file mode 100644 index 0000000..a18b191 --- /dev/null +++ b/crates/client-tauri/ui/tsconfig.json @@ -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" }] +} diff --git a/crates/client-tauri/ui/tsconfig.node.json b/crates/client-tauri/ui/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/crates/client-tauri/ui/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/crates/client-tauri/ui/vite.config.ts b/crates/client-tauri/ui/vite.config.ts new file mode 100644 index 0000000..c1d3d0a --- /dev/null +++ b/crates/client-tauri/ui/vite.config.ts @@ -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, + }, +}) diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml new file mode 100644 index 0000000..c81a111 --- /dev/null +++ b/crates/common/Cargo.toml @@ -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" diff --git a/crates/common/src/crypto.rs b/crates/common/src/crypto.rs new file mode 100644 index 0000000..e842e1e --- /dev/null +++ b/crates/common/src/crypto.rs @@ -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 { + 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, +} + +impl Encryptor { + /// 创建新的加密器 + pub fn new(key: &[u8; KEY_SIZE]) -> Result { + 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> { + 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, +} + +impl Decryptor { + /// 创建新的解密器 + pub fn new(key: &[u8; KEY_SIZE]) -> Result { + 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> { + 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)); + } +} diff --git a/crates/common/src/error.rs b/crates/common/src/error.rs new file mode 100644 index 0000000..a1687b9 --- /dev/null +++ b/crates/common/src/error.rs @@ -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 = std::result::Result; + +impl From for Error { + fn from(e: serde_json::Error) -> Self { + Error::SerializationError(e.to_string()) + } +} + +impl From for Error { + fn from(e: bincode::Error) -> Self { + Error::SerializationError(e.to_string()) + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs new file mode 100644 index 0000000..843a55c --- /dev/null +++ b/crates/common/src/lib.rs @@ -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::*; diff --git a/crates/common/src/protocol.rs b/crates/common/src/protocol.rs new file mode 100644 index 0000000..467a3a0 --- /dev/null +++ b/crates/common/src/protocol.rs @@ -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, + /// 时间戳 + pub timestamp: i64, +} + +impl ProtocolMessage { + pub fn new(msg_type: MessageType, payload: Vec) -> Self { + use chrono::Utc; + Self { + id: rand::random(), + msg_type, + payload, + timestamp: Utc::now().timestamp_millis(), + } + } + + pub fn encode(&self) -> crate::Result> { + bincode::serialize(self).map_err(Into::into) + } + + pub fn decode(data: &[u8]) -> crate::Result { + 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, + pub error: Option, +} + +// ==================== 信令消息 ==================== + +/// 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, + pub sdp_mline_index: Option, +} + +// ==================== 连接消息 ==================== + +/// 连接请求 +#[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, + /// 时间戳 + 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, +} + +/// 剪贴板格式 +#[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, + 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, +} diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs new file mode 100644 index 0000000..b217ca4 --- /dev/null +++ b/crates/common/src/types.rs @@ -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, + pub created_at: DateTime, + pub last_login: Option>, +} + +/// 设备信息 +#[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, + pub is_online: bool, + pub allow_remote: bool, + pub last_seen: DateTime, + pub created_at: DateTime, +} + +/// 操作系统类型 +#[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, + pub ended_at: Option>, + 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, + pub controller_device_id: DeviceId, + pub controlled_device_id: DeviceId, + pub controlled_device_name: String, + pub started_at: DateTime, + pub ended_at: Option>, + pub duration_seconds: Option, + 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, +} + +/// 注册请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegisterRequest { + pub username: String, + pub password: String, + pub email: Option, +} diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs new file mode 100644 index 0000000..2dccb16 --- /dev/null +++ b/crates/common/src/utils.rs @@ -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) -> 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() +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml new file mode 100644 index 0000000..604cb28 --- /dev/null +++ b/crates/server/Cargo.toml @@ -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" \ No newline at end of file diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs new file mode 100644 index 0000000..1c1db0a --- /dev/null +++ b/crates/server/src/config.rs @@ -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, + /// 数据库URL + pub database_url: String, + /// JWT密钥 + pub jwt_secret: String, + /// JWT过期时间(秒) + pub jwt_expiry: i64, + /// STUN服务器地址列表 + pub stun_servers: Vec, + /// 是否启用本地 STUN 服务 + pub enable_local_stun: bool, + /// 是否启用本地 TURN 服务 + pub enable_local_turn: bool, + /// TURN服务器地址(如果不使用本地) + pub turn_server: Option, + /// 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, + pub turn_server: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TurnConfig { + pub url: String, + pub username: String, + pub credential: String, +} + +impl Config { + pub fn from_env() -> Result { + 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 = 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, + } + } +} diff --git a/crates/server/src/db.rs b/crates/server/src/db.rs new file mode 100644 index 0000000..8d603b7 --- /dev/null +++ b/crates/server/src/db.rs @@ -0,0 +1,120 @@ +//! 数据库管理 + +use anyhow::Result; +use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite}; + +pub type DbPool = Pool; + +#[derive(Clone)] +pub struct Database { + pub pool: DbPool, +} + +impl Database { + pub async fn new(database_url: &str) -> Result { + 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(()) + } +} diff --git a/crates/server/src/handlers/admin.rs b/crates/server/src/handlers/admin.rs new file mode 100644 index 0000000..231574a --- /dev/null +++ b/crates/server/src/handlers/admin.rs @@ -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, + pub turn_port: u16, + pub turn_enabled: bool, + pub turn_server: Option, + 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>, + _admin: AdminUser, + Query(params): Query, +) -> impl IntoResponse { + let repo = UserRepository::new(&state.db); + + match repo.find_all(params.offset(), params.limit()).await { + Ok((users, total)) => { + let items: Vec = 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>, + _admin: AdminUser, + Path(user_id): Path, +) -> 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>, + _admin: AdminUser, + Query(params): Query, +) -> 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 = 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>, + _admin: AdminUser, + Query(params): Query, +) -> impl IntoResponse { + let repo = SessionRepository::new(&state.db); + + match repo.find_all(params.offset(), params.limit()).await { + Ok((sessions, total)) => { + let items: Vec = 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>, + _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>, + _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, +) -> 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() +} diff --git a/crates/server/src/handlers/auth.rs b/crates/server/src/handlers/auth.rs new file mode 100644 index 0000000..98bbf8d --- /dev/null +++ b/crates/server/src/handlers/auth.rs @@ -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>, + Json(req): Json, +) -> 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>, + Json(req): Json, +) -> 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>, + _user: AuthUser, +) -> impl IntoResponse { + // JWT 是无状态的,客户端只需删除本地令牌 + // 如果需要服务端失效令牌,可以使用令牌黑名单 + (StatusCode::OK, Json(ApiResponse::ok("已退出登录"))).into_response() +} + +/// 刷新令牌 +pub async fn refresh_token( + State(state): State>, + 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()), + } +} diff --git a/crates/server/src/handlers/devices.rs b/crates/server/src/handlers/devices.rs new file mode 100644 index 0000000..192fe66 --- /dev/null +++ b/crates/server/src/handlers/devices.rs @@ -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>, + OptionalAuthUser(auth_user): OptionalAuthUser, + Json(req): Json, +) -> 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>, + user: AuthUser, +) -> impl IntoResponse { + let repo = DeviceRepository::new(&state.db); + + match repo.find_by_user(&user.user_id).await { + Ok(devices) => { + let response: Vec = 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>, + Path(device_id): Path, + 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>, + Path(device_id): Path, + 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>, + Path(device_id): Path, + 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() + } +} diff --git a/crates/server/src/handlers/mod.rs b/crates/server/src/handlers/mod.rs new file mode 100644 index 0000000..8e32482 --- /dev/null +++ b/crates/server/src/handlers/mod.rs @@ -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> for AuthUser { + type Rejection = AuthError; + + async fn from_request_parts(parts: &mut Parts, state: &Arc) -> Result { + // 从 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); + +#[axum::async_trait] +impl FromRequestParts> for OptionalAuthUser { + type Rejection = std::convert::Infallible; + + async fn from_request_parts(parts: &mut Parts, state: &Arc) -> Result { + Ok(OptionalAuthUser( + AuthUser::from_request_parts(parts, state).await.ok() + )) + } +} + +/// 管理员用户提取器 +pub struct AdminUser(pub AuthUser); + +#[axum::async_trait] +impl FromRequestParts> for AdminUser { + type Rejection = AuthError; + + async fn from_request_parts(parts: &mut Parts, state: &Arc) -> Result { + 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() +} diff --git a/crates/server/src/handlers/sessions.rs b/crates/server/src/handlers/sessions.rs new file mode 100644 index 0000000..ad112e2 --- /dev/null +++ b/crates/server/src/handlers/sessions.rs @@ -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>, + 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 = 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>, + user: AuthUser, + Query(params): Query, +) -> 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 = 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>, + Path(session_id): Path, + _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>, + Path(session_id): Path, + 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()), + } +} diff --git a/crates/server/src/handlers/setup.rs b/crates/server/src/handlers/setup.rs new file mode 100644 index 0000000..a318309 --- /dev/null +++ b/crates/server/src/handlers/setup.rs @@ -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, + pub turn_username: Option, + pub turn_password: Option, +} + +/// 初始化响应 +#[derive(Debug, Serialize)] +pub struct SetupInitResponse { + pub token: String, + pub message: String, +} + +/// 检查是否需要初始化 +pub async fn check_setup_status( + State(state): State>, +) -> 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>, + Json(req): Json, +) -> impl IntoResponse { + // 验证输入 + if req.admin_username.len() < 3 { + return ( + StatusCode::BAD_REQUEST, + Json(ApiResponse::::err("用户名至少3个字符")), + ); + } + if req.admin_password.len() < 6 { + return ( + StatusCode::BAD_REQUEST, + Json(ApiResponse::::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::::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::::err(format!("创建用户失败: {}", e))), + ); + } + }; + + // 将用户设置为管理员 + if let Err(e) = repo.set_role(&user.id, "admin").await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::::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::::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::::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 文件"); + } +} diff --git a/crates/server/src/handlers/users.rs b/crates/server/src/handlers/users.rs new file mode 100644 index 0000000..6f5469e --- /dev/null +++ b/crates/server/src/handlers/users.rs @@ -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>, + 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>, + user: AuthUser, + Json(req): Json, +) -> 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()), + } +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs new file mode 100644 index 0000000..32fe686 --- /dev/null +++ b/crates/server/src/main.rs @@ -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>) -> Json { + Json(state.config.get_ice_servers()) +} diff --git a/crates/server/src/models.rs b/crates/server/src/models.rs new file mode 100644 index 0000000..0ec2c0d --- /dev/null +++ b/crates/server/src/models.rs @@ -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, + pub role: String, + pub created_at: String, + pub last_login: Option, +} + +/// 数据库设备模型 +#[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, + pub is_online: bool, + pub allow_remote: bool, + pub verification_code: Option, + 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, + pub connection_type: String, + pub status: String, +} + +/// 数据库控制历史模型 +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct HistoryRow { + pub id: String, + pub user_id: Option, + pub controller_device_id: String, + pub controlled_device_id: String, + pub controlled_device_name: String, + pub started_at: String, + pub ended_at: Option, + pub duration_seconds: Option, + pub connection_type: String, +} + +// ==================== API 请求/响应模型 ==================== + +/// 注册请求 +#[derive(Debug, Deserialize)] +pub struct RegisterRequest { + pub username: String, + pub password: String, + pub email: Option, +} + +/// 登录请求 +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, + pub device_id: Option, +} + +/// 认证响应 +#[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, + 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, + pub username: Option, +} + +/// 会话响应 +#[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, + 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, + pub duration: Option, + 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, + pub password: Option, +} + +/// 通用API响应 +#[derive(Debug, Serialize)] +pub struct ApiResponse { + pub success: bool, + pub data: Option, + pub error: Option, +} + +impl ApiResponse { + 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, + pub limit: Option, +} + +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 { + pub items: Vec, + pub total: i64, + pub page: u32, + pub limit: u32, + pub total_pages: u32, +} diff --git a/crates/server/src/services/auth.rs b/crates/server/src/services/auth.rs new file mode 100644 index 0000000..3a918f3 --- /dev/null +++ b/crates/server/src/services/auth.rs @@ -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 { + 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 { + let token_data = decode::( + 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 { + 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 { + 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> { + 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, 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 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, + } + } +} diff --git a/crates/server/src/services/device.rs b/crates/server/src/services/device.rs new file mode 100644 index 0000000..272d55f --- /dev/null +++ b/crates/server/src/services/device.rs @@ -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 { + // 检查设备是否已存在 + 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 { + 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> { + 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> { + 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 { + 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 { + 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 { + 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, 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, 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 { + 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 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, + pub is_online: bool, + pub allow_remote: bool, + pub verification_code: Option, + pub last_seen: String, + pub created_at: String, + pub username: Option, +} + +impl From 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, + } + } +} diff --git a/crates/server/src/services/mod.rs b/crates/server/src/services/mod.rs new file mode 100644 index 0000000..40f33aa --- /dev/null +++ b/crates/server/src/services/mod.rs @@ -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, + pub tx: tokio::sync::mpsc::Sender, +} + +/// 应用状态 +pub struct AppState { + pub db: Database, + pub config: Config, + /// 在线设备的WebSocket连接 (device_id -> connection) + pub connections: RwLock>, + /// 活跃的远程控制会话 (session_id -> (controller_ws, controlled_ws)) + pub active_sessions: RwLock>, +} + +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, tx: tokio::sync::mpsc::Sender) { + 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) + } +} diff --git a/crates/server/src/services/session.rs b/crates/server/src/services/session.rs new file mode 100644 index 0000000..aff3f03 --- /dev/null +++ b/crates/server/src/services/session.rs @@ -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 { + 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 { + 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> { + 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> { + 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, 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 { + 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 { + 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 { + 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, 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, 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 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 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, + } + } +} diff --git a/crates/server/src/stun_server.rs b/crates/server/src/stun_server.rs new file mode 100644 index 0000000..95fe289 --- /dev/null +++ b/crates/server/src/stun_server.rs @@ -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> { + 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 { + 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 { + 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) +} diff --git a/crates/server/src/turn_server.rs b/crates/server/src/turn_server.rs new file mode 100644 index 0000000..16bc428 --- /dev/null +++ b/crates/server/src/turn_server.rs @@ -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, + relay_addr: SocketAddr, + username: String, + permissions: HashMap, // peer IP -> expiry + channels: HashMap, // channel number -> peer addr + created_at: Instant, + lifetime: Duration, +} + +/// TURN Server +pub struct TurnServer { + port: u16, + realm: String, + username: String, + password: String, + allocations: Arc>>, + nonces: Arc>>, +} + +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>>, + nonces: &Arc>>, +) { + 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>>, + nonces: &Arc>>, +) -> 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>>, + nonces: &Arc>>, +) -> 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>>, +) -> 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>>, +) -> 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>>, +) -> 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>>, +) -> 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, + main_socket: UdpSocket, + client_addr: SocketAddr, + allocations: Arc>>, +) { + 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>> { + 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 { + 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> { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 +} diff --git a/crates/server/src/websocket.rs b/crates/server/src/websocket.rs new file mode 100644 index 0000000..540f362 --- /dev/null +++ b/crates/server/src/websocket.rs @@ -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, +} + +/// 信令消息 +#[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, + }, + /// 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, + sdp_mline_index: Option, + }, + /// 会话结束 + #[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, + delta: Option, + }, + /// 键盘事件 + #[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>, + Query(query): Query, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_signal_socket(socket, state, query)) +} + +async fn handle_signal_socket(socket: WebSocket, state: Arc, query: WsQuery) { + let (mut sender, mut receiver) = socket.split(); + let (tx, mut rx) = mpsc::channel::(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, 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>, + Path(device_id): Path, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_remote_socket(socket, state, device_id)) +} + +async fn handle_remote_socket(socket: WebSocket, state: Arc, 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::(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; +} diff --git a/crates/server/static/index.html b/crates/server/static/index.html new file mode 100644 index 0000000..b53df7d --- /dev/null +++ b/crates/server/static/index.html @@ -0,0 +1,2359 @@ + + + + + + + EasyRemote 管理后台 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..a85b55f --- /dev/null +++ b/static/index.html @@ -0,0 +1,2258 @@ + + + + + + + EasyRemote 管理后台 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/static/static/index.html b/static/static/index.html new file mode 100644 index 0000000..42fffa7 --- /dev/null +++ b/static/static/index.html @@ -0,0 +1,979 @@ + + + + + + EasyRemote 管理后台 + + + + + + + + + + + +