乐于分享
好东西不私藏

RustDesk 源码拆解:连接管道、编解码器与跨平台输入注入的工程实现

RustDesk 源码拆解:连接管道、编解码器与跨平台输入注入的工程实现

项目卡片

  • 项目:RustDesk[1]
  • 状态:v1.4.6 / 114k+ Star / 持续活跃
  • 一句话判断:目前最成熟的开源远程桌面,Rust 后端 + Flutter 前端的架构分离方案值得深挖

RustDesk 的标签通常是"开源版 TeamViewer",但我想聊的不是它能替代什么,而是它的代码能教我们什么。这个项目的主 crate 加上 7 个 workspace 子 crate,Rust 侧约 5.3 万行,覆盖了 P2P 穿透管道、可插拔编解码器、发布-订阅式服务模板、跨平台输入注入。这篇文章从源码层面拆解关键设计,回答一个问题——这堆代码里有什么可学、可复用、可改造的地方。

整体架构:Rust 负责所有脏活,Flutter 只管画界面

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 处理所有系统级操作。

P2P 连接管道:三路并发 + 指数退避 + 中继兜底

远程桌面最棘手的部分不在画界面,而在"怎么连上"。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 绑定),每次连接生成新密钥对,实现前向保密。

连接管道的三阶段:穿透请求 → 三路并发直连 → 中继兜底。

编解码器:Trait 抽象 + Feature Flag 按需编译

视频编码是另一块硬骨头。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<Selfwhere SelfSized;
    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 抽象层与多种编码器实现的关系。

服务模板:一个泛型 struct 管住所有后台服务

src/server/service.rs 定义了一个泛型服务模板 ServiceTmpl<T>,是整个服务端架构的骨架。

pub trait ServiceSend + 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_subscribessubscribes 两级缓冲:新加入的连接先放入 new_subscribes,等下一次 snapshot 时才合并到主表。这意味着新连接可以在下一次数据推送时拿到完整状态(比如当前视频帧),而不是等下一帧。

这个模板被 VideoService、AudioService、InputService、ClipboardService 统一使用,每种服务只需实现自己的数据处理逻辑,连接管理、线程模型、生命周期全部复用。

ServiceTmpl<T>
├── VideoService  (23.976FPS 帧捕获 + 编码 + 广播)
├── AudioService  (Opus 编码 + 音频流推送)
├── InputService  (键盘/鼠标/触控事件注入)
├── ClipboardService (剪贴板双向同步)
├── DisplayService (多显示器管理)
├── TerminalService (远程终端)
└── PrinterService (远程打印)

如果你在设计一个多连接的服务端程序(比如直播推流、协同编辑、IoT 设备管理),这个 ServiceTmpl 模式值得直接抄——它解决了"多个消费者订阅同一个数据源、消费者动态加入退出、数据推送不丢帧"这三个常见问题。

跨平台输入注入:Trait 抹平 OS 差异

把远程的键鼠事件"注入"到被控端,看起来简单,做起来全是坑。RustDesk fork 并改造了 enigo 库(libs/enigo/),定义了 MouseControllableKeyboardControllable 两个 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 SendInput API
  • macOS:Quartz CGEventCreateKeyboardEvent / CGEventCreateMouseEvent
  • Linux X11XTestFakeKeyEvent / XTestFakeMotionEvent
  • Linux Wayland:通过 PipeWire 的 Remote Desktop Portal 协议
  • Linux uinput:创建虚拟输入设备节点

input_service.rs 中,这些调用不是简单的一对一转发。RustDesk 做了多层处理:

  1. 键位映射:远程端操作系统可能和被控端不同(比如 macOS 的 Command 键映射到 Windows 的 Ctrl)
  2. 修饰键状态追踪:记录当前 Shift/Ctrl/Alt/WIN 的按下状态,防止状态不一致
  3. 鼠标锁定模式:支持"相对鼠标"模式,用于远程游戏场景
  4. 隐私模式过滤:隐私模式开启时,屏蔽敏感窗口的输入事件

keyboard.rs(1598 行)是整个输入处理中最复杂的部分,包含了按键名称到键码的映射表、GrabState 状态机、多平台键盘布局处理。

安全模型:不信任任何中间节点

RustDesk 的安全设计有一个前提假设:Rendezvous 服务器本身不可信。即使它被攻破,攻击者也拿不到会话密钥。

密钥交换流程(src/common.rs:2024-2031):

pub fn create_symmetric_key_msg(their_pk_b: [u832])
    -> (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 是开放的,实现 newencode_to_messageset_quality 等方法就能接入新的编码器。比如接入 SVT-AV1 或者 NVIDIA NVENC,只需要新增一个实现。

3. 自定义连接策略。 _start_innerconnect 函数在 src/client.rs 中,你可以修改连接优先级、添加新的连接通道(比如 QUIC)、或者实现自己的 NAT 穿透逻辑。

4. Web 客户端对接。 Rust 后端已经支持 WebSocket 连接(use_ws 配置),这意味着你可以用任何前端框架(React、Vue、甚至纯 WebGL)替换 Flutter,只要处理 Protobuf 消息和视频帧解码。

5. ServiceTmpl 扩展。 新增一种服务类型(比如屏幕录制、AI 辅助标注),只需要实现 Service trait 和 Subscriber trait,然后在 connection.rshandle_msg 中分发消息即可。

5.3 万行 Rust 代码,网络编程、音视频、系统调用、跨平台适配几乎全占了。这种规模的项目,看它的架构取舍比看任何教科书都实在。


这里会继续拆真实可用的开发者工具:少讲概念,多看入口、成本和坑点。你只需要判断一件事——它值不值得放进自己的工作流。

引用链接

[1]RustDesk: https://github.com/rustdesk/rustdesk