feat: implement TS3 protocol layer, session management, and ServerQuery client
CI/CD / Test (ubuntu-latest) (push) Successful in 2m3s
CI/CD / Build Frontend (push) Failing after 11s
CI/CD / Test (macos-latest) (push) Has been cancelled
CI/CD / Test (windows-latest) (push) Has been cancelled
CI/CD / Build Desktop (linux) (push) Has been cancelled
CI/CD / Build Desktop (macos) (push) Has been cancelled
CI/CD / Build Desktop (windows) (push) Has been cancelled
CI/CD / Release (push) Has been cancelled

- Add real X25519 ECDH key exchange for initivexpand2 bootstrap
- Add P-256 identity key generation with TeamSpeak tomcrypt format
- Add session event loop with tokio::select! for packet/command handling
- Add ServerQuery TCP client with typed parsing (channels, clients, permissions)
- Add Tauri commands: connect (session-based), join_channel, send_message, disconnect
- Add frontend ServerQuery snapshot panel
- Fix CI: Node 22, rustup setup, npm install, workspace checks
- Add mock UDP handshake tests through initserver
- 54 tests passing across shared, tscore, tsaudio, tsdb
This commit is contained in:
ReTeamSpeak
2026-05-12 17:24:02 +09:00
parent ea08823c97
commit 2b3ae9ae15
45 changed files with 3287 additions and 446 deletions
+164 -38
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
interface Identity {
@@ -18,6 +18,33 @@ interface Bookmark {
last_connected: string | null;
}
interface ServerQueryChannel {
id: number;
name: string;
total_clients: number;
}
interface ServerQueryClient {
id: number;
database_id: number;
nickname: string;
}
interface ServerQueryServerInfo {
name: string;
platform: string;
version: string;
max_clients: number;
clients_online: number;
}
interface ServerQuerySnapshot {
server: ServerQueryServerInfo | null;
channels: ServerQueryChannel[];
clients: ServerQueryClient[];
permissions: unknown[];
}
function App() {
const [identities, setIdentities] = useState<Identity[]>([]);
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
@@ -25,12 +52,21 @@ function App() {
const [nickname, setNickname] = useState('');
const [password, setPassword] = useState('');
const [connected, setConnected] = useState(false);
const [queryPort, setQueryPort] = useState(10011);
const [querySnapshot, setQuerySnapshot] = useState<ServerQuerySnapshot | null>(null);
const [queryLoading, setQueryLoading] = useState(false);
const [queryError, setQueryError] = useState<string | null>(null);
useEffect(() => {
loadIdentities();
loadBookmarks();
}, []);
useEffect(() => {
setQuerySnapshot(null);
setQueryError(null);
}, [selectedBookmark]);
async function loadIdentities() {
try {
const result = await invoke<Identity[]>('get_identities');
@@ -74,6 +110,31 @@ function App() {
}
}
async function handleLoadServerQuery() {
if (!selectedBookmark || queryLoading) return;
setQueryLoading(true);
setQueryError(null);
try {
const snapshot = await invoke<ServerQuerySnapshot>('server_query_snapshot', {
request: {
address: selectedBookmark.address,
port: queryPort,
username: null,
password: null,
virtual_server_id: null,
include_permissions: false,
},
});
setQuerySnapshot(snapshot);
} catch (error) {
setQueryError(String(error));
setQuerySnapshot(null);
} finally {
setQueryLoading(false);
}
}
return (
<div className="app">
<header className="app-header">
@@ -91,6 +152,7 @@ function App() {
<aside className="sidebar">
<section className="bookmarks-section">
<h2></h2>
<div className="identity-summary">{identities.length}</div>
<ul className="bookmark-list">
{bookmarks.map((bookmark) => (
<li
@@ -108,45 +170,109 @@ function App() {
<div className="content">
{selectedBookmark ? (
<div className="connect-form">
<h2> {selectedBookmark.name}</h2>
<div className="form-group">
<label></label>
<input
type="text"
value={`${selectedBookmark.address}:${selectedBookmark.port}`}
disabled
/>
<div className="server-panel">
<div className="connect-form">
<h2> {selectedBookmark.name}</h2>
<div className="form-group">
<label></label>
<input
type="text"
value={`${selectedBookmark.address}:${selectedBookmark.port}`}
disabled
/>
</div>
<div className="form-group">
<label></label>
<input
type="text"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder={selectedBookmark.nickname || '请输入昵称'}
/>
</div>
<div className="form-group">
<label></label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="可选"
/>
</div>
<div className="form-actions">
{connected ? (
<button className="disconnect-btn" onClick={handleDisconnect}>
</button>
) : (
<button className="connect-btn" onClick={handleConnect}>
</button>
)}
</div>
</div>
<div className="form-group">
<label></label>
<input
type="text"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder={selectedBookmark.nickname || '请输入昵称'}
/>
</div>
<div className="form-group">
<label></label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="可选"
/>
</div>
<div className="form-actions">
{connected ? (
<button className="disconnect-btn" onClick={handleDisconnect}>
</button>
) : (
<button className="connect-btn" onClick={handleConnect}>
</button>
<section className="query-panel">
<div className="query-header">
<div>
<h2>ServerQuery </h2>
<p> ServerQuery 10011</p>
</div>
<div className="query-actions">
<input
type="number"
value={queryPort}
min={1}
max={65535}
onChange={(e) => setQueryPort(Number(e.target.value))}
aria-label="ServerQuery port"
/>
<button className="connect-btn" onClick={handleLoadServerQuery} disabled={queryLoading}>
{queryLoading ? '读取中...' : '读取快照'}
</button>
</div>
</div>
{queryError && <div className="query-error">{queryError}</div>}
{querySnapshot && (
<div className="query-grid">
<div className="query-card">
<h3>{querySnapshot.server?.name || '服务器'}</h3>
<p>{querySnapshot.server?.platform || '未知平台'}</p>
<p>{querySnapshot.server?.version || '未知版本'}</p>
<strong>
{querySnapshot.server?.clients_online ?? querySnapshot.clients.length}/
{querySnapshot.server?.max_clients ?? '-'} 线
</strong>
</div>
<div className="query-card">
<h3></h3>
<ul className="query-list">
{querySnapshot.channels.map((channel) => (
<li key={channel.id}>
<span>{channel.name}</span>
<small>{channel.total_clients} </small>
</li>
))}
</ul>
</div>
<div className="query-card">
<h3></h3>
<ul className="query-list">
{querySnapshot.clients.map((client) => (
<li key={client.id}>
<span>{client.nickname}</span>
<small>#{client.id}</small>
</li>
))}
</ul>
</div>
</div>
)}
</div>
</section>
</div>
) : (
<div className="welcome">
+120
View File
@@ -92,6 +92,12 @@ body {
color: var(--text-secondary);
}
.identity-summary {
margin-bottom: 12px;
color: var(--text-secondary);
font-size: 12px;
}
.bookmark-list {
list-style: none;
}
@@ -134,6 +140,13 @@ body {
overflow-y: auto;
}
.server-panel {
display: grid;
grid-template-columns: minmax(320px, 400px) minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.connect-form {
max-width: 400px;
}
@@ -201,6 +214,11 @@ body {
background-color: var(--primary-dark);
}
.connect-btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.disconnect-btn {
background-color: var(--error-color);
color: white;
@@ -229,3 +247,105 @@ body {
font-size: 16px;
color: var(--text-secondary);
}
.query-panel {
padding: 20px;
background-color: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: 12px;
}
.query-header {
display: flex;
gap: 16px;
justify-content: space-between;
margin-bottom: 16px;
}
.query-header h2 {
font-size: 18px;
margin-bottom: 6px;
}
.query-header p {
color: var(--text-secondary);
font-size: 13px;
}
.query-actions {
display: flex;
gap: 8px;
align-items: flex-start;
}
.query-actions input {
width: 96px;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
}
.query-error {
padding: 10px 12px;
margin-bottom: 16px;
color: var(--error-color);
background-color: #ffebee;
border-radius: 6px;
font-size: 13px;
}
.query-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.query-card {
min-width: 0;
padding: 14px;
background-color: var(--background-color);
border-radius: 10px;
}
.query-card h3 {
margin-bottom: 8px;
font-size: 15px;
}
.query-card p,
.query-card small {
color: var(--text-secondary);
font-size: 12px;
}
.query-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 220px;
overflow-y: auto;
list-style: none;
}
.query-list li {
display: flex;
justify-content: space-between;
gap: 12px;
}
.query-list span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 900px) {
.server-panel,
.query-grid {
grid-template-columns: 1fr;
}
.query-header {
flex-direction: column;
}
}
+3
View File
@@ -29,6 +29,9 @@ tsdb = { path = "../../tsdb" }
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
[build-dependencies]
tauri-build = "2"
[lib]
name = "re_teamspeak_lib"
crate-type = ["lib", "cdylib", "staticlib"]
+2 -4
View File
@@ -1,5 +1,3 @@
use tauri_build::{build_mobile, Result};
fn main() -> Result<()> {
build_mobile()
fn main() {
tauri_build::build()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

+236 -39
View File
@@ -1,7 +1,12 @@
//! Tauri 命令
use tauri::State;
use serde::{Deserialize, Serialize};
use shared::{PermissionInfo, ServerQueryChannel, ServerQueryClient, ServerQueryServerInfo};
use std::net::SocketAddr;
use std::time::Duration;
use tauri::State;
use tokio::net::lookup_host;
use tscore::{ClientConfig, IdentityKey, QueryClient, Session};
use crate::AppState;
@@ -33,21 +38,49 @@ pub struct MessageInfo {
pub is_read: bool,
}
#[tauri::command]
pub async fn get_identities(state: State<'_, AppState>) -> Result<Vec<IdentityInfo>, String> {
let identities = state.db.get_all_identities().map_err(|e| e.to_string())?;
Ok(identities.into_iter().map(|i| IdentityInfo {
id: i.id,
name: i.name,
counter: i.counter,
max_counter: i.max_counter,
}).collect())
#[derive(Debug, Serialize, Deserialize)]
pub struct ServerQuerySnapshotRequest {
pub address: String,
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
pub virtual_server_id: Option<u64>,
pub include_permissions: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ServerQuerySnapshot {
pub server: Option<ServerQueryServerInfo>,
pub channels: Vec<ServerQueryChannel>,
pub clients: Vec<ServerQueryClient>,
pub permissions: Vec<PermissionInfo>,
}
#[tauri::command]
pub async fn create_identity(state: State<'_, AppState>, name: String) -> Result<IdentityInfo, String> {
let private_key = "placeholder";
let identity = state.db.create_identity(&name, private_key).map_err(|e| e.to_string())?;
pub async fn get_identities(state: State<'_, AppState>) -> Result<Vec<IdentityInfo>, String> {
let db = state.db.lock().await;
let identities = db.get_all_identities().map_err(|e| e.to_string())?;
Ok(identities
.into_iter()
.map(|i| IdentityInfo {
id: i.id,
name: i.name,
counter: i.counter,
max_counter: i.max_counter,
})
.collect())
}
#[tauri::command]
pub async fn create_identity(
state: State<'_, AppState>,
name: String,
) -> Result<IdentityInfo, String> {
let private_key = IdentityKey::generate().private_key_base64();
let db = state.db.lock().await;
let identity = db
.create_identity(&name, &private_key)
.map_err(|e| e.to_string())?;
Ok(IdentityInfo {
id: identity.id,
name: identity.name,
@@ -58,22 +91,27 @@ pub async fn create_identity(state: State<'_, AppState>, name: String) -> Result
#[tauri::command]
pub async fn delete_identity(state: State<'_, AppState>, id: String) -> Result<(), String> {
state.db.delete_identity(&id).map_err(|e| e.to_string())?;
let db = state.db.lock().await;
db.delete_identity(&id).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn get_bookmarks(state: State<'_, AppState>) -> Result<Vec<BookmarkInfo>, String> {
let bookmarks = state.db.get_all_bookmarks().map_err(|e| e.to_string())?;
Ok(bookmarks.into_iter().map(|b| BookmarkInfo {
id: b.id,
name: b.name,
address: b.address,
port: b.port,
nickname: b.nickname,
auto_connect: b.auto_connect,
last_connected: b.last_connected,
}).collect())
let db = state.db.lock().await;
let bookmarks = db.get_all_bookmarks().map_err(|e| e.to_string())?;
Ok(bookmarks
.into_iter()
.map(|b| BookmarkInfo {
id: b.id,
name: b.name,
address: b.address,
port: b.port,
nickname: b.nickname,
auto_connect: b.auto_connect,
last_connected: b.last_connected,
})
.collect())
}
#[tauri::command]
@@ -84,7 +122,9 @@ pub async fn create_bookmark(
port: u16,
nickname: Option<String>,
) -> Result<BookmarkInfo, String> {
let bookmark = state.db.create_bookmark(&name, &address, port, nickname.as_deref())
let db = state.db.lock().await;
let bookmark = db
.create_bookmark(&name, &address, port, nickname.as_deref())
.map_err(|e| e.to_string())?;
Ok(BookmarkInfo {
id: bookmark.id,
@@ -99,7 +139,8 @@ pub async fn create_bookmark(
#[tauri::command]
pub async fn delete_bookmark(state: State<'_, AppState>, id: String) -> Result<(), String> {
state.db.delete_bookmark(&id).map_err(|e| e.to_string())?;
let db = state.db.lock().await;
db.delete_bookmark(&id).map_err(|e| e.to_string())?;
Ok(())
}
@@ -111,29 +152,125 @@ pub async fn connect(
nickname: String,
password: Option<String>,
) -> Result<(), String> {
let socket_addr = resolve_server_address(&address, port).await?;
let identity = {
let db = state.db.lock().await;
db.get_all_identities()
.map_err(|e| e.to_string())?
.into_iter()
.next()
.and_then(|identity| IdentityKey::from_private_key_base64(&identity.private_key).ok())
.unwrap_or_else(IdentityKey::generate)
};
let mut config = ClientConfig::new(socket_addr, nickname.clone());
config.server_password = password;
config.identity = identity;
let (mut session, handle) = Session::connect(config, Duration::from_secs(15))
.await
.map_err(|e| e.to_string())?;
let client_id = session.client_id();
tokio::spawn(async move {
if let Err(e) = session.run().await {
tracing::error!("session error: {e}");
}
});
{
let mut session_guard = state.session_handle.lock().await;
*session_guard = Some(handle);
}
let mut conn_state = state.connection_state.lock().await;
conn_state.connected = true;
conn_state.server_address = Some(address.clone());
conn_state.server_address = Some(address);
conn_state.server_port = Some(port);
conn_state.nickname = Some(nickname);
conn_state.client_id = client_id;
Ok(())
}
#[tauri::command]
pub async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
let handle = {
let mut session_guard = state.session_handle.lock().await;
session_guard.take()
};
if let Some(handle) = handle {
handle.disconnect().await.map_err(|e| e.to_string())?;
}
let mut conn_state = state.connection_state.lock().await;
*conn_state = crate::state::ConnectionState::new();
Ok(())
}
#[tauri::command]
pub async fn send_message(
pub async fn join_channel(
state: State<'_, AppState>,
channel_id: u64,
password: Option<String>,
) -> Result<(), String> {
let session_guard = state.session_handle.lock().await;
let handle = session_guard.as_ref().ok_or("not connected")?;
handle
.join_channel(channel_id, password)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn send_channel_message(
state: State<'_, AppState>,
target: String,
message: String,
) -> Result<(), String> {
// TODO: 实现发送消息
Ok(())
let session_guard = state.session_handle.lock().await;
let handle = session_guard.as_ref().ok_or("not connected")?;
handle
.send_channel_message(&message)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn send_server_message(
state: State<'_, AppState>,
message: String,
) -> Result<(), String> {
let session_guard = state.session_handle.lock().await;
let handle = session_guard.as_ref().ok_or("not connected")?;
handle
.send_server_message(&message)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn send_private_message(
state: State<'_, AppState>,
client_id: u64,
message: String,
) -> Result<(), String> {
let session_guard = state.session_handle.lock().await;
let handle = session_guard.as_ref().ok_or("not connected")?;
handle
.send_private_message(client_id, &message)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn send_raw_command(state: State<'_, AppState>, command: String) -> Result<(), String> {
let session_guard = state.session_handle.lock().await;
let handle = session_guard.as_ref().ok_or("not connected")?;
handle
.send_command_str(&command)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
@@ -143,13 +280,73 @@ pub async fn get_messages(
limit: i64,
offset: i64,
) -> Result<Vec<MessageInfo>, String> {
let messages = state.db.get_server_messages(&server_address, limit, offset)
let db = state.db.lock().await;
let messages = db
.get_server_messages(&server_address, limit, offset)
.map_err(|e| e.to_string())?;
Ok(messages.into_iter().map(|m| MessageInfo {
id: m.id,
invoker_name: m.invoker_name,
message: m.message,
timestamp: m.timestamp,
is_read: m.is_read,
}).collect())
Ok(messages
.into_iter()
.map(|m| MessageInfo {
id: m.id,
invoker_name: m.invoker_name,
message: m.message,
timestamp: m.timestamp,
is_read: m.is_read,
})
.collect())
}
#[tauri::command]
pub async fn server_query_snapshot(
request: ServerQuerySnapshotRequest,
) -> Result<ServerQuerySnapshot, String> {
let socket_addr = resolve_server_address(&request.address, request.port).await?;
let mut client = QueryClient::connect(socket_addr)
.await
.map_err(|e| e.to_string())?;
client.set_read_timeout(Duration::from_secs(5));
if let (Some(username), Some(password)) =
(request.username.as_deref(), request.password.as_deref())
{
client
.login(username, password)
.await
.map_err(|e| e.to_string())?;
}
if let Some(server_id) = request.virtual_server_id {
client
.use_server(server_id)
.await
.map_err(|e| e.to_string())?;
}
let server = client.server_info().await.map_err(|e| e.to_string())?;
let channels = client.channel_list().await.map_err(|e| e.to_string())?;
let clients = client.client_list().await.map_err(|e| e.to_string())?;
let permissions = if request.include_permissions {
client.permission_list().await.map_err(|e| e.to_string())?
} else {
Vec::new()
};
Ok(ServerQuerySnapshot {
server,
channels,
clients,
permissions,
})
}
async fn resolve_server_address(address: &str, port: u16) -> Result<SocketAddr, String> {
if let Ok(socket_addr) = format!("{}:{}", address, port).parse::<SocketAddr>() {
return Ok(socket_addr);
}
lookup_host((address, port))
.await
.map_err(|e| format!("无法解析服务器地址: {e}"))?
.next()
.ok_or_else(|| "无法解析服务器地址".to_string())
}
+12 -5
View File
@@ -6,8 +6,9 @@ mod commands;
mod state;
pub struct AppState {
pub db: tsdb::DatabaseManager,
pub db: tokio::sync::Mutex<tsdb::DatabaseManager>,
pub connection_state: tokio::sync::Mutex<state::ConnectionState>,
pub session_handle: tokio::sync::Mutex<Option<tscore::SessionHandle>>,
}
pub fn run() {
@@ -24,12 +25,13 @@ pub fn run() {
std::fs::create_dir_all(&app_dir).expect("无法创建应用数据目录");
let db_path = app_dir.join("re-teamspeak.db");
let db = tsdb::DatabaseManager::new(db_path.to_str().unwrap())
.expect("无法初始化数据库");
let db =
tsdb::DatabaseManager::new(db_path.to_str().unwrap()).expect("无法初始化数据库");
let state = AppState {
db,
db: tokio::sync::Mutex::new(db),
connection_state: tokio::sync::Mutex::new(state::ConnectionState::new()),
session_handle: tokio::sync::Mutex::new(None),
};
app.manage(state);
@@ -44,8 +46,13 @@ pub fn run() {
commands::delete_bookmark,
commands::connect,
commands::disconnect,
commands::send_message,
commands::join_channel,
commands::send_channel_message,
commands::send_server_message,
commands::send_private_message,
commands::send_raw_command,
commands::get_messages,
commands::server_query_snapshot,
])
.run(tauri::generate_context!())
.expect("运行应用时出错");
+2 -10
View File
@@ -10,7 +10,6 @@
"beforeBuildCommand": "cd ../frontend && npm run build"
},
"app": {
"title": "ReTeamSpeak",
"windows": [
{
"title": "ReTeamSpeak",
@@ -28,14 +27,7 @@
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
"active": false,
"targets": "all"
}
}