Initial commit: ReTeamSpeak cross-platform TeamSpeak client
CI/CD / Build Frontend (push) Failing after 11s
CI/CD / Test (macos-latest) (push) Has been cancelled
CI/CD / Test (windows-latest) (push) Has been cancelled
CI/CD / Build Desktop (linux) (push) Has been cancelled
CI/CD / Build Desktop (macos) (push) Has been cancelled
CI/CD / Build Desktop (windows) (push) Has been cancelled
CI/CD / Release (push) Has been cancelled
CI/CD / Test (ubuntu-latest) (push) Failing after 2s

- tscore: Protocol implementation (packets, crypto, connection handshake)
- tsaudio: Audio engine (capture, playback, codec, VAD, jitter buffer)
- tsdb: SQLite database (identities, bookmarks, messages, settings)
- shared: Core types and events
- tauri-app: Tauri v2 desktop application with React frontend
- docs: SRS, SAD, SDD documentation
- CI/CD: GitHub Actions workflow
- 32 unit tests passing
This commit is contained in:
ReTeamSpeak
2026-05-12 14:41:54 +09:00
commit ea08823c97
79 changed files with 11386 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<!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
@@ -0,0 +1,25 @@
{
"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"
}
}
+163
View File
@@ -0,0 +1,163 @@
import React, { 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;
}
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);
useEffect(() => {
loadIdentities();
loadBookmarks();
}, []);
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);
}
}
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>
<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="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>
) : (
<div className="welcome">
<h2>使 ReTeamSpeak</h2>
<p></p>
</div>
)}
</div>
</main>
</div>
);
}
export default App;
+10
View File
@@ -0,0 +1,10 @@
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>,
);
+231
View File
@@ -0,0 +1,231 @@
: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);
}
.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;
}
.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);
}
.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);
}
+21
View File
@@ -0,0 +1,21 @@
{
"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
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
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,
},
});
+34
View File
@@ -0,0 +1,34 @@
[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"]
[lib]
name = "re_teamspeak_lib"
crate-type = ["lib", "cdylib", "staticlib"]
+5
View File
@@ -0,0 +1,5 @@
use tauri_build::{build_mobile, Result};
fn main() -> Result<()> {
build_mobile()
}
@@ -0,0 +1,25 @@
{
"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"
]
}
@@ -0,0 +1,28 @@
<?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>
@@ -0,0 +1,40 @@
<?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>
+155
View File
@@ -0,0 +1,155 @@
//! Tauri 命令
use tauri::State;
use serde::{Deserialize, Serialize};
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,
}
#[tauri::command]
pub async fn get_identities(state: State<'_, AppState>) -> Result<Vec<IdentityInfo>, String> {
let identities = state.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 = "placeholder";
let identity = state.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> {
state.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 bookmarks = state.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 bookmark = state.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> {
state.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 mut conn_state = state.connection_state.lock().await;
conn_state.connected = true;
conn_state.server_address = Some(address.clone());
conn_state.server_port = Some(port);
conn_state.nickname = Some(nickname);
Ok(())
}
#[tauri::command]
pub async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
let mut conn_state = state.connection_state.lock().await;
*conn_state = crate::state::ConnectionState::new();
Ok(())
}
#[tauri::command]
pub async fn send_message(
state: State<'_, AppState>,
target: String,
message: String,
) -> Result<(), String> {
// TODO: 实现发送消息
Ok(())
}
#[tauri::command]
pub async fn get_messages(
state: State<'_, AppState>,
server_address: String,
limit: i64,
offset: i64,
) -> Result<Vec<MessageInfo>, String> {
let messages = state.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())
}
+52
View File
@@ -0,0 +1,52 @@
//! ReTeamSpeak Tauri 应用
use tauri::Manager;
mod commands;
mod state;
pub struct AppState {
pub db: tsdb::DatabaseManager,
pub connection_state: tokio::sync::Mutex<state::ConnectionState>,
}
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,
connection_state: tokio::sync::Mutex::new(state::ConnectionState::new()),
};
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::send_message,
commands::get_messages,
])
.run(tauri::generate_context!())
.expect("运行应用时出错");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
re_teamspeak_lib::run();
}
+29
View File
@@ -0,0 +1,29 @@
//! 应用状态管理
/// 连接状态
#[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()
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"$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": {
"title": "ReTeamSpeak",
"windows": [
{
"title": "ReTeamSpeak",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"center": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}