夜雨聆风学习资料网

ARTICLE · 1047711

RustDesk 源码深度解析:一款远程桌面是如何炼成的

RustDesk 源码深度解析:一款远程桌面是如何炼成的

本文基于 RustDesk v1.2.0 源码(GitHub 主仓库,约 2022 年 11 月主干)逐层拆解其架构与核心实现。文末附 1.3/1.4 版本演进说明。代码均可对照仓库阅读。

一、RustDesk 是什么

RustDesk 是一款用 Rust + Flutter 打造的开源远程桌面软件,定位是 TeamViewer / ToDesk 的开源替代品:

  • 全平台:Windows / macOS / Linux / Android / iOS / Web
  • 自建服务器:ID/中继服务器(rustdesk-server)完全开源,数据可不出内网
  • P2P 直连优先:NAT 打洞成功后点对点传输,中继只是兜底
  • 端到端加密:基于 NaCl 家族的非对称握手 + 对称流加密

截至 2025 年,其在 GitHub 已收获 90k+ Star,是 Rust 生态中最火的 GUI 应用之一。它也是学习"高性能网络编程 + 多媒体管线 + 跨平台系统编程"的极佳范本。

二、整体架构

2.1 三大角色

RustDesk 的世界里只有三种角色:

┌──────────────┐   UDP/TCP    ┌──────────────┐
│   被控端      │◄────────────►│  hbbs        │  ID/信令服务器
│  (Server)    │              │ (Rendezvous) │  负责: 注册、打洞撮合、密钥托管
└──────┬───────┘              └──────────────┘
       │                            ▲
       │ P2P 直连(TCP, 加密)          │ 信令
       │        ┌──────────────┐    │
       └───────►│   主控端      │────┘
                │  (Client)    │
                └──────────────┘
       打洞失败时, 双方都接入:
                ┌──────────────┐
                │  hbbr        │  中继服务器(纯转发)
                │  (Relay)     │
                └──────────────┘

注意一个反直觉的命名:源码中 **被控端叫 server,主控端叫 client**。阅读代码前先建立这个心智模型,否则容易迷路。

  • hbbs(RustDesk Bootstrap Server):被控端持续向它注册"我在哪",主控端向它查询"目标在哪",并撮合双方打洞;
  • hbbr(Relay Server):打洞失败时的流量中继,只做字节转发,不解析协议;
  • hbbs/hbbr 在独立仓库 rustdesk-server 中,客户端主仓库不含服务端代码。

2.2 客户端分层架构

单个客户端内部是典型的 "UI 进程 + 服务进程" 双进程模型,核心全部用 Rust 实现:

┌─────────────────────────────────────────────────┐
│  flutter/ (Dart)                                 │
│  UI 层: 会话窗口 / 文件管理 / 设置 / CM 连接管理     │
└───────────────────┬─────────────────────────────┘
                    │ flutter_rust_bridge (FFI, 零拷贝传帧)
┌───────────────────▼─────────────────────────────┐
│  src/ (Rust 核心库 librustdesk)                   │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐          │
│  │ client   │ │ server   │ │ rendezvous_mediator │
│  │ 主控逻辑   │ │ 被控逻辑  │ │ 信令中介             │
│  └──────────┘ └──────────┘ └──────────┘          │
│  ┌──────────────────────────────────────┐        │
│  │ server/{video,audio,input,clipboard} │ 四大服务 │
│  └──────────────────────────────────────┘        │
└───────┬───────────────────────────┬─────────────┘
        │ IPC (named pipe / uds)    │ 动态库
┌───────▼──────────────┐   ┌────────▼─────────────┐
│ --server 服务进程      │   │ libs/hbb_common       │
│ 后台常驻: 信令/被控服务 │   │ 协议/加密/配置/网络    │
└──────────────────────┘   │ libs/scrap 屏幕采集+编解码│
                           │ libs/enigo  输入模拟    │
                           └──────────────────────┘

2.3 仓库结构

rustdesk/
├── src/                     # Rust 核心
│   ├── client.rs            # 主控端: 连接管理/视频音频解码线程
│   ├── client/io_loop.rs    # 主控端消息泵(收发/文件任务/剪贴板)
│   ├── server.rs            # 被控端: Server 对象与连接入口
│   ├── server/connection.rs # 被控端单条连接的状态机(最核心)
│   ├── server/video_service.rs  # 屏幕采集服务
│   ├── server/audio_service.rs  # 音频采集服务
│   ├── server/input_service.rs  # 键鼠注入服务
│   ├── server/video_qos.rs      # 自适应码率
│   ├── rendezvous_mediator.rs   # 与 hbbs 的信令客户端
│   ├── ipc.rs               # UI进程 <-> 服务进程 IPC
│   ├── flutter_ffi.rs       # 暴露给 Flutter 的 API
│   └── ...
├── flutter/                 # Flutter UI(Dart)
├── libs/
│   ├── hbb_common/          # 协议定义/编解码/加密/配置(复用库)
│   │   └── protos/{rendezvous,message}.proto
│   ├── scrap/               # Screen CAPtuRe: 跨平台采集+VP9/硬编
│   ├── enigo/               # 跨平台输入模拟(fork)
│   ├── clipboard/           # 剪贴板(Windows CLIPRDR)
│   └── virtual_display/     # Windows 虚拟显示器驱动封装
└── Cargo.toml               # crate-type = cdylib/staticlib/rlib

一个值得注意的细节:Cargo.toml 里 crate-type = ["cdylib", "staticlib", "rlib"] —— 同一份核心代码,桌面端编译成可执行文件,移动端编译成动态库塞进 Flutter 壳,这就是"一次核心,多端发布"。

三、核心技术栈

能力
选型
说明
语言
Rust (edition 2021)
无 GC、内存安全、async 生态成熟
UI
Flutter (flutter_rust_bridge)
跨 6 端;早期版本用 Sciter(src/ui/*.tis 仍残留)
异步运行时
tokio
单线程 current_thread + 多线程混用
序列化
protobuf (rust-protobuf)
两套协议:信令/会话
加密
sodiumoxide (libsodium)
box_(X25519) / sign(Ed25519) / secretbox(XSalsa20-Poly1305)
视频编码
libvpx (VP9) + hwcodec
硬编走 NVENC/QSV/AMF,安卓 MediaCodec
音频编码
magnum-opus (Opus)
LowDelay 应用模式
屏幕采集
自研 scrap 库
DXGI/GDI/Magnifier、X11、PipeWire、Quartz、MediaProjection
输入模拟
自研 enigo fork + rdev
SendInput / XTest / CGEvent / uinput
IPC
parity-tokio-ipc
Windows 命名管道 / Unix 域套接字
文件传输
自研 fs.rs
分块 + 摘要校验 + 断点续传

四、连接建立全流程(最精华的部分)

远程桌面最难的不是传画面,而是"两台都在 NAT 后面的机器如何找到彼此"。RustDesk 的方案是一套教科书级的 TCP 打洞 + 中继兜底 实现。

4.1 协议先行:两套 Protobuf

libs/hbb_common/protos/rendezvous.proto 定义信令消息(与 hbbs 交互),核心消息一目了然:

messageRegisterPeerstring id = 1int32 serial = 2; }        // 被控端心跳注册
messageRegisterPkstring id; bytes uuid; bytes pk; }         // 注册签名公钥
messagePunchHoleRequeststring id; NatType nat_type; ConnType conn_type; } // 主控发起
messagePunchHolebytes socket_addr; string relay_server; }    // hbbs 通知被控端
messagePunchHoleSentbytes socket_addr; string id; ... }      // 被控端回给主控端
messageRequestRelaystring id; string uuid; bytes socket_addr; } // 请求中继

而 message.proto 定义会话内消息:VideoFrameMouseEventKeyEventFileTransferLoginRequest/Response 等几十种,被控端和主控端之间的所有流量都走它。

4.2 自定义二进制帧:BytesCodec

TCP 是字节流,Protobuf 不自带分帧。RustDesk 没有用现成方案,而是写了一个极简的变长头编码libs/hbb_common/src/bytes_codec.rs):

// 头 1~4 字节: 低 2 位是"头长度-1", 其余位小端存放 payload 长度
if data.len() <= 0x3F {
    buf.put_u8((data.len() << 2asu8);              // 1 字节头, 最大 63B
elseif data.len() <= 0x3FFF {
    buf.put_u16_le((data.len() << 2asu16 | 0x1);   // 2 字节头, 最大 16KB
elseif data.len() <= 0x3FFFFF {
    ...                                                // 3 字节头, 最大 4MB
else {
    buf.put_u32_le((data.len() << 2asu32 | 0x3);   // 4 字节头, 最大 1GB
}

小消息(键鼠事件通常几十字节)只花 1 字节 overhead,比常见的 4 字节定长头省 75%。对键鼠这种高频小包,积少成多非常可观——这是典型的"协议为自己业务量身定制"的工程决策。

tcp.rs 中的 FramedStream 在此之上封装出 send/next,并天然支持两种模式:

  • Protobuf 模式send(&msg) 自动序列化;
  • Raw 模式set_raw() 后透传字节,用于中继连接(hbbr 不解析内容)。

4.3 被控端:注册与心跳(rendezvous_mediator.rs)

被控端启动后,RendezvousMediator::start_all() 为每一个配置的 rendezvous 服务器各起一个任务,核心循环用 tokio::select! 同时监听"UDP 收包"和"1 秒定时器":

select! {
    n = socket.next() => { /* 处理 hbbs 消息: 打洞/中继/配置更新 */ },
    _ = timer.tick() => {
// 到期未收到 RegisterPeerResponse 则重发注册
if timeout || elapsed_resp >= REG_INTERVAL {
            rz.register_peer(&mut socket).await;
// 连续 6 次失败: 标记延迟为 -1, 重新 DNS, 重建 UDP socket
if fails > MAX_FAILS2 { ... socket = rebind_udp(...)?; }
        }
    }
}

几个耐人寻味的细节:

  1. 延迟统计用 EMA 平滑ema_latency = latency/30 + ema_latency*29/30,避免抖动导致服务器频繁切换;
  2. 网络切换自愈:注释里写明"拨号网络重连后旧 UDP socket 会失效",所以超时后干脆 rebind_udp 重建;
  3. 多服务器竞速:同时注册所有配置的 hbbs,谁快用谁(Config::update_latency)。

4.4 打洞流程:一次完整的三方握手

假设 A(主控)要连 B(被控):

第 1 步:A 通过 TCP 连上 hbbs,发送 PunchHoleRequest{id: B, nat_type: ...}src/client.rs::_start)。最多重试 3 次,超时时间递增(6s/12s/18s)。

第 2 步:hbbs 通过 UDP 通知 B:PunchHole{socket_addr: A的公网地址, relay_server}

第 3 步(关键,rendezvous_mediator.rs::handle_punch_hole):B 判断双方 NAT 类型:

asyncfnhandle_punch_hole(&self, ph: PunchHole, server: ServerPtr) -> ResultType<()> {
// 任一方是对称型 NAT -> 直连无望, 直接走中继
if ph.nat_type == NatType::SYMMETRIC || Config::get_nat_type() == SYMMETRIC {
let uuid = Uuid::new_v4().to_string();
returnself.create_relay(ph.socket_addr, relay_server, uuid, server, truetrue).await;
    }
let peer_addr = AddrMangle::decode(&ph.socket_addr);
letmut socket = {
// ① 先 TCP 连一下 hbbs —— 目的是让 NAT 为本机分配一个公网映射端口
let socket = socket_client::connect_tcp(self.addr, any_addr, RENDEZVOUS_TIMEOUT).await?;
let local_addr = socket.local_addr();
// ② 立刻用同一个本地端口去连 A 的公网地址(TCP 同时打开打洞), 只等 300ms
        allow_err!(socket_client::connect_tcp(peer_addr, local_addr, 300).await);
        socket
    };
// ③ 通过"连 hbbs 的这条 socket"回发 PunchHoleSent(内含自己的真实地址)
    msg_out.set_punch_hole_sent(PunchHoleSent { socket_addr: ph.socket_addr, id, ... });
    socket.send_raw(bytes).await?;
    crate::accept_connection(server, socket, peer_addr, true).await
}

这里的 TCP 打洞技巧是:B 先连一次 hbbs,让中间的 NAT 设备为"B 的某个本地端口"建立到公网的映射;随后 B 用同一个本地端口主动连接 A 的公网地址——如果 A 也在几乎同时向 B 的映射地址发起连接(TCP simultaneous open),双方 SYN 在 NAT 上"对撞"成功,直连建立。300ms 的超时说明这只是一次"试探性点火"。

主控端 A 在 Client::connect 里则根据双方的 NAT 类型 + 历史直连失败记录PeerConfig.direct_failures)动态计算直连超时:

// ASYMMETRIC(锥形NAT) x ASYMMETRIC: 给足 CONNECT_TIMEOUT
// 有直连失败史: 超时缩为 punch_time_used * 3, 否则 * 6
// SYMMETRIC 或同局域网: 只等 1s, 马上转中继
let n = if direct_failures > 0 { 3 } else { 6 };
connect_timeout = punch_time_used * n;

"失败记忆"让打洞成功率随使用越来越高——直连失败过的设备对,下次直接少等一半时间快速切中继。

第 4 步:直连失败 → request_relay,双方各自连上 hbbr,由它转发字节。注意中继流量对 hbbr 是加密的不透明字节(下文详述),服务器看不到内容。

第 5 步:同局域网识别。hbbs 发现两端 socket 是同一 IP 时下发 FetchLocalAddr,双方改用内网地址直连(handle_intranet),绕过公网。

4.5 安全握手:三层防线

直连建立后的第一件事是加密握手(server.rs::create_tcp_connection):

// 被控端生成一次性 X25519 密钥对
let (our_pk_b, our_sk_b) = box_::gen_keypair();
// 用 Ed25519 签名(IdPk{id, pk}) 发给主控端
msg_out.set_signed_id(SignedId {
    id: sign::sign(&IdPk{ id, pk: our_pk_b }.write_to_bytes()?, &sk).into(), ..
});
// 主控端回包后, 用对方公钥解开对称密钥
let symmetric_key = box_::open(&pk.symmetric_value, &nonce, &their_pk_b, &our_sk_b)?;
stream.set_key(secretbox::Key(key)); // 之后所有消息自动对称加密

三层防线分别是:

  1. ID 防冒充:被控端的签名公钥(pk)注册在 hbbs 上并与 uuid 绑定。任何人想冒充别人的 ID,hbbs 校验 RegisterPk 时发现 uuid/pk 不匹配(UUID_MISMATCH),客户端会自动换 ID 重注册(handle_uuid_mismatch);
  2. 中间人防御:主控端拿到 hbbs 签发的 pk 后,先验证 SignedId 签名(decode_id_pk),确认"这个 pk 确实属于这个 ID",再进行密钥交换。自建服务器时用户可在两端配置相同公钥指纹;
  3. 传输加密:握手后 FramedStream 的每一条消息都用 secretbox(XSalsa20-Poly1305) 加密,nonce 由单调递增的收/发序列号生成(get_nonce(seqnum)),同一密钥下 nonce 永不重用:
pubasyncfnsend_raw(&mutself, msg: Vec<u8>) -> ResultType<()> {
ifletSome(key) = self.2.as_mut() {
        key.1 += 1;                              // 发送序号 +1
let nonce = Self::get_nonce(key.1);      // 序号即 nonce
        msg = secretbox::seal(&msg, &nonce, &key.0);
    }
self.send_bytes(bytes::Bytes::from(msg)).await
}

额外加分项:协议里传输的地址都经过 AddrMangle::encode/decode 混淆,网络抓包不能直接读出明文 IP。

4.6 登录鉴权

加密通道建立后,被控端下发挑战:

let hash = Hash {
    salt: Config::get_salt(),
    challenge: Config::get_auto_password(6),  // 动态 challenge
};

主控端基于密码 + salt 计算 SHA256 应答;支持永久密码与临时密码(6 位数字,get_auto_password)两级,以及"记住密码"、连接管理器(CM)弹窗人工确认。

五、被控端架构:发布-订阅的服务模型

被控端可能同时被多个主控端连接(多看/多控)。如果每个连接独立采集编码,CPU 会被打爆。RustDesk 的答案是一个单生产者-多消费者的服务模型(server.rs + server/service.rs):

pubstructServer {
    connections: ConnMap,                        // id -> 连接
    services: HashMap<&'staticstrBox<dyn Service>>,  // 5 个服务
}
// 启动时注册服务
server.add_service(Box::new(audio_service::new()));     // "audio"
server.add_service(Box::new(video_service::new()));     // "video"
server.add_service(Box::new(clipboard_service::new())); // "clipboard"
server.add_service(Box::new(input_service::new_cursor()));// 光标位置广播
server.add_service(Box::new(input_service::new_pos()));   // 远端光标绘制

每个 ServiceTmpl 内部有一条独立线程跑 run() 回调,其生命周期管理非常克制:

pubfnrun<F>(&self, callback: F) {
let thread = thread::spawn(move || {
letmut error_timeout = HIBERNATE_TIMEOUT;
while sp.active() {
if sp.has_subscribes() {          // ★ 有订阅者才干活
ifletErr(err) = callback(sp.clone()) {
// 出错: 指数退避 30s -> 60s -> ... 封顶 1s
                    error_timeout *= 2;
                    thread::sleep(...);
                }
            }
            thread::sleep(HIBERNATE_TIMEOUT); // 无订阅: 蛰伏 30s 再看
        }
    });
}

屏幕只采集/编码一次,Arc<Message> 广播给所有订阅连接send_video_frame),视频帧的引用计数让多客户端观看近零成本。错误处理用指数退避避免疯狂重试;无订阅者时服务自动"冬眠"。

新订阅者中途加入怎么办?snapshot 机制:新连接先进 new_subscribes,在安全的时间点(帧间隙)由 ServiceSwap 在 Drop 时原子换入正式列表,避免迭代中被修改。

六、视频管线:从像素到字节

6.1 采集层(libs/scrap)

scrap 库按平台提供统一的 Capturer trait:

平台
采集方式
备注
Windows
DXGI Desktop Duplication API
GPU 级抓屏,主力方案
Windows
GDI BitBlt
DXGI 黑屏/失败时自动回退
Windows
Magnification API
隐私模式:只有主控端"看得到"画面
Linux X11
XGetImage / XShm
传统方案
Linux Wayland
PipeWire (dbus 协商)
须用户授权屏幕共享
macOS
CoreGraphics (Quartz)
Android
MediaProjection + MediaCodec
直接输出硬编码 H264/H265

回退逻辑写在主循环里,非常朴素务实:

Err(ref e) if e.kind() == WouldBlock => {
if try_gdi > 3 {           // 连续 4 次无画面
        c.set_gdi();           // 切换到 GDI 模式
        log::info!("No image, fall back to gdi");
    }
}
Err(err) => {
if !c.is_gdi() {
        c.set_gdi();
        log::info!("dxgi error, fall back to gdi: {:?}", err);
continue;
    }
}

多显示器支持也很直白:主循环每秒核对一次显示器数量/分辨率,发现变化就 bail!("SWITCH") 主动炸掉采集循环,由外层 run() 重建 Capturer——用"快速失败+重建"代替复杂的动态调整

6.2 编码层:软硬结合 + 能力协商

编码器选择(scrap/src/common/codec.rs):

let encoder_cfg = match Encoder::current_hw_encoder_name() {
Some(codec_name) => EncoderCfg::HW(HwEncoderConfig { codec_name, width, height, bitrate }),
None => EncoderCfg::VPX(VpxEncoderConfig {
        width, height,
        timebase: [11000],
        bitrate,
        codec: VpxVideoCodecId::VP9,
        num_threads: (num_cpus::get() / 2as _,  // 只用一半核, 给解码/UI 留量
    }),
};

最有趣的是多端能力协商:每个主控端连接时上报自己的解码能力评分(VideoCodecState{score_vpx, score_h264, score_h265}),被控端汇总:

// 编码器得分(VPX 固定 90) + 所有解码端得分之和 -> 全局最优
score_vpx += states.iter().map(|s| s.1.score_vpx).sum::<i32>();
if enabled_h265 && score_h265 >= score_vpx && score_h265 >= score_h264 {
    *name.lock().unwrap() = best.h265...  // 切 H265
}

即:**编码策略不是"我支持什么就用什么",而是"所有观看者都解得动什么才用什么"**。一个手机弱解码端加入,会把整桌编码降档到 VP9;全员高端显卡则自动上 H265 硬编。编码器切换同样通过 bail!("SWITCH") 触发循环重建。

6.3 QoS:基于延迟的码率自适应

video_qos.rs 是一个 200 行的微型 ABR 控制器,思路却很完整:

enumDelayState {
    Normal = 0,
    LowDelay = 200,    // >200ms
    HighDelay = 500,   // >500ms
    Broken = 1000,     // >1s
}

pubfnupdate_network_delay(&mutself, delay: u32) {
self.current_delay = delay / 2 + self.current_delay / 2;  // EMA 平滑
let current_state = DelayState::from_delay(self.current_delay);
// 状态迁移 + 连续 5 次以上才动作(去抖)
if current_state != self.state && self.debounce_count > 5 {
self.state = current_state;
self.debounce_count = 0;
self.refresh_quality();
    } else { self.debounce_count += 1; }
}

fnrefresh_quality(&mutself) {
matchself.state {
        Normal    => { self.fps = base_fps(); quality = user_quality; }     // 30fps/原画质
        LowDelay  => { fps 不变; quality = min(user, 50); }                 // 降画质
        HighDelay => { self.fps = base_fps() / 2; quality = min(user, 25); }// 砍半帧率
        Broken    => { self.fps = base_fps() / 4; quality = 10; }           // 残血保命
    }
let _ = self.generate_bitrate().ok();
}

码率公式来自 NVIDIA 直播指南:bitrate = width*height/800 * quality%。延迟数据从哪来?会话层每秒一次 TestDelay 往返测量(last_test_delay),和 WebRTC REMB 思路异曲同工,但实现只有两页纸。

6.4 背压控制:帧确认机制

远程桌面特有的大屏高码率场景下,发送端跑得比网络快会导致延迟雪崩。RustDesk 的解法是采集-发送-确认闭环VideoFrameController):

// 发出一帧后, 阻塞等待所有连接的 ACK(最多 3s)
frame_controller.set_send(now, send_conn_ids);
while wait_begin.elapsed() < timeout {
    frame_controller.try_wait_next(&mut fetched_conn_ids, 300);
if fetched_conn_ids.len() >= frame_controller.send_conn_ids.len() {
break;   // 所有客户端都收到了, 才继续采集下一帧
    }
}

主控端解码渲染完一帧后通过 notify_video_frame_feched 回执。整条管线因此变成端到端的流控:客户端渲染卡住 → 不回 ACK → 被控端暂停采集 → 内存和延迟都不涨。这个设计后来也用在 1.2+ 的 video_ack_required 协商里,弱设备可退化为不确认模式。

七、音频管线:Opus + 10ms 粒度

音频服务同样是 GenericService(audio_service.rs),按平台分两套实现:

#[cfg(not(any(target_os = "linux", target_os = "android")))]
pubfnnew() -> GenericService {
let sp = GenericService::new(NAME, true);
    sp.repeat::<cpal_impl::State, _>(33, cpal_impl::run);  // 33ms 周期
    sp
}
  • Windows:WASAPI Loopback 录制"系统正在播放的声音"(文件头注释里详细记录了为什么选 cpal 而非 soundio——后者不支持 loopback);
  • macOS:CoreAudio;
  • Linux/Android:PulseAudio monitor 源(跑在独立 IPC 子进程 _pa 中,避免阻塞主循环)。

编码用 Opus LowDelay 模式AUDIO_DATA_SIZE_U8 = 960*4 恰好是 48kHz 双声道 f32 的 10ms 数据。静音时主动发零帧维持解码端时钟。主控端 AudioHandler 解码后交给 cpal/oboe(Android) 播放。

八、输入注入:远端键鼠的"最后一公里"

input_service.rs 把 MouseEvent/KeyEvent 还原成本地输入事件,统一通过 enigo 投递:

fnhandle_mouse_(evt: &MouseEvent, conn: i32) {
if !active_mouse_(conn) { return; }        // ★ 多连接抢占仲裁
let buttons = evt.mask >> 3;               // 高位: 按键
let evt_type = evt.mask & 0x7;             // 低 3 位: 事件类型
    ...
match evt_type {
0 => en.mouse_move_to(evt.x, evt.y),
1 => en.mouse_down(...),               // 按下前先同步修饰键
        ...
    }
}

三个值得抄走的设计:

1. 多连接控制权仲裁active_mouse_):多个主控端同时操作时,只有"最近 1 秒内有输入的那个连接"拥有控制权;且物理鼠标一动就立即抢占(检测真实光标位置与注入位置的距离 MOUSE_ACTIVE_DISTANCE),本地用户永远优先——这是防止远程会话"抢不走机器"的安全阀。

2. 修饰键状态对齐fix_modifiers):远端说"我按下 Ctrl 时",本地可能残留着上次会话没松开的 Alt。每次按键前逐一比对修饰键状态,多按的补松开:

fnfix_modifier(modifiers, key0: ControlKey, key1: Key, en: &mut Enigo) {
if get_modifier_state(key1, en) && !modifiers.contains(&key0) {
        en.key_up(key1);   // 本地按着但远端没按 -> 松开
    }
}
// Windows 的 AltGr(右Alt=Ctrl+Alt) 特判, 避免欧洲键盘布局误伤

3. 按键卡死自愈fix_key_down_timeout_loop):网络断开时远端可能永远收不到 key_up,被控端后台线程扫描所有按下超过阈值的键强制抬起——"小而关键"的健壮性设计。

九、进程模型与 IPC

桌面版默认以双进程运行:

rustdesk.exe            # UI 进程: Flutter 窗口、托盘、文件管理器
rustdesk.exe --server   # 服务进程: 常驻后台, 信令注册、被控服务

两者通过 parity-tokio-ipc(Windows 命名管道 / Unix 域套接字)通信,src/ipc.rs 定义了 40+ 种 Data 消息:配置同步(SyncConfig)、连接管理(Authorize/Close)、权限切换、剪贴板文件、音频输入选择等。

为什么剪贴板服务在 UI 进程?因为它需要访问用户会话桌面(服务进程在 Windows 上跑在 Session 0,抓不到用户剪贴板)。这个"服务进程采集、UI 进程剪贴板、IPC 汇聚"的拓扑是 Windows 远控软件的经典形态(TeamViewer 亦然)。

主控端的消息泵client/io_loop.rs)则用一个大 tokio::select! 同时处理五路事件:

loop {
    tokio::select! {
        res = peer.next() => { ... handle_msg_from_peer(bytes, &mut peer) }  // 网络入包
        d = self.receiver.recv() => { ... handle_msg_from_ui(d, &mut peer) } // UI 指令(键鼠/选项)
        _msg = rx_clip_client.recv() => { ... }                                // 剪贴板文件
        _ = self.timer.tick() => { ... /* 30s 超时断开 + 文件任务分片续传 */ }
        _ = status_timer.tick() => { ... /* 每秒统计速度/fps 上报 UI */ }
    }
}

解码后的视频帧不进 UI 事件队列,而是通过 MediaSender 走独立通道直达 Flutter 渲染层,避免 JSON 序列化大数组。

十、Flutter 桥接:零拷贝传帧

flutter_ffi.rs 通过 flutter_rust_bridge 暴露 300+ 个 API。帧传输的关键类型:

pubenumEventToUI {
    Event(String),                     // 结构化事件(JSON)
    Rgba(ZeroCopyBuffer<Vec<u8>>),     // ★ 视频帧: 零拷贝 RGBA
}

ZeroCopyBuffer 让 Dart 侧直接获得 RGBA 字节流的引用,Canvas 逐帧绘制,不经历 Base64/JSON 编码。UI 侧(flutter/lib/models/model.dart)维护会话状态机,把 Rust 抛上来的事件流转为界面更新。

十一、其它值得玩味的实现

  • 局域网发现:UDP 广播 PeerDiscovery{cmd:"ping"} / "pong",携带 id/hostname/username,实现无服务器内网直连列表(rendezvous_mediator.rs::lan_discovery);
  • 在线状态位图OnlineResponse.states 用每个 ID 一个 bit 的紧凑位图批量返回好友在线状态,协议抠到极致;
  • 直接访问模式direct_server 允许被控端在 21117 端口裸监听 TCP,知道 IP 就能连,完全绕过 hbbs;
  • 文件传输fs.rs 的 TransferJob 把文件分块传输、SHA256 摘要校验(DigestCheckResult)、覆盖检测、断点确认,还有 Windows 剪贴板文件(CLIPRDR)专用通道;
  • 密码安全password_security.rs 提供迭代次数拉满的哈希与两次加盐,防配置文件被拖库后暴力破解;
  • 编译优化[profile.release] lto=true, codegen-units=1, panic='abort', strip=true——发行包体积和性能全都要。

十二、版本演进速览

本文剖析的 v1.2.0 是架构定型版本,之后的演进大都在同一骨架上进行:

  • **1.2 (2023)**:Sciter UI 全面切换到 Flutter 桌面端;Wayland PipeWire 采集成熟;Windows 虚拟显示器;
  • **1.3 (2024)**:多用户同时控制/观看的多会话架构、新的 hbbs API v2、--server 服务化重构、键鼠事件更精细的 keyboard mode(Legacy/Map/Translate);
  • **1.4 (2025)**:UI 侧 HTTP 请求下沉到原生 Rust 提升兼容性、性能优化与大量平台修复、UI 焕新。

服务端 rustdesk-server(hbbs/hbbr)始终是独立的 Rust 仓库,代码量只有客户端的零头,但打洞撮合逻辑与本文 4.4 节严格对应,建议对照阅读。

十三、总结:RustDesk 教会我们什么

从这份代码里能提炼出几条可迁移的工程经验:

  1. 核心与外壳分离:Rust 核心编译为 cdylib/rlib,UI 只是可替换的壳(Sciter → Flutter 的平滑迁移证明了这点);
  2. 协议为业务定制:1 字节起的变长帧头、bit 级在线状态位图——通用方案(Dubbo/gRPC 式 5 字节头)在键鼠高频小包面前就是浪费;
  3. 失败要快,恢复要稳:到处可见 bail!("SWITCH") 快速炸循环再重建,配合指数退避,比在循环里写一堆 if-else 处理状态迁移干净得多;
  4. 单生产者多消费者:屏幕采集编码一次、Arc<Message> 广播,是共享昂贵资源给多连接的范式;
  5. 端到端流控:视频 ACK 闭环让发送速率天然匹配最慢的接收端,无需复杂的网络层拥塞控制;
  6. 协商优于配置:编解码能力评分协商,把"选 H264 还是 VP9"从用户配置变成运行时自动决策;
  7. 安全内建而非外挂:签名 ID + 托管公钥 + NaCl 握手 + 序列号 nonce,中继服务器从头到尾只见密文。

RustDesk 的代码风格并不"学院派"——注释里有大量 to-do、魔术数字和实战 workaround(比如 tokio timer bug 的绕行、NVIDIA 显卡 DXGI 内存泄漏的规避),但这正是一个被真实世界的网络环境和硬件兼容性锤炼过的项目该有的样子。

推荐阅读路径hbb_common/protos/*.proto(10 分钟建立协议观)→ client.rs::_start(连接流程)→ rendezvous_mediator.rs::handle_punch_hole(打洞精髓)→ server/connection.rs::start(会话状态机)→ server/video_service.rs::run(视频主循环)。

(完)

相关学习资料