refactor: remove legacy tauri stack and update iced docs
@@ -1,10 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"tscore",
|
||||
"tsaudio",
|
||||
"tsdb",
|
||||
"shared",
|
||||
"iced-app",
|
||||
]
|
||||
|
||||
@@ -20,41 +16,8 @@ tokio = { version = "1", features = ["full"] }
|
||||
futures = "0.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
aes = "0.8"
|
||||
eax = "0.5"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
p256 = { version = "0.13", features = ["ecdh", "ecdsa"] }
|
||||
curve25519-dalek-ng = "4"
|
||||
num-bigint = "0.4"
|
||||
simple_asn1 = "0.6"
|
||||
|
||||
quicklz = "0.1"
|
||||
|
||||
opus = "0.3"
|
||||
cpal = "0.15"
|
||||
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
|
||||
hickory-resolver = "0.24"
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
|
||||
base64 = "0.21"
|
||||
hex = "0.4"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
url = "2"
|
||||
rand = "0.8"
|
||||
|
||||
tscore = { path = "tscore" }
|
||||
tsaudio = { path = "tsaudio" }
|
||||
tsdb = { path = "tsdb" }
|
||||
shared = { path = "shared" }
|
||||
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", branch = "master", default-features = false, features = ["default-tls", "audio"] }
|
||||
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", branch = "master", default-features = false, features = ["default-tls"] }
|
||||
tsproto-packets = { git = "https://github.com/ReSpeak/tsclientlib.git", branch = "master" }
|
||||
|
||||
@@ -20,13 +20,14 @@ tracing-subscriber = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
cpal = { version = "0.15", optional = true }
|
||||
audiopus = { version = "0.3.0-rc.0", optional = true }
|
||||
nnnoiseless = { version = "0.5", optional = true }
|
||||
sonora = { version = "0.1", optional = true }
|
||||
rusqlite = { version = "0.31", optional = true }
|
||||
|
||||
shared = { workspace = true }
|
||||
tscore = { workspace = true }
|
||||
tsdb = { workspace = true }
|
||||
tsclientlib = { workspace = true }
|
||||
tsproto-packets = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
audio = ["dep:cpal", "dep:audiopus"]
|
||||
audio = ["dep:cpal", "dep:audiopus", "dep:nnnoiseless", "dep:sonora"]
|
||||
rusqlite = ["dep:rusqlite"]
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
use tsclientlib::Identity;
|
||||
|
||||
#[cfg(feature = "rusqlite")]
|
||||
use rusqlite::Connection;
|
||||
|
||||
#[cfg(feature = "rusqlite")]
|
||||
pub fn import_identity_from_ts3(path: &str) -> Result<Identity, String> {
|
||||
let conn = Connection::open(path)
|
||||
.map_err(|e| format!("Failed to open TS3 settings database: {e}"))?;
|
||||
|
||||
let identity_str: String = conn
|
||||
.query_row(
|
||||
"SELECT value FROM properties WHERE key = 'identity_secret_key'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to query identity from database: {e}"))?;
|
||||
|
||||
tracing::info!("Found identity string: {}", &identity_str[..identity_str.len().min(50)]);
|
||||
|
||||
Identity::new_from_ts_str(&identity_str)
|
||||
.map_err(|e| format!("Failed to parse identity: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "rusqlite"))]
|
||||
pub fn import_identity_from_ts3(_path: &str) -> Result<Identity, String> {
|
||||
Err("rusqlite feature not enabled".to_string())
|
||||
}
|
||||
|
||||
pub fn find_ts3_config_dir() -> Option<String> {
|
||||
let home = std::env::var("HOME").ok()?;
|
||||
|
||||
let paths = [
|
||||
format!("{}/.ts3client/settings.db", home),
|
||||
format!("{}/.config/teamspeak3/settings.db", home),
|
||||
];
|
||||
|
||||
for path in &paths {
|
||||
if std::path::Path::new(path).exists() {
|
||||
return Some(path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
use tsclientlib::sync::{SyncConnection, SyncConnectionHandle, SyncStreamItem};
|
||||
use tsclientlib::{ChannelId, ClientId, Connection, DisconnectOptions, MessageTarget};
|
||||
use tsclientlib::{ChannelId, ClientId, Connection, DisconnectOptions, Identity, MessageTarget};
|
||||
use tsclientlib::events::{Event, PropertyId};
|
||||
use tsclientlib::prelude::*;
|
||||
#[cfg(feature = "audio")]
|
||||
@@ -18,6 +18,12 @@ mod theme;
|
||||
#[cfg(feature = "audio")]
|
||||
mod audio;
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
mod noise_cancel;
|
||||
|
||||
#[cfg(feature = "rusqlite")]
|
||||
mod identity;
|
||||
|
||||
fn main() -> iced::Result {
|
||||
iced::application("ReTeamSpeak", App::update, App::view)
|
||||
.theme(App::theme)
|
||||
@@ -63,11 +69,25 @@ enum Message {
|
||||
#[cfg(feature = "audio")]
|
||||
ToggleTalkMode,
|
||||
#[cfg(feature = "audio")]
|
||||
ToggleNoiseCancel,
|
||||
#[cfg(feature = "audio")]
|
||||
SetNoiseCancelMethod(noise_cancel::NoiseCancelMethod),
|
||||
#[cfg(feature = "audio")]
|
||||
StartContinuous,
|
||||
#[cfg(feature = "audio")]
|
||||
StopContinuous,
|
||||
#[cfg(feature = "audio")]
|
||||
MicSamples(Vec<f32>),
|
||||
ToggleMicMute,
|
||||
ToggleSpeakerMute,
|
||||
ToggleHeadsetMute,
|
||||
ToggleAfk,
|
||||
ImportIdentity,
|
||||
IdentityImported(Result<Identity, String>),
|
||||
#[cfg(feature = "audio")]
|
||||
SetOutputDevice(String),
|
||||
#[cfg(feature = "audio")]
|
||||
SetInputDevice(String),
|
||||
Noop,
|
||||
}
|
||||
|
||||
@@ -163,7 +183,18 @@ struct App {
|
||||
#[cfg(feature = "audio")]
|
||||
vad: Arc<Mutex<audio::VoiceActivation>>,
|
||||
#[cfg(feature = "audio")]
|
||||
noise_reducer: Arc<Mutex<noise_cancel::NoiseReducer>>,
|
||||
#[cfg(feature = "audio")]
|
||||
nc_method: noise_cancel::NoiseCancelMethod,
|
||||
imported_identity: Option<Identity>,
|
||||
#[cfg(feature = "audio")]
|
||||
audio_send_tx: Option<tokio::sync::mpsc::Sender<Vec<u8>>>,
|
||||
mic_muted: bool,
|
||||
speaker_muted: bool,
|
||||
headset_muted: bool,
|
||||
afk: bool,
|
||||
selected_output_device: String,
|
||||
selected_input_device: String,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -227,7 +258,18 @@ impl App {
|
||||
#[cfg(feature = "audio")]
|
||||
vad: Arc::new(Mutex::new(audio::VoiceActivation::new(0.005))),
|
||||
#[cfg(feature = "audio")]
|
||||
noise_reducer: Arc::new(Mutex::new(noise_cancel::NoiseReducer::new(noise_cancel::NoiseCancelMethod::None))),
|
||||
#[cfg(feature = "audio")]
|
||||
nc_method: noise_cancel::NoiseCancelMethod::None,
|
||||
imported_identity: None,
|
||||
#[cfg(feature = "audio")]
|
||||
audio_send_tx: None,
|
||||
mic_muted: false,
|
||||
speaker_muted: false,
|
||||
headset_muted: false,
|
||||
afk: false,
|
||||
selected_output_device: String::new(),
|
||||
selected_input_device: String::new(),
|
||||
};
|
||||
|
||||
(app, Task::none())
|
||||
@@ -324,6 +366,8 @@ impl App {
|
||||
let event_rx_store = self.event_rx.clone();
|
||||
#[cfg(feature = "audio")]
|
||||
let audio = self.audio.clone();
|
||||
#[cfg(feature = "audio")]
|
||||
let imported_identity = self.imported_identity.clone();
|
||||
|
||||
self.error = None;
|
||||
self.connected = false;
|
||||
@@ -343,6 +387,11 @@ impl App {
|
||||
builder = builder.password(pwd);
|
||||
}
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
if let Some(ref id) = imported_identity {
|
||||
builder = builder.identity(id.clone());
|
||||
}
|
||||
|
||||
let con = builder.connect().map_err(|e| e.to_string())?;
|
||||
let sync_con: SyncConnection = con.into();
|
||||
let mut handle = sync_con.get_handle();
|
||||
@@ -415,9 +464,11 @@ impl App {
|
||||
#[cfg(feature = "audio")]
|
||||
{
|
||||
let audio = self.audio.clone();
|
||||
let selected_device = self.selected_output_device.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut playback = audio.lock().await;
|
||||
if let Err(e) = playback.start(None) {
|
||||
let device = if selected_device.is_empty() { None } else { Some(selected_device.as_str()) };
|
||||
if let Err(e) = playback.start(device) {
|
||||
tracing::warn!("Audio playback failed to start: {e}");
|
||||
}
|
||||
});
|
||||
@@ -530,19 +581,10 @@ impl App {
|
||||
self.query_loading = true;
|
||||
self.query_error = None;
|
||||
self.query_response = None;
|
||||
let addr = format!("{}:{}", self.query_address, self.query_port);
|
||||
let cmd = self.query_command.clone();
|
||||
Task::perform(
|
||||
async move {
|
||||
use tscore::QueryClient;
|
||||
let mut client = QueryClient::connect(&addr)
|
||||
.await
|
||||
.map_err(|e| format!("Connect: {e}"))?;
|
||||
let resp = client
|
||||
.execute(&cmd)
|
||||
.await
|
||||
.map_err(|e| format!("Query: {e}"))?;
|
||||
Ok::<String, String>(resp.raw)
|
||||
// ServerQuery not yet implemented with tsclientlib
|
||||
Err::<String, String>("ServerQuery not implemented".to_string())
|
||||
},
|
||||
|result| match result {
|
||||
Ok(raw) => Message::QueryResponse(raw),
|
||||
@@ -564,10 +606,12 @@ impl App {
|
||||
Message::PttPressed => {
|
||||
if !self.ptt_active && self.connected && self.talk_mode == audio::TalkMode::PushToTalk {
|
||||
self.ptt_active = true;
|
||||
let device = if self.selected_input_device.is_empty() { None } else { Some(self.selected_input_device.clone()) };
|
||||
start_transmission(
|
||||
self.mic.clone(),
|
||||
self.handle.clone(),
|
||||
None,
|
||||
device,
|
||||
self.noise_reducer.clone(),
|
||||
);
|
||||
}
|
||||
Task::none()
|
||||
@@ -596,10 +640,12 @@ impl App {
|
||||
Message::StartContinuous => {
|
||||
if !self.continuous_active && self.connected && self.talk_mode == audio::TalkMode::Continuous {
|
||||
self.continuous_active = true;
|
||||
let device = if self.selected_input_device.is_empty() { None } else { Some(self.selected_input_device.clone()) };
|
||||
start_transmission(
|
||||
self.mic.clone(),
|
||||
self.handle.clone(),
|
||||
None,
|
||||
device,
|
||||
self.noise_reducer.clone(),
|
||||
);
|
||||
}
|
||||
Task::none()
|
||||
@@ -620,7 +666,7 @@ impl App {
|
||||
Task::perform(
|
||||
async move {
|
||||
let mut vad_guard = vad.lock().await;
|
||||
let state = vad_guard.process(&samples);
|
||||
vad_guard.process(&samples);
|
||||
let is_speaking = vad_guard.is_speaking();
|
||||
drop(vad_guard);
|
||||
match (was_speaking, is_speaking) {
|
||||
@@ -635,6 +681,175 @@ impl App {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
Message::ToggleNoiseCancel => {
|
||||
let methods = noise_cancel::NoiseCancelMethod::all();
|
||||
let current_idx = methods.iter().position(|m| *m == self.nc_method).unwrap_or(0);
|
||||
let next_idx = (current_idx + 1) % methods.len();
|
||||
self.nc_method = methods[next_idx];
|
||||
let nc = self.noise_reducer.clone();
|
||||
let method = self.nc_method;
|
||||
tokio::spawn(async move {
|
||||
nc.lock().await.set_method(method);
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
Message::SetNoiseCancelMethod(method) => {
|
||||
self.nc_method = method;
|
||||
let nc = self.noise_reducer.clone();
|
||||
tokio::spawn(async move {
|
||||
nc.lock().await.set_method(method);
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
Message::ToggleMicMute => {
|
||||
self.mic_muted = !self.mic_muted;
|
||||
let h = self.handle.clone();
|
||||
let muted = self.mic_muted;
|
||||
Task::perform(
|
||||
async move {
|
||||
let mut guard = h.lock().await;
|
||||
if let Some(ref mut handle) = *guard {
|
||||
let _ = handle
|
||||
.with_connection(move |con| -> Result<(), String> {
|
||||
let state = con.get_state().map_err(|e| e.to_string())?;
|
||||
if state.clients.get(&state.own_client).is_some() {
|
||||
let cmd = state.client_update()
|
||||
.set_input_muted(muted);
|
||||
cmd.send(con).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
},
|
||||
|_| Message::Noop,
|
||||
)
|
||||
}
|
||||
Message::ToggleSpeakerMute => {
|
||||
self.speaker_muted = !self.speaker_muted;
|
||||
let h = self.handle.clone();
|
||||
let muted = self.speaker_muted;
|
||||
Task::perform(
|
||||
async move {
|
||||
let mut guard = h.lock().await;
|
||||
if let Some(ref mut handle) = *guard {
|
||||
let _ = handle
|
||||
.with_connection(move |con| -> Result<(), String> {
|
||||
let state = con.get_state().map_err(|e| e.to_string())?;
|
||||
if state.clients.get(&state.own_client).is_some() {
|
||||
let cmd = state.client_update()
|
||||
.set_output_muted(muted);
|
||||
cmd.send(con).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
},
|
||||
|_| Message::Noop,
|
||||
)
|
||||
}
|
||||
Message::ToggleHeadsetMute => {
|
||||
self.headset_muted = !self.headset_muted;
|
||||
let h = self.handle.clone();
|
||||
let muted = self.headset_muted;
|
||||
Task::perform(
|
||||
async move {
|
||||
let mut guard = h.lock().await;
|
||||
if let Some(ref mut handle) = *guard {
|
||||
let _ = handle
|
||||
.with_connection(move |con| -> Result<(), String> {
|
||||
let state = con.get_state().map_err(|e| e.to_string())?;
|
||||
if state.clients.get(&state.own_client).is_some() {
|
||||
let cmd = state.client_update()
|
||||
.set_output_hardware_enabled(!muted);
|
||||
cmd.send(con).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
},
|
||||
|_| Message::Noop,
|
||||
)
|
||||
}
|
||||
Message::ToggleAfk => {
|
||||
self.afk = !self.afk;
|
||||
let h = self.handle.clone();
|
||||
let afk = self.afk;
|
||||
Task::perform(
|
||||
async move {
|
||||
let mut guard = h.lock().await;
|
||||
if let Some(ref mut handle) = *guard {
|
||||
let _ = handle
|
||||
.with_connection(move |con| -> Result<(), String> {
|
||||
let state = con.get_state().map_err(|e| e.to_string())?;
|
||||
if state.clients.get(&state.own_client).is_some() {
|
||||
let cmd = if afk {
|
||||
state.client_update()
|
||||
.set_input_muted(true)
|
||||
.set_away(Some("AFK"))
|
||||
} else {
|
||||
state.client_update()
|
||||
.set_input_muted(false)
|
||||
.set_away(Some(""))
|
||||
};
|
||||
cmd.send(con).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
},
|
||||
|_| Message::Noop,
|
||||
)
|
||||
}
|
||||
#[cfg(feature = "rusqlite")]
|
||||
Message::ImportIdentity => {
|
||||
Task::perform(
|
||||
async move {
|
||||
if let Some(path) = identity::find_ts3_config_dir() {
|
||||
match identity::import_identity_from_ts3(&path) {
|
||||
Ok(id) => Message::IdentityImported(Ok(id)),
|
||||
Err(e) => Message::IdentityImported(Err(e)),
|
||||
}
|
||||
} else {
|
||||
Message::IdentityImported(Err("No TeamSpeak config found".to_string()))
|
||||
}
|
||||
},
|
||||
|msg| msg,
|
||||
)
|
||||
}
|
||||
#[cfg(not(feature = "rusqlite"))]
|
||||
Message::ImportIdentity => {
|
||||
self.error = Some("Identity import requires the 'rusqlite' feature".to_string());
|
||||
Task::none()
|
||||
}
|
||||
Message::IdentityImported(result) => {
|
||||
match result {
|
||||
Ok(id) => {
|
||||
self.imported_identity = Some(id);
|
||||
self.error = None;
|
||||
tracing::info!("Identity imported successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
self.error = Some(e);
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
Message::SetOutputDevice(device) => {
|
||||
self.selected_output_device = device;
|
||||
Task::none()
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
Message::SetInputDevice(device) => {
|
||||
self.selected_input_device = device;
|
||||
Task::none()
|
||||
}
|
||||
Message::Noop => Task::none(),
|
||||
}
|
||||
}
|
||||
@@ -1056,14 +1271,46 @@ impl App {
|
||||
}
|
||||
|
||||
if self.connected {
|
||||
let mic_label = if self.mic_muted { "🔇 Mic" } else { "🎤 Mic" };
|
||||
let speaker_label = if self.speaker_muted { "🔇 Speaker" } else { "🔊 Speaker" };
|
||||
let headset_label = if self.headset_muted { "🔇 Headset" } else { "🎧 Headset" };
|
||||
let afk_label = if self.afk { "AFK ✓" } else { "AFK" };
|
||||
|
||||
let control_bar = row![
|
||||
button(text(mic_label).size(11))
|
||||
.padding([6, 12])
|
||||
.style(if self.mic_muted { theme::danger_button } else { theme::secondary_button })
|
||||
.on_press(Message::ToggleMicMute),
|
||||
button(text(speaker_label).size(11))
|
||||
.padding([6, 12])
|
||||
.style(if self.speaker_muted { theme::danger_button } else { theme::secondary_button })
|
||||
.on_press(Message::ToggleSpeakerMute),
|
||||
button(text(headset_label).size(11))
|
||||
.padding([6, 12])
|
||||
.style(if self.headset_muted { theme::danger_button } else { theme::secondary_button })
|
||||
.on_press(Message::ToggleHeadsetMute),
|
||||
horizontal_space(),
|
||||
button(text(afk_label).size(11))
|
||||
.padding([6, 12])
|
||||
.style(if self.afk { theme::danger_button } else { theme::secondary_button })
|
||||
.on_press(Message::ToggleAfk),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
let server_header = container(
|
||||
column![
|
||||
text(&self.server_name).size(18),
|
||||
row![
|
||||
text(&self.server_name).size(18),
|
||||
horizontal_space(),
|
||||
],
|
||||
text(format!("{} / {} — {}/{} online",
|
||||
self.server_platform, self.server_version,
|
||||
self.clients.len(), self.server_max_clients))
|
||||
.size(12)
|
||||
.style(text::secondary),
|
||||
vertical_space().height(8),
|
||||
control_bar,
|
||||
]
|
||||
.spacing(4),
|
||||
)
|
||||
@@ -1243,6 +1490,74 @@ impl App {
|
||||
}
|
||||
|
||||
fn view_settings_content(&self) -> Element<'_, Message> {
|
||||
let output_label = if self.selected_output_device.is_empty() {
|
||||
"System Default"
|
||||
} else {
|
||||
&self.selected_output_device
|
||||
};
|
||||
let input_label = if self.selected_input_device.is_empty() {
|
||||
"System Default"
|
||||
} else {
|
||||
&self.selected_input_device
|
||||
};
|
||||
|
||||
let mut settings_col = column![
|
||||
text("Settings").size(20),
|
||||
vertical_space().height(16),
|
||||
]
|
||||
.spacing(8)
|
||||
.padding(20);
|
||||
|
||||
// Audio devices card
|
||||
#[allow(unused_mut)]
|
||||
let mut devices_col = column![
|
||||
text("Audio Output Device").size(13),
|
||||
text(output_label).size(12).style(text::secondary),
|
||||
text("Audio Input Device").size(13),
|
||||
text(input_label).size(12).style(text::secondary),
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
{
|
||||
let outputs = audio::AudioPlayback::list_output_devices();
|
||||
let inputs = audio::AudioPlayback::list_input_devices();
|
||||
let mut output_btns = row![].spacing(4);
|
||||
for dev in outputs {
|
||||
let is_selected = self.selected_output_device == dev || (self.selected_output_device.is_empty() && dev == "default");
|
||||
output_btns = output_btns.push(
|
||||
button(text(dev.as_str()).size(10))
|
||||
.padding([4, 8])
|
||||
.style(if is_selected { theme::primary_button } else { theme::secondary_button })
|
||||
.on_press(Message::SetOutputDevice(dev)),
|
||||
);
|
||||
}
|
||||
let mut input_btns = row![].spacing(4);
|
||||
for dev in inputs {
|
||||
let is_selected = self.selected_input_device == dev || (self.selected_input_device.is_empty() && dev == "default");
|
||||
input_btns = input_btns.push(
|
||||
button(text(dev.as_str()).size(10))
|
||||
.padding([4, 8])
|
||||
.style(if is_selected { theme::primary_button } else { theme::secondary_button })
|
||||
.on_press(Message::SetInputDevice(dev)),
|
||||
);
|
||||
}
|
||||
devices_col = devices_col
|
||||
.push(vertical_space().height(4))
|
||||
.push(text("Output devices:").size(11).style(text::secondary))
|
||||
.push(output_btns)
|
||||
.push(vertical_space().height(4))
|
||||
.push(text("Input devices:").size(11).style(text::secondary))
|
||||
.push(input_btns);
|
||||
}
|
||||
|
||||
settings_col = settings_col.push(
|
||||
container(devices_col.spacing(4).padding(16))
|
||||
.style(theme::card_container)
|
||||
.width(500),
|
||||
);
|
||||
|
||||
// Voice settings card
|
||||
#[cfg(feature = "audio")]
|
||||
let talk_mode_text = match self.talk_mode {
|
||||
audio::TalkMode::PushToTalk => "Push-to-Talk (hold V)",
|
||||
@@ -1251,53 +1566,61 @@ impl App {
|
||||
#[cfg(not(feature = "audio"))]
|
||||
let talk_mode_text = "N/A";
|
||||
|
||||
container(
|
||||
column![
|
||||
text("Settings").size(20),
|
||||
vertical_space().height(16),
|
||||
container(
|
||||
column![
|
||||
text("Audio Output Device").size(13),
|
||||
text("System Default").size(12).style(text::secondary),
|
||||
vertical_space().height(12),
|
||||
text("Audio Input Device").size(13),
|
||||
text("System Default").size(12).style(text::secondary),
|
||||
vertical_space().height(12),
|
||||
text("Voice Activation Mode").size(13),
|
||||
{
|
||||
#[cfg(feature = "audio")]
|
||||
{
|
||||
row![
|
||||
text(talk_mode_text).size(12).style(text::secondary),
|
||||
horizontal_space(),
|
||||
button(text("Toggle").size(11))
|
||||
.padding([4, 12])
|
||||
.style(theme::secondary_button)
|
||||
.on_press(Message::ToggleTalkMode),
|
||||
]
|
||||
.align_y(iced::Alignment::Center)
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
{
|
||||
text(talk_mode_text).size(12).style(text::secondary)
|
||||
}
|
||||
},
|
||||
vertical_space().height(12),
|
||||
text("Push-to-Talk Key").size(13),
|
||||
text("V (hold to talk)").size(12).style(text::secondary),
|
||||
]
|
||||
.spacing(4)
|
||||
.padding(16),
|
||||
)
|
||||
#[cfg(feature = "audio")]
|
||||
let nc_label = self.nc_method.label();
|
||||
#[cfg(not(feature = "audio"))]
|
||||
let nc_label = "N/A";
|
||||
|
||||
let voice_col = column![
|
||||
text("Voice Activation Mode").size(13),
|
||||
text(talk_mode_text).size(12).style(text::secondary),
|
||||
vertical_space().height(8),
|
||||
text("Push-to-Talk Key").size(13),
|
||||
text("V (hold to talk)").size(12).style(text::secondary),
|
||||
vertical_space().height(8),
|
||||
text("Noise Cancellation").size(13),
|
||||
text(nc_label).size(12).style(text::secondary),
|
||||
]
|
||||
.spacing(4)
|
||||
.padding(16);
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
let voice_col = voice_col.push(
|
||||
button(text("Toggle Noise Cancellation").size(11))
|
||||
.padding([4, 12])
|
||||
.style(theme::secondary_button)
|
||||
.on_press(Message::ToggleNoiseCancel),
|
||||
);
|
||||
|
||||
settings_col = settings_col.push(
|
||||
container(voice_col)
|
||||
.style(theme::card_container)
|
||||
.width(400),
|
||||
]
|
||||
.spacing(8)
|
||||
.padding(20),
|
||||
)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into()
|
||||
.width(500),
|
||||
);
|
||||
|
||||
// Identity card
|
||||
let identity_col = column![
|
||||
text("TeamSpeak Identity").size(13),
|
||||
text("Import from TS3 client").size(12).style(text::secondary),
|
||||
vertical_space().height(8),
|
||||
button(text("Import Identity").size(12))
|
||||
.padding([8, 16])
|
||||
.style(theme::primary_button)
|
||||
.on_press(Message::ImportIdentity),
|
||||
]
|
||||
.spacing(4)
|
||||
.padding(16);
|
||||
|
||||
settings_col = settings_col.push(
|
||||
container(identity_col)
|
||||
.style(theme::card_container)
|
||||
.width(500),
|
||||
);
|
||||
|
||||
container(settings_col)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn view_welcome(&self) -> Element<'_, Message> {
|
||||
@@ -1361,7 +1684,6 @@ async fn run_connection(con: SyncConnection, event_tx: mpsc::Sender<TsEvent>) {
|
||||
Ok(SyncStreamItem::IdentityLevelIncreased) => TsEvent::IdentityLevelIncreased,
|
||||
Ok(SyncStreamItem::DisconnectedTemporarily(_)) => TsEvent::DisconnectedTemporarily,
|
||||
Ok(SyncStreamItem::NetworkStatsUpdated) => continue,
|
||||
Ok(_) => continue,
|
||||
Err(e) => TsEvent::Error(e.to_string()),
|
||||
};
|
||||
if event_tx.send(ts_event).await.is_err() {
|
||||
@@ -1375,14 +1697,15 @@ async fn run_connection(con: SyncConnection, event_tx: mpsc::Sender<TsEvent>) {
|
||||
fn start_transmission(
|
||||
mic: Arc<Mutex<audio::Microphone>>,
|
||||
handle: Arc<Mutex<Option<SyncConnectionHandle>>>,
|
||||
_device_name: Option<&str>,
|
||||
device_name: Option<String>,
|
||||
noise_reducer: Arc<Mutex<noise_cancel::NoiseReducer>>,
|
||||
) {
|
||||
let (sample_tx, sample_rx) = std::sync::mpsc::channel();
|
||||
|
||||
tokio::spawn(async move {
|
||||
{
|
||||
let mut mic_guard = mic.lock().await;
|
||||
if mic_guard.start(None, sample_tx).is_err() {
|
||||
if mic_guard.start(device_name.as_deref(), sample_tx).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1417,7 +1740,13 @@ fn start_transmission(
|
||||
loop {
|
||||
match sample_rx.recv() {
|
||||
Ok(samples) => {
|
||||
enc.encode_and_send(&samples, &audio_tx);
|
||||
// Apply noise reduction
|
||||
{
|
||||
let mut nr = noise_reducer.lock().await;
|
||||
let mut samples_copy = samples.clone();
|
||||
nr.process(&mut samples_copy);
|
||||
enc.encode_and_send(&samples_copy, &audio_tx);
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NoiseCancelMethod {
|
||||
None,
|
||||
Nnnoiseless,
|
||||
Sonora,
|
||||
}
|
||||
|
||||
impl NoiseCancelMethod {
|
||||
pub fn label(&self) -> &str {
|
||||
match self {
|
||||
Self::None => "Off",
|
||||
Self::Nnnoiseless => "RNNoise (nnnoiseless)",
|
||||
Self::Sonora => "WebRTC (Sonora)",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all() -> &'static [NoiseCancelMethod] {
|
||||
&[Self::None, Self::Nnnoiseless, Self::Sonora]
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoiseReducer {
|
||||
method: NoiseCancelMethod,
|
||||
#[cfg(feature = "nnnoiseless")]
|
||||
nnnoiseless: Option<nnnoiseless::DenoiseState<'static>>,
|
||||
#[cfg(feature = "sonora")]
|
||||
sonora: Option<sonora::NoiseSuppression>,
|
||||
#[cfg(feature = "nnnoiseless")]
|
||||
residual_buf: Vec<f32>,
|
||||
}
|
||||
|
||||
impl NoiseReducer {
|
||||
pub fn new(method: NoiseCancelMethod) -> Self {
|
||||
let mut this = Self {
|
||||
method,
|
||||
#[cfg(feature = "nnnoiseless")]
|
||||
nnnoiseless: None,
|
||||
#[cfg(feature = "sonora")]
|
||||
sonora: None,
|
||||
#[cfg(feature = "nnnoiseless")]
|
||||
residual_buf: Vec::new(),
|
||||
};
|
||||
this.init_method();
|
||||
this
|
||||
}
|
||||
|
||||
fn init_method(&mut self) {
|
||||
#[cfg(feature = "nnnoiseless")]
|
||||
{
|
||||
self.nnnoiseless = match self.method {
|
||||
NoiseCancelMethod::Nnnoiseless => Some(nnnoiseless::DenoiseState::new()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
#[cfg(feature = "sonora")]
|
||||
{
|
||||
self.sonora = match self.method {
|
||||
NoiseCancelMethod::Sonora => {
|
||||
Some(sonora::NoiseSuppression::new(48000, 480).expect("Failed to create Sonora NS"))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
#[cfg(feature = "nnnoiseless")]
|
||||
{
|
||||
self.residual_buf.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_method(&mut self, method: NoiseCancelMethod) {
|
||||
self.method = method;
|
||||
self.init_method();
|
||||
}
|
||||
|
||||
pub fn method(&self) -> NoiseCancelMethod {
|
||||
self.method
|
||||
}
|
||||
|
||||
pub fn process(&mut self, samples: &mut [f32]) {
|
||||
match self.method {
|
||||
NoiseCancelMethod::None => {}
|
||||
#[cfg(feature = "nnnoiseless")]
|
||||
NoiseCancelMethod::Nnnoiseless => self.process_nnnoiseless(samples),
|
||||
#[cfg(not(feature = "nnnoiseless"))]
|
||||
NoiseCancelMethod::Nnnoiseless => {}
|
||||
#[cfg(feature = "sonora")]
|
||||
NoiseCancelMethod::Sonora => self.process_sonora(samples),
|
||||
#[cfg(not(feature = "sonora"))]
|
||||
NoiseCancelMethod::Sonora => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "nnnoiseless")]
|
||||
fn process_nnnoiseless(&mut self, samples: &mut [f32]) {
|
||||
let denoise = match &mut self.nnnoiseless {
|
||||
Some(d) => d,
|
||||
None => return,
|
||||
};
|
||||
|
||||
self.residual_buf.extend_from_slice(samples);
|
||||
let frame_size = nnnoiseless::DenoiseState::FRAME_SIZE; // 480
|
||||
let mut output = vec![0.0f32; frame_size];
|
||||
let mut write_pos = 0;
|
||||
|
||||
while self.residual_buf.len() >= frame_size {
|
||||
let frame: Vec<f32> = self.residual_buf.drain(..frame_size).collect();
|
||||
let mut input = [0.0f32; 480];
|
||||
for (i, &s) in frame.iter().enumerate().take(frame_size) {
|
||||
input[i] = s * 32768.0;
|
||||
}
|
||||
denoise.process_frame(&mut output, &input);
|
||||
for i in 0..frame_size {
|
||||
if write_pos + i < samples.len() {
|
||||
samples[write_pos + i] = output[i] / 32768.0;
|
||||
}
|
||||
}
|
||||
write_pos += frame_size;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "sonora")]
|
||||
fn process_sonora(&mut self, samples: &mut [f32]) {
|
||||
let ns = match &mut self.sonora {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Sonora processes 10ms frames (480 samples at 48kHz)
|
||||
let frame_size = 480;
|
||||
let mut offset = 0;
|
||||
|
||||
while offset + frame_size <= samples.len() {
|
||||
let frame = &mut samples[offset..offset + frame_size];
|
||||
ns.process(frame);
|
||||
offset += frame_size;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
[package]
|
||||
name = "shared"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
@@ -1,112 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::types::*;
|
||||
|
||||
/// 配置管理器
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigManager {
|
||||
pub app: AppConfig,
|
||||
pub connections: Vec<SavedConnection>,
|
||||
pub recent_servers: Vec<RecentServer>,
|
||||
}
|
||||
|
||||
/// 保存的连接
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SavedConnection {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
pub nickname: String,
|
||||
pub server_password: Option<String>,
|
||||
pub channel: Option<String>,
|
||||
pub channel_password: Option<String>,
|
||||
pub default_token: Option<String>,
|
||||
pub auto_connect: bool,
|
||||
pub last_connected: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
/// 最近连接的服务器
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecentServer {
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
pub name: String,
|
||||
pub last_connected: chrono::DateTime<chrono::Utc>,
|
||||
pub connect_count: u32,
|
||||
}
|
||||
|
||||
impl ConfigManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
app: AppConfig::default(),
|
||||
connections: Vec::new(),
|
||||
recent_servers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(path: &PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let config: Self = toml::from_str(&content)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let content = toml::to_string_pretty(self)?;
|
||||
std::fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_connection(&mut self, connection: SavedConnection) {
|
||||
if let Some(existing) = self.connections.iter_mut().find(|c| c.id == connection.id) {
|
||||
*existing = connection;
|
||||
} else {
|
||||
self.connections.push(connection);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_connection(&mut self, id: &str) {
|
||||
self.connections.retain(|c| c.id != id);
|
||||
}
|
||||
|
||||
pub fn get_connection(&self, id: &str) -> Option<&SavedConnection> {
|
||||
self.connections.iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
pub fn add_recent_server(&mut self, address: &str, port: u16, name: &str) {
|
||||
let now = chrono::Utc::now();
|
||||
if let Some(existing) = self
|
||||
.recent_servers
|
||||
.iter_mut()
|
||||
.find(|s| s.address == address && s.port == port)
|
||||
{
|
||||
existing.last_connected = now;
|
||||
existing.connect_count += 1;
|
||||
existing.name = name.to_string();
|
||||
} else {
|
||||
self.recent_servers.push(RecentServer {
|
||||
address: address.to_string(),
|
||||
port,
|
||||
name: name.to_string(),
|
||||
last_connected: now,
|
||||
connect_count: 1,
|
||||
});
|
||||
}
|
||||
self.recent_servers
|
||||
.sort_by_key(|b| std::cmp::Reverse(b.last_connected));
|
||||
if self.recent_servers.len() > 20 {
|
||||
self.recent_servers.truncate(20);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_recent_servers(&self) -> &[RecentServer] {
|
||||
&self.recent_servers
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConfigManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// 应用错误
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AppError {
|
||||
#[error("连接错误: {0}")]
|
||||
Connection(String),
|
||||
|
||||
#[error("协议错误: {code} - {message}")]
|
||||
Protocol { code: u32, message: String },
|
||||
|
||||
#[error("网络错误: {0}")]
|
||||
Network(#[from] std::io::Error),
|
||||
|
||||
#[error("加密错误: {0}")]
|
||||
Crypto(String),
|
||||
|
||||
#[error("音频错误: {0}")]
|
||||
Audio(String),
|
||||
|
||||
#[error("数据库错误: {0}")]
|
||||
Database(String),
|
||||
|
||||
#[error("序列化错误: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
|
||||
#[error("配置错误: {0}")]
|
||||
Config(String),
|
||||
|
||||
#[error("身份错误: {0}")]
|
||||
Identity(String),
|
||||
|
||||
#[error("权限错误: {0}")]
|
||||
Permission(String),
|
||||
|
||||
#[error("超时错误: {0}")]
|
||||
Timeout(String),
|
||||
|
||||
#[error("未连接")]
|
||||
NotConnected,
|
||||
|
||||
#[error("已连接")]
|
||||
AlreadyConnected,
|
||||
|
||||
#[error("无效参数: {0}")]
|
||||
InvalidArgument(String),
|
||||
|
||||
#[error("不支持的操作: {0}")]
|
||||
Unsupported(String),
|
||||
|
||||
#[error("内部错误: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// 结果类型别名
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
|
||||
impl From<AppError> for String {
|
||||
fn from(err: AppError) -> Self {
|
||||
err.to_string()
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
use crate::types::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 应用事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum AppEvent {
|
||||
Connection(ConnectionEvent),
|
||||
Client(ClientEvent),
|
||||
Channel(ChannelEvent),
|
||||
Server(ServerEvent),
|
||||
Message(MessageEvent),
|
||||
Audio(AudioEvent),
|
||||
FileTransfer(FileTransferEvent),
|
||||
Error(ErrorEvent),
|
||||
}
|
||||
|
||||
/// 连接事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ConnectionEvent {
|
||||
Connecting {
|
||||
address: String,
|
||||
},
|
||||
Connected {
|
||||
server: ServerInfo,
|
||||
own_client: ClientId,
|
||||
},
|
||||
StateChanged {
|
||||
state: ConnectionState,
|
||||
},
|
||||
DisconnectedTemporarily {
|
||||
reason: String,
|
||||
},
|
||||
Disconnected {
|
||||
reason: String,
|
||||
},
|
||||
ConnectionFailed {
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 客户端事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ClientEvent {
|
||||
EnteredView {
|
||||
client: ClientInfo,
|
||||
reason: Reason,
|
||||
},
|
||||
LeftView {
|
||||
client_id: ClientId,
|
||||
reason: Reason,
|
||||
reason_message: Option<String>,
|
||||
},
|
||||
Updated {
|
||||
client_id: ClientId,
|
||||
changes: ClientChanges,
|
||||
},
|
||||
Moved {
|
||||
client_id: ClientId,
|
||||
from_channel: ChannelId,
|
||||
to_channel: ChannelId,
|
||||
reason: Reason,
|
||||
},
|
||||
StartedTalking {
|
||||
client_id: ClientId,
|
||||
},
|
||||
StoppedTalking {
|
||||
client_id: ClientId,
|
||||
},
|
||||
ServerGroupChanged {
|
||||
client_id: ClientId,
|
||||
group_id: ServerGroupId,
|
||||
added: bool,
|
||||
},
|
||||
ChannelGroupChanged {
|
||||
client_id: ClientId,
|
||||
group_id: ChannelGroupId,
|
||||
},
|
||||
}
|
||||
|
||||
/// 客户端变更
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClientChanges {
|
||||
pub name: Option<String>,
|
||||
pub input_muted: Option<bool>,
|
||||
pub output_muted: Option<bool>,
|
||||
pub output_only_muted: Option<bool>,
|
||||
pub input_hardware_enabled: Option<bool>,
|
||||
pub output_hardware_enabled: Option<bool>,
|
||||
pub talk_power_granted: Option<bool>,
|
||||
pub metadata: Option<String>,
|
||||
pub is_recording: Option<bool>,
|
||||
pub away_message: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub is_priority_speaker: Option<bool>,
|
||||
pub phonetic_name: Option<String>,
|
||||
pub is_channel_commander: Option<bool>,
|
||||
pub badges: Option<String>,
|
||||
}
|
||||
|
||||
/// 频道事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ChannelEvent {
|
||||
Created {
|
||||
channel: ChannelInfo,
|
||||
},
|
||||
Deleted {
|
||||
channel_id: ChannelId,
|
||||
},
|
||||
Updated {
|
||||
channel_id: ChannelId,
|
||||
changes: ChannelChanges,
|
||||
},
|
||||
Moved {
|
||||
channel_id: ChannelId,
|
||||
new_parent: ChannelId,
|
||||
new_order: ChannelId,
|
||||
},
|
||||
PasswordChanged {
|
||||
channel_id: ChannelId,
|
||||
},
|
||||
DescriptionChanged {
|
||||
channel_id: ChannelId,
|
||||
},
|
||||
Subscribed {
|
||||
channel_id: ChannelId,
|
||||
subscribed: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// 频道变更
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChannelChanges {
|
||||
pub name: Option<String>,
|
||||
pub topic: Option<String>,
|
||||
pub codec: Option<Codec>,
|
||||
pub codec_quality: Option<u8>,
|
||||
pub max_clients: Option<i32>,
|
||||
pub max_family_clients: Option<i32>,
|
||||
pub channel_type: Option<ChannelType>,
|
||||
pub needed_talk_power: Option<i32>,
|
||||
pub phonetic_name: Option<String>,
|
||||
pub icon_id: Option<IconId>,
|
||||
}
|
||||
|
||||
/// 服务器事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ServerEvent {
|
||||
Updated { changes: ServerChanges },
|
||||
ServerGroupList { groups: Vec<ServerGroupInfo> },
|
||||
ChannelGroupList { groups: Vec<ChannelGroupInfo> },
|
||||
}
|
||||
|
||||
/// 服务器变更
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerChanges {
|
||||
pub name: Option<String>,
|
||||
pub welcome_message: Option<String>,
|
||||
pub host_message: Option<String>,
|
||||
pub host_message_mode: Option<HostMessageMode>,
|
||||
pub max_clients: Option<u16>,
|
||||
}
|
||||
|
||||
/// 消息事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum MessageEvent {
|
||||
Received { message: ChatMessage },
|
||||
Sent { message: ChatMessage },
|
||||
Read { message_id: u64 },
|
||||
UnreadCountChanged { count: u32 },
|
||||
}
|
||||
|
||||
/// 音频设备
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioDevice {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
/// 音频事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum AudioEvent {
|
||||
InputDeviceChanged {
|
||||
device: Option<String>,
|
||||
},
|
||||
OutputDeviceChanged {
|
||||
device: Option<String>,
|
||||
},
|
||||
InputVolumeChanged {
|
||||
volume: f32,
|
||||
},
|
||||
OutputVolumeChanged {
|
||||
volume: f32,
|
||||
},
|
||||
InputMutedChanged {
|
||||
muted: bool,
|
||||
},
|
||||
OutputMutedChanged {
|
||||
muted: bool,
|
||||
},
|
||||
DeviceList {
|
||||
input_devices: Vec<AudioDevice>,
|
||||
output_devices: Vec<AudioDevice>,
|
||||
},
|
||||
InputLevel {
|
||||
level: f32,
|
||||
},
|
||||
OutputLevel {
|
||||
level: f32,
|
||||
},
|
||||
}
|
||||
|
||||
/// 文件传输事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum FileTransferEvent {
|
||||
Started {
|
||||
transfer_id: String,
|
||||
file_name: String,
|
||||
file_size: u64,
|
||||
is_upload: bool,
|
||||
},
|
||||
Progress {
|
||||
transfer_id: String,
|
||||
progress: f32,
|
||||
},
|
||||
Completed {
|
||||
transfer_id: String,
|
||||
},
|
||||
Failed {
|
||||
transfer_id: String,
|
||||
error: String,
|
||||
},
|
||||
Cancelled {
|
||||
transfer_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 错误事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ErrorEvent {
|
||||
Protocol { code: u32, message: String },
|
||||
Network { message: String },
|
||||
Audio { message: String },
|
||||
Database { message: String },
|
||||
Other { message: String },
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
pub mod config;
|
||||
pub mod errors;
|
||||
pub mod events;
|
||||
pub mod types;
|
||||
|
||||
pub use config::*;
|
||||
pub use errors::*;
|
||||
pub use events::*;
|
||||
pub use types::*;
|
||||
@@ -1,464 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
/// TeamSpeak 核心类型定义
|
||||
///
|
||||
/// 客户端 ID
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ClientId(pub u16);
|
||||
|
||||
/// 频道 ID
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ChannelId(pub u64);
|
||||
|
||||
/// 服务器组 ID
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ServerGroupId(pub u64);
|
||||
|
||||
/// 频道组 ID
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ChannelGroupId(pub u64);
|
||||
|
||||
/// 客户端数据库 ID
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ClientDbId(pub u64);
|
||||
|
||||
/// 用户唯一标识符
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct Uid(pub String);
|
||||
|
||||
/// 权限 ID
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct PermissionId(pub u32);
|
||||
|
||||
/// TeamSpeak permission catalog entry.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PermissionInfo {
|
||||
pub id: PermissionId,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Minimal channel row returned by ServerQuery `channellist`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ServerQueryChannel {
|
||||
pub id: ChannelId,
|
||||
pub parent_id: ChannelId,
|
||||
pub order: ChannelId,
|
||||
pub name: String,
|
||||
pub total_clients: u32,
|
||||
pub needed_subscribe_power: i32,
|
||||
}
|
||||
|
||||
/// Minimal client row returned by ServerQuery `clientlist`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ServerQueryClient {
|
||||
pub id: ClientId,
|
||||
pub channel_id: ChannelId,
|
||||
pub database_id: ClientDbId,
|
||||
pub nickname: String,
|
||||
pub client_type: ClientType,
|
||||
pub unique_identifier: String,
|
||||
}
|
||||
|
||||
/// Minimal server row returned by ServerQuery `serverinfo`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ServerQueryServerInfo {
|
||||
pub name: String,
|
||||
pub platform: String,
|
||||
pub version: String,
|
||||
pub max_clients: u16,
|
||||
pub clients_online: u16,
|
||||
pub channels_online: u64,
|
||||
pub uptime: u64,
|
||||
}
|
||||
|
||||
/// 图标 ID
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct IconId(pub i32);
|
||||
|
||||
/// 音频编解码器
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Codec {
|
||||
SpeexNarrowband,
|
||||
SpeexWideband,
|
||||
SpeexUltrawideband,
|
||||
CeltMono,
|
||||
OpusVoice,
|
||||
OpusMusic,
|
||||
}
|
||||
|
||||
impl Codec {
|
||||
pub fn sample_rate(&self) -> u32 {
|
||||
match self {
|
||||
Self::SpeexNarrowband => 8000,
|
||||
Self::SpeexWideband => 16000,
|
||||
Self::SpeexUltrawideband => 32000,
|
||||
Self::CeltMono | Self::OpusVoice | Self::OpusMusic => 48000,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn channels(&self) -> u16 {
|
||||
match self {
|
||||
Self::OpusMusic => 2,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Codec {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::SpeexNarrowband => write!(f, "Speex Narrowband"),
|
||||
Self::SpeexWideband => write!(f, "Speex Wideband"),
|
||||
Self::SpeexUltrawideband => write!(f, "Speex Ultrawideband"),
|
||||
Self::CeltMono => write!(f, "CELT Mono"),
|
||||
Self::OpusVoice => write!(f, "Opus Voice"),
|
||||
Self::OpusMusic => write!(f, "Opus Music"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 频道类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ChannelType {
|
||||
Permanent,
|
||||
SemiPermanent,
|
||||
Temporary,
|
||||
}
|
||||
|
||||
/// 客户端类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ClientType {
|
||||
Normal,
|
||||
Query { admin: bool },
|
||||
}
|
||||
|
||||
/// 文本消息目标模式
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TextMessageTargetMode {
|
||||
Unknown,
|
||||
Client,
|
||||
Channel,
|
||||
Server,
|
||||
}
|
||||
|
||||
/// 连接状态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConnectionState {
|
||||
Uninitialized,
|
||||
Connecting,
|
||||
IdentityLevelIncreasing,
|
||||
Connected,
|
||||
ChannelListFinished,
|
||||
DisconnectedTemporarily,
|
||||
Disconnected,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// 离开原因
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Reason {
|
||||
None,
|
||||
Moved,
|
||||
Subscription,
|
||||
LostConnection,
|
||||
KickChannel,
|
||||
KickServer,
|
||||
KickServerBan,
|
||||
Serverstop,
|
||||
Clientdisconnect,
|
||||
Channelupdate,
|
||||
Channeledit,
|
||||
ClientdisconnectServerShutdown,
|
||||
}
|
||||
|
||||
/// 服务器信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerInfo {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub platform: String,
|
||||
pub version: String,
|
||||
pub max_clients: u16,
|
||||
pub clients_online: u16,
|
||||
pub channels_online: u64,
|
||||
pub uptime: u64,
|
||||
pub codec_encryption_mode: CodecEncryptionMode,
|
||||
pub host_message: String,
|
||||
pub host_message_mode: HostMessageMode,
|
||||
pub welcome_message: String,
|
||||
pub default_server_group: ServerGroupId,
|
||||
pub default_channel_group: ChannelGroupId,
|
||||
}
|
||||
|
||||
/// 编解码器加密模式
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CodecEncryptionMode {
|
||||
PerChannel,
|
||||
ForcedOff,
|
||||
ForcedOn,
|
||||
}
|
||||
|
||||
/// 主机消息模式
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum HostMessageMode {
|
||||
None,
|
||||
Log,
|
||||
Modal,
|
||||
Modalquit,
|
||||
}
|
||||
|
||||
/// 频道信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChannelInfo {
|
||||
pub id: ChannelId,
|
||||
pub parent_id: ChannelId,
|
||||
pub name: String,
|
||||
pub topic: String,
|
||||
pub codec: Codec,
|
||||
pub codec_quality: u8,
|
||||
pub max_clients: i32,
|
||||
pub max_family_clients: i32,
|
||||
pub order: ChannelId,
|
||||
pub channel_type: ChannelType,
|
||||
pub is_default: bool,
|
||||
pub has_password: bool,
|
||||
pub codec_latency_factor: i32,
|
||||
pub is_unencrypted: bool,
|
||||
pub delete_delay: u32,
|
||||
pub needed_talk_power: i32,
|
||||
pub forced_silence: bool,
|
||||
pub phonetic_name: String,
|
||||
pub icon_id: IconId,
|
||||
pub is_private: bool,
|
||||
pub storage_quota: u32,
|
||||
pub subscribed: bool,
|
||||
}
|
||||
|
||||
/// 客户端信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClientInfo {
|
||||
pub id: ClientId,
|
||||
pub channel_id: ChannelId,
|
||||
pub uid: Uid,
|
||||
pub name: String,
|
||||
pub input_muted: bool,
|
||||
pub output_muted: bool,
|
||||
pub output_only_muted: bool,
|
||||
pub input_hardware_enabled: bool,
|
||||
pub output_hardware_enabled: bool,
|
||||
pub talk_power_granted: bool,
|
||||
pub metadata: String,
|
||||
pub is_recording: bool,
|
||||
pub database_id: ClientDbId,
|
||||
pub channel_group: ChannelGroupId,
|
||||
pub server_groups: Vec<ServerGroupId>,
|
||||
pub away_message: String,
|
||||
pub client_type: ClientType,
|
||||
pub avatar_hash: String,
|
||||
pub talk_power: i32,
|
||||
pub description: String,
|
||||
pub is_priority_speaker: bool,
|
||||
pub unread_messages: u32,
|
||||
pub phonetic_name: String,
|
||||
pub icon_id: IconId,
|
||||
pub is_channel_commander: bool,
|
||||
pub country_code: String,
|
||||
pub badges: String,
|
||||
}
|
||||
|
||||
/// 服务器组信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerGroupInfo {
|
||||
pub id: ServerGroupId,
|
||||
pub name: String,
|
||||
pub group_type: GroupType,
|
||||
pub icon_id: IconId,
|
||||
pub is_permanent: bool,
|
||||
pub sort_id: i32,
|
||||
pub naming_mode: GroupNamingMode,
|
||||
pub needed_modify_power: i32,
|
||||
pub needed_member_add_power: i32,
|
||||
pub needed_member_remove_power: i32,
|
||||
}
|
||||
|
||||
/// 频道组信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChannelGroupInfo {
|
||||
pub id: ChannelGroupId,
|
||||
pub name: String,
|
||||
pub group_type: GroupType,
|
||||
pub icon_id: IconId,
|
||||
pub is_permanent: bool,
|
||||
pub sort_id: i32,
|
||||
pub naming_mode: GroupNamingMode,
|
||||
pub needed_modify_power: i32,
|
||||
pub needed_member_add_power: i32,
|
||||
pub needed_member_remove_power: i32,
|
||||
}
|
||||
|
||||
/// 组类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum GroupType {
|
||||
Template,
|
||||
Regular,
|
||||
Query,
|
||||
}
|
||||
|
||||
/// 组命名模式
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum GroupNamingMode {
|
||||
None,
|
||||
Before,
|
||||
After,
|
||||
}
|
||||
|
||||
/// 连接配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectConfig {
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
pub nickname: String,
|
||||
pub server_password: Option<String>,
|
||||
pub channel: Option<String>,
|
||||
pub channel_password: Option<String>,
|
||||
pub default_token: Option<String>,
|
||||
pub identity: Option<IdentityConfig>,
|
||||
}
|
||||
|
||||
/// 身份配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IdentityConfig {
|
||||
pub private_key: String,
|
||||
pub counter: u64,
|
||||
pub max_counter: u64,
|
||||
}
|
||||
|
||||
/// 音频配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioConfig {
|
||||
pub input_device: Option<String>,
|
||||
pub output_device: Option<String>,
|
||||
pub input_volume: f32,
|
||||
pub output_volume: f32,
|
||||
pub vad_enabled: bool,
|
||||
pub vad_threshold: f32,
|
||||
pub ptt_enabled: bool,
|
||||
pub ptt_key: Option<String>,
|
||||
pub noise_suppression: bool,
|
||||
pub echo_cancellation: bool,
|
||||
}
|
||||
|
||||
impl Default for AudioConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
input_device: None,
|
||||
output_device: None,
|
||||
input_volume: 1.0,
|
||||
output_volume: 1.0,
|
||||
vad_enabled: true,
|
||||
vad_threshold: 0.5,
|
||||
ptt_enabled: false,
|
||||
ptt_key: None,
|
||||
noise_suppression: true,
|
||||
echo_cancellation: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 热键动作
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum HotkeyAction {
|
||||
InputMuteToggle,
|
||||
OutputMuteToggle,
|
||||
AwayToggle,
|
||||
PushToTalk,
|
||||
ChannelCommanderToggle,
|
||||
}
|
||||
|
||||
/// 热键配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HotkeyConfig {
|
||||
pub action: HotkeyAction,
|
||||
pub key: String,
|
||||
pub modifiers: Vec<String>,
|
||||
}
|
||||
|
||||
/// 应用配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub nickname: String,
|
||||
pub audio: AudioConfig,
|
||||
pub hotkeys: Vec<HotkeyConfig>,
|
||||
pub theme: String,
|
||||
pub language: String,
|
||||
pub minimize_to_tray: bool,
|
||||
pub start_minimized: bool,
|
||||
pub auto_reconnect: bool,
|
||||
pub reconnect_delay: u32,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
nickname: "User".to_string(),
|
||||
audio: AudioConfig::default(),
|
||||
hotkeys: Vec::new(),
|
||||
theme: "dark".to_string(),
|
||||
language: "en".to_string(),
|
||||
minimize_to_tray: true,
|
||||
start_minimized: false,
|
||||
auto_reconnect: true,
|
||||
reconnect_delay: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 聊天消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub id: u64,
|
||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||
pub invoker: ClientId,
|
||||
pub invoker_name: String,
|
||||
pub invoker_uid: Uid,
|
||||
pub target: MessageTarget,
|
||||
pub message: String,
|
||||
pub is_read: bool,
|
||||
}
|
||||
|
||||
/// 消息目标
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum MessageTarget {
|
||||
Server,
|
||||
Channel(ChannelId),
|
||||
Client(ClientId),
|
||||
}
|
||||
|
||||
/// 文件信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileInfo {
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub is_directory: bool,
|
||||
}
|
||||
|
||||
/// 文件传输状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum FileTransferStatus {
|
||||
Pending,
|
||||
InProgress { progress: f32 },
|
||||
Completed,
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// 文件传输请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileTransferRequest {
|
||||
pub channel_id: ChannelId,
|
||||
pub path: String,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ReTeamSpeak</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "re-teamspeak-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,449 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
interface Identity {
|
||||
id: string;
|
||||
name: string;
|
||||
counter: number;
|
||||
max_counter: number;
|
||||
}
|
||||
|
||||
interface Bookmark {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
nickname: string | null;
|
||||
auto_connect: boolean;
|
||||
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;
|
||||
total_clients: number;
|
||||
}
|
||||
|
||||
interface ServerQueryClient {
|
||||
id: number;
|
||||
database_id: number;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
interface ServerQueryServerInfo {
|
||||
name: string;
|
||||
platform: string;
|
||||
version: string;
|
||||
max_clients: number;
|
||||
clients_online: number;
|
||||
}
|
||||
|
||||
interface ServerQuerySnapshot {
|
||||
server: ServerQueryServerInfo | null;
|
||||
channels: ServerQueryChannel[];
|
||||
clients: ServerQueryClient[];
|
||||
permissions: unknown[];
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [identities, setIdentities] = useState<Identity[]>([]);
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
|
||||
const [selectedBookmark, setSelectedBookmark] = useState<Bookmark | null>(null);
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||
const [channels, setChannels] = useState<ChannelEntry[]>([]);
|
||||
const [clients, setClients] = useState<ClientEntry[]>([]);
|
||||
const [messages, setMessages] = useState<TextMessage[]>([]);
|
||||
const [queryPort, setQueryPort] = useState(10011);
|
||||
const [querySnapshot, setQuerySnapshot] = useState<ServerQuerySnapshot | null>(null);
|
||||
const [queryLoading, setQueryLoading] = useState(false);
|
||||
const [queryError, setQueryError] = useState<string | null>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadIdentities();
|
||||
loadBookmarks();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setQuerySnapshot(null);
|
||||
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<SessionEvent[]>('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<Identity[]>('get_identities');
|
||||
setIdentities(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to load identities:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBookmarks() {
|
||||
try {
|
||||
const result = await invoke<Bookmark[]>('get_bookmarks');
|
||||
setBookmarks(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to load bookmarks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
if (!selectedBookmark) return;
|
||||
|
||||
try {
|
||||
await invoke('connect', {
|
||||
address: selectedBookmark.address,
|
||||
port: selectedBookmark.port,
|
||||
nickname: nickname || selectedBookmark.nickname || 'User',
|
||||
password: password || null,
|
||||
});
|
||||
setConnected(true);
|
||||
setMessages([]);
|
||||
} catch (error) {
|
||||
console.error('Failed to connect:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
try {
|
||||
await invoke('disconnect');
|
||||
setConnected(false);
|
||||
setServerInfo(null);
|
||||
setChannels([]);
|
||||
setClients([]);
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoadServerQuery() {
|
||||
if (!selectedBookmark || queryLoading) return;
|
||||
|
||||
setQueryLoading(true);
|
||||
setQueryError(null);
|
||||
try {
|
||||
const snapshot = await invoke<ServerQuerySnapshot>('server_query_snapshot', {
|
||||
request: {
|
||||
address: selectedBookmark.address,
|
||||
port: queryPort,
|
||||
username: null,
|
||||
password: null,
|
||||
virtual_server_id: null,
|
||||
include_permissions: false,
|
||||
},
|
||||
});
|
||||
setQuerySnapshot(snapshot);
|
||||
} catch (error) {
|
||||
setQueryError(String(error));
|
||||
setQuerySnapshot(null);
|
||||
} finally {
|
||||
setQueryLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>ReTeamSpeak</h1>
|
||||
<div className="connection-status">
|
||||
{connected ? (
|
||||
<span className="status connected">
|
||||
已连接 {serverInfo ? `— ${serverInfo.name}` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="status disconnected">未连接</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
<aside className="sidebar">
|
||||
<section className="bookmarks-section">
|
||||
<h2>服务器书签</h2>
|
||||
<div className="identity-summary">身份数量:{identities.length}</div>
|
||||
<ul className="bookmark-list">
|
||||
{bookmarks.map((bookmark) => (
|
||||
<li
|
||||
key={bookmark.id}
|
||||
className={`bookmark-item ${selectedBookmark?.id === bookmark.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedBookmark(bookmark)}
|
||||
>
|
||||
<span className="bookmark-name">{bookmark.name}</span>
|
||||
<span className="bookmark-address">{bookmark.address}:{bookmark.port}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div className="content">
|
||||
{selectedBookmark ? (
|
||||
<div className="server-panel">
|
||||
<div className="connect-form">
|
||||
<h2>连接到 {selectedBookmark.name}</h2>
|
||||
<div className="form-group">
|
||||
<label>服务器地址</label>
|
||||
<input
|
||||
type="text"
|
||||
value={`${selectedBookmark.address}:${selectedBookmark.port}`}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>昵称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
placeholder={selectedBookmark.nickname || '请输入昵称'}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="可选"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
{connected ? (
|
||||
<button className="disconnect-btn" onClick={handleDisconnect}>
|
||||
断开连接
|
||||
</button>
|
||||
) : (
|
||||
<button className="connect-btn" onClick={handleConnect}>
|
||||
连接
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connected && (
|
||||
<section className="session-panel">
|
||||
<div className="query-grid">
|
||||
<div className="query-card">
|
||||
<h3>服务器</h3>
|
||||
{serverInfo ? (
|
||||
<>
|
||||
<p><strong>{serverInfo.name}</strong></p>
|
||||
<p>{serverInfo.platform} / {serverInfo.version}</p>
|
||||
<p>{serverInfo.clients_online} / {serverInfo.max_clients} 在线</p>
|
||||
</>
|
||||
) : (
|
||||
<p>等待服务器信息...</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="query-card">
|
||||
<h3>频道 ({channels.length})</h3>
|
||||
<ul className="query-list">
|
||||
{channels.map((ch) => (
|
||||
<li key={ch.cid}>
|
||||
<span>{ch.channel_name}</span>
|
||||
<small>{ch.total_clients} 人</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="query-card">
|
||||
<h3>客户端 ({clients.length})</h3>
|
||||
<ul className="query-list">
|
||||
{clients.map((c) => (
|
||||
<li key={c.clid}>
|
||||
<span>{c.client_nickname}</span>
|
||||
<small>#{c.clid}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="messages-panel">
|
||||
<h3>消息 ({messages.length})</h3>
|
||||
<ul className="message-list">
|
||||
{messages.map((msg, i) => (
|
||||
<li key={i}>
|
||||
<strong>{msg.invoker_name}:</strong> {msg.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="query-panel">
|
||||
<div className="query-header">
|
||||
<div>
|
||||
<h2>ServerQuery 快照</h2>
|
||||
<p>读取公开 ServerQuery 信息,默认端口通常是 10011。</p>
|
||||
</div>
|
||||
<div className="query-actions">
|
||||
<input
|
||||
type="number"
|
||||
value={queryPort}
|
||||
min={1}
|
||||
max={65535}
|
||||
onChange={(e) => setQueryPort(Number(e.target.value))}
|
||||
aria-label="ServerQuery port"
|
||||
/>
|
||||
<button className="connect-btn" onClick={handleLoadServerQuery} disabled={queryLoading}>
|
||||
{queryLoading ? '读取中...' : '读取快照'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{queryError && <div className="query-error">{queryError}</div>}
|
||||
|
||||
{querySnapshot && (
|
||||
<div className="query-grid">
|
||||
<div className="query-card">
|
||||
<h3>{querySnapshot.server?.name || '服务器'}</h3>
|
||||
<p>{querySnapshot.server?.platform || '未知平台'}</p>
|
||||
<p>{querySnapshot.server?.version || '未知版本'}</p>
|
||||
<strong>
|
||||
{querySnapshot.server?.clients_online ?? querySnapshot.clients.length}/
|
||||
{querySnapshot.server?.max_clients ?? '-'} 在线
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<div className="query-card">
|
||||
<h3>频道</h3>
|
||||
<ul className="query-list">
|
||||
{querySnapshot.channels.map((channel) => (
|
||||
<li key={channel.id}>
|
||||
<span>{channel.name}</span>
|
||||
<small>{channel.total_clients} 人</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="query-card">
|
||||
<h3>客户端</h3>
|
||||
<ul className="query-list">
|
||||
{querySnapshot.clients.map((client) => (
|
||||
<li key={client.id}>
|
||||
<span>{client.nickname}</span>
|
||||
<small>#{client.id}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="welcome">
|
||||
<h2>欢迎使用 ReTeamSpeak</h2>
|
||||
<p>请从左侧选择一个服务器书签进行连接</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -1,351 +0,0 @@
|
||||
:root {
|
||||
--primary-color: #2196f3;
|
||||
--primary-dark: #1976d2;
|
||||
--secondary-color: #ff9800;
|
||||
--background-color: #f5f5f5;
|
||||
--surface-color: #ffffff;
|
||||
--text-color: #333333;
|
||||
--text-secondary: #666666;
|
||||
--border-color: #e0e0e0;
|
||||
--success-color: #4caf50;
|
||||
--error-color: #f44336;
|
||||
--warning-color: #ff9800;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
background-color: var(--surface-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status.connected {
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status.disconnected {
|
||||
background-color: var(--text-secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
background-color: var(--surface-color);
|
||||
border-right: 1px solid var(--border-color);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bookmarks-section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.bookmarks-section h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.identity-summary {
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bookmark-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.bookmark-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.bookmark-item:hover {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.bookmark-item.selected {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.bookmark-item.selected .bookmark-address {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.bookmark-name {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.bookmark-address {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.server-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 400px) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.connect-form {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.connect-form h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.connect-btn,
|
||||
.disconnect-btn {
|
||||
padding: 10px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.connect-btn {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.connect-btn:hover {
|
||||
background-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.connect-btn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.disconnect-btn {
|
||||
background-color: var(--error-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.disconnect-btn:hover {
|
||||
background-color: #d32f2f;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcome h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.welcome p {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.query-panel {
|
||||
padding: 20px;
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.query-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.query-header h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.query-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.query-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.query-actions input {
|
||||
width: 96px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.query-error {
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--error-color);
|
||||
background-color: #ffebee;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.query-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.query-card {
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
background-color: var(--background-color);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.query-card h3 {
|
||||
margin-bottom: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.query-card p,
|
||||
.query-card small {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.query-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.query-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.query-list span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.server-panel,
|
||||
.query-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.query-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
envPrefix: ['VITE_', 'TAURI_'],
|
||||
build: {
|
||||
target: process.env.TAURI_PLATFORM === 'windows' ? 'chrome105' : 'safari13',
|
||||
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
|
||||
sourcemap: !!process.env.TAURI_DEBUG,
|
||||
},
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
[package]
|
||||
name = "re-teamspeak"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
shared = { path = "../../shared" }
|
||||
tscore = { path = "../../tscore" }
|
||||
tsaudio = { path = "../../tsaudio" }
|
||||
tsdb = { path = "../../tsdb" }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = "2"
|
||||
|
||||
[lib]
|
||||
name = "re_teamspeak_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "默认权限配置",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"dialog:allow-message",
|
||||
"dialog:allow-ask",
|
||||
"dialog:allow-confirm",
|
||||
"http:default",
|
||||
"http:allow-fetch",
|
||||
"notification:default",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify",
|
||||
"opener:default",
|
||||
"opener:allow-open-url",
|
||||
"opener:allow-open-path",
|
||||
"shell:default",
|
||||
"shell:allow-open"
|
||||
]
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.reteamspeak.app">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="ReTeamSpeak"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ReTeamSpeak">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,40 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>ReTeamSpeak</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>ReTeamSpeak needs access to your microphone for voice communication.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 974 B |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 903 B |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 14 KiB |
@@ -1,383 +0,0 @@
|
||||
//! Tauri 命令
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::{PermissionInfo, ServerQueryChannel, ServerQueryClient, ServerQueryServerInfo};
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
use tauri::State;
|
||||
use tokio::net::lookup_host;
|
||||
use tscore::{ClientConfig, IdentityKey, QueryClient, Session, SessionEvent};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct IdentityInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub counter: u64,
|
||||
pub max_counter: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BookmarkInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
pub nickname: Option<String>,
|
||||
pub auto_connect: bool,
|
||||
pub last_connected: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct MessageInfo {
|
||||
pub id: i64,
|
||||
pub invoker_name: String,
|
||||
pub message: String,
|
||||
pub timestamp: String,
|
||||
pub is_read: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ServerQuerySnapshotRequest {
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub virtual_server_id: Option<u64>,
|
||||
pub include_permissions: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ServerQuerySnapshot {
|
||||
pub server: Option<ServerQueryServerInfo>,
|
||||
pub channels: Vec<ServerQueryChannel>,
|
||||
pub clients: Vec<ServerQueryClient>,
|
||||
pub permissions: Vec<PermissionInfo>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_identities(state: State<'_, AppState>) -> Result<Vec<IdentityInfo>, String> {
|
||||
let db = state.db.lock().await;
|
||||
let identities = db.get_all_identities().map_err(|e| e.to_string())?;
|
||||
Ok(identities
|
||||
.into_iter()
|
||||
.map(|i| IdentityInfo {
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
counter: i.counter,
|
||||
max_counter: i.max_counter,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_identity(
|
||||
state: State<'_, AppState>,
|
||||
name: String,
|
||||
) -> Result<IdentityInfo, String> {
|
||||
let private_key = IdentityKey::generate().private_key_base64();
|
||||
let db = state.db.lock().await;
|
||||
let identity = db
|
||||
.create_identity(&name, &private_key)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(IdentityInfo {
|
||||
id: identity.id,
|
||||
name: identity.name,
|
||||
counter: identity.counter,
|
||||
max_counter: identity.max_counter,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_identity(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
let db = state.db.lock().await;
|
||||
db.delete_identity(&id).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_bookmarks(state: State<'_, AppState>) -> Result<Vec<BookmarkInfo>, String> {
|
||||
let db = state.db.lock().await;
|
||||
let bookmarks = db.get_all_bookmarks().map_err(|e| e.to_string())?;
|
||||
Ok(bookmarks
|
||||
.into_iter()
|
||||
.map(|b| BookmarkInfo {
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
address: b.address,
|
||||
port: b.port,
|
||||
nickname: b.nickname,
|
||||
auto_connect: b.auto_connect,
|
||||
last_connected: b.last_connected,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_bookmark(
|
||||
state: State<'_, AppState>,
|
||||
name: String,
|
||||
address: String,
|
||||
port: u16,
|
||||
nickname: Option<String>,
|
||||
) -> Result<BookmarkInfo, String> {
|
||||
let db = state.db.lock().await;
|
||||
let bookmark = db
|
||||
.create_bookmark(&name, &address, port, nickname.as_deref())
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(BookmarkInfo {
|
||||
id: bookmark.id,
|
||||
name: bookmark.name,
|
||||
address: bookmark.address,
|
||||
port: bookmark.port,
|
||||
nickname: bookmark.nickname,
|
||||
auto_connect: bookmark.auto_connect,
|
||||
last_connected: bookmark.last_connected,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_bookmark(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
let db = state.db.lock().await;
|
||||
db.delete_bookmark(&id).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn connect(
|
||||
state: State<'_, AppState>,
|
||||
address: String,
|
||||
port: u16,
|
||||
nickname: String,
|
||||
password: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let socket_addr = resolve_server_address(&address, port).await?;
|
||||
let identity = {
|
||||
let db = state.db.lock().await;
|
||||
db.get_all_identities()
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|identity| IdentityKey::from_private_key_base64(&identity.private_key).ok())
|
||||
.unwrap_or_else(IdentityKey::generate)
|
||||
};
|
||||
|
||||
let mut config = ClientConfig::new(socket_addr, nickname.clone());
|
||||
config.server_password = password;
|
||||
config.identity = identity;
|
||||
|
||||
let (mut session, handle) = Session::connect(config, Duration::from_secs(15))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let client_id = session.client_id();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = session.run().await {
|
||||
tracing::error!("session error: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
let mut session_guard = state.session_handle.lock().await;
|
||||
*session_guard = Some(handle);
|
||||
}
|
||||
|
||||
let mut conn_state = state.connection_state.lock().await;
|
||||
conn_state.connected = true;
|
||||
conn_state.server_address = Some(address);
|
||||
conn_state.server_port = Some(port);
|
||||
conn_state.nickname = Some(nickname);
|
||||
conn_state.client_id = client_id;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let handle = {
|
||||
let mut session_guard = state.session_handle.lock().await;
|
||||
session_guard.take()
|
||||
};
|
||||
|
||||
if let Some(handle) = handle {
|
||||
handle.disconnect().await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let mut conn_state = state.connection_state.lock().await;
|
||||
*conn_state = crate::state::ConnectionState::new();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn join_channel(
|
||||
state: State<'_, AppState>,
|
||||
channel_id: u64,
|
||||
password: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let session_guard = state.session_handle.lock().await;
|
||||
let handle = session_guard.as_ref().ok_or("not connected")?;
|
||||
handle
|
||||
.join_channel(channel_id, password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_channel_message(
|
||||
state: State<'_, AppState>,
|
||||
message: String,
|
||||
) -> Result<(), String> {
|
||||
let session_guard = state.session_handle.lock().await;
|
||||
let handle = session_guard.as_ref().ok_or("not connected")?;
|
||||
handle
|
||||
.send_channel_message(&message)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_server_message(
|
||||
state: State<'_, AppState>,
|
||||
message: String,
|
||||
) -> Result<(), String> {
|
||||
let session_guard = state.session_handle.lock().await;
|
||||
let handle = session_guard.as_ref().ok_or("not connected")?;
|
||||
handle
|
||||
.send_server_message(&message)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_private_message(
|
||||
state: State<'_, AppState>,
|
||||
client_id: u64,
|
||||
message: String,
|
||||
) -> Result<(), String> {
|
||||
let session_guard = state.session_handle.lock().await;
|
||||
let handle = session_guard.as_ref().ok_or("not connected")?;
|
||||
handle
|
||||
.send_private_message(client_id, &message)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_raw_command(state: State<'_, AppState>, command: String) -> Result<(), String> {
|
||||
let session_guard = state.session_handle.lock().await;
|
||||
let handle = session_guard.as_ref().ok_or("not connected")?;
|
||||
handle
|
||||
.send_command_str(&command)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn poll_events(state: State<'_, AppState>) -> Result<Vec<SessionEvent>, 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>,
|
||||
server_address: String,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
let db = state.db.lock().await;
|
||||
let messages = db
|
||||
.get_server_messages(&server_address, limit, offset)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(messages
|
||||
.into_iter()
|
||||
.map(|m| MessageInfo {
|
||||
id: m.id,
|
||||
invoker_name: m.invoker_name,
|
||||
message: m.message,
|
||||
timestamp: m.timestamp,
|
||||
is_read: m.is_read,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn server_query_snapshot(
|
||||
request: ServerQuerySnapshotRequest,
|
||||
) -> Result<ServerQuerySnapshot, String> {
|
||||
let socket_addr = resolve_server_address(&request.address, request.port).await?;
|
||||
let mut client = QueryClient::connect(socket_addr)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
client.set_read_timeout(Duration::from_secs(5));
|
||||
|
||||
if let (Some(username), Some(password)) =
|
||||
(request.username.as_deref(), request.password.as_deref())
|
||||
{
|
||||
client
|
||||
.login(username, password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
if let Some(server_id) = request.virtual_server_id {
|
||||
client
|
||||
.use_server(server_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let server = client.server_info().await.map_err(|e| e.to_string())?;
|
||||
let channels = client.channel_list().await.map_err(|e| e.to_string())?;
|
||||
let clients = client.client_list().await.map_err(|e| e.to_string())?;
|
||||
let permissions = if request.include_permissions {
|
||||
client.permission_list().await.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(ServerQuerySnapshot {
|
||||
server,
|
||||
channels,
|
||||
clients,
|
||||
permissions,
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_server_address(address: &str, port: u16) -> Result<SocketAddr, String> {
|
||||
if let Ok(socket_addr) = format!("{}:{}", address, port).parse::<SocketAddr>() {
|
||||
return Ok(socket_addr);
|
||||
}
|
||||
|
||||
lookup_host((address, port))
|
||||
.await
|
||||
.map_err(|e| format!("无法解析服务器地址: {e}"))?
|
||||
.next()
|
||||
.ok_or_else(|| "无法解析服务器地址".to_string())
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
//! ReTeamSpeak Tauri 应用
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
mod commands;
|
||||
mod state;
|
||||
|
||||
pub struct AppState {
|
||||
pub db: tokio::sync::Mutex<tsdb::DatabaseManager>,
|
||||
pub connection_state: tokio::sync::Mutex<state::ConnectionState>,
|
||||
pub session_handle: tokio::sync::Mutex<Option<tscore::SessionHandle>>,
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.setup(|app| {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let app_dir = app.path().app_data_dir().expect("无法获取应用数据目录");
|
||||
std::fs::create_dir_all(&app_dir).expect("无法创建应用数据目录");
|
||||
|
||||
let db_path = app_dir.join("re-teamspeak.db");
|
||||
let db =
|
||||
tsdb::DatabaseManager::new(db_path.to_str().unwrap()).expect("无法初始化数据库");
|
||||
|
||||
let state = AppState {
|
||||
db: tokio::sync::Mutex::new(db),
|
||||
connection_state: tokio::sync::Mutex::new(state::ConnectionState::new()),
|
||||
session_handle: tokio::sync::Mutex::new(None),
|
||||
};
|
||||
app.manage(state);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_identities,
|
||||
commands::create_identity,
|
||||
commands::delete_identity,
|
||||
commands::get_bookmarks,
|
||||
commands::create_bookmark,
|
||||
commands::delete_bookmark,
|
||||
commands::connect,
|
||||
commands::disconnect,
|
||||
commands::join_channel,
|
||||
commands::send_channel_message,
|
||||
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,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("运行应用时出错");
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
re_teamspeak_lib::run();
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
//! 应用状态管理
|
||||
|
||||
/// 连接状态
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnectionState {
|
||||
pub connected: bool,
|
||||
pub server_address: Option<String>,
|
||||
pub server_port: Option<u16>,
|
||||
pub client_id: Option<u16>,
|
||||
pub nickname: Option<String>,
|
||||
}
|
||||
|
||||
impl ConnectionState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connected: false,
|
||||
server_address: None,
|
||||
server_port: None,
|
||||
client_id: None,
|
||||
nickname: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConnectionState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/schema/master/tauri-conf-v2-schema.json",
|
||||
"productName": "ReTeamSpeak",
|
||||
"version": "1.0.0",
|
||||
"identifier": "com.reteamspeak.app",
|
||||
"build": {
|
||||
"frontendDist": "../frontend/dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "cd ../frontend && npm run dev",
|
||||
"beforeBuildCommand": "cd ../frontend && npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "ReTeamSpeak",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": false,
|
||||
"targets": "all"
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
[package]
|
||||
name = "tsaudio"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "TeamSpeak 音频引擎"
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
opus = { workspace = true, optional = true }
|
||||
cpal = { workspace = true, optional = true }
|
||||
rubato = { version = "0.14", optional = true }
|
||||
crossbeam-channel = "0.5"
|
||||
|
||||
shared = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
full = ["cpal", "opus", "rubato"]
|
||||
@@ -1,194 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::{AudioFrame, AudioResult};
|
||||
|
||||
pub struct JitterBuffer {
|
||||
frames: BTreeMap<u16, BufferedFrame>,
|
||||
next_output_seq: u16,
|
||||
capacity: usize,
|
||||
target_delay_ms: u32,
|
||||
last_output: Option<Instant>,
|
||||
output_interval: Duration,
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
struct BufferedFrame {
|
||||
frame: AudioFrame,
|
||||
#[allow(dead_code)]
|
||||
received_at: Instant,
|
||||
}
|
||||
|
||||
impl JitterBuffer {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
frames: BTreeMap::new(),
|
||||
next_output_seq: 0,
|
||||
capacity,
|
||||
target_delay_ms: 60,
|
||||
last_output: None,
|
||||
output_interval: Duration::from_millis(20),
|
||||
initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_target_delay(capacity: usize, target_delay_ms: u32) -> Self {
|
||||
Self {
|
||||
target_delay_ms,
|
||||
..Self::new(capacity)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, frame: AudioFrame) -> AudioResult<()> {
|
||||
if self.frames.len() >= self.capacity {
|
||||
self.evict_oldest();
|
||||
}
|
||||
|
||||
let seq = frame.sequence;
|
||||
|
||||
if !self.initialized {
|
||||
self.next_output_seq = seq;
|
||||
self.initialized = true;
|
||||
}
|
||||
|
||||
self.frames.insert(
|
||||
seq,
|
||||
BufferedFrame {
|
||||
frame,
|
||||
received_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn pop(&mut self) -> Option<AudioFrame> {
|
||||
let now = Instant::now();
|
||||
|
||||
if let Some(last) = self.last_output {
|
||||
if now.duration_since(last) < self.output_interval {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(frame) = self.frames.remove(&self.next_output_seq) {
|
||||
self.next_output_seq = self.next_output_seq.wrapping_add(1);
|
||||
self.last_output = Some(now);
|
||||
return Some(frame.frame);
|
||||
}
|
||||
|
||||
if !self.frames.is_empty() {
|
||||
if let Some((&seq, _)) = self.frames.iter().next() {
|
||||
let frame = self.frames.remove(&seq).unwrap();
|
||||
self.next_output_seq = seq.wrapping_add(1);
|
||||
self.last_output = Some(now);
|
||||
return Some(frame.frame);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.frames.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.frames.is_empty()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.frames.clear();
|
||||
self.initialized = false;
|
||||
self.last_output = None;
|
||||
}
|
||||
|
||||
pub fn set_target_delay(&mut self, ms: u32) {
|
||||
self.target_delay_ms = ms;
|
||||
}
|
||||
|
||||
pub fn buffered_ms(&self) -> u32 {
|
||||
(self.frames.len() as u32) * 20
|
||||
}
|
||||
|
||||
fn evict_oldest(&mut self) {
|
||||
if let Some((&seq, _)) = self.frames.iter().next() {
|
||||
self.frames.remove(&seq);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_frame(seq: u16, data: Vec<f32>) -> AudioFrame {
|
||||
AudioFrame {
|
||||
sequence: seq,
|
||||
codec: 4,
|
||||
samples: data,
|
||||
sample_rate: 48000,
|
||||
channels: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jitter_buffer_basic() {
|
||||
let mut jb = JitterBuffer::new(100);
|
||||
|
||||
jb.push(make_frame(0, vec![1.0])).unwrap();
|
||||
jb.push(make_frame(1, vec![2.0])).unwrap();
|
||||
jb.push(make_frame(2, vec![3.0])).unwrap();
|
||||
|
||||
jb.last_output = Some(Instant::now() - Duration::from_millis(25));
|
||||
let frame = jb.pop().unwrap();
|
||||
assert_eq!(frame.sequence, 0);
|
||||
assert_eq!(frame.samples, vec![1.0]);
|
||||
|
||||
jb.last_output = Some(Instant::now() - Duration::from_millis(25));
|
||||
let frame = jb.pop().unwrap();
|
||||
assert_eq!(frame.sequence, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jitter_buffer_reorder() {
|
||||
let mut jb = JitterBuffer::new(100);
|
||||
|
||||
jb.push(make_frame(2, vec![3.0])).unwrap();
|
||||
jb.push(make_frame(0, vec![1.0])).unwrap();
|
||||
jb.push(make_frame(1, vec![2.0])).unwrap();
|
||||
|
||||
jb.last_output = Some(Instant::now() - Duration::from_millis(25));
|
||||
let frame = jb.pop().unwrap();
|
||||
assert_eq!(frame.sequence, 2);
|
||||
assert_eq!(frame.samples, vec![3.0]);
|
||||
|
||||
jb.last_output = Some(Instant::now() - Duration::from_millis(25));
|
||||
let frame = jb.pop().unwrap();
|
||||
assert_eq!(frame.sequence, 0);
|
||||
assert_eq!(frame.samples, vec![1.0]);
|
||||
|
||||
jb.last_output = Some(Instant::now() - Duration::from_millis(25));
|
||||
let frame = jb.pop().unwrap();
|
||||
assert_eq!(frame.sequence, 1);
|
||||
assert_eq!(frame.samples, vec![2.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jitter_buffer_empty() {
|
||||
let mut jb = JitterBuffer::new(100);
|
||||
assert!(jb.pop().is_none());
|
||||
assert!(jb.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jitter_buffer_clear() {
|
||||
let mut jb = JitterBuffer::new(100);
|
||||
jb.push(make_frame(0, vec![1.0])).unwrap();
|
||||
jb.push(make_frame(1, vec![2.0])).unwrap();
|
||||
|
||||
jb.clear();
|
||||
assert!(jb.is_empty());
|
||||
assert!(!jb.initialized);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
//! 音频采集
|
||||
|
||||
use super::{AudioConfig, AudioError, AudioFrame, AudioResult};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct AudioCapture {
|
||||
config: AudioConfig,
|
||||
}
|
||||
|
||||
impl AudioCapture {
|
||||
pub fn new(config: AudioConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
pub async fn start(&mut self) -> AudioResult<()> {
|
||||
#[cfg(feature = "cpal")]
|
||||
{
|
||||
// TODO: cpal 实现
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop(&mut self) -> AudioResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn capture(&mut self) -> AudioResult<AudioFrame> {
|
||||
Err(AudioError::Device("未实现".to_string()))
|
||||
}
|
||||
|
||||
pub fn list_devices() -> AudioResult<Vec<String>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
use super::{AudioError, AudioResult};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct OpusEncoder {
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
#[cfg(feature = "opus")]
|
||||
encoder: opus::Encoder,
|
||||
}
|
||||
|
||||
impl OpusEncoder {
|
||||
pub fn new(sample_rate: u32, channels: u16) -> AudioResult<Self> {
|
||||
#[cfg(feature = "opus")]
|
||||
{
|
||||
let ch = match channels {
|
||||
1 => opus::Channels::Mono,
|
||||
2 => opus::Channels::Stereo,
|
||||
_ => return Err(AudioError::Codec("unsupported channel count".to_string())),
|
||||
};
|
||||
let sr = match sample_rate {
|
||||
8000 => opus::SampleRate::Hz8000,
|
||||
12000 => opus::SampleRate::Hz12000,
|
||||
16000 => opus::SampleRate::Hz16000,
|
||||
24000 => opus::SampleRate::Hz24000,
|
||||
48000 => opus::SampleRate::Hz48000,
|
||||
_ => return Err(AudioError::Codec("unsupported sample rate".to_string())),
|
||||
};
|
||||
let encoder = opus::Encoder::new(sr, ch, opus::Application::Voip)
|
||||
.map_err(|e| AudioError::Codec(format!("opus encoder init: {e}")))?;
|
||||
Ok(Self {
|
||||
sample_rate,
|
||||
channels,
|
||||
encoder,
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "opus"))]
|
||||
{
|
||||
Ok(Self {
|
||||
sample_rate,
|
||||
channels,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode(&mut self, samples: &[f32]) -> AudioResult<Vec<u8>> {
|
||||
#[cfg(feature = "opus")]
|
||||
{
|
||||
let mut output = vec![0u8; 4000];
|
||||
let len = self
|
||||
.encoder
|
||||
.encode_float(samples, &mut output)
|
||||
.map_err(|e| AudioError::Codec(format!("opus encode: {e}")))?;
|
||||
output.truncate(len);
|
||||
Ok(output)
|
||||
}
|
||||
#[cfg(not(feature = "opus"))]
|
||||
{
|
||||
let _ = samples;
|
||||
Err(AudioError::Codec("opus feature not enabled".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct OpusDecoder {
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
#[cfg(feature = "opus")]
|
||||
decoder: opus::Decoder,
|
||||
}
|
||||
|
||||
impl OpusDecoder {
|
||||
pub fn new(sample_rate: u32, channels: u16) -> AudioResult<Self> {
|
||||
#[cfg(feature = "opus")]
|
||||
{
|
||||
let ch = match channels {
|
||||
1 => opus::Channels::Mono,
|
||||
2 => opus::Channels::Stereo,
|
||||
_ => return Err(AudioError::Codec("unsupported channel count".to_string())),
|
||||
};
|
||||
let sr = match sample_rate {
|
||||
8000 => opus::SampleRate::Hz8000,
|
||||
12000 => opus::SampleRate::Hz12000,
|
||||
16000 => opus::SampleRate::Hz16000,
|
||||
24000 => opus::SampleRate::Hz24000,
|
||||
48000 => opus::SampleRate::Hz48000,
|
||||
_ => return Err(AudioError::Codec("unsupported sample rate".to_string())),
|
||||
};
|
||||
let decoder = opus::Decoder::new(sr, ch)
|
||||
.map_err(|e| AudioError::Codec(format!("opus decoder init: {e}")))?;
|
||||
Ok(Self {
|
||||
sample_rate,
|
||||
channels,
|
||||
decoder,
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "opus"))]
|
||||
{
|
||||
Ok(Self {
|
||||
sample_rate,
|
||||
channels,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode(&mut self, data: &[u8], fec: bool) -> AudioResult<Vec<f32>> {
|
||||
#[cfg(feature = "opus")]
|
||||
{
|
||||
let frame_size = (self.sample_rate as usize * 20) / 1000;
|
||||
let mut output = vec![0f32; frame_size * self.channels as usize];
|
||||
let decoded = self
|
||||
.decoder
|
||||
.decode_float(Some(data), &mut output, fec)
|
||||
.map_err(|e| AudioError::Codec(format!("opus decode: {e}")))?;
|
||||
output.truncate(decoded * self.channels as usize);
|
||||
Ok(output)
|
||||
}
|
||||
#[cfg(not(feature = "opus"))]
|
||||
{
|
||||
let _ = (data, fec);
|
||||
Err(AudioError::Codec("opus feature not enabled".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_packet(&mut self, data: &[u8]) -> AudioResult<Vec<f32>> {
|
||||
self.decode(data, false)
|
||||
}
|
||||
|
||||
pub fn decode_packet_fec(&mut self, data: &[u8]) -> AudioResult<Vec<f32>> {
|
||||
self.decode(data, true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_opus_encoder_new() {
|
||||
let encoder = OpusEncoder::new(48000, 1);
|
||||
assert!(encoder.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_opus_decoder_new() {
|
||||
let decoder = OpusDecoder::new(48000, 1);
|
||||
assert!(decoder.is_ok());
|
||||
}
|
||||
|
||||
#[cfg(feature = "opus")]
|
||||
#[test]
|
||||
fn test_opus_encode_decode_roundtrip() {
|
||||
let mut encoder = OpusEncoder::new(48000, 1).unwrap();
|
||||
let mut decoder = OpusDecoder::new(48000, 1).unwrap();
|
||||
|
||||
let samples: Vec<f32> = (0..960).map(|i| (i as f32 * 0.01).sin() * 0.5).collect();
|
||||
let encoded = encoder.encode(&samples).unwrap();
|
||||
assert!(!encoded.is_empty());
|
||||
|
||||
let decoded = decoder.decode_packet(&encoded).unwrap();
|
||||
assert!(!decoded.is_empty());
|
||||
assert_eq!(decoded.len(), 960);
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
use crate::buffer::JitterBuffer;
|
||||
use crate::codec::OpusDecoder;
|
||||
use crate::playback::AudioPlayback;
|
||||
use crate::{AudioConfig, AudioFrame, AudioResult};
|
||||
|
||||
pub struct VoiceEngine {
|
||||
#[allow(dead_code)]
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
//! TeamSpeak 音频引擎
|
||||
|
||||
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::*;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AudioError {
|
||||
#[error("设备错误: {0}")]
|
||||
Device(String),
|
||||
#[error("编解码器错误: {0}")]
|
||||
Codec(String),
|
||||
#[error("缓冲区错误: {0}")]
|
||||
Buffer(String),
|
||||
#[error("配置错误: {0}")]
|
||||
Config(String),
|
||||
#[error("IO 错误: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
pub type AudioResult<T> = Result<T, AudioError>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioConfig {
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
pub bits_per_sample: u16,
|
||||
pub frame_size: usize,
|
||||
}
|
||||
|
||||
impl Default for AudioConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sample_rate: 48000,
|
||||
channels: 1,
|
||||
bits_per_sample: 16,
|
||||
frame_size: 960,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioFrame {
|
||||
pub sequence: u16,
|
||||
pub codec: u8,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
pub samples: Vec<f32>,
|
||||
}
|
||||
|
||||
impl AudioFrame {
|
||||
pub fn new(sample_rate: u32, channels: u16, samples: Vec<f32>) -> Self {
|
||||
Self {
|
||||
sequence: 0,
|
||||
codec: 4,
|
||||
sample_rate,
|
||||
channels,
|
||||
samples,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_sequence(mut self, seq: u16) -> Self {
|
||||
self.sequence = seq;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_codec(mut self, codec: u8) -> Self {
|
||||
self.codec = codec;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn frame_size(&self) -> usize {
|
||||
self.samples.len()
|
||||
}
|
||||
|
||||
pub fn duration_ms(&self) -> f64 {
|
||||
(self.frame_size() as f64 / self.channels as f64) / (self.sample_rate as f64) * 1000.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioDeviceInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub is_default: bool,
|
||||
pub sample_rates: Vec<u32>,
|
||||
pub channels: Vec<u16>,
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::{AudioConfig, AudioError, AudioFrame, AudioResult};
|
||||
|
||||
pub struct AudioPlayback {
|
||||
config: AudioConfig,
|
||||
#[cfg(feature = "cpal")]
|
||||
stream: Option<cpal::Stream>,
|
||||
buffer: Arc<Mutex<Vec<f32>>>,
|
||||
}
|
||||
|
||||
impl AudioPlayback {
|
||||
pub fn new(config: AudioConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
#[cfg(feature = "cpal")]
|
||||
stream: None,
|
||||
buffer: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&mut self) -> AudioResult<()> {
|
||||
#[cfg(feature = "cpal")]
|
||||
{
|
||||
use cpal::traits::{DeviceTrait, HostTrait};
|
||||
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_output_device()
|
||||
.ok_or_else(|| AudioError::Device("no output device found".to_string()))?;
|
||||
|
||||
let supported = device
|
||||
.default_output_config()
|
||||
.map_err(|e| AudioError::Device(format!("get output config: {e}")))?;
|
||||
|
||||
let sample_format = supported.sample_format();
|
||||
let config: cpal::StreamConfig = supported.into();
|
||||
let channels = config.channels as usize;
|
||||
|
||||
let buffer = self.buffer.clone();
|
||||
|
||||
let err_fn = |err: cpal::StreamError| {
|
||||
tracing::error!("audio output stream error: {err}");
|
||||
};
|
||||
|
||||
let stream = match sample_format {
|
||||
cpal::SampleFormat::F32 => device
|
||||
.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||
let mut buf = buffer.lock().unwrap();
|
||||
let samples_needed = data.len();
|
||||
let available = buf.len().min(samples_needed);
|
||||
for (i, sample) in buf.drain(..available).enumerate() {
|
||||
data[i] = sample;
|
||||
}
|
||||
for sample in &mut data[available..] {
|
||||
*sample = 0.0;
|
||||
}
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)
|
||||
.map_err(|e| AudioError::Device(format!("build output stream: {e}")))?,
|
||||
cpal::SampleFormat::I16 => device
|
||||
.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
||||
let mut buf = buffer.lock().unwrap();
|
||||
let samples_needed = data.len();
|
||||
let available = buf.len().min(samples_needed);
|
||||
for (i, sample) in buf.drain(..available).enumerate() {
|
||||
data[i] = (sample * 32767.0) as i16;
|
||||
}
|
||||
for sample in &mut data[available..] {
|
||||
*sample = 0;
|
||||
}
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)
|
||||
.map_err(|e| AudioError::Device(format!("build output stream: {e}")))?,
|
||||
_ => {
|
||||
return Err(AudioError::Device(format!(
|
||||
"unsupported sample format: {sample_format:?}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
stream
|
||||
.play()
|
||||
.map_err(|e| AudioError::Device(format!("play stream: {e}")))?;
|
||||
|
||||
self.stream = Some(stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "cpal"))]
|
||||
{
|
||||
Err(AudioError::Device("cpal feature not enabled".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) -> AudioResult<()> {
|
||||
#[cfg(feature = "cpal")]
|
||||
{
|
||||
self.stream = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn play(&mut self, frame: AudioFrame) -> AudioResult<()> {
|
||||
let mut buf = self
|
||||
.buffer
|
||||
.lock()
|
||||
.map_err(|e| AudioError::Buffer(format!("lock buffer: {e}")))?;
|
||||
|
||||
buf.extend_from_slice(&frame.samples);
|
||||
|
||||
let max_samples = self.config.sample_rate as usize * 2;
|
||||
if buf.len() > max_samples {
|
||||
let drain = buf.len() - max_samples;
|
||||
buf.drain(..drain);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn play_samples(&mut self, samples: &[f32]) -> AudioResult<()> {
|
||||
let mut buf = self
|
||||
.buffer
|
||||
.lock()
|
||||
.map_err(|e| AudioError::Buffer(format!("lock buffer: {e}")))?;
|
||||
|
||||
buf.extend_from_slice(samples);
|
||||
|
||||
let max_samples = self.config.sample_rate as usize * 2;
|
||||
if buf.len() > max_samples {
|
||||
let drain = buf.len() - max_samples;
|
||||
buf.drain(..drain);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_devices() -> AudioResult<Vec<String>> {
|
||||
#[cfg(feature = "cpal")]
|
||||
{
|
||||
use cpal::traits::DeviceTrait;
|
||||
use cpal::traits::HostTrait;
|
||||
|
||||
let host = cpal::default_host();
|
||||
let mut devices = Vec::new();
|
||||
|
||||
if let Ok(output_devices) = host.output_devices() {
|
||||
for device in output_devices {
|
||||
if let Ok(name) = device.name() {
|
||||
devices.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "cpal"))]
|
||||
{
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn buffer_len(&self) -> usize {
|
||||
self.buffer.lock().map(|b| b.len()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//! 语音活动检测 (VAD)
|
||||
|
||||
/// VAD 状态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VadState {
|
||||
Silent,
|
||||
Speaking,
|
||||
}
|
||||
|
||||
/// 语音活动检测器
|
||||
pub struct VadDetector {
|
||||
threshold: f32,
|
||||
state: VadState,
|
||||
}
|
||||
|
||||
impl VadDetector {
|
||||
pub fn new(threshold: f32) -> Self {
|
||||
Self {
|
||||
threshold,
|
||||
state: VadState::Silent,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detect(&mut self, samples: &[f32]) -> VadState {
|
||||
let energy: f32 = samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32;
|
||||
if energy > self.threshold {
|
||||
self.state = VadState::Speaking;
|
||||
} else {
|
||||
self.state = VadState::Silent;
|
||||
}
|
||||
self.state
|
||||
}
|
||||
|
||||
pub fn state(&self) -> VadState {
|
||||
self.state
|
||||
}
|
||||
|
||||
pub fn set_threshold(&mut self, threshold: f32) {
|
||||
self.threshold = threshold;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
[package]
|
||||
name = "tscore"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "TeamSpeak 3 协议核心实现"
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
aes = { workspace = true }
|
||||
eax = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
p256 = { workspace = true }
|
||||
curve25519-dalek-ng = { workspace = true }
|
||||
num-bigint = { workspace = true }
|
||||
simple_asn1 = { workspace = true }
|
||||
generic-array = "0.14"
|
||||
typenum = "1"
|
||||
|
||||
quicklz = { workspace = true }
|
||||
|
||||
bytes = "1"
|
||||
|
||||
base64 = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
shared = { workspace = true }
|
||||
@@ -1,941 +0,0 @@
|
||||
//! Client connection - full handshake implementation
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::state::{ConnectionState, ConnectionStateMachine};
|
||||
use crate::crypto::{self, IdentityKey, KeyCache, SharedSecret};
|
||||
use crate::protocol::{
|
||||
AckPacket, Command, CommandBuilder, Direction, Flags, InPacket, InitPacket, InitStep,
|
||||
OutPacket, PacketType,
|
||||
};
|
||||
use crate::ProtocolError;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ChannelEntry {
|
||||
pub cid: u64,
|
||||
pub pid: u64,
|
||||
pub channel_order: u64,
|
||||
pub channel_name: String,
|
||||
pub total_clients: u16,
|
||||
pub channel_needed_subscribe_power: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ClientEntry {
|
||||
pub clid: u16,
|
||||
pub cid: u64,
|
||||
pub client_database_id: u64,
|
||||
pub client_nickname: String,
|
||||
pub client_type: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub enum CommandEvent {
|
||||
InitServer {
|
||||
client_id: u16,
|
||||
name: String,
|
||||
platform: String,
|
||||
version: String,
|
||||
max_clients: u16,
|
||||
clients_online: u16,
|
||||
channels_online: u16,
|
||||
},
|
||||
ChannelList(Vec<ChannelEntry>),
|
||||
ChannelListFinished,
|
||||
ClientList(Vec<ClientEntry>),
|
||||
ClientEntered {
|
||||
clid: u16,
|
||||
cid: u64,
|
||||
client_nickname: String,
|
||||
},
|
||||
ClientLeft {
|
||||
clid: u16,
|
||||
reason: String,
|
||||
},
|
||||
ClientMoved {
|
||||
clid: u16,
|
||||
cid: u64,
|
||||
},
|
||||
TextMessage {
|
||||
invoker_id: u16,
|
||||
invoker_name: String,
|
||||
message: String,
|
||||
target_mode: u8,
|
||||
},
|
||||
Error {
|
||||
id: u32,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct HandleResult {
|
||||
pub responses: Vec<Vec<u8>>,
|
||||
pub events: Vec<CommandEvent>,
|
||||
}
|
||||
|
||||
/// Client configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientConfig {
|
||||
pub address: SocketAddr,
|
||||
pub nickname: String,
|
||||
pub version: String,
|
||||
pub platform: String,
|
||||
pub server_password: Option<String>,
|
||||
pub channel: Option<String>,
|
||||
pub channel_password: Option<String>,
|
||||
pub default_token: Option<String>,
|
||||
pub identity: IdentityKey,
|
||||
}
|
||||
|
||||
impl ClientConfig {
|
||||
pub fn new(address: SocketAddr, nickname: String) -> Self {
|
||||
Self {
|
||||
address,
|
||||
nickname,
|
||||
version: "3.0.19.3 [Build: 1466672534]".to_string(),
|
||||
platform: "Linux".to_string(),
|
||||
server_password: None,
|
||||
channel: None,
|
||||
channel_password: None,
|
||||
default_token: None,
|
||||
identity: IdentityKey::generate(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Client connection
|
||||
pub struct Client {
|
||||
config: ClientConfig,
|
||||
state_machine: ConnectionStateMachine,
|
||||
shared_secret: Option<SharedSecret>,
|
||||
key_cache: KeyCache,
|
||||
client_id: Option<u16>,
|
||||
/// Client random A0
|
||||
random0: Option<[u8; 4]>,
|
||||
/// Server random A1
|
||||
random1: Option<[u8; 16]>,
|
||||
/// A0 reversed
|
||||
random0_r: Option<[u8; 4]>,
|
||||
/// RSA parameters
|
||||
rsa_x: Option<[u8; 64]>,
|
||||
rsa_n: Option<[u8; 64]>,
|
||||
rsa_level: Option<u32>,
|
||||
/// Server random A2
|
||||
random2: Option<[u8; 100]>,
|
||||
/// Client alpha
|
||||
alpha: Option<[u8; 10]>,
|
||||
outgoing_command_id: u16,
|
||||
outgoing_ack_id: u16,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn new(config: ClientConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
state_machine: ConnectionStateMachine::new(),
|
||||
shared_secret: None,
|
||||
key_cache: KeyCache::new(),
|
||||
client_id: None,
|
||||
random0: None,
|
||||
random1: None,
|
||||
random0_r: None,
|
||||
rsa_x: None,
|
||||
rsa_n: None,
|
||||
rsa_level: None,
|
||||
random2: None,
|
||||
alpha: None,
|
||||
// clientinitiv is embedded in Init4 and consumes command packet id 0.
|
||||
outgoing_command_id: 1,
|
||||
outgoing_ack_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> ConnectionState {
|
||||
self.state_machine.state()
|
||||
}
|
||||
|
||||
pub fn client_id(&self) -> Option<u16> {
|
||||
self.client_id
|
||||
}
|
||||
|
||||
pub fn shared_secret(&self) -> &Option<SharedSecret> {
|
||||
&self.shared_secret
|
||||
}
|
||||
|
||||
pub fn key_cache_mut(&mut self) -> &mut KeyCache {
|
||||
&mut self.key_cache
|
||||
}
|
||||
|
||||
/// Start connection handshake
|
||||
pub fn start_handshake(&mut self) -> Result<Vec<u8>, ProtocolError> {
|
||||
self.state_machine
|
||||
.transition(ConnectionState::Connecting)
|
||||
.map_err(ProtocolError::PacketParse)?;
|
||||
|
||||
// Generate random A0
|
||||
let mut random0 = [0u8; 4];
|
||||
rand::Rng::fill(&mut rand::thread_rng(), &mut random0);
|
||||
self.random0 = Some(random0);
|
||||
|
||||
// Build Init0 packet
|
||||
let init = InitPacket {
|
||||
step: InitStep::Init0,
|
||||
version: Some(Self::encode_version(&self.config.version)),
|
||||
timestamp: Some(Self::current_timestamp()),
|
||||
random0: Some(random0),
|
||||
random1: None,
|
||||
random0_r: None,
|
||||
x: None,
|
||||
n: None,
|
||||
level: None,
|
||||
random2: None,
|
||||
y: None,
|
||||
command: None,
|
||||
};
|
||||
|
||||
let data = init.to_c2s_packet_bytes();
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Handle received data
|
||||
pub fn handle_data(&mut self, data: &[u8]) -> Result<HandleResult, ProtocolError> {
|
||||
let mut responses = Vec::new();
|
||||
let mut events = Vec::new();
|
||||
|
||||
match self.state() {
|
||||
ConnectionState::Connecting => {
|
||||
// Handle Init1
|
||||
let init = Self::parse_server_init(data)?;
|
||||
if init.step == InitStep::Init1 {
|
||||
self.random1 = init.random1;
|
||||
self.random0_r = init.random0_r;
|
||||
|
||||
// Send Init2
|
||||
let response = self.build_init2()?;
|
||||
responses.push(response);
|
||||
} else if init.step == InitStep::Reset {
|
||||
// Server requested reset, resend Init0
|
||||
let response = self.start_handshake()?;
|
||||
responses.push(response);
|
||||
}
|
||||
}
|
||||
ConnectionState::IdentityLevelIncreasing => {
|
||||
// Handle Init3
|
||||
let init = Self::parse_server_init(data)?;
|
||||
if init.step == InitStep::Init3 {
|
||||
self.rsa_x = init.x;
|
||||
self.rsa_n = init.n;
|
||||
self.rsa_level = init.level;
|
||||
self.random2 = init.random2;
|
||||
|
||||
// Compute RSA solution
|
||||
let response = self.build_init4()?;
|
||||
responses.push(response);
|
||||
}
|
||||
}
|
||||
ConnectionState::Connected => {
|
||||
// Handle command packets
|
||||
let packet = InPacket::parse(Direction::S2C, data)?;
|
||||
let packet_type = packet.header.flags.packet_type();
|
||||
let content = if !packet.header.flags.is_unencrypted() {
|
||||
if packet_type == PacketType::Ack && packet.header.packet_id <= 1 {
|
||||
crypto::decrypt_fake(&packet).or_else(|_| {
|
||||
if let Some(ref secret) = self.shared_secret {
|
||||
crypto::decrypt_packet(&packet, 0, &secret.iv, &mut self.key_cache)
|
||||
} else {
|
||||
Err(ProtocolError::MacVerificationFailed)
|
||||
}
|
||||
})?
|
||||
} else if let Some(ref secret) = self.shared_secret {
|
||||
crypto::decrypt_packet(&packet, 0, &secret.iv, &mut self.key_cache)?
|
||||
} else {
|
||||
crypto::decrypt_fake(&packet)?
|
||||
}
|
||||
} else {
|
||||
packet.data.clone()
|
||||
};
|
||||
|
||||
if packet_type == PacketType::Ack || packet_type == PacketType::AckLow {
|
||||
if content.len() >= 2 {
|
||||
let acked_id = u16::from_be_bytes([content[0], content[1]]);
|
||||
if packet_type == PacketType::Ack && acked_id == 1 {
|
||||
responses.push(self.build_clientinit_packet()?);
|
||||
}
|
||||
}
|
||||
return Ok(HandleResult { responses, events });
|
||||
}
|
||||
|
||||
if matches!(packet_type, PacketType::Command | PacketType::CommandLow) {
|
||||
responses.push(self.build_ack_packet(packet_type, packet.header.packet_id)?);
|
||||
}
|
||||
|
||||
// Parse commands
|
||||
let cmd_str = String::from_utf8_lossy(&content);
|
||||
for cmd in Command::parse_many(&cmd_str)? {
|
||||
match cmd.name.as_str() {
|
||||
"initserver" => {
|
||||
// Connection complete
|
||||
if let Some(id) = cmd.get("client_id") {
|
||||
self.client_id = id.parse().ok();
|
||||
}
|
||||
self.state_machine
|
||||
.transition(ConnectionState::ChannelListFinished)
|
||||
.map_err(ProtocolError::PacketParse)?;
|
||||
|
||||
events.push(CommandEvent::InitServer {
|
||||
client_id: self.client_id.unwrap_or(0),
|
||||
name: cmd
|
||||
.get("virtualserver_name")
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
platform: cmd
|
||||
.get("virtualserver_platform")
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
version: cmd
|
||||
.get("virtualserver_version")
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
max_clients: cmd
|
||||
.get("virtualserver_maxclients")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0),
|
||||
clients_online: cmd
|
||||
.get("virtualserver_clientsonline")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0),
|
||||
channels_online: cmd
|
||||
.get("virtualserver_channelsonline")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0),
|
||||
});
|
||||
}
|
||||
"initivexpand" => {
|
||||
// Old protocol key exchange
|
||||
responses.extend(self.handle_initivexpand(&cmd)?);
|
||||
}
|
||||
"initivexpand2" => {
|
||||
// New protocol key exchange
|
||||
responses.extend(self.handle_initivexpand2(&cmd)?);
|
||||
}
|
||||
"channellist" => {
|
||||
events
|
||||
.push(CommandEvent::ChannelList(Self::parse_channel_entries(&cmd)));
|
||||
}
|
||||
"channellistfinished" => {
|
||||
self.state_machine
|
||||
.transition(ConnectionState::ChannelListFinished)
|
||||
.map_err(ProtocolError::PacketParse)?;
|
||||
events.push(CommandEvent::ChannelListFinished);
|
||||
}
|
||||
"clientlist" => {
|
||||
events.push(CommandEvent::ClientList(Self::parse_client_entries(&cmd)));
|
||||
}
|
||||
"notifycliententerview" => {
|
||||
events.push(CommandEvent::ClientEntered {
|
||||
clid: cmd.get("clid").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||
cid: cmd.get("cid").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||
client_nickname: cmd
|
||||
.get("client_nickname")
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
"notifyclientleftview" => {
|
||||
events.push(CommandEvent::ClientLeft {
|
||||
clid: cmd.get("clid").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||
reason: cmd.get("reasonmsg").unwrap_or_default().to_string(),
|
||||
});
|
||||
}
|
||||
"notifyclientmoved" => {
|
||||
events.push(CommandEvent::ClientMoved {
|
||||
clid: cmd.get("clid").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||
cid: cmd.get("ctid").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||
});
|
||||
}
|
||||
"notifytextmessage" => {
|
||||
events.push(CommandEvent::TextMessage {
|
||||
invoker_id: cmd
|
||||
.get("invokerid")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0),
|
||||
invoker_name: cmd
|
||||
.get("invokername")
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
message: cmd.get("msg").unwrap_or_default().to_string(),
|
||||
target_mode: cmd
|
||||
.get("targetmode")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0),
|
||||
});
|
||||
}
|
||||
"error" => {
|
||||
if let Some(id) = cmd.get("id") {
|
||||
if id != "0" {
|
||||
events.push(CommandEvent::Error {
|
||||
id: id.parse().unwrap_or(0),
|
||||
message: cmd.get("msg").unwrap_or("unknown").to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(HandleResult { responses, events })
|
||||
}
|
||||
|
||||
fn parse_channel_entries(cmd: &Command) -> Vec<ChannelEntry> {
|
||||
let mut entries = 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_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);
|
||||
|
||||
entries.push(ChannelEntry {
|
||||
cid,
|
||||
pid,
|
||||
channel_order,
|
||||
channel_name,
|
||||
total_clients,
|
||||
channel_needed_subscribe_power,
|
||||
});
|
||||
entries
|
||||
}
|
||||
|
||||
fn parse_client_entries(cmd: &Command) -> Vec<ClientEntry> {
|
||||
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_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);
|
||||
|
||||
vec![ClientEntry {
|
||||
clid,
|
||||
cid,
|
||||
client_database_id,
|
||||
client_nickname,
|
||||
client_type,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Build Init2 packet
|
||||
fn build_init2(&mut self) -> Result<Vec<u8>, ProtocolError> {
|
||||
let init = InitPacket {
|
||||
step: InitStep::Init2,
|
||||
version: Some(Self::encode_version(&self.config.version)),
|
||||
timestamp: None,
|
||||
random0: None,
|
||||
random1: self.random1,
|
||||
random0_r: self.random0_r,
|
||||
x: None,
|
||||
n: None,
|
||||
level: None,
|
||||
random2: None,
|
||||
y: None,
|
||||
command: None,
|
||||
};
|
||||
|
||||
self.state_machine
|
||||
.transition(ConnectionState::IdentityLevelIncreasing)
|
||||
.map_err(ProtocolError::PacketParse)?;
|
||||
|
||||
Ok(init.to_c2s_packet_bytes())
|
||||
}
|
||||
|
||||
/// Build Init4 packet
|
||||
fn build_init4(&mut self) -> Result<Vec<u8>, ProtocolError> {
|
||||
// Compute y = x^(2^level) mod n
|
||||
let x = self
|
||||
.rsa_x
|
||||
.ok_or_else(|| ProtocolError::PacketParse("missing RSA x".to_string()))?;
|
||||
let n = self
|
||||
.rsa_n
|
||||
.ok_or_else(|| ProtocolError::PacketParse("missing RSA n".to_string()))?;
|
||||
let level = self
|
||||
.rsa_level
|
||||
.ok_or_else(|| ProtocolError::PacketParse("missing RSA level".to_string()))?;
|
||||
|
||||
let y = Self::solve_rsa_puzzle(&x, &n, level);
|
||||
|
||||
// Generate alpha
|
||||
let mut alpha = [0u8; 10];
|
||||
rand::Rng::fill(&mut rand::thread_rng(), &mut alpha);
|
||||
self.alpha = Some(alpha);
|
||||
|
||||
// Build clientinitiv command
|
||||
let alpha_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, alpha);
|
||||
let omega = self.get_identity_omega()?;
|
||||
let ip = self.config.address.ip().to_string();
|
||||
|
||||
let cmd = CommandBuilder::new("clientinitiv")
|
||||
.arg("alpha", &alpha_b64)
|
||||
.arg("omega", &omega)
|
||||
.arg("ot", "1")
|
||||
.arg("ip", &ip)
|
||||
.build();
|
||||
|
||||
let init = InitPacket {
|
||||
step: InitStep::Init4,
|
||||
version: Some(Self::encode_version(&self.config.version)),
|
||||
timestamp: None,
|
||||
random0: None,
|
||||
random1: None,
|
||||
random0_r: None,
|
||||
x: Some(x),
|
||||
n: Some(n),
|
||||
level: Some(level),
|
||||
random2: self.random2,
|
||||
y: Some(y),
|
||||
command: Some(cmd.to_string().into_bytes()),
|
||||
};
|
||||
|
||||
self.state_machine
|
||||
.transition(ConnectionState::Connected)
|
||||
.map_err(ProtocolError::PacketParse)?;
|
||||
|
||||
Ok(init.to_c2s_packet_bytes())
|
||||
}
|
||||
|
||||
/// Handle initivexpand (old protocol)
|
||||
fn handle_initivexpand(&mut self, cmd: &Command) -> Result<Vec<Vec<u8>>, ProtocolError> {
|
||||
let alpha_b64 = cmd
|
||||
.get("alpha")
|
||||
.ok_or_else(|| ProtocolError::PacketParse("missing alpha".to_string()))?;
|
||||
let beta_b64 = cmd
|
||||
.get("beta")
|
||||
.ok_or_else(|| ProtocolError::PacketParse("missing beta".to_string()))?;
|
||||
let _omega = cmd
|
||||
.get("omega")
|
||||
.ok_or_else(|| ProtocolError::PacketParse("missing omega".to_string()))?;
|
||||
|
||||
let alpha_bytes =
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, alpha_b64)
|
||||
.map_err(|_| ProtocolError::PacketParse("invalid alpha".to_string()))?;
|
||||
let beta_bytes =
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, beta_b64)
|
||||
.map_err(|_| ProtocolError::PacketParse("invalid beta".to_string()))?;
|
||||
|
||||
let mut alpha = [0u8; 10];
|
||||
alpha.copy_from_slice(&alpha_bytes);
|
||||
let mut beta = [0u8; 10];
|
||||
beta.copy_from_slice(&beta_bytes);
|
||||
|
||||
// Compute shared secret
|
||||
let shared_data = [0u8; 32]; // TODO: Compute from ECDH
|
||||
let secret = SharedSecret::compute_old(&alpha, &beta, &shared_data);
|
||||
self.shared_secret = Some(secret);
|
||||
|
||||
// Send clientek
|
||||
let ek = self.get_identity_omega()?;
|
||||
let proof = self.generate_proof(&ek, beta_b64);
|
||||
|
||||
let cmd = CommandBuilder::new("clientek")
|
||||
.arg("ek", &ek)
|
||||
.arg("proof", &proof)
|
||||
.build();
|
||||
|
||||
Ok(vec![
|
||||
self.build_command_packet(cmd.to_string().into_bytes())?
|
||||
])
|
||||
}
|
||||
|
||||
/// Handle initivexpand2 (new protocol)
|
||||
///
|
||||
/// When the server sends a license (`l`), this performs real ECDH key
|
||||
/// exchange using an ephemeral Ed25519 key pair. When no license is
|
||||
/// present (mocked environments), it falls back to a zeroed shared
|
||||
/// secret so the bootstrap sequence still completes.
|
||||
fn handle_initivexpand2(&mut self, cmd: &Command) -> Result<Vec<Vec<u8>>, ProtocolError> {
|
||||
let beta_b64 = cmd
|
||||
.get("beta")
|
||||
.ok_or_else(|| ProtocolError::PacketParse("missing beta".to_string()))?;
|
||||
let _omega = cmd
|
||||
.get("omega")
|
||||
.ok_or_else(|| ProtocolError::PacketParse("missing omega".to_string()))?;
|
||||
|
||||
let beta_bytes =
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, beta_b64)
|
||||
.map_err(|_| ProtocolError::PacketParse("invalid beta".to_string()))?;
|
||||
|
||||
let mut beta = [0u8; 54];
|
||||
if beta_bytes.len() >= 54 {
|
||||
beta.copy_from_slice(&beta_bytes[..54]);
|
||||
} else {
|
||||
beta[..beta_bytes.len()].copy_from_slice(&beta_bytes);
|
||||
}
|
||||
|
||||
let ephemeral = crypto::ephemeral::EphemeralKey::generate();
|
||||
let ek_bytes = ephemeral.public_bytes();
|
||||
let ek_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, ek_bytes);
|
||||
|
||||
let alpha = self.alpha.unwrap_or([0; 10]);
|
||||
|
||||
let shared_secret = if let Some(l) = cmd.get("l") {
|
||||
match self.derive_server_ephemeral_key(l) {
|
||||
Ok(server_ek) => ephemeral.compute_shared_secret(&server_ek),
|
||||
Err(_) => [0u8; 32],
|
||||
}
|
||||
} else {
|
||||
[0u8; 32]
|
||||
};
|
||||
|
||||
let (iv, mac) = crypto::ephemeral::compute_iv_mac(&alpha, &beta, &shared_secret);
|
||||
self.shared_secret = Some(SharedSecret::new(iv, mac));
|
||||
|
||||
let mut proof_data = Vec::with_capacity(32 + 54);
|
||||
proof_data.extend_from_slice(&ek_bytes);
|
||||
proof_data.extend_from_slice(&beta);
|
||||
let proof = self.config.identity.sign_der_base64(&proof_data);
|
||||
|
||||
let cmd = CommandBuilder::new("clientek")
|
||||
.arg("ek", &ek_b64)
|
||||
.arg("proof", &proof)
|
||||
.build();
|
||||
|
||||
Ok(vec![
|
||||
self.build_command_packet(cmd.to_string().into_bytes())?
|
||||
])
|
||||
}
|
||||
|
||||
/// Derive the server's ephemeral Ed25519 public key from the license data
|
||||
/// embedded in the `initivexpand2` response.
|
||||
///
|
||||
/// The license is a base64-encoded blob that contains, among other things,
|
||||
/// the server's ephemeral Ed25519 public key. Full license parsing requires
|
||||
/// signature verification against the root key, but for now we attempt a
|
||||
/// best-effort extraction of the 32-byte compressed Edwards point.
|
||||
fn derive_server_ephemeral_key(
|
||||
&self,
|
||||
license_b64: &str,
|
||||
) -> Result<curve25519_dalek_ng::montgomery::MontgomeryPoint, ProtocolError> {
|
||||
let license_bytes =
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, license_b64)
|
||||
.map_err(|_| ProtocolError::PacketParse("invalid license base64".to_string()))?;
|
||||
|
||||
if license_bytes.len() < 32 {
|
||||
return Err(ProtocolError::PacketParse("license too short".to_string()));
|
||||
}
|
||||
|
||||
let mut key_bytes = [0u8; 32];
|
||||
key_bytes.copy_from_slice(&license_bytes[license_bytes.len() - 32..]);
|
||||
Ok(crypto::ephemeral::parse_x25519_public_key(&key_bytes))
|
||||
}
|
||||
|
||||
fn build_ack_packet(
|
||||
&mut self,
|
||||
packet_type: PacketType,
|
||||
acked_packet_id: u16,
|
||||
) -> Result<Vec<u8>, ProtocolError> {
|
||||
let ack_type = packet_type
|
||||
.ack_type()
|
||||
.ok_or_else(|| ProtocolError::InvalidPacketType(packet_type.to_u8()))?;
|
||||
let mut packet = AckPacket::new(Direction::C2S, ack_type, acked_packet_id).to_out_packet();
|
||||
packet.set_packet_id(self.outgoing_ack_id);
|
||||
packet.set_client_id(self.client_id.unwrap_or(0));
|
||||
self.outgoing_ack_id = self.outgoing_ack_id.wrapping_add(1);
|
||||
|
||||
if self.shared_secret.is_none() || acked_packet_id == 0 {
|
||||
crypto::encrypt_fake(&mut packet)?;
|
||||
} else if let Some(ref secret) = self.shared_secret {
|
||||
crypto::encrypt_packet(&mut packet, 0, &secret.iv, &mut self.key_cache)?;
|
||||
}
|
||||
|
||||
Ok(packet.to_bytes())
|
||||
}
|
||||
|
||||
pub fn build_command_packet(&mut self, content: Vec<u8>) -> Result<Vec<u8>, ProtocolError> {
|
||||
let packet_id = self.outgoing_command_id;
|
||||
let mut flags = Flags::new(PacketType::Command.to_u8());
|
||||
flags.set_newprotocol(true);
|
||||
|
||||
let mut packet = OutPacket::new(Direction::C2S, flags, content);
|
||||
packet.set_packet_id(packet_id);
|
||||
packet.set_client_id(self.client_id.unwrap_or(0));
|
||||
|
||||
let is_clientek = packet.content().starts_with(b"clientek");
|
||||
if is_clientek && packet_id == 1 {
|
||||
crypto::encrypt_fake(&mut packet)?;
|
||||
} else if let Some(ref secret) = self.shared_secret {
|
||||
crypto::encrypt_packet(&mut packet, 0, &secret.iv, &mut self.key_cache)?;
|
||||
} else {
|
||||
crypto::encrypt_fake(&mut packet)?;
|
||||
}
|
||||
|
||||
self.outgoing_command_id = self.outgoing_command_id.wrapping_add(1);
|
||||
Ok(packet.to_bytes())
|
||||
}
|
||||
|
||||
fn build_clientinit_packet(&mut self) -> Result<Vec<u8>, ProtocolError> {
|
||||
self.build_command_packet(self.build_clientinit())
|
||||
}
|
||||
|
||||
/// Build clientinit command
|
||||
pub fn build_clientinit(&self) -> Vec<u8> {
|
||||
let channel_password = self
|
||||
.config
|
||||
.channel_password
|
||||
.as_deref()
|
||||
.map(crypto::hash_password)
|
||||
.unwrap_or_default();
|
||||
|
||||
let server_password = self
|
||||
.config
|
||||
.server_password
|
||||
.as_deref()
|
||||
.map(crypto::hash_password)
|
||||
.unwrap_or_default();
|
||||
|
||||
let cmd = CommandBuilder::new("clientinit")
|
||||
.arg("client_nickname", &self.config.nickname)
|
||||
.arg("client_version", &self.config.version)
|
||||
.arg("client_platform", &self.config.platform)
|
||||
.arg("client_input_hardware", "1")
|
||||
.arg("client_output_hardware", "1")
|
||||
.arg(
|
||||
"client_default_channel",
|
||||
self.config.channel.as_deref().unwrap_or(""),
|
||||
)
|
||||
.arg("client_default_channel_password", &channel_password)
|
||||
.arg("client_server_password", &server_password)
|
||||
.arg("client_meta_data", "")
|
||||
.arg(
|
||||
"client_version_sign",
|
||||
"a1OYzvM18mrmfUQBUgxYBxYz2DUU6y5k3/mEL6FurzU0y97Bd1FL7+PRpcHyPkg4R+kKAFZ1nhyzbgkGphDWDg==",
|
||||
)
|
||||
.arg("client_key_offset", "0")
|
||||
.arg("client_nickname_phonetic", "")
|
||||
.arg(
|
||||
"client_default_token",
|
||||
self.config.default_token.as_deref().unwrap_or(""),
|
||||
)
|
||||
.arg("hwid", "87056c6e1268aaf5055abf8256415e0e,408978b6d98810cc03f0aa16a4c75600")
|
||||
.build();
|
||||
|
||||
cmd.to_string().into_bytes()
|
||||
}
|
||||
|
||||
/// Encode version number
|
||||
fn encode_version(version: &str) -> u32 {
|
||||
// Extract build timestamp from version string
|
||||
if let Some(start) = version.find("[Build: ") {
|
||||
let rest = &version[start + 8..];
|
||||
if let Some(end) = rest.find(']') {
|
||||
let ts_str = &rest[..end];
|
||||
if let Ok(ts) = ts_str.parse::<u32>() {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
}
|
||||
1466672534 // default value
|
||||
}
|
||||
|
||||
/// Get current timestamp
|
||||
fn current_timestamp() -> u32 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or(Duration::from_secs(0))
|
||||
.as_secs() as u32
|
||||
}
|
||||
|
||||
/// Solve RSA puzzle
|
||||
/// y = x^(2^level) mod n
|
||||
fn solve_rsa_puzzle(x: &[u8; 64], n: &[u8; 64], level: u32) -> [u8; 64] {
|
||||
let x_big = num_bigint::BigUint::from_bytes_be(x);
|
||||
let n_big = num_bigint::BigUint::from_bytes_be(n);
|
||||
|
||||
// y = x^(2^level) mod n
|
||||
// Need to perform level squaring operations
|
||||
let mut y = x_big;
|
||||
for _ in 0..level {
|
||||
y = (y.clone() * y) % &n_big;
|
||||
}
|
||||
|
||||
let mut result = [0u8; 64];
|
||||
let bytes = y.to_bytes_be();
|
||||
let offset = 64 - bytes.len();
|
||||
result[offset..].copy_from_slice(&bytes);
|
||||
result
|
||||
}
|
||||
|
||||
/// Get identity public key (omega)
|
||||
fn get_identity_omega(&self) -> Result<String, ProtocolError> {
|
||||
self.config
|
||||
.identity
|
||||
.public_key_ts_base64()
|
||||
.map_err(|e| ProtocolError::Encryption(format!("identity public key encoding failed: {e}")))
|
||||
}
|
||||
|
||||
/// Generate proof
|
||||
fn generate_proof(&self, data: &str, beta: &str) -> String {
|
||||
let combined = format!("{}{}", data, beta);
|
||||
self.config.identity.sign_der_base64(combined.as_bytes())
|
||||
}
|
||||
|
||||
fn parse_server_init(data: &[u8]) -> Result<InitPacket, ProtocolError> {
|
||||
if data.len() >= crate::protocol::S2C_HEADER_SIZE {
|
||||
if let Ok(packet) = InPacket::parse(Direction::S2C, data) {
|
||||
if packet.header.flags.packet_type() == PacketType::Init {
|
||||
if packet.header.mac != crate::protocol::INIT_MAC {
|
||||
return Err(ProtocolError::PacketParse(
|
||||
"invalid init packet MAC".to_string(),
|
||||
));
|
||||
}
|
||||
return InitPacket::parse_s2c(&packet.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
InitPacket::parse_s2c(data)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::protocol::{Direction, InPacket, PacketType, INIT_MAC, INIT_PACKET_ID};
|
||||
use base64::Engine;
|
||||
|
||||
#[test]
|
||||
fn test_encode_version() {
|
||||
let version = "3.0.19.3 [Build: 1466672534]";
|
||||
assert_eq!(Client::encode_version(version), 1466672534);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rsa_puzzle() {
|
||||
// Test with non-zero values
|
||||
let mut x = [0u8; 64];
|
||||
x[63] = 2; // x = 2
|
||||
let mut n = [0u8; 64];
|
||||
n[63] = 7; // n = 7
|
||||
|
||||
// level=0: y = x^(2^0) mod n = x^1 mod n = 2 mod 7 = 2
|
||||
let y = Client::solve_rsa_puzzle(&x, &n, 0);
|
||||
assert_eq!(y[63], 2);
|
||||
|
||||
// level=1: y = x^(2^1) mod n = x^2 mod n = 4 mod 7 = 4
|
||||
let y = Client::solve_rsa_puzzle(&x, &n, 1);
|
||||
assert_eq!(y[63], 4);
|
||||
|
||||
// level=2: y = x^(2^2) mod n = x^4 mod n = 16 mod 7 = 2
|
||||
let y = Client::solve_rsa_puzzle(&x, &n, 2);
|
||||
assert_eq!(y[63], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_start_handshake_returns_init_datagram() {
|
||||
let addr = "127.0.0.1:9987".parse().unwrap();
|
||||
let mut client = Client::new(ClientConfig::new(addr, "Test".to_string()));
|
||||
|
||||
let data = client.start_handshake().unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &data).unwrap();
|
||||
|
||||
assert_eq!(packet.header.mac, INIT_MAC);
|
||||
assert_eq!(packet.header.packet_id, INIT_PACKET_ID);
|
||||
assert_eq!(packet.header.flags.packet_type(), PacketType::Init);
|
||||
assert_eq!(packet.content_size(), 21);
|
||||
assert_eq!(packet.content()[4], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initivexpand2_builds_bootstrap_packets() {
|
||||
let addr = "127.0.0.1:9987".parse().unwrap();
|
||||
let mut client = Client::new(ClientConfig::new(addr, "Test".to_string()));
|
||||
client.start_handshake().unwrap();
|
||||
client
|
||||
.state_machine
|
||||
.transition(ConnectionState::Connected)
|
||||
.unwrap();
|
||||
client.alpha = Some([2; 10]);
|
||||
|
||||
let mut server_packet = OutPacket::new(
|
||||
Direction::S2C,
|
||||
Flags::new(PacketType::Command.to_u8()),
|
||||
CommandBuilder::new("initivexpand2")
|
||||
.arg(
|
||||
"beta",
|
||||
&base64::engine::general_purpose::STANDARD.encode([1; 54]),
|
||||
)
|
||||
.arg("omega", "server")
|
||||
.build()
|
||||
.to_string()
|
||||
.into_bytes(),
|
||||
);
|
||||
server_packet.set_packet_id(0);
|
||||
crypto::encrypt_fake(&mut server_packet).unwrap();
|
||||
|
||||
let result = client.handle_data(&server_packet.to_bytes()).unwrap();
|
||||
assert_eq!(result.responses.len(), 2);
|
||||
|
||||
let ack = InPacket::parse(Direction::C2S, &result.responses[0]).unwrap();
|
||||
assert_eq!(ack.header.packet_id, 0);
|
||||
assert_eq!(ack.header.flags.packet_type(), PacketType::Ack);
|
||||
let ack_content = crypto::decrypt_fake(&ack).unwrap();
|
||||
assert_eq!(ack_content, 0u16.to_be_bytes());
|
||||
|
||||
let clientek = InPacket::parse(Direction::C2S, &result.responses[1]).unwrap();
|
||||
assert_eq!(clientek.header.packet_id, 1);
|
||||
assert_eq!(clientek.header.flags.packet_type(), PacketType::Command);
|
||||
assert!(clientek.header.flags.is_newprotocol());
|
||||
let clientek_content = crypto::decrypt_fake(&clientek).unwrap();
|
||||
let command = Command::parse(&String::from_utf8(clientek_content).unwrap()).unwrap();
|
||||
assert_eq!(command.name, "clientek");
|
||||
assert!(command.has("ek"));
|
||||
assert!(command.has("proof"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clientek_ack_builds_encrypted_clientinit() {
|
||||
let addr = "127.0.0.1:9987".parse().unwrap();
|
||||
let mut client = Client::new(ClientConfig::new(addr, "Test".to_string()));
|
||||
client.start_handshake().unwrap();
|
||||
client
|
||||
.state_machine
|
||||
.transition(ConnectionState::Connected)
|
||||
.unwrap();
|
||||
client.shared_secret = Some(SharedSecret::compute_new(&[2; 10], &[1; 54], &[0; 32]));
|
||||
client.outgoing_command_id = 2;
|
||||
|
||||
let mut ack = AckPacket::new(Direction::S2C, PacketType::Ack, 1).to_out_packet();
|
||||
ack.set_packet_id(0);
|
||||
crypto::encrypt_fake(&mut ack).unwrap();
|
||||
|
||||
let result = client.handle_data(&ack.to_bytes()).unwrap();
|
||||
assert_eq!(result.responses.len(), 1);
|
||||
|
||||
let clientinit = InPacket::parse(Direction::C2S, &result.responses[0]).unwrap();
|
||||
assert_eq!(clientinit.header.packet_id, 2);
|
||||
assert_eq!(clientinit.header.flags.packet_type(), PacketType::Command);
|
||||
|
||||
let mut key_cache = KeyCache::new();
|
||||
let secret = client.shared_secret.as_ref().unwrap();
|
||||
let content = crypto::decrypt_packet(&clientinit, 0, &secret.iv, &mut key_cache).unwrap();
|
||||
let command = Command::parse(&String::from_utf8(content).unwrap()).unwrap();
|
||||
assert_eq!(command.name, "clientinit");
|
||||
assert_eq!(command.get("client_nickname"), Some("Test"));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
//! Connection management
|
||||
|
||||
pub mod client;
|
||||
pub mod resend;
|
||||
pub mod session;
|
||||
pub mod state;
|
||||
|
||||
pub use client::*;
|
||||
pub use resend::*;
|
||||
pub use session::*;
|
||||
pub use state::*;
|
||||
@@ -1,257 +0,0 @@
|
||||
//! Packet retransmission and acknowledgment system
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Packet ID
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct PacketId {
|
||||
pub generation_id: u32,
|
||||
pub packet_id: u16,
|
||||
}
|
||||
|
||||
impl PacketId {
|
||||
pub fn new(generation_id: u32, packet_id: u16) -> Self {
|
||||
Self {
|
||||
generation_id,
|
||||
packet_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment(&mut self) {
|
||||
let (new_id, overflow) = self.packet_id.overflowing_add(1);
|
||||
self.packet_id = new_id;
|
||||
if overflow {
|
||||
self.generation_id += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sent packet information
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SentPacket {
|
||||
pub data: Vec<u8>,
|
||||
pub sent_at: Instant,
|
||||
pub retry_count: u32,
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
impl SentPacket {
|
||||
pub fn new(data: Vec<u8>) -> Self {
|
||||
Self {
|
||||
data,
|
||||
sent_at: Instant::now(),
|
||||
retry_count: 0,
|
||||
timeout: Duration::from_millis(500), // Initial timeout 500ms
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.sent_at.elapsed() > self.timeout
|
||||
}
|
||||
|
||||
pub fn should_retry(&self, max_retries: u32) -> bool {
|
||||
self.is_expired() && self.retry_count < max_retries
|
||||
}
|
||||
|
||||
pub fn retry(&mut self) {
|
||||
self.retry_count += 1;
|
||||
self.sent_at = Instant::now();
|
||||
// Exponential backoff
|
||||
self.timeout = Duration::from_millis(500 * (1 << self.retry_count).min(32));
|
||||
}
|
||||
}
|
||||
|
||||
/// Retransmission manager
|
||||
pub struct ResendManager {
|
||||
/// Packets awaiting acknowledgment
|
||||
pending: BTreeMap<PacketId, SentPacket>,
|
||||
/// Maximum retry count
|
||||
max_retries: u32,
|
||||
/// Connection timeout
|
||||
connection_timeout: Duration,
|
||||
}
|
||||
|
||||
impl ResendManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pending: BTreeMap::new(),
|
||||
max_retries: 10,
|
||||
connection_timeout: Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add sent packet
|
||||
pub fn add_sent(&mut self, id: PacketId, data: Vec<u8>) {
|
||||
self.pending.insert(id, SentPacket::new(data));
|
||||
}
|
||||
|
||||
/// Acknowledge packet
|
||||
pub fn ack(&mut self, id: &PacketId) -> bool {
|
||||
self.pending.remove(id).is_some()
|
||||
}
|
||||
|
||||
/// Get packets that need retransmission
|
||||
pub fn get_retransmissions(&mut self) -> Vec<(PacketId, Vec<u8>)> {
|
||||
let mut retransmissions = Vec::new();
|
||||
let mut to_retry = Vec::new();
|
||||
|
||||
for (id, packet) in self.pending.iter() {
|
||||
if packet.should_retry(self.max_retries) {
|
||||
to_retry.push(*id);
|
||||
}
|
||||
}
|
||||
|
||||
for id in to_retry {
|
||||
if let Some(packet) = self.pending.get_mut(&id) {
|
||||
packet.retry();
|
||||
retransmissions.push((id, packet.data.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
retransmissions
|
||||
}
|
||||
|
||||
/// Check if connection timed out
|
||||
pub fn is_connection_timeout(&self) -> bool {
|
||||
self.pending
|
||||
.values()
|
||||
.any(|p| p.sent_at.elapsed() > self.connection_timeout)
|
||||
}
|
||||
|
||||
/// Get number of pending packets
|
||||
pub fn pending_count(&self) -> usize {
|
||||
self.pending.len()
|
||||
}
|
||||
|
||||
/// Clear all pending packets
|
||||
pub fn clear(&mut self) {
|
||||
self.pending.clear();
|
||||
}
|
||||
|
||||
/// Set maximum retry count
|
||||
pub fn set_max_retries(&mut self, max_retries: u32) {
|
||||
self.max_retries = max_retries;
|
||||
}
|
||||
|
||||
/// Set connection timeout
|
||||
pub fn set_connection_timeout(&mut self, timeout: Duration) {
|
||||
self.connection_timeout = timeout;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ResendManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// RTT estimator
|
||||
pub struct RttEstimator {
|
||||
srtt: Duration,
|
||||
rtt_var: Duration,
|
||||
rto: Duration,
|
||||
}
|
||||
|
||||
impl RttEstimator {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
srtt: Duration::from_millis(500),
|
||||
rtt_var: Duration::from_millis(250),
|
||||
rto: Duration::from_millis(1000),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update RTT estimate
|
||||
pub fn update(&mut self, measured_rtt: Duration) {
|
||||
let alpha = 0.125;
|
||||
let beta = 0.25;
|
||||
|
||||
let diff = measured_rtt.abs_diff(self.srtt);
|
||||
|
||||
self.rtt_var = Duration::from_secs_f64(
|
||||
(1.0 - beta) * self.rtt_var.as_secs_f64() + beta * diff.as_secs_f64(),
|
||||
);
|
||||
|
||||
self.srtt = Duration::from_secs_f64(
|
||||
(1.0 - alpha) * self.srtt.as_secs_f64() + alpha * measured_rtt.as_secs_f64(),
|
||||
);
|
||||
|
||||
self.rto = self.srtt + self.rtt_var * 4;
|
||||
// Clamp RTO range
|
||||
if self.rto < Duration::from_millis(100) {
|
||||
self.rto = Duration::from_millis(100);
|
||||
}
|
||||
if self.rto > Duration::from_secs(60) {
|
||||
self.rto = Duration::from_secs(60);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current RTO
|
||||
pub fn rto(&self) -> Duration {
|
||||
self.rto
|
||||
}
|
||||
|
||||
/// Get smoothed RTT
|
||||
pub fn srtt(&self) -> Duration {
|
||||
self.srtt
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RttEstimator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_resend_manager() {
|
||||
let mut manager = ResendManager::new();
|
||||
|
||||
let id = PacketId::new(0, 1);
|
||||
manager.add_sent(id, vec![1, 2, 3]);
|
||||
|
||||
assert_eq!(manager.pending_count(), 1);
|
||||
|
||||
// Acknowledge
|
||||
assert!(manager.ack(&id));
|
||||
assert_eq!(manager.pending_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rtt_estimator() {
|
||||
let mut estimator = RttEstimator::new();
|
||||
|
||||
// Initial SRTT is 500ms
|
||||
assert_eq!(estimator.srtt(), Duration::from_millis(500));
|
||||
|
||||
// Update multiple times, SRTT should converge
|
||||
for _ in 0..100 {
|
||||
estimator.update(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
// After many updates, SRTT should approach 100ms
|
||||
assert!(estimator.srtt() < Duration::from_millis(150));
|
||||
// RTO should be greater than SRTT
|
||||
assert!(estimator.rto() > estimator.srtt());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sent_packet_retry() {
|
||||
let mut packet = SentPacket::new(vec![1, 2, 3]);
|
||||
assert!(!packet.is_expired());
|
||||
|
||||
// Simulate timeout
|
||||
packet.sent_at = Instant::now() - Duration::from_millis(600);
|
||||
assert!(packet.is_expired());
|
||||
assert!(packet.should_retry(10));
|
||||
|
||||
packet.retry();
|
||||
assert_eq!(packet.retry_count, 1);
|
||||
assert!(!packet.is_expired());
|
||||
}
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
use std::time::Duration;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::client::{ChannelEntry, Client, ClientConfig, ClientEntry, CommandEvent};
|
||||
use super::state::ConnectionState;
|
||||
use crate::protocol::{parse_voice_packet, Direction, InPacket, PacketType, VoiceData};
|
||||
use crate::ProtocolError;
|
||||
|
||||
pub enum SessionCommand {
|
||||
SendCommand(Vec<u8>),
|
||||
JoinChannel {
|
||||
channel_id: u64,
|
||||
password: Option<String>,
|
||||
},
|
||||
MoveClient {
|
||||
client_id: u16,
|
||||
channel_id: u64,
|
||||
},
|
||||
SendTextMessage {
|
||||
target_mode: TextMessageTarget,
|
||||
target_id: u64,
|
||||
message: String,
|
||||
},
|
||||
RequestChannelList,
|
||||
RequestClientList,
|
||||
Disconnect,
|
||||
}
|
||||
|
||||
pub enum TextMessageTarget {
|
||||
Server = 3,
|
||||
Channel = 2,
|
||||
Client = 1,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub enum SessionEvent {
|
||||
Connected {
|
||||
client_id: u16,
|
||||
},
|
||||
ChannelList(Vec<ChannelEntry>),
|
||||
ClientList(Vec<ClientEntry>),
|
||||
ClientEntered {
|
||||
clid: u16,
|
||||
cid: u64,
|
||||
client_nickname: String,
|
||||
},
|
||||
ClientLeft {
|
||||
clid: u16,
|
||||
reason: String,
|
||||
},
|
||||
ClientMoved {
|
||||
clid: u16,
|
||||
cid: u64,
|
||||
},
|
||||
TextMessage {
|
||||
invoker_id: u16,
|
||||
invoker_name: String,
|
||||
message: String,
|
||||
target_mode: u8,
|
||||
},
|
||||
ServerInfo {
|
||||
name: String,
|
||||
platform: String,
|
||||
version: String,
|
||||
max_clients: u16,
|
||||
clients_online: u16,
|
||||
channels_online: u16,
|
||||
},
|
||||
VoiceData {
|
||||
codec: u8,
|
||||
packet_id: u16,
|
||||
audio_data: Vec<u8>,
|
||||
is_whisper: bool,
|
||||
},
|
||||
Error(String),
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
pub struct SessionHandle {
|
||||
command_tx: mpsc::Sender<SessionCommand>,
|
||||
event_rx: mpsc::Receiver<SessionEvent>,
|
||||
}
|
||||
|
||||
impl SessionHandle {
|
||||
pub async fn send_raw_command(&self, command: Vec<u8>) -> Result<(), ProtocolError> {
|
||||
self.command_tx
|
||||
.send(SessionCommand::SendCommand(command))
|
||||
.await
|
||||
.map_err(|_| ProtocolError::ConnectionClosed)
|
||||
}
|
||||
|
||||
pub async fn send_command_str(&self, command: &str) -> Result<(), ProtocolError> {
|
||||
self.send_raw_command(command.as_bytes().to_vec()).await
|
||||
}
|
||||
|
||||
pub async fn join_channel(
|
||||
&self,
|
||||
channel_id: u64,
|
||||
password: Option<String>,
|
||||
) -> Result<(), ProtocolError> {
|
||||
self.command_tx
|
||||
.send(SessionCommand::JoinChannel {
|
||||
channel_id,
|
||||
password,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::ConnectionClosed)
|
||||
}
|
||||
|
||||
pub async fn move_client(&self, client_id: u16, channel_id: u64) -> Result<(), ProtocolError> {
|
||||
self.command_tx
|
||||
.send(SessionCommand::MoveClient {
|
||||
client_id,
|
||||
channel_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::ConnectionClosed)
|
||||
}
|
||||
|
||||
pub async fn send_server_message(&self, message: &str) -> Result<(), ProtocolError> {
|
||||
self.command_tx
|
||||
.send(SessionCommand::SendTextMessage {
|
||||
target_mode: TextMessageTarget::Server,
|
||||
target_id: 0,
|
||||
message: message.to_string(),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::ConnectionClosed)
|
||||
}
|
||||
|
||||
pub async fn send_channel_message(&self, message: &str) -> Result<(), ProtocolError> {
|
||||
self.command_tx
|
||||
.send(SessionCommand::SendTextMessage {
|
||||
target_mode: TextMessageTarget::Channel,
|
||||
target_id: 0,
|
||||
message: message.to_string(),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::ConnectionClosed)
|
||||
}
|
||||
|
||||
pub async fn send_private_message(
|
||||
&self,
|
||||
client_id: u64,
|
||||
message: &str,
|
||||
) -> Result<(), ProtocolError> {
|
||||
self.command_tx
|
||||
.send(SessionCommand::SendTextMessage {
|
||||
target_mode: TextMessageTarget::Client,
|
||||
target_id: client_id,
|
||||
message: message.to_string(),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::ConnectionClosed)
|
||||
}
|
||||
|
||||
pub async fn request_channel_list(&self) -> Result<(), ProtocolError> {
|
||||
self.command_tx
|
||||
.send(SessionCommand::RequestChannelList)
|
||||
.await
|
||||
.map_err(|_| ProtocolError::ConnectionClosed)
|
||||
}
|
||||
|
||||
pub async fn request_client_list(&self) -> Result<(), ProtocolError> {
|
||||
self.command_tx
|
||||
.send(SessionCommand::RequestClientList)
|
||||
.await
|
||||
.map_err(|_| ProtocolError::ConnectionClosed)
|
||||
}
|
||||
|
||||
pub async fn disconnect(&self) -> Result<(), ProtocolError> {
|
||||
self.command_tx
|
||||
.send(SessionCommand::Disconnect)
|
||||
.await
|
||||
.map_err(|_| ProtocolError::ConnectionClosed)
|
||||
}
|
||||
|
||||
pub async fn recv_event(&mut self) -> Option<SessionEvent> {
|
||||
self.event_rx.recv().await
|
||||
}
|
||||
|
||||
pub fn try_recv_event(&mut self) -> Option<SessionEvent> {
|
||||
self.event_rx.try_recv().ok()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
client: Client,
|
||||
socket: UdpSocket,
|
||||
command_rx: mpsc::Receiver<SessionCommand>,
|
||||
event_tx: mpsc::Sender<SessionEvent>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub async fn connect(
|
||||
config: ClientConfig,
|
||||
timeout: Duration,
|
||||
) -> Result<(Self, SessionHandle), ProtocolError> {
|
||||
let bind_addr = if config.address.is_ipv4() {
|
||||
"0.0.0.0:0"
|
||||
} else {
|
||||
"[::]:0"
|
||||
};
|
||||
let socket = UdpSocket::bind(bind_addr).await?;
|
||||
socket.connect(config.address).await?;
|
||||
|
||||
let mut client = Client::new(config);
|
||||
let init0 = client.start_handshake()?;
|
||||
socket.send(&init0).await?;
|
||||
|
||||
let mut init_events = Vec::new();
|
||||
|
||||
tokio::time::timeout(timeout, async {
|
||||
let mut buf = [0u8; 2048];
|
||||
loop {
|
||||
let len = socket.recv(&mut buf).await?;
|
||||
let result = client.handle_data(&buf[..len])?;
|
||||
for response in &result.responses {
|
||||
socket.send(response).await?;
|
||||
}
|
||||
init_events.extend(result.events);
|
||||
|
||||
if client.state() == ConnectionState::ChannelListFinished {
|
||||
return Ok::<(), ProtocolError>(());
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Timeout("handshake timed out".to_string()))??;
|
||||
|
||||
let (command_tx, command_rx) = mpsc::channel(32);
|
||||
let (event_tx, event_rx) = mpsc::channel(32);
|
||||
|
||||
let session = Self {
|
||||
client,
|
||||
socket,
|
||||
command_rx,
|
||||
event_tx,
|
||||
};
|
||||
|
||||
let handle = SessionHandle {
|
||||
command_tx,
|
||||
event_rx,
|
||||
};
|
||||
|
||||
Ok((session, handle))
|
||||
}
|
||||
|
||||
pub fn client_id(&self) -> Option<u16> {
|
||||
self.client.client_id()
|
||||
}
|
||||
|
||||
pub fn state(&self) -> ConnectionState {
|
||||
self.client.state()
|
||||
}
|
||||
|
||||
pub async fn run(&mut self) -> Result<(), ProtocolError> {
|
||||
let mut buf = [0u8; 2048];
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = self.socket.recv(&mut buf) => {
|
||||
let len = result?;
|
||||
let data = &buf[..len];
|
||||
|
||||
if let Ok(packet) = InPacket::parse(Direction::S2C, data) {
|
||||
let packet_type = packet.header.flags.packet_type();
|
||||
|
||||
if packet_type == PacketType::Voice || packet_type == PacketType::VoiceWhisper {
|
||||
self.handle_voice_packet(&packet).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let handle_result = self.client.handle_data(data)?;
|
||||
for response in &handle_result.responses {
|
||||
self.socket.send(response).await?;
|
||||
}
|
||||
for event in handle_result.events {
|
||||
self.emit_session_event(event).await;
|
||||
}
|
||||
}
|
||||
Some(command) = self.command_rx.recv() => {
|
||||
match self.handle_command(command).await {
|
||||
Ok(()) => {}
|
||||
Err(ProtocolError::ConnectionClosed) => {
|
||||
let _ = self.event_tx.send(SessionEvent::Disconnected).await;
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = self.event_tx.send(SessionEvent::Error(e.to_string())).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_voice_packet(&mut self, packet: &InPacket) {
|
||||
let voice_data = match parse_voice_packet(packet) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
match voice_data {
|
||||
VoiceData::Normal(voice) => {
|
||||
let _ = self
|
||||
.event_tx
|
||||
.send(SessionEvent::VoiceData {
|
||||
codec: voice.codec.to_u8(),
|
||||
packet_id: voice.packet_id,
|
||||
audio_data: voice.audio_data,
|
||||
is_whisper: false,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
VoiceData::Whisper(whisper) => {
|
||||
let _ = self
|
||||
.event_tx
|
||||
.send(SessionEvent::VoiceData {
|
||||
codec: whisper.codec.to_u8(),
|
||||
packet_id: whisper.packet_id,
|
||||
audio_data: whisper.audio_data,
|
||||
is_whisper: true,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn emit_session_event(&self, event: CommandEvent) {
|
||||
let session_event = match event {
|
||||
CommandEvent::InitServer {
|
||||
client_id,
|
||||
name,
|
||||
platform,
|
||||
version,
|
||||
max_clients,
|
||||
clients_online,
|
||||
channels_online,
|
||||
} => {
|
||||
let _ = self
|
||||
.event_tx
|
||||
.send(SessionEvent::ServerInfo {
|
||||
name,
|
||||
platform,
|
||||
version,
|
||||
max_clients,
|
||||
clients_online,
|
||||
channels_online,
|
||||
})
|
||||
.await;
|
||||
SessionEvent::Connected { client_id }
|
||||
}
|
||||
CommandEvent::ChannelList(channels) => SessionEvent::ChannelList(channels),
|
||||
CommandEvent::ChannelListFinished => return,
|
||||
CommandEvent::ClientList(clients) => SessionEvent::ClientList(clients),
|
||||
CommandEvent::ClientEntered {
|
||||
clid,
|
||||
cid,
|
||||
client_nickname,
|
||||
} => SessionEvent::ClientEntered {
|
||||
clid,
|
||||
cid,
|
||||
client_nickname,
|
||||
},
|
||||
CommandEvent::ClientLeft { clid, reason } => SessionEvent::ClientLeft { clid, reason },
|
||||
CommandEvent::ClientMoved { clid, cid } => SessionEvent::ClientMoved { clid, cid },
|
||||
CommandEvent::TextMessage {
|
||||
invoker_id,
|
||||
invoker_name,
|
||||
message,
|
||||
target_mode,
|
||||
} => SessionEvent::TextMessage {
|
||||
invoker_id,
|
||||
invoker_name,
|
||||
message,
|
||||
target_mode,
|
||||
},
|
||||
CommandEvent::Error { id, message } => {
|
||||
SessionEvent::Error(format!("server error {id}: {message}"))
|
||||
}
|
||||
};
|
||||
|
||||
let _ = self.event_tx.send(session_event).await;
|
||||
}
|
||||
|
||||
async fn handle_command(&mut self, command: SessionCommand) -> Result<(), ProtocolError> {
|
||||
match command {
|
||||
SessionCommand::SendCommand(content) => {
|
||||
let packet = self.client.build_command_packet(content)?;
|
||||
self.socket.send(&packet).await?;
|
||||
}
|
||||
SessionCommand::JoinChannel {
|
||||
channel_id,
|
||||
password,
|
||||
} => {
|
||||
let client_id = self.client.client_id().unwrap_or(0);
|
||||
let mut cmd = format!("clientmove clid={client_id} cid={channel_id}");
|
||||
if let Some(pwd) = password {
|
||||
cmd.push_str(&format!(" cpw={pwd}"));
|
||||
}
|
||||
let packet = self.client.build_command_packet(cmd.into_bytes())?;
|
||||
self.socket.send(&packet).await?;
|
||||
}
|
||||
SessionCommand::MoveClient {
|
||||
client_id,
|
||||
channel_id,
|
||||
} => {
|
||||
let cmd = format!("clientmove clid={client_id} cid={channel_id}");
|
||||
let packet = self.client.build_command_packet(cmd.into_bytes())?;
|
||||
self.socket.send(&packet).await?;
|
||||
}
|
||||
SessionCommand::SendTextMessage {
|
||||
target_mode,
|
||||
target_id,
|
||||
message,
|
||||
} => {
|
||||
let cmd = format!(
|
||||
"sendtextmessage targetmode={} target={} msg={}",
|
||||
target_mode as u8,
|
||||
target_id,
|
||||
crate::query::escape(&message)
|
||||
);
|
||||
let packet = self.client.build_command_packet(cmd.into_bytes())?;
|
||||
self.socket.send(&packet).await?;
|
||||
}
|
||||
SessionCommand::RequestChannelList => {
|
||||
let packet = self.client.build_command_packet(b"channellist".to_vec())?;
|
||||
self.socket.send(&packet).await?;
|
||||
}
|
||||
SessionCommand::RequestClientList => {
|
||||
let packet = self.client.build_command_packet(b"clientlist".to_vec())?;
|
||||
self.socket.send(&packet).await?;
|
||||
}
|
||||
SessionCommand::Disconnect => {
|
||||
let packet = self
|
||||
.client
|
||||
.build_command_packet(b"clientdisconnect".to_vec())?;
|
||||
self.socket.send(&packet).await?;
|
||||
return Err(ProtocolError::ConnectionClosed);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
//! Connection state management
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Connection state
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConnectionState {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
IdentityLevelIncreasing,
|
||||
Connected,
|
||||
ChannelListFinished,
|
||||
DisconnectedTemporarily,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl ConnectionState {
|
||||
pub fn is_connected(&self) -> bool {
|
||||
matches!(self, Self::Connected | Self::ChannelListFinished)
|
||||
}
|
||||
|
||||
pub fn is_connecting(&self) -> bool {
|
||||
matches!(self, Self::Connecting | Self::IdentityLevelIncreasing)
|
||||
}
|
||||
|
||||
pub fn is_disconnected(&self) -> bool {
|
||||
matches!(self, Self::Disconnected | Self::Error)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ConnectionState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Disconnected => write!(f, "Disconnected"),
|
||||
Self::Connecting => write!(f, "Connecting"),
|
||||
Self::IdentityLevelIncreasing => write!(f, "IdentityLevelIncreasing"),
|
||||
Self::Connected => write!(f, "Connected"),
|
||||
Self::ChannelListFinished => write!(f, "ChannelListFinished"),
|
||||
Self::DisconnectedTemporarily => write!(f, "DisconnectedTemporarily"),
|
||||
Self::Error => write!(f, "Error"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connection state machine
|
||||
pub struct ConnectionStateMachine {
|
||||
state: ConnectionState,
|
||||
}
|
||||
|
||||
impl ConnectionStateMachine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: ConnectionState::Disconnected,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> ConnectionState {
|
||||
self.state
|
||||
}
|
||||
|
||||
pub fn transition(&mut self, new_state: ConnectionState) -> Result<(), String> {
|
||||
let valid = matches!(
|
||||
(self.state, new_state),
|
||||
(ConnectionState::Disconnected, ConnectionState::Connecting)
|
||||
| (
|
||||
ConnectionState::Connecting,
|
||||
ConnectionState::IdentityLevelIncreasing
|
||||
)
|
||||
| (ConnectionState::Connecting, ConnectionState::Connected)
|
||||
| (
|
||||
ConnectionState::IdentityLevelIncreasing,
|
||||
ConnectionState::Connected
|
||||
)
|
||||
| (
|
||||
ConnectionState::Connected,
|
||||
ConnectionState::ChannelListFinished
|
||||
)
|
||||
| (
|
||||
ConnectionState::Connected,
|
||||
ConnectionState::DisconnectedTemporarily
|
||||
)
|
||||
| (
|
||||
ConnectionState::ChannelListFinished,
|
||||
ConnectionState::DisconnectedTemporarily
|
||||
)
|
||||
| (
|
||||
ConnectionState::DisconnectedTemporarily,
|
||||
ConnectionState::Connected
|
||||
)
|
||||
| (
|
||||
ConnectionState::DisconnectedTemporarily,
|
||||
ConnectionState::Disconnected
|
||||
)
|
||||
| (_, ConnectionState::Error)
|
||||
| (ConnectionState::Error, ConnectionState::Disconnected)
|
||||
);
|
||||
|
||||
if valid {
|
||||
self.state = new_state;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Invalid state transition: {} -> {}",
|
||||
self.state, new_state
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConnectionStateMachine {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
//! EAX mode encryption
|
||||
|
||||
use aes::Aes128;
|
||||
use eax::aead::consts::U8;
|
||||
use eax::{AeadInPlace, Eax, KeyInit};
|
||||
use generic_array::GenericArray;
|
||||
|
||||
use super::keys;
|
||||
use crate::protocol::{InPacket, OutPacket};
|
||||
use crate::ProtocolError;
|
||||
|
||||
/// EAX cipher
|
||||
pub struct EaxCipher {
|
||||
cipher: Eax<Aes128, U8>,
|
||||
}
|
||||
|
||||
impl EaxCipher {
|
||||
pub fn new(key: &[u8; 16]) -> Self {
|
||||
let key = GenericArray::from_slice(key);
|
||||
Self {
|
||||
cipher: Eax::<Aes128, U8>::new(key),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encrypt(
|
||||
&self,
|
||||
nonce: &[u8; 16],
|
||||
header: &[u8],
|
||||
data: &mut [u8],
|
||||
) -> Result<[u8; 8], ProtocolError> {
|
||||
let nonce = GenericArray::from_slice(nonce);
|
||||
let tag = self
|
||||
.cipher
|
||||
.encrypt_in_place_detached(nonce, header, data)
|
||||
.map_err(|_| ProtocolError::Encryption("EAX encryption failed".to_string()))?;
|
||||
|
||||
let mut mac = [0u8; 8];
|
||||
mac.copy_from_slice(&tag[..8]);
|
||||
Ok(mac)
|
||||
}
|
||||
|
||||
pub fn decrypt(
|
||||
&self,
|
||||
nonce: &[u8; 16],
|
||||
header: &[u8],
|
||||
data: &mut [u8],
|
||||
mac: &[u8; 8],
|
||||
) -> Result<(), ProtocolError> {
|
||||
let nonce = GenericArray::from_slice(nonce);
|
||||
let tag = GenericArray::from_slice(mac);
|
||||
|
||||
self.cipher
|
||||
.decrypt_in_place_detached(nonce, header, data, tag)
|
||||
.map_err(|_| ProtocolError::Decryption("MAC verification failed".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt packet
|
||||
pub fn encrypt_packet(
|
||||
packet: &mut OutPacket,
|
||||
generation_id: u32,
|
||||
iv: &[u8; 64],
|
||||
key_cache: &mut keys::KeyCache,
|
||||
) -> Result<(), ProtocolError> {
|
||||
let packet_type = packet.header.flags.packet_type();
|
||||
let direction = packet.direction;
|
||||
let packet_id = packet.header.packet_id;
|
||||
|
||||
let (key, nonce) = key_cache.get_or_create(packet_type, direction, generation_id, iv);
|
||||
let enc_key = keys::create_encryption_key(&key, packet_id);
|
||||
|
||||
let cipher = EaxCipher::new(&enc_key);
|
||||
let meta = packet.header.get_meta(direction);
|
||||
let mac = cipher.encrypt(&nonce, &meta, &mut packet.data)?;
|
||||
packet.header.mac = mac;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decrypt packet
|
||||
pub fn decrypt_packet(
|
||||
packet: &InPacket,
|
||||
generation_id: u32,
|
||||
iv: &[u8; 64],
|
||||
key_cache: &mut keys::KeyCache,
|
||||
) -> Result<Vec<u8>, ProtocolError> {
|
||||
let packet_type = packet.header.flags.packet_type();
|
||||
let direction = packet.direction;
|
||||
let packet_id = packet.header.packet_id;
|
||||
|
||||
let (key, nonce) = key_cache.get_or_create(packet_type, direction, generation_id, iv);
|
||||
let enc_key = keys::create_encryption_key(&key, packet_id);
|
||||
|
||||
let cipher = EaxCipher::new(&enc_key);
|
||||
let meta = packet.header.get_meta(direction);
|
||||
let mut data = packet.data.clone();
|
||||
cipher.decrypt(&nonce, &meta, &mut data, &packet.header.mac)?;
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Fake encryption
|
||||
pub fn encrypt_fake(packet: &mut OutPacket) -> Result<(), ProtocolError> {
|
||||
let cipher = EaxCipher::new(&keys::FAKE_KEY);
|
||||
let meta = packet.header.get_meta(packet.direction);
|
||||
let mac = cipher.encrypt(&keys::FAKE_NONCE, &meta, &mut packet.data)?;
|
||||
packet.header.mac = mac;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fake decryption
|
||||
pub fn decrypt_fake(packet: &InPacket) -> Result<Vec<u8>, ProtocolError> {
|
||||
let cipher = EaxCipher::new(&keys::FAKE_KEY);
|
||||
let meta = packet.header.get_meta(packet.direction);
|
||||
let mut data = packet.data.clone();
|
||||
cipher.decrypt(&keys::FAKE_NONCE, &meta, &mut data, &packet.header.mac)?;
|
||||
Ok(data)
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
use curve25519_dalek_ng::constants::X25519_BASEPOINT;
|
||||
use curve25519_dalek_ng::montgomery::MontgomeryPoint;
|
||||
use curve25519_dalek_ng::scalar::Scalar;
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha512};
|
||||
|
||||
pub struct EphemeralKey {
|
||||
private: Scalar,
|
||||
public: MontgomeryPoint,
|
||||
}
|
||||
|
||||
impl EphemeralKey {
|
||||
pub fn generate() -> Self {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::Rng::fill(&mut rand::thread_rng(), &mut bytes);
|
||||
let private = Scalar::from_bytes_mod_order(bytes);
|
||||
let public = X25519_BASEPOINT * private;
|
||||
Self { private, public }
|
||||
}
|
||||
|
||||
pub fn from_private_bytes(bytes: &[u8; 32]) -> Self {
|
||||
let private = Scalar::from_bytes_mod_order(*bytes);
|
||||
let public = X25519_BASEPOINT * private;
|
||||
Self { private, public }
|
||||
}
|
||||
|
||||
pub fn public_bytes(&self) -> [u8; 32] {
|
||||
self.public.to_bytes()
|
||||
}
|
||||
|
||||
pub fn compute_shared_secret(&self, other_public: &MontgomeryPoint) -> [u8; 32] {
|
||||
let shared = other_public * self.private;
|
||||
shared.to_bytes()
|
||||
}
|
||||
|
||||
pub fn public_point(&self) -> &MontgomeryPoint {
|
||||
&self.public
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_iv_mac(
|
||||
alpha: &[u8; 10],
|
||||
beta: &[u8; 54],
|
||||
shared_secret: &[u8; 32],
|
||||
) -> ([u8; 64], [u8; 8]) {
|
||||
let mut hasher = Sha512::new();
|
||||
hasher.update(shared_secret);
|
||||
let hash = hasher.finalize();
|
||||
|
||||
let mut iv = [0u8; 64];
|
||||
iv.copy_from_slice(&hash);
|
||||
|
||||
for i in 0..10 {
|
||||
iv[i] ^= alpha[i];
|
||||
}
|
||||
for i in 0..54 {
|
||||
iv[i + 10] ^= beta[i];
|
||||
}
|
||||
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(iv);
|
||||
let mac_hash = hasher.finalize();
|
||||
|
||||
let mut mac = [0u8; 8];
|
||||
mac.copy_from_slice(&mac_hash[..8]);
|
||||
|
||||
(iv, mac)
|
||||
}
|
||||
|
||||
pub fn parse_x25519_public_key(bytes: &[u8; 32]) -> MontgomeryPoint {
|
||||
MontgomeryPoint(*bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ephemeral_key_generates_nonzero_public_key() {
|
||||
let key = EphemeralKey::generate();
|
||||
assert_ne!(key.public_bytes(), [0u8; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ephemeral_key_from_bytes_produces_expected_public_key() {
|
||||
let bytes = [1u8; 32];
|
||||
let key = EphemeralKey::from_private_bytes(&bytes);
|
||||
assert_ne!(key.public_bytes(), [0u8; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ecdh_shared_secret_is_symmetric() {
|
||||
let alice = EphemeralKey::generate();
|
||||
let bob = EphemeralKey::generate();
|
||||
|
||||
let alice_shared = alice.compute_shared_secret(bob.public_point());
|
||||
let bob_shared = bob.compute_shared_secret(alice.public_point());
|
||||
|
||||
assert_eq!(alice_shared, bob_shared);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_iv_mac_matches_manual_hash_computation() {
|
||||
let alpha = [1u8; 10];
|
||||
let beta = [2u8; 54];
|
||||
let shared_secret = [3u8; 32];
|
||||
|
||||
let (iv, mac) = compute_iv_mac(&alpha, &beta, &shared_secret);
|
||||
|
||||
let mut hasher = Sha512::new();
|
||||
hasher.update(&shared_secret);
|
||||
let hash = hasher.finalize();
|
||||
let mut expected_iv = [0u8; 64];
|
||||
expected_iv.copy_from_slice(&hash);
|
||||
for i in 0..10 {
|
||||
expected_iv[i] ^= alpha[i];
|
||||
}
|
||||
for i in 0..54 {
|
||||
expected_iv[i + 10] ^= beta[i];
|
||||
}
|
||||
assert_eq!(iv, expected_iv);
|
||||
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(&iv);
|
||||
let mac_hash = hasher.finalize();
|
||||
let mut expected_mac = [0u8; 8];
|
||||
expected_mac.copy_from_slice(&mac_hash[..8]);
|
||||
assert_eq!(mac, expected_mac);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_iv_mac_with_zero_shared_secret_matches_shared_secret_new() {
|
||||
let alpha = [42u8; 10];
|
||||
let beta = [7u8; 54];
|
||||
let shared_secret = [0u8; 32];
|
||||
|
||||
let (iv, mac) = compute_iv_mac(&alpha, &beta, &shared_secret);
|
||||
let secret = crate::crypto::SharedSecret::compute_new(&alpha, &beta, &shared_secret);
|
||||
|
||||
assert_eq!(iv, secret.iv);
|
||||
assert_eq!(mac, secret.mac);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_x25519_public_key_returns_montgomery_point() {
|
||||
let key = EphemeralKey::generate();
|
||||
let bytes = key.public_bytes();
|
||||
let parsed = parse_x25519_public_key(&bytes);
|
||||
assert_eq!(parsed.to_bytes(), bytes);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//! Hash functions
|
||||
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha256, Sha512};
|
||||
|
||||
/// SHA-1 hash
|
||||
pub fn sha1(data: &[u8]) -> [u8; 20] {
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(data);
|
||||
let result = hasher.finalize();
|
||||
let mut hash = [0u8; 20];
|
||||
hash.copy_from_slice(&result);
|
||||
hash
|
||||
}
|
||||
|
||||
/// SHA-256 hash
|
||||
pub fn sha256(data: &[u8]) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
let result = hasher.finalize();
|
||||
let mut hash = [0u8; 32];
|
||||
hash.copy_from_slice(&result);
|
||||
hash
|
||||
}
|
||||
|
||||
/// SHA-512 hash
|
||||
pub fn sha512(data: &[u8]) -> [u8; 64] {
|
||||
let mut hasher = Sha512::new();
|
||||
hasher.update(data);
|
||||
let result = hasher.finalize();
|
||||
let mut hash = [0u8; 64];
|
||||
hash.copy_from_slice(&result);
|
||||
hash
|
||||
}
|
||||
|
||||
/// Compute password hash
|
||||
pub fn hash_password(password: &str) -> String {
|
||||
let hash = sha1(password.as_bytes());
|
||||
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, hash)
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
//! TeamSpeak identity key handling.
|
||||
|
||||
use base64::Engine;
|
||||
use num_bigint::{BigInt, Sign};
|
||||
use p256::ecdsa::signature::Signer;
|
||||
use p256::ecdsa::SigningKey;
|
||||
use p256::elliptic_curve::sec1::ToEncodedPoint;
|
||||
use p256::SecretKey;
|
||||
use sha1::{Digest, Sha1};
|
||||
use simple_asn1::ASN1Block;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum IdentityError {
|
||||
#[error("invalid base64 private key: {0}")]
|
||||
Base64(#[from] base64::DecodeError),
|
||||
|
||||
#[error("invalid P-256 private key")]
|
||||
InvalidPrivateKey,
|
||||
|
||||
#[error("ASN.1 encode error: {0}")]
|
||||
Asn1Encode(#[from] simple_asn1::ASN1EncodeErr),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct IdentityKey {
|
||||
secret: SecretKey,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for IdentityKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("IdentityKey")
|
||||
.field("uid", &self.uid())
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl IdentityKey {
|
||||
pub fn generate() -> Self {
|
||||
Self {
|
||||
secret: SecretKey::random(&mut rand::thread_rng()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_private_key_base64(data: &str) -> Result<Self, IdentityError> {
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(data)?;
|
||||
if bytes.len() != 32 {
|
||||
return Err(IdentityError::InvalidPrivateKey);
|
||||
}
|
||||
|
||||
let secret = SecretKey::from_bytes(p256::FieldBytes::from_slice(&bytes))
|
||||
.map_err(|_| IdentityError::InvalidPrivateKey)?;
|
||||
Ok(Self { secret })
|
||||
}
|
||||
|
||||
pub fn private_key_base64(&self) -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(self.secret.to_bytes())
|
||||
}
|
||||
|
||||
pub fn public_key_tomcrypt(&self) -> Result<Vec<u8>, IdentityError> {
|
||||
let encoded = self.secret.public_key().to_encoded_point(false);
|
||||
let x = BigInt::from_bytes_be(Sign::Plus, encoded.x().expect("P-256 x coordinate"));
|
||||
let y = BigInt::from_bytes_be(Sign::Plus, encoded.y().expect("P-256 y coordinate"));
|
||||
|
||||
Ok(simple_asn1::to_der(&ASN1Block::Sequence(
|
||||
0,
|
||||
vec![
|
||||
ASN1Block::BitString(0, 1, vec![0]),
|
||||
ASN1Block::Integer(0, 32.into()),
|
||||
ASN1Block::Integer(0, x),
|
||||
ASN1Block::Integer(0, y),
|
||||
],
|
||||
))?)
|
||||
}
|
||||
|
||||
pub fn public_key_ts_base64(&self) -> Result<String, IdentityError> {
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(self.public_key_tomcrypt()?))
|
||||
}
|
||||
|
||||
pub fn uid(&self) -> String {
|
||||
let omega = self.public_key_ts_base64().unwrap_or_default();
|
||||
let hash = Sha1::digest(omega.as_bytes());
|
||||
base64::engine::general_purpose::STANDARD.encode(hash)
|
||||
}
|
||||
|
||||
pub fn sign_der_base64(&self, data: &[u8]) -> String {
|
||||
let signing_key = SigningKey::from(self.secret.clone());
|
||||
let signature: p256::ecdsa::DerSignature = signing_key.sign(data);
|
||||
base64::engine::general_purpose::STANDARD.encode(signature.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use p256::ecdsa::signature::Verifier;
|
||||
use p256::ecdsa::{Signature, VerifyingKey};
|
||||
|
||||
#[test]
|
||||
fn identity_round_trips_private_key() {
|
||||
let identity = IdentityKey::generate();
|
||||
let exported = identity.private_key_base64();
|
||||
let imported = IdentityKey::from_private_key_base64(&exported).unwrap();
|
||||
|
||||
assert_eq!(imported.private_key_base64(), exported);
|
||||
assert_eq!(
|
||||
imported.public_key_ts_base64().unwrap(),
|
||||
identity.public_key_ts_base64().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_produces_ts_public_key_and_uid() {
|
||||
let identity = IdentityKey::generate();
|
||||
let public_key = identity.public_key_tomcrypt().unwrap();
|
||||
let public_key_b64 = identity.public_key_ts_base64().unwrap();
|
||||
let uid = identity.uid();
|
||||
|
||||
assert!(public_key.starts_with(&[0x30]));
|
||||
assert!(public_key_b64.len() > 80);
|
||||
assert!(!uid.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_signs_verifiable_der_signature() {
|
||||
let identity = IdentityKey::generate();
|
||||
let data = b"client proof data";
|
||||
let signature = base64::engine::general_purpose::STANDARD
|
||||
.decode(identity.sign_der_base64(data))
|
||||
.unwrap();
|
||||
|
||||
let signing_key = SigningKey::from(identity.secret.clone());
|
||||
let verifying_key = VerifyingKey::from(&signing_key);
|
||||
let signature = Signature::from_der(&signature).unwrap();
|
||||
verifying_key.verify(data, &signature).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
//! Key management
|
||||
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha256, Sha512};
|
||||
|
||||
use crate::protocol::Direction;
|
||||
use crate::protocol::PacketType;
|
||||
|
||||
/// Fake encryption key
|
||||
pub const FAKE_KEY: [u8; 16] = *b"c:\\windows\\syste";
|
||||
|
||||
/// Fake encryption nonce
|
||||
pub const FAKE_NONCE: [u8; 16] = *b"m\\firewall32.cpl";
|
||||
|
||||
/// License root key
|
||||
pub const ROOT_KEY: [u8; 32] = [
|
||||
0xcd, 0x0d, 0xe2, 0xae, 0xd4, 0x63, 0x45, 0x50, 0x9a, 0x7e, 0x3c, 0xfd, 0x8f, 0x68, 0xb3, 0xdc,
|
||||
0x75, 0x55, 0xb2, 0x9d, 0xcc, 0xec, 0x73, 0xcd, 0x18, 0x75, 0x0f, 0x99, 0x38, 0x12, 0x40, 0x8a,
|
||||
];
|
||||
|
||||
/// Shared secret
|
||||
#[derive(Clone)]
|
||||
pub struct SharedSecret {
|
||||
pub iv: [u8; 64],
|
||||
pub mac: [u8; 8],
|
||||
}
|
||||
|
||||
impl SharedSecret {
|
||||
pub fn new(iv: [u8; 64], mac: [u8; 8]) -> Self {
|
||||
Self { iv, mac }
|
||||
}
|
||||
|
||||
pub fn compute_old(alpha: &[u8; 10], beta: &[u8; 10], shared_data: &[u8; 32]) -> Self {
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(shared_data);
|
||||
let hash = hasher.finalize();
|
||||
|
||||
let mut iv = [0u8; 64];
|
||||
iv[..20].copy_from_slice(&hash);
|
||||
|
||||
for i in 0..10 {
|
||||
iv[i] ^= alpha[i];
|
||||
}
|
||||
for i in 0..10 {
|
||||
iv[i + 10] ^= beta[i];
|
||||
}
|
||||
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(iv);
|
||||
let mac_hash = hasher.finalize();
|
||||
|
||||
let mut mac = [0u8; 8];
|
||||
mac.copy_from_slice(&mac_hash[..8]);
|
||||
|
||||
Self::new(iv, mac)
|
||||
}
|
||||
|
||||
pub fn compute_new(alpha: &[u8; 10], beta: &[u8; 54], shared_data: &[u8; 32]) -> Self {
|
||||
let mut hasher = Sha512::new();
|
||||
hasher.update(shared_data);
|
||||
let hash = hasher.finalize();
|
||||
|
||||
let mut iv = [0u8; 64];
|
||||
iv.copy_from_slice(&hash);
|
||||
|
||||
for i in 0..10 {
|
||||
iv[i] ^= alpha[i];
|
||||
}
|
||||
for i in 0..54 {
|
||||
iv[i + 10] ^= beta[i];
|
||||
}
|
||||
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(iv);
|
||||
let mac_hash = hasher.finalize();
|
||||
|
||||
let mut mac = [0u8; 8];
|
||||
mac.copy_from_slice(&mac_hash[..8]);
|
||||
|
||||
Self::new(iv, mac)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SharedSecret {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "SharedSecret {{ iv: [hidden], mac: [hidden] }}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached key
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CachedKey {
|
||||
pub generation_id: u32,
|
||||
pub key: [u8; 16],
|
||||
pub nonce: [u8; 16],
|
||||
}
|
||||
|
||||
impl CachedKey {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
generation_id: u32::MAX,
|
||||
key: [0; 16],
|
||||
nonce: [0; 16],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_valid(&self, generation_id: u32) -> bool {
|
||||
self.generation_id == generation_id
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CachedKey {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Key cache
|
||||
pub struct KeyCache {
|
||||
cache: [[CachedKey; 2]; 8],
|
||||
}
|
||||
|
||||
impl KeyCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_or_create(
|
||||
&mut self,
|
||||
packet_type: PacketType,
|
||||
direction: Direction,
|
||||
generation_id: u32,
|
||||
iv: &[u8; 64],
|
||||
) -> ([u8; 16], [u8; 16]) {
|
||||
let type_idx = packet_type.to_usize();
|
||||
let dir_idx = match direction {
|
||||
Direction::C2S => 1,
|
||||
Direction::S2C => 0,
|
||||
};
|
||||
|
||||
let cached = &mut self.cache[type_idx][dir_idx];
|
||||
if !cached.is_valid(generation_id) {
|
||||
let (key, nonce) = create_key_nonce(packet_type, direction, generation_id, iv);
|
||||
cached.generation_id = generation_id;
|
||||
cached.key = key;
|
||||
cached.nonce = nonce;
|
||||
}
|
||||
|
||||
(cached.key, cached.nonce)
|
||||
}
|
||||
|
||||
pub fn invalidate(&mut self) {
|
||||
self.cache = Default::default();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for KeyCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create key and nonce
|
||||
pub fn create_key_nonce(
|
||||
packet_type: PacketType,
|
||||
direction: Direction,
|
||||
generation_id: u32,
|
||||
iv: &[u8; 64],
|
||||
) -> ([u8; 16], [u8; 16]) {
|
||||
let mut temp = [0u8; 70];
|
||||
|
||||
temp[0] = match direction {
|
||||
Direction::C2S => 0x31,
|
||||
Direction::S2C => 0x30,
|
||||
};
|
||||
|
||||
temp[1] = packet_type.to_u8();
|
||||
temp[2..6].copy_from_slice(&generation_id.to_be_bytes());
|
||||
temp[6..].copy_from_slice(iv);
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(temp);
|
||||
let hash = hasher.finalize();
|
||||
|
||||
let mut key = [0u8; 16];
|
||||
let mut nonce = [0u8; 16];
|
||||
key.copy_from_slice(&hash[..16]);
|
||||
nonce.copy_from_slice(&hash[16..]);
|
||||
|
||||
(key, nonce)
|
||||
}
|
||||
|
||||
/// Create encryption key
|
||||
pub fn create_encryption_key(key: &[u8; 16], packet_id: u16) -> [u8; 16] {
|
||||
let mut result = *key;
|
||||
result[0] ^= (packet_id >> 8) as u8;
|
||||
result[1] ^= (packet_id & 0xff) as u8;
|
||||
result
|
||||
}
|
||||
|
||||
/// Compute hash cash level
|
||||
pub fn get_hash_cash_level(omega: &str, offset: u64) -> u8 {
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(format!("{}{}", omega, offset).as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
|
||||
let mut level = 0;
|
||||
for &byte in hash.iter() {
|
||||
if byte == 0 {
|
||||
level += 8;
|
||||
} else {
|
||||
level += byte.trailing_zeros() as u8;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
level
|
||||
}
|
||||
|
||||
/// Compute UID
|
||||
pub fn compute_uid(public_key: &[u8]) -> String {
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(public_key);
|
||||
let hash = hasher.finalize();
|
||||
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, hash)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
//! Cryptography module
|
||||
|
||||
pub mod eax;
|
||||
pub mod ephemeral;
|
||||
pub mod hash;
|
||||
pub mod identity;
|
||||
pub mod keys;
|
||||
mod tests;
|
||||
|
||||
pub use eax::*;
|
||||
pub use hash::*;
|
||||
pub use identity::*;
|
||||
pub use keys::*;
|
||||
@@ -1,162 +0,0 @@
|
||||
//! Cryptography tests
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::crypto::*;
|
||||
use crate::protocol::{Direction, Flags, InPacket, OutPacket, PacketType};
|
||||
|
||||
#[test]
|
||||
fn test_sha1() {
|
||||
let hash = sha1(b"hello");
|
||||
assert_eq!(hash.len(), 20);
|
||||
// SHA1("hello") = aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d
|
||||
assert_eq!(hash[0], 0xaa);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sha256() {
|
||||
let hash = sha256(b"hello");
|
||||
assert_eq!(hash.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sha512() {
|
||||
let hash = sha512(b"hello");
|
||||
assert_eq!(hash.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_password() {
|
||||
let hash = hash_password("password");
|
||||
assert!(!hash.is_empty());
|
||||
// base64(sha1("password"))
|
||||
assert!(hash.contains("=") || hash.len() > 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_key_nonce() {
|
||||
let iv = [0u8; 64];
|
||||
let (key, nonce) = create_key_nonce(PacketType::Command, Direction::C2S, 0, &iv);
|
||||
assert_ne!(key, [0u8; 16]);
|
||||
assert_ne!(nonce, [0u8; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_encryption_key() {
|
||||
let key = [
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
||||
0x0f, 0x10,
|
||||
];
|
||||
let encrypted = create_encryption_key(&key, 0x1234);
|
||||
assert_eq!(encrypted[0], key[0] ^ 0x12);
|
||||
assert_eq!(encrypted[1], key[1] ^ 0x34);
|
||||
// Other bytes unchanged
|
||||
assert_eq!(encrypted[2], key[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shared_secret_old() {
|
||||
let alpha = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a];
|
||||
let beta = [0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14];
|
||||
let shared_data = [0x15; 32];
|
||||
|
||||
let secret = SharedSecret::compute_old(&alpha, &beta, &shared_data);
|
||||
assert_ne!(secret.iv, [0u8; 64]);
|
||||
assert_ne!(secret.mac, [0u8; 8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shared_secret_new() {
|
||||
let alpha = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a];
|
||||
let beta = [0x0b; 54];
|
||||
let shared_data = [0x15; 32];
|
||||
|
||||
let secret = SharedSecret::compute_new(&alpha, &beta, &shared_data);
|
||||
assert_ne!(secret.iv, [0u8; 64]);
|
||||
assert_ne!(secret.mac, [0u8; 8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_cache() {
|
||||
let mut cache = KeyCache::new();
|
||||
let iv = [0u8; 64];
|
||||
|
||||
let (key1, nonce1) = cache.get_or_create(PacketType::Command, Direction::C2S, 0, &iv);
|
||||
let (key2, nonce2) = cache.get_or_create(PacketType::Command, Direction::C2S, 0, &iv);
|
||||
assert_eq!(key1, key2);
|
||||
assert_eq!(nonce1, nonce2);
|
||||
|
||||
// Different generation_id should return different keys
|
||||
let (key3, _) = cache.get_or_create(PacketType::Command, Direction::C2S, 1, &iv);
|
||||
assert_ne!(key1, key3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_eax_encrypt_decrypt() {
|
||||
let key = [
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
||||
0x0f, 0x10,
|
||||
];
|
||||
let nonce = [
|
||||
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
|
||||
0x1f, 0x20,
|
||||
];
|
||||
|
||||
let cipher = EaxCipher::new(&key);
|
||||
let header = b"test header";
|
||||
let mut data = b"Hello, World!".to_vec();
|
||||
|
||||
// Encrypt
|
||||
let mac = cipher.encrypt(&nonce, header, &mut data).unwrap();
|
||||
|
||||
// Decrypt
|
||||
cipher.decrypt(&nonce, header, &mut data, &mac).unwrap();
|
||||
|
||||
assert_eq!(data, b"Hello, World!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fake_encrypt_decrypt() {
|
||||
let mut packet = OutPacket::new(
|
||||
Direction::C2S,
|
||||
Flags::new(PacketType::Command.to_u8()),
|
||||
b"test data".to_vec(),
|
||||
);
|
||||
packet.header.packet_id = 1;
|
||||
|
||||
// Fake encryption
|
||||
encrypt_fake(&mut packet).unwrap();
|
||||
|
||||
// Fake decryption
|
||||
let in_packet = InPacket {
|
||||
direction: Direction::C2S,
|
||||
header: packet.header.clone(),
|
||||
data: packet.data.clone(),
|
||||
};
|
||||
let decrypted = decrypt_fake(&in_packet).unwrap();
|
||||
|
||||
assert_eq!(decrypted, b"test data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_cash_level() {
|
||||
// Test that different offsets produce different levels
|
||||
let level0 = get_hash_cash_level("test_key", 0);
|
||||
let level1 = get_hash_cash_level("test_key", 1);
|
||||
assert!(level0 <= 160);
|
||||
assert!(level1 <= 160);
|
||||
|
||||
// Use a key that produces a higher level
|
||||
let level_high = get_hash_cash_level("a", 12345);
|
||||
assert!(level_high <= 160);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_uid() {
|
||||
let public_key = b"test_public_key_data";
|
||||
let uid = compute_uid(public_key);
|
||||
assert!(!uid.is_empty());
|
||||
// UID should be a base64-encoded SHA1 hash
|
||||
assert!(uid.len() > 20);
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
//! TeamSpeak 3 protocol core implementation
|
||||
|
||||
pub mod connection;
|
||||
pub mod crypto;
|
||||
pub mod network;
|
||||
pub mod protocol;
|
||||
pub mod query;
|
||||
|
||||
pub use connection::*;
|
||||
pub use crypto::*;
|
||||
pub use network::*;
|
||||
pub use protocol::*;
|
||||
pub use query::*;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Protocol error
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ProtocolError {
|
||||
#[error("packet parse error: {0}")]
|
||||
PacketParse(String),
|
||||
|
||||
#[error("encryption error: {0}")]
|
||||
Encryption(String),
|
||||
|
||||
#[error("decryption error: {0}")]
|
||||
Decryption(String),
|
||||
|
||||
#[error("compression error: {0}")]
|
||||
Compression(String),
|
||||
|
||||
#[error("decompression error: {0}")]
|
||||
Decompression(String),
|
||||
|
||||
#[error("invalid packet type: {0}")]
|
||||
InvalidPacketType(u8),
|
||||
|
||||
#[error("invalid flags: {0}")]
|
||||
InvalidFlags(u8),
|
||||
|
||||
#[error("packet too large: {size} > {max}")]
|
||||
PacketTooLarge { size: usize, max: usize },
|
||||
|
||||
#[error("packet too small: {size} < {min}")]
|
||||
PacketTooSmall { size: usize, min: usize },
|
||||
|
||||
#[error("invalid client ID: {0}")]
|
||||
InvalidClientId(u16),
|
||||
|
||||
#[error("invalid packet ID: {0}")]
|
||||
InvalidPacketId(u16),
|
||||
|
||||
#[error("MAC verification failed")]
|
||||
MacVerificationFailed,
|
||||
|
||||
#[error("timeout: {0}")]
|
||||
Timeout(String),
|
||||
|
||||
#[error("connection closed")]
|
||||
ConnectionClosed,
|
||||
|
||||
#[error("command error: {0}")]
|
||||
Command(String),
|
||||
|
||||
#[error("network error: {0}")]
|
||||
Network(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// Protocol result type
|
||||
pub type ProtocolResult<T> = Result<T, ProtocolError>;
|
||||
|
||||
impl From<protocol::CommandError> for ProtocolError {
|
||||
fn from(err: protocol::CommandError) -> Self {
|
||||
ProtocolError::Command(err.to_string())
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//! Network module
|
||||
|
||||
pub mod resolver;
|
||||
pub mod socket;
|
||||
|
||||
pub use resolver::*;
|
||||
pub use socket::*;
|
||||
@@ -1,38 +0,0 @@
|
||||
//! Address resolution
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
/// Server address
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ServerAddress {
|
||||
/// Direct IP address
|
||||
Ip(SocketAddr),
|
||||
/// Domain name
|
||||
Domain(String),
|
||||
/// Server nickname
|
||||
Nickname(String),
|
||||
}
|
||||
|
||||
impl ServerAddress {
|
||||
pub async fn resolve(&self) -> Result<SocketAddr, Box<dyn std::error::Error>> {
|
||||
match self {
|
||||
Self::Ip(addr) => Ok(*addr),
|
||||
Self::Domain(domain) => resolve_domain(domain).await,
|
||||
Self::Nickname(nickname) => resolve_nickname(nickname).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_domain(domain: &str) -> Result<SocketAddr, Box<dyn std::error::Error>> {
|
||||
// Try direct resolution
|
||||
let addrs = tokio::net::lookup_host(format!("{}:9987", domain)).await?;
|
||||
addrs
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| "failed to resolve domain".into())
|
||||
}
|
||||
|
||||
async fn resolve_nickname(nickname: &str) -> Result<SocketAddr, Box<dyn std::error::Error>> {
|
||||
// TODO: Implement TSDNS and nickname resolution
|
||||
resolve_domain(nickname).await
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
//! UDP Socket abstraction
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
use crate::connection::{Client, ClientConfig, ConnectionState};
|
||||
use crate::{ProtocolError, ProtocolResult};
|
||||
|
||||
/// Socket trait
|
||||
pub trait Socket {
|
||||
fn poll_recv_from(
|
||||
&self,
|
||||
cx: &mut Context,
|
||||
buf: &mut tokio::io::ReadBuf,
|
||||
) -> Poll<std::io::Result<SocketAddr>>;
|
||||
|
||||
fn poll_send_to(
|
||||
&self,
|
||||
cx: &mut Context,
|
||||
buf: &[u8],
|
||||
target: SocketAddr,
|
||||
) -> Poll<std::io::Result<usize>>;
|
||||
|
||||
fn local_addr(&self) -> std::io::Result<SocketAddr>;
|
||||
}
|
||||
|
||||
/// UDP Socket implementation
|
||||
pub struct UdpSocketWrapper {
|
||||
socket: UdpSocket,
|
||||
}
|
||||
|
||||
impl UdpSocketWrapper {
|
||||
pub async fn bind(addr: SocketAddr) -> std::io::Result<Self> {
|
||||
let socket = UdpSocket::bind(addr).await?;
|
||||
Ok(Self { socket })
|
||||
}
|
||||
|
||||
pub async fn connect(&self, addr: SocketAddr) -> std::io::Result<()> {
|
||||
self.socket.connect(addr).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Socket for UdpSocketWrapper {
|
||||
fn poll_recv_from(
|
||||
&self,
|
||||
cx: &mut Context,
|
||||
buf: &mut tokio::io::ReadBuf,
|
||||
) -> Poll<std::io::Result<SocketAddr>> {
|
||||
self.socket.poll_recv_from(cx, buf)
|
||||
}
|
||||
|
||||
fn poll_send_to(
|
||||
&self,
|
||||
cx: &mut Context,
|
||||
buf: &[u8],
|
||||
target: SocketAddr,
|
||||
) -> Poll<std::io::Result<usize>> {
|
||||
self.socket.poll_send_to(cx, buf, target)
|
||||
}
|
||||
|
||||
fn local_addr(&self) -> std::io::Result<SocketAddr> {
|
||||
self.socket.local_addr()
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the unencrypted TS3 init handshake over UDP.
|
||||
///
|
||||
/// This stops after Init4 is sent and the client reaches `Connected`; encrypted
|
||||
/// command negotiation still has to be completed by the higher-level session.
|
||||
pub async fn perform_init_handshake(
|
||||
config: ClientConfig,
|
||||
timeout: Duration,
|
||||
) -> ProtocolResult<Client> {
|
||||
perform_handshake_until(
|
||||
config,
|
||||
timeout,
|
||||
ConnectionState::Connected,
|
||||
"init handshake",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Run the UDP connection handshake through `clientinit` and `initserver`.
|
||||
///
|
||||
/// This exercises the post-Init4 command bootstrap. Full compatibility with
|
||||
/// public servers still depends on replacing the placeholder ECDH shared-data
|
||||
/// path in `Client::handle_initivexpand2`.
|
||||
pub async fn perform_connect_handshake(
|
||||
config: ClientConfig,
|
||||
timeout: Duration,
|
||||
) -> ProtocolResult<Client> {
|
||||
perform_handshake_until(
|
||||
config,
|
||||
timeout,
|
||||
ConnectionState::ChannelListFinished,
|
||||
"connect handshake",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn perform_handshake_until(
|
||||
config: ClientConfig,
|
||||
timeout: Duration,
|
||||
target_state: ConnectionState,
|
||||
label: &str,
|
||||
) -> ProtocolResult<Client> {
|
||||
let bind_addr = if config.address.is_ipv4() {
|
||||
"0.0.0.0:0"
|
||||
} else {
|
||||
"[::]:0"
|
||||
};
|
||||
let socket = UdpSocket::bind(bind_addr).await?;
|
||||
socket.connect(config.address).await?;
|
||||
|
||||
let mut client = Client::new(config);
|
||||
let init0 = client.start_handshake()?;
|
||||
socket.send(&init0).await?;
|
||||
|
||||
tokio::time::timeout(timeout, async move {
|
||||
let mut buf = [0u8; 2048];
|
||||
loop {
|
||||
let len = socket.recv(&mut buf).await?;
|
||||
let result = client.handle_data(&buf[..len])?;
|
||||
for response in result.responses {
|
||||
socket.send(&response).await?;
|
||||
}
|
||||
|
||||
if client.state() == target_state {
|
||||
return Ok(client);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Timeout(format!("{label} timed out")))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crypto::{self, KeyCache, SharedSecret};
|
||||
use crate::protocol::{
|
||||
AckPacket, Command, CommandBuilder, Direction, Flags, InPacket, InitPacket, InitStep,
|
||||
OutPacket, PacketType, INIT_MAC, INIT_PACKET_ID,
|
||||
};
|
||||
use base64::Engine;
|
||||
|
||||
fn s2c_init_datagram(init: InitPacket) -> Vec<u8> {
|
||||
let mut packet = OutPacket::new(
|
||||
Direction::S2C,
|
||||
Flags::new(PacketType::Init.to_u8()),
|
||||
init.to_bytes(),
|
||||
);
|
||||
packet.set_mac(INIT_MAC);
|
||||
packet.set_packet_id(INIT_PACKET_ID);
|
||||
packet.to_bytes()
|
||||
}
|
||||
|
||||
fn s2c_fake_command(packet_id: u16, command: Command) -> Vec<u8> {
|
||||
let mut packet = OutPacket::new(
|
||||
Direction::S2C,
|
||||
Flags::new(PacketType::Command.to_u8()),
|
||||
command.to_string().into_bytes(),
|
||||
);
|
||||
packet.set_packet_id(packet_id);
|
||||
crypto::encrypt_fake(&mut packet).unwrap();
|
||||
packet.to_bytes()
|
||||
}
|
||||
|
||||
fn s2c_fake_ack(packet_id: u16, acked_packet_id: u16) -> Vec<u8> {
|
||||
let mut packet =
|
||||
AckPacket::new(Direction::S2C, PacketType::Ack, acked_packet_id).to_out_packet();
|
||||
packet.set_packet_id(packet_id);
|
||||
crypto::encrypt_fake(&mut packet).unwrap();
|
||||
packet.to_bytes()
|
||||
}
|
||||
|
||||
fn s2c_encrypted_command(packet_id: u16, command: Command, secret: &SharedSecret) -> Vec<u8> {
|
||||
let mut packet = OutPacket::new(
|
||||
Direction::S2C,
|
||||
Flags::new(PacketType::Command.to_u8()),
|
||||
command.to_string().into_bytes(),
|
||||
);
|
||||
packet.set_packet_id(packet_id);
|
||||
let mut key_cache = KeyCache::new();
|
||||
crypto::encrypt_packet(&mut packet, 0, &secret.iv, &mut key_cache).unwrap();
|
||||
packet.to_bytes()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_perform_init_handshake() {
|
||||
let server = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let server_addr = server.local_addr().unwrap();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let mut buf = [0u8; 2048];
|
||||
|
||||
let (len, client_addr) = server.recv_from(&mut buf).await.unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
|
||||
assert_eq!(packet.header.mac, INIT_MAC);
|
||||
assert_eq!(packet.header.flags.packet_type(), PacketType::Init);
|
||||
assert_eq!(
|
||||
InitPacket::parse_c2s(packet.content()).unwrap().step,
|
||||
InitStep::Init0
|
||||
);
|
||||
|
||||
let init1 = InitPacket {
|
||||
step: InitStep::Init1,
|
||||
version: None,
|
||||
timestamp: None,
|
||||
random0: None,
|
||||
random1: Some([1; 16]),
|
||||
random0_r: Some([2; 4]),
|
||||
x: None,
|
||||
n: None,
|
||||
level: None,
|
||||
random2: None,
|
||||
y: None,
|
||||
command: None,
|
||||
};
|
||||
server
|
||||
.send_to(&s2c_init_datagram(init1), client_addr)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (len, client_addr) = server.recv_from(&mut buf).await.unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
|
||||
assert_eq!(
|
||||
InitPacket::parse_c2s(packet.content()).unwrap().step,
|
||||
InitStep::Init2
|
||||
);
|
||||
|
||||
let mut x = [0u8; 64];
|
||||
x[63] = 2;
|
||||
let mut n = [0u8; 64];
|
||||
n[63] = 7;
|
||||
let init3 = InitPacket {
|
||||
step: InitStep::Init3,
|
||||
version: None,
|
||||
timestamp: None,
|
||||
random0: None,
|
||||
random1: None,
|
||||
random0_r: None,
|
||||
x: Some(x),
|
||||
n: Some(n),
|
||||
level: Some(1),
|
||||
random2: Some([3; 100]),
|
||||
y: None,
|
||||
command: None,
|
||||
};
|
||||
server
|
||||
.send_to(&s2c_init_datagram(init3), client_addr)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (len, _) = server.recv_from(&mut buf).await.unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
|
||||
let init4 = InitPacket::parse_c2s(packet.content()).unwrap();
|
||||
assert_eq!(init4.step, InitStep::Init4);
|
||||
let command = String::from_utf8(init4.command.unwrap()).unwrap();
|
||||
let command = Command::parse(&command).unwrap();
|
||||
let omega = command.get("omega").unwrap();
|
||||
let omega = base64::engine::general_purpose::STANDARD
|
||||
.decode(omega)
|
||||
.unwrap();
|
||||
assert_eq!(command.name, "clientinitiv");
|
||||
assert!(omega.starts_with(&[0x30]));
|
||||
});
|
||||
|
||||
let config = ClientConfig::new(server_addr, "Tester".to_string());
|
||||
let client = perform_init_handshake(config, Duration::from_secs(1))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(client.state(), ConnectionState::Connected);
|
||||
|
||||
server_task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_perform_connect_handshake() {
|
||||
let server = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let server_addr = server.local_addr().unwrap();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let mut buf = [0u8; 2048];
|
||||
|
||||
let (len, client_addr) = server.recv_from(&mut buf).await.unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
|
||||
assert_eq!(
|
||||
InitPacket::parse_c2s(packet.content()).unwrap().step,
|
||||
InitStep::Init0
|
||||
);
|
||||
|
||||
server
|
||||
.send_to(
|
||||
&s2c_init_datagram(InitPacket {
|
||||
step: InitStep::Init1,
|
||||
version: None,
|
||||
timestamp: None,
|
||||
random0: None,
|
||||
random1: Some([1; 16]),
|
||||
random0_r: Some([2; 4]),
|
||||
x: None,
|
||||
n: None,
|
||||
level: None,
|
||||
random2: None,
|
||||
y: None,
|
||||
command: None,
|
||||
}),
|
||||
client_addr,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (len, client_addr) = server.recv_from(&mut buf).await.unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
|
||||
assert_eq!(
|
||||
InitPacket::parse_c2s(packet.content()).unwrap().step,
|
||||
InitStep::Init2
|
||||
);
|
||||
|
||||
let mut x = [0u8; 64];
|
||||
x[63] = 2;
|
||||
let mut n = [0u8; 64];
|
||||
n[63] = 7;
|
||||
server
|
||||
.send_to(
|
||||
&s2c_init_datagram(InitPacket {
|
||||
step: InitStep::Init3,
|
||||
version: None,
|
||||
timestamp: None,
|
||||
random0: None,
|
||||
random1: None,
|
||||
random0_r: None,
|
||||
x: Some(x),
|
||||
n: Some(n),
|
||||
level: Some(1),
|
||||
random2: Some([3; 100]),
|
||||
y: None,
|
||||
command: None,
|
||||
}),
|
||||
client_addr,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (len, _) = server.recv_from(&mut buf).await.unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
|
||||
let init4 = InitPacket::parse_c2s(packet.content()).unwrap();
|
||||
let command =
|
||||
Command::parse(&String::from_utf8(init4.command.unwrap()).unwrap()).unwrap();
|
||||
let alpha_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(command.get("alpha").unwrap())
|
||||
.unwrap();
|
||||
let mut alpha = [0u8; 10];
|
||||
alpha.copy_from_slice(&alpha_bytes);
|
||||
|
||||
let beta = [1u8; 54];
|
||||
let secret = SharedSecret::compute_new(&alpha, &beta, &[0; 32]);
|
||||
server
|
||||
.send_to(
|
||||
&s2c_fake_command(
|
||||
0,
|
||||
CommandBuilder::new("initivexpand2")
|
||||
.arg(
|
||||
"beta",
|
||||
&base64::engine::general_purpose::STANDARD.encode(beta),
|
||||
)
|
||||
.arg("omega", "server")
|
||||
.build(),
|
||||
),
|
||||
client_addr,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (len, _) = server.recv_from(&mut buf).await.unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
|
||||
assert_eq!(packet.header.flags.packet_type(), PacketType::Ack);
|
||||
assert_eq!(crypto::decrypt_fake(&packet).unwrap(), 0u16.to_be_bytes());
|
||||
|
||||
let (len, _) = server.recv_from(&mut buf).await.unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
|
||||
assert_eq!(packet.header.packet_id, 1);
|
||||
let command =
|
||||
Command::parse(&String::from_utf8(crypto::decrypt_fake(&packet).unwrap()).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(command.name, "clientek");
|
||||
|
||||
server
|
||||
.send_to(&s2c_fake_ack(0, 1), client_addr)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (len, _) = server.recv_from(&mut buf).await.unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
|
||||
assert_eq!(packet.header.packet_id, 2);
|
||||
let mut key_cache = KeyCache::new();
|
||||
let command = Command::parse(
|
||||
&String::from_utf8(
|
||||
crypto::decrypt_packet(&packet, 0, &secret.iv, &mut key_cache).unwrap(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(command.name, "clientinit");
|
||||
|
||||
server
|
||||
.send_to(
|
||||
&s2c_encrypted_command(
|
||||
1,
|
||||
CommandBuilder::new("initserver")
|
||||
.arg("client_id", "7")
|
||||
.build(),
|
||||
&secret,
|
||||
),
|
||||
client_addr,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let config = ClientConfig::new(server_addr, "Tester".to_string());
|
||||
let client = perform_connect_handshake(config, Duration::from_secs(1))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(client.state(), ConnectionState::ChannelListFinished);
|
||||
assert_eq!(client.client_id(), Some(7));
|
||||
|
||||
server_task.await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
//! Command parsing and serialization
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Command parsing error
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CommandError {
|
||||
InvalidFormat(String),
|
||||
MissingParameter(String),
|
||||
InvalidParameterValue { name: String, value: String },
|
||||
EscapeError(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for CommandError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidFormat(msg) => write!(f, "invalid command format: {}", msg),
|
||||
Self::MissingParameter(name) => write!(f, "missing required parameter: {}", name),
|
||||
Self::InvalidParameterValue { name, value } => {
|
||||
write!(f, "invalid parameter value: {}={}", name, value)
|
||||
}
|
||||
Self::EscapeError(msg) => write!(f, "escape sequence error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CommandError {}
|
||||
|
||||
pub type CommandResult<T> = Result<T, CommandError>;
|
||||
|
||||
/// Escape sequence handling
|
||||
pub mod escape {
|
||||
use super::CommandError;
|
||||
|
||||
pub fn escape(input: &str) -> String {
|
||||
let mut result = String::with_capacity(input.len());
|
||||
for c in input.chars() {
|
||||
match c {
|
||||
'\\' => result.push_str("\\\\"),
|
||||
' ' => result.push_str("\\s"),
|
||||
'|' => result.push_str("\\p"),
|
||||
'/' => result.push_str("\\/"),
|
||||
'\n' => result.push_str("\\n"),
|
||||
'\r' => result.push_str("\\r"),
|
||||
'\t' => result.push_str("\\t"),
|
||||
_ => result.push(c),
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn unescape(input: &str) -> Result<String, CommandError> {
|
||||
let mut result = String::with_capacity(input.len());
|
||||
let mut chars = input.chars();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\\' {
|
||||
match chars.next() {
|
||||
Some('\\') => result.push('\\'),
|
||||
Some('s') => result.push(' '),
|
||||
Some('p') => result.push('|'),
|
||||
Some('/') => result.push('/'),
|
||||
Some('n') => result.push('\n'),
|
||||
Some('r') => result.push('\r'),
|
||||
Some('t') => result.push('\t'),
|
||||
Some(other) => {
|
||||
return Err(CommandError::EscapeError(format!(
|
||||
"unknown escape sequence: \\{}",
|
||||
other
|
||||
)))
|
||||
}
|
||||
None => {
|
||||
return Err(CommandError::EscapeError("unexpected end of escape sequence".to_string()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// Command argument
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandArgument {
|
||||
pub name: String,
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
impl CommandArgument {
|
||||
pub fn new(name: &str, value: Option<&str>) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
value: value.map(|s| s.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_value(name: &str, value: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
value: Some(value.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn without_value(name: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
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)
|
||||
),
|
||||
None => write!(f, "{}", escape::escape(&self.name)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Command {
|
||||
pub name: String,
|
||||
pub args: Vec<CommandArgument>,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
args: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_args(name: &str, args: Vec<CommandArgument>) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
args,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn arg(mut self, arg: CommandArgument) -> Self {
|
||||
self.args.push(arg);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn key_value(mut self, name: &str, value: &str) -> Self {
|
||||
self.args.push(CommandArgument::with_value(name, value));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn flag(mut self, name: &str) -> Self {
|
||||
self.args.push(CommandArgument::without_value(name));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn get(&self, name: &str) -> Option<&str> {
|
||||
self.args
|
||||
.iter()
|
||||
.find(|a| a.name == name)
|
||||
.and_then(|a| a.value.as_deref())
|
||||
}
|
||||
|
||||
pub fn has(&self, name: &str) -> bool {
|
||||
self.args.iter().any(|a| a.name == name)
|
||||
}
|
||||
|
||||
pub fn parse(input: &str) -> CommandResult<Self> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
return Err(CommandError::InvalidFormat("empty command".to_string()));
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = input.splitn(2, ' ').collect();
|
||||
let name = parts[0].to_string();
|
||||
let args_str = if parts.len() > 1 { parts[1] } else { "" };
|
||||
|
||||
let mut args = Vec::new();
|
||||
if !args_str.is_empty() {
|
||||
for arg_str in args_str.split(' ') {
|
||||
if arg_str.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(eq_pos) = arg_str.find('=') {
|
||||
let name = escape::unescape(&arg_str[..eq_pos])?;
|
||||
let value = escape::unescape(&arg_str[eq_pos + 1..])?;
|
||||
args.push(CommandArgument::with_value(&name, &value));
|
||||
} else {
|
||||
let name = escape::unescape(arg_str)?;
|
||||
args.push(CommandArgument::without_value(&name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self { name, args })
|
||||
}
|
||||
|
||||
pub fn parse_many(input: &str) -> CommandResult<Vec<Self>> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
return Err(CommandError::InvalidFormat("empty command".to_string()));
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = input.splitn(2, ' ').collect();
|
||||
let name = parts[0];
|
||||
let args_str = if parts.len() > 1 { parts[1] } else { "" };
|
||||
|
||||
if args_str.is_empty() {
|
||||
return Ok(vec![Self::parse(input)?]);
|
||||
}
|
||||
|
||||
args_str
|
||||
.split('|')
|
||||
.map(|part| {
|
||||
if part.is_empty() {
|
||||
Self::parse(name)
|
||||
} else {
|
||||
Self::parse(&format!("{} {}", name, part))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Command {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.name)?;
|
||||
for arg in &self.args {
|
||||
write!(f, " {arg}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Command builder
|
||||
pub struct CommandBuilder {
|
||||
command: Command,
|
||||
}
|
||||
|
||||
impl CommandBuilder {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
command: Command::new(name),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn arg(mut self, name: &str, value: &str) -> Self {
|
||||
self.command = self.command.key_value(name, value);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn flag(mut self, name: &str) -> Self {
|
||||
self.command = self.command.flag(name);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Command {
|
||||
self.command
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//! Protocol module
|
||||
|
||||
pub mod commands;
|
||||
pub mod packet;
|
||||
mod tests;
|
||||
pub mod types;
|
||||
pub mod voice;
|
||||
|
||||
pub use commands::*;
|
||||
pub use packet::*;
|
||||
pub use types::*;
|
||||
pub use voice::*;
|
||||
@@ -1,790 +0,0 @@
|
||||
//! Packet definition and handling
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use super::types::*;
|
||||
use crate::ProtocolError;
|
||||
|
||||
/// Maximum packet size
|
||||
pub const MAX_PACKET_SIZE: usize = 500;
|
||||
|
||||
/// C2S header size
|
||||
pub const C2S_HEADER_SIZE: usize = 13; // 8 (MAC) + 2 (PId) + 2 (CId) + 1 (PT)
|
||||
|
||||
/// S2C header size
|
||||
pub const S2C_HEADER_SIZE: usize = 11; // 8 (MAC) + 2 (PId) + 1 (PT)
|
||||
|
||||
/// Init packets use a fixed MAC and packet id during the TS3 handshake.
|
||||
pub const INIT_MAC: [u8; 8] = *b"TS3INIT1";
|
||||
pub const INIT_PACKET_ID: u16 = 0x65;
|
||||
|
||||
/// Packet direction
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Direction {
|
||||
C2S,
|
||||
S2C,
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
pub fn reverse(&self) -> Self {
|
||||
match self {
|
||||
Self::C2S => Self::S2C,
|
||||
Self::S2C => Self::C2S,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Packet flags
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Flags(pub u8);
|
||||
|
||||
impl Flags {
|
||||
pub const UNENCRYPTED: u8 = 0x80;
|
||||
pub const COMPRESSED: u8 = 0x40;
|
||||
pub const NEWPROTOCOL: u8 = 0x20;
|
||||
pub const FRAGMENTED: u8 = 0x10;
|
||||
|
||||
pub fn new(flags: u8) -> Self {
|
||||
Self(flags)
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self(0)
|
||||
}
|
||||
|
||||
pub fn is_unencrypted(&self) -> bool {
|
||||
self.0 & Self::UNENCRYPTED != 0
|
||||
}
|
||||
|
||||
pub fn is_compressed(&self) -> bool {
|
||||
self.0 & Self::COMPRESSED != 0
|
||||
}
|
||||
|
||||
pub fn is_newprotocol(&self) -> bool {
|
||||
self.0 & Self::NEWPROTOCOL != 0
|
||||
}
|
||||
|
||||
pub fn is_fragmented(&self) -> bool {
|
||||
self.0 & Self::FRAGMENTED != 0
|
||||
}
|
||||
|
||||
pub fn packet_type(&self) -> PacketType {
|
||||
PacketType::from_u8(self.0 & 0x0F)
|
||||
}
|
||||
|
||||
pub fn set_unencrypted(&mut self, value: bool) {
|
||||
if value {
|
||||
self.0 |= Self::UNENCRYPTED;
|
||||
} else {
|
||||
self.0 &= !Self::UNENCRYPTED;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_compressed(&mut self, value: bool) {
|
||||
if value {
|
||||
self.0 |= Self::COMPRESSED;
|
||||
} else {
|
||||
self.0 &= !Self::COMPRESSED;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_newprotocol(&mut self, value: bool) {
|
||||
if value {
|
||||
self.0 |= Self::NEWPROTOCOL;
|
||||
} else {
|
||||
self.0 &= !Self::NEWPROTOCOL;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_fragmented(&mut self, value: bool) {
|
||||
if value {
|
||||
self.0 |= Self::FRAGMENTED;
|
||||
} else {
|
||||
self.0 &= !Self::FRAGMENTED;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Flags {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"Flags({:08b}: UE={}, CP={}, NP={}, FR={}, Type={:?})",
|
||||
self.0,
|
||||
self.is_unencrypted(),
|
||||
self.is_compressed(),
|
||||
self.is_newprotocol(),
|
||||
self.is_fragmented(),
|
||||
self.packet_type()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Packet header
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Header {
|
||||
pub mac: [u8; 8],
|
||||
pub packet_id: u16,
|
||||
pub client_id: Option<u16>,
|
||||
pub flags: Flags,
|
||||
}
|
||||
|
||||
impl Header {
|
||||
pub fn parse_c2s(data: &[u8]) -> Result<Self, ProtocolError> {
|
||||
if data.len() < C2S_HEADER_SIZE {
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: C2S_HEADER_SIZE,
|
||||
});
|
||||
}
|
||||
|
||||
let mut mac = [0u8; 8];
|
||||
mac.copy_from_slice(&data[0..8]);
|
||||
let packet_id = u16::from_be_bytes([data[8], data[9]]);
|
||||
let client_id = u16::from_be_bytes([data[10], data[11]]);
|
||||
let flags = Flags::new(data[12]);
|
||||
|
||||
Ok(Self {
|
||||
mac,
|
||||
packet_id,
|
||||
client_id: Some(client_id),
|
||||
flags,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_s2c(data: &[u8]) -> Result<Self, ProtocolError> {
|
||||
if data.len() < S2C_HEADER_SIZE {
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: S2C_HEADER_SIZE,
|
||||
});
|
||||
}
|
||||
|
||||
let mut mac = [0u8; 8];
|
||||
mac.copy_from_slice(&data[0..8]);
|
||||
let packet_id = u16::from_be_bytes([data[8], data[9]]);
|
||||
let flags = Flags::new(data[10]);
|
||||
|
||||
Ok(Self {
|
||||
mac,
|
||||
packet_id,
|
||||
client_id: None,
|
||||
flags,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_c2s_bytes(&self) -> [u8; C2S_HEADER_SIZE] {
|
||||
let mut bytes = [0u8; C2S_HEADER_SIZE];
|
||||
bytes[0..8].copy_from_slice(&self.mac);
|
||||
bytes[8..10].copy_from_slice(&self.packet_id.to_be_bytes());
|
||||
if let Some(client_id) = self.client_id {
|
||||
bytes[10..12].copy_from_slice(&client_id.to_be_bytes());
|
||||
}
|
||||
bytes[12] = self.flags.0;
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn to_s2c_bytes(&self) -> [u8; S2C_HEADER_SIZE] {
|
||||
let mut bytes = [0u8; S2C_HEADER_SIZE];
|
||||
bytes[0..8].copy_from_slice(&self.mac);
|
||||
bytes[8..10].copy_from_slice(&self.packet_id.to_be_bytes());
|
||||
bytes[10] = self.flags.0;
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn size(&self, direction: Direction) -> usize {
|
||||
match direction {
|
||||
Direction::C2S => C2S_HEADER_SIZE,
|
||||
Direction::S2C => S2C_HEADER_SIZE,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_meta(&self, direction: Direction) -> Vec<u8> {
|
||||
match direction {
|
||||
Direction::C2S => {
|
||||
let mut meta = Vec::with_capacity(5);
|
||||
meta.extend_from_slice(&self.packet_id.to_be_bytes());
|
||||
meta.extend_from_slice(&self.client_id.unwrap_or(0).to_be_bytes());
|
||||
meta.push(self.flags.0);
|
||||
meta
|
||||
}
|
||||
Direction::S2C => {
|
||||
let mut meta = Vec::with_capacity(3);
|
||||
meta.extend_from_slice(&self.packet_id.to_be_bytes());
|
||||
meta.push(self.flags.0);
|
||||
meta
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inbound packet
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InPacket {
|
||||
pub direction: Direction,
|
||||
pub header: Header,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl InPacket {
|
||||
pub fn parse(direction: Direction, data: &[u8]) -> Result<Self, ProtocolError> {
|
||||
let header = match direction {
|
||||
Direction::C2S => Header::parse_c2s(data)?,
|
||||
Direction::S2C => Header::parse_s2c(data)?,
|
||||
};
|
||||
|
||||
let header_size = header.size(direction);
|
||||
let content = data[header_size..].to_vec();
|
||||
|
||||
Ok(Self {
|
||||
direction,
|
||||
header,
|
||||
data: content,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn content(&self) -> &[u8] {
|
||||
&self.data
|
||||
}
|
||||
|
||||
pub fn content_size(&self) -> usize {
|
||||
self.data.len()
|
||||
}
|
||||
|
||||
pub fn total_size(&self) -> usize {
|
||||
self.header.size(self.direction) + self.data.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Outbound packet
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutPacket {
|
||||
pub direction: Direction,
|
||||
pub header: Header,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl OutPacket {
|
||||
pub fn new(direction: Direction, flags: Flags, content: Vec<u8>) -> Self {
|
||||
let header = Header {
|
||||
mac: [0; 8],
|
||||
packet_id: 0,
|
||||
client_id: if direction == Direction::C2S {
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
flags,
|
||||
};
|
||||
|
||||
Self {
|
||||
direction,
|
||||
header,
|
||||
data: content,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_packet_id(&mut self, id: u16) {
|
||||
self.header.packet_id = id;
|
||||
}
|
||||
|
||||
pub fn set_client_id(&mut self, id: u16) {
|
||||
self.header.client_id = Some(id);
|
||||
}
|
||||
|
||||
pub fn set_mac(&mut self, mac: [u8; 8]) {
|
||||
self.header.mac = mac;
|
||||
}
|
||||
|
||||
pub fn content(&self) -> &[u8] {
|
||||
&self.data
|
||||
}
|
||||
|
||||
pub fn content_mut(&mut self) -> &mut Vec<u8> {
|
||||
&mut self.data
|
||||
}
|
||||
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let header_size = self.header.size(self.direction);
|
||||
let mut bytes = Vec::with_capacity(header_size + self.data.len());
|
||||
|
||||
match self.direction {
|
||||
Direction::C2S => {
|
||||
bytes.extend_from_slice(&self.header.to_c2s_bytes());
|
||||
}
|
||||
Direction::S2C => {
|
||||
bytes.extend_from_slice(&self.header.to_s2c_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
bytes.extend_from_slice(&self.data);
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn total_size(&self) -> usize {
|
||||
self.header.size(self.direction) + self.data.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Acknowledgment packet
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AckPacket {
|
||||
pub direction: Direction,
|
||||
pub packet_type: PacketType,
|
||||
pub acked_packet_id: u16,
|
||||
}
|
||||
|
||||
impl AckPacket {
|
||||
pub fn new(direction: Direction, packet_type: PacketType, acked_packet_id: u16) -> Self {
|
||||
Self {
|
||||
direction,
|
||||
packet_type,
|
||||
acked_packet_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_out_packet(&self) -> OutPacket {
|
||||
let flags = Flags::new(self.packet_type.to_u8());
|
||||
let mut content = Vec::with_capacity(2);
|
||||
content.extend_from_slice(&self.acked_packet_id.to_be_bytes());
|
||||
OutPacket::new(self.direction, flags, content)
|
||||
}
|
||||
}
|
||||
|
||||
/// Init step
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InitStep {
|
||||
Init0,
|
||||
Init1,
|
||||
Init2,
|
||||
Init3,
|
||||
Init4,
|
||||
Reset,
|
||||
}
|
||||
|
||||
/// Init packet
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InitPacket {
|
||||
pub step: InitStep,
|
||||
pub version: Option<u32>,
|
||||
pub timestamp: Option<u32>,
|
||||
pub random0: Option<[u8; 4]>,
|
||||
pub random1: Option<[u8; 16]>,
|
||||
pub random0_r: Option<[u8; 4]>,
|
||||
pub x: Option<[u8; 64]>,
|
||||
pub n: Option<[u8; 64]>,
|
||||
pub level: Option<u32>,
|
||||
pub random2: Option<[u8; 100]>,
|
||||
pub y: Option<[u8; 64]>,
|
||||
pub command: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl InitPacket {
|
||||
pub fn parse_c2s(data: &[u8]) -> Result<Self, ProtocolError> {
|
||||
if data.len() < 5 {
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 5,
|
||||
});
|
||||
}
|
||||
|
||||
let version = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
|
||||
let step = match data[4] {
|
||||
0 => InitStep::Init0,
|
||||
2 => InitStep::Init2,
|
||||
4 => InitStep::Init4,
|
||||
127 => InitStep::Reset,
|
||||
_ => return Err(ProtocolError::InvalidPacketType(data[4])),
|
||||
};
|
||||
|
||||
let mut packet = Self {
|
||||
step,
|
||||
version: Some(version),
|
||||
timestamp: None,
|
||||
random0: None,
|
||||
random1: None,
|
||||
random0_r: None,
|
||||
x: None,
|
||||
n: None,
|
||||
level: None,
|
||||
random2: None,
|
||||
y: None,
|
||||
command: None,
|
||||
};
|
||||
|
||||
match step {
|
||||
InitStep::Init0 => {
|
||||
if data.len() < 21 {
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 21,
|
||||
});
|
||||
}
|
||||
packet.timestamp = Some(u32::from_be_bytes([data[5], data[6], data[7], data[8]]));
|
||||
let mut random0 = [0u8; 4];
|
||||
random0.copy_from_slice(&data[9..13]);
|
||||
packet.random0 = Some(random0);
|
||||
}
|
||||
InitStep::Init2 => {
|
||||
if data.len() < 25 {
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 25,
|
||||
});
|
||||
}
|
||||
let mut random1 = [0u8; 16];
|
||||
random1.copy_from_slice(&data[5..21]);
|
||||
packet.random1 = Some(random1);
|
||||
let mut random0_r = [0u8; 4];
|
||||
random0_r.copy_from_slice(&data[21..25]);
|
||||
packet.random0_r = Some(random0_r);
|
||||
}
|
||||
InitStep::Init4 => {
|
||||
if data.len() < 301 {
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 301,
|
||||
});
|
||||
}
|
||||
let mut x = [0u8; 64];
|
||||
x.copy_from_slice(&data[5..69]);
|
||||
packet.x = Some(x);
|
||||
let mut n = [0u8; 64];
|
||||
n.copy_from_slice(&data[69..133]);
|
||||
packet.n = Some(n);
|
||||
packet.level = Some(u32::from_be_bytes([
|
||||
data[133], data[134], data[135], data[136],
|
||||
]));
|
||||
let mut random2 = [0u8; 100];
|
||||
random2.copy_from_slice(&data[137..237]);
|
||||
packet.random2 = Some(random2);
|
||||
let mut y = [0u8; 64];
|
||||
y.copy_from_slice(&data[237..301]);
|
||||
packet.y = Some(y);
|
||||
if data.len() > 301 {
|
||||
packet.command = Some(data[301..].to_vec());
|
||||
}
|
||||
}
|
||||
InitStep::Init1 | InitStep::Init3 | InitStep::Reset => {}
|
||||
}
|
||||
|
||||
Ok(packet)
|
||||
}
|
||||
|
||||
pub fn parse_s2c(data: &[u8]) -> Result<Self, ProtocolError> {
|
||||
Self::parse(data)
|
||||
}
|
||||
|
||||
pub fn parse(data: &[u8]) -> Result<Self, ProtocolError> {
|
||||
if data.is_empty() {
|
||||
return Err(ProtocolError::PacketTooSmall { size: 0, min: 1 });
|
||||
}
|
||||
|
||||
let step = match data[0] {
|
||||
0 => InitStep::Init0,
|
||||
1 => InitStep::Init1,
|
||||
2 => InitStep::Init2,
|
||||
3 => InitStep::Init3,
|
||||
4 => InitStep::Init4,
|
||||
127 => InitStep::Reset,
|
||||
_ => return Err(ProtocolError::InvalidPacketType(data[0])),
|
||||
};
|
||||
|
||||
let mut packet = Self {
|
||||
step,
|
||||
version: None,
|
||||
timestamp: None,
|
||||
random0: None,
|
||||
random1: None,
|
||||
random0_r: None,
|
||||
x: None,
|
||||
n: None,
|
||||
level: None,
|
||||
random2: None,
|
||||
y: None,
|
||||
command: None,
|
||||
};
|
||||
|
||||
match step {
|
||||
InitStep::Init0 => {
|
||||
if data.len() < 21 {
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 21,
|
||||
});
|
||||
}
|
||||
packet.version = Some(u32::from_be_bytes([data[1], data[2], data[3], data[4]]));
|
||||
packet.timestamp = Some(u32::from_be_bytes([data[6], data[7], data[8], data[9]]));
|
||||
let mut random0 = [0u8; 4];
|
||||
random0.copy_from_slice(&data[10..14]);
|
||||
packet.random0 = Some(random0);
|
||||
}
|
||||
InitStep::Init1 => {
|
||||
if data.len() < 21 {
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 21,
|
||||
});
|
||||
}
|
||||
let mut random1 = [0u8; 16];
|
||||
random1.copy_from_slice(&data[1..17]);
|
||||
packet.random1 = Some(random1);
|
||||
let mut random0_r = [0u8; 4];
|
||||
random0_r.copy_from_slice(&data[17..21]);
|
||||
packet.random0_r = Some(random0_r);
|
||||
}
|
||||
InitStep::Init2 => {
|
||||
if data.len() < 26 {
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 26,
|
||||
});
|
||||
}
|
||||
packet.version = Some(u32::from_be_bytes([data[1], data[2], data[3], data[4]]));
|
||||
let mut random1 = [0u8; 16];
|
||||
random1.copy_from_slice(&data[6..22]);
|
||||
packet.random1 = Some(random1);
|
||||
let mut random0_r = [0u8; 4];
|
||||
random0_r.copy_from_slice(&data[22..26]);
|
||||
packet.random0_r = Some(random0_r);
|
||||
}
|
||||
InitStep::Init3 => {
|
||||
if data.len() < 233 {
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 233,
|
||||
});
|
||||
}
|
||||
let mut x = [0u8; 64];
|
||||
x.copy_from_slice(&data[1..65]);
|
||||
packet.x = Some(x);
|
||||
let mut n = [0u8; 64];
|
||||
n.copy_from_slice(&data[65..129]);
|
||||
packet.n = Some(n);
|
||||
packet.level = Some(u32::from_be_bytes([
|
||||
data[129], data[130], data[131], data[132],
|
||||
]));
|
||||
let mut random2 = [0u8; 100];
|
||||
random2.copy_from_slice(&data[133..233]);
|
||||
packet.random2 = Some(random2);
|
||||
}
|
||||
InitStep::Init4 => {
|
||||
if data.len() < 361 {
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 361,
|
||||
});
|
||||
}
|
||||
packet.version = Some(u32::from_be_bytes([data[1], data[2], data[3], data[4]]));
|
||||
let mut x = [0u8; 64];
|
||||
x.copy_from_slice(&data[6..70]);
|
||||
packet.x = Some(x);
|
||||
let mut n = [0u8; 64];
|
||||
n.copy_from_slice(&data[70..134]);
|
||||
packet.n = Some(n);
|
||||
packet.level = Some(u32::from_be_bytes([
|
||||
data[134], data[135], data[136], data[137],
|
||||
]));
|
||||
let mut random2 = [0u8; 100];
|
||||
random2.copy_from_slice(&data[138..238]);
|
||||
packet.random2 = Some(random2);
|
||||
let mut y = [0u8; 64];
|
||||
y.copy_from_slice(&data[238..302]);
|
||||
packet.y = Some(y);
|
||||
if data.len() > 302 {
|
||||
packet.command = Some(data[302..].to_vec());
|
||||
}
|
||||
}
|
||||
InitStep::Reset => {}
|
||||
}
|
||||
|
||||
Ok(packet)
|
||||
}
|
||||
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
|
||||
match self.step {
|
||||
InitStep::Init0 => {
|
||||
bytes.push(0);
|
||||
if let Some(version) = self.version {
|
||||
bytes.extend_from_slice(&version.to_be_bytes());
|
||||
} else {
|
||||
bytes.extend_from_slice(&[0; 4]);
|
||||
}
|
||||
bytes.push(0);
|
||||
if let Some(timestamp) = self.timestamp {
|
||||
bytes.extend_from_slice(×tamp.to_be_bytes());
|
||||
} else {
|
||||
bytes.extend_from_slice(&[0; 4]);
|
||||
}
|
||||
if let Some(random0) = self.random0 {
|
||||
bytes.extend_from_slice(&random0);
|
||||
} else {
|
||||
bytes.extend_from_slice(&[0; 4]);
|
||||
}
|
||||
bytes.extend_from_slice(&[0; 8]);
|
||||
}
|
||||
InitStep::Init1 => {
|
||||
bytes.push(1);
|
||||
if let Some(random1) = self.random1 {
|
||||
bytes.extend_from_slice(&random1);
|
||||
} else {
|
||||
bytes.extend_from_slice(&[0; 16]);
|
||||
}
|
||||
if let Some(random0_r) = self.random0_r {
|
||||
bytes.extend_from_slice(&random0_r);
|
||||
} else {
|
||||
bytes.extend_from_slice(&[0; 4]);
|
||||
}
|
||||
}
|
||||
InitStep::Init2 => {
|
||||
bytes.push(2);
|
||||
if let Some(version) = self.version {
|
||||
bytes.extend_from_slice(&version.to_be_bytes());
|
||||
} else {
|
||||
bytes.extend_from_slice(&[0; 4]);
|
||||
}
|
||||
if let Some(random1) = self.random1 {
|
||||
bytes.extend_from_slice(&random1);
|
||||
} else {
|
||||
bytes.extend_from_slice(&[0; 16]);
|
||||
}
|
||||
if let Some(random0_r) = self.random0_r {
|
||||
bytes.extend_from_slice(&random0_r);
|
||||
} else {
|
||||
bytes.extend_from_slice(&[0; 4]);
|
||||
}
|
||||
}
|
||||
InitStep::Init3 => {
|
||||
bytes.push(3);
|
||||
if let Some(x) = self.x {
|
||||
bytes.extend_from_slice(&x);
|
||||
} else {
|
||||
bytes.extend_from_slice(&[0; 64]);
|
||||
}
|
||||
if let Some(n) = self.n {
|
||||
bytes.extend_from_slice(&n);
|
||||
} else {
|
||||
bytes.extend_from_slice(&[0; 64]);
|
||||
}
|
||||
if let Some(level) = self.level {
|
||||
bytes.extend_from_slice(&level.to_be_bytes());
|
||||
} else {
|
||||
bytes.extend_from_slice(&[0; 4]);
|
||||
}
|
||||
if let Some(random2) = self.random2 {
|
||||
bytes.extend_from_slice(&random2);
|
||||
} else {
|
||||
bytes.extend_from_slice(&[0; 100]);
|
||||
}
|
||||
}
|
||||
InitStep::Init4 => {
|
||||
bytes.push(4);
|
||||
if let Some(version) = self.version {
|
||||
bytes.extend_from_slice(&version.to_be_bytes());
|
||||
} else {
|
||||
bytes.extend_from_slice(&[0; 4]);
|
||||
}
|
||||
if let Some(x) = self.x {
|
||||
bytes.extend_from_slice(&x);
|
||||
} else {
|
||||
bytes.extend_from_slice(&[0; 64]);
|
||||
}
|
||||
if let Some(n) = self.n {
|
||||
bytes.extend_from_slice(&n);
|
||||
} else {
|
||||
bytes.extend_from_slice(&[0; 64]);
|
||||
}
|
||||
if let Some(level) = self.level {
|
||||
bytes.extend_from_slice(&level.to_be_bytes());
|
||||
} else {
|
||||
bytes.extend_from_slice(&[0; 4]);
|
||||
}
|
||||
if let Some(random2) = self.random2 {
|
||||
bytes.extend_from_slice(&random2);
|
||||
} else {
|
||||
bytes.extend_from_slice(&[0; 100]);
|
||||
}
|
||||
if let Some(y) = self.y {
|
||||
bytes.extend_from_slice(&y);
|
||||
} else {
|
||||
bytes.extend_from_slice(&[0; 64]);
|
||||
}
|
||||
if let Some(ref command) = self.command {
|
||||
bytes.extend_from_slice(command);
|
||||
}
|
||||
}
|
||||
InitStep::Reset => {
|
||||
bytes.push(127);
|
||||
bytes.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn to_c2s_bytes(&self) -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
|
||||
match self.step {
|
||||
InitStep::Init0 => {
|
||||
bytes.extend_from_slice(&self.version.unwrap_or_default().to_be_bytes());
|
||||
bytes.push(0);
|
||||
bytes.extend_from_slice(&self.timestamp.unwrap_or_default().to_be_bytes());
|
||||
bytes.extend_from_slice(&self.random0.unwrap_or_default());
|
||||
bytes.extend_from_slice(&[0; 8]);
|
||||
}
|
||||
InitStep::Init2 => {
|
||||
bytes.extend_from_slice(&self.version.unwrap_or_default().to_be_bytes());
|
||||
bytes.push(2);
|
||||
bytes.extend_from_slice(&self.random1.unwrap_or_default());
|
||||
bytes.extend_from_slice(&self.random0_r.unwrap_or_default());
|
||||
}
|
||||
InitStep::Init4 => {
|
||||
bytes.extend_from_slice(&self.version.unwrap_or_default().to_be_bytes());
|
||||
bytes.push(4);
|
||||
bytes.extend_from_slice(&self.x.unwrap_or([0; 64]));
|
||||
bytes.extend_from_slice(&self.n.unwrap_or([0; 64]));
|
||||
bytes.extend_from_slice(&self.level.unwrap_or_default().to_be_bytes());
|
||||
bytes.extend_from_slice(&self.random2.unwrap_or([0; 100]));
|
||||
bytes.extend_from_slice(&self.y.unwrap_or([0; 64]));
|
||||
if let Some(ref command) = self.command {
|
||||
bytes.extend_from_slice(command);
|
||||
}
|
||||
}
|
||||
InitStep::Reset => {
|
||||
bytes.extend_from_slice(&self.version.unwrap_or_default().to_be_bytes());
|
||||
bytes.push(127);
|
||||
}
|
||||
InitStep::Init1 | InitStep::Init3 => {
|
||||
bytes.extend_from_slice(&self.version.unwrap_or_default().to_be_bytes());
|
||||
bytes.push(self.step_byte());
|
||||
}
|
||||
}
|
||||
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn to_c2s_packet_bytes(&self) -> Vec<u8> {
|
||||
let mut packet = OutPacket::new(
|
||||
Direction::C2S,
|
||||
Flags::new(PacketType::Init.to_u8()),
|
||||
self.to_c2s_bytes(),
|
||||
);
|
||||
packet.set_mac(INIT_MAC);
|
||||
packet.set_packet_id(INIT_PACKET_ID);
|
||||
packet.to_bytes()
|
||||
}
|
||||
|
||||
fn step_byte(&self) -> u8 {
|
||||
match self.step {
|
||||
InitStep::Init0 => 0,
|
||||
InitStep::Init1 => 1,
|
||||
InitStep::Init2 => 2,
|
||||
InitStep::Init3 => 3,
|
||||
InitStep::Init4 => 4,
|
||||
InitStep::Reset => 127,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
//! Packet processing tests
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::protocol::*;
|
||||
|
||||
#[test]
|
||||
fn test_packet_type_conversion() {
|
||||
assert_eq!(PacketType::from_u8(0x00), PacketType::Voice);
|
||||
assert_eq!(PacketType::from_u8(0x02), PacketType::Command);
|
||||
assert_eq!(PacketType::from_u8(0x08), PacketType::Init);
|
||||
assert_eq!(PacketType::Voice.to_u8(), 0x00);
|
||||
assert_eq!(PacketType::Command.to_u8(), 0x02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flags() {
|
||||
let flags = Flags::new(0x80);
|
||||
assert!(flags.is_unencrypted());
|
||||
assert!(!flags.is_compressed());
|
||||
assert!(!flags.is_newprotocol());
|
||||
assert!(!flags.is_fragmented());
|
||||
|
||||
let flags = Flags::new(0x40);
|
||||
assert!(!flags.is_unencrypted());
|
||||
assert!(flags.is_compressed());
|
||||
|
||||
let flags = Flags::new(0x20);
|
||||
assert!(flags.is_newprotocol());
|
||||
|
||||
let flags = Flags::new(0x10);
|
||||
assert!(flags.is_fragmented());
|
||||
|
||||
let flags = Flags::new(0x02);
|
||||
assert_eq!(flags.packet_type(), PacketType::Command);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_c2s() {
|
||||
let mut data = vec![0u8; 13];
|
||||
// MAC
|
||||
data[0..8].copy_from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
|
||||
// Packet ID = 42
|
||||
data[8..10].copy_from_slice(&42u16.to_be_bytes());
|
||||
// Client ID = 1
|
||||
data[10..12].copy_from_slice(&1u16.to_be_bytes());
|
||||
// Flags = Command
|
||||
data[12] = 0x02;
|
||||
|
||||
let header = Header::parse_c2s(&data).unwrap();
|
||||
assert_eq!(header.packet_id, 42);
|
||||
assert_eq!(header.client_id, Some(1));
|
||||
assert_eq!(header.flags.packet_type(), PacketType::Command);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_s2c() {
|
||||
let mut data = vec![0u8; 11];
|
||||
// MAC
|
||||
data[0..8].copy_from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
|
||||
// Packet ID = 10
|
||||
data[8..10].copy_from_slice(&10u16.to_be_bytes());
|
||||
// Flags = Voice
|
||||
data[10] = 0x00;
|
||||
|
||||
let header = Header::parse_s2c(&data).unwrap();
|
||||
assert_eq!(header.packet_id, 10);
|
||||
assert!(header.client_id.is_none());
|
||||
assert_eq!(header.flags.packet_type(), PacketType::Voice);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_in_packet_parse() {
|
||||
let mut data = vec![0u8; 15];
|
||||
// S2C header
|
||||
data[0..8].copy_from_slice(&[0; 8]); // MAC
|
||||
data[8..10].copy_from_slice(&1u16.to_be_bytes()); // PId
|
||||
data[10] = 0x02; // Command type
|
||||
// Content
|
||||
data[11] = b'H';
|
||||
data[12] = b'i';
|
||||
data[13] = b'!';
|
||||
data[14] = 0;
|
||||
|
||||
let packet = InPacket::parse(Direction::S2C, &data).unwrap();
|
||||
assert_eq!(packet.header.packet_id, 1);
|
||||
assert_eq!(packet.content(), b"Hi!\0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_out_packet() {
|
||||
let content = b"Hello".to_vec();
|
||||
let mut packet = OutPacket::new(Direction::C2S, Flags::new(0x02), content);
|
||||
packet.set_packet_id(42);
|
||||
packet.set_client_id(1);
|
||||
|
||||
let bytes = packet.to_bytes();
|
||||
assert_eq!(bytes.len(), 13 + 5); // header + content
|
||||
assert_eq!(packet.header.packet_id, 42);
|
||||
assert_eq!(packet.header.client_id, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_parse() {
|
||||
let cmd = Command::parse("clientinit client_nickname=Test\\sUser client_version=3.0.19.3")
|
||||
.unwrap();
|
||||
assert_eq!(cmd.name, "clientinit");
|
||||
assert_eq!(cmd.get("client_nickname"), Some("Test User"));
|
||||
assert_eq!(cmd.get("client_version"), Some("3.0.19.3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_parse_many() {
|
||||
let commands = Command::parse_many(
|
||||
"channellist cid=1 channel_name=Root|cid=2 channel_name=Gaming\\pVoice",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(commands.len(), 2);
|
||||
assert_eq!(commands[0].name, "channellist");
|
||||
assert_eq!(commands[0].get("cid"), Some("1"));
|
||||
assert_eq!(commands[0].get("channel_name"), Some("Root"));
|
||||
assert_eq!(commands[1].name, "channellist");
|
||||
assert_eq!(commands[1].get("cid"), Some("2"));
|
||||
assert_eq!(commands[1].get("channel_name"), Some("Gaming|Voice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_serialize() {
|
||||
let cmd = Command::new("sendtextmessage")
|
||||
.key_value("targetmode", "2")
|
||||
.key_value("msg", "Hello World!");
|
||||
assert_eq!(
|
||||
cmd.to_string(),
|
||||
"sendtextmessage targetmode=2 msg=Hello\\sWorld!"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_builder() {
|
||||
let cmd = CommandBuilder::new("clientinit")
|
||||
.arg("client_nickname", "Test")
|
||||
.arg("client_version", "3.0.19.3")
|
||||
.flag("verbose")
|
||||
.build();
|
||||
|
||||
assert_eq!(cmd.name, "clientinit");
|
||||
assert_eq!(cmd.get("client_nickname"), Some("Test"));
|
||||
assert!(cmd.has("verbose"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_sequences() {
|
||||
use crate::protocol::commands::escape;
|
||||
|
||||
assert_eq!(escape::escape("hello world"), "hello\\sworld");
|
||||
assert_eq!(escape::escape("a|b"), "a\\pb");
|
||||
assert_eq!(escape::escape("a\\b"), "a\\\\b");
|
||||
|
||||
assert_eq!(escape::unescape("hello\\sworld").unwrap(), "hello world");
|
||||
assert_eq!(escape::unescape("a\\pb").unwrap(), "a|b");
|
||||
assert_eq!(escape::unescape("a\\\\b").unwrap(), "a\\b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_packet_parse() {
|
||||
// Init0
|
||||
let mut data = vec![0u8; 21];
|
||||
data[0] = 0; // step
|
||||
data[1..5].copy_from_slice(&1466672534u32.to_be_bytes()); // version
|
||||
data[6..10].copy_from_slice(&1000000u32.to_be_bytes()); // timestamp
|
||||
data[10..14].copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); // random0
|
||||
|
||||
let init = InitPacket::parse(&data).unwrap();
|
||||
assert_eq!(init.step, InitStep::Init0);
|
||||
assert_eq!(init.version, Some(1466672534));
|
||||
assert_eq!(init.random0, Some([0xAA, 0xBB, 0xCC, 0xDD]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_packet_serialize() {
|
||||
let init = InitPacket {
|
||||
step: InitStep::Init0,
|
||||
version: Some(1466672534),
|
||||
timestamp: Some(1000000),
|
||||
random0: Some([0xAA, 0xBB, 0xCC, 0xDD]),
|
||||
random1: None,
|
||||
random0_r: None,
|
||||
x: None,
|
||||
n: None,
|
||||
level: None,
|
||||
random2: None,
|
||||
y: None,
|
||||
command: None,
|
||||
};
|
||||
|
||||
let data = init.to_bytes();
|
||||
assert_eq!(data[0], 0); // step
|
||||
assert_eq!(data[1..5], 1466672534u32.to_be_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_c2s_init_packet_serialize() {
|
||||
let init = InitPacket {
|
||||
step: InitStep::Init0,
|
||||
version: Some(1466672534),
|
||||
timestamp: Some(1000000),
|
||||
random0: Some([0xAA, 0xBB, 0xCC, 0xDD]),
|
||||
random1: None,
|
||||
random0_r: None,
|
||||
x: None,
|
||||
n: None,
|
||||
level: None,
|
||||
random2: None,
|
||||
y: None,
|
||||
command: None,
|
||||
};
|
||||
|
||||
let content = init.to_c2s_bytes();
|
||||
assert_eq!(content.len(), 21);
|
||||
assert_eq!(content[0..4], 1466672534u32.to_be_bytes());
|
||||
assert_eq!(content[4], 0);
|
||||
assert_eq!(content[5..9], 1000000u32.to_be_bytes());
|
||||
assert_eq!(content[9..13], [0xAA, 0xBB, 0xCC, 0xDD]);
|
||||
|
||||
let parsed = InitPacket::parse_c2s(&content).unwrap();
|
||||
assert_eq!(parsed.step, InitStep::Init0);
|
||||
assert_eq!(parsed.version, Some(1466672534));
|
||||
assert_eq!(parsed.timestamp, Some(1000000));
|
||||
assert_eq!(parsed.random0, Some([0xAA, 0xBB, 0xCC, 0xDD]));
|
||||
|
||||
let bytes = init.to_c2s_packet_bytes();
|
||||
let packet = InPacket::parse(Direction::C2S, &bytes).unwrap();
|
||||
assert_eq!(packet.header.mac, INIT_MAC);
|
||||
assert_eq!(packet.header.packet_id, INIT_PACKET_ID);
|
||||
assert_eq!(packet.header.flags.packet_type(), PacketType::Init);
|
||||
assert_eq!(packet.content(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ack_packet() {
|
||||
let ack = AckPacket::new(Direction::C2S, PacketType::Ack, 42);
|
||||
let packet = ack.to_out_packet();
|
||||
assert_eq!(packet.header.flags.packet_type(), PacketType::Ack);
|
||||
assert_eq!(packet.data, 42u16.to_be_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_packet_type_properties() {
|
||||
assert!(PacketType::Command.must_encrypt());
|
||||
assert!(!PacketType::Voice.must_encrypt());
|
||||
assert!(PacketType::Command.can_fragment());
|
||||
assert!(!PacketType::Voice.can_fragment());
|
||||
assert!(PacketType::Command.needs_ack());
|
||||
assert!(!PacketType::Voice.needs_ack());
|
||||
assert!(PacketType::Voice.is_voice());
|
||||
assert!(PacketType::VoiceWhisper.is_voice());
|
||||
assert!(!PacketType::Command.is_voice());
|
||||
}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
//! Protocol type definitions
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Packet type
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PacketType {
|
||||
Voice,
|
||||
VoiceWhisper,
|
||||
Command,
|
||||
CommandLow,
|
||||
Ping,
|
||||
Pong,
|
||||
Ack,
|
||||
AckLow,
|
||||
Init,
|
||||
}
|
||||
|
||||
impl PacketType {
|
||||
pub fn from_u8(value: u8) -> Self {
|
||||
match value {
|
||||
0x00 => Self::Voice,
|
||||
0x01 => Self::VoiceWhisper,
|
||||
0x02 => Self::Command,
|
||||
0x03 => Self::CommandLow,
|
||||
0x04 => Self::Ping,
|
||||
0x05 => Self::Pong,
|
||||
0x06 => Self::Ack,
|
||||
0x07 => Self::AckLow,
|
||||
0x08 => Self::Init,
|
||||
_ => Self::Init,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_u8(&self) -> u8 {
|
||||
match self {
|
||||
Self::Voice => 0x00,
|
||||
Self::VoiceWhisper => 0x01,
|
||||
Self::Command => 0x02,
|
||||
Self::CommandLow => 0x03,
|
||||
Self::Ping => 0x04,
|
||||
Self::Pong => 0x05,
|
||||
Self::Ack => 0x06,
|
||||
Self::AckLow => 0x07,
|
||||
Self::Init => 0x08,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_usize(&self) -> usize {
|
||||
self.to_u8() as usize
|
||||
}
|
||||
|
||||
pub fn is_voice(&self) -> bool {
|
||||
matches!(self, Self::Voice | Self::VoiceWhisper)
|
||||
}
|
||||
|
||||
pub fn needs_ack(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Command | Self::CommandLow | Self::Ping | Self::Init
|
||||
)
|
||||
}
|
||||
|
||||
pub fn can_resend(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Command | Self::CommandLow | Self::Ack | Self::AckLow | Self::Init
|
||||
)
|
||||
}
|
||||
|
||||
pub fn can_encrypt(&self) -> bool {
|
||||
!matches!(self, Self::Init)
|
||||
}
|
||||
|
||||
pub fn must_encrypt(&self) -> bool {
|
||||
matches!(self, Self::Command | Self::CommandLow)
|
||||
}
|
||||
|
||||
pub fn can_fragment(&self) -> bool {
|
||||
matches!(self, Self::Command | Self::CommandLow)
|
||||
}
|
||||
|
||||
pub fn can_compress(&self) -> bool {
|
||||
matches!(self, Self::Command | Self::CommandLow)
|
||||
}
|
||||
|
||||
pub fn ack_type(&self) -> Option<Self> {
|
||||
match self {
|
||||
Self::Command => Some(Self::Ack),
|
||||
Self::CommandLow => Some(Self::AckLow),
|
||||
Self::Ping => Some(Self::Pong),
|
||||
Self::Init => Some(Self::Init),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PacketType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Voice => write!(f, "Voice"),
|
||||
Self::VoiceWhisper => write!(f, "VoiceWhisper"),
|
||||
Self::Command => write!(f, "Command"),
|
||||
Self::CommandLow => write!(f, "CommandLow"),
|
||||
Self::Ping => write!(f, "Ping"),
|
||||
Self::Pong => write!(f, "Pong"),
|
||||
Self::Ack => write!(f, "Ack"),
|
||||
Self::AckLow => write!(f, "AckLow"),
|
||||
Self::Init => write!(f, "Init"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Codec type
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum CodecType {
|
||||
SpeexNarrowband,
|
||||
SpeexWideband,
|
||||
SpeexUltrawideband,
|
||||
CeltMono,
|
||||
OpusVoice,
|
||||
OpusMusic,
|
||||
}
|
||||
|
||||
impl CodecType {
|
||||
pub fn from_u8(value: u8) -> Self {
|
||||
match value {
|
||||
0 => Self::SpeexNarrowband,
|
||||
1 => Self::SpeexWideband,
|
||||
2 => Self::SpeexUltrawideband,
|
||||
3 => Self::CeltMono,
|
||||
4 => Self::OpusVoice,
|
||||
5 => Self::OpusMusic,
|
||||
_ => Self::OpusVoice,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_u8(&self) -> u8 {
|
||||
match self {
|
||||
Self::SpeexNarrowband => 0,
|
||||
Self::SpeexWideband => 1,
|
||||
Self::SpeexUltrawideband => 2,
|
||||
Self::CeltMono => 3,
|
||||
Self::OpusVoice => 4,
|
||||
Self::OpusMusic => 5,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sample_rate(&self) -> u32 {
|
||||
match self {
|
||||
Self::SpeexNarrowband => 8000,
|
||||
Self::SpeexWideband => 16000,
|
||||
Self::SpeexUltrawideband => 32000,
|
||||
Self::CeltMono | Self::OpusVoice | Self::OpusMusic => 48000,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn channels(&self) -> u16 {
|
||||
match self {
|
||||
Self::OpusMusic => 2,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whisper type
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GroupWhisperType {
|
||||
ServerGroup,
|
||||
ChannelGroup,
|
||||
ChannelCommander,
|
||||
AllClients,
|
||||
}
|
||||
|
||||
impl GroupWhisperType {
|
||||
pub fn from_u8(value: u8) -> Self {
|
||||
match value {
|
||||
0 => Self::ServerGroup,
|
||||
1 => Self::ChannelGroup,
|
||||
2 => Self::ChannelCommander,
|
||||
3 => Self::AllClients,
|
||||
_ => Self::AllClients,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_u8(&self) -> u8 {
|
||||
match self {
|
||||
Self::ServerGroup => 0,
|
||||
Self::ChannelGroup => 1,
|
||||
Self::ChannelCommander => 2,
|
||||
Self::AllClients => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whisper target
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GroupWhisperTarget {
|
||||
AllChannels,
|
||||
CurrentChannel,
|
||||
ParentChannel,
|
||||
AllParentChannel,
|
||||
ChannelFamily,
|
||||
CompleteChannelFamily,
|
||||
Subchannels,
|
||||
}
|
||||
|
||||
impl GroupWhisperTarget {
|
||||
pub fn from_u8(value: u8) -> Self {
|
||||
match value {
|
||||
0 => Self::AllChannels,
|
||||
1 => Self::CurrentChannel,
|
||||
2 => Self::ParentChannel,
|
||||
3 => Self::AllParentChannel,
|
||||
4 => Self::ChannelFamily,
|
||||
5 => Self::CompleteChannelFamily,
|
||||
6 => Self::Subchannels,
|
||||
_ => Self::AllChannels,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_u8(&self) -> u8 {
|
||||
match self {
|
||||
Self::AllChannels => 0,
|
||||
Self::CurrentChannel => 1,
|
||||
Self::ParentChannel => 2,
|
||||
Self::AllParentChannel => 3,
|
||||
Self::ChannelFamily => 4,
|
||||
Self::CompleteChannelFamily => 5,
|
||||
Self::Subchannels => 6,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
use super::types::CodecType;
|
||||
use super::{Direction, Flags, InPacket, OutPacket, PacketType};
|
||||
use crate::ProtocolError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VoicePacket {
|
||||
pub packet_id: u16,
|
||||
pub codec: CodecType,
|
||||
pub audio_data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WhisperPacket {
|
||||
pub packet_id: u16,
|
||||
pub codec: CodecType,
|
||||
pub channel_targets: Vec<u16>,
|
||||
pub client_targets: Vec<u16>,
|
||||
pub audio_data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum VoiceData {
|
||||
Normal(VoicePacket),
|
||||
Whisper(WhisperPacket),
|
||||
}
|
||||
|
||||
impl VoicePacket {
|
||||
pub fn parse(data: &[u8]) -> Result<Self, ProtocolError> {
|
||||
if data.len() < 3 {
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 3,
|
||||
});
|
||||
}
|
||||
|
||||
let packet_id = u16::from_be_bytes([data[0], data[1]]);
|
||||
let codec = CodecType::from_u8(data[2]);
|
||||
let audio_data = data[3..].to_vec();
|
||||
|
||||
Ok(Self {
|
||||
packet_id,
|
||||
codec,
|
||||
audio_data,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut result = Vec::with_capacity(3 + self.audio_data.len());
|
||||
result.extend_from_slice(&self.packet_id.to_be_bytes());
|
||||
result.push(self.codec.to_u8());
|
||||
result.extend_from_slice(&self.audio_data);
|
||||
result
|
||||
}
|
||||
|
||||
pub fn sample_rate(&self) -> u32 {
|
||||
self.codec.sample_rate()
|
||||
}
|
||||
|
||||
pub fn channels(&self) -> u16 {
|
||||
self.codec.channels()
|
||||
}
|
||||
|
||||
pub fn is_opus(&self) -> bool {
|
||||
matches!(self.codec, CodecType::OpusVoice | CodecType::OpusMusic)
|
||||
}
|
||||
}
|
||||
|
||||
impl WhisperPacket {
|
||||
pub fn parse(data: &[u8]) -> Result<Self, ProtocolError> {
|
||||
if data.len() < 5 {
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 5,
|
||||
});
|
||||
}
|
||||
|
||||
let packet_id = u16::from_be_bytes([data[0], data[1]]);
|
||||
let codec = CodecType::from_u8(data[2]);
|
||||
let num_channels = data[3] as usize;
|
||||
let num_clients = data[4] as usize;
|
||||
|
||||
let header_len = 5 + (num_channels * 2) + (num_clients * 2);
|
||||
if data.len() < header_len {
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: header_len,
|
||||
});
|
||||
}
|
||||
|
||||
let mut offset = 5;
|
||||
let mut channel_targets = Vec::with_capacity(num_channels);
|
||||
for _ in 0..num_channels {
|
||||
channel_targets.push(u16::from_be_bytes([data[offset], data[offset + 1]]));
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
let mut client_targets = Vec::with_capacity(num_clients);
|
||||
for _ in 0..num_clients {
|
||||
client_targets.push(u16::from_be_bytes([data[offset], data[offset + 1]]));
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
let audio_data = data[offset..].to_vec();
|
||||
|
||||
Ok(Self {
|
||||
packet_id,
|
||||
codec,
|
||||
channel_targets,
|
||||
client_targets,
|
||||
audio_data,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let header_len = 5 + (self.channel_targets.len() * 2) + (self.client_targets.len() * 2);
|
||||
let mut result = Vec::with_capacity(header_len + self.audio_data.len());
|
||||
|
||||
result.extend_from_slice(&self.packet_id.to_be_bytes());
|
||||
result.push(self.codec.to_u8());
|
||||
result.push(self.channel_targets.len() as u8);
|
||||
result.push(self.client_targets.len() as u8);
|
||||
|
||||
for &channel in &self.channel_targets {
|
||||
result.extend_from_slice(&channel.to_be_bytes());
|
||||
}
|
||||
for &client in &self.client_targets {
|
||||
result.extend_from_slice(&client.to_be_bytes());
|
||||
}
|
||||
|
||||
result.extend_from_slice(&self.audio_data);
|
||||
result
|
||||
}
|
||||
|
||||
pub fn sample_rate(&self) -> u32 {
|
||||
self.codec.sample_rate()
|
||||
}
|
||||
|
||||
pub fn channels(&self) -> u16 {
|
||||
self.codec.channels()
|
||||
}
|
||||
|
||||
pub fn is_opus(&self) -> bool {
|
||||
matches!(self.codec, CodecType::OpusVoice | CodecType::OpusMusic)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_voice_packet(packet: &InPacket) -> Result<VoiceData, ProtocolError> {
|
||||
let data = &packet.data;
|
||||
let packet_type = packet.header.flags.packet_type();
|
||||
|
||||
match packet_type {
|
||||
PacketType::Voice => Ok(VoiceData::Normal(VoicePacket::parse(data)?)),
|
||||
PacketType::VoiceWhisper => Ok(VoiceData::Whisper(WhisperPacket::parse(data)?)),
|
||||
_ => Err(ProtocolError::InvalidPacketType(packet_type.to_u8())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_voice_packet(codec: CodecType, audio_data: &[u8], packet_id: u16) -> OutPacket {
|
||||
let voice = VoicePacket {
|
||||
packet_id,
|
||||
codec,
|
||||
audio_data: audio_data.to_vec(),
|
||||
};
|
||||
|
||||
let mut packet = OutPacket::new(
|
||||
Direction::C2S,
|
||||
Flags::new(PacketType::Voice.to_u8()),
|
||||
voice.to_bytes(),
|
||||
);
|
||||
packet.set_packet_id(packet_id);
|
||||
packet
|
||||
}
|
||||
|
||||
pub fn create_whisper_packet(
|
||||
codec: CodecType,
|
||||
audio_data: &[u8],
|
||||
packet_id: u16,
|
||||
channel_targets: Vec<u16>,
|
||||
client_targets: Vec<u16>,
|
||||
) -> OutPacket {
|
||||
let whisper = WhisperPacket {
|
||||
packet_id,
|
||||
codec,
|
||||
channel_targets,
|
||||
client_targets,
|
||||
audio_data: audio_data.to_vec(),
|
||||
};
|
||||
|
||||
let mut packet = OutPacket::new(
|
||||
Direction::C2S,
|
||||
Flags::new(PacketType::VoiceWhisper.to_u8()),
|
||||
whisper.to_bytes(),
|
||||
);
|
||||
packet.set_packet_id(packet_id);
|
||||
packet
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_voice_packet_parse() {
|
||||
let data = vec![0x00, 0x01, 0x04, 0xAA, 0xBB, 0xCC];
|
||||
let packet = VoicePacket::parse(&data).unwrap();
|
||||
|
||||
assert_eq!(packet.packet_id, 1);
|
||||
assert_eq!(packet.codec, CodecType::OpusVoice);
|
||||
assert_eq!(packet.audio_data, vec![0xAA, 0xBB, 0xCC]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_voice_packet_roundtrip() {
|
||||
let original = VoicePacket {
|
||||
packet_id: 42,
|
||||
codec: CodecType::OpusVoice,
|
||||
audio_data: vec![0x01, 0x02, 0x03, 0x04],
|
||||
};
|
||||
|
||||
let bytes = original.to_bytes();
|
||||
let parsed = VoicePacket::parse(&bytes).unwrap();
|
||||
|
||||
assert_eq!(parsed.packet_id, original.packet_id);
|
||||
assert_eq!(parsed.codec, original.codec);
|
||||
assert_eq!(parsed.audio_data, original.audio_data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whisper_packet_parse() {
|
||||
let data = vec![
|
||||
0x00, 0x01, // packet_id = 1
|
||||
0x04, // codec = OpusVoice
|
||||
0x01, // 1 channel target
|
||||
0x02, // 2 client targets
|
||||
0x00, 0x0A, // channel 10
|
||||
0x00, 0x14, // client 20
|
||||
0x00, 0x1E, // client 30
|
||||
0xAA, 0xBB, // audio data
|
||||
];
|
||||
let packet = WhisperPacket::parse(&data).unwrap();
|
||||
|
||||
assert_eq!(packet.packet_id, 1);
|
||||
assert_eq!(packet.codec, CodecType::OpusVoice);
|
||||
assert_eq!(packet.channel_targets, vec![10]);
|
||||
assert_eq!(packet.client_targets, vec![20, 30]);
|
||||
assert_eq!(packet.audio_data, vec![0xAA, 0xBB]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whisper_packet_roundtrip() {
|
||||
let original = WhisperPacket {
|
||||
packet_id: 42,
|
||||
codec: CodecType::OpusMusic,
|
||||
channel_targets: vec![1, 2],
|
||||
client_targets: vec![100, 200, 300],
|
||||
audio_data: vec![0x01, 0x02, 0x03],
|
||||
};
|
||||
|
||||
let bytes = original.to_bytes();
|
||||
let parsed = WhisperPacket::parse(&bytes).unwrap();
|
||||
|
||||
assert_eq!(parsed.packet_id, original.packet_id);
|
||||
assert_eq!(parsed.codec, original.codec);
|
||||
assert_eq!(parsed.channel_targets, original.channel_targets);
|
||||
assert_eq!(parsed.client_targets, original.client_targets);
|
||||
assert_eq!(parsed.audio_data, original.audio_data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_voice_packet_too_small() {
|
||||
let data = vec![0x00, 0x01]; // missing codec byte
|
||||
assert!(VoicePacket::parse(&data).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whisper_packet_too_small() {
|
||||
let data = vec![0x00, 0x01, 0x04, 0x01]; // missing client count
|
||||
assert!(WhisperPacket::parse(&data).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codec_properties() {
|
||||
let voice = VoicePacket {
|
||||
packet_id: 0,
|
||||
codec: CodecType::OpusVoice,
|
||||
audio_data: vec![],
|
||||
};
|
||||
assert_eq!(voice.sample_rate(), 48000);
|
||||
assert_eq!(voice.channels(), 1);
|
||||
assert!(voice.is_opus());
|
||||
|
||||
let music = VoicePacket {
|
||||
packet_id: 0,
|
||||
codec: CodecType::OpusMusic,
|
||||
audio_data: vec![],
|
||||
};
|
||||
assert_eq!(music.sample_rate(), 48000);
|
||||
assert_eq!(music.channels(), 2);
|
||||
assert!(music.is_opus());
|
||||
|
||||
let speex = VoicePacket {
|
||||
packet_id: 0,
|
||||
codec: CodecType::SpeexNarrowband,
|
||||
audio_data: vec![],
|
||||
};
|
||||
assert_eq!(speex.sample_rate(), 8000);
|
||||
assert!(!speex.is_opus());
|
||||
}
|
||||
}
|
||||
@@ -1,733 +0,0 @@
|
||||
//! TeamSpeak ServerQuery TCP client support.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use shared::{
|
||||
ChannelId, ClientDbId, ClientId, ClientType, PermissionId, PermissionInfo, ServerQueryChannel,
|
||||
ServerQueryClient, ServerQueryServerInfo,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpStream, ToSocketAddrs};
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const BUFFER_SIZE: usize = 1024;
|
||||
const GREETING_MARKER: &str = "ServerQuery interface";
|
||||
|
||||
pub type QueryResult<T> = Result<T, QueryError>;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum QueryError {
|
||||
#[error("ServerQuery I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("ServerQuery read timed out")]
|
||||
Timeout,
|
||||
|
||||
#[error("ServerQuery connection closed")]
|
||||
ConnectionClosed,
|
||||
|
||||
#[error("ServerQuery response did not include a status line")]
|
||||
MissingStatus,
|
||||
|
||||
#[error("invalid ServerQuery field: {0}")]
|
||||
InvalidField(String),
|
||||
|
||||
#[error("invalid ServerQuery status id: {0}")]
|
||||
InvalidStatusId(String),
|
||||
|
||||
#[error("ServerQuery error {id}: {message}")]
|
||||
Status { id: u32, message: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct QueryStatus {
|
||||
pub id: u32,
|
||||
pub message: String,
|
||||
pub fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl QueryStatus {
|
||||
pub fn get(&self, name: &str) -> Option<&str> {
|
||||
self.fields
|
||||
.iter()
|
||||
.find(|(key, _)| key == name)
|
||||
.map(|(_, value)| value.as_str())
|
||||
}
|
||||
|
||||
pub fn require(&self, name: &str) -> QueryResult<&str> {
|
||||
self.get(name)
|
||||
.ok_or_else(|| QueryError::InvalidField(format!("missing {name}")))
|
||||
}
|
||||
|
||||
pub fn get_u32(&self, name: &str) -> QueryResult<u32> {
|
||||
let value = self.require(name)?;
|
||||
value
|
||||
.parse::<u32>()
|
||||
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
|
||||
}
|
||||
|
||||
pub fn get_u64(&self, name: &str) -> QueryResult<u64> {
|
||||
let value = self.require(name)?;
|
||||
value
|
||||
.parse::<u64>()
|
||||
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
|
||||
}
|
||||
|
||||
pub fn get_u16(&self, name: &str) -> QueryResult<u16> {
|
||||
let value = self.require(name)?;
|
||||
value
|
||||
.parse::<u16>()
|
||||
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
|
||||
}
|
||||
|
||||
pub fn get_i32(&self, name: &str) -> QueryResult<i32> {
|
||||
let value = self.require(name)?;
|
||||
value
|
||||
.parse::<i32>()
|
||||
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
|
||||
}
|
||||
|
||||
pub fn get_bool(&self, name: &str) -> QueryResult<bool> {
|
||||
Ok(self.get_u32(name)? != 0)
|
||||
}
|
||||
|
||||
pub fn get_u32_or(&self, name: &str, default: u32) -> QueryResult<u32> {
|
||||
self.get(name)
|
||||
.map(|_| self.get_u32(name))
|
||||
.unwrap_or(Ok(default))
|
||||
}
|
||||
|
||||
pub fn get_i32_or(&self, name: &str, default: i32) -> QueryResult<i32> {
|
||||
self.get(name)
|
||||
.map(|_| self.get_i32(name))
|
||||
.unwrap_or(Ok(default))
|
||||
}
|
||||
|
||||
pub fn get_u64_or(&self, name: &str, default: u64) -> QueryResult<u64> {
|
||||
self.get(name)
|
||||
.map(|_| self.get_u64(name))
|
||||
.unwrap_or(Ok(default))
|
||||
}
|
||||
|
||||
pub fn get_u16_or(&self, name: &str, default: u16) -> QueryResult<u16> {
|
||||
self.get(name)
|
||||
.map(|_| self.get_u16(name))
|
||||
.unwrap_or(Ok(default))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct QueryRecord {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl QueryRecord {
|
||||
pub fn fields(&self) -> &[(String, String)] {
|
||||
&self.fields
|
||||
}
|
||||
|
||||
pub fn get(&self, name: &str) -> Option<&str> {
|
||||
self.fields
|
||||
.iter()
|
||||
.find(|(key, _)| key == name)
|
||||
.map(|(_, value)| value.as_str())
|
||||
}
|
||||
|
||||
pub fn require(&self, name: &str) -> QueryResult<&str> {
|
||||
self.get(name)
|
||||
.ok_or_else(|| QueryError::InvalidField(format!("missing {name}")))
|
||||
}
|
||||
|
||||
pub fn get_u32(&self, name: &str) -> QueryResult<u32> {
|
||||
let value = self.require(name)?;
|
||||
value
|
||||
.parse::<u32>()
|
||||
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
|
||||
}
|
||||
|
||||
pub fn get_u64(&self, name: &str) -> QueryResult<u64> {
|
||||
let value = self.require(name)?;
|
||||
value
|
||||
.parse::<u64>()
|
||||
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
|
||||
}
|
||||
|
||||
pub fn get_u16(&self, name: &str) -> QueryResult<u16> {
|
||||
let value = self.require(name)?;
|
||||
value
|
||||
.parse::<u16>()
|
||||
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
|
||||
}
|
||||
|
||||
pub fn get_i32(&self, name: &str) -> QueryResult<i32> {
|
||||
let value = self.require(name)?;
|
||||
value
|
||||
.parse::<i32>()
|
||||
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
|
||||
}
|
||||
|
||||
pub fn get_bool(&self, name: &str) -> QueryResult<bool> {
|
||||
Ok(self.get_u32(name)? != 0)
|
||||
}
|
||||
|
||||
pub fn get_u32_or(&self, name: &str, default: u32) -> QueryResult<u32> {
|
||||
self.get(name)
|
||||
.map(|_| self.get_u32(name))
|
||||
.unwrap_or(Ok(default))
|
||||
}
|
||||
|
||||
pub fn get_i32_or(&self, name: &str, default: i32) -> QueryResult<i32> {
|
||||
self.get(name)
|
||||
.map(|_| self.get_i32(name))
|
||||
.unwrap_or(Ok(default))
|
||||
}
|
||||
|
||||
pub fn get_u64_or(&self, name: &str, default: u64) -> QueryResult<u64> {
|
||||
self.get(name)
|
||||
.map(|_| self.get_u64(name))
|
||||
.unwrap_or(Ok(default))
|
||||
}
|
||||
|
||||
pub fn get_u16_or(&self, name: &str, default: u16) -> QueryResult<u16> {
|
||||
self.get(name)
|
||||
.map(|_| self.get_u16(name))
|
||||
.unwrap_or(Ok(default))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct QueryResponse {
|
||||
pub raw: String,
|
||||
pub records: Vec<QueryRecord>,
|
||||
pub status: QueryStatus,
|
||||
}
|
||||
|
||||
pub struct QueryClient {
|
||||
stream: TcpStream,
|
||||
greeting: String,
|
||||
read_timeout: Duration,
|
||||
}
|
||||
|
||||
impl QueryClient {
|
||||
pub async fn connect<A: ToSocketAddrs>(addr: A) -> QueryResult<Self> {
|
||||
let stream = TcpStream::connect(addr).await?;
|
||||
Self::from_stream(stream).await
|
||||
}
|
||||
|
||||
pub async fn from_stream(stream: TcpStream) -> QueryResult<Self> {
|
||||
let mut client = Self {
|
||||
stream,
|
||||
greeting: String::new(),
|
||||
read_timeout: DEFAULT_READ_TIMEOUT,
|
||||
};
|
||||
client.greeting = client.read_greeting().await?;
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
pub fn greeting(&self) -> &str {
|
||||
&self.greeting
|
||||
}
|
||||
|
||||
pub fn set_read_timeout(&mut self, timeout: Duration) {
|
||||
self.read_timeout = timeout;
|
||||
}
|
||||
|
||||
pub async fn execute(&mut self, command: &str) -> QueryResult<QueryResponse> {
|
||||
self.write_command(command).await?;
|
||||
let raw = self.read_until_status().await?;
|
||||
decode_response(raw)
|
||||
}
|
||||
|
||||
pub async fn login(&mut self, user: &str, password: &str) -> QueryResult<()> {
|
||||
let command = format!("login {} {}", escape(user), escape(password));
|
||||
self.execute(&command).await.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn use_server(&mut self, server_id: u64) -> QueryResult<()> {
|
||||
self.execute(&format!("use {server_id}")).await.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn whoami(&mut self) -> QueryResult<Option<QueryRecord>> {
|
||||
let mut response = self.execute("whoami").await?;
|
||||
Ok(response.records.pop())
|
||||
}
|
||||
|
||||
pub async fn permission_list(&mut self) -> QueryResult<Vec<PermissionInfo>> {
|
||||
let response = self.execute("permissionlist").await?;
|
||||
records_to_permissions(&response.records)
|
||||
}
|
||||
|
||||
pub async fn channel_list(&mut self) -> QueryResult<Vec<ServerQueryChannel>> {
|
||||
let response = self.execute("channellist").await?;
|
||||
records_to_channels(&response.records)
|
||||
}
|
||||
|
||||
pub async fn client_list(&mut self) -> QueryResult<Vec<ServerQueryClient>> {
|
||||
let response = self.execute("clientlist").await?;
|
||||
records_to_clients(&response.records)
|
||||
}
|
||||
|
||||
pub async fn server_info(&mut self) -> QueryResult<Option<ServerQueryServerInfo>> {
|
||||
let response = self.execute("serverinfo").await?;
|
||||
response
|
||||
.records
|
||||
.first()
|
||||
.map(record_to_server_info)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn write_command(&mut self, command: &str) -> QueryResult<()> {
|
||||
let mut payload = command.to_string();
|
||||
if !payload.ends_with("\n\r") && !payload.ends_with("\r\n") {
|
||||
payload.push_str("\n\r");
|
||||
}
|
||||
|
||||
self.stream.write_all(payload.as_bytes()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_greeting(&mut self) -> QueryResult<String> {
|
||||
self.read_until(|content| content.contains(GREETING_MARKER))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn read_until_status(&mut self) -> QueryResult<String> {
|
||||
self.read_until(contains_status_line).await
|
||||
}
|
||||
|
||||
async fn read_until<F>(&mut self, done: F) -> QueryResult<String>
|
||||
where
|
||||
F: Fn(&str) -> bool,
|
||||
{
|
||||
let timeout = self.read_timeout;
|
||||
let stream = &mut self.stream;
|
||||
|
||||
tokio::time::timeout(timeout, async move {
|
||||
let mut data = Vec::new();
|
||||
let mut buffer = [0u8; BUFFER_SIZE];
|
||||
|
||||
loop {
|
||||
let len = stream.read(&mut buffer).await?;
|
||||
if len == 0 {
|
||||
return Err(QueryError::ConnectionClosed);
|
||||
}
|
||||
|
||||
data.extend_from_slice(&buffer[..len]);
|
||||
let content = String::from_utf8_lossy(&data);
|
||||
if done(&content) {
|
||||
return Ok(content.into_owned());
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| QueryError::Timeout)?
|
||||
}
|
||||
}
|
||||
|
||||
pub fn records_to_permissions(records: &[QueryRecord]) -> QueryResult<Vec<PermissionInfo>> {
|
||||
records.iter().map(record_to_permission).collect()
|
||||
}
|
||||
|
||||
pub fn record_to_permission(record: &QueryRecord) -> QueryResult<PermissionInfo> {
|
||||
Ok(PermissionInfo {
|
||||
id: PermissionId(record.get_u32("permid")?),
|
||||
name: record.require("permname")?.to_string(),
|
||||
description: record.get("permdesc").unwrap_or_default().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn records_to_channels(records: &[QueryRecord]) -> QueryResult<Vec<ServerQueryChannel>> {
|
||||
records.iter().map(record_to_channel).collect()
|
||||
}
|
||||
|
||||
pub fn record_to_channel(record: &QueryRecord) -> QueryResult<ServerQueryChannel> {
|
||||
Ok(ServerQueryChannel {
|
||||
id: ChannelId(record.get_u64("cid")?),
|
||||
parent_id: ChannelId(record.get_u64_or("pid", 0)?),
|
||||
order: ChannelId(record.get_u64_or("channel_order", 0)?),
|
||||
name: record.require("channel_name")?.to_string(),
|
||||
total_clients: record.get_u32_or("total_clients", 0)?,
|
||||
needed_subscribe_power: record.get_i32_or("channel_needed_subscribe_power", 0)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn records_to_clients(records: &[QueryRecord]) -> QueryResult<Vec<ServerQueryClient>> {
|
||||
records.iter().map(record_to_client).collect()
|
||||
}
|
||||
|
||||
pub fn record_to_client(record: &QueryRecord) -> QueryResult<ServerQueryClient> {
|
||||
let client_type = if record.get_u32_or("client_type", 0)? == 0 {
|
||||
ClientType::Normal
|
||||
} else {
|
||||
ClientType::Query { admin: false }
|
||||
};
|
||||
|
||||
Ok(ServerQueryClient {
|
||||
id: ClientId(record.get_u16("clid")?),
|
||||
channel_id: ChannelId(record.get_u64("cid")?),
|
||||
database_id: ClientDbId(record.get_u64_or("client_database_id", 0)?),
|
||||
nickname: record.require("client_nickname")?.to_string(),
|
||||
client_type,
|
||||
unique_identifier: record
|
||||
.get("client_unique_identifier")
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn record_to_server_info(record: &QueryRecord) -> QueryResult<ServerQueryServerInfo> {
|
||||
Ok(ServerQueryServerInfo {
|
||||
name: record.require("virtualserver_name")?.to_string(),
|
||||
platform: record
|
||||
.get("virtualserver_platform")
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
version: record
|
||||
.get("virtualserver_version")
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
max_clients: record.get_u16_or("virtualserver_maxclients", 0)?,
|
||||
clients_online: record.get_u16_or("virtualserver_clientsonline", 0)?,
|
||||
channels_online: record.get_u64_or("virtualserver_channelsonline", 0)?,
|
||||
uptime: record.get_u64_or("virtualserver_uptime", 0)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decode_response(raw: String) -> QueryResult<QueryResponse> {
|
||||
let mut records = Vec::new();
|
||||
let mut status = None;
|
||||
|
||||
for line in raw
|
||||
.lines()
|
||||
.map(normalize_line)
|
||||
.filter(|line| !line.is_empty())
|
||||
{
|
||||
if let Some(status_line) = line.strip_prefix("error ") {
|
||||
status = Some(parse_status(status_line)?);
|
||||
break;
|
||||
}
|
||||
|
||||
for record in line.split('|').filter(|record| !record.is_empty()) {
|
||||
records.push(parse_record(record)?);
|
||||
}
|
||||
}
|
||||
|
||||
let status = status.ok_or(QueryError::MissingStatus)?;
|
||||
if status.id != 0 {
|
||||
return Err(QueryError::Status {
|
||||
id: status.id,
|
||||
message: status.message,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(QueryResponse {
|
||||
raw,
|
||||
records,
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn escape(input: &str) -> String {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
for ch in input.chars() {
|
||||
match ch {
|
||||
'\\' => output.push_str("\\\\"),
|
||||
' ' => output.push_str("\\s"),
|
||||
'|' => output.push_str("\\p"),
|
||||
'/' => output.push_str("\\/"),
|
||||
'\n' => output.push_str("\\n"),
|
||||
'\r' => output.push_str("\\r"),
|
||||
'\t' => output.push_str("\\t"),
|
||||
_ => output.push(ch),
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub fn unescape(input: &str) -> QueryResult<String> {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut chars = input.chars();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch != '\\' {
|
||||
output.push(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
match chars.next() {
|
||||
Some('s') => output.push(' '),
|
||||
Some('p') => output.push('|'),
|
||||
Some('/') => output.push('/'),
|
||||
Some('\\') => output.push('\\'),
|
||||
Some('a') => output.push('\u{0007}'),
|
||||
Some('b') => output.push('\u{0008}'),
|
||||
Some('f') => output.push('\u{000c}'),
|
||||
Some('n') => output.push('\n'),
|
||||
Some('r') => output.push('\r'),
|
||||
Some('t') => output.push('\t'),
|
||||
Some('v') => output.push('\u{000b}'),
|
||||
Some(other) => {
|
||||
return Err(QueryError::InvalidField(format!(
|
||||
"unknown escape \\{other}"
|
||||
)))
|
||||
}
|
||||
None => return Err(QueryError::InvalidField("trailing escape".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn parse_status(input: &str) -> QueryResult<QueryStatus> {
|
||||
let fields = parse_fields(input)?;
|
||||
let id = fields
|
||||
.iter()
|
||||
.find(|(key, _)| key == "id")
|
||||
.map(|(_, value)| value.as_str())
|
||||
.ok_or_else(|| QueryError::InvalidField(input.to_string()))?;
|
||||
let id = id
|
||||
.parse::<u32>()
|
||||
.map_err(|_| QueryError::InvalidStatusId(id.to_string()))?;
|
||||
let message = fields
|
||||
.iter()
|
||||
.find(|(key, _)| key == "msg")
|
||||
.map(|(_, value)| value.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(QueryStatus {
|
||||
id,
|
||||
message,
|
||||
fields,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_record(input: &str) -> QueryResult<QueryRecord> {
|
||||
Ok(QueryRecord {
|
||||
fields: parse_fields(input)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_fields(input: &str) -> QueryResult<Vec<(String, String)>> {
|
||||
input
|
||||
.split(' ')
|
||||
.filter(|field| !field.is_empty())
|
||||
.map(|field| {
|
||||
let (key, value) = field.split_once('=').unwrap_or((field, ""));
|
||||
Ok((unescape(key)?, unescape(value)?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn contains_status_line(content: &str) -> bool {
|
||||
content
|
||||
.lines()
|
||||
.map(normalize_line)
|
||||
.any(|line| line.starts_with("error id="))
|
||||
}
|
||||
|
||||
fn normalize_line(line: &str) -> &str {
|
||||
line.trim_end_matches('\r').trim_end_matches('\n')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[test]
|
||||
fn decodes_success_response_records() {
|
||||
let response = decode_response(
|
||||
"clid=7 client_database_id=12 client_nickname=hello\\sworld|clid=8 client_nickname=a\\pb\r\nerror id=0 msg=ok\r\n"
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status.id, 0);
|
||||
assert_eq!(response.records.len(), 2);
|
||||
assert_eq!(
|
||||
response.records[0].get("client_nickname"),
|
||||
Some("hello world")
|
||||
);
|
||||
assert_eq!(response.records[1].get("client_nickname"), Some("a|b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_error_status() {
|
||||
let error =
|
||||
decode_response("error id=256 msg=command\\snot\\sfound\n\r".to_string()).unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
QueryError::Status { id: 256, message } if message == "command not found"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_query_values() {
|
||||
assert_eq!(escape("a b|c/d\\e"), "a\\sb\\pc\\/d\\\\e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_permissionlist_records() {
|
||||
let response = decode_response(
|
||||
"permid=1 permname=b_serverinstance_help_view permdesc=Retrieve\\sinformation\\sabout\\sServerQuery\\scommands|permid=32769 permname=i_needed_modify_power_serverinstance_help_view\r\nerror id=0 msg=ok\r\n"
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let permissions = records_to_permissions(&response.records).unwrap();
|
||||
assert_eq!(permissions.len(), 2);
|
||||
assert_eq!(permissions[0].id, PermissionId(1));
|
||||
assert_eq!(permissions[0].name, "b_serverinstance_help_view");
|
||||
assert_eq!(
|
||||
permissions[0].description,
|
||||
"Retrieve information about ServerQuery commands"
|
||||
);
|
||||
assert_eq!(permissions[1].id, PermissionId(32769));
|
||||
assert_eq!(permissions[1].description, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_common_serverquery_records() {
|
||||
let response = decode_response(
|
||||
"cid=1 pid=0 channel_order=0 channel_name=Lobby total_clients=2 channel_needed_subscribe_power=0|cid=2 pid=1 channel_order=1 channel_name=Voice\\sRoom total_clients=0 channel_needed_subscribe_power=25\r\nerror id=0 msg=ok\r\n"
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let channels = records_to_channels(&response.records).unwrap();
|
||||
assert_eq!(channels.len(), 2);
|
||||
assert_eq!(channels[0].id, ChannelId(1));
|
||||
assert_eq!(channels[1].name, "Voice Room");
|
||||
assert_eq!(channels[1].needed_subscribe_power, 25);
|
||||
|
||||
let response = decode_response(
|
||||
"clid=8 cid=1 client_database_id=1 client_nickname=serveradmin client_type=1 client_unique_identifier=serveradmin|clid=9 cid=2 client_database_id=42 client_nickname=Normal\\sUser client_type=0 client_unique_identifier=abc\r\nerror id=0 msg=ok\r\n"
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let clients = records_to_clients(&response.records).unwrap();
|
||||
assert_eq!(clients.len(), 2);
|
||||
assert_eq!(clients[0].id, ClientId(8));
|
||||
assert_eq!(clients[0].client_type, ClientType::Query { admin: false });
|
||||
assert_eq!(clients[1].nickname, "Normal User");
|
||||
assert_eq!(clients[1].client_type, ClientType::Normal);
|
||||
|
||||
let response = decode_response(
|
||||
"virtualserver_name=Test\\sServer virtualserver_platform=Linux virtualserver_version=3.13.7 virtualserver_maxclients=32 virtualserver_clientsonline=4 virtualserver_channelsonline=12 virtualserver_uptime=3600\r\nerror id=0 msg=ok\r\n"
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let server = record_to_server_info(&response.records[0]).unwrap();
|
||||
assert_eq!(server.name, "Test Server");
|
||||
assert_eq!(server.max_clients, 32);
|
||||
assert_eq!(server.clients_online, 4);
|
||||
assert_eq!(server.channels_online, 12);
|
||||
assert_eq!(server.uptime, 3600);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executes_commands_against_mock_server() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
stream
|
||||
.write_all(b"TS3\r\nWelcome to the TeamSpeak 3 ServerQuery interface\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let command = read_command(&mut stream).await;
|
||||
assert_eq!(command, "login serveradmin secret\\spass");
|
||||
stream.write_all(b"error id=0 msg=ok\r\n").await.unwrap();
|
||||
|
||||
let command = read_command(&mut stream).await;
|
||||
assert_eq!(command, "whoami");
|
||||
stream
|
||||
.write_all(
|
||||
b"clid=4 client_database_id=10 client_nickname=serveradmin\r\nerror id=0 msg=ok\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let command = read_command(&mut stream).await;
|
||||
assert_eq!(command, "permissionlist");
|
||||
stream
|
||||
.write_all(
|
||||
b"permid=24 permname=b_virtualserver_select permdesc=Select\\sa\\svirtual\\sserver|permid=248 permname=i_ft_quota_mb_upload_per_client permdesc=Upload\\squota\\sper\\sclient\\sin\\sMByte\r\nerror id=0 msg=ok\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let command = read_command(&mut stream).await;
|
||||
assert_eq!(command, "channellist");
|
||||
stream
|
||||
.write_all(
|
||||
b"cid=1 pid=0 channel_order=0 channel_name=Lobby total_clients=1 channel_needed_subscribe_power=0\r\nerror id=0 msg=ok\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let command = read_command(&mut stream).await;
|
||||
assert_eq!(command, "clientlist");
|
||||
stream
|
||||
.write_all(
|
||||
b"clid=9 cid=1 client_database_id=42 client_nickname=Normal\\sUser client_type=0 client_unique_identifier=abc\r\nerror id=0 msg=ok\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let command = read_command(&mut stream).await;
|
||||
assert_eq!(command, "serverinfo");
|
||||
stream
|
||||
.write_all(
|
||||
b"virtualserver_name=Mock\\sServer virtualserver_platform=Linux virtualserver_version=3.13.7 virtualserver_maxclients=32 virtualserver_clientsonline=1 virtualserver_channelsonline=1 virtualserver_uptime=99\r\nerror id=0 msg=ok\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let mut client = QueryClient::connect(addr).await.unwrap();
|
||||
assert!(client.greeting().contains("TS3"));
|
||||
client.login("serveradmin", "secret pass").await.unwrap();
|
||||
|
||||
let whoami = client.whoami().await.unwrap().unwrap();
|
||||
assert_eq!(whoami.get("clid"), Some("4"));
|
||||
assert_eq!(whoami.get("client_database_id"), Some("10"));
|
||||
|
||||
let permissions = client.permission_list().await.unwrap();
|
||||
assert_eq!(permissions.len(), 2);
|
||||
assert_eq!(permissions[0].id, PermissionId(24));
|
||||
assert_eq!(permissions[0].name, "b_virtualserver_select");
|
||||
assert_eq!(permissions[1].id, PermissionId(248));
|
||||
|
||||
let channels = client.channel_list().await.unwrap();
|
||||
assert_eq!(channels[0].name, "Lobby");
|
||||
assert_eq!(channels[0].total_clients, 1);
|
||||
|
||||
let clients = client.client_list().await.unwrap();
|
||||
assert_eq!(clients[0].nickname, "Normal User");
|
||||
assert_eq!(clients[0].database_id, ClientDbId(42));
|
||||
|
||||
let server_info = client.server_info().await.unwrap().unwrap();
|
||||
assert_eq!(server_info.name, "Mock Server");
|
||||
assert_eq!(server_info.uptime, 99);
|
||||
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
async fn read_command(stream: &mut TcpStream) -> String {
|
||||
let mut data = Vec::new();
|
||||
let mut buffer = [0u8; 64];
|
||||
loop {
|
||||
let len = stream.read(&mut buffer).await.unwrap();
|
||||
assert_ne!(len, 0);
|
||||
data.extend_from_slice(&buffer[..len]);
|
||||
let content = String::from_utf8_lossy(&data);
|
||||
if content.ends_with("\n\r") || content.ends_with("\r\n") {
|
||||
return content.trim_end_matches(['\n', '\r']).to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
[package]
|
||||
name = "tsdb"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "TeamSpeak 数据存储"
|
||||
|
||||
[dependencies]
|
||||
# Error handling
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
# Logging
|
||||
tracing = { workspace = true }
|
||||
|
||||
# Database
|
||||
rusqlite = { workspace = true }
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# Utils
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
# Internal
|
||||
shared = { workspace = true }
|
||||
@@ -1,171 +0,0 @@
|
||||
//! Bookmark management
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::params;
|
||||
|
||||
use super::{DatabaseError, DatabaseManager, DatabaseResult};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Bookmark {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
pub nickname: Option<String>,
|
||||
pub server_password: Option<String>,
|
||||
pub channel: Option<String>,
|
||||
pub channel_password: Option<String>,
|
||||
pub default_token: Option<String>,
|
||||
pub auto_connect: bool,
|
||||
pub last_connected: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl DatabaseManager {
|
||||
pub fn create_bookmark(
|
||||
&self,
|
||||
name: &str,
|
||||
address: &str,
|
||||
port: u16,
|
||||
nickname: Option<&str>,
|
||||
) -> DatabaseResult<Bookmark> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
self.connection().execute(
|
||||
"INSERT INTO bookmarks (id, name, address, port, nickname, auto_connect, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![id, name, address, port, nickname, false, now, now],
|
||||
)?;
|
||||
|
||||
Ok(Bookmark {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
address: address.to_string(),
|
||||
port,
|
||||
nickname: nickname.map(|s| s.to_string()),
|
||||
server_password: None,
|
||||
channel: None,
|
||||
channel_password: None,
|
||||
default_token: None,
|
||||
auto_connect: false,
|
||||
last_connected: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_bookmark(&self, id: &str) -> DatabaseResult<Bookmark> {
|
||||
let conn = self.connection();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, address, port, nickname, server_password, channel, channel_password, default_token, auto_connect, last_connected, created_at, updated_at FROM bookmarks WHERE id = ?1"
|
||||
)?;
|
||||
|
||||
let bookmark = stmt
|
||||
.query_row(params![id], |row| {
|
||||
Ok(Bookmark {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
address: row.get(2)?,
|
||||
port: row.get(3)?,
|
||||
nickname: row.get(4)?,
|
||||
server_password: row.get(5)?,
|
||||
channel: row.get(6)?,
|
||||
channel_password: row.get(7)?,
|
||||
default_token: row.get(8)?,
|
||||
auto_connect: row.get::<_, i32>(9)? != 0,
|
||||
last_connected: row.get(10)?,
|
||||
created_at: row.get(11)?,
|
||||
updated_at: row.get(12)?,
|
||||
})
|
||||
})
|
||||
.map_err(|_| DatabaseError::NotFound(format!("Bookmark {} not found", id)))?;
|
||||
|
||||
Ok(bookmark)
|
||||
}
|
||||
|
||||
pub fn get_all_bookmarks(&self) -> DatabaseResult<Vec<Bookmark>> {
|
||||
let conn = self.connection();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, address, port, nickname, server_password, channel, channel_password, default_token, auto_connect, last_connected, created_at, updated_at FROM bookmarks ORDER BY name"
|
||||
)?;
|
||||
|
||||
let bookmarks = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(Bookmark {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
address: row.get(2)?,
|
||||
port: row.get(3)?,
|
||||
nickname: row.get(4)?,
|
||||
server_password: row.get(5)?,
|
||||
channel: row.get(6)?,
|
||||
channel_password: row.get(7)?,
|
||||
default_token: row.get(8)?,
|
||||
auto_connect: row.get::<_, i32>(9)? != 0,
|
||||
last_connected: row.get(10)?,
|
||||
created_at: row.get(11)?,
|
||||
updated_at: row.get(12)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(bookmarks)
|
||||
}
|
||||
|
||||
pub fn update_bookmark(
|
||||
&self,
|
||||
id: &str,
|
||||
name: Option<&str>,
|
||||
address: Option<&str>,
|
||||
port: Option<u16>,
|
||||
nickname: Option<&str>,
|
||||
) -> DatabaseResult<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
if let Some(name) = name {
|
||||
self.connection().execute(
|
||||
"UPDATE bookmarks SET name = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![name, now, id],
|
||||
)?;
|
||||
}
|
||||
|
||||
if let Some(address) = address {
|
||||
self.connection().execute(
|
||||
"UPDATE bookmarks SET address = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![address, now, id],
|
||||
)?;
|
||||
}
|
||||
|
||||
if let Some(port) = port {
|
||||
self.connection().execute(
|
||||
"UPDATE bookmarks SET port = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![port, now, id],
|
||||
)?;
|
||||
}
|
||||
|
||||
if let Some(nickname) = nickname {
|
||||
self.connection().execute(
|
||||
"UPDATE bookmarks SET nickname = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![nickname, now, id],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_bookmark(&self, id: &str) -> DatabaseResult<()> {
|
||||
self.connection()
|
||||
.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_bookmark_last_connected(&self, id: &str) -> DatabaseResult<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
self.connection().execute(
|
||||
"UPDATE bookmarks SET last_connected = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![now, now, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
//! Configuration management
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::params;
|
||||
use rusqlite::OptionalExtension;
|
||||
|
||||
use super::{DatabaseManager, DatabaseResult};
|
||||
|
||||
impl DatabaseManager {
|
||||
pub fn get_setting(&self, key: &str) -> DatabaseResult<Option<String>> {
|
||||
let conn = self.connection();
|
||||
let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?1")?;
|
||||
|
||||
let result = stmt
|
||||
.query_row(params![key], |row| row.get::<_, String>(0))
|
||||
.optional()?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn set_setting(&self, key: &str, value: &str) -> DatabaseResult<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
self.connection().execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?1, ?2, ?3)",
|
||||
params![key, value, now],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_setting(&self, key: &str) -> DatabaseResult<()> {
|
||||
self.connection()
|
||||
.execute("DELETE FROM settings WHERE key = ?1", params![key])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_all_settings(&self) -> DatabaseResult<Vec<(String, String)>> {
|
||||
let conn = self.connection();
|
||||
let mut stmt = conn.prepare("SELECT key, value FROM settings ORDER BY key")?;
|
||||
|
||||
let settings = stmt
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
//! Identity management
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::params;
|
||||
|
||||
use super::{DatabaseError, DatabaseManager, DatabaseResult};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Identity {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub private_key: String,
|
||||
pub counter: u64,
|
||||
pub max_counter: u64,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl DatabaseManager {
|
||||
pub fn create_identity(&self, name: &str, private_key: &str) -> DatabaseResult<Identity> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
self.connection().execute(
|
||||
"INSERT INTO identities (id, name, private_key, counter, max_counter, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![id, name, private_key, 0, 0, now, now],
|
||||
)?;
|
||||
|
||||
Ok(Identity {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
private_key: private_key.to_string(),
|
||||
counter: 0,
|
||||
max_counter: 0,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_identity(&self, id: &str) -> DatabaseResult<Identity> {
|
||||
let conn = self.connection();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, private_key, counter, max_counter, created_at, updated_at FROM identities WHERE id = ?1"
|
||||
)?;
|
||||
|
||||
let identity = stmt
|
||||
.query_row(params![id], |row| {
|
||||
Ok(Identity {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
private_key: row.get(2)?,
|
||||
counter: row.get(3)?,
|
||||
max_counter: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
})
|
||||
.map_err(|_| DatabaseError::NotFound(format!("Identity {} not found", id)))?;
|
||||
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
pub fn get_all_identities(&self) -> DatabaseResult<Vec<Identity>> {
|
||||
let conn = self.connection();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, private_key, counter, max_counter, created_at, updated_at FROM identities ORDER BY name"
|
||||
)?;
|
||||
|
||||
let identities = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(Identity {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
private_key: row.get(2)?,
|
||||
counter: row.get(3)?,
|
||||
max_counter: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(identities)
|
||||
}
|
||||
|
||||
pub fn update_identity(
|
||||
&self,
|
||||
id: &str,
|
||||
name: Option<&str>,
|
||||
counter: Option<u64>,
|
||||
) -> DatabaseResult<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
if let Some(name) = name {
|
||||
self.connection().execute(
|
||||
"UPDATE identities SET name = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![name, now, id],
|
||||
)?;
|
||||
}
|
||||
|
||||
if let Some(counter) = counter {
|
||||
self.connection().execute(
|
||||
"UPDATE identities SET counter = ?1, max_counter = MAX(max_counter, ?1), updated_at = ?2 WHERE id = ?3",
|
||||
params![counter, now, id],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_identity(&self, id: &str) -> DatabaseResult<()> {
|
||||
self.connection()
|
||||
.execute("DELETE FROM identities WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
//! Database storage
|
||||
|
||||
pub mod bookmark;
|
||||
pub mod config;
|
||||
pub mod identity;
|
||||
pub mod message;
|
||||
|
||||
pub use bookmark::*;
|
||||
pub use identity::*;
|
||||
pub use message::*;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DatabaseError {
|
||||
#[error("SQLite error: {0}")]
|
||||
Sqlite(#[from] rusqlite::Error),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Already exists: {0}")]
|
||||
AlreadyExists(String),
|
||||
}
|
||||
|
||||
pub type DatabaseResult<T> = Result<T, DatabaseError>;
|
||||
|
||||
pub struct DatabaseManager {
|
||||
conn: rusqlite::Connection,
|
||||
}
|
||||
|
||||
impl DatabaseManager {
|
||||
pub fn new(path: &str) -> DatabaseResult<Self> {
|
||||
let conn = rusqlite::Connection::open(path)?;
|
||||
let manager = Self { conn };
|
||||
manager.init_tables()?;
|
||||
Ok(manager)
|
||||
}
|
||||
|
||||
fn init_tables(&self) -> DatabaseResult<()> {
|
||||
self.conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS identities (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
max_counter INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 9987,
|
||||
nickname TEXT,
|
||||
server_password TEXT,
|
||||
channel TEXT,
|
||||
channel_password TEXT,
|
||||
default_token TEXT,
|
||||
auto_connect INTEGER NOT NULL DEFAULT 0,
|
||||
last_connected TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_address TEXT NOT NULL,
|
||||
invoker_id INTEGER NOT NULL,
|
||||
invoker_name TEXT NOT NULL,
|
||||
invoker_uid TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id INTEGER,
|
||||
message TEXT NOT NULL,
|
||||
is_read INTEGER NOT NULL DEFAULT 0,
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn connection(&self) -> &rusqlite::Connection {
|
||||
&self.conn
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
//! Message management
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::params;
|
||||
|
||||
use super::{DatabaseError, DatabaseManager, DatabaseResult};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Message {
|
||||
pub id: i64,
|
||||
pub server_address: String,
|
||||
pub invoker_id: i64,
|
||||
pub invoker_name: String,
|
||||
pub invoker_uid: String,
|
||||
pub target_type: String,
|
||||
pub target_id: Option<i64>,
|
||||
pub message: String,
|
||||
pub is_read: bool,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
impl DatabaseManager {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn create_message(
|
||||
&self,
|
||||
server_address: &str,
|
||||
invoker_id: i64,
|
||||
invoker_name: &str,
|
||||
invoker_uid: &str,
|
||||
target_type: &str,
|
||||
target_id: Option<i64>,
|
||||
message: &str,
|
||||
) -> DatabaseResult<Message> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
self.connection().execute(
|
||||
"INSERT INTO messages (server_address, invoker_id, invoker_name, invoker_uid, target_type, target_id, message, is_read, timestamp) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![server_address, invoker_id, invoker_name, invoker_uid, target_type, target_id, message, false, now],
|
||||
)?;
|
||||
|
||||
let id = self.connection().last_insert_rowid();
|
||||
|
||||
Ok(Message {
|
||||
id,
|
||||
server_address: server_address.to_string(),
|
||||
invoker_id,
|
||||
invoker_name: invoker_name.to_string(),
|
||||
invoker_uid: invoker_uid.to_string(),
|
||||
target_type: target_type.to_string(),
|
||||
target_id,
|
||||
message: message.to_string(),
|
||||
is_read: false,
|
||||
timestamp: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_message(&self, id: i64) -> DatabaseResult<Message> {
|
||||
let conn = self.connection();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, server_address, invoker_id, invoker_name, invoker_uid, target_type, target_id, message, is_read, timestamp FROM messages WHERE id = ?1"
|
||||
)?;
|
||||
|
||||
let message = stmt
|
||||
.query_row(params![id], |row| {
|
||||
Ok(Message {
|
||||
id: row.get(0)?,
|
||||
server_address: row.get(1)?,
|
||||
invoker_id: row.get(2)?,
|
||||
invoker_name: row.get(3)?,
|
||||
invoker_uid: row.get(4)?,
|
||||
target_type: row.get(5)?,
|
||||
target_id: row.get(6)?,
|
||||
message: row.get(7)?,
|
||||
is_read: row.get::<_, i32>(8)? != 0,
|
||||
timestamp: row.get(9)?,
|
||||
})
|
||||
})
|
||||
.map_err(|_| DatabaseError::NotFound(format!("Message {} not found", id)))?;
|
||||
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
pub fn get_server_messages(
|
||||
&self,
|
||||
server_address: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> DatabaseResult<Vec<Message>> {
|
||||
let conn = self.connection();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, server_address, invoker_id, invoker_name, invoker_uid, target_type, target_id, message, is_read, timestamp FROM messages WHERE server_address = ?1 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3"
|
||||
)?;
|
||||
|
||||
let messages = stmt
|
||||
.query_map(params![server_address, limit, offset], |row| {
|
||||
Ok(Message {
|
||||
id: row.get(0)?,
|
||||
server_address: row.get(1)?,
|
||||
invoker_id: row.get(2)?,
|
||||
invoker_name: row.get(3)?,
|
||||
invoker_uid: row.get(4)?,
|
||||
target_type: row.get(5)?,
|
||||
target_id: row.get(6)?,
|
||||
message: row.get(7)?,
|
||||
is_read: row.get::<_, i32>(8)? != 0,
|
||||
timestamp: row.get(9)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
pub fn mark_message_read(&self, id: i64) -> DatabaseResult<()> {
|
||||
self.connection()
|
||||
.execute("UPDATE messages SET is_read = 1 WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_message(&self, id: i64) -> DatabaseResult<()> {
|
||||
self.connection()
|
||||
.execute("DELETE FROM messages WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_server_messages(&self, server_address: &str) -> DatabaseResult<()> {
|
||||
self.connection().execute(
|
||||
"DELETE FROM messages WHERE server_address = ?1",
|
||||
params![server_address],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||