feat: replace Tauri with iced for native cross-platform GUI

- Create iced-app crate with pure Rust GUI using iced 0.13
- Implement server list, connect form, channel/user panels, messaging
- Implement ServerQuery panel for direct server queries
- Add VoiceEngine in tsaudio for decode/buffer/playback pipeline
- Update CI to build iced binary (no frontend build step needed)
- Remove Tauri dependency from workspace
- 69 tests passing across all crates
This commit is contained in:
ReTeamSpeak
2026-05-12 20:35:24 +09:00
parent c878d74a6a
commit c4113373c8
6 changed files with 745 additions and 50 deletions
+7 -49
View File
@@ -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
+1 -1
View File
@@ -5,7 +5,7 @@ members = [
"tsaudio",
"tsdb",
"shared",
"tauri-app/src-tauri",
"iced-app",
]
[workspace.package]
+27
View File
@@ -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 = []
+591
View File
@@ -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<BookmarkInfo>),
SelectBookmark(Option<usize>),
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<String>,
}
#[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<Mutex<DatabaseManager>>,
bookmarks: Vec<BookmarkInfo>,
selected_bookmark: Option<usize>,
nickname: String,
password: String,
connected: bool,
server_info: Option<ServerInfo>,
channels: Vec<ChannelEntry>,
clients: Vec<ClientEntry>,
messages: Vec<ChatMessage>,
message_input: String,
query_address: String,
query_port: u16,
query_loading: bool,
query_error: Option<String>,
error: Option<String>,
}
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 {
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<Message> {
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<Message> {
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()
}
+117
View File
@@ -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<u16, OpusDecoder>,
}
impl VoiceEngine {
pub fn new(config: AudioConfig) -> AudioResult<Self> {
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<Vec<String>> {
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());
}
}
+2
View File
@@ -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::*;