1917 lines
62 KiB
Markdown
1917 lines
62 KiB
Markdown
# Agent Audio & IME 技术设计方案
|
||
|
||
## 1. 项目背景
|
||
|
||
### 1.1 问题描述
|
||
|
||
当前 RCoder 项目的虚拟桌面(noVNC)存在以下问题:
|
||
|
||
1. **音频缺失**:远程桌面播放视频时没有声音,无法听到音频内容
|
||
2. **输入法限制**:无法使用客户端本地输入法(如搜狗输入法)输入中文到远程桌面
|
||
|
||
### 1.2 现有架构
|
||
|
||
```
|
||
外部客户端(浏览器)
|
||
↓
|
||
RCoder 主容器 (Pingora 代理 + HTTP API)
|
||
↓ 透明代理
|
||
Agent Runner 子容器
|
||
├── noVNC 服务 (端口 6080)
|
||
├── 音频流服务 (端口 6090)
|
||
└── IME 输入法服务 (端口 6091)
|
||
```
|
||
|
||
**核心要求**:
|
||
- 子容器的端口不对外暴露,所有服务通过 Pingora 代理访问
|
||
- 使用 `{user_id}` 路由到对应的 ComputerAgentRunner 容器(每个 user_id 对应一个容器)
|
||
- 使用 `{project_id}` 用于日志追踪和路径匹配
|
||
- 复用现有的 VNC 代理架构和容器 IP 解析机制
|
||
|
||
---
|
||
|
||
## 2. 技术方案设计
|
||
|
||
### 2.1 整体架构
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ 客户端浏览器 │
|
||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||
│ │ noVNC │ │ Audio │ │ IME │ │ 本地 │ │
|
||
│ │ Viewer │ │ Player │ │ Client │ │ 输入法 │ │
|
||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||
└───────┼─────────────┼─────────────┼─────────────┼──────────┘
|
||
│ │ │ │
|
||
│ WebSocket │ WebSocket │ WebSocket │
|
||
│ │ │ │
|
||
┌───────▼─────────────▼─────────────▼─────────────▼──────────┐
|
||
│ RCoder 主容器 (Pingora 代理) │
|
||
│ ┌────────────────────────────────────────────────────┐ │
|
||
│ │ Router (matchit) │ │
|
||
│ │ - /computer/vnc/{user}/{proj}/{*path} → VNC │ │
|
||
│ │ - /computer/audio/{user}/{proj}/{*path} → Audio │ │
|
||
│ │ - /computer/ime/{user}/{proj}/{*path} → IME │ │
|
||
│ └────────────────────────────────────────────────────┘ │
|
||
│ ↓ │
|
||
│ ┌────────────────────────────────────────────────────┐ │
|
||
│ │ VncBackendResolver (容器 IP 解析) │ │
|
||
│ │ - 查询 Docker 容器 IP │ │
|
||
│ │ - IP 缓存管理 │ │
|
||
│ └────────────────────────────────────────────────────┘ │
|
||
└──────────────────────┬───────────────────────────────────┘
|
||
│ 内部网络
|
||
┌──────────────────────▼───────────────────────────────────┐
|
||
│ Agent Runner 子容器 (per user/project) │
|
||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||
│ │ noVNC │ │ Audio Server │ │ IME Server │ │
|
||
│ │ :6080 │ │ :6090 │ │ :6091 │ │
|
||
│ │ WebSocket │ │ WebSocket │ │ WebSocket │ │
|
||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||
│ ↓ ↓ ↓ │
|
||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||
│ │ VNC Server │ │ PulseAudio │ │ xdotool │ │
|
||
│ │ TigerVNC │ │ pcmflux │ │ xclip │ │
|
||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||
└──────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 2.2 音频流传输方案
|
||
|
||
#### 2.2.1 容器端实现
|
||
|
||
**已实现组件**:
|
||
- `audio_server.py`:基于 pcmflux 的音频流服务
|
||
- PulseAudio 虚拟音频设备:`virtual_speaker.monitor`
|
||
- WebSocket 服务端:`ws://0.0.0.0:6089`(音频流)
|
||
- HTTP 静态服务:`http://0.0.0.0:6090`(播放器页面)
|
||
|
||
**工作原理**:
|
||
```
|
||
应用程序 (Chrome/Firefox 播放视频)
|
||
↓ 音频输出
|
||
PulseAudio 虚拟音频设备 (virtual_speaker.monitor)
|
||
↓ 音频采集
|
||
pcmflux (C++ 库)
|
||
↓ Opus 编码 (48kHz, 2 channels, 128kbps)
|
||
audio_server.py
|
||
↓ WebSocket (二进制帧: 0x01 + opus_bytes)
|
||
客户端浏览器 (Opus 解码 + Web Audio API 播放)
|
||
```
|
||
|
||
**关键配置**:
|
||
```python
|
||
# audio_server.py
|
||
HTTP_PORT = 6090 # 静态文件服务(播放器页面)
|
||
WS_PORT = 6089 # WebSocket 音频流
|
||
AUDIO_DEVICE = "virtual_speaker.monitor"
|
||
|
||
# AudioCaptureSettings
|
||
sample_rate = 48000
|
||
channels = 2
|
||
opus_bitrate = 128000
|
||
frame_duration_ms = 20
|
||
use_vbr = True
|
||
use_silence_gate = True # 跳过静音片段,节省带宽
|
||
```
|
||
|
||
#### 2.2.2 Pingora 代理层设计
|
||
|
||
**新增路由规则**:
|
||
|
||
```rust
|
||
// crates/rcoder-proxy/src/router.rs
|
||
|
||
/// 路由类型枚举 - 新增音频代理
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum RouteType {
|
||
VncProxy,
|
||
PortProxy,
|
||
HealthCheck,
|
||
ApiProxy,
|
||
/// 音频流代理: `/computer/audio/{user_id}/{project_id}/{*path}`
|
||
///
|
||
/// - `user_id`: 用户标识符
|
||
/// - `project_id`: 项目标识符
|
||
/// - `path`: 剩余路径(如 `ws`, `/` 等)
|
||
///
|
||
/// **目标**: 容器内的音频流服务
|
||
/// - HTTP (端口 6090): 静态文件(播放器页面)
|
||
/// - WebSocket (端口 6089): 音频流
|
||
///
|
||
/// **示例**:
|
||
/// - `/computer/audio/user_123/proj_456/` → 播放器页面 (HTTP)
|
||
/// - `/computer/audio/user_123/proj_456/ws` → 音频流 (WebSocket)
|
||
AudioProxy,
|
||
}
|
||
|
||
pub fn create_router() -> Result<Router<RouteType>, anyhow::Error> {
|
||
let mut router = Router::new();
|
||
|
||
// 现有路由...
|
||
|
||
// ========================================================================
|
||
// 音频流代理路由
|
||
// ========================================================================
|
||
//
|
||
// 路径格式: /computer/audio/{user_id}/{project_id}/{*path}
|
||
//
|
||
// 功能: 将 HTTP 和 WebSocket 请求代理到用户容器的音频流服务
|
||
//
|
||
// 参数:
|
||
// - user_id: 用户标识符,用于查找对应的容器 IP
|
||
// - project_id: 项目标识符
|
||
// - path: 剩余路径
|
||
// - "/" 或空: 静态播放器页面 (HTTP 6090)
|
||
// - "ws": 音频流 WebSocket (WS 6089)
|
||
//
|
||
// 示例:
|
||
// - /computer/audio/user_123/proj_456/ -> 容器IP:6090/
|
||
// - /computer/audio/user_123/proj_456/ws -> 容器IP:6089/ws (WebSocket)
|
||
//
|
||
router
|
||
.insert(
|
||
"/computer/audio/{user_id}/{project_id}/{*path}",
|
||
RouteType::AudioProxy,
|
||
)
|
||
.map_err(|e| {
|
||
tracing::error!("❌ [ROUTER] 音频代理路由插入失败: {}", e);
|
||
anyhow::anyhow!("Audio proxy route configuration error: {}", e)
|
||
})?;
|
||
|
||
Ok(router)
|
||
}
|
||
```
|
||
|
||
**代理实现**:
|
||
|
||
```rust
|
||
// crates/rcoder-proxy/src/service.rs
|
||
|
||
impl PortProxy {
|
||
/// 处理音频流代理请求
|
||
async fn handle_audio_request(
|
||
&self,
|
||
upstream_request: &mut RequestHeader,
|
||
original_uri: &http::Uri,
|
||
params: Params<'_, '_>,
|
||
ctx: &mut TrackingCtx,
|
||
) -> PingoraResult<()> {
|
||
// 从路径参数中提取 user_id 和 project_id
|
||
let user_id = params.get("user_id").ok_or_else(|| {
|
||
error!("音频路由缺少 user_id 参数");
|
||
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(400))
|
||
})?;
|
||
|
||
let project_id = params.get("project_id").ok_or_else(|| {
|
||
error!("音频路由缺少 project_id 参数");
|
||
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(400))
|
||
})?;
|
||
|
||
// 提取剩余路径(标准化空路径和尾斜杠)
|
||
let remaining_path = match params.get("path") {
|
||
Some(p) if !p.is_empty() => p,
|
||
_ => "",
|
||
};
|
||
|
||
// 判断是 WebSocket 音频流还是 HTTP 静态文件
|
||
// 规则:
|
||
// - "ws" 或 "ws/*" -> WebSocket 音频流 (端口 6089)
|
||
// - 其他所有情况(包括空路径、"/"、"index.html" 等) -> HTTP 静态文件 (端口 6090)
|
||
let (target_port, target_path) = if remaining_path == "ws" || remaining_path.starts_with("ws/") {
|
||
// WebSocket 音频流 (端口 6089)
|
||
(6089_u16, format!("/{}", remaining_path))
|
||
} else {
|
||
// HTTP 静态文件 (端口 6090),包括空路径默认为根路径
|
||
let path = if remaining_path.is_empty() { "/" } else { remaining_path };
|
||
(6090_u16, format!("/{}", path.trim_start_matches('/')))
|
||
};
|
||
|
||
// 从缓存中获取容器 IP
|
||
let container_ip = self.vnc_backends.get(user_id)
|
||
.map(|entry| entry.value().clone())
|
||
.ok_or_else(|| {
|
||
warn!(
|
||
"❌ [AUDIO] 用户容器不存在: user_id={}, project_id={}",
|
||
user_id, project_id
|
||
);
|
||
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(404))
|
||
})?;
|
||
|
||
// 记录上下文信息
|
||
ctx.target_port = Some(target_port);
|
||
ctx.upstream_host = Some(format!("{}:{}", container_ip, target_port));
|
||
|
||
info!(
|
||
"🎵 [AUDIO] 音频代理: user_id={}, project_id={}, path={}, target={}:{}",
|
||
user_id, project_id, remaining_path, container_ip, target_port
|
||
);
|
||
|
||
// 重写 URI
|
||
let new_uri = Self::rewrite_uri(original_uri, target_path)?;
|
||
upstream_request.set_uri(new_uri);
|
||
|
||
// 设置通用请求头
|
||
Self::set_common_headers(upstream_request)?;
|
||
|
||
// 对于 WebSocket 请求,保持升级头
|
||
// Pingora 会自动处理 WebSocket 升级
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 处理音频流的上游连接
|
||
async fn handle_audio_upstream(
|
||
&self,
|
||
ctx: &TrackingCtx,
|
||
params: Params<'_, '_>,
|
||
) -> PingoraResult<Box<HttpPeer>> {
|
||
let user_id = params.get("user_id").ok_or_else(|| {
|
||
error!("音频路由缺少 user_id 参数");
|
||
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(400))
|
||
})?;
|
||
|
||
let remaining_path = match params.get("path") {
|
||
Some(p) if !p.is_empty() => p,
|
||
_ => "",
|
||
};
|
||
|
||
// 判断目标端口(与 handle_audio_request 保持一致)
|
||
let target_port = if remaining_path == "ws" || remaining_path.starts_with("ws/") {
|
||
6089_u16 // WebSocket 音频流
|
||
} else {
|
||
6090_u16 // HTTP 静态文件(包括空路径)
|
||
};
|
||
|
||
// 获取容器 IP
|
||
let container_ip = self.vnc_backends.get(user_id)
|
||
.map(|entry| entry.value().clone())
|
||
.ok_or_else(|| {
|
||
warn!("❌ [AUDIO] 容器不存在: user_id={}", user_id);
|
||
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(404))
|
||
})?;
|
||
|
||
// 记录指标
|
||
self.metrics.record_request();
|
||
self.metrics.record_request_port(target_port).await;
|
||
self.metrics.inc_active();
|
||
|
||
// 创建 HTTP Peer
|
||
let peer_addr = format!("{}:{}", container_ip, target_port);
|
||
let mut peer = Box::new(HttpPeer::new(
|
||
peer_addr.clone(),
|
||
false, // 不使用 TLS
|
||
"".to_string(),
|
||
));
|
||
|
||
// 配置连接参数(音频流和 IME 长连接优化)
|
||
peer.options.connection_timeout = Some(Duration::from_secs(10));
|
||
peer.options.read_timeout = None; // 无限等待(音频流可能持续数小时)
|
||
peer.options.write_timeout = Some(Duration::from_secs(30));
|
||
peer.options.total_connection_timeout = Some(Duration::from_secs(15));
|
||
peer.options.idle_timeout = Some(Duration::from_secs(3600)); // 1 小时空闲超时
|
||
|
||
debug!(
|
||
"🎵 [AUDIO] 连接到音频后端: {} (port={})",
|
||
peer_addr, target_port
|
||
);
|
||
|
||
Ok(peer)
|
||
}
|
||
}
|
||
|
||
#[async_trait]
|
||
impl ProxyHttp for PortProxy {
|
||
// upstream_request_filter 中添加路由处理
|
||
async fn upstream_request_filter(
|
||
&self,
|
||
_session: &mut Session,
|
||
upstream_request: &mut RequestHeader,
|
||
ctx: &mut Self::CTX,
|
||
) -> PingoraResult<()> {
|
||
let original_uri = upstream_request.uri.clone();
|
||
let path = original_uri.path();
|
||
|
||
let matched = self.router.at(path).map_err(|_| {
|
||
warn!("未匹配到路由: {}", path);
|
||
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(404))
|
||
})?;
|
||
|
||
match matched.value {
|
||
RouteType::VncProxy => {
|
||
self.handle_vnc_request(upstream_request, &original_uri, matched.params, ctx).await?;
|
||
}
|
||
RouteType::AudioProxy => {
|
||
self.handle_audio_request(upstream_request, &original_uri, matched.params, ctx).await?;
|
||
}
|
||
RouteType::PortProxy => {
|
||
self.handle_port_proxy_request(upstream_request, &original_uri, matched.params).await?;
|
||
}
|
||
// ... 其他路由类型
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// upstream_peer 中添加路由处理
|
||
async fn upstream_peer(
|
||
&self,
|
||
_session: &mut Session,
|
||
ctx: &mut Self::CTX,
|
||
) -> PingoraResult<Box<HttpPeer>> {
|
||
let req_header = _session.req_header();
|
||
let path = req_header.uri.path();
|
||
|
||
let matched = self.router.at(path).map_err(|_| {
|
||
warn!("未匹配到路由: {}", path);
|
||
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(404))
|
||
})?;
|
||
|
||
match matched.value {
|
||
RouteType::VncProxy => self.handle_vnc_upstream(ctx, matched.params).await,
|
||
RouteType::AudioProxy => self.handle_audio_upstream(ctx, matched.params).await,
|
||
RouteType::PortProxy => self.handle_port_proxy_upstream(ctx, matched.params).await,
|
||
// ... 其他路由类型
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 2.2.3 客户端集成
|
||
|
||
**前端使用示例**:
|
||
|
||
```html
|
||
<!-- 音频播放器页面 -->
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>Remote Desktop Audio</title>
|
||
</head>
|
||
<body>
|
||
<h1>Remote Desktop Audio Stream</h1>
|
||
<div id="status">连接中...</div>
|
||
<button id="playBtn">开始播放</button>
|
||
|
||
<script>
|
||
const userId = 'user_123';
|
||
const projectId = 'proj_456';
|
||
|
||
// 通过 Pingora 代理连接音频流
|
||
const wsUrl = `ws://${window.location.host}/computer/audio/${userId}/${projectId}/ws`;
|
||
|
||
let audioContext;
|
||
let ws;
|
||
|
||
document.getElementById('playBtn').addEventListener('click', async () => {
|
||
// 初始化 Web Audio API
|
||
audioContext = new AudioContext({sampleRate: 48000});
|
||
|
||
// 连接 WebSocket
|
||
ws = new WebSocket(wsUrl);
|
||
ws.binaryType = 'arraybuffer';
|
||
|
||
ws.onopen = () => {
|
||
document.getElementById('status').textContent = '已连接';
|
||
};
|
||
|
||
ws.onmessage = async (event) => {
|
||
const data = new Uint8Array(event.data);
|
||
|
||
// 检查协议头 (0x01 表示 Opus 音频)
|
||
if (data[0] === 0x01) {
|
||
const opusData = data.slice(1);
|
||
|
||
// 解码 Opus (需要 opus-decoder 库)
|
||
const pcmData = await decodeOpus(opusData);
|
||
|
||
// 播放音频
|
||
const audioBuffer = audioContext.createBuffer(
|
||
2, pcmData.length / 2, 48000
|
||
);
|
||
// ... 填充 audioBuffer 并播放
|
||
}
|
||
};
|
||
|
||
ws.onerror = (err) => {
|
||
console.error('WebSocket 错误:', err);
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
document.getElementById('status').textContent = '已断开';
|
||
};
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
---
|
||
|
||
### 2.3 输入法透传方案
|
||
|
||
#### 2.3.1 容器端实现
|
||
|
||
**已实现组件**:
|
||
- `ime_server.py`:WebSocket 输入法服务
|
||
- `xdotool`:X11 自动化工具(模拟键盘输入)
|
||
- `xclip`:剪贴板工具(备用方案)
|
||
|
||
**工作原理**:
|
||
|
||
```
|
||
客户端浏览器
|
||
↓ 用户使用本地输入法输入中文
|
||
JavaScript 监听输入事件 (compositionend)
|
||
↓ WebSocket 发送 JSON: {"type": "text", "text": "你好"}
|
||
ime_server.py (WebSocket Server)
|
||
↓ 调用 xdotool type 或剪贴板粘贴
|
||
X11 服务器
|
||
↓ 将文本输入到当前焦点窗口
|
||
远程桌面应用程序接收文本
|
||
```
|
||
|
||
**关键实现**:
|
||
|
||
```python
|
||
# ime_server.py
|
||
IME_PORT = 6091
|
||
IME_HOST = '0.0.0.0'
|
||
|
||
# 消息格式
|
||
{
|
||
"type": "text", # 消息类型
|
||
"text": "你好世界", # 要输入的文本
|
||
"method": "xdotool" # 输入方法: xdotool | clipboard
|
||
}
|
||
|
||
# xdotool 输入
|
||
subprocess.run([
|
||
'xdotool', 'type',
|
||
'--clearmodifiers', # 清除修饰键
|
||
'--delay', '10', # 字符间延迟 10ms
|
||
'--', text
|
||
], env={'DISPLAY': ':0'})
|
||
|
||
# 备用方案:剪贴板粘贴
|
||
# 1. 复制到剪贴板
|
||
subprocess.Popen(['xclip', '-selection', 'clipboard'],
|
||
stdin=subprocess.PIPE).communicate(text.encode('utf-8'))
|
||
# 2. 模拟 Ctrl+V
|
||
subprocess.run(['xdotool', 'key', '--clearmodifiers', 'ctrl+v'])
|
||
```
|
||
|
||
#### 2.3.2 Pingora 代理层设计
|
||
|
||
**新增路由规则**:
|
||
|
||
```rust
|
||
// crates/rcoder-proxy/src/router.rs
|
||
|
||
pub enum RouteType {
|
||
// ... 现有类型
|
||
|
||
/// IME 输入法代理: `/computer/ime/{user_id}/{project_id}/{*path}`
|
||
///
|
||
/// - `user_id`: 用户标识符
|
||
/// - `project_id`: 项目标识符
|
||
/// - `path`: 剩余路径(通常为空或 "ws")
|
||
///
|
||
/// **目标**: 容器内的 IME 输入法服务(端口 6091, WebSocket)
|
||
///
|
||
/// **示例**:
|
||
/// - `/computer/ime/user_123/proj_456/` → WebSocket 升级
|
||
ImeProxy,
|
||
}
|
||
|
||
pub fn create_router() -> Result<Router<RouteType>, anyhow::Error> {
|
||
let mut router = Router::new();
|
||
|
||
// ... 现有路由
|
||
|
||
// ========================================================================
|
||
// IME 输入法代理路由
|
||
// ========================================================================
|
||
//
|
||
// 路径格式: /computer/ime/{user_id}/{project_id}/{*path}
|
||
//
|
||
// 功能: 将 WebSocket 请求代理到用户容器的 IME 输入法服务
|
||
//
|
||
// 参数:
|
||
// - user_id: 用户标识符,用于查找对应的容器 IP
|
||
// - project_id: 项目标识符
|
||
// - path: 剩余路径(通常为空)
|
||
//
|
||
// 示例:
|
||
// - /computer/ime/user_123/proj_456/ -> 容器IP:6091/ (WebSocket)
|
||
//
|
||
router
|
||
.insert(
|
||
"/computer/ime/{user_id}/{project_id}/{*path}",
|
||
RouteType::ImeProxy,
|
||
)
|
||
.map_err(|e| {
|
||
tracing::error!("❌ [ROUTER] IME 代理路由插入失败: {}", e);
|
||
anyhow::anyhow!("IME proxy route configuration error: {}", e)
|
||
})?;
|
||
|
||
Ok(router)
|
||
}
|
||
```
|
||
|
||
**代理实现**:
|
||
|
||
```rust
|
||
// crates/rcoder-proxy/src/service.rs
|
||
|
||
/// IME 输入法服务端口
|
||
pub const IME_PORT: u16 = 6091;
|
||
|
||
impl PortProxy {
|
||
/// 处理 IME 输入法代理请求
|
||
async fn handle_ime_request(
|
||
&self,
|
||
upstream_request: &mut RequestHeader,
|
||
original_uri: &http::Uri,
|
||
params: Params<'_, '_>,
|
||
ctx: &mut TrackingCtx,
|
||
) -> PingoraResult<()> {
|
||
let user_id = params.get("user_id").ok_or_else(|| {
|
||
error!("IME 路由缺少 user_id 参数");
|
||
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(400))
|
||
})?;
|
||
|
||
let project_id = params.get("project_id").ok_or_else(|| {
|
||
error!("IME 路由缺少 project_id 参数");
|
||
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(400))
|
||
})?;
|
||
|
||
let remaining_path = params.get("path").unwrap_or("");
|
||
let target_path = format!("/{}", remaining_path);
|
||
|
||
// 获取容器 IP
|
||
let container_ip = self.vnc_backends.get(user_id)
|
||
.map(|entry| entry.value().clone())
|
||
.ok_or_else(|| {
|
||
warn!(
|
||
"❌ [IME] 用户容器不存在: user_id={}, project_id={}",
|
||
user_id, project_id
|
||
);
|
||
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(404))
|
||
})?;
|
||
|
||
// 记录上下文
|
||
ctx.target_port = Some(IME_PORT);
|
||
ctx.upstream_host = Some(format!("{}:{}", container_ip, IME_PORT));
|
||
|
||
info!(
|
||
"⌨️ [IME] 输入法代理: user_id={}, project_id={}, path={}, target={}:{}",
|
||
user_id, project_id, remaining_path, container_ip, IME_PORT
|
||
);
|
||
|
||
// 重写 URI
|
||
let new_uri = Self::rewrite_uri(original_uri, target_path)?;
|
||
upstream_request.set_uri(new_uri);
|
||
|
||
// 设置通用请求头
|
||
Self::set_common_headers(upstream_request)?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 处理 IME 输入法的上游连接
|
||
async fn handle_ime_upstream(
|
||
&self,
|
||
ctx: &TrackingCtx,
|
||
params: Params<'_, '_>,
|
||
) -> PingoraResult<Box<HttpPeer>> {
|
||
let user_id = params.get("user_id").ok_or_else(|| {
|
||
error!("IME 路由缺少 user_id 参数");
|
||
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(400))
|
||
})?;
|
||
|
||
// 获取容器 IP
|
||
let container_ip = self.vnc_backends.get(user_id)
|
||
.map(|entry| entry.value().clone())
|
||
.ok_or_else(|| {
|
||
warn!("❌ [IME] 容器不存在: user_id={}", user_id);
|
||
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(404))
|
||
})?;
|
||
|
||
// 记录指标
|
||
self.metrics.record_request();
|
||
self.metrics.record_request_port(IME_PORT).await;
|
||
self.metrics.inc_active();
|
||
|
||
// 创建 HTTP Peer
|
||
let peer_addr = format!("{}:{}", container_ip, IME_PORT);
|
||
let mut peer = Box::new(HttpPeer::new(
|
||
peer_addr.clone(),
|
||
false, // 不使用 TLS
|
||
"".to_string(),
|
||
));
|
||
|
||
// 配置连接参数(IME 输入法长连接优化)
|
||
peer.options.connection_timeout = Some(Duration::from_secs(10));
|
||
peer.options.read_timeout = None; // 无限等待(IME 需要保持长连接)
|
||
peer.options.write_timeout = Some(Duration::from_secs(30));
|
||
peer.options.total_connection_timeout = Some(Duration::from_secs(15));
|
||
peer.options.idle_timeout = Some(Duration::from_secs(3600)); // 1 小时空闲超时
|
||
|
||
debug!("⌨️ [IME] 连接到 IME 后端: {}", peer_addr);
|
||
|
||
Ok(peer)
|
||
}
|
||
}
|
||
|
||
// 在 upstream_request_filter 和 upstream_peer 中添加 ImeProxy 分支
|
||
#[async_trait]
|
||
impl ProxyHttp for PortProxy {
|
||
async fn upstream_request_filter(
|
||
&self,
|
||
_session: &mut Session,
|
||
upstream_request: &mut RequestHeader,
|
||
ctx: &mut Self::CTX,
|
||
) -> PingoraResult<()> {
|
||
// ...
|
||
match matched.value {
|
||
RouteType::VncProxy => { /* ... */ }
|
||
RouteType::AudioProxy => { /* ... */ }
|
||
RouteType::ImeProxy => {
|
||
self.handle_ime_request(upstream_request, &original_uri, matched.params, ctx).await?;
|
||
}
|
||
// ...
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
async fn upstream_peer(
|
||
&self,
|
||
_session: &mut Session,
|
||
ctx: &mut Self::CTX,
|
||
) -> PingoraResult<Box<HttpPeer>> {
|
||
// ...
|
||
match matched.value {
|
||
RouteType::VncProxy => self.handle_vnc_upstream(ctx, matched.params).await,
|
||
RouteType::AudioProxy => self.handle_audio_upstream(ctx, matched.params).await,
|
||
RouteType::ImeProxy => self.handle_ime_upstream(ctx, matched.params).await,
|
||
// ...
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 2.3.3 客户端集成
|
||
|
||
**前端实现示例**:
|
||
|
||
```javascript
|
||
// ime_client.js - 输入法客户端封装
|
||
|
||
class ImeClient {
|
||
constructor(userId, projectId) {
|
||
this.userId = userId;
|
||
this.projectId = projectId;
|
||
this.ws = null;
|
||
this.connected = false;
|
||
}
|
||
|
||
connect() {
|
||
const wsUrl = `ws://${window.location.host}/computer/ime/${this.userId}/${this.projectId}/`;
|
||
|
||
this.ws = new WebSocket(wsUrl);
|
||
|
||
this.ws.onopen = () => {
|
||
console.log('[IME] 连接成功');
|
||
this.connected = true;
|
||
};
|
||
|
||
this.ws.onerror = (err) => {
|
||
console.error('[IME] 连接错误:', err);
|
||
this.connected = false;
|
||
};
|
||
|
||
this.ws.onclose = () => {
|
||
console.log('[IME] 连接关闭');
|
||
this.connected = false;
|
||
};
|
||
|
||
this.ws.onmessage = (event) => {
|
||
try {
|
||
const response = JSON.parse(event.data);
|
||
if (response.status !== 'ok') {
|
||
console.error('[IME] 服务器错误:', response.message);
|
||
}
|
||
} catch (e) {
|
||
console.error('[IME] 响应解析失败:', e);
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 发送文本到远程桌面
|
||
* @param {string} text - 要输入的文本
|
||
* @param {string} method - 输入方法: 'xdotool' | 'clipboard'
|
||
*/
|
||
sendText(text, method = 'xdotool') {
|
||
if (!this.connected) {
|
||
console.warn('[IME] 未连接,无法发送文本');
|
||
return;
|
||
}
|
||
|
||
const message = JSON.stringify({
|
||
type: 'text',
|
||
text: text,
|
||
method: method
|
||
});
|
||
|
||
this.ws.send(message);
|
||
}
|
||
|
||
disconnect() {
|
||
if (this.ws) {
|
||
this.ws.close();
|
||
this.ws = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 使用示例:监听 noVNC 画布的输入事件
|
||
function setupImeForNoVNC(userId, projectId) {
|
||
const imeClient = new ImeClient(userId, projectId);
|
||
imeClient.connect();
|
||
|
||
// 获取 noVNC 的画布元素
|
||
const canvas = document.querySelector('#noVNC_canvas');
|
||
|
||
// 创建隐藏的输入框用于捕获输入法输入
|
||
const inputProxy = document.createElement('input');
|
||
inputProxy.type = 'text';
|
||
inputProxy.style.position = 'absolute';
|
||
inputProxy.style.opacity = '0';
|
||
inputProxy.style.pointerEvents = 'none';
|
||
document.body.appendChild(inputProxy);
|
||
|
||
// 当用户点击画布时,聚焦到输入代理框
|
||
canvas.addEventListener('click', () => {
|
||
inputProxy.focus();
|
||
});
|
||
|
||
// 监听输入法完成事件(用户输入完成一个词组)
|
||
inputProxy.addEventListener('compositionend', (event) => {
|
||
const text = event.data;
|
||
|
||
if (text && text.length > 0) {
|
||
console.log('[IME] 输入完成:', text);
|
||
imeClient.sendText(text);
|
||
|
||
// 清空输入框
|
||
inputProxy.value = '';
|
||
}
|
||
});
|
||
|
||
// 监听普通按键(非输入法输入)
|
||
inputProxy.addEventListener('keydown', (event) => {
|
||
// 对于特殊键(如回车、退格),直接发送到 noVNC
|
||
if (event.key === 'Enter' || event.key === 'Backspace') {
|
||
// 让 noVNC 处理这些特殊键
|
||
event.preventDefault();
|
||
// 这里需要调用 noVNC 的键盘事件处理
|
||
}
|
||
});
|
||
|
||
return imeClient;
|
||
}
|
||
|
||
// 初始化
|
||
const imeClient = setupImeForNoVNC('user_123', 'proj_456');
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 容器 IP 解析复用
|
||
|
||
### 3.1 现有机制
|
||
|
||
RCoder 已经为 VNC 代理实现了容器 IP 解析机制:
|
||
|
||
```rust
|
||
// crates/rcoder-proxy/src/vnc_resolver.rs
|
||
|
||
pub trait VncBackendResolver: Send + Sync {
|
||
/// 根据 user_id 解析容器 IP
|
||
async fn resolve(&self, user_id: &str) -> Result<VncBackendInfo, VncResolveError>;
|
||
|
||
/// 检查容器是否存在
|
||
async fn exists(&self, user_id: &str) -> bool;
|
||
}
|
||
|
||
pub struct VncBackendInfo {
|
||
pub container_ip: String,
|
||
pub vnc_port: u16,
|
||
pub is_running: bool,
|
||
}
|
||
```
|
||
|
||
### 3.2 复用策略
|
||
|
||
音频和 IME 代理**直接复用** VNC 的容器 IP 解析机制:
|
||
|
||
```rust
|
||
// crates/rcoder-proxy/src/service.rs
|
||
|
||
pub struct PortProxy {
|
||
// ...
|
||
/// VNC 后端映射: user_id -> container_ip
|
||
/// 这个映射同时用于 VNC、Audio 和 IME 代理
|
||
vnc_backends: Arc<DashMap<String, String>>,
|
||
}
|
||
|
||
impl PortProxy {
|
||
// 音频和 IME 代理使用相同的容器 IP 查询
|
||
async fn handle_audio_request(&self, ...) -> PingoraResult<()> {
|
||
let container_ip = self.vnc_backends.get(user_id)
|
||
.map(|entry| entry.value().clone())
|
||
.ok_or_else(|| /* 容器不存在 */)?;
|
||
|
||
// 使用 container_ip 连接到音频端口 6089/6090
|
||
}
|
||
|
||
async fn handle_ime_request(&self, ...) -> PingoraResult<()> {
|
||
let container_ip = self.vnc_backends.get(user_id)
|
||
.map(|entry| entry.value().clone())
|
||
.ok_or_else(|| /* 容器不存在 */)?;
|
||
|
||
// 使用 container_ip 连接到 IME 端口 6091
|
||
}
|
||
}
|
||
```
|
||
|
||
**优势**:
|
||
- 无需额外的 IP 解析逻辑
|
||
- 保持与 VNC 代理的一致性
|
||
- 利用现有的 IP 缓存机制(`DashMap`)
|
||
|
||
### 3.3 容器 IP 更新机制
|
||
|
||
当容器重启或 IP 变化时,需要更新缓存:
|
||
|
||
```rust
|
||
// crates/rcoder/src/api/computer_agent.rs
|
||
|
||
impl PingoraProxyService {
|
||
/// 注册或更新容器的后端 IP(VNC、Audio、IME 共享)
|
||
pub async fn register_vnc_backend(&self, user_id: String, container_ip: String) {
|
||
self.vnc_backends.insert(user_id.clone(), container_ip.clone());
|
||
tracing::info!(
|
||
"✅ [PROXY] 注册容器后端: user_id={}, container_ip={}",
|
||
user_id, container_ip
|
||
);
|
||
}
|
||
|
||
/// 移除容器的后端 IP
|
||
pub async fn unregister_vnc_backend(&self, user_id: &str) {
|
||
self.vnc_backends.remove(user_id);
|
||
tracing::info!("🗑️ [PROXY] 移除容器后端: user_id={}", user_id);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 实现步骤
|
||
|
||
### 4.1 Phase 1: 路由层实现
|
||
|
||
#### 任务清单
|
||
|
||
- [ ] **修改 `router.rs`**
|
||
- [ ] 在 `RouteType` 枚举中添加 `AudioProxy` 和 `ImeProxy`
|
||
- [ ] 在 `create_router()` 中注册音频路由:`/computer/audio/{user_id}/{project_id}/{*path}`
|
||
- [ ] 在 `create_router()` 中注册 IME 路由:`/computer/ime/{user_id}/{project_id}/{*path}`
|
||
- [ ] 更新 `get_routes_documentation()` 添加新路由文档
|
||
|
||
- [ ] **编写单元测试**
|
||
- [ ] 测试音频路由匹配:`test_audio_route_matching()`
|
||
- [ ] 测试 IME 路由匹配:`test_ime_route_matching()`
|
||
- [ ] 测试路由参数提取:`test_audio_ime_parameter_extraction()`
|
||
|
||
### 4.2 Phase 2: 代理逻辑实现
|
||
|
||
#### 任务清单
|
||
|
||
- [ ] **修改 `service.rs` - 音频代理**
|
||
- [ ] 实现 `handle_audio_request()` 方法
|
||
- [ ] 实现 `handle_audio_upstream()` 方法
|
||
- [ ] 在 `upstream_request_filter()` 中添加 `RouteType::AudioProxy` 分支
|
||
- [ ] 在 `upstream_peer()` 中添加 `RouteType::AudioProxy` 分支
|
||
- [ ] 添加音频代理的日志和指标记录
|
||
|
||
- [ ] **修改 `service.rs` - IME 代理**
|
||
- [ ] 实现 `handle_ime_request()` 方法
|
||
- [ ] 实现 `handle_ime_upstream()` 方法
|
||
- [ ] 在 `upstream_request_filter()` 中添加 `RouteType::ImeProxy` 分支
|
||
- [ ] 在 `upstream_peer()` 中添加 `RouteType::ImeProxy` 分支
|
||
- [ ] 添加 IME 代理的日志和指标记录
|
||
|
||
- [ ] **添加常量定义**
|
||
- [ ] 定义 `AUDIO_HTTP_PORT = 6090`
|
||
- [ ] 定义 `AUDIO_WS_PORT = 6089`
|
||
- [ ] 定义 `IME_PORT = 6091`
|
||
|
||
### 4.3 Phase 3: 集成测试
|
||
|
||
#### 任务清单
|
||
|
||
- [ ] **容器端测试**
|
||
- [ ] 验证 `audio_server.py` 在容器内正常运行
|
||
- [ ] 验证 `ime_server.py` 在容器内正常运行
|
||
- [ ] 测试容器内服务端口监听状态(6089, 6090, 6091)
|
||
|
||
- [ ] **代理层测试**
|
||
- [ ] 测试音频 HTTP 请求代理:`curl http://localhost:8087/computer/audio/user_123/proj_456/`
|
||
- [ ] 测试音频 WebSocket 代理:`wscat -c ws://localhost:8087/computer/audio/user_123/proj_456/ws`
|
||
- [ ] 测试 IME WebSocket 代理:`wscat -c ws://localhost:8087/computer/ime/user_123/proj_456/`
|
||
- [ ] 验证容器 IP 解析正确性
|
||
- [ ] 验证 WebSocket 升级成功
|
||
|
||
- [ ] **端到端测试**
|
||
- [ ] 在浏览器中播放远程桌面音频
|
||
- [ ] 验证音频实时性(延迟 < 500ms)
|
||
- [ ] 在浏览器中使用本地输入法输入中文
|
||
- [ ] 验证中文正确输入到远程桌面应用
|
||
|
||
### 4.4 Phase 4: 文档和优化
|
||
|
||
#### 任务清单
|
||
|
||
- [ ] **更新文档**
|
||
- [ ] 更新 `CLAUDE.md` 添加音频和 IME 架构说明
|
||
- [ ] 更新 API 文档说明新的代理路由
|
||
- [ ] 编写客户端集成示例代码
|
||
|
||
- [ ] **性能优化**
|
||
- [ ] 音频流延迟优化(目标 < 200ms)
|
||
- [ ] 连接池优化(复用 WebSocket 连接)
|
||
- [ ] 容器 IP 缓存策略优化
|
||
|
||
- [ ] **错误处理**
|
||
- [ ] 添加容器不存在的友好错误提示
|
||
- [ ] 添加 WebSocket 断开自动重连机制
|
||
- [ ] 添加音频服务不可用的降级处理
|
||
|
||
---
|
||
|
||
## 5. 关键技术细节
|
||
|
||
### 5.1 WebSocket 升级处理
|
||
|
||
Pingora 自动处理 WebSocket 升级,无需手动处理 `Upgrade` 和 `Connection` 头:
|
||
|
||
```rust
|
||
// Pingora 会自动识别 WebSocket 升级请求
|
||
// 只需确保正确设置上游连接参数
|
||
|
||
peer.options.read_timeout = Some(Duration::from_secs(300)); // 长连接
|
||
peer.options.write_timeout = Some(Duration::from_secs(30));
|
||
```
|
||
|
||
### 5.2 音频流协议设计
|
||
|
||
**协议格式**:
|
||
|
||
```
|
||
帧头: 1 byte
|
||
- 0x01: Opus 音频帧
|
||
- 0x02: 控制消息(预留)
|
||
- 0x03: 心跳消息(预留)
|
||
|
||
帧体: N bytes
|
||
- Opus 编码的音频数据(PCM -> Opus, 48kHz, 2 channels)
|
||
```
|
||
|
||
**优势**:
|
||
- 简单高效,无需复杂解析
|
||
- 二进制传输,带宽占用小
|
||
- Opus 编码,延迟低(20ms 帧)
|
||
|
||
### 5.3 输入法透传安全性
|
||
|
||
**潜在风险**:
|
||
- 恶意客户端可能发送恶意命令注入
|
||
- 特殊字符可能导致 X11 系统异常
|
||
- 超长文本可能导致内存溢出或拒绝服务
|
||
|
||
**防护措施**:
|
||
```python
|
||
# ime_server.py
|
||
|
||
import re
|
||
|
||
def sanitize_text(text: str) -> str:
|
||
"""清理文本,防止命令注入和异常字符"""
|
||
# 1. 长度限制(从 10000 降低到 1000)
|
||
if len(text) > 1000:
|
||
raise ValueError("Text too long (max 1000 chars)")
|
||
|
||
# 2. 过滤危险控制字符(保留合法的换行和制表符)
|
||
dangerous_chars = ['\x00', '\x1b'] # NULL 字符和 ESC 字符
|
||
if any(c in text for c in dangerous_chars):
|
||
raise ValueError("Text contains dangerous control characters")
|
||
|
||
# 3. 可选:只允许可打印字符和常见空白字符
|
||
# if not all(c.isprintable() or c in '\n\r\t' for c in text):
|
||
# raise ValueError("Text contains non-printable characters")
|
||
|
||
# 4. 可选:限制为 Unicode 基本多文种平面(防止异常字符)
|
||
# if any(ord(c) > 0xFFFF for c in text):
|
||
# raise ValueError("Text contains characters outside BMP")
|
||
|
||
return text
|
||
|
||
# 使用 '--' 参数分隔符,防止参数注入
|
||
subprocess.run([
|
||
'xdotool', 'type',
|
||
'--clearmodifiers', # 清除修饰键
|
||
'--delay', '10', # 字符间延迟 10ms
|
||
'--', sanitize_text(text) # 使用 '--' 防止参数注入
|
||
], env={'DISPLAY': ':0'})
|
||
```
|
||
|
||
**安全检查清单**:
|
||
- ✅ 长度限制(1000 字符)
|
||
- ✅ 危险字符过滤(NULL, ESC)
|
||
- ✅ 使用 `--` 参数分隔符
|
||
- ✅ xdotool 使用 `--clearmodifiers` 清除修饰键
|
||
- ✅ 可选:只允许可打印字符
|
||
- ✅ 可选:Unicode 范围限制
|
||
|
||
### 5.4 容器端口映射策略
|
||
|
||
**不对外暴露端口**:
|
||
|
||
```yaml
|
||
# docker-compose.yml (正确做法)
|
||
services:
|
||
agent-runner:
|
||
image: rcoder-agent-runner:latest
|
||
networks:
|
||
- agent-network
|
||
# ❌ 不暴露端口到宿主机
|
||
# ports:
|
||
# - "6080:6080"
|
||
# - "6089:6089"
|
||
# - "6090:6090"
|
||
# - "6091:6091"
|
||
|
||
networks:
|
||
agent-network:
|
||
driver: bridge
|
||
```
|
||
|
||
**通过内部网络访问**:
|
||
|
||
```rust
|
||
// 容器间通过内部 IP 直接通信
|
||
let container_ip = "172.18.0.5"; // Docker 分配的内部 IP
|
||
let audio_ws_addr = format!("{}:6089", container_ip);
|
||
let audio_http_addr = format!("{}:6090", container_ip);
|
||
let ime_addr = format!("{}:6091", container_ip);
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 性能指标
|
||
|
||
### 6.1 音频流性能目标
|
||
|
||
| 指标 | 目标值 | 说明 |
|
||
|------|--------|------|
|
||
| 音频延迟 | < 200ms | 从音频产生到浏览器播放的端到端延迟 |
|
||
| 带宽占用 | ~128 Kbps | Opus 编码,48kHz, 2 channels |
|
||
| CPU 占用 | < 5% | 容器内 audio_server.py 的 CPU 使用率 |
|
||
| 并发连接 | 100+ | 单个 Pingora 实例支持的音频流连接数 |
|
||
|
||
### 6.2 IME 输入性能目标
|
||
|
||
| 指标 | 目标值 | 说明 |
|
||
|------|--------|------|
|
||
| 输入延迟 | < 100ms | 从客户端发送到远程桌面显示的延迟 |
|
||
| 吞吐量 | 1000+ 字符/秒 | 输入法透传的最大吞吐量 |
|
||
| 可靠性 | 99.9% | 文本传输成功率 |
|
||
|
||
---
|
||
|
||
## 7. 风险评估
|
||
|
||
### 7.1 技术风险
|
||
|
||
| 风险 | 影响 | 缓解措施 |
|
||
|------|------|----------|
|
||
| WebSocket 连接不稳定 | 音频/输入中断 | 实现自动重连机制 + 心跳检测 |
|
||
| 音频延迟过高 | 用户体验差 | 优化编码参数 + 网络 QoS |
|
||
| 容器 IP 变化 | 代理失败 | 实现 IP 缓存自动更新机制 |
|
||
| xdotool 输入失败 | 中文输入失效 | 提供剪贴板粘贴备用方案 |
|
||
|
||
### 7.2 安全风险
|
||
|
||
| 风险 | 影响 | 缓解措施 |
|
||
|------|------|----------|
|
||
| 命令注入攻击 | 容器被攻击 | 使用 `--` 参数分隔符 + 输入验证 |
|
||
| 未授权访问 | 数据泄露 | 基于 `user_id` 的访问控制 |
|
||
| WebSocket 劫持 | 会话劫持 | 使用 HTTPS + 会话令牌验证 |
|
||
|
||
---
|
||
|
||
## 8. 未来优化方向
|
||
|
||
### 8.1 音频质量优化
|
||
|
||
- **自适应比特率**:根据网络状况动态调整 Opus 比特率
|
||
- **多声道支持**:支持 5.1 环绕声
|
||
- **音频增强**:噪音抑制、回声消除
|
||
|
||
### 8.2 输入法增强
|
||
|
||
- **双向同步**:支持远程桌面的输入状态同步到客户端
|
||
- **快捷键透传**:支持 Ctrl+C、Ctrl+V 等快捷键
|
||
- **富文本支持**:支持输入带格式的文本
|
||
|
||
### 8.3 架构优化
|
||
|
||
- **端口统一**:将 VNC、Audio、IME 合并到单个 WebSocket 连接(多路复用)
|
||
- **P2P 模式**:客户端与容器直接建立 WebRTC 连接,降低延迟
|
||
- **边缘节点**:部署边缘 Pingora 节点,降低网络延迟
|
||
|
||
---
|
||
|
||
## 9. 总结
|
||
|
||
本技术方案通过以下方式解决了虚拟桌面的音频和输入法问题:
|
||
|
||
### 9.1 核心特性
|
||
|
||
✅ **音频流传输**
|
||
- 基于 pcmflux + Opus 编码的实时音频流
|
||
- 通过 Pingora 代理实现透明转发
|
||
- 低延迟(< 200ms)、低带宽(128 Kbps)
|
||
|
||
✅ **输入法透传**
|
||
- 客户端使用本地输入法输入中文
|
||
- 通过 WebSocket 发送到容器
|
||
- 使用 xdotool 注入到远程桌面
|
||
|
||
✅ **架构一致性**
|
||
- 复用 VNC 代理的容器 IP 解析机制
|
||
- 统一的路由规则和代理逻辑
|
||
- 完全隐藏子容器端口
|
||
|
||
### 9.2 实现路径
|
||
|
||
1. **路由层**:在 `router.rs` 中添加 `AudioProxy` 和 `ImeProxy` 路由
|
||
2. **代理层**:在 `service.rs` 中实现音频和 IME 的代理逻辑
|
||
3. **容器端**:确保 `audio_server.py` 和 `ime_server.py` 正常运行
|
||
4. **客户端**:集成音频播放器和输入法客户端
|
||
|
||
### 9.3 关键优势
|
||
|
||
- **零端口暴露**:所有服务通过 Pingora 内部路由访问
|
||
- **高性能**:Opus 编码 + WebSocket 长连接
|
||
- **低延迟**:音频 < 200ms,输入 < 100ms
|
||
- **易维护**:复用现有架构,代码改动最小
|
||
|
||
---
|
||
|
||
## 10. 测试页面实现
|
||
|
||
### 10.1 完整测试页面
|
||
|
||
为了方便测试 VNC、音频流和输入法透传功能,我们提供了一个集成测试页面:`docker/vnc-test.html`
|
||
|
||
**功能特性**:
|
||
1. **VNC 连接管理**:支持 RCoder 代理模式和直接端口模式
|
||
2. **音频流播放**:实时音频流接收和播放,支持音量控制
|
||
3. **输入法透传**:使用本地输入法输入中文到远程桌面
|
||
4. **可视化反馈**:连接状态显示、音频可视化、IME 状态提示
|
||
|
||
### 10.2 页面结构
|
||
|
||
```html
|
||
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<title>VNC + 音频 + 输入法 测试页面</title>
|
||
<style>
|
||
/* 样式包含:
|
||
* - 响应式布局
|
||
* - 状态指示器(连接/断开/连接中)
|
||
* - 音频可视化动画
|
||
* - 输入法状态提示
|
||
*/
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- 1. 配置区域 -->
|
||
<div class="header">
|
||
<h1>🔌 VNC + 🎵 音频 + ⌨️ 输入法 测试</h1>
|
||
|
||
<!-- 模式切换:代理模式 / 直接端口模式 -->
|
||
<div class="mode-toggle">...</div>
|
||
|
||
<!-- RCoder 代理模式配置 -->
|
||
<div class="config-section proxy-mode">
|
||
<input id="baseUrlInput" placeholder="http://127.0.0.1:8088" />
|
||
<input id="userIdInput" placeholder="user_123" />
|
||
<input id="projectIdInput" placeholder="project_id" />
|
||
</div>
|
||
|
||
<!-- 直接端口模式配置 -->
|
||
<div class="config-section direct-mode">
|
||
<input id="portInput" placeholder="VNC 端口 (6080)" />
|
||
<input id="audioPortInput" placeholder="音频端口 (6089)" />
|
||
<input id="imePortInput" placeholder="IME 端口 (6091)" />
|
||
</div>
|
||
|
||
<!-- VNC 连接控制 -->
|
||
<div class="controls">
|
||
<button onclick="connectVNC()">连接 VNC</button>
|
||
<button onclick="disconnectVNC()">断开 VNC</button>
|
||
<div class="status" id="vncStatus">VNC 未连接</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 2. 音频控制面板 -->
|
||
<div class="feature-panel">
|
||
<h3>🎵 音频流</h3>
|
||
<button onclick="toggleAudio()">启动音频</button>
|
||
<input type="range" id="volumeSlider" min="0" max="100" value="80" />
|
||
<div class="audio-visualizer">
|
||
<!-- 音频可视化条 -->
|
||
</div>
|
||
<div class="status" id="audioStatus">音频未连接</div>
|
||
</div>
|
||
|
||
<!-- 3. 输入法控制面板 -->
|
||
<div class="feature-panel">
|
||
<h3>⌨️ 输入法透传</h3>
|
||
<button onclick="toggleIME()">启动输入法</button>
|
||
<div class="ime-status">未激活</div>
|
||
</div>
|
||
|
||
<!-- 4. VNC 显示区域 -->
|
||
<div class="vnc-container">
|
||
<iframe id="vncFrame"></iframe>
|
||
</div>
|
||
|
||
<!-- 5. 隐藏的输入代理框(用于捕获输入法输入) -->
|
||
<input type="text" id="imeInput" class="ime-input" />
|
||
|
||
<!-- 引入 Opus 解码器库 -->
|
||
<script src="https://cdn.jsdelivr.net/npm/opus-decoder@0.7.9/build/opus-decoder.min.js"></script>
|
||
|
||
<script>
|
||
// JavaScript 逻辑(详见下文)
|
||
</script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
**Opus 解码器安装选项**:
|
||
|
||
1. **CDN 引入**(推荐,用于快速测试):
|
||
```html
|
||
<script src="https://cdn.jsdelivr.net/npm/opus-decoder@0.7.9/build/opus-decoder.min.js"></script>
|
||
```
|
||
|
||
2. **NPM 安装**(用于生产环境):
|
||
```bash
|
||
npm install opus-decoder
|
||
```
|
||
```javascript
|
||
import OpusDecoder from 'opus-decoder';
|
||
```
|
||
|
||
3. **本地文件**(离线环境):
|
||
```bash
|
||
npm install opus-decoder
|
||
cp node_modules/opus-decoder/build/opus-decoder.min.js ./public/
|
||
```
|
||
```html
|
||
<script src="/opus-decoder.min.js"></script>
|
||
```
|
||
|
||
### 10.3 核心 JavaScript 实现
|
||
|
||
#### 10.3.1 音频流连接
|
||
|
||
```javascript
|
||
// 全局状态
|
||
let audioContext = null;
|
||
let audioWs = null;
|
||
let audioConnected = false;
|
||
let audioGainNode = null;
|
||
|
||
// 构建音频 WebSocket URL
|
||
function buildAudioWsUrl() {
|
||
const mode = document.querySelector('input[name="connectionMode"]:checked').value;
|
||
|
||
if (mode === "proxy") {
|
||
// RCoder 代理模式
|
||
const baseUrl = document.getElementById("baseUrlInput").value.trim()
|
||
.replace(/^http/, "ws").replace(/\/+$/, "");
|
||
const userId = document.getElementById("userIdInput").value.trim();
|
||
const projectId = document.getElementById("projectIdInput").value.trim();
|
||
|
||
return `${baseUrl}/computer/audio/${userId}/${projectId}/ws`;
|
||
} else {
|
||
// 直接端口模式
|
||
const port = document.getElementById("audioPortInput").value.trim();
|
||
return `ws://localhost:${port}/ws`;
|
||
}
|
||
}
|
||
|
||
// 连接音频流
|
||
async function connectAudio() {
|
||
const wsUrl = buildAudioWsUrl();
|
||
if (!wsUrl) return;
|
||
|
||
try {
|
||
// 初始化 Web Audio API
|
||
if (!audioContext) {
|
||
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||
sampleRate: 48000
|
||
});
|
||
audioGainNode = audioContext.createGain();
|
||
audioGainNode.gain.value = 0.8;
|
||
audioGainNode.connect(audioContext.destination);
|
||
}
|
||
|
||
// 恢复 AudioContext(浏览器安全策略要求用户交互)
|
||
if (audioContext.state === "suspended") {
|
||
await audioContext.resume();
|
||
}
|
||
|
||
// 连接 WebSocket
|
||
audioWs = new WebSocket(wsUrl);
|
||
audioWs.binaryType = "arraybuffer";
|
||
|
||
audioWs.onopen = () => {
|
||
console.log("[Audio] WebSocket 已连接");
|
||
audioConnected = true;
|
||
updateAudioStatus("connected", "音频已连接");
|
||
};
|
||
|
||
audioWs.onmessage = async (event) => {
|
||
try {
|
||
const data = new Uint8Array(event.data);
|
||
|
||
// 检查协议头 (0x01 表示 Opus 音频)
|
||
if (data[0] === 0x01) {
|
||
const opusData = data.slice(1);
|
||
// 这里需要 Opus 解码器(如 opus-decoder.js)
|
||
await playAudioChunk(opusData);
|
||
}
|
||
} catch (err) {
|
||
console.error("[Audio] 处理音频数据失败:", err);
|
||
}
|
||
};
|
||
|
||
audioWs.onerror = (err) => {
|
||
console.error("[Audio] WebSocket 错误:", err);
|
||
updateAudioStatus("disconnected", "音频连接失败");
|
||
};
|
||
|
||
audioWs.onclose = () => {
|
||
console.log("[Audio] WebSocket 已关闭");
|
||
audioConnected = false;
|
||
updateAudioStatus("disconnected", "音频已断开");
|
||
};
|
||
} catch (err) {
|
||
console.error("[Audio] 连接失败:", err);
|
||
alert("音频启动失败: " + err.message);
|
||
}
|
||
}
|
||
|
||
// 播放音频块(完整的 Opus 解码实现)
|
||
async function playAudioChunk(opusData) {
|
||
try {
|
||
// 1. 解码 Opus -> PCM (Float32Array, 48kHz, 立体声)
|
||
// 使用 opus-decoder.js: https://github.com/Rillke/opus-decoder
|
||
const decoder = new OpusDecoder({
|
||
rate: 48000,
|
||
channels: 2
|
||
});
|
||
const pcmData = decoder.decode(opusData);
|
||
|
||
// 2. 创建 AudioBuffer (48kHz, 2 channels)
|
||
const samplesPerChannel = pcmData.length / 2; // 立体声 = 2 channels
|
||
const audioBuffer = audioContext.createBuffer(
|
||
2, // 2 channels (立体声)
|
||
samplesPerChannel,
|
||
48000 // 48kHz 采样率
|
||
);
|
||
|
||
// 3. 填充 AudioBuffer (分离左右声道)
|
||
const leftChannel = audioBuffer.getChannelData(0);
|
||
const rightChannel = audioBuffer.getChannelData(1);
|
||
for (let i = 0; i < samplesPerChannel; i++) {
|
||
leftChannel[i] = pcmData[i * 2];
|
||
rightChannel[i] = pcmData[i * 2 + 1];
|
||
}
|
||
|
||
// 4. 播放音频
|
||
const source = audioContext.createBufferSource();
|
||
source.buffer = audioBuffer;
|
||
source.connect(audioGainNode);
|
||
source.start();
|
||
|
||
// 可选:可视化音频
|
||
updateAudioVisualizer(pcmData);
|
||
|
||
} catch (err) {
|
||
console.error("[Audio] 解码或播放失败:", err);
|
||
}
|
||
}
|
||
|
||
// 音频可视化更新(示例)
|
||
function updateAudioVisualizer(pcmData) {
|
||
const visualizer = document.getElementById('audioVisualizer');
|
||
if (!visualizer || visualizer.style.display === 'none') return;
|
||
|
||
// 计算音量(RMS)
|
||
let sum = 0;
|
||
for (let i = 0; i < pcmData.length; i++) {
|
||
sum += pcmData[i] * pcmData[i];
|
||
}
|
||
const rms = Math.sqrt(sum / pcmData.length);
|
||
|
||
// 更新可视化条高度(根据音量动态调整)
|
||
const bars = visualizer.querySelectorAll('span');
|
||
bars.forEach((bar, index) => {
|
||
const height = 5 + (rms * 20 * (1 + index * 0.2));
|
||
bar.style.height = `${Math.min(height, 20)}px`;
|
||
});
|
||
}
|
||
|
||
// 设置音量
|
||
function setVolume(value) {
|
||
if (audioGainNode) {
|
||
audioGainNode.gain.value = value / 100;
|
||
}
|
||
document.getElementById("volumeValue").textContent = value + "%";
|
||
}
|
||
```
|
||
|
||
#### 10.3.2 输入法透传实现
|
||
|
||
```javascript
|
||
// 全局状态
|
||
let imeWs = null;
|
||
let imeConnected = false;
|
||
let imeEnabled = false;
|
||
|
||
// 构建 IME WebSocket URL
|
||
function buildIMEWsUrl() {
|
||
const mode = document.querySelector('input[name="connectionMode"]:checked').value;
|
||
|
||
if (mode === "proxy") {
|
||
const baseUrl = document.getElementById("baseUrlInput").value.trim()
|
||
.replace(/^http/, "ws").replace(/\/+$/, "");
|
||
const userId = document.getElementById("userIdInput").value.trim();
|
||
const projectId = document.getElementById("projectIdInput").value.trim();
|
||
|
||
return `${baseUrl}/computer/ime/${userId}/${projectId}/`;
|
||
} else {
|
||
const port = document.getElementById("imePortInput").value.trim();
|
||
return `ws://localhost:${port}/`;
|
||
}
|
||
}
|
||
|
||
// 连接 IME 服务
|
||
function connectIME() {
|
||
const wsUrl = buildIMEWsUrl();
|
||
if (!wsUrl) return;
|
||
|
||
try {
|
||
console.log("连接 IME 服务:", wsUrl);
|
||
|
||
// 连接 WebSocket
|
||
imeWs = new WebSocket(wsUrl);
|
||
|
||
imeWs.onopen = () => {
|
||
console.log("[IME] WebSocket 已连接");
|
||
imeConnected = true;
|
||
imeEnabled = true;
|
||
|
||
// 更新 UI
|
||
document.getElementById("imeBtn").textContent = "停止输入法";
|
||
document.getElementById("imeStatus").textContent =
|
||
"已激活 - 点击 VNC 区域开始输入";
|
||
|
||
// 设置输入事件监听
|
||
setupIMEListeners();
|
||
};
|
||
|
||
imeWs.onmessage = (event) => {
|
||
try {
|
||
const response = JSON.parse(event.data);
|
||
if (response.status === "ok") {
|
||
console.log("[IME] 文本发送成功");
|
||
} else {
|
||
console.error("[IME] 服务器错误:", response.message);
|
||
}
|
||
} catch (err) {
|
||
console.error("[IME] 响应解析失败:", err);
|
||
}
|
||
};
|
||
|
||
imeWs.onerror = (err) => {
|
||
console.error("[IME] WebSocket 错误:", err);
|
||
alert("IME 连接失败,请检查服务是否运行");
|
||
};
|
||
|
||
imeWs.onclose = () => {
|
||
console.log("[IME] WebSocket 已关闭");
|
||
imeConnected = false;
|
||
imeEnabled = false;
|
||
document.getElementById("imeBtn").textContent = "启动输入法";
|
||
};
|
||
} catch (err) {
|
||
console.error("[IME] 连接失败:", err);
|
||
alert("IME 启动失败: " + err.message);
|
||
}
|
||
}
|
||
|
||
// 设置输入法监听器
|
||
function setupIMEListeners() {
|
||
const vncContainer = document.getElementById("vncContainer");
|
||
const imeInput = document.getElementById("imeInput");
|
||
|
||
// 点击 VNC 区域时聚焦到隐藏输入框
|
||
vncContainer.addEventListener("click", () => {
|
||
if (imeEnabled) {
|
||
imeInput.focus();
|
||
console.log("[IME] 输入框已聚焦");
|
||
}
|
||
});
|
||
|
||
// 监听输入法完成事件
|
||
imeInput.addEventListener("compositionend", (event) => {
|
||
const text = event.data;
|
||
|
||
if (text && text.length > 0 && imeConnected) {
|
||
console.log("[IME] 输入完成:", text);
|
||
sendTextToRemote(text);
|
||
|
||
// 清空输入框
|
||
imeInput.value = "";
|
||
}
|
||
});
|
||
}
|
||
|
||
// 发送文本到远程桌面
|
||
function sendTextToRemote(text) {
|
||
if (!imeConnected || !imeWs) {
|
||
console.warn("[IME] 未连接,无法发送文本");
|
||
return;
|
||
}
|
||
|
||
const message = JSON.stringify({
|
||
type: "text",
|
||
text: text,
|
||
method: "xdotool" // 或 "clipboard"
|
||
});
|
||
|
||
imeWs.send(message);
|
||
console.log("[IME] 发送文本:", text);
|
||
}
|
||
```
|
||
|
||
### 10.4 使用说明
|
||
|
||
#### 10.4.1 RCoder 代理模式
|
||
|
||
1. **配置连接参数**:
|
||
- RCoder 服务地址:`http://127.0.0.1:8088`
|
||
- User ID:`user_123`
|
||
- Project ID:从聊天响应中获取
|
||
|
||
2. **连接 VNC**:
|
||
- 点击"连接 VNC"按钮
|
||
- 等待 iframe 加载完成
|
||
|
||
3. **启动音频**:
|
||
- 点击"启动音频"按钮
|
||
- 在远程桌面播放视频/音乐
|
||
- 调节音量滑块控制音量
|
||
|
||
4. **启动输入法**:
|
||
- 点击"启动输入法"按钮
|
||
- 点击 VNC 画面区域获得焦点
|
||
- 使用本地输入法输入中文
|
||
- 输入完成后自动发送到远程桌面
|
||
|
||
#### 10.4.2 直接端口模式
|
||
|
||
适用于容器端口直接映射到宿主机的场景:
|
||
|
||
1. **查找端口映射**:
|
||
```bash
|
||
docker port <container_name>
|
||
# 输出示例:
|
||
# 6080/tcp -> 0.0.0.0:50001
|
||
# 6089/tcp -> 0.0.0.0:50002
|
||
# 6091/tcp -> 0.0.0.0:50003
|
||
```
|
||
|
||
2. **配置端口**:
|
||
- VNC 端口:`50001`
|
||
- 音频 WebSocket 端口:`50002`
|
||
- IME 端口:`50003`
|
||
|
||
3. **连接和使用**:同 RCoder 代理模式
|
||
|
||
### 10.5 故障排查
|
||
|
||
#### 10.5.1 音频无声
|
||
|
||
**问题**:音频流连接成功但听不到声音
|
||
|
||
**排查步骤**:
|
||
1. 检查浏览器控制台是否有错误
|
||
2. 确认 AudioContext 已恢复(`audioContext.state === "running"`)
|
||
3. 检查音量设置(默认 80%)
|
||
4. 确认容器内 PulseAudio 服务运行正常
|
||
5. 确认 `audio_server.py` 正在采集音频
|
||
|
||
**解决方案**:
|
||
```javascript
|
||
// 手动恢复 AudioContext
|
||
if (audioContext.state === "suspended") {
|
||
audioContext.resume().then(() => {
|
||
console.log("AudioContext 已恢复");
|
||
});
|
||
}
|
||
```
|
||
|
||
#### 10.5.2 输入法无响应
|
||
|
||
**问题**:输入中文后不显示在远程桌面
|
||
|
||
**排查步骤**:
|
||
1. 确认 IME WebSocket 已连接(查看控制台日志)
|
||
2. 确认点击了 VNC 区域获得焦点
|
||
3. 确认远程桌面有可输入的窗口(如文本编辑器)
|
||
4. 检查容器内 `xdotool` 是否可用:`docker exec <container> which xdotool`
|
||
|
||
**解决方案**:
|
||
```javascript
|
||
// 测试发送
|
||
sendTextToRemote("测试文本");
|
||
|
||
// 切换到剪贴板模式
|
||
const message = JSON.stringify({
|
||
type: "text",
|
||
text: text,
|
||
method: "clipboard" // 使用剪贴板粘贴
|
||
});
|
||
```
|
||
|
||
#### 10.5.3 WebSocket 连接失败
|
||
|
||
**问题**:`WebSocket connection failed`
|
||
|
||
**排查步骤**:
|
||
1. 检查 URL 格式是否正确
|
||
2. 确认 Pingora 代理服务运行正常
|
||
3. 确认容器内服务监听正确端口
|
||
4. 检查网络连通性:`curl -v <websocket_url>`
|
||
|
||
**解决方案**:
|
||
```bash
|
||
# 检查代理服务状态
|
||
curl http://127.0.0.1:8088/health
|
||
|
||
# 检查容器服务
|
||
docker exec <container> netstat -tuln | grep -E "6089|6091"
|
||
|
||
# 查看代理日志
|
||
tail -f /path/to/pingora.log
|
||
```
|
||
|
||
### 10.6 性能优化建议
|
||
|
||
1. **音频延迟优化**:
|
||
- 使用 `AudioWorklet` 替代 `ScriptProcessorNode`
|
||
- 调整 Opus 帧长度(默认 20ms)
|
||
- 使用 WebAssembly Opus 解码器
|
||
|
||
2. **输入法响应优化**:
|
||
- 批量发送字符(debounce)
|
||
- 使用二进制协议替代 JSON
|
||
- 预连接 WebSocket
|
||
|
||
3. **UI 优化**:
|
||
- 使用 CSS `will-change` 优化动画
|
||
- 虚拟化长列表(如日志)
|
||
- 懒加载音频可视化
|
||
|
||
---
|
||
|
||
## 11. 实现检查清单和优化建议
|
||
|
||
### 11.1 代码实现检查清单
|
||
|
||
#### Phase 1: 路由层实现 ✅
|
||
- [x] 在 `RouteType` 枚举中添加 `AudioProxy` 和 `ImeProxy`
|
||
- [x] 注册音频路由:`/computer/audio/{user_id}/{project_id}/{*path}`
|
||
- [x] 注册 IME 路由:`/computer/ime/{user_id}/{project_id}/{*path}`
|
||
- [x] 添加路由参数提取逻辑(路径标准化)
|
||
|
||
#### Phase 2: 代理逻辑实现 ✅
|
||
- [x] 实现音频代理 `handle_audio_request()` 和 `handle_audio_upstream()`
|
||
- [x] 实现 IME 代理 `handle_ime_request()` 和 `handle_ime_upstream()`
|
||
- [x] 修复音频路由端口判断逻辑(空路径默认 HTTP 6090)
|
||
- [x] 优化 WebSocket 超时配置(`read_timeout = None`,支持长连接)
|
||
- [x] 添加容器 IP 解析逻辑(基于 `user_id`)
|
||
|
||
#### Phase 3: 安全性加固 ✅
|
||
- [x] 加强 IME 输入验证(长度限制 1000 字符)
|
||
- [x] 过滤危险控制字符(NULL, ESC)
|
||
- [x] 使用 `--` 参数分隔符防止命令注入
|
||
- [x] 添加安全检查清单
|
||
|
||
#### Phase 4: 前端实现 ✅
|
||
- [x] 完成 Opus 解码器集成(opus-decoder.js)
|
||
- [x] 实现音频流播放逻辑(PCM 分离声道)
|
||
- [x] 添加音频可视化功能(RMS 音量计算)
|
||
- [x] 集成输入法透传客户端
|
||
|
||
### 11.2 技术方案修复总结
|
||
|
||
| 问题类别 | 问题描述 | 修复方案 | 状态 |
|
||
|---------|---------|---------|------|
|
||
| 路由逻辑 | 音频路由空路径未明确处理 | 添加路径标准化,空路径默认 HTTP 6090 | ✅ 已修复 |
|
||
| 超时配置 | WebSocket 300 秒超时太短 | 设置 `read_timeout = None`,支持长连接 | ✅ 已修复 |
|
||
| 安全验证 | IME 长度限制 10000 太宽松 | 降低到 1000 字符,添加危险字符过滤 | ✅ 已修复 |
|
||
| 前端实现 | Opus 解码逻辑未实现 | 完整实现 opus-decoder.js 集成 | ✅ 已修复 |
|
||
| 文档说明 | 容器 IP 键值策略不明确 | 明确使用 `user_id`(ComputerAgentRunner 业务场景) | ✅ 已澄清 |
|
||
|
||
### 11.3 架构设计说明
|
||
|
||
#### 容器 IP 解析策略
|
||
```rust
|
||
// ✅ 正确:使用 user_id 作为键
|
||
let container_ip = self.vnc_backends.get(user_id)
|
||
.map(|entry| entry.value().clone())
|
||
.ok_or_else(|| /* 容器不存在 */)?;
|
||
|
||
// 说明:
|
||
// - ComputerAgentRunner 业务场景:每个 user_id 对应一个容器
|
||
// - project_id 用于日志追踪和路径匹配,不参与 IP 查询
|
||
// - vnc_backends 类型:Arc<DashMap<String, String>> (user_id -> container_ip)
|
||
```
|
||
|
||
#### 音频路由端口规则
|
||
```rust
|
||
// ✅ 正确:明确的端口路由规则
|
||
let (target_port, target_path) = if remaining_path == "ws" || remaining_path.starts_with("ws/") {
|
||
(6089_u16, format!("/{}", remaining_path)) // WebSocket 音频流
|
||
} else {
|
||
let path = if remaining_path.is_empty() { "/" } else { remaining_path };
|
||
(6090_u16, format!("/{}", path.trim_start_matches('/'))) // HTTP 静态文件
|
||
};
|
||
|
||
// 路由示例:
|
||
// /computer/audio/user_123/proj_456/ -> 容器IP:6090/ (HTTP)
|
||
// /computer/audio/user_123/proj_456/ws -> 容器IP:6089/ws (WebSocket)
|
||
```
|
||
|
||
#### WebSocket 长连接配置
|
||
```rust
|
||
// ✅ 正确:音频流和 IME 长连接不需要 read_timeout
|
||
peer.options.connection_timeout = Some(Duration::from_secs(10));
|
||
peer.options.read_timeout = None; // 无限等待(支持数小时长连接)
|
||
peer.options.write_timeout = Some(Duration::from_secs(30));
|
||
peer.options.total_connection_timeout = Some(Duration::from_secs(15));
|
||
peer.options.idle_timeout = Some(Duration::from_secs(3600)); // 1 小时空闲超时
|
||
```
|
||
|
||
### 11.4 DashMap 并发安全提醒
|
||
|
||
**推荐用法**(使用 entry API):
|
||
```rust
|
||
// ✅ 推荐:使用 entry API(原子性操作)
|
||
self.vnc_backends.entry(user_id.clone())
|
||
.or_insert_with(|| {
|
||
tracing::info!("注册新容器: user_id={}, container_ip={}", user_id, container_ip);
|
||
container_ip.clone()
|
||
});
|
||
|
||
// ✅ 安全:只读操作(get)不会死锁
|
||
let container_ip = self.vnc_backends.get(user_id)
|
||
.map(|entry| entry.value().clone());
|
||
```
|
||
|
||
**避免用法**(可能导致死锁):
|
||
```rust
|
||
// ❌ 避免:先 get 再 insert(非原子性,可能死锁)
|
||
if self.vnc_backends.get(key).is_none() {
|
||
self.vnc_backends.insert(key, value);
|
||
}
|
||
```
|
||
|
||
### 11.5 实现优先级建议
|
||
|
||
| 优先级 | 任务 | 复杂度 | 预计工作量 |
|
||
|--------|------|--------|-----------|
|
||
| **P0** | 路由层实现(router.rs) | 低 | 1-2 小时 |
|
||
| **P0** | 代理逻辑实现(service.rs) | 中 | 3-4 小时 |
|
||
| **P1** | IME 安全验证加强 | 低 | 1 小时 |
|
||
| **P1** | 前端 Opus 解码集成 | 中 | 2-3 小时 |
|
||
| **P2** | 集成测试(代理 + 容器) | 中 | 2-3 小时 |
|
||
| **P3** | 性能优化和压力测试 | 高 | 4-6 小时 |
|
||
|
||
### 11.6 测试验证建议
|
||
|
||
1. **单元测试**:
|
||
```bash
|
||
cargo test -p rcoder-proxy test_audio_route_matching
|
||
cargo test -p rcoder-proxy test_ime_route_matching
|
||
```
|
||
|
||
2. **集成测试**(使用 vnc-test.html):
|
||
```bash
|
||
# 1. 启动 RCoder 服务
|
||
cargo run --bin rcoder -- --port 8088 --enable-proxy
|
||
|
||
# 2. 打开测试页面
|
||
open docker/vnc-test.html
|
||
|
||
# 3. 验证功能
|
||
- VNC 连接成功
|
||
- 音频流播放正常(延迟 < 500ms)
|
||
- 输入法透传正常(中文输入显示在远程桌面)
|
||
```
|
||
|
||
3. **容器端服务验证**:
|
||
```bash
|
||
# 进入容器检查服务状态
|
||
docker exec <container> netstat -tuln | grep -E "6089|6090|6091"
|
||
docker exec <container> ps aux | grep -E "audio_server|ime_server"
|
||
```
|
||
|
||
---
|
||
|
||
**文档版本**: v2.0
|
||
**更新日期**: 2026-01-06
|
||
**作者**: Claude (Sonnet 4.5)
|
||
**变更说明**:
|
||
- 修复音频路由端口判断逻辑
|
||
- 优化 WebSocket 超时配置(支持长连接)
|
||
- 加强 IME 安全验证
|
||
- 完整实现前端 Opus 解码
|
||
- 澄清容器 IP 解析策略(user_id 键值)
|
||
- 添加实现检查清单和测试建议
|