diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d90cdaf..191c304 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index ddb8165..ed8780a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/build.bat b/build.bat deleted file mode 100644 index 4ba6018..0000000 --- a/build.bat +++ /dev/null @@ -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 diff --git a/build.sh b/build.sh deleted file mode 100644 index 24b2df5..0000000 --- a/build.sh +++ /dev/null @@ -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 "$@" diff --git a/docs/README.md b/docs/README.md index 12899f8..a8008b5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 的商业模式。 \ No newline at end of file +- `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 diff --git a/docs/SAD.md b/docs/SAD.md index 1f004b8..91c087a 100644 --- a/docs/SAD.md +++ b/docs/SAD.md @@ -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, // 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, -} -``` - -##### `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, ProtocolError>; - pub fn handle_data(&mut self, data: &[u8]) -> Result>, ProtocolError>; - fn build_init2(&mut self) -> Result, ProtocolError>; - fn build_init4(&mut self) -> Result, ProtocolError>; - fn handle_initivexpand(&mut self, cmd: &Command) -> Result, ProtocolError>; - fn handle_initivexpand2(&mut self, cmd: &Command) -> Result, 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, // 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; - 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, String>; -#[tauri::command] async fn create_identity(state, name) -> Result; -#[tauri::command] async fn get_bookmarks(state) -> Result, String>; -#[tauri::command] async fn create_bookmark(state, name, address, port, nickname) -> Result; -#[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, 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 diff --git a/docs/SDD.md b/docs/SDD.md index e82e50a..14bd683 100644 --- a/docs/SDD.md +++ b/docs/SDD.md @@ -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 │ -│ 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 │ -│ 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::() - / 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> (ring buffer) │ -│ head: usize │ -│ tail: usize │ -│ size: usize │ -│ capacity: usize │ -├─────────────────────────────────────────────────────────┤ -│ push(frame) → Result<()> // Add frame │ -│ pop() → Option // 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; - fn get_identity(&self, id) -> DatabaseResult; - fn get_all_identities(&self) -> DatabaseResult>; - 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; - fn get_bookmark(&self, id) -> DatabaseResult; - fn get_all_bookmarks(&self) -> DatabaseResult>; - 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; - fn get_server_messages(&self, server_address, limit, offset) -> DatabaseResult>; - fn mark_message_read(&self, id) -> DatabaseResult<()>; - - // Settings operations - fn get_setting(&self, key) -> DatabaseResult>; - fn set_setting(&self, key, value) -> DatabaseResult<()>; -} -``` - ---- - -### 1.5 `tauri-app` Design - -#### 1.5.1 Tauri Command Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Frontend (React) │ -│ ┌───────────────────────────────────────────────────────┐ │ -│ │ invoke("get_bookmarks") → Promise │ │ -│ │ invoke("connect", {addr, port, nick, pass}) │ │ -│ │ invoke("send_message", {target, message}) │ │ -│ └───────────────────────────────────────────────────────┘ │ -└───────────────────────────┬─────────────────────────────────┘ - │ Tauri IPC - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ Tauri Shell (Rust) │ -│ ┌───────────────────────────────────────────────────────┐ │ -│ │ #[tauri::command] │ │ -│ │ async fn get_bookmarks(state: State) │ │ -│ │ -> Result, 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, │ │ -│ │ } │ │ -│ └───────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ -``` - -#### 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; - create_identity(name: string): Promise; - delete_identity(id: string): Promise; - - // Bookmark management - get_bookmarks(): Promise; - create_bookmark(name: string, address: string, port: number, - nickname?: string): Promise; - delete_bookmark(id: string): Promise; - - // Connection - connect(address: string, port: number, nickname: string, - password?: string): Promise; - disconnect(): Promise; - - // Messaging - send_message(target: string, message: string): Promise; - get_messages(server_address: string, limit: number, - offset: number): Promise; -} - -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>; - fn decode(&self, data: &[u8]) -> Result; -} - -// Audio layer -pub trait AudioCapture { - async fn start(&mut self) -> AudioResult<()>; - async fn stop(&mut self) -> AudioResult<()>; - async fn capture(&mut self) -> AudioResult; -} - -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; - fn get(&self, id: &str) -> DatabaseResult; - fn list(&self) -> DatabaseResult>; - 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 diff --git a/docs/SRS.md b/docs/SRS.md index 8519085..e10fc87 100644 --- a/docs/SRS.md +++ b/docs/SRS.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 333d9b9..4575031 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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), - Change { change: JsM2B, return_code }, -} - -// 代理 → 前端 -pub enum MessageP2F { - Error(String), - Connected { server, own_client }, - DisconnectedTemporarily(), - TalkersChanged(Vec<(String, bool)>), - Events(Vec), - Message(JsInMessage), - Loudnesses(HashMap), - Result(ResultStruct), -} -``` - -### 6.3 IBackend / IBackendConnection -```typescript -interface IBackend { - createNewConnection(returnCodes: ReturnCodeTracker): IBackendConnection; - graphql(query: string, variables?: Record): Promise<{data: T}>; - get_settings(): Promise>; - set_settings(diff: Record): Promise; -} - -interface IBackendConnection { - id: string; - connect(onMsg, onError, onClose): Promise; - send(data: OutMsg): void; - close(): void; - fetch_image(req: IFileRequest): Promise; - upload_bytes(req: IFileRequest, data: Blob): Promise; -} -``` - -## 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 机制防止身份伪造 \ No newline at end of file +- one desktop client crate +- `iced` UI +- `tsclientlib` protocol/state/audio integration +- optional local audio helpers for capture/playback/noise reduction diff --git a/docs/components.md b/docs/components.md index 54af4ab..c6960f3 100644 --- a/docs/components.md +++ b/docs/components.md @@ -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); // 用户唯一标识 -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 } -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, - pub channels: HashMap, - pub channel_groups: HashMap, - pub server_groups: HashMap, -} - -pub struct Server { /* 名称、版本、最大客户端数、加密模式等 */ } -pub struct Channel { /* 名称、类型、编解码器、权限等 */ } -pub struct Client { /* 名称、频道、静音状态、权限等 */ } -``` - -**事件系统**: -```rust -pub enum Event { - PropertyAdded { id: PropertyId, invoker: Option, extra: ExtraInfo }, - PropertyChanged { id: PropertyId, old: PropertyValue, invoker: Option, extra: ExtraInfo }, - PropertyRemoved { id: PropertyId, old: PropertyValue, invoker: Option, 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, - pub address: SocketAddr, - pub resender: Resender, - pub codec: PacketCodec, - pub udp_socket: Box, -} -``` - -**连接握手流程**: -``` -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>, -} - -enum ConnectionState { - Connecting(BoxFuture<...>, bool), - IdentityLevelIncreasing { recv, state }, - Connected { con: ConnectedConnection, book: data::Connection }, -} - -pub enum StreamItem { - BookEvents(Vec), - 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 { - queues: HashMap, - avg_buffer_samples: usize, -} - -pub struct AudioQueue { - decoder: Decoder, // Opus 解码器 - packet_buffer: VecDeque, - decoded_buffer: Vec, - last_buffer_size_min: SlidingWindowMinimum, -} -``` - -### 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; - sendMessage(target, message): void; - switchChannel(channelId): void; - startWhispering(whisperData): void; -} -``` - -**数据状态镜像 (book.ts)**: -```typescript -class Book { - channels: Map; - clients: Map; - serverGroups: Map; - channelGroups: Map; - - processEvent(event: InBookChangeMsg): void; -} -``` - -**后端抽象层 (backend/)**: -```typescript -interface IBackend { - createNewConnection(returnCodes: ReturnCodeTracker): IBackendConnection; - graphql(query: string, variables?: Record): Promise<{data: T}>; - get_settings(): Promise>; - set_settings(diff: Record): Promise; -} - -interface IBackendConnection { - id: string; - connect(onMsg, onError, onClose): Promise; - send(data: OutMsg): void; - close(): void; - fetch_image(req: IFileRequest): Promise; - upload_bytes(req: IFileRequest, data: Blob): Promise; -} -``` - -#### 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 -``` - -**WebSocket 处理器 (websocket.rs)**: -```rust -struct Ws { - state: QintState, - id: String, - addr: Addr, -} - -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>>, - pub audio_data: Arc, - pub hotkeys: HotkeyManager, - pub settings: RwLock, - pub database: Addr, - pub graphql_schema: Schema, - 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, - bridge: Box, - audio_to_ts: Addr, - ts_to_audio: Addr, - database: Addr, -} - -impl Actor for QintConnection { - type Context = Context; -} - -impl Handler 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; -} - -impl Handler for DbHandler { - fn handle(&mut self, msg: GetIdentityAndServerMsg, ctx: &mut Self::Context) -> Self::Result { - // 从数据库获取身份和服务器信息 - } -} - -impl Handler for DbHandler { - fn handle(&mut self, msg: WriteMessageMsg, ctx: &mut Self::Context) { - // 写入聊天消息到数据库 - } -} -``` - -**音频管道**: -```rust -// AudioToTs - 麦克风采集和编码 -pub struct AudioToTs { - connections: Vec>, - encoder: OpusEncoder, - vad: VadDetector, - loudness_meter: LoudnessMeter, -} - -impl Handler for AudioToTs { - fn handle(&mut self, msg: SendPacketMsg, ctx: &mut Self::Context) { - // 编码并发送音频数据 - } -} - -// TsToAudio - 音频解码和播放 -pub struct TsToAudio { - queues: HashMap, - output_device: AudioDevice, -} - -impl Handler 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, - list: Vec, - should_reload: Cell, -} -``` - -**动作系统**: -```rust -pub struct ActionDefinition { - contains: Option, - regex: Option, - chat: Option, - response: Option, - command: Option, - shell: Option, -} - -pub struct Action { - matchers: Vec, - reaction: Option, -} - -pub enum Matcher { - Regex(Regex), - Mode(Option), -} - -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, - name: String, - disconnect_message: String, - rate_limit: u8, - prefix: String, - actions: ActionFile, -} - -pub struct ActionFile { - include: Vec, - on_message: Vec, -} -``` - -### 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` | 获取设置 | -| `set_settings` | `diff: Record` | `()` | 更新设置 | - -### 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, - pub input_muted: bool, - pub output_muted: bool, - pub hotkeys: Vec, - pub client_volumes: HashMap, - pub theme: String, - pub language: String, -} -``` - -### 6.2 连接配置 - -```rust -pub struct ConnectOptions { - address: ServerAddress, - local_address: Option, - identity: Option, - server: Option, - name: String, - version: Version, - channel: Option, - channel_password: Option, - server_password: Option, - default_token: Option, -} -``` - -## 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 { - 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 验证 \ No newline at end of file +- `tsclientlib` +- `tsproto-packets` +- `iced` +- `cpal` +- `audiopus` +- `nnnoiseless` +- `sonora` diff --git a/docs/requirements.md b/docs/requirements.md index b19f008..c9ae1ac 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -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 项目文档 \ No newline at end of file +- 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 diff --git a/src/Cargo.toml b/src/Cargo.toml index 58b9f0f..b977e37 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -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" } diff --git a/src/iced-app/Cargo.toml b/src/iced-app/Cargo.toml index 9edbf5b..61df0f0 100644 --- a/src/iced-app/Cargo.toml +++ b/src/iced-app/Cargo.toml @@ -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"] diff --git a/src/iced-app/src/identity.rs b/src/iced-app/src/identity.rs new file mode 100644 index 0000000..31da42f --- /dev/null +++ b/src/iced-app/src/identity.rs @@ -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 { + 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 { + Err("rusqlite feature not enabled".to_string()) +} + +pub fn find_ts3_config_dir() -> Option { + 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 +} diff --git a/src/iced-app/src/main.rs b/src/iced-app/src/main.rs index 6c55411..a5a0e72 100644 --- a/src/iced-app/src/main.rs +++ b/src/iced-app/src/main.rs @@ -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), + ToggleMicMute, + ToggleSpeakerMute, + ToggleHeadsetMute, + ToggleAfk, + ImportIdentity, + IdentityImported(Result), + #[cfg(feature = "audio")] + SetOutputDevice(String), + #[cfg(feature = "audio")] + SetInputDevice(String), Noop, } @@ -163,7 +183,18 @@ struct App { #[cfg(feature = "audio")] vad: Arc>, #[cfg(feature = "audio")] + noise_reducer: Arc>, + #[cfg(feature = "audio")] + nc_method: noise_cancel::NoiseCancelMethod, + imported_identity: Option, + #[cfg(feature = "audio")] audio_send_tx: Option>>, + 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::(resp.raw) + // ServerQuery not yet implemented with tsclientlib + Err::("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) { 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) { fn start_transmission( mic: Arc>, handle: Arc>>, - _device_name: Option<&str>, + device_name: Option, + noise_reducer: Arc>, ) { 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, } diff --git a/src/iced-app/src/noise_cancel.rs b/src/iced-app/src/noise_cancel.rs new file mode 100644 index 0000000..6806baa --- /dev/null +++ b/src/iced-app/src/noise_cancel.rs @@ -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>, + #[cfg(feature = "sonora")] + sonora: Option, + #[cfg(feature = "nnnoiseless")] + residual_buf: Vec, +} + +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 = 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; + } + } +} \ No newline at end of file diff --git a/src/shared/Cargo.toml b/src/shared/Cargo.toml deleted file mode 100644 index 9e03f37..0000000 --- a/src/shared/Cargo.toml +++ /dev/null @@ -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 } diff --git a/src/shared/src/config.rs b/src/shared/src/config.rs deleted file mode 100644 index 4bbec20..0000000 --- a/src/shared/src/config.rs +++ /dev/null @@ -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, - pub recent_servers: Vec, -} - -/// 保存的连接 -#[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, - pub channel: Option, - pub channel_password: Option, - pub default_token: Option, - pub auto_connect: bool, - pub last_connected: Option>, -} - -/// 最近连接的服务器 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RecentServer { - pub address: String, - pub port: u16, - pub name: String, - pub last_connected: chrono::DateTime, - 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> { - 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> { - 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() - } -} diff --git a/src/shared/src/errors.rs b/src/shared/src/errors.rs deleted file mode 100644 index e8481c3..0000000 --- a/src/shared/src/errors.rs +++ /dev/null @@ -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 = Result; - -impl From for String { - fn from(err: AppError) -> Self { - err.to_string() - } -} diff --git a/src/shared/src/events.rs b/src/shared/src/events.rs deleted file mode 100644 index b5aab38..0000000 --- a/src/shared/src/events.rs +++ /dev/null @@ -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, - }, - 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, - pub input_muted: Option, - pub output_muted: Option, - pub output_only_muted: Option, - pub input_hardware_enabled: Option, - pub output_hardware_enabled: Option, - pub talk_power_granted: Option, - pub metadata: Option, - pub is_recording: Option, - pub away_message: Option, - pub description: Option, - pub is_priority_speaker: Option, - pub phonetic_name: Option, - pub is_channel_commander: Option, - pub badges: Option, -} - -/// 频道事件 -#[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, - pub topic: Option, - pub codec: Option, - pub codec_quality: Option, - pub max_clients: Option, - pub max_family_clients: Option, - pub channel_type: Option, - pub needed_talk_power: Option, - pub phonetic_name: Option, - pub icon_id: Option, -} - -/// 服务器事件 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ServerEvent { - Updated { changes: ServerChanges }, - ServerGroupList { groups: Vec }, - ChannelGroupList { groups: Vec }, -} - -/// 服务器变更 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerChanges { - pub name: Option, - pub welcome_message: Option, - pub host_message: Option, - pub host_message_mode: Option, - pub max_clients: Option, -} - -/// 消息事件 -#[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, - }, - OutputDeviceChanged { - device: Option, - }, - InputVolumeChanged { - volume: f32, - }, - OutputVolumeChanged { - volume: f32, - }, - InputMutedChanged { - muted: bool, - }, - OutputMutedChanged { - muted: bool, - }, - DeviceList { - input_devices: Vec, - output_devices: Vec, - }, - 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 }, -} diff --git a/src/shared/src/lib.rs b/src/shared/src/lib.rs deleted file mode 100644 index 1589667..0000000 --- a/src/shared/src/lib.rs +++ /dev/null @@ -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::*; diff --git a/src/shared/src/types.rs b/src/shared/src/types.rs deleted file mode 100644 index 0c8c08c..0000000 --- a/src/shared/src/types.rs +++ /dev/null @@ -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, - 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, - pub channel: Option, - pub channel_password: Option, - pub default_token: Option, - pub identity: Option, -} - -/// 身份配置 -#[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, - pub output_device: Option, - pub input_volume: f32, - pub output_volume: f32, - pub vad_enabled: bool, - pub vad_threshold: f32, - pub ptt_enabled: bool, - pub ptt_key: Option, - 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, -} - -/// 应用配置 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AppConfig { - pub nickname: String, - pub audio: AudioConfig, - pub hotkeys: Vec, - 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, - 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, - 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, -} diff --git a/src/tauri-app/frontend/index.html b/src/tauri-app/frontend/index.html deleted file mode 100644 index cc1ce67..0000000 --- a/src/tauri-app/frontend/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - ReTeamSpeak - - -
- - - diff --git a/src/tauri-app/frontend/package.json b/src/tauri-app/frontend/package.json deleted file mode 100644 index f35a289..0000000 --- a/src/tauri-app/frontend/package.json +++ /dev/null @@ -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" - } -} diff --git a/src/tauri-app/frontend/src/App.tsx b/src/tauri-app/frontend/src/App.tsx deleted file mode 100644 index 408da71..0000000 --- a/src/tauri-app/frontend/src/App.tsx +++ /dev/null @@ -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([]); - const [bookmarks, setBookmarks] = useState([]); - const [selectedBookmark, setSelectedBookmark] = useState(null); - const [nickname, setNickname] = useState(''); - const [password, setPassword] = useState(''); - const [connected, setConnected] = useState(false); - const [serverInfo, setServerInfo] = useState(null); - const [channels, setChannels] = useState([]); - const [clients, setClients] = useState([]); - const [messages, setMessages] = useState([]); - const [queryPort, setQueryPort] = useState(10011); - const [querySnapshot, setQuerySnapshot] = useState(null); - const [queryLoading, setQueryLoading] = useState(false); - const [queryError, setQueryError] = useState(null); - const pollRef = useRef | 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('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('get_identities'); - setIdentities(result); - } catch (error) { - console.error('Failed to load identities:', error); - } - } - - async function loadBookmarks() { - try { - const result = await invoke('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('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 ( -
-
-

ReTeamSpeak

-
- {connected ? ( - - 已连接 {serverInfo ? `— ${serverInfo.name}` : ''} - - ) : ( - 未连接 - )} -
-
- -
- - -
- {selectedBookmark ? ( -
-
-

连接到 {selectedBookmark.name}

-
- - -
-
- - setNickname(e.target.value)} - placeholder={selectedBookmark.nickname || '请输入昵称'} - /> -
-
- - setPassword(e.target.value)} - placeholder="可选" - /> -
-
- {connected ? ( - - ) : ( - - )} -
-
- - {connected && ( -
-
-
-

服务器

- {serverInfo ? ( - <> -

{serverInfo.name}

-

{serverInfo.platform} / {serverInfo.version}

-

{serverInfo.clients_online} / {serverInfo.max_clients} 在线

- - ) : ( -

等待服务器信息...

- )} -
- -
-

频道 ({channels.length})

-
    - {channels.map((ch) => ( -
  • - {ch.channel_name} - {ch.total_clients} 人 -
  • - ))} -
-
- -
-

客户端 ({clients.length})

-
    - {clients.map((c) => ( -
  • - {c.client_nickname} - #{c.clid} -
  • - ))} -
-
-
- -
-

消息 ({messages.length})

-
    - {messages.map((msg, i) => ( -
  • - {msg.invoker_name}: {msg.message} -
  • - ))} -
-
-
- )} - -
-
-
-

ServerQuery 快照

-

读取公开 ServerQuery 信息,默认端口通常是 10011。

-
-
- setQueryPort(Number(e.target.value))} - aria-label="ServerQuery port" - /> - -
-
- - {queryError &&
{queryError}
} - - {querySnapshot && ( -
-
-

{querySnapshot.server?.name || '服务器'}

-

{querySnapshot.server?.platform || '未知平台'}

-

{querySnapshot.server?.version || '未知版本'}

- - {querySnapshot.server?.clients_online ?? querySnapshot.clients.length}/ - {querySnapshot.server?.max_clients ?? '-'} 在线 - -
- -
-

频道

-
    - {querySnapshot.channels.map((channel) => ( -
  • - {channel.name} - {channel.total_clients} 人 -
  • - ))} -
-
- -
-

客户端

-
    - {querySnapshot.clients.map((client) => ( -
  • - {client.nickname} - #{client.id} -
  • - ))} -
-
-
- )} -
-
- ) : ( -
-

欢迎使用 ReTeamSpeak

-

请从左侧选择一个服务器书签进行连接

-
- )} -
-
-
- ); -} - -export default App; diff --git a/src/tauri-app/frontend/src/main.tsx b/src/tauri-app/frontend/src/main.tsx deleted file mode 100644 index 97b89ca..0000000 --- a/src/tauri-app/frontend/src/main.tsx +++ /dev/null @@ -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( - - - , -); diff --git a/src/tauri-app/frontend/src/styles.css b/src/tauri-app/frontend/src/styles.css deleted file mode 100644 index ff15627..0000000 --- a/src/tauri-app/frontend/src/styles.css +++ /dev/null @@ -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; - } -} diff --git a/src/tauri-app/frontend/tsconfig.json b/src/tauri-app/frontend/tsconfig.json deleted file mode 100644 index 3934b8f..0000000 --- a/src/tauri-app/frontend/tsconfig.json +++ /dev/null @@ -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" }] -} diff --git a/src/tauri-app/frontend/tsconfig.node.json b/src/tauri-app/frontend/tsconfig.node.json deleted file mode 100644 index 97ede7e..0000000 --- a/src/tauri-app/frontend/tsconfig.node.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true, - "strict": true - }, - "include": ["vite.config.ts"] -} diff --git a/src/tauri-app/frontend/vite.config.ts b/src/tauri-app/frontend/vite.config.ts deleted file mode 100644 index 1e0944c..0000000 --- a/src/tauri-app/frontend/vite.config.ts +++ /dev/null @@ -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, - }, -}); diff --git a/src/tauri-app/src-tauri/Cargo.toml b/src/tauri-app/src-tauri/Cargo.toml deleted file mode 100644 index d6a8b10..0000000 --- a/src/tauri-app/src-tauri/Cargo.toml +++ /dev/null @@ -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"] diff --git a/src/tauri-app/src-tauri/build.rs b/src/tauri-app/src-tauri/build.rs deleted file mode 100644 index d860e1e..0000000 --- a/src/tauri-app/src-tauri/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - tauri_build::build() -} diff --git a/src/tauri-app/src-tauri/capabilities/default.json b/src/tauri-app/src-tauri/capabilities/default.json deleted file mode 100644 index b6ec43e..0000000 --- a/src/tauri-app/src-tauri/capabilities/default.json +++ /dev/null @@ -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" - ] -} diff --git a/src/tauri-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src/tauri-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index 6d25185..0000000 --- a/src/tauri-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/src/tauri-app/src-tauri/gen/apple/Info.plist b/src/tauri-app/src-tauri/gen/apple/Info.plist deleted file mode 100644 index 3dad915..0000000 --- a/src/tauri-app/src-tauri/gen/apple/Info.plist +++ /dev/null @@ -1,40 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleDisplayName - ReTeamSpeak - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIRequiredDeviceCapabilities - - armv7 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - NSMicrophoneUsageDescription - ReTeamSpeak needs access to your microphone for voice communication. - - diff --git a/src/tauri-app/src-tauri/icons/128x128.png b/src/tauri-app/src-tauri/icons/128x128.png deleted file mode 100644 index 6be5e50..0000000 Binary files a/src/tauri-app/src-tauri/icons/128x128.png and /dev/null differ diff --git a/src/tauri-app/src-tauri/icons/128x128@2x.png b/src/tauri-app/src-tauri/icons/128x128@2x.png deleted file mode 100644 index e81bece..0000000 Binary files a/src/tauri-app/src-tauri/icons/128x128@2x.png and /dev/null differ diff --git a/src/tauri-app/src-tauri/icons/32x32.png b/src/tauri-app/src-tauri/icons/32x32.png deleted file mode 100644 index a437dd5..0000000 Binary files a/src/tauri-app/src-tauri/icons/32x32.png and /dev/null differ diff --git a/src/tauri-app/src-tauri/icons/Square107x107Logo.png b/src/tauri-app/src-tauri/icons/Square107x107Logo.png deleted file mode 100644 index 0ca4f27..0000000 Binary files a/src/tauri-app/src-tauri/icons/Square107x107Logo.png and /dev/null differ diff --git a/src/tauri-app/src-tauri/icons/Square142x142Logo.png b/src/tauri-app/src-tauri/icons/Square142x142Logo.png deleted file mode 100644 index b81f820..0000000 Binary files a/src/tauri-app/src-tauri/icons/Square142x142Logo.png and /dev/null differ diff --git a/src/tauri-app/src-tauri/icons/Square150x150Logo.png b/src/tauri-app/src-tauri/icons/Square150x150Logo.png deleted file mode 100644 index 624c7bf..0000000 Binary files a/src/tauri-app/src-tauri/icons/Square150x150Logo.png and /dev/null differ diff --git a/src/tauri-app/src-tauri/icons/Square284x284Logo.png b/src/tauri-app/src-tauri/icons/Square284x284Logo.png deleted file mode 100644 index c021d2b..0000000 Binary files a/src/tauri-app/src-tauri/icons/Square284x284Logo.png and /dev/null differ diff --git a/src/tauri-app/src-tauri/icons/Square30x30Logo.png b/src/tauri-app/src-tauri/icons/Square30x30Logo.png deleted file mode 100644 index 6219700..0000000 Binary files a/src/tauri-app/src-tauri/icons/Square30x30Logo.png and /dev/null differ diff --git a/src/tauri-app/src-tauri/icons/Square310x310Logo.png b/src/tauri-app/src-tauri/icons/Square310x310Logo.png deleted file mode 100644 index f9bc048..0000000 Binary files a/src/tauri-app/src-tauri/icons/Square310x310Logo.png and /dev/null differ diff --git a/src/tauri-app/src-tauri/icons/Square44x44Logo.png b/src/tauri-app/src-tauri/icons/Square44x44Logo.png deleted file mode 100644 index d5fbfb2..0000000 Binary files a/src/tauri-app/src-tauri/icons/Square44x44Logo.png and /dev/null differ diff --git a/src/tauri-app/src-tauri/icons/Square71x71Logo.png b/src/tauri-app/src-tauri/icons/Square71x71Logo.png deleted file mode 100644 index 63440d7..0000000 Binary files a/src/tauri-app/src-tauri/icons/Square71x71Logo.png and /dev/null differ diff --git a/src/tauri-app/src-tauri/icons/Square89x89Logo.png b/src/tauri-app/src-tauri/icons/Square89x89Logo.png deleted file mode 100644 index f3f705a..0000000 Binary files a/src/tauri-app/src-tauri/icons/Square89x89Logo.png and /dev/null differ diff --git a/src/tauri-app/src-tauri/icons/StoreLogo.png b/src/tauri-app/src-tauri/icons/StoreLogo.png deleted file mode 100644 index 4556388..0000000 Binary files a/src/tauri-app/src-tauri/icons/StoreLogo.png and /dev/null differ diff --git a/src/tauri-app/src-tauri/icons/icon.icns b/src/tauri-app/src-tauri/icons/icon.icns deleted file mode 100644 index 12a5bce..0000000 Binary files a/src/tauri-app/src-tauri/icons/icon.icns and /dev/null differ diff --git a/src/tauri-app/src-tauri/icons/icon.ico b/src/tauri-app/src-tauri/icons/icon.ico deleted file mode 100644 index 06c23c8..0000000 Binary files a/src/tauri-app/src-tauri/icons/icon.ico and /dev/null differ diff --git a/src/tauri-app/src-tauri/icons/icon.png b/src/tauri-app/src-tauri/icons/icon.png deleted file mode 100644 index e1cd261..0000000 Binary files a/src/tauri-app/src-tauri/icons/icon.png and /dev/null differ diff --git a/src/tauri-app/src-tauri/src/commands.rs b/src/tauri-app/src-tauri/src/commands.rs deleted file mode 100644 index 47d83db..0000000 --- a/src/tauri-app/src-tauri/src/commands.rs +++ /dev/null @@ -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, - pub auto_connect: bool, - pub last_connected: Option, -} - -#[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, - pub password: Option, - pub virtual_server_id: Option, - pub include_permissions: bool, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ServerQuerySnapshot { - pub server: Option, - pub channels: Vec, - pub clients: Vec, - pub permissions: Vec, -} - -#[tauri::command] -pub async fn get_identities(state: State<'_, AppState>) -> Result, 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 { - 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, 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, -) -> Result { - 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, -) -> 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, -) -> 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, 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, 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 { - 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 { - if let Ok(socket_addr) = format!("{}:{}", address, port).parse::() { - return Ok(socket_addr); - } - - lookup_host((address, port)) - .await - .map_err(|e| format!("无法解析服务器地址: {e}"))? - .next() - .ok_or_else(|| "无法解析服务器地址".to_string()) -} diff --git a/src/tauri-app/src-tauri/src/lib.rs b/src/tauri-app/src-tauri/src/lib.rs deleted file mode 100644 index a0a57a4..0000000 --- a/src/tauri-app/src-tauri/src/lib.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! ReTeamSpeak Tauri 应用 - -use tauri::Manager; - -mod commands; -mod state; - -pub struct AppState { - pub db: tokio::sync::Mutex, - pub connection_state: tokio::sync::Mutex, - pub session_handle: tokio::sync::Mutex>, -} - -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("运行应用时出错"); -} diff --git a/src/tauri-app/src-tauri/src/main.rs b/src/tauri-app/src-tauri/src/main.rs deleted file mode 100644 index 289c1ee..0000000 --- a/src/tauri-app/src-tauri/src/main.rs +++ /dev/null @@ -1,5 +0,0 @@ -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -fn main() { - re_teamspeak_lib::run(); -} diff --git a/src/tauri-app/src-tauri/src/state.rs b/src/tauri-app/src-tauri/src/state.rs deleted file mode 100644 index 8067d8c..0000000 --- a/src/tauri-app/src-tauri/src/state.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! 应用状态管理 - -/// 连接状态 -#[derive(Debug, Clone)] -pub struct ConnectionState { - pub connected: bool, - pub server_address: Option, - pub server_port: Option, - pub client_id: Option, - pub nickname: Option, -} - -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() - } -} diff --git a/src/tauri-app/src-tauri/tauri.conf.json b/src/tauri-app/src-tauri/tauri.conf.json deleted file mode 100644 index ee5d4d0..0000000 --- a/src/tauri-app/src-tauri/tauri.conf.json +++ /dev/null @@ -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" - } -} diff --git a/src/tsaudio/Cargo.toml b/src/tsaudio/Cargo.toml deleted file mode 100644 index ebbe0a8..0000000 --- a/src/tsaudio/Cargo.toml +++ /dev/null @@ -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"] diff --git a/src/tsaudio/src/buffer.rs b/src/tsaudio/src/buffer.rs deleted file mode 100644 index 00e188e..0000000 --- a/src/tsaudio/src/buffer.rs +++ /dev/null @@ -1,194 +0,0 @@ -use std::collections::BTreeMap; -use std::time::{Duration, Instant}; - -use super::{AudioFrame, AudioResult}; - -pub struct JitterBuffer { - frames: BTreeMap, - next_output_seq: u16, - capacity: usize, - target_delay_ms: u32, - last_output: Option, - 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 { - 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) -> 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); - } -} diff --git a/src/tsaudio/src/capture.rs b/src/tsaudio/src/capture.rs deleted file mode 100644 index 75ef557..0000000 --- a/src/tsaudio/src/capture.rs +++ /dev/null @@ -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 { - Err(AudioError::Device("未实现".to_string())) - } - - pub fn list_devices() -> AudioResult> { - Ok(Vec::new()) - } -} diff --git a/src/tsaudio/src/codec.rs b/src/tsaudio/src/codec.rs deleted file mode 100644 index cd722dc..0000000 --- a/src/tsaudio/src/codec.rs +++ /dev/null @@ -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 { - #[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> { - #[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 { - #[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> { - #[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> { - self.decode(data, false) - } - - pub fn decode_packet_fec(&mut self, data: &[u8]) -> AudioResult> { - 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 = (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); - } -} diff --git a/src/tsaudio/src/engine.rs b/src/tsaudio/src/engine.rs deleted file mode 100644 index 238a521..0000000 --- a/src/tsaudio/src/engine.rs +++ /dev/null @@ -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, -} - -impl VoiceEngine { - pub fn new(config: AudioConfig) -> AudioResult { - let decoder = OpusDecoder::new(config.sample_rate, config.channels)?; - let jitter_buffer = JitterBuffer::with_target_delay(100, 60); - let playback = AudioPlayback::new(config.clone()); - - Ok(Self { - decoder, - jitter_buffer, - playback, - config, - client_decoders: std::collections::HashMap::new(), - }) - } - - pub fn start(&mut self) -> AudioResult<()> { - self.playback.start() - } - - pub fn stop(&mut self) -> AudioResult<()> { - self.playback.stop() - } - - pub fn process_voice_data( - &mut self, - client_id: u16, - codec: u8, - packet_id: u16, - audio_data: &[u8], - ) -> AudioResult<()> { - let decoder = self.client_decoders.entry(client_id).or_insert_with(|| { - OpusDecoder::new(self.config.sample_rate, self.config.channels) - .unwrap_or_else(|_| { - OpusDecoder::new(48000, 1).unwrap() - }) - }); - - let samples = decoder.decode_packet(audio_data)?; - - let frame = AudioFrame::new(self.config.sample_rate, self.config.channels, samples) - .with_sequence(packet_id) - .with_codec(codec); - - self.jitter_buffer.push(frame)?; - - Ok(()) - } - - pub fn output_tick(&mut self) -> AudioResult<()> { - if let Some(frame) = self.jitter_buffer.pop() { - self.playback.play(frame)?; - } - Ok(()) - } - - pub fn run_output_loop(&mut self) -> AudioResult<()> { - loop { - self.output_tick()?; - std::thread::sleep(std::time::Duration::from_millis(5)); - } - } - - pub fn list_output_devices() -> AudioResult> { - AudioPlayback::list_devices() - } - - pub fn buffered_ms(&self) -> u32 { - self.jitter_buffer.buffered_ms() - } - - pub fn jitter_buffer_len(&self) -> usize { - self.jitter_buffer.len() - } - - pub fn playback_buffer_len(&self) -> usize { - self.playback.buffer_len() - } - - pub fn clear_jitter_buffer(&mut self) { - self.jitter_buffer.clear(); - } - - pub fn remove_client(&mut self, client_id: u16) { - self.client_decoders.remove(&client_id); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_voice_engine_new() { - let config = AudioConfig::default(); - let engine = VoiceEngine::new(config); - assert!(engine.is_ok()); - } - - #[test] - fn test_voice_engine_list_devices() { - let devices = VoiceEngine::list_output_devices(); - assert!(devices.is_ok()); - } -} diff --git a/src/tsaudio/src/lib.rs b/src/tsaudio/src/lib.rs deleted file mode 100644 index 7e16746..0000000 --- a/src/tsaudio/src/lib.rs +++ /dev/null @@ -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 = Result; - -#[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, -} - -impl AudioFrame { - pub fn new(sample_rate: u32, channels: u16, samples: Vec) -> 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, - pub channels: Vec, -} diff --git a/src/tsaudio/src/playback.rs b/src/tsaudio/src/playback.rs deleted file mode 100644 index 027a3c8..0000000 --- a/src/tsaudio/src/playback.rs +++ /dev/null @@ -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, - buffer: Arc>>, -} - -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> { - #[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) - } -} diff --git a/src/tsaudio/src/vad.rs b/src/tsaudio/src/vad.rs deleted file mode 100644 index 118e707..0000000 --- a/src/tsaudio/src/vad.rs +++ /dev/null @@ -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::() / 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; - } -} diff --git a/src/tscore/Cargo.toml b/src/tscore/Cargo.toml deleted file mode 100644 index 565131d..0000000 --- a/src/tscore/Cargo.toml +++ /dev/null @@ -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 } diff --git a/src/tscore/src/connection/client.rs b/src/tscore/src/connection/client.rs deleted file mode 100644 index 3c0a829..0000000 --- a/src/tscore/src/connection/client.rs +++ /dev/null @@ -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), - ChannelListFinished, - ClientList(Vec), - 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>, - pub events: Vec, -} - -/// Client configuration -#[derive(Debug, Clone)] -pub struct ClientConfig { - pub address: SocketAddr, - pub nickname: String, - pub version: String, - pub platform: String, - pub server_password: Option, - pub channel: Option, - pub channel_password: Option, - pub default_token: Option, - 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, - key_cache: KeyCache, - client_id: Option, - /// 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, - /// 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 { - self.client_id - } - - pub fn shared_secret(&self) -> &Option { - &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, 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 { - 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 { - 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 { - 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, 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, 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>, 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>, 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 { - 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, 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) -> Result, 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, ProtocolError> { - self.build_command_packet(self.build_clientinit()) - } - - /// Build clientinit command - pub fn build_clientinit(&self) -> Vec { - 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::() { - 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 { - 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 { - 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")); - } -} diff --git a/src/tscore/src/connection/mod.rs b/src/tscore/src/connection/mod.rs deleted file mode 100644 index ce74106..0000000 --- a/src/tscore/src/connection/mod.rs +++ /dev/null @@ -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::*; diff --git a/src/tscore/src/connection/resend.rs b/src/tscore/src/connection/resend.rs deleted file mode 100644 index 9b74806..0000000 --- a/src/tscore/src/connection/resend.rs +++ /dev/null @@ -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, - pub sent_at: Instant, - pub retry_count: u32, - pub timeout: Duration, -} - -impl SentPacket { - pub fn new(data: Vec) -> 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, - /// 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) { - 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)> { - 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()); - } -} diff --git a/src/tscore/src/connection/session.rs b/src/tscore/src/connection/session.rs deleted file mode 100644 index 59f5862..0000000 --- a/src/tscore/src/connection/session.rs +++ /dev/null @@ -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), - JoinChannel { - channel_id: u64, - password: Option, - }, - 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), - ClientList(Vec), - 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, - is_whisper: bool, - }, - Error(String), - Disconnected, -} - -pub struct SessionHandle { - command_tx: mpsc::Sender, - event_rx: mpsc::Receiver, -} - -impl SessionHandle { - pub async fn send_raw_command(&self, command: Vec) -> 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, - ) -> 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 { - self.event_rx.recv().await - } - - pub fn try_recv_event(&mut self) -> Option { - self.event_rx.try_recv().ok() - } -} - -pub struct Session { - client: Client, - socket: UdpSocket, - command_rx: mpsc::Receiver, - event_tx: mpsc::Sender, -} - -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 { - 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(()) - } -} diff --git a/src/tscore/src/connection/state.rs b/src/tscore/src/connection/state.rs deleted file mode 100644 index dd534ee..0000000 --- a/src/tscore/src/connection/state.rs +++ /dev/null @@ -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() - } -} diff --git a/src/tscore/src/crypto/eax.rs b/src/tscore/src/crypto/eax.rs deleted file mode 100644 index 22762ed..0000000 --- a/src/tscore/src/crypto/eax.rs +++ /dev/null @@ -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, -} - -impl EaxCipher { - pub fn new(key: &[u8; 16]) -> Self { - let key = GenericArray::from_slice(key); - Self { - cipher: Eax::::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, 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, 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) -} diff --git a/src/tscore/src/crypto/ephemeral.rs b/src/tscore/src/crypto/ephemeral.rs deleted file mode 100644 index 2d727c8..0000000 --- a/src/tscore/src/crypto/ephemeral.rs +++ /dev/null @@ -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); - } -} diff --git a/src/tscore/src/crypto/hash.rs b/src/tscore/src/crypto/hash.rs deleted file mode 100644 index a886723..0000000 --- a/src/tscore/src/crypto/hash.rs +++ /dev/null @@ -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) -} diff --git a/src/tscore/src/crypto/identity.rs b/src/tscore/src/crypto/identity.rs deleted file mode 100644 index 0d0b4bd..0000000 --- a/src/tscore/src/crypto/identity.rs +++ /dev/null @@ -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 { - 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, 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 { - 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(); - } -} diff --git a/src/tscore/src/crypto/keys.rs b/src/tscore/src/crypto/keys.rs deleted file mode 100644 index d800a3f..0000000 --- a/src/tscore/src/crypto/keys.rs +++ /dev/null @@ -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) -} diff --git a/src/tscore/src/crypto/mod.rs b/src/tscore/src/crypto/mod.rs deleted file mode 100644 index 5463433..0000000 --- a/src/tscore/src/crypto/mod.rs +++ /dev/null @@ -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::*; diff --git a/src/tscore/src/crypto/tests.rs b/src/tscore/src/crypto/tests.rs deleted file mode 100644 index b60626c..0000000 --- a/src/tscore/src/crypto/tests.rs +++ /dev/null @@ -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); - } -} diff --git a/src/tscore/src/lib.rs b/src/tscore/src/lib.rs deleted file mode 100644 index 8a92528..0000000 --- a/src/tscore/src/lib.rs +++ /dev/null @@ -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 = Result; - -impl From for ProtocolError { - fn from(err: protocol::CommandError) -> Self { - ProtocolError::Command(err.to_string()) - } -} diff --git a/src/tscore/src/network/mod.rs b/src/tscore/src/network/mod.rs deleted file mode 100644 index f4399a8..0000000 --- a/src/tscore/src/network/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Network module - -pub mod resolver; -pub mod socket; - -pub use resolver::*; -pub use socket::*; diff --git a/src/tscore/src/network/resolver.rs b/src/tscore/src/network/resolver.rs deleted file mode 100644 index c28dc40..0000000 --- a/src/tscore/src/network/resolver.rs +++ /dev/null @@ -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> { - 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> { - // 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> { - // TODO: Implement TSDNS and nickname resolution - resolve_domain(nickname).await -} diff --git a/src/tscore/src/network/socket.rs b/src/tscore/src/network/socket.rs deleted file mode 100644 index 413202e..0000000 --- a/src/tscore/src/network/socket.rs +++ /dev/null @@ -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>; - - fn poll_send_to( - &self, - cx: &mut Context, - buf: &[u8], - target: SocketAddr, - ) -> Poll>; - - fn local_addr(&self) -> std::io::Result; -} - -/// UDP Socket implementation -pub struct UdpSocketWrapper { - socket: UdpSocket, -} - -impl UdpSocketWrapper { - pub async fn bind(addr: SocketAddr) -> std::io::Result { - 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> { - self.socket.poll_recv_from(cx, buf) - } - - fn poll_send_to( - &self, - cx: &mut Context, - buf: &[u8], - target: SocketAddr, - ) -> Poll> { - self.socket.poll_send_to(cx, buf, target) - } - - fn local_addr(&self) -> std::io::Result { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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(); - } -} diff --git a/src/tscore/src/protocol/commands.rs b/src/tscore/src/protocol/commands.rs deleted file mode 100644 index e4f38f5..0000000 --- a/src/tscore/src/protocol/commands.rs +++ /dev/null @@ -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 = Result; - -/// 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 { - 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, -} - -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, -} - -impl Command { - pub fn new(name: &str) -> Self { - Self { - name: name.to_string(), - args: Vec::new(), - } - } - - pub fn with_args(name: &str, args: Vec) -> 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 { - 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> { - 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 - } -} diff --git a/src/tscore/src/protocol/mod.rs b/src/tscore/src/protocol/mod.rs deleted file mode 100644 index 1e22af8..0000000 --- a/src/tscore/src/protocol/mod.rs +++ /dev/null @@ -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::*; diff --git a/src/tscore/src/protocol/packet.rs b/src/tscore/src/protocol/packet.rs deleted file mode 100644 index 46453ab..0000000 --- a/src/tscore/src/protocol/packet.rs +++ /dev/null @@ -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, - pub flags: Flags, -} - -impl Header { - pub fn parse_c2s(data: &[u8]) -> Result { - 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 { - 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 { - 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, -} - -impl InPacket { - pub fn parse(direction: Direction, data: &[u8]) -> Result { - 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, -} - -impl OutPacket { - pub fn new(direction: Direction, flags: Flags, content: Vec) -> 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 { - &mut self.data - } - - pub fn to_bytes(&self) -> Vec { - 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, - pub timestamp: Option, - 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, - pub random2: Option<[u8; 100]>, - pub y: Option<[u8; 64]>, - pub command: Option>, -} - -impl InitPacket { - pub fn parse_c2s(data: &[u8]) -> Result { - 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::parse(data) - } - - pub fn parse(data: &[u8]) -> Result { - 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 { - let mut bytes = Vec::new(); - - match self.step { - InitStep::Init0 => { - bytes.push(0); - if let Some(version) = self.version { - bytes.extend_from_slice(&version.to_be_bytes()); - } else { - bytes.extend_from_slice(&[0; 4]); - } - bytes.push(0); - if let Some(timestamp) = self.timestamp { - bytes.extend_from_slice(×tamp.to_be_bytes()); - } else { - bytes.extend_from_slice(&[0; 4]); - } - if let Some(random0) = self.random0 { - bytes.extend_from_slice(&random0); - } else { - bytes.extend_from_slice(&[0; 4]); - } - bytes.extend_from_slice(&[0; 8]); - } - InitStep::Init1 => { - bytes.push(1); - if let Some(random1) = self.random1 { - bytes.extend_from_slice(&random1); - } else { - bytes.extend_from_slice(&[0; 16]); - } - if let Some(random0_r) = self.random0_r { - bytes.extend_from_slice(&random0_r); - } else { - bytes.extend_from_slice(&[0; 4]); - } - } - InitStep::Init2 => { - bytes.push(2); - if let Some(version) = self.version { - bytes.extend_from_slice(&version.to_be_bytes()); - } else { - bytes.extend_from_slice(&[0; 4]); - } - if let Some(random1) = self.random1 { - bytes.extend_from_slice(&random1); - } else { - bytes.extend_from_slice(&[0; 16]); - } - if let Some(random0_r) = self.random0_r { - bytes.extend_from_slice(&random0_r); - } else { - bytes.extend_from_slice(&[0; 4]); - } - } - InitStep::Init3 => { - bytes.push(3); - if let Some(x) = self.x { - bytes.extend_from_slice(&x); - } else { - bytes.extend_from_slice(&[0; 64]); - } - if let Some(n) = self.n { - bytes.extend_from_slice(&n); - } else { - bytes.extend_from_slice(&[0; 64]); - } - if let Some(level) = self.level { - bytes.extend_from_slice(&level.to_be_bytes()); - } else { - bytes.extend_from_slice(&[0; 4]); - } - if let Some(random2) = self.random2 { - bytes.extend_from_slice(&random2); - } else { - bytes.extend_from_slice(&[0; 100]); - } - } - InitStep::Init4 => { - bytes.push(4); - if let Some(version) = self.version { - bytes.extend_from_slice(&version.to_be_bytes()); - } else { - bytes.extend_from_slice(&[0; 4]); - } - if let Some(x) = self.x { - bytes.extend_from_slice(&x); - } else { - bytes.extend_from_slice(&[0; 64]); - } - if let Some(n) = self.n { - bytes.extend_from_slice(&n); - } else { - bytes.extend_from_slice(&[0; 64]); - } - if let Some(level) = self.level { - bytes.extend_from_slice(&level.to_be_bytes()); - } else { - bytes.extend_from_slice(&[0; 4]); - } - if let Some(random2) = self.random2 { - bytes.extend_from_slice(&random2); - } else { - bytes.extend_from_slice(&[0; 100]); - } - if let Some(y) = self.y { - bytes.extend_from_slice(&y); - } else { - bytes.extend_from_slice(&[0; 64]); - } - if let Some(ref command) = self.command { - bytes.extend_from_slice(command); - } - } - InitStep::Reset => { - bytes.push(127); - bytes.push(0); - } - } - - bytes - } - - pub fn to_c2s_bytes(&self) -> Vec { - 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 { - 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, - } - } -} diff --git a/src/tscore/src/protocol/tests.rs b/src/tscore/src/protocol/tests.rs deleted file mode 100644 index 854ad1e..0000000 --- a/src/tscore/src/protocol/tests.rs +++ /dev/null @@ -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()); - } -} diff --git a/src/tscore/src/protocol/types.rs b/src/tscore/src/protocol/types.rs deleted file mode 100644 index 37c5366..0000000 --- a/src/tscore/src/protocol/types.rs +++ /dev/null @@ -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 { - 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, - } - } -} diff --git a/src/tscore/src/protocol/voice.rs b/src/tscore/src/protocol/voice.rs deleted file mode 100644 index f534fdf..0000000 --- a/src/tscore/src/protocol/voice.rs +++ /dev/null @@ -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, -} - -#[derive(Debug, Clone)] -pub struct WhisperPacket { - pub packet_id: u16, - pub codec: CodecType, - pub channel_targets: Vec, - pub client_targets: Vec, - pub audio_data: Vec, -} - -#[derive(Debug, Clone)] -pub enum VoiceData { - Normal(VoicePacket), - Whisper(WhisperPacket), -} - -impl VoicePacket { - pub fn parse(data: &[u8]) -> Result { - 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 { - 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 { - 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 { - 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 { - 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, - client_targets: Vec, -) -> 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()); - } -} diff --git a/src/tscore/src/query.rs b/src/tscore/src/query.rs deleted file mode 100644 index 20b1699..0000000 --- a/src/tscore/src/query.rs +++ /dev/null @@ -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 = Result; - -#[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 { - let value = self.require(name)?; - value - .parse::() - .map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}"))) - } - - pub fn get_u64(&self, name: &str) -> QueryResult { - let value = self.require(name)?; - value - .parse::() - .map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}"))) - } - - pub fn get_u16(&self, name: &str) -> QueryResult { - let value = self.require(name)?; - value - .parse::() - .map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}"))) - } - - pub fn get_i32(&self, name: &str) -> QueryResult { - let value = self.require(name)?; - value - .parse::() - .map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}"))) - } - - pub fn get_bool(&self, name: &str) -> QueryResult { - Ok(self.get_u32(name)? != 0) - } - - pub fn get_u32_or(&self, name: &str, default: u32) -> QueryResult { - self.get(name) - .map(|_| self.get_u32(name)) - .unwrap_or(Ok(default)) - } - - pub fn get_i32_or(&self, name: &str, default: i32) -> QueryResult { - self.get(name) - .map(|_| self.get_i32(name)) - .unwrap_or(Ok(default)) - } - - pub fn get_u64_or(&self, name: &str, default: u64) -> QueryResult { - self.get(name) - .map(|_| self.get_u64(name)) - .unwrap_or(Ok(default)) - } - - pub fn get_u16_or(&self, name: &str, default: u16) -> QueryResult { - 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 { - let value = self.require(name)?; - value - .parse::() - .map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}"))) - } - - pub fn get_u64(&self, name: &str) -> QueryResult { - let value = self.require(name)?; - value - .parse::() - .map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}"))) - } - - pub fn get_u16(&self, name: &str) -> QueryResult { - let value = self.require(name)?; - value - .parse::() - .map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}"))) - } - - pub fn get_i32(&self, name: &str) -> QueryResult { - let value = self.require(name)?; - value - .parse::() - .map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}"))) - } - - pub fn get_bool(&self, name: &str) -> QueryResult { - Ok(self.get_u32(name)? != 0) - } - - pub fn get_u32_or(&self, name: &str, default: u32) -> QueryResult { - self.get(name) - .map(|_| self.get_u32(name)) - .unwrap_or(Ok(default)) - } - - pub fn get_i32_or(&self, name: &str, default: i32) -> QueryResult { - self.get(name) - .map(|_| self.get_i32(name)) - .unwrap_or(Ok(default)) - } - - pub fn get_u64_or(&self, name: &str, default: u64) -> QueryResult { - self.get(name) - .map(|_| self.get_u64(name)) - .unwrap_or(Ok(default)) - } - - pub fn get_u16_or(&self, name: &str, default: u16) -> QueryResult { - 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, - pub status: QueryStatus, -} - -pub struct QueryClient { - stream: TcpStream, - greeting: String, - read_timeout: Duration, -} - -impl QueryClient { - pub async fn connect(addr: A) -> QueryResult { - let stream = TcpStream::connect(addr).await?; - Self::from_stream(stream).await - } - - pub async fn from_stream(stream: TcpStream) -> QueryResult { - 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 { - 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> { - let mut response = self.execute("whoami").await?; - Ok(response.records.pop()) - } - - pub async fn permission_list(&mut self) -> QueryResult> { - let response = self.execute("permissionlist").await?; - records_to_permissions(&response.records) - } - - pub async fn channel_list(&mut self) -> QueryResult> { - let response = self.execute("channellist").await?; - records_to_channels(&response.records) - } - - pub async fn client_list(&mut self) -> QueryResult> { - let response = self.execute("clientlist").await?; - records_to_clients(&response.records) - } - - pub async fn server_info(&mut self) -> QueryResult> { - 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 { - self.read_until(|content| content.contains(GREETING_MARKER)) - .await - } - - async fn read_until_status(&mut self) -> QueryResult { - self.read_until(contains_status_line).await - } - - async fn read_until(&mut self, done: F) -> QueryResult - 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> { - records.iter().map(record_to_permission).collect() -} - -pub fn record_to_permission(record: &QueryRecord) -> QueryResult { - 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> { - records.iter().map(record_to_channel).collect() -} - -pub fn record_to_channel(record: &QueryRecord) -> QueryResult { - 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> { - records.iter().map(record_to_client).collect() -} - -pub fn record_to_client(record: &QueryRecord) -> QueryResult { - 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 { - 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 { - 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 { - 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 { - 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::() - .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 { - Ok(QueryRecord { - fields: parse_fields(input)?, - }) -} - -fn parse_fields(input: &str) -> QueryResult> { - 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(); - } - } - } -} diff --git a/src/tsdb/Cargo.toml b/src/tsdb/Cargo.toml deleted file mode 100644 index dd1149c..0000000 --- a/src/tsdb/Cargo.toml +++ /dev/null @@ -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 } diff --git a/src/tsdb/src/bookmark.rs b/src/tsdb/src/bookmark.rs deleted file mode 100644 index 1302775..0000000 --- a/src/tsdb/src/bookmark.rs +++ /dev/null @@ -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, - pub server_password: Option, - pub channel: Option, - pub channel_password: Option, - pub default_token: Option, - pub auto_connect: bool, - pub last_connected: Option, - 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 { - 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 { - 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> { - 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::, _>>()?; - - Ok(bookmarks) - } - - pub fn update_bookmark( - &self, - id: &str, - name: Option<&str>, - address: Option<&str>, - port: Option, - 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(()) - } -} diff --git a/src/tsdb/src/config.rs b/src/tsdb/src/config.rs deleted file mode 100644 index 423b86e..0000000 --- a/src/tsdb/src/config.rs +++ /dev/null @@ -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> { - 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> { - 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::, _>>()?; - - Ok(settings) - } -} diff --git a/src/tsdb/src/identity.rs b/src/tsdb/src/identity.rs deleted file mode 100644 index 23d8cc3..0000000 --- a/src/tsdb/src/identity.rs +++ /dev/null @@ -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 { - 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 { - 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> { - 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::, _>>()?; - - Ok(identities) - } - - pub fn update_identity( - &self, - id: &str, - name: Option<&str>, - counter: Option, - ) -> 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(()) - } -} diff --git a/src/tsdb/src/lib.rs b/src/tsdb/src/lib.rs deleted file mode 100644 index 44ed624..0000000 --- a/src/tsdb/src/lib.rs +++ /dev/null @@ -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 = Result; - -pub struct DatabaseManager { - conn: rusqlite::Connection, -} - -impl DatabaseManager { - pub fn new(path: &str) -> DatabaseResult { - 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 - } -} diff --git a/src/tsdb/src/message.rs b/src/tsdb/src/message.rs deleted file mode 100644 index faf1ef1..0000000 --- a/src/tsdb/src/message.rs +++ /dev/null @@ -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, - 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, - message: &str, - ) -> DatabaseResult { - 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 { - 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> { - 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::, _>>()?; - - 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(()) - } -}