Initial commit: FunConnect project with server, relay, client and admin panel

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-24 20:56:36 +08:00
parent eb6e901440
commit b6891483ae
167 changed files with 16147 additions and 106 deletions

52
client/Cargo.toml Normal file
View File

@@ -0,0 +1,52 @@
[package]
name = "funmc-client"
version = "0.1.0"
edition = "2021"
[lib]
name = "funmc_client_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "funmc-client"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
funmc-shared = { path = "../shared" }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
tauri-plugin-notification = "2"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3"
sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls", "uuid", "chrono", "migrate"] }
# QUIC transport
quinn = "0.11"
rustls = { version = "0.23", default-features = false, features = ["ring"] }
rcgen = "0.13"
# Async networking helpers
bytes = "1"
rand = "0.8"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
local-ip-address = "0.6"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]

3
client/build.rs Normal file
View File

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

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://schema.tauri.app/config/2",
"identifier": "default",
"description": "FunMC 默认权限配置",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open",
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
"notification:allow-register-action-types"
]
}

9
client/icons/.gitkeep Normal file
View File

@@ -0,0 +1,9 @@
# Icon files should be placed here
# Required icons:
# - 32x32.png
# - 128x128.png
# - 128x128@2x.png
# - icon.icns (macOS)
# - icon.ico (Windows)
#
# Use `cargo tauri icon` command to generate icons from a source image

BIN
client/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

BIN
client/icons/128x128@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

BIN
client/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

BIN
client/icons/icon.icns Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

BIN
client/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

42
client/icons/icon.svg Normal file
View File

@@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a1d27"/>
<stop offset="100%" style="stop-color:#0f1117"/>
</linearGradient>
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4ade80"/>
<stop offset="100%" style="stop-color:#22c55e"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="512" height="512" rx="100" fill="url(#bg)"/>
<!-- Border -->
<rect x="8" y="8" width="496" height="496" rx="92" fill="none" stroke="url(#accent)" stroke-width="4" opacity="0.5"/>
<!-- Minecraft-style cube layers -->
<g transform="translate(256, 256)">
<!-- Bottom layer -->
<path d="M0 60 L-100 10 L0 -40 L100 10 Z" fill="#22c55e" opacity="0.6"/>
<!-- Middle layer -->
<path d="M0 20 L-100 -30 L0 -80 L100 -30 Z" fill="#4ade80" opacity="0.8"/>
<!-- Top layer -->
<path d="M0 -20 L-100 -70 L0 -120 L100 -70 Z" fill="#86efac"/>
<!-- Left side -->
<path d="M-100 -70 L-100 -30 L0 20 L0 -20 Z" fill="#16a34a" opacity="0.9"/>
<!-- Right side -->
<path d="M100 -70 L100 -30 L0 20 L0 -20 Z" fill="#15803d" opacity="0.9"/>
</g>
<!-- Connection lines representing multiplayer -->
<g stroke="#4ade80" stroke-width="3" fill="none" opacity="0.4">
<circle cx="130" cy="150" r="20"/>
<circle cx="382" cy="150" r="20"/>
<circle cx="130" cy="362" r="20"/>
<circle cx="382" cy="362" r="20"/>
<line x1="150" y1="150" x2="236" y2="200"/>
<line x1="362" y1="150" x2="276" y2="200"/>
<line x1="150" y1="362" x2="236" y2="312"/>
<line x1="362" y1="362" x2="276" y2="312"/>
</g>
<!-- FunMC text -->
<text x="256" y="440" text-anchor="middle" font-family="Arial, sans-serif" font-size="48" font-weight="bold" fill="#e2e8f0">FunMC</text>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS auth_cache (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -0,0 +1,68 @@
-- FunMC 客户端本地数据库
-- 用于存储本地缓存和设置
-- 用户会话信息缓存
CREATE TABLE IF NOT EXISTS user_session (
id INTEGER PRIMARY KEY CHECK (id = 1),
user_id TEXT NOT NULL,
username TEXT NOT NULL,
email TEXT NOT NULL,
avatar_seed TEXT NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL,
server_url TEXT NOT NULL DEFAULT 'https://funmc.com',
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
-- 最近加入的房间缓存
CREATE TABLE IF NOT EXISTS recent_rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT NOT NULL UNIQUE,
room_name TEXT NOT NULL,
owner_username TEXT NOT NULL,
game_version TEXT NOT NULL DEFAULT '1.20',
last_joined_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
-- 好友缓存(用于离线显示)
CREATE TABLE IF NOT EXISTS friends_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
avatar_seed TEXT NOT NULL,
is_online INTEGER NOT NULL DEFAULT 0,
cached_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
-- 应用设置
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
-- 连接历史记录
CREATE TABLE IF NOT EXISTS connection_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT NOT NULL,
room_name TEXT NOT NULL,
session_type TEXT NOT NULL CHECK (session_type IN ('p2p', 'relay')),
local_port INTEGER NOT NULL,
bytes_sent INTEGER NOT NULL DEFAULT 0,
bytes_received INTEGER NOT NULL DEFAULT 0,
latency_ms INTEGER,
started_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
ended_at INTEGER
);
-- 中继节点延迟缓存
CREATE TABLE IF NOT EXISTS relay_latency_cache (
node_url TEXT PRIMARY KEY NOT NULL,
node_name TEXT NOT NULL,
latency_ms INTEGER NOT NULL,
tested_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_recent_rooms_last_joined ON recent_rooms(last_joined_at DESC);
CREATE INDEX IF NOT EXISTS idx_connection_history_started ON connection_history(started_at DESC);

115
client/src/commands/auth.rs Normal file
View File

@@ -0,0 +1,115 @@
use serde::{Deserialize, Serialize};
use tauri::State;
use crate::state::{AppState, CurrentUser, Tokens};
#[derive(Debug, Serialize)]
pub struct AuthResult {
pub user: CurrentUser,
pub token: String,
}
#[derive(Debug, Deserialize)]
struct ServerAuthResponse {
access_token: String,
refresh_token: String,
user: CurrentUser,
}
#[tauri::command]
pub async fn login(
username: String,
password: String,
state: State<'_, AppState>,
) -> Result<AuthResult, String> {
let client = reqwest::Client::new();
let url = format!("{}/api/v1/auth/login", state.get_server_url());
let resp = client
.post(&url)
.json(&serde_json::json!({ "username": username, "password": password }))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let err: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(err["error"].as_str().unwrap_or("login failed").to_string());
}
let body: ServerAuthResponse = resp.json().await.map_err(|e| e.to_string())?;
let token = body.access_token.clone();
state.set_auth(
body.user.clone(),
Tokens {
access_token: body.access_token,
refresh_token: body.refresh_token,
},
);
Ok(AuthResult { user: body.user, token })
}
#[tauri::command]
pub async fn register(
username: String,
email: String,
password: String,
state: State<'_, AppState>,
) -> Result<AuthResult, String> {
let client = reqwest::Client::new();
let url = format!("{}/api/v1/auth/register", state.get_server_url());
let resp = client
.post(&url)
.json(&serde_json::json!({
"username": username,
"email": email,
"password": password,
}))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let err: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(err["error"].as_str().unwrap_or("registration failed").to_string());
}
let body: ServerAuthResponse = resp.json().await.map_err(|e| e.to_string())?;
let token = body.access_token.clone();
state.set_auth(
body.user.clone(),
Tokens {
access_token: body.access_token,
refresh_token: body.refresh_token,
},
);
Ok(AuthResult { user: body.user, token })
}
#[tauri::command]
pub async fn logout(state: State<'_, AppState>) -> Result<(), String> {
let refresh_token = {
state.tokens.lock().unwrap().as_ref().map(|t| t.refresh_token.clone())
};
if let Some(rt) = refresh_token {
let client = reqwest::Client::new();
let url = format!("{}/api/v1/auth/logout", state.get_server_url());
let _ = client
.post(&url)
.json(&serde_json::json!({ "refresh_token": rt }))
.send()
.await;
}
state.clear_auth();
Ok(())
}
#[tauri::command]
pub async fn get_current_user(state: State<'_, AppState>) -> Result<Option<AuthResult>, String> {
let user = state.user.lock().unwrap().clone();
let token = state.tokens.lock().unwrap().as_ref().map(|t| t.access_token.clone());
match (user, token) {
(Some(u), Some(t)) => Ok(Some(AuthResult { user: u, token: t })),
_ => Ok(None),
}
}

View File

@@ -0,0 +1,48 @@
use tauri::State;
use crate::AppState;
use crate::state::ServerConfig;
#[tauri::command]
pub async fn set_server_url(url: String, state: State<'_, AppState>) -> Result<(), String> {
state.set_server_url(url.clone());
tracing::info!("Server URL set to: {}", url);
Ok(())
}
#[tauri::command]
pub async fn get_server_url(state: State<'_, AppState>) -> Result<String, String> {
Ok(state.get_server_url())
}
#[tauri::command]
pub async fn fetch_server_config(state: State<'_, AppState>) -> Result<ServerConfig, String> {
let server_url = state.get_server_url();
let url = format!("{}/api/v1/client-config", server_url);
let client = reqwest::Client::new();
let response = client
.get(&url)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("连接服务器失败: {}", e))?;
if !response.status().is_success() {
return Err(format!("服务器返回错误: {}", response.status()));
}
let config: ServerConfig = response
.json()
.await
.map_err(|e| format!("解析配置失败: {}", e))?;
*state.server_config.lock().unwrap() = Some(config.clone());
Ok(config)
}
#[tauri::command]
pub async fn get_cached_server_config(state: State<'_, AppState>) -> Result<Option<ServerConfig>, String> {
Ok(state.server_config.lock().unwrap().clone())
}

View File

@@ -0,0 +1,118 @@
use serde::{Deserialize, Serialize};
use tauri::State;
use uuid::Uuid;
use crate::state::AppState;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FriendDto {
pub id: Uuid,
pub username: String,
pub avatar_seed: String,
pub is_online: bool,
pub status: String,
}
fn auth_header(state: &AppState) -> Result<String, String> {
state
.get_access_token()
.map(|t| format!("Bearer {}", t))
.ok_or_else(|| "not authenticated".into())
}
#[tauri::command]
pub async fn list_friends(state: State<'_, AppState>) -> Result<Vec<FriendDto>, String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/friends", state.get_server_url());
let resp = client
.get(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?
.json::<Vec<FriendDto>>()
.await
.map_err(|e| e.to_string())?;
Ok(resp)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FriendRequest {
pub id: Uuid,
pub username: String,
pub avatar_seed: String,
}
#[tauri::command]
pub async fn list_requests(state: State<'_, AppState>) -> Result<Vec<FriendRequest>, String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/friends/requests", state.get_server_url());
let resp = client
.get(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?
.json::<Vec<FriendRequest>>()
.await
.map_err(|e| e.to_string())?;
Ok(resp)
}
#[tauri::command]
pub async fn send_friend_request(
username: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/friends/request", state.get_server_url());
let resp = client
.post(&url)
.header("Authorization", token)
.json(&serde_json::json!({ "username": username }))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let err: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(err["error"].as_str().unwrap_or("failed").to_string());
}
Ok(())
}
#[tauri::command]
pub async fn accept_friend_request(
requester_id: Uuid,
state: State<'_, AppState>,
) -> Result<(), String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/friends/{}/accept", state.get_server_url(), requester_id);
let resp = client
.put(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err("failed to accept request".into());
}
Ok(())
}
#[tauri::command]
pub async fn remove_friend(friend_id: Uuid, state: State<'_, AppState>) -> Result<(), String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/friends/{}", state.get_server_url(), friend_id);
client
.delete(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}

View File

@@ -0,0 +1,7 @@
pub mod auth;
pub mod config;
pub mod friends;
pub mod network;
pub mod relay_nodes;
pub mod rooms;
pub mod signaling;

View File

@@ -0,0 +1,323 @@
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;
use crate::network::{
minecraft_proxy::{find_mc_port, start_client_proxy, start_host_proxy},
p2p::{attempt_p2p, build_my_peer_info, find_quic_port},
quic::open_endpoint,
relay::connect_relay,
relay_selector::fetch_best_node,
session::{ConnectionStats, NetworkSession},
lan_discovery::broadcast_lan_server,
};
use funmc_shared::protocol::PeerInfo;
use crate::state::AppState;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkSessionDto {
pub room_id: String,
pub local_port: u16,
pub session_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionInfo {
pub room_id: String,
pub local_port: u16,
pub connect_addr: String,
pub session_type: String,
}
fn auth_token(state: &AppState) -> Result<String, String> {
state.get_access_token().ok_or_else(|| "not authenticated".into())
}
fn my_user_id(state: &AppState) -> Result<Uuid, String> {
state
.user
.lock()
.unwrap()
.as_ref()
.map(|u| u.id)
.ok_or_else(|| "not authenticated".into())
}
/// Upload host peer info to server for P2P connection
async fn upload_host_info(
server_url: &str,
token: &str,
room_id: Uuid,
peer_info: &PeerInfo,
) {
let client = reqwest::Client::new();
let url = format!("{}/api/v1/rooms/{}/host-info", server_url, room_id);
let nat_type_str = match peer_info.nat_type {
funmc_shared::protocol::NatType::None => "none",
funmc_shared::protocol::NatType::FullCone => "full_cone",
funmc_shared::protocol::NatType::RestrictedCone => "restricted_cone",
funmc_shared::protocol::NatType::PortRestrictedCone => "port_restricted",
funmc_shared::protocol::NatType::Symmetric => "symmetric",
funmc_shared::protocol::NatType::Unknown => "unknown",
};
let _ = client
.post(&url)
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({
"public_addr": peer_info.public_addr,
"local_addrs": peer_info.local_addrs,
"nat_type": nat_type_str,
}))
.send()
.await;
}
/// House side: open a QUIC listener, accept connections from peers (direct or relayed),
/// forward each to the local MC server (default 25565).
#[tauri::command]
pub async fn start_hosting(
room_id: String,
room_name: Option<String>,
mc_port: Option<u16>,
state: State<'_, AppState>,
app: AppHandle,
) -> Result<NetworkSessionDto, String> {
let room_uuid = Uuid::parse_str(&room_id).map_err(|e| e.to_string())?;
let room_name = room_name.unwrap_or_else(|| format!("Room {}", &room_id[..8]));
let mc_server_port = mc_port.unwrap_or(25565);
let mc_addr = format!("127.0.0.1:{}", mc_server_port).parse().unwrap();
let token = auth_token(&state)?;
let user_id = my_user_id(&state)?;
let quic_port = find_quic_port().await.map_err(|e| e.to_string())?;
let bind_addr = format!("0.0.0.0:{}", quic_port).parse().unwrap();
let endpoint = open_endpoint(bind_addr).map_err(|e| e.to_string())?;
let endpoint = Arc::new(endpoint);
// Build and upload peer info for P2P connections
let my_peer_info = build_my_peer_info(user_id, quic_port).await;
tracing::info!("Host public addr: {}", my_peer_info.public_addr);
upload_host_info(&state.get_server_url(), &token, room_uuid, &my_peer_info).await;
let session = NetworkSession::new(room_uuid, quic_port, true, "hosting");
let cancel = session.cancel.clone();
let stats = session.stats.clone();
// Store session
*state.network_session.write().await = Some(session);
// Accept incoming QUIC connections in background
let ep2 = endpoint.clone();
let cancel2 = cancel.clone();
let stats2 = stats.clone();
tokio::spawn(async move {
while let Some(inc) = ep2.accept().await {
let mc_addr2 = mc_addr;
let cancel3 = cancel2.clone();
let stats3 = stats2.clone();
tokio::spawn(async move {
match inc.await {
Ok(conn) => {
tracing::info!("Host: accepted QUIC connection from {}", conn.remote_address());
{
let mut s = stats3.lock().await;
s.connected = true;
s.session_type = "p2p".into();
}
let _ = start_host_proxy(mc_addr2, conn, cancel3).await;
}
Err(e) => tracing::warn!("Host: QUIC incoming error: {}", e),
}
});
}
});
// Broadcast LAN discovery so MC client can find the server
let cancel_lan = cancel.clone();
let room_name2 = room_name.clone();
let local_port = quic_port;
tokio::spawn(async move {
let _ = broadcast_lan_server(&room_name2, local_port, cancel_lan).await;
});
// Emit event
let _ = app.emit("network:status_changed", serde_json::json!({
"type": "hosting",
"port": quic_port,
}));
Ok(NetworkSessionDto {
room_id,
local_port: quic_port,
session_type: "hosting".into(),
})
}
/// Fetch host's peer info from the server
async fn fetch_host_peer_info(
server_url: &str,
token: &str,
room_id: Uuid,
) -> Option<PeerInfo> {
let client = reqwest::Client::new();
let url = format!("{}/api/v1/rooms/{}/host-info", server_url, room_id);
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.ok()?;
if resp.status().is_success() {
resp.json::<PeerInfo>().await.ok()
} else {
None
}
}
/// Client side: attempt P2P → relay fallback → start local MC proxy
#[tauri::command]
pub async fn join_room_network(
room_id: String,
host_user_id: Option<String>,
state: State<'_, AppState>,
app: AppHandle,
) -> Result<ConnectionInfo, String> {
let room_uuid = Uuid::parse_str(&room_id).map_err(|e| e.to_string())?;
let host_uuid = host_user_id
.as_ref()
.and_then(|id| Uuid::parse_str(id).ok());
let token = auth_token(&state)?;
let user_id = my_user_id(&state)?;
let mc_local_port = find_mc_port().await;
let quic_port = find_quic_port().await.map_err(|e| e.to_string())?;
let bind_addr = format!("0.0.0.0:{}", quic_port).parse().unwrap();
let endpoint = open_endpoint(bind_addr).map_err(|e| e.to_string())?;
let endpoint = Arc::new(endpoint);
// Build our own peer info (STUN discovery)
let my_peer_info = build_my_peer_info(user_id, quic_port).await;
tracing::info!("My public addr: {}", my_peer_info.public_addr);
// Emit status update
let _ = app.emit("network:status_changed", serde_json::json!({
"type": "connecting",
"status": "尝试 P2P 直连...",
}));
// Try P2P connection first if we have host info
let mut p2p_conn: Option<quinn::Connection> = None;
if let Some(host_info) = fetch_host_peer_info(&state.get_server_url(), &token, room_uuid).await {
tracing::info!("Got host peer info, attempting P2P to {}", host_info.public_addr);
match attempt_p2p(user_id, host_info, endpoint.clone(), quic_port).await {
Ok(conn) => {
tracing::info!("P2P connection established!");
p2p_conn = Some(conn);
}
Err(e) => {
tracing::warn!("P2P failed: {}, falling back to relay", e);
}
}
} else {
tracing::info!("No host peer info available, using relay directly");
}
// Use P2P connection or fall back to relay
let (conn, session_type) = if let Some(p2p) = p2p_conn {
(p2p, "p2p")
} else {
// Update status
let _ = app.emit("network:status_changed", serde_json::json!({
"type": "connecting",
"status": "连接中继服务器...",
}));
// Try to find the best relay node
let relay_node = fetch_best_node(&state.get_server_url(), &token).await;
match relay_node {
Ok(node) => {
tracing::info!("Using relay node: {} ({})", node.name, node.url);
match connect_relay(&node.url, room_uuid, &token).await {
Ok(c) => (c, "relay"),
Err(e) => {
tracing::warn!("Relay failed: {}. No connection available.", e);
return Err(format!("Could not connect: {}", e));
}
}
}
Err(_) => {
return Err("No relay nodes available".into());
}
}
};
let session = NetworkSession::new(room_uuid, mc_local_port, false, session_type);
let cancel = session.cancel.clone();
let stats = session.stats.clone();
{
let mut s = stats.lock().await;
s.connected = true;
s.session_type = session_type.to_string();
}
*state.network_session.write().await = Some(session);
// Start MC proxy in background
let conn2 = conn.clone();
let cancel2 = cancel.clone();
tokio::spawn(async move {
let _ = start_client_proxy(mc_local_port, conn2, cancel2).await;
});
let connect_addr = format!("127.0.0.1:{}", mc_local_port);
let _ = app.emit("network:status_changed", serde_json::json!({
"type": session_type,
"port": mc_local_port,
"connect_addr": connect_addr,
}));
Ok(ConnectionInfo {
room_id,
local_port: mc_local_port,
connect_addr,
session_type: session_type.to_string(),
})
}
/// Get live connection statistics
#[tauri::command]
pub async fn get_connection_stats(
state: State<'_, AppState>,
) -> Result<ConnectionStats, String> {
let guard = state.network_session.read().await;
match guard.as_ref() {
Some(s) => Ok(s.stats.lock().await.clone()),
None => Ok(ConnectionStats {
session_type: "none".into(),
latency_ms: 0,
bytes_sent: 0,
bytes_received: 0,
connected: false,
}),
}
}
/// Stop all active network sessions
#[tauri::command]
pub async fn stop_network(state: State<'_, AppState>) -> Result<(), String> {
let mut guard = state.network_session.write().await;
if let Some(s) = guard.take() {
s.cancel.notify_waiters();
tracing::info!("Network session stopped");
}
Ok(())
}

View File

@@ -0,0 +1,111 @@
use serde::{Deserialize, Serialize};
use tauri::State;
use crate::state::AppState;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayNodeDto {
pub id: String,
pub name: String,
pub url: String,
pub region: String,
pub is_active: bool,
pub priority: i32,
pub last_ping_ms: Option<i32>,
}
fn auth_header(state: &AppState) -> Result<String, String> {
state
.get_access_token()
.map(|t| format!("Bearer {}", t))
.ok_or_else(|| "not authenticated".into())
}
#[tauri::command]
pub async fn list_relay_nodes(state: State<'_, AppState>) -> Result<Vec<RelayNodeDto>, String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/relay/nodes", state.get_server_url());
let nodes = client
.get(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?
.json::<Vec<RelayNodeDto>>()
.await
.map_err(|e| e.to_string())?;
Ok(nodes)
}
#[tauri::command]
pub async fn add_relay_node(
name: String,
url: String,
region: Option<String>,
priority: Option<i32>,
state: State<'_, AppState>,
) -> Result<RelayNodeDto, String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let api_url = format!("{}/api/v1/relay/nodes", state.get_server_url());
let resp = client
.post(&api_url)
.header("Authorization", token)
.json(&serde_json::json!({ "name": name, "url": url, "region": region, "priority": priority }))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let err: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(err["error"].as_str().unwrap_or("failed").to_string());
}
let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
let node_id = body["id"].as_str().unwrap_or("").to_string();
// Return a DTO with what we know
Ok(RelayNodeDto {
id: node_id,
name,
url,
region: region.unwrap_or_else(|| "auto".into()),
is_active: true,
priority: priority.unwrap_or(0),
last_ping_ms: None,
})
}
#[tauri::command]
pub async fn remove_relay_node(
node_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/relay/nodes/{}", state.get_server_url(), node_id);
client
.delete(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn report_relay_ping(
node_id: String,
ping_ms: i32,
state: State<'_, AppState>,
) -> Result<(), String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/relay/nodes/{}/ping", state.get_server_url(), node_id);
client
.post(&url)
.header("Authorization", token)
.json(&serde_json::json!({ "ping_ms": ping_ms }))
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}

View File

@@ -0,0 +1,187 @@
use serde::{Deserialize, Serialize};
use tauri::State;
use uuid::Uuid;
use crate::state::AppState;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoomDto {
pub id: Uuid,
pub name: String,
pub owner_id: Uuid,
pub owner_username: String,
pub max_players: i32,
pub current_players: i64,
pub is_public: bool,
pub has_password: bool,
pub game_version: String,
pub status: String,
}
fn auth_header(state: &AppState) -> Result<String, String> {
state
.get_access_token()
.map(|t| format!("Bearer {}", t))
.ok_or_else(|| "not authenticated".into())
}
#[tauri::command]
pub async fn list_rooms(state: State<'_, AppState>) -> Result<Vec<RoomDto>, String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/rooms", state.get_server_url());
let resp = client
.get(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?
.json::<Vec<RoomDto>>()
.await
.map_err(|e| e.to_string())?;
Ok(resp)
}
#[tauri::command]
pub async fn create_room(
name: String,
max_players: Option<i32>,
is_public: Option<bool>,
password: Option<String>,
game_version: Option<String>,
state: State<'_, AppState>,
) -> Result<Uuid, String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/rooms", state.get_server_url());
let resp = client
.post(&url)
.header("Authorization", token)
.json(&serde_json::json!({
"name": name,
"max_players": max_players,
"is_public": is_public,
"password": password,
"game_version": game_version,
}))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let err: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(err["error"].as_str().unwrap_or("failed to create room").to_string());
}
let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
let id = body["id"]
.as_str()
.and_then(|s| Uuid::parse_str(s).ok())
.ok_or("invalid room id")?;
Ok(id)
}
#[tauri::command]
pub async fn join_room(
room_id: Uuid,
password: Option<String>,
state: State<'_, AppState>,
) -> Result<(), String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/rooms/{}/join", state.get_server_url(), room_id);
let resp = client
.post(&url)
.header("Authorization", token)
.json(&serde_json::json!({ "password": password }))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let err: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(err["error"].as_str().unwrap_or("failed to join").to_string());
}
Ok(())
}
#[tauri::command]
pub async fn leave_room(room_id: Uuid, state: State<'_, AppState>) -> Result<(), String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/rooms/{}/leave", state.get_server_url(), room_id);
client
.post(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoomMemberDto {
pub user_id: Uuid,
pub username: String,
pub role: String,
pub is_online: bool,
}
#[tauri::command]
pub async fn get_room_members(room_id: Uuid, state: State<'_, AppState>) -> Result<Vec<RoomMemberDto>, String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/rooms/{}/members", state.get_server_url(), room_id);
let resp = client
.get(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?
.json::<Vec<RoomMemberDto>>()
.await
.map_err(|e| e.to_string())?;
Ok(resp)
}
#[tauri::command]
pub async fn kick_room_member(
room_id: String,
target_user_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!(
"{}/api/v1/rooms/{}/members/{}",
state.get_server_url(),
room_id,
target_user_id
);
let resp = client
.delete(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let err: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(err["error"].as_str().unwrap_or("failed to kick member").to_string());
}
Ok(())
}
#[tauri::command]
pub async fn close_room(room_id: String, state: State<'_, AppState>) -> Result<(), String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/rooms/{}", state.get_server_url(), room_id);
let resp = client
.delete(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let err: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(err["error"].as_str().unwrap_or("failed to close room").to_string());
}
Ok(())
}

View File

@@ -0,0 +1,72 @@
use tauri::{AppHandle, State};
use crate::network::signaling::SignalingClient;
use crate::state::AppState;
static mut SIGNALING_CLIENT: Option<SignalingClient> = None;
#[tauri::command]
pub async fn connect_signaling(
state: State<'_, AppState>,
app: AppHandle,
) -> Result<(), String> {
let token = state
.get_access_token()
.ok_or_else(|| "not authenticated".to_string())?;
let client = SignalingClient::connect(&state.get_server_url(), &token, app)
.await
.map_err(|e| e.to_string())?;
unsafe {
SIGNALING_CLIENT = Some(client);
}
tracing::info!("Signaling WebSocket connected");
Ok(())
}
#[tauri::command]
pub async fn disconnect_signaling() -> Result<(), String> {
unsafe {
SIGNALING_CLIENT = None;
}
tracing::info!("Signaling WebSocket disconnected");
Ok(())
}
#[tauri::command]
pub async fn send_signaling_message(message: String) -> Result<(), String> {
let msg: funmc_shared::protocol::SignalingMessage =
serde_json::from_str(&message).map_err(|e| e.to_string())?;
unsafe {
if let Some(client) = &SIGNALING_CLIENT {
client.send(msg);
Ok(())
} else {
Err("signaling not connected".into())
}
}
}
#[tauri::command]
pub async fn send_chat_message(room_id: String, content: String) -> Result<(), String> {
use uuid::Uuid;
use funmc_shared::protocol::SignalingMessage;
let room_uuid = Uuid::parse_str(&room_id).map_err(|e| e.to_string())?;
let msg = SignalingMessage::SendChat {
room_id: room_uuid,
content,
};
unsafe {
if let Some(client) = &SIGNALING_CLIENT {
client.send(msg);
Ok(())
} else {
Err("signaling not connected".into())
}
}
}

21
client/src/config.rs Normal file
View File

@@ -0,0 +1,21 @@
/// FunMC 客户端配置常量
pub const APP_NAME: &str = "FunMC";
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const APP_AUTHOR: &str = "魔幻方";
pub const DEFAULT_API_SERVER: &str = "https://funmc.com";
pub const DEFAULT_RELAY_HOST: &str = "funmc.com";
pub const DEFAULT_RELAY_PORT: u16 = 7900;
pub const BACKUP_RELAY_PORT: u16 = 7901;
pub const MC_DEFAULT_PORT: u16 = 25565;
pub const MC_PORT_RANGE: std::ops::Range<u16> = 25565..25576;
pub const QUIC_PORT_RANGE: std::ops::Range<u16> = 34000..34100;
pub const STUN_SERVER: &str = "stun.l.google.com:19302";
pub const P2P_TIMEOUT_SECS: u64 = 8;
pub const RELAY_TIMEOUT_SECS: u64 = 10;
pub const PING_TIMEOUT_SECS: u64 = 3;

16
client/src/db.rs Normal file
View File

@@ -0,0 +1,16 @@
use anyhow::Result;
use sqlx::SqlitePool;
use tauri::{AppHandle, Manager};
pub async fn init(app: &AppHandle) -> Result<()> {
let app_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&app_dir)?;
let db_path = app_dir.join("funmc.db");
let db_url = format!("sqlite://{}?mode=rwc", db_path.display());
let pool = SqlitePool::connect(&db_url).await?;
sqlx::migrate!("./migrations/sqlite").run(&pool).await?;
app.manage(pool);
Ok(())
}

73
client/src/lib.rs Normal file
View File

@@ -0,0 +1,73 @@
pub mod commands;
pub mod config;
pub mod db;
pub mod network;
pub mod state;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::state::AppState;
pub fn run() {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG")
.unwrap_or_else(|_| "funmc_client_lib=debug".into()),
),
)
.with(tracing_subscriber::fmt::layer())
.init();
let app_state = AppState::new();
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_notification::init())
.manage(app_state)
.setup(|app| {
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = db::init(&handle).await {
tracing::error!("DB init error: {}", e);
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::auth::login,
commands::auth::register,
commands::auth::logout,
commands::auth::get_current_user,
commands::config::set_server_url,
commands::config::get_server_url,
commands::config::fetch_server_config,
commands::config::get_cached_server_config,
commands::friends::list_friends,
commands::friends::list_requests,
commands::friends::send_friend_request,
commands::friends::accept_friend_request,
commands::friends::remove_friend,
commands::rooms::list_rooms,
commands::rooms::create_room,
commands::rooms::join_room,
commands::rooms::leave_room,
commands::rooms::get_room_members,
commands::rooms::kick_room_member,
commands::rooms::close_room,
commands::network::start_hosting,
commands::network::join_room_network,
commands::network::get_connection_stats,
commands::network::stop_network,
commands::relay_nodes::list_relay_nodes,
commands::relay_nodes::add_relay_node,
commands::relay_nodes::remove_relay_node,
commands::relay_nodes::report_relay_ping,
commands::signaling::connect_signaling,
commands::signaling::disconnect_signaling,
commands::signaling::send_signaling_message,
commands::signaling::send_chat_message,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
client/src/main.rs Normal file
View File

@@ -0,0 +1,6 @@
// Tauri entry point
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
funmc_client_lib::run();
}

View File

@@ -0,0 +1,88 @@
/// Minecraft LAN server discovery spoofing
///
/// MC uses UDP multicast 224.0.2.60:4445 to advertise LAN servers.
/// We listen on this address and also broadcast fake "LAN server" packets
/// so that Minecraft clients see the FunMC room as a local server.
///
/// Broadcast format: "[MOTD]<name>[/MOTD][AD]<port>[/AD]"
use anyhow::Result;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use tokio::net::UdpSocket;
use tokio::sync::Notify;
const MC_MULTICAST_ADDR: Ipv4Addr = Ipv4Addr::new(224, 0, 2, 60);
const MC_MULTICAST_PORT: u16 = 4445;
const BROADCAST_INTERVAL: Duration = Duration::from_secs(1);
/// Start broadcasting a fake LAN server announcement
pub async fn broadcast_lan_server(
server_name: &str,
local_port: u16,
cancel: Arc<Notify>,
) -> Result<()> {
let sock = UdpSocket::bind("0.0.0.0:0").await?;
sock.set_multicast_ttl_v4(1)?;
let motd = format!("[MOTD]{}[/MOTD][AD]{}[/AD]", server_name, local_port);
let target = SocketAddr::new(IpAddr::V4(MC_MULTICAST_ADDR), MC_MULTICAST_PORT);
tracing::info!("Broadcasting MC LAN server: {} on port {}", server_name, local_port);
loop {
tokio::select! {
_ = cancel.notified() => {
tracing::info!("LAN broadcast stopped");
break;
}
_ = tokio::time::sleep(BROADCAST_INTERVAL) => {
if let Err(e) = sock.send_to(motd.as_bytes(), target).await {
tracing::warn!("LAN broadcast send error: {}", e);
}
}
}
}
Ok(())
}
/// Listen for MC LAN server announcements from the multicast group
/// Returns parsed (name, port) pairs
pub async fn listen_lan_servers(
cancel: Arc<Notify>,
tx: tokio::sync::mpsc::Sender<(String, u16)>,
) -> Result<()> {
let sock = UdpSocket::bind(("0.0.0.0", MC_MULTICAST_PORT)).await?;
sock.join_multicast_v4(MC_MULTICAST_ADDR, Ipv4Addr::UNSPECIFIED)?;
sock.set_multicast_loop_v4(false)?;
let mut buf = [0u8; 1024];
loop {
tokio::select! {
_ = cancel.notified() => break,
result = sock.recv_from(&mut buf) => {
if let Ok((n, _)) = result {
let text = String::from_utf8_lossy(&buf[..n]);
if let Some(parsed) = parse_motd(&text) {
let _ = tx.send(parsed).await;
}
}
}
}
}
Ok(())
}
fn parse_motd(text: &str) -> Option<(String, u16)> {
let name = extract_between(text, "[MOTD]", "[/MOTD]")?;
let port_str = extract_between(text, "[AD]", "[/AD]")?;
let port = port_str.parse::<u16>().ok()?;
Some((name.to_string(), port))
}
fn extract_between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> {
let s = text.find(start)? + start.len();
let e = text[s..].find(end)? + s;
Some(&text[s..e])
}

View File

@@ -0,0 +1,144 @@
/// Minecraft TCP → QUIC tunnel proxy
use anyhow::Result;
use quinn::Connection;
use std::net::SocketAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Notify;
use std::sync::Arc;
pub async fn start_client_proxy(
local_port: u16,
conn: Connection,
cancel: Arc<Notify>,
) -> Result<()> {
let listener = TcpListener::bind(("127.0.0.1", local_port)).await?;
tracing::info!("MC proxy listening on 127.0.0.1:{}", local_port);
loop {
tokio::select! {
_ = cancel.notified() => {
tracing::info!("MC proxy shutting down");
break;
}
accept = listener.accept() => {
match accept {
Ok((tcp_stream, _peer)) => {
let conn2 = conn.clone();
tokio::spawn(async move {
if let Err(e) = proxy_tcp_to_quic(tcp_stream, conn2).await {
tracing::debug!("proxy_tcp_to_quic: {}", e);
}
});
}
Err(e) => {
tracing::error!("MC proxy accept error: {}", e);
break;
}
}
}
}
}
Ok(())
}
async fn proxy_tcp_to_quic(tcp: TcpStream, conn: Connection) -> Result<()> {
let (mut qs, mut qr) = conn.open_bi().await?;
let (mut tcp_r, mut tcp_w) = tokio::io::split(tcp);
let t1 = tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
loop {
let n = tcp_r.read(&mut buf).await?;
if n == 0 { break; }
qs.write_all(&buf[..n]).await?;
}
let _ = qs.finish();
Ok::<_, anyhow::Error>(())
});
let t2 = tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
loop {
let n = qr.read(&mut buf).await?.unwrap_or(0);
if n == 0 { break; }
tcp_w.write_all(&buf[..n]).await?;
}
Ok::<_, anyhow::Error>(())
});
let _ = tokio::try_join!(t1, t2);
Ok(())
}
pub async fn start_host_proxy(
mc_server_addr: SocketAddr,
conn: Connection,
cancel: Arc<Notify>,
) -> Result<()> {
tracing::info!("Host proxy forwarding QUIC streams to MC server at {}", mc_server_addr);
loop {
tokio::select! {
_ = cancel.notified() => break,
stream = conn.accept_bi() => {
match stream {
Ok((qs, qr)) => {
tokio::spawn(async move {
if let Err(e) = proxy_quic_to_mc(qs, qr, mc_server_addr).await {
tracing::debug!("proxy_quic_to_mc: {}", e);
}
});
}
Err(e) => {
tracing::warn!("host proxy stream accept error: {}", e);
break;
}
}
}
}
}
Ok(())
}
async fn proxy_quic_to_mc(
mut qs: quinn::SendStream,
mut qr: quinn::RecvStream,
mc_addr: SocketAddr,
) -> Result<()> {
let tcp = TcpStream::connect(mc_addr).await?;
let (mut tcp_r, mut tcp_w) = tokio::io::split(tcp);
let t1 = tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
loop {
let n = qr.read(&mut buf).await?.unwrap_or(0);
if n == 0 { break; }
tcp_w.write_all(&buf[..n]).await?;
}
Ok::<_, anyhow::Error>(())
});
let t2 = tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
loop {
let n = tcp_r.read(&mut buf).await?;
if n == 0 { break; }
qs.write_all(&buf[..n]).await?;
}
let _ = qs.finish();
Ok::<_, anyhow::Error>(())
});
let _ = tokio::try_join!(t1, t2);
Ok(())
}
pub async fn find_mc_port() -> u16 {
for port in 25565u16..=25575 {
if TcpListener::bind(("127.0.0.1", port)).await.is_ok() {
return port;
}
}
25565
}

View File

@@ -0,0 +1,9 @@
pub mod lan_discovery;
pub mod minecraft_proxy;
pub mod nat;
pub mod p2p;
pub mod quic;
pub mod relay;
pub mod relay_selector;
pub mod session;
pub mod signaling;

149
client/src/network/nat.rs Normal file
View File

@@ -0,0 +1,149 @@
/// NAT type detection via STUN (RFC 5389)
/// Uses stun.l.google.com:19302 to discover public IP and port
use anyhow::{anyhow, Result};
use std::net::SocketAddr;
use std::time::Duration;
use tokio::net::UdpSocket;
const STUN_SERVER: &str = "stun.l.google.com:19302";
const STUN_BINDING_REQUEST: u16 = 0x0001;
const STUN_MAGIC_COOKIE: u32 = 0x2112A442;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub enum NatType {
None, // Direct internet connection
FullCone, // Any external host can send to our mapped addr
RestrictedCone, // External must receive from us first (IP)
PortRestricted, // External must receive from us first (IP+port)
Symmetric, // Different mapping per destination (hardest to pierce)
Unknown,
}
#[derive(Debug, Clone)]
pub struct StunResult {
pub public_addr: SocketAddr,
pub nat_type: NatType,
}
/// Build a minimal STUN Binding Request packet
fn build_binding_request(transaction_id: &[u8; 12]) -> Vec<u8> {
let mut pkt = Vec::with_capacity(20);
pkt.extend_from_slice(&STUN_BINDING_REQUEST.to_be_bytes());
pkt.extend_from_slice(&0u16.to_be_bytes()); // length
pkt.extend_from_slice(&STUN_MAGIC_COOKIE.to_be_bytes());
pkt.extend_from_slice(transaction_id);
pkt
}
/// Parse XOR-MAPPED-ADDRESS or MAPPED-ADDRESS from STUN response
fn parse_mapped_address(data: &[u8]) -> Option<SocketAddr> {
if data.len() < 20 {
return None;
}
let msg_len = u16::from_be_bytes([data[2], data[3]]) as usize;
let mut pos = 20;
while pos + 4 <= 20 + msg_len {
let attr_type = u16::from_be_bytes([data[pos], data[pos + 1]]);
let attr_len = u16::from_be_bytes([data[pos + 2], data[pos + 3]]) as usize;
pos += 4;
if pos + attr_len > data.len() {
break;
}
let attr_data = &data[pos..pos + attr_len];
match attr_type {
// XOR-MAPPED-ADDRESS (0x0020)
0x0020 => {
if attr_data.len() >= 8 && attr_data[1] == 0x01 {
let xport = u16::from_be_bytes([attr_data[2], attr_data[3]])
^ (STUN_MAGIC_COOKIE >> 16) as u16;
let xip = u32::from_be_bytes([attr_data[4], attr_data[5], attr_data[6], attr_data[7]])
^ STUN_MAGIC_COOKIE;
let ip = std::net::Ipv4Addr::from(xip);
return Some(SocketAddr::from((ip, xport)));
}
}
// MAPPED-ADDRESS (0x0001)
0x0001 => {
if attr_data.len() >= 8 && attr_data[1] == 0x01 {
let port = u16::from_be_bytes([attr_data[2], attr_data[3]]);
let ip = std::net::Ipv4Addr::new(attr_data[4], attr_data[5], attr_data[6], attr_data[7]);
return Some(SocketAddr::from((ip, port)));
}
}
_ => {}
}
pos += attr_len;
// 4-byte alignment
if attr_len % 4 != 0 {
pos += 4 - (attr_len % 4);
}
}
None
}
/// Send one STUN binding request and return the mapped address
pub async fn stun_query(sock: &UdpSocket, stun_addr: &str) -> Result<SocketAddr> {
let tid: [u8; 12] = rand::random();
let pkt = build_binding_request(&tid);
let addrs: Vec<SocketAddr> = tokio::net::lookup_host(stun_addr).await?.collect();
let server = addrs.into_iter().next().ok_or_else(|| anyhow!("STUN server DNS failed"))?;
sock.send_to(&pkt, server).await?;
let mut buf = [0u8; 512];
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err(anyhow!("STUN timeout"));
}
match tokio::time::timeout(remaining, sock.recv_from(&mut buf)).await {
Ok(Ok((n, _))) => {
if let Some(addr) = parse_mapped_address(&buf[..n]) {
return Ok(addr);
}
}
_ => return Err(anyhow!("STUN failed")),
}
}
}
/// Perform basic NAT detection: get public IP and classify NAT type
pub async fn detect_nat() -> Result<StunResult> {
// Bind a local UDP socket
let sock = UdpSocket::bind("0.0.0.0:0").await?;
// Query primary STUN server
let mapped1 = stun_query(&sock, STUN_SERVER).await?;
// Query again to same server to check if mapping is stable
let mapped2 = stun_query(&sock, STUN_SERVER).await?;
let nat_type = if mapped1 == mapped2 {
// Same port from same destination = Full Cone or Restricted (simplified)
// Deep detection would require a second STUN server, omitted for now
NatType::FullCone
} else {
NatType::Symmetric
};
Ok(StunResult {
public_addr: mapped1,
nat_type,
})
}
/// Get local LAN addresses
pub fn get_local_addrs(port: u16) -> Vec<SocketAddr> {
let mut addrs = Vec::new();
if let Ok(ifaces) = local_ip_address::list_afinet_netifas() {
for (_, ip) in ifaces {
if !ip.is_loopback() {
addrs.push(SocketAddr::new(ip, port));
}
}
}
addrs
}

129
client/src/network/p2p.rs Normal file
View File

@@ -0,0 +1,129 @@
/// P2P UDP hole punching using QUIC (quinn)
///
/// Algorithm:
/// 1. Both peers bind a UDP socket and discover their public address via STUN
/// 2. Exchange PeerInfo (public addr + local addrs) through the signaling server
/// 3. Send simultaneous UDP probes to pierce NAT holes
/// 4. Attempt QUIC connection to peer's public + local addresses
/// 5. First successful connection wins
use anyhow::{anyhow, Result};
use funmc_shared::protocol::PeerInfo;
use quinn::{Connection, Endpoint};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::timeout;
use uuid::Uuid;
use crate::network::nat::{detect_nat, get_local_addrs, NatType};
pub const QUIC_P2P_PORT_RANGE: std::ops::Range<u16> = 34000..34100;
/// Find a free UDP port in our P2P range
pub async fn find_quic_port() -> Result<u16> {
for port in QUIC_P2P_PORT_RANGE {
if tokio::net::UdpSocket::bind(("0.0.0.0", port)).await.is_ok() {
return Ok(port);
}
}
// Fall back to OS-assigned
let sock = tokio::net::UdpSocket::bind("0.0.0.0:0").await?;
Ok(sock.local_addr()?.port())
}
/// Attempt P2P hole punch. Returns the QUIC Connection and endpoint on success.
pub async fn attempt_p2p(
my_user_id: Uuid,
peer_info: PeerInfo,
endpoint: Arc<Endpoint>,
my_port: u16,
) -> Result<Connection> {
// Collect all candidate addresses to try
let mut candidates: Vec<SocketAddr> = Vec::new();
// Parse peer's public address
if let Ok(addr) = peer_info.public_addr.parse::<SocketAddr>() {
candidates.push(addr);
}
for local_addr_str in &peer_info.local_addrs {
if let Ok(addr) = local_addr_str.parse::<SocketAddr>() {
candidates.push(addr);
}
}
tracing::info!("P2P: trying {} candidates for peer {}", candidates.len(), peer_info.user_id);
// Try all candidates concurrently with a 5 second total timeout
let ep = endpoint.clone();
let result = timeout(Duration::from_secs(8), async move {
let mut handles = Vec::new();
for addr in candidates {
let ep2 = ep.clone();
handles.push(tokio::spawn(async move {
// Send a few UDP probes first to open NAT hole
for _ in 0..3 {
let _ = ep2.connect(addr, "funmc");
tokio::time::sleep(Duration::from_millis(100)).await;
}
// Attempt real QUIC connection
match ep2.connect(addr, "funmc") {
Ok(connecting) => {
match timeout(Duration::from_secs(3), connecting).await {
Ok(Ok(conn)) => Some(conn),
_ => None,
}
}
Err(_) => None,
}
}));
}
for handle in handles {
if let Ok(Some(conn)) = handle.await {
return Some(conn);
}
}
None
}).await;
match result {
Ok(Some(conn)) => {
tracing::info!("P2P connection established with {}", peer_info.user_id);
Ok(conn)
}
_ => {
tracing::warn!("P2P failed with {}, will use relay", peer_info.user_id);
Err(anyhow!("P2P hole punch failed"))
}
}
}
/// Collect our own PeerInfo for signaling exchange
pub async fn build_my_peer_info(user_id: Uuid, port: u16) -> PeerInfo {
let (public_addr, nat_type) = match detect_nat().await {
Ok(r) => (r.public_addr.to_string(), r.nat_type),
Err(_) => (format!("0.0.0.0:{}", port), crate::network::nat::NatType::Unknown),
};
let nat_type_shared = match nat_type {
NatType::None => funmc_shared::protocol::NatType::None,
NatType::FullCone => funmc_shared::protocol::NatType::FullCone,
NatType::RestrictedCone => funmc_shared::protocol::NatType::RestrictedCone,
NatType::PortRestricted => funmc_shared::protocol::NatType::PortRestrictedCone,
NatType::Symmetric => funmc_shared::protocol::NatType::Symmetric,
NatType::Unknown => funmc_shared::protocol::NatType::Unknown,
};
let local_addrs: Vec<String> = get_local_addrs(port)
.into_iter()
.map(|a| a.to_string())
.collect();
PeerInfo {
user_id,
public_addr,
local_addrs,
nat_type: nat_type_shared,
}
}

View File

@@ -0,0 +1,99 @@
/// QUIC endpoint management using quinn
/// Provides a shared QUIC endpoint for both P2P and relay connections
use anyhow::Result;
use quinn::{ClientConfig, Endpoint, ServerConfig};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
use std::net::SocketAddr;
use std::sync::Arc;
/// Build a self-signed TLS certificate for QUIC
pub fn make_self_signed() -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
let cert = rcgen::generate_simple_self_signed(vec!["funmc".to_string()])?;
let cert_der = CertificateDer::from(cert.cert);
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der()));
Ok((vec![cert_der], key_der))
}
/// Create a QUIC ServerConfig with a self-signed cert
pub fn make_server_config() -> Result<ServerConfig> {
let (certs, key) = make_self_signed()?;
let mut tls = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)?;
tls.alpn_protocols = vec![b"funmc".to_vec()];
let mut sc = ServerConfig::with_crypto(Arc::new(
quinn::crypto::rustls::QuicServerConfig::try_from(tls)?,
));
// Tune transport params for game traffic
let mut transport = quinn::TransportConfig::default();
transport.max_idle_timeout(Some(std::time::Duration::from_secs(30).try_into()?));
transport.keep_alive_interval(Some(std::time::Duration::from_secs(5)));
sc.transport_config(Arc::new(transport));
Ok(sc)
}
/// Create a QUIC ClientConfig that accepts any self-signed cert (P2P peers)
pub fn make_client_config_insecure() -> ClientConfig {
let crypto = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAnyCert))
.with_no_client_auth();
let mut cc = ClientConfig::new(Arc::new(
quinn::crypto::rustls::QuicClientConfig::try_from(crypto).unwrap(),
));
let mut transport = quinn::TransportConfig::default();
transport.max_idle_timeout(Some(std::time::Duration::from_secs(30).try_into().unwrap()));
transport.keep_alive_interval(Some(std::time::Duration::from_secs(5)));
cc.transport_config(Arc::new(transport));
cc
}
/// Open a QUIC endpoint bound to the given address
pub fn open_endpoint(bind_addr: SocketAddr) -> Result<Endpoint> {
let sc = make_server_config()?;
let mut ep = Endpoint::server(sc, bind_addr)?;
ep.set_default_client_config(make_client_config_insecure());
Ok(ep)
}
// ---- TLS cert verifier that accepts anything (P2P peers use self-signed) ----
#[derive(Debug)]
struct AcceptAnyCert;
impl rustls::client::danger::ServerCertVerifier for AcceptAnyCert {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer,
_intermediates: &[CertificateDer],
_server_name: &rustls::pki_types::ServerName,
_ocsp: &[u8],
_now: rustls::pki_types::UnixTime,
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer,
_dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer,
_dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}

View File

@@ -0,0 +1,68 @@
/// QUIC relay client — used when P2P hole punch fails
use anyhow::{anyhow, Result};
use quinn::Connection;
use std::net::SocketAddr;
use std::time::Duration;
use tokio::time::timeout;
use uuid::Uuid;
use crate::network::quic::open_endpoint;
use crate::network::relay_selector::DEFAULT_RELAY_PORT;
/// Connect to a relay server's QUIC endpoint
pub async fn connect_relay(
relay_url: &str,
room_id: Uuid,
access_token: &str,
) -> Result<Connection> {
let (host, port) = parse_relay_url(relay_url);
let relay_addr: SocketAddr = {
let addrs: Vec<SocketAddr> = tokio::net::lookup_host(format!("{}:{}", host, port))
.await?
.collect();
addrs.into_iter().next().ok_or_else(|| anyhow!("relay DNS failed"))?
};
let ep = open_endpoint("0.0.0.0:0".parse()?)?;
tracing::info!("Connecting to relay at {}", relay_addr);
let connecting = ep.connect(relay_addr, &host)?;
let conn = timeout(Duration::from_secs(10), connecting).await
.map_err(|_| anyhow!("relay connection timed out"))?
.map_err(|e| anyhow!("relay QUIC error: {}", e))?;
// Send auth handshake on a unidirectional stream using write_chunk
let mut stream = conn.open_uni().await?;
let handshake = serde_json::json!({
"token": access_token,
"room_id": room_id.to_string(),
});
let msg = serde_json::to_vec(&handshake)?;
let len = (msg.len() as u32).to_be_bytes();
stream.write_chunk(bytes::Bytes::copy_from_slice(&len)).await
.map_err(|e| anyhow!("{}", e))?;
stream.write_chunk(bytes::Bytes::from(msg)).await
.map_err(|e| anyhow!("{}", e))?;
let _ = stream.finish();
let _ = stream.stopped().await;
tracing::info!("Relay handshake sent for room {}", room_id);
Ok(conn)
}
fn parse_relay_url(url: &str) -> (String, u16) {
let cleaned = url
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_start_matches("quic://");
if let Some((host, port_str)) = cleaned.rsplit_once(':') {
if let Ok(port) = port_str.parse::<u16>() {
return (host.to_string(), port);
}
}
(cleaned.to_string(), DEFAULT_RELAY_PORT)
}

View File

@@ -0,0 +1,118 @@
/// Relay node selection — client-side multi-node support
/// Fetches relay nodes from server, measures latency, picks lowest RTT
use anyhow::Result;
use std::net::ToSocketAddrs;
use std::time::{Duration, Instant};
pub const DEFAULT_RELAY_PORT: u16 = 7900;
pub const BACKUP_RELAY_PORT: u16 = 7901;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RelayNode {
pub id: String,
pub name: String,
pub url: String,
pub region: String,
pub priority: i32,
pub last_ping_ms: Option<i32>,
}
impl RelayNode {
pub fn from_server_url(server_url: &str) -> Self {
let relay_url = extract_relay_url(server_url);
Self {
id: "server".into(),
name: "服务器节点".into(),
url: relay_url,
region: "auto".into(),
priority: 100,
last_ping_ms: None,
}
}
pub fn from_relay_url(relay_url: &str) -> Self {
Self {
id: "configured".into(),
name: "配置节点".into(),
url: relay_url.to_string(),
region: "auto".into(),
priority: 100,
last_ping_ms: None,
}
}
pub fn parse_addr(&self) -> Option<std::net::SocketAddr> {
self.url.to_socket_addrs().ok()?.next()
}
}
fn extract_relay_url(server_url: &str) -> String {
let host = server_url
.trim_start_matches("https://")
.trim_start_matches("http://")
.split(':')
.next()
.unwrap_or("localhost");
format!("{}:{}", host, DEFAULT_RELAY_PORT)
}
/// Fetch available relay nodes from server and return sorted by latency
pub async fn fetch_best_node(server_url: &str, token: &str) -> Result<RelayNode> {
let client = reqwest::Client::new();
let url = format!("{}/api/v1/relay/nodes", server_url);
let default_node = RelayNode::from_server_url(server_url);
let nodes: Vec<RelayNode> = match client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.timeout(Duration::from_secs(5))
.send()
.await
{
Ok(resp) => resp.json().await.unwrap_or_else(|_| vec![default_node.clone()]),
Err(_) => vec![default_node.clone()],
};
let nodes = if nodes.is_empty() { vec![default_node.clone()] } else { nodes };
let mut best: Option<(RelayNode, u128)> = None;
let mut handles = vec![];
for node in nodes.clone() {
handles.push(tokio::spawn(async move {
let rtt = ping_relay_node(&node).await;
(node, rtt)
}));
}
for handle in handles {
if let Ok((node, Some(rtt))) = handle.await {
if best.is_none() || rtt < best.as_ref().unwrap().1 {
best = Some((node, rtt));
}
}
}
Ok(best
.map(|(n, _)| n)
.unwrap_or_else(|| nodes.into_iter().next().unwrap_or(default_node)))
}
async fn ping_relay_node(node: &RelayNode) -> Option<u128> {
let addr = node.parse_addr()?;
let start = Instant::now();
let socket = tokio::net::UdpSocket::bind("0.0.0.0:0").await.ok()?;
socket.connect(addr).await.ok()?;
let ping_data = b"FUNMC_PING";
socket.send(ping_data).await.ok()?;
let mut buf = [0u8; 32];
match tokio::time::timeout(Duration::from_secs(3), socket.recv(&mut buf)).await {
Ok(Ok(_)) => Some(start.elapsed().as_millis()),
_ => None,
}
}

View File

@@ -0,0 +1,42 @@
// Network session state held in AppState
use std::sync::Arc;
use tokio::sync::Mutex;
use uuid::Uuid;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ConnectionStats {
pub session_type: String,
pub latency_ms: u32,
pub bytes_sent: u64,
pub bytes_received: u64,
pub connected: bool,
}
pub struct NetworkSession {
pub room_id: Uuid,
pub local_port: u16,
pub is_host: bool,
pub session_type: String, // "p2p" | "relay"
pub stats: Arc<Mutex<ConnectionStats>>,
/// Cancel token: drop to stop all tasks
pub cancel: Arc<tokio::sync::Notify>,
}
impl NetworkSession {
pub fn new(room_id: Uuid, local_port: u16, is_host: bool, session_type: &str) -> Self {
Self {
room_id,
local_port,
is_host,
session_type: session_type.to_string(),
stats: Arc::new(Mutex::new(ConnectionStats {
session_type: session_type.to_string(),
latency_ms: 0,
bytes_sent: 0,
bytes_received: 0,
connected: false,
})),
cancel: Arc::new(tokio::sync::Notify::new()),
}
}
}

View File

@@ -0,0 +1,73 @@
/// WebSocket signaling client
/// Connects to the server's /api/v1/ws endpoint and handles incoming messages
use anyhow::Result;
use futures_util::{SinkExt, StreamExt};
use funmc_shared::protocol::SignalingMessage;
use tauri::{AppHandle, Emitter};
use tokio::sync::mpsc;
use tokio_tungstenite::{connect_async, tungstenite::Message};
pub struct SignalingClient {
pub tx: mpsc::UnboundedSender<SignalingMessage>,
}
impl SignalingClient {
/// Spawn signaling connection in background, forward incoming events to Tauri
pub async fn connect(server_url: &str, token: &str, app: AppHandle) -> Result<Self> {
let ws_url = server_url
.replace("https://", "wss://")
.replace("http://", "ws://");
let ws_url = format!("{}/api/v1/ws?token={}", ws_url, token);
let (ws_stream, _) = connect_async(&ws_url).await?;
let (mut ws_tx, mut ws_rx) = ws_stream.split();
let (tx, mut rx) = mpsc::unbounded_channel::<SignalingMessage>();
// Forward outgoing messages to WebSocket
tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if let Ok(json) = serde_json::to_string(&msg) {
if ws_tx.send(Message::Text(json)).await.is_err() {
break;
}
}
}
});
// Receive incoming messages and emit Tauri events
tokio::spawn(async move {
while let Some(Ok(msg)) = ws_rx.next().await {
if let Message::Text(text) = msg {
if let Ok(signal) = serde_json::from_str::<SignalingMessage>(&text) {
let event_name = match &signal {
SignalingMessage::FriendRequest { .. } => "signaling:friend_request",
SignalingMessage::FriendAccepted { .. } => "signaling:friend_accepted",
SignalingMessage::RoomInvite { .. } => "signaling:room_invite",
SignalingMessage::MemberJoined { .. } => "signaling:member_joined",
SignalingMessage::MemberLeft { .. } => "signaling:member_left",
SignalingMessage::Kicked { .. } => "signaling:kicked",
SignalingMessage::RoomClosed { .. } => "signaling:room_closed",
SignalingMessage::UserOnline { .. } => "signaling:user_online",
SignalingMessage::UserOffline { .. } => "signaling:user_offline",
SignalingMessage::ChatMessage { .. } => "signaling:chat_message",
SignalingMessage::Offer { .. }
| SignalingMessage::Answer { .. }
| SignalingMessage::IceCandidate { .. } => "signaling:network",
_ => continue,
};
let payload = serde_json::to_value(&signal).unwrap_or_default();
let _ = app.emit(event_name, payload);
}
}
}
});
Ok(Self { tx })
}
pub fn send(&self, msg: SignalingMessage) {
let _ = self.tx.send(msg);
}
}

77
client/src/state.rs Normal file
View File

@@ -0,0 +1,77 @@
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::network::session::NetworkSession;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurrentUser {
pub id: Uuid,
pub username: String,
pub email: String,
pub avatar_seed: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tokens {
pub access_token: String,
pub refresh_token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServerConfig {
pub server_name: String,
pub server_url: String,
pub relay_url: String,
#[serde(default)]
pub version: String,
}
pub struct AppState {
pub server_url: Mutex<String>,
pub server_config: Mutex<Option<ServerConfig>>,
pub user: Mutex<Option<CurrentUser>>,
pub tokens: Mutex<Option<Tokens>>,
pub network_session: Arc<RwLock<Option<NetworkSession>>>,
}
impl AppState {
pub fn new() -> Self {
let server_url = std::env::var("FUNMC_SERVER")
.unwrap_or_else(|_| "http://localhost:3000".into());
Self {
server_url: Mutex::new(server_url),
server_config: Mutex::new(None),
user: Mutex::new(None),
tokens: Mutex::new(None),
network_session: Arc::new(RwLock::new(None)),
}
}
pub fn get_server_url(&self) -> String {
self.server_url.lock().unwrap().clone()
}
pub fn set_server_url(&self, url: String) {
*self.server_url.lock().unwrap() = url;
}
pub fn get_access_token(&self) -> Option<String> {
self.tokens.lock().ok()?.as_ref().map(|t| t.access_token.clone())
}
pub fn set_auth(&self, user: CurrentUser, tokens: Tokens) {
*self.user.lock().unwrap() = Some(user);
*self.tokens.lock().unwrap() = Some(tokens);
}
pub fn clear_auth(&self) {
*self.user.lock().unwrap() = None;
*self.tokens.lock().unwrap() = None;
}
pub fn get_user_id(&self) -> Option<Uuid> {
self.user.lock().ok()?.as_ref().map(|u| u.id)
}
}

98
client/tauri.conf.json Normal file
View File

@@ -0,0 +1,98 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "FunMC",
"version": "0.1.0",
"identifier": "com.funmc.app",
"build": {
"frontendDist": "../ui/dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "cd ui && npm run dev",
"beforeBuildCommand": "cd ui && npm run build"
},
"app": {
"withGlobalTauri": false,
"windows": [
{
"label": "main",
"title": "FunMC - Minecraft 联机助手",
"width": 1100,
"height": 700,
"minWidth": 900,
"minHeight": 600,
"resizable": true,
"decorations": true,
"center": true,
"transparent": false
}
],
"security": {
"csp": null
}
},
"ios": {
"developmentTeam": null,
"minimumSystemVersion": "13.0"
},
"android": {
"minSdkVersion": 24,
"targetSdkVersion": 34
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"publisher": "魔幻方",
"copyright": "© 2024 魔幻方. All rights reserved.",
"category": "Game",
"shortDescription": "Minecraft 联机助手",
"longDescription": "FunMC 是一款由魔幻方开发的 Minecraft 联机工具,支持 P2P 直连和中继服务器,让 Minecraft 联机变得简单。",
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": null,
"wix": null,
"nsis": {
"installerIcon": "icons/icon.ico",
"headerImage": null,
"sidebarImage": null,
"installMode": "currentUser",
"languages": ["SimpChinese", "English"],
"displayLanguageSelector": true
}
},
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null,
"minimumSystemVersion": "10.13"
},
"linux": {
"appimage": {
"bundleMediaFramework": true
},
"deb": {
"depends": []
},
"rpm": {
"depends": []
}
}
},
"plugins": {
"shell": {
"all": false,
"open": true
},
"notification": {
"all": true
}
}
}

19
client/ui/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FunMC</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

36
client/ui/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "funmc-client-ui",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-notification": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.22.3",
"zustand": "^4.5.2",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.10"
}
}

View File

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

View File

@@ -0,0 +1,218 @@
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'
import { useState } from 'react'
const navItems = [
{
to: '/',
label: '大厅',
icon: (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
</svg>
),
},
{
to: '/friends',
label: '好友',
icon: (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
),
},
{
to: '/settings',
label: '设置',
icon: (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M22 12h-2M4 12H2M12 22v-2M12 4V2"/>
</svg>
),
},
]
export function AppLayout() {
const { user, logout } = useAuthStore()
const navigate = useNavigate()
const location = useLocation()
const [showUserMenu, setShowUserMenu] = useState(false)
const handleLogout = async () => {
await logout()
navigate('/login')
}
const avatarColor = user?.avatar_seed
? `hsl(${parseInt(user.avatar_seed.slice(0, 8), 16) % 360}, 60%, 50%)`
: '#4ade80'
const isInRoom = location.pathname.startsWith('/room/')
return (
<div className="flex flex-col md:flex-row h-screen bg-bg-primary overflow-hidden">
{/* Desktop Sidebar - hidden on mobile */}
<aside className="hidden md:flex w-56 flex-shrink-0 bg-bg-secondary border-r border-border flex-col">
{/* Logo */}
<div className="px-4 py-5 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-accent-green/20 border border-accent-green/40 flex items-center justify-center">
<svg className="w-4 h-4 text-accent-green" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</div>
<span className="font-bold text-text-primary text-lg">FunMC</span>
</div>
</div>
{/* Nav */}
<nav className="flex-1 p-3 space-y-1">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors duration-150 ${
isActive
? 'bg-accent-green/15 text-accent-green border border-accent-green/20'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
}`
}
>
{item.icon}
{item.label}
</NavLink>
))}
</nav>
{/* User profile */}
<div className="p-3 border-t border-border">
<div className="flex items-center gap-3 px-2 py-2">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0"
style={{ backgroundColor: avatarColor }}
>
{user?.username?.[0]?.toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{user?.username}</p>
<p className="text-xs text-text-muted">线</p>
</div>
<button
onClick={handleLogout}
title="退出登录"
className="text-text-muted hover:text-accent-red transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
</div>
{/* Brand footer */}
<div className="px-4 py-3 border-t border-border bg-bg-tertiary/50">
<div className="flex items-center justify-center gap-2">
<svg className="w-4 h-4 text-accent-purple" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
<span className="text-xs text-text-muted"></span>
</div>
<p className="text-center text-[10px] text-text-muted/60 mt-1">v0.1.0</p>
</div>
</aside>
{/* Mobile Header - shown only on mobile */}
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-bg-secondary border-b border-border safe-area-top">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-accent-green/20 border border-accent-green/40 flex items-center justify-center">
<svg className="w-3.5 h-3.5 text-accent-green" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</div>
<span className="font-bold text-text-primary">FunMC</span>
</div>
{/* User avatar button */}
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-2"
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-bold"
style={{ backgroundColor: avatarColor }}
>
{user?.username?.[0]?.toUpperCase()}
</div>
</button>
{/* User dropdown menu */}
{showUserMenu && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowUserMenu(false)}
/>
<div className="absolute right-0 top-full mt-2 w-48 bg-bg-secondary border border-border rounded-lg shadow-xl z-50 py-2 animate-fade-in">
<div className="px-4 py-2 border-b border-border">
<p className="text-sm font-medium text-text-primary">{user?.username}</p>
<p className="text-xs text-text-muted">{user?.email}</p>
</div>
<button
onClick={handleLogout}
className="w-full px-4 py-2 text-left text-sm text-accent-red hover:bg-bg-tertiary flex items-center gap-2"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
退
</button>
</div>
</>
)}
</div>
</header>
{/* Main content */}
<main className="flex-1 overflow-hidden">
<Outlet />
</main>
{/* Mobile Bottom Navigation - hidden when in room or on desktop */}
{!isInRoom && (
<nav className="md:hidden flex items-center justify-around bg-bg-secondary border-t border-border safe-area-bottom py-2">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`flex flex-col items-center gap-1 px-4 py-2 rounded-lg transition-colors ${
isActive
? 'text-accent-green'
: 'text-text-muted'
}`
}
>
{item.icon}
<span className="text-xs font-medium">{item.label}</span>
</NavLink>
))}
</nav>
)}
</div>
)
}
export default AppLayout

View File

@@ -0,0 +1,110 @@
import { cn, generateAvatarColor, getInitials } from '../lib/utils';
interface AvatarProps {
seed: string;
name: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
showOnline?: boolean;
isOnline?: boolean;
}
export function Avatar({
seed,
name,
size = 'md',
className,
showOnline = false,
isOnline = false,
}: AvatarProps) {
const sizeClasses = {
sm: 'w-6 h-6 text-xs',
md: 'w-8 h-8 text-sm',
lg: 'w-10 h-10 text-base',
xl: 'w-12 h-12 text-lg',
};
const dotSizeClasses = {
sm: 'w-1.5 h-1.5 -bottom-0 -right-0',
md: 'w-2 h-2 -bottom-0.5 -right-0.5',
lg: 'w-2.5 h-2.5 -bottom-0.5 -right-0.5',
xl: 'w-3 h-3 -bottom-1 -right-1',
};
const bgColor = generateAvatarColor(seed);
const initials = getInitials(name);
return (
<div className={cn('relative flex-shrink-0', className)}>
<div
className={cn(
'rounded-full flex items-center justify-center font-semibold text-white',
sizeClasses[size]
)}
style={{ backgroundColor: bgColor }}
>
{initials}
</div>
{showOnline && (
<span
className={cn(
'absolute rounded-full border-2 border-bg-secondary',
dotSizeClasses[size],
isOnline ? 'bg-accent-green shadow-[0_0_6px_#4ade80]' : 'bg-text-muted'
)}
/>
)}
</div>
);
}
interface AvatarGroupProps {
users: Array<{ seed: string; name: string }>;
max?: number;
size?: 'sm' | 'md' | 'lg';
}
export function AvatarGroup({ users, max = 4, size = 'md' }: AvatarGroupProps) {
const displayed = users.slice(0, max);
const remaining = users.length - max;
const overlapClasses = {
sm: '-ml-2',
md: '-ml-3',
lg: '-ml-4',
};
const sizeClasses = {
sm: 'w-6 h-6 text-xs',
md: 'w-8 h-8 text-sm',
lg: 'w-10 h-10 text-base',
};
return (
<div className="flex items-center">
{displayed.map((user, index) => (
<div
key={user.seed}
className={cn(
'rounded-full border-2 border-bg-secondary',
index > 0 && overlapClasses[size]
)}
style={{ zIndex: displayed.length - index }}
>
<Avatar seed={user.seed} name={user.name} size={size} />
</div>
))}
{remaining > 0 && (
<div
className={cn(
'rounded-full bg-bg-tertiary flex items-center justify-center font-medium text-text-secondary border-2 border-bg-secondary',
overlapClasses[size],
sizeClasses[size]
)}
>
+{remaining}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { useEffect, useState } from 'react'
import { useConfigStore } from '../stores/configStore'
type ConnectionState = 'connected' | 'connecting' | 'disconnected' | 'error'
interface ConnectionStatusProps {
showDetails?: boolean
}
export function ConnectionStatus({ showDetails = false }: ConnectionStatusProps) {
const { config } = useConfigStore()
const [status, setStatus] = useState<ConnectionState>('connecting')
const [latency, setLatency] = useState<number | null>(null)
useEffect(() => {
const checkConnection = async () => {
if (!config?.server_url) {
setStatus('disconnected')
return
}
try {
const start = performance.now()
const response = await fetch(`${config.server_url}/api/v1/health`, {
signal: AbortSignal.timeout(5000),
})
const elapsed = Math.round(performance.now() - start)
if (response.ok) {
setStatus('connected')
setLatency(elapsed)
} else {
setStatus('error')
setLatency(null)
}
} catch {
setStatus('disconnected')
setLatency(null)
}
}
checkConnection()
const interval = setInterval(checkConnection, 30000)
return () => clearInterval(interval)
}, [config?.server_url])
const statusConfig = {
connected: {
color: 'bg-green-500',
text: '已连接',
icon: '🟢',
},
connecting: {
color: 'bg-yellow-500 animate-pulse',
text: '连接中',
icon: '🟡',
},
disconnected: {
color: 'bg-red-500',
text: '未连接',
icon: '🔴',
},
error: {
color: 'bg-orange-500',
text: '连接异常',
icon: '🟠',
},
}
const { color, text, icon } = statusConfig[status]
if (!showDetails) {
return (
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${color}`} />
<span className="text-xs text-gray-400">{text}</span>
</div>
)
}
return (
<div className="flex items-center gap-3 px-3 py-2 bg-gray-800/50 rounded-lg">
<span className="text-sm">{icon}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white">{config?.server_name || 'FunMC'}</p>
<p className="text-xs text-gray-400 truncate">{config?.server_url}</p>
</div>
<div className="text-right">
<p className="text-xs text-gray-400">{text}</p>
{latency !== null && (
<p className={`text-xs font-mono ${
latency < 100 ? 'text-green-400' :
latency < 300 ? 'text-yellow-400' : 'text-red-400'
}`}>
{latency}ms
</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,60 @@
import { useState } from 'react';
import { Modal } from './Modal';
import { useRoomStore } from '../stores/roomStore';
import { useToast } from './Toast';
const VERSIONS = ['1.21.4', '1.21.3', '1.21', '1.20.4', '1.20.1', '1.19.4', '1.18.2', '1.16.5', '1.12.2'];
export function CreateRoomModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
const { createRoom, loading } = useRoomStore();
const { showToast } = useToast();
const [form, setForm] = useState({ name: '', password: '', max_players: 8, game_version: '1.21.4', is_public: true });
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim()) { showToast('请输入房间名称', 'error'); return; }
try {
await createRoom({ name: form.name.trim(), password: form.password || undefined, max_players: form.max_players, game_version: form.game_version, is_public: form.is_public });
showToast('房间创建成功', 'success');
onClose();
setForm({ name: '', password: '', max_players: 8, game_version: '1.21.4', is_public: true });
} catch (err) { showToast(err instanceof Error ? err.message : '创建失败', 'error'); }
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="创建房间">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1.5"> <span className="text-accent-red">*</span></label>
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="input w-full" placeholder="输入房间名称" maxLength={50} autoFocus />
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1.5"> <span className="text-text-muted">()</span></label>
<input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} className="input w-full" placeholder="留空表示无密码" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1.5"></label>
<select value={form.max_players} onChange={(e) => setForm({ ...form, max_players: parseInt(e.target.value) })} className="input w-full">
{[2, 4, 6, 8, 10, 12, 16, 20].map((n) => <option key={n} value={n}>{n} </option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1.5"></label>
<select value={form.game_version} onChange={(e) => setForm({ ...form, game_version: e.target.value })} className="input w-full">
{VERSIONS.map((v) => <option key={v} value={v}>{v}</option>)}
</select>
</div>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_public" checked={form.is_public} onChange={(e) => setForm({ ...form, is_public: e.target.checked })} className="w-4 h-4 rounded" />
<label htmlFor="is_public" className="text-sm text-text-secondary"></label>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<button type="button" onClick={onClose} className="btn-ghost"></button>
<button type="submit" className="btn-primary" disabled={loading}>{loading ? '创建中...' : '创建房间'}</button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,98 @@
import { cn } from '../lib/utils';
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
className?: string;
}
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
return (
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
{icon && <div className="mb-4 text-text-muted opacity-40">{icon}</div>}
<h3 className="text-sm font-medium text-text-primary mb-1">{title}</h3>
{description && <p className="text-xs text-text-muted max-w-xs">{description}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
);
}
export function NoRoomsState({ onCreate }: { onCreate?: () => void }) {
return (
<EmptyState
icon={
<svg
className="w-12 h-12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
}
title="暂无公开房间"
description="创建第一个房间开始游戏,或等待其他玩家创建"
action={
onCreate && (
<button onClick={onCreate} className="btn-primary">
</button>
)
}
/>
);
}
export function NoFriendsState() {
return (
<EmptyState
icon={
<svg
className="w-12 h-12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
}
title="还没有好友"
description="在右侧输入用户名添加好友,一起联机游戏"
/>
);
}
export function NoRequestsState() {
return (
<EmptyState
icon={
<svg
className="w-10 h-10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
}
title="暂无好友请求"
description="当有人想添加你为好友时,请求会显示在这里"
/>
);
}

View File

@@ -0,0 +1,59 @@
import { Component, ErrorInfo, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo)
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
<div className="bg-gray-800 rounded-2xl p-8 max-w-md w-full text-center">
<div className="text-5xl mb-4">😵</div>
<h2 className="text-xl font-bold text-white mb-2"></h2>
<p className="text-gray-400 text-sm mb-6">
</p>
<div className="bg-gray-700/50 rounded-lg p-3 mb-6 text-left">
<code className="text-xs text-red-400 break-all">
{this.state.error?.message || '未知错误'}
</code>
</div>
<button
onClick={() => window.location.reload()}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
</button>
</div>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,121 @@
import { Avatar } from './Avatar';
import { cn } from '../lib/utils';
import { Friend, FriendRequest } from '../stores/friendStore';
interface FriendCardProps {
friend: Friend;
onRemove?: () => void;
onInvite?: () => void;
className?: string;
}
export function FriendCard({ friend, onRemove, onInvite, className }: FriendCardProps) {
return (
<div
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-bg-tertiary transition-colors group',
className
)}
>
<Avatar
seed={friend.avatar_seed}
name={friend.username}
size="md"
showOnline
isOnline={friend.is_online}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{friend.username}</p>
<p className="text-xs text-text-muted">{friend.is_online ? '在线' : '离线'}</p>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onInvite && friend.is_online && (
<button
onClick={onInvite}
className="p-1.5 rounded text-text-muted hover:text-accent-green hover:bg-accent-green/10 transition-colors"
title="邀请加入房间"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="8.5" cy="7" r="4" />
<line x1="20" y1="8" x2="20" y2="14" />
<line x1="23" y1="11" x2="17" y2="11" />
</svg>
</button>
)}
{onRemove && (
<button
onClick={onRemove}
className="p-1.5 rounded text-text-muted hover:text-accent-red hover:bg-accent-red/10 transition-colors"
title="删除好友"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4h6v2" />
</svg>
</button>
)}
</div>
</div>
);
}
interface FriendRequestCardProps {
request: FriendRequest;
onAccept: () => void;
onReject?: () => void;
className?: string;
}
export function FriendRequestCard({
request,
onAccept,
onReject,
className,
}: FriendRequestCardProps) {
return (
<div
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg bg-bg-tertiary',
className
)}
>
<Avatar seed={request.avatar_seed} name={request.username} size="md" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{request.username}</p>
<p className="text-xs text-text-muted"></p>
</div>
<div className="flex items-center gap-2">
{onReject && (
<button
onClick={onReject}
className="btn-ghost py-1 px-2 text-xs"
>
</button>
)}
<button onClick={onAccept} className="btn-primary py-1 px-3 text-xs">
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { useState } from 'react';
import { Modal } from './Modal';
import { useRoomStore, Room } from '../stores/roomStore';
import { useToast } from './Toast';
export function JoinRoomModal({ isOpen, onClose, room }: { isOpen: boolean; onClose: () => void; room: Room | null }) {
const { joinRoom, loading } = useRoomStore();
const { showToast } = useToast();
const [password, setPassword] = useState('');
if (!room) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (room.has_password && !password) { showToast('请输入房间密码', 'error'); return; }
try {
await joinRoom(room.id, password || undefined);
showToast('加入房间成功', 'success');
onClose();
setPassword('');
} catch (err) { showToast(err instanceof Error ? err.message : '加入失败', 'error'); }
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="加入房间">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="card bg-bg-tertiary">
<h4 className="font-medium text-text-primary">{room.name}</h4>
<p className="text-sm text-text-muted mt-1">: {room.owner_username}</p>
<div className="flex items-center gap-4 mt-2 text-xs text-text-secondary">
<span>: {room.current_players}/{room.max_players}</span>
<span>: {room.game_version}</span>
</div>
</div>
{room.has_password && (
<div>
<label className="block text-sm font-medium text-text-secondary mb-1.5"></label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="input w-full" placeholder="输入房间密码" autoFocus />
</div>
)}
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<button type="button" onClick={onClose} className="btn-ghost"></button>
<button type="submit" className="btn-primary" disabled={loading}>{loading ? '加入中...' : '加入房间'}</button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,68 @@
import { cn } from '../lib/utils';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
const sizeClasses = {
sm: 'w-4 h-4 border-2',
md: 'w-6 h-6 border-2',
lg: 'w-8 h-8 border-3',
};
return (
<div
className={cn(
'animate-spin rounded-full border-accent-green/20 border-t-accent-green',
sizeClasses[size],
className
)}
/>
);
}
interface LoadingOverlayProps {
message?: string;
}
export function LoadingOverlay({ message = '加载中...' }: LoadingOverlayProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-bg-primary/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4">
<LoadingSpinner size="lg" />
<p className="text-sm text-text-secondary">{message}</p>
</div>
</div>
);
}
interface LoadingCardProps {
className?: string;
}
export function LoadingCard({ className }: LoadingCardProps) {
return (
<div className={cn('card animate-pulse', className)}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-bg-tertiary" />
<div className="flex-1 space-y-2">
<div className="h-4 w-24 rounded bg-bg-tertiary" />
<div className="h-3 w-16 rounded bg-bg-tertiary" />
</div>
</div>
</div>
);
}
export function LoadingPage() {
return (
<div className="h-full flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<LoadingSpinner size="lg" />
<p className="text-sm text-text-muted">...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg'
text?: string
className?: string
}
export function LoadingSpinner({ size = 'md', text, className = '' }: LoadingSpinnerProps) {
const sizeClasses = {
sm: 'w-4 h-4 border-2',
md: 'w-8 h-8 border-3',
lg: 'w-12 h-12 border-4',
}
return (
<div className={`flex flex-col items-center justify-center ${className}`}>
<div
className={`${sizeClasses[size]} border-gray-600 border-t-green-500 rounded-full animate-spin`}
/>
{text && <p className="mt-3 text-sm text-gray-400">{text}</p>}
</div>
)
}
export function FullPageLoading({ text = '加载中...' }: { text?: string }) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="text-center">
<LoadingSpinner size="lg" />
<p className="mt-4 text-gray-400">{text}</p>
</div>
</div>
)
}
export function CardLoading() {
return (
<div className="animate-pulse">
<div className="h-4 bg-gray-700 rounded w-3/4 mb-3"></div>
<div className="h-3 bg-gray-700 rounded w-1/2 mb-2"></div>
<div className="h-3 bg-gray-700 rounded w-2/3"></div>
</div>
)
}

View File

@@ -0,0 +1,117 @@
import { useEffect, useRef } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg';
}
export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
document.addEventListener('keydown', handleEsc);
}
return () => document.removeEventListener('keydown', handleEsc);
}, [isOpen, onClose]);
if (!isOpen) return null;
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
}[size];
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
onClick={(e) => e.target === overlayRef.current && onClose()}
>
<div
className={`w-full ${sizeClasses} bg-bg-secondary border border-border rounded-xl shadow-2xl animate-scale-in`}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h2 id="modal-title" className="text-lg font-semibold text-text-primary">
{title}
</h2>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-text-muted hover:text-text-primary hover:bg-bg-tertiary transition-colors"
aria-label="关闭"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-5">
{children}
</div>
</div>
</div>
);
}
interface ConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'default';
}
export function ConfirmModal({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = '确认',
cancelText = '取消',
variant = 'default',
}: ConfirmModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
<p className="text-sm text-text-secondary mb-6">{message}</p>
<div className="flex justify-end gap-3">
<button onClick={onClose} className="btn-secondary">
{cancelText}
</button>
<button
onClick={() => {
onConfirm();
onClose();
}}
className={variant === 'danger' ? 'btn-danger' : 'btn-primary'}
>
{confirmText}
</button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,73 @@
import { useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { cn, formatBytes, formatDuration } from '../lib/utils';
interface ConnectionStats {
is_connected: boolean;
connection_type: 'p2p' | 'relay' | 'none';
local_address: string | null;
remote_address: string | null;
bytes_sent: number;
bytes_received: number;
latency_ms: number;
connected_since: string | null;
packets_sent: number;
packets_received: number;
packets_lost: number;
}
export function NetworkStats({ className, compact = false }: { className?: string; compact?: boolean }) {
const [stats, setStats] = useState<ConnectionStats | null>(null);
useEffect(() => {
const fetchStats = async () => {
try {
const data = await invoke<ConnectionStats>('get_connection_stats');
setStats(data);
} catch {}
};
fetchStats();
const interval = setInterval(fetchStats, 2000);
return () => clearInterval(interval);
}, []);
if (!stats) {
return <div className={cn('card animate-pulse', className)}><div className="h-4 w-24 bg-bg-tertiary rounded" /></div>;
}
if (compact) {
return (
<div className={cn('flex items-center gap-3', className)}>
<ConnectionBadge type={stats.connection_type} />
{stats.is_connected && <span className="text-xs text-text-muted">{stats.latency_ms}ms</span>}
</div>
);
}
return (
<div className={cn('card', className)}>
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-text-primary"></h3>
<ConnectionBadge type={stats.connection_type} />
</div>
{stats.is_connected ? (
<div className="grid grid-cols-2 gap-3">
<div><p className="text-xs text-text-muted"></p><p className="text-sm font-medium">{stats.latency_ms}ms</p></div>
<div><p className="text-xs text-text-muted"></p><p className="text-sm font-medium">{formatBytes(stats.bytes_sent)}</p></div>
<div><p className="text-xs text-text-muted"></p><p className="text-sm font-medium">{formatBytes(stats.bytes_received)}</p></div>
<div><p className="text-xs text-text-muted"></p><p className="text-sm font-medium">{stats.connected_since ? formatDuration(Date.now() - new Date(stats.connected_since).getTime()) : '-'}</p></div>
</div>
) : <p className="text-text-muted text-sm"></p>}
</div>
);
}
function ConnectionBadge({ type }: { type: string }) {
const styles: Record<string, string> = {
p2p: 'bg-accent-green/15 text-accent-green',
relay: 'bg-accent-orange/15 text-accent-orange',
none: 'bg-text-muted/15 text-text-muted',
};
const labels: Record<string, string> = { p2p: 'P2P 直连', relay: '中继', none: '未连接' };
return <span className={cn('px-2 py-0.5 rounded-full text-xs font-medium', styles[type])}>{labels[type]}</span>;
}

View File

@@ -0,0 +1,144 @@
import { cn } from '../lib/utils';
import { Room } from '../stores/roomStore';
interface RoomCardProps {
room: Room;
onClick?: () => void;
className?: string;
}
export function RoomCard({ room, onClick, className }: RoomCardProps) {
const statusColors: Record<string, string> = {
open: 'bg-accent-green/15 text-accent-green border-accent-green/20',
in_game: 'bg-accent-orange/15 text-accent-orange border-accent-orange/20',
closed: 'bg-text-muted/15 text-text-muted border-text-muted/20',
};
const statusLabels: Record<string, string> = {
open: '开放',
in_game: '游戏中',
closed: '已关闭',
};
return (
<div
className={cn(
'card-hover group',
onClick && 'cursor-pointer',
className
)}
onClick={onClick}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-text-primary truncate group-hover:text-accent-green transition-colors">
{room.name}
</h3>
{room.has_password && (
<svg
className="w-3.5 h-3.5 text-text-muted flex-shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
)}
</div>
<p className="text-text-muted text-xs mt-1">: {room.owner_username}</p>
</div>
<div className="flex-shrink-0">
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border',
statusColors[room.status] ?? statusColors.closed
)}
>
{statusLabels[room.status] ?? room.status}
</span>
</div>
</div>
<div className="flex items-center gap-4 mt-3 pt-3 border-t border-border">
<div className="flex items-center gap-1.5 text-text-secondary text-xs">
<svg
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span>
{room.current_players}/{room.max_players}
</span>
</div>
<div className="flex items-center gap-1.5 text-text-secondary text-xs">
<svg
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
<span>{room.game_version}</span>
</div>
{!room.is_public && (
<div className="flex items-center gap-1.5 text-text-muted text-xs">
<svg
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
<span></span>
</div>
)}
</div>
</div>
);
}
interface RoomCardSkeletonProps {
className?: string;
}
export function RoomCardSkeleton({ className }: RoomCardSkeletonProps) {
return (
<div className={cn('card animate-pulse', className)}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-2">
<div className="h-5 w-32 bg-bg-tertiary rounded" />
<div className="h-3 w-20 bg-bg-tertiary rounded" />
</div>
<div className="h-5 w-12 bg-bg-tertiary rounded-full" />
</div>
<div className="flex items-center gap-4 mt-3 pt-3 border-t border-border">
<div className="h-3 w-12 bg-bg-tertiary rounded" />
<div className="h-3 w-10 bg-bg-tertiary rounded" />
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { useState, useEffect, createContext, useContext, useCallback } from 'react';
interface Toast {
id: string;
type: 'success' | 'error' | 'info' | 'warning';
message: string;
duration?: number;
}
interface ToastContextValue {
toasts: Toast[];
addToast: (toast: Omit<Toast, 'id'>) => void;
removeToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((toast: Omit<Toast, 'id'>) => {
const id = Math.random().toString(36).slice(2, 9);
setToasts((prev) => [...prev, { ...toast, id }]);
}, []);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return (
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
{children}
<ToastContainer />
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
const toast = {
success: (message: string) => context.addToast({ type: 'success', message }),
error: (message: string) => context.addToast({ type: 'error', message }),
info: (message: string) => context.addToast({ type: 'info', message }),
warning: (message: string) => context.addToast({ type: 'warning', message }),
};
return toast;
}
function ToastContainer() {
const context = useContext(ToastContext);
if (!context) return null;
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{context.toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onClose={() => context.removeToast(toast.id)} />
))}
</div>
);
}
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
useEffect(() => {
const timer = setTimeout(onClose, toast.duration || 4000);
return () => clearTimeout(timer);
}, [toast.duration, onClose]);
const bgColor = {
success: 'bg-accent-green/15 border-accent-green/30 text-accent-green',
error: 'bg-accent-red/15 border-accent-red/30 text-accent-red',
info: 'bg-accent-blue/15 border-accent-blue/30 text-accent-blue',
warning: 'bg-accent-orange/15 border-accent-orange/30 text-accent-orange',
}[toast.type];
const icon = {
success: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
),
error: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
),
info: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
warning: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
),
}[toast.type];
return (
<div
className={`flex items-center gap-3 px-4 py-3 rounded-lg border backdrop-blur-sm shadow-lg animate-slide-up ${bgColor}`}
role="alert"
>
{icon}
<span className="text-sm font-medium text-text-primary">{toast.message}</span>
<button
onClick={onClose}
className="ml-2 p-1 rounded hover:bg-white/10 transition-colors"
aria-label="关闭"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
}

View File

@@ -0,0 +1,12 @@
export { AppLayout } from './AppLayout';
export { Avatar, AvatarGroup } from './Avatar';
export { ConnectionStatus } from './ConnectionStatus';
export { CreateRoomModal } from './CreateRoomModal';
export { EmptyState, NoRoomsState, NoFriendsState, NoRequestsState, NoSearchResultsState } from './EmptyState';
export { FriendCard, FriendRequestCard } from './FriendCard';
export { JoinRoomModal } from './JoinRoomModal';
export { Loading, LoadingOverlay, LoadingCard, LoadingSpinner } from './Loading';
export { Modal, ConfirmModal } from './Modal';
export { NetworkStats } from './NetworkStats';
export { RoomCard, RoomCardSkeleton } from './RoomCard';
export { ToastProvider, useToast } from './Toast';

View File

@@ -0,0 +1,6 @@
{
"server_url": "",
"server_name": "",
"relay_url": "",
"version": "0.1.0"
}

View File

@@ -0,0 +1 @@
export { useSignalingEvents, useNetworkStatus, useFriendEvents, useRoomEvents } from './useWebSocket';

View File

@@ -0,0 +1,147 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
import { useAuthStore } from '../stores/authStore';
type SignalingEvent =
| 'friend:request_received'
| 'friend:request_accepted'
| 'friend:online'
| 'friend:offline'
| 'room:invite_received'
| 'room:member_joined'
| 'room:member_left'
| 'network:signaling'
| 'network:status_changed';
interface EventHandler<T = unknown> {
event: SignalingEvent;
handler: (payload: T) => void;
}
export function useSignalingEvents(handlers: EventHandler[]) {
useEffect(() => {
const unsubscribes: (() => void)[] = [];
for (const { event, handler } of handlers) {
listen(event, (e) => handler(e.payload)).then((unsub) => {
unsubscribes.push(unsub);
});
}
return () => {
unsubscribes.forEach((unsub) => unsub());
};
}, [handlers]);
}
export function useNetworkStatus() {
const [status, setStatus] = useState<{
type: 'p2p' | 'relay' | 'disconnected';
port?: number;
connectAddr?: string;
}>({ type: 'disconnected' });
useEffect(() => {
const unsub = listen<{ type: string; port?: number; connect_addr?: string }>(
'network:status_changed',
(event) => {
setStatus({
type: event.payload.type as 'p2p' | 'relay' | 'disconnected',
port: event.payload.port,
connectAddr: event.payload.connect_addr,
});
}
);
return () => {
unsub.then((fn) => fn());
};
}, []);
return status;
}
export function useFriendEvents(
onRequest?: (from: string, username: string) => void,
onAccepted?: (from: string, username: string) => void,
onOnline?: (userId: string) => void,
onOffline?: (userId: string) => void
) {
useEffect(() => {
const unsubscribes: Promise<() => void>[] = [];
if (onRequest) {
unsubscribes.push(
listen<{ from: string; username: string }>('friend:request_received', (e) => {
onRequest(e.payload.from, e.payload.username);
})
);
}
if (onAccepted) {
unsubscribes.push(
listen<{ from: string; username: string }>('friend:request_accepted', (e) => {
onAccepted(e.payload.from, e.payload.username);
})
);
}
if (onOnline) {
unsubscribes.push(
listen<{ user_id: string }>('friend:online', (e) => {
onOnline(e.payload.user_id);
})
);
}
if (onOffline) {
unsubscribes.push(
listen<{ user_id: string }>('friend:offline', (e) => {
onOffline(e.payload.user_id);
})
);
}
return () => {
unsubscribes.forEach((p) => p.then((fn) => fn()));
};
}, [onRequest, onAccepted, onOnline, onOffline]);
}
export function useRoomEvents(
onInvite?: (from: string, roomId: string, roomName: string) => void,
onMemberJoined?: (roomId: string, userId: string, username: string) => void,
onMemberLeft?: (roomId: string, userId: string) => void
) {
useEffect(() => {
const unsubscribes: Promise<() => void>[] = [];
if (onInvite) {
unsubscribes.push(
listen<{ from: string; room_id: string; room_name: string }>('room:invite_received', (e) => {
onInvite(e.payload.from, e.payload.room_id, e.payload.room_name);
})
);
}
if (onMemberJoined) {
unsubscribes.push(
listen<{ room_id: string; user_id: string; username: string }>('room:member_joined', (e) => {
onMemberJoined(e.payload.room_id, e.payload.user_id, e.payload.username);
})
);
}
if (onMemberLeft) {
unsubscribes.push(
listen<{ room_id: string; user_id: string }>('room:member_left', (e) => {
onMemberLeft(e.payload.room_id, e.payload.user_id);
})
);
}
return () => {
unsubscribes.forEach((p) => p.then((fn) => fn()));
};
}, [onInvite, onMemberJoined, onMemberLeft]);
}

207
client/ui/src/index.css Normal file
View File

@@ -0,0 +1,207 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
height: 100%;
background-color: #0f1117;
color: #e2e8f0;
font-family: 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
user-select: none;
}
/* Safe area for mobile devices (notch, home indicator) */
.safe-area-top {
padding-top: env(safe-area-inset-top, 0);
}
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0);
}
.safe-area-left {
padding-left: env(safe-area-inset-left, 0);
}
.safe-area-right {
padding-right: env(safe-area-inset-right, 0);
}
/* Touch-friendly tap targets */
@media (pointer: coarse) {
button, a, input, select, textarea {
min-height: 44px;
}
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #363a50;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #4a4f6a;
}
/* 禁止拖拽 */
img, a {
-webkit-user-drag: none;
user-drag: none;
}
}
@layer components {
.btn-primary {
@apply px-4 py-2 rounded-lg bg-accent-green text-bg-primary font-semibold text-sm
hover:bg-green-300 active:scale-95 transition-all duration-150 disabled:opacity-50
disabled:cursor-not-allowed;
}
.btn-secondary {
@apply px-4 py-2 rounded-lg bg-bg-tertiary text-text-primary font-medium text-sm
border border-border hover:bg-bg-hover active:scale-95 transition-all duration-150;
}
.btn-danger {
@apply px-4 py-2 rounded-lg bg-accent-red/20 text-accent-red font-medium text-sm
border border-accent-red/30 hover:bg-accent-red/30 active:scale-95 transition-all duration-150;
}
.btn-ghost {
@apply px-4 py-2 rounded-lg text-text-secondary font-medium text-sm
hover:bg-bg-tertiary hover:text-text-primary active:scale-95 transition-all duration-150;
}
.input-field {
@apply w-full px-3 py-2 rounded-lg bg-bg-tertiary border border-border text-text-primary
text-sm placeholder:text-text-muted focus:outline-none focus:border-accent-green/60
focus:ring-1 focus:ring-accent-green/30 transition-all duration-150;
}
.card {
@apply bg-bg-secondary rounded-xl border border-border p-4;
}
.card-hover {
@apply bg-bg-secondary rounded-xl border border-border p-4
hover:border-accent-green/30 hover:bg-bg-tertiary/50 transition-all duration-200 cursor-pointer;
}
.badge-online {
@apply w-2 h-2 rounded-full bg-accent-green shadow-[0_0_6px_#4ade80];
}
.badge-offline {
@apply w-2 h-2 rounded-full bg-text-muted;
}
.badge-status {
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium;
}
.badge-status-open {
@apply badge-status bg-accent-green/15 text-accent-green border border-accent-green/20;
}
.badge-status-ingame {
@apply badge-status bg-accent-orange/15 text-accent-orange border border-accent-orange/20;
}
.badge-status-closed {
@apply badge-status bg-text-muted/15 text-text-muted border border-text-muted/20;
}
/* 加载动画 */
.loading-spinner {
@apply animate-spin rounded-full border-2 border-accent-green/20 border-t-accent-green;
}
/* 工具提示 */
.tooltip {
@apply absolute z-50 px-2 py-1 text-xs font-medium text-text-primary
bg-bg-tertiary border border-border rounded-lg shadow-lg
animate-fade-in;
}
/* 连接状态指示器 */
.connection-indicator {
@apply inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium;
}
.connection-p2p {
@apply connection-indicator bg-accent-green/15 text-accent-green;
}
.connection-relay {
@apply connection-indicator bg-accent-orange/15 text-accent-orange;
}
.connection-disconnected {
@apply connection-indicator bg-text-muted/15 text-text-muted;
}
}
@layer utilities {
/* 动画 */
.animate-fade-in {
animation: fadeIn 0.2s ease-out;
}
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
.animate-slide-down {
animation: slideDown 0.3s ease-out;
}
.animate-scale-in {
animation: scaleIn 0.2s ease-out;
}
.animate-pulse-green {
animation: pulseGreen 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes pulseGreen {
0%, 100% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(74, 222, 128, 0); }
}
/* Minecraft 风格像素边框 */
.pixel-border {
box-shadow:
inset -2px -2px 0 0 #1a1d27,
inset 2px 2px 0 0 #4a4f6a;
}
}

View File

@@ -0,0 +1,93 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
export function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}小时${minutes % 60}分钟`;
}
if (minutes > 0) {
return `${minutes}分钟${seconds % 60}`;
}
return `${seconds}`;
}
export function formatRelativeTime(date: Date | string | number): string {
const now = new Date();
const then = new Date(date);
const diffMs = now.getTime() - then.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return '刚刚';
if (diffMin < 60) return `${diffMin}分钟前`;
if (diffHour < 24) return `${diffHour}小时前`;
if (diffDay < 7) return `${diffDay}天前`;
return then.toLocaleDateString('zh-CN');
}
export function generateAvatarColor(seed: string): string {
const hash = seed.split('').reduce((acc, char) => {
return char.charCodeAt(0) + ((acc << 5) - acc);
}, 0);
const h = Math.abs(hash) % 360;
return `hsl(${h}, 60%, 50%)`;
}
export function getInitials(name: string): string {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
export function copyToClipboard(text: string): Promise<void> {
return navigator.clipboard.writeText(text);
}
export function debounce<T extends (...args: unknown[]) => unknown>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), wait);
};
}
export function throttle<T extends (...args: unknown[]) => unknown>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle: boolean;
return (...args: Parameters<T>) => {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}

124
client/ui/src/main.tsx Normal file
View File

@@ -0,0 +1,124 @@
import React, { useEffect, useState } from 'react'
import ReactDOM from 'react-dom/client'
import { createMemoryRouter, RouterProvider, redirect, Navigate } from 'react-router-dom'
import './index.css'
import LoginPage from './pages/Login'
import RegisterPage from './pages/Register'
import DashboardPage from './pages/Dashboard'
import FriendsPage from './pages/Friends'
import RoomPage from './pages/Room'
import SettingsPage from './pages/Settings'
import ServerSetupPage from './pages/ServerSetup'
import AppLayout from './components/AppLayout'
import { ToastProvider } from './components/Toast'
import { useAuthStore } from './stores/authStore'
import { useConfigStore } from './stores/configStore'
function requireAuth() {
const { user } = useAuthStore.getState()
if (!user) return redirect('/login')
return null
}
function requireGuest() {
const { user } = useAuthStore.getState()
if (user) return redirect('/dashboard')
return null
}
function requireConfig() {
const { config } = useConfigStore.getState()
if (!config || !config.server_url) return redirect('/setup')
return null
}
const router = createMemoryRouter([
{
path: '/setup',
element: <ServerSetupPage />,
},
{
path: '/login',
loader: () => {
requireConfig()
return requireGuest()
},
element: <LoginPage />,
},
{
path: '/register',
loader: () => {
requireConfig()
return requireGuest()
},
element: <RegisterPage />,
},
{
path: '/',
loader: requireAuth,
element: <AppLayout />,
children: [
{ index: true, element: <Navigate to="/dashboard" replace /> },
{ path: 'dashboard', element: <DashboardPage /> },
{ path: 'friends', element: <FriendsPage /> },
{ path: 'room/:roomId', element: <RoomPage /> },
{ path: 'settings', element: <SettingsPage /> },
],
},
{ path: '*', element: <Navigate to="/login" replace /> },
], { initialEntries: ['/setup'] })
function App() {
const { init: initAuth } = useAuthStore()
const { initConfig, config } = useConfigStore()
const [ready, setReady] = useState(false)
useEffect(() => {
const initialize = async () => {
await initConfig()
const { config: currentConfig } = useConfigStore.getState()
if (currentConfig && currentConfig.server_url) {
await initAuth()
const { user } = useAuthStore.getState()
if (user) {
router.navigate('/dashboard', { replace: true })
} else {
router.navigate('/login', { replace: true })
}
} else {
router.navigate('/setup', { replace: true })
}
setReady(true)
}
initialize()
}, [])
if (!ready) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="text-center">
<div className="text-4xl mb-4">🎮</div>
<div className="text-white text-xl font-bold">FunMC</div>
<div className="text-gray-400 text-sm mt-2">...</div>
</div>
</div>
)
}
return (
<ToastProvider>
<RouterProvider router={router} />
</ToastProvider>
)
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -0,0 +1,232 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useRoomStore, Room } from '../stores/roomStore'
import { useAuthStore } from '../stores/authStore'
function RoomCard({ room, onJoin }: { room: Room; onJoin: (room: Room) => void }) {
const statusColors: Record<string, string> = {
open: 'text-accent-green',
in_game: 'text-accent-orange',
closed: 'text-text-muted',
}
const statusLabels: Record<string, string> = {
open: '开放',
in_game: '游戏中',
closed: '已关闭',
}
return (
<div className="card hover:border-border-DEFAULT hover:bg-bg-tertiary transition-colors duration-150 cursor-pointer group"
onClick={() => onJoin(room)}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-text-primary truncate">{room.name}</h3>
{room.has_password && (
<svg className="w-3.5 h-3.5 text-text-muted flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
)}
</div>
<p className="text-text-muted text-xs mt-1">: {room.owner_username}</p>
</div>
<div className="text-right flex-shrink-0">
<span className={`text-xs font-medium ${statusColors[room.status] ?? 'text-text-muted'}`}>
{statusLabels[room.status] ?? room.status}
</span>
</div>
</div>
<div className="flex items-center gap-4 mt-3 pt-3 border-t border-border">
<div className="flex items-center gap-1.5 text-text-secondary text-xs">
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
{room.current_players}/{room.max_players}
</div>
<div className="flex items-center gap-1.5 text-text-secondary text-xs">
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/>
</svg>
{room.game_version}
</div>
</div>
</div>
)
}
export default function DashboardPage() {
const { rooms, loading, fetchRooms, joinRoom, createRoom } = useRoomStore()
const { user } = useAuthStore()
const navigate = useNavigate()
const [showCreate, setShowCreate] = useState(false)
const [createName, setCreateName] = useState('')
const [createVersion, setCreateVersion] = useState('1.20')
const [createMax, setCreateMax] = useState('10')
const [createPw, setCreatePw] = useState('')
const [createPublic, setCreatePublic] = useState(true)
const [createError, setCreateError] = useState('')
const [creating, setCreating] = useState(false)
useEffect(() => {
fetchRooms()
const id = setInterval(fetchRooms, 10000)
return () => clearInterval(id)
}, [])
const handleJoin = async (room: Room) => {
try {
let password: string | undefined
if (room.has_password) {
password = window.prompt(`房间 "${room.name}" 需要密码:`) ?? undefined
if (password === undefined) return
}
await joinRoom(room.id, password)
navigate(`/room/${room.id}`)
} catch (e: any) {
alert(`加入失败: ${e}`)
}
}
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
setCreateError('')
setCreating(true)
try {
const roomId = await createRoom({
name: createName,
maxPlayers: parseInt(createMax) || 10,
isPublic: createPublic,
password: createPw || undefined,
gameVersion: createVersion,
})
await joinRoom(roomId)
navigate(`/room/${roomId}`)
} catch (e: any) {
setCreateError(String(e))
setCreating(false)
}
}
return (
<div className="h-full flex flex-col overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-text-primary"></h1>
<p className="text-text-muted text-xs mt-0.5"></p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => fetchRooms()}
className="btn-secondary flex items-center gap-2"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
</button>
<button
onClick={() => setShowCreate(true)}
className="btn-primary flex items-center gap-2"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</div>
</div>
{/* Room grid */}
<div className="flex-1 overflow-y-auto p-6">
{loading && rooms.length === 0 ? (
<div className="flex items-center justify-center h-32 text-text-muted text-sm">
...
</div>
) : rooms.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-text-muted">
<svg className="w-12 h-12 mb-3 opacity-30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
</svg>
<p className="text-sm"></p>
<p className="text-xs mt-1"></p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{rooms.map((room) => (
<RoomCard key={room.id} room={room} onJoin={handleJoin} />
))}
</div>
)}
</div>
{/* Create room modal */}
{showCreate && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50 animate-fade-in">
<div className="card w-full max-w-sm animate-slide-up">
<div className="flex items-center justify-between mb-5">
<h2 className="font-semibold text-text-primary"></h2>
<button onClick={() => setShowCreate(false)} className="text-text-muted hover:text-text-primary">
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<form onSubmit={handleCreate} className="space-y-3">
<div>
<label className="block text-xs text-text-secondary mb-1"></label>
<input className="input-field" placeholder="我的房间" value={createName}
onChange={(e) => setCreateName(e.target.value)} required />
</div>
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-xs text-text-secondary mb-1"></label>
<input className="input-field" placeholder="1.20" value={createVersion}
onChange={(e) => setCreateVersion(e.target.value)} />
</div>
<div className="flex-1">
<label className="block text-xs text-text-secondary mb-1"></label>
<input className="input-field" type="number" min="2" max="20" value={createMax}
onChange={(e) => setCreateMax(e.target.value)} />
</div>
</div>
<div>
<label className="block text-xs text-text-secondary mb-1"></label>
<input className="input-field" type="password" placeholder="留空表示无密码" value={createPw}
onChange={(e) => setCreatePw(e.target.value)} />
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={createPublic}
onChange={(e) => setCreatePublic(e.target.checked)}
className="w-4 h-4 rounded accent-accent-green" />
<span className="text-sm text-text-secondary"></span>
</label>
{createError && (
<div className="px-3 py-2 rounded-lg bg-accent-red/10 border border-accent-red/30 text-accent-red text-xs">
{createError}
</div>
)}
<div className="flex gap-2 pt-1">
<button type="button" className="btn-secondary flex-1" onClick={() => setShowCreate(false)}></button>
<button type="submit" className="btn-primary flex-1" disabled={creating}>
{creating ? '创建中...' : '创建'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,168 @@
import { useEffect, useState } from 'react'
import { useFriendStore, Friend } from '../stores/friendStore'
function Avatar({ seed, username, size = 8 }: { seed: string; username: string; size?: number }) {
const color = `hsl(${parseInt(seed.slice(0, 8), 16) % 360}, 60%, 50%)`
return (
<div
className={`w-${size} h-${size} rounded-full flex items-center justify-center text-white font-bold flex-shrink-0`}
style={{ backgroundColor: color, fontSize: size < 10 ? '0.75rem' : '1rem' }}
>
{username[0]?.toUpperCase()}
</div>
)
}
function FriendItem({ friend, onRemove }: { friend: Friend; onRemove: (id: string) => void }) {
return (
<div className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-bg-tertiary transition-colors group">
<div className="relative">
<Avatar seed={friend.avatar_seed} username={friend.username} size={8} />
<span className={`absolute -bottom-0.5 -right-0.5 ${friend.is_online ? 'badge-online' : 'badge-offline'}`} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{friend.username}</p>
<p className="text-xs text-text-muted">{friend.is_online ? '在线' : '离线'}</p>
</div>
<button
onClick={() => onRemove(friend.id)}
className="opacity-0 group-hover:opacity-100 text-text-muted hover:text-accent-red transition-all"
title="删除好友"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14H6L5 6"/>
<path d="M10 11v6M14 11v6"/>
<path d="M9 6V4h6v2"/>
</svg>
</button>
</div>
)
}
export default function FriendsPage() {
const { friends, requests, loading, fetchFriends, fetchRequests, sendRequest, acceptRequest, removeFriend } = useFriendStore()
const [tab, setTab] = useState<'friends' | 'requests'>('friends')
const [addUsername, setAddUsername] = useState('')
const [addError, setAddError] = useState('')
const [addSuccess, setAddSuccess] = useState('')
const [sending, setSending] = useState(false)
useEffect(() => {
fetchFriends()
fetchRequests()
}, [])
const handleSendRequest = async (e: React.FormEvent) => {
e.preventDefault()
setAddError('')
setAddSuccess('')
setSending(true)
try {
await sendRequest(addUsername)
setAddSuccess(`已向 ${addUsername} 发送好友请求`)
setAddUsername('')
} catch (e: any) {
setAddError(String(e))
} finally {
setSending(false)
}
}
const onlineCount = friends.filter((f) => f.is_online).length
return (
<div className="h-full flex flex-col overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-border">
<h1 className="text-xl font-bold text-text-primary"></h1>
<p className="text-text-muted text-xs mt-0.5">{onlineCount} 线 · {friends.length} </p>
</div>
<div className="flex-1 flex overflow-hidden">
{/* Left: friend list */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Tabs */}
<div className="flex gap-1 px-4 pt-4">
{(['friends', 'requests'] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
tab === t
? 'bg-bg-tertiary text-text-primary'
: 'text-text-muted hover:text-text-secondary'
}`}
>
{t === 'friends' ? '好友列表' : `好友请求 ${requests.length > 0 ? `(${requests.length})` : ''}`}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-1">
{tab === 'friends' ? (
loading ? (
<p className="text-text-muted text-sm text-center py-8">...</p>
) : friends.length === 0 ? (
<div className="text-center py-12 text-text-muted">
<svg className="w-10 h-10 mx-auto mb-3 opacity-30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<p className="text-sm"></p>
<p className="text-xs mt-1"></p>
</div>
) : (
friends.map((f) => (
<FriendItem
key={f.id}
friend={f}
onRemove={(id) => { if (confirm(`确定删除好友 ${f.username}`)) removeFriend(id) }}
/>
))
)
) : requests.length === 0 ? (
<p className="text-text-muted text-sm text-center py-8"></p>
) : (
requests.map((req) => (
<div key={req.id} className="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-bg-tertiary">
<Avatar seed={req.avatar_seed} username={req.username} size={8} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary">{req.username}</p>
<p className="text-xs text-text-muted"></p>
</div>
<button
onClick={() => acceptRequest(req.id).then(fetchFriends)}
className="btn-primary py-1 px-3 text-xs"
>
</button>
</div>
))
)}
</div>
</div>
{/* Right: add friend */}
<div className="w-64 border-l border-border p-4 flex-shrink-0">
<h3 className="text-sm font-semibold text-text-primary mb-3"></h3>
<form onSubmit={handleSendRequest} className="space-y-2">
<input
className="input-field"
placeholder="输入用户名"
value={addUsername}
onChange={(e) => setAddUsername(e.target.value)}
required
/>
{addError && <p className="text-xs text-accent-red">{addError}</p>}
{addSuccess && <p className="text-xs text-accent-green">{addSuccess}</p>}
<button type="submit" disabled={sending} className="btn-primary w-full">
{sending ? '发送中...' : '发送请求'}
</button>
</form>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,102 @@
import { useState, useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'
export default function LoginPage() {
const { login, loading } = useAuthStore()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
try {
await login(username, password)
navigate('/dashboard')
} catch (e: any) {
setError(String(e))
}
}
return (
<div className="min-h-screen bg-bg-primary flex items-center justify-center p-4">
<div className="w-full max-w-sm animate-fade-in">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-accent-green/10 border border-accent-green/30 mb-4">
<svg className="w-8 h-8 text-accent-green" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<h1 className="text-2xl font-bold text-text-primary">FunMC</h1>
<p className="text-text-secondary text-sm mt-1">Minecraft </p>
</div>
{/* Card */}
<div className="card">
<h2 className="text-lg font-semibold text-text-primary mb-6"></h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-text-secondary mb-1.5"></label>
<input
type="text"
className="input-field"
placeholder="输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
/>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1.5"></label>
<input
type="password"
className="input-field"
placeholder="输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && (
<div className="px-3 py-2 rounded-lg bg-accent-red/10 border border-accent-red/30 text-accent-red text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="btn-primary w-full mt-2"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
<p className="text-center text-text-muted text-sm mt-4">
{' '}
<Link to="/register" className="text-accent-green hover:underline">
</Link>
</p>
</div>
{/* Brand footer */}
<div className="mt-8 text-center">
<div className="flex items-center justify-center gap-2">
<svg className="w-4 h-4 text-accent-purple" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
<span className="text-xs text-text-muted"></span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,137 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'
export default function RegisterPage() {
const { register, loading } = useAuthStore()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (password !== confirm) {
setError('两次密码不一致')
return
}
if (password.length < 8) {
setError('密码至少 8 位')
return
}
try {
await register(username, email, password)
navigate('/dashboard')
} catch (e: any) {
setError(String(e))
}
}
return (
<div className="min-h-screen bg-bg-primary flex items-center justify-center p-4">
<div className="w-full max-w-sm animate-fade-in">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-accent-green/10 border border-accent-green/30 mb-4">
<svg className="w-8 h-8 text-accent-green" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</div>
<h1 className="text-2xl font-bold text-text-primary">FunMC</h1>
<p className="text-text-secondary text-sm mt-1"></p>
</div>
<div className="card">
<h2 className="text-lg font-semibold text-text-primary mb-6"></h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-text-secondary mb-1.5"></label>
<input
type="text"
className="input-field"
placeholder="3-32 个字符"
value={username}
onChange={(e) => setUsername(e.target.value)}
minLength={3}
maxLength={32}
required
autoFocus
/>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1.5"></label>
<input
type="email"
className="input-field"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1.5"></label>
<input
type="password"
className="input-field"
placeholder="至少 8 位"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
required
/>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1.5"></label>
<input
type="password"
className="input-field"
placeholder="再次输入密码"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
/>
</div>
{error && (
<div className="px-3 py-2 rounded-lg bg-accent-red/10 border border-accent-red/30 text-accent-red text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="btn-primary w-full mt-2"
>
{loading ? '注册中...' : '创建账号'}
</button>
</form>
<p className="text-center text-text-muted text-sm mt-4">
{' '}
<Link to="/login" className="text-accent-green hover:underline">
</Link>
</p>
</div>
{/* Brand footer */}
<div className="mt-8 text-center">
<div className="flex items-center justify-center gap-2">
<svg className="w-4 h-4 text-accent-purple" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
<span className="text-xs text-text-muted"></span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,430 @@
import { useEffect, useState, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { useRoomStore } from '../stores/roomStore'
import { useNetworkStore } from '../stores/networkStore'
import { useAuthStore } from '../stores/authStore'
import { useChatStore } from '../stores/chatStore'
import { useToast } from '../components/Toast'
export default function RoomPage() {
const { roomId } = useParams<{ roomId: string }>()
const navigate = useNavigate()
const { currentRoom, rooms, members, leaveRoom, setCurrentRoom, fetchMembers } = useRoomStore()
const { session, connectAddr, startHosting, joinNetwork, stopNetwork, stats, refreshStats } = useNetworkStore()
const { user } = useAuthStore()
const { messages, sendMessage, clearMessages, subscribeToChat } = useChatStore()
const { showToast } = useToast()
const [connecting, setConnecting] = useState(false)
const [copied, setCopied] = useState(false)
const [chatInput, setChatInput] = useState('')
const [showChat, setShowChat] = useState(true)
const [kickingUser, setKickingUser] = useState<string | null>(null)
const chatContainerRef = useRef<HTMLDivElement>(null)
const room = currentRoom ?? rooms.find((r) => r.id === roomId)
useEffect(() => {
if (roomId) {
fetchMembers(roomId)
const membersInterval = setInterval(() => fetchMembers(roomId), 5000)
return () => clearInterval(membersInterval)
}
}, [roomId])
useEffect(() => {
const id = setInterval(refreshStats, 2000)
return () => clearInterval(id)
}, [])
useEffect(() => {
let unlisten: (() => void) | null = null
subscribeToChat().then((fn) => {
unlisten = fn
})
return () => {
unlisten?.()
clearMessages()
}
}, [])
useEffect(() => {
const unlistenKicked = listen<{ room_id: string; reason: string }>('signaling:kicked', (event) => {
if (event.payload.room_id === roomId) {
showToast(event.payload.reason || '你已被踢出房间', 'error')
navigate('/dashboard')
}
})
const unlistenClosed = listen<{ room_id: string }>('signaling:room_closed', (event) => {
if (event.payload.room_id === roomId) {
showToast('房间已关闭', 'info')
navigate('/dashboard')
}
})
return () => {
unlistenKicked.then((fn) => fn())
unlistenClosed.then((fn) => fn())
}
}, [roomId])
useEffect(() => {
if (chatContainerRef.current) {
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight
}
}, [messages])
useEffect(() => {
if (!room && !currentRoom) {
navigate('/dashboard')
}
}, [room])
const isOwner = room?.owner_id === user?.id
const handleConnect = async () => {
if (!roomId || !room) return
setConnecting(true)
try {
if (isOwner) {
await startHosting(roomId, room.name)
} else {
await joinNetwork(roomId, room.owner_id)
}
} catch (e: any) {
alert(`连接失败: ${e}`)
} finally {
setConnecting(false)
}
}
const handleLeave = async () => {
if (!roomId) return
await stopNetwork()
await leaveRoom(roomId)
setCurrentRoom(null)
navigate('/dashboard')
}
const handleCopy = () => {
const addr = connectAddr ?? '127.0.0.1:25565'
navigator.clipboard.writeText(addr)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleSendChat = async (e: React.FormEvent) => {
e.preventDefault()
if (!roomId || !chatInput.trim()) return
try {
await sendMessage(roomId, chatInput.trim())
setChatInput('')
} catch (e: any) {
console.error('Failed to send message:', e)
}
}
const handleKickMember = async (targetUserId: string, username: string) => {
if (!roomId) return
if (!confirm(`确定要踢出 ${username} 吗?`)) return
setKickingUser(targetUserId)
try {
await invoke('kick_room_member', { roomId, targetUserId })
showToast(`已踢出 ${username}`, 'success')
fetchMembers(roomId)
} catch (e: any) {
showToast(`踢出失败: ${e}`, 'error')
} finally {
setKickingUser(null)
}
}
const handleCloseRoom = async () => {
if (!roomId) return
if (!confirm('确定要关闭房间吗?所有成员将被踢出。')) return
try {
await invoke('close_room', { roomId })
showToast('房间已关闭', 'success')
navigate('/dashboard')
} catch (e: any) {
showToast(`关闭失败: ${e}`, 'error')
}
}
const sessionTypeLabel = stats?.session_type === 'p2p' ? 'P2P 直连' : stats?.session_type === 'relay' ? '中继' : '未连接'
const sessionTypeColor = stats?.session_type === 'p2p' ? 'text-accent-green' : stats?.session_type === 'relay' ? 'text-accent-orange' : 'text-text-muted'
return (
<div className="h-full flex flex-col overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-text-primary">{room?.name ?? '房间'}</h1>
<p className="text-text-muted text-xs mt-0.5">
{room?.game_version} · {room?.current_players}/{room?.max_players}
{isOwner && <span className="ml-2 text-accent-green"></span>}
</p>
</div>
<button onClick={handleLeave} className="btn-danger flex items-center gap-2">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{/* Network status card */}
<div className="card">
<h2 className="text-sm font-semibold text-text-primary mb-4"></h2>
{!session ? (
<div className="space-y-3">
<p className="text-text-secondary text-sm">
{isOwner
? '点击"开始托管"让 Minecraft 服务器接受来自此房间成员的连接。'
: '点击"连接"获取本地代理地址,然后在 Minecraft 中添加该服务器。'}
</p>
<button
onClick={handleConnect}
disabled={connecting}
className="btn-primary flex items-center gap-2"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
{connecting ? '连接中...' : isOwner ? '开始托管' : '连接'}
</button>
</div>
) : (
<div className="space-y-4">
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-bg-tertiary rounded-lg p-3">
<p className="text-xs text-text-muted mb-1"></p>
<p className={`text-sm font-semibold ${sessionTypeColor}`}>{sessionTypeLabel}</p>
</div>
<div className="bg-bg-tertiary rounded-lg p-3">
<p className="text-xs text-text-muted mb-1"></p>
<p className="text-sm font-semibold text-text-primary">
{stats?.connected ? `${stats.latency_ms} ms` : '—'}
</p>
</div>
<div className="bg-bg-tertiary rounded-lg p-3">
<p className="text-xs text-text-muted mb-1"></p>
<p className="text-sm font-mono text-text-primary">
{stats ? formatBytes(stats.bytes_sent) : '—'}
</p>
</div>
<div className="bg-bg-tertiary rounded-lg p-3">
<p className="text-xs text-text-muted mb-1"></p>
<p className="text-sm font-mono text-text-primary">
{stats ? formatBytes(stats.bytes_received) : '—'}
</p>
</div>
</div>
{/* MC connect address */}
{!isOwner && connectAddr && (
<div>
<p className="text-xs text-text-muted mb-2"> Minecraft </p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-bg-tertiary border border-border rounded-lg px-3 py-2 text-sm font-mono text-accent-green">
{connectAddr}
</code>
<button onClick={handleCopy} className="btn-secondary px-3 py-2 text-xs">
{copied ? '已复制' : '复制'}
</button>
</div>
</div>
)}
{isOwner && (
<div className="px-3 py-2 bg-accent-green/10 border border-accent-green/20 rounded-lg">
<p className="text-xs text-accent-green">
Minecraft FunMC
</p>
</div>
)}
</div>
)}
</div>
{/* Room members */}
<div className="card">
<h2 className="text-sm font-semibold text-text-primary mb-3">
({members.length})
</h2>
<div className="space-y-2">
{members.length === 0 ? (
<p className="text-text-muted text-sm">...</p>
) : (
members.map((member) => (
<div
key={member.user_id}
className="flex items-center justify-between py-2 px-3 bg-bg-tertiary rounded-lg"
>
<div className="flex items-center gap-3">
<div className="relative">
<div className="w-8 h-8 rounded-full bg-accent-green/20 flex items-center justify-center text-accent-green text-sm font-medium">
{member.username.charAt(0).toUpperCase()}
</div>
<span
className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-bg-tertiary ${
member.is_online ? 'bg-accent-green' : 'bg-text-muted'
}`}
/>
</div>
<div>
<p className="text-sm text-text-primary font-medium">
{member.username}
{member.user_id === user?.id && (
<span className="ml-1 text-text-muted">()</span>
)}
</p>
<p className="text-xs text-text-muted">
{member.role === 'owner' ? '房主' : '成员'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span
className={`text-xs ${
member.is_online ? 'text-accent-green' : 'text-text-muted'
}`}
>
{member.is_online ? '在线' : '离线'}
</span>
{isOwner && member.user_id !== user?.id && (
<button
onClick={() => handleKickMember(member.user_id, member.username)}
disabled={kickingUser === member.user_id}
className="text-xs text-accent-red hover:text-red-400 disabled:opacity-50"
title="踢出"
>
{kickingUser === member.user_id ? '...' : '踢出'}
</button>
)}
</div>
</div>
))
)}
</div>
{isOwner && (
<div className="mt-4 pt-4 border-t border-border">
<button
onClick={handleCloseRoom}
className="text-xs text-accent-red hover:text-red-400"
>
</button>
</div>
)}
</div>
{/* Room info */}
<div className="card">
<h2 className="text-sm font-semibold text-text-primary mb-3"></h2>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary">{room?.owner_username}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary">{room?.game_version}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary">{room?.current_players}/{room?.max_players}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary capitalize">{room?.status}</span>
</div>
</div>
</div>
{/* Chat */}
<div className="card">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-text-primary"></h2>
<button
onClick={() => setShowChat(!showChat)}
className="text-text-muted hover:text-text-primary text-xs"
>
{showChat ? '收起' : '展开'}
</button>
</div>
{showChat && (
<>
<div
ref={chatContainerRef}
className="h-48 overflow-y-auto bg-bg-tertiary rounded-lg p-3 mb-3 space-y-2"
>
{messages.length === 0 ? (
<p className="text-text-muted text-xs text-center py-8">
</p>
) : (
messages
.filter((msg) => msg.room_id === roomId)
.map((msg, i) => (
<div key={i} className="text-sm">
<span className="text-accent-green font-medium">
{msg.username}
{msg.from === user?.id && ' (你)'}
</span>
<span className="text-text-muted mx-1">:</span>
<span className="text-text-primary">{msg.content}</span>
<span className="text-text-muted text-xs ml-2">
{new Date(msg.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
))
)}
</div>
<form onSubmit={handleSendChat} className="flex gap-2">
<input
type="text"
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
placeholder="输入消息..."
className="input-field flex-1"
maxLength={500}
/>
<button
type="submit"
disabled={!chatInput.trim()}
className="btn-primary px-4"
>
</button>
</form>
</>
)}
</div>
</div>
</div>
)
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}

View File

@@ -0,0 +1,130 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useConfigStore } from '../stores/configStore'
export default function ServerSetupPage() {
const navigate = useNavigate()
const { setCustomServer, config, loading, error } = useConfigStore()
const [serverUrl, setServerUrl] = useState('')
const [manualMode, setManualMode] = useState(false)
const handleAutoConnect = async () => {
if (config && config.server_url) {
navigate('/login')
}
}
const handleManualConnect = async () => {
if (!serverUrl.trim()) return
let url = serverUrl.trim()
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'http://' + url
}
await setCustomServer(url)
const { config: newConfig } = useConfigStore.getState()
if (newConfig && newConfig.server_url) {
navigate('/login')
}
}
const hasEmbeddedConfig = config && config.server_url && config.server_url !== 'http://localhost:3000'
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="text-6xl mb-4">🎮</div>
<h1 className="text-3xl font-bold text-white mb-2">FunMC</h1>
<p className="text-gray-400">Minecraft </p>
</div>
<div className="bg-gray-800/50 backdrop-blur rounded-2xl p-6 border border-gray-700/50">
{hasEmbeddedConfig ? (
<>
<div className="text-center mb-6">
<div className="inline-flex items-center gap-2 bg-green-500/20 text-green-400 px-4 py-2 rounded-full text-sm">
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></span>
</div>
</div>
<div className="bg-gray-700/50 rounded-xl p-4 mb-6">
<p className="text-sm text-gray-400 mb-1"></p>
<p className="text-white font-medium">{config?.server_name || 'FunMC Server'}</p>
<p className="text-sm text-gray-400 mt-3 mb-1"></p>
<p className="text-green-400 font-mono text-sm">{config?.server_url}</p>
</div>
<button
onClick={handleAutoConnect}
className="w-full py-3 bg-green-600 text-white rounded-xl font-medium hover:bg-green-700 transition-colors"
>
</button>
<button
onClick={() => setManualMode(true)}
className="w-full mt-3 py-3 bg-gray-700 text-gray-300 rounded-xl font-medium hover:bg-gray-600 transition-colors"
>
使
</button>
</>
) : manualMode || !hasEmbeddedConfig ? (
<>
<h2 className="text-lg font-semibold text-white mb-4 text-center">
</h2>
<div className="mb-6">
<label className="block text-sm text-gray-400 mb-2">
</label>
<input
type="text"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
placeholder="例如: funmc.com:3000 或 192.168.1.100:3000"
className="w-full px-4 py-3 bg-gray-700/50 border border-gray-600 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-green-500 transition-colors"
onKeyDown={(e) => e.key === 'Enter' && handleManualConnect()}
/>
<p className="mt-2 text-xs text-gray-500">
</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
<button
onClick={handleManualConnect}
disabled={loading || !serverUrl.trim()}
className="w-full py-3 bg-green-600 text-white rounded-xl font-medium hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '连接中...' : '连接'}
</button>
{hasEmbeddedConfig && (
<button
onClick={() => setManualMode(false)}
className="w-full mt-3 py-3 bg-gray-700 text-gray-300 rounded-xl font-medium hover:bg-gray-600 transition-colors"
>
</button>
)}
</>
) : null}
</div>
<p className="text-center text-gray-500 text-xs mt-6">
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,299 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useRelayNodeStore } from '../stores/relayNodeStore'
import { useConfigStore } from '../stores/configStore'
import { useAuthStore } from '../stores/authStore'
export default function SettingsPage() {
const navigate = useNavigate()
const { nodes, loading, fetchNodes, addNode, removeNode, reportPing } = useRelayNodeStore()
const { config, customServerUrl, setCustomServer, clearCustomServer } = useConfigStore()
const { logout } = useAuthStore()
const [newName, setNewName] = useState('')
const [newUrl, setNewUrl] = useState('')
const [newRegion, setNewRegion] = useState('')
const [adding, setAdding] = useState(false)
const [addError, setAddError] = useState('')
const [pinging, setPinging] = useState<string | null>(null)
const [showServerChange, setShowServerChange] = useState(false)
const [newServerUrl, setNewServerUrl] = useState('')
const [serverError, setServerError] = useState('')
const [changingServer, setChangingServer] = useState(false)
useEffect(() => {
fetchNodes()
}, [])
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault()
setAddError('')
setAdding(true)
try {
await addNode(newName, newUrl, newRegion || undefined)
setNewName('')
setNewUrl('')
setNewRegion('')
} catch (e: any) {
setAddError(String(e))
} finally {
setAdding(false)
}
}
const handlePing = async (id: string, url: string) => {
setPinging(id)
try {
const start = performance.now()
await fetch(`${url}/ping`, { signal: AbortSignal.timeout(4000) }).catch(() => {})
const rtt = Math.round(performance.now() - start)
await reportPing(id, rtt)
} finally {
setPinging(null)
}
}
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h1 className="text-xl font-bold text-text-primary"></h1>
<p className="text-text-muted text-xs mt-0.5"></p>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6 max-w-2xl">
{/* Server config */}
<div className="card">
<h2 className="text-sm font-semibold text-text-primary mb-1"></h2>
<p className="text-xs text-text-muted mb-4"></p>
<div className="p-3 rounded-lg bg-bg-tertiary border border-border mb-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-text-primary">
{config?.server_name || 'FunMC Server'}
</p>
<p className="text-xs text-text-muted font-mono mt-1">
{config?.server_url || '未配置'}
</p>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 bg-accent-green rounded-full animate-pulse"></span>
<span className="text-xs text-accent-green"></span>
</div>
</div>
{customServerUrl && (
<p className="text-xs text-accent-orange mt-2">使</p>
)}
</div>
{showServerChange ? (
<div className="space-y-3">
<input
type="text"
value={newServerUrl}
onChange={(e) => setNewServerUrl(e.target.value)}
placeholder="输入新的服务器地址"
className="input-field"
/>
{serverError && (
<p className="text-xs text-accent-red">{serverError}</p>
)}
<div className="flex gap-2">
<button
onClick={async () => {
setServerError('')
setChangingServer(true)
try {
let url = newServerUrl.trim()
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'http://' + url
}
await setCustomServer(url)
const { config: newConfig } = useConfigStore.getState()
if (newConfig && newConfig.server_url) {
await logout()
navigate('/login')
} else {
setServerError('无法连接到服务器')
}
} catch (e) {
setServerError(String(e))
} finally {
setChangingServer(false)
}
}}
disabled={changingServer || !newServerUrl.trim()}
className="btn-primary flex-1"
>
{changingServer ? '连接中...' : '切换服务器'}
</button>
<button
onClick={() => {
setShowServerChange(false)
setNewServerUrl('')
setServerError('')
}}
className="btn-secondary"
>
</button>
</div>
</div>
) : (
<div className="flex gap-2">
<button
onClick={() => setShowServerChange(true)}
className="btn-secondary text-sm"
>
</button>
{customServerUrl && (
<button
onClick={async () => {
clearCustomServer()
await logout()
navigate('/setup')
}}
className="btn-secondary text-sm text-accent-orange"
>
</button>
)}
</div>
)}
</div>
{/* Relay nodes */}
<div className="card">
<h2 className="text-sm font-semibold text-text-primary mb-1"></h2>
<p className="text-xs text-text-muted mb-4">
P2P 穿
</p>
{loading ? (
<p className="text-xs text-text-muted">...</p>
) : nodes.length === 0 ? (
<p className="text-xs text-text-muted"></p>
) : (
<div className="space-y-2 mb-4">
{nodes.map((node) => (
<div key={node.id} className="flex items-center gap-2 p-2.5 rounded-lg bg-bg-tertiary border border-border">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-primary truncate">{node.name}</span>
{node.region && node.region !== 'auto' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-bg-hover text-text-muted">
{node.region}
</span>
)}
{node.priority > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded bg-accent-green/15 text-accent-green border border-accent-green/20">
</span>
)}
</div>
<div className="flex items-center gap-3 mt-0.5">
<p className="text-xs text-text-muted font-mono truncate">{node.url}</p>
{node.last_ping_ms != null && (
<span className={`text-xs font-mono flex-shrink-0 ${
node.last_ping_ms < 80 ? 'text-accent-green' :
node.last_ping_ms < 200 ? 'text-accent-orange' : 'text-accent-red'
}`}>
{node.last_ping_ms} ms
</span>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => handlePing(node.id, node.url)}
disabled={pinging === node.id}
className="text-xs px-2 py-1 rounded text-text-muted hover:text-accent-blue hover:bg-accent-blue/10 transition-colors"
>
{pinging === node.id ? '测速...' : '测速'}
</button>
<button
onClick={() => removeNode(node.id)}
className="text-text-muted hover:text-accent-red transition-colors p-1"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
))}
</div>
)}
{/* Add form */}
<form onSubmit={handleAdd} className="border-t border-border pt-4 space-y-2">
<p className="text-xs text-text-secondary font-medium mb-2"></p>
<div className="flex gap-2">
<input
className="input-field w-28"
placeholder="名称"
value={newName}
onChange={(e) => setNewName(e.target.value)}
required
/>
<input
className="input-field flex-1"
placeholder="https://relay.example.com"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
type="url"
required
/>
<input
className="input-field w-24"
placeholder="区域"
value={newRegion}
onChange={(e) => setNewRegion(e.target.value)}
/>
<button type="submit" disabled={adding} className="btn-secondary flex-shrink-0">
{adding ? '添加中' : '添加'}
</button>
</div>
{addError && <p className="text-xs text-accent-red">{addError}</p>}
</form>
</div>
{/* About */}
<div className="card">
<h2 className="text-sm font-semibold text-text-primary mb-3"></h2>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary font-mono">0.1.0</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-accent-purple font-medium"></span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<a href="https://funmc.com" target="_blank" rel="noopener" className="text-accent-green hover:underline">funmc.com</a>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary">Tauri 2 + React</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary">QUIC (quinn) over UDP</span>
</div>
</div>
<div className="mt-4 pt-4 border-t border-border text-center">
<div className="flex items-center justify-center gap-2 mb-1">
<svg className="w-4 h-4 text-accent-purple" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
<span className="text-sm font-medium text-text-primary"></span>
</div>
<p className="text-xs text-text-muted">© 2024 . All rights reserved.</p>
</div>
</div>
</div>
</div>
)
}

42
client/ui/src/router.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { AppLayout } from './components/AppLayout';
import Login from './pages/Login';
import Register from './pages/Register';
import DashboardPage from './pages/Dashboard';
import RoomPage from './pages/Room';
import FriendsPage from './pages/Friends';
import SettingsPage from './pages/Settings';
import { useAuthStore } from './stores/authStore';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { token } = useAuthStore();
if (!token) return <Navigate to="/login" replace />;
return <>{children}</>;
}
function PublicRoute({ children }: { children: React.ReactNode }) {
const { token } = useAuthStore();
if (token) return <Navigate to="/" replace />;
return <>{children}</>;
}
export const router = createBrowserRouter([
{
path: '/login',
element: <PublicRoute><Login /></PublicRoute>,
},
{
path: '/register',
element: <PublicRoute><Register /></PublicRoute>,
},
{
path: '/',
element: <ProtectedRoute><AppLayout /></ProtectedRoute>,
children: [
{ index: true, element: <DashboardPage /> },
{ path: 'room/:roomId', element: <RoomPage /> },
{ path: 'friends', element: <FriendsPage /> },
{ path: 'settings', element: <SettingsPage /> },
],
},
]);

View File

@@ -0,0 +1,101 @@
import { create } from 'zustand'
import { invoke } from '@tauri-apps/api/core'
export interface User {
id: string
username: string
email: string
avatar_seed: string
}
interface AuthResult {
user: User
token: string
}
interface AuthState {
user: User | null
token: string | null
loading: boolean
error: string | null
initialized: boolean
login: (username: string, password: string) => Promise<void>
register: (username: string, email: string, password: string) => Promise<void>
logout: () => Promise<void>
init: () => Promise<void>
connectSignaling: () => Promise<void>
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
token: localStorage.getItem('auth_token'),
loading: false,
error: null,
initialized: false,
init: async () => {
if (get().initialized) return
try {
const result = await invoke<AuthResult | null>('get_current_user')
if (result && result.user && result.token) {
localStorage.setItem('auth_token', result.token)
set({ user: result.user, token: result.token, initialized: true })
// Auto-connect signaling after init
get().connectSignaling().catch(console.error)
} else {
localStorage.removeItem('auth_token')
set({ user: null, token: null, initialized: true })
}
} catch (e) {
console.error('Auth init failed:', e)
localStorage.removeItem('auth_token')
set({ user: null, token: null, initialized: true })
}
},
connectSignaling: async () => {
try {
await invoke('connect_signaling')
} catch (e) {
console.error('Signaling connection failed:', e)
}
},
login: async (username, password) => {
set({ loading: true, error: null })
try {
const result = await invoke<AuthResult>('login', { username, password })
localStorage.setItem('auth_token', result.token)
set({ user: result.user, token: result.token, loading: false })
// Connect signaling after login
get().connectSignaling().catch(console.error)
} catch (e: any) {
set({ error: String(e), loading: false })
throw e
}
},
register: async (username, email, password) => {
set({ loading: true, error: null })
try {
const result = await invoke<AuthResult>('register', { username, email, password })
localStorage.setItem('auth_token', result.token)
set({ user: result.user, token: result.token, loading: false })
// Connect signaling after register
get().connectSignaling().catch(console.error)
} catch (e: any) {
set({ error: String(e), loading: false })
throw e
}
},
logout: async () => {
try {
await invoke('disconnect_signaling')
await invoke('logout')
} finally {
localStorage.removeItem('auth_token')
set({ user: null, token: null })
}
},
}))

View File

@@ -0,0 +1,44 @@
import { create } from 'zustand'
import { invoke } from '@tauri-apps/api/core'
import { listen, UnlistenFn } from '@tauri-apps/api/event'
export interface ChatMessage {
room_id: string
from: string
username: string
content: string
timestamp: number
}
interface ChatState {
messages: ChatMessage[]
sendMessage: (roomId: string, content: string) => Promise<void>
addMessage: (message: ChatMessage) => void
clearMessages: () => void
subscribeToChat: () => Promise<UnlistenFn>
}
export const useChatStore = create<ChatState>((set, get) => ({
messages: [],
sendMessage: async (roomId, content) => {
await invoke('send_chat_message', { roomId, content })
},
addMessage: (message) => {
set((state) => ({
messages: [...state.messages.slice(-99), message],
}))
},
clearMessages: () => {
set({ messages: [] })
},
subscribeToChat: async () => {
const unlisten = await listen<ChatMessage>('signaling:chat_message', (event) => {
get().addMessage(event.payload)
})
return unlisten
},
}))

View File

@@ -0,0 +1,122 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { invoke } from '@tauri-apps/api/core'
export interface ServerConfig {
server_name: string
server_url: string
relay_url: string
version: string
}
interface ConfigState {
config: ServerConfig | null
customServerUrl: string | null
loading: boolean
error: string | null
initConfig: () => Promise<void>
setCustomServer: (url: string) => Promise<void>
clearCustomServer: () => void
getServerUrl: () => string
}
const DEFAULT_CONFIG: ServerConfig = {
server_name: 'FunMC',
server_url: 'http://localhost:3000',
relay_url: 'localhost:7900',
version: '0.1.0',
}
async function fetchServerConfigViaInvoke(serverUrl: string): Promise<ServerConfig | null> {
try {
// First set the server URL in Rust backend
await invoke('set_server_url', { url: serverUrl })
// Then fetch the config via the backend
const config = await invoke<ServerConfig>('fetch_server_config')
return config
} catch (e) {
console.error('Failed to fetch server config:', e)
return null
}
}
export const useConfigStore = create<ConfigState>()(
persist(
(set, get) => ({
config: null,
customServerUrl: null,
loading: false,
error: null,
initConfig: async () => {
set({ loading: true, error: null })
try {
const { customServerUrl } = get()
// Priority: custom server > default
if (customServerUrl) {
const serverConfig = await fetchServerConfigViaInvoke(customServerUrl)
if (serverConfig) {
set({ config: serverConfig, loading: false })
return
}
}
// Try default server
const defaultConfig = await fetchServerConfigViaInvoke(DEFAULT_CONFIG.server_url)
if (defaultConfig) {
set({ config: defaultConfig, loading: false })
return
}
// Use default config if no server is reachable
set({ config: DEFAULT_CONFIG, loading: false })
await invoke('set_server_url', { url: DEFAULT_CONFIG.server_url })
} catch (e) {
console.error('Config init error:', e)
set({ error: String(e), loading: false, config: DEFAULT_CONFIG })
}
},
setCustomServer: async (url: string) => {
set({ loading: true, error: null })
try {
const normalizedUrl = url.endsWith('/') ? url.slice(0, -1) : url
const serverConfig = await fetchServerConfigViaInvoke(normalizedUrl)
if (serverConfig) {
set({
customServerUrl: normalizedUrl,
config: serverConfig,
loading: false
})
} else {
set({
error: '无法连接到服务器,请检查地址是否正确',
loading: false
})
}
} catch (e) {
set({ error: String(e), loading: false })
}
},
clearCustomServer: () => {
set({ customServerUrl: null })
get().initConfig()
},
getServerUrl: () => {
const { config, customServerUrl } = get()
return customServerUrl || config?.server_url || DEFAULT_CONFIG.server_url
},
}),
{
name: 'funmc-config',
partialize: (state) => ({ customServerUrl: state.customServerUrl }),
}
)
)

View File

@@ -0,0 +1,66 @@
import { create } from 'zustand'
import { invoke } from '@tauri-apps/api/core'
export interface Friend {
id: string
username: string
avatar_seed: string
is_online: boolean
status: string
}
export interface FriendRequest {
id: string
username: string
avatar_seed: string
}
interface FriendState {
friends: Friend[]
requests: FriendRequest[]
loading: boolean
fetchFriends: () => Promise<void>
fetchRequests: () => Promise<void>
sendRequest: (username: string) => Promise<void>
acceptRequest: (requesterId: string) => Promise<void>
removeFriend: (friendId: string) => Promise<void>
}
export const useFriendStore = create<FriendState>((set) => ({
friends: [],
requests: [],
loading: false,
fetchFriends: async () => {
set({ loading: true })
try {
const friends = await invoke<Friend[]>('list_friends')
set({ friends, loading: false })
} catch {
set({ loading: false })
}
},
fetchRequests: async () => {
try {
const requests = await invoke<FriendRequest[]>('list_requests')
set({ requests })
} catch {}
},
sendRequest: async (username) => {
await invoke('send_friend_request', { username })
},
acceptRequest: async (requesterId) => {
await invoke('accept_friend_request', { requesterId })
set((s) => ({
requests: s.requests.filter((r) => r.id !== requesterId),
}))
},
removeFriend: async (friendId) => {
await invoke('remove_friend', { friendId })
set((s) => ({ friends: s.friends.filter((f) => f.id !== friendId) }))
},
}))

View File

@@ -0,0 +1,66 @@
import { create } from 'zustand'
import { invoke } from '@tauri-apps/api/core'
export interface ConnectionStats {
session_type: string
latency_ms: number
bytes_sent: number
bytes_received: number
connected: boolean
}
export interface NetworkSession {
room_id: string
local_port: number
session_type: string
}
interface NetworkState {
stats: ConnectionStats | null
session: NetworkSession | null
connectAddr: string | null
startHosting: (roomId: string, roomName?: string, mcPort?: number) => Promise<NetworkSession>
joinNetwork: (roomId: string, hostUserId?: string) => Promise<string>
stopNetwork: () => Promise<void>
refreshStats: () => Promise<void>
}
export const useNetworkStore = create<NetworkState>((set) => ({
stats: null,
session: null,
connectAddr: null,
startHosting: async (roomId, roomName, mcPort) => {
const session = await invoke<NetworkSession>('start_hosting', {
roomId,
roomName: roomName ?? null,
mcPort: mcPort ?? null
})
set({ session })
return session
},
joinNetwork: async (roomId, hostUserId) => {
const info = await invoke<{ connect_addr: string; local_port: number; session_type: string }>(
'join_room_network',
{ roomId, hostUserId: hostUserId ?? null }
)
set({
connectAddr: info.connect_addr,
session: { room_id: roomId, local_port: info.local_port, session_type: info.session_type },
})
return info.connect_addr
},
stopNetwork: async () => {
await invoke('stop_network')
set({ session: null, connectAddr: null, stats: null })
},
refreshStats: async () => {
try {
const stats = await invoke<ConnectionStats>('get_connection_stats')
set({ stats })
} catch {}
},
}))

View File

@@ -0,0 +1,57 @@
import { create } from 'zustand'
import { invoke } from '@tauri-apps/api/core'
export interface RelayNode {
id: string
name: string
url: string
region: string
is_active: boolean
priority: number
last_ping_ms: number | null
}
interface RelayNodeState {
nodes: RelayNode[]
loading: boolean
fetchNodes: () => Promise<void>
addNode: (name: string, url: string, region?: string, priority?: number) => Promise<void>
removeNode: (id: string) => Promise<void>
reportPing: (id: string, pingMs: number) => Promise<void>
}
export const useRelayNodeStore = create<RelayNodeState>((set) => ({
nodes: [],
loading: false,
fetchNodes: async () => {
set({ loading: true })
try {
const nodes = await invoke<RelayNode[]>('list_relay_nodes')
set({ nodes, loading: false })
} catch {
set({ loading: false })
}
},
addNode: async (name, url, region, priority) => {
const node = await invoke<RelayNode>('add_relay_node', { name, url, region, priority })
set((s) => ({ nodes: [...s.nodes, node] }))
},
removeNode: async (id) => {
await invoke('remove_relay_node', { nodeId: id })
set((s) => ({ nodes: s.nodes.filter((n) => n.id !== id) }))
},
reportPing: async (id, pingMs) => {
await invoke('report_relay_ping', { nodeId: id, pingMs })
set((s) => ({
nodes: s.nodes.map((n) =>
n.id === id
? { ...n, last_ping_ms: n.last_ping_ms == null ? pingMs : Math.round((n.last_ping_ms + pingMs) / 2) }
: n
),
}))
},
}))

View File

@@ -0,0 +1,89 @@
import { create } from 'zustand'
import { invoke } from '@tauri-apps/api/core'
export interface Room {
id: string
name: string
owner_id: string
owner_username: string
max_players: number
current_players: number
is_public: boolean
has_password: boolean
game_version: string
status: string
}
export interface RoomMember {
user_id: string
username: string
role: string
is_online: boolean
}
interface RoomState {
rooms: Room[]
currentRoom: Room | null
members: RoomMember[]
loading: boolean
fetchRooms: () => Promise<void>
createRoom: (params: {
name: string
maxPlayers?: number
isPublic?: boolean
password?: string
gameVersion?: string
}) => Promise<string>
joinRoom: (roomId: string, password?: string) => Promise<void>
leaveRoom: (roomId: string) => Promise<void>
fetchMembers: (roomId: string) => Promise<void>
setCurrentRoom: (room: Room | null) => void
}
export const useRoomStore = create<RoomState>((set) => ({
rooms: [],
currentRoom: null,
members: [],
loading: false,
fetchRooms: async () => {
set({ loading: true })
try {
const rooms = await invoke<Room[]>('list_rooms')
set({ rooms, loading: false })
} catch {
set({ loading: false })
}
},
createRoom: async ({ name, maxPlayers, isPublic, password, gameVersion }) => {
const roomId = await invoke<string>('create_room', {
name,
maxPlayers,
isPublic,
password,
gameVersion,
})
return roomId
},
joinRoom: async (roomId, password) => {
await invoke('join_room', { roomId, password })
},
leaveRoom: async (roomId) => {
await invoke('leave_room', { roomId })
set({ currentRoom: null, members: [] })
},
fetchMembers: async (roomId) => {
try {
const members = await invoke<RoomMember[]>('get_room_members', { roomId })
set({ members })
} catch {
set({ members: [] })
}
},
setCurrentRoom: (room) => set({ currentRoom: room }),
}))

View File

@@ -0,0 +1,49 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
'bg-primary': '#0d1117',
'bg-secondary': '#161b22',
'bg-tertiary': '#21262d',
'border': '#30363d',
'text-primary': '#f0f6fc',
'text-secondary': '#c9d1d9',
'text-muted': '#8b949e',
'accent-green': '#3fb950',
'accent-blue': '#58a6ff',
'accent-purple': '#a371f7',
'accent-orange': '#d29922',
'accent-red': '#f85149',
},
fontFamily: {
'sans': ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
'mono': ['JetBrains Mono', 'Fira Code', 'Consolas', 'monospace'],
},
animation: {
'fade-in': 'fadeIn 0.2s ease-out',
'slide-up': 'slideUp 0.3s ease-out',
'pulse-green': 'pulseGreen 2s infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
pulseGreen: {
'0%, 100%': { boxShadow: '0 0 0 0 rgba(63, 185, 80, 0.4)' },
'50%': { boxShadow: '0 0 0 8px rgba(63, 185, 80, 0)' },
},
},
},
},
plugins: [],
}

24
client/ui/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

23
client/ui/vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
clearScreen: false,
server: {
port: 5173,
strictPort: true,
},
envPrefix: ['VITE_', 'TAURI_'],
build: {
target: ['es2021', 'chrome100', 'safari15'],
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
sourcemap: !!process.env.TAURI_DEBUG,
},
})