From ace2f5deed395d91c3c757b61725e303871c162a Mon Sep 17 00:00:00 2001 From: ReTeamSpeak Date: Tue, 12 May 2026 19:25:02 +0900 Subject: [PATCH] feat: add session event polling, channel/client list, and text messages - Add SessionEvent enum with Connected, ChannelList, ClientList, ServerInfo, TextMessage, ClientEntered, ClientLeft, ClientMoved - Add poll_events, request_channel_list, request_client_list Tauri commands - Session now parses initserver, channellist, clientlist, notify* commands and emits events - Frontend polls events every 500ms when connected - Frontend displays server info, channel list, client list, and text messages --- src/tauri-app/frontend/src/App.tsx | 164 +++++++++++++++++++++++- src/tauri-app/src-tauri/src/commands.rs | 33 ++++- src/tauri-app/src-tauri/src/lib.rs | 3 + src/tscore/src/connection/session.rs | 124 +++++++++++++----- src/tscore/src/protocol/commands.rs | 9 +- 5 files changed, 298 insertions(+), 35 deletions(-) diff --git a/src/tauri-app/frontend/src/App.tsx b/src/tauri-app/frontend/src/App.tsx index fd80f8e..408da71 100644 --- a/src/tauri-app/frontend/src/App.tsx +++ b/src/tauri-app/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { invoke } from '@tauri-apps/api/core'; interface Identity { @@ -18,6 +18,51 @@ interface Bookmark { last_connected: string | null; } +interface ChannelEntry { + cid: number; + pid: number; + channel_order: number; + channel_name: string; + total_clients: number; + channel_needed_subscribe_power: number; +} + +interface ClientEntry { + clid: number; + cid: number; + client_database_id: number; + client_nickname: string; + client_type: number; +} + +interface ServerInfo { + name: string; + platform: string; + version: string; + max_clients: number; + clients_online: number; + channels_online: number; +} + +interface TextMessage { + invoker_id: number; + invoker_name: string; + message: string; + target_mode: number; +} + +type SessionEvent = + | { Connected: { client_id: number } } + | { ChannelList: ChannelEntry[] } + | { ClientList: ClientEntry[] } + | { ServerInfo: ServerInfo } + | { TextMessage: TextMessage } + | { ClientEntered: { clid: number; cid: number; client_nickname: string } } + | { ClientLeft: { clid: number; reason: string } } + | { ClientMoved: { clid: number; cid: number } } + | { Error: string } + | { Disconnected: null }; + interface ServerQueryChannel { id: number; name: string; @@ -52,10 +97,15 @@ function App() { const [nickname, setNickname] = useState(''); const [password, setPassword] = useState(''); const [connected, setConnected] = useState(false); + const [serverInfo, setServerInfo] = useState(null); + const [channels, setChannels] = useState([]); + const [clients, setClients] = useState([]); + const [messages, setMessages] = useState([]); const [queryPort, setQueryPort] = useState(10011); const [querySnapshot, setQuerySnapshot] = useState(null); const [queryLoading, setQueryLoading] = useState(false); const [queryError, setQueryError] = useState(null); + const pollRef = useRef | null>(null); useEffect(() => { loadIdentities(); @@ -67,6 +117,56 @@ function App() { setQueryError(null); }, [selectedBookmark]); + useEffect(() => { + if (connected) { + pollEvents(); + pollRef.current = setInterval(pollEvents, 500); + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + } else { + if (pollRef.current) clearInterval(pollRef.current); + } + }, [connected]); + + async function pollEvents() { + try { + const events = await invoke('poll_events'); + for (const event of events) { + handleEvent(event); + } + } catch (error) { + console.error('Failed to poll events:', error); + } + } + + function handleEvent(event: SessionEvent) { + if ('Connected' in event) { + console.log('Connected as client', event.Connected.client_id); + } else if ('ServerInfo' in event) { + setServerInfo(event.ServerInfo); + } else if ('ChannelList' in event) { + setChannels(event.ChannelList); + } else if ('ClientList' in event) { + setClients(event.ClientList); + } else if ('TextMessage' in event) { + setMessages((prev) => [...prev, event.TextMessage]); + } else if ('ClientEntered' in event) { + console.log('Client entered:', event.ClientEntered); + } else if ('ClientLeft' in event) { + console.log('Client left:', event.ClientLeft); + } else if ('ClientMoved' in event) { + console.log('Client moved:', event.ClientMoved); + } else if ('Error' in event) { + console.error('Session error:', event.Error); + } else if ('Disconnected' in event) { + setConnected(false); + setServerInfo(null); + setChannels([]); + setClients([]); + } + } + async function loadIdentities() { try { const result = await invoke('get_identities'); @@ -96,6 +196,7 @@ function App() { password: password || null, }); setConnected(true); + setMessages([]); } catch (error) { console.error('Failed to connect:', error); } @@ -105,6 +206,9 @@ function App() { try { await invoke('disconnect'); setConnected(false); + setServerInfo(null); + setChannels([]); + setClients([]); } catch (error) { console.error('Failed to disconnect:', error); } @@ -141,7 +245,9 @@ function App() {

ReTeamSpeak

{connected ? ( - 已连接 + + 已连接 {serverInfo ? `— ${serverInfo.name}` : ''} + ) : ( 未连接 )} @@ -212,6 +318,60 @@ function App() {
+ {connected && ( +
+
+
+

服务器

+ {serverInfo ? ( + <> +

{serverInfo.name}

+

{serverInfo.platform} / {serverInfo.version}

+

{serverInfo.clients_online} / {serverInfo.max_clients} 在线

+ + ) : ( +

等待服务器信息...

+ )} +
+ +
+

频道 ({channels.length})

+
    + {channels.map((ch) => ( +
  • + {ch.channel_name} + {ch.total_clients} 人 +
  • + ))} +
+
+ +
+

客户端 ({clients.length})

+
    + {clients.map((c) => ( +
  • + {c.client_nickname} + #{c.clid} +
  • + ))} +
+
+
+ +
+

消息 ({messages.length})

+
    + {messages.map((msg, i) => ( +
  • + {msg.invoker_name}: {msg.message} +
  • + ))} +
+
+
+ )} +
diff --git a/src/tauri-app/src-tauri/src/commands.rs b/src/tauri-app/src-tauri/src/commands.rs index 0bf83ed..47d83db 100644 --- a/src/tauri-app/src-tauri/src/commands.rs +++ b/src/tauri-app/src-tauri/src/commands.rs @@ -6,7 +6,7 @@ use std::net::SocketAddr; use std::time::Duration; use tauri::State; use tokio::net::lookup_host; -use tscore::{ClientConfig, IdentityKey, QueryClient, Session}; +use tscore::{ClientConfig, IdentityKey, QueryClient, Session, SessionEvent}; use crate::AppState; @@ -273,6 +273,37 @@ pub async fn send_raw_command(state: State<'_, AppState>, command: String) -> Re .map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn poll_events(state: State<'_, AppState>) -> Result, String> { + let mut session_guard = state.session_handle.lock().await; + let handle = session_guard.as_mut().ok_or("not connected")?; + let mut events = Vec::new(); + while let Some(event) = handle.try_recv_event() { + events.push(event); + } + Ok(events) +} + +#[tauri::command] +pub async fn request_channel_list(state: State<'_, AppState>) -> Result<(), String> { + let session_guard = state.session_handle.lock().await; + let handle = session_guard.as_ref().ok_or("not connected")?; + handle + .request_channel_list() + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn request_client_list(state: State<'_, AppState>) -> Result<(), String> { + let session_guard = state.session_handle.lock().await; + let handle = session_guard.as_ref().ok_or("not connected")?; + handle + .request_client_list() + .await + .map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn get_messages( state: State<'_, AppState>, diff --git a/src/tauri-app/src-tauri/src/lib.rs b/src/tauri-app/src-tauri/src/lib.rs index b924f03..a0a57a4 100644 --- a/src/tauri-app/src-tauri/src/lib.rs +++ b/src/tauri-app/src-tauri/src/lib.rs @@ -51,6 +51,9 @@ pub fn run() { commands::send_server_message, commands::send_private_message, commands::send_raw_command, + commands::poll_events, + commands::request_channel_list, + commands::request_client_list, commands::get_messages, commands::server_query_snapshot, ]) diff --git a/src/tscore/src/connection/session.rs b/src/tscore/src/connection/session.rs index 3764f09..5c6a0dd 100644 --- a/src/tscore/src/connection/session.rs +++ b/src/tscore/src/connection/session.rs @@ -283,13 +283,34 @@ impl Session { fn collect_init_event(cmd: &Command, events: &mut Vec) { match cmd.name.as_str() { "initserver" => { - let client_id = cmd.get("client_id").and_then(|v| v.parse().ok()).unwrap_or(0); - let name = cmd.get("virtualserver_name").unwrap_or_default().to_string(); - let platform = cmd.get("virtualserver_platform").unwrap_or_default().to_string(); - let version = cmd.get("virtualserver_version").unwrap_or_default().to_string(); - let max_clients = cmd.get("virtualserver_maxclients").and_then(|v| v.parse().ok()).unwrap_or(0); - let clients_online = cmd.get("virtualserver_clientsonline").and_then(|v| v.parse().ok()).unwrap_or(0); - let channels_online = cmd.get("virtualserver_channelsonline").and_then(|v| v.parse().ok()).unwrap_or(0); + let client_id = cmd + .get("client_id") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let name = cmd + .get("virtualserver_name") + .unwrap_or_default() + .to_string(); + let platform = cmd + .get("virtualserver_platform") + .unwrap_or_default() + .to_string(); + let version = cmd + .get("virtualserver_version") + .unwrap_or_default() + .to_string(); + let max_clients = cmd + .get("virtualserver_maxclients") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let clients_online = cmd + .get("virtualserver_clientsonline") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let channels_online = cmd + .get("virtualserver_channelsonline") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); events.push(SessionEvent::ServerInfo { name, @@ -313,10 +334,19 @@ impl Session { let mut channels = Vec::new(); let cid = cmd.get("cid").and_then(|v| v.parse().ok()).unwrap_or(0); let pid = cmd.get("pid").and_then(|v| v.parse().ok()).unwrap_or(0); - let channel_order = cmd.get("channel_order").and_then(|v| v.parse().ok()).unwrap_or(0); + let channel_order = cmd + .get("channel_order") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); let channel_name = cmd.get("channel_name").unwrap_or_default().to_string(); - let total_clients = cmd.get("total_clients").and_then(|v| v.parse().ok()).unwrap_or(0); - let channel_needed_subscribe_power = cmd.get("channel_needed_subscribe_power").and_then(|v| v.parse().ok()).unwrap_or(0); + let total_clients = cmd + .get("total_clients") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let channel_needed_subscribe_power = cmd + .get("channel_needed_subscribe_power") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); channels.push(ChannelEntry { cid, @@ -392,22 +422,46 @@ impl Session { async fn emit_command_event(&self, cmd: &Command) { let event = match cmd.name.as_str() { "initserver" => { - let client_id = cmd.get("client_id").and_then(|v| v.parse().ok()).unwrap_or(0); - let name = cmd.get("virtualserver_name").unwrap_or_default().to_string(); - let platform = cmd.get("virtualserver_platform").unwrap_or_default().to_string(); - let version = cmd.get("virtualserver_version").unwrap_or_default().to_string(); - let max_clients = cmd.get("virtualserver_maxclients").and_then(|v| v.parse().ok()).unwrap_or(0); - let clients_online = cmd.get("virtualserver_clientsonline").and_then(|v| v.parse().ok()).unwrap_or(0); - let channels_online = cmd.get("virtualserver_channelsonline").and_then(|v| v.parse().ok()).unwrap_or(0); + let client_id = cmd + .get("client_id") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let name = cmd + .get("virtualserver_name") + .unwrap_or_default() + .to_string(); + let platform = cmd + .get("virtualserver_platform") + .unwrap_or_default() + .to_string(); + let version = cmd + .get("virtualserver_version") + .unwrap_or_default() + .to_string(); + let max_clients = cmd + .get("virtualserver_maxclients") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let clients_online = cmd + .get("virtualserver_clientsonline") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let channels_online = cmd + .get("virtualserver_channelsonline") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); - let _ = self.event_tx.send(SessionEvent::ServerInfo { - name, - platform, - version, - max_clients, - clients_online, - channels_online, - }).await; + let _ = self + .event_tx + .send(SessionEvent::ServerInfo { + name, + platform, + version, + max_clients, + clients_online, + channels_online, + }) + .await; Some(SessionEvent::Connected { client_id }) } "channellist" => { @@ -439,10 +493,16 @@ impl Session { Some(SessionEvent::ClientMoved { clid, cid }) } "notifytextmessage" => { - let invoker_id = cmd.get("invokerid").and_then(|v| v.parse().ok()).unwrap_or(0); + let invoker_id = cmd + .get("invokerid") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); let invoker_name = cmd.get("invokername").unwrap_or_default().to_string(); let message = cmd.get("msg").unwrap_or_default().to_string(); - let target_mode = cmd.get("targetmode").and_then(|v| v.parse().ok()).unwrap_or(0); + let target_mode = cmd + .get("targetmode") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); Some(SessionEvent::TextMessage { invoker_id, invoker_name, @@ -470,9 +530,15 @@ impl Session { fn parse_client_list(cmd: &Command) -> Vec { let clid = cmd.get("clid").and_then(|v| v.parse().ok()).unwrap_or(0); let cid = cmd.get("cid").and_then(|v| v.parse().ok()).unwrap_or(0); - let client_database_id = cmd.get("client_database_id").and_then(|v| v.parse().ok()).unwrap_or(0); + let client_database_id = cmd + .get("client_database_id") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); let client_nickname = cmd.get("client_nickname").unwrap_or_default().to_string(); - let client_type = cmd.get("client_type").and_then(|v| v.parse().ok()).unwrap_or(0); + let client_type = cmd + .get("client_type") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); vec![ClientEntry { clid, diff --git a/src/tscore/src/protocol/commands.rs b/src/tscore/src/protocol/commands.rs index 20ab9fd..4fe2d98 100644 --- a/src/tscore/src/protocol/commands.rs +++ b/src/tscore/src/protocol/commands.rs @@ -110,13 +110,17 @@ impl CommandArgument { value: None, } } - } impl fmt::Display for CommandArgument { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self.value { - Some(value) => write!(f, "{}={}", escape::escape(&self.name), escape::escape(value)), + Some(value) => write!( + f, + "{}={}", + escape::escape(&self.name), + escape::escape(value) + ), None => write!(f, "{}", escape::escape(&self.name)), } } @@ -226,7 +230,6 @@ impl Command { }) .collect() } - } impl fmt::Display for Command {