refactor: remove legacy tauri stack and update iced docs

This commit is contained in:
ReTeamSpeak
2026-05-13 13:35:22 +09:00
parent 4a54f6667c
commit abf380476f
94 changed files with 789 additions and 13393 deletions
+7 -3
View File
@@ -59,15 +59,19 @@ jobs:
- name: Check
working-directory: src
run: cargo check -p shared -p tscore -p tsaudio -p tsdb -p re-teamspeak
run: cargo check -p re-teamspeak
- name: Check (rusqlite)
working-directory: src
run: cargo check -p re-teamspeak --features rusqlite
- name: Test
working-directory: src
run: cargo test -p shared -p tscore -p tsaudio -p tsdb
run: cargo test -p re-teamspeak
- name: Clippy
working-directory: src
run: cargo clippy -p shared -p tscore -p tsaudio -p tsdb -p re-teamspeak -- -D warnings
run: cargo clippy -p re-teamspeak --features rusqlite -- -D warnings
continue-on-error: true
# Build desktop apps
+66 -57
View File
@@ -1,83 +1,92 @@
# ReTeamSpeak
Cross-platform TeamSpeak 3 client supporting Windows, macOS, Linux, iOS, and Android.
Desktop TeamSpeak 3 client built with `iced` and `tsclientlib` for Windows, Linux, and macOS.
## Features
## Current Direction
- TeamSpeak 3 protocol implementation (UDP, AES-128-EAX encryption)
- Voice communication with Opus codec
- Text messaging (server, channel, private)
- Channel and client management
- Identity and bookmark management
- Cross-platform UI (Tauri v2 + React)
- UI: `iced`
- TeamSpeak protocol + state sync: `tsclientlib`
- Protocol declarations reference: `refercence/tsdeclarations/`
- Old in-tree protocol/audio/database/Tauri implementation has been removed
## Architecture
## Current User-Facing Features
```
src/
├── shared/ # Shared types (~700 lines)
├── tscore/ # Protocol core (~1800 lines)
│ ├── protocol/ # Packet parsing, commands
│ ├── crypto/ # AES-EAX, ECDH, SHA
│ └── connection/ # Handshake, state machine, resend
├── tsaudio/ # Audio engine (~280 lines)
├── tsdb/ # Database layer (~400 lines)
└── tauri-app/ # Application shell
├── src-tauri/ # Rust backend
└── frontend/ # React frontend
```
- Connect to a TeamSpeak server by address and port
- Use a nickname and optional server password
- Browse channels and join a channel
- Receive live server/channel/client state updates
- Send and receive channel chat messages
- Mute microphone state and sync it to TeamSpeak
- Mute speaker/output state and sync it to TeamSpeak
- Toggle headset/output hardware state and sync it to TeamSpeak
- Toggle AFK state and sync it to TeamSpeak
- Import TeamSpeak identity from the local TS3 `settings.db`
- Select input and output audio devices
- Push-to-talk and continuous voice modes
- Noise cancellation selection in settings
## Current Gaps
- ServerQuery page is UI-only right now; execution is intentionally stubbed
- Bookmarks are currently in-memory only
- Identity import requires the `rusqlite` feature
- Full audio build requires system audio/build dependencies
## Build
### Prerequisites
Workspace root for cargo commands: `src/`
- Rust 1.70+
- Node.js 20+
- System dependencies (see below)
### Default build
### Linux
```bash
sudo apt install libdbus-1-dev pkg-config libgtk-3-dev \
libwebkit2gtk-4.1-dev libayatana-appindicator3-dev \
librsvg2-dev libssl-dev libasound2-dev
cd src
cargo check -p re-teamspeak
```
### Build Commands
### With identity import
```bash
# Build core libraries
cargo build -p shared -p tscore -p tsaudio -p tsdb
# Run tests
cargo test -p shared -p tscore -p tsaudio -p tsdb
# Build frontend
cd src/tauri-app/frontend && npm install && npm run build
# Build desktop app
cd src && cargo build --release
cd src
cargo check -p re-teamspeak --features rusqlite
```
### Using Podman (no root)
### With audio and identity import
On systems missing packages, use Podman:
```bash
podman run --rm -v $(pwd):/workspace:Z -w /workspace/src \
docker.io/library/debian:trixie bash -c "
docker.io/library/debian:trixie bash -lc '
apt-get update -qq &&
apt-get install -y -qq curl pkg-config libdbus-1-dev libgtk-3-dev \
libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev \
libssl-dev libasound2-dev build-essential &&
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y &&
source \$HOME/.cargo/env &&
cargo test -p shared -p tscore -p tsaudio -p tsdb
"
apt-get install -y -qq \
build-essential curl cmake pkg-config libasound2-dev libssl-dev &&
curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y &&
. /root/.cargo/env &&
cargo check -p re-teamspeak --features audio,rusqlite
'
```
## Documentation
## Repository Layout
- [SRS](docs/SRS.md) - Software Requirements Specification
- [SAD](docs/SAD.md) - Software Architecture Document
- [SDD](docs/SDD.md) - Software Design Document
- [Protocol](docs/protocol.md) - TS3 Protocol Analysis
- [Protocol Stack](docs/protocol_stack.md) - Protocol Stack Details
```text
src/
├── Cargo.toml
└── iced-app/
├── Cargo.toml
└── src/
├── main.rs
├── audio.rs
├── noise_cancel.rs
├── identity.rs
└── theme.rs
```
## Reference Material
- `refercence/tsclientlib/`
- `refercence/tsdeclarations/`
- `docs/protocol.md`
- `docs/protocol_stack.md`
## License
-96
View File
@@ -1,96 +0,0 @@
@echo off
REM ReTeamSpeak Windows 构建脚本
setlocal enabledelayedexpansion
set SCRIPT_DIR=%~dp0
set PROJECT_DIR=%SCRIPT_DIR%
set BUILD_DIR=%PROJECT_DIR%build
REM 检查依赖
echo [INFO] 检查依赖...
where rustc >nul 2>nul
if %errorlevel% neq 0 (
echo [ERROR] Rust 未安装。请访问 https://rustup.rs 安装 Rust。
exit /b 1
)
where node >nul 2>nul
if %errorlevel% neq 0 (
echo [ERROR] Node.js 未安装。请访问 https://nodejs.org 安装 Node.js。
exit /b 1
)
where npm >nul 2>nul
if %errorlevel% neq 0 (
echo [ERROR] npm 未安装。
exit /b 1
)
echo [INFO] 依赖检查完成。
REM 解析命令
if "%1"=="" goto :help
if "%1"=="all" goto :build_all
if "%1"=="desktop" goto :build_desktop
if "%1"=="frontend" goto :build_frontend
if "%1"=="clean" goto :clean
if "%1"=="help" goto :help
goto :help
:build_frontend
echo [INFO] 构建前端...
cd /d "%PROJECT_DIR%\src\tauri-app\frontend"
call npm install
call npm run build
if %errorlevel% neq 0 (
echo [ERROR] 前端构建失败。
exit /b 1
)
echo [INFO] 前端构建完成。
goto :eof
:build_desktop
echo [INFO] 构建桌面应用...
cd /d "%PROJECT_DIR%\src\tauri-app\src-tauri"
cargo build --release
if %errorlevel% neq 0 (
echo [ERROR] 桌面应用构建失败。
exit /b 1
)
echo [INFO] 桌面应用构建完成。
goto :eof
:build_all
echo [INFO] 构建所有平台...
call :build_frontend
call :build_desktop
echo [INFO] 所有平台构建完成。
goto :eof
:clean
echo [INFO] 清理构建...
if exist "%BUILD_DIR%" rmdir /s /q "%BUILD_DIR%"
cd /d "%PROJECT_DIR%\src\tauri-app\src-tauri"
cargo clean
cd /d "%PROJECT_DIR%\src\tauri-app\frontend"
if exist "node_modules" rmdir /s /q "node_modules"
if exist "dist" rmdir /s /q "dist"
echo [INFO] 清理完成。
goto :eof
:help
echo 用法: %0 [命令]
echo.
echo 命令:
echo all 构建所有平台
echo desktop 构建桌面应用
echo frontend 构建前端
echo clean 清理构建
echo help 显示帮助
echo.
echo 示例:
echo %0 all # 构建所有平台
echo %0 desktop # 仅构建桌面应用
goto :eof
-182
View File
@@ -1,182 +0,0 @@
#!/bin/bash
# ReTeamSpeak 跨平台构建脚本
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$SCRIPT_DIR"
BUILD_DIR="$PROJECT_DIR/build"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 检查依赖
check_dependencies() {
log_info "检查依赖..."
# 检查 Rust
if ! command -v rustc &> /dev/null; then
log_error "Rust 未安装。请访问 https://rustup.rs 安装 Rust。"
exit 1
fi
# 检查 Node.js
if ! command -v node &> /dev/null; then
log_error "Node.js 未安装。请访问 https://nodejs.org 安装 Node.js。"
exit 1
fi
# 检查 npm
if ! command -v npm &> /dev/null; then
log_error "npm 未安装。"
exit 1
fi
log_info "依赖检查完成。"
}
# 构建前端
build_frontend() {
log_info "构建前端..."
cd "$PROJECT_DIR/src/tauri-app/frontend"
npm install
npm run build
log_info "前端构建完成。"
}
# 构建桌面应用
build_desktop() {
log_info "构建桌面应用..."
cd "$PROJECT_DIR/src/tauri-app/src-tauri"
cargo build --release
log_info "桌面应用构建完成。"
}
# 构建 Android 应用
build_android() {
log_info "构建 Android 应用..."
cd "$PROJECT_DIR/src/tauri-app/src-tauri"
# 检查 Android SDK
if [ -z "$ANDROID_HOME" ]; then
log_error "ANDROID_HOME 环境变量未设置。"
exit 1
fi
cargo tauri android build
log_info "Android 应用构建完成。"
}
# 构建 iOS 应用
build_ios() {
log_info "构建 iOS 应用..."
cd "$PROJECT_DIR/src/tauri-app/src-tauri"
# 检查 Xcode
if ! command -v xcodebuild &> /dev/null; then
log_error "Xcode 未安装。"
exit 1
fi
cargo tauri ios build
log_info "iOS 应用构建完成。"
}
# 构建所有平台
build_all() {
log_info "构建所有平台..."
build_frontend
build_desktop
# 检查是否在 macOS 上
if [[ "$OSTYPE" == "darwin"* ]]; then
build_ios
fi
# 检查 Android SDK
if [ -n "$ANDROID_HOME" ]; then
build_android
fi
log_info "所有平台构建完成。"
}
# 清理构建
clean() {
log_info "清理构建..."
rm -rf "$BUILD_DIR"
cd "$PROJECT_DIR/src/tauri-app/src-tauri"
cargo clean
cd "$PROJECT_DIR/src/tauri-app/frontend"
rm -rf node_modules dist
log_info "清理完成。"
}
# 显示帮助
show_help() {
echo "用法: $0 [命令]"
echo ""
echo "命令:"
echo " all 构建所有平台"
echo " desktop 构建桌面应用"
echo " android 构建 Android 应用"
echo " ios 构建 iOS 应用"
echo " frontend 构建前端"
echo " clean 清理构建"
echo " help 显示帮助"
echo ""
echo "示例:"
echo " $0 all # 构建所有平台"
echo " $0 desktop # 仅构建桌面应用"
echo " $0 android # 仅构建 Android 应用"
}
# 主函数
main() {
check_dependencies
case "${1:-help}" in
all)
build_all
;;
desktop)
build_frontend
build_desktop
;;
android)
build_frontend
build_android
;;
ios)
build_frontend
build_ios
;;
frontend)
build_frontend
;;
clean)
clean
;;
help|*)
show_help
;;
esac
}
main "$@"
+7 -77
View File
@@ -1,79 +1,9 @@
# TeamSpeak 逆向工程分析文档
# Docs
本文档包含对 TeamSpeak 3 协议和相关代码库的分析结果,用于指导后续开发工作。
This directory now documents the current desktop client direction:
## 文档结构
- **[protocol.md](protocol.md)** - TeamSpeak 3 协议详细分析
- **[protocol_stack.md](protocol_stack.md)** - TeamSpeak 3 协议栈深度分析(层次结构、加密机制、可靠传输等)
- **[architecture.md](architecture.md)** - 系统架构分析 (SAD)
- **[components.md](components.md)** - 组件设计分析 (SDD)
- **[requirements.md](requirements.md)** - 需求分析 (SRS)
## 参考代码库
本分析基于以下参考代码库:
### 1. tsdeclarations
- **位置**: `refercence/tsdeclarations/`
- **描述**: TeamSpeak 3 协议的机器可读定义,包含消息、数据包、枚举、错误码等定义
- **关键文件**:
- `ts3protocol.md` - 协议详细规范
- `Packets.txt` - 数据包结构定义
- `Messages.toml` - 消息定义
- `Book.toml` - 数据结构定义
- `Enums.toml` - 枚举类型定义
- `Errors.csv` - 错误码定义
### 2. tsclientlib
- **位置**: `refercence/tsclientlib/`
- **描述**: Rust 实现的 TeamSpeak 客户端库,提供高级 API
- **关键组件**:
- `tsclientlib` - 主客户端库
- `tsproto` - 底层协议实现
- `ts-bookkeeping` - 客户端/频道状态管理
- `tsproto-packets` - 数据包解析
- `tsproto-structs` - 协议结构体
- `tsproto-types` - 基础类型定义
### 3. Qint
- **位置**: `refercence/Qint/`
- **描述**: 现代化的 TeamSpeak 客户端,基于 Tauri 框架
- **技术栈**:
- 后端: Rust (Tauri)
- 前端: TypeScript/JavaScript
- 代理层: Rust (websocket/web)
### 4. SimpleBot
- **位置**: `refercence/SimpleBot/`
- **描述**: 简单的 TeamSpeak 聊天机器人,展示 tsclientlib 的使用
- **功能**: 连接到服务器,响应特定消息,支持脚本执行
### 5. ts3stats
- **位置**: `refercence/ts3stats/`
- **描述**: TeamSpeak 3 服务器统计工具
- **技术栈**: Python
- **功能**: 分析服务器日志,生成用户统计图表
## 协议概述
TeamSpeak 3 使用基于 UDP 的自定义协议,具有以下特点:
1. **加密**: 使用 AES-128-CTR + OMAC (EAX 模式)
2. **压缩**: 使用 QuickLZ 算法
3. **分片**: 支持大数据包分片传输
4. **可靠性**: 使用选择性重传机制确保可靠传输
5. **身份验证**: 使用 ECDH 密钥交换和 RSA 拼图防攻击
## 开发建议
基于分析结果,建议开发工作遵循以下原则:
1. **协议兼容性**: 严格遵循 tsdeclarations 中的协议定义
2. **代码复用**: 优先使用 tsclientlib 作为底层库
3. **模块化设计**: 参考 Qint 的架构,分离前端、代理和核心逻辑
4. **测试覆盖**: 参考 SimpleBot 的测试方法,确保协议兼容性
## 法律声明
本分析仅用于学习和研究目的。TeamSpeak 是 TeamSpeak Systems GmbH 的商标。开发的客户端应遵守相关法律法规,不得用于商业用途或侵犯 TeamSpeak 的商业模式。
- `SRS.md` - current product scope and user-facing requirements
- `SAD.md` - current architecture (`iced` + `tsclientlib`)
- `SDD.md` - implementation notes for the iced app
- `protocol.md` - TeamSpeak protocol research reference
- `protocol_stack.md` - lower-level protocol stack notes
+33 -453
View File
@@ -1,472 +1,52 @@
# Software Architecture Document (SAD)
# ReTeamSpeak - Cross-Platform TeamSpeak Client
# Software Architecture Document
**Version**: 1.0.0
**Date**: 2026-05-12
**Status**: Based on actual implementation
## Overview
---
The current application is a single desktop client crate built around `iced` and `tsclientlib`.
## 1. Architectural Overview
## Architecture
### 1.1 System Context
```
┌─────────────────────────────────────────────────────────────────┐
│ User Environment │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Desktop │ │ Web │ │ Mobile │ │
│ │ Windows │ │ Browser │ │ iOS / Android │ │
│ │ macOS │ │ │ │ │ │
│ │ Linux │ │ │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │
│ │ │ │ │
│ └─────────────────┼──────────────────────┘ │
│ │ │
│ ┌──────▼───────┐ │
│ │ ReTeamSpeak │ │
│ │ Client │ │
│ └──────┬───────┘ │
│ │ │
│ ┌──────▼───────┐ │
│ │ TS3 Server │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```text
User
-> iced UI (`src/iced-app/src/main.rs`)
-> tsclientlib sync connection/state
-> optional local audio pipeline (`audio.rs`, `noise_cancel.rs`)
-> TeamSpeak server
```
### 1.2 Layered Architecture
## Major Components
```
┌─────────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ React Frontend (TypeScript) │ │
│ │ - Connection UI, Chat, Channel Tree, Settings │ │
│ └────────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Application Layer │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Tauri Shell (Rust) │ │
│ │ - Command handlers, State management, IPC bridge │ │
│ └────────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Business Logic Layer │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ tscore │ │ tsaudio │ │ tsdb │ │ shared │ │
│ │ Protocol │ │ Audio │ │ Database │ │ Types │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Infrastructure Layer │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ - Tokio (Async Runtime) │ │
│ │ - rusqlite (SQLite) │ │
│ │ - cpal (Audio I/O) │ │
│ │ - AES/EAX, SHA, ECDH (Crypto) │ │
│ └────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### UI Layer
---
- `main.rs`
- Handles navigation, connection flow, channel/client/chat rendering, settings UI
## 2. Module Architecture
### TeamSpeak Integration
### 2.1 Crate Dependency Graph
- `tsclientlib`
- Connection setup, state mirror, channel moves, text messages, client updates, voice packet send/receive
```
┌─────────────────┐
│ tauri-app │
│ (Application) │
└────┬───┬───┬────┘
│ │ │
┌────────────┘ │ └────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ tscore │ │ tsaudio │ │ tsdb │
│ Protocol │ │ Audio │ │ Database │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└───────────────┼──────────────────┘
┌──────────┐
│ shared │
│ Types │
└──────────┘
```
### Audio Layer
### 2.2 Module Responsibilities
- `audio.rs`
- `cpal` input/output, Opus encode, simple voice activation logic
#### `shared` - Shared Types Library
**Path**: `src/shared/`
**Lines**: ~700
**Purpose**: Defines core data types shared across all modules
### Noise Reduction
| File | Responsibility |
|------|---------------|
| `types.rs` | Core types: ClientId, ChannelId, ServerGroupId, Codec, ConnectionState, etc. |
| `events.rs` | Event types: AppEvent, ConnectionEvent, ClientEvent, ChannelEvent, etc. |
| `errors.rs` | Error types: AppError with variants for each subsystem |
| `config.rs` | Configuration: ConfigManager, SavedConnection, RecentServer |
- `noise_cancel.rs`
- Supports `None`, `nnnoiseless`, and `sonora`
**Key Types**:
```rust
pub struct ClientId(pub u16);
pub struct ChannelId(pub u64);
pub struct ServerGroupId(pub u64);
pub struct Uid(pub String);
### Identity Import
pub enum ConnectionState {
Disconnected, Connecting, IdentityLevelIncreasing,
Connected, ChannelListFinished, DisconnectedTemporarily, Error,
}
- `identity.rs`
- Reads TeamSpeak `settings.db` and imports `identity_secret_key`
pub enum Codec {
SpeexNarrowband, SpeexWideband, SpeexUltrawideband,
CeltMono, OpusVoice, OpusMusic,
}
```
## Removed Architecture
---
The following older architecture is no longer part of the product:
#### `tscore` - Protocol Core Library
**Path**: `src/tscore/`
**Lines**: ~1800
**Purpose**: Implements TS3 protocol (packets, encryption, connection)
**Sub-modules**:
##### `protocol/` - Packet and Command Handling
| File | Lines | Responsibility |
|------|-------|---------------|
| `packet.rs` | 607 | Packet structures (InPacket, OutPacket, Header, InitPacket) |
| `types.rs` | 227 | Protocol types (PacketType, CodecType, GroupWhisperType) |
| `commands.rs` | 239 | Command parsing/serialization with escape sequences |
**Packet Structure**:
```rust
pub struct Header {
pub mac: [u8; 8], // EAX authentication tag
pub packet_id: u16, // Packet sequence number
pub client_id: Option<u16>, // Client ID (C2S only)
pub flags: Flags, // Type + UE/CP/NP/FR flags
}
pub struct InPacket {
pub direction: Direction,
pub header: Header,
pub data: Vec<u8>,
}
```
##### `crypto/` - Encryption and Key Management
| File | Lines | Responsibility |
|------|-------|---------------|
| `eax.rs` | 118 | AES-128-EAX encrypt/decrypt |
| `keys.rs` | 229 | Key derivation, KeyCache, SharedSecret |
| `hash.rs` | 50 | SHA-1/256/512 hash functions |
**Key Derivation**:
```rust
fn create_key_nonce(
packet_type: PacketType,
direction: Direction,
generation_id: u32,
iv: &[u8; 64],
) -> ([u8; 16], [u8; 16]) {
// SHA-256(direction | type | generation_id | iv)
// Returns (key, nonce) for AES-EAX
}
```
##### `connection/` - Connection Management
| File | Lines | Responsibility |
|------|-------|---------------|
| `client.rs` | 519 | Client connection with full handshake implementation |
| `state.rs` | 90 | Connection state machine |
| `resend.rs` | 240 | Packet retransmission with RTT estimation |
**Handshake Flow**:
```rust
impl Client {
pub fn start_handshake(&mut self) -> Result<Vec<u8>, ProtocolError>;
pub fn handle_data(&mut self, data: &[u8]) -> Result<Vec<Vec<u8>>, ProtocolError>;
fn build_init2(&mut self) -> Result<Vec<u8>, ProtocolError>;
fn build_init4(&mut self) -> Result<Vec<u8>, ProtocolError>;
fn handle_initivexpand(&mut self, cmd: &Command) -> Result<Vec<u8>, ProtocolError>;
fn handle_initivexpand2(&mut self, cmd: &Command) -> Result<Vec<u8>, ProtocolError>;
fn solve_rsa_puzzle(x: &[u8; 64], n: &[u8; 64], level: u32) -> [u8; 64];
}
```
---
#### `tsaudio` - Audio Engine
**Path**: `src/tsaudio/`
**Lines**: ~280
**Purpose**: Audio capture, playback, and codec operations
| File | Lines | Responsibility |
|------|-------|---------------|
| `lib.rs` | 80 | Core types (AudioConfig, AudioFrame, AudioError) |
| `capture.rs` | 30 | Audio capture (cpal-based, optional) |
| `playback.rs` | 30 | Audio playback (cpal-based, optional) |
| `codec.rs` | 55 | Opus encoder/decoder (optional) |
| `vad.rs` | 45 | Voice Activity Detection |
| `buffer.rs` | 65 | Jitter buffer for smooth playback |
**Audio Frame**:
```rust
pub struct AudioFrame {
pub sample_rate: u32, // 48000 Hz
pub channels: u16, // 1 (mono) or 2 (stereo)
pub samples: Vec<f32>, // PCM samples
}
```
---
#### `tsdb` - Database Layer
**Path**: `src/tsdb/`
**Lines**: ~400
**Purpose**: SQLite database for persistent storage
| File | Lines | Responsibility |
|------|-------|---------------|
| `lib.rs` | 104 | Database initialization and table creation |
| `identity.rs` | 115 | Identity CRUD operations |
| `bookmark.rs` | 176 | Bookmark CRUD operations |
| `message.rs` | 139 | Message storage and retrieval |
| `config.rs` | 75 | Settings key-value storage |
**Database Manager**:
```rust
pub struct DatabaseManager {
conn: rusqlite::Connection,
}
impl DatabaseManager {
pub fn new(path: &str) -> DatabaseResult<Self>;
fn init_tables(&self) -> DatabaseResult<()>;
// Identity, Bookmark, Message, Settings CRUD...
}
```
---
#### `tauri-app` - Application Shell
**Path**: `src/tauri-app/`
**Lines**: ~500
##### `src-tauri/` - Rust Backend
| File | Lines | Responsibility |
|------|-------|---------------|
| `lib.rs` | 60 | Tauri setup, plugin registration, state init |
| `main.rs` | 5 | Entry point |
| `commands.rs` | 155 | Tauri command handlers (IPC bridge) |
| `state.rs` | 40 | Connection state management |
**Tauri Commands**:
```rust
#[tauri::command] async fn get_identities(state) -> Result<Vec<IdentityInfo>, String>;
#[tauri::command] async fn create_identity(state, name) -> Result<IdentityInfo, String>;
#[tauri::command] async fn get_bookmarks(state) -> Result<Vec<BookmarkInfo>, String>;
#[tauri::command] async fn create_bookmark(state, name, address, port, nickname) -> Result<BookmarkInfo, String>;
#[tauri::command] async fn connect(state, address, port, nickname, password) -> Result<(), String>;
#[tauri::command] async fn disconnect(state) -> Result<(), String>;
#[tauri::command] async fn send_message(state, target, message) -> Result<(), String>;
#[tauri::command] async fn get_messages(state, server_address, limit, offset) -> Result<Vec<MessageInfo>, String>;
```
##### `frontend/` - React Frontend
| File | Responsibility |
|------|---------------|
| `App.tsx` | Main application component |
| `main.tsx` | Entry point |
| `styles.css` | Application styles |
---
## 3. Data Flow Architecture
### 3.1 Connection Establishment
```
User Frontend Tauri Shell tscore
│ │ │ │
│── Connect(addr) ───────>│ │ │
│ │── connect() ────────>│ │
│ │ │── Client::new() ─>│
│ │ │ │
│ │ │<── start_handshake│
│ │ │ (Init0) │
│ │ │ │
│ │ │──── UDP Send ────>│
│ │ │ │
│ │ │<── handle_data ───│
│ │ │ (Init1) │
│ │ │ │
│ │ │ ... (Init2-4) ... │
│ │ │ │
│ │ │<── Connected ─────│
│ │<── Connected ────────│ │
│<── Connected ───────────│ │ │
```
### 3.2 Voice Data Flow
```
Microphone ──> cpal capture ──> VAD ──> Opus encode ──> Voice packet
UDP send
Speaker <── cpal playback <── Jitter buffer <── Opus decode <── Voice packet
```
### 3.3 Message Flow
```
User input ──> Frontend ──> Tauri command ──> tscore
Command serialization
Encryption (AES-EAX)
UDP send
Server ──> UDP recv ──> Decrypt ──> Parse ──> Event ──> Frontend
```
---
## 4. Cross-Cutting Concerns
### 4.1 Error Handling
```rust
// Protocol errors
pub enum ProtocolError {
PacketParse(String), Encryption(String), Decryption(String),
Compression(String), Decompression(String), InvalidPacketType(u8),
PacketTooLarge { size, max }, PacketTooSmall { size, min },
MacVerificationFailed, Timeout(String), ConnectionClosed,
Command(String), Network(std::io::Error),
}
// Application errors
pub enum AppError {
Connection(String), Protocol { code, message }, Network(std::io::Error),
Crypto(String), Audio(String), Database(String), Serialization(serde_json::Error),
Config(String), Identity(String), Permission(String), Timeout(String),
NotConnected, AlreadyConnected, InvalidArgument(String),
}
```
### 4.2 Logging
- Framework: `tracing` with `tracing-subscriber`
- Levels: ERROR, WARN, INFO, DEBUG, TRACE
- Environment filter: `RUST_LOG=tscore=debug,tsaudio=debug`
### 4.3 Configuration
- Format: TOML
- Location: Platform-specific app data directory
- Encryption: ChaCha20-Poly1305 for identity keys
---
## 5. Deployment Architecture
### 5.1 Desktop (Windows/macOS/Linux)
```
┌─────────────────────────────────────┐
│ Tauri Application │
│ ┌───────────────────────────────┐ │
│ │ WebView (System) │ │
│ │ React Frontend (dist/) │ │
│ └───────────────────────────────┘ │
│ ┌───────────────────────────────┐ │
│ │ Rust Backend (lib) │ │
│ │ tscore + tsaudio + tsdb │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
```
### 5.2 Mobile (iOS/Android)
```
┌─────────────────────────────────────┐
│ Tauri Mobile App │
│ ┌───────────────────────────────┐ │
│ │ WebView (Platform) │ │
│ │ React Frontend (dist/) │ │
│ └───────────────────────────────┘ │
│ ┌───────────────────────────────┐ │
│ │ Rust Backend (cdylib) │ │
│ │ + Platform audio (Oboe) │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
```
---
## 6. Performance Characteristics
### 6.1 Measured Performance
- **Connection time**: ~200ms (local network)
- **Packet encryption**: ~10μs per packet
- **RSA puzzle (level 8)**: ~100ms
- **Memory usage**: ~50MB (idle)
### 6.2 Scalability
- **Max packet size**: 500 bytes
- **Max decompressed size**: 2MB
- **Fragment queue limit**: 200 packets
- **Resend timeout**: 500ms initial, exponential backoff
---
## 7. Security Architecture
### 7.1 Encryption Layers
1. **Transport**: AES-128-EAX per packet
2. **Key Exchange**: ECDH (P-256 for identity, Curve25519 for session)
3. **Storage**: ChaCha20-Poly1305 for identity keys
4. **Passwords**: base64(sha1(password))
### 7.2 Anti-DoS
- RSA puzzle computation (configurable difficulty)
- Hash Cash for identity verification
- Rate limiting (planned)
---
## 8. Build and Test
### 8.1 Build System
- **Rust**: Cargo workspace
- **Frontend**: npm + Vite
- **Desktop**: Tauri CLI
- **CI/CD**: GitHub Actions
### 8.2 Test Coverage
```
Module Tests Status
────────────────────────────────
shared 0 -
tscore 32 ✓ All passing
tsaudio 0 -
tsdb 0 -
────────────────────────────────
Total 32 ✓
```
---
## 9. References
- TS3 Protocol Paper: `refercence/tsdeclarations/ts3protocol.md`
- tsclientlib: `refercence/tsclientlib/` (reference implementation)
- Qint: `refercence/Qint/` (reference UI)
- Tauri v2: https://tauri.app
- Tauri application shell
- Custom `tscore` protocol implementation
- Custom `tsaudio` audio engine
- Custom `tsdb` persistence layer
- Shared internal model crate used only by the removed stack
+33 -718
View File
@@ -1,729 +1,44 @@
# Software Design Document (SDD)
# ReTeamSpeak - Cross-Platform TeamSpeak Client
# Software Design Document
**Version**: 1.0.0
**Date**: 2026-05-12
**Status**: Based on actual implementation
## iced-app
---
### `main.rs`
## 1. Detailed Design
- Defines the full app state and message enum
- Uses `SyncConnection` / `SyncConnectionHandle` from `tsclientlib`
- Maintains local UI mirrors for:
- bookmarks
- server metadata
- channels
- clients
- chat messages
- device selection
- mute / AFK state
### 1.1 `shared` Module Design
### `audio.rs`
#### 1.1.1 Core Type System
- Output playback through `cpal`
- Input capture through `cpal`
- Opus encode for outgoing voice
- Simple VAD for continuous talk mode
```
┌─────────────────────────────────────────────────────────────┐
│ Type Hierarchy │
├─────────────────────────────────────────────────────────────┤
│ Identifier Types (Newtype Pattern) │
│ ├── ClientId(u16) // In-session client ID │
│ ├── ChannelId(u64) // Channel identifier │
│ ├── ServerGroupId(u64) // Server group │
│ ├── ChannelGroupId(u64) // Channel group │
│ ├── ClientDbId(u64) // Database client ID │
│ ├── Uid(String) // Unique identity (base64) │
│ ├── PermissionId(u32) // Permission ID │
│ └── IconId(i32) // Icon identifier │
│ │
│ Enumerations │
│ ├── Codec { SpeexNB, SpeexWB, SpeexUWB, Celt, OpusVoice, │
│ │ OpusMusic } │
│ ├── ChannelType { Permanent, SemiPermanent, Temporary } │
│ ├── ClientType { Normal, Query { admin } } │
│ ├── ConnectionState { Disconnected, Connecting, ... } │
│ ├── Reason { None, Moved, LostConnection, KickChannel, ... }│
│ ├── CodecEncryptionMode { PerChannel, ForcedOff, ForcedOn }│
│ ├── HostMessageMode { None, Log, Modal, Modalquit } │
│ ├── GroupType { Template, Regular, Query } │
│ └── GroupNamingMode { None, Before, After } │
│ │
│ Data Structures │
│ ├── ServerInfo { id, name, platform, version, max_clients, │
│ │ clients_online, ... } │
│ ├── ChannelInfo { id, parent_id, name, codec, max_clients, │
│ │ channel_type, ... } │
│ ├── ClientInfo { id, channel_id, uid, name, muted, ... } │
│ ├── ChatMessage { id, timestamp, invoker, target, message }│
│ └── AppConfig { nickname, audio, hotkeys, theme, ... } │
└─────────────────────────────────────────────────────────────┘
```
### `noise_cancel.rs`
#### 1.1.2 Event System Design
- Runtime-selected noise cancellation backend
- Current backends:
- `None`
- `nnnoiseless`
- `sonora`
```
AppEvent
├── Connection(ConnectionEvent)
│ ├── Connecting { address }
│ ├── Connected { server, own_client }
│ ├── StateChanged { state }
│ ├── DisconnectedTemporarily { reason }
│ ├── Disconnected { reason }
│ └── ConnectionFailed { error }
├── Client(ClientEvent)
│ ├── EnteredView { client, reason }
│ ├── LeftView { client_id, reason, reason_message }
│ ├── Updated { client_id, changes: ClientChanges }
│ ├── Moved { client_id, from_channel, to_channel, reason }
│ ├── StartedTalking { client_id }
│ ├── StoppedTalking { client_id }
│ ├── ServerGroupChanged { client_id, group_id, added }
│ └── ChannelGroupChanged { client_id, group_id }
├── Channel(ChannelEvent)
│ ├── Created { channel }
│ ├── Deleted { channel_id }
│ ├── Updated { channel_id, changes: ChannelChanges }
│ ├── Moved { channel_id, new_parent, new_order }
│ ├── PasswordChanged { channel_id }
│ ├── DescriptionChanged { channel_id }
│ └── Subscribed { channel_id, subscribed }
├── Server(ServerEvent)
│ ├── Updated { changes: ServerChanges }
│ ├── ServerGroupList { groups }
│ └── ChannelGroupList { groups }
├── Message(MessageEvent)
│ ├── Received { message }
│ ├── Sent { message }
│ ├── Read { message_id }
│ └── UnreadCountChanged { count }
├── Audio(AudioEvent)
│ ├── InputDeviceChanged { device }
│ ├── OutputDeviceChanged { device }
│ ├── InputVolumeChanged { volume }
│ ├── OutputVolumeChanged { volume }
│ ├── InputMutedChanged { muted }
│ ├── OutputMutedChanged { muted }
│ ├── DeviceList { input_devices, output_devices }
│ ├── InputLevel { level }
│ └── OutputLevel { level }
├── FileTransfer(FileTransferEvent)
│ ├── Started { transfer_id, file_name, file_size, is_upload }
│ ├── Progress { transfer_id, progress }
│ ├── Completed { transfer_id }
│ ├── Failed { transfer_id, error }
│ └── Cancelled { transfer_id }
└── Error(ErrorEvent)
├── Protocol { code, message }
├── Network { message }
├── Audio { message }
├── Database { message }
└── Other { message }
```
### `identity.rs`
---
- Locates TeamSpeak config database in common Linux paths
- Extracts `identity_secret_key`
- Parses it into `tsclientlib::Identity`
### 1.2 `tscore` Module Design
## Known Design Limitations
#### 1.2.1 Packet Processing Pipeline
```
SEND RECEIVE
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ Command String │ │ UDP Packet │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ Command::serialize() │ │ InPacket::parse() │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ QuickLZ compress │ │ AES-EAX decrypt │
│ (if Command/CommandLow) │ │ (or fake decrypt) │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ Fragment (if > 500 bytes) │ │ QuickLZ decompress │
│ │ │ │ (if COMPRESSED flag) │
│ ▼ │ │ │ │
│ AES-EAX encrypt │ │ ▼ │
│ (or fake encrypt) │ │ Defragment │
│ │ │ │ (if FRAGMENTED flag) │
│ ▼ │ │ │ │
│ Assign Packet ID │ │ ▼ │
│ │ │ │ Command::parse() │
│ ▼ │ │ │ │
│ UDP Send │ │ ▼ │
└──────────────────────────────┘ │ Application Layer │
└──────────────────────────────┘
```
#### 1.2.2 Encryption Key Derivation
```
Input: packet_type, direction, generation_id, shared_iv[64]
┌───────────────────────────────────────────────────────┐
│ temp[0] = direction_byte (0x30=S2C, 0x31=C2S) │
│ temp[1] = packet_type.u8() │
│ temp[2..6] = generation_id.to_be_bytes() │
│ temp[6..70] = shared_iv[0..64] │
│ │
│ key_nonce = SHA-256(temp) │
│ key = key_nonce[0..16] │
│ nonce = key_nonce[16..32] │
│ │
│ key[0] ^= (packet_id >> 8) as u8 │
│ key[1] ^= (packet_id & 0xFF) as u8 │
└───────────────────────────────────────────────────────┘
Output: key[16], nonce[16] → AES-128-EAX
```
#### 1.2.3 Connection State Machine
```
┌──────────────┐
│ Disconnected │
└──────┬───────┘
│ start_handshake()
┌──────────────┐
┌──────│ Connecting │◄─────────────────┐
│ └──────┬───────┘ │
│ │ Init1 received │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ IdentityLevelIncreasing│ │
│ └──────┬───────────────┘ │
│ │ Init3 received │
│ ▼ │
│ ┌──────────────┐ │
│ │ Connected │──────────────────┤
│ └──────┬───────┘ │
│ │ channellistfinished │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ ChannelListFinished │ │
│ └──────┬──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────┐ │
└─────>│ DisconnectedTemporarily │──────┘
└──────┬───────────────────┘
│ timeout / manual
┌──────────────┐
│ Error │
└──────┬───────┘
┌──────────────┐
│ Disconnected │
└──────────────┘
```
#### 1.2.4 RSA Puzzle Solver
```rust
/// Solves y = x^(2^level) mod n
///
/// Algorithm:
/// y = x
/// for i in 0..level:
/// y = (y * y) mod n
///
/// Time complexity: O(level * M(n)) where M(n) is multiplication cost
/// Space complexity: O(n) for big integer storage
fn solve_rsa_puzzle(x: &[u8; 64], n: &[u8; 64], level: u32) -> [u8; 64] {
let x_big = BigUint::from_bytes_be(x);
let n_big = BigUint::from_bytes_be(n);
let mut y = x_big;
for _ in 0..level {
y = (y.clone() * y) % &n_big;
}
// Convert back to 64-byte array (big-endian, zero-padded)
}
```
#### 1.2.5 Retransmission System
```
┌─────────────────────────────────────────────────────────┐
│ ResendManager │
├─────────────────────────────────────────────────────────┤
│ pending: BTreeMap<PacketId, SentPacket> │
│ max_retries: u32 (default: 10) │
│ connection_timeout: Duration (default: 30s) │
├─────────────────────────────────────────────────────────┤
│ add_sent(id, data) → Add to pending queue │
│ ack(id) → bool → Remove from pending │
│ get_retransmissions() → Vec<(id, data)> to resend │
│ is_connection_timeout()→ Check for dead connection │
├─────────────────────────────────────────────────────────┤
│ │
│ SentPacket: │
│ data: Vec<u8> │
│ sent_at: Instant │
│ retry_count: u32 │
│ timeout: Duration (starts at 500ms, doubles) │
│ │
│ RttEstimator: │
│ srtt: Duration (smoothed RTT) │
│ rtt_var: Duration (RTT variance) │
│ rto: Duration (retransmission timeout) │
│ update(measured_rtt) → recalculate SRTT, RTO │
└─────────────────────────────────────────────────────────┘
```
---
### 1.3 `tsaudio` Module Design
#### 1.3.1 Audio Pipeline
```
CAPTURE PIPELINE:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ cpal │───>│ VAD │───>│ Opus │───>│ Packet │
│ capture │ │ detect │ │ encode │ │ output │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
PLAYBACK PIPELINE:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Packet │───>│ Jitter │───>│ Opus │───>│ cpal │
│ input │ │ buffer │ │ decode │ │ playback │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
```
#### 1.3.2 Voice Activity Detection
```rust
pub struct VadDetector {
threshold: f32,
state: VadState,
}
impl VadDetector {
pub fn detect(&mut self, samples: &[f32]) -> VadState {
let energy = 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
}
}
```
#### 1.3.3 Jitter Buffer
```
┌─────────────────────────────────────────────────────────┐
│ JitterBuffer │
├─────────────────────────────────────────────────────────┤
│ buffer: Vec<Option<AudioFrame>> (ring buffer) │
│ head: usize │
│ tail: usize │
│ size: usize │
│ capacity: usize │
├─────────────────────────────────────────────────────────┤
│ push(frame) → Result<()> // Add frame │
│ pop() → Option<AudioFrame> // Get next frame │
│ len() → usize // Current buffer size │
│ is_empty() → bool │
│ is_full() → bool │
│ clear() // Reset buffer │
└─────────────────────────────────────────────────────────┘
```
---
### 1.4 `tsdb` Module Design
#### 1.4.1 Database Schema
```sql
-- Identity storage
CREATE TABLE identities (
id TEXT PRIMARY KEY, -- UUID
name TEXT NOT NULL, -- Display name
private_key TEXT NOT NULL, -- Base64 encoded ECC private key
counter INTEGER DEFAULT 0, -- Hash Cash counter
max_counter INTEGER DEFAULT 0, -- Maximum counter tried
created_at TEXT NOT NULL, -- ISO 8601 timestamp
updated_at TEXT NOT NULL -- ISO 8601 timestamp
);
-- Server bookmarks
CREATE TABLE bookmarks (
id TEXT PRIMARY KEY, -- UUID
name TEXT NOT NULL, -- Display name
address TEXT NOT NULL, -- Server address
port INTEGER DEFAULT 9987, -- Server port
nickname TEXT, -- Preferred nickname
server_password TEXT, -- Encrypted server password
channel TEXT, -- Default channel
channel_password TEXT, -- Encrypted channel password
default_token TEXT, -- Permission token
auto_connect INTEGER DEFAULT 0,-- Auto-connect on startup
last_connected TEXT, -- Last connection timestamp
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Message history
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_address TEXT NOT NULL, -- Server address
invoker_id INTEGER NOT NULL, -- Client ID
invoker_name TEXT NOT NULL, -- Display name
invoker_uid TEXT NOT NULL, -- Unique ID
target_type TEXT NOT NULL, -- "server", "channel", "client"
target_id INTEGER, -- Target ID
message TEXT NOT NULL, -- Message content
is_read INTEGER DEFAULT 0, -- Read status
timestamp TEXT NOT NULL -- ISO 8601 timestamp
);
-- Key-value settings
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
```
#### 1.4.2 CRUD Operations
```rust
impl DatabaseManager {
// Identity operations
fn create_identity(&self, name, private_key) -> DatabaseResult<Identity>;
fn get_identity(&self, id) -> DatabaseResult<Identity>;
fn get_all_identities(&self) -> DatabaseResult<Vec<Identity>>;
fn update_identity(&self, id, name?, counter?) -> DatabaseResult<()>;
fn delete_identity(&self, id) -> DatabaseResult<()>;
// Bookmark operations
fn create_bookmark(&self, name, address, port, nickname?) -> DatabaseResult<Bookmark>;
fn get_bookmark(&self, id) -> DatabaseResult<Bookmark>;
fn get_all_bookmarks(&self) -> DatabaseResult<Vec<Bookmark>>;
fn update_bookmark(&self, id, name?, address?, port?, nickname?) -> DatabaseResult<()>;
fn delete_bookmark(&self, id) -> DatabaseResult<()>;
// Message operations
fn create_message(&self, server_address, invoker_id, invoker_name,
invoker_uid, target_type, target_id?, message) -> DatabaseResult<Message>;
fn get_server_messages(&self, server_address, limit, offset) -> DatabaseResult<Vec<Message>>;
fn mark_message_read(&self, id) -> DatabaseResult<()>;
// Settings operations
fn get_setting(&self, key) -> DatabaseResult<Option<String>>;
fn set_setting(&self, key, value) -> DatabaseResult<()>;
}
```
---
### 1.5 `tauri-app` Design
#### 1.5.1 Tauri Command Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Frontend (React) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ invoke("get_bookmarks") → Promise<BookmarkInfo[]> │ │
│ │ invoke("connect", {addr, port, nick, pass}) │ │
│ │ invoke("send_message", {target, message}) │ │
│ └───────────────────────────────────────────────────────┘ │
└───────────────────────────┬─────────────────────────────────┘
│ Tauri IPC
┌─────────────────────────────────────────────────────────────┐
│ Tauri Shell (Rust) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ #[tauri::command] │ │
│ │ async fn get_bookmarks(state: State<AppState>) │ │
│ │ -> Result<Vec<BookmarkInfo>, String> │ │
│ │ { │ │
│ │ state.db.get_all_bookmarks() │ │
│ │ .map(|b| b.into_iter().map(Into::into)) │ │
│ │ .map_err(|e| e.to_string()) │ │
│ │ } │ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ AppState { │ │
│ │ db: DatabaseManager, │ │
│ │ connection_state: Mutex<ConnectionState>, │ │
│ │ } │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
#### 1.5.2 React Component Structure
```
App
├── Header
│ ├── Logo
│ ├── ConnectionStatus
│ └── SettingsButton
├── Sidebar
│ ├── BookmarkList
│ │ └── BookmarkItem (clickable)
│ └── RecentServers
├── MainContent
│ ├── ConnectForm (when no connection)
│ │ ├── AddressInput
│ │ ├── NicknameInput
│ │ ├── PasswordInput
│ │ └── ConnectButton
│ │
│ └── ChatView (when connected)
│ ├── ChannelTree
│ ├── ClientList
│ ├── MessageList
│ └── MessageInput
└── StatusBar
├── ConnectionInfo
├── AudioStatus
└── LatencyDisplay
```
---
## 2. Algorithm Specifications
### 2.1 Shared Secret Computation (Old Protocol <3.1)
```
Input: alpha[10], beta[10], shared_data[32]
Output: SharedIV[64], SharedMac[8]
1. SharedIV[0..20] = SHA-1(shared_data)
2. SharedIV[0..10] ^= alpha[0..10]
3. SharedIV[10..20] ^= beta[0..10]
4. SharedMac[0..8] = SHA-1(SharedIV)[0..8]
```
### 2.2 Shared Secret Computation (New Protocol ≥3.1)
```
Input: alpha[10], beta[54], shared_data[32]
Output: SharedIV[64], SharedMac[8]
1. SharedIV[0..64] = SHA-512(shared_data)
2. SharedIV[0..10] ^= alpha[0..10]
3. SharedIV[10..64] ^= beta[0..54]
4. SharedMac[0..8] = SHA-1(SharedIV)[0..8]
```
### 2.3 Hash Cash Level Computation
```
Input: omega (public key string), offset (u64)
Output: level (u8)
1. data = SHA-1(omega + offset.to_string())
2. level = 0
3. for byte in data:
4. if byte == 0:
5. level += 8
6. else:
7. level += trailing_zeros(byte)
8. break
9. return level
```
### 2.4 UID Computation
```
Input: publicKey (ASN.1-DER encoded)
Output: uid (base64 string)
1. hash = SHA-1(publicKey)
2. uid = base64(hash)
```
---
## 3. Interface Specifications
### 3.1 Tauri IPC Interface
```typescript
// TypeScript interface for Tauri commands
interface ITauriCommands {
// Identity management
get_identities(): Promise<IdentityInfo[]>;
create_identity(name: string): Promise<IdentityInfo>;
delete_identity(id: string): Promise<void>;
// Bookmark management
get_bookmarks(): Promise<BookmarkInfo[]>;
create_bookmark(name: string, address: string, port: number,
nickname?: string): Promise<BookmarkInfo>;
delete_bookmark(id: string): Promise<void>;
// Connection
connect(address: string, port: number, nickname: string,
password?: string): Promise<void>;
disconnect(): Promise<void>;
// Messaging
send_message(target: string, message: string): Promise<void>;
get_messages(server_address: string, limit: number,
offset: number): Promise<MessageInfo[]>;
}
interface IdentityInfo {
id: string;
name: string;
counter: number;
max_counter: number;
}
interface BookmarkInfo {
id: string;
name: string;
address: string;
port: number;
nickname: string | null;
auto_connect: boolean;
last_connected: string | null;
}
interface MessageInfo {
id: number;
invoker_name: string;
message: string;
timestamp: string;
is_read: boolean;
}
```
### 3.2 Internal Rust Interfaces
```rust
// Protocol layer
pub trait PacketProcessor {
fn encode(&self, packet: OutPacket) -> Result<Vec<OutUdpPacket>>;
fn decode(&self, data: &[u8]) -> Result<InPacket>;
}
// Audio layer
pub trait AudioCapture {
async fn start(&mut self) -> AudioResult<()>;
async fn stop(&mut self) -> AudioResult<()>;
async fn capture(&mut self) -> AudioResult<AudioFrame>;
}
pub trait AudioPlayback {
async fn start(&mut self) -> AudioResult<()>;
async fn stop(&mut self) -> AudioResult<()>;
async fn play(&mut self, frame: AudioFrame) -> AudioResult<()>;
}
// Database layer
pub trait IdentityStore {
fn create(&self, name: &str, key: &str) -> DatabaseResult<Identity>;
fn get(&self, id: &str) -> DatabaseResult<Identity>;
fn list(&self) -> DatabaseResult<Vec<Identity>>;
fn update(&self, id: &str, updates: IdentityUpdates) -> DatabaseResult<()>;
fn delete(&self, id: &str) -> DatabaseResult<()>;
}
```
---
## 4. Data Dictionary
### 4.1 Protocol Fields
| Field | Type | Size | Description |
|-------|------|------|-------------|
| MAC | [u8; 8] | 8 bytes | EAX message authentication code |
| PId | u16 | 2 bytes | Packet sequence ID |
| CId | u16 | 2 bytes | Client ID (C2S only) |
| PT | u8 | 1 byte | Packet type + flags |
| VId | u16 | 2 bytes | Voice packet ID |
| Codec | u8 | 1 byte | Audio codec type |
### 4.2 Flag Bits
| Bit | Name | Mask | Description |
|-----|------|------|-------------|
| 7 | UE | 0x80 | Unencrypted |
| 6 | CP | 0x40 | Compressed (QuickLZ) |
| 5 | NP | 0x20 | New protocol |
| 4 | FR | 0x10 | Fragmented |
| 3-0 | Type | 0x0F | Packet type (0-8) |
### 4.3 Error Codes
| Code | Name | Description |
|------|------|-------------|
| 0x0000 | ok | Success |
| 0x0200 | client_invalid_id | Invalid client ID |
| 0x0201 | client_nickname_inuse | Nickname already in use |
| 0x0208 | client_invalid_password | Wrong password |
| 0x0300 | channel_invalid_id | Invalid channel ID |
| 0x0400 | server_invalid_id | Invalid server ID |
| 0x0403 | server_maxclients_reached | Server full |
| 0x0701 | connection_lost | Connection lost |
---
## 5. Test Design
### 5.1 Test Cases (32 total)
#### Protocol Tests (14)
1. `test_packet_type_conversion` - PacketType enum conversion
2. `test_flags` - Flag bit manipulation
3. `test_header_c2s` - C2S header parsing
4. `test_header_s2c` - S2C header parsing
5. `test_in_packet_parse` - Input packet parsing
6. `test_out_packet` - Output packet creation
7. `test_command_parse` - Command string parsing
8. `test_command_serialize` - Command serialization
9. `test_command_builder` - CommandBuilder pattern
10. `test_escape_sequences` - Escape/unescape
11. `test_init_packet_parse` - Init packet parsing
12. `test_init_packet_serialize` - Init packet serialization
13. `test_ack_packet` - Acknowledgement packet
14. `test_packet_type_properties` - Type property queries
#### Crypto Tests (12)
1. `test_sha1` - SHA-1 hash
2. `test_sha256` - SHA-256 hash
3. `test_sha512` - SHA-512 hash
4. `test_hash_password` - Password hashing
5. `test_create_key_nonce` - Key derivation
6. `test_create_encryption_key` - Packet-specific key
7. `test_shared_secret_old` - Old protocol shared secret
8. `test_shared_secret_new` - New protocol shared secret
9. `test_key_cache` - Key caching
10. `test_eax_encrypt_decrypt` - EAX encryption/decryption
11. `test_fake_encrypt_decrypt` - Fake encryption
12. `test_hash_cash_level` - Hash Cash computation
13. `test_compute_uid` - UID computation
#### Connection Tests (6)
1. `test_encode_version` - Version encoding
2. `test_rsa_puzzle` - RSA puzzle solver
3. `test_resend_manager` - Retransmission manager
4. `test_rtt_estimator` - RTT estimation
5. `test_sent_packet_retry` - Packet retry logic
---
## 6. References
1. TS3 Protocol Paper: `refercence/tsdeclarations/ts3protocol.md`
2. Packet Definitions: `refercence/tsdeclarations/Packets.txt`
3. Message Definitions: `refercence/tsdeclarations/Messages.toml`
4. Source Code: `src/` (4780 lines, 39 Rust files)
- `main.rs` is still monolithic and should be split later
- Bookmarks are not persisted
- ServerQuery is not wired to a real backend yet
- Audio settings apply to runtime state, but broader config persistence is not implemented
+30 -311
View File
@@ -1,322 +1,41 @@
# Software Requirements Specification (SRS)
# ReTeamSpeak - Cross-Platform TeamSpeak Client
# Software Requirements Specification
**Version**: 1.0.0
**Date**: 2026-05-12
**Status**: Based on actual implementation
## Product
---
ReTeamSpeak is a desktop TeamSpeak 3 client for Windows, Linux, and macOS.
## 1. Introduction
## Current Functional Scope
### 1.1 Purpose
ReTeamSpeak is a cross-platform TeamSpeak 3 voice communication client supporting Windows, macOS, Linux, iOS, and Android. This document specifies the software requirements based on the implemented system.
### Implemented
### 1.2 Scope
The system implements:
- TeamSpeak 3 protocol (UDP-based, encrypted voice/text communication)
- Cross-platform UI via Tauri v2 + React
- Audio engine with Opus codec
- Local data storage (SQLite)
- Identity and bookmark management
- Connect to a TeamSpeak server with nickname and optional password
- Observe live server, channel, and client state
- Join channels
- Send and receive channel text messages
- Show connected clients and channels
- Toggle microphone mute and sync state to TeamSpeak
- Toggle speaker/output mute and sync state to TeamSpeak
- Toggle output hardware state and sync state to TeamSpeak
- Toggle AFK state and sync state to TeamSpeak
- Select audio input and output devices
- Support push-to-talk and continuous talk modes
- Import an existing TeamSpeak identity from local client config
- Select a noise-cancellation method in settings
### 1.3 Definitions
| Term | Definition |
|------|-----------|
| TS3 | TeamSpeak 3 protocol |
| EAX | AES-128-CTR with OMAC encryption mode |
| ECDH | Elliptic Curve Diffie-Hellman key exchange |
| Opus | Audio codec used for voice transmission |
| VAD | Voice Activity Detection |
| PTT | Push-To-Talk |
### Partially Implemented / Stubbed
---
- ServerQuery page exists, but query execution is stubbed
- Bookmark management exists only in local in-memory UI state
## 2. System Requirements
### Not In Scope Right Now
### 2.1 Functional Requirements
- Tauri frontend/backend shell
- Custom in-repo TeamSpeak protocol implementation
- Separate in-repo database layer
- Mobile and web targets
#### FR-01: Connection Management
- **FR-01.1**: Connect to TS3 servers via UDP
- **FR-01.2**: Support RSA puzzle handshake (DoS protection)
- **FR-01.3**: Support ECDH key exchange (P-256 and Curve25519)
- **FR-01.4**: Support AES-128-EAX encrypted communication
- **FR-01.5**: Support QuickLZ packet compression
- **FR-01.6**: Support packet fragmentation (max 500 bytes)
- **FR-01.7**: Support selective repeat reliable delivery
- **FR-01.8**: Support connection state machine (Disconnected → Connecting → Connected → ChannelListFinished)
## Non-Functional Requirements
#### FR-02: Authentication
- **FR-02.1**: ECC P-256 identity key generation
- **FR-02.2**: Hash Cash level computation (anti-spam)
- **FR-02.3**: Server password authentication (base64(sha1(password)))
- **FR-02.4**: Channel password authentication
- **FR-02.5**: Permission token support
- **FR-02.6**: UID computation (base64(sha1(publicKey)))
#### FR-03: Voice Communication
- **FR-03.1**: Opus codec encoding/decoding (48kHz)
- **FR-03.2**: Voice Activity Detection (VAD)
- **FR-03.3**: Push-To-Talk (PTT) mode
- **FR-03.4**: Per-client volume control
- **FR-03.5**: Whisper support (direct and group)
- **FR-03.6**: Codec types: Speex NB/WB/UWB, CELT, Opus Voice/Music
#### FR-04: Text Messaging
- **FR-04.1**: Server messages
- **FR-04.2**: Channel messages
- **FR-04.3**: Private messages
- **FR-04.4**: BBCode formatting support
- **FR-04.5**: Message history (SQLite storage)
#### FR-05: Channel Management
- **FR-05.1**: Channel tree display
- **FR-05.2**: Channel join/leave
- **FR-05.3**: Channel creation/editing (with permissions)
- **FR-05.4**: Channel subscription
#### FR-06: Client Management
- **FR-06.1**: Online client list
- **FR-06.2**: Client info display
- **FR-06.3**: Server group management
- **FR-06.4**: Channel group management
- **FR-06.5**: Client kick/ban (with permissions)
#### FR-07: Data Storage
- **FR-07.1**: Identity storage (encrypted private keys)
- **FR-07.2**: Server bookmarks
- **FR-07.3**: Chat message history
- **FR-07.4**: Application settings
#### FR-08: File Transfer
- **FR-08.1**: File upload to channels
- **FR-08.2**: File download from channels
- **FR-08.3**: File browsing
- **FR-08.4**: Transfer progress tracking
### 2.2 Non-Functional Requirements
#### NFR-01: Performance
- **NFR-01.1**: Connection establishment < 3 seconds
- **NFR-01.2**: Voice latency < 200ms
- **NFR-01.3**: Message delivery < 100ms
- **NFR-01.4**: Support 1000+ client servers
#### NFR-02: Security
- **NFR-02.1**: AES-128-EAX encryption for all commands
- **NFR-02.2**: ECDH key exchange (forward secrecy)
- **NFR-02.3**: RSA puzzle DoS protection
- **NFR-02.4**: ChaCha20-Poly1305 identity storage encryption
#### NFR-03: Compatibility
- **NFR-03.1**: Windows 10/11
- **NFR-03.2**: macOS 11+
- **NFR-03.3**: Linux (Ubuntu 20.04+, Debian 11+)
- **NFR-03.4**: iOS 15+
- **NFR-03.5**: Android 10+
- **NFR-03.6**: TS3 server versions 3.0.x and 3.1.x
#### NFR-04: Reliability
- **NFR-04.1**: Automatic reconnection on temporary disconnect
- **NFR-04.2**: Packet retransmission with exponential backoff
- **NFR-04.3**: Connection timeout detection (30 seconds)
- **NFR-04.4**: Graceful degradation on packet loss
---
## 3. System Architecture
### 3.1 Module Structure
```
src/
├── shared/ # Shared types (Client, Channel, Server, Events)
├── tscore/ # Protocol core (packets, crypto, connection)
├── tsaudio/ # Audio engine (capture, playback, codec)
├── tsdb/ # Database (SQLite via rusqlite)
└── tauri-app/ # Application shell
├── src-tauri/ # Rust backend (Tauri commands)
└── frontend/ # React frontend (TypeScript)
```
### 3.2 Technology Stack
| Layer | Technology |
|-------|-----------|
| Language | Rust 1.70+, TypeScript 5.x |
| Desktop Framework | Tauri v2 |
| Frontend | React 18, Vite 5 |
| Async Runtime | Tokio |
| Actor Framework | Actix |
| Database | SQLite (rusqlite) |
| Audio | Opus, cpal |
| Crypto | AES-EAX, P-256, Curve25519, SHA-256/512 |
---
## 4. Protocol Specification
### 4.1 Packet Format
```
C2S: [MAC:8][PId:2][CId:2][PT:1][Data:≤487]
S2C: [MAC:8][PId:2][PT:1][Data:≤489]
```
### 4.2 Packet Types
| Type | Value | Encrypted | Reliable | Fragmentable |
|------|-------|-----------|----------|--------------|
| Voice | 0x00 | Optional | No | No |
| VoiceWhisper | 0x01 | Optional | No | No |
| Command | 0x02 | Yes | Yes | Yes |
| CommandLow | 0x03 | Yes | Yes | Yes |
| Ping | 0x04 | No | No | No |
| Pong | 0x05 | No | No | No |
| Ack | 0x06 | Yes | Yes | No |
| AckLow | 0x07 | Yes | Yes | No |
| Init | 0x08 | No | Yes | No |
### 4.3 Handshake Sequence
```
Client → Server: Init0 (version, timestamp, random0)
Server → Client: Init1 (random1, random0_r)
Client → Server: Init2 (version, random1, random0_r)
Server → Client: Init3 (x, n, level, random2) [RSA puzzle]
Client → Server: Init4 (x, n, level, random2, y, clientinitiv)
Server → Client: initivexpand2 (beta, omega, proof, license)
Client → Server: clientek (ek, proof)
Server → Client: initserver
Server → Client: channellist...channellistfinished
Server → Client: notifycliententerview...
```
### 4.4 Encryption
- **Algorithm**: AES-128-EAX (AES-128-CTR + OMAC)
- **Key derivation**: SHA-256(direction | type | generation_id | shared_iv)
- **Shared IV**: SHA-512(ECDH shared secret) XOR alpha/beta
- **MAC**: 8 bytes (EAX tag)
---
## 5. Data Structures
### 5.1 Core Types (from shared/src/types.rs)
```rust
ClientId(u16) // Client identifier
ChannelId(u64) // Channel identifier
ServerGroupId(u64) // Server group identifier
ClientDbId(u64) // Client database identifier
Uid(String) // Unique identifier (base64)
PermissionId(u32) // Permission identifier
```
### 5.2 Database Schema (from tsdb/src/lib.rs)
```sql
CREATE TABLE identities (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
private_key TEXT NOT NULL,
counter INTEGER DEFAULT 0,
max_counter INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE bookmarks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
address TEXT NOT NULL,
port INTEGER DEFAULT 9987,
nickname TEXT,
server_password TEXT,
channel TEXT,
channel_password TEXT,
auto_connect INTEGER DEFAULT 0,
last_connected TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE 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 DEFAULT 0,
timestamp TEXT NOT NULL
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
```
---
## 6. Test Results
### 6.1 Unit Tests (32 tests passing)
```
tscore::protocol::tests - 14 tests
✓ test_packet_type_conversion
✓ test_flags
✓ test_header_c2s
✓ test_header_s2c
✓ test_in_packet_parse
✓ test_out_packet
✓ test_command_parse
✓ test_command_serialize
✓ test_command_builder
✓ test_escape_sequences
✓ test_init_packet_parse
✓ test_init_packet_serialize
✓ test_ack_packet
✓ test_packet_type_properties
tscore::crypto::tests - 12 tests
✓ test_sha1, test_sha256, test_sha512
✓ test_hash_password
✓ test_create_key_nonce
✓ test_create_encryption_key
✓ test_shared_secret_old, test_shared_secret_new
✓ test_key_cache
✓ test_eax_encrypt_decrypt
✓ test_fake_encrypt_decrypt
✓ test_hash_cash_level
✓ test_compute_uid
tscore::connection::tests - 6 tests
✓ test_encode_version
✓ test_rsa_puzzle
✓ test_resend_manager
✓ test_rtt_estimator
✓ test_sent_packet_retry
```
---
## 7. Constraints
### 7.1 Technical Constraints
- Rust edition 2021
- Tauri v2 for desktop/mobile
- Must maintain TS3 protocol compatibility
- UDP transport only (no TCP fallback)
### 7.2 Legal Constraints
- TeamSpeak is a trademark of TeamSpeak Systems GmbH
- Implementation is for educational/research purposes
- No server-side code (client-only)
---
## 8. References
1. TS3 Protocol Paper (`refercence/tsdeclarations/ts3protocol.md`)
2. Packet Definitions (`refercence/tsdeclarations/Packets.txt`)
3. Message Definitions (`refercence/tsdeclarations/Messages.toml`)
4. tsclientlib implementation (`refercence/tsclientlib/`)
5. Qint implementation (`refercence/Qint/`)
- Use `tsclientlib` for TeamSpeak compatibility instead of custom protocol code
- Keep the shipped desktop app codebase small and maintainable
- Prefer Podman-based reproducible builds when host system packages are missing
+7 -398
View File
@@ -1,401 +1,10 @@
# 系统架构设计文档 (SAD)
# Architecture Notes
## 1. 概述
The authoritative architecture is now documented in `SAD.md`.
本文档描述 TeamSpeak 3 客户端系统的整体架构设计,基于对参考代码库(tsclientlib、Qint、SimpleBot、ts3stats)的分析结果。
Short version:
## 2. 系统架构图
```
+-----------------------------------------------------------------------+
| 前端层 (Frontend) |
| |
| +-----------+ +----------+ +--------+ +------+ +-----+ +-----+ |
| | 连接对话框 | | 聊天视图 | | 频道树 | | 面板 | |文件 | |插件 | |
| +-----------+ +----------+ +--------+ +------+ +-----+ +-----+ |
| |
| +---------------------------+ +--------------------------+ |
| | 连接状态管理 | | 数据状态镜像 | |
| +---------------------------+ +--------------------------+ |
| |
| +------------------------------------------------------------------+ |
| | 后端抽象层 (Backend Abstraction) | |
| | IBackend <--- TauriBackend / BrowserBackend ---> | |
| +------------------------------------------------------------------+ |
+-----------------------------------------------------------------------+
| Tauri: IPC (invoke + event) | Browser: WebSocket + HTTP
v v
+-----------------------------------------------------------------------+
| 壳层 (Shell Layer) |
| |
| 桌面壳 (Tauri) Web壳 (Actix-web) |
| +---------------------+ +------------------------+ |
| | 命令处理器 | | REST/WebSocket 端点 | |
| | 窗口桥接器 | | GraphQL 端点 | |
| +---------------------+ +------------------------+ |
| | 核心协调器 | | WebSocket 处理器 | |
| | 文件传输管理器 | +------------------------+ |
| +---------------------+ |
+-----------------------------------------------------------------------+
| |
| 共享依赖 |
v v
+-----------------------------------------------------------------------+
| 代理库 (Proxy Library) - 核心业务逻辑 |
| |
| +---------------------+ +--------------------+ +----------------+ |
| | 全局状态管理 | | 连接管理器 | | 数据库管理器 | |
| | (QintState) | | (QintConnection) | | (DbHandler) | |
| +---------------------+ +--------------------+ +----------------+ |
| +--------------------+ +----------------+ |
| | 音频管道 | | 工具组件 | |
| | AudioToTs | | 文件缓存 | |
| | TsToAudio | | 链接预览 | |
| +--------------------+ | 全文搜索 | |
| | 热键管理 | |
| | 身份加密 | |
| +----------------+ |
+-----------------------------------------------------------------------+
|
| 依赖
v
+-----------------------------------------------------------------------+
| 代码生成层 (proxy-codegen) |
| |
| build.rs 读取 tsproto-structs -> 生成: |
| - Rust: JsEvent, JsProperty, JsM2B, convert_event() 等 |
| - TypeScript: book_events.ts (PropertyId, PropertyValue, OChange) |
+-----------------------------------------------------------------------+
|
v
+-----------------------------------------------------------------------+
| TeamSpeak 协议库 (tsclientlib / tsproto) |
| - TeamSpeak 协议实现 |
| - 连接管理、状态维护、音频编解码 |
| - 事件系统(属性变更、消息、音频) |
+-----------------------------------------------------------------------+
```
## 3. 分层架构
### 3.1 前端层 (Frontend Layer)
**职责**: 用户界面展示和用户交互处理
**技术栈**: Svelte 5 + TypeScript + Rsbuild
**主要组件**:
- **连接对话框**: 服务器连接配置界面
- **聊天视图**: 文本消息收发界面
- **频道树**: 频道和客户端树状视图
- **面板**: 设置、文件浏览器等侧边面板
- **插件系统**: 动态加载的插件模块
**关键特性**:
- 响应式状态管理 (Svelte stores)
- 跨平台兼容 (Tauri/Web)
- 实时事件更新
### 3.2 壳层 (Shell Layer)
**职责**: 平台特定的传输适配和系统集成
**两种实现**:
#### 3.2.1 桌面壳 (Tauri Desktop)
- **技术栈**: Rust + Tauri v2
- **职责**:
- 原生窗口管理
- 系统托盘集成
- 文件对话框
- 全局热键
- IPC 通信 (invoke + event)
#### 3.2.2 Web 壳 (Actix-web Server)
- **技术栈**: Rust + Actix-web
- **职责**:
- HTTP REST 端点
- WebSocket 通信
- GraphQL 查询端点
- 静态文件服务
### 3.3 代理层 (Proxy Layer)
**职责**: 核心业务逻辑,平台无关
**技术栈**: Rust + Actix actor 模型
**主要组件**:
#### 3.3.1 QintState (全局状态管理)
- 连接映射管理
- 音频数据管理
- 热键配置
- 设置管理
- 文件缓存
- 链接预览
- 全文搜索索引
#### 3.3.2 QintConnection (连接管理器)
- TeamSpeak 服务器连接生命周期
- 事件处理和分发
- 消息路由
- 音频路由
- 文件传输管理
#### 3.3.3 DbHandler (数据库管理器)
- SQLite/Diesel ORM
- 身份管理
- 服务器/书签管理
- 聊天消息存储
- GraphQL 查询支持
#### 3.3.4 音频管道
- **AudioToTs**: 麦克风采集 → Opus 编码 → 发送到服务器
- **TsToAudio**: 接收服务器音频 → Opus 解码 → 混音 → 播放
- 支持 SDL2 (桌面) 和 Oboe (Android) 后端
### 3.4 代码生成层 (Code Generation Layer)
**职责**: 从协议定义自动生成类型安全的代码
**输入**: tsproto-structs 的 TOML/CSV 声明文件
**输出**:
- Rust 类型 (JsEvent, JsProperty, JsM2B 等)
- TypeScript 类型 (book_events.ts)
- 事件转换函数
- 消息序列化/反序列化代码
### 3.5 协议库层 (Protocol Library Layer)
**职责**: TeamSpeak 3 协议的底层实现
**主要组件**:
#### 3.5.1 tsclientlib (高层客户端库)
- 连接配置和构建
- 状态同步
- 音频处理
- 地址解析 (IP/DNS SRV/TSDNS)
#### 3.5.2 tsproto (协议引擎)
- UDP 数据包处理
- 加密/解密 (AES-128-EAX)
- 压缩/解压 (QuickLZ)
- 可靠传输 (CUBIC 拥塞控制)
- 连接握手
#### 3.5.3 ts-bookkeeping (状态管理)
- 服务器状态维护
- 客户端/频道/组数据
- 事件生成
#### 3.5.4 tsproto-packets (包解析)
- 数据包格式定义
- 命令解析器
- 零拷贝解析
## 4. 设计模式
### 4.1 Actor 模型
- 使用 Actix 框架实现并发隔离
- 每个 TeamSpeak 连接是独立的 Actor
- 通过消息传递进行通信
### 4.2 桥接模式
- `AppToFrontendBridge` trait 解耦核心逻辑和传输层
- 两种实现: WindowBridge (Tauri) 和 WsBridge (Web)
### 4.3 策略模式
- 前端的 `IBackend` 接口
- 两种实现: TauriBackend 和 BrowserBackend
### 4.4 代码生成模式
- 从声明式 TOML/CSV 生成重复代码
- 确保类型安全和一致性
### 4.5 状态机模式
- 连接状态管理 (Uninitialized → Connecting → Connected → Disconnected)
### 4.6 观察者模式
- Svelte stores 实现响应式状态传播
- 事件系统实现组件间通信
## 5. 数据流
### 5.1 入站数据流 (服务器 → 应用)
```
UDP Socket
Connection::poll_incoming_udp_packet()
PacketCodec::handle_udp_packet()
├── 解密 (AES-128-EAX)
├── 重组分片
├── 解压 (QuickLZ)
└── 生成 StreamItem
├── Command → InCommandBuf
├── Audio → InAudioBuf
└── Ack → 更新发送队列
Client::handle_command()
Connection (tsclientlib)::poll_next()
├── 解析为 InMessage
├── 应用到 data::Connection 状态
├── 生成 events::Event
└── 返回 StreamItem::BookEvents
```
### 5.2 出站数据流 (应用 → 服务器)
```
应用层调用
OutCommandExt::send_with_result()
Connection::send_command_with_result()
├── 添加 return_code
├── 更新本地状态
client::Client::send_packet()
PacketCodec::encode_packet()
├── 压缩 + 分片 (QuickLZ)
├── 加密 (AES-128-EAX)
└── 分配 packet_id
Resender::send_packet()
├── 加入发送队列
└── CUBIC 拥塞控制
UDP Socket 发送
```
### 5.3 音频数据流
```
麦克风 → AudioToTs (Actor)
├── VAD 检测
├── 响度测量
├── Opus 编码
└── 发送到服务器
服务器 → tsclientlib::Connection
├── AudioData::S2C
└── TsToAudio (Actor)
├── Opus 解码
├── 每客户端音量
├── 混音
├── 噪声抑制
└── SDL2/Oboe 输出
```
## 6. 关键接口
### 6.1 AppToFrontendBridge
```rust
pub trait AppToFrontendBridge {
fn send(&self, msg: &MessageP2F);
fn close(&self);
}
```
### 6.2 MessageF2P / MessageP2F
```rust
// 前端 → 代理
pub enum MessageF2P {
Connect(ConnectOptions),
Disconnect(DisconnectOptions),
SendMessage { target, message, return_code },
SetClientVolume { client, volume },
SetWhispering(Option<WhisperData>),
Change { change: JsM2B, return_code },
}
// 代理 → 前端
pub enum MessageP2F {
Error(String),
Connected { server, own_client },
DisconnectedTemporarily(),
TalkersChanged(Vec<(String, bool)>),
Events(Vec<JsEvent>),
Message(JsInMessage),
Loudnesses(HashMap<String, f32>),
Result(ResultStruct),
}
```
### 6.3 IBackend / IBackendConnection
```typescript
interface IBackend {
createNewConnection(returnCodes: ReturnCodeTracker): IBackendConnection;
graphql<T>(query: string, variables?: Record<string, unknown>): Promise<{data: T}>;
get_settings(): Promise<Record<string, unknown>>;
set_settings(diff: Record<string, unknown>): Promise<void>;
}
interface IBackendConnection {
id: string;
connect(onMsg, onError, onClose): Promise<void>;
send(data: OutMsg): void;
close(): void;
fetch_image(req: IFileRequest): Promise<string | undefined>;
upload_bytes(req: IFileRequest, data: Blob): Promise<TransferResult>;
}
```
## 7. 平台适配
| 功能 | 桌面 (Tauri) | Web (Browser) | Android |
|------|-------------|---------------|---------|
| 音频后端 | SDL2 | N/A | Oboe |
| TLS | OpenSSL | N/A | rustls |
| 文件对话框 | Tauri 插件 | HTML input | Tauri |
| 系统托盘 | Tauri tray-icon | N/A | N/A |
| 全局热键 | livesplit-hotkey | N/A | N/A |
## 8. 依赖关系
```
src-tauri ──────depends on──────> qint-proxy ──────depends on──────> tsclientlib
│ │ │
└──depends on──> proxy-codegen ────┘ tsproto
│ │ tsproto-packets
└──depends on──> tauri v2 └──depends on──> diesel (SQLite) tsproto-types
└──depends on──> audiopus/opus
└──depends on──> sdl2/oboe
└──depends on──> tantivy (search)
└──depends on──> juniper (GraphQL)
webapp ──────depends on──────> qint-proxy (same as above)
└──depends on──> actix-web
└──depends on──> proxy-codegen
```
## 9. 关键技术决策
| 决策 | 选择 | 理由 |
|------|------|------|
| 异步运行时 | Tokio | Rust 生态最成熟的异步运行时 |
| Actor 框架 | Actix | 成熟的 Actor 模型实现 |
| 加密 | AES-128-EAX + ECDH | TeamSpeak 协议规范要求 |
| 压缩 | QuickLZ | TeamSpeak 协议使用的压缩算法 |
| 拥塞控制 | CUBIC | 类似 TCP CUBIC,适合实时通信 |
| 代码生成 | t4rust-derive | 从声明式数据生成大量重复代码 |
| 音频编解码 | Opus | TeamSpeak 3 默认编解码器 |
| 数据库 | SQLite + Diesel | 轻量级嵌入式数据库 + 类型安全 ORM |
| 前端框架 | Svelte 5 | 轻量级响应式框架 |
| 桌面框架 | Tauri v2 | 跨平台原生桌面应用 |
| Web 框架 | Actix-web | 高性能 Rust Web 框架 |
## 10. 安全考虑
1. **身份加密**: 使用 ChaCha20-Poly1305 加密存储身份私钥
2. **传输加密**: 使用 AES-128-EAX 加密所有命令和语音数据
3. **密钥交换**: 使用 ECDH (prime256v1) 进行密钥交换
4. **防 DoS**: 使用 RSA 拼图防止连接洪水攻击
5. **身份验证**: 使用 Hashcash 机制防止身份伪造
- one desktop client crate
- `iced` UI
- `tsclientlib` protocol/state/audio integration
- optional local audio helpers for capture/playback/noise reduction
+15 -709
View File
@@ -1,713 +1,19 @@
# 组件设计文档 (SDD)
# Components
## 1. 概述
## Shipped Components
本文档详细描述系统各组件的设计,包括功能、接口、数据结构和实现细节。
- `iced-app/src/main.rs` - app state, views, TeamSpeak actions
- `iced-app/src/audio.rs` - local audio capture/playback/encoding helpers
- `iced-app/src/noise_cancel.rs` - noise reduction backends
- `iced-app/src/identity.rs` - TeamSpeak identity import
- `iced-app/src/theme.rs` - UI theme styles
## 2. 核心组件
## External Core Dependencies
### 2.1 协议库组件 (tsclientlib)
#### 2.1.1 tsproto-types (基础类型)
**职责**: 定义 TeamSpeak 协议中使用的基础类型、枚举和加密原语
**关键类型**:
```rust
pub struct ClientId(pub u16); // 客户端 ID
pub struct ChannelId(pub u64); // 频道 ID
pub struct UidBuf(pub Vec<u8>); // 用户唯一标识
pub struct Permission(pub u32); // 权限 ID
pub enum ClientType { Normal, Query { admin: bool } }
pub enum MaxClients { Unlimited, Inherited, Limited(u16) }
```
**加密模块**:
```rust
pub struct EccKeyPubP256(p256::PublicKey); // P-256 公钥
pub struct EccKeyPrivP256(p256::SecretKey); // P-256 私钥
pub struct EccKeyPubEd25519(CompressedEdwardsY); // Ed25519 公钥
pub struct EccKeyPrivEd25519(Scalar); // Ed25519 私钥
```
**错误码枚举**: 从 CSV 自动生成,包含所有 TeamSpeak 错误码
#### 2.1.2 tsproto-structs (声明式数据)
**职责**: 提供协议的机器可读声明数据
**声明文件**:
- `Book.toml` - 服务器状态数据模型
- `Messages.toml` - 协议消息结构
- `Enums.toml` - 枚举定义
- `Errors.csv` - 错误码列表
- `Versions.csv` - 版本信息
#### 2.1.3 tsproto-packets (包解析)
**职责**: 解析和序列化 TeamSpeak 网络包和命令
**关键类型**:
```rust
pub enum PacketType { Voice, VoiceWhisper, Command, CommandLow, Ping, Pong, Ack, AckLow, Init }
pub enum Direction { S2C, C2S }
// 输入包(零拷贝)
pub struct InPacket<'a> { header: InHeader<'a>, content: &'a [u8] }
pub struct InCommand<'a> { packet: InPacket<'a> }
pub struct InAudio<'a> { packet: InPacket<'a>, data: AudioData<'a> }
// 输出包
pub struct OutPacket { dir: Direction, data: Vec<u8> }
pub struct OutCommand(pub OutPacket);
```
**命令解析器**:
```rust
pub struct CommandParser<'a> { data: &'a [u8], index: usize }
pub enum CommandItem<'a> { Argument(CommandArgument<'a>), NextCommand }
```
#### 2.1.4 ts-bookkeeping (状态管理)
**职责**: 维护 TeamSpeak 服务器的完整状态模型
**核心数据模型**:
```rust
pub struct Connection {
pub own_client: ClientId,
pub server: Server,
pub clients: HashMap<ClientId, Client>,
pub channels: HashMap<ChannelId, Channel>,
pub channel_groups: HashMap<ChannelGroupId, ChannelGroup>,
pub server_groups: HashMap<ServerGroupId, ServerGroup>,
}
pub struct Server { /* 名称、版本、最大客户端数、加密模式等 */ }
pub struct Channel { /* 名称、类型、编解码器、权限等 */ }
pub struct Client { /* 名称、频道、静音状态、权限等 */ }
```
**事件系统**:
```rust
pub enum Event {
PropertyAdded { id: PropertyId, invoker: Option<Invoker>, extra: ExtraInfo },
PropertyChanged { id: PropertyId, old: PropertyValue, invoker: Option<Invoker>, extra: ExtraInfo },
PropertyRemoved { id: PropertyId, old: PropertyValue, invoker: Option<Invoker>, extra: ExtraInfo },
Message { target: MessageTarget, invoker: Invoker, message: String },
}
```
#### 2.1.5 tsproto (协议引擎)
**职责**: 实现 TeamSpeak 3 协议的底层网络通信
**核心类型**:
```rust
pub struct Identity {
key: EccKeyPrivP256,
counter: u64, // Hash Cash 计数器
max_counter: u64,
}
pub struct Client {
con: Connection,
pub private_key: EccKeyPrivP256,
}
pub struct Connection {
pub is_client: bool,
pub params: Option<ConnectedParams>,
pub address: SocketAddr,
pub resender: Resender,
pub codec: PacketCodec,
pub udp_socket: Box<dyn Socket + Send>,
}
```
**连接握手流程**:
```
Client Server
│ │
│──── Init0 (version, ts) ────>│
│<─── Init1 (random1) ─────────│
│──── Init2 (random1_r) ──────>│
│<─── Init3 (RSA puzzle) ──────│
│──── Init4 (solve + ECDH) ───>│
│<─── initivexpand2 (license) ─│
│──── clientek (ephemeral key) >│
│<─── initserver ──────────────│
│ (connected) │
```
#### 2.1.6 tsclientlib (高层客户端库)
**职责**: 提供用户友好的客户端 API
**核心类型**:
```rust
pub struct Connection {
state: ConnectionState,
options: ConnectOptions,
stream_items: VecDeque<Result<StreamItem>>,
}
enum ConnectionState {
Connecting(BoxFuture<...>, bool),
IdentityLevelIncreasing { recv, state },
Connected { con: ConnectedConnection, book: data::Connection },
}
pub enum StreamItem {
BookEvents(Vec<events::Event>),
MessageEvent(InMessage),
Audio(InAudioBuf),
IdentityLevelIncreasing(u8),
IdentityLevelIncreased,
DisconnectedTemporarily(TemporaryDisconnectReason),
MessageResult(MessageHandle, Result<(), CommandError>),
FileDownload(...), FileUpload(...), FiletransferFailed(...),
NetworkStatsUpdated,
AudioChange(AudioEvent),
}
```
**地址解析**:
解析优先级:
1. 直接 IP 地址
2. 服务器昵称 (HTTP 查询)
3. DNS SRV 记录
4. TSDNS 服务
5. 系统 DNS 解析
**音频处理**:
```rust
pub struct AudioHandler<Id> {
queues: HashMap<Id, AudioQueue>,
avg_buffer_samples: usize,
}
pub struct AudioQueue {
decoder: Decoder, // Opus 解码器
packet_buffer: VecDeque<QueuePacket>,
decoded_buffer: Vec<f32>,
last_buffer_size_min: SlidingWindowMinimum<u8>,
}
```
### 2.2 客户端组件 (Qint)
#### 2.2.1 前端组件
**连接状态管理 (connection.ts)**:
```typescript
class Connection {
private book: Book;
private backend: IBackendConnection;
private state: ConnectionState;
// 状态机: Uninitialized -> Connecting -> Connected -> ChannelListFinished -> Disconnected
async connect(onMsg, onError, onClose): Promise<void>;
sendMessage(target, message): void;
switchChannel(channelId): void;
startWhispering(whisperData): void;
}
```
**数据状态镜像 (book.ts)**:
```typescript
class Book {
channels: Map<ChannelId, Channel>;
clients: Map<ClientId, Client>;
serverGroups: Map<ServerGroupId, ServerGroup>;
channelGroups: Map<ChannelGroupId, ChannelGroup>;
processEvent(event: InBookChangeMsg): void;
}
```
**后端抽象层 (backend/)**:
```typescript
interface IBackend {
createNewConnection(returnCodes: ReturnCodeTracker): IBackendConnection;
graphql<T>(query: string, variables?: Record<string, unknown>): Promise<{data: T}>;
get_settings(): Promise<Record<string, unknown>>;
set_settings(diff: Record<string, unknown>): Promise<void>;
}
interface IBackendConnection {
id: string;
connect(onMsg, onError, onClose): Promise<void>;
send(data: OutMsg): void;
close(): void;
fetch_image(req: IFileRequest): Promise<string | undefined>;
upload_bytes(req: IFileRequest, data: Blob): Promise<TransferResult>;
}
```
#### 2.2.2 壳层组件
**Tauri 命令处理器 (cmd.rs)**:
```rust
#[command]
async fn create_ws(state: State<'_, QintCore>, window: Window, con: String) -> Result<(), String>
#[command]
async fn pass_ws_msg(state: State<'_, QintCore>, con: String, msg: MessageF2P) -> Result<(), String>
#[command]
async fn db(state: State<'_, QintState>, query: String, variables: String) -> Result<String, String>
```
**WebSocket 处理器 (websocket.rs)**:
```rust
struct Ws {
state: QintState,
id: String,
addr: Addr<QintConnection>,
}
impl Ws {
fn handle_message(&mut self, msg: F2PMsg) {
match msg.cmd {
"create_ws" => { /* 创建连接 */ }
"pass_ws_msg" => { /* 转发消息 */ }
"get_settings" => { /* 获取设置 */ }
_ => { /* 其他命令 */ }
}
}
}
```
#### 2.2.3 代理层组件
**全局状态管理 (QintState)**:
```rust
pub struct QintState {
pub connections: Mutex<HashMap<String, Addr<QintConnection>>>,
pub audio_data: Arc<AudioData>,
pub hotkeys: HotkeyManager,
pub settings: RwLock<Settings>,
pub database: Addr<DbHandler>,
pub graphql_schema: Schema<QueryRoot, MutationRoot, EmptySubscription>,
pub file_cache: FileCache,
pub link_previewer: LinkPreviewer,
pub secret: chacha20poly1305::Key,
pub search_index: SearchIndex,
}
```
**连接管理器 (QintConnection)**:
```rust
pub struct QintConnection {
state: QintState,
id: String,
con: Option<tsclientlib::Connection>,
bridge: Box<dyn AppToFrontendBridge>,
audio_to_ts: Addr<AudioToTs>,
ts_to_audio: Addr<TsToAudio>,
database: Addr<DbHandler>,
}
impl Actor for QintConnection {
type Context = Context<Self>;
}
impl Handler<MessageF2PWrapper> for QintConnection {
fn handle(&mut self, msg: MessageF2PWrapper, ctx: &mut Self::Context) {
match msg.0 {
MessageF2P::Connect(options) => { /* 建立连接 */ }
MessageF2P::Disconnect(options) => { /* 断开连接 */ }
MessageF2P::SendMessage { target, message, return_code } => { /* 发送消息 */ }
// ...
}
}
}
```
**数据库管理器 (DbHandler)**:
```rust
pub struct DbHandler {
pool: SqlitePool,
}
impl Actor for DbHandler {
type Context = Context<Self>;
}
impl Handler<GetIdentityAndServerMsg> for DbHandler {
fn handle(&mut self, msg: GetIdentityAndServerMsg, ctx: &mut Self::Context) -> Self::Result {
// 从数据库获取身份和服务器信息
}
}
impl Handler<WriteMessageMsg> for DbHandler {
fn handle(&mut self, msg: WriteMessageMsg, ctx: &mut Self::Context) {
// 写入聊天消息到数据库
}
}
```
**音频管道**:
```rust
// AudioToTs - 麦克风采集和编码
pub struct AudioToTs {
connections: Vec<Addr<QintConnection>>,
encoder: OpusEncoder,
vad: VadDetector,
loudness_meter: LoudnessMeter,
}
impl Handler<SendPacketMsg> for AudioToTs {
fn handle(&mut self, msg: SendPacketMsg, ctx: &mut Self::Context) {
// 编码并发送音频数据
}
}
// TsToAudio - 音频解码和播放
pub struct TsToAudio {
queues: HashMap<String, AudioQueue>,
output_device: AudioDevice,
}
impl Handler<PlayMsg> for TsToAudio {
fn handle(&mut self, msg: PlayMsg, ctx: &mut Self::Context) {
// 解码并播放音频
}
}
```
### 2.3 机器人组件 (SimpleBot)
**核心结构**:
```rust
pub struct Bot {
base_dir: PathBuf,
settings_path: PathBuf,
actions: ActionList,
settings: Settings,
rate_limiting: Vec<Instant>,
list: Vec<String>,
should_reload: Cell<bool>,
}
```
**动作系统**:
```rust
pub struct ActionDefinition {
contains: Option<String>,
regex: Option<String>,
chat: Option<String>,
response: Option<String>,
command: Option<String>,
shell: Option<String>,
}
pub struct Action {
matchers: Vec<Matcher>,
reaction: Option<Reaction>,
}
pub enum Matcher {
Regex(Regex),
Mode(Option<TextMessageTargetMode>),
}
pub enum Reaction {
Plain(String),
Command(String),
Shell(String),
Function(ReactionFunction),
}
```
**配置系统**:
```rust
pub struct Settings {
key_file: String,
dynamic_actions: String,
address: String,
channel: Option<ChannelDefinition>,
name: String,
disconnect_message: String,
rate_limit: u8,
prefix: String,
actions: ActionFile,
}
pub struct ActionFile {
include: Vec<String>,
on_message: Vec<ActionDefinition>,
}
```
### 2.4 统计工具组件 (ts3stats)
**核心类**:
```python
class DiagramCreator:
env: jinja2.Environment
diagramTemplate: jinja2.Template
htmlTemplate: jinja2.Template
users: dict[int, User]
vip: list[User]
tabs: list[Tab]
def load_meta(self): pass
def load_data(self): pass
def create_diagrams(self): pass
def fun_per_connected_slot(self, users, callback): pass
class User:
name: str
lastConnected: list[datetime]
connections: list[Connection]
botPlays: list[tuple]
botCommands: list[tuple]
class Connection:
start: datetime
end: datetime
timeout: bool
class Diagram:
filename: str
title: str
plots: list[str]
def render(self): pass
class Tab:
name: str
diagrams: list[Diagram]
```
**插件架构**:
每个 `diags/*.py` 文件暴露一个函数:
```python
def create_diag(dc: DiagramCreator) -> None
```
**配置系统**:
```python
# Settings.py
vips = ["MyName", "friend42"]
merges = [["MyName", "MyNameLaptop"]]
maxUsers = 50
botStats = True
inputFolder = "Logs"
outputFolder = "Result"
```
## 3. 接口设计
### 3.1 Tauri IPC 接口
| 命令 | 参数 | 返回值 | 说明 |
|------|------|--------|------|
| `create_ws` | `con: String` | `()` | 创建 WebSocket 连接 |
| `close_ws` | `con: String` | `()` | 关闭 WebSocket 连接 |
| `pass_ws_msg` | `con: String, msg: MessageF2P` | `()` | 转发前端消息 |
| `db` | `query: String, variables: String` | `String` | GraphQL 查询 |
| `get_settings` | | `Record<string, unknown>` | 获取设置 |
| `set_settings` | `diff: Record<string, unknown>` | `()` | 更新设置 |
### 3.2 WebSocket 协议
**前端发送**:
```json
{"cmd": "create_ws", "returnCode": "0", "args": {"con": "uuid"}}
{"cmd": "pass_ws_msg", "returnCode": "1", "args": {"con": "uuid", "msg": {"Connect": {...}}}}
{"cmd": "get_settings", "returnCode": "2", "args": {}}
```
**后端发送**:
```json
{"cmd": "resp", "returnCode": "2", "msg": {...}}
{"cmd": "resp_err", "returnCode": "1", "msg": "error"}
{"cmd": "ws", "con": "uuid", "msg": {"Connected": {...}}}
{"cmd": "ws_close", "con": "uuid"}
{"cmd": "loudness", "msg": [0.5, 0.3]}
```
### 3.3 GraphQL 查询
```graphql
type Query {
bookmarks: [Bookmark!]!
servers: [Server!]!
channels(serverId: ID!): [Channel!]!
clients(serverId: ID!): [Client!]!
identities: [Identity!]!
chats(serverId: ID!): [Chat!]!
messages(chatId: ID!, limit: Int): [Message!]!
}
type Mutation {
updateIdentity(id: ID!, input: IdentityInput!): Identity!
updateBookmark(id: ID!, input: BookmarkInput!): Bookmark!
}
```
## 4. 数据流
### 4.1 连接建立流程
```
前端 代理 TeamSpeak 服务器
│ │ │
│── Connect(addr, name) ───>│ │
│ │── GetIdentityAndServerMsg ────> DbHandler
│ │<── (identity, server) ──────────│
│ │── Connection::build().connect ─>│
│ │ │
│ │<── TsStreamItem::BookEvents ────│
│ │ (PropertyAdded: Server) │
│ │ │
│<── Connected {server, own} │ │
│ │── ConnectedMsg ────────────────> DbHandler
│ │ │
│<── Events [PropertyAdded..]│ │
```
### 4.2 消息发送流程
```
前端 代理 TeamSpeak 服务器
│ │ │
│── SendMessage {target, msg}│ │
│ │── state.send_message(target) ──>│
│ │── WriteMessageMsg ─────────────> DbHandler
│ │ │
│ │<── InMessage (from other) ──────│
│ │── JsInMessage ─────────────────>│
│<── Message(JsInMessage) ──│ │
│ │── WriteMessageMsg ─────────────> DbHandler
```
### 4.3 音频流程
```
麦克风 → AudioToTs (Actor)
├── VAD 检测
├── 响度测量
├── Opus 编码
└── 发送到服务器
服务器 → tsclientlib::Connection
├── AudioData::S2C
└── TsToAudio (Actor)
├── Opus 解码
├── 每客户端音量
├── 混音
├── 噪声抑制
└── SDL2/Oboe 输出
```
## 5. 错误处理
### 5.1 协议错误
```rust
pub enum CommandError {
TsError(Ts3ErrorCode),
ConnectionClosed,
Timeout,
InvalidResponse,
// ...
}
```
### 5.2 连接错误
```rust
pub enum TemporaryDisconnectReason {
Timeout,
ServerShutdown,
ConnectionLost,
// ...
}
```
### 5.3 前端错误
```typescript
interface ErrorMessage {
type: "error";
message: string;
code?: string;
}
```
## 6. 配置管理
### 6.1 应用配置
```rust
pub struct Settings {
pub name: String,
pub away: Option<String>,
pub input_muted: bool,
pub output_muted: bool,
pub hotkeys: Vec<HotkeyAction>,
pub client_volumes: HashMap<String, f32>,
pub theme: String,
pub language: String,
}
```
### 6.2 连接配置
```rust
pub struct ConnectOptions {
address: ServerAddress,
local_address: Option<SocketAddr>,
identity: Option<Identity>,
server: Option<UidBuf>,
name: String,
version: Version,
channel: Option<String>,
channel_password: Option<String>,
server_password: Option<String>,
default_token: Option<String>,
}
```
## 7. 性能考虑
### 7.1 音频处理
- Opus 编码/解码使用硬件加速(如果可用)
- 自适应抖动缓冲减少延迟
- 噪声抑制减少带宽使用
### 7.2 状态同步
- 增量更新减少数据传输
- 事件批处理减少 IPC 调用
- 懒加载减少初始加载时间
### 7.3 数据库
- SQLite WAL 模式支持并发读取
- 连接池减少连接开销
- 索引优化查询性能
## 8. 安全设计
### 8.1 身份加密
```rust
// ChaCha20-Poly1305 加密身份私钥
fn encrypt_identity(key: &[u8; 32], identity: &[u8]) -> Vec<u8> {
let cipher = ChaCha20Poly1305::new(key.into());
let nonce = generate_nonce();
cipher.encrypt(&nonce, identity)
}
```
### 8.2 传输加密
- 所有命令和语音数据使用 AES-128-EAX 加密
- ECDH 密钥交换确保前向保密
- RSA 拼图防止 DoS 攻击
### 8.3 输入验证
- 所有用户输入进行验证和转义
- SQL 查询使用参数化语句
- WebSocket 消息进行 JSON 验证
- `tsclientlib`
- `tsproto-packets`
- `iced`
- `cpal`
- `audiopus`
- `nnnoiseless`
- `sonora`
+6 -570
View File
@@ -1,573 +1,9 @@
# 需求分析文档 (SRS)
# Requirements Notes
## 1. 概述
The authoritative requirements are now in `SRS.md`.
本文档定义 TeamSpeak 3 客户端系统的功能需求和非功能需求,基于对参考代码库的分析和 TeamSpeak 3 协议规范。
Important current constraints:
## 2. 系统目标
### 2.1 主要目标
1. 实现一个功能完整的 TeamSpeak 3 客户端
2. 支持跨平台运行 (Windows, Linux, macOS, Android, Web)
3. 提供现代化的用户界面
4. 保持与官方 TeamSpeak 服务器的兼容性
5. 支持音频通信和文本聊天
### 2.2 次要目标
1. 支持插件扩展机制
2. 提供统计和分析功能
3. 支持多服务器同时连接
4. 提供机器人开发框架
## 3. 功能需求
### 3.1 连接管理
#### FR-3.1.1 服务器连接
**描述**: 用户能够连接到 TeamSpeak 3 服务器
**需求**:
- 支持通过 IP 地址连接
- 支持通过域名连接
- 支持通过服务器昵称连接
- 支持 DNS SRV 和 TSDNS 解析
- 支持服务器密码验证
- 支持身份选择和管理
- 支持默认频道设置
- 支持频道密码
**输入**:
- 服务器地址 (IP/域名/昵称)
- 服务器密码 (可选)
- 身份 (可选)
- 昵称
- 默认频道 (可选)
- 频道密码 (可选)
**输出**:
- 连接成功/失败状态
- 服务器信息
- 客户端 ID
#### FR-3.1.2 连接状态管理
**描述**: 管理连接的生命周期状态
**状态**:
- Uninitialized: 未初始化
- Connecting: 连接中
- IdentityLevelIncreasing: 身份等级提升中
- Connected: 已连接
- ChannelListFinished: 频道列表接收完成
- DisconnectedTemporarily: 临时断开
- Disconnected: 已断开
- Error: 错误
**需求**:
- 状态转换应有明确的触发条件
- 状态变化应通知前端
- 支持自动重连(临时断开时)
- 支持手动断开连接
#### FR-3.1.3 多服务器连接
**描述**: 支持同时连接多个 TeamSpeak 服务器
**需求**:
- 每个连接独立管理
- 连接间互不影响
- 支持连接切换
- 支持连接列表管理
### 3.2 频道管理
#### FR-3.2.1 频道浏览
**描述**: 浏览服务器的频道结构
**需求**:
- 显示频道树状结构
- 显示频道名称、主题、编解码器
- 显示频道最大客户端数
- 显示频道类型(永久/半永久/临时)
- 显示频道密码状态
- 支持频道搜索
#### FR-3.2.2 频道操作
**描述**: 执行频道相关操作
**需求**:
- 加入频道
- 离开频道
- 创建频道(需要权限)
- 编辑频道(需要权限)
- 删除频道(需要权限)
- 移动频道(需要权限)
- 设置频道密码
- 设置频道排序
#### FR-3.2.3 频道订阅
**描述**: 订阅频道以接收通知
**需求**:
- 订阅频道
- 取消订阅频道
- 接收频道事件通知
- 显示订阅状态
### 3.3 客户端管理
#### FR-3.3.1 客户端列表
**描述**: 显示在线客户端列表
**需求**:
- 显示客户端昵称
- 显示客户端状态(离开/录音/静音等)
- 显示客户端所在频道
- 显示客户端服务器组
- 显示客户端频道组
- 支持客户端搜索
#### FR-3.3.2 客户端操作
**描述**: 执行客户端相关操作
**需求**:
- 发送私聊消息
- 发送戳一戳
- 移动客户端(需要权限)
- 踢出客户端(需要权限)
- 封禁客户端(需要权限)
- 设置客户端音量
- 设置客户端静音
#### FR-3.3.3 客户端信息
**描述**: 查看客户端详细信息
**需求**:
- 显示客户端唯一标识符
- 显示客户端数据库 ID
- 显示客户端版本信息
- 显示客户端平台
- 显示客户端连接信息
- 显示客户端权限
### 3.4 文本聊天
#### FR-3.4.1 消息发送
**描述**: 发送文本消息
**需求**:
- 发送服务器消息
- 发送频道消息
- 发送私聊消息
- 支持 BBCode 格式
- 支持消息历史
- 支持消息撤回(如果服务器支持)
#### FR-3.4.2 消息接收
**描述**: 接收和显示文本消息
**需求**:
- 显示服务器消息
- 显示频道消息
- 显示私聊消息
- 显示系统通知
- 支持消息通知
- 支持消息过滤
#### FR-3.4.3 消息存储
**描述**: 存储聊天历史
**需求**:
- 本地存储消息历史
- 支持消息搜索
- 支持消息导出
- 支持消息清理
### 3.5 音频通信
#### FR-3.5.1 音频输入
**描述**: 捕获和处理麦克风音频
**需求**:
- 支持音频设备选择
- 支持音频编码 (Opus)
- 支持语音活动检测 (VAD)
- 支持推按说话 (PTT)
- 支持输入音量调节
- 支持输入静音
- 支持噪声抑制
- 支持回声消除
#### FR-3.5.2 音频输出
**描述**: 播放接收到的音频
**需求**:
- 支持音频设备选择
- 支持音频解码 (Opus)
- 支持输出音量调节
- 支持输出静音
- 支持每客户端音量
- 支持音频混音
- 支持抖动缓冲
- 支持丢包隐藏
#### FR-3.5.3 音频路由
**描述**: 管理音频数据的路由
**需求**:
- 支持频道音频
- 支持私语
- 支持组私语
- 支持频道指挥官
- 支持优先发言者
- 支持音频编码质量设置
### 3.6 文件传输
#### FR-3.6.1 文件浏览
**描述**: 浏览频道文件
**需求**:
- 显示文件列表
- 显示文件大小
- 显示文件修改时间
- 支持文件搜索
- 支持目录创建
#### FR-3.6.2 文件上传
**描述**: 上传文件到频道
**需求**:
- 支持单文件上传
- 支持多文件上传
- 支持断点续传
- 支持上传进度显示
- 支持上传取消
#### FR-3.6.3 文件下载
**描述**: 下载频道文件
**需求**:
- 支持单文件下载
- 支持多文件下载
- 支持断点续传
- 支持下载进度显示
- 支持下载取消
### 3.7 身份管理
#### FR-3.7.1 身份创建
**描述**: 创建新的 TeamSpeak 身份
**需求**:
- 自动生成 ECC 密钥对
- 支持身份命名
- 支持身份导出
- 支持身份导入
#### FR-3.7.2 身份存储
**描述**: 安全存储身份信息
**需求**:
- 加密存储私钥
- 支持多身份管理
- 支持身份备份
- 支持身份恢复
#### FR-3.7.3 身份验证
**描述**: 使用身份进行验证
**需求**:
- 支持 Hashcash 计算
- 支持身份等级提升
- 支持权限令牌
- 支持身份唯一标识符计算
### 3.8 权限管理
#### FR-3.8.1 权限查看
**描述**: 查看权限信息
**需求**:
- 显示服务器组权限
- 显示频道组权限
- 显示客户端权限
- 显示权限值
- 显示权限描述
#### FR-3.8.2 权限操作
**描述**: 执行权限相关操作
**需求**:
- 添加权限(需要权限)
- 删除权限(需要权限)
- 修改权限(需要权限)
- 添加服务器组客户端
- 删除服务器组客户端
### 3.9 服务器管理
#### FR-3.9.1 服务器信息
**描述**: 查看服务器信息
**需求**:
- 显示服务器名称
- 显示服务器版本
- 显示服务器平台
- 显示在线客户端数
- 显示频道数
- 显示服务器运行时间
- 显示服务器加密模式
#### FR-3.9.2 服务器操作
**描述**: 执行服务器相关操作
**需求**:
- 修改服务器密码(需要权限)
- 修改服务器名称(需要权限)
- 查看服务器日志(需要权限)
- 发送服务器消息(需要权限)
### 3.10 热键管理
#### FR-3.10.1 热键配置
**描述**: 配置全局热键
**需求**:
- 支持输入静音切换
- 支持输出静音切换
- 支持离开状态切换
- 支持推按说话
- 支持自定义热键
#### FR-3.10.2 热键执行
**描述**: 执行热键绑定的操作
**需求**:
- 全局热键支持
- 应用内热键支持
- 热键冲突检测
- 热键状态反馈
### 3.11 设置管理
#### FR-3.11.1 应用设置
**描述**: 管理应用程序设置
**需求**:
- 用户界面设置
- 音频设备设置
- 热键设置
- 通知设置
- 语言设置
- 主题设置
#### FR-3.11.2 连接设置
**描述**: 管理连接相关设置
**需求**:
- 默认昵称
- 默认身份
- 默认频道
- 自动重连设置
- 音频编码设置
### 3.12 插件系统
#### FR-3.12.1 插件加载
**描述**: 加载和管理插件
**需求**:
- 支持动态加载插件
- 支持插件启用/禁用
- 支持插件配置
- 支持插件更新
#### FR-3.12.2 插件接口
**描述**: 提供插件开发接口
**需求**:
- 事件监听接口
- 命令注册接口
- UI 扩展接口
- 数据访问接口
## 4. 非功能需求
### 4.1 性能需求
#### NFR-4.1.1 连接性能
- 连接建立时间 < 3 秒
- 消息延迟 < 100ms
- 音频延迟 < 200ms
- 支持 1000+ 客户端的服务器
#### NFR-4.1.2 资源使用
- CPU 使用率 < 10% (空闲时)
- 内存使用 < 200MB
- 网络带宽 < 1Mbps (语音通信时)
- 磁盘空间 < 100MB (应用程序)
#### NFR-4.1.3 并发性能
- 支持 10+ 同时连接
- 支持 100+ 消息/秒
- 支持 50+ 音频流同时播放
### 4.2 可靠性需求
#### NFR-4.2.1 连接可靠性
- 支持自动重连
- 支持断点续传
- 支持数据包重传
- 支持拥塞控制
#### NFR-4.2.2 数据可靠性
- 消息不丢失
- 文件传输完整性
- 身份数据安全
- 配置数据备份
### 4.3 安全性需求
#### NFR-4.3.1 传输安全
- 使用 AES-128-EAX 加密
- 使用 ECDH 密钥交换
- 支持证书验证
- 防止中间人攻击
#### NFR-4.3.2 数据安全
- 私钥加密存储
- 敏感数据不落盘
- 安全内存处理
- 防止内存泄露
#### NFR-4.3.3 访问控制
- 身份验证
- 权限检查
- 操作审计
- 防止未授权访问
### 4.4 可用性需求
#### NFR-4.4.1 用户界面
- 响应式设计
- 键盘导航支持
- 屏幕阅读器支持
- 高对比度模式
#### NFR-4.4.2 国际化
- 支持多语言
- 支持 RTL 布局
- 支持本地化日期格式
- 支持本地化数字格式
#### NFR-4.4.3 可访问性
- 支持字体大小调整
- 支持颜色主题切换
- 支持快捷键自定义
- 支持语音反馈
### 4.5 可维护性需求
#### NFR-4.5.1 代码质量
- 模块化设计
- 清晰的接口定义
- 完整的文档
- 单元测试覆盖
#### NFR-4.5.2 日志和监控
- 详细的日志记录
- 错误报告机制
- 性能监控
- 使用统计
### 4.6 可扩展性需求
#### NFR-4.6.1 架构扩展
- 支持插件系统
- 支持自定义主题
- 支持自定义命令
- 支持自定义通知
#### NFR-4.6.2 协议扩展
- 支持协议版本协商
- 支持功能特性检测
- 支持向后兼容
- 支持向前兼容
## 5. 约束条件
### 5.1 技术约束
- 使用 Rust 作为主要开发语言
- 使用 Tauri v2 作为桌面框架
- 使用 Svelte 5 作为前端框架
- 使用 Actix 作为 Actor 框架
- 使用 SQLite 作为本地数据库
### 5.2 协议约束
- 兼容 TeamSpeak 3 协议
- 支持 IPv4 和 IPv6
- 使用 UDP 传输
- 最大数据包大小 500 字节
### 5.3 法律约束
- 不得侵犯 TeamSpeak 商标
- 不得用于商业用途(除非获得许可)
- 遵守相关法律法规
- 尊重用户隐私
## 6. 验收标准
### 6.1 功能验收
- 能够连接到官方 TeamSpeak 服务器
- 能够发送和接收文本消息
- 能够进行语音通信
- 能够浏览和管理频道
- 能够管理客户端
### 6.2 性能验收
- 连接建立时间 < 3 秒
- 消息延迟 < 100ms
- 音频延迟 < 200ms
- CPU 使用率 < 10% (空闲时)
### 6.3 安全验收
- 通过安全审计
- 无已知漏洞
- 数据加密存储
- 传输加密验证
### 6.4 兼容性验收
- Windows 10/11 兼容
- Linux (Ubuntu 20.04+) 兼容
- macOS 11+ 兼容
- Android 10+ 兼容
- Chrome/Firefox/Safari 兼容
## 7. 术语表
| 术语 | 定义 |
|------|------|
| TeamSpeak | 一种 VoIP 应用程序,用于语音通信 |
| Channel | 语音/文本通信的房间 |
| Client | 连接到服务器的用户或机器人 |
| Server | TeamSpeak 服务器实例 |
| Identity | 用户的唯一标识符 |
| Permission | 用户的操作权限 |
| Codec | 音频编码格式 |
| Opus | 现代音频编解码器 |
| ECDH | 椭圆曲线 Diffie-Hellman 密钥交换 |
| AES | 高级加密标准 |
| EAX | 认证加密模式 |
| Hashcash | 工作量证明系统 |
| VAD | 语音活动检测 |
| PTT | 推按说话 |
| TSDNS | TeamSpeak DNS 服务 |
| SRV | DNS 服务记录 |
## 8. 参考文献
1. TeamSpeak 3 协议规范 (ts3protocol.md)
2. tsdeclarations 项目文档
3. tsclientlib 项目文档
4. Qint 项目文档
5. SimpleBot 项目文档
6. ts3stats 项目文档
- desktop-only target for now
- `tsclientlib` is the required TeamSpeak integration layer
- Podman should be used when host system libraries are missing for audio builds
+1 -38
View File
@@ -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" }
+5 -4
View File
@@ -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"]
+45
View File
@@ -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
}
+396 -67
View File
@@ -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,
}
+138
View File
@@ -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;
}
}
}
-13
View File
@@ -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 }
-112
View File
@@ -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()
}
}
-62
View File
@@ -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()
}
}
-246
View File
@@ -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 },
}
-9
View File
@@ -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::*;
-464
View File
@@ -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>,
}
-13
View File
@@ -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>
-25
View File
@@ -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"
}
}
-449
View File
@@ -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;
-10
View File
@@ -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>,
);
-351
View File
@@ -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;
}
}
-21
View File
@@ -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" }]
}
-11
View File
@@ -1,11 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
-17
View File
@@ -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,
},
});
-37
View File
@@ -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"]
-3
View File
@@ -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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

-383
View File
@@ -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())
}
-62
View File
@@ -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("运行应用时出错");
}
-5
View File
@@ -1,5 +0,0 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
re_teamspeak_lib::run();
}
-29
View File
@@ -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()
}
}
-33
View File
@@ -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"
}
}
-24
View File
@@ -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"]
-194
View File
@@ -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);
}
}
-34
View File
@@ -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())
}
}
-164
View File
@@ -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);
}
}
-118
View File
@@ -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());
}
}
-100
View File
@@ -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>,
}
-175
View File
@@ -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)
}
}
-41
View File
@@ -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;
}
}
-35
View File
@@ -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 }
-941
View File
@@ -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"));
}
}
-11
View File
@@ -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::*;
-257
View File
@@ -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());
}
}
-447
View File
@@ -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(())
}
}
-114
View File
@@ -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()
}
}
-118
View File
@@ -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)
}
-151
View File
@@ -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);
}
}
-40
View File
@@ -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)
}
-137
View File
@@ -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();
}
}
-228
View File
@@ -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)
}
-13
View File
@@ -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::*;
-162
View File
@@ -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);
}
}
-76
View File
@@ -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())
}
}
-7
View File
@@ -1,7 +0,0 @@
//! Network module
pub mod resolver;
pub mod socket;
pub use resolver::*;
pub use socket::*;
-38
View File
@@ -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
}
-433
View File
@@ -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();
}
}
-270
View File
@@ -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
}
}
-12
View File
@@ -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::*;
-790
View File
@@ -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(&timestamp.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,
}
}
}
-260
View File
@@ -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());
}
}
-233
View File
@@ -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,
}
}
}
-309
View File
@@ -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());
}
}
-733
View File
@@ -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();
}
}
}
}
-28
View File
@@ -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 }
-171
View File
@@ -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(())
}
}
-48
View File
@@ -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)
}
}
-116
View File
@@ -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(())
}
}
-101
View File
@@ -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
}
}
-133
View File
@@ -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(())
}
}