refactor: switch to tsclientlib for TeamSpeak protocol

- Replace custom Session/SessionHandle with tsclientlib SyncConnection
- SyncConnection runs in background task, forwards events via mpsc
- iced subscription reads events from mpsc channel
- SendChannelMessage sends via SyncConnectionHandle::with_connection
- Disconnect sends DisconnectOptions via handle
- State refresh reads data::Connection (clients, channels, server)
- Keep tscore QueryClient for ServerQuery panel
- Add tsclientlib as path dependency (from refercence/)
- CI checkout with submodules: recursive for tsdeclarations
- 69 tests passing, clippy clean
This commit is contained in:
ReTeamSpeak
2026-05-12 22:10:45 +09:00
parent dce2f90544
commit 55c9b9fec8
4 changed files with 428 additions and 196 deletions
+4
View File
@@ -31,6 +31,8 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
@@ -94,6 +96,8 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
+2
View File
@@ -56,3 +56,5 @@ tscore = { path = "tscore" }
tsaudio = { path = "tsaudio" }
tsdb = { path = "tsdb" }
shared = { path = "shared" }
tsclientlib = { path = "../refercence/tsclientlib/tsclientlib", default-features = false, features = ["default-tls"] }
tsproto-packets = { path = "../refercence/tsclientlib/utils/tsproto-packets" }
+3 -1
View File
@@ -12,6 +12,7 @@ path = "src/main.rs"
[dependencies]
iced = { version = "0.13", features = ["tokio", "debug"] }
tokio = { workspace = true }
futures = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
@@ -20,8 +21,9 @@ chrono = { workspace = true }
shared = { workspace = true }
tscore = { workspace = true }
tsaudio = { workspace = true }
tsdb = { workspace = true }
tsclientlib = { workspace = true }
tsproto-packets = { workspace = true }
[features]
default = []
+399 -175
View File
@@ -1,12 +1,15 @@
use iced::futures::sink::SinkExt;
use iced::futures::StreamExt;
use iced::widget::{button, column, container, horizontal_rule, horizontal_space, row, scrollable, text, text_input, vertical_space};
use iced::{Element, Length, Subscription, Task};
use iced::futures::sink::SinkExt;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::{mpsc, Mutex};
use tscore::{ClientConfig, IdentityKey, Session, SessionEvent, SessionHandle};
use tsdb::DatabaseManager;
use tsclientlib::sync::{SyncConnection, SyncConnectionHandle, SyncStreamItem};
use tsclientlib::{ChannelId, ClientId, Connection, DisconnectOptions, MessageTarget};
use tsclientlib::events::{Event, PropertyId};
use tsclientlib::prelude::*;
fn main() -> iced::Result {
iced::application("ReTeamSpeak", App::update, App::view)
@@ -29,11 +32,8 @@ enum Message {
NicknameChanged(String),
PasswordChanged(String),
Connect,
Connected(u16),
Disconnect,
Disconnected,
ConnectionError(String),
SessionEvent(SessionEvent),
TsEvent(TsEvent),
MessageInputChanged(String),
SendChannelMessage,
QueryAddressChanged(String),
@@ -44,6 +44,19 @@ enum Message {
Noop,
}
#[derive(Debug, Clone)]
enum TsEvent {
Connected,
BookEvents(Vec<Event>),
MessageEvent(tsclientlib::InMessage),
AudioChange(bool, bool),
IdentityLevelIncreasing(u8),
IdentityLevelIncreased,
DisconnectedTemporarily,
Disconnected,
Error(String),
}
#[derive(Debug, Clone)]
struct BookmarkInfo {
name: String,
@@ -54,26 +67,21 @@ struct BookmarkInfo {
#[derive(Debug, Clone)]
struct ChannelEntry {
id: ChannelId,
name: String,
#[allow(dead_code)]
cid: u64,
channel_name: String,
#[allow(dead_code)]
total_clients: u16,
parent: ChannelId,
order: ChannelId,
}
#[derive(Debug, Clone)]
struct ClientEntry {
clid: u16,
client_nickname: String,
}
#[derive(Debug, Clone)]
struct ServerInfo {
id: ClientId,
name: String,
platform: String,
version: String,
max_clients: u16,
clients_online: u16,
channel: ChannelId,
input_muted: bool,
#[allow(dead_code)]
output_muted: bool,
}
#[derive(Debug, Clone)]
@@ -84,16 +92,18 @@ struct ChatMessage {
struct App {
page: Page,
#[allow(dead_code)]
db: Arc<Mutex<DatabaseManager>>,
bookmarks: Vec<BookmarkInfo>,
selected_bookmark: Option<usize>,
nickname: String,
password: String,
connected: bool,
handle: Arc<Mutex<Option<SessionHandle>>>,
session_id: u64,
server_info: Option<ServerInfo>,
handle: Arc<Mutex<Option<SyncConnectionHandle>>>,
event_rx: Arc<Mutex<Option<mpsc::Receiver<TsEvent>>>>,
server_name: String,
server_platform: String,
server_version: String,
server_max_clients: u16,
own_client_id: Option<ClientId>,
channels: Vec<ChannelEntry>,
clients: Vec<ClientEntry>,
messages: Vec<ChatMessage>,
@@ -104,17 +114,13 @@ struct App {
query_response: Option<String>,
query_error: Option<String>,
error: Option<String>,
identity_level: u8,
}
impl App {
fn new() -> (Self, Task<Message>) {
let db_path = std::path::PathBuf::from("re-teamspeak.db");
let db = DatabaseManager::new(db_path.to_str().unwrap_or("re-teamspeak.db"))
.expect("failed to create database");
let app = Self {
page: Page::ServerList,
db: Arc::new(Mutex::new(db)),
bookmarks: vec![
BookmarkInfo {
name: "Local Server".to_string(),
@@ -134,8 +140,12 @@ impl App {
password: String::new(),
connected: false,
handle: Arc::new(Mutex::new(None)),
session_id: 0,
server_info: None,
event_rx: Arc::new(Mutex::new(None)),
server_name: String::new(),
server_platform: String::new(),
server_version: String::new(),
server_max_clients: 0,
own_client_id: None,
channels: Vec::new(),
clients: Vec::new(),
messages: Vec::new(),
@@ -146,6 +156,7 @@ impl App {
query_response: None,
query_error: None,
error: None,
identity_level: 0,
};
(app, Task::none())
@@ -182,7 +193,7 @@ impl App {
None => return Task::none(),
};
let nickname = if self.nickname.is_empty() {
"User".to_string()
"ReTeamSpeak".to_string()
} else {
self.nickname.clone()
};
@@ -193,133 +204,109 @@ impl App {
};
let handle_store = self.handle.clone();
self.session_id += 1;
self.error = None;
self.connected = false;
let event_rx_store = self.event_rx.clone();
self.error = None;
self.connected = false;
Task::perform(
async move {
let socket_addr: std::net::SocketAddr =
match format!("{}:{}", bookmark.address, bookmark.port).parse() {
Ok(a) => a,
Err(_) => {
use tokio::net::lookup_host;
match lookup_host((&*bookmark.address, bookmark.port)).await {
Ok(mut addrs) => match addrs.next() {
Some(a) => a,
None => return Err("Cannot resolve address".to_string()),
},
Err(e) => return Err(format!("DNS error: {e}")),
}
}
};
let (event_tx, event_rx) = mpsc::channel(100);
*event_rx_store.lock().await = Some(event_rx);
let mut builder = Connection::build(format!(
"{}:{}",
bookmark.address, bookmark.port
))
.name(nickname);
let identity = IdentityKey::generate();
let mut config = ClientConfig::new(socket_addr, nickname);
config.server_password = password;
config.identity = identity;
if let Some(pwd) = password {
builder = builder.password(pwd);
}
Session::connect(config, std::time::Duration::from_secs(15))
let con = builder.connect().map_err(|e| e.to_string())?;
let sync_con: SyncConnection = con.into();
let mut handle = sync_con.get_handle();
*handle_store.lock().await = Some(handle.clone());
tokio::spawn(run_connection(sync_con, event_tx));
handle
.wait_until_connected()
.await
.map_err(|e| e.to_string())
.map_err(|e| e.to_string())?;
Ok::<SyncConnectionHandle, String>(handle)
},
move |result| match result {
Ok((session, handle)) => {
let client_id = session.client_id().unwrap_or(0);
let handle_arc = handle_store.clone();
tokio::spawn(async move {
*handle_arc.lock().await = Some(handle);
});
tokio::spawn(async move {
let mut session = session;
if let Err(e) = session.run().await {
tracing::error!("session error: {e}");
}
});
Message::Connected(client_id)
}
Err(e) => Message::ConnectionError(e),
|result| match result {
Ok(_handle) => Message::TsEvent(TsEvent::Connected),
Err(e) => Message::TsEvent(TsEvent::Error(e)),
},
)
}
Message::Connected(_client_id) => {
self.connected = true;
let h = self.handle.clone();
Task::perform(async move {
let guard = h.lock().await;
if let Some(ref handle) = *guard {
let _ = handle.request_channel_list().await;
let _ = handle.request_client_list().await;
}
}, |_| Message::Noop)
}
Message::Disconnect => {
let h = self.handle.clone();
let rx = self.event_rx.clone();
self.connected = false;
self.server_info = None;
self.server_name.clear();
self.channels.clear();
self.clients.clear();
self.messages.clear();
self.session_id += 1;
Task::perform(async move {
self.own_client_id = None;
Task::perform(
async move {
*rx.lock().await = None;
let mut guard = h.lock().await;
if let Some(ref handle) = *guard {
let _ = handle.disconnect().await;
if let Some(ref mut handle) = *guard {
let _ = handle.disconnect(DisconnectOptions::new()).await;
}
*guard = None;
}, |_| Message::Disconnected)
},
|_| Message::Noop,
)
}
Message::Disconnected => {
Message::TsEvent(event) => match event {
TsEvent::Connected => {
self.connected = true;
Task::none()
}
TsEvent::BookEvents(events) => {
for event in &events {
self.handle_book_event(event);
}
Task::none()
}
TsEvent::MessageEvent(_msg) => Task::none(),
TsEvent::AudioChange(_can_send, _can_receive) => Task::none(),
TsEvent::IdentityLevelIncreasing(level) => {
self.identity_level = level;
Task::none()
}
TsEvent::IdentityLevelIncreased => {
self.identity_level = 0;
Task::none()
}
TsEvent::DisconnectedTemporarily => {
self.error = Some("Connection lost, reconnecting...".to_string());
Task::none()
}
TsEvent::Disconnected => {
self.connected = false;
self.server_info = None;
self.server_name.clear();
self.channels.clear();
self.clients.clear();
self.messages.clear();
self.own_client_id = None;
Task::none()
}
Message::ConnectionError(err) => {
self.error = Some(err);
Task::none()
}
Message::SessionEvent(event) => {
match event {
SessionEvent::Connected { client_id } => {
tracing::info!("Connected as client {client_id}");
}
SessionEvent::ServerInfo { name, platform, version, max_clients, clients_online, .. } => {
self.server_info = Some(ServerInfo { name, platform, version, max_clients, clients_online });
}
SessionEvent::ChannelList(channels) => {
self.channels = channels.into_iter().map(|c| ChannelEntry {
cid: c.cid,
channel_name: c.channel_name,
total_clients: c.total_clients,
}).collect();
}
SessionEvent::ClientList(clients) => {
self.clients = clients.into_iter().map(|c| ClientEntry {
clid: c.clid,
client_nickname: c.client_nickname,
}).collect();
}
SessionEvent::TextMessage { invoker_name, message, .. } => {
self.messages.push(ChatMessage { invoker_name, message });
}
SessionEvent::ClientEntered { clid, client_nickname, .. } => {
self.clients.push(ClientEntry { clid, client_nickname });
}
SessionEvent::ClientLeft { clid, .. } => {
self.clients.retain(|c| c.clid != clid);
}
SessionEvent::Error(err) => {
self.error = Some(err);
}
SessionEvent::Disconnected => {
return self.update(Message::Disconnected);
}
_ => {}
}
TsEvent::Error(e) => {
self.error = Some(e);
Task::none()
}
},
Message::MessageInputChanged(input) => {
self.message_input = input;
Task::none()
@@ -331,15 +318,32 @@ impl App {
return Task::none();
}
let h = self.handle.clone();
Task::perform(async move {
let guard = h.lock().await;
if let Some(ref handle) = *guard {
if let Err(e) = handle.send_channel_message(&msg).await {
return Message::ConnectionError(e.to_string());
Task::perform(
async move {
let mut guard = h.lock().await;
if let Some(ref mut handle) = *guard {
let result = handle
.with_connection(move |con| -> Result<(), String> {
let state = con.get_state().map_err(|e| e.to_string())?;
let cmd = state.send_message(MessageTarget::Channel, &msg);
cmd.send(con).map_err(|e| e.to_string())?;
Ok(())
})
.await;
if let Err(e) = result {
return Some(e.to_string());
}
}
None
},
|err| {
if let Some(e) = err {
Message::TsEvent(TsEvent::Error(e))
} else {
Message::Noop
}, |m| m)
}
},
)
}
Message::QueryAddressChanged(addr) => {
self.query_address = addr;
@@ -361,9 +365,12 @@ impl App {
Task::perform(
async move {
use tscore::QueryClient;
let mut client = QueryClient::connect(&addr).await
let mut client = QueryClient::connect(&addr)
.await
.map_err(|e| format!("Connect: {e}"))?;
let resp = client.execute("help").await
let resp = client
.execute("help")
.await
.map_err(|e| format!("Query: {e}"))?;
Ok::<String, String>(resp.raw)
},
@@ -387,36 +394,211 @@ impl App {
}
}
fn handle_book_event(&mut self, event: &Event) {
match event {
Event::PropertyAdded { id, invoker: _, .. } => match id {
PropertyId::Server => {
self.refresh_server_state_from_handle();
}
PropertyId::Client(_cid) => {
self.refresh_clients_from_handle();
}
PropertyId::Channel(_chid) => {
self.refresh_channels_from_handle();
}
_ => {}
},
Event::PropertyChanged { id, invoker, .. } => match id {
PropertyId::ClientName(cid) => {
if let Some(c) = self.clients.iter_mut().find(|c| c.id == *cid) {
if let Some(inv) = invoker {
c.name = inv.name.clone();
}
}
}
PropertyId::ClientChannel(cid) => {
if let Some(c) = self.clients.iter_mut().find(|c| c.id == *cid) {
if let Some(inv) = invoker {
c.name = inv.name.clone();
}
}
}
PropertyId::ClientInputMuted(_cid) => {
self.refresh_clients_from_handle();
}
PropertyId::ChannelName(_chid) => {
self.refresh_channels_from_handle();
}
_ => {}
},
Event::PropertyRemoved { id, .. } => match id {
PropertyId::Client(cid) => {
self.clients.retain(|c| c.id != *cid);
}
PropertyId::Channel(chid) => {
self.channels.retain(|c| c.id != *chid);
}
_ => {}
},
Event::Message {
invoker,
message,
..
} => {
self.messages.push(ChatMessage {
invoker_name: invoker.name.clone(),
message: message.clone(),
});
}
}
}
fn refresh_server_state_from_handle(&mut self) {
let h = self.handle.clone();
let rt = tokio::runtime::Handle::current();
rt.block_on(async {
let mut guard = h.lock().await;
if let Some(ref mut handle) = *guard {
let result = handle
.with_connection(|con| {
if let Ok(state) = con.get_state() {
return Some((
state.server.name.clone(),
state.server.platform.clone(),
state.server.version.clone(),
state.server.max_clients,
state.own_client,
state
.channels
.values()
.map(|ch| ChannelEntry {
id: ch.id,
name: ch.name.clone(),
parent: ch.parent,
order: ch.order,
})
.collect::<Vec<_>>(),
state
.clients
.values()
.map(|c| ClientEntry {
id: c.id,
name: c.name.clone(),
channel: c.channel,
input_muted: c.input_muted,
output_muted: c.output_muted,
})
.collect::<Vec<_>>(),
));
}
None
})
.await;
if let Ok(Some((name, platform, version, max_clients, own_id, channels, clients))) =
result
{
self.server_name = name;
self.server_platform = platform;
self.server_version = version;
self.server_max_clients = max_clients;
self.own_client_id = Some(own_id);
self.channels = channels;
self.clients = clients;
}
}
});
}
fn refresh_clients_from_handle(&mut self) {
let h = self.handle.clone();
let rt = tokio::runtime::Handle::current();
rt.block_on(async {
let mut guard = h.lock().await;
if let Some(ref mut handle) = *guard {
let result = handle
.with_connection(|con| {
if let Ok(state) = con.get_state() {
return Some(
state
.clients
.values()
.map(|c| ClientEntry {
id: c.id,
name: c.name.clone(),
channel: c.channel,
input_muted: c.input_muted,
output_muted: c.output_muted,
})
.collect::<Vec<_>>(),
);
}
None
})
.await;
if let Ok(Some(clients)) = result {
self.clients = clients;
}
}
});
}
fn refresh_channels_from_handle(&mut self) {
let h = self.handle.clone();
let rt = tokio::runtime::Handle::current();
rt.block_on(async {
let mut guard = h.lock().await;
if let Some(ref mut handle) = *guard {
let result = handle
.with_connection(|con| {
if let Ok(state) = con.get_state() {
return Some(
state
.channels
.values()
.map(|ch| ChannelEntry {
id: ch.id,
name: ch.name.clone(),
parent: ch.parent,
order: ch.order,
})
.collect::<Vec<_>>(),
);
}
None
})
.await;
if let Ok(Some(channels)) = result {
self.channels = channels;
}
}
});
}
fn subscription(&self) -> Subscription<Message> {
if !self.connected {
return Subscription::none();
}
let handle = self.handle.clone();
let session_id = self.session_id;
let event_rx = self.event_rx.clone();
Subscription::run_with_id(
session_id,
1u64,
iced::stream::channel(100, move |mut sender| async move {
loop {
let event = {
let mut guard = handle.lock().await;
match guard.as_mut() {
Some(h) => h.recv_event().await,
None => break,
}
let rx = {
let mut guard = event_rx.lock().await;
guard.take()
};
match event {
Some(event) => {
let is_disconnect = matches!(event, SessionEvent::Disconnected);
if sender.send(Message::SessionEvent(event)).await.is_err() {
let Some(mut rx) = rx else { return };
while let Some(event) = rx.recv().await {
let is_disconnect = matches!(
event,
TsEvent::Disconnected | TsEvent::Error(_)
);
if sender.send(Message::TsEvent(event)).await.is_err() {
break;
}
if is_disconnect {
break;
}
}
None => break,
}
}
}),
)
}
@@ -535,30 +717,45 @@ impl App {
]
.spacing(4)
.padding(16);
if let Some(err) = &self.error {
connect_form = connect_form.push(text(format!("Error: {err}")).size(12));
}
if self.identity_level > 0 {
connect_form = connect_form.push(
text(format!("Error: {err}")).size(12)
text(format!(
"Computing identity level {}... this may take a while",
self.identity_level
))
.size(12),
);
}
if self.connected {
let server_info = if let Some(info) = &self.server_info {
column![
text(&info.name).size(18),
text(format!("{} / {}", info.platform, info.version)).size(12),
text(format!("{}/{} online", info.clients_online, info.max_clients)).size(12),
let server_info = column![
text(&self.server_name).size(18),
text(format!("{} / {}", self.server_platform, self.server_version)).size(12),
text(format!("max clients: {}", self.server_max_clients)).size(12),
]
.spacing(4)
} else {
column![text("Connected").size(18)]
};
.spacing(4);
let mut channel_entries: Vec<(ChannelId, String)> = self
.channels
.iter()
.map(|ch| (ch.id, ch.name.clone()))
.collect();
// Sort by order (use existing order from channels)
let order_map: std::collections::HashMap<ChannelId, u64> =
self.channels.iter().map(|ch| (ch.id, ch.order.0)).collect();
channel_entries.sort_by_key(|(id, _)| order_map.get(id).copied().unwrap_or(0));
let mut channels = column![text("Channels").size(14)].spacing(2);
for ch in &self.channels {
for (ch_id, ch_name) in &channel_entries {
let client_count = self.clients.iter().filter(|c| c.channel == *ch_id).count();
channels = channels.push(
row![
text(&ch.channel_name).size(12).width(Length::Fill),
text(format!("{}", ch.total_clients)).size(11),
text(ch_name.clone()).size(12).width(Length::Fill),
text(format!("{}", client_count)).size(11),
]
.spacing(8),
);
@@ -566,12 +763,14 @@ impl App {
let mut clients = column![text("Clients").size(14)].spacing(2);
for c in &self.clients {
clients = clients.push(text(&c.client_nickname).size(12));
let mute_indicator = if c.input_muted { " [M]" } else { "" };
clients =
clients.push(text(format!("{}{}", c.name, mute_indicator)).size(12));
}
let mut messages = column![].spacing(4);
let mut messages_col = column![].spacing(4);
for msg in &self.messages {
messages = messages.push(
messages_col = messages_col.push(
row![
text(format!("{}:", msg.invoker_name)).size(12),
text(&msg.message).size(12),
@@ -603,7 +802,7 @@ impl App {
.spacing(16),
horizontal_rule(1),
text("Messages").size(14),
scrollable(messages).height(120),
scrollable(messages_col).height(120),
message_input,
]
.spacing(8)
@@ -697,6 +896,31 @@ impl App {
}
}
async fn run_connection(con: SyncConnection, event_tx: mpsc::Sender<TsEvent>) {
let mut stream = con;
while let Some(item) = stream.next().await {
let ts_event = match item {
Ok(SyncStreamItem::BookEvents(events)) => TsEvent::BookEvents(events),
Ok(SyncStreamItem::MessageEvent(msg)) => TsEvent::MessageEvent(msg),
Ok(SyncStreamItem::AudioChange(change)) => match change {
tsclientlib::AudioEvent::CanSendAudio(can) => TsEvent::AudioChange(can, true),
tsclientlib::AudioEvent::CanReceiveAudio(can) => TsEvent::AudioChange(false, can),
},
Ok(SyncStreamItem::IdentityLevelIncreasing(level)) => {
TsEvent::IdentityLevelIncreasing(level)
}
Ok(SyncStreamItem::IdentityLevelIncreased) => TsEvent::IdentityLevelIncreased,
Ok(SyncStreamItem::DisconnectedTemporarily(_)) => TsEvent::DisconnectedTemporarily,
Ok(SyncStreamItem::NetworkStatsUpdated) => continue,
Err(e) => TsEvent::Error(e.to_string()),
};
if event_tx.send(ts_event).await.is_err() {
break;
}
}
let _ = event_tx.send(TsEvent::Disconnected).await;
}
fn nav_button(label: &str, page: Page, current: Page) -> Element<'static, Message> {
button(text(label.to_string()).size(12))
.padding([6, 12])