项目卡片
项目:RustDesk[1] 状态:v1.4.6 / 114k+ Star / 持续活跃 一句话判断:目前最成熟的开源远程桌面,Rust 后端 + Flutter 前端的架构分离方案值得深挖
RustDesk 的标签通常是"开源版 TeamViewer",但我想聊的不是它能替代什么,而是它的代码能教我们什么。这个项目的主 crate 加上 7 个 workspace 子 crate,Rust 侧约 5.3 万行,覆盖了 P2P 穿透管道、可插拔编解码器、发布-订阅式服务模板、跨平台输入注入。这篇文章从源码层面拆解关键设计,回答一个问题——这堆代码里有什么可学、可复用、可改造的地方。
RustDesk 的分工很干脆:系统调用、网络 I/O、音视频编解码、输入注入全部放在 Rust 侧,Flutter 只做 UI 渲染和用户交互。两者通过 flutter_rust_bridge(FRB)桥接。
┌─────────────────────────────────────────────┐
│ Flutter UI (Dart) │
│ ┌─────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ Desktop │ │ Mobile │ │ Web Client │ │
│ │ Window │ │ Session │ │ (WebSocket) │ │
│ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │
│ └────────────┼──────────────┘ │
│ flutter_rust_bridge │
├─────────────────────────────────────────────┤
│ Rust Core │
│ ┌──────────┐ ┌────────┐ ┌───────────────┐ │
│ │ Client │ │ Server │ │ Services │ │
│ │ (连接管理) │ │ (连接处理)│ │ Video/Audio/ │ │
│ └────┬─────┘ └───┬────┘ │ Input/Clip...│ │
│ │ │ └───────┬───────┘ │
│ ┌────┴───────────┴──────────────┴───────┐ │
│ │ libs/ : scrap / hbb_common / enigo │ │
│ │ clipboard / virtual_display │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
Cargo workspace 包含 7 个子 crate,职责分明:
scrap(1.2 万行):屏幕采集 + 编解码,按平台条件编译 X11/DXGI/Quartz/Wayland 实现 hbb_common:网络抽象、Protobuf 消息定义、配置管理 enigo:跨平台鼠标键盘注入 clipboard:剪贴板同步 virtual_display:虚拟显示器(远程办公场景) remote_printer:远程打印 portable:便携模式支持
这种"胖后端 + 薄前端"的结构有一个实际好处:同一个 Rust 后端可以对接多个前端。Flutter 桌面端是主前端,同时还有 Web 客户端通过 WebSocket 对接,未来换前端框架时 Rust 侧几乎不用动。
RustDesk 整体架构分层示意:Flutter 负责渲染,Rust 处理所有系统级操作。

远程桌面最棘手的部分不在画界面,而在"怎么连上"。src/client.rs 里的连接建立流程值得单独拿出来看。
连接建立的三阶段
第一阶段:穿透请求。 客户端向 Rendezvous 服务器发送 PunchHoleRequest,携带自己的 NAT 类型、UDP 端口、是否强制中继等参数。Rendezvous 服务器返回对端的地址和 NAT 类型。
// src/client.rs:460-471
msg_out.set_punch_hole_request(PunchHoleRequest {
id: peer.to_owned(),
nat_type: nat_type.into(),
licence_key: key.to_owned(),
conn_type: conn_type.into(),
udp_port: udp_nat_port as _,
force_relay: interface.is_force_relay(),
socket_addr_v6: ipv6.1.unwrap_or_default(),
..Default::default()
});
第二阶段:三路并发直连。 拿到对端地址后,RustDesk 同时发起三种连接尝试:TCP 直连、UDP NAT 穿透、IPv6 直连,用 select_ok 返回第一个成功的。
// src/client.rs:693-712
let mut connect_futures = Vec::new();
connect_futures.push(connect_tcp_local(peer, Some(local_addr), connect_timeout).boxed());
if let Some(udp_socket_nat) = udp_socket_nat {
connect_futures.push(udp_nat_connect(udp_socket_nat, "UDP", connect_timeout).boxed());
}
if let Some(udp_socket_v6) = udp_socket_v6 {
connect_futures.push(udp_nat_connect(udp_socket_v6, "IPv6", connect_timeout).boxed());
}
let (mut conn, kcp, mut typ) = match select_ok(connect_futures).await { ... };
第三阶段:中继回退。 三路全部失败后,如果 Rendezvous 服务器提供了中继地址,走 request_relay。
// src/client.rs:714-736
if interface.is_force_relay() || conn.is_err() {
if !relay_server.is_empty() {
conn = Self::request_relay(peer_id, relay_server.to_owned(), ...).await;
typ = "Relay";
} else {
bail!("Failed to make direct connection to remote desktop");
}
}
值得学的设计细节
连接超时不是写死的,而是根据 NAT 类型和打洞耗时动态计算:
对称 NAT(SYMMETRIC)→ 最短超时 1 秒,几乎跳过直连 本地网络(is_local)→ 最短超时 1 秒 非对称 NAT → 超时 = 打洞耗时 × 6(首次)或 × 3(有直连失败记录时) 有中继地址兜底时,给直连更多时间(× 6);没有中继时,快速失败
这套策略在 connect 函数中约 50 行就实现了,简洁且实用。如果你在做的项目也面临"多通道连接 + 智能回退"的需求,这段代码可以直接参考。
连接建立后,还有一层安全封装 secure_connection:通过 Ed25519 签名验证对端身份,再用 Curve25519 做密钥交换,最后用 XSalsa20-Poly1305 对称加密。密钥材料全部来自 sodiumoxide(NaCl 的 Rust 绑定),每次连接生成新密钥对,实现前向保密。
连接管道的三阶段:穿透请求 → 三路并发直连 → 中继兜底。

视频编码是另一块硬骨头。RustDesk 在 libs/scrap/src/common/codec.rs 中定义了一套可插拔的编解码器架构。
EncoderApi Trait
// libs/scrap/src/common/codec.rs:60-83
pub trait EncoderApi {
fn new(cfg: EncoderCfg, i444: bool) -> ResultType<Self> where Self: Sized;
fn encode_to_message(&mut self, frame: EncodeInput, ms: i64) -> ResultType<VideoFrame>;
fn yuvfmt(&self) -> EncodeYuvFormat;
fn set_quality(&mut self, ratio: f32) -> ResultType<()>;
fn bitrate(&self) -> u32;
fn support_changing_quality(&self) -> bool;
fn latency_free(&self) -> bool;
fn is_hardware(&self) -> bool;
fn disable(self);
}
这套 trait 背后支持六种编码器配置:
pub enum EncoderCfg {
VPX(VpxEncoderConfig), // VP8/VP9 软编码
AOM(AomEncoderConfig), // AV1 软编码
HWRAM(HwRamEncoderConfig), // H.264/H.265 硬件编码(CPU 可访问帧)
VRAM(VRamEncoderConfig), // GPU 直接编码(VRAM 帧不拷贝到 CPU)
}
其中 VRAM 编码器是一个关键优化:GPU 采集的纹理不经过 CPU 内存,直接送进 GPU 编码器,省掉了 GPU→CPU→GPU 的数据搬运。在 Windows 上使用 DirectX 11/12,通过 #[cfg(feature = "vram")] 条件编译。
动态编码切换
编码器不是启动时一锤子买卖。Encoder::update() 方法会根据所有已连接客户端的解码能力,动态选择最合适的编码格式:
所有客户端都支持 AV1 → 用 AV1 所有客户端都支持 H.264 硬解 → 切到硬件编码 有客户端只支持 VP9 → 降级到 VP9
硬件编码器创建失败时,自动回退到 VP9 软编码,并清除硬件编码配置,下次不会再尝试。这种"降级有兜底"的模式在多媒体应用中非常实用。
Feature Flag 控制编译目标
[features]
hwcodec = ["scrap/hwcodec"] # H.264/H.265 硬件编解码
vram = ["scrap/vram"] # GPU 直编码
mediacodec = ["scrap/mediacodec"] # Android MediaCodec
不开启硬件编码特性时,编译产物不包含任何硬件编解码代码,二进制体积显著减小。这对嵌入式设备或 CI 构建很有价值。
EncoderApi trait 抽象层与多种编码器实现的关系。

src/server/service.rs 定义了一个泛型服务模板 ServiceTmpl<T>,是整个服务端架构的骨架。
pub trait Service: Send + Sync {
fn name(&self) -> String;
fn on_subscribe(&self, sub: ConnInner);
fn on_unsubscribe(&self, id: i32);
fn is_subed(&self, id: i32) -> bool;
fn join(&self);
fn ok(&self) -> bool;
}
核心思路是发布-订阅模式:每个服务(Video、Audio、Input、Clipboard 等)是一个 ServiceTmpl<ConnInner> 实例,连接加入时 on_subscribe,断开时 on_unsubscribe。服务内部维护一个 subscriber HashMap,当新帧/新事件到来时,遍历所有 subscriber 广播。
ServiceInner 还引入了 new_subscribes 和 subscribes 两级缓冲:新加入的连接先放入 new_subscribes,等下一次 snapshot 时才合并到主表。这意味着新连接可以在下一次数据推送时拿到完整状态(比如当前视频帧),而不是等下一帧。
这个模板被 VideoService、AudioService、InputService、ClipboardService 统一使用,每种服务只需实现自己的数据处理逻辑,连接管理、线程模型、生命周期全部复用。
ServiceTmpl<T>
├── VideoService (23.976FPS 帧捕获 + 编码 + 广播)
├── AudioService (Opus 编码 + 音频流推送)
├── InputService (键盘/鼠标/触控事件注入)
├── ClipboardService (剪贴板双向同步)
├── DisplayService (多显示器管理)
├── TerminalService (远程终端)
└── PrinterService (远程打印)
如果你在设计一个多连接的服务端程序(比如直播推流、协同编辑、IoT 设备管理),这个 ServiceTmpl 模式值得直接抄——它解决了"多个消费者订阅同一个数据源、消费者动态加入退出、数据推送不丢帧"这三个常见问题。
把远程的键鼠事件"注入"到被控端,看起来简单,做起来全是坑。RustDesk fork 并改造了 enigo 库(libs/enigo/),定义了 MouseControllable 和 KeyboardControllable 两个 trait:
pub trait MouseControllable {
fn mouse_move_to(&mut self, x: i32, y: i32);
fn mouse_move_relative(&mut self, x: i32, y: i32);
fn mouse_down(&mut self, button: MouseButton);
fn mouse_up(&mut self, button: MouseButton);
fn mouse_click(&mut self, button: MouseButton);
fn mouse_scroll_x(&mut self, length: i32);
fn mouse_scroll_y(&mut self, length: i32);
// ...
}
底层实现按平台分叉:
Windows:Win32 SendInputAPImacOS:Quartz CGEventCreateKeyboardEvent/CGEventCreateMouseEventLinux X11: XTestFakeKeyEvent/XTestFakeMotionEventLinux Wayland:通过 PipeWire 的 Remote Desktop Portal 协议 Linux uinput:创建虚拟输入设备节点
在 input_service.rs 中,这些调用不是简单的一对一转发。RustDesk 做了多层处理:
键位映射:远程端操作系统可能和被控端不同(比如 macOS 的 Command 键映射到 Windows 的 Ctrl) 修饰键状态追踪:记录当前 Shift/Ctrl/Alt/WIN 的按下状态,防止状态不一致 鼠标锁定模式:支持"相对鼠标"模式,用于远程游戏场景 隐私模式过滤:隐私模式开启时,屏蔽敏感窗口的输入事件
keyboard.rs(1598 行)是整个输入处理中最复杂的部分,包含了按键名称到键码的映射表、GrabState 状态机、多平台键盘布局处理。
RustDesk 的安全设计有一个前提假设:Rendezvous 服务器本身不可信。即使它被攻破,攻击者也拿不到会话密钥。
密钥交换流程(src/common.rs:2024-2031):
pub fn create_symmetric_key_msg(their_pk_b: [u8; 32])
-> (Bytes, Bytes, secretbox::Key)
{
let their_pk_b = box_::PublicKey(their_pk_b);
let (our_pk_b, out_sk_b) = box_::gen_keypair();
let key = secretbox::gen_key();
let nonce = box_::Nonce([0u8; box_::NONCEBYTES]);
let sealed_key = box_::seal(&key.0, &nonce, &their_pk_b, &out_sk_b);
(Vec::from(our_pk_b.0).into(), sealed_key.into(), key)
}
流程是:每方生成临时 Curve25519 密钥对 → 用对端公钥加密会话密钥 → 用 XSalsa20-Poly1305 加密后续所有通信。Rendezvous 服务器只看到加密后的密钥交换消息,拿不到会话密钥。
此外还有:
登录失败锁定: LOGIN_FAILURES数组追踪 IP 和设备的失败次数,超过阈值触发临时封禁权限细分: CONTROL_PERMISSIONS_ARRAY按连接 ID 存储独立权限(键盘、鼠标、剪贴板、文件传输可分别开关)隐私模式:被控端可以屏蔽特定窗口的屏幕采集和输入注入,通过 Window Focus 判断
如果你想在 RustDesk 基础上做二次开发,代码里有几个现成的扩展点:
1. 插件系统。 src/plugin/ 已经实现了完整的插件框架:插件通过独立进程运行(后缀 _plugin 的 IPC 通道),支持安装/卸载/更新,有 desc.rs 定义插件元数据。你可以写一个 RustDesk 插件来扩展功能,而不需要 fork 主项目。
2. 自定义编码器。 EncoderApi trait 是开放的,实现 new、encode_to_message、set_quality 等方法就能接入新的编码器。比如接入 SVT-AV1 或者 NVIDIA NVENC,只需要新增一个实现。
3. 自定义连接策略。 _start_inner 和 connect 函数在 src/client.rs 中,你可以修改连接优先级、添加新的连接通道(比如 QUIC)、或者实现自己的 NAT 穿透逻辑。
4. Web 客户端对接。 Rust 后端已经支持 WebSocket 连接(use_ws 配置),这意味着你可以用任何前端框架(React、Vue、甚至纯 WebGL)替换 Flutter,只要处理 Protobuf 消息和视频帧解码。
5. ServiceTmpl 扩展。 新增一种服务类型(比如屏幕录制、AI 辅助标注),只需要实现 Service trait 和 Subscriber trait,然后在 connection.rs 的 handle_msg 中分发消息即可。
5.3 万行 Rust 代码,网络编程、音视频、系统调用、跨平台适配几乎全占了。这种规模的项目,看它的架构取舍比看任何教科书都实在。
这里会继续拆真实可用的开发者工具:少讲概念,多看入口、成本和坑点。你只需要判断一件事——它值不值得放进自己的工作流。
引用链接
[1]RustDesk: https://github.com/rustdesk/rustdesk
夜雨聆风