diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4816906..aa2dda8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,12 +41,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y \ - libdbus-1-dev \ pkg-config \ - libgtk-3-dev \ - libwebkit2gtk-4.1-dev \ - libayatana-appindicator3-dev \ - librsvg2-dev \ libssl-dev \ libasound2-dev @@ -63,7 +58,7 @@ jobs: - name: Check working-directory: src - run: cargo check -p shared -p tscore -p tsaudio -p tsdb + run: cargo check -p shared -p tscore -p tsaudio -p tsdb -p re-teamspeak - name: Test working-directory: src @@ -71,39 +66,13 @@ jobs: - name: Clippy working-directory: src - run: cargo clippy -p shared -p tscore -p tsaudio -p tsdb -- -D warnings + run: cargo clippy -p shared -p tscore -p tsaudio -p tsdb -p re-teamspeak -- -D warnings continue-on-error: true - # Build frontend - build-frontend: - name: Build Frontend - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - - - name: Install dependencies - working-directory: src/tauri-app/frontend - run: npm install - - - name: Build - working-directory: src/tauri-app/frontend - run: npm run build - - - name: Upload frontend artifact - uses: actions/upload-artifact@v4 - with: - name: frontend-dist - path: src/tauri-app/frontend/dist/ - # Build desktop apps build-desktop: name: Build Desktop (${{ matrix.platform }}) - needs: [test, build-frontend] + needs: test runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -135,21 +104,10 @@ jobs: run: | sudo apt-get update sudo apt-get install -y \ - libdbus-1-dev \ pkg-config \ - libgtk-3-dev \ - libwebkit2gtk-4.1-dev \ - libayatana-appindicator3-dev \ - librsvg2-dev \ libssl-dev \ libasound2-dev - - name: Download frontend artifact - uses: actions/download-artifact@v4 - with: - name: frontend-dist - path: src/tauri-app/frontend/dist/ - - name: Cache cargo uses: actions/cache@v4 with: @@ -163,27 +121,27 @@ jobs: - name: Build working-directory: src - run: cargo build --release + run: cargo build --release -p re-teamspeak - name: Package (Linux) if: matrix.platform == 'linux' run: | mkdir -p dist - cp src/target/release/re-teamspeak dist/ || true + cp src/target/release/re-teamspeak dist/ tar -czf re-teamspeak-${{ matrix.artifact }}.tar.gz -C dist . - name: Package (Windows) if: matrix.platform == 'windows' run: | mkdir dist - copy src\target\release\re-teamspeak.exe dist\ || copy src\target\release\re_teamspeak.exe dist\ || echo "Binary not found" + copy src\target\release\re-teamspeak.exe dist\ Compress-Archive -Path dist\* -DestinationPath re-teamspeak-${{ matrix.artifact }}.zip - name: Package (macOS) if: matrix.platform == 'macos' run: | mkdir -p dist - cp src/target/release/re-teamspeak dist/ || true + cp src/target/release/re-teamspeak dist/ tar -czf re-teamspeak-${{ matrix.artifact }}.tar.gz -C dist . - name: Upload artifact diff --git a/src/Cargo.toml b/src/Cargo.toml index e510115..021906c 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -5,7 +5,7 @@ members = [ "tsaudio", "tsdb", "shared", - "tauri-app/src-tauri", + "iced-app", ] [workspace.package] diff --git a/src/iced-app/Cargo.toml b/src/iced-app/Cargo.toml new file mode 100644 index 0000000..ff916f5 --- /dev/null +++ b/src/iced-app/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "re-teamspeak" +version = "1.0.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "TeamSpeak 3 client built with iced" + +[[bin]] +name = "re-teamspeak" +path = "src/main.rs" + +[dependencies] +iced = { version = "0.13", features = ["tokio", "debug"] } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +chrono = { workspace = true } + +shared = { workspace = true } +tscore = { workspace = true } +tsaudio = { workspace = true } +tsdb = { workspace = true } + +[features] +default = [] diff --git a/src/iced-app/src/main.rs b/src/iced-app/src/main.rs new file mode 100644 index 0000000..534cf7f --- /dev/null +++ b/src/iced-app/src/main.rs @@ -0,0 +1,591 @@ +use iced::widget::{button, column, container, horizontal_rule, horizontal_space, row, scrollable, text, text_input, vertical_space}; +use iced::{Element, Length, Subscription, Task}; + +use std::sync::Arc; +use tokio::sync::Mutex; + +use tscore::{ClientConfig, IdentityKey, Session, SessionEvent}; +use tsdb::DatabaseManager; + +fn main() -> iced::Result { + tracing_subscriber::fmt::init(); + iced::application("ReTeamSpeak", App::update, App::view) + .subscription(App::subscription) + .run_with(App::new) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Page { + ServerList, + ServerQuery, + Settings, +} + +#[derive(Debug, Clone)] +enum Message { + PageChanged(Page), + LoadBookmarks, + BookmarksLoaded(Vec), + SelectBookmark(Option), + NicknameChanged(String), + PasswordChanged(String), + Connect, + Connected(u16), + Disconnect, + Disconnected, + ConnectionError(String), + PollEvents, + SessionEvent(SessionEvent), + MessageInputChanged(String), + SendChannelMessage, + QueryAddressChanged(String), + QueryPortChanged(String), + RunServerQuery, + Noop, +} + +#[derive(Debug, Clone)] +struct BookmarkInfo { + id: String, + name: String, + address: String, + port: u16, + nickname: Option, +} + +#[derive(Debug, Clone)] +struct ChannelEntry { + cid: u64, + channel_name: String, + total_clients: u16, +} + +#[derive(Debug, Clone)] +struct ClientEntry { + clid: u16, + client_nickname: String, +} + +#[derive(Debug, Clone)] +struct ServerInfo { + name: String, + platform: String, + version: String, + max_clients: u16, + clients_online: u16, +} + +#[derive(Debug, Clone)] +struct ChatMessage { + invoker_name: String, + message: String, +} + +struct App { + page: Page, + db: Arc>, + bookmarks: Vec, + selected_bookmark: Option, + nickname: String, + password: String, + connected: bool, + server_info: Option, + channels: Vec, + clients: Vec, + messages: Vec, + message_input: String, + query_address: String, + query_port: u16, + query_loading: bool, + query_error: Option, + error: Option, +} + +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 { + id: "1".to_string(), + name: "Local Server".to_string(), + address: "127.0.0.1".to_string(), + port: 9987, + nickname: Some("User".to_string()), + }, + BookmarkInfo { + id: "2".to_string(), + name: "KR TeamSpeak".to_string(), + address: "kr.teamspeak.app".to_string(), + port: 9987, + nickname: None, + }, + ], + selected_bookmark: None, + nickname: String::new(), + password: String::new(), + connected: false, + server_info: None, + channels: Vec::new(), + clients: Vec::new(), + messages: Vec::new(), + message_input: String::new(), + query_address: String::new(), + query_port: 10011, + query_loading: false, + query_error: None, + error: None, + }; + + (app, Task::none()) + } + + fn update(&mut self, message: Message) -> Task { + match message { + Message::PageChanged(page) => { + self.page = page; + Task::none() + } + Message::LoadBookmarks => Task::none(), + Message::BookmarksLoaded(_) => Task::none(), + Message::SelectBookmark(idx) => { + self.selected_bookmark = idx; + if let Some(i) = idx { + if let Some(b) = self.bookmarks.get(i) { + if let Some(nick) = &b.nickname { + self.nickname = nick.clone(); + } + } + } + Task::none() + } + Message::NicknameChanged(n) => { + self.nickname = n; + Task::none() + } + Message::PasswordChanged(p) => { + self.password = p; + Task::none() + } + Message::Connect => { + let bookmark = match self.selected_bookmark.and_then(|i| self.bookmarks.get(i)) { + Some(b) => b.clone(), + None => return Task::none(), + }; + let nickname = if self.nickname.is_empty() { + "User".to_string() + } else { + self.nickname.clone() + }; + let password = if self.password.is_empty() { + None + } else { + Some(self.password.clone()) + }; + + 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 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()) + }, + |result| match result { + Ok((session, _handle)) => { + let client_id = session.client_id().unwrap_or(0); + 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), + }, + ) + } + Message::Connected(client_id) => { + self.connected = true; + tracing::info!("Connected as client {client_id}"); + Task::none() + } + Message::Disconnect => { + self.connected = false; + self.server_info = None; + self.channels.clear(); + self.clients.clear(); + self.messages.clear(); + Task::none() + } + 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::PollEvents => Task::none(), + Message::SessionEvent(event) => { + match event { + 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); + } + _ => {} + } + Task::none() + } + Message::MessageInputChanged(input) => { + self.message_input = input; + Task::none() + } + Message::SendChannelMessage => { + self.message_input.clear(); + Task::none() + } + Message::QueryAddressChanged(addr) => { + self.query_address = addr; + Task::none() + } + Message::QueryPortChanged(port) => { + self.query_port = port.parse().unwrap_or(10011); + Task::none() + } + Message::RunServerQuery => { + self.query_loading = true; + self.query_error = None; + Task::none() + } + Message::Noop => Task::none(), + } + } + + fn subscription(&self) -> Subscription { + Subscription::none() + } + + fn view(&self) -> Element<'_, Message> { + let sidebar = self.view_sidebar(); + let content = self.view_content(); + + row![sidebar, content] + .width(Length::Fill) + .height(Length::Fill) + .into() + } + + fn view_sidebar(&self) -> Element<'_, Message> { + let nav = row![ + nav_button("Servers", Page::ServerList, self.page), + nav_button("Query", Page::ServerQuery, self.page), + nav_button("Settings", Page::Settings, self.page), + ] + .spacing(4) + .padding(8); + + let content = match self.page { + Page::ServerList => self.view_server_list(), + Page::ServerQuery => container(text("ServerQuery panel")).padding(8).into(), + Page::Settings => container(text("Settings panel")).padding(8).into(), + }; + + column![nav, horizontal_rule(1), content] + .width(260) + .height(Length::Fill) + .into() + } + + fn view_server_list(&self) -> Element<'_, Message> { + let mut list = column![].spacing(2).padding(8); + + for (i, bookmark) in self.bookmarks.iter().enumerate() { + let is_selected = self.selected_bookmark == Some(i); + + let btn = button( + column![ + text(&bookmark.name).size(14), + text(format!("{}:{}", bookmark.address, bookmark.port)).size(11), + ] + .spacing(2), + ) + .width(Length::Fill) + .on_press(Message::SelectBookmark(Some(i))) + .padding(8) + .style(if is_selected { + button::primary + } else { + button::secondary + }); + + list = list.push(btn); + } + + column![ + text("Server Bookmarks").size(16), + vertical_space().height(8), + scrollable(list).height(Length::Fill), + ] + .spacing(4) + .padding(8) + .into() + } + + fn view_content(&self) -> Element<'_, Message> { + match self.page { + Page::ServerList => self.view_server_content(), + Page::ServerQuery => self.view_query_content(), + Page::Settings => self.view_settings_content(), + } + } + + fn view_server_content(&self) -> Element<'_, Message> { + if let Some(idx) = self.selected_bookmark { + if let Some(bookmark) = self.bookmarks.get(idx) { + let connect_form = column![ + text(format!("Connect to {}", bookmark.name)).size(20), + vertical_space().height(12), + row![ + text("Nickname:").width(80), + text_input("Enter nickname", &self.nickname) + .width(Length::Fill) + .on_input(Message::NicknameChanged), + ] + .spacing(8) + .align_y(iced::Alignment::Center), + vertical_space().height(8), + row![ + text("Password:").width(80), + text_input("Optional", &self.password) + .width(Length::Fill) + .secure(true) + .on_input(Message::PasswordChanged), + ] + .spacing(8) + .align_y(iced::Alignment::Center), + vertical_space().height(12), + row![ + horizontal_space(), + if self.connected { + button(text("Disconnect").size(14)) + .padding([8, 24]) + .on_press(Message::Disconnect) + } else { + button(text("Connect").size(14)) + .padding([8, 24]) + .on_press(Message::Connect) + }, + ], + ] + .spacing(4) + .padding(16); + + 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 mut channels = column![text("Channels").size(14)].spacing(2); + for ch in &self.channels { + channels = channels.push( + row![ + text(&ch.channel_name).size(12).width(Length::Fill), + text(format!("{}", ch.total_clients)).size(11), + ] + .spacing(8), + ); + } + + 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 mut messages = column![].spacing(4); + for msg in &self.messages { + messages = messages.push( + row![ + text(format!("{}:", msg.invoker_name)).size(12), + text(&msg.message).size(12), + ] + .spacing(4), + ); + } + + let message_input = row![ + text_input("Type a message...", &self.message_input) + .width(Length::Fill) + .on_input(Message::MessageInputChanged) + .on_submit(Message::SendChannelMessage), + button(text("Send").size(12)) + .padding([8, 16]) + .on_press(Message::SendChannelMessage), + ] + .spacing(8); + + column![ + connect_form, + horizontal_rule(1), + server_info, + vertical_space().height(12), + row![ + scrollable(channels).height(200), + scrollable(clients).height(200), + ] + .spacing(16), + horizontal_rule(1), + text("Messages").size(14), + scrollable(messages).height(120), + message_input, + ] + .spacing(8) + .padding(16) + .into() + } else { + connect_form.into() + } + } else { + self.view_welcome() + } + } else { + self.view_welcome() + } + } + + fn view_query_content(&self) -> Element<'_, Message> { + column![ + text("ServerQuery").size(20), + vertical_space().height(12), + row![ + text("Address:").width(80), + text_input("e.g. teamspeak.example.com", &self.query_address) + .width(Length::Fill) + .on_input(Message::QueryAddressChanged), + ] + .spacing(8) + .align_y(iced::Alignment::Center), + vertical_space().height(8), + row![ + text("Port:").width(80), + text_input("10011", &self.query_port.to_string()) + .width(Length::Fill) + .on_input(Message::QueryPortChanged), + ] + .spacing(8) + .align_y(iced::Alignment::Center), + vertical_space().height(12), + row![ + horizontal_space(), + button(text(if self.query_loading { "Loading..." } else { "Query" }).size(14)) + .padding([8, 24]) + .on_press_maybe(if self.query_loading { None } else { Some(Message::RunServerQuery) }), + ], + ] + .spacing(4) + .padding(16) + .into() + } + + fn view_settings_content(&self) -> Element<'_, Message> { + container( + column![ + text("Settings").size(24), + vertical_space().height(12), + text("Settings will be available in a future update.").size(14), + ] + .align_x(iced::Alignment::Center), + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() + } + + fn view_welcome(&self) -> Element<'_, Message> { + container( + column![ + text("Welcome to ReTeamSpeak").size(24), + vertical_space().height(12), + text("Select a server bookmark from the sidebar to connect.").size(14), + ] + .align_x(iced::Alignment::Center), + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() + } +} + +fn nav_button(label: &str, page: Page, current: Page) -> Element<'static, Message> { + button(text(label.to_string()).size(12)) + .padding([6, 12]) + .on_press(Message::PageChanged(page)) + .style(if current == page { + button::primary + } else { + button::secondary + }) + .into() +} diff --git a/src/tsaudio/src/engine.rs b/src/tsaudio/src/engine.rs new file mode 100644 index 0000000..ca08ffc --- /dev/null +++ b/src/tsaudio/src/engine.rs @@ -0,0 +1,117 @@ +use crate::buffer::JitterBuffer; +use crate::codec::OpusDecoder; +use crate::playback::AudioPlayback; +use crate::{AudioConfig, AudioError, AudioFrame, AudioResult}; + +pub struct VoiceEngine { + decoder: OpusDecoder, + jitter_buffer: JitterBuffer, + playback: AudioPlayback, + config: AudioConfig, + client_decoders: std::collections::HashMap, +} + +impl VoiceEngine { + pub fn new(config: AudioConfig) -> AudioResult { + let decoder = OpusDecoder::new(config.sample_rate, config.channels)?; + let jitter_buffer = JitterBuffer::with_target_delay(100, 60); + let playback = AudioPlayback::new(config.clone()); + + Ok(Self { + decoder, + jitter_buffer, + playback, + config, + client_decoders: std::collections::HashMap::new(), + }) + } + + pub fn start(&mut self) -> AudioResult<()> { + self.playback.start() + } + + pub fn stop(&mut self) -> AudioResult<()> { + self.playback.stop() + } + + pub fn process_voice_data( + &mut self, + client_id: u16, + codec: u8, + packet_id: u16, + audio_data: &[u8], + ) -> AudioResult<()> { + let decoder = self.client_decoders.entry(client_id).or_insert_with(|| { + OpusDecoder::new(self.config.sample_rate, self.config.channels) + .unwrap_or_else(|_| { + OpusDecoder::new(48000, 1).unwrap() + }) + }); + + let samples = decoder.decode_packet(audio_data)?; + + let frame = AudioFrame::new(self.config.sample_rate, self.config.channels, samples) + .with_sequence(packet_id) + .with_codec(codec); + + self.jitter_buffer.push(frame)?; + + Ok(()) + } + + pub fn output_tick(&mut self) -> AudioResult<()> { + if let Some(frame) = self.jitter_buffer.pop() { + self.playback.play(frame)?; + } + Ok(()) + } + + pub fn run_output_loop(&mut self) -> AudioResult<()> { + loop { + self.output_tick()?; + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + + pub fn list_output_devices() -> AudioResult> { + AudioPlayback::list_devices() + } + + pub fn buffered_ms(&self) -> u32 { + self.jitter_buffer.buffered_ms() + } + + pub fn jitter_buffer_len(&self) -> usize { + self.jitter_buffer.len() + } + + pub fn playback_buffer_len(&self) -> usize { + self.playback.buffer_len() + } + + pub fn clear_jitter_buffer(&mut self) { + self.jitter_buffer.clear(); + } + + pub fn remove_client(&mut self, client_id: u16) { + self.client_decoders.remove(&client_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_voice_engine_new() { + let config = AudioConfig::default(); + let engine = VoiceEngine::new(config); + assert!(engine.is_ok()); + } + + #[test] + fn test_voice_engine_list_devices() { + let devices = VoiceEngine::list_output_devices(); + assert!(devices.is_ok()); + } +} diff --git a/src/tsaudio/src/lib.rs b/src/tsaudio/src/lib.rs index cb5a93a..7e16746 100644 --- a/src/tsaudio/src/lib.rs +++ b/src/tsaudio/src/lib.rs @@ -3,12 +3,14 @@ pub mod buffer; pub mod capture; pub mod codec; +pub mod engine; pub mod playback; pub mod vad; pub use buffer::*; pub use capture::*; pub use codec::*; +pub use engine::*; pub use playback::*; pub use vad::*;