diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d90cdaf..a1d873d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/src/Cargo.toml b/src/Cargo.toml index 021906c..6e5fc4c 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -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" } diff --git a/src/iced-app/Cargo.toml b/src/iced-app/Cargo.toml index ff916f5..32010eb 100644 --- a/src/iced-app/Cargo.toml +++ b/src/iced-app/Cargo.toml @@ -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 = [] diff --git a/src/iced-app/src/main.rs b/src/iced-app/src/main.rs index 0519cce..62e4c20 100644 --- a/src/iced-app/src/main.rs +++ b/src/iced-app/src/main.rs @@ -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), + 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>, bookmarks: Vec, selected_bookmark: Option, nickname: String, password: String, connected: bool, - handle: Arc>>, - session_id: u64, - server_info: Option, + handle: Arc>>, + event_rx: Arc>>>, + server_name: String, + server_platform: String, + server_version: String, + server_max_clients: u16, + own_client_id: Option, channels: Vec, clients: Vec, messages: Vec, @@ -104,17 +114,13 @@ struct App { query_response: Option, query_error: Option, error: Option, + identity_level: u8, } impl App { fn new() -> (Self, Task) { - 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; - - Session::connect(config, std::time::Duration::from_secs(15)) - .await - .map_err(|e| e.to_string()) - }, - 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) + if let Some(pwd) = password { + builder = builder.password(pwd); } - Err(e) => Message::ConnectionError(e), + + 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())?; + + Ok::(handle) + }, + |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 { - let mut guard = h.lock().await; - if let Some(ref handle) = *guard { - let _ = handle.disconnect().await; - } - *guard = None; - }, |_| Message::Disconnected) + self.own_client_id = None; + Task::perform( + async move { + *rx.lock().await = None; + let mut guard = h.lock().await; + if let Some(ref mut handle) = *guard { + let _ = handle.disconnect(DisconnectOptions::new()).await; + } + *guard = None; + }, + |_| Message::Noop, + ) } - Message::Disconnected => { - self.connected = false; - self.server_info = None; - self.channels.clear(); - self.clients.clear(); - self.messages.clear(); - 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); - } - _ => {} + Message::TsEvent(event) => match event { + TsEvent::Connected => { + self.connected = true; + Task::none() } - 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_name.clear(); + self.channels.clear(); + self.clients.clear(); + self.messages.clear(); + self.own_client_id = None; + Task::none() + } + 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()); } - Message::Noop - }, |m| m) + } + None + }, + |err| { + if let Some(e) = err { + Message::TsEvent(TsEvent::Error(e)) + } else { + Message::Noop + } + }, + ) } 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::(resp.raw) }, @@ -387,34 +394,209 @@ 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::>(), + 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::>(), + )); + } + 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::>(), + ); + } + 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::>(), + ); + } + None + }) + .await; + if let Ok(Some(channels)) = result { + self.channels = channels; + } + } + }); + } + fn subscription(&self) -> Subscription { 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, - } - }; - match event { - Some(event) => { - let is_disconnect = matches!(event, SessionEvent::Disconnected); - if sender.send(Message::SessionEvent(event)).await.is_err() { - break; - } - if is_disconnect { - break; - } - } - None => break, + let rx = { + let mut guard = event_rx.lock().await; + guard.take() + }; + 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; } } }), @@ -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), - ] - .spacing(4) - } else { - column![text("Connected").size(18)] - }; + 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); + + 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 = + 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) { + 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])