Implement P2P connection features, including local IP retrieval, P2P address exchange, and connection readiness handling. Enhance input control testing functionality and improve WebSocket message handling for P2P events. Update UI to support local input control testing and display results. Adjust connection state management for P2P scenarios.

This commit is contained in:
Ethanfly 2026-01-06 10:27:04 +08:00
parent fd34c415f5
commit 0ad21a24b0
13 changed files with 2263 additions and 151 deletions

View File

@ -225,6 +225,31 @@ pub struct TurnConfig {
pub struct NatTraversal;
impl NatTraversal {
/// 获取所有本地 IP 地址
pub fn get_local_ips() -> Vec<String> {
let mut ips = Vec::new();
// 尝试使用 socket 连接来获取本地 IP
if let Ok(socket) = std::net::UdpSocket::bind("0.0.0.0:0") {
// 连接到公网地址来获取本机实际使用的 IP
if let Ok(()) = socket.connect("8.8.8.8:80") {
if let Ok(addr) = socket.local_addr() {
let ip = addr.ip().to_string();
if !ip.starts_with("127.") && ip != "0.0.0.0" {
ips.push(ip);
}
}
}
}
// 如果没有获取到,添加 localhost
if ips.is_empty() {
ips.push("127.0.0.1".to_string());
}
ips
}
/// 从服务器获取 ICE 服务器配置
pub async fn fetch_ice_servers(server_url: &str) -> Result<IceServersConfig> {
// 将 ws:// 或 wss:// 转换为 http:// 或 https://

View File

@ -19,6 +19,14 @@ pub struct DisplayInfoMsg {
pub name: String,
}
/// TURN 服务器信息(用于 P2P 中继)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TurnServerInfo {
pub url: String,
pub username: String,
pub credential: String,
}
/// 信令消息
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
@ -162,6 +170,38 @@ pub enum SignalMessage {
fps: u32,
quality: u32,
},
/// P2P 地址交换请求
#[serde(rename = "p2p_exchange")]
P2PExchange {
session_id: String,
from_device: String,
to_device: String,
/// 本地地址列表 (局域网)
local_addrs: Vec<String>,
/// 公网地址 (通过 STUN 获取)
public_addr: Option<String>,
/// UDP 监听端口
udp_port: u16,
/// TURN 服务器信息(用于中继)
turn_server: Option<TurnServerInfo>,
},
/// P2P 连接就绪
#[serde(rename = "p2p_ready")]
P2PReady {
session_id: String,
from_device: String,
to_device: String,
/// 成功连接的地址
connected_addr: String,
},
/// P2P 连接失败,回退到服务器中转
#[serde(rename = "p2p_fallback")]
P2PFallback {
session_id: String,
from_device: String,
to_device: String,
reason: String,
},
}
/// 信令客户端
@ -182,6 +222,11 @@ impl SignalClient {
}
}
/// 获取服务器 URL
pub fn server_url(&self) -> Option<String> {
Some(self.server_url.clone())
}
/// 连接到信令服务器
pub async fn connect(
&mut self,
@ -222,14 +267,42 @@ impl SignalClient {
tokio::spawn(async move {
while let Some(Ok(msg)) = read.next().await {
if let Message::Text(text) = msg {
println!("★★★ [客户端] 收到WebSocket消息: {}", &text[..text.len().min(200)]);
// 检测消息类型
let is_heartbeat = text.contains("heartbeat");
let is_mouse = text.contains("mouse_event");
let is_keyboard = text.contains("keyboard_event");
let is_screen = text.contains("screen_frame");
// 打印控制消息(鼠标、键盘)
if is_mouse || is_keyboard {
println!("★★★ [被控端] 收到控制消息: {}", &text[..text.len().min(200)]);
} else if !is_heartbeat && !is_screen {
println!("★★★ [客户端] 收到WebSocket消息: {}", &text[..text.len().min(300)]);
}
match serde_json::from_str::<SignalMessage>(&text) {
Ok(signal_msg) => {
println!("★★★ [客户端] 解析成功: {:?}", std::mem::discriminant(&signal_msg));
// 打印非心跳、非屏幕帧消息
match &signal_msg {
SignalMessage::HeartbeatAck => {},
SignalMessage::Heartbeat { .. } => {},
SignalMessage::ScreenFrame { .. } => {},
SignalMessage::MouseEvent { x, y, event_type, .. } => {
if event_type != "move" {
println!("★★★ [被控端] 解析鼠标事件成功: type={}, x={:.0}, y={:.0}", event_type, x, y);
}
}
SignalMessage::KeyboardEvent { key, event_type, .. } => {
println!("★★★ [被控端] 解析键盘事件成功: key={}, type={}", key, event_type);
}
other => {
println!("★★★ [客户端] 解析成功: {:?}", std::mem::discriminant(other));
}
}
on_message(signal_msg);
}
Err(e) => {
println!("★★★ [客户端] 解析失败: {}, 原始消息: {}", e, &text[..text.len().min(100)]);
println!("★★★ [客户端] 解析失败: {}, 原始消息: {}", e, &text[..text.len().min(200)]);
}
}
}

View File

@ -11,7 +11,7 @@ easyremote-common = { path = "../common" }
easyremote-client-core = { path = "../client-core" }
# Tauri
tauri = { version = "1.5", features = ["shell-open", "dialog-all", "clipboard-all", "window-all", "global-shortcut-all", "process-all", "system-tray"] }
tauri = { version = "1.5", features = ["shell-open", "dialog-all", "clipboard-all", "window-all", "global-shortcut-all", "process-all", "system-tray", "window-data-url"] }
# Windows single instance
[target.'cfg(windows)'.dependencies]
@ -46,6 +46,8 @@ thiserror = { workspace = true }
dirs = "5.0"
image = { workspace = true }
once_cell = "1.19"
urlencoding = "2.1"
url = "2.5"
[features]
default = ["custom-protocol"]

File diff suppressed because it is too large Load Diff

View File

@ -176,11 +176,16 @@ fn main() {
_ => {}
})
.on_window_event(|event| {
// 关闭窗口时最小化到托盘而不是退出
// 关闭窗口时的处理
if let WindowEvent::CloseRequested { api, .. } = event.event() {
event.window().hide().unwrap();
let window = event.window();
// 只有主窗口最小化到托盘,其他窗口(如远程控制窗口)直接关闭
if window.label() == "main" {
window.hide().unwrap();
api.prevent_close();
}
// 远程控制窗口直接关闭(不阻止)
}
})
.setup(|app| {
// 设置窗口标题以便单例检测
@ -224,6 +229,7 @@ fn main() {
commands::connect_to_device,
commands::disconnect,
commands::get_connection_state,
commands::open_remote_window,
// 历史记录
commands::get_history,
// 配置
@ -233,6 +239,8 @@ fn main() {
// 开机启动
commands::get_autostart,
commands::set_autostart,
// 测试
commands::test_local_input,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@ -4,6 +4,24 @@ use easyremote_common::types::{DeviceId, VerificationCode};
use easyremote_client_core::ClientConfig;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio::net::UdpSocket;
/// P2P 连接信息
#[derive(Debug, Clone)]
pub struct P2PConnection {
/// 会话 ID
pub session_id: String,
/// 对方设备 ID
pub peer_device_id: String,
/// 本地 UDP 端口
pub local_port: u16,
/// 对方地址
pub peer_addr: Option<std::net::SocketAddr>,
/// 是否已连接
pub connected: bool,
}
/// 应用状态
pub struct AppState {
@ -21,6 +39,10 @@ pub struct AppState {
pub connection_state: ConnectionState,
/// 当前会话ID
pub current_session_id: Option<String>,
/// P2P 连接(用于被控端接收直连数据)
pub p2p_socket: Option<Arc<UdpSocket>>,
/// P2P 连接信息
pub p2p_connection: Option<P2PConnection>,
}
/// 当前用户信息
@ -38,15 +60,20 @@ pub enum ConnectionState {
Disconnected,
/// 正在连接
Connecting,
/// 正在进行 P2P 握手
P2PHandshaking {
target_device_id: String,
},
/// 已连接到设备
Connected {
target_device_id: String,
target_device_name: String,
connection_type: String,
connection_type: String, // "p2p" 或 "relay"
},
/// 被控制中
BeingControlled {
controller_device_id: String,
connection_type: String, // "p2p" 或 "relay"
},
}
@ -75,6 +102,8 @@ impl AppState {
auth_token,
connection_state: ConnectionState::Disconnected,
current_session_id: None,
p2p_socket: None,
p2p_connection: None,
}
}

View File

@ -83,6 +83,7 @@
},
"windows": [
{
"label": "main",
"fullscreen": false,
"height": 700,
"resizable": true,

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { invoke } from '@tauri-apps/api/tauri'
import { WebviewWindow } from '@tauri-apps/api/window'
import type { DeviceInfo, CurrentUser, Device, HistoryItem, ConnectRequest, ConnectionState, ClientConfig } from './types'
//
@ -32,6 +33,9 @@ const configForm = ref({
server_url: ''
})
//
const testResult = ref('')
//
const activeTab = ref<'local' | 'remote' | 'devices' | 'history' | 'settings'>('local')
@ -100,6 +104,19 @@ async function toggleAutostart() {
}
}
//
async function testLocalInput() {
try {
testResult.value = '正在测试...'
const result = await invoke('test_local_input')
testResult.value = result as string
showSuccess('测试完成,请查看结果')
} catch (e: any) {
testResult.value = '测试失败: ' + e
showError('测试失败: ' + e)
}
}
async function loadConfig() {
try {
config.value = await invoke('get_config')
@ -109,6 +126,14 @@ async function loadConfig() {
}
}
async function loadDeviceInfo() {
try {
deviceInfo.value = await invoke('get_device_info')
} catch (e) {
console.error('加载设备信息失败:', e)
}
}
async function saveConfig() {
loading.value = true
try {
@ -238,34 +263,49 @@ async function connectToDevice(targetDeviceId?: string, targetCode?: string) {
return
}
loading.value = true
error.value = ''
try {
const request: ConnectRequest = {
//
const tempDevice: Device = {
id: '',
device_id: deviceId.replace(/\s/g, ''),
verification_code: code
}
await invoke('connect_to_device', { request })
connectionState.value = await invoke('get_connection_state')
showSuccess('连接成功')
} catch (e: any) {
error.value = e.toString()
} finally {
loading.value = false
name: `设备 ${deviceId}`,
os_type: 'Unknown',
os_version: '',
is_online: true,
last_seen: ''
}
//
openRemoteWindow(tempDevice)
}
async function quickConnect(device: Device) {
loading.value = true
error.value = ''
try {
await invoke('connect_to_device', {
request: { device_id: device.device_id, verification_code: '' }
//
openRemoteWindow(device)
}
//
async function openRemoteWindow(device: Device) {
const serverUrl = config.value.server_url
console.log('打开远程控制窗口:', {
deviceId: device.device_id,
deviceName: device.name,
serverUrl: serverUrl
})
connectionState.value = await invoke('get_connection_state')
showSuccess('正在连接...')
//
try {
loading.value = true
await invoke('open_remote_window', {
deviceId: device.device_id,
deviceName: device.name,
serverUrl: serverUrl
})
console.log('远程控制窗口命令已发送')
showSuccess('正在打开远程控制窗口...')
} catch (e: any) {
error.value = e.toString()
console.error('打开远程控制窗口失败:', e)
showError('打开远程控制窗口失败: ' + e.toString())
} finally {
loading.value = false
}
@ -749,6 +789,24 @@ function getDeviceIcon(osType: string): string {
</div>
</div>
<!-- 调试工具 -->
<div class="card">
<div class="card-header">
<h3 class="card-title">调试工具</h3>
</div>
<div class="settings-list">
<div class="settings-item">
<span class="settings-label">测试本地输入控制</span>
<button class="btn btn-secondary btn-sm" @click="testLocalInput">
测试鼠标移动和点击
</button>
</div>
<div v-if="testResult" class="test-result">
<pre>{{ testResult }}</pre>
</div>
</div>
</div>
<!-- 关于 -->
<div class="card">
<div class="card-header">
@ -831,6 +889,7 @@ function getDeviceIcon(osType: string): string {
</div>
</div>
</transition>
</div>
</template>
@ -1258,6 +1317,30 @@ function getDeviceIcon(osType: string): string {
flex-direction: column;
}
/* 测试结果 */
.test-result {
margin-top: 12px;
padding: 12px;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
.test-result pre {
margin: 0;
color: var(--text-secondary);
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
/* 错误信息 */
.error-message {
color: var(--error);
@ -1439,4 +1522,5 @@ function getDeviceIcon(osType: string): string {
width: 100%;
}
}
</style>

View File

@ -89,7 +89,14 @@ pub async fn list_all_devices(
match repo.find_all_with_username(params.offset(), params.limit()).await {
Ok((devices, total)) => {
let items: Vec<DeviceResponse> = devices.into_iter().map(Into::into).collect();
// 使用实时 WebSocket 连接状态覆盖数据库中的状态
let mut items: Vec<DeviceResponse> = Vec::with_capacity(devices.len());
for device in devices {
let mut device_resp: DeviceResponse = device.into();
// 检查实际的 WebSocket 连接状态
device_resp.is_online = state.is_device_online(&device_resp.device_id).await;
items.push(device_resp);
}
let total_pages = ((total as f64) / (params.limit() as f64)).ceil() as u32;
let response = PaginatedResponse {
@ -144,7 +151,8 @@ pub async fn get_stats(
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);
// 使用实时 WebSocket 连接数作为在线设备数
let online_devices = state.count_online_devices().await as i64;
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));

View File

@ -55,7 +55,14 @@ pub async fn list_devices(
match repo.find_by_user(&user.user_id).await {
Ok(devices) => {
let response: Vec<DeviceResponse> = devices.into_iter().map(Into::into).collect();
// 使用实时 WebSocket 连接状态覆盖数据库中的状态
let mut response: Vec<DeviceResponse> = Vec::with_capacity(devices.len());
for device in devices {
let mut device_resp: DeviceResponse = device.into();
// 检查实际的 WebSocket 连接状态
device_resp.is_online = state.is_device_online(&device_resp.device_id).await;
response.push(device_resp);
}
(StatusCode::OK, Json(ApiResponse::ok(response))).into_response()
}
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),

View File

@ -63,4 +63,10 @@ impl AppState {
let connections = self.connections.read().await;
connections.contains_key(device_id)
}
/// 获取在线设备数量
pub async fn count_online_devices(&self) -> usize {
let connections = self.connections.read().await;
connections.len()
}
}

View File

@ -169,6 +169,33 @@ pub enum SignalMessage {
to_device: String,
display_index: usize,
},
/// P2P 地址交换
#[serde(rename = "p2p_exchange")]
P2PExchange {
session_id: String,
from_device: String,
to_device: String,
local_addrs: Vec<String>,
public_addr: Option<String>,
udp_port: u16,
turn_server: Option<TurnServerInfoMsg>,
},
/// P2P 连接就绪
#[serde(rename = "p2p_ready")]
P2PReady {
session_id: String,
from_device: String,
to_device: String,
connected_addr: String,
},
/// P2P 回退到服务器中转
#[serde(rename = "p2p_fallback")]
P2PFallback {
session_id: String,
from_device: String,
to_device: String,
reason: String,
},
}
/// 显示器信息
@ -181,6 +208,14 @@ pub struct DisplayInfoMsg {
pub name: String,
}
/// TURN 服务器信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TurnServerInfoMsg {
pub url: String,
pub username: String,
pub credential: String,
}
/// 信令WebSocket处理器
pub async fn signal_handler(
ws: WebSocketUpgrade,
@ -418,6 +453,24 @@ async fn handle_signal_message(state: &Arc<AppState>, device_id: &str, text: &st
let _ = state.send_to_device(&to_device, text).await;
}
// P2P 地址交换 - 转发给对方
SignalMessage::P2PExchange { to_device, .. } => {
tracing::info!("★★★ P2P 地址交换: 转发到 {}", to_device);
let _ = state.send_to_device(&to_device, text).await;
}
// P2P 连接就绪 - 转发给对方
SignalMessage::P2PReady { to_device, .. } => {
tracing::info!("★★★ P2P 连接就绪: 转发到 {}", to_device);
let _ = state.send_to_device(&to_device, text).await;
}
// P2P 回退 - 转发给对方
SignalMessage::P2PFallback { to_device, .. } => {
tracing::info!("★★★ P2P 回退到服务器中转: 转发到 {}", to_device);
let _ = state.send_to_device(&to_device, text).await;
}
_ => {}
}
}
@ -494,6 +547,11 @@ async fn handle_remote_socket(socket: WebSocket, state: Arc<AppState>, device_id
let recv_task = tokio::spawn(async move {
while let Some(Ok(msg)) = receiver.next().await {
if let Message::Text(text) = msg {
// 打印调试信息(仅非移动事件)
if !text.contains("\"event_type\":\"move\"") {
tracing::info!("★★★ [Remote] 收到消息: {}", &text[..text.len().min(200)]);
}
// 替换 from_device 为正确的浏览器ID
let modified_text = if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(obj) = json.as_object_mut() {
@ -505,8 +563,12 @@ async fn handle_remote_socket(socket: WebSocket, state: Arc<AppState>, device_id
} else {
text
};
// 转发消息到目标设备
let _ = state_clone.send_to_device(&target_device_id, &modified_text).await;
let result = state_clone.send_to_device(&target_device_id, &modified_text).await;
if !text.contains("\"event_type\":\"move\"") {
tracing::info!("★★★ [Remote] 转发到 {} 结果: {}", target_device_id, result);
}
}
}
});