Compare commits

...
28 Commits
Author SHA1 Message Date
ReTeamSpeak 9b131ae533 fix: run CI only for tag builds 2026-05-13 20:52:29 +09:00
ReTeamSpeak c8caf83355 feat: polish connected server voice UI 2026-05-13 20:49:58 +09:00
ReTeamSpeak 73168ef965 feat: redesign bookmarks and settings surfaces
Make the disconnected app flow feel more deliberate with a real bookmark management page, categorized settings navigation, and proper device dropdowns. This moves the UI closer to the Apple-style voice-first layout while leaving unrelated in-progress audio work untouched.
2026-05-13 19:35:45 +09:00
ReTeamSpeak 83346c337f fix: isolate continuous-mode mic capture and clarify voice settings
- Use a separate Microphone instance for continuous-mode VAD monitoring
  so it no longer races with the transmit capture path.
- Respect selected input device in continuous-mode monitor subscription.
- Make talk-mode and Sonora processing labels clearer in settings UI.
2026-05-13 19:14:50 +09:00
ReTeamSpeak 272d3a1cb7 feat: add advanced Sonora voice controls 2026-05-13 17:35:40 +09:00
ReTeamSpeak a852374bf9 fix: stabilize tsclientlib audio playback buffering 2026-05-13 16:27:29 +09:00
ReTeamSpeak 43967cf309 fix: resolve clippy warnings in iced client 2026-05-13 16:06:04 +09:00
ReTeamSpeak 358aaa2ea5 fix: install opus on macOS CI 2026-05-13 15:58:20 +09:00
ReTeamSpeak 57456a8147 refactor: make audio mandatory and split iced app modules 2026-05-13 15:50:16 +09:00
ReTeamSpeak abf380476f refactor: remove legacy tauri stack and update iced docs 2026-05-13 13:35:22 +09:00
ReTeamSpeak 4a54f6667c feat: continuous talk mode with voice activation (VAD)
Continuous Talk:
- Voice Activity Detection with energy-based detection
- Smoothing window (5 frames) to avoid false triggers
- Hangover timer (15 frames / 300ms) to keep transmitting during pauses
- Hysteresis: threshold * 0.7 to stop, threshold to start
- Configurable threshold (default 0.005)

Talk Modes:
- Push-to-Talk: hold V key to talk (existing)
- Continuous: mic always on, VAD auto-starts/stops transmission
- Toggle between modes in Settings page

Architecture:
- VoiceActivation struct with state machine (Silent/Speaking/Hangover)
- Subscription monitors mic input in continuous mode
- MicSamples messages feed VAD, which triggers StartContinuous/StopContinuous
- start_transmission/stop_transmission helper functions (shared by PTT and VAD)
- Mic lifecycle managed by subscription (starts on mode enable, stops on disable)

69 tests passing, clippy clean on both builds
2026-05-13 11:08:16 +09:00
ReTeamSpeak 190fcf2a7e feat: push-to-talk with mic capture and Opus encoding
Push-to-Talk:
- Hold V key to activate microphone
- cpal input stream captures mono 48kHz f32 samples
- Opus encoder (audiopus) encodes 20ms frames
- Encoded packets sent via SyncConnectionHandle -> con.send_audio()
- End-of-transmission packet sent on PTT release
- Microphone struct for mic lifecycle management
- OpusEncoderState with sample buffering and frame splitting

Audio:
- Replaced opus crate with audiopus (matches tsclientlib)
- Microphone start/stop with device selection
- PTT keyboard event subscription (V key hold/release)
- Audio gated behind 'audio' feature (needs ALSA/cmake)

69 tests passing, clippy clean on both audio and non-audio builds
2026-05-13 10:54:58 +09:00
ReTeamSpeak 61f2d5bdb6 feat: Apple-style dark UI, bookmark CRUD, join channel, settings
UI Redesign:
- Custom dark theme with Apple-inspired palette (SIDEBAR_BG, BG_PRIMARY, etc.)
- Rounded cards with subtle shadows and depth
- Styled inputs with focus/hover states
- Styled buttons (primary, secondary, danger, nav, bookmark)
- Proper visual hierarchy with generous whitespace
- Sidebar with dark background, cards with elevated surfaces

Features:
- Bookmark CRUD: add new server, delete existing
- Join channel: click channel in list to move to it
- Settings page with audio device info placeholder
- ServerQuery: configurable command input
- Chat messages with invoker name display
- Channel list shows current channel highlighted
- Client list with mute indicators
- Error display with danger styling
- Identity level progress indicator

Architecture:
- Custom theme module (theme.rs) with all styles
- Bookmark editing form in sidebar
- Channel click handler sends client_move command
- 69 tests passing, clippy clean
2026-05-13 10:31:07 +09:00
ReTeamSpeak 073095b06a feat: audio playback, device selector, i18n fixes
Audio:
- Add AudioPlayback with channel-based cpal output (avoids Send issue)
- Add AudioCapture stub with input device enumeration
- Audio gated behind 'audio' feature flag (needs ALSA/cmake)
- list_output_devices() / list_input_devices() for device selection
- StreamItem::Audio packets forwarded via mpsc channel to cpal callback

i18n:
- Replace all Chinese error messages with English in tsdb
- Replace all Chinese comments/doc strings with English in tscore
- All user-facing strings now in English

Build:
- Add Containerfile.build for containerized builds with audio deps
- CMAKE_POLICY_VERSION_MINIMUM=3.5 workaround for audiopus_sys
- tsclientlib audio feature enabled in workspace (needs cmake)
- 69 tests passing, clippy clean on both audio and non-audio builds
2026-05-13 00:53:08 +09:00
ReTeamSpeak 0d805269b5 fix: subscription event loop, session_id for reconnect, audio feature gate
- Fix event_rx consumed by take() - now locks and polls each time
- Add session_id to restart subscription on reconnect
- Remove duplicate code in Connect handler
- Gate audio behind optional cpal feature (needs ALSA dev libs)
- tsclientlib audio feature disabled in workspace (needs cmake)
- 69 tests passing, clippy clean
2026-05-12 23:06:19 +09:00
ReTeamSpeak 2bbd1c41fd fix: use git dependency for tsclientlib (CI submodule compat)
Switch from path dependency to git dependency for tsclientlib/tsproto-packets
so CI can fetch them without needing git submodule initialization.
2026-05-12 22:24:24 +09:00
ReTeamSpeak 55c9b9fec8 refactor: switch to tsclientlib for TeamSpeak protocol
- Replace custom Session/SessionHandle with tsclientlib SyncConnection
- SyncConnection runs in background task, forwards events via mpsc
- iced subscription reads events from mpsc channel
- SendChannelMessage sends via SyncConnectionHandle::with_connection
- Disconnect sends DisconnectOptions via handle
- State refresh reads data::Connection (clients, channels, server)
- Keep tscore QueryClient for ServerQuery panel
- Add tsclientlib as path dependency (from refercence/)
- CI checkout with submodules: recursive for tsdeclarations
- 69 tests passing, clippy clean
2026-05-12 22:10:45 +09:00
ReTeamSpeak dce2f90544 fix: clippy warnings and CI Node.js deprecation
- Remove unused imports in session.rs, buffer.rs, engine.rs
- Allow dead_code on intentionally unused fields (decoder, received_at)
- Allow enum_variant_names on Message enum
- Add FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 to CI env
2026-05-12 21:32:29 +09:00
ReTeamSpeak b816b1308f fix: wire SessionHandle, event subscription, and message sending
- Store SessionHandle in Arc<Mutex> instead of dropping it
- Implement iced subscription with stream::channel to forward SessionEvent
- SendChannelMessage now sends via SessionHandle
- Disconnect sends clientdisconnect command then clears state
- ServerQuery panel now executes 'help' command via QueryClient
- Remove tracing_subscriber to avoid console window on Windows
- Request channel/client lists on connect
- Show connection errors and query responses in UI
2026-05-12 21:12:22 +09:00
ReTeamSpeak c4113373c8 feat: replace Tauri with iced for native cross-platform GUI
- Create iced-app crate with pure Rust GUI using iced 0.13
- Implement server list, connect form, channel/user panels, messaging
- Implement ServerQuery panel for direct server queries
- Add VoiceEngine in tsaudio for decode/buffer/playback pipeline
- Update CI to build iced binary (no frontend build step needed)
- Remove Tauri dependency from workspace
- 69 tests passing across all crates
2026-05-12 20:35:24 +09:00
ReTeamSpeak c878d74a6a feat: add voice packet handling to session
- Add VoiceData event to SessionEvent (codec, packet_id, audio_data, is_whisper)
- Session detects Voice/VoiceWhisper packets and emits VoiceData events
- Add shared_secret() and key_cache_mut() accessors to Client
- Voice packets bypass command processing for lower latency
- 67 tests passing across all crates
2026-05-12 20:15:56 +09:00
ReTeamSpeak 25af2cb295 feat: implement voice packet parsing, Opus codec, jitter buffer, and cpal playback
Voice packet parsing (tscore/protocol/voice.rs):
- VoicePacket and WhisperPacket types with parse/serialize
- Codec type, sample rate, channel count detection
- Tests for roundtrip and error cases

Opus codec (tsaudio/codec.rs):
- Real Opus encoder/decoder with opus crate (behind feature gate)
- 48kHz mono/stereo support
- Encode/decode with proper error handling

Jitter buffer (tsaudio/buffer.rs):
- Sequence number based reordering
- Adaptive output timing (20ms intervals)
- Packet loss handling with fallback to oldest frame
- Tests for basic and reorder scenarios

cpal playback (tsaudio/playback.rs):
- Default output device detection
- f32 and i16 sample format support
- Ring buffer for smooth playback
- Device listing

AudioFrame enhanced with sequence and codec fields.
2026-05-12 20:08:23 +09:00
ReTeamSpeak d34f5dd952 refactor: handle_data now returns HandleResult with parsed events
- Add CommandEvent enum (InitServer, ChannelList, ClientList, TextMessage, etc.)
- Add HandleResult struct with responses and events
- handle_data returns HandleResult instead of Vec<Vec<u8>>
- Session uses HandleResult for real event emission
- Update socket.rs and tests for new return type
2026-05-12 19:53:22 +09:00
ReTeamSpeak ace2f5deed feat: add session event polling, channel/client list, and text messages
- Add SessionEvent enum with Connected, ChannelList, ClientList, ServerInfo, TextMessage, ClientEntered, ClientLeft, ClientMoved
- Add poll_events, request_channel_list, request_client_list Tauri commands
- Session now parses initserver, channellist, clientlist, notify* commands and emits events
- Frontend polls events every 500ms when connected
- Frontend displays server info, channel list, client list, and text messages
2026-05-12 19:25:02 +09:00
ReTeamSpeak 7a767e9d2e fix: add Tauri template icons for Windows/macOS/Linux builds 2026-05-12 19:12:39 +09:00
ReTeamSpeak a1410b541b ci: revert FORCE_JAVASCRIPT_ACTIONS_TO_NODE24, let v4 actions run on Node.js 20 2026-05-12 18:58:34 +09:00
ReTeamSpeak 364b24bf19 ci: opt into Node.js 24 and fix checkout in opencode workflow 2026-05-12 18:47:36 +09:00
ReTeamSpeak 460ce6bb66 ci: restore full GitHub Actions workflow with v4 actions and multi-OS matrix 2026-05-12 18:43:24 +09:00
99 changed files with 5971 additions and 12063 deletions
+102 -94
View File
@@ -2,180 +2,188 @@ name: CI/CD
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
release:
types: [ created ]
tags:
- '*'
env:
CARGO_TERM_COLOR: always
CMAKE_POLICY_VERSION_MINIMUM: 3.5
jobs:
# Test job
test:
name: Test
runs-on: ubuntu-latest
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: windows-latest
target: x86_64-pc-windows-msvc
- os: macos-latest
target: x86_64-apple-darwin
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
with:
submodules: false
- name: Install Rust
shell: bash
run: |
if ! command -v rustup >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.cargo/bin:$PATH"
fi
rustup toolchain install stable --profile minimal --target x86_64-unknown-linux-gnu
rustup default stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install system dependencies
- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libdbus-1-dev \
cmake \
pkg-config \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libopus-dev \
libssl-dev \
libasound2-dev
- name: Install system dependencies (macOS)
if: runner.os == 'macOS'
run: brew install opus pkg-config
- name: Cache cargo
uses: actions/cache@v3
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
src/target
key: linux-cargo-${{ hashFiles('src/Cargo.lock') }}
restore-keys: linux-cargo-
key: ${{ runner.os }}-cargo-${{ hashFiles('src/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Check
working-directory: src
run: cargo check -p shared -p tscore -p tsaudio -p tsdb
run: cargo check -p re-teamspeak
- 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 -- -D warnings
run: cargo clippy -p re-teamspeak -- -D warnings
continue-on-error: true
# Build frontend
build-frontend:
name: Build Frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '22'
- name: Install dependencies
working-directory: src/tauri-app/frontend
run: npm install
- name: Build
working-directory: src/tauri-app/frontend
run: npm run build
- name: Upload frontend artifact
uses: actions/upload-artifact@v3
with:
name: frontend-dist
path: src/tauri-app/frontend/dist/
# Build desktop app (Linux only)
# Build desktop apps
build-desktop:
name: Build Desktop (Linux)
needs: [test, build-frontend]
runs-on: ubuntu-latest
name: Build Desktop (${{ matrix.platform }})
needs: test
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
platform: linux
target: x86_64-unknown-linux-gnu
artifact: linux-amd64
- os: windows-latest
platform: windows
target: x86_64-pc-windows-msvc
artifact: windows-amd64
- os: macos-latest
platform: macos
target: x86_64-apple-darwin
artifact: macos-amd64
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
with:
submodules: false
- name: Install Rust
shell: bash
run: |
if ! command -v rustup >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.cargo/bin:$PATH"
fi
rustup toolchain install stable --profile minimal --target x86_64-unknown-linux-gnu
rustup default stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install system dependencies
- name: Install system dependencies (Linux)
if: matrix.platform == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libdbus-1-dev \
cmake \
pkg-config \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libopus-dev \
libssl-dev \
libasound2-dev
- name: Download frontend artifact
uses: actions/download-artifact@v3
with:
name: frontend-dist
path: src/tauri-app/frontend/dist/
- name: Install system dependencies (macOS)
if: matrix.platform == 'macos'
run: brew install opus pkg-config
- name: Cache cargo
uses: actions/cache@v3
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
src/target
key: linux-cargo-${{ hashFiles('src/Cargo.lock') }}
restore-keys: linux-cargo-
key: ${{ runner.os }}-cargo-${{ hashFiles('src/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Build
working-directory: src
run: cargo build --release
run: cargo build --release -p re-teamspeak
- name: Package
- name: Package (Linux)
if: matrix.platform == 'linux'
run: |
mkdir -p dist
cp src/target/release/re-teamspeak dist/ || true
tar -czf re-teamspeak-linux-amd64.tar.gz -C dist .
cp src/target/release/re-teamspeak dist/
tar -czf re-teamspeak-${{ matrix.artifact }}.tar.gz -C dist .
- name: Package (Windows)
if: matrix.platform == 'windows'
run: |
mkdir dist
copy src\target\release\re-teamspeak.exe dist\
Compress-Archive -Path dist\* -DestinationPath re-teamspeak-${{ matrix.artifact }}.zip
- name: Package (macOS)
if: matrix.platform == 'macos'
run: |
mkdir -p dist
cp src/target/release/re-teamspeak dist/
tar -czf re-teamspeak-${{ matrix.artifact }}.tar.gz -C dist .
- name: Upload artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v7
with:
name: re-teamspeak-linux-amd64
path: re-teamspeak-linux-amd64.*
name: re-teamspeak-${{ matrix.artifact }}
path: re-teamspeak-${{ matrix.artifact }}.*
# Release
release:
name: Release
needs: build-desktop
if: github.event_name == 'release'
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download artifacts
uses: actions/download-artifact@v3
uses: actions/download-artifact@v8
with:
path: artifacts/
- name: Create release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v3
with:
files: artifacts/**/*
draft: true
tag_name: ${{ github.ref_name }}
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+14
View File
@@ -0,0 +1,14 @@
FROM registry.fedoraproject.org/fedora:44
RUN dnf install -y \
gcc \
pkg-config \
cmake \
openssl-devel \
alsa-lib-devel \
&& dnf clean all
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
WORKDIR /build
+108 -59
View File
@@ -1,83 +1,132 @@
# 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 an exported identity string or file
- Select input and output audio devices
- Push-to-talk and continuous voice modes
- Noise cancellation selection in settings
- Local per-user mute and volume controls in the channel tree
- Persistent bookmarks, favorites, recents, and settings
## Current Gaps
- Remote audio playback still needs runtime validation against a real TeamSpeak server
- The legacy `src/README.md` and root `Dockerfile` still need to be reconciled with the current app layout
## 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
### Audio Build
Host builds need system audio libraries and a Rust toolchain new enough for `sonora`.
On Linux that generally means `cmake`, `pkg-config`, `libasound2-dev`, and `libopus-dev`.
Direct host build:
```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
```
### Using Podman (no root)
Repeatable Podman build:
```bash
podman run --rm -v $(pwd):/workspace:Z -w /workspace/src \
docker.io/library/debian:trixie bash -c "
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
"
./scripts/check-audio-podman.sh
```
## Documentation
### Run the app in Podman
- [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
From a desktop shell with `WAYLAND_DISPLAY` or `DISPLAY` set:
```bash
./scripts/run-audio-podman.sh
```
The runtime wrapper will try to pass through:
- `/dev/snd`
- `/dev/dri`
- PulseAudio socket
- PipeWire socket
- user D-Bus socket
- Wayland socket or X11 socket
If you want to validate the container wiring without launching the GUI app, pass a custom command:
```bash
./scripts/run-audio-podman.sh -- /usr/bin/env
```
Override image or features when needed:
```bash
RETEAMSPEAK_AUDIO_IMAGE=docker.io/library/rust:1.91-bookworm \
./scripts/check-audio-podman.sh
```
Equivalent raw Podman command:
```bash
podman run --rm -v "$(pwd):/workspace:Z" -w /workspace/src \
docker.io/library/rust:1.91-bookworm bash -lc '
apt-get update >/dev/null &&
apt-get install -y --no-install-recommends pkg-config libasound2-dev libopus-dev >/dev/null &&
/usr/local/cargo/bin/cargo check -p re-teamspeak
'
```
## Repository Layout
```text
src/
├── Cargo.toml
└── iced-app/
├── Cargo.toml
└── src/
├── main.rs
├── audio.rs
├── identity.rs
├── icons.rs
├── noise_cancel.rs
├── persistence.rs
├── theme.rs
├── types.rs
└── view.rs
```
## Reference Material
- `refercence/tsclientlib/`
- `refercence/tsdeclarations/`
- `docs/protocol.md`
- `docs/protocol_stack.md`
## License
-96
View File
@@ -1,96 +0,0 @@
@echo off
REM ReTeamSpeak Windows 构建脚本
setlocal enabledelayedexpansion
set SCRIPT_DIR=%~dp0
set PROJECT_DIR=%SCRIPT_DIR%
set BUILD_DIR=%PROJECT_DIR%build
REM 检查依赖
echo [INFO] 检查依赖...
where rustc >nul 2>nul
if %errorlevel% neq 0 (
echo [ERROR] Rust 未安装。请访问 https://rustup.rs 安装 Rust。
exit /b 1
)
where node >nul 2>nul
if %errorlevel% neq 0 (
echo [ERROR] Node.js 未安装。请访问 https://nodejs.org 安装 Node.js。
exit /b 1
)
where npm >nul 2>nul
if %errorlevel% neq 0 (
echo [ERROR] npm 未安装。
exit /b 1
)
echo [INFO] 依赖检查完成。
REM 解析命令
if "%1"=="" goto :help
if "%1"=="all" goto :build_all
if "%1"=="desktop" goto :build_desktop
if "%1"=="frontend" goto :build_frontend
if "%1"=="clean" goto :clean
if "%1"=="help" goto :help
goto :help
:build_frontend
echo [INFO] 构建前端...
cd /d "%PROJECT_DIR%\src\tauri-app\frontend"
call npm install
call npm run build
if %errorlevel% neq 0 (
echo [ERROR] 前端构建失败。
exit /b 1
)
echo [INFO] 前端构建完成。
goto :eof
:build_desktop
echo [INFO] 构建桌面应用...
cd /d "%PROJECT_DIR%\src\tauri-app\src-tauri"
cargo build --release
if %errorlevel% neq 0 (
echo [ERROR] 桌面应用构建失败。
exit /b 1
)
echo [INFO] 桌面应用构建完成。
goto :eof
:build_all
echo [INFO] 构建所有平台...
call :build_frontend
call :build_desktop
echo [INFO] 所有平台构建完成。
goto :eof
:clean
echo [INFO] 清理构建...
if exist "%BUILD_DIR%" rmdir /s /q "%BUILD_DIR%"
cd /d "%PROJECT_DIR%\src\tauri-app\src-tauri"
cargo clean
cd /d "%PROJECT_DIR%\src\tauri-app\frontend"
if exist "node_modules" rmdir /s /q "node_modules"
if exist "dist" rmdir /s /q "dist"
echo [INFO] 清理完成。
goto :eof
:help
echo 用法: %0 [命令]
echo.
echo 命令:
echo all 构建所有平台
echo desktop 构建桌面应用
echo frontend 构建前端
echo clean 清理构建
echo help 显示帮助
echo.
echo 示例:
echo %0 all # 构建所有平台
echo %0 desktop # 仅构建桌面应用
goto :eof
-182
View File
@@ -1,182 +0,0 @@
#!/bin/bash
# ReTeamSpeak 跨平台构建脚本
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$SCRIPT_DIR"
BUILD_DIR="$PROJECT_DIR/build"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 检查依赖
check_dependencies() {
log_info "检查依赖..."
# 检查 Rust
if ! command -v rustc &> /dev/null; then
log_error "Rust 未安装。请访问 https://rustup.rs 安装 Rust。"
exit 1
fi
# 检查 Node.js
if ! command -v node &> /dev/null; then
log_error "Node.js 未安装。请访问 https://nodejs.org 安装 Node.js。"
exit 1
fi
# 检查 npm
if ! command -v npm &> /dev/null; then
log_error "npm 未安装。"
exit 1
fi
log_info "依赖检查完成。"
}
# 构建前端
build_frontend() {
log_info "构建前端..."
cd "$PROJECT_DIR/src/tauri-app/frontend"
npm install
npm run build
log_info "前端构建完成。"
}
# 构建桌面应用
build_desktop() {
log_info "构建桌面应用..."
cd "$PROJECT_DIR/src/tauri-app/src-tauri"
cargo build --release
log_info "桌面应用构建完成。"
}
# 构建 Android 应用
build_android() {
log_info "构建 Android 应用..."
cd "$PROJECT_DIR/src/tauri-app/src-tauri"
# 检查 Android SDK
if [ -z "$ANDROID_HOME" ]; then
log_error "ANDROID_HOME 环境变量未设置。"
exit 1
fi
cargo tauri android build
log_info "Android 应用构建完成。"
}
# 构建 iOS 应用
build_ios() {
log_info "构建 iOS 应用..."
cd "$PROJECT_DIR/src/tauri-app/src-tauri"
# 检查 Xcode
if ! command -v xcodebuild &> /dev/null; then
log_error "Xcode 未安装。"
exit 1
fi
cargo tauri ios build
log_info "iOS 应用构建完成。"
}
# 构建所有平台
build_all() {
log_info "构建所有平台..."
build_frontend
build_desktop
# 检查是否在 macOS 上
if [[ "$OSTYPE" == "darwin"* ]]; then
build_ios
fi
# 检查 Android SDK
if [ -n "$ANDROID_HOME" ]; then
build_android
fi
log_info "所有平台构建完成。"
}
# 清理构建
clean() {
log_info "清理构建..."
rm -rf "$BUILD_DIR"
cd "$PROJECT_DIR/src/tauri-app/src-tauri"
cargo clean
cd "$PROJECT_DIR/src/tauri-app/frontend"
rm -rf node_modules dist
log_info "清理完成。"
}
# 显示帮助
show_help() {
echo "用法: $0 [命令]"
echo ""
echo "命令:"
echo " all 构建所有平台"
echo " desktop 构建桌面应用"
echo " android 构建 Android 应用"
echo " ios 构建 iOS 应用"
echo " frontend 构建前端"
echo " clean 清理构建"
echo " help 显示帮助"
echo ""
echo "示例:"
echo " $0 all # 构建所有平台"
echo " $0 desktop # 仅构建桌面应用"
echo " $0 android # 仅构建 Android 应用"
}
# 主函数
main() {
check_dependencies
case "${1:-help}" in
all)
build_all
;;
desktop)
build_frontend
build_desktop
;;
android)
build_frontend
build_android
;;
ios)
build_frontend
build_ios
;;
frontend)
build_frontend
;;
clean)
clean
;;
help|*)
show_help
;;
esac
}
main "$@"
+7 -77
View File
@@ -1,79 +1,9 @@
# TeamSpeak 逆向工程分析文档
# Docs
本文档包含对 TeamSpeak 3 协议和相关代码库的分析结果,用于指导后续开发工作。
This directory now documents the current desktop client direction:
## 文档结构
- **[protocol.md](protocol.md)** - TeamSpeak 3 协议详细分析
- **[protocol_stack.md](protocol_stack.md)** - TeamSpeak 3 协议栈深度分析(层次结构、加密机制、可靠传输等)
- **[architecture.md](architecture.md)** - 系统架构分析 (SAD)
- **[components.md](components.md)** - 组件设计分析 (SDD)
- **[requirements.md](requirements.md)** - 需求分析 (SRS)
## 参考代码库
本分析基于以下参考代码库:
### 1. tsdeclarations
- **位置**: `refercence/tsdeclarations/`
- **描述**: TeamSpeak 3 协议的机器可读定义,包含消息、数据包、枚举、错误码等定义
- **关键文件**:
- `ts3protocol.md` - 协议详细规范
- `Packets.txt` - 数据包结构定义
- `Messages.toml` - 消息定义
- `Book.toml` - 数据结构定义
- `Enums.toml` - 枚举类型定义
- `Errors.csv` - 错误码定义
### 2. tsclientlib
- **位置**: `refercence/tsclientlib/`
- **描述**: Rust 实现的 TeamSpeak 客户端库,提供高级 API
- **关键组件**:
- `tsclientlib` - 主客户端库
- `tsproto` - 底层协议实现
- `ts-bookkeeping` - 客户端/频道状态管理
- `tsproto-packets` - 数据包解析
- `tsproto-structs` - 协议结构体
- `tsproto-types` - 基础类型定义
### 3. Qint
- **位置**: `refercence/Qint/`
- **描述**: 现代化的 TeamSpeak 客户端,基于 Tauri 框架
- **技术栈**:
- 后端: Rust (Tauri)
- 前端: TypeScript/JavaScript
- 代理层: Rust (websocket/web)
### 4. SimpleBot
- **位置**: `refercence/SimpleBot/`
- **描述**: 简单的 TeamSpeak 聊天机器人,展示 tsclientlib 的使用
- **功能**: 连接到服务器,响应特定消息,支持脚本执行
### 5. ts3stats
- **位置**: `refercence/ts3stats/`
- **描述**: TeamSpeak 3 服务器统计工具
- **技术栈**: Python
- **功能**: 分析服务器日志,生成用户统计图表
## 协议概述
TeamSpeak 3 使用基于 UDP 的自定义协议,具有以下特点:
1. **加密**: 使用 AES-128-CTR + OMAC (EAX 模式)
2. **压缩**: 使用 QuickLZ 算法
3. **分片**: 支持大数据包分片传输
4. **可靠性**: 使用选择性重传机制确保可靠传输
5. **身份验证**: 使用 ECDH 密钥交换和 RSA 拼图防攻击
## 开发建议
基于分析结果,建议开发工作遵循以下原则:
1. **协议兼容性**: 严格遵循 tsdeclarations 中的协议定义
2. **代码复用**: 优先使用 tsclientlib 作为底层库
3. **模块化设计**: 参考 Qint 的架构,分离前端、代理和核心逻辑
4. **测试覆盖**: 参考 SimpleBot 的测试方法,确保协议兼容性
## 法律声明
本分析仅用于学习和研究目的。TeamSpeak 是 TeamSpeak Systems GmbH 的商标。开发的客户端应遵守相关法律法规,不得用于商业用途或侵犯 TeamSpeak 的商业模式。
- `SRS.md` - current product scope and user-facing requirements
- `SAD.md` - current architecture (`iced` + `tsclientlib`)
- `SDD.md` - implementation notes for the iced app
- `protocol.md` - TeamSpeak protocol research reference
- `protocol_stack.md` - lower-level protocol stack notes
+33 -453
View File
@@ -1,472 +1,52 @@
# Software Architecture Document (SAD)
# ReTeamSpeak - Cross-Platform TeamSpeak Client
# Software Architecture Document
**Version**: 1.0.0
**Date**: 2026-05-12
**Status**: Based on actual implementation
## Overview
---
The current application is a single desktop client crate built around `iced` and `tsclientlib`.
## 1. Architectural Overview
## Architecture
### 1.1 System Context
```
┌─────────────────────────────────────────────────────────────────┐
│ User Environment │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Desktop │ │ Web │ │ Mobile │ │
│ │ Windows │ │ Browser │ │ iOS / Android │ │
│ │ macOS │ │ │ │ │ │
│ │ Linux │ │ │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │
│ │ │ │ │
│ └─────────────────┼──────────────────────┘ │
│ │ │
│ ┌──────▼───────┐ │
│ │ ReTeamSpeak │ │
│ │ Client │ │
│ └──────┬───────┘ │
│ │ │
│ ┌──────▼───────┐ │
│ │ TS3 Server │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```text
User
-> iced UI (`src/iced-app/src/main.rs`)
-> tsclientlib sync connection/state
-> optional local audio pipeline (`audio.rs`, `noise_cancel.rs`)
-> TeamSpeak server
```
### 1.2 Layered Architecture
## Major Components
```
┌─────────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ React Frontend (TypeScript) │ │
│ │ - Connection UI, Chat, Channel Tree, Settings │ │
│ └────────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Application Layer │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Tauri Shell (Rust) │ │
│ │ - Command handlers, State management, IPC bridge │ │
│ └────────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Business Logic Layer │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ tscore │ │ tsaudio │ │ tsdb │ │ shared │ │
│ │ Protocol │ │ Audio │ │ Database │ │ Types │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Infrastructure Layer │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ - Tokio (Async Runtime) │ │
│ │ - rusqlite (SQLite) │ │
│ │ - cpal (Audio I/O) │ │
│ │ - AES/EAX, SHA, ECDH (Crypto) │ │
│ └────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### UI Layer
---
- `main.rs`
- Handles navigation, connection flow, channel/client/chat rendering, settings UI
## 2. Module Architecture
### TeamSpeak Integration
### 2.1 Crate Dependency Graph
- `tsclientlib`
- Connection setup, state mirror, channel moves, text messages, client updates, voice packet send/receive
```
┌─────────────────┐
│ tauri-app │
│ (Application) │
└────┬───┬───┬────┘
│ │ │
┌────────────┘ │ └────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ tscore │ │ tsaudio │ │ tsdb │
│ Protocol │ │ Audio │ │ Database │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└───────────────┼──────────────────┘
┌──────────┐
│ shared │
│ Types │
└──────────┘
```
### Audio Layer
### 2.2 Module Responsibilities
- `audio.rs`
- `cpal` input/output, Opus encode, simple voice activation logic
#### `shared` - Shared Types Library
**Path**: `src/shared/`
**Lines**: ~700
**Purpose**: Defines core data types shared across all modules
### Noise Reduction
| File | Responsibility |
|------|---------------|
| `types.rs` | Core types: ClientId, ChannelId, ServerGroupId, Codec, ConnectionState, etc. |
| `events.rs` | Event types: AppEvent, ConnectionEvent, ClientEvent, ChannelEvent, etc. |
| `errors.rs` | Error types: AppError with variants for each subsystem |
| `config.rs` | Configuration: ConfigManager, SavedConnection, RecentServer |
- `noise_cancel.rs`
- Supports `None`, `nnnoiseless`, and `sonora`
**Key Types**:
```rust
pub struct ClientId(pub u16);
pub struct ChannelId(pub u64);
pub struct ServerGroupId(pub u64);
pub struct Uid(pub String);
### Identity Import
pub enum ConnectionState {
Disconnected, Connecting, IdentityLevelIncreasing,
Connected, ChannelListFinished, DisconnectedTemporarily, Error,
}
- `identity.rs`
- Reads TeamSpeak `settings.db` and imports `identity_secret_key`
pub enum Codec {
SpeexNarrowband, SpeexWideband, SpeexUltrawideband,
CeltMono, OpusVoice, OpusMusic,
}
```
## Removed Architecture
---
The following older architecture is no longer part of the product:
#### `tscore` - Protocol Core Library
**Path**: `src/tscore/`
**Lines**: ~1800
**Purpose**: Implements TS3 protocol (packets, encryption, connection)
**Sub-modules**:
##### `protocol/` - Packet and Command Handling
| File | Lines | Responsibility |
|------|-------|---------------|
| `packet.rs` | 607 | Packet structures (InPacket, OutPacket, Header, InitPacket) |
| `types.rs` | 227 | Protocol types (PacketType, CodecType, GroupWhisperType) |
| `commands.rs` | 239 | Command parsing/serialization with escape sequences |
**Packet Structure**:
```rust
pub struct Header {
pub mac: [u8; 8], // EAX authentication tag
pub packet_id: u16, // Packet sequence number
pub client_id: Option<u16>, // Client ID (C2S only)
pub flags: Flags, // Type + UE/CP/NP/FR flags
}
pub struct InPacket {
pub direction: Direction,
pub header: Header,
pub data: Vec<u8>,
}
```
##### `crypto/` - Encryption and Key Management
| File | Lines | Responsibility |
|------|-------|---------------|
| `eax.rs` | 118 | AES-128-EAX encrypt/decrypt |
| `keys.rs` | 229 | Key derivation, KeyCache, SharedSecret |
| `hash.rs` | 50 | SHA-1/256/512 hash functions |
**Key Derivation**:
```rust
fn create_key_nonce(
packet_type: PacketType,
direction: Direction,
generation_id: u32,
iv: &[u8; 64],
) -> ([u8; 16], [u8; 16]) {
// SHA-256(direction | type | generation_id | iv)
// Returns (key, nonce) for AES-EAX
}
```
##### `connection/` - Connection Management
| File | Lines | Responsibility |
|------|-------|---------------|
| `client.rs` | 519 | Client connection with full handshake implementation |
| `state.rs` | 90 | Connection state machine |
| `resend.rs` | 240 | Packet retransmission with RTT estimation |
**Handshake Flow**:
```rust
impl Client {
pub fn start_handshake(&mut self) -> Result<Vec<u8>, ProtocolError>;
pub fn handle_data(&mut self, data: &[u8]) -> Result<Vec<Vec<u8>>, ProtocolError>;
fn build_init2(&mut self) -> Result<Vec<u8>, ProtocolError>;
fn build_init4(&mut self) -> Result<Vec<u8>, ProtocolError>;
fn handle_initivexpand(&mut self, cmd: &Command) -> Result<Vec<u8>, ProtocolError>;
fn handle_initivexpand2(&mut self, cmd: &Command) -> Result<Vec<u8>, ProtocolError>;
fn solve_rsa_puzzle(x: &[u8; 64], n: &[u8; 64], level: u32) -> [u8; 64];
}
```
---
#### `tsaudio` - Audio Engine
**Path**: `src/tsaudio/`
**Lines**: ~280
**Purpose**: Audio capture, playback, and codec operations
| File | Lines | Responsibility |
|------|-------|---------------|
| `lib.rs` | 80 | Core types (AudioConfig, AudioFrame, AudioError) |
| `capture.rs` | 30 | Audio capture (cpal-based, optional) |
| `playback.rs` | 30 | Audio playback (cpal-based, optional) |
| `codec.rs` | 55 | Opus encoder/decoder (optional) |
| `vad.rs` | 45 | Voice Activity Detection |
| `buffer.rs` | 65 | Jitter buffer for smooth playback |
**Audio Frame**:
```rust
pub struct AudioFrame {
pub sample_rate: u32, // 48000 Hz
pub channels: u16, // 1 (mono) or 2 (stereo)
pub samples: Vec<f32>, // PCM samples
}
```
---
#### `tsdb` - Database Layer
**Path**: `src/tsdb/`
**Lines**: ~400
**Purpose**: SQLite database for persistent storage
| File | Lines | Responsibility |
|------|-------|---------------|
| `lib.rs` | 104 | Database initialization and table creation |
| `identity.rs` | 115 | Identity CRUD operations |
| `bookmark.rs` | 176 | Bookmark CRUD operations |
| `message.rs` | 139 | Message storage and retrieval |
| `config.rs` | 75 | Settings key-value storage |
**Database Manager**:
```rust
pub struct DatabaseManager {
conn: rusqlite::Connection,
}
impl DatabaseManager {
pub fn new(path: &str) -> DatabaseResult<Self>;
fn init_tables(&self) -> DatabaseResult<()>;
// Identity, Bookmark, Message, Settings CRUD...
}
```
---
#### `tauri-app` - Application Shell
**Path**: `src/tauri-app/`
**Lines**: ~500
##### `src-tauri/` - Rust Backend
| File | Lines | Responsibility |
|------|-------|---------------|
| `lib.rs` | 60 | Tauri setup, plugin registration, state init |
| `main.rs` | 5 | Entry point |
| `commands.rs` | 155 | Tauri command handlers (IPC bridge) |
| `state.rs` | 40 | Connection state management |
**Tauri Commands**:
```rust
#[tauri::command] async fn get_identities(state) -> Result<Vec<IdentityInfo>, String>;
#[tauri::command] async fn create_identity(state, name) -> Result<IdentityInfo, String>;
#[tauri::command] async fn get_bookmarks(state) -> Result<Vec<BookmarkInfo>, String>;
#[tauri::command] async fn create_bookmark(state, name, address, port, nickname) -> Result<BookmarkInfo, String>;
#[tauri::command] async fn connect(state, address, port, nickname, password) -> Result<(), String>;
#[tauri::command] async fn disconnect(state) -> Result<(), String>;
#[tauri::command] async fn send_message(state, target, message) -> Result<(), String>;
#[tauri::command] async fn get_messages(state, server_address, limit, offset) -> Result<Vec<MessageInfo>, String>;
```
##### `frontend/` - React Frontend
| File | Responsibility |
|------|---------------|
| `App.tsx` | Main application component |
| `main.tsx` | Entry point |
| `styles.css` | Application styles |
---
## 3. Data Flow Architecture
### 3.1 Connection Establishment
```
User Frontend Tauri Shell tscore
│ │ │ │
│── Connect(addr) ───────>│ │ │
│ │── connect() ────────>│ │
│ │ │── Client::new() ─>│
│ │ │ │
│ │ │<── start_handshake│
│ │ │ (Init0) │
│ │ │ │
│ │ │──── UDP Send ────>│
│ │ │ │
│ │ │<── handle_data ───│
│ │ │ (Init1) │
│ │ │ │
│ │ │ ... (Init2-4) ... │
│ │ │ │
│ │ │<── Connected ─────│
│ │<── Connected ────────│ │
│<── Connected ───────────│ │ │
```
### 3.2 Voice Data Flow
```
Microphone ──> cpal capture ──> VAD ──> Opus encode ──> Voice packet
UDP send
Speaker <── cpal playback <── Jitter buffer <── Opus decode <── Voice packet
```
### 3.3 Message Flow
```
User input ──> Frontend ──> Tauri command ──> tscore
Command serialization
Encryption (AES-EAX)
UDP send
Server ──> UDP recv ──> Decrypt ──> Parse ──> Event ──> Frontend
```
---
## 4. Cross-Cutting Concerns
### 4.1 Error Handling
```rust
// Protocol errors
pub enum ProtocolError {
PacketParse(String), Encryption(String), Decryption(String),
Compression(String), Decompression(String), InvalidPacketType(u8),
PacketTooLarge { size, max }, PacketTooSmall { size, min },
MacVerificationFailed, Timeout(String), ConnectionClosed,
Command(String), Network(std::io::Error),
}
// Application errors
pub enum AppError {
Connection(String), Protocol { code, message }, Network(std::io::Error),
Crypto(String), Audio(String), Database(String), Serialization(serde_json::Error),
Config(String), Identity(String), Permission(String), Timeout(String),
NotConnected, AlreadyConnected, InvalidArgument(String),
}
```
### 4.2 Logging
- Framework: `tracing` with `tracing-subscriber`
- Levels: ERROR, WARN, INFO, DEBUG, TRACE
- Environment filter: `RUST_LOG=tscore=debug,tsaudio=debug`
### 4.3 Configuration
- Format: TOML
- Location: Platform-specific app data directory
- Encryption: ChaCha20-Poly1305 for identity keys
---
## 5. Deployment Architecture
### 5.1 Desktop (Windows/macOS/Linux)
```
┌─────────────────────────────────────┐
│ Tauri Application │
│ ┌───────────────────────────────┐ │
│ │ WebView (System) │ │
│ │ React Frontend (dist/) │ │
│ └───────────────────────────────┘ │
│ ┌───────────────────────────────┐ │
│ │ Rust Backend (lib) │ │
│ │ tscore + tsaudio + tsdb │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
```
### 5.2 Mobile (iOS/Android)
```
┌─────────────────────────────────────┐
│ Tauri Mobile App │
│ ┌───────────────────────────────┐ │
│ │ WebView (Platform) │ │
│ │ React Frontend (dist/) │ │
│ └───────────────────────────────┘ │
│ ┌───────────────────────────────┐ │
│ │ Rust Backend (cdylib) │ │
│ │ + Platform audio (Oboe) │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
```
---
## 6. Performance Characteristics
### 6.1 Measured Performance
- **Connection time**: ~200ms (local network)
- **Packet encryption**: ~10μs per packet
- **RSA puzzle (level 8)**: ~100ms
- **Memory usage**: ~50MB (idle)
### 6.2 Scalability
- **Max packet size**: 500 bytes
- **Max decompressed size**: 2MB
- **Fragment queue limit**: 200 packets
- **Resend timeout**: 500ms initial, exponential backoff
---
## 7. Security Architecture
### 7.1 Encryption Layers
1. **Transport**: AES-128-EAX per packet
2. **Key Exchange**: ECDH (P-256 for identity, Curve25519 for session)
3. **Storage**: ChaCha20-Poly1305 for identity keys
4. **Passwords**: base64(sha1(password))
### 7.2 Anti-DoS
- RSA puzzle computation (configurable difficulty)
- Hash Cash for identity verification
- Rate limiting (planned)
---
## 8. Build and Test
### 8.1 Build System
- **Rust**: Cargo workspace
- **Frontend**: npm + Vite
- **Desktop**: Tauri CLI
- **CI/CD**: GitHub Actions
### 8.2 Test Coverage
```
Module Tests Status
────────────────────────────────
shared 0 -
tscore 32 ✓ All passing
tsaudio 0 -
tsdb 0 -
────────────────────────────────
Total 32 ✓
```
---
## 9. References
- TS3 Protocol Paper: `refercence/tsdeclarations/ts3protocol.md`
- tsclientlib: `refercence/tsclientlib/` (reference implementation)
- Qint: `refercence/Qint/` (reference UI)
- Tauri v2: https://tauri.app
- Tauri application shell
- Custom `tscore` protocol implementation
- Custom `tsaudio` audio engine
- Custom `tsdb` persistence layer
- Shared internal model crate used only by the removed stack
+33 -718
View File
@@ -1,729 +1,44 @@
# Software Design Document (SDD)
# ReTeamSpeak - Cross-Platform TeamSpeak Client
# Software Design Document
**Version**: 1.0.0
**Date**: 2026-05-12
**Status**: Based on actual implementation
## iced-app
---
### `main.rs`
## 1. Detailed Design
- Defines the full app state and message enum
- Uses `SyncConnection` / `SyncConnectionHandle` from `tsclientlib`
- Maintains local UI mirrors for:
- bookmarks
- server metadata
- channels
- clients
- chat messages
- device selection
- mute / AFK state
### 1.1 `shared` Module Design
### `audio.rs`
#### 1.1.1 Core Type System
- Output playback through `cpal`
- Input capture through `cpal`
- Opus encode for outgoing voice
- Simple VAD for continuous talk mode
```
┌─────────────────────────────────────────────────────────────┐
│ Type Hierarchy │
├─────────────────────────────────────────────────────────────┤
│ Identifier Types (Newtype Pattern) │
│ ├── ClientId(u16) // In-session client ID │
│ ├── ChannelId(u64) // Channel identifier │
│ ├── ServerGroupId(u64) // Server group │
│ ├── ChannelGroupId(u64) // Channel group │
│ ├── ClientDbId(u64) // Database client ID │
│ ├── Uid(String) // Unique identity (base64) │
│ ├── PermissionId(u32) // Permission ID │
│ └── IconId(i32) // Icon identifier │
│ │
│ Enumerations │
│ ├── Codec { SpeexNB, SpeexWB, SpeexUWB, Celt, OpusVoice, │
│ │ OpusMusic } │
│ ├── ChannelType { Permanent, SemiPermanent, Temporary } │
│ ├── ClientType { Normal, Query { admin } } │
│ ├── ConnectionState { Disconnected, Connecting, ... } │
│ ├── Reason { None, Moved, LostConnection, KickChannel, ... }│
│ ├── CodecEncryptionMode { PerChannel, ForcedOff, ForcedOn }│
│ ├── HostMessageMode { None, Log, Modal, Modalquit } │
│ ├── GroupType { Template, Regular, Query } │
│ └── GroupNamingMode { None, Before, After } │
│ │
│ Data Structures │
│ ├── ServerInfo { id, name, platform, version, max_clients, │
│ │ clients_online, ... } │
│ ├── ChannelInfo { id, parent_id, name, codec, max_clients, │
│ │ channel_type, ... } │
│ ├── ClientInfo { id, channel_id, uid, name, muted, ... } │
│ ├── ChatMessage { id, timestamp, invoker, target, message }│
│ └── AppConfig { nickname, audio, hotkeys, theme, ... } │
└─────────────────────────────────────────────────────────────┘
```
### `noise_cancel.rs`
#### 1.1.2 Event System Design
- Runtime-selected noise cancellation backend
- Current backends:
- `None`
- `nnnoiseless`
- `sonora`
```
AppEvent
├── Connection(ConnectionEvent)
│ ├── Connecting { address }
│ ├── Connected { server, own_client }
│ ├── StateChanged { state }
│ ├── DisconnectedTemporarily { reason }
│ ├── Disconnected { reason }
│ └── ConnectionFailed { error }
├── Client(ClientEvent)
│ ├── EnteredView { client, reason }
│ ├── LeftView { client_id, reason, reason_message }
│ ├── Updated { client_id, changes: ClientChanges }
│ ├── Moved { client_id, from_channel, to_channel, reason }
│ ├── StartedTalking { client_id }
│ ├── StoppedTalking { client_id }
│ ├── ServerGroupChanged { client_id, group_id, added }
│ └── ChannelGroupChanged { client_id, group_id }
├── Channel(ChannelEvent)
│ ├── Created { channel }
│ ├── Deleted { channel_id }
│ ├── Updated { channel_id, changes: ChannelChanges }
│ ├── Moved { channel_id, new_parent, new_order }
│ ├── PasswordChanged { channel_id }
│ ├── DescriptionChanged { channel_id }
│ └── Subscribed { channel_id, subscribed }
├── Server(ServerEvent)
│ ├── Updated { changes: ServerChanges }
│ ├── ServerGroupList { groups }
│ └── ChannelGroupList { groups }
├── Message(MessageEvent)
│ ├── Received { message }
│ ├── Sent { message }
│ ├── Read { message_id }
│ └── UnreadCountChanged { count }
├── Audio(AudioEvent)
│ ├── InputDeviceChanged { device }
│ ├── OutputDeviceChanged { device }
│ ├── InputVolumeChanged { volume }
│ ├── OutputVolumeChanged { volume }
│ ├── InputMutedChanged { muted }
│ ├── OutputMutedChanged { muted }
│ ├── DeviceList { input_devices, output_devices }
│ ├── InputLevel { level }
│ └── OutputLevel { level }
├── FileTransfer(FileTransferEvent)
│ ├── Started { transfer_id, file_name, file_size, is_upload }
│ ├── Progress { transfer_id, progress }
│ ├── Completed { transfer_id }
│ ├── Failed { transfer_id, error }
│ └── Cancelled { transfer_id }
└── Error(ErrorEvent)
├── Protocol { code, message }
├── Network { message }
├── Audio { message }
├── Database { message }
└── Other { message }
```
### `identity.rs`
---
- Locates TeamSpeak config database in common Linux paths
- Extracts `identity_secret_key`
- Parses it into `tsclientlib::Identity`
### 1.2 `tscore` Module Design
## Known Design Limitations
#### 1.2.1 Packet Processing Pipeline
```
SEND RECEIVE
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ Command String │ │ UDP Packet │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ Command::serialize() │ │ InPacket::parse() │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ QuickLZ compress │ │ AES-EAX decrypt │
│ (if Command/CommandLow) │ │ (or fake decrypt) │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ Fragment (if > 500 bytes) │ │ QuickLZ decompress │
│ │ │ │ (if COMPRESSED flag) │
│ ▼ │ │ │ │
│ AES-EAX encrypt │ │ ▼ │
│ (or fake encrypt) │ │ Defragment │
│ │ │ │ (if FRAGMENTED flag) │
│ ▼ │ │ │ │
│ Assign Packet ID │ │ ▼ │
│ │ │ │ Command::parse() │
│ ▼ │ │ │ │
│ UDP Send │ │ ▼ │
└──────────────────────────────┘ │ Application Layer │
└──────────────────────────────┘
```
#### 1.2.2 Encryption Key Derivation
```
Input: packet_type, direction, generation_id, shared_iv[64]
┌───────────────────────────────────────────────────────┐
│ temp[0] = direction_byte (0x30=S2C, 0x31=C2S) │
│ temp[1] = packet_type.u8() │
│ temp[2..6] = generation_id.to_be_bytes() │
│ temp[6..70] = shared_iv[0..64] │
│ │
│ key_nonce = SHA-256(temp) │
│ key = key_nonce[0..16] │
│ nonce = key_nonce[16..32] │
│ │
│ key[0] ^= (packet_id >> 8) as u8 │
│ key[1] ^= (packet_id & 0xFF) as u8 │
└───────────────────────────────────────────────────────┘
Output: key[16], nonce[16] → AES-128-EAX
```
#### 1.2.3 Connection State Machine
```
┌──────────────┐
│ Disconnected │
└──────┬───────┘
│ start_handshake()
┌──────────────┐
┌──────│ Connecting │◄─────────────────┐
│ └──────┬───────┘ │
│ │ Init1 received │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ IdentityLevelIncreasing│ │
│ └──────┬───────────────┘ │
│ │ Init3 received │
│ ▼ │
│ ┌──────────────┐ │
│ │ Connected │──────────────────┤
│ └──────┬───────┘ │
│ │ channellistfinished │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ ChannelListFinished │ │
│ └──────┬──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────┐ │
└─────>│ DisconnectedTemporarily │──────┘
└──────┬───────────────────┘
│ timeout / manual
┌──────────────┐
│ Error │
└──────┬───────┘
┌──────────────┐
│ Disconnected │
└──────────────┘
```
#### 1.2.4 RSA Puzzle Solver
```rust
/// Solves y = x^(2^level) mod n
///
/// Algorithm:
/// y = x
/// for i in 0..level:
/// y = (y * y) mod n
///
/// Time complexity: O(level * M(n)) where M(n) is multiplication cost
/// Space complexity: O(n) for big integer storage
fn solve_rsa_puzzle(x: &[u8; 64], n: &[u8; 64], level: u32) -> [u8; 64] {
let x_big = BigUint::from_bytes_be(x);
let n_big = BigUint::from_bytes_be(n);
let mut y = x_big;
for _ in 0..level {
y = (y.clone() * y) % &n_big;
}
// Convert back to 64-byte array (big-endian, zero-padded)
}
```
#### 1.2.5 Retransmission System
```
┌─────────────────────────────────────────────────────────┐
│ ResendManager │
├─────────────────────────────────────────────────────────┤
│ pending: BTreeMap<PacketId, SentPacket> │
│ max_retries: u32 (default: 10) │
│ connection_timeout: Duration (default: 30s) │
├─────────────────────────────────────────────────────────┤
│ add_sent(id, data) → Add to pending queue │
│ ack(id) → bool → Remove from pending │
│ get_retransmissions() → Vec<(id, data)> to resend │
│ is_connection_timeout()→ Check for dead connection │
├─────────────────────────────────────────────────────────┤
│ │
│ SentPacket: │
│ data: Vec<u8> │
│ sent_at: Instant │
│ retry_count: u32 │
│ timeout: Duration (starts at 500ms, doubles) │
│ │
│ RttEstimator: │
│ srtt: Duration (smoothed RTT) │
│ rtt_var: Duration (RTT variance) │
│ rto: Duration (retransmission timeout) │
│ update(measured_rtt) → recalculate SRTT, RTO │
└─────────────────────────────────────────────────────────┘
```
---
### 1.3 `tsaudio` Module Design
#### 1.3.1 Audio Pipeline
```
CAPTURE PIPELINE:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ cpal │───>│ VAD │───>│ Opus │───>│ Packet │
│ capture │ │ detect │ │ encode │ │ output │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
PLAYBACK PIPELINE:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Packet │───>│ Jitter │───>│ Opus │───>│ cpal │
│ input │ │ buffer │ │ decode │ │ playback │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
```
#### 1.3.2 Voice Activity Detection
```rust
pub struct VadDetector {
threshold: f32,
state: VadState,
}
impl VadDetector {
pub fn detect(&mut self, samples: &[f32]) -> VadState {
let energy = samples.iter().map(|s| s * s).sum::<f32>()
/ samples.len() as f32;
if energy > self.threshold {
self.state = VadState::Speaking;
} else {
self.state = VadState::Silent;
}
self.state
}
}
```
#### 1.3.3 Jitter Buffer
```
┌─────────────────────────────────────────────────────────┐
│ JitterBuffer │
├─────────────────────────────────────────────────────────┤
│ buffer: Vec<Option<AudioFrame>> (ring buffer) │
│ head: usize │
│ tail: usize │
│ size: usize │
│ capacity: usize │
├─────────────────────────────────────────────────────────┤
│ push(frame) → Result<()> // Add frame │
│ pop() → Option<AudioFrame> // Get next frame │
│ len() → usize // Current buffer size │
│ is_empty() → bool │
│ is_full() → bool │
│ clear() // Reset buffer │
└─────────────────────────────────────────────────────────┘
```
---
### 1.4 `tsdb` Module Design
#### 1.4.1 Database Schema
```sql
-- Identity storage
CREATE TABLE identities (
id TEXT PRIMARY KEY, -- UUID
name TEXT NOT NULL, -- Display name
private_key TEXT NOT NULL, -- Base64 encoded ECC private key
counter INTEGER DEFAULT 0, -- Hash Cash counter
max_counter INTEGER DEFAULT 0, -- Maximum counter tried
created_at TEXT NOT NULL, -- ISO 8601 timestamp
updated_at TEXT NOT NULL -- ISO 8601 timestamp
);
-- Server bookmarks
CREATE TABLE bookmarks (
id TEXT PRIMARY KEY, -- UUID
name TEXT NOT NULL, -- Display name
address TEXT NOT NULL, -- Server address
port INTEGER DEFAULT 9987, -- Server port
nickname TEXT, -- Preferred nickname
server_password TEXT, -- Encrypted server password
channel TEXT, -- Default channel
channel_password TEXT, -- Encrypted channel password
default_token TEXT, -- Permission token
auto_connect INTEGER DEFAULT 0,-- Auto-connect on startup
last_connected TEXT, -- Last connection timestamp
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Message history
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_address TEXT NOT NULL, -- Server address
invoker_id INTEGER NOT NULL, -- Client ID
invoker_name TEXT NOT NULL, -- Display name
invoker_uid TEXT NOT NULL, -- Unique ID
target_type TEXT NOT NULL, -- "server", "channel", "client"
target_id INTEGER, -- Target ID
message TEXT NOT NULL, -- Message content
is_read INTEGER DEFAULT 0, -- Read status
timestamp TEXT NOT NULL -- ISO 8601 timestamp
);
-- Key-value settings
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
```
#### 1.4.2 CRUD Operations
```rust
impl DatabaseManager {
// Identity operations
fn create_identity(&self, name, private_key) -> DatabaseResult<Identity>;
fn get_identity(&self, id) -> DatabaseResult<Identity>;
fn get_all_identities(&self) -> DatabaseResult<Vec<Identity>>;
fn update_identity(&self, id, name?, counter?) -> DatabaseResult<()>;
fn delete_identity(&self, id) -> DatabaseResult<()>;
// Bookmark operations
fn create_bookmark(&self, name, address, port, nickname?) -> DatabaseResult<Bookmark>;
fn get_bookmark(&self, id) -> DatabaseResult<Bookmark>;
fn get_all_bookmarks(&self) -> DatabaseResult<Vec<Bookmark>>;
fn update_bookmark(&self, id, name?, address?, port?, nickname?) -> DatabaseResult<()>;
fn delete_bookmark(&self, id) -> DatabaseResult<()>;
// Message operations
fn create_message(&self, server_address, invoker_id, invoker_name,
invoker_uid, target_type, target_id?, message) -> DatabaseResult<Message>;
fn get_server_messages(&self, server_address, limit, offset) -> DatabaseResult<Vec<Message>>;
fn mark_message_read(&self, id) -> DatabaseResult<()>;
// Settings operations
fn get_setting(&self, key) -> DatabaseResult<Option<String>>;
fn set_setting(&self, key, value) -> DatabaseResult<()>;
}
```
---
### 1.5 `tauri-app` Design
#### 1.5.1 Tauri Command Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Frontend (React) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ invoke("get_bookmarks") → Promise<BookmarkInfo[]> │ │
│ │ invoke("connect", {addr, port, nick, pass}) │ │
│ │ invoke("send_message", {target, message}) │ │
│ └───────────────────────────────────────────────────────┘ │
└───────────────────────────┬─────────────────────────────────┘
│ Tauri IPC
┌─────────────────────────────────────────────────────────────┐
│ Tauri Shell (Rust) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ #[tauri::command] │ │
│ │ async fn get_bookmarks(state: State<AppState>) │ │
│ │ -> Result<Vec<BookmarkInfo>, String> │ │
│ │ { │ │
│ │ state.db.get_all_bookmarks() │ │
│ │ .map(|b| b.into_iter().map(Into::into)) │ │
│ │ .map_err(|e| e.to_string()) │ │
│ │ } │ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ AppState { │ │
│ │ db: DatabaseManager, │ │
│ │ connection_state: Mutex<ConnectionState>, │ │
│ │ } │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
#### 1.5.2 React Component Structure
```
App
├── Header
│ ├── Logo
│ ├── ConnectionStatus
│ └── SettingsButton
├── Sidebar
│ ├── BookmarkList
│ │ └── BookmarkItem (clickable)
│ └── RecentServers
├── MainContent
│ ├── ConnectForm (when no connection)
│ │ ├── AddressInput
│ │ ├── NicknameInput
│ │ ├── PasswordInput
│ │ └── ConnectButton
│ │
│ └── ChatView (when connected)
│ ├── ChannelTree
│ ├── ClientList
│ ├── MessageList
│ └── MessageInput
└── StatusBar
├── ConnectionInfo
├── AudioStatus
└── LatencyDisplay
```
---
## 2. Algorithm Specifications
### 2.1 Shared Secret Computation (Old Protocol <3.1)
```
Input: alpha[10], beta[10], shared_data[32]
Output: SharedIV[64], SharedMac[8]
1. SharedIV[0..20] = SHA-1(shared_data)
2. SharedIV[0..10] ^= alpha[0..10]
3. SharedIV[10..20] ^= beta[0..10]
4. SharedMac[0..8] = SHA-1(SharedIV)[0..8]
```
### 2.2 Shared Secret Computation (New Protocol ≥3.1)
```
Input: alpha[10], beta[54], shared_data[32]
Output: SharedIV[64], SharedMac[8]
1. SharedIV[0..64] = SHA-512(shared_data)
2. SharedIV[0..10] ^= alpha[0..10]
3. SharedIV[10..64] ^= beta[0..54]
4. SharedMac[0..8] = SHA-1(SharedIV)[0..8]
```
### 2.3 Hash Cash Level Computation
```
Input: omega (public key string), offset (u64)
Output: level (u8)
1. data = SHA-1(omega + offset.to_string())
2. level = 0
3. for byte in data:
4. if byte == 0:
5. level += 8
6. else:
7. level += trailing_zeros(byte)
8. break
9. return level
```
### 2.4 UID Computation
```
Input: publicKey (ASN.1-DER encoded)
Output: uid (base64 string)
1. hash = SHA-1(publicKey)
2. uid = base64(hash)
```
---
## 3. Interface Specifications
### 3.1 Tauri IPC Interface
```typescript
// TypeScript interface for Tauri commands
interface ITauriCommands {
// Identity management
get_identities(): Promise<IdentityInfo[]>;
create_identity(name: string): Promise<IdentityInfo>;
delete_identity(id: string): Promise<void>;
// Bookmark management
get_bookmarks(): Promise<BookmarkInfo[]>;
create_bookmark(name: string, address: string, port: number,
nickname?: string): Promise<BookmarkInfo>;
delete_bookmark(id: string): Promise<void>;
// Connection
connect(address: string, port: number, nickname: string,
password?: string): Promise<void>;
disconnect(): Promise<void>;
// Messaging
send_message(target: string, message: string): Promise<void>;
get_messages(server_address: string, limit: number,
offset: number): Promise<MessageInfo[]>;
}
interface IdentityInfo {
id: string;
name: string;
counter: number;
max_counter: number;
}
interface BookmarkInfo {
id: string;
name: string;
address: string;
port: number;
nickname: string | null;
auto_connect: boolean;
last_connected: string | null;
}
interface MessageInfo {
id: number;
invoker_name: string;
message: string;
timestamp: string;
is_read: boolean;
}
```
### 3.2 Internal Rust Interfaces
```rust
// Protocol layer
pub trait PacketProcessor {
fn encode(&self, packet: OutPacket) -> Result<Vec<OutUdpPacket>>;
fn decode(&self, data: &[u8]) -> Result<InPacket>;
}
// Audio layer
pub trait AudioCapture {
async fn start(&mut self) -> AudioResult<()>;
async fn stop(&mut self) -> AudioResult<()>;
async fn capture(&mut self) -> AudioResult<AudioFrame>;
}
pub trait AudioPlayback {
async fn start(&mut self) -> AudioResult<()>;
async fn stop(&mut self) -> AudioResult<()>;
async fn play(&mut self, frame: AudioFrame) -> AudioResult<()>;
}
// Database layer
pub trait IdentityStore {
fn create(&self, name: &str, key: &str) -> DatabaseResult<Identity>;
fn get(&self, id: &str) -> DatabaseResult<Identity>;
fn list(&self) -> DatabaseResult<Vec<Identity>>;
fn update(&self, id: &str, updates: IdentityUpdates) -> DatabaseResult<()>;
fn delete(&self, id: &str) -> DatabaseResult<()>;
}
```
---
## 4. Data Dictionary
### 4.1 Protocol Fields
| Field | Type | Size | Description |
|-------|------|------|-------------|
| MAC | [u8; 8] | 8 bytes | EAX message authentication code |
| PId | u16 | 2 bytes | Packet sequence ID |
| CId | u16 | 2 bytes | Client ID (C2S only) |
| PT | u8 | 1 byte | Packet type + flags |
| VId | u16 | 2 bytes | Voice packet ID |
| Codec | u8 | 1 byte | Audio codec type |
### 4.2 Flag Bits
| Bit | Name | Mask | Description |
|-----|------|------|-------------|
| 7 | UE | 0x80 | Unencrypted |
| 6 | CP | 0x40 | Compressed (QuickLZ) |
| 5 | NP | 0x20 | New protocol |
| 4 | FR | 0x10 | Fragmented |
| 3-0 | Type | 0x0F | Packet type (0-8) |
### 4.3 Error Codes
| Code | Name | Description |
|------|------|-------------|
| 0x0000 | ok | Success |
| 0x0200 | client_invalid_id | Invalid client ID |
| 0x0201 | client_nickname_inuse | Nickname already in use |
| 0x0208 | client_invalid_password | Wrong password |
| 0x0300 | channel_invalid_id | Invalid channel ID |
| 0x0400 | server_invalid_id | Invalid server ID |
| 0x0403 | server_maxclients_reached | Server full |
| 0x0701 | connection_lost | Connection lost |
---
## 5. Test Design
### 5.1 Test Cases (32 total)
#### Protocol Tests (14)
1. `test_packet_type_conversion` - PacketType enum conversion
2. `test_flags` - Flag bit manipulation
3. `test_header_c2s` - C2S header parsing
4. `test_header_s2c` - S2C header parsing
5. `test_in_packet_parse` - Input packet parsing
6. `test_out_packet` - Output packet creation
7. `test_command_parse` - Command string parsing
8. `test_command_serialize` - Command serialization
9. `test_command_builder` - CommandBuilder pattern
10. `test_escape_sequences` - Escape/unescape
11. `test_init_packet_parse` - Init packet parsing
12. `test_init_packet_serialize` - Init packet serialization
13. `test_ack_packet` - Acknowledgement packet
14. `test_packet_type_properties` - Type property queries
#### Crypto Tests (12)
1. `test_sha1` - SHA-1 hash
2. `test_sha256` - SHA-256 hash
3. `test_sha512` - SHA-512 hash
4. `test_hash_password` - Password hashing
5. `test_create_key_nonce` - Key derivation
6. `test_create_encryption_key` - Packet-specific key
7. `test_shared_secret_old` - Old protocol shared secret
8. `test_shared_secret_new` - New protocol shared secret
9. `test_key_cache` - Key caching
10. `test_eax_encrypt_decrypt` - EAX encryption/decryption
11. `test_fake_encrypt_decrypt` - Fake encryption
12. `test_hash_cash_level` - Hash Cash computation
13. `test_compute_uid` - UID computation
#### Connection Tests (6)
1. `test_encode_version` - Version encoding
2. `test_rsa_puzzle` - RSA puzzle solver
3. `test_resend_manager` - Retransmission manager
4. `test_rtt_estimator` - RTT estimation
5. `test_sent_packet_retry` - Packet retry logic
---
## 6. References
1. TS3 Protocol Paper: `refercence/tsdeclarations/ts3protocol.md`
2. Packet Definitions: `refercence/tsdeclarations/Packets.txt`
3. Message Definitions: `refercence/tsdeclarations/Messages.toml`
4. Source Code: `src/` (4780 lines, 39 Rust files)
- `main.rs` is still monolithic and should be split later
- Bookmarks are not persisted
- ServerQuery is not wired to a real backend yet
- Audio settings apply to runtime state, but broader config persistence is not implemented
+30 -311
View File
@@ -1,322 +1,41 @@
# Software Requirements Specification (SRS)
# ReTeamSpeak - Cross-Platform TeamSpeak Client
# Software Requirements Specification
**Version**: 1.0.0
**Date**: 2026-05-12
**Status**: Based on actual implementation
## Product
---
ReTeamSpeak is a desktop TeamSpeak 3 client for Windows, Linux, and macOS.
## 1. Introduction
## Current Functional Scope
### 1.1 Purpose
ReTeamSpeak is a cross-platform TeamSpeak 3 voice communication client supporting Windows, macOS, Linux, iOS, and Android. This document specifies the software requirements based on the implemented system.
### Implemented
### 1.2 Scope
The system implements:
- TeamSpeak 3 protocol (UDP-based, encrypted voice/text communication)
- Cross-platform UI via Tauri v2 + React
- Audio engine with Opus codec
- Local data storage (SQLite)
- Identity and bookmark management
- Connect to a TeamSpeak server with nickname and optional password
- Observe live server, channel, and client state
- Join channels
- Send and receive channel text messages
- Show connected clients and channels
- Toggle microphone mute and sync state to TeamSpeak
- Toggle speaker/output mute and sync state to TeamSpeak
- Toggle output hardware state and sync state to TeamSpeak
- Toggle AFK state and sync state to TeamSpeak
- Select audio input and output devices
- Support push-to-talk and continuous talk modes
- Import an existing TeamSpeak identity from local client config
- Select a noise-cancellation method in settings
### 1.3 Definitions
| Term | Definition |
|------|-----------|
| TS3 | TeamSpeak 3 protocol |
| EAX | AES-128-CTR with OMAC encryption mode |
| ECDH | Elliptic Curve Diffie-Hellman key exchange |
| Opus | Audio codec used for voice transmission |
| VAD | Voice Activity Detection |
| PTT | Push-To-Talk |
### Partially Implemented / Stubbed
---
- ServerQuery page exists, but query execution is stubbed
- Bookmark management exists only in local in-memory UI state
## 2. System Requirements
### Not In Scope Right Now
### 2.1 Functional Requirements
- Tauri frontend/backend shell
- Custom in-repo TeamSpeak protocol implementation
- Separate in-repo database layer
- Mobile and web targets
#### FR-01: Connection Management
- **FR-01.1**: Connect to TS3 servers via UDP
- **FR-01.2**: Support RSA puzzle handshake (DoS protection)
- **FR-01.3**: Support ECDH key exchange (P-256 and Curve25519)
- **FR-01.4**: Support AES-128-EAX encrypted communication
- **FR-01.5**: Support QuickLZ packet compression
- **FR-01.6**: Support packet fragmentation (max 500 bytes)
- **FR-01.7**: Support selective repeat reliable delivery
- **FR-01.8**: Support connection state machine (Disconnected → Connecting → Connected → ChannelListFinished)
## Non-Functional Requirements
#### FR-02: Authentication
- **FR-02.1**: ECC P-256 identity key generation
- **FR-02.2**: Hash Cash level computation (anti-spam)
- **FR-02.3**: Server password authentication (base64(sha1(password)))
- **FR-02.4**: Channel password authentication
- **FR-02.5**: Permission token support
- **FR-02.6**: UID computation (base64(sha1(publicKey)))
#### FR-03: Voice Communication
- **FR-03.1**: Opus codec encoding/decoding (48kHz)
- **FR-03.2**: Voice Activity Detection (VAD)
- **FR-03.3**: Push-To-Talk (PTT) mode
- **FR-03.4**: Per-client volume control
- **FR-03.5**: Whisper support (direct and group)
- **FR-03.6**: Codec types: Speex NB/WB/UWB, CELT, Opus Voice/Music
#### FR-04: Text Messaging
- **FR-04.1**: Server messages
- **FR-04.2**: Channel messages
- **FR-04.3**: Private messages
- **FR-04.4**: BBCode formatting support
- **FR-04.5**: Message history (SQLite storage)
#### FR-05: Channel Management
- **FR-05.1**: Channel tree display
- **FR-05.2**: Channel join/leave
- **FR-05.3**: Channel creation/editing (with permissions)
- **FR-05.4**: Channel subscription
#### FR-06: Client Management
- **FR-06.1**: Online client list
- **FR-06.2**: Client info display
- **FR-06.3**: Server group management
- **FR-06.4**: Channel group management
- **FR-06.5**: Client kick/ban (with permissions)
#### FR-07: Data Storage
- **FR-07.1**: Identity storage (encrypted private keys)
- **FR-07.2**: Server bookmarks
- **FR-07.3**: Chat message history
- **FR-07.4**: Application settings
#### FR-08: File Transfer
- **FR-08.1**: File upload to channels
- **FR-08.2**: File download from channels
- **FR-08.3**: File browsing
- **FR-08.4**: Transfer progress tracking
### 2.2 Non-Functional Requirements
#### NFR-01: Performance
- **NFR-01.1**: Connection establishment < 3 seconds
- **NFR-01.2**: Voice latency < 200ms
- **NFR-01.3**: Message delivery < 100ms
- **NFR-01.4**: Support 1000+ client servers
#### NFR-02: Security
- **NFR-02.1**: AES-128-EAX encryption for all commands
- **NFR-02.2**: ECDH key exchange (forward secrecy)
- **NFR-02.3**: RSA puzzle DoS protection
- **NFR-02.4**: ChaCha20-Poly1305 identity storage encryption
#### NFR-03: Compatibility
- **NFR-03.1**: Windows 10/11
- **NFR-03.2**: macOS 11+
- **NFR-03.3**: Linux (Ubuntu 20.04+, Debian 11+)
- **NFR-03.4**: iOS 15+
- **NFR-03.5**: Android 10+
- **NFR-03.6**: TS3 server versions 3.0.x and 3.1.x
#### NFR-04: Reliability
- **NFR-04.1**: Automatic reconnection on temporary disconnect
- **NFR-04.2**: Packet retransmission with exponential backoff
- **NFR-04.3**: Connection timeout detection (30 seconds)
- **NFR-04.4**: Graceful degradation on packet loss
---
## 3. System Architecture
### 3.1 Module Structure
```
src/
├── shared/ # Shared types (Client, Channel, Server, Events)
├── tscore/ # Protocol core (packets, crypto, connection)
├── tsaudio/ # Audio engine (capture, playback, codec)
├── tsdb/ # Database (SQLite via rusqlite)
└── tauri-app/ # Application shell
├── src-tauri/ # Rust backend (Tauri commands)
└── frontend/ # React frontend (TypeScript)
```
### 3.2 Technology Stack
| Layer | Technology |
|-------|-----------|
| Language | Rust 1.70+, TypeScript 5.x |
| Desktop Framework | Tauri v2 |
| Frontend | React 18, Vite 5 |
| Async Runtime | Tokio |
| Actor Framework | Actix |
| Database | SQLite (rusqlite) |
| Audio | Opus, cpal |
| Crypto | AES-EAX, P-256, Curve25519, SHA-256/512 |
---
## 4. Protocol Specification
### 4.1 Packet Format
```
C2S: [MAC:8][PId:2][CId:2][PT:1][Data:≤487]
S2C: [MAC:8][PId:2][PT:1][Data:≤489]
```
### 4.2 Packet Types
| Type | Value | Encrypted | Reliable | Fragmentable |
|------|-------|-----------|----------|--------------|
| Voice | 0x00 | Optional | No | No |
| VoiceWhisper | 0x01 | Optional | No | No |
| Command | 0x02 | Yes | Yes | Yes |
| CommandLow | 0x03 | Yes | Yes | Yes |
| Ping | 0x04 | No | No | No |
| Pong | 0x05 | No | No | No |
| Ack | 0x06 | Yes | Yes | No |
| AckLow | 0x07 | Yes | Yes | No |
| Init | 0x08 | No | Yes | No |
### 4.3 Handshake Sequence
```
Client → Server: Init0 (version, timestamp, random0)
Server → Client: Init1 (random1, random0_r)
Client → Server: Init2 (version, random1, random0_r)
Server → Client: Init3 (x, n, level, random2) [RSA puzzle]
Client → Server: Init4 (x, n, level, random2, y, clientinitiv)
Server → Client: initivexpand2 (beta, omega, proof, license)
Client → Server: clientek (ek, proof)
Server → Client: initserver
Server → Client: channellist...channellistfinished
Server → Client: notifycliententerview...
```
### 4.4 Encryption
- **Algorithm**: AES-128-EAX (AES-128-CTR + OMAC)
- **Key derivation**: SHA-256(direction | type | generation_id | shared_iv)
- **Shared IV**: SHA-512(ECDH shared secret) XOR alpha/beta
- **MAC**: 8 bytes (EAX tag)
---
## 5. Data Structures
### 5.1 Core Types (from shared/src/types.rs)
```rust
ClientId(u16) // Client identifier
ChannelId(u64) // Channel identifier
ServerGroupId(u64) // Server group identifier
ClientDbId(u64) // Client database identifier
Uid(String) // Unique identifier (base64)
PermissionId(u32) // Permission identifier
```
### 5.2 Database Schema (from tsdb/src/lib.rs)
```sql
CREATE TABLE identities (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
private_key TEXT NOT NULL,
counter INTEGER DEFAULT 0,
max_counter INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE bookmarks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
address TEXT NOT NULL,
port INTEGER DEFAULT 9987,
nickname TEXT,
server_password TEXT,
channel TEXT,
channel_password TEXT,
auto_connect INTEGER DEFAULT 0,
last_connected TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_address TEXT NOT NULL,
invoker_id INTEGER NOT NULL,
invoker_name TEXT NOT NULL,
invoker_uid TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id INTEGER,
message TEXT NOT NULL,
is_read INTEGER DEFAULT 0,
timestamp TEXT NOT NULL
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
```
---
## 6. Test Results
### 6.1 Unit Tests (32 tests passing)
```
tscore::protocol::tests - 14 tests
✓ test_packet_type_conversion
✓ test_flags
✓ test_header_c2s
✓ test_header_s2c
✓ test_in_packet_parse
✓ test_out_packet
✓ test_command_parse
✓ test_command_serialize
✓ test_command_builder
✓ test_escape_sequences
✓ test_init_packet_parse
✓ test_init_packet_serialize
✓ test_ack_packet
✓ test_packet_type_properties
tscore::crypto::tests - 12 tests
✓ test_sha1, test_sha256, test_sha512
✓ test_hash_password
✓ test_create_key_nonce
✓ test_create_encryption_key
✓ test_shared_secret_old, test_shared_secret_new
✓ test_key_cache
✓ test_eax_encrypt_decrypt
✓ test_fake_encrypt_decrypt
✓ test_hash_cash_level
✓ test_compute_uid
tscore::connection::tests - 6 tests
✓ test_encode_version
✓ test_rsa_puzzle
✓ test_resend_manager
✓ test_rtt_estimator
✓ test_sent_packet_retry
```
---
## 7. Constraints
### 7.1 Technical Constraints
- Rust edition 2021
- Tauri v2 for desktop/mobile
- Must maintain TS3 protocol compatibility
- UDP transport only (no TCP fallback)
### 7.2 Legal Constraints
- TeamSpeak is a trademark of TeamSpeak Systems GmbH
- Implementation is for educational/research purposes
- No server-side code (client-only)
---
## 8. References
1. TS3 Protocol Paper (`refercence/tsdeclarations/ts3protocol.md`)
2. Packet Definitions (`refercence/tsdeclarations/Packets.txt`)
3. Message Definitions (`refercence/tsdeclarations/Messages.toml`)
4. tsclientlib implementation (`refercence/tsclientlib/`)
5. Qint implementation (`refercence/Qint/`)
- Use `tsclientlib` for TeamSpeak compatibility instead of custom protocol code
- Keep the shipped desktop app codebase small and maintainable
- Prefer Podman-based reproducible builds when host system packages are missing
+7 -398
View File
@@ -1,401 +1,10 @@
# 系统架构设计文档 (SAD)
# Architecture Notes
## 1. 概述
The authoritative architecture is now documented in `SAD.md`.
本文档描述 TeamSpeak 3 客户端系统的整体架构设计,基于对参考代码库(tsclientlib、Qint、SimpleBot、ts3stats)的分析结果。
Short version:
## 2. 系统架构图
```
+-----------------------------------------------------------------------+
| 前端层 (Frontend) |
| |
| +-----------+ +----------+ +--------+ +------+ +-----+ +-----+ |
| | 连接对话框 | | 聊天视图 | | 频道树 | | 面板 | |文件 | |插件 | |
| +-----------+ +----------+ +--------+ +------+ +-----+ +-----+ |
| |
| +---------------------------+ +--------------------------+ |
| | 连接状态管理 | | 数据状态镜像 | |
| +---------------------------+ +--------------------------+ |
| |
| +------------------------------------------------------------------+ |
| | 后端抽象层 (Backend Abstraction) | |
| | IBackend <--- TauriBackend / BrowserBackend ---> | |
| +------------------------------------------------------------------+ |
+-----------------------------------------------------------------------+
| Tauri: IPC (invoke + event) | Browser: WebSocket + HTTP
v v
+-----------------------------------------------------------------------+
| 壳层 (Shell Layer) |
| |
| 桌面壳 (Tauri) Web壳 (Actix-web) |
| +---------------------+ +------------------------+ |
| | 命令处理器 | | REST/WebSocket 端点 | |
| | 窗口桥接器 | | GraphQL 端点 | |
| +---------------------+ +------------------------+ |
| | 核心协调器 | | WebSocket 处理器 | |
| | 文件传输管理器 | +------------------------+ |
| +---------------------+ |
+-----------------------------------------------------------------------+
| |
| 共享依赖 |
v v
+-----------------------------------------------------------------------+
| 代理库 (Proxy Library) - 核心业务逻辑 |
| |
| +---------------------+ +--------------------+ +----------------+ |
| | 全局状态管理 | | 连接管理器 | | 数据库管理器 | |
| | (QintState) | | (QintConnection) | | (DbHandler) | |
| +---------------------+ +--------------------+ +----------------+ |
| +--------------------+ +----------------+ |
| | 音频管道 | | 工具组件 | |
| | AudioToTs | | 文件缓存 | |
| | TsToAudio | | 链接预览 | |
| +--------------------+ | 全文搜索 | |
| | 热键管理 | |
| | 身份加密 | |
| +----------------+ |
+-----------------------------------------------------------------------+
|
| 依赖
v
+-----------------------------------------------------------------------+
| 代码生成层 (proxy-codegen) |
| |
| build.rs 读取 tsproto-structs -> 生成: |
| - Rust: JsEvent, JsProperty, JsM2B, convert_event() 等 |
| - TypeScript: book_events.ts (PropertyId, PropertyValue, OChange) |
+-----------------------------------------------------------------------+
|
v
+-----------------------------------------------------------------------+
| TeamSpeak 协议库 (tsclientlib / tsproto) |
| - TeamSpeak 协议实现 |
| - 连接管理、状态维护、音频编解码 |
| - 事件系统(属性变更、消息、音频) |
+-----------------------------------------------------------------------+
```
## 3. 分层架构
### 3.1 前端层 (Frontend Layer)
**职责**: 用户界面展示和用户交互处理
**技术栈**: Svelte 5 + TypeScript + Rsbuild
**主要组件**:
- **连接对话框**: 服务器连接配置界面
- **聊天视图**: 文本消息收发界面
- **频道树**: 频道和客户端树状视图
- **面板**: 设置、文件浏览器等侧边面板
- **插件系统**: 动态加载的插件模块
**关键特性**:
- 响应式状态管理 (Svelte stores)
- 跨平台兼容 (Tauri/Web)
- 实时事件更新
### 3.2 壳层 (Shell Layer)
**职责**: 平台特定的传输适配和系统集成
**两种实现**:
#### 3.2.1 桌面壳 (Tauri Desktop)
- **技术栈**: Rust + Tauri v2
- **职责**:
- 原生窗口管理
- 系统托盘集成
- 文件对话框
- 全局热键
- IPC 通信 (invoke + event)
#### 3.2.2 Web 壳 (Actix-web Server)
- **技术栈**: Rust + Actix-web
- **职责**:
- HTTP REST 端点
- WebSocket 通信
- GraphQL 查询端点
- 静态文件服务
### 3.3 代理层 (Proxy Layer)
**职责**: 核心业务逻辑,平台无关
**技术栈**: Rust + Actix actor 模型
**主要组件**:
#### 3.3.1 QintState (全局状态管理)
- 连接映射管理
- 音频数据管理
- 热键配置
- 设置管理
- 文件缓存
- 链接预览
- 全文搜索索引
#### 3.3.2 QintConnection (连接管理器)
- TeamSpeak 服务器连接生命周期
- 事件处理和分发
- 消息路由
- 音频路由
- 文件传输管理
#### 3.3.3 DbHandler (数据库管理器)
- SQLite/Diesel ORM
- 身份管理
- 服务器/书签管理
- 聊天消息存储
- GraphQL 查询支持
#### 3.3.4 音频管道
- **AudioToTs**: 麦克风采集 → Opus 编码 → 发送到服务器
- **TsToAudio**: 接收服务器音频 → Opus 解码 → 混音 → 播放
- 支持 SDL2 (桌面) 和 Oboe (Android) 后端
### 3.4 代码生成层 (Code Generation Layer)
**职责**: 从协议定义自动生成类型安全的代码
**输入**: tsproto-structs 的 TOML/CSV 声明文件
**输出**:
- Rust 类型 (JsEvent, JsProperty, JsM2B 等)
- TypeScript 类型 (book_events.ts)
- 事件转换函数
- 消息序列化/反序列化代码
### 3.5 协议库层 (Protocol Library Layer)
**职责**: TeamSpeak 3 协议的底层实现
**主要组件**:
#### 3.5.1 tsclientlib (高层客户端库)
- 连接配置和构建
- 状态同步
- 音频处理
- 地址解析 (IP/DNS SRV/TSDNS)
#### 3.5.2 tsproto (协议引擎)
- UDP 数据包处理
- 加密/解密 (AES-128-EAX)
- 压缩/解压 (QuickLZ)
- 可靠传输 (CUBIC 拥塞控制)
- 连接握手
#### 3.5.3 ts-bookkeeping (状态管理)
- 服务器状态维护
- 客户端/频道/组数据
- 事件生成
#### 3.5.4 tsproto-packets (包解析)
- 数据包格式定义
- 命令解析器
- 零拷贝解析
## 4. 设计模式
### 4.1 Actor 模型
- 使用 Actix 框架实现并发隔离
- 每个 TeamSpeak 连接是独立的 Actor
- 通过消息传递进行通信
### 4.2 桥接模式
- `AppToFrontendBridge` trait 解耦核心逻辑和传输层
- 两种实现: WindowBridge (Tauri) 和 WsBridge (Web)
### 4.3 策略模式
- 前端的 `IBackend` 接口
- 两种实现: TauriBackend 和 BrowserBackend
### 4.4 代码生成模式
- 从声明式 TOML/CSV 生成重复代码
- 确保类型安全和一致性
### 4.5 状态机模式
- 连接状态管理 (Uninitialized → Connecting → Connected → Disconnected)
### 4.6 观察者模式
- Svelte stores 实现响应式状态传播
- 事件系统实现组件间通信
## 5. 数据流
### 5.1 入站数据流 (服务器 → 应用)
```
UDP Socket
Connection::poll_incoming_udp_packet()
PacketCodec::handle_udp_packet()
├── 解密 (AES-128-EAX)
├── 重组分片
├── 解压 (QuickLZ)
└── 生成 StreamItem
├── Command → InCommandBuf
├── Audio → InAudioBuf
└── Ack → 更新发送队列
Client::handle_command()
Connection (tsclientlib)::poll_next()
├── 解析为 InMessage
├── 应用到 data::Connection 状态
├── 生成 events::Event
└── 返回 StreamItem::BookEvents
```
### 5.2 出站数据流 (应用 → 服务器)
```
应用层调用
OutCommandExt::send_with_result()
Connection::send_command_with_result()
├── 添加 return_code
├── 更新本地状态
client::Client::send_packet()
PacketCodec::encode_packet()
├── 压缩 + 分片 (QuickLZ)
├── 加密 (AES-128-EAX)
└── 分配 packet_id
Resender::send_packet()
├── 加入发送队列
└── CUBIC 拥塞控制
UDP Socket 发送
```
### 5.3 音频数据流
```
麦克风 → AudioToTs (Actor)
├── VAD 检测
├── 响度测量
├── Opus 编码
└── 发送到服务器
服务器 → tsclientlib::Connection
├── AudioData::S2C
└── TsToAudio (Actor)
├── Opus 解码
├── 每客户端音量
├── 混音
├── 噪声抑制
└── SDL2/Oboe 输出
```
## 6. 关键接口
### 6.1 AppToFrontendBridge
```rust
pub trait AppToFrontendBridge {
fn send(&self, msg: &MessageP2F);
fn close(&self);
}
```
### 6.2 MessageF2P / MessageP2F
```rust
// 前端 → 代理
pub enum MessageF2P {
Connect(ConnectOptions),
Disconnect(DisconnectOptions),
SendMessage { target, message, return_code },
SetClientVolume { client, volume },
SetWhispering(Option<WhisperData>),
Change { change: JsM2B, return_code },
}
// 代理 → 前端
pub enum MessageP2F {
Error(String),
Connected { server, own_client },
DisconnectedTemporarily(),
TalkersChanged(Vec<(String, bool)>),
Events(Vec<JsEvent>),
Message(JsInMessage),
Loudnesses(HashMap<String, f32>),
Result(ResultStruct),
}
```
### 6.3 IBackend / IBackendConnection
```typescript
interface IBackend {
createNewConnection(returnCodes: ReturnCodeTracker): IBackendConnection;
graphql<T>(query: string, variables?: Record<string, unknown>): Promise<{data: T}>;
get_settings(): Promise<Record<string, unknown>>;
set_settings(diff: Record<string, unknown>): Promise<void>;
}
interface IBackendConnection {
id: string;
connect(onMsg, onError, onClose): Promise<void>;
send(data: OutMsg): void;
close(): void;
fetch_image(req: IFileRequest): Promise<string | undefined>;
upload_bytes(req: IFileRequest, data: Blob): Promise<TransferResult>;
}
```
## 7. 平台适配
| 功能 | 桌面 (Tauri) | Web (Browser) | Android |
|------|-------------|---------------|---------|
| 音频后端 | SDL2 | N/A | Oboe |
| TLS | OpenSSL | N/A | rustls |
| 文件对话框 | Tauri 插件 | HTML input | Tauri |
| 系统托盘 | Tauri tray-icon | N/A | N/A |
| 全局热键 | livesplit-hotkey | N/A | N/A |
## 8. 依赖关系
```
src-tauri ──────depends on──────> qint-proxy ──────depends on──────> tsclientlib
│ │ │
└──depends on──> proxy-codegen ────┘ tsproto
│ │ tsproto-packets
└──depends on──> tauri v2 └──depends on──> diesel (SQLite) tsproto-types
└──depends on──> audiopus/opus
└──depends on──> sdl2/oboe
└──depends on──> tantivy (search)
└──depends on──> juniper (GraphQL)
webapp ──────depends on──────> qint-proxy (same as above)
└──depends on──> actix-web
└──depends on──> proxy-codegen
```
## 9. 关键技术决策
| 决策 | 选择 | 理由 |
|------|------|------|
| 异步运行时 | Tokio | Rust 生态最成熟的异步运行时 |
| Actor 框架 | Actix | 成熟的 Actor 模型实现 |
| 加密 | AES-128-EAX + ECDH | TeamSpeak 协议规范要求 |
| 压缩 | QuickLZ | TeamSpeak 协议使用的压缩算法 |
| 拥塞控制 | CUBIC | 类似 TCP CUBIC,适合实时通信 |
| 代码生成 | t4rust-derive | 从声明式数据生成大量重复代码 |
| 音频编解码 | Opus | TeamSpeak 3 默认编解码器 |
| 数据库 | SQLite + Diesel | 轻量级嵌入式数据库 + 类型安全 ORM |
| 前端框架 | Svelte 5 | 轻量级响应式框架 |
| 桌面框架 | Tauri v2 | 跨平台原生桌面应用 |
| Web 框架 | Actix-web | 高性能 Rust Web 框架 |
## 10. 安全考虑
1. **身份加密**: 使用 ChaCha20-Poly1305 加密存储身份私钥
2. **传输加密**: 使用 AES-128-EAX 加密所有命令和语音数据
3. **密钥交换**: 使用 ECDH (prime256v1) 进行密钥交换
4. **防 DoS**: 使用 RSA 拼图防止连接洪水攻击
5. **身份验证**: 使用 Hashcash 机制防止身份伪造
- one desktop client crate
- `iced` UI
- `tsclientlib` protocol/state/audio integration
- optional local audio helpers for capture/playback/noise reduction
+15 -709
View File
@@ -1,713 +1,19 @@
# 组件设计文档 (SDD)
# Components
## 1. 概述
## Shipped Components
本文档详细描述系统各组件的设计,包括功能、接口、数据结构和实现细节。
- `iced-app/src/main.rs` - app state, views, TeamSpeak actions
- `iced-app/src/audio.rs` - local audio capture/playback/encoding helpers
- `iced-app/src/noise_cancel.rs` - noise reduction backends
- `iced-app/src/identity.rs` - TeamSpeak identity import
- `iced-app/src/theme.rs` - UI theme styles
## 2. 核心组件
## External Core Dependencies
### 2.1 协议库组件 (tsclientlib)
#### 2.1.1 tsproto-types (基础类型)
**职责**: 定义 TeamSpeak 协议中使用的基础类型、枚举和加密原语
**关键类型**:
```rust
pub struct ClientId(pub u16); // 客户端 ID
pub struct ChannelId(pub u64); // 频道 ID
pub struct UidBuf(pub Vec<u8>); // 用户唯一标识
pub struct Permission(pub u32); // 权限 ID
pub enum ClientType { Normal, Query { admin: bool } }
pub enum MaxClients { Unlimited, Inherited, Limited(u16) }
```
**加密模块**:
```rust
pub struct EccKeyPubP256(p256::PublicKey); // P-256 公钥
pub struct EccKeyPrivP256(p256::SecretKey); // P-256 私钥
pub struct EccKeyPubEd25519(CompressedEdwardsY); // Ed25519 公钥
pub struct EccKeyPrivEd25519(Scalar); // Ed25519 私钥
```
**错误码枚举**: 从 CSV 自动生成,包含所有 TeamSpeak 错误码
#### 2.1.2 tsproto-structs (声明式数据)
**职责**: 提供协议的机器可读声明数据
**声明文件**:
- `Book.toml` - 服务器状态数据模型
- `Messages.toml` - 协议消息结构
- `Enums.toml` - 枚举定义
- `Errors.csv` - 错误码列表
- `Versions.csv` - 版本信息
#### 2.1.3 tsproto-packets (包解析)
**职责**: 解析和序列化 TeamSpeak 网络包和命令
**关键类型**:
```rust
pub enum PacketType { Voice, VoiceWhisper, Command, CommandLow, Ping, Pong, Ack, AckLow, Init }
pub enum Direction { S2C, C2S }
// 输入包(零拷贝)
pub struct InPacket<'a> { header: InHeader<'a>, content: &'a [u8] }
pub struct InCommand<'a> { packet: InPacket<'a> }
pub struct InAudio<'a> { packet: InPacket<'a>, data: AudioData<'a> }
// 输出包
pub struct OutPacket { dir: Direction, data: Vec<u8> }
pub struct OutCommand(pub OutPacket);
```
**命令解析器**:
```rust
pub struct CommandParser<'a> { data: &'a [u8], index: usize }
pub enum CommandItem<'a> { Argument(CommandArgument<'a>), NextCommand }
```
#### 2.1.4 ts-bookkeeping (状态管理)
**职责**: 维护 TeamSpeak 服务器的完整状态模型
**核心数据模型**:
```rust
pub struct Connection {
pub own_client: ClientId,
pub server: Server,
pub clients: HashMap<ClientId, Client>,
pub channels: HashMap<ChannelId, Channel>,
pub channel_groups: HashMap<ChannelGroupId, ChannelGroup>,
pub server_groups: HashMap<ServerGroupId, ServerGroup>,
}
pub struct Server { /* 名称、版本、最大客户端数、加密模式等 */ }
pub struct Channel { /* 名称、类型、编解码器、权限等 */ }
pub struct Client { /* 名称、频道、静音状态、权限等 */ }
```
**事件系统**:
```rust
pub enum Event {
PropertyAdded { id: PropertyId, invoker: Option<Invoker>, extra: ExtraInfo },
PropertyChanged { id: PropertyId, old: PropertyValue, invoker: Option<Invoker>, extra: ExtraInfo },
PropertyRemoved { id: PropertyId, old: PropertyValue, invoker: Option<Invoker>, extra: ExtraInfo },
Message { target: MessageTarget, invoker: Invoker, message: String },
}
```
#### 2.1.5 tsproto (协议引擎)
**职责**: 实现 TeamSpeak 3 协议的底层网络通信
**核心类型**:
```rust
pub struct Identity {
key: EccKeyPrivP256,
counter: u64, // Hash Cash 计数器
max_counter: u64,
}
pub struct Client {
con: Connection,
pub private_key: EccKeyPrivP256,
}
pub struct Connection {
pub is_client: bool,
pub params: Option<ConnectedParams>,
pub address: SocketAddr,
pub resender: Resender,
pub codec: PacketCodec,
pub udp_socket: Box<dyn Socket + Send>,
}
```
**连接握手流程**:
```
Client Server
│ │
│──── Init0 (version, ts) ────>│
│<─── Init1 (random1) ─────────│
│──── Init2 (random1_r) ──────>│
│<─── Init3 (RSA puzzle) ──────│
│──── Init4 (solve + ECDH) ───>│
│<─── initivexpand2 (license) ─│
│──── clientek (ephemeral key) >│
│<─── initserver ──────────────│
│ (connected) │
```
#### 2.1.6 tsclientlib (高层客户端库)
**职责**: 提供用户友好的客户端 API
**核心类型**:
```rust
pub struct Connection {
state: ConnectionState,
options: ConnectOptions,
stream_items: VecDeque<Result<StreamItem>>,
}
enum ConnectionState {
Connecting(BoxFuture<...>, bool),
IdentityLevelIncreasing { recv, state },
Connected { con: ConnectedConnection, book: data::Connection },
}
pub enum StreamItem {
BookEvents(Vec<events::Event>),
MessageEvent(InMessage),
Audio(InAudioBuf),
IdentityLevelIncreasing(u8),
IdentityLevelIncreased,
DisconnectedTemporarily(TemporaryDisconnectReason),
MessageResult(MessageHandle, Result<(), CommandError>),
FileDownload(...), FileUpload(...), FiletransferFailed(...),
NetworkStatsUpdated,
AudioChange(AudioEvent),
}
```
**地址解析**:
解析优先级:
1. 直接 IP 地址
2. 服务器昵称 (HTTP 查询)
3. DNS SRV 记录
4. TSDNS 服务
5. 系统 DNS 解析
**音频处理**:
```rust
pub struct AudioHandler<Id> {
queues: HashMap<Id, AudioQueue>,
avg_buffer_samples: usize,
}
pub struct AudioQueue {
decoder: Decoder, // Opus 解码器
packet_buffer: VecDeque<QueuePacket>,
decoded_buffer: Vec<f32>,
last_buffer_size_min: SlidingWindowMinimum<u8>,
}
```
### 2.2 客户端组件 (Qint)
#### 2.2.1 前端组件
**连接状态管理 (connection.ts)**:
```typescript
class Connection {
private book: Book;
private backend: IBackendConnection;
private state: ConnectionState;
// 状态机: Uninitialized -> Connecting -> Connected -> ChannelListFinished -> Disconnected
async connect(onMsg, onError, onClose): Promise<void>;
sendMessage(target, message): void;
switchChannel(channelId): void;
startWhispering(whisperData): void;
}
```
**数据状态镜像 (book.ts)**:
```typescript
class Book {
channels: Map<ChannelId, Channel>;
clients: Map<ClientId, Client>;
serverGroups: Map<ServerGroupId, ServerGroup>;
channelGroups: Map<ChannelGroupId, ChannelGroup>;
processEvent(event: InBookChangeMsg): void;
}
```
**后端抽象层 (backend/)**:
```typescript
interface IBackend {
createNewConnection(returnCodes: ReturnCodeTracker): IBackendConnection;
graphql<T>(query: string, variables?: Record<string, unknown>): Promise<{data: T}>;
get_settings(): Promise<Record<string, unknown>>;
set_settings(diff: Record<string, unknown>): Promise<void>;
}
interface IBackendConnection {
id: string;
connect(onMsg, onError, onClose): Promise<void>;
send(data: OutMsg): void;
close(): void;
fetch_image(req: IFileRequest): Promise<string | undefined>;
upload_bytes(req: IFileRequest, data: Blob): Promise<TransferResult>;
}
```
#### 2.2.2 壳层组件
**Tauri 命令处理器 (cmd.rs)**:
```rust
#[command]
async fn create_ws(state: State<'_, QintCore>, window: Window, con: String) -> Result<(), String>
#[command]
async fn pass_ws_msg(state: State<'_, QintCore>, con: String, msg: MessageF2P) -> Result<(), String>
#[command]
async fn db(state: State<'_, QintState>, query: String, variables: String) -> Result<String, String>
```
**WebSocket 处理器 (websocket.rs)**:
```rust
struct Ws {
state: QintState,
id: String,
addr: Addr<QintConnection>,
}
impl Ws {
fn handle_message(&mut self, msg: F2PMsg) {
match msg.cmd {
"create_ws" => { /* 创建连接 */ }
"pass_ws_msg" => { /* 转发消息 */ }
"get_settings" => { /* 获取设置 */ }
_ => { /* 其他命令 */ }
}
}
}
```
#### 2.2.3 代理层组件
**全局状态管理 (QintState)**:
```rust
pub struct QintState {
pub connections: Mutex<HashMap<String, Addr<QintConnection>>>,
pub audio_data: Arc<AudioData>,
pub hotkeys: HotkeyManager,
pub settings: RwLock<Settings>,
pub database: Addr<DbHandler>,
pub graphql_schema: Schema<QueryRoot, MutationRoot, EmptySubscription>,
pub file_cache: FileCache,
pub link_previewer: LinkPreviewer,
pub secret: chacha20poly1305::Key,
pub search_index: SearchIndex,
}
```
**连接管理器 (QintConnection)**:
```rust
pub struct QintConnection {
state: QintState,
id: String,
con: Option<tsclientlib::Connection>,
bridge: Box<dyn AppToFrontendBridge>,
audio_to_ts: Addr<AudioToTs>,
ts_to_audio: Addr<TsToAudio>,
database: Addr<DbHandler>,
}
impl Actor for QintConnection {
type Context = Context<Self>;
}
impl Handler<MessageF2PWrapper> for QintConnection {
fn handle(&mut self, msg: MessageF2PWrapper, ctx: &mut Self::Context) {
match msg.0 {
MessageF2P::Connect(options) => { /* 建立连接 */ }
MessageF2P::Disconnect(options) => { /* 断开连接 */ }
MessageF2P::SendMessage { target, message, return_code } => { /* 发送消息 */ }
// ...
}
}
}
```
**数据库管理器 (DbHandler)**:
```rust
pub struct DbHandler {
pool: SqlitePool,
}
impl Actor for DbHandler {
type Context = Context<Self>;
}
impl Handler<GetIdentityAndServerMsg> for DbHandler {
fn handle(&mut self, msg: GetIdentityAndServerMsg, ctx: &mut Self::Context) -> Self::Result {
// 从数据库获取身份和服务器信息
}
}
impl Handler<WriteMessageMsg> for DbHandler {
fn handle(&mut self, msg: WriteMessageMsg, ctx: &mut Self::Context) {
// 写入聊天消息到数据库
}
}
```
**音频管道**:
```rust
// AudioToTs - 麦克风采集和编码
pub struct AudioToTs {
connections: Vec<Addr<QintConnection>>,
encoder: OpusEncoder,
vad: VadDetector,
loudness_meter: LoudnessMeter,
}
impl Handler<SendPacketMsg> for AudioToTs {
fn handle(&mut self, msg: SendPacketMsg, ctx: &mut Self::Context) {
// 编码并发送音频数据
}
}
// TsToAudio - 音频解码和播放
pub struct TsToAudio {
queues: HashMap<String, AudioQueue>,
output_device: AudioDevice,
}
impl Handler<PlayMsg> for TsToAudio {
fn handle(&mut self, msg: PlayMsg, ctx: &mut Self::Context) {
// 解码并播放音频
}
}
```
### 2.3 机器人组件 (SimpleBot)
**核心结构**:
```rust
pub struct Bot {
base_dir: PathBuf,
settings_path: PathBuf,
actions: ActionList,
settings: Settings,
rate_limiting: Vec<Instant>,
list: Vec<String>,
should_reload: Cell<bool>,
}
```
**动作系统**:
```rust
pub struct ActionDefinition {
contains: Option<String>,
regex: Option<String>,
chat: Option<String>,
response: Option<String>,
command: Option<String>,
shell: Option<String>,
}
pub struct Action {
matchers: Vec<Matcher>,
reaction: Option<Reaction>,
}
pub enum Matcher {
Regex(Regex),
Mode(Option<TextMessageTargetMode>),
}
pub enum Reaction {
Plain(String),
Command(String),
Shell(String),
Function(ReactionFunction),
}
```
**配置系统**:
```rust
pub struct Settings {
key_file: String,
dynamic_actions: String,
address: String,
channel: Option<ChannelDefinition>,
name: String,
disconnect_message: String,
rate_limit: u8,
prefix: String,
actions: ActionFile,
}
pub struct ActionFile {
include: Vec<String>,
on_message: Vec<ActionDefinition>,
}
```
### 2.4 统计工具组件 (ts3stats)
**核心类**:
```python
class DiagramCreator:
env: jinja2.Environment
diagramTemplate: jinja2.Template
htmlTemplate: jinja2.Template
users: dict[int, User]
vip: list[User]
tabs: list[Tab]
def load_meta(self): pass
def load_data(self): pass
def create_diagrams(self): pass
def fun_per_connected_slot(self, users, callback): pass
class User:
name: str
lastConnected: list[datetime]
connections: list[Connection]
botPlays: list[tuple]
botCommands: list[tuple]
class Connection:
start: datetime
end: datetime
timeout: bool
class Diagram:
filename: str
title: str
plots: list[str]
def render(self): pass
class Tab:
name: str
diagrams: list[Diagram]
```
**插件架构**:
每个 `diags/*.py` 文件暴露一个函数:
```python
def create_diag(dc: DiagramCreator) -> None
```
**配置系统**:
```python
# Settings.py
vips = ["MyName", "friend42"]
merges = [["MyName", "MyNameLaptop"]]
maxUsers = 50
botStats = True
inputFolder = "Logs"
outputFolder = "Result"
```
## 3. 接口设计
### 3.1 Tauri IPC 接口
| 命令 | 参数 | 返回值 | 说明 |
|------|------|--------|------|
| `create_ws` | `con: String` | `()` | 创建 WebSocket 连接 |
| `close_ws` | `con: String` | `()` | 关闭 WebSocket 连接 |
| `pass_ws_msg` | `con: String, msg: MessageF2P` | `()` | 转发前端消息 |
| `db` | `query: String, variables: String` | `String` | GraphQL 查询 |
| `get_settings` | | `Record<string, unknown>` | 获取设置 |
| `set_settings` | `diff: Record<string, unknown>` | `()` | 更新设置 |
### 3.2 WebSocket 协议
**前端发送**:
```json
{"cmd": "create_ws", "returnCode": "0", "args": {"con": "uuid"}}
{"cmd": "pass_ws_msg", "returnCode": "1", "args": {"con": "uuid", "msg": {"Connect": {...}}}}
{"cmd": "get_settings", "returnCode": "2", "args": {}}
```
**后端发送**:
```json
{"cmd": "resp", "returnCode": "2", "msg": {...}}
{"cmd": "resp_err", "returnCode": "1", "msg": "error"}
{"cmd": "ws", "con": "uuid", "msg": {"Connected": {...}}}
{"cmd": "ws_close", "con": "uuid"}
{"cmd": "loudness", "msg": [0.5, 0.3]}
```
### 3.3 GraphQL 查询
```graphql
type Query {
bookmarks: [Bookmark!]!
servers: [Server!]!
channels(serverId: ID!): [Channel!]!
clients(serverId: ID!): [Client!]!
identities: [Identity!]!
chats(serverId: ID!): [Chat!]!
messages(chatId: ID!, limit: Int): [Message!]!
}
type Mutation {
updateIdentity(id: ID!, input: IdentityInput!): Identity!
updateBookmark(id: ID!, input: BookmarkInput!): Bookmark!
}
```
## 4. 数据流
### 4.1 连接建立流程
```
前端 代理 TeamSpeak 服务器
│ │ │
│── Connect(addr, name) ───>│ │
│ │── GetIdentityAndServerMsg ────> DbHandler
│ │<── (identity, server) ──────────│
│ │── Connection::build().connect ─>│
│ │ │
│ │<── TsStreamItem::BookEvents ────│
│ │ (PropertyAdded: Server) │
│ │ │
│<── Connected {server, own} │ │
│ │── ConnectedMsg ────────────────> DbHandler
│ │ │
│<── Events [PropertyAdded..]│ │
```
### 4.2 消息发送流程
```
前端 代理 TeamSpeak 服务器
│ │ │
│── SendMessage {target, msg}│ │
│ │── state.send_message(target) ──>│
│ │── WriteMessageMsg ─────────────> DbHandler
│ │ │
│ │<── InMessage (from other) ──────│
│ │── JsInMessage ─────────────────>│
│<── Message(JsInMessage) ──│ │
│ │── WriteMessageMsg ─────────────> DbHandler
```
### 4.3 音频流程
```
麦克风 → AudioToTs (Actor)
├── VAD 检测
├── 响度测量
├── Opus 编码
└── 发送到服务器
服务器 → tsclientlib::Connection
├── AudioData::S2C
└── TsToAudio (Actor)
├── Opus 解码
├── 每客户端音量
├── 混音
├── 噪声抑制
└── SDL2/Oboe 输出
```
## 5. 错误处理
### 5.1 协议错误
```rust
pub enum CommandError {
TsError(Ts3ErrorCode),
ConnectionClosed,
Timeout,
InvalidResponse,
// ...
}
```
### 5.2 连接错误
```rust
pub enum TemporaryDisconnectReason {
Timeout,
ServerShutdown,
ConnectionLost,
// ...
}
```
### 5.3 前端错误
```typescript
interface ErrorMessage {
type: "error";
message: string;
code?: string;
}
```
## 6. 配置管理
### 6.1 应用配置
```rust
pub struct Settings {
pub name: String,
pub away: Option<String>,
pub input_muted: bool,
pub output_muted: bool,
pub hotkeys: Vec<HotkeyAction>,
pub client_volumes: HashMap<String, f32>,
pub theme: String,
pub language: String,
}
```
### 6.2 连接配置
```rust
pub struct ConnectOptions {
address: ServerAddress,
local_address: Option<SocketAddr>,
identity: Option<Identity>,
server: Option<UidBuf>,
name: String,
version: Version,
channel: Option<String>,
channel_password: Option<String>,
server_password: Option<String>,
default_token: Option<String>,
}
```
## 7. 性能考虑
### 7.1 音频处理
- Opus 编码/解码使用硬件加速(如果可用)
- 自适应抖动缓冲减少延迟
- 噪声抑制减少带宽使用
### 7.2 状态同步
- 增量更新减少数据传输
- 事件批处理减少 IPC 调用
- 懒加载减少初始加载时间
### 7.3 数据库
- SQLite WAL 模式支持并发读取
- 连接池减少连接开销
- 索引优化查询性能
## 8. 安全设计
### 8.1 身份加密
```rust
// ChaCha20-Poly1305 加密身份私钥
fn encrypt_identity(key: &[u8; 32], identity: &[u8]) -> Vec<u8> {
let cipher = ChaCha20Poly1305::new(key.into());
let nonce = generate_nonce();
cipher.encrypt(&nonce, identity)
}
```
### 8.2 传输加密
- 所有命令和语音数据使用 AES-128-EAX 加密
- ECDH 密钥交换确保前向保密
- RSA 拼图防止 DoS 攻击
### 8.3 输入验证
- 所有用户输入进行验证和转义
- SQL 查询使用参数化语句
- WebSocket 消息进行 JSON 验证
- `tsclientlib`
- `tsproto-packets`
- `iced`
- `cpal`
- `audiopus`
- `nnnoiseless`
- `sonora`
+550
View File
@@ -0,0 +1,550 @@
# Design Guidelines
## Product Goal
Build a modern TeamSpeak 3 client for everyday users that feels clean, fast, minimal, and voice-first. The app should stay familiar to existing TeamSpeak users while adopting an Apple-style interface inspired by Finder, FaceTime, Apple Music, and System Settings.
The first version is a personal voice client, not a server administration tool.
## MVP Focus
The first version should prioritize:
- connecting to TeamSpeak 3 servers
- bookmarks and recent servers
- browsing and joining channels
- voice chat with push-to-talk and voice activation
- microphone mute and deafen
- input/output device selection
- global and per-user volume controls
- speaking, muted, deafened, and away indicators
- basic server, channel, and private chat
- identity management
- hotkeys
- notifications
- light and dark mode
The first version should explicitly exclude:
- server management
- permissions and group editors
- ban tools and logs
- file transfer
- plugin support
- whisper system
- advanced moderation flows
- overlays
## Core Design Principles
### Voice First
The UI should make the user's voice state obvious at all times.
Users should always be able to quickly understand:
- whether they are connected
- which channel they are in
- whether the microphone is live or muted
- whether output is deafened
- whether push-to-talk or voice activation is active
- whether audio devices are correctly selected
### Simple by Default
Default surfaces should stay compact and calm.
Prefer:
- clean navigation
- compact controls
- context menus
- popovers
- focused settings categories
- progressive disclosure for advanced options
Avoid:
- dense admin-style toolbars
- technical tables in primary flows
- large clusters of small icons
- exposing advanced TeamSpeak administration features in the core UI
### Apple-Style UI
The interface should use:
- soft backgrounds
- rounded cards and buttons
- subtle shadows
- thin dividers
- spacious padding
- calm accent colors
- strong typography hierarchy
- minimal visual noise
The app should feel lightweight and polished rather than industrial or admin-heavy.
## Visual System
### Colors
Light mode:
- Background: `#F5F5F7`
- Surface: `#FFFFFF`
- Elevated Surface: `#FBFBFD`
- Primary Accent: `#007AFF`
- Text Primary: `#1D1D1F`
- Text Secondary: `#6E6E73`
- Border: `#D2D2D7`
- Success: `#34C759`
- Warning: `#FF9500`
- Danger: `#FF3B30`
- Muted: `#8E8E93`
Dark mode:
- Background: `#000000`
- Surface: `#1C1C1E`
- Elevated Surface: `#2C2C2E`
- Primary Accent: `#0A84FF`
- Text Primary: `#F5F5F7`
- Text Secondary: `#AEAEB2`
- Border: `#38383A`
- Success: `#30D158`
- Warning: `#FF9F0A`
- Danger: `#FF453A`
- Muted: `#8E8E93`
### Typography
- Large Title: 32px, weight 700
- Title: 24px, weight 600
- Section Title: 17px, weight 600
- Body: 15px, weight 400
- Small: 13px, weight 400
- Caption: 11px, weight 500
Preferred family order:
- SF Pro Display
- SF Pro Text
- Inter
- system UI fallback
### Spacing And Sizing
- Tiny: 4px
- Small: 8px
- Medium: 12px
- Large: 16px
- XL: 24px
- XXL: 32px
Recommended component sizes:
- Sidebar width: 220px
- Channel row height: 36px
- User row height: 34px
- Card radius: 16px
- Button radius: 12px
- Popover radius: 18px
- Main content padding: 20px
## App Structure
The main information architecture should be:
- Home
- Servers
- Bookmarks
- Recent Servers
- Active Server
- Identities
- Settings
Settings should be organized into:
- General
- Audio
- Capture
- Playback
- Hotkeys
- Notifications
- Appearance
- Chat
- Identities
- Advanced
## Main Layout
Use a two-column structure:
- left sidebar for navigation, bookmarks, and recent servers
- main content area for home, active server, identities, and settings
### Sidebar
The sidebar should expose:
- Home
- Bookmarks
- Recent Servers
- Identities
- Settings
Server state indicators should be simple and readable:
- connected
- disconnected
- connecting
- error
### Active Server View
The active server view should emphasize:
- server name and connection state
- channel tree
- users inside channels
- current channel
- chat access
- compact persistent voice status
The layout should avoid looking like a legacy server management client.
## Persistent Voice Status
Voice controls must always be accessible, but they do not need to live in a large bottom bar.
Use a compact voice capsule near the bottom of the active server view.
The capsule should show:
- connection state
- current channel
- microphone state
- talk mode
- optional latency or quick device status
Selecting the capsule can open a lightweight popover with:
- microphone device
- output device
- talk mode
- volume
- mute mic
- deafen
- disconnect
## Home Screen
The home screen should help the user return to voice quickly.
Primary content:
- continue last server
- favorite servers
- recent servers
- current audio device
- current voice mode
## Connection Flow
The connect flow should support:
- server address
- nickname
- password
- default identity
- reconnect
- disconnect
- connection status
- ping and packet loss
- clear error states
Advanced connection options should stay behind a collapsed or secondary section.
Errors should use human-readable copy such as:
- unable to connect to server
- wrong server password
- server is full
- connection lost
## Bookmarks
Bookmarks should support:
- add
- edit
- delete
- reorder
- favorite servers
- recent servers
- default nickname per bookmark
- default identity per bookmark
- optional stored password
- optional default channel
The bookmark UI should be lightweight and optimized for quick connect.
## Channel Browser
The channel browser should support:
- nested channel tree
- expand and collapse
- current channel highlighting
- join on selection
- user counts
- locked, passworded, and full states
- channel and user search
The first version should not expose channel creation or editing flows.
## User Presence
Each user row should show, when available:
- nickname
- speaking state
- muted state
- deafened state
- away state
- idle state
- local mute state
- server group badge
User actions for the first version:
- view basic info
- adjust per-user volume
- mute locally
- send private message
- copy unique ID
Do not expose moderation actions in the primary first-version UI.
## Voice Features
Required voice modes:
- push-to-talk
- voice activation
- optional continuous transmission
Required controls:
- mute microphone
- deafen
- select input device
- select output device
- adjust output volume
- adjust microphone gain
- test microphone
- test speakers
- show microphone level
- show activation threshold
- adjust per-user volume
Default mode should be push-to-talk.
## Chat
The first version should support:
- server chat
- channel chat
- private messages
Chat should include:
- timestamps
- sender nickname
- basic formatting
- copy message
- clear local conversation
- unread indicators
- open links
Do not include threads, reactions, attachments, or rich embeds.
## Identities
Identity handling is part of the first version.
Support:
- create
- rename
- delete
- import
- export
- choose default identity
- assign identity per bookmark
- show unique ID
- show security level when available
## Hotkeys And Notifications
Hotkeys should cover:
- push-to-talk
- mute microphone
- deafen
- toggle away
- volume up and down
- open settings
- open search or command palette
- disconnect
The client should detect conflicts and support reset.
Notifications should cover:
- connected and disconnected
- connection lost
- current-channel join and leave events
- private messages
- mute and unmute state changes
- errors
## Appearance
Appearance options should include:
- light mode
- dark mode
- follow system
- compact mode
- comfortable mode
- avatar visibility
- group badge visibility
- font size adjustments
## Search And Command Palette
The app should include a simple command/search interface, opened by `Cmd+K` on macOS and `Ctrl+K` elsewhere.
Search targets:
- bookmarks
- recent servers
- channels
- users
- settings
Common actions:
- join channel
- search user
- mute microphone
- deafen
- open audio settings
- connect to server
## Accessibility
The client should support:
- full keyboard navigation
- screen reader labels
- high contrast mode
- reduce motion mode
- large text mode
- color-blind-safe indicators
- visible focus states
- text labels for important icons
Voice status must never rely on color alone.
## Performance
The app should remain responsive on large servers.
Design and implementation should aim for:
- 500+ visible users
- deep channel trees
- smooth scrolling
- lazy rendering for large trees
- fast search
- low idle CPU and memory use
- UI and audio work staying decoupled
- reconnect without freezing the UI
## Platform Notes
### macOS
Prefer macOS-style patterns such as:
- sidebar navigation
- system light and dark mode
- native-feeling shortcuts
- menu bar integration where useful
### Windows
Keep the visual style Apple-inspired, but respect Windows expectations such as:
- notifications
- tray support
- device switching
- game-friendly push-to-talk behavior
### Linux
Support:
- PipeWire
- PulseAudio
- desktop theme integration where practical
- global hotkeys where possible
## Implementation Priorities
### Must Have
- server connection
- bookmarks and recents
- channel tree
- voice chat
- push-to-talk and voice activation
- mute and deafen
- device selection
- speaking indicators
- chat and private messages
- identity import and export
- light and dark mode
### Should Have
- auto reconnect
- notification profiles
- hotkey editor
- microphone test
- speaker test
- level meter
- search
- compact mode
- command palette
### Later
- temporary channel creation
- basic moderation actions
- overlay
- server management
- permissions
- whisper
- file transfer
- plugin or theme extensions
## Final Standard
Every UI decision for the first version should reinforce one promise:
> A clean, modern, Apple-style TeamSpeak 3 client for joining servers, talking with people, and managing your own voice experience.
+6 -570
View File
@@ -1,573 +1,9 @@
# 需求分析文档 (SRS)
# Requirements Notes
## 1. 概述
The authoritative requirements are now in `SRS.md`.
本文档定义 TeamSpeak 3 客户端系统的功能需求和非功能需求,基于对参考代码库的分析和 TeamSpeak 3 协议规范。
Important current constraints:
## 2. 系统目标
### 2.1 主要目标
1. 实现一个功能完整的 TeamSpeak 3 客户端
2. 支持跨平台运行 (Windows, Linux, macOS, Android, Web)
3. 提供现代化的用户界面
4. 保持与官方 TeamSpeak 服务器的兼容性
5. 支持音频通信和文本聊天
### 2.2 次要目标
1. 支持插件扩展机制
2. 提供统计和分析功能
3. 支持多服务器同时连接
4. 提供机器人开发框架
## 3. 功能需求
### 3.1 连接管理
#### FR-3.1.1 服务器连接
**描述**: 用户能够连接到 TeamSpeak 3 服务器
**需求**:
- 支持通过 IP 地址连接
- 支持通过域名连接
- 支持通过服务器昵称连接
- 支持 DNS SRV 和 TSDNS 解析
- 支持服务器密码验证
- 支持身份选择和管理
- 支持默认频道设置
- 支持频道密码
**输入**:
- 服务器地址 (IP/域名/昵称)
- 服务器密码 (可选)
- 身份 (可选)
- 昵称
- 默认频道 (可选)
- 频道密码 (可选)
**输出**:
- 连接成功/失败状态
- 服务器信息
- 客户端 ID
#### FR-3.1.2 连接状态管理
**描述**: 管理连接的生命周期状态
**状态**:
- Uninitialized: 未初始化
- Connecting: 连接中
- IdentityLevelIncreasing: 身份等级提升中
- Connected: 已连接
- ChannelListFinished: 频道列表接收完成
- DisconnectedTemporarily: 临时断开
- Disconnected: 已断开
- Error: 错误
**需求**:
- 状态转换应有明确的触发条件
- 状态变化应通知前端
- 支持自动重连(临时断开时)
- 支持手动断开连接
#### FR-3.1.3 多服务器连接
**描述**: 支持同时连接多个 TeamSpeak 服务器
**需求**:
- 每个连接独立管理
- 连接间互不影响
- 支持连接切换
- 支持连接列表管理
### 3.2 频道管理
#### FR-3.2.1 频道浏览
**描述**: 浏览服务器的频道结构
**需求**:
- 显示频道树状结构
- 显示频道名称、主题、编解码器
- 显示频道最大客户端数
- 显示频道类型(永久/半永久/临时)
- 显示频道密码状态
- 支持频道搜索
#### FR-3.2.2 频道操作
**描述**: 执行频道相关操作
**需求**:
- 加入频道
- 离开频道
- 创建频道(需要权限)
- 编辑频道(需要权限)
- 删除频道(需要权限)
- 移动频道(需要权限)
- 设置频道密码
- 设置频道排序
#### FR-3.2.3 频道订阅
**描述**: 订阅频道以接收通知
**需求**:
- 订阅频道
- 取消订阅频道
- 接收频道事件通知
- 显示订阅状态
### 3.3 客户端管理
#### FR-3.3.1 客户端列表
**描述**: 显示在线客户端列表
**需求**:
- 显示客户端昵称
- 显示客户端状态(离开/录音/静音等)
- 显示客户端所在频道
- 显示客户端服务器组
- 显示客户端频道组
- 支持客户端搜索
#### FR-3.3.2 客户端操作
**描述**: 执行客户端相关操作
**需求**:
- 发送私聊消息
- 发送戳一戳
- 移动客户端(需要权限)
- 踢出客户端(需要权限)
- 封禁客户端(需要权限)
- 设置客户端音量
- 设置客户端静音
#### FR-3.3.3 客户端信息
**描述**: 查看客户端详细信息
**需求**:
- 显示客户端唯一标识符
- 显示客户端数据库 ID
- 显示客户端版本信息
- 显示客户端平台
- 显示客户端连接信息
- 显示客户端权限
### 3.4 文本聊天
#### FR-3.4.1 消息发送
**描述**: 发送文本消息
**需求**:
- 发送服务器消息
- 发送频道消息
- 发送私聊消息
- 支持 BBCode 格式
- 支持消息历史
- 支持消息撤回(如果服务器支持)
#### FR-3.4.2 消息接收
**描述**: 接收和显示文本消息
**需求**:
- 显示服务器消息
- 显示频道消息
- 显示私聊消息
- 显示系统通知
- 支持消息通知
- 支持消息过滤
#### FR-3.4.3 消息存储
**描述**: 存储聊天历史
**需求**:
- 本地存储消息历史
- 支持消息搜索
- 支持消息导出
- 支持消息清理
### 3.5 音频通信
#### FR-3.5.1 音频输入
**描述**: 捕获和处理麦克风音频
**需求**:
- 支持音频设备选择
- 支持音频编码 (Opus)
- 支持语音活动检测 (VAD)
- 支持推按说话 (PTT)
- 支持输入音量调节
- 支持输入静音
- 支持噪声抑制
- 支持回声消除
#### FR-3.5.2 音频输出
**描述**: 播放接收到的音频
**需求**:
- 支持音频设备选择
- 支持音频解码 (Opus)
- 支持输出音量调节
- 支持输出静音
- 支持每客户端音量
- 支持音频混音
- 支持抖动缓冲
- 支持丢包隐藏
#### FR-3.5.3 音频路由
**描述**: 管理音频数据的路由
**需求**:
- 支持频道音频
- 支持私语
- 支持组私语
- 支持频道指挥官
- 支持优先发言者
- 支持音频编码质量设置
### 3.6 文件传输
#### FR-3.6.1 文件浏览
**描述**: 浏览频道文件
**需求**:
- 显示文件列表
- 显示文件大小
- 显示文件修改时间
- 支持文件搜索
- 支持目录创建
#### FR-3.6.2 文件上传
**描述**: 上传文件到频道
**需求**:
- 支持单文件上传
- 支持多文件上传
- 支持断点续传
- 支持上传进度显示
- 支持上传取消
#### FR-3.6.3 文件下载
**描述**: 下载频道文件
**需求**:
- 支持单文件下载
- 支持多文件下载
- 支持断点续传
- 支持下载进度显示
- 支持下载取消
### 3.7 身份管理
#### FR-3.7.1 身份创建
**描述**: 创建新的 TeamSpeak 身份
**需求**:
- 自动生成 ECC 密钥对
- 支持身份命名
- 支持身份导出
- 支持身份导入
#### FR-3.7.2 身份存储
**描述**: 安全存储身份信息
**需求**:
- 加密存储私钥
- 支持多身份管理
- 支持身份备份
- 支持身份恢复
#### FR-3.7.3 身份验证
**描述**: 使用身份进行验证
**需求**:
- 支持 Hashcash 计算
- 支持身份等级提升
- 支持权限令牌
- 支持身份唯一标识符计算
### 3.8 权限管理
#### FR-3.8.1 权限查看
**描述**: 查看权限信息
**需求**:
- 显示服务器组权限
- 显示频道组权限
- 显示客户端权限
- 显示权限值
- 显示权限描述
#### FR-3.8.2 权限操作
**描述**: 执行权限相关操作
**需求**:
- 添加权限(需要权限)
- 删除权限(需要权限)
- 修改权限(需要权限)
- 添加服务器组客户端
- 删除服务器组客户端
### 3.9 服务器管理
#### FR-3.9.1 服务器信息
**描述**: 查看服务器信息
**需求**:
- 显示服务器名称
- 显示服务器版本
- 显示服务器平台
- 显示在线客户端数
- 显示频道数
- 显示服务器运行时间
- 显示服务器加密模式
#### FR-3.9.2 服务器操作
**描述**: 执行服务器相关操作
**需求**:
- 修改服务器密码(需要权限)
- 修改服务器名称(需要权限)
- 查看服务器日志(需要权限)
- 发送服务器消息(需要权限)
### 3.10 热键管理
#### FR-3.10.1 热键配置
**描述**: 配置全局热键
**需求**:
- 支持输入静音切换
- 支持输出静音切换
- 支持离开状态切换
- 支持推按说话
- 支持自定义热键
#### FR-3.10.2 热键执行
**描述**: 执行热键绑定的操作
**需求**:
- 全局热键支持
- 应用内热键支持
- 热键冲突检测
- 热键状态反馈
### 3.11 设置管理
#### FR-3.11.1 应用设置
**描述**: 管理应用程序设置
**需求**:
- 用户界面设置
- 音频设备设置
- 热键设置
- 通知设置
- 语言设置
- 主题设置
#### FR-3.11.2 连接设置
**描述**: 管理连接相关设置
**需求**:
- 默认昵称
- 默认身份
- 默认频道
- 自动重连设置
- 音频编码设置
### 3.12 插件系统
#### FR-3.12.1 插件加载
**描述**: 加载和管理插件
**需求**:
- 支持动态加载插件
- 支持插件启用/禁用
- 支持插件配置
- 支持插件更新
#### FR-3.12.2 插件接口
**描述**: 提供插件开发接口
**需求**:
- 事件监听接口
- 命令注册接口
- UI 扩展接口
- 数据访问接口
## 4. 非功能需求
### 4.1 性能需求
#### NFR-4.1.1 连接性能
- 连接建立时间 < 3 秒
- 消息延迟 < 100ms
- 音频延迟 < 200ms
- 支持 1000+ 客户端的服务器
#### NFR-4.1.2 资源使用
- CPU 使用率 < 10% (空闲时)
- 内存使用 < 200MB
- 网络带宽 < 1Mbps (语音通信时)
- 磁盘空间 < 100MB (应用程序)
#### NFR-4.1.3 并发性能
- 支持 10+ 同时连接
- 支持 100+ 消息/秒
- 支持 50+ 音频流同时播放
### 4.2 可靠性需求
#### NFR-4.2.1 连接可靠性
- 支持自动重连
- 支持断点续传
- 支持数据包重传
- 支持拥塞控制
#### NFR-4.2.2 数据可靠性
- 消息不丢失
- 文件传输完整性
- 身份数据安全
- 配置数据备份
### 4.3 安全性需求
#### NFR-4.3.1 传输安全
- 使用 AES-128-EAX 加密
- 使用 ECDH 密钥交换
- 支持证书验证
- 防止中间人攻击
#### NFR-4.3.2 数据安全
- 私钥加密存储
- 敏感数据不落盘
- 安全内存处理
- 防止内存泄露
#### NFR-4.3.3 访问控制
- 身份验证
- 权限检查
- 操作审计
- 防止未授权访问
### 4.4 可用性需求
#### NFR-4.4.1 用户界面
- 响应式设计
- 键盘导航支持
- 屏幕阅读器支持
- 高对比度模式
#### NFR-4.4.2 国际化
- 支持多语言
- 支持 RTL 布局
- 支持本地化日期格式
- 支持本地化数字格式
#### NFR-4.4.3 可访问性
- 支持字体大小调整
- 支持颜色主题切换
- 支持快捷键自定义
- 支持语音反馈
### 4.5 可维护性需求
#### NFR-4.5.1 代码质量
- 模块化设计
- 清晰的接口定义
- 完整的文档
- 单元测试覆盖
#### NFR-4.5.2 日志和监控
- 详细的日志记录
- 错误报告机制
- 性能监控
- 使用统计
### 4.6 可扩展性需求
#### NFR-4.6.1 架构扩展
- 支持插件系统
- 支持自定义主题
- 支持自定义命令
- 支持自定义通知
#### NFR-4.6.2 协议扩展
- 支持协议版本协商
- 支持功能特性检测
- 支持向后兼容
- 支持向前兼容
## 5. 约束条件
### 5.1 技术约束
- 使用 Rust 作为主要开发语言
- 使用 Tauri v2 作为桌面框架
- 使用 Svelte 5 作为前端框架
- 使用 Actix 作为 Actor 框架
- 使用 SQLite 作为本地数据库
### 5.2 协议约束
- 兼容 TeamSpeak 3 协议
- 支持 IPv4 和 IPv6
- 使用 UDP 传输
- 最大数据包大小 500 字节
### 5.3 法律约束
- 不得侵犯 TeamSpeak 商标
- 不得用于商业用途(除非获得许可)
- 遵守相关法律法规
- 尊重用户隐私
## 6. 验收标准
### 6.1 功能验收
- 能够连接到官方 TeamSpeak 服务器
- 能够发送和接收文本消息
- 能够进行语音通信
- 能够浏览和管理频道
- 能够管理客户端
### 6.2 性能验收
- 连接建立时间 < 3 秒
- 消息延迟 < 100ms
- 音频延迟 < 200ms
- CPU 使用率 < 10% (空闲时)
### 6.3 安全验收
- 通过安全审计
- 无已知漏洞
- 数据加密存储
- 传输加密验证
### 6.4 兼容性验收
- Windows 10/11 兼容
- Linux (Ubuntu 20.04+) 兼容
- macOS 11+ 兼容
- Android 10+ 兼容
- Chrome/Firefox/Safari 兼容
## 7. 术语表
| 术语 | 定义 |
|------|------|
| TeamSpeak | 一种 VoIP 应用程序,用于语音通信 |
| Channel | 语音/文本通信的房间 |
| Client | 连接到服务器的用户或机器人 |
| Server | TeamSpeak 服务器实例 |
| Identity | 用户的唯一标识符 |
| Permission | 用户的操作权限 |
| Codec | 音频编码格式 |
| Opus | 现代音频编解码器 |
| ECDH | 椭圆曲线 Diffie-Hellman 密钥交换 |
| AES | 高级加密标准 |
| EAX | 认证加密模式 |
| Hashcash | 工作量证明系统 |
| VAD | 语音活动检测 |
| PTT | 推按说话 |
| TSDNS | TeamSpeak DNS 服务 |
| SRV | DNS 服务记录 |
## 8. 参考文献
1. TeamSpeak 3 协议规范 (ts3protocol.md)
2. tsdeclarations 项目文档
3. tsclientlib 项目文档
4. Qint 项目文档
5. SimpleBot 项目文档
6. ts3stats 项目文档
- desktop-only target for now
- `tsclientlib` is the required TeamSpeak integration layer
- Podman should be used when host system libraries are missing for audio builds
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
REPO_ROOT="$(readlink -f "$SCRIPT_DIR/..")"
IMAGE="${RETEAMSPEAK_AUDIO_IMAGE:-docker.io/library/rust:1.91-bookworm}"
podman run --rm \
-v "$REPO_ROOT:/workspace:Z" \
-w /workspace/src \
"$IMAGE" \
bash -lc "apt-get update >/dev/null && \
apt-get install -y --no-install-recommends cmake pkg-config libasound2-dev libopus-dev >/dev/null && \
/usr/local/cargo/bin/cargo check -p re-teamspeak"
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
REPO_ROOT="$(readlink -f "$SCRIPT_DIR/..")"
UID_VALUE="$(id -u)"
GID_VALUE="$(id -g)"
RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$UID_VALUE}"
IMAGE="${RETEAMSPEAK_AUDIO_IMAGE:-docker.io/library/rust:1.91-bookworm}"
if [[ $# -gt 0 && "$1" == "--" ]]; then
shift
if [[ $# -eq 0 ]]; then
printf 'Expected a container command after --\n' >&2
exit 1
fi
CONTAINER_CMD=("$@")
else
CONTAINER_CMD=(/usr/local/cargo/bin/cargo run -p re-teamspeak)
fi
quoted_cmd() {
local quoted=()
local part
for part in "$@"; do
quoted+=("$(printf '%q' "$part")")
done
printf '%s ' "${quoted[@]}"
}
PODMAN_ARGS=(
run
--rm
--network host
-v "$REPO_ROOT:/workspace:Z"
-w /workspace/src
-e CARGO_TARGET_DIR=/tmp/re-teamspeak-target
)
if [[ -t 0 && -t 1 ]]; then
PODMAN_ARGS+=( -it )
fi
if [[ -e /dev/snd ]]; then
PODMAN_ARGS+=( --device /dev/snd )
fi
if [[ -e /dev/dri ]]; then
PODMAN_ARGS+=( --device /dev/dri )
fi
if [[ -S "$RUNTIME_DIR/pulse/native" ]]; then
PODMAN_ARGS+=(
-v "$RUNTIME_DIR/pulse:$RUNTIME_DIR/pulse:Z"
-e "PULSE_SERVER=unix:$RUNTIME_DIR/pulse/native"
)
fi
if [[ -S "$RUNTIME_DIR/pipewire-0" ]]; then
PODMAN_ARGS+=(
-v "$RUNTIME_DIR/pipewire-0:$RUNTIME_DIR/pipewire-0:Z"
-v "$RUNTIME_DIR/pipewire-0-manager:$RUNTIME_DIR/pipewire-0-manager:Z"
-e "PIPEWIRE_REMOTE=$RUNTIME_DIR/pipewire-0"
-e "XDG_RUNTIME_DIR=$RUNTIME_DIR"
)
fi
if [[ -S "$RUNTIME_DIR/bus" ]]; then
PODMAN_ARGS+=(
-v "$RUNTIME_DIR/bus:$RUNTIME_DIR/bus:Z"
-e "DBUS_SESSION_BUS_ADDRESS=unix:path=$RUNTIME_DIR/bus"
)
fi
if [[ -n "${WAYLAND_DISPLAY:-}" && -S "$RUNTIME_DIR/$WAYLAND_DISPLAY" ]]; then
PODMAN_ARGS+=(
-v "$RUNTIME_DIR/$WAYLAND_DISPLAY:$RUNTIME_DIR/$WAYLAND_DISPLAY:Z"
-e "WAYLAND_DISPLAY=$WAYLAND_DISPLAY"
-e "XDG_RUNTIME_DIR=$RUNTIME_DIR"
)
elif [[ -n "${DISPLAY:-}" && -d /tmp/.X11-unix ]]; then
PODMAN_ARGS+=(
-v /tmp/.X11-unix:/tmp/.X11-unix:ro
-e "DISPLAY=$DISPLAY"
)
if [[ -n "${XAUTHORITY:-}" && -f "$XAUTHORITY" ]]; then
PODMAN_ARGS+=(
-v "$XAUTHORITY:$XAUTHORITY:ro,Z"
-e "XAUTHORITY=$XAUTHORITY"
)
fi
elif [[ ${CONTAINER_CMD[0]} == /usr/local/cargo/bin/cargo && ${CONTAINER_CMD[1]} == run ]]; then
printf 'No Wayland or X11 session detected in this shell.\n' >&2
printf 'Run this from a desktop session, or pass a custom container command with --.\n' >&2
exit 1
fi
APT_PACKAGES=(
cmake
pkg-config
libasound2-dev
libopus-dev
libxkbcommon0
libwayland-client0
libwayland-cursor0
libwayland-egl1
libx11-6
libxrandr2
libxi6
libxcursor1
libxinerama1
libgl1
libegl1
libdbus-1-3
)
APT_CMD="apt-get update >/dev/null && apt-get install -y --no-install-recommends $(quoted_cmd "${APT_PACKAGES[@]}") >/dev/null"
RUN_CMD="$(quoted_cmd "${CONTAINER_CMD[@]}")"
exec podman "${PODMAN_ARGS[@]}" "$IMAGE" \
bash -lc "$APT_CMD && $RUN_CMD"
+3 -38
View File
@@ -1,11 +1,7 @@
[workspace]
resolver = "2"
members = [
"tscore",
"tsaudio",
"tsdb",
"shared",
"tauri-app/src-tauri",
"iced-app",
]
[workspace.package]
@@ -20,39 +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"] }
tsproto-packets = { git = "https://github.com/ReSpeak/tsclientlib.git", branch = "master" }
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "re-teamspeak"
version = "1.0.0"
edition = "2021"
license = "MIT OR Apache-2.0"
description = "TeamSpeak 3 client built with iced"
[[bin]]
name = "re-teamspeak"
path = "src/main.rs"
[dependencies]
iced = { version = "0.13", features = ["tokio", "debug", "svg"] }
tokio = { workspace = true }
futures = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
chrono = { workspace = true }
cpal = { version = "0.15" }
audiopus = { version = "0.3.0-rc.0" }
nnnoiseless = { version = "0.5" }
sonora = { version = "0.1" }
tsclientlib = { workspace = true }
tsproto-packets = { workspace = true }
+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5933 3.32241C18.6939 3.45014 19.5 4.399 19.5 5.50699V21L12 17.25L4.5 21V5.50699C4.5 4.399 5.30608 3.45014 6.40668 3.32241C8.24156 3.10947 10.108 3 12 3C13.892 3 15.7584 3.10947 17.5933 3.32241Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 396 B

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.5 8.25L12 15.75L4.5 8.25" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 227 B

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.25 4.5L15.75 12L8.25 19.5" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 227 B

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.59356 3.94014C9.68397 3.39768 10.1533 3.00009 10.7033 3.00009H13.2972C13.8472 3.00009 14.3165 3.39768 14.4069 3.94014L14.6204 5.22119C14.6828 5.59523 14.9327 5.9068 15.2645 6.09045C15.3387 6.13151 15.412 6.17393 15.4844 6.21766C15.8095 6.41393 16.2048 6.47495 16.5604 6.34175L17.7772 5.88587C18.2922 5.69293 18.8712 5.9006 19.1462 6.37687L20.4432 8.6233C20.7181 9.09957 20.6085 9.70482 20.1839 10.0544L19.1795 10.8812C18.887 11.122 18.742 11.4938 18.7491 11.8726C18.7498 11.915 18.7502 11.9575 18.7502 12.0001C18.7502 12.0427 18.7498 12.0852 18.7491 12.1275C18.742 12.5064 18.887 12.8782 19.1795 13.119L20.1839 13.9458C20.6085 14.2953 20.7181 14.9006 20.4432 15.3769L19.1462 17.6233C18.8712 18.0996 18.2922 18.3072 17.7772 18.1143L16.5604 17.6584C16.2048 17.5252 15.8095 17.5862 15.4844 17.7825C15.412 17.8263 15.3387 17.8687 15.2645 17.9097C14.9327 18.0934 14.6828 18.4049 14.6204 18.779L14.4069 20.06C14.3165 20.6025 13.8472 21.0001 13.2972 21.0001H10.7033C10.1533 21.0001 9.68397 20.6025 9.59356 20.06L9.38005 18.779C9.31771 18.4049 9.06774 18.0934 8.73597 17.9097C8.66179 17.8687 8.58847 17.8263 8.51604 17.7825C8.19101 17.5863 7.79568 17.5252 7.44011 17.6584L6.22325 18.1143C5.70826 18.3072 5.12926 18.0996 4.85429 17.6233L3.55731 15.3769C3.28234 14.9006 3.39199 14.2954 3.81657 13.9458L4.82092 13.119C5.11343 12.8782 5.25843 12.5064 5.25141 12.1276C5.25063 12.0852 5.25023 12.0427 5.25023 12.0001C5.25023 11.9575 5.25063 11.915 5.25141 11.8726C5.25843 11.4938 5.11343 11.122 4.82092 10.8812L3.81657 10.0544C3.39199 9.70484 3.28234 9.09958 3.55731 8.62332L4.85429 6.37688C5.12926 5.90061 5.70825 5.69295 6.22325 5.88588L7.4401 6.34176C7.79566 6.47496 8.19099 6.41394 8.51603 6.21767C8.58846 6.17393 8.66179 6.13151 8.73597 6.09045C9.06774 5.9068 9.31771 5.59523 9.38005 5.22119L9.59356 3.94014Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 12C15 13.6569 13.6569 15 12 15C10.3431 15 9 13.6569 9 12C9 10.3432 10.3431 9.00001 12 9.00001C13.6569 9.00001 15 10.3432 15 12Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.25 12L11.2045 3.04549C11.6438 2.60615 12.3562 2.60615 12.7955 3.04549L21.75 12M4.5 9.75V19.875C4.5 20.4963 5.00368 21 5.625 21H9.75V16.125C9.75 15.5037 10.2537 15 10.875 15H13.125C13.7463 15 14.25 15.5037 14.25 16.125V21H18.375C18.9963 21 19.5 20.4963 19.5 19.875V9.75M8.25 21H16.5" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 483 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 18.75C15.3137 18.75 18 16.0637 18 12.75V11.25M12 18.75C8.68629 18.75 6 16.0637 6 12.75V11.25M12 18.75V22.5M8.25 22.5H15.75M12 15.75C10.3431 15.75 9 14.4069 9 12.75V4.5C9 2.84315 10.3431 1.5 12 1.5C13.6569 1.5 15 2.84315 15 4.5V12.75C15 14.4069 13.6569 15.75 12 15.75Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 470 B

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.114 5.63597C22.6287 9.15069 22.6287 14.8492 19.114 18.3639M16.4626 8.28771C18.5129 10.338 18.5129 13.6621 16.4626 15.7123M6.75 8.24999L11.4697 3.53032C11.9421 3.05784 12.75 3.39247 12.75 4.06065V19.9393C12.75 20.6075 11.9421 20.9421 11.4697 20.4697L6.75 15.75H4.50905C3.62971 15.75 2.8059 15.2435 2.57237 14.3957C2.36224 13.6329 2.25 12.8296 2.25 12C2.25 11.1704 2.36224 10.367 2.57237 9.60423C2.8059 8.75646 3.62971 8.24999 4.50905 8.24999H6.75Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 649 B

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.25 9.74999L19.5 12M19.5 12L21.75 14.25M19.5 12L21.75 9.74999M19.5 12L17.25 14.25M6.75 8.24999L11.4697 3.53032C11.9421 3.05784 12.75 3.39247 12.75 4.06065V19.9393C12.75 20.6075 11.9421 20.9421 11.4697 20.4697L6.75 15.75H4.50905C3.62971 15.75 2.8059 15.2435 2.57237 14.3957C2.36224 13.6329 2.25 12.8296 2.25 12C2.25 11.1704 2.36224 10.367 2.57237 9.60423C2.8059 8.75646 3.62971 8.24999 4.50905 8.24999H6.75Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 608 B

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.9815 18.7248C16.6121 16.9175 14.4424 15.75 12 15.75C9.55761 15.75 7.38789 16.9175 6.01846 18.7248M17.9815 18.7248C19.8335 17.0763 21 14.6744 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 14.6744 4.1665 17.0763 6.01846 18.7248M17.9815 18.7248C16.3915 20.1401 14.2962 21 12 21C9.70383 21 7.60851 20.1401 6.01846 18.7248M15 9.75C15 11.4069 13.6569 12.75 12 12.75C10.3431 12.75 9 11.4069 9 9.75C9 8.09315 10.3431 6.75 12 6.75C13.6569 6.75 15 8.09315 15 9.75Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 670 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 10.5H16M13.75 6.375C13.75 8.23896 12.239 9.75 10.375 9.75C8.51104 9.75 7 8.23896 7 6.375C7 4.51104 8.51104 3 10.375 3C12.239 3 13.75 4.51104 13.75 6.375ZM4.00092 19.2343C4.00031 19.198 4 19.1615 4 19.125C4 15.6042 6.85418 12.75 10.375 12.75C13.8958 12.75 16.75 15.6042 16.75 19.125V19.1276C16.75 19.1632 16.7497 19.1988 16.7491 19.2343C14.8874 20.3552 12.7065 21 10.375 21C8.04353 21 5.86264 20.3552 4.00092 19.2343Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 619 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.75 6C15.75 8.07107 14.071 9.75 12 9.75C9.9289 9.75 8.24996 8.07107 8.24996 6C8.24996 3.92893 9.9289 2.25 12 2.25C14.071 2.25 15.75 3.92893 15.75 6Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.5011 20.1182C4.5714 16.0369 7.90184 12.75 12 12.75C16.0982 12.75 19.4287 16.0371 19.4988 20.1185C17.216 21.166 14.6764 21.75 12.0003 21.75C9.32396 21.75 6.78406 21.1659 4.5011 20.1182Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 633 B

+370
View File
@@ -0,0 +1,370 @@
use serde::{Deserialize, Serialize};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tsclientlib::audio::AudioHandler;
use tsclientlib::ClientId;
use tsproto_packets::packets::{AudioData, InAudioBuf};
const SAMPLE_RATE: u32 = 48000;
const CHANNELS: u16 = 2;
const FRAME_SIZE: usize = 960; // 20ms at 48kHz mono
const PLAYBACK_FILL_SAMPLES: usize = 1920; // 20ms at 48kHz stereo for tsclientlib playback
const OPUS_MAX_PACKET: usize = 1275;
type PacketSender = std::sync::mpsc::Sender<InAudioBuf>;
struct PlaybackState {
sender: PacketSender,
volumes: Arc<Mutex<HashMap<ClientId, f32>>>,
muted_clients: Arc<Mutex<HashMap<ClientId, bool>>>,
_stream: cpal::Stream,
}
unsafe impl Send for PlaybackState {}
unsafe impl Sync for PlaybackState {}
pub struct AudioPlayback {
state: Option<PlaybackState>,
}
impl AudioPlayback {
pub fn new() -> Self {
Self { state: None }
}
pub fn list_output_devices() -> Vec<String> {
let host = cpal::default_host();
host.output_devices()
.map(|d| d.filter_map(|d| d.name().ok()).collect())
.unwrap_or_default()
}
pub fn list_input_devices() -> Vec<String> {
let host = cpal::default_host();
host.input_devices()
.map(|d| d.filter_map(|d| d.name().ok()).collect())
.unwrap_or_default()
}
pub fn start(&mut self, device_name: Option<&str>) -> Result<(), String> {
let host = cpal::default_host();
let device = if let Some(name) = device_name {
host.output_devices()
.map_err(|e| format!("Failed to enumerate devices: {e}"))?
.find(|d| d.name().map(|n| n == name).unwrap_or(false))
.ok_or_else(|| format!("Output device '{}' not found", name))?
} else {
host.default_output_device()
.ok_or_else(|| "No audio output device found".to_string())?
};
let device_name_str = device.name().unwrap_or_default();
tracing::info!("Using output device: {}", device_name_str);
let config = cpal::StreamConfig {
channels: CHANNELS,
sample_rate: cpal::SampleRate(SAMPLE_RATE),
buffer_size: cpal::BufferSize::Default,
};
let (tx, rx) = std::sync::mpsc::channel::<InAudioBuf>();
let volumes = Arc::new(Mutex::new(HashMap::<ClientId, f32>::new()));
let muted_clients = Arc::new(Mutex::new(HashMap::<ClientId, bool>::new()));
let callback_volumes = Arc::clone(&volumes);
let callback_muted = Arc::clone(&muted_clients);
let mut handler = AudioHandler::<ClientId>::new();
let mut playback_chunk = vec![0.0f32; PLAYBACK_FILL_SAMPLES];
let mut playback_chunk_offset = PLAYBACK_FILL_SAMPLES;
let stream = device
.build_output_stream(
&config,
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
while let Ok(packet) = rx.try_recv() {
let from = match packet.data().data() {
AudioData::S2C { from, .. } => *from,
AudioData::S2CWhisper { from, .. } => *from,
_ => continue,
};
let _ = handler.handle_packet(ClientId(from), packet);
}
let volumes = callback_volumes.lock().ok().map(|volumes| volumes.clone());
let muted_clients = callback_muted.lock().ok().map(|muted| muted.clone());
for (id, queue) in handler.get_mut_queues().iter_mut() {
let volume = volumes
.as_ref()
.and_then(|volumes| volumes.get(id).copied())
.unwrap_or(1.0);
let muted = muted_clients
.as_ref()
.and_then(|muted| muted.get(id).copied())
.unwrap_or(false);
queue.volume = if muted { 0.0 } else { volume };
}
let mut written = 0;
while written < data.len() {
if playback_chunk_offset >= playback_chunk.len() {
playback_chunk.fill(0.0);
handler.fill_buffer(&mut playback_chunk);
playback_chunk_offset = 0;
}
let available = playback_chunk.len() - playback_chunk_offset;
let remaining = data.len() - written;
let copy_len = available.min(remaining);
data[written..written + copy_len].copy_from_slice(
&playback_chunk
[playback_chunk_offset..playback_chunk_offset + copy_len],
);
playback_chunk_offset += copy_len;
written += copy_len;
}
},
|err| tracing::error!("Audio output error: {err}"),
None,
)
.map_err(|e| format!("Failed to build output stream: {e}"))?;
stream
.play()
.map_err(|e| format!("Failed to start playback: {e}"))?;
self.state = Some(PlaybackState {
sender: tx,
volumes,
muted_clients,
_stream: stream,
});
tracing::info!("Audio playback started");
Ok(())
}
pub fn stop(&mut self) {
self.state = None;
tracing::info!("Audio playback stopped");
}
pub fn send_packet(&self, packet: InAudioBuf) {
if let Some(ref state) = self.state {
let _ = state.sender.send(packet);
}
}
pub fn set_client_volume(&self, client: ClientId, volume: f32) {
if let Some(ref state) = self.state {
if let Ok(mut volumes) = state.volumes.lock() {
volumes.insert(client, volume.clamp(0.0, 2.0));
}
}
}
pub fn set_client_muted(&self, client: ClientId, muted: bool) {
if let Some(ref state) = self.state {
if let Ok(mut muted_clients) = state.muted_clients.lock() {
muted_clients.insert(client, muted);
}
}
}
}
struct CaptureState {
_stream: cpal::Stream,
}
unsafe impl Send for CaptureState {}
unsafe impl Sync for CaptureState {}
pub struct Microphone {
state: Option<CaptureState>,
}
impl Microphone {
pub fn new() -> Self {
Self { state: None }
}
pub fn start(
&mut self,
device_name: Option<&str>,
sample_tx: std::sync::mpsc::Sender<Vec<f32>>,
) -> Result<(), String> {
let host = cpal::default_host();
let device = if let Some(name) = device_name {
host.input_devices()
.map_err(|e| format!("Failed to enumerate devices: {e}"))?
.find(|d| d.name().map(|n| n == name).unwrap_or(false))
.ok_or_else(|| format!("Input device '{}' not found", name))?
} else {
host.default_input_device()
.ok_or_else(|| "No audio input device found".to_string())?
};
let device_name_str = device.name().unwrap_or_default();
tracing::info!("Using input device: {}", device_name_str);
let config = cpal::StreamConfig {
channels: 1,
sample_rate: cpal::SampleRate(SAMPLE_RATE),
buffer_size: cpal::BufferSize::Default,
};
let stream = device
.build_input_stream(
&config,
move |data: &[f32], _: &cpal::InputCallbackInfo| {
let _ = sample_tx.send(data.to_vec());
},
|err| tracing::error!("Audio input error: {err}"),
None,
)
.map_err(|e| format!("Failed to build input stream: {e}"))?;
stream
.play()
.map_err(|e| format!("Failed to start capture: {e}"))?;
self.state = Some(CaptureState {
_stream: stream,
});
tracing::info!("Microphone started");
Ok(())
}
pub fn stop(&mut self) {
self.state = None;
tracing::info!("Microphone stopped");
}
}
pub struct OpusEncoderState {
encoder: audiopus::coder::Encoder,
packet_id: u16,
opus_buf: [u8; OPUS_MAX_PACKET],
sample_buf: Vec<f32>,
}
impl OpusEncoderState {
pub fn new() -> Result<Self, String> {
let encoder = audiopus::coder::Encoder::new(
audiopus::SampleRate::Hz48000,
audiopus::Channels::Mono,
audiopus::Application::Voip,
)
.map_err(|e| format!("Failed to create Opus encoder: {e}"))?;
Ok(Self {
encoder,
packet_id: 0,
opus_buf: [0u8; OPUS_MAX_PACKET],
sample_buf: Vec::with_capacity(FRAME_SIZE * 2),
})
}
pub fn encode_and_send(
&mut self,
samples: &[f32],
audio_tx: &tokio::sync::mpsc::Sender<Vec<u8>>,
) {
self.sample_buf.extend_from_slice(samples);
while self.sample_buf.len() >= FRAME_SIZE {
let frame: Vec<f32> = self.sample_buf.drain(..FRAME_SIZE).collect();
match self.encoder.encode_float(&frame, &mut self.opus_buf) {
Ok(len) => {
let packet_data = self.opus_buf[..len].to_vec();
let _ = audio_tx.blocking_send(packet_data);
self.packet_id = self.packet_id.wrapping_add(1);
}
Err(e) => {
tracing::debug!("Opus encode error: {e}");
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TalkMode {
PushToTalk,
Continuous,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VadState {
Silent,
Speaking,
Hangover,
}
pub struct VoiceActivation {
threshold: f32,
hangover_frames: usize,
hangover_counter: usize,
smoothing_window: usize,
energy_history: Vec<f32>,
state: VadState,
}
impl VoiceActivation {
pub fn new(threshold: f32) -> Self {
Self {
threshold,
hangover_frames: 15,
hangover_counter: 0,
smoothing_window: 5,
energy_history: Vec::new(),
state: VadState::Silent,
}
}
pub fn process(&mut self, samples: &[f32]) -> VadState {
let energy: f32 = samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32;
self.energy_history.push(energy);
if self.energy_history.len() > self.smoothing_window {
self.energy_history.remove(0);
}
let avg_energy: f32 =
self.energy_history.iter().sum::<f32>() / self.energy_history.len() as f32;
match self.state {
VadState::Silent => {
if avg_energy > self.threshold {
self.state = VadState::Speaking;
self.hangover_counter = 0;
}
}
VadState::Speaking => {
if avg_energy <= self.threshold * 0.7 {
self.state = VadState::Hangover;
self.hangover_counter = 0;
}
}
VadState::Hangover => {
if avg_energy > self.threshold {
self.state = VadState::Speaking;
self.hangover_counter = 0;
} else {
self.hangover_counter += 1;
if self.hangover_counter >= self.hangover_frames {
self.state = VadState::Silent;
}
}
}
}
self.state
}
pub fn is_speaking(&self) -> bool {
matches!(self.state, VadState::Speaking | VadState::Hangover)
}
}
+54
View File
@@ -0,0 +1,54 @@
use iced::widget::svg::{self, Handle};
use iced::widget::{svg as svg_widget, Svg};
use iced::{Color, Length, Theme};
use std::sync::LazyLock;
#[derive(Debug, Clone, Copy)]
pub enum Icon {
Home,
Bookmark,
UserCircle,
User,
UserMinus,
Cog,
ChevronRight,
ChevronDown,
Microphone,
SpeakerWave,
SpeakerXMark,
}
static HOME: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/home.svg")));
static BOOKMARK: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/bookmark.svg")));
static USER_CIRCLE: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/user-circle.svg")));
static USER: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/user.svg")));
static USER_MINUS: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/user-minus.svg")));
static COG: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/cog-6-tooth.svg")));
static CHEVRON_RIGHT: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/chevron-right.svg")));
static CHEVRON_DOWN: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/chevron-down.svg")));
static MICROPHONE: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/microphone.svg")));
static SPEAKER_WAVE: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/speaker-wave.svg")));
static SPEAKER_X_MARK: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/speaker-x-mark.svg")));
fn handle(icon: Icon) -> Handle {
match icon {
Icon::Home => HOME.clone(),
Icon::Bookmark => BOOKMARK.clone(),
Icon::UserCircle => USER_CIRCLE.clone(),
Icon::User => USER.clone(),
Icon::UserMinus => USER_MINUS.clone(),
Icon::Cog => COG.clone(),
Icon::ChevronRight => CHEVRON_RIGHT.clone(),
Icon::ChevronDown => CHEVRON_DOWN.clone(),
Icon::Microphone => MICROPHONE.clone(),
Icon::SpeakerWave => SPEAKER_WAVE.clone(),
Icon::SpeakerXMark => SPEAKER_X_MARK.clone(),
}
}
pub fn view<'a>(icon: Icon, size: f32, color: Color) -> Svg<'a, Theme> {
svg_widget(handle(icon))
.width(Length::Fixed(size))
.height(Length::Fixed(size))
.style(move |_theme: &Theme, _status| svg::Style { color: Some(color) })
}
+23
View File
@@ -0,0 +1,23 @@
use std::fs;
use std::path::Path;
use tsclientlib::Identity;
pub fn import_identity_from_string(raw: &str) -> Result<Identity, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("Identity string is empty".to_string());
}
Identity::new_from_ts_str(trimmed)
.or_else(|_| Identity::new_from_str(trimmed))
.map_err(|e| format!("Failed to parse identity: {e}"))
}
pub fn import_identity_from_file(path: &str) -> Result<Identity, String> {
let path_ref = Path::new(path);
let raw = fs::read_to_string(path_ref)
.map_err(|e| format!("Failed to read identity file '{}': {e}", path_ref.display()))?;
import_identity_from_string(&raw)
}
File diff suppressed because it is too large Load Diff
+221
View File
@@ -0,0 +1,221 @@
use serde::{Deserialize, Serialize};
use sonora::config::{
AdaptiveDigital, GainController2, HighPassFilter, NoiseSuppression,
NoiseSuppressionLevel,
};
use sonora::{AudioProcessing, Config, StreamConfig};
const SONORA_FRAME_SIZE: usize = 480;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
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]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SonoraSettings {
pub noise_suppression_level: NoiseSuppressionLevelSetting,
pub agc_enabled: bool,
pub high_pass_enabled: bool,
}
impl Default for SonoraSettings {
fn default() -> Self {
Self {
noise_suppression_level: NoiseSuppressionLevelSetting::Moderate,
agc_enabled: true,
high_pass_enabled: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NoiseSuppressionLevelSetting {
Low,
Moderate,
High,
VeryHigh,
}
impl NoiseSuppressionLevelSetting {
pub fn label(self) -> &'static str {
match self {
Self::Low => "Low",
Self::Moderate => "Moderate",
Self::High => "High",
Self::VeryHigh => "Very High",
}
}
pub fn all() -> &'static [NoiseSuppressionLevelSetting] {
&[Self::Low, Self::Moderate, Self::High, Self::VeryHigh]
}
fn to_sonora(self) -> NoiseSuppressionLevel {
match self {
Self::Low => NoiseSuppressionLevel::Low,
Self::Moderate => NoiseSuppressionLevel::Moderate,
Self::High => NoiseSuppressionLevel::High,
Self::VeryHigh => NoiseSuppressionLevel::VeryHigh,
}
}
}
pub struct NoiseReducer {
method: NoiseCancelMethod,
sonora_settings: SonoraSettings,
nnnoiseless: Option<Box<nnnoiseless::DenoiseState<'static>>>,
sonora: Option<SonoraNoiseReducer>,
residual_buf: Vec<f32>,
}
struct SonoraNoiseReducer {
processor: AudioProcessing,
input: Vec<f32>,
output: Vec<f32>,
}
impl SonoraNoiseReducer {
fn new(settings: SonoraSettings) -> Self {
let stream = StreamConfig::new(48_000, 1);
let config = Config {
high_pass_filter: settings.high_pass_enabled.then(HighPassFilter::default),
noise_suppression: Some(NoiseSuppression {
level: settings.noise_suppression_level.to_sonora(),
..NoiseSuppression::default()
}),
gain_controller2: settings.agc_enabled.then(|| GainController2 {
adaptive_digital: Some(AdaptiveDigital::default()),
..GainController2::default()
}),
..Config::default()
};
Self {
processor: AudioProcessing::builder()
.config(config)
.capture_config(stream)
.render_config(stream)
.build(),
input: vec![0.0; SONORA_FRAME_SIZE],
output: vec![0.0; SONORA_FRAME_SIZE],
}
}
fn process(&mut self, frame: &mut [f32]) {
self.input.copy_from_slice(frame);
let src = [&self.input[..]];
let mut dest = [&mut self.output[..]];
if let Err(error) = self.processor.process_capture_f32(&src, &mut dest) {
tracing::debug!("Sonora noise suppression error: {error}");
return;
}
frame.copy_from_slice(&self.output);
}
}
impl NoiseReducer {
pub fn new(method: NoiseCancelMethod, sonora_settings: SonoraSettings) -> Self {
let mut this = Self {
method,
sonora_settings,
nnnoiseless: None,
sonora: None,
residual_buf: Vec::new(),
};
this.init_method();
this
}
fn init_method(&mut self) {
self.nnnoiseless = match self.method {
NoiseCancelMethod::Nnnoiseless => Some(nnnoiseless::DenoiseState::new()),
_ => None,
};
self.sonora = match self.method {
NoiseCancelMethod::Sonora => Some(SonoraNoiseReducer::new(self.sonora_settings)),
_ => None,
};
self.residual_buf.clear();
}
pub fn set_method(&mut self, method: NoiseCancelMethod) {
self.method = method;
self.init_method();
}
pub fn set_sonora_settings(&mut self, settings: SonoraSettings) {
self.sonora_settings = settings;
if self.method == NoiseCancelMethod::Sonora {
self.init_method();
}
}
pub fn process(&mut self, _samples: &mut [f32]) {
match self.method {
NoiseCancelMethod::None => {}
NoiseCancelMethod::Nnnoiseless => self.process_nnnoiseless(_samples),
NoiseCancelMethod::Sonora => self.process_sonora(_samples),
}
}
fn process_nnnoiseless(&mut self, samples: &mut [f32]) {
let denoise = match &mut self.nnnoiseless {
Some(d) => d,
None => return,
};
self.residual_buf.extend_from_slice(samples);
let frame_size = nnnoiseless::DenoiseState::FRAME_SIZE; // 480
let mut output = vec![0.0f32; frame_size];
let mut write_pos = 0;
while self.residual_buf.len() >= frame_size {
let frame: Vec<f32> = self.residual_buf.drain(..frame_size).collect();
let mut input = [0.0f32; 480];
for (i, &s) in frame.iter().enumerate().take(frame_size) {
input[i] = s * 32768.0;
}
denoise.process_frame(&mut output, &input);
for i in 0..frame_size {
if write_pos + i < samples.len() {
samples[write_pos + i] = output[i] / 32768.0;
}
}
write_pos += frame_size;
}
}
fn process_sonora(&mut self, samples: &mut [f32]) {
let reducer = match &mut self.sonora {
Some(s) => s,
None => return,
};
// Sonora processes 10ms frames (480 samples at 48kHz)
let frame_size = SONORA_FRAME_SIZE;
let mut offset = 0;
while offset + frame_size <= samples.len() {
let frame = &mut samples[offset..offset + frame_size];
reducer.process(frame);
offset += frame_size;
}
}
}
+120
View File
@@ -0,0 +1,120 @@
use std::fs;
use std::path::PathBuf;
use crate::types::{AppSettings, BookmarkInfo};
pub(crate) fn load_bookmarks() -> Vec<BookmarkInfo> {
let Some(path) = bookmarks_config_path() else {
return default_bookmarks();
};
let Ok(raw) = fs::read_to_string(path) else {
return default_bookmarks();
};
match serde_json::from_str::<Vec<BookmarkInfo>>(&raw) {
Ok(bookmarks) if !bookmarks.is_empty() => bookmarks,
_ => default_bookmarks(),
}
}
pub(crate) fn persist_bookmarks(bookmarks: &[BookmarkInfo]) {
let Some(path) = bookmarks_config_path() else {
return;
};
let Some(parent) = path.parent() else {
return;
};
if fs::create_dir_all(parent).is_err() {
return;
}
let Ok(raw) = serde_json::to_string_pretty(bookmarks) else {
return;
};
if let Err(error) = fs::write(path, raw) {
tracing::warn!("Failed to persist bookmarks: {error}");
}
}
pub(crate) fn load_settings() -> AppSettings {
let Some(path) = settings_config_path() else {
return AppSettings::default();
};
let Ok(raw) = fs::read_to_string(path) else {
return AppSettings::default();
};
serde_json::from_str::<AppSettings>(&raw).unwrap_or_default()
}
pub(crate) fn persist_settings(settings: &AppSettings) {
let Some(path) = settings_config_path() else {
return;
};
let Some(parent) = path.parent() else {
return;
};
if fs::create_dir_all(parent).is_err() {
return;
}
let Ok(raw) = serde_json::to_string_pretty(settings) else {
return;
};
if let Err(error) = fs::write(path, raw) {
tracing::warn!("Failed to persist settings: {error}");
}
}
fn default_bookmarks() -> Vec<BookmarkInfo> {
vec![
BookmarkInfo {
name: "Local Server".to_string(),
address: "127.0.0.1".to_string(),
port: 9987,
nickname: Some("User".to_string()),
favorite: true,
last_used_at: None,
},
BookmarkInfo {
name: "KR TeamSpeak".to_string(),
address: "kr.teamspeak.app".to_string(),
port: 9987,
nickname: None,
favorite: true,
last_used_at: None,
},
]
}
fn config_dir() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var_os("APPDATA").map(|base| PathBuf::from(base).join("re-teamspeak"))
}
#[cfg(not(target_os = "windows"))]
{
if let Some(base) = std::env::var_os("XDG_CONFIG_HOME") {
return Some(PathBuf::from(base).join("re-teamspeak"));
}
std::env::var_os("HOME")
.map(|home| PathBuf::from(home).join(".config").join("re-teamspeak"))
}
}
fn bookmarks_config_path() -> Option<PathBuf> {
config_dir().map(|path| path.join("bookmarks.json"))
}
fn settings_config_path() -> Option<PathBuf> {
config_dir().map(|path| path.join("settings.json"))
}
+127
View File
@@ -0,0 +1,127 @@
use iced::futures::StreamExt;
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
use tsclientlib::sync::{SyncConnection, SyncConnectionHandle, SyncStreamItem};
use tsclientlib::AudioEvent;
use tsproto_packets::packets::{AudioData, CodecType, OutAudio};
use crate::audio;
use crate::noise_cancel;
use crate::types::TsEvent;
pub async fn run_connection(
con: SyncConnection,
event_tx: mpsc::Sender<TsEvent>,
audio: Arc<Mutex<audio::AudioPlayback>>,
) {
let mut stream = con;
while let Some(item) = stream.next().await {
let ts_event = match item {
Ok(SyncStreamItem::BookEvents(events)) => TsEvent::BookEvents(events),
Ok(SyncStreamItem::MessageEvent(msg)) => TsEvent::MessageEvent(msg),
Ok(SyncStreamItem::AudioChange(change)) => match change {
AudioEvent::CanSendAudio(can) => TsEvent::AudioChange(can, true),
AudioEvent::CanReceiveAudio(can) => TsEvent::AudioChange(false, can),
},
Ok(SyncStreamItem::IdentityLevelIncreasing(level)) => {
TsEvent::IdentityLevelIncreasing(level)
}
Ok(SyncStreamItem::IdentityLevelIncreased) => TsEvent::IdentityLevelIncreased,
Ok(SyncStreamItem::DisconnectedTemporarily(_)) => TsEvent::DisconnectedTemporarily,
Ok(SyncStreamItem::NetworkStatsUpdated) => continue,
Ok(SyncStreamItem::Audio(audio_buf)) => {
let playback = audio.lock().await;
playback.send_packet(audio_buf);
continue;
}
Err(e) => TsEvent::Error(e.to_string()),
};
if event_tx.send(ts_event).await.is_err() {
break;
}
}
let _ = event_tx.send(TsEvent::Disconnected).await;
}
pub fn start_transmission(
mic: Arc<Mutex<audio::Microphone>>,
handle: Arc<Mutex<Option<SyncConnectionHandle>>>,
device_name: Option<String>,
noise_reducer: Arc<Mutex<noise_cancel::NoiseReducer>>,
) {
let (sample_tx, sample_rx) = std::sync::mpsc::channel();
tokio::spawn(async move {
{
let mut mic_guard = mic.lock().await;
if mic_guard.start(device_name.as_deref(), sample_tx).is_err() {
return;
}
}
let mut enc = match audio::OpusEncoderState::new() {
Ok(e) => e,
Err(_) => return,
};
let (audio_tx, mut audio_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(100);
let send_handle = handle.clone();
tokio::spawn(async move {
while let Some(data) = audio_rx.recv().await {
let mut guard = send_handle.lock().await;
if let Some(ref mut h) = *guard {
let _ = h
.with_connection(move |con| {
let packet = OutAudio::new(&AudioData::C2S {
id: 0,
codec: CodecType::OpusVoice,
data: &data,
});
let _ = con.send_audio(packet);
Ok::<(), ()>(())
})
.await;
}
}
});
while let Ok(samples) = sample_rx.recv() {
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);
}
let mut mic_guard = mic.lock().await;
mic_guard.stop();
});
}
pub fn stop_transmission(
mic: Arc<Mutex<audio::Microphone>>,
handle: Arc<Mutex<Option<SyncConnectionHandle>>>,
) {
tokio::spawn(async move {
{
let mut mic_guard = mic.lock().await;
mic_guard.stop();
}
let mut guard = handle.lock().await;
if let Some(ref mut h) = *guard {
let _ = h
.with_connection(move |con| {
let packet = OutAudio::new(&AudioData::C2S {
id: 0,
codec: CodecType::OpusVoice,
data: &[],
});
let _ = con.send_audio(packet);
Ok::<(), ()>(())
})
.await;
}
});
}
+555
View File
@@ -0,0 +1,555 @@
use iced::widget::{button, container, text_input};
use iced::{Background, Border, Color, Shadow, Theme};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum AppearanceMode {
Light,
Dark,
}
impl AppearanceMode {
pub fn label(self) -> &'static str {
match self {
Self::Light => "Light",
Self::Dark => "Dark",
}
}
}
const LIGHT_BG: Color = Color {
r: 245.0 / 255.0,
g: 245.0 / 255.0,
b: 247.0 / 255.0,
a: 1.0,
};
const LIGHT_SURFACE: Color = Color {
r: 1.0,
g: 1.0,
b: 1.0,
a: 1.0,
};
const LIGHT_ELEVATED: Color = Color {
r: 251.0 / 255.0,
g: 251.0 / 255.0,
b: 253.0 / 255.0,
a: 1.0,
};
const LIGHT_TEXT: Color = Color {
r: 29.0 / 255.0,
g: 29.0 / 255.0,
b: 31.0 / 255.0,
a: 1.0,
};
const LIGHT_TEXT_SECONDARY: Color = Color {
r: 110.0 / 255.0,
g: 110.0 / 255.0,
b: 115.0 / 255.0,
a: 1.0,
};
const LIGHT_BORDER: Color = Color {
r: 210.0 / 255.0,
g: 210.0 / 255.0,
b: 215.0 / 255.0,
a: 1.0,
};
const LIGHT_ACCENT: Color = Color {
r: 0.0,
g: 122.0 / 255.0,
b: 1.0,
a: 1.0,
};
const DARK_BG: Color = Color {
r: 11.0 / 255.0,
g: 11.0 / 255.0,
b: 13.0 / 255.0,
a: 1.0,
};
const DARK_SURFACE: Color = Color {
r: 28.0 / 255.0,
g: 28.0 / 255.0,
b: 31.0 / 255.0,
a: 1.0,
};
const DARK_ELEVATED: Color = Color {
r: 22.0 / 255.0,
g: 22.0 / 255.0,
b: 24.0 / 255.0,
a: 1.0,
};
const DARK_TEXT: Color = Color {
r: 245.0 / 255.0,
g: 245.0 / 255.0,
b: 247.0 / 255.0,
a: 1.0,
};
const DARK_TEXT_SECONDARY: Color = Color {
r: 161.0 / 255.0,
g: 161.0 / 255.0,
b: 170.0 / 255.0,
a: 1.0,
};
const DARK_BORDER: Color = Color {
r: 1.0,
g: 1.0,
b: 1.0,
a: 0.08,
};
const DARK_ACCENT: Color = Color {
r: 10.0 / 255.0,
g: 132.0 / 255.0,
b: 1.0,
a: 1.0,
};
const SUCCESS: Color = Color {
r: 52.0 / 255.0,
g: 199.0 / 255.0,
b: 89.0 / 255.0,
a: 1.0,
};
const DANGER: Color = Color {
r: 1.0,
g: 69.0 / 255.0,
b: 58.0 / 255.0,
a: 1.0,
};
pub fn light_theme() -> Theme {
Theme::custom(
"ReTeamSpeak Light".to_string(),
iced::theme::Palette {
background: LIGHT_BG,
text: LIGHT_TEXT,
primary: LIGHT_ACCENT,
success: SUCCESS,
danger: DANGER,
},
)
}
pub fn dark_theme() -> Theme {
Theme::custom(
"ReTeamSpeak Dark".to_string(),
iced::theme::Palette {
background: DARK_BG,
text: DARK_TEXT,
primary: DARK_ACCENT,
success: SUCCESS,
danger: DANGER,
},
)
}
fn is_dark(theme: &Theme) -> bool {
theme.palette().background.r < 0.2
}
fn surface(theme: &Theme) -> Color {
if is_dark(theme) {
DARK_SURFACE
} else {
LIGHT_SURFACE
}
}
fn elevated(theme: &Theme) -> Color {
if is_dark(theme) {
DARK_ELEVATED
} else {
LIGHT_ELEVATED
}
}
fn text_secondary(theme: &Theme) -> Color {
if is_dark(theme) {
DARK_TEXT_SECONDARY
} else {
LIGHT_TEXT_SECONDARY
}
}
pub fn icon_color(theme: &Theme) -> Color {
text_secondary(theme)
}
pub fn nav_icon_color(theme: &Theme) -> Color {
text_secondary(theme)
}
pub fn status_color(theme: &Theme, connected: bool) -> Color {
if connected {
theme.palette().success
} else {
text_secondary(theme)
}
}
fn border(theme: &Theme) -> Color {
if is_dark(theme) {
DARK_BORDER
} else {
LIGHT_BORDER
}
}
fn accent(theme: &Theme) -> Color {
theme.palette().primary
}
fn accent_hover(theme: &Theme) -> Color {
let primary = accent(theme);
Color {
a: primary.a,
..Color::from_rgba(
(primary.r + 0.05).min(1.0),
(primary.g + 0.05).min(1.0),
(primary.b + 0.05).min(1.0),
1.0,
)
}
}
pub fn sidebar_container(theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(elevated(theme))),
border: Border {
color: border(theme),
width: if is_dark(theme) { 1.0 } else { 0.0 },
radius: 0.0.into(),
},
..container::Style::default()
}
}
pub fn main_panel_container(theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(theme.palette().background)),
..container::Style::default()
}
}
pub fn sidebar_section_container(theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(surface(theme))),
border: Border {
color: border(theme),
width: 1.0,
radius: 16.0.into(),
},
shadow: Shadow {
color: Color::from_rgba(0.0, 0.0, 0.0, if is_dark(theme) { 0.18 } else { 0.08 }),
offset: iced::Vector::new(0.0, 4.0),
blur_radius: 16.0,
},
..container::Style::default()
}
}
pub fn hero_container(theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(surface(theme))),
border: Border {
color: border(theme),
width: 1.0,
radius: 20.0.into(),
},
shadow: Shadow {
color: Color::from_rgba(0.0, 0.0, 0.0, if is_dark(theme) { 0.24 } else { 0.10 }),
offset: iced::Vector::new(0.0, 8.0),
blur_radius: 24.0,
},
..container::Style::default()
}
}
pub fn card_container(theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(surface(theme))),
border: Border {
color: border(theme),
width: 1.0,
radius: 16.0.into(),
},
shadow: Shadow {
color: Color::from_rgba(0.0, 0.0, 0.0, if is_dark(theme) { 0.16 } else { 0.07 }),
offset: iced::Vector::new(0.0, 4.0),
blur_radius: 16.0,
},
..container::Style::default()
}
}
pub fn voice_capsule_container(theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(Color::from_rgba(
elevated(theme).r,
elevated(theme).g,
elevated(theme).b,
if is_dark(theme) { 0.9 } else { 0.96 },
))),
border: Border {
color: border(theme),
width: 1.0,
radius: 22.0.into(),
},
shadow: Shadow {
color: Color::from_rgba(0.0, 0.0, 0.0, if is_dark(theme) { 0.20 } else { 0.08 }),
offset: iced::Vector::new(0.0, 6.0),
blur_radius: 18.0,
},
..container::Style::default()
}
}
pub fn elevated_container(theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(elevated(theme))),
border: Border {
color: border(theme),
width: 1.0,
radius: 12.0.into(),
},
..container::Style::default()
}
}
pub fn muted_elevated_container(theme: &Theme) -> container::Style {
let background = if is_dark(theme) {
Color::from_rgba(1.0, 1.0, 1.0, 0.04)
} else {
LIGHT_ELEVATED
};
container::Style {
background: Some(Background::Color(background)),
border: Border {
color: border(theme),
width: 1.0,
radius: 14.0.into(),
},
..container::Style::default()
}
}
pub fn input_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
let base = match status {
text_input::Status::Focused => accent(theme),
text_input::Status::Hovered => text_secondary(theme),
_ => border(theme),
};
text_input::Style {
background: Background::Color(elevated(theme)),
border: Border {
color: base,
width: if matches!(status, text_input::Status::Focused) {
1.5
} else {
1.0
},
radius: 12.0.into(),
},
icon: text_secondary(theme),
placeholder: text_secondary(theme),
value: theme.palette().text,
selection: accent(theme),
}
}
pub fn primary_button(theme: &Theme, status: button::Status) -> button::Style {
let background = match status {
button::Status::Hovered => accent_hover(theme),
button::Status::Pressed => accent_hover(theme),
button::Status::Disabled => elevated(theme),
_ => accent(theme),
};
button::Style {
background: Some(Background::Color(background)),
text_color: Color::WHITE,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 999.0.into(),
},
shadow: Shadow {
color: Color::from_rgba(0.0, 0.0, 0.0, if is_dark(theme) { 0.20 } else { 0.10 }),
offset: iced::Vector::new(0.0, 3.0),
blur_radius: 10.0,
},
}
}
pub fn secondary_button(theme: &Theme, status: button::Status) -> button::Style {
let background = match status {
button::Status::Hovered => elevated(theme),
button::Status::Pressed => elevated(theme),
_ => surface(theme),
};
button::Style {
background: Some(Background::Color(background)),
text_color: theme.palette().text,
border: Border {
color: border(theme),
width: 1.0,
radius: 999.0.into(),
},
shadow: Shadow::default(),
}
}
pub fn danger_button(_theme: &Theme, _status: button::Status) -> button::Style {
button::Style {
background: Some(Background::Color(DANGER)),
text_color: Color::WHITE,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 999.0.into(),
},
shadow: Shadow::default(),
}
}
pub fn subtle_button(theme: &Theme, status: button::Status) -> button::Style {
let background = match status {
button::Status::Hovered | button::Status::Pressed => {
if is_dark(theme) {
Color::from_rgba(1.0, 1.0, 1.0, 0.08)
} else {
LIGHT_ELEVATED
}
}
_ => Color::TRANSPARENT,
};
button::Style {
background: Some(Background::Color(background)),
text_color: theme.palette().text,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 12.0.into(),
},
shadow: Shadow::default(),
}
}
pub fn channel_button_active(theme: &Theme, _status: button::Status) -> button::Style {
button::Style {
background: Some(Background::Color(if is_dark(theme) {
Color::from_rgba(accent(theme).r, accent(theme).g, accent(theme).b, 0.18)
} else {
Color::from_rgba(accent(theme).r, accent(theme).g, accent(theme).b, 0.12)
})),
text_color: theme.palette().text,
border: Border {
color: if is_dark(theme) {
Color::from_rgba(accent(theme).r, accent(theme).g, accent(theme).b, 0.36)
} else {
accent(theme)
},
width: 1.0,
radius: 12.0.into(),
},
shadow: Shadow::default(),
}
}
pub fn channel_button(theme: &Theme, status: button::Status) -> button::Style {
let background = match status {
button::Status::Hovered | button::Status::Pressed => {
if is_dark(theme) {
Color::from_rgba(1.0, 1.0, 1.0, 0.05)
} else {
LIGHT_ELEVATED
}
}
_ => Color::TRANSPARENT,
};
button::Style {
background: Some(Background::Color(background)),
text_color: theme.palette().text,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 12.0.into(),
},
shadow: Shadow::default(),
}
}
pub fn nav_button_active(theme: &Theme, _status: button::Status) -> button::Style {
button::Style {
background: Some(Background::Color(accent(theme))),
text_color: Color::WHITE,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 999.0.into(),
},
shadow: Shadow::default(),
}
}
pub fn nav_button_inactive(theme: &Theme, status: button::Status) -> button::Style {
let background = if matches!(status, button::Status::Hovered) {
surface(theme)
} else {
Color::TRANSPARENT
};
button::Style {
background: Some(Background::Color(background)),
text_color: if matches!(status, button::Status::Hovered) {
theme.palette().text
} else {
text_secondary(theme)
},
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 999.0.into(),
},
shadow: Shadow::default(),
}
}
pub fn bookmark_button_selected(theme: &Theme, _status: button::Status) -> button::Style {
button::Style {
background: Some(Background::Color(elevated(theme))),
text_color: theme.palette().text,
border: Border {
color: accent(theme),
width: 1.0,
radius: 14.0.into(),
},
shadow: Shadow::default(),
}
}
pub fn bookmark_button(theme: &Theme, status: button::Status) -> button::Style {
let background = if matches!(status, button::Status::Hovered) {
elevated(theme)
} else {
Color::TRANSPARENT
};
button::Style {
background: Some(Background::Color(background)),
text_color: if matches!(status, button::Status::Hovered) {
theme.palette().text
} else {
text_secondary(theme)
},
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 14.0.into(),
},
shadow: Shadow::default(),
}
}
+200
View File
@@ -0,0 +1,200 @@
use serde::{Deserialize, Serialize};
use tsclientlib::events::Event;
use tsclientlib::{ChannelId, ClientId, Identity};
use crate::theme;
use crate::{audio, noise_cancel};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Page {
Home,
Bookmarks,
Identities,
Settings,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SettingsSection {
General,
Audio,
Capture,
Playback,
Hotkeys,
Notifications,
Appearance,
Chat,
Identities,
Advanced,
}
impl SettingsSection {
pub(crate) const ALL: [Self; 10] = [
Self::General,
Self::Audio,
Self::Capture,
Self::Playback,
Self::Hotkeys,
Self::Notifications,
Self::Appearance,
Self::Chat,
Self::Identities,
Self::Advanced,
];
pub(crate) fn label(self) -> &'static str {
match self {
Self::General => "General",
Self::Audio => "Audio",
Self::Capture => "Capture",
Self::Playback => "Playback",
Self::Hotkeys => "Hotkeys",
Self::Notifications => "Notifications",
Self::Appearance => "Appearance",
Self::Chat => "Chat",
Self::Identities => "Identities",
Self::Advanced => "Advanced",
}
}
}
#[derive(Debug, Clone)]
#[allow(clippy::enum_variant_names)]
pub(crate) enum Message {
PageChanged(Page),
SelectBookmark(Option<usize>),
AddBookmark,
CancelAddBookmark,
DeleteBookmark(usize),
BookmarkNameChanged(String),
BookmarkAddressChanged(String),
BookmarkPortChanged(String),
NicknameChanged(String),
PasswordChanged(String),
Connect,
JoinChannel(ChannelId),
ToggleChannelCollapsed(ChannelId),
Disconnect,
TsEvent(TsEvent),
ChannelSearchChanged(String),
ToggleLocalClientMute(ClientId),
IncreaseClientVolume(ClientId),
DecreaseClientVolume(ClientId),
MessageInputChanged(String),
SendChannelMessage,
PttPressed,
PttReleased,
ToggleTalkMode,
SetNoiseCancelMethod(noise_cancel::NoiseCancelMethod),
SetSonoraNoiseSuppressionLevel(noise_cancel::NoiseSuppressionLevelSetting),
ToggleSonoraAgc,
ToggleSonoraHighPass,
StartContinuous,
StopContinuous,
MicSamples(Vec<f32>),
ToggleMicMute,
ToggleSpeakerMute,
ToggleHeadsetMute,
ToggleAfk,
IdentityStringChanged(String),
IdentityFilePathChanged(String),
ImportIdentityFromString,
ImportIdentityFromFile,
IdentityImported(Result<Identity, String>),
SetOutputDevice(String),
SetInputDevice(String),
SelectSettingsSection(SettingsSection),
SetAppearance(theme::AppearanceMode),
Noop,
}
#[derive(Debug, Clone)]
pub(crate) enum TsEvent {
Connected,
BookEvents(Vec<Event>),
MessageEvent(tsclientlib::InMessage),
AudioChange(bool, bool),
IdentityLevelIncreasing(u8),
IdentityLevelIncreased,
DisconnectedTemporarily,
Disconnected,
Error(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub(crate) struct BookmarkInfo {
pub(crate) name: String,
pub(crate) address: String,
pub(crate) port: u16,
pub(crate) nickname: Option<String>,
pub(crate) favorite: bool,
pub(crate) last_used_at: Option<i64>,
}
impl Default for BookmarkInfo {
fn default() -> Self {
Self {
name: String::new(),
address: String::new(),
port: 9987,
nickname: None,
favorite: false,
last_used_at: None,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct ChannelEntry {
pub(crate) id: ChannelId,
pub(crate) name: String,
#[allow(dead_code)]
pub(crate) parent: ChannelId,
pub(crate) order: ChannelId,
}
#[derive(Debug, Clone)]
pub(crate) struct ClientEntry {
pub(crate) id: ClientId,
pub(crate) name: String,
pub(crate) channel: ChannelId,
pub(crate) input_muted: bool,
#[allow(dead_code)]
pub(crate) output_muted: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct ChatMessage {
pub(crate) invoker_name: String,
pub(crate) message: String,
}
#[derive(Debug, Clone)]
pub(crate) struct LocalClientAudio {
pub(crate) muted: bool,
pub(crate) volume: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub(crate) struct AppSettings {
pub(crate) appearance_mode: theme::AppearanceMode,
pub(crate) selected_output_device: String,
pub(crate) selected_input_device: String,
pub(crate) talk_mode: audio::TalkMode,
pub(crate) noise_cancel_method: noise_cancel::NoiseCancelMethod,
pub(crate) sonora_settings: noise_cancel::SonoraSettings,
}
impl Default for AppSettings {
fn default() -> Self {
Self {
appearance_mode: theme::AppearanceMode::Dark,
selected_output_device: String::new(),
selected_input_device: String::new(),
talk_mode: audio::TalkMode::PushToTalk,
noise_cancel_method: noise_cancel::NoiseCancelMethod::None,
sonora_settings: noise_cancel::SonoraSettings::default(),
}
}
}
File diff suppressed because it is too large Load Diff
-13
View File
@@ -1,13 +0,0 @@
[package]
name = "shared"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
toml = { workspace = true }
-112
View File
@@ -1,112 +0,0 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::types::*;
/// 配置管理器
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigManager {
pub app: AppConfig,
pub connections: Vec<SavedConnection>,
pub recent_servers: Vec<RecentServer>,
}
/// 保存的连接
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedConnection {
pub id: String,
pub name: String,
pub address: String,
pub port: u16,
pub nickname: String,
pub server_password: Option<String>,
pub channel: Option<String>,
pub channel_password: Option<String>,
pub default_token: Option<String>,
pub auto_connect: bool,
pub last_connected: Option<chrono::DateTime<chrono::Utc>>,
}
/// 最近连接的服务器
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecentServer {
pub address: String,
pub port: u16,
pub name: String,
pub last_connected: chrono::DateTime<chrono::Utc>,
pub connect_count: u32,
}
impl ConfigManager {
pub fn new() -> Self {
Self {
app: AppConfig::default(),
connections: Vec::new(),
recent_servers: Vec::new(),
}
}
pub fn load(path: &PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let config: Self = toml::from_str(&content)?;
Ok(config)
}
pub fn save(&self, path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
let content = toml::to_string_pretty(self)?;
std::fs::write(path, content)?;
Ok(())
}
pub fn add_connection(&mut self, connection: SavedConnection) {
if let Some(existing) = self.connections.iter_mut().find(|c| c.id == connection.id) {
*existing = connection;
} else {
self.connections.push(connection);
}
}
pub fn remove_connection(&mut self, id: &str) {
self.connections.retain(|c| c.id != id);
}
pub fn get_connection(&self, id: &str) -> Option<&SavedConnection> {
self.connections.iter().find(|c| c.id == id)
}
pub fn add_recent_server(&mut self, address: &str, port: u16, name: &str) {
let now = chrono::Utc::now();
if let Some(existing) = self
.recent_servers
.iter_mut()
.find(|s| s.address == address && s.port == port)
{
existing.last_connected = now;
existing.connect_count += 1;
existing.name = name.to_string();
} else {
self.recent_servers.push(RecentServer {
address: address.to_string(),
port,
name: name.to_string(),
last_connected: now,
connect_count: 1,
});
}
self.recent_servers
.sort_by_key(|b| std::cmp::Reverse(b.last_connected));
if self.recent_servers.len() > 20 {
self.recent_servers.truncate(20);
}
}
pub fn get_recent_servers(&self) -> &[RecentServer] {
&self.recent_servers
}
}
impl Default for ConfigManager {
fn default() -> Self {
Self::new()
}
}
-62
View File
@@ -1,62 +0,0 @@
use thiserror::Error;
/// 应用错误
#[derive(Error, Debug)]
pub enum AppError {
#[error("连接错误: {0}")]
Connection(String),
#[error("协议错误: {code} - {message}")]
Protocol { code: u32, message: String },
#[error("网络错误: {0}")]
Network(#[from] std::io::Error),
#[error("加密错误: {0}")]
Crypto(String),
#[error("音频错误: {0}")]
Audio(String),
#[error("数据库错误: {0}")]
Database(String),
#[error("序列化错误: {0}")]
Serialization(#[from] serde_json::Error),
#[error("配置错误: {0}")]
Config(String),
#[error("身份错误: {0}")]
Identity(String),
#[error("权限错误: {0}")]
Permission(String),
#[error("超时错误: {0}")]
Timeout(String),
#[error("未连接")]
NotConnected,
#[error("已连接")]
AlreadyConnected,
#[error("无效参数: {0}")]
InvalidArgument(String),
#[error("不支持的操作: {0}")]
Unsupported(String),
#[error("内部错误: {0}")]
Internal(String),
}
/// 结果类型别名
pub type AppResult<T> = Result<T, AppError>;
impl From<AppError> for String {
fn from(err: AppError) -> Self {
err.to_string()
}
}
-246
View File
@@ -1,246 +0,0 @@
use crate::types::*;
use serde::{Deserialize, Serialize};
/// 应用事件
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AppEvent {
Connection(ConnectionEvent),
Client(ClientEvent),
Channel(ChannelEvent),
Server(ServerEvent),
Message(MessageEvent),
Audio(AudioEvent),
FileTransfer(FileTransferEvent),
Error(ErrorEvent),
}
/// 连接事件
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ConnectionEvent {
Connecting {
address: String,
},
Connected {
server: ServerInfo,
own_client: ClientId,
},
StateChanged {
state: ConnectionState,
},
DisconnectedTemporarily {
reason: String,
},
Disconnected {
reason: String,
},
ConnectionFailed {
error: String,
},
}
/// 客户端事件
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ClientEvent {
EnteredView {
client: ClientInfo,
reason: Reason,
},
LeftView {
client_id: ClientId,
reason: Reason,
reason_message: Option<String>,
},
Updated {
client_id: ClientId,
changes: ClientChanges,
},
Moved {
client_id: ClientId,
from_channel: ChannelId,
to_channel: ChannelId,
reason: Reason,
},
StartedTalking {
client_id: ClientId,
},
StoppedTalking {
client_id: ClientId,
},
ServerGroupChanged {
client_id: ClientId,
group_id: ServerGroupId,
added: bool,
},
ChannelGroupChanged {
client_id: ClientId,
group_id: ChannelGroupId,
},
}
/// 客户端变更
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientChanges {
pub name: Option<String>,
pub input_muted: Option<bool>,
pub output_muted: Option<bool>,
pub output_only_muted: Option<bool>,
pub input_hardware_enabled: Option<bool>,
pub output_hardware_enabled: Option<bool>,
pub talk_power_granted: Option<bool>,
pub metadata: Option<String>,
pub is_recording: Option<bool>,
pub away_message: Option<String>,
pub description: Option<String>,
pub is_priority_speaker: Option<bool>,
pub phonetic_name: Option<String>,
pub is_channel_commander: Option<bool>,
pub badges: Option<String>,
}
/// 频道事件
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ChannelEvent {
Created {
channel: ChannelInfo,
},
Deleted {
channel_id: ChannelId,
},
Updated {
channel_id: ChannelId,
changes: ChannelChanges,
},
Moved {
channel_id: ChannelId,
new_parent: ChannelId,
new_order: ChannelId,
},
PasswordChanged {
channel_id: ChannelId,
},
DescriptionChanged {
channel_id: ChannelId,
},
Subscribed {
channel_id: ChannelId,
subscribed: bool,
},
}
/// 频道变更
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelChanges {
pub name: Option<String>,
pub topic: Option<String>,
pub codec: Option<Codec>,
pub codec_quality: Option<u8>,
pub max_clients: Option<i32>,
pub max_family_clients: Option<i32>,
pub channel_type: Option<ChannelType>,
pub needed_talk_power: Option<i32>,
pub phonetic_name: Option<String>,
pub icon_id: Option<IconId>,
}
/// 服务器事件
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ServerEvent {
Updated { changes: ServerChanges },
ServerGroupList { groups: Vec<ServerGroupInfo> },
ChannelGroupList { groups: Vec<ChannelGroupInfo> },
}
/// 服务器变更
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerChanges {
pub name: Option<String>,
pub welcome_message: Option<String>,
pub host_message: Option<String>,
pub host_message_mode: Option<HostMessageMode>,
pub max_clients: Option<u16>,
}
/// 消息事件
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MessageEvent {
Received { message: ChatMessage },
Sent { message: ChatMessage },
Read { message_id: u64 },
UnreadCountChanged { count: u32 },
}
/// 音频设备
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioDevice {
pub id: String,
pub name: String,
pub is_default: bool,
}
/// 音频事件
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AudioEvent {
InputDeviceChanged {
device: Option<String>,
},
OutputDeviceChanged {
device: Option<String>,
},
InputVolumeChanged {
volume: f32,
},
OutputVolumeChanged {
volume: f32,
},
InputMutedChanged {
muted: bool,
},
OutputMutedChanged {
muted: bool,
},
DeviceList {
input_devices: Vec<AudioDevice>,
output_devices: Vec<AudioDevice>,
},
InputLevel {
level: f32,
},
OutputLevel {
level: f32,
},
}
/// 文件传输事件
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FileTransferEvent {
Started {
transfer_id: String,
file_name: String,
file_size: u64,
is_upload: bool,
},
Progress {
transfer_id: String,
progress: f32,
},
Completed {
transfer_id: String,
},
Failed {
transfer_id: String,
error: String,
},
Cancelled {
transfer_id: String,
},
}
/// 错误事件
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ErrorEvent {
Protocol { code: u32, message: String },
Network { message: String },
Audio { message: String },
Database { message: String },
Other { message: String },
}
-9
View File
@@ -1,9 +0,0 @@
pub mod config;
pub mod errors;
pub mod events;
pub mod types;
pub use config::*;
pub use errors::*;
pub use events::*;
pub use types::*;
-464
View File
@@ -1,464 +0,0 @@
use serde::{Deserialize, Serialize};
use std::fmt;
/// TeamSpeak 核心类型定义
///
/// 客户端 ID
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ClientId(pub u16);
/// 频道 ID
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ChannelId(pub u64);
/// 服务器组 ID
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ServerGroupId(pub u64);
/// 频道组 ID
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ChannelGroupId(pub u64);
/// 客户端数据库 ID
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ClientDbId(pub u64);
/// 用户唯一标识符
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Uid(pub String);
/// 权限 ID
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PermissionId(pub u32);
/// TeamSpeak permission catalog entry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PermissionInfo {
pub id: PermissionId,
pub name: String,
pub description: String,
}
/// Minimal channel row returned by ServerQuery `channellist`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerQueryChannel {
pub id: ChannelId,
pub parent_id: ChannelId,
pub order: ChannelId,
pub name: String,
pub total_clients: u32,
pub needed_subscribe_power: i32,
}
/// Minimal client row returned by ServerQuery `clientlist`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerQueryClient {
pub id: ClientId,
pub channel_id: ChannelId,
pub database_id: ClientDbId,
pub nickname: String,
pub client_type: ClientType,
pub unique_identifier: String,
}
/// Minimal server row returned by ServerQuery `serverinfo`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerQueryServerInfo {
pub name: String,
pub platform: String,
pub version: String,
pub max_clients: u16,
pub clients_online: u16,
pub channels_online: u64,
pub uptime: u64,
}
/// 图标 ID
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct IconId(pub i32);
/// 音频编解码器
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Codec {
SpeexNarrowband,
SpeexWideband,
SpeexUltrawideband,
CeltMono,
OpusVoice,
OpusMusic,
}
impl Codec {
pub fn sample_rate(&self) -> u32 {
match self {
Self::SpeexNarrowband => 8000,
Self::SpeexWideband => 16000,
Self::SpeexUltrawideband => 32000,
Self::CeltMono | Self::OpusVoice | Self::OpusMusic => 48000,
}
}
pub fn channels(&self) -> u16 {
match self {
Self::OpusMusic => 2,
_ => 1,
}
}
}
impl fmt::Display for Codec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::SpeexNarrowband => write!(f, "Speex Narrowband"),
Self::SpeexWideband => write!(f, "Speex Wideband"),
Self::SpeexUltrawideband => write!(f, "Speex Ultrawideband"),
Self::CeltMono => write!(f, "CELT Mono"),
Self::OpusVoice => write!(f, "Opus Voice"),
Self::OpusMusic => write!(f, "Opus Music"),
}
}
}
/// 频道类型
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChannelType {
Permanent,
SemiPermanent,
Temporary,
}
/// 客户端类型
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ClientType {
Normal,
Query { admin: bool },
}
/// 文本消息目标模式
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TextMessageTargetMode {
Unknown,
Client,
Channel,
Server,
}
/// 连接状态
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConnectionState {
Uninitialized,
Connecting,
IdentityLevelIncreasing,
Connected,
ChannelListFinished,
DisconnectedTemporarily,
Disconnected,
Error,
}
/// 离开原因
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Reason {
None,
Moved,
Subscription,
LostConnection,
KickChannel,
KickServer,
KickServerBan,
Serverstop,
Clientdisconnect,
Channelupdate,
Channeledit,
ClientdisconnectServerShutdown,
}
/// 服务器信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerInfo {
pub id: u64,
pub name: String,
pub platform: String,
pub version: String,
pub max_clients: u16,
pub clients_online: u16,
pub channels_online: u64,
pub uptime: u64,
pub codec_encryption_mode: CodecEncryptionMode,
pub host_message: String,
pub host_message_mode: HostMessageMode,
pub welcome_message: String,
pub default_server_group: ServerGroupId,
pub default_channel_group: ChannelGroupId,
}
/// 编解码器加密模式
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CodecEncryptionMode {
PerChannel,
ForcedOff,
ForcedOn,
}
/// 主机消息模式
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum HostMessageMode {
None,
Log,
Modal,
Modalquit,
}
/// 频道信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelInfo {
pub id: ChannelId,
pub parent_id: ChannelId,
pub name: String,
pub topic: String,
pub codec: Codec,
pub codec_quality: u8,
pub max_clients: i32,
pub max_family_clients: i32,
pub order: ChannelId,
pub channel_type: ChannelType,
pub is_default: bool,
pub has_password: bool,
pub codec_latency_factor: i32,
pub is_unencrypted: bool,
pub delete_delay: u32,
pub needed_talk_power: i32,
pub forced_silence: bool,
pub phonetic_name: String,
pub icon_id: IconId,
pub is_private: bool,
pub storage_quota: u32,
pub subscribed: bool,
}
/// 客户端信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientInfo {
pub id: ClientId,
pub channel_id: ChannelId,
pub uid: Uid,
pub name: String,
pub input_muted: bool,
pub output_muted: bool,
pub output_only_muted: bool,
pub input_hardware_enabled: bool,
pub output_hardware_enabled: bool,
pub talk_power_granted: bool,
pub metadata: String,
pub is_recording: bool,
pub database_id: ClientDbId,
pub channel_group: ChannelGroupId,
pub server_groups: Vec<ServerGroupId>,
pub away_message: String,
pub client_type: ClientType,
pub avatar_hash: String,
pub talk_power: i32,
pub description: String,
pub is_priority_speaker: bool,
pub unread_messages: u32,
pub phonetic_name: String,
pub icon_id: IconId,
pub is_channel_commander: bool,
pub country_code: String,
pub badges: String,
}
/// 服务器组信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerGroupInfo {
pub id: ServerGroupId,
pub name: String,
pub group_type: GroupType,
pub icon_id: IconId,
pub is_permanent: bool,
pub sort_id: i32,
pub naming_mode: GroupNamingMode,
pub needed_modify_power: i32,
pub needed_member_add_power: i32,
pub needed_member_remove_power: i32,
}
/// 频道组信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelGroupInfo {
pub id: ChannelGroupId,
pub name: String,
pub group_type: GroupType,
pub icon_id: IconId,
pub is_permanent: bool,
pub sort_id: i32,
pub naming_mode: GroupNamingMode,
pub needed_modify_power: i32,
pub needed_member_add_power: i32,
pub needed_member_remove_power: i32,
}
/// 组类型
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum GroupType {
Template,
Regular,
Query,
}
/// 组命名模式
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum GroupNamingMode {
None,
Before,
After,
}
/// 连接配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectConfig {
pub address: String,
pub port: u16,
pub nickname: String,
pub server_password: Option<String>,
pub channel: Option<String>,
pub channel_password: Option<String>,
pub default_token: Option<String>,
pub identity: Option<IdentityConfig>,
}
/// 身份配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdentityConfig {
pub private_key: String,
pub counter: u64,
pub max_counter: u64,
}
/// 音频配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioConfig {
pub input_device: Option<String>,
pub output_device: Option<String>,
pub input_volume: f32,
pub output_volume: f32,
pub vad_enabled: bool,
pub vad_threshold: f32,
pub ptt_enabled: bool,
pub ptt_key: Option<String>,
pub noise_suppression: bool,
pub echo_cancellation: bool,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
input_device: None,
output_device: None,
input_volume: 1.0,
output_volume: 1.0,
vad_enabled: true,
vad_threshold: 0.5,
ptt_enabled: false,
ptt_key: None,
noise_suppression: true,
echo_cancellation: true,
}
}
}
/// 热键动作
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum HotkeyAction {
InputMuteToggle,
OutputMuteToggle,
AwayToggle,
PushToTalk,
ChannelCommanderToggle,
}
/// 热键配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HotkeyConfig {
pub action: HotkeyAction,
pub key: String,
pub modifiers: Vec<String>,
}
/// 应用配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub nickname: String,
pub audio: AudioConfig,
pub hotkeys: Vec<HotkeyConfig>,
pub theme: String,
pub language: String,
pub minimize_to_tray: bool,
pub start_minimized: bool,
pub auto_reconnect: bool,
pub reconnect_delay: u32,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
nickname: "User".to_string(),
audio: AudioConfig::default(),
hotkeys: Vec::new(),
theme: "dark".to_string(),
language: "en".to_string(),
minimize_to_tray: true,
start_minimized: false,
auto_reconnect: true,
reconnect_delay: 5,
}
}
}
/// 聊天消息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub id: u64,
pub timestamp: chrono::DateTime<chrono::Utc>,
pub invoker: ClientId,
pub invoker_name: String,
pub invoker_uid: Uid,
pub target: MessageTarget,
pub message: String,
pub is_read: bool,
}
/// 消息目标
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MessageTarget {
Server,
Channel(ChannelId),
Client(ClientId),
}
/// 文件信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileInfo {
pub name: String,
pub size: u64,
pub created_at: chrono::DateTime<chrono::Utc>,
pub is_directory: bool,
}
/// 文件传输状态
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FileTransferStatus {
Pending,
InProgress { progress: f32 },
Completed,
Failed(String),
Cancelled,
}
/// 文件传输请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileTransferRequest {
pub channel_id: ChannelId,
pub path: String,
pub password: Option<String>,
}
-13
View File
@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ReTeamSpeak</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-25
View File
@@ -1,25 +0,0 @@
{
"name": "re-teamspeak-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"typescript": "^5.3.0",
"vite": "^5.0.0"
}
}
-289
View File
@@ -1,289 +0,0 @@
import { useState, useEffect } 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 ServerQueryChannel {
id: number;
name: string;
total_clients: number;
}
interface ServerQueryClient {
id: number;
database_id: number;
nickname: string;
}
interface ServerQueryServerInfo {
name: string;
platform: string;
version: string;
max_clients: number;
clients_online: number;
}
interface ServerQuerySnapshot {
server: ServerQueryServerInfo | null;
channels: ServerQueryChannel[];
clients: ServerQueryClient[];
permissions: unknown[];
}
function App() {
const [identities, setIdentities] = useState<Identity[]>([]);
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
const [selectedBookmark, setSelectedBookmark] = useState<Bookmark | null>(null);
const [nickname, setNickname] = useState('');
const [password, setPassword] = useState('');
const [connected, setConnected] = useState(false);
const [queryPort, setQueryPort] = useState(10011);
const [querySnapshot, setQuerySnapshot] = useState<ServerQuerySnapshot | null>(null);
const [queryLoading, setQueryLoading] = useState(false);
const [queryError, setQueryError] = useState<string | null>(null);
useEffect(() => {
loadIdentities();
loadBookmarks();
}, []);
useEffect(() => {
setQuerySnapshot(null);
setQueryError(null);
}, [selectedBookmark]);
async function loadIdentities() {
try {
const result = await invoke<Identity[]>('get_identities');
setIdentities(result);
} catch (error) {
console.error('Failed to load identities:', error);
}
}
async function loadBookmarks() {
try {
const result = await invoke<Bookmark[]>('get_bookmarks');
setBookmarks(result);
} catch (error) {
console.error('Failed to load bookmarks:', error);
}
}
async function handleConnect() {
if (!selectedBookmark) return;
try {
await invoke('connect', {
address: selectedBookmark.address,
port: selectedBookmark.port,
nickname: nickname || selectedBookmark.nickname || 'User',
password: password || null,
});
setConnected(true);
} catch (error) {
console.error('Failed to connect:', error);
}
}
async function handleDisconnect() {
try {
await invoke('disconnect');
setConnected(false);
} catch (error) {
console.error('Failed to disconnect:', error);
}
}
async function handleLoadServerQuery() {
if (!selectedBookmark || queryLoading) return;
setQueryLoading(true);
setQueryError(null);
try {
const snapshot = await invoke<ServerQuerySnapshot>('server_query_snapshot', {
request: {
address: selectedBookmark.address,
port: queryPort,
username: null,
password: null,
virtual_server_id: null,
include_permissions: false,
},
});
setQuerySnapshot(snapshot);
} catch (error) {
setQueryError(String(error));
setQuerySnapshot(null);
} finally {
setQueryLoading(false);
}
}
return (
<div className="app">
<header className="app-header">
<h1>ReTeamSpeak</h1>
<div className="connection-status">
{connected ? (
<span className="status connected"></span>
) : (
<span className="status disconnected"></span>
)}
</div>
</header>
<main className="app-main">
<aside className="sidebar">
<section className="bookmarks-section">
<h2></h2>
<div className="identity-summary">{identities.length}</div>
<ul className="bookmark-list">
{bookmarks.map((bookmark) => (
<li
key={bookmark.id}
className={`bookmark-item ${selectedBookmark?.id === bookmark.id ? 'selected' : ''}`}
onClick={() => setSelectedBookmark(bookmark)}
>
<span className="bookmark-name">{bookmark.name}</span>
<span className="bookmark-address">{bookmark.address}:{bookmark.port}</span>
</li>
))}
</ul>
</section>
</aside>
<div className="content">
{selectedBookmark ? (
<div className="server-panel">
<div className="connect-form">
<h2> {selectedBookmark.name}</h2>
<div className="form-group">
<label></label>
<input
type="text"
value={`${selectedBookmark.address}:${selectedBookmark.port}`}
disabled
/>
</div>
<div className="form-group">
<label></label>
<input
type="text"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder={selectedBookmark.nickname || '请输入昵称'}
/>
</div>
<div className="form-group">
<label></label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="可选"
/>
</div>
<div className="form-actions">
{connected ? (
<button className="disconnect-btn" onClick={handleDisconnect}>
</button>
) : (
<button className="connect-btn" onClick={handleConnect}>
</button>
)}
</div>
</div>
<section className="query-panel">
<div className="query-header">
<div>
<h2>ServerQuery </h2>
<p> ServerQuery 10011</p>
</div>
<div className="query-actions">
<input
type="number"
value={queryPort}
min={1}
max={65535}
onChange={(e) => setQueryPort(Number(e.target.value))}
aria-label="ServerQuery port"
/>
<button className="connect-btn" onClick={handleLoadServerQuery} disabled={queryLoading}>
{queryLoading ? '读取中...' : '读取快照'}
</button>
</div>
</div>
{queryError && <div className="query-error">{queryError}</div>}
{querySnapshot && (
<div className="query-grid">
<div className="query-card">
<h3>{querySnapshot.server?.name || '服务器'}</h3>
<p>{querySnapshot.server?.platform || '未知平台'}</p>
<p>{querySnapshot.server?.version || '未知版本'}</p>
<strong>
{querySnapshot.server?.clients_online ?? querySnapshot.clients.length}/
{querySnapshot.server?.max_clients ?? '-'} 线
</strong>
</div>
<div className="query-card">
<h3></h3>
<ul className="query-list">
{querySnapshot.channels.map((channel) => (
<li key={channel.id}>
<span>{channel.name}</span>
<small>{channel.total_clients} </small>
</li>
))}
</ul>
</div>
<div className="query-card">
<h3></h3>
<ul className="query-list">
{querySnapshot.clients.map((client) => (
<li key={client.id}>
<span>{client.nickname}</span>
<small>#{client.id}</small>
</li>
))}
</ul>
</div>
</div>
)}
</section>
</div>
) : (
<div className="welcome">
<h2>使 ReTeamSpeak</h2>
<p></p>
</div>
)}
</div>
</main>
</div>
);
}
export default App;
-10
View File
@@ -1,10 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
-351
View File
@@ -1,351 +0,0 @@
:root {
--primary-color: #2196f3;
--primary-dark: #1976d2;
--secondary-color: #ff9800;
--background-color: #f5f5f5;
--surface-color: #ffffff;
--text-color: #333333;
--text-secondary: #666666;
--border-color: #e0e0e0;
--success-color: #4caf50;
--error-color: #f44336;
--warning-color: #ff9800;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: var(--background-color);
color: var(--text-color);
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background-color: var(--surface-color);
border-bottom: 1px solid var(--border-color);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.app-header h1 {
font-size: 24px;
font-weight: 600;
color: var(--primary-color);
}
.connection-status {
display: flex;
align-items: center;
}
.status {
padding: 6px 12px;
border-radius: 16px;
font-size: 14px;
font-weight: 500;
}
.status.connected {
background-color: var(--success-color);
color: white;
}
.status.disconnected {
background-color: var(--text-secondary);
color: white;
}
.app-main {
display: flex;
flex: 1;
overflow: hidden;
}
.sidebar {
width: 300px;
background-color: var(--surface-color);
border-right: 1px solid var(--border-color);
overflow-y: auto;
}
.bookmarks-section {
padding: 16px;
}
.bookmarks-section h2 {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text-secondary);
}
.identity-summary {
margin-bottom: 12px;
color: var(--text-secondary);
font-size: 12px;
}
.bookmark-list {
list-style: none;
}
.bookmark-item {
display: flex;
flex-direction: column;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s;
}
.bookmark-item:hover {
background-color: var(--background-color);
}
.bookmark-item.selected {
background-color: var(--primary-color);
color: white;
}
.bookmark-item.selected .bookmark-address {
color: rgba(255, 255, 255, 0.8);
}
.bookmark-name {
font-weight: 500;
margin-bottom: 4px;
}
.bookmark-address {
font-size: 12px;
color: var(--text-secondary);
}
.content {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.server-panel {
display: grid;
grid-template-columns: minmax(320px, 400px) minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.connect-form {
max-width: 400px;
}
.connect-form h2 {
font-size: 20px;
font-weight: 600;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
color: var(--text-secondary);
}
.form-group input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: var(--primary-color);
}
.form-group input:disabled {
background-color: var(--background-color);
color: var(--text-secondary);
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 24px;
}
.connect-btn,
.disconnect-btn {
padding: 10px 24px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.connect-btn {
background-color: var(--primary-color);
color: white;
}
.connect-btn:hover {
background-color: var(--primary-dark);
}
.connect-btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.disconnect-btn {
background-color: var(--error-color);
color: white;
}
.disconnect-btn:hover {
background-color: #d32f2f;
}
.welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
}
.welcome h2 {
font-size: 24px;
font-weight: 600;
margin-bottom: 12px;
}
.welcome p {
font-size: 16px;
color: var(--text-secondary);
}
.query-panel {
padding: 20px;
background-color: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: 12px;
}
.query-header {
display: flex;
gap: 16px;
justify-content: space-between;
margin-bottom: 16px;
}
.query-header h2 {
font-size: 18px;
margin-bottom: 6px;
}
.query-header p {
color: var(--text-secondary);
font-size: 13px;
}
.query-actions {
display: flex;
gap: 8px;
align-items: flex-start;
}
.query-actions input {
width: 96px;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
}
.query-error {
padding: 10px 12px;
margin-bottom: 16px;
color: var(--error-color);
background-color: #ffebee;
border-radius: 6px;
font-size: 13px;
}
.query-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.query-card {
min-width: 0;
padding: 14px;
background-color: var(--background-color);
border-radius: 10px;
}
.query-card h3 {
margin-bottom: 8px;
font-size: 15px;
}
.query-card p,
.query-card small {
color: var(--text-secondary);
font-size: 12px;
}
.query-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 220px;
overflow-y: auto;
list-style: none;
}
.query-list li {
display: flex;
justify-content: space-between;
gap: 12px;
}
.query-list span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 900px) {
.server-panel,
.query-grid {
grid-template-columns: 1fr;
}
.query-header {
flex-direction: column;
}
}
-21
View File
@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
-11
View File
@@ -1,11 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
-17
View File
@@ -1,17 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
port: 5173,
strictPort: true,
},
envPrefix: ['VITE_', 'TAURI_'],
build: {
target: process.env.TAURI_PLATFORM === 'windows' ? 'chrome105' : 'safari13',
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
sourcemap: !!process.env.TAURI_DEBUG,
},
});
-37
View File
@@ -1,37 +0,0 @@
[package]
name = "re-teamspeak"
version = "1.0.0"
edition = "2021"
license = "MIT OR Apache-2.0"
[dependencies]
tauri = { version = "2", features = ["devtools"] }
tauri-plugin-dialog = "2"
tauri-plugin-http = "2"
tauri-plugin-notification = "2"
tauri-plugin-opener = "2"
tauri-plugin-shell = "2"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
shared = { path = "../../shared" }
tscore = { path = "../../tscore" }
tsaudio = { path = "../../tsaudio" }
tsdb = { path = "../../tsdb" }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
[build-dependencies]
tauri-build = "2"
[lib]
name = "re_teamspeak_lib"
crate-type = ["lib", "cdylib", "staticlib"]
-3
View File
@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}
@@ -1,25 +0,0 @@
{
"identifier": "default",
"description": "默认权限配置",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"dialog:allow-open",
"dialog:allow-save",
"dialog:allow-message",
"dialog:allow-ask",
"dialog:allow-confirm",
"http:default",
"http:allow-fetch",
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
"opener:default",
"opener:allow-open-url",
"opener:allow-open-path",
"shell:default",
"shell:allow-open"
]
}
@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.reteamspeak.app">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="ReTeamSpeak"
android:supportsRtl="true"
android:theme="@style/Theme.ReTeamSpeak">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -1,40 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>ReTeamSpeak</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSMicrophoneUsageDescription</key>
<string>ReTeamSpeak needs access to your microphone for voice communication.</string>
</dict>
</plist>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 B

-352
View File
@@ -1,352 +0,0 @@
//! Tauri 命令
use serde::{Deserialize, Serialize};
use shared::{PermissionInfo, ServerQueryChannel, ServerQueryClient, ServerQueryServerInfo};
use std::net::SocketAddr;
use std::time::Duration;
use tauri::State;
use tokio::net::lookup_host;
use tscore::{ClientConfig, IdentityKey, QueryClient, Session};
use crate::AppState;
#[derive(Debug, Serialize, Deserialize)]
pub struct IdentityInfo {
pub id: String,
pub name: String,
pub counter: u64,
pub max_counter: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BookmarkInfo {
pub id: String,
pub name: String,
pub address: String,
pub port: u16,
pub nickname: Option<String>,
pub auto_connect: bool,
pub last_connected: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct MessageInfo {
pub id: i64,
pub invoker_name: String,
pub message: String,
pub timestamp: String,
pub is_read: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ServerQuerySnapshotRequest {
pub address: String,
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
pub virtual_server_id: Option<u64>,
pub include_permissions: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ServerQuerySnapshot {
pub server: Option<ServerQueryServerInfo>,
pub channels: Vec<ServerQueryChannel>,
pub clients: Vec<ServerQueryClient>,
pub permissions: Vec<PermissionInfo>,
}
#[tauri::command]
pub async fn get_identities(state: State<'_, AppState>) -> Result<Vec<IdentityInfo>, String> {
let db = state.db.lock().await;
let identities = db.get_all_identities().map_err(|e| e.to_string())?;
Ok(identities
.into_iter()
.map(|i| IdentityInfo {
id: i.id,
name: i.name,
counter: i.counter,
max_counter: i.max_counter,
})
.collect())
}
#[tauri::command]
pub async fn create_identity(
state: State<'_, AppState>,
name: String,
) -> Result<IdentityInfo, String> {
let private_key = IdentityKey::generate().private_key_base64();
let db = state.db.lock().await;
let identity = db
.create_identity(&name, &private_key)
.map_err(|e| e.to_string())?;
Ok(IdentityInfo {
id: identity.id,
name: identity.name,
counter: identity.counter,
max_counter: identity.max_counter,
})
}
#[tauri::command]
pub async fn delete_identity(state: State<'_, AppState>, id: String) -> Result<(), String> {
let db = state.db.lock().await;
db.delete_identity(&id).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn get_bookmarks(state: State<'_, AppState>) -> Result<Vec<BookmarkInfo>, String> {
let db = state.db.lock().await;
let bookmarks = db.get_all_bookmarks().map_err(|e| e.to_string())?;
Ok(bookmarks
.into_iter()
.map(|b| BookmarkInfo {
id: b.id,
name: b.name,
address: b.address,
port: b.port,
nickname: b.nickname,
auto_connect: b.auto_connect,
last_connected: b.last_connected,
})
.collect())
}
#[tauri::command]
pub async fn create_bookmark(
state: State<'_, AppState>,
name: String,
address: String,
port: u16,
nickname: Option<String>,
) -> Result<BookmarkInfo, String> {
let db = state.db.lock().await;
let bookmark = db
.create_bookmark(&name, &address, port, nickname.as_deref())
.map_err(|e| e.to_string())?;
Ok(BookmarkInfo {
id: bookmark.id,
name: bookmark.name,
address: bookmark.address,
port: bookmark.port,
nickname: bookmark.nickname,
auto_connect: bookmark.auto_connect,
last_connected: bookmark.last_connected,
})
}
#[tauri::command]
pub async fn delete_bookmark(state: State<'_, AppState>, id: String) -> Result<(), String> {
let db = state.db.lock().await;
db.delete_bookmark(&id).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn connect(
state: State<'_, AppState>,
address: String,
port: u16,
nickname: String,
password: Option<String>,
) -> Result<(), String> {
let socket_addr = resolve_server_address(&address, port).await?;
let identity = {
let db = state.db.lock().await;
db.get_all_identities()
.map_err(|e| e.to_string())?
.into_iter()
.next()
.and_then(|identity| IdentityKey::from_private_key_base64(&identity.private_key).ok())
.unwrap_or_else(IdentityKey::generate)
};
let mut config = ClientConfig::new(socket_addr, nickname.clone());
config.server_password = password;
config.identity = identity;
let (mut session, handle) = Session::connect(config, Duration::from_secs(15))
.await
.map_err(|e| e.to_string())?;
let client_id = session.client_id();
tokio::spawn(async move {
if let Err(e) = session.run().await {
tracing::error!("session error: {e}");
}
});
{
let mut session_guard = state.session_handle.lock().await;
*session_guard = Some(handle);
}
let mut conn_state = state.connection_state.lock().await;
conn_state.connected = true;
conn_state.server_address = Some(address);
conn_state.server_port = Some(port);
conn_state.nickname = Some(nickname);
conn_state.client_id = client_id;
Ok(())
}
#[tauri::command]
pub async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
let handle = {
let mut session_guard = state.session_handle.lock().await;
session_guard.take()
};
if let Some(handle) = handle {
handle.disconnect().await.map_err(|e| e.to_string())?;
}
let mut conn_state = state.connection_state.lock().await;
*conn_state = crate::state::ConnectionState::new();
Ok(())
}
#[tauri::command]
pub async fn join_channel(
state: State<'_, AppState>,
channel_id: u64,
password: Option<String>,
) -> Result<(), String> {
let session_guard = state.session_handle.lock().await;
let handle = session_guard.as_ref().ok_or("not connected")?;
handle
.join_channel(channel_id, password)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn send_channel_message(
state: State<'_, AppState>,
message: String,
) -> Result<(), String> {
let session_guard = state.session_handle.lock().await;
let handle = session_guard.as_ref().ok_or("not connected")?;
handle
.send_channel_message(&message)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn send_server_message(
state: State<'_, AppState>,
message: String,
) -> Result<(), String> {
let session_guard = state.session_handle.lock().await;
let handle = session_guard.as_ref().ok_or("not connected")?;
handle
.send_server_message(&message)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn send_private_message(
state: State<'_, AppState>,
client_id: u64,
message: String,
) -> Result<(), String> {
let session_guard = state.session_handle.lock().await;
let handle = session_guard.as_ref().ok_or("not connected")?;
handle
.send_private_message(client_id, &message)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn send_raw_command(state: State<'_, AppState>, command: String) -> Result<(), String> {
let session_guard = state.session_handle.lock().await;
let handle = session_guard.as_ref().ok_or("not connected")?;
handle
.send_command_str(&command)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_messages(
state: State<'_, AppState>,
server_address: String,
limit: i64,
offset: i64,
) -> Result<Vec<MessageInfo>, String> {
let db = state.db.lock().await;
let messages = db
.get_server_messages(&server_address, limit, offset)
.map_err(|e| e.to_string())?;
Ok(messages
.into_iter()
.map(|m| MessageInfo {
id: m.id,
invoker_name: m.invoker_name,
message: m.message,
timestamp: m.timestamp,
is_read: m.is_read,
})
.collect())
}
#[tauri::command]
pub async fn server_query_snapshot(
request: ServerQuerySnapshotRequest,
) -> Result<ServerQuerySnapshot, String> {
let socket_addr = resolve_server_address(&request.address, request.port).await?;
let mut client = QueryClient::connect(socket_addr)
.await
.map_err(|e| e.to_string())?;
client.set_read_timeout(Duration::from_secs(5));
if let (Some(username), Some(password)) =
(request.username.as_deref(), request.password.as_deref())
{
client
.login(username, password)
.await
.map_err(|e| e.to_string())?;
}
if let Some(server_id) = request.virtual_server_id {
client
.use_server(server_id)
.await
.map_err(|e| e.to_string())?;
}
let server = client.server_info().await.map_err(|e| e.to_string())?;
let channels = client.channel_list().await.map_err(|e| e.to_string())?;
let clients = client.client_list().await.map_err(|e| e.to_string())?;
let permissions = if request.include_permissions {
client.permission_list().await.map_err(|e| e.to_string())?
} else {
Vec::new()
};
Ok(ServerQuerySnapshot {
server,
channels,
clients,
permissions,
})
}
async fn resolve_server_address(address: &str, port: u16) -> Result<SocketAddr, String> {
if let Ok(socket_addr) = format!("{}:{}", address, port).parse::<SocketAddr>() {
return Ok(socket_addr);
}
lookup_host((address, port))
.await
.map_err(|e| format!("无法解析服务器地址: {e}"))?
.next()
.ok_or_else(|| "无法解析服务器地址".to_string())
}
-59
View File
@@ -1,59 +0,0 @@
//! ReTeamSpeak Tauri 应用
use tauri::Manager;
mod commands;
mod state;
pub struct AppState {
pub db: tokio::sync::Mutex<tsdb::DatabaseManager>,
pub connection_state: tokio::sync::Mutex<state::ConnectionState>,
pub session_handle: tokio::sync::Mutex<Option<tscore::SessionHandle>>,
}
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_shell::init())
.setup(|app| {
tracing_subscriber::fmt::init();
let app_dir = app.path().app_data_dir().expect("无法获取应用数据目录");
std::fs::create_dir_all(&app_dir).expect("无法创建应用数据目录");
let db_path = app_dir.join("re-teamspeak.db");
let db =
tsdb::DatabaseManager::new(db_path.to_str().unwrap()).expect("无法初始化数据库");
let state = AppState {
db: tokio::sync::Mutex::new(db),
connection_state: tokio::sync::Mutex::new(state::ConnectionState::new()),
session_handle: tokio::sync::Mutex::new(None),
};
app.manage(state);
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::get_identities,
commands::create_identity,
commands::delete_identity,
commands::get_bookmarks,
commands::create_bookmark,
commands::delete_bookmark,
commands::connect,
commands::disconnect,
commands::join_channel,
commands::send_channel_message,
commands::send_server_message,
commands::send_private_message,
commands::send_raw_command,
commands::get_messages,
commands::server_query_snapshot,
])
.run(tauri::generate_context!())
.expect("运行应用时出错");
}
-5
View File
@@ -1,5 +0,0 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
re_teamspeak_lib::run();
}
-29
View File
@@ -1,29 +0,0 @@
//! 应用状态管理
/// 连接状态
#[derive(Debug, Clone)]
pub struct ConnectionState {
pub connected: bool,
pub server_address: Option<String>,
pub server_port: Option<u16>,
pub client_id: Option<u16>,
pub nickname: Option<String>,
}
impl ConnectionState {
pub fn new() -> Self {
Self {
connected: false,
server_address: None,
server_port: None,
client_id: None,
nickname: None,
}
}
}
impl Default for ConnectionState {
fn default() -> Self {
Self::new()
}
}
-33
View File
@@ -1,33 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/schema/master/tauri-conf-v2-schema.json",
"productName": "ReTeamSpeak",
"version": "1.0.0",
"identifier": "com.reteamspeak.app",
"build": {
"frontendDist": "../frontend/dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "cd ../frontend && npm run dev",
"beforeBuildCommand": "cd ../frontend && npm run build"
},
"app": {
"windows": [
{
"title": "ReTeamSpeak",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"center": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": false,
"targets": "all"
}
}
-24
View File
@@ -1,24 +0,0 @@
[package]
name = "tsaudio"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "TeamSpeak 音频引擎"
[dependencies]
tokio = { workspace = true }
futures = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
opus = { workspace = true, optional = true }
cpal = { workspace = true, optional = true }
rubato = { version = "0.14", optional = true }
crossbeam-channel = "0.5"
shared = { workspace = true }
[features]
default = []
full = ["cpal", "opus", "rubato"]
-65
View File
@@ -1,65 +0,0 @@
//! 抖动缓冲
use super::{AudioError, AudioFrame, AudioResult};
/// 抖动缓冲
pub struct JitterBuffer {
buffer: Vec<Option<AudioFrame>>,
head: usize,
tail: usize,
size: usize,
capacity: usize,
}
impl JitterBuffer {
pub fn new(capacity: usize) -> Self {
Self {
buffer: vec![None; capacity],
head: 0,
tail: 0,
size: 0,
capacity,
}
}
pub fn push(&mut self, frame: AudioFrame) -> AudioResult<()> {
if self.size >= self.capacity {
return Err(AudioError::Buffer("缓冲区已满".to_string()));
}
self.buffer[self.tail] = Some(frame);
self.tail = (self.tail + 1) % self.capacity;
self.size += 1;
Ok(())
}
pub fn pop(&mut self) -> Option<AudioFrame> {
if self.size == 0 {
return None;
}
let frame = self.buffer[self.head].take();
self.head = (self.head + 1) % self.capacity;
self.size -= 1;
frame
}
pub fn len(&self) -> usize {
self.size
}
pub fn is_empty(&self) -> bool {
self.size == 0
}
pub fn is_full(&self) -> bool {
self.size >= self.capacity
}
pub fn clear(&mut self) {
self.buffer.iter_mut().for_each(|f| *f = None);
self.head = 0;
self.tail = 0;
self.size = 0;
}
}
-34
View File
@@ -1,34 +0,0 @@
//! 音频采集
use super::{AudioConfig, AudioError, AudioFrame, AudioResult};
#[allow(dead_code)]
pub struct AudioCapture {
config: AudioConfig,
}
impl AudioCapture {
pub fn new(config: AudioConfig) -> Self {
Self { config }
}
pub async fn start(&mut self) -> AudioResult<()> {
#[cfg(feature = "cpal")]
{
// TODO: cpal 实现
}
Ok(())
}
pub async fn stop(&mut self) -> AudioResult<()> {
Ok(())
}
pub async fn capture(&mut self) -> AudioResult<AudioFrame> {
Err(AudioError::Device("未实现".to_string()))
}
pub fn list_devices() -> AudioResult<Vec<String>> {
Ok(Vec::new())
}
}
-49
View File
@@ -1,49 +0,0 @@
//! Opus 编解码器
use super::{AudioError, AudioResult};
#[allow(dead_code)]
pub struct OpusEncoder {
sample_rate: u32,
channels: u16,
}
impl OpusEncoder {
pub fn new(sample_rate: u32, channels: u16) -> AudioResult<Self> {
Ok(Self {
sample_rate,
channels,
})
}
pub fn encode(&mut self, _samples: &[f32]) -> AudioResult<Vec<u8>> {
#[cfg(feature = "opus")]
{
// TODO: opus 实现
}
Err(AudioError::Codec("Opus 未启用".to_string()))
}
}
#[allow(dead_code)]
pub struct OpusDecoder {
sample_rate: u32,
channels: u16,
}
impl OpusDecoder {
pub fn new(sample_rate: u32, channels: u16) -> AudioResult<Self> {
Ok(Self {
sample_rate,
channels,
})
}
pub fn decode(&mut self, _data: &[u8], _fec: bool) -> AudioResult<Vec<f32>> {
#[cfg(feature = "opus")]
{
// TODO: opus 实现
}
Err(AudioError::Codec("Opus 未启用".to_string()))
}
}
-84
View File
@@ -1,84 +0,0 @@
//! TeamSpeak 音频引擎
pub mod buffer;
pub mod capture;
pub mod codec;
pub mod playback;
pub mod vad;
pub use buffer::*;
pub use capture::*;
pub use codec::*;
pub use playback::*;
pub use vad::*;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AudioError {
#[error("设备错误: {0}")]
Device(String),
#[error("编解码器错误: {0}")]
Codec(String),
#[error("缓冲区错误: {0}")]
Buffer(String),
#[error("配置错误: {0}")]
Config(String),
#[error("IO 错误: {0}")]
Io(#[from] std::io::Error),
}
pub type AudioResult<T> = Result<T, AudioError>;
#[derive(Debug, Clone)]
pub struct AudioConfig {
pub sample_rate: u32,
pub channels: u16,
pub bits_per_sample: u16,
pub frame_size: usize,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
sample_rate: 48000,
channels: 1,
bits_per_sample: 16,
frame_size: 960,
}
}
}
#[derive(Debug, Clone)]
pub struct AudioFrame {
pub sample_rate: u32,
pub channels: u16,
pub samples: Vec<f32>,
}
impl AudioFrame {
pub fn new(sample_rate: u32, channels: u16, samples: Vec<f32>) -> Self {
Self {
sample_rate,
channels,
samples,
}
}
pub fn frame_size(&self) -> usize {
self.samples.len()
}
pub fn duration_ms(&self) -> f64 {
(self.frame_size() as f64 / self.channels as f64) / (self.sample_rate as f64) * 1000.0
}
}
#[derive(Debug, Clone)]
pub struct AudioDeviceInfo {
pub id: String,
pub name: String,
pub is_default: bool,
pub sample_rates: Vec<u32>,
pub channels: Vec<u16>,
}
-34
View File
@@ -1,34 +0,0 @@
//! 音频播放
use super::{AudioConfig, AudioFrame, AudioResult};
#[allow(dead_code)]
pub struct AudioPlayback {
config: AudioConfig,
}
impl AudioPlayback {
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 play(&mut self, _frame: AudioFrame) -> AudioResult<()> {
Ok(())
}
pub fn list_devices() -> AudioResult<Vec<String>> {
Ok(Vec::new())
}
}
-41
View File
@@ -1,41 +0,0 @@
//! 语音活动检测 (VAD)
/// VAD 状态
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VadState {
Silent,
Speaking,
}
/// 语音活动检测器
pub struct VadDetector {
threshold: f32,
state: VadState,
}
impl VadDetector {
pub fn new(threshold: f32) -> Self {
Self {
threshold,
state: VadState::Silent,
}
}
pub fn detect(&mut self, samples: &[f32]) -> VadState {
let energy: f32 = samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32;
if energy > self.threshold {
self.state = VadState::Speaking;
} else {
self.state = VadState::Silent;
}
self.state
}
pub fn state(&self) -> VadState {
self.state
}
pub fn set_threshold(&mut self, threshold: f32) {
self.threshold = threshold;
}
}
-35
View File
@@ -1,35 +0,0 @@
[package]
name = "tscore"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "TeamSpeak 3 协议核心实现"
[dependencies]
tokio = { workspace = true }
futures = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
aes = { workspace = true }
eax = { workspace = true }
sha1 = { workspace = true }
sha2 = { workspace = true }
p256 = { workspace = true }
curve25519-dalek-ng = { workspace = true }
num-bigint = { workspace = true }
simple_asn1 = { workspace = true }
generic-array = "0.14"
typenum = "1"
quicklz = { workspace = true }
bytes = "1"
base64 = { workspace = true }
rand = { workspace = true }
shared = { workspace = true }
-749
View File
@@ -1,749 +0,0 @@
//! 客户端连接 - 完整握手实现
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)]
pub struct ClientConfig {
pub address: SocketAddr,
pub nickname: String,
pub version: String,
pub platform: String,
pub server_password: Option<String>,
pub channel: Option<String>,
pub channel_password: Option<String>,
pub default_token: Option<String>,
pub identity: IdentityKey,
}
impl ClientConfig {
pub fn new(address: SocketAddr, nickname: String) -> Self {
Self {
address,
nickname,
version: "3.0.19.3 [Build: 1466672534]".to_string(),
platform: "Linux".to_string(),
server_password: None,
channel: None,
channel_password: None,
default_token: None,
identity: IdentityKey::generate(),
}
}
}
/// 客户端连接
pub struct Client {
config: ClientConfig,
state_machine: ConnectionStateMachine,
shared_secret: Option<SharedSecret>,
key_cache: KeyCache,
client_id: Option<u16>,
/// 客户端随机数 A0
random0: Option<[u8; 4]>,
/// 服务器随机数 A1
random1: Option<[u8; 16]>,
/// A0 反转
random0_r: Option<[u8; 4]>,
/// RSA 参数
rsa_x: Option<[u8; 64]>,
rsa_n: Option<[u8; 64]>,
rsa_level: Option<u32>,
/// 服务器随机数 A2
random2: Option<[u8; 100]>,
/// 客户端 alpha
alpha: Option<[u8; 10]>,
outgoing_command_id: u16,
outgoing_ack_id: u16,
}
impl Client {
pub fn new(config: ClientConfig) -> Self {
Self {
config,
state_machine: ConnectionStateMachine::new(),
shared_secret: None,
key_cache: KeyCache::new(),
client_id: None,
random0: None,
random1: None,
random0_r: None,
rsa_x: None,
rsa_n: None,
rsa_level: None,
random2: None,
alpha: None,
// clientinitiv is embedded in Init4 and consumes command packet id 0.
outgoing_command_id: 1,
outgoing_ack_id: 0,
}
}
pub fn state(&self) -> ConnectionState {
self.state_machine.state()
}
pub fn client_id(&self) -> Option<u16> {
self.client_id
}
/// 开始连接握手
pub fn start_handshake(&mut self) -> Result<Vec<u8>, ProtocolError> {
self.state_machine
.transition(ConnectionState::Connecting)
.map_err(ProtocolError::PacketParse)?;
// 生成随机数 A0
let mut random0 = [0u8; 4];
rand::Rng::fill(&mut rand::thread_rng(), &mut random0);
self.random0 = Some(random0);
// 构建 Init0 数据包
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)
}
/// 处理接收到的数据
pub fn handle_data(&mut self, data: &[u8]) -> Result<Vec<Vec<u8>>, ProtocolError> {
let mut responses = Vec::new();
match self.state() {
ConnectionState::Connecting => {
// 处理 Init1
let init = Self::parse_server_init(data)?;
if init.step == InitStep::Init1 {
self.random1 = init.random1;
self.random0_r = init.random0_r;
// 发送 Init2
let response = self.build_init2()?;
responses.push(response);
} else if init.step == InitStep::Reset {
// 服务器要求重置,重新发送 Init0
let response = self.start_handshake()?;
responses.push(response);
}
}
ConnectionState::IdentityLevelIncreasing => {
// 处理 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;
// 计算 RSA 解答
let response = self.build_init4()?;
responses.push(response);
}
}
ConnectionState::Connected => {
// 处理命令数据包
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(responses);
}
if matches!(packet_type, PacketType::Command | PacketType::CommandLow) {
responses.push(self.build_ack_packet(packet_type, packet.header.packet_id)?);
}
// 解析命令
let cmd_str = String::from_utf8_lossy(&content);
for cmd in Command::parse_many(&cmd_str)? {
match cmd.name.as_str() {
"initserver" => {
// 连接完成
if let Some(id) = cmd.get("client_id") {
self.client_id = id.parse().ok();
}
self.state_machine
.transition(ConnectionState::ChannelListFinished)
.map_err(ProtocolError::PacketParse)?;
}
"initivexpand" => {
// 旧协议密钥交换
responses.extend(self.handle_initivexpand(&cmd)?);
}
"initivexpand2" => {
// 新协议密钥交换
responses.extend(self.handle_initivexpand2(&cmd)?);
}
"channellist" => {
// 频道列表
}
"channellistfinished" => {
self.state_machine
.transition(ConnectionState::ChannelListFinished)
.map_err(ProtocolError::PacketParse)?;
}
"notifycliententerview" => {
// 客户端进入视图
}
"error" => {
if let Some(id) = cmd.get("id") {
if id != "0" {
return Err(ProtocolError::PacketParse(format!(
"服务器错误: {}",
cmd.get("msg").unwrap_or("未知")
)));
}
}
}
_ => {}
}
}
}
_ => {}
}
Ok(responses)
}
/// 构建 Init2 数据包
fn build_init2(&mut self) -> Result<Vec<u8>, ProtocolError> {
let init = InitPacket {
step: InitStep::Init2,
version: Some(Self::encode_version(&self.config.version)),
timestamp: None,
random0: None,
random1: self.random1,
random0_r: self.random0_r,
x: None,
n: None,
level: None,
random2: None,
y: None,
command: None,
};
self.state_machine
.transition(ConnectionState::IdentityLevelIncreasing)
.map_err(ProtocolError::PacketParse)?;
Ok(init.to_c2s_packet_bytes())
}
/// 构建 Init4 数据包
fn build_init4(&mut self) -> Result<Vec<u8>, ProtocolError> {
// 计算 y = x^(2^level) mod n
let x = self
.rsa_x
.ok_or_else(|| ProtocolError::PacketParse("缺少 RSA x".to_string()))?;
let n = self
.rsa_n
.ok_or_else(|| ProtocolError::PacketParse("缺少 RSA n".to_string()))?;
let level = self
.rsa_level
.ok_or_else(|| ProtocolError::PacketParse("缺少 RSA level".to_string()))?;
let y = Self::solve_rsa_puzzle(&x, &n, level);
// 生成 alpha
let mut alpha = [0u8; 10];
rand::Rng::fill(&mut rand::thread_rng(), &mut alpha);
self.alpha = Some(alpha);
// 构建 clientinitiv 命令
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())
}
/// 处理 initivexpand (旧协议)
fn handle_initivexpand(&mut self, cmd: &Command) -> Result<Vec<Vec<u8>>, ProtocolError> {
let alpha_b64 = cmd
.get("alpha")
.ok_or_else(|| ProtocolError::PacketParse("缺少 alpha".to_string()))?;
let beta_b64 = cmd
.get("beta")
.ok_or_else(|| ProtocolError::PacketParse("缺少 beta".to_string()))?;
let _omega = cmd
.get("omega")
.ok_or_else(|| ProtocolError::PacketParse("缺少 omega".to_string()))?;
let alpha_bytes =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, alpha_b64)
.map_err(|_| ProtocolError::PacketParse("无效的 alpha".to_string()))?;
let beta_bytes =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, beta_b64)
.map_err(|_| ProtocolError::PacketParse("无效的 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);
// 计算共享密钥
let shared_data = [0u8; 32]; // TODO: 从 ECDH 计算
let secret = SharedSecret::compute_old(&alpha, &beta, &shared_data);
self.shared_secret = Some(secret);
// 发送 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())?
])
}
/// 处理 initivexpand2 (新协议)
///
/// When the server sends a license (`l`), this performs real ECDH key
/// exchange using an ephemeral Ed25519 key pair. When no license is
/// present (mocked environments), it falls back to a zeroed shared
/// secret so the bootstrap sequence still completes.
fn handle_initivexpand2(&mut self, cmd: &Command) -> Result<Vec<Vec<u8>>, ProtocolError> {
let beta_b64 = cmd
.get("beta")
.ok_or_else(|| ProtocolError::PacketParse("缺少 beta".to_string()))?;
let _omega = cmd
.get("omega")
.ok_or_else(|| ProtocolError::PacketParse("缺少 omega".to_string()))?;
let beta_bytes =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, beta_b64)
.map_err(|_| ProtocolError::PacketParse("无效的 beta".to_string()))?;
let mut beta = [0u8; 54];
if beta_bytes.len() >= 54 {
beta.copy_from_slice(&beta_bytes[..54]);
} else {
beta[..beta_bytes.len()].copy_from_slice(&beta_bytes);
}
let ephemeral = crypto::ephemeral::EphemeralKey::generate();
let ek_bytes = ephemeral.public_bytes();
let ek_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, ek_bytes);
let alpha = self.alpha.unwrap_or([0; 10]);
let shared_secret = if let Some(l) = cmd.get("l") {
match self.derive_server_ephemeral_key(l) {
Ok(server_ek) => ephemeral.compute_shared_secret(&server_ek),
Err(_) => [0u8; 32],
}
} else {
[0u8; 32]
};
let (iv, mac) = crypto::ephemeral::compute_iv_mac(&alpha, &beta, &shared_secret);
self.shared_secret = Some(SharedSecret::new(iv, mac));
let mut proof_data = Vec::with_capacity(32 + 54);
proof_data.extend_from_slice(&ek_bytes);
proof_data.extend_from_slice(&beta);
let proof = self.config.identity.sign_der_base64(&proof_data);
let cmd = CommandBuilder::new("clientek")
.arg("ek", &ek_b64)
.arg("proof", &proof)
.build();
Ok(vec![
self.build_command_packet(cmd.to_string().into_bytes())?
])
}
/// Derive the server's ephemeral Ed25519 public key from the license data
/// embedded in the `initivexpand2` response.
///
/// The license is a base64-encoded blob that contains, among other things,
/// the server's ephemeral Ed25519 public key. Full license parsing requires
/// signature verification against the root key, but for now we attempt a
/// best-effort extraction of the 32-byte compressed Edwards point.
fn derive_server_ephemeral_key(
&self,
license_b64: &str,
) -> Result<curve25519_dalek_ng::montgomery::MontgomeryPoint, ProtocolError> {
let license_bytes =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, license_b64)
.map_err(|_| ProtocolError::PacketParse("invalid license base64".to_string()))?;
if license_bytes.len() < 32 {
return Err(ProtocolError::PacketParse("license too short".to_string()));
}
let mut key_bytes = [0u8; 32];
key_bytes.copy_from_slice(&license_bytes[license_bytes.len() - 32..]);
Ok(crypto::ephemeral::parse_x25519_public_key(&key_bytes))
}
fn build_ack_packet(
&mut self,
packet_type: PacketType,
acked_packet_id: u16,
) -> Result<Vec<u8>, ProtocolError> {
let ack_type = packet_type
.ack_type()
.ok_or_else(|| ProtocolError::InvalidPacketType(packet_type.to_u8()))?;
let mut packet = AckPacket::new(Direction::C2S, ack_type, acked_packet_id).to_out_packet();
packet.set_packet_id(self.outgoing_ack_id);
packet.set_client_id(self.client_id.unwrap_or(0));
self.outgoing_ack_id = self.outgoing_ack_id.wrapping_add(1);
if self.shared_secret.is_none() || acked_packet_id == 0 {
crypto::encrypt_fake(&mut packet)?;
} else if let Some(ref secret) = self.shared_secret {
crypto::encrypt_packet(&mut packet, 0, &secret.iv, &mut self.key_cache)?;
}
Ok(packet.to_bytes())
}
pub fn build_command_packet(&mut self, content: Vec<u8>) -> Result<Vec<u8>, ProtocolError> {
let packet_id = self.outgoing_command_id;
let mut flags = Flags::new(PacketType::Command.to_u8());
flags.set_newprotocol(true);
let mut packet = OutPacket::new(Direction::C2S, flags, content);
packet.set_packet_id(packet_id);
packet.set_client_id(self.client_id.unwrap_or(0));
let is_clientek = packet.content().starts_with(b"clientek");
if is_clientek && packet_id == 1 {
crypto::encrypt_fake(&mut packet)?;
} else if let Some(ref secret) = self.shared_secret {
crypto::encrypt_packet(&mut packet, 0, &secret.iv, &mut self.key_cache)?;
} else {
crypto::encrypt_fake(&mut packet)?;
}
self.outgoing_command_id = self.outgoing_command_id.wrapping_add(1);
Ok(packet.to_bytes())
}
fn build_clientinit_packet(&mut self) -> Result<Vec<u8>, ProtocolError> {
self.build_command_packet(self.build_clientinit())
}
/// 构建 clientinit 命令
pub fn build_clientinit(&self) -> Vec<u8> {
let channel_password = self
.config
.channel_password
.as_deref()
.map(crypto::hash_password)
.unwrap_or_default();
let server_password = self
.config
.server_password
.as_deref()
.map(crypto::hash_password)
.unwrap_or_default();
let cmd = CommandBuilder::new("clientinit")
.arg("client_nickname", &self.config.nickname)
.arg("client_version", &self.config.version)
.arg("client_platform", &self.config.platform)
.arg("client_input_hardware", "1")
.arg("client_output_hardware", "1")
.arg(
"client_default_channel",
self.config.channel.as_deref().unwrap_or(""),
)
.arg("client_default_channel_password", &channel_password)
.arg("client_server_password", &server_password)
.arg("client_meta_data", "")
.arg(
"client_version_sign",
"a1OYzvM18mrmfUQBUgxYBxYz2DUU6y5k3/mEL6FurzU0y97Bd1FL7+PRpcHyPkg4R+kKAFZ1nhyzbgkGphDWDg==",
)
.arg("client_key_offset", "0")
.arg("client_nickname_phonetic", "")
.arg(
"client_default_token",
self.config.default_token.as_deref().unwrap_or(""),
)
.arg("hwid", "87056c6e1268aaf5055abf8256415e0e,408978b6d98810cc03f0aa16a4c75600")
.build();
cmd.to_string().into_bytes()
}
/// 编码版本号
fn encode_version(version: &str) -> u32 {
// 从版本字符串提取构建时间戳
if let Some(start) = version.find("[Build: ") {
let rest = &version[start + 8..];
if let Some(end) = rest.find(']') {
let ts_str = &rest[..end];
if let Ok(ts) = ts_str.parse::<u32>() {
return ts;
}
}
}
1466672534 // 默认值
}
/// 获取当前时间戳
fn current_timestamp() -> u32 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(Duration::from_secs(0))
.as_secs() as u32
}
/// 解决 RSA 拼图
/// 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
// 需要做 level 次平方操作
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
}
/// 获取身份公钥 (omega)
fn get_identity_omega(&self) -> Result<String, ProtocolError> {
self.config
.identity
.public_key_ts_base64()
.map_err(|e| ProtocolError::Encryption(format!("身份公钥编码失败: {e}")))
}
/// 生成证明
fn generate_proof(&self, data: &str, beta: &str) -> String {
let combined = format!("{}{}", data, beta);
self.config.identity.sign_der_base64(combined.as_bytes())
}
fn parse_server_init(data: &[u8]) -> Result<InitPacket, ProtocolError> {
if data.len() >= crate::protocol::S2C_HEADER_SIZE {
if let Ok(packet) = InPacket::parse(Direction::S2C, data) {
if packet.header.flags.packet_type() == PacketType::Init {
if packet.header.mac != crate::protocol::INIT_MAC {
return Err(ProtocolError::PacketParse(
"invalid init packet MAC".to_string(),
));
}
return InitPacket::parse_s2c(&packet.data);
}
}
}
InitPacket::parse_s2c(data)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::{Direction, InPacket, PacketType, INIT_MAC, INIT_PACKET_ID};
use base64::Engine;
#[test]
fn test_encode_version() {
let version = "3.0.19.3 [Build: 1466672534]";
assert_eq!(Client::encode_version(version), 1466672534);
}
#[test]
fn test_rsa_puzzle() {
// 使用非零值测试
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 responses = client.handle_data(&server_packet.to_bytes()).unwrap();
assert_eq!(responses.len(), 2);
let ack = InPacket::parse(Direction::C2S, &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, &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 responses = client.handle_data(&ack.to_bytes()).unwrap();
assert_eq!(responses.len(), 1);
let clientinit = InPacket::parse(Direction::C2S, &responses[0]).unwrap();
assert_eq!(clientinit.header.packet_id, 2);
assert_eq!(clientinit.header.flags.packet_type(), PacketType::Command);
let mut key_cache = KeyCache::new();
let secret = client.shared_secret.as_ref().unwrap();
let content = crypto::decrypt_packet(&clientinit, 0, &secret.iv, &mut key_cache).unwrap();
let command = Command::parse(&String::from_utf8(content).unwrap()).unwrap();
assert_eq!(command.name, "clientinit");
assert_eq!(command.get("client_nickname"), Some("Test"));
}
}
-11
View File
@@ -1,11 +0,0 @@
//! 连接管理
pub mod client;
pub mod resend;
pub mod session;
pub mod state;
pub use client::*;
pub use resend::*;
pub use session::*;
pub use state::*;
-257
View File
@@ -1,257 +0,0 @@
//! 数据包重传和确认系统
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
/// 数据包 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;
}
}
}
/// 已发送的数据包信息
#[derive(Debug, Clone)]
pub struct SentPacket {
pub data: Vec<u8>,
pub sent_at: Instant,
pub retry_count: u32,
pub timeout: Duration,
}
impl SentPacket {
pub fn new(data: Vec<u8>) -> Self {
Self {
data,
sent_at: Instant::now(),
retry_count: 0,
timeout: Duration::from_millis(500), // 初始超时 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();
// 指数退避
self.timeout = Duration::from_millis(500 * (1 << self.retry_count).min(32));
}
}
/// 重传管理器
pub struct ResendManager {
/// 等待确认的数据包
pending: BTreeMap<PacketId, SentPacket>,
/// 最大重试次数
max_retries: u32,
/// 连接超时
connection_timeout: Duration,
}
impl ResendManager {
pub fn new() -> Self {
Self {
pending: BTreeMap::new(),
max_retries: 10,
connection_timeout: Duration::from_secs(30),
}
}
/// 添加已发送的数据包
pub fn add_sent(&mut self, id: PacketId, data: Vec<u8>) {
self.pending.insert(id, SentPacket::new(data));
}
/// 确认数据包
pub fn ack(&mut self, id: &PacketId) -> bool {
self.pending.remove(id).is_some()
}
/// 获取需要重传的数据包
pub fn get_retransmissions(&mut self) -> Vec<(PacketId, Vec<u8>)> {
let mut retransmissions = Vec::new();
let mut to_retry = Vec::new();
for (id, packet) in self.pending.iter() {
if packet.should_retry(self.max_retries) {
to_retry.push(*id);
}
}
for id in to_retry {
if let Some(packet) = self.pending.get_mut(&id) {
packet.retry();
retransmissions.push((id, packet.data.clone()));
}
}
retransmissions
}
/// 检查是否连接超时
pub fn is_connection_timeout(&self) -> bool {
self.pending
.values()
.any(|p| p.sent_at.elapsed() > self.connection_timeout)
}
/// 获取待确认数据包数量
pub fn pending_count(&self) -> usize {
self.pending.len()
}
/// 清空所有待确认数据包
pub fn clear(&mut self) {
self.pending.clear();
}
/// 设置最大重试次数
pub fn set_max_retries(&mut self, max_retries: u32) {
self.max_retries = max_retries;
}
/// 设置连接超时
pub fn set_connection_timeout(&mut self, timeout: Duration) {
self.connection_timeout = timeout;
}
}
impl Default for ResendManager {
fn default() -> Self {
Self::new()
}
}
/// RTT 估算器
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),
}
}
/// 更新 RTT 估算
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;
// 限制 RTO 范围
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);
}
}
/// 获取当前 RTO
pub fn rto(&self) -> Duration {
self.rto
}
/// 获取平滑 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);
// 确认
assert!(manager.ack(&id));
assert_eq!(manager.pending_count(), 0);
}
#[test]
fn test_rtt_estimator() {
let mut estimator = RttEstimator::new();
// 初始 SRTT 是 500ms
assert_eq!(estimator.srtt(), Duration::from_millis(500));
// 更新多次,SRTT 应该逐渐收敛
for _ in 0..100 {
estimator.update(Duration::from_millis(100));
}
// 经过多次更新后,SRTT 应该接近 100ms
assert!(estimator.srtt() < Duration::from_millis(150));
// RTO 应该大于 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());
// 模拟超时
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());
}
}
-291
View File
@@ -1,291 +0,0 @@
use std::time::Duration;
use tokio::net::UdpSocket;
use tokio::sync::mpsc;
use super::client::{Client, ClientConfig};
use super::state::ConnectionState;
use crate::ProtocolError;
pub enum SessionCommand {
SendCommand(Vec<u8>),
JoinChannel {
channel_id: u64,
password: Option<String>,
},
MoveClient {
client_id: u16,
channel_id: u64,
},
SendTextMessage {
target_mode: TextMessageTarget,
target_id: u64,
message: String,
},
Disconnect,
}
pub enum TextMessageTarget {
Server = 3,
Channel = 2,
Client = 1,
}
pub struct SessionHandle {
command_tx: mpsc::Sender<SessionCommand>,
}
impl SessionHandle {
pub async fn send_raw_command(&self, command: Vec<u8>) -> Result<(), ProtocolError> {
self.command_tx
.send(SessionCommand::SendCommand(command))
.await
.map_err(|_| ProtocolError::ConnectionClosed)
}
pub async fn send_command_str(&self, command: &str) -> Result<(), ProtocolError> {
self.send_raw_command(command.as_bytes().to_vec()).await
}
pub async fn join_channel(
&self,
channel_id: u64,
password: Option<String>,
) -> Result<(), ProtocolError> {
self.command_tx
.send(SessionCommand::JoinChannel {
channel_id,
password,
})
.await
.map_err(|_| ProtocolError::ConnectionClosed)
}
pub async fn move_client(&self, client_id: u16, channel_id: u64) -> Result<(), ProtocolError> {
self.command_tx
.send(SessionCommand::MoveClient {
client_id,
channel_id,
})
.await
.map_err(|_| ProtocolError::ConnectionClosed)
}
pub async fn send_server_message(&self, message: &str) -> Result<(), ProtocolError> {
self.command_tx
.send(SessionCommand::SendTextMessage {
target_mode: TextMessageTarget::Server,
target_id: 0,
message: message.to_string(),
})
.await
.map_err(|_| ProtocolError::ConnectionClosed)
}
pub async fn send_channel_message(&self, message: &str) -> Result<(), ProtocolError> {
self.command_tx
.send(SessionCommand::SendTextMessage {
target_mode: TextMessageTarget::Channel,
target_id: 0,
message: message.to_string(),
})
.await
.map_err(|_| ProtocolError::ConnectionClosed)
}
pub async fn send_private_message(
&self,
client_id: u64,
message: &str,
) -> Result<(), ProtocolError> {
self.command_tx
.send(SessionCommand::SendTextMessage {
target_mode: TextMessageTarget::Client,
target_id: client_id,
message: message.to_string(),
})
.await
.map_err(|_| ProtocolError::ConnectionClosed)
}
pub async fn disconnect(&self) -> Result<(), ProtocolError> {
self.command_tx
.send(SessionCommand::Disconnect)
.await
.map_err(|_| ProtocolError::ConnectionClosed)
}
}
pub struct Session {
client: Client,
socket: UdpSocket,
command_rx: mpsc::Receiver<SessionCommand>,
event_tx: mpsc::Sender<SessionEvent>,
}
pub enum SessionEvent {
Connected {
client_id: u16,
},
ChannelList(Vec<ChannelEntry>),
ClientEntered {
clid: u16,
cid: u64,
client_nickname: String,
},
ClientLeft {
clid: u16,
},
TextMessage {
invoker_id: u16,
invoker_name: String,
message: String,
},
Error(String),
Disconnected,
}
#[derive(Debug, Clone)]
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,
}
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?;
tokio::time::timeout(timeout, async {
let mut buf = [0u8; 2048];
loop {
let len = socket.recv(&mut buf).await?;
let responses = client.handle_data(&buf[..len])?;
for response in responses {
socket.send(&response).await?;
}
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 };
Ok((session, handle))
}
pub fn client_id(&self) -> Option<u16> {
self.client.client_id()
}
pub fn state(&self) -> ConnectionState {
self.client.state()
}
pub async fn run(&mut self) -> Result<(), ProtocolError> {
let mut buf = [0u8; 2048];
loop {
tokio::select! {
result = self.socket.recv(&mut buf) => {
let len = result?;
let responses = self.client.handle_data(&buf[..len])?;
for response in responses {
self.socket.send(&response).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_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::Disconnect => {
let packet = self
.client
.build_command_packet(b"clientdisconnect".to_vec())?;
self.socket.send(&packet).await?;
return Err(ProtocolError::ConnectionClosed);
}
}
Ok(())
}
}
-114
View File
@@ -1,114 +0,0 @@
//! 连接状态管理
use std::fmt;
/// 连接状态
#[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"),
}
}
}
/// 连接状态机
pub struct ConnectionStateMachine {
state: ConnectionState,
}
impl ConnectionStateMachine {
pub fn new() -> Self {
Self {
state: ConnectionState::Disconnected,
}
}
pub fn state(&self) -> ConnectionState {
self.state
}
pub fn transition(&mut self, new_state: ConnectionState) -> Result<(), String> {
let valid = matches!(
(self.state, new_state),
(ConnectionState::Disconnected, ConnectionState::Connecting)
| (
ConnectionState::Connecting,
ConnectionState::IdentityLevelIncreasing
)
| (ConnectionState::Connecting, ConnectionState::Connected)
| (
ConnectionState::IdentityLevelIncreasing,
ConnectionState::Connected
)
| (
ConnectionState::Connected,
ConnectionState::ChannelListFinished
)
| (
ConnectionState::Connected,
ConnectionState::DisconnectedTemporarily
)
| (
ConnectionState::ChannelListFinished,
ConnectionState::DisconnectedTemporarily
)
| (
ConnectionState::DisconnectedTemporarily,
ConnectionState::Connected
)
| (
ConnectionState::DisconnectedTemporarily,
ConnectionState::Disconnected
)
| (_, ConnectionState::Error)
| (ConnectionState::Error, ConnectionState::Disconnected)
);
if valid {
self.state = new_state;
Ok(())
} else {
Err(format!(
"Invalid state transition: {} -> {}",
self.state, new_state
))
}
}
}
impl Default for ConnectionStateMachine {
fn default() -> Self {
Self::new()
}
}
-118
View File
@@ -1,118 +0,0 @@
//! EAX 模式加密
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 加密器
pub struct EaxCipher {
cipher: Eax<Aes128, U8>,
}
impl EaxCipher {
pub fn new(key: &[u8; 16]) -> Self {
let key = GenericArray::from_slice(key);
Self {
cipher: Eax::<Aes128, U8>::new(key),
}
}
pub fn encrypt(
&self,
nonce: &[u8; 16],
header: &[u8],
data: &mut [u8],
) -> Result<[u8; 8], ProtocolError> {
let nonce = GenericArray::from_slice(nonce);
let tag = self
.cipher
.encrypt_in_place_detached(nonce, header, data)
.map_err(|_| ProtocolError::Encryption("EAX 加密失败".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 验证失败".to_string()))
}
}
/// 加密数据包
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(())
}
/// 解密数据包
pub fn decrypt_packet(
packet: &InPacket,
generation_id: u32,
iv: &[u8; 64],
key_cache: &mut keys::KeyCache,
) -> Result<Vec<u8>, ProtocolError> {
let packet_type = packet.header.flags.packet_type();
let direction = packet.direction;
let packet_id = packet.header.packet_id;
let (key, nonce) = key_cache.get_or_create(packet_type, direction, generation_id, iv);
let enc_key = keys::create_encryption_key(&key, packet_id);
let cipher = EaxCipher::new(&enc_key);
let meta = packet.header.get_meta(direction);
let mut data = packet.data.clone();
cipher.decrypt(&nonce, &meta, &mut data, &packet.header.mac)?;
Ok(data)
}
/// 假加密
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(())
}
/// 假解密
pub fn decrypt_fake(packet: &InPacket) -> Result<Vec<u8>, ProtocolError> {
let cipher = EaxCipher::new(&keys::FAKE_KEY);
let meta = packet.header.get_meta(packet.direction);
let mut data = packet.data.clone();
cipher.decrypt(&keys::FAKE_NONCE, &meta, &mut data, &packet.header.mac)?;
Ok(data)
}
-151
View File
@@ -1,151 +0,0 @@
use curve25519_dalek_ng::constants::X25519_BASEPOINT;
use curve25519_dalek_ng::montgomery::MontgomeryPoint;
use curve25519_dalek_ng::scalar::Scalar;
use sha1::Sha1;
use sha2::{Digest, Sha512};
pub struct EphemeralKey {
private: Scalar,
public: MontgomeryPoint,
}
impl EphemeralKey {
pub fn generate() -> Self {
let mut bytes = [0u8; 32];
rand::Rng::fill(&mut rand::thread_rng(), &mut bytes);
let private = Scalar::from_bytes_mod_order(bytes);
let public = X25519_BASEPOINT * private;
Self { private, public }
}
pub fn from_private_bytes(bytes: &[u8; 32]) -> Self {
let private = Scalar::from_bytes_mod_order(*bytes);
let public = X25519_BASEPOINT * private;
Self { private, public }
}
pub fn public_bytes(&self) -> [u8; 32] {
self.public.to_bytes()
}
pub fn compute_shared_secret(&self, other_public: &MontgomeryPoint) -> [u8; 32] {
let shared = other_public * self.private;
shared.to_bytes()
}
pub fn public_point(&self) -> &MontgomeryPoint {
&self.public
}
}
pub fn compute_iv_mac(
alpha: &[u8; 10],
beta: &[u8; 54],
shared_secret: &[u8; 32],
) -> ([u8; 64], [u8; 8]) {
let mut hasher = Sha512::new();
hasher.update(shared_secret);
let hash = hasher.finalize();
let mut iv = [0u8; 64];
iv.copy_from_slice(&hash);
for i in 0..10 {
iv[i] ^= alpha[i];
}
for i in 0..54 {
iv[i + 10] ^= beta[i];
}
let mut hasher = Sha1::new();
hasher.update(iv);
let mac_hash = hasher.finalize();
let mut mac = [0u8; 8];
mac.copy_from_slice(&mac_hash[..8]);
(iv, mac)
}
pub fn parse_x25519_public_key(bytes: &[u8; 32]) -> MontgomeryPoint {
MontgomeryPoint(*bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ephemeral_key_generates_nonzero_public_key() {
let key = EphemeralKey::generate();
assert_ne!(key.public_bytes(), [0u8; 32]);
}
#[test]
fn ephemeral_key_from_bytes_produces_expected_public_key() {
let bytes = [1u8; 32];
let key = EphemeralKey::from_private_bytes(&bytes);
assert_ne!(key.public_bytes(), [0u8; 32]);
}
#[test]
fn ecdh_shared_secret_is_symmetric() {
let alice = EphemeralKey::generate();
let bob = EphemeralKey::generate();
let alice_shared = alice.compute_shared_secret(bob.public_point());
let bob_shared = bob.compute_shared_secret(alice.public_point());
assert_eq!(alice_shared, bob_shared);
}
#[test]
fn compute_iv_mac_matches_manual_hash_computation() {
let alpha = [1u8; 10];
let beta = [2u8; 54];
let shared_secret = [3u8; 32];
let (iv, mac) = compute_iv_mac(&alpha, &beta, &shared_secret);
let mut hasher = Sha512::new();
hasher.update(&shared_secret);
let hash = hasher.finalize();
let mut expected_iv = [0u8; 64];
expected_iv.copy_from_slice(&hash);
for i in 0..10 {
expected_iv[i] ^= alpha[i];
}
for i in 0..54 {
expected_iv[i + 10] ^= beta[i];
}
assert_eq!(iv, expected_iv);
let mut hasher = Sha1::new();
hasher.update(&iv);
let mac_hash = hasher.finalize();
let mut expected_mac = [0u8; 8];
expected_mac.copy_from_slice(&mac_hash[..8]);
assert_eq!(mac, expected_mac);
}
#[test]
fn compute_iv_mac_with_zero_shared_secret_matches_shared_secret_new() {
let alpha = [42u8; 10];
let beta = [7u8; 54];
let shared_secret = [0u8; 32];
let (iv, mac) = compute_iv_mac(&alpha, &beta, &shared_secret);
let secret = crate::crypto::SharedSecret::compute_new(&alpha, &beta, &shared_secret);
assert_eq!(iv, secret.iv);
assert_eq!(mac, secret.mac);
}
#[test]
fn parse_x25519_public_key_returns_montgomery_point() {
let key = EphemeralKey::generate();
let bytes = key.public_bytes();
let parsed = parse_x25519_public_key(&bytes);
assert_eq!(parsed.to_bytes(), bytes);
}
}
-40
View File
@@ -1,40 +0,0 @@
//! 哈希函数
use sha1::Sha1;
use sha2::{Digest, Sha256, Sha512};
/// SHA-1 哈希
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 哈希
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 哈希
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
}
/// 计算密码哈希
pub fn hash_password(password: &str) -> String {
let hash = sha1(password.as_bytes());
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, hash)
}
-137
View File
@@ -1,137 +0,0 @@
//! TeamSpeak identity key handling.
use base64::Engine;
use num_bigint::{BigInt, Sign};
use p256::ecdsa::signature::Signer;
use p256::ecdsa::SigningKey;
use p256::elliptic_curve::sec1::ToEncodedPoint;
use p256::SecretKey;
use sha1::{Digest, Sha1};
use simple_asn1::ASN1Block;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum IdentityError {
#[error("invalid base64 private key: {0}")]
Base64(#[from] base64::DecodeError),
#[error("invalid P-256 private key")]
InvalidPrivateKey,
#[error("ASN.1 encode error: {0}")]
Asn1Encode(#[from] simple_asn1::ASN1EncodeErr),
}
#[derive(Clone)]
pub struct IdentityKey {
secret: SecretKey,
}
impl std::fmt::Debug for IdentityKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IdentityKey")
.field("uid", &self.uid())
.finish_non_exhaustive()
}
}
impl IdentityKey {
pub fn generate() -> Self {
Self {
secret: SecretKey::random(&mut rand::thread_rng()),
}
}
pub fn from_private_key_base64(data: &str) -> Result<Self, IdentityError> {
let bytes = base64::engine::general_purpose::STANDARD.decode(data)?;
if bytes.len() != 32 {
return Err(IdentityError::InvalidPrivateKey);
}
let secret = SecretKey::from_bytes(p256::FieldBytes::from_slice(&bytes))
.map_err(|_| IdentityError::InvalidPrivateKey)?;
Ok(Self { secret })
}
pub fn private_key_base64(&self) -> String {
base64::engine::general_purpose::STANDARD.encode(self.secret.to_bytes())
}
pub fn public_key_tomcrypt(&self) -> Result<Vec<u8>, IdentityError> {
let encoded = self.secret.public_key().to_encoded_point(false);
let x = BigInt::from_bytes_be(Sign::Plus, encoded.x().expect("P-256 x coordinate"));
let y = BigInt::from_bytes_be(Sign::Plus, encoded.y().expect("P-256 y coordinate"));
Ok(simple_asn1::to_der(&ASN1Block::Sequence(
0,
vec![
ASN1Block::BitString(0, 1, vec![0]),
ASN1Block::Integer(0, 32.into()),
ASN1Block::Integer(0, x),
ASN1Block::Integer(0, y),
],
))?)
}
pub fn public_key_ts_base64(&self) -> Result<String, IdentityError> {
Ok(base64::engine::general_purpose::STANDARD.encode(self.public_key_tomcrypt()?))
}
pub fn uid(&self) -> String {
let omega = self.public_key_ts_base64().unwrap_or_default();
let hash = Sha1::digest(omega.as_bytes());
base64::engine::general_purpose::STANDARD.encode(hash)
}
pub fn sign_der_base64(&self, data: &[u8]) -> String {
let signing_key = SigningKey::from(self.secret.clone());
let signature: p256::ecdsa::DerSignature = signing_key.sign(data);
base64::engine::general_purpose::STANDARD.encode(signature.as_bytes())
}
}
#[cfg(test)]
mod tests {
use super::*;
use p256::ecdsa::signature::Verifier;
use p256::ecdsa::{Signature, VerifyingKey};
#[test]
fn identity_round_trips_private_key() {
let identity = IdentityKey::generate();
let exported = identity.private_key_base64();
let imported = IdentityKey::from_private_key_base64(&exported).unwrap();
assert_eq!(imported.private_key_base64(), exported);
assert_eq!(
imported.public_key_ts_base64().unwrap(),
identity.public_key_ts_base64().unwrap()
);
}
#[test]
fn identity_produces_ts_public_key_and_uid() {
let identity = IdentityKey::generate();
let public_key = identity.public_key_tomcrypt().unwrap();
let public_key_b64 = identity.public_key_ts_base64().unwrap();
let uid = identity.uid();
assert!(public_key.starts_with(&[0x30]));
assert!(public_key_b64.len() > 80);
assert!(!uid.is_empty());
}
#[test]
fn identity_signs_verifiable_der_signature() {
let identity = IdentityKey::generate();
let data = b"client proof data";
let signature = base64::engine::general_purpose::STANDARD
.decode(identity.sign_der_base64(data))
.unwrap();
let signing_key = SigningKey::from(identity.secret.clone());
let verifying_key = VerifyingKey::from(&signing_key);
let signature = Signature::from_der(&signature).unwrap();
verifying_key.verify(data, &signature).unwrap();
}
}
-228
View File
@@ -1,228 +0,0 @@
//! 密钥管理
use sha1::Sha1;
use sha2::{Digest, Sha256, Sha512};
use crate::protocol::Direction;
use crate::protocol::PacketType;
/// 假加密密钥
pub const FAKE_KEY: [u8; 16] = *b"c:\\windows\\syste";
/// 假加密 Nonce
pub const FAKE_NONCE: [u8; 16] = *b"m\\firewall32.cpl";
/// 许可证根密钥
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,
];
/// 共享密钥
#[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] }}")
}
}
/// 缓存的密钥
#[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()
}
}
/// 密钥缓存
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()
}
}
/// 创建密钥和 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)
}
/// 创建用于加密的密钥
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
}
/// 计算 Hash Cash 级别
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
}
/// 计算 UID
pub fn compute_uid(public_key: &[u8]) -> String {
let mut hasher = Sha1::new();
hasher.update(public_key);
let hash = hasher.finalize();
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, hash)
}
-13
View File
@@ -1,13 +0,0 @@
//! 加密模块
pub mod eax;
pub mod ephemeral;
pub mod hash;
pub mod identity;
pub mod keys;
mod tests;
pub use eax::*;
pub use hash::*;
pub use identity::*;
pub use keys::*;
-162
View File
@@ -1,162 +0,0 @@
//! 加密测试
#[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);
// 其他字节不变
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);
// 不同的 generation_id 应该返回不同的密钥
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();
// 加密
let mac = cipher.encrypt(&nonce, header, &mut data).unwrap();
// 解密
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;
// 假加密
encrypt_fake(&mut packet).unwrap();
// 假解密
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() {
// 测试不同的 offset 产生不同的 level
let level0 = get_hash_cash_level("test_key", 0);
let level1 = get_hash_cash_level("test_key", 1);
assert!(level0 <= 160);
assert!(level1 <= 160);
// 使用一个会产生更高 level 的 key
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 应该是 base64 编码的 SHA1 哈希
assert!(uid.len() > 20);
}
}
-76
View File
@@ -1,76 +0,0 @@
//! TeamSpeak 3 协议核心实现
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;
/// 协议错误
#[derive(Error, Debug)]
pub enum ProtocolError {
#[error("数据包解析错误: {0}")]
PacketParse(String),
#[error("加密错误: {0}")]
Encryption(String),
#[error("解密错误: {0}")]
Decryption(String),
#[error("压缩错误: {0}")]
Compression(String),
#[error("解压错误: {0}")]
Decompression(String),
#[error("无效的数据包类型: {0}")]
InvalidPacketType(u8),
#[error("无效的标志位: {0}")]
InvalidFlags(u8),
#[error("数据包过大: {size} > {max}")]
PacketTooLarge { size: usize, max: usize },
#[error("数据包过小: {size} < {min}")]
PacketTooSmall { size: usize, min: usize },
#[error("无效的客户端 ID: {0}")]
InvalidClientId(u16),
#[error("无效的数据包 ID: {0}")]
InvalidPacketId(u16),
#[error("MAC 验证失败")]
MacVerificationFailed,
#[error("超时: {0}")]
Timeout(String),
#[error("连接关闭")]
ConnectionClosed,
#[error("命令错误: {0}")]
Command(String),
#[error("网络错误: {0}")]
Network(#[from] std::io::Error),
}
/// 协议结果类型
pub type ProtocolResult<T> = Result<T, ProtocolError>;
impl From<protocol::CommandError> for ProtocolError {
fn from(err: protocol::CommandError) -> Self {
ProtocolError::Command(err.to_string())
}
}
-7
View File
@@ -1,7 +0,0 @@
//! 网络模块
pub mod resolver;
pub mod socket;
pub use resolver::*;
pub use socket::*;
-38
View File
@@ -1,38 +0,0 @@
//! 地址解析
use std::net::SocketAddr;
/// 服务器地址
#[derive(Debug, Clone)]
pub enum ServerAddress {
/// 直接 IP 地址
Ip(SocketAddr),
/// 域名
Domain(String),
/// 服务器昵称
Nickname(String),
}
impl ServerAddress {
pub async fn resolve(&self) -> Result<SocketAddr, Box<dyn std::error::Error>> {
match self {
Self::Ip(addr) => Ok(*addr),
Self::Domain(domain) => resolve_domain(domain).await,
Self::Nickname(nickname) => resolve_nickname(nickname).await,
}
}
}
async fn resolve_domain(domain: &str) -> Result<SocketAddr, Box<dyn std::error::Error>> {
// 尝试直接解析
let addrs = tokio::net::lookup_host(format!("{}:9987", domain)).await?;
addrs
.into_iter()
.next()
.ok_or_else(|| "无法解析域名".into())
}
async fn resolve_nickname(nickname: &str) -> Result<SocketAddr, Box<dyn std::error::Error>> {
// TODO: 实现 TSDNS 和昵称解析
resolve_domain(nickname).await
}
-433
View File
@@ -1,433 +0,0 @@
//! UDP Socket 抽象
use std::net::SocketAddr;
use std::task::{Context, Poll};
use std::time::Duration;
use tokio::net::UdpSocket;
use crate::connection::{Client, ClientConfig, ConnectionState};
use crate::{ProtocolError, ProtocolResult};
/// Socket trait
pub trait Socket {
fn poll_recv_from(
&self,
cx: &mut Context,
buf: &mut tokio::io::ReadBuf,
) -> Poll<std::io::Result<SocketAddr>>;
fn poll_send_to(
&self,
cx: &mut Context,
buf: &[u8],
target: SocketAddr,
) -> Poll<std::io::Result<usize>>;
fn local_addr(&self) -> std::io::Result<SocketAddr>;
}
/// UDP Socket 实现
pub struct UdpSocketWrapper {
socket: UdpSocket,
}
impl UdpSocketWrapper {
pub async fn bind(addr: SocketAddr) -> std::io::Result<Self> {
let socket = UdpSocket::bind(addr).await?;
Ok(Self { socket })
}
pub async fn connect(&self, addr: SocketAddr) -> std::io::Result<()> {
self.socket.connect(addr).await
}
}
impl Socket for UdpSocketWrapper {
fn poll_recv_from(
&self,
cx: &mut Context,
buf: &mut tokio::io::ReadBuf,
) -> Poll<std::io::Result<SocketAddr>> {
self.socket.poll_recv_from(cx, buf)
}
fn poll_send_to(
&self,
cx: &mut Context,
buf: &[u8],
target: SocketAddr,
) -> Poll<std::io::Result<usize>> {
self.socket.poll_send_to(cx, buf, target)
}
fn local_addr(&self) -> std::io::Result<SocketAddr> {
self.socket.local_addr()
}
}
/// Run the unencrypted TS3 init handshake over UDP.
///
/// This stops after Init4 is sent and the client reaches `Connected`; encrypted
/// command negotiation still has to be completed by the higher-level session.
pub async fn perform_init_handshake(
config: ClientConfig,
timeout: Duration,
) -> ProtocolResult<Client> {
perform_handshake_until(
config,
timeout,
ConnectionState::Connected,
"init handshake",
)
.await
}
/// Run the UDP connection handshake through `clientinit` and `initserver`.
///
/// This exercises the post-Init4 command bootstrap. Full compatibility with
/// public servers still depends on replacing the placeholder ECDH shared-data
/// path in `Client::handle_initivexpand2`.
pub async fn perform_connect_handshake(
config: ClientConfig,
timeout: Duration,
) -> ProtocolResult<Client> {
perform_handshake_until(
config,
timeout,
ConnectionState::ChannelListFinished,
"connect handshake",
)
.await
}
async fn perform_handshake_until(
config: ClientConfig,
timeout: Duration,
target_state: ConnectionState,
label: &str,
) -> ProtocolResult<Client> {
let bind_addr = if config.address.is_ipv4() {
"0.0.0.0:0"
} else {
"[::]:0"
};
let socket = UdpSocket::bind(bind_addr).await?;
socket.connect(config.address).await?;
let mut client = Client::new(config);
let init0 = client.start_handshake()?;
socket.send(&init0).await?;
tokio::time::timeout(timeout, async move {
let mut buf = [0u8; 2048];
loop {
let len = socket.recv(&mut buf).await?;
let responses = client.handle_data(&buf[..len])?;
for response in responses {
socket.send(&response).await?;
}
if client.state() == target_state {
return Ok(client);
}
}
})
.await
.map_err(|_| ProtocolError::Timeout(format!("{label} timed out")))?
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::{self, KeyCache, SharedSecret};
use crate::protocol::{
AckPacket, Command, CommandBuilder, Direction, Flags, InPacket, InitPacket, InitStep,
OutPacket, PacketType, INIT_MAC, INIT_PACKET_ID,
};
use base64::Engine;
fn s2c_init_datagram(init: InitPacket) -> Vec<u8> {
let mut packet = OutPacket::new(
Direction::S2C,
Flags::new(PacketType::Init.to_u8()),
init.to_bytes(),
);
packet.set_mac(INIT_MAC);
packet.set_packet_id(INIT_PACKET_ID);
packet.to_bytes()
}
fn s2c_fake_command(packet_id: u16, command: Command) -> Vec<u8> {
let mut packet = OutPacket::new(
Direction::S2C,
Flags::new(PacketType::Command.to_u8()),
command.to_string().into_bytes(),
);
packet.set_packet_id(packet_id);
crypto::encrypt_fake(&mut packet).unwrap();
packet.to_bytes()
}
fn s2c_fake_ack(packet_id: u16, acked_packet_id: u16) -> Vec<u8> {
let mut packet =
AckPacket::new(Direction::S2C, PacketType::Ack, acked_packet_id).to_out_packet();
packet.set_packet_id(packet_id);
crypto::encrypt_fake(&mut packet).unwrap();
packet.to_bytes()
}
fn s2c_encrypted_command(packet_id: u16, command: Command, secret: &SharedSecret) -> Vec<u8> {
let mut packet = OutPacket::new(
Direction::S2C,
Flags::new(PacketType::Command.to_u8()),
command.to_string().into_bytes(),
);
packet.set_packet_id(packet_id);
let mut key_cache = KeyCache::new();
crypto::encrypt_packet(&mut packet, 0, &secret.iv, &mut key_cache).unwrap();
packet.to_bytes()
}
#[tokio::test]
async fn test_perform_init_handshake() {
let server = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let server_addr = server.local_addr().unwrap();
let server_task = tokio::spawn(async move {
let mut buf = [0u8; 2048];
let (len, client_addr) = server.recv_from(&mut buf).await.unwrap();
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
assert_eq!(packet.header.mac, INIT_MAC);
assert_eq!(packet.header.flags.packet_type(), PacketType::Init);
assert_eq!(
InitPacket::parse_c2s(packet.content()).unwrap().step,
InitStep::Init0
);
let init1 = InitPacket {
step: InitStep::Init1,
version: None,
timestamp: None,
random0: None,
random1: Some([1; 16]),
random0_r: Some([2; 4]),
x: None,
n: None,
level: None,
random2: None,
y: None,
command: None,
};
server
.send_to(&s2c_init_datagram(init1), client_addr)
.await
.unwrap();
let (len, client_addr) = server.recv_from(&mut buf).await.unwrap();
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
assert_eq!(
InitPacket::parse_c2s(packet.content()).unwrap().step,
InitStep::Init2
);
let mut x = [0u8; 64];
x[63] = 2;
let mut n = [0u8; 64];
n[63] = 7;
let init3 = InitPacket {
step: InitStep::Init3,
version: None,
timestamp: None,
random0: None,
random1: None,
random0_r: None,
x: Some(x),
n: Some(n),
level: Some(1),
random2: Some([3; 100]),
y: None,
command: None,
};
server
.send_to(&s2c_init_datagram(init3), client_addr)
.await
.unwrap();
let (len, _) = server.recv_from(&mut buf).await.unwrap();
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
let init4 = InitPacket::parse_c2s(packet.content()).unwrap();
assert_eq!(init4.step, InitStep::Init4);
let command = String::from_utf8(init4.command.unwrap()).unwrap();
let command = Command::parse(&command).unwrap();
let omega = command.get("omega").unwrap();
let omega = base64::engine::general_purpose::STANDARD
.decode(omega)
.unwrap();
assert_eq!(command.name, "clientinitiv");
assert!(omega.starts_with(&[0x30]));
});
let config = ClientConfig::new(server_addr, "Tester".to_string());
let client = perform_init_handshake(config, Duration::from_secs(1))
.await
.unwrap();
assert_eq!(client.state(), ConnectionState::Connected);
server_task.await.unwrap();
}
#[tokio::test]
async fn test_perform_connect_handshake() {
let server = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let server_addr = server.local_addr().unwrap();
let server_task = tokio::spawn(async move {
let mut buf = [0u8; 2048];
let (len, client_addr) = server.recv_from(&mut buf).await.unwrap();
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
assert_eq!(
InitPacket::parse_c2s(packet.content()).unwrap().step,
InitStep::Init0
);
server
.send_to(
&s2c_init_datagram(InitPacket {
step: InitStep::Init1,
version: None,
timestamp: None,
random0: None,
random1: Some([1; 16]),
random0_r: Some([2; 4]),
x: None,
n: None,
level: None,
random2: None,
y: None,
command: None,
}),
client_addr,
)
.await
.unwrap();
let (len, client_addr) = server.recv_from(&mut buf).await.unwrap();
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
assert_eq!(
InitPacket::parse_c2s(packet.content()).unwrap().step,
InitStep::Init2
);
let mut x = [0u8; 64];
x[63] = 2;
let mut n = [0u8; 64];
n[63] = 7;
server
.send_to(
&s2c_init_datagram(InitPacket {
step: InitStep::Init3,
version: None,
timestamp: None,
random0: None,
random1: None,
random0_r: None,
x: Some(x),
n: Some(n),
level: Some(1),
random2: Some([3; 100]),
y: None,
command: None,
}),
client_addr,
)
.await
.unwrap();
let (len, _) = server.recv_from(&mut buf).await.unwrap();
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
let init4 = InitPacket::parse_c2s(packet.content()).unwrap();
let command =
Command::parse(&String::from_utf8(init4.command.unwrap()).unwrap()).unwrap();
let alpha_bytes = base64::engine::general_purpose::STANDARD
.decode(command.get("alpha").unwrap())
.unwrap();
let mut alpha = [0u8; 10];
alpha.copy_from_slice(&alpha_bytes);
let beta = [1u8; 54];
let secret = SharedSecret::compute_new(&alpha, &beta, &[0; 32]);
server
.send_to(
&s2c_fake_command(
0,
CommandBuilder::new("initivexpand2")
.arg(
"beta",
&base64::engine::general_purpose::STANDARD.encode(beta),
)
.arg("omega", "server")
.build(),
),
client_addr,
)
.await
.unwrap();
let (len, _) = server.recv_from(&mut buf).await.unwrap();
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
assert_eq!(packet.header.flags.packet_type(), PacketType::Ack);
assert_eq!(crypto::decrypt_fake(&packet).unwrap(), 0u16.to_be_bytes());
let (len, _) = server.recv_from(&mut buf).await.unwrap();
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
assert_eq!(packet.header.packet_id, 1);
let command =
Command::parse(&String::from_utf8(crypto::decrypt_fake(&packet).unwrap()).unwrap())
.unwrap();
assert_eq!(command.name, "clientek");
server
.send_to(&s2c_fake_ack(0, 1), client_addr)
.await
.unwrap();
let (len, _) = server.recv_from(&mut buf).await.unwrap();
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
assert_eq!(packet.header.packet_id, 2);
let mut key_cache = KeyCache::new();
let command = Command::parse(
&String::from_utf8(
crypto::decrypt_packet(&packet, 0, &secret.iv, &mut key_cache).unwrap(),
)
.unwrap(),
)
.unwrap();
assert_eq!(command.name, "clientinit");
server
.send_to(
&s2c_encrypted_command(
1,
CommandBuilder::new("initserver")
.arg("client_id", "7")
.build(),
&secret,
),
client_addr,
)
.await
.unwrap();
});
let config = ClientConfig::new(server_addr, "Tester".to_string());
let client = perform_connect_handshake(config, Duration::from_secs(1))
.await
.unwrap();
assert_eq!(client.state(), ConnectionState::ChannelListFinished);
assert_eq!(client.client_id(), Some(7));
server_task.await.unwrap();
}
}
-267
View File
@@ -1,267 +0,0 @@
//! 命令解析和序列化
use std::fmt;
/// 命令解析错误
#[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, "无效的命令格式: {}", msg),
Self::MissingParameter(name) => write!(f, "缺少必需的参数: {}", name),
Self::InvalidParameterValue { name, value } => {
write!(f, "无效的参数值: {}={}", name, value)
}
Self::EscapeError(msg) => write!(f, "转义序列错误: {}", msg),
}
}
}
impl std::error::Error for CommandError {}
pub type CommandResult<T> = Result<T, CommandError>;
/// 转义序列处理
pub mod escape {
use super::CommandError;
pub fn escape(input: &str) -> String {
let mut result = String::with_capacity(input.len());
for c in input.chars() {
match c {
'\\' => result.push_str("\\\\"),
' ' => result.push_str("\\s"),
'|' => result.push_str("\\p"),
'/' => result.push_str("\\/"),
'\n' => result.push_str("\\n"),
'\r' => result.push_str("\\r"),
'\t' => result.push_str("\\t"),
_ => result.push(c),
}
}
result
}
pub fn unescape(input: &str) -> Result<String, CommandError> {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('\\') => result.push('\\'),
Some('s') => result.push(' '),
Some('p') => result.push('|'),
Some('/') => result.push('/'),
Some('n') => result.push('\n'),
Some('r') => result.push('\r'),
Some('t') => result.push('\t'),
Some(other) => {
return Err(CommandError::EscapeError(format!(
"未知的转义序列: \\{}",
other
)))
}
None => {
return Err(CommandError::EscapeError("意外的转义序列结束".to_string()))
}
}
} else {
result.push(c);
}
}
Ok(result)
}
}
/// 命令参数
#[derive(Debug, Clone)]
pub struct CommandArgument {
pub name: String,
pub value: Option<String>,
}
impl CommandArgument {
pub fn new(name: &str, value: Option<&str>) -> Self {
Self {
name: name.to_string(),
value: value.map(|s| s.to_string()),
}
}
pub fn with_value(name: &str, value: &str) -> Self {
Self {
name: name.to_string(),
value: Some(value.to_string()),
}
}
pub fn without_value(name: &str) -> Self {
Self {
name: name.to_string(),
value: None,
}
}
}
impl fmt::Display for CommandArgument {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.value {
Some(value) => write!(f, "{}={}", escape::escape(&self.name), escape::escape(value)),
None => write!(f, "{}", escape::escape(&self.name)),
}
}
}
/// 命令
#[derive(Debug, Clone)]
pub struct Command {
pub name: String,
pub args: Vec<CommandArgument>,
}
impl Command {
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
args: Vec::new(),
}
}
pub fn with_args(name: &str, args: Vec<CommandArgument>) -> Self {
Self {
name: name.to_string(),
args,
}
}
pub fn arg(mut self, arg: CommandArgument) -> Self {
self.args.push(arg);
self
}
pub fn key_value(mut self, name: &str, value: &str) -> Self {
self.args.push(CommandArgument::with_value(name, value));
self
}
pub fn flag(mut self, name: &str) -> Self {
self.args.push(CommandArgument::without_value(name));
self
}
pub fn get(&self, name: &str) -> Option<&str> {
self.args
.iter()
.find(|a| a.name == name)
.and_then(|a| a.value.as_deref())
}
pub fn has(&self, name: &str) -> bool {
self.args.iter().any(|a| a.name == name)
}
pub fn parse(input: &str) -> CommandResult<Self> {
let input = input.trim();
if input.is_empty() {
return Err(CommandError::InvalidFormat("空命令".to_string()));
}
let parts: Vec<&str> = input.splitn(2, ' ').collect();
let name = parts[0].to_string();
let args_str = if parts.len() > 1 { parts[1] } else { "" };
let mut args = Vec::new();
if !args_str.is_empty() {
for arg_str in args_str.split(' ') {
if arg_str.is_empty() {
continue;
}
if let Some(eq_pos) = arg_str.find('=') {
let name = escape::unescape(&arg_str[..eq_pos])?;
let value = escape::unescape(&arg_str[eq_pos + 1..])?;
args.push(CommandArgument::with_value(&name, &value));
} else {
let name = escape::unescape(arg_str)?;
args.push(CommandArgument::without_value(&name));
}
}
}
Ok(Self { name, args })
}
pub fn parse_many(input: &str) -> CommandResult<Vec<Self>> {
let input = input.trim();
if input.is_empty() {
return Err(CommandError::InvalidFormat("空命令".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(())
}
}
/// 命令构建器
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
}
}
-10
View File
@@ -1,10 +0,0 @@
//! 协议模块
pub mod commands;
pub mod packet;
mod tests;
pub mod types;
pub use commands::*;
pub use packet::*;
pub use types::*;
-790
View File
@@ -1,790 +0,0 @@
//! 数据包定义和处理
use std::fmt;
use super::types::*;
use crate::ProtocolError;
/// 最大数据包大小
pub const MAX_PACKET_SIZE: usize = 500;
/// C2S 头部大小
pub const C2S_HEADER_SIZE: usize = 13; // 8 (MAC) + 2 (PId) + 2 (CId) + 1 (PT)
/// S2C 头部大小
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;
/// 数据包方向
#[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,
}
}
}
/// 数据包标志位
#[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()
)
}
}
/// 数据包头部
#[derive(Debug, Clone)]
pub struct Header {
pub mac: [u8; 8],
pub packet_id: u16,
pub client_id: Option<u16>,
pub flags: Flags,
}
impl Header {
pub fn parse_c2s(data: &[u8]) -> Result<Self, ProtocolError> {
if data.len() < C2S_HEADER_SIZE {
return Err(ProtocolError::PacketTooSmall {
size: data.len(),
min: C2S_HEADER_SIZE,
});
}
let mut mac = [0u8; 8];
mac.copy_from_slice(&data[0..8]);
let packet_id = u16::from_be_bytes([data[8], data[9]]);
let client_id = u16::from_be_bytes([data[10], data[11]]);
let flags = Flags::new(data[12]);
Ok(Self {
mac,
packet_id,
client_id: Some(client_id),
flags,
})
}
pub fn parse_s2c(data: &[u8]) -> Result<Self, ProtocolError> {
if data.len() < S2C_HEADER_SIZE {
return Err(ProtocolError::PacketTooSmall {
size: data.len(),
min: S2C_HEADER_SIZE,
});
}
let mut mac = [0u8; 8];
mac.copy_from_slice(&data[0..8]);
let packet_id = u16::from_be_bytes([data[8], data[9]]);
let flags = Flags::new(data[10]);
Ok(Self {
mac,
packet_id,
client_id: None,
flags,
})
}
pub fn to_c2s_bytes(&self) -> [u8; C2S_HEADER_SIZE] {
let mut bytes = [0u8; C2S_HEADER_SIZE];
bytes[0..8].copy_from_slice(&self.mac);
bytes[8..10].copy_from_slice(&self.packet_id.to_be_bytes());
if let Some(client_id) = self.client_id {
bytes[10..12].copy_from_slice(&client_id.to_be_bytes());
}
bytes[12] = self.flags.0;
bytes
}
pub fn to_s2c_bytes(&self) -> [u8; S2C_HEADER_SIZE] {
let mut bytes = [0u8; S2C_HEADER_SIZE];
bytes[0..8].copy_from_slice(&self.mac);
bytes[8..10].copy_from_slice(&self.packet_id.to_be_bytes());
bytes[10] = self.flags.0;
bytes
}
pub fn size(&self, direction: Direction) -> usize {
match direction {
Direction::C2S => C2S_HEADER_SIZE,
Direction::S2C => S2C_HEADER_SIZE,
}
}
pub fn get_meta(&self, direction: Direction) -> Vec<u8> {
match direction {
Direction::C2S => {
let mut meta = Vec::with_capacity(5);
meta.extend_from_slice(&self.packet_id.to_be_bytes());
meta.extend_from_slice(&self.client_id.unwrap_or(0).to_be_bytes());
meta.push(self.flags.0);
meta
}
Direction::S2C => {
let mut meta = Vec::with_capacity(3);
meta.extend_from_slice(&self.packet_id.to_be_bytes());
meta.push(self.flags.0);
meta
}
}
}
}
/// 输入数据包
#[derive(Debug, Clone)]
pub struct InPacket {
pub direction: Direction,
pub header: Header,
pub data: Vec<u8>,
}
impl InPacket {
pub fn parse(direction: Direction, data: &[u8]) -> Result<Self, ProtocolError> {
let header = match direction {
Direction::C2S => Header::parse_c2s(data)?,
Direction::S2C => Header::parse_s2c(data)?,
};
let header_size = header.size(direction);
let content = data[header_size..].to_vec();
Ok(Self {
direction,
header,
data: content,
})
}
pub fn content(&self) -> &[u8] {
&self.data
}
pub fn content_size(&self) -> usize {
self.data.len()
}
pub fn total_size(&self) -> usize {
self.header.size(self.direction) + self.data.len()
}
}
/// 输出数据包
#[derive(Debug, Clone)]
pub struct OutPacket {
pub direction: Direction,
pub header: Header,
pub data: Vec<u8>,
}
impl OutPacket {
pub fn new(direction: Direction, flags: Flags, content: Vec<u8>) -> Self {
let header = Header {
mac: [0; 8],
packet_id: 0,
client_id: if direction == Direction::C2S {
Some(0)
} else {
None
},
flags,
};
Self {
direction,
header,
data: content,
}
}
pub fn set_packet_id(&mut self, id: u16) {
self.header.packet_id = id;
}
pub fn set_client_id(&mut self, id: u16) {
self.header.client_id = Some(id);
}
pub fn set_mac(&mut self, mac: [u8; 8]) {
self.header.mac = mac;
}
pub fn content(&self) -> &[u8] {
&self.data
}
pub fn content_mut(&mut self) -> &mut Vec<u8> {
&mut self.data
}
pub fn to_bytes(&self) -> Vec<u8> {
let header_size = self.header.size(self.direction);
let mut bytes = Vec::with_capacity(header_size + self.data.len());
match self.direction {
Direction::C2S => {
bytes.extend_from_slice(&self.header.to_c2s_bytes());
}
Direction::S2C => {
bytes.extend_from_slice(&self.header.to_s2c_bytes());
}
}
bytes.extend_from_slice(&self.data);
bytes
}
pub fn total_size(&self) -> usize {
self.header.size(self.direction) + self.data.len()
}
}
/// 确认数据包
#[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)
}
}
/// 初始化步骤
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InitStep {
Init0,
Init1,
Init2,
Init3,
Init4,
Reset,
}
/// 初始化数据包
#[derive(Debug, Clone)]
pub struct InitPacket {
pub step: InitStep,
pub version: Option<u32>,
pub timestamp: Option<u32>,
pub random0: Option<[u8; 4]>,
pub random1: Option<[u8; 16]>,
pub random0_r: Option<[u8; 4]>,
pub x: Option<[u8; 64]>,
pub n: Option<[u8; 64]>,
pub level: Option<u32>,
pub random2: Option<[u8; 100]>,
pub y: Option<[u8; 64]>,
pub command: Option<Vec<u8>>,
}
impl InitPacket {
pub fn parse_c2s(data: &[u8]) -> Result<Self, ProtocolError> {
if data.len() < 5 {
return Err(ProtocolError::PacketTooSmall {
size: data.len(),
min: 5,
});
}
let version = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
let step = match data[4] {
0 => InitStep::Init0,
2 => InitStep::Init2,
4 => InitStep::Init4,
127 => InitStep::Reset,
_ => return Err(ProtocolError::InvalidPacketType(data[4])),
};
let mut packet = Self {
step,
version: Some(version),
timestamp: None,
random0: None,
random1: None,
random0_r: None,
x: None,
n: None,
level: None,
random2: None,
y: None,
command: None,
};
match step {
InitStep::Init0 => {
if data.len() < 21 {
return Err(ProtocolError::PacketTooSmall {
size: data.len(),
min: 21,
});
}
packet.timestamp = Some(u32::from_be_bytes([data[5], data[6], data[7], data[8]]));
let mut random0 = [0u8; 4];
random0.copy_from_slice(&data[9..13]);
packet.random0 = Some(random0);
}
InitStep::Init2 => {
if data.len() < 25 {
return Err(ProtocolError::PacketTooSmall {
size: data.len(),
min: 25,
});
}
let mut random1 = [0u8; 16];
random1.copy_from_slice(&data[5..21]);
packet.random1 = Some(random1);
let mut random0_r = [0u8; 4];
random0_r.copy_from_slice(&data[21..25]);
packet.random0_r = Some(random0_r);
}
InitStep::Init4 => {
if data.len() < 301 {
return Err(ProtocolError::PacketTooSmall {
size: data.len(),
min: 301,
});
}
let mut x = [0u8; 64];
x.copy_from_slice(&data[5..69]);
packet.x = Some(x);
let mut n = [0u8; 64];
n.copy_from_slice(&data[69..133]);
packet.n = Some(n);
packet.level = Some(u32::from_be_bytes([
data[133], data[134], data[135], data[136],
]));
let mut random2 = [0u8; 100];
random2.copy_from_slice(&data[137..237]);
packet.random2 = Some(random2);
let mut y = [0u8; 64];
y.copy_from_slice(&data[237..301]);
packet.y = Some(y);
if data.len() > 301 {
packet.command = Some(data[301..].to_vec());
}
}
InitStep::Init1 | InitStep::Init3 | InitStep::Reset => {}
}
Ok(packet)
}
pub fn parse_s2c(data: &[u8]) -> Result<Self, ProtocolError> {
Self::parse(data)
}
pub fn parse(data: &[u8]) -> Result<Self, ProtocolError> {
if data.is_empty() {
return Err(ProtocolError::PacketTooSmall { size: 0, min: 1 });
}
let step = match data[0] {
0 => InitStep::Init0,
1 => InitStep::Init1,
2 => InitStep::Init2,
3 => InitStep::Init3,
4 => InitStep::Init4,
127 => InitStep::Reset,
_ => return Err(ProtocolError::InvalidPacketType(data[0])),
};
let mut packet = Self {
step,
version: None,
timestamp: None,
random0: None,
random1: None,
random0_r: None,
x: None,
n: None,
level: None,
random2: None,
y: None,
command: None,
};
match step {
InitStep::Init0 => {
if data.len() < 21 {
return Err(ProtocolError::PacketTooSmall {
size: data.len(),
min: 21,
});
}
packet.version = Some(u32::from_be_bytes([data[1], data[2], data[3], data[4]]));
packet.timestamp = Some(u32::from_be_bytes([data[6], data[7], data[8], data[9]]));
let mut random0 = [0u8; 4];
random0.copy_from_slice(&data[10..14]);
packet.random0 = Some(random0);
}
InitStep::Init1 => {
if data.len() < 21 {
return Err(ProtocolError::PacketTooSmall {
size: data.len(),
min: 21,
});
}
let mut random1 = [0u8; 16];
random1.copy_from_slice(&data[1..17]);
packet.random1 = Some(random1);
let mut random0_r = [0u8; 4];
random0_r.copy_from_slice(&data[17..21]);
packet.random0_r = Some(random0_r);
}
InitStep::Init2 => {
if data.len() < 26 {
return Err(ProtocolError::PacketTooSmall {
size: data.len(),
min: 26,
});
}
packet.version = Some(u32::from_be_bytes([data[1], data[2], data[3], data[4]]));
let mut random1 = [0u8; 16];
random1.copy_from_slice(&data[6..22]);
packet.random1 = Some(random1);
let mut random0_r = [0u8; 4];
random0_r.copy_from_slice(&data[22..26]);
packet.random0_r = Some(random0_r);
}
InitStep::Init3 => {
if data.len() < 233 {
return Err(ProtocolError::PacketTooSmall {
size: data.len(),
min: 233,
});
}
let mut x = [0u8; 64];
x.copy_from_slice(&data[1..65]);
packet.x = Some(x);
let mut n = [0u8; 64];
n.copy_from_slice(&data[65..129]);
packet.n = Some(n);
packet.level = Some(u32::from_be_bytes([
data[129], data[130], data[131], data[132],
]));
let mut random2 = [0u8; 100];
random2.copy_from_slice(&data[133..233]);
packet.random2 = Some(random2);
}
InitStep::Init4 => {
if data.len() < 361 {
return Err(ProtocolError::PacketTooSmall {
size: data.len(),
min: 361,
});
}
packet.version = Some(u32::from_be_bytes([data[1], data[2], data[3], data[4]]));
let mut x = [0u8; 64];
x.copy_from_slice(&data[6..70]);
packet.x = Some(x);
let mut n = [0u8; 64];
n.copy_from_slice(&data[70..134]);
packet.n = Some(n);
packet.level = Some(u32::from_be_bytes([
data[134], data[135], data[136], data[137],
]));
let mut random2 = [0u8; 100];
random2.copy_from_slice(&data[138..238]);
packet.random2 = Some(random2);
let mut y = [0u8; 64];
y.copy_from_slice(&data[238..302]);
packet.y = Some(y);
if data.len() > 302 {
packet.command = Some(data[302..].to_vec());
}
}
InitStep::Reset => {}
}
Ok(packet)
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut bytes = Vec::new();
match self.step {
InitStep::Init0 => {
bytes.push(0);
if let Some(version) = self.version {
bytes.extend_from_slice(&version.to_be_bytes());
} else {
bytes.extend_from_slice(&[0; 4]);
}
bytes.push(0);
if let Some(timestamp) = self.timestamp {
bytes.extend_from_slice(&timestamp.to_be_bytes());
} else {
bytes.extend_from_slice(&[0; 4]);
}
if let Some(random0) = self.random0 {
bytes.extend_from_slice(&random0);
} else {
bytes.extend_from_slice(&[0; 4]);
}
bytes.extend_from_slice(&[0; 8]);
}
InitStep::Init1 => {
bytes.push(1);
if let Some(random1) = self.random1 {
bytes.extend_from_slice(&random1);
} else {
bytes.extend_from_slice(&[0; 16]);
}
if let Some(random0_r) = self.random0_r {
bytes.extend_from_slice(&random0_r);
} else {
bytes.extend_from_slice(&[0; 4]);
}
}
InitStep::Init2 => {
bytes.push(2);
if let Some(version) = self.version {
bytes.extend_from_slice(&version.to_be_bytes());
} else {
bytes.extend_from_slice(&[0; 4]);
}
if let Some(random1) = self.random1 {
bytes.extend_from_slice(&random1);
} else {
bytes.extend_from_slice(&[0; 16]);
}
if let Some(random0_r) = self.random0_r {
bytes.extend_from_slice(&random0_r);
} else {
bytes.extend_from_slice(&[0; 4]);
}
}
InitStep::Init3 => {
bytes.push(3);
if let Some(x) = self.x {
bytes.extend_from_slice(&x);
} else {
bytes.extend_from_slice(&[0; 64]);
}
if let Some(n) = self.n {
bytes.extend_from_slice(&n);
} else {
bytes.extend_from_slice(&[0; 64]);
}
if let Some(level) = self.level {
bytes.extend_from_slice(&level.to_be_bytes());
} else {
bytes.extend_from_slice(&[0; 4]);
}
if let Some(random2) = self.random2 {
bytes.extend_from_slice(&random2);
} else {
bytes.extend_from_slice(&[0; 100]);
}
}
InitStep::Init4 => {
bytes.push(4);
if let Some(version) = self.version {
bytes.extend_from_slice(&version.to_be_bytes());
} else {
bytes.extend_from_slice(&[0; 4]);
}
if let Some(x) = self.x {
bytes.extend_from_slice(&x);
} else {
bytes.extend_from_slice(&[0; 64]);
}
if let Some(n) = self.n {
bytes.extend_from_slice(&n);
} else {
bytes.extend_from_slice(&[0; 64]);
}
if let Some(level) = self.level {
bytes.extend_from_slice(&level.to_be_bytes());
} else {
bytes.extend_from_slice(&[0; 4]);
}
if let Some(random2) = self.random2 {
bytes.extend_from_slice(&random2);
} else {
bytes.extend_from_slice(&[0; 100]);
}
if let Some(y) = self.y {
bytes.extend_from_slice(&y);
} else {
bytes.extend_from_slice(&[0; 64]);
}
if let Some(ref command) = self.command {
bytes.extend_from_slice(command);
}
}
InitStep::Reset => {
bytes.push(127);
bytes.push(0);
}
}
bytes
}
pub fn to_c2s_bytes(&self) -> Vec<u8> {
let mut bytes = Vec::new();
match self.step {
InitStep::Init0 => {
bytes.extend_from_slice(&self.version.unwrap_or_default().to_be_bytes());
bytes.push(0);
bytes.extend_from_slice(&self.timestamp.unwrap_or_default().to_be_bytes());
bytes.extend_from_slice(&self.random0.unwrap_or_default());
bytes.extend_from_slice(&[0; 8]);
}
InitStep::Init2 => {
bytes.extend_from_slice(&self.version.unwrap_or_default().to_be_bytes());
bytes.push(2);
bytes.extend_from_slice(&self.random1.unwrap_or_default());
bytes.extend_from_slice(&self.random0_r.unwrap_or_default());
}
InitStep::Init4 => {
bytes.extend_from_slice(&self.version.unwrap_or_default().to_be_bytes());
bytes.push(4);
bytes.extend_from_slice(&self.x.unwrap_or([0; 64]));
bytes.extend_from_slice(&self.n.unwrap_or([0; 64]));
bytes.extend_from_slice(&self.level.unwrap_or_default().to_be_bytes());
bytes.extend_from_slice(&self.random2.unwrap_or([0; 100]));
bytes.extend_from_slice(&self.y.unwrap_or([0; 64]));
if let Some(ref command) = self.command {
bytes.extend_from_slice(command);
}
}
InitStep::Reset => {
bytes.extend_from_slice(&self.version.unwrap_or_default().to_be_bytes());
bytes.push(127);
}
InitStep::Init1 | InitStep::Init3 => {
bytes.extend_from_slice(&self.version.unwrap_or_default().to_be_bytes());
bytes.push(self.step_byte());
}
}
bytes
}
pub fn to_c2s_packet_bytes(&self) -> Vec<u8> {
let mut packet = OutPacket::new(
Direction::C2S,
Flags::new(PacketType::Init.to_u8()),
self.to_c2s_bytes(),
);
packet.set_mac(INIT_MAC);
packet.set_packet_id(INIT_PACKET_ID);
packet.to_bytes()
}
fn step_byte(&self) -> u8 {
match self.step {
InitStep::Init0 => 0,
InitStep::Init1 => 1,
InitStep::Init2 => 2,
InitStep::Init3 => 3,
InitStep::Init4 => 4,
InitStep::Reset => 127,
}
}
}
-260
View File
@@ -1,260 +0,0 @@
//! 数据包处理测试
#[cfg(test)]
mod tests {
use crate::protocol::*;
#[test]
fn test_packet_type_conversion() {
assert_eq!(PacketType::from_u8(0x00), PacketType::Voice);
assert_eq!(PacketType::from_u8(0x02), PacketType::Command);
assert_eq!(PacketType::from_u8(0x08), PacketType::Init);
assert_eq!(PacketType::Voice.to_u8(), 0x00);
assert_eq!(PacketType::Command.to_u8(), 0x02);
}
#[test]
fn test_flags() {
let flags = Flags::new(0x80);
assert!(flags.is_unencrypted());
assert!(!flags.is_compressed());
assert!(!flags.is_newprotocol());
assert!(!flags.is_fragmented());
let flags = Flags::new(0x40);
assert!(!flags.is_unencrypted());
assert!(flags.is_compressed());
let flags = Flags::new(0x20);
assert!(flags.is_newprotocol());
let flags = Flags::new(0x10);
assert!(flags.is_fragmented());
let flags = Flags::new(0x02);
assert_eq!(flags.packet_type(), PacketType::Command);
}
#[test]
fn test_header_c2s() {
let mut data = vec![0u8; 13];
// MAC
data[0..8].copy_from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
// Packet ID = 42
data[8..10].copy_from_slice(&42u16.to_be_bytes());
// Client ID = 1
data[10..12].copy_from_slice(&1u16.to_be_bytes());
// Flags = Command
data[12] = 0x02;
let header = Header::parse_c2s(&data).unwrap();
assert_eq!(header.packet_id, 42);
assert_eq!(header.client_id, Some(1));
assert_eq!(header.flags.packet_type(), PacketType::Command);
}
#[test]
fn test_header_s2c() {
let mut data = vec![0u8; 11];
// MAC
data[0..8].copy_from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
// Packet ID = 10
data[8..10].copy_from_slice(&10u16.to_be_bytes());
// Flags = Voice
data[10] = 0x00;
let header = Header::parse_s2c(&data).unwrap();
assert_eq!(header.packet_id, 10);
assert!(header.client_id.is_none());
assert_eq!(header.flags.packet_type(), PacketType::Voice);
}
#[test]
fn test_in_packet_parse() {
let mut data = vec![0u8; 15];
// S2C header
data[0..8].copy_from_slice(&[0; 8]); // MAC
data[8..10].copy_from_slice(&1u16.to_be_bytes()); // PId
data[10] = 0x02; // Command type
// Content
data[11] = b'H';
data[12] = b'i';
data[13] = b'!';
data[14] = 0;
let packet = InPacket::parse(Direction::S2C, &data).unwrap();
assert_eq!(packet.header.packet_id, 1);
assert_eq!(packet.content(), b"Hi!\0");
}
#[test]
fn test_out_packet() {
let content = b"Hello".to_vec();
let mut packet = OutPacket::new(Direction::C2S, Flags::new(0x02), content);
packet.set_packet_id(42);
packet.set_client_id(1);
let bytes = packet.to_bytes();
assert_eq!(bytes.len(), 13 + 5); // header + content
assert_eq!(packet.header.packet_id, 42);
assert_eq!(packet.header.client_id, Some(1));
}
#[test]
fn test_command_parse() {
let cmd = Command::parse("clientinit client_nickname=Test\\sUser client_version=3.0.19.3")
.unwrap();
assert_eq!(cmd.name, "clientinit");
assert_eq!(cmd.get("client_nickname"), Some("Test User"));
assert_eq!(cmd.get("client_version"), Some("3.0.19.3"));
}
#[test]
fn test_command_parse_many() {
let commands = Command::parse_many(
"channellist cid=1 channel_name=Root|cid=2 channel_name=Gaming\\pVoice",
)
.unwrap();
assert_eq!(commands.len(), 2);
assert_eq!(commands[0].name, "channellist");
assert_eq!(commands[0].get("cid"), Some("1"));
assert_eq!(commands[0].get("channel_name"), Some("Root"));
assert_eq!(commands[1].name, "channellist");
assert_eq!(commands[1].get("cid"), Some("2"));
assert_eq!(commands[1].get("channel_name"), Some("Gaming|Voice"));
}
#[test]
fn test_command_serialize() {
let cmd = Command::new("sendtextmessage")
.key_value("targetmode", "2")
.key_value("msg", "Hello World!");
assert_eq!(
cmd.to_string(),
"sendtextmessage targetmode=2 msg=Hello\\sWorld!"
);
}
#[test]
fn test_command_builder() {
let cmd = CommandBuilder::new("clientinit")
.arg("client_nickname", "Test")
.arg("client_version", "3.0.19.3")
.flag("verbose")
.build();
assert_eq!(cmd.name, "clientinit");
assert_eq!(cmd.get("client_nickname"), Some("Test"));
assert!(cmd.has("verbose"));
}
#[test]
fn test_escape_sequences() {
use crate::protocol::commands::escape;
assert_eq!(escape::escape("hello world"), "hello\\sworld");
assert_eq!(escape::escape("a|b"), "a\\pb");
assert_eq!(escape::escape("a\\b"), "a\\\\b");
assert_eq!(escape::unescape("hello\\sworld").unwrap(), "hello world");
assert_eq!(escape::unescape("a\\pb").unwrap(), "a|b");
assert_eq!(escape::unescape("a\\\\b").unwrap(), "a\\b");
}
#[test]
fn test_init_packet_parse() {
// Init0
let mut data = vec![0u8; 21];
data[0] = 0; // step
data[1..5].copy_from_slice(&1466672534u32.to_be_bytes()); // version
data[6..10].copy_from_slice(&1000000u32.to_be_bytes()); // timestamp
data[10..14].copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); // random0
let init = InitPacket::parse(&data).unwrap();
assert_eq!(init.step, InitStep::Init0);
assert_eq!(init.version, Some(1466672534));
assert_eq!(init.random0, Some([0xAA, 0xBB, 0xCC, 0xDD]));
}
#[test]
fn test_init_packet_serialize() {
let init = InitPacket {
step: InitStep::Init0,
version: Some(1466672534),
timestamp: Some(1000000),
random0: Some([0xAA, 0xBB, 0xCC, 0xDD]),
random1: None,
random0_r: None,
x: None,
n: None,
level: None,
random2: None,
y: None,
command: None,
};
let data = init.to_bytes();
assert_eq!(data[0], 0); // step
assert_eq!(data[1..5], 1466672534u32.to_be_bytes());
}
#[test]
fn test_c2s_init_packet_serialize() {
let init = InitPacket {
step: InitStep::Init0,
version: Some(1466672534),
timestamp: Some(1000000),
random0: Some([0xAA, 0xBB, 0xCC, 0xDD]),
random1: None,
random0_r: None,
x: None,
n: None,
level: None,
random2: None,
y: None,
command: None,
};
let content = init.to_c2s_bytes();
assert_eq!(content.len(), 21);
assert_eq!(content[0..4], 1466672534u32.to_be_bytes());
assert_eq!(content[4], 0);
assert_eq!(content[5..9], 1000000u32.to_be_bytes());
assert_eq!(content[9..13], [0xAA, 0xBB, 0xCC, 0xDD]);
let parsed = InitPacket::parse_c2s(&content).unwrap();
assert_eq!(parsed.step, InitStep::Init0);
assert_eq!(parsed.version, Some(1466672534));
assert_eq!(parsed.timestamp, Some(1000000));
assert_eq!(parsed.random0, Some([0xAA, 0xBB, 0xCC, 0xDD]));
let bytes = init.to_c2s_packet_bytes();
let packet = InPacket::parse(Direction::C2S, &bytes).unwrap();
assert_eq!(packet.header.mac, INIT_MAC);
assert_eq!(packet.header.packet_id, INIT_PACKET_ID);
assert_eq!(packet.header.flags.packet_type(), PacketType::Init);
assert_eq!(packet.content(), content);
}
#[test]
fn test_ack_packet() {
let ack = AckPacket::new(Direction::C2S, PacketType::Ack, 42);
let packet = ack.to_out_packet();
assert_eq!(packet.header.flags.packet_type(), PacketType::Ack);
assert_eq!(packet.data, 42u16.to_be_bytes());
}
#[test]
fn test_packet_type_properties() {
assert!(PacketType::Command.must_encrypt());
assert!(!PacketType::Voice.must_encrypt());
assert!(PacketType::Command.can_fragment());
assert!(!PacketType::Voice.can_fragment());
assert!(PacketType::Command.needs_ack());
assert!(!PacketType::Voice.needs_ack());
assert!(PacketType::Voice.is_voice());
assert!(PacketType::VoiceWhisper.is_voice());
assert!(!PacketType::Command.is_voice());
}
}
-233
View File
@@ -1,233 +0,0 @@
//! 协议类型定义
use std::fmt;
/// 数据包类型
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PacketType {
Voice,
VoiceWhisper,
Command,
CommandLow,
Ping,
Pong,
Ack,
AckLow,
Init,
}
impl PacketType {
pub fn from_u8(value: u8) -> Self {
match value {
0x00 => Self::Voice,
0x01 => Self::VoiceWhisper,
0x02 => Self::Command,
0x03 => Self::CommandLow,
0x04 => Self::Ping,
0x05 => Self::Pong,
0x06 => Self::Ack,
0x07 => Self::AckLow,
0x08 => Self::Init,
_ => Self::Init,
}
}
pub fn to_u8(&self) -> u8 {
match self {
Self::Voice => 0x00,
Self::VoiceWhisper => 0x01,
Self::Command => 0x02,
Self::CommandLow => 0x03,
Self::Ping => 0x04,
Self::Pong => 0x05,
Self::Ack => 0x06,
Self::AckLow => 0x07,
Self::Init => 0x08,
}
}
pub fn to_usize(&self) -> usize {
self.to_u8() as usize
}
pub fn is_voice(&self) -> bool {
matches!(self, Self::Voice | Self::VoiceWhisper)
}
pub fn needs_ack(&self) -> bool {
matches!(
self,
Self::Command | Self::CommandLow | Self::Ping | Self::Init
)
}
pub fn can_resend(&self) -> bool {
matches!(
self,
Self::Command | Self::CommandLow | Self::Ack | Self::AckLow | Self::Init
)
}
pub fn can_encrypt(&self) -> bool {
!matches!(self, Self::Init)
}
pub fn must_encrypt(&self) -> bool {
matches!(self, Self::Command | Self::CommandLow)
}
pub fn can_fragment(&self) -> bool {
matches!(self, Self::Command | Self::CommandLow)
}
pub fn can_compress(&self) -> bool {
matches!(self, Self::Command | Self::CommandLow)
}
pub fn ack_type(&self) -> Option<Self> {
match self {
Self::Command => Some(Self::Ack),
Self::CommandLow => Some(Self::AckLow),
Self::Ping => Some(Self::Pong),
Self::Init => Some(Self::Init),
_ => None,
}
}
}
impl fmt::Display for PacketType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Voice => write!(f, "Voice"),
Self::VoiceWhisper => write!(f, "VoiceWhisper"),
Self::Command => write!(f, "Command"),
Self::CommandLow => write!(f, "CommandLow"),
Self::Ping => write!(f, "Ping"),
Self::Pong => write!(f, "Pong"),
Self::Ack => write!(f, "Ack"),
Self::AckLow => write!(f, "AckLow"),
Self::Init => write!(f, "Init"),
}
}
}
/// 编解码器类型
#[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,
}
}
}
/// 私语类型
#[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,
}
}
}
/// 私语目标
#[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,
}
}
}
-733
View File
@@ -1,733 +0,0 @@
//! TeamSpeak ServerQuery TCP client support.
use std::time::Duration;
use shared::{
ChannelId, ClientDbId, ClientId, ClientType, PermissionId, PermissionInfo, ServerQueryChannel,
ServerQueryClient, ServerQueryServerInfo,
};
use thiserror::Error;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpStream, ToSocketAddrs};
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(2);
const BUFFER_SIZE: usize = 1024;
const GREETING_MARKER: &str = "ServerQuery interface";
pub type QueryResult<T> = Result<T, QueryError>;
#[derive(Debug, Error)]
pub enum QueryError {
#[error("ServerQuery I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("ServerQuery read timed out")]
Timeout,
#[error("ServerQuery connection closed")]
ConnectionClosed,
#[error("ServerQuery response did not include a status line")]
MissingStatus,
#[error("invalid ServerQuery field: {0}")]
InvalidField(String),
#[error("invalid ServerQuery status id: {0}")]
InvalidStatusId(String),
#[error("ServerQuery error {id}: {message}")]
Status { id: u32, message: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueryStatus {
pub id: u32,
pub message: String,
pub fields: Vec<(String, String)>,
}
impl QueryStatus {
pub fn get(&self, name: &str) -> Option<&str> {
self.fields
.iter()
.find(|(key, _)| key == name)
.map(|(_, value)| value.as_str())
}
pub fn require(&self, name: &str) -> QueryResult<&str> {
self.get(name)
.ok_or_else(|| QueryError::InvalidField(format!("missing {name}")))
}
pub fn get_u32(&self, name: &str) -> QueryResult<u32> {
let value = self.require(name)?;
value
.parse::<u32>()
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
}
pub fn get_u64(&self, name: &str) -> QueryResult<u64> {
let value = self.require(name)?;
value
.parse::<u64>()
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
}
pub fn get_u16(&self, name: &str) -> QueryResult<u16> {
let value = self.require(name)?;
value
.parse::<u16>()
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
}
pub fn get_i32(&self, name: &str) -> QueryResult<i32> {
let value = self.require(name)?;
value
.parse::<i32>()
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
}
pub fn get_bool(&self, name: &str) -> QueryResult<bool> {
Ok(self.get_u32(name)? != 0)
}
pub fn get_u32_or(&self, name: &str, default: u32) -> QueryResult<u32> {
self.get(name)
.map(|_| self.get_u32(name))
.unwrap_or(Ok(default))
}
pub fn get_i32_or(&self, name: &str, default: i32) -> QueryResult<i32> {
self.get(name)
.map(|_| self.get_i32(name))
.unwrap_or(Ok(default))
}
pub fn get_u64_or(&self, name: &str, default: u64) -> QueryResult<u64> {
self.get(name)
.map(|_| self.get_u64(name))
.unwrap_or(Ok(default))
}
pub fn get_u16_or(&self, name: &str, default: u16) -> QueryResult<u16> {
self.get(name)
.map(|_| self.get_u16(name))
.unwrap_or(Ok(default))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueryRecord {
fields: Vec<(String, String)>,
}
impl QueryRecord {
pub fn fields(&self) -> &[(String, String)] {
&self.fields
}
pub fn get(&self, name: &str) -> Option<&str> {
self.fields
.iter()
.find(|(key, _)| key == name)
.map(|(_, value)| value.as_str())
}
pub fn require(&self, name: &str) -> QueryResult<&str> {
self.get(name)
.ok_or_else(|| QueryError::InvalidField(format!("missing {name}")))
}
pub fn get_u32(&self, name: &str) -> QueryResult<u32> {
let value = self.require(name)?;
value
.parse::<u32>()
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
}
pub fn get_u64(&self, name: &str) -> QueryResult<u64> {
let value = self.require(name)?;
value
.parse::<u64>()
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
}
pub fn get_u16(&self, name: &str) -> QueryResult<u16> {
let value = self.require(name)?;
value
.parse::<u16>()
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
}
pub fn get_i32(&self, name: &str) -> QueryResult<i32> {
let value = self.require(name)?;
value
.parse::<i32>()
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
}
pub fn get_bool(&self, name: &str) -> QueryResult<bool> {
Ok(self.get_u32(name)? != 0)
}
pub fn get_u32_or(&self, name: &str, default: u32) -> QueryResult<u32> {
self.get(name)
.map(|_| self.get_u32(name))
.unwrap_or(Ok(default))
}
pub fn get_i32_or(&self, name: &str, default: i32) -> QueryResult<i32> {
self.get(name)
.map(|_| self.get_i32(name))
.unwrap_or(Ok(default))
}
pub fn get_u64_or(&self, name: &str, default: u64) -> QueryResult<u64> {
self.get(name)
.map(|_| self.get_u64(name))
.unwrap_or(Ok(default))
}
pub fn get_u16_or(&self, name: &str, default: u16) -> QueryResult<u16> {
self.get(name)
.map(|_| self.get_u16(name))
.unwrap_or(Ok(default))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueryResponse {
pub raw: String,
pub records: Vec<QueryRecord>,
pub status: QueryStatus,
}
pub struct QueryClient {
stream: TcpStream,
greeting: String,
read_timeout: Duration,
}
impl QueryClient {
pub async fn connect<A: ToSocketAddrs>(addr: A) -> QueryResult<Self> {
let stream = TcpStream::connect(addr).await?;
Self::from_stream(stream).await
}
pub async fn from_stream(stream: TcpStream) -> QueryResult<Self> {
let mut client = Self {
stream,
greeting: String::new(),
read_timeout: DEFAULT_READ_TIMEOUT,
};
client.greeting = client.read_greeting().await?;
Ok(client)
}
pub fn greeting(&self) -> &str {
&self.greeting
}
pub fn set_read_timeout(&mut self, timeout: Duration) {
self.read_timeout = timeout;
}
pub async fn execute(&mut self, command: &str) -> QueryResult<QueryResponse> {
self.write_command(command).await?;
let raw = self.read_until_status().await?;
decode_response(raw)
}
pub async fn login(&mut self, user: &str, password: &str) -> QueryResult<()> {
let command = format!("login {} {}", escape(user), escape(password));
self.execute(&command).await.map(|_| ())
}
pub async fn use_server(&mut self, server_id: u64) -> QueryResult<()> {
self.execute(&format!("use {server_id}")).await.map(|_| ())
}
pub async fn whoami(&mut self) -> QueryResult<Option<QueryRecord>> {
let mut response = self.execute("whoami").await?;
Ok(response.records.pop())
}
pub async fn permission_list(&mut self) -> QueryResult<Vec<PermissionInfo>> {
let response = self.execute("permissionlist").await?;
records_to_permissions(&response.records)
}
pub async fn channel_list(&mut self) -> QueryResult<Vec<ServerQueryChannel>> {
let response = self.execute("channellist").await?;
records_to_channels(&response.records)
}
pub async fn client_list(&mut self) -> QueryResult<Vec<ServerQueryClient>> {
let response = self.execute("clientlist").await?;
records_to_clients(&response.records)
}
pub async fn server_info(&mut self) -> QueryResult<Option<ServerQueryServerInfo>> {
let response = self.execute("serverinfo").await?;
response
.records
.first()
.map(record_to_server_info)
.transpose()
}
async fn write_command(&mut self, command: &str) -> QueryResult<()> {
let mut payload = command.to_string();
if !payload.ends_with("\n\r") && !payload.ends_with("\r\n") {
payload.push_str("\n\r");
}
self.stream.write_all(payload.as_bytes()).await?;
Ok(())
}
async fn read_greeting(&mut self) -> QueryResult<String> {
self.read_until(|content| content.contains(GREETING_MARKER))
.await
}
async fn read_until_status(&mut self) -> QueryResult<String> {
self.read_until(contains_status_line).await
}
async fn read_until<F>(&mut self, done: F) -> QueryResult<String>
where
F: Fn(&str) -> bool,
{
let timeout = self.read_timeout;
let stream = &mut self.stream;
tokio::time::timeout(timeout, async move {
let mut data = Vec::new();
let mut buffer = [0u8; BUFFER_SIZE];
loop {
let len = stream.read(&mut buffer).await?;
if len == 0 {
return Err(QueryError::ConnectionClosed);
}
data.extend_from_slice(&buffer[..len]);
let content = String::from_utf8_lossy(&data);
if done(&content) {
return Ok(content.into_owned());
}
}
})
.await
.map_err(|_| QueryError::Timeout)?
}
}
pub fn records_to_permissions(records: &[QueryRecord]) -> QueryResult<Vec<PermissionInfo>> {
records.iter().map(record_to_permission).collect()
}
pub fn record_to_permission(record: &QueryRecord) -> QueryResult<PermissionInfo> {
Ok(PermissionInfo {
id: PermissionId(record.get_u32("permid")?),
name: record.require("permname")?.to_string(),
description: record.get("permdesc").unwrap_or_default().to_string(),
})
}
pub fn records_to_channels(records: &[QueryRecord]) -> QueryResult<Vec<ServerQueryChannel>> {
records.iter().map(record_to_channel).collect()
}
pub fn record_to_channel(record: &QueryRecord) -> QueryResult<ServerQueryChannel> {
Ok(ServerQueryChannel {
id: ChannelId(record.get_u64("cid")?),
parent_id: ChannelId(record.get_u64_or("pid", 0)?),
order: ChannelId(record.get_u64_or("channel_order", 0)?),
name: record.require("channel_name")?.to_string(),
total_clients: record.get_u32_or("total_clients", 0)?,
needed_subscribe_power: record.get_i32_or("channel_needed_subscribe_power", 0)?,
})
}
pub fn records_to_clients(records: &[QueryRecord]) -> QueryResult<Vec<ServerQueryClient>> {
records.iter().map(record_to_client).collect()
}
pub fn record_to_client(record: &QueryRecord) -> QueryResult<ServerQueryClient> {
let client_type = if record.get_u32_or("client_type", 0)? == 0 {
ClientType::Normal
} else {
ClientType::Query { admin: false }
};
Ok(ServerQueryClient {
id: ClientId(record.get_u16("clid")?),
channel_id: ChannelId(record.get_u64("cid")?),
database_id: ClientDbId(record.get_u64_or("client_database_id", 0)?),
nickname: record.require("client_nickname")?.to_string(),
client_type,
unique_identifier: record
.get("client_unique_identifier")
.unwrap_or_default()
.to_string(),
})
}
pub fn record_to_server_info(record: &QueryRecord) -> QueryResult<ServerQueryServerInfo> {
Ok(ServerQueryServerInfo {
name: record.require("virtualserver_name")?.to_string(),
platform: record
.get("virtualserver_platform")
.unwrap_or_default()
.to_string(),
version: record
.get("virtualserver_version")
.unwrap_or_default()
.to_string(),
max_clients: record.get_u16_or("virtualserver_maxclients", 0)?,
clients_online: record.get_u16_or("virtualserver_clientsonline", 0)?,
channels_online: record.get_u64_or("virtualserver_channelsonline", 0)?,
uptime: record.get_u64_or("virtualserver_uptime", 0)?,
})
}
pub fn decode_response(raw: String) -> QueryResult<QueryResponse> {
let mut records = Vec::new();
let mut status = None;
for line in raw
.lines()
.map(normalize_line)
.filter(|line| !line.is_empty())
{
if let Some(status_line) = line.strip_prefix("error ") {
status = Some(parse_status(status_line)?);
break;
}
for record in line.split('|').filter(|record| !record.is_empty()) {
records.push(parse_record(record)?);
}
}
let status = status.ok_or(QueryError::MissingStatus)?;
if status.id != 0 {
return Err(QueryError::Status {
id: status.id,
message: status.message,
});
}
Ok(QueryResponse {
raw,
records,
status,
})
}
pub fn escape(input: &str) -> String {
let mut output = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'\\' => output.push_str("\\\\"),
' ' => output.push_str("\\s"),
'|' => output.push_str("\\p"),
'/' => output.push_str("\\/"),
'\n' => output.push_str("\\n"),
'\r' => output.push_str("\\r"),
'\t' => output.push_str("\\t"),
_ => output.push(ch),
}
}
output
}
pub fn unescape(input: &str) -> QueryResult<String> {
let mut output = String::with_capacity(input.len());
let mut chars = input.chars();
while let Some(ch) = chars.next() {
if ch != '\\' {
output.push(ch);
continue;
}
match chars.next() {
Some('s') => output.push(' '),
Some('p') => output.push('|'),
Some('/') => output.push('/'),
Some('\\') => output.push('\\'),
Some('a') => output.push('\u{0007}'),
Some('b') => output.push('\u{0008}'),
Some('f') => output.push('\u{000c}'),
Some('n') => output.push('\n'),
Some('r') => output.push('\r'),
Some('t') => output.push('\t'),
Some('v') => output.push('\u{000b}'),
Some(other) => {
return Err(QueryError::InvalidField(format!(
"unknown escape \\{other}"
)))
}
None => return Err(QueryError::InvalidField("trailing escape".to_string())),
}
}
Ok(output)
}
fn parse_status(input: &str) -> QueryResult<QueryStatus> {
let fields = parse_fields(input)?;
let id = fields
.iter()
.find(|(key, _)| key == "id")
.map(|(_, value)| value.as_str())
.ok_or_else(|| QueryError::InvalidField(input.to_string()))?;
let id = id
.parse::<u32>()
.map_err(|_| QueryError::InvalidStatusId(id.to_string()))?;
let message = fields
.iter()
.find(|(key, _)| key == "msg")
.map(|(_, value)| value.clone())
.unwrap_or_default();
Ok(QueryStatus {
id,
message,
fields,
})
}
fn parse_record(input: &str) -> QueryResult<QueryRecord> {
Ok(QueryRecord {
fields: parse_fields(input)?,
})
}
fn parse_fields(input: &str) -> QueryResult<Vec<(String, String)>> {
input
.split(' ')
.filter(|field| !field.is_empty())
.map(|field| {
let (key, value) = field.split_once('=').unwrap_or((field, ""));
Ok((unescape(key)?, unescape(value)?))
})
.collect()
}
fn contains_status_line(content: &str) -> bool {
content
.lines()
.map(normalize_line)
.any(|line| line.starts_with("error id="))
}
fn normalize_line(line: &str) -> &str {
line.trim_end_matches('\r').trim_end_matches('\n')
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::net::TcpListener;
#[test]
fn decodes_success_response_records() {
let response = decode_response(
"clid=7 client_database_id=12 client_nickname=hello\\sworld|clid=8 client_nickname=a\\pb\r\nerror id=0 msg=ok\r\n"
.to_string(),
)
.unwrap();
assert_eq!(response.status.id, 0);
assert_eq!(response.records.len(), 2);
assert_eq!(
response.records[0].get("client_nickname"),
Some("hello world")
);
assert_eq!(response.records[1].get("client_nickname"), Some("a|b"));
}
#[test]
fn decodes_error_status() {
let error =
decode_response("error id=256 msg=command\\snot\\sfound\n\r".to_string()).unwrap_err();
assert!(matches!(
error,
QueryError::Status { id: 256, message } if message == "command not found"
));
}
#[test]
fn escapes_query_values() {
assert_eq!(escape("a b|c/d\\e"), "a\\sb\\pc\\/d\\\\e");
}
#[test]
fn decodes_permissionlist_records() {
let response = decode_response(
"permid=1 permname=b_serverinstance_help_view permdesc=Retrieve\\sinformation\\sabout\\sServerQuery\\scommands|permid=32769 permname=i_needed_modify_power_serverinstance_help_view\r\nerror id=0 msg=ok\r\n"
.to_string(),
)
.unwrap();
let permissions = records_to_permissions(&response.records).unwrap();
assert_eq!(permissions.len(), 2);
assert_eq!(permissions[0].id, PermissionId(1));
assert_eq!(permissions[0].name, "b_serverinstance_help_view");
assert_eq!(
permissions[0].description,
"Retrieve information about ServerQuery commands"
);
assert_eq!(permissions[1].id, PermissionId(32769));
assert_eq!(permissions[1].description, "");
}
#[test]
fn decodes_common_serverquery_records() {
let response = decode_response(
"cid=1 pid=0 channel_order=0 channel_name=Lobby total_clients=2 channel_needed_subscribe_power=0|cid=2 pid=1 channel_order=1 channel_name=Voice\\sRoom total_clients=0 channel_needed_subscribe_power=25\r\nerror id=0 msg=ok\r\n"
.to_string(),
)
.unwrap();
let channels = records_to_channels(&response.records).unwrap();
assert_eq!(channels.len(), 2);
assert_eq!(channels[0].id, ChannelId(1));
assert_eq!(channels[1].name, "Voice Room");
assert_eq!(channels[1].needed_subscribe_power, 25);
let response = decode_response(
"clid=8 cid=1 client_database_id=1 client_nickname=serveradmin client_type=1 client_unique_identifier=serveradmin|clid=9 cid=2 client_database_id=42 client_nickname=Normal\\sUser client_type=0 client_unique_identifier=abc\r\nerror id=0 msg=ok\r\n"
.to_string(),
)
.unwrap();
let clients = records_to_clients(&response.records).unwrap();
assert_eq!(clients.len(), 2);
assert_eq!(clients[0].id, ClientId(8));
assert_eq!(clients[0].client_type, ClientType::Query { admin: false });
assert_eq!(clients[1].nickname, "Normal User");
assert_eq!(clients[1].client_type, ClientType::Normal);
let response = decode_response(
"virtualserver_name=Test\\sServer virtualserver_platform=Linux virtualserver_version=3.13.7 virtualserver_maxclients=32 virtualserver_clientsonline=4 virtualserver_channelsonline=12 virtualserver_uptime=3600\r\nerror id=0 msg=ok\r\n"
.to_string(),
)
.unwrap();
let server = record_to_server_info(&response.records[0]).unwrap();
assert_eq!(server.name, "Test Server");
assert_eq!(server.max_clients, 32);
assert_eq!(server.clients_online, 4);
assert_eq!(server.channels_online, 12);
assert_eq!(server.uptime, 3600);
}
#[tokio::test]
async fn executes_commands_against_mock_server() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
stream
.write_all(b"TS3\r\nWelcome to the TeamSpeak 3 ServerQuery interface\r\n")
.await
.unwrap();
let command = read_command(&mut stream).await;
assert_eq!(command, "login serveradmin secret\\spass");
stream.write_all(b"error id=0 msg=ok\r\n").await.unwrap();
let command = read_command(&mut stream).await;
assert_eq!(command, "whoami");
stream
.write_all(
b"clid=4 client_database_id=10 client_nickname=serveradmin\r\nerror id=0 msg=ok\r\n",
)
.await
.unwrap();
let command = read_command(&mut stream).await;
assert_eq!(command, "permissionlist");
stream
.write_all(
b"permid=24 permname=b_virtualserver_select permdesc=Select\\sa\\svirtual\\sserver|permid=248 permname=i_ft_quota_mb_upload_per_client permdesc=Upload\\squota\\sper\\sclient\\sin\\sMByte\r\nerror id=0 msg=ok\r\n",
)
.await
.unwrap();
let command = read_command(&mut stream).await;
assert_eq!(command, "channellist");
stream
.write_all(
b"cid=1 pid=0 channel_order=0 channel_name=Lobby total_clients=1 channel_needed_subscribe_power=0\r\nerror id=0 msg=ok\r\n",
)
.await
.unwrap();
let command = read_command(&mut stream).await;
assert_eq!(command, "clientlist");
stream
.write_all(
b"clid=9 cid=1 client_database_id=42 client_nickname=Normal\\sUser client_type=0 client_unique_identifier=abc\r\nerror id=0 msg=ok\r\n",
)
.await
.unwrap();
let command = read_command(&mut stream).await;
assert_eq!(command, "serverinfo");
stream
.write_all(
b"virtualserver_name=Mock\\sServer virtualserver_platform=Linux virtualserver_version=3.13.7 virtualserver_maxclients=32 virtualserver_clientsonline=1 virtualserver_channelsonline=1 virtualserver_uptime=99\r\nerror id=0 msg=ok\r\n",
)
.await
.unwrap();
});
let mut client = QueryClient::connect(addr).await.unwrap();
assert!(client.greeting().contains("TS3"));
client.login("serveradmin", "secret pass").await.unwrap();
let whoami = client.whoami().await.unwrap().unwrap();
assert_eq!(whoami.get("clid"), Some("4"));
assert_eq!(whoami.get("client_database_id"), Some("10"));
let permissions = client.permission_list().await.unwrap();
assert_eq!(permissions.len(), 2);
assert_eq!(permissions[0].id, PermissionId(24));
assert_eq!(permissions[0].name, "b_virtualserver_select");
assert_eq!(permissions[1].id, PermissionId(248));
let channels = client.channel_list().await.unwrap();
assert_eq!(channels[0].name, "Lobby");
assert_eq!(channels[0].total_clients, 1);
let clients = client.client_list().await.unwrap();
assert_eq!(clients[0].nickname, "Normal User");
assert_eq!(clients[0].database_id, ClientDbId(42));
let server_info = client.server_info().await.unwrap().unwrap();
assert_eq!(server_info.name, "Mock Server");
assert_eq!(server_info.uptime, 99);
server.await.unwrap();
}
async fn read_command(stream: &mut TcpStream) -> String {
let mut data = Vec::new();
let mut buffer = [0u8; 64];
loop {
let len = stream.read(&mut buffer).await.unwrap();
assert_ne!(len, 0);
data.extend_from_slice(&buffer[..len]);
let content = String::from_utf8_lossy(&data);
if content.ends_with("\n\r") || content.ends_with("\r\n") {
return content.trim_end_matches(['\n', '\r']).to_string();
}
}
}
}
-28
View File
@@ -1,28 +0,0 @@
[package]
name = "tsdb"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "TeamSpeak 数据存储"
[dependencies]
# Error handling
thiserror = { workspace = true }
anyhow = { workspace = true }
# Logging
tracing = { workspace = true }
# Database
rusqlite = { workspace = true }
# Serialization
serde = { workspace = true }
serde_json = { workspace = true }
# Utils
chrono = { workspace = true }
uuid = { workspace = true }
# Internal
shared = { workspace = true }
-178
View File
@@ -1,178 +0,0 @@
//! 书签管理
use chrono::Utc;
use rusqlite::params;
use super::{DatabaseError, DatabaseManager, DatabaseResult};
/// 书签信息
#[derive(Debug, Clone)]
pub struct Bookmark {
pub id: String,
pub name: String,
pub address: String,
pub port: u16,
pub nickname: Option<String>,
pub server_password: Option<String>,
pub channel: Option<String>,
pub channel_password: Option<String>,
pub default_token: Option<String>,
pub auto_connect: bool,
pub last_connected: Option<String>,
pub created_at: String,
pub updated_at: String,
}
impl DatabaseManager {
/// 创建书签
pub fn create_bookmark(
&self,
name: &str,
address: &str,
port: u16,
nickname: Option<&str>,
) -> DatabaseResult<Bookmark> {
let id = uuid::Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
self.connection().execute(
"INSERT INTO bookmarks (id, name, address, port, nickname, auto_connect, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![id, name, address, port, nickname, false, now, now],
)?;
Ok(Bookmark {
id,
name: name.to_string(),
address: address.to_string(),
port,
nickname: nickname.map(|s| s.to_string()),
server_password: None,
channel: None,
channel_password: None,
default_token: None,
auto_connect: false,
last_connected: None,
created_at: now.clone(),
updated_at: now,
})
}
/// 获取书签
pub fn get_bookmark(&self, id: &str) -> DatabaseResult<Bookmark> {
let conn = self.connection();
let mut stmt = conn.prepare(
"SELECT id, name, address, port, nickname, server_password, channel, channel_password, default_token, auto_connect, last_connected, created_at, updated_at FROM bookmarks WHERE id = ?1"
)?;
let bookmark = stmt
.query_row(params![id], |row| {
Ok(Bookmark {
id: row.get(0)?,
name: row.get(1)?,
address: row.get(2)?,
port: row.get(3)?,
nickname: row.get(4)?,
server_password: row.get(5)?,
channel: row.get(6)?,
channel_password: row.get(7)?,
default_token: row.get(8)?,
auto_connect: row.get::<_, i32>(9)? != 0,
last_connected: row.get(10)?,
created_at: row.get(11)?,
updated_at: row.get(12)?,
})
})
.map_err(|_| DatabaseError::NotFound(format!("书签 {} 未找到", id)))?;
Ok(bookmark)
}
/// 获取所有书签
pub fn get_all_bookmarks(&self) -> DatabaseResult<Vec<Bookmark>> {
let conn = self.connection();
let mut stmt = conn.prepare(
"SELECT id, name, address, port, nickname, server_password, channel, channel_password, default_token, auto_connect, last_connected, created_at, updated_at FROM bookmarks ORDER BY name"
)?;
let bookmarks = stmt
.query_map([], |row| {
Ok(Bookmark {
id: row.get(0)?,
name: row.get(1)?,
address: row.get(2)?,
port: row.get(3)?,
nickname: row.get(4)?,
server_password: row.get(5)?,
channel: row.get(6)?,
channel_password: row.get(7)?,
default_token: row.get(8)?,
auto_connect: row.get::<_, i32>(9)? != 0,
last_connected: row.get(10)?,
created_at: row.get(11)?,
updated_at: row.get(12)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(bookmarks)
}
/// 更新书签
pub fn update_bookmark(
&self,
id: &str,
name: Option<&str>,
address: Option<&str>,
port: Option<u16>,
nickname: Option<&str>,
) -> DatabaseResult<()> {
let now = Utc::now().to_rfc3339();
if let Some(name) = name {
self.connection().execute(
"UPDATE bookmarks SET name = ?1, updated_at = ?2 WHERE id = ?3",
params![name, now, id],
)?;
}
if let Some(address) = address {
self.connection().execute(
"UPDATE bookmarks SET address = ?1, updated_at = ?2 WHERE id = ?3",
params![address, now, id],
)?;
}
if let Some(port) = port {
self.connection().execute(
"UPDATE bookmarks SET port = ?1, updated_at = ?2 WHERE id = ?3",
params![port, now, id],
)?;
}
if let Some(nickname) = nickname {
self.connection().execute(
"UPDATE bookmarks SET nickname = ?1, updated_at = ?2 WHERE id = ?3",
params![nickname, now, id],
)?;
}
Ok(())
}
/// 删除书签
pub fn delete_bookmark(&self, id: &str) -> DatabaseResult<()> {
self.connection()
.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])?;
Ok(())
}
/// 更新最后连接时间
pub fn update_bookmark_last_connected(&self, id: &str) -> DatabaseResult<()> {
let now = Utc::now().to_rfc3339();
self.connection().execute(
"UPDATE bookmarks SET last_connected = ?1, updated_at = ?2 WHERE id = ?3",
params![now, now, id],
)?;
Ok(())
}
}
-48
View File
@@ -1,48 +0,0 @@
//! 配置管理
use chrono::Utc;
use rusqlite::params;
use rusqlite::OptionalExtension;
use super::{DatabaseManager, DatabaseResult};
impl DatabaseManager {
pub fn get_setting(&self, key: &str) -> DatabaseResult<Option<String>> {
let conn = self.connection();
let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?1")?;
let result = stmt
.query_row(params![key], |row| row.get::<_, String>(0))
.optional()?;
Ok(result)
}
pub fn set_setting(&self, key: &str, value: &str) -> DatabaseResult<()> {
let now = Utc::now().to_rfc3339();
self.connection().execute(
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?1, ?2, ?3)",
params![key, value, now],
)?;
Ok(())
}
pub fn delete_setting(&self, key: &str) -> DatabaseResult<()> {
self.connection()
.execute("DELETE FROM settings WHERE key = ?1", params![key])?;
Ok(())
}
pub fn get_all_settings(&self) -> DatabaseResult<Vec<(String, String)>> {
let conn = self.connection();
let mut stmt = conn.prepare("SELECT key, value FROM settings ORDER BY key")?;
let settings = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
.collect::<Result<Vec<_>, _>>()?;
Ok(settings)
}
}
-122
View File
@@ -1,122 +0,0 @@
//! 身份管理
use chrono::Utc;
use rusqlite::params;
use super::{DatabaseError, DatabaseManager, DatabaseResult};
/// 身份信息
#[derive(Debug, Clone)]
pub struct Identity {
pub id: String,
pub name: String,
pub private_key: String,
pub counter: u64,
pub max_counter: u64,
pub created_at: String,
pub updated_at: String,
}
impl DatabaseManager {
/// 创建身份
pub fn create_identity(&self, name: &str, private_key: &str) -> DatabaseResult<Identity> {
let id = uuid::Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
self.connection().execute(
"INSERT INTO identities (id, name, private_key, counter, max_counter, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![id, name, private_key, 0, 0, now, now],
)?;
Ok(Identity {
id,
name: name.to_string(),
private_key: private_key.to_string(),
counter: 0,
max_counter: 0,
created_at: now.clone(),
updated_at: now,
})
}
/// 获取身份
pub fn get_identity(&self, id: &str) -> DatabaseResult<Identity> {
let conn = self.connection();
let mut stmt = conn.prepare(
"SELECT id, name, private_key, counter, max_counter, created_at, updated_at FROM identities WHERE id = ?1"
)?;
let identity = stmt
.query_row(params![id], |row| {
Ok(Identity {
id: row.get(0)?,
name: row.get(1)?,
private_key: row.get(2)?,
counter: row.get(3)?,
max_counter: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
})
.map_err(|_| DatabaseError::NotFound(format!("身份 {} 未找到", id)))?;
Ok(identity)
}
/// 获取所有身份
pub fn get_all_identities(&self) -> DatabaseResult<Vec<Identity>> {
let conn = self.connection();
let mut stmt = conn.prepare(
"SELECT id, name, private_key, counter, max_counter, created_at, updated_at FROM identities ORDER BY name"
)?;
let identities = stmt
.query_map([], |row| {
Ok(Identity {
id: row.get(0)?,
name: row.get(1)?,
private_key: row.get(2)?,
counter: row.get(3)?,
max_counter: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(identities)
}
/// 更新身份
pub fn update_identity(
&self,
id: &str,
name: Option<&str>,
counter: Option<u64>,
) -> DatabaseResult<()> {
let now = Utc::now().to_rfc3339();
if let Some(name) = name {
self.connection().execute(
"UPDATE identities SET name = ?1, updated_at = ?2 WHERE id = ?3",
params![name, now, id],
)?;
}
if let Some(counter) = counter {
self.connection().execute(
"UPDATE identities SET counter = ?1, max_counter = MAX(max_counter, ?1), updated_at = ?2 WHERE id = ?3",
params![counter, now, id],
)?;
}
Ok(())
}
/// 删除身份
pub fn delete_identity(&self, id: &str) -> DatabaseResult<()> {
self.connection()
.execute("DELETE FROM identities WHERE id = ?1", params![id])?;
Ok(())
}
}
-104
View File
@@ -1,104 +0,0 @@
//! 数据存储
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 错误: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("序列化错误: {0}")]
Serialization(#[from] serde_json::Error),
#[error("IO 错误: {0}")]
Io(#[from] std::io::Error),
#[error("未找到: {0}")]
NotFound(String),
#[error("已存在: {0}")]
AlreadyExists(String),
}
/// 数据库结果类型
pub type DatabaseResult<T> = Result<T, DatabaseError>;
/// 数据库管理器
pub struct DatabaseManager {
conn: rusqlite::Connection,
}
impl DatabaseManager {
pub fn new(path: &str) -> DatabaseResult<Self> {
let conn = rusqlite::Connection::open(path)?;
let manager = Self { conn };
manager.init_tables()?;
Ok(manager)
}
fn init_tables(&self) -> DatabaseResult<()> {
self.conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS identities (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
private_key TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
max_counter INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
address TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 9987,
nickname TEXT,
server_password TEXT,
channel TEXT,
channel_password TEXT,
default_token TEXT,
auto_connect INTEGER NOT NULL DEFAULT 0,
last_connected TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_address TEXT NOT NULL,
invoker_id INTEGER NOT NULL,
invoker_name TEXT NOT NULL,
invoker_uid TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id INTEGER,
message TEXT NOT NULL,
is_read INTEGER NOT NULL DEFAULT 0,
timestamp TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
",
)?;
Ok(())
}
pub fn connection(&self) -> &rusqlite::Connection {
&self.conn
}
}
-140
View File
@@ -1,140 +0,0 @@
//! 消息管理
use chrono::Utc;
use rusqlite::params;
use super::{DatabaseError, DatabaseManager, DatabaseResult};
/// 消息信息
#[derive(Debug, Clone)]
pub struct Message {
pub id: i64,
pub server_address: String,
pub invoker_id: i64,
pub invoker_name: String,
pub invoker_uid: String,
pub target_type: String,
pub target_id: Option<i64>,
pub message: String,
pub is_read: bool,
pub timestamp: String,
}
impl DatabaseManager {
/// 创建消息
#[allow(clippy::too_many_arguments)]
pub fn create_message(
&self,
server_address: &str,
invoker_id: i64,
invoker_name: &str,
invoker_uid: &str,
target_type: &str,
target_id: Option<i64>,
message: &str,
) -> DatabaseResult<Message> {
let now = Utc::now().to_rfc3339();
self.connection().execute(
"INSERT INTO messages (server_address, invoker_id, invoker_name, invoker_uid, target_type, target_id, message, is_read, timestamp) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![server_address, invoker_id, invoker_name, invoker_uid, target_type, target_id, message, false, now],
)?;
let id = self.connection().last_insert_rowid();
Ok(Message {
id,
server_address: server_address.to_string(),
invoker_id,
invoker_name: invoker_name.to_string(),
invoker_uid: invoker_uid.to_string(),
target_type: target_type.to_string(),
target_id,
message: message.to_string(),
is_read: false,
timestamp: now,
})
}
/// 获取消息
pub fn get_message(&self, id: i64) -> DatabaseResult<Message> {
let conn = self.connection();
let mut stmt = conn.prepare(
"SELECT id, server_address, invoker_id, invoker_name, invoker_uid, target_type, target_id, message, is_read, timestamp FROM messages WHERE id = ?1"
)?;
let message = stmt
.query_row(params![id], |row| {
Ok(Message {
id: row.get(0)?,
server_address: row.get(1)?,
invoker_id: row.get(2)?,
invoker_name: row.get(3)?,
invoker_uid: row.get(4)?,
target_type: row.get(5)?,
target_id: row.get(6)?,
message: row.get(7)?,
is_read: row.get::<_, i32>(8)? != 0,
timestamp: row.get(9)?,
})
})
.map_err(|_| DatabaseError::NotFound(format!("消息 {} 未找到", id)))?;
Ok(message)
}
/// 获取服务器消息
pub fn get_server_messages(
&self,
server_address: &str,
limit: i64,
offset: i64,
) -> DatabaseResult<Vec<Message>> {
let conn = self.connection();
let mut stmt = conn.prepare(
"SELECT id, server_address, invoker_id, invoker_name, invoker_uid, target_type, target_id, message, is_read, timestamp FROM messages WHERE server_address = ?1 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3"
)?;
let messages = stmt
.query_map(params![server_address, limit, offset], |row| {
Ok(Message {
id: row.get(0)?,
server_address: row.get(1)?,
invoker_id: row.get(2)?,
invoker_name: row.get(3)?,
invoker_uid: row.get(4)?,
target_type: row.get(5)?,
target_id: row.get(6)?,
message: row.get(7)?,
is_read: row.get::<_, i32>(8)? != 0,
timestamp: row.get(9)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(messages)
}
/// 标记消息为已读
pub fn mark_message_read(&self, id: i64) -> DatabaseResult<()> {
self.connection()
.execute("UPDATE messages SET is_read = 1 WHERE id = ?1", params![id])?;
Ok(())
}
/// 删除消息
pub fn delete_message(&self, id: i64) -> DatabaseResult<()> {
self.connection()
.execute("DELETE FROM messages WHERE id = ?1", params![id])?;
Ok(())
}
/// 清空服务器消息
pub fn clear_server_messages(&self, server_address: &str) -> DatabaseResult<()> {
self.connection().execute(
"DELETE FROM messages WHERE server_address = ?1",
params![server_address],
)?;
Ok(())
}
}