From abf380476ff82a7ec6f0803eec2f04dc6ed58998 Mon Sep 17 00:00:00 2001 From: ReTeamSpeak Date: Wed, 13 May 2026 13:35:22 +0900 Subject: [PATCH] refactor: remove legacy tauri stack and update iced docs --- .github/workflows/ci.yml | 10 +- README.md | 123 +-- build.bat | 96 -- build.sh | 182 ---- docs/README.md | 84 +- docs/SAD.md | 486 +-------- docs/SDD.md | 751 +------------- docs/SRS.md | 341 +------ docs/architecture.md | 405 +------- docs/components.md | 724 +------------- docs/requirements.md | 576 +---------- src/Cargo.toml | 39 +- src/iced-app/Cargo.toml | 9 +- src/iced-app/src/identity.rs | 45 + src/iced-app/src/main.rs | 463 +++++++-- src/iced-app/src/noise_cancel.rs | 138 +++ src/shared/Cargo.toml | 13 - src/shared/src/config.rs | 112 --- src/shared/src/errors.rs | 62 -- src/shared/src/events.rs | 246 ----- src/shared/src/lib.rs | 9 - src/shared/src/types.rs | 464 --------- src/tauri-app/frontend/index.html | 13 - src/tauri-app/frontend/package.json | 25 - src/tauri-app/frontend/src/App.tsx | 449 --------- src/tauri-app/frontend/src/main.tsx | 10 - src/tauri-app/frontend/src/styles.css | 351 ------- src/tauri-app/frontend/tsconfig.json | 21 - src/tauri-app/frontend/tsconfig.node.json | 11 - src/tauri-app/frontend/vite.config.ts | 17 - src/tauri-app/src-tauri/Cargo.toml | 37 - src/tauri-app/src-tauri/build.rs | 3 - .../src-tauri/capabilities/default.json | 25 - .../android/app/src/main/AndroidManifest.xml | 28 - src/tauri-app/src-tauri/gen/apple/Info.plist | 40 - src/tauri-app/src-tauri/icons/128x128.png | Bin 3512 -> 0 bytes src/tauri-app/src-tauri/icons/128x128@2x.png | Bin 7012 -> 0 bytes src/tauri-app/src-tauri/icons/32x32.png | Bin 974 -> 0 bytes .../src-tauri/icons/Square107x107Logo.png | Bin 2863 -> 0 bytes .../src-tauri/icons/Square142x142Logo.png | Bin 3858 -> 0 bytes .../src-tauri/icons/Square150x150Logo.png | Bin 3966 -> 0 bytes .../src-tauri/icons/Square284x284Logo.png | Bin 7737 -> 0 bytes .../src-tauri/icons/Square30x30Logo.png | Bin 903 -> 0 bytes .../src-tauri/icons/Square310x310Logo.png | Bin 8591 -> 0 bytes .../src-tauri/icons/Square44x44Logo.png | Bin 1299 -> 0 bytes .../src-tauri/icons/Square71x71Logo.png | Bin 2011 -> 0 bytes .../src-tauri/icons/Square89x89Logo.png | Bin 2468 -> 0 bytes src/tauri-app/src-tauri/icons/StoreLogo.png | Bin 1523 -> 0 bytes src/tauri-app/src-tauri/icons/icon.icns | Bin 98451 -> 0 bytes src/tauri-app/src-tauri/icons/icon.ico | Bin 37710 -> 0 bytes src/tauri-app/src-tauri/icons/icon.png | Bin 14183 -> 0 bytes src/tauri-app/src-tauri/src/commands.rs | 383 ------- src/tauri-app/src-tauri/src/lib.rs | 62 -- src/tauri-app/src-tauri/src/main.rs | 5 - src/tauri-app/src-tauri/src/state.rs | 29 - src/tauri-app/src-tauri/tauri.conf.json | 33 - src/tsaudio/Cargo.toml | 24 - src/tsaudio/src/buffer.rs | 194 ---- src/tsaudio/src/capture.rs | 34 - src/tsaudio/src/codec.rs | 164 --- src/tsaudio/src/engine.rs | 118 --- src/tsaudio/src/lib.rs | 100 -- src/tsaudio/src/playback.rs | 175 ---- src/tsaudio/src/vad.rs | 41 - src/tscore/Cargo.toml | 35 - src/tscore/src/connection/client.rs | 941 ------------------ src/tscore/src/connection/mod.rs | 11 - src/tscore/src/connection/resend.rs | 257 ----- src/tscore/src/connection/session.rs | 447 --------- src/tscore/src/connection/state.rs | 114 --- src/tscore/src/crypto/eax.rs | 118 --- src/tscore/src/crypto/ephemeral.rs | 151 --- src/tscore/src/crypto/hash.rs | 40 - src/tscore/src/crypto/identity.rs | 137 --- src/tscore/src/crypto/keys.rs | 228 ----- src/tscore/src/crypto/mod.rs | 13 - src/tscore/src/crypto/tests.rs | 162 --- src/tscore/src/lib.rs | 76 -- src/tscore/src/network/mod.rs | 7 - src/tscore/src/network/resolver.rs | 38 - src/tscore/src/network/socket.rs | 433 -------- src/tscore/src/protocol/commands.rs | 270 ----- src/tscore/src/protocol/mod.rs | 12 - src/tscore/src/protocol/packet.rs | 790 --------------- src/tscore/src/protocol/tests.rs | 260 ----- src/tscore/src/protocol/types.rs | 233 ----- src/tscore/src/protocol/voice.rs | 309 ------ src/tscore/src/query.rs | 733 -------------- src/tsdb/Cargo.toml | 28 - src/tsdb/src/bookmark.rs | 171 ---- src/tsdb/src/config.rs | 48 - src/tsdb/src/identity.rs | 116 --- src/tsdb/src/lib.rs | 101 -- src/tsdb/src/message.rs | 133 --- 94 files changed, 789 insertions(+), 13393 deletions(-) delete mode 100644 build.bat delete mode 100644 build.sh create mode 100644 src/iced-app/src/identity.rs create mode 100644 src/iced-app/src/noise_cancel.rs delete mode 100644 src/shared/Cargo.toml delete mode 100644 src/shared/src/config.rs delete mode 100644 src/shared/src/errors.rs delete mode 100644 src/shared/src/events.rs delete mode 100644 src/shared/src/lib.rs delete mode 100644 src/shared/src/types.rs delete mode 100644 src/tauri-app/frontend/index.html delete mode 100644 src/tauri-app/frontend/package.json delete mode 100644 src/tauri-app/frontend/src/App.tsx delete mode 100644 src/tauri-app/frontend/src/main.tsx delete mode 100644 src/tauri-app/frontend/src/styles.css delete mode 100644 src/tauri-app/frontend/tsconfig.json delete mode 100644 src/tauri-app/frontend/tsconfig.node.json delete mode 100644 src/tauri-app/frontend/vite.config.ts delete mode 100644 src/tauri-app/src-tauri/Cargo.toml delete mode 100644 src/tauri-app/src-tauri/build.rs delete mode 100644 src/tauri-app/src-tauri/capabilities/default.json delete mode 100644 src/tauri-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml delete mode 100644 src/tauri-app/src-tauri/gen/apple/Info.plist delete mode 100644 src/tauri-app/src-tauri/icons/128x128.png delete mode 100644 src/tauri-app/src-tauri/icons/128x128@2x.png delete mode 100644 src/tauri-app/src-tauri/icons/32x32.png delete mode 100644 src/tauri-app/src-tauri/icons/Square107x107Logo.png delete mode 100644 src/tauri-app/src-tauri/icons/Square142x142Logo.png delete mode 100644 src/tauri-app/src-tauri/icons/Square150x150Logo.png delete mode 100644 src/tauri-app/src-tauri/icons/Square284x284Logo.png delete mode 100644 src/tauri-app/src-tauri/icons/Square30x30Logo.png delete mode 100644 src/tauri-app/src-tauri/icons/Square310x310Logo.png delete mode 100644 src/tauri-app/src-tauri/icons/Square44x44Logo.png delete mode 100644 src/tauri-app/src-tauri/icons/Square71x71Logo.png delete mode 100644 src/tauri-app/src-tauri/icons/Square89x89Logo.png delete mode 100644 src/tauri-app/src-tauri/icons/StoreLogo.png delete mode 100644 src/tauri-app/src-tauri/icons/icon.icns delete mode 100644 src/tauri-app/src-tauri/icons/icon.ico delete mode 100644 src/tauri-app/src-tauri/icons/icon.png delete mode 100644 src/tauri-app/src-tauri/src/commands.rs delete mode 100644 src/tauri-app/src-tauri/src/lib.rs delete mode 100644 src/tauri-app/src-tauri/src/main.rs delete mode 100644 src/tauri-app/src-tauri/src/state.rs delete mode 100644 src/tauri-app/src-tauri/tauri.conf.json delete mode 100644 src/tsaudio/Cargo.toml delete mode 100644 src/tsaudio/src/buffer.rs delete mode 100644 src/tsaudio/src/capture.rs delete mode 100644 src/tsaudio/src/codec.rs delete mode 100644 src/tsaudio/src/engine.rs delete mode 100644 src/tsaudio/src/lib.rs delete mode 100644 src/tsaudio/src/playback.rs delete mode 100644 src/tsaudio/src/vad.rs delete mode 100644 src/tscore/Cargo.toml delete mode 100644 src/tscore/src/connection/client.rs delete mode 100644 src/tscore/src/connection/mod.rs delete mode 100644 src/tscore/src/connection/resend.rs delete mode 100644 src/tscore/src/connection/session.rs delete mode 100644 src/tscore/src/connection/state.rs delete mode 100644 src/tscore/src/crypto/eax.rs delete mode 100644 src/tscore/src/crypto/ephemeral.rs delete mode 100644 src/tscore/src/crypto/hash.rs delete mode 100644 src/tscore/src/crypto/identity.rs delete mode 100644 src/tscore/src/crypto/keys.rs delete mode 100644 src/tscore/src/crypto/mod.rs delete mode 100644 src/tscore/src/crypto/tests.rs delete mode 100644 src/tscore/src/lib.rs delete mode 100644 src/tscore/src/network/mod.rs delete mode 100644 src/tscore/src/network/resolver.rs delete mode 100644 src/tscore/src/network/socket.rs delete mode 100644 src/tscore/src/protocol/commands.rs delete mode 100644 src/tscore/src/protocol/mod.rs delete mode 100644 src/tscore/src/protocol/packet.rs delete mode 100644 src/tscore/src/protocol/tests.rs delete mode 100644 src/tscore/src/protocol/types.rs delete mode 100644 src/tscore/src/protocol/voice.rs delete mode 100644 src/tscore/src/query.rs delete mode 100644 src/tsdb/Cargo.toml delete mode 100644 src/tsdb/src/bookmark.rs delete mode 100644 src/tsdb/src/config.rs delete mode 100644 src/tsdb/src/identity.rs delete mode 100644 src/tsdb/src/lib.rs delete mode 100644 src/tsdb/src/message.rs 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 6be5e50e9b9ae84d9e2ee433f32ef446495eaf3b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3512 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 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 e81becee571e96f76aa5667f9324c05e5e7a4479..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7012 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 Sh@|Ye~|0>}wMPLT8jm@Y!H33Sz}5aFI6 zM9Lzqz|;A*0sGs=2A1uU!1nk2dGF7knQwr99SAFen)x(eCO;F8y2C~0FD1YxRTPcy zPWVxkUYmeuz}Tv?7&Fe-!UE{)ZW)Mb;H)^#eHDv$`dkZGguJz@^MA!ZNGAUqt{|0H zpZ7Ch9S`q5!>R%}>}62!+(T^evyO+ImSo2wpu)su4^3nw5(%)KD%gbSev^*HZZ&3( z#&c@Z0gH|}Ck)w6fh0&NBJ62ib%R}(3@$VFl*_#l2W$wQ-~4RmZZAt5O*^2Q5}Xr8Hy@c`#pM?kc?hFWxRXr*mUfUCXf4ka5DD~ zat6d85COB05l#(P9*cQZ3EC8fVdS~?&vN#rce(aF9@xp80O2{{FBvU+{X>Hoh;xI` z{$e^Nw1y*VbO8wv`8|-m?NwNaKGTGaF{P^JLB^DbOYWIbn%eT`*!^C1H36=O8Z-M> zkD~88ry`eSo`tEBN4>w7OWZwUzlh{WM1m8R6zepqGcGMaV7vWY9b?K4b6~|HVG)ec wi>I@ws#sZo7or4_*4M>7;p5{nr2pZ?Uu4>Krr0kU)&Kwi07*qoM6N<$f)&@lf&c&j 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 0ca4f27198838968bd60ed7d371bfa23496b7fe5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2863 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 Y2S8W#vM)6=T>t<807*qoM6N<$f*y@n<^TWy 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 c021d2ba76619c08969ab688db3b27f29257aa6f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7737 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 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 621970023096ed9f494ba18ace15421a45cd65fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 903 zcmV;219<$2P)2 z+CUKPMqaqGiH;zb!R4$B-WXS^YzQr=@UH>k4?*L)&R=zYjBrZenKdc9|JlS$SO*RJ zKt8FSTDAdk1g_WPAO!p^V!AuL;Lm;uQyV;zKq)J3i(;q*;k+pD%f3eltU`PYdy9(k0&%` zuWAPcV6|-y?|?7O1W!KSK}pbk8#~!|FA@(VJkt^V@0lio{afoAeo*f&$W2s6${5!1eKvAGD2$GZwSB98L2ZVS- zKn8ENRkZ*sb!@QugOrQNK3(sy1v%J#m|rpB+h|Nkqa3FRT>74xSs{#&saU2Lf!_Iq zKmuKAESh`gs!fneGWn+nf}l?7jE$HW!Af&vE5=G!QU)U2v&HLIBGXKk4nQx{hsHjL zLPMAo5=*uInFbq7(aa`Y2VX5wCmaeqvECOFv)a>0t>ZaEb*cJccER=BB?KFZhV$c^ znL*l8x*UYZv4WK|j?~Jt6~~F%{pk~z5A*>^M`?r5m9@RJ_x|uEtX(6Vk@Y()MVto* z93wr)%3m%|#OZ~srm>zF(JvDuTq*@;d&^>_BJm5hOU`3FjG70L#Vzv9I?`<7$T@

jU?lMi@tgxr7CqX_r3uw^y4tVU3Pm0sw;|1WSUO%?=bG`*Kmz6u4{#ti;T7AWIBAEh!(Y zz>O01&#X?Ds@L)Sb{CkG#Yz4$3o d@96)?#cz^xWoA}>B$xmI002ovPDHLkV1l3&k#zt7 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 f9bc04839491e66c07b16ab03743c0c53b4109cc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8591 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 JPDHLkV1lm|RXG3v 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 63440d7984936a9caa89275928d8dce97e4d033b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2011 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∓t$dRJGIadE?vc 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 Kx8Vjl{{H|h@<;Lj 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 06c23c82fea615c9394c4c7f487392e3f0a2fd4a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37710 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

5l#v3A#xI3*z~1~fvo9Q(N^(==!|_FZ z*duZ=+M1~)8E|otX8KNZlr?qels#x_1Xq@9IIw~@9uAREJVH)Xw^}UclF6327}E42 zT)E&?U%TK?(+K7%R!`H5oX0i)4Qn5??Iw3p5J~6_u+aWehY{DSn}3V2p$bgjnAu?o)v@iC254fXeMv50$9YrpU`N?u@QIWs)T?SP|fa}(|9 zqAX+!7`cx=4)cCBg5h~pu(?@9`)aCr#oyz$ld=#RFxYCNZCZls@4v2~*e-t6PEVvV z&bbK3b3wt(Coc!ufAbXXC<**#HQ%J9k`New6iG<5RjtO4XVO?dCvwxD{kJ#tfQr(X zg^NTwF-FwAeS_{V4bfel8l`~NbfrTR2s!G>WduFWxH(t~aK4q=6rEE^$+Uox>gJO2 z{L<;6Q6nHa5#ZEM>H58not!)z(6*_=^~8}jWf*IG$AUKVWOZ4?)GfF z+BM#*wKKmLFD7E~W3U!$IVm$k_k1f&Kz6WV8@55P?r~bcg-Za-!rvW?ns&)KOGT2~ zlkAyqhQj=P$Eg3w#K~}zH@J5bo-BfHjInKSz$@?+Z)NPD4pHj^_Qxmi`UqoTy=`sV zLVxrXGuBr=QRm|}wg75yetQQK4fY3#P_~J}zEfPnb2C4Wo!E(d*(cA;b?7$g2in<( zPn)ghX}nzJPmb6(3Dpeg_GW~Hc}Lt=lgsSZz z!5QXyz7KaR;D`3Ee}d`af{H>WWZ|Io1QI3~4Ll_`g1(cRnhLK73Ro)7zPCd={1W2x zRp%Xlvv4>!<2@}$hz|!V{T}_eHx2xkLl^hQoZTCnsjCl|W_@5Fx2(+j0ogy&Y+;L- z<)G$*CiN7hOm^s!{U>1F7U=iNk{+u~dAC!eDz%=|glFW0jEZU1&o(G_c#wTxUjnG} z#cg3>jEpUi#Mlq@t?Msg_#geK^Lx@DyHWf7=AS5vVyM7YOjvUVCfcpVR<(+5!H?9- zySI6s>o3m&*zr||=wcPGyBkQV`EWJl@bH8qobjOp+sXL*)=&yX)8aAbf~tGv?a2SN zu^Ddo-z?DWk9h9Yz#5p^NU#x~wYSd?H@w@!2Gb4G)6-utEMV~~M85Br5ff(v5O1|T z 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, - 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(()) - } -}