//! viewer-core:安卓主控的连接 / 协议 / 收帧层(纯 Rust,平台无关)。 //! //! 设计边界(对齐《多平台架构设计》与 2026-10-29 方案裁决): //! - **只做「怎么连 + 收发什么」**:QUIC 会话、认证握手、视频帧流、输入上行、 //! Ping/Pong、KeyFrameReq。解码(MediaCodec)、渲染(SurfaceView)、触摸映射 //! 全在 Kotlin 侧,本 crate 不碰像素,也不引入 openh264。 //! - **连接层是唯一的可替换点**:`PeerTarget::Direct` 现在只支持 `IP:端口` 局域网 //! 直连;将来设备码 + 信令 + 打洞只换 `connect()` 内部。**不定义 Relay 变体** //! (中继已被团队裁决排除,见《多平台架构设计》§6.1)。 //! - 帧经 mpsc 通道交付,**不丢帧**:消费方(Kotlin)按 `keyframe` 标志自行决定 //! 跳帧策略,且跳帧后必须 `request_keyframe()` 并等 IDR(协议 v2 提供该字段)。 //! 丢帧决策放在这里会让 Rust 层承担它看不到的解码器状态。 //! //! 安全现状:`insecure_skip_verify` 默认 true(与桌面 viewer 的 SkipVerify 一致, //! 仅限局域网测试)。产品化前必须换成信令下发的证书指纹钉扎(`signaling.rs` //! 已有 `CertFingerprint`),该项登记在方案阶段 4。 use anyhow::{bail, Context, Result}; use common::{ read_control, read_video_frame, write_control, ControlMsg, InputEvent, VideoFrameHeader, PROTO_VERSION, }; use rustls::client::danger::{ HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier, }; use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; use rustls::{DigitallySignedStruct, SignatureScheme}; use std::net::ToSocketAddrs; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::mpsc; /// 连接目标。当前只有局域网直连;设备码 + 打洞在后续阶段替换 `connect()` 内部实现。 #[derive(Debug, Clone)] pub enum PeerTarget { /// 局域网 / 可直连地址 Direct { host: String, port: u16 }, } /// 一次会话的参数 #[derive(Debug, Clone)] pub struct ConnectConfig { pub target: PeerTarget, /// 被控端验证码 pub code: String, /// 仅测试用:跳过服务端证书校验(与桌面 viewer 行为一致)。 /// 公网/产品形态必须为 false 并启用证书钉扎。 pub insecure_skip_verify: bool, } impl ConnectConfig { /// 局域网测试的便捷构造 pub fn direct(host: impl Into, port: u16, code: impl Into) -> Self { Self { target: PeerTarget::Direct { host: host.into(), port, }, code: code.into(), insecure_skip_verify: true, } } } /// 交付给消费方的一帧视频。字段语义与 `common::VideoFrameHeader` 一致, /// payload 为标准 Annex-B H.264(可直接喂 MediaCodec)。 #[derive(Debug, Clone)] pub struct Frame { pub width: u32, pub height: u32, pub ts_ms: u64, pub seq: u64, pub keyframe: bool, pub payload: Vec, } impl From<(VideoFrameHeader, Vec)> for Frame { fn from((h, payload): (VideoFrameHeader, Vec)) -> Self { Self { width: h.width, height: h.height, ts_ms: h.ts_ms, seq: h.seq, keyframe: h.keyframe, payload, } } } /// 会话状态快照(供 UI 显示,不做 EMA——那属于 Kotlin 的 HUD 层) #[derive(Debug, Clone, Copy, Default)] pub struct SessionStats { /// 最近一次 Ping 往返(ms),0 = 还没测出 pub rtt_ms: u64, /// 收到的帧数 pub frames_in: u64, /// 收到的字节数(视频载荷累计) pub bytes_in: u64, } /// MVP:跳过服务端证书校验(自签证书,仅局域网)。 #[derive(Debug)] struct SkipVerify; impl ServerCertVerifier for SkipVerify { fn verify_server_cert( &self, _end_entity: &CertificateDer<'_>, _intermediates: &[CertificateDer<'_>], _server_name: &ServerName<'_>, _ocsp_response: &[u8], _now: UnixTime, ) -> std::result::Result { Ok(ServerCertVerified::assertion()) } fn verify_tls12_signature( &self, _message: &[u8], _certificate: &CertificateDer<'_>, _dss: &DigitallySignedStruct, ) -> std::result::Result { Ok(HandshakeSignatureValid::assertion()) } fn verify_tls13_signature( &self, _message: &[u8], _certificate: &CertificateDer<'_>, _dss: &DigitallySignedStruct, ) -> std::result::Result { Ok(HandshakeSignatureValid::assertion()) } fn supported_verify_schemes(&self) -> Vec { vec![ SignatureScheme::RSA_PKCS1_SHA256, SignatureScheme::ECDSA_NISTP256_SHA256, SignatureScheme::ED25519, SignatureScheme::RSA_PSS_SHA256, ] } } /// 输入/控制命令(写入流 2) enum Command { Input(InputEvent), KeyFrameReq, Ping(u64), } /// 已建立的会话。Drop 时后台任务随连接关闭自然退出;显式 `close()` 更干净。 pub struct Session { tx: mpsc::UnboundedSender, /// 帧接收端。通道关闭 = 会话结束(被控端断开或流错误)。 /// Mutex 包一层是为了让 `next_frame` 取 `&self`:JNI 层里解码线程独占 recv, /// 输入线程并发调 `send_input`(走无锁的 unbounded tx),两者互不阻塞。 frames: Arc>>, stats: Arc>, /// 被控端桌面尺寸(AuthOk 携带)。主控端据此在解码前定好视图宽高比—— /// 若等首帧再改尺寸会触发 SurfaceView 重新测量,把刚创建的解码器 surface 打掉。 desktop: (u32, u32), /// 保持 Endpoint 存活(drop 会销毁连接) _endpoint: quinn::Endpoint, conn: quinn::Connection, } impl Session { /// 建立连接并完成认证握手 + 首帧关键帧请求。 pub async fn connect(cfg: ConnectConfig) -> Result { rustls::crypto::ring::default_provider() .install_default() .ok(); let tls = if cfg.insecure_skip_verify { rustls::ClientConfig::builder() .dangerous() .with_custom_certificate_verifier(Arc::new(SkipVerify)) .with_no_client_auth() } else { // 证书钉扎尚未接入(方案阶段 4)。这里明确报错而不是静默放行。 bail!("insecure_skip_verify=false 需要证书钉扎,尚未实现(见方案阶段 4)"); }; let qc: quinn::crypto::rustls::QuicClientConfig = Arc::new(tls) .try_into() .context("build QUIC client config")?; // 接收窗口放大:视频下行大流量,quinn 默认 512KB 流窗口会卡吞吐 // (与桌面 viewer / agent 的实测调优一致) let mut transport = quinn::TransportConfig::default(); transport.receive_window(quinn::VarInt::from_u32(16 * 1024 * 1024)); transport.stream_receive_window(quinn::VarInt::from_u32(8 * 1024 * 1024)); let mut client_config = quinn::ClientConfig::new(Arc::new(qc)); client_config.transport_config(Arc::new(transport)); let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse()?)?; endpoint.set_default_client_config(client_config); let PeerTarget::Direct { host, port } = &cfg.target; let addr = (host.as_str(), *port) .to_socket_addrs()? .next() .with_context(|| format!("resolve {host}:{port}"))?; let conn = endpoint .connect(addr, "localhost")? .await .with_context(|| format!("QUIC connect {addr}"))?; // ---- 流 1:认证 + 视频下行 ---- let (mut v_send, mut v_recv) = conn.open_bi().await?; write_control( &mut v_send, &ControlMsg::Auth { code: cfg.code.clone(), proto_version: PROTO_VERSION, }, ) .await .context("send Auth")?; let desktop: (u32, u32); match read_control(&mut v_recv).await.context("read AuthOk")? { ControlMsg::AuthOk { width, height, proto_version, } => { if proto_version != PROTO_VERSION { bail!( "protocol mismatch: agent={proto_version}, viewer-core={PROTO_VERSION}" ); } desktop = (width, height); log(&format!("auth ok, remote desktop {width}x{height}")); } ControlMsg::AuthFail(e) => bail!("auth failed: {e}"), m => bail!("unexpected first message: {m:?}"), } // ---- 流 2:输入 + 控制 + Ping/Pong ---- let (mut ctl_send, mut ctl_recv) = conn.open_bi().await?; // 入会即请求 IDR:从 GOP 中间接入时裸 P 帧解不出来(桌面端同款教训) write_control(&mut ctl_send, &ControlMsg::KeyFrameReq).await?; let stats = Arc::new(std::sync::Mutex::new(SessionStats::default())); // 帧通道:容量 2 足够——消费方(Kotlin)每帧都要喂解码器, // 积压超过 2 帧说明该跳帧的是消费方的决策,不是这里的。 let (frame_tx, frames) = mpsc::channel::(2); { let stats = stats.clone(); tokio::spawn(async move { loop { match read_video_frame(&mut v_recv).await { Ok((h, payload)) => { { let mut s = stats.lock().unwrap(); s.frames_in += 1; s.bytes_in += payload.len() as u64; } let frame: Frame = (h, payload).into(); if frame_tx.send(frame).await.is_err() { break; // 消费方已退出 } } Err(e) => { log(&format!("video stream ended: {e}")); break; } } } }); } // 命令通道 → 流 2 写任务 let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::(); tokio::spawn(async move { loop { let Some(cmd) = cmd_rx.recv().await else { break; }; let msg = match cmd { Command::Input(ev) => ControlMsg::Input(ev), Command::KeyFrameReq => ControlMsg::KeyFrameReq, Command::Ping(t) => ControlMsg::Ping(t), }; if write_control(&mut ctl_send, &msg).await.is_err() { break; } } }); // 流 2 读任务:Pong → RTT { let stats = stats.clone(); tokio::spawn(async move { loop { match read_control(&mut ctl_recv).await { Ok(ControlMsg::Pong { t, .. }) => { let rtt = monotonic_ms().saturating_sub(t); stats.lock().unwrap().rtt_ms = rtt; } Ok(_) => {} Err(e) => { log(&format!("control stream ended: {e}")); break; } } } }); } // Ping 任务:500ms 一次(与桌面 viewer 一致) { let cmd_tx_ping = cmd_tx.clone(); tokio::spawn(async move { loop { tokio::time::sleep(Duration::from_millis(500)).await; if cmd_tx_ping.send(Command::Ping(monotonic_ms())).is_err() { break; } } }); } Ok(Self { tx: cmd_tx, frames: Arc::new(tokio::sync::Mutex::new(frames)), stats, desktop, _endpoint: endpoint, conn, }) } /// 被控端桌面尺寸 `(width, height)`,来自 AuthOk,解码前即可用。 pub fn desktop_size(&self) -> (u32, u32) { self.desktop } /// 取下一帧。`None` = 会话结束。 /// `&self`:JNI 层里解码线程独占 recv,输入线程可并发 `send_input`。 pub async fn next_frame(&self) -> Option { self.frames.lock().await.recv().await } /// 发送一个输入事件(非阻塞入队,由写任务串行发出)。 pub fn send_input(&self, ev: InputEvent) { let _ = self.tx.send(Command::Input(ev)); } /// 协议 v3:发送一段文本(UTF-16 code unit)到被控端注入。 /// /// 为什么参数是 `&[u16]` 而不是 `&str`:被控端的注入通道(Windows /// `KEYEVENTF_UNICODE`)与主控端来源(Java 字符串)都是 UTF-16 code unit, /// 中间任何一次 UTF-8 往返都会把 IME 提交边界的落单代理项变成替换字符。 /// 见 `common::InputEvent::Text` 的取舍说明。 /// /// 超限处理:截断到 [`common::MAX_TEXT_UNITS`] 并打日志——静默丢弃会让用户 /// 以为长文本已送达。日志只报长度,不报内容。 pub fn send_text(&self, units: &[u16]) { if units.is_empty() { return; // 空提交没有注入语义,也不该占用一条消息 } let (units, truncated) = common::clamp_text_units(units.to_vec()); if truncated { log(&format!( "文本超过上限,已截断到 {} 个 code unit", common::MAX_TEXT_UNITS )); } self.send_input(InputEvent::Text { units }); } /// 请求被控端强制关键帧(解码器错误/跳帧后的恢复路径)。 pub fn request_keyframe(&self) { let _ = self.tx.send(Command::KeyFrameReq); } /// 当前会话统计快照。 pub fn stats(&self) -> SessionStats { *self.stats.lock().unwrap() } /// 被控端地址(日志/UI 用) pub fn peer_addr(&self) -> String { self.conn.remote_address().to_string() } /// 主动结束会话。 pub async fn close(self) { let _ = self.conn.close(0u32.into(), b"viewer closing"); } } /// 单调毫秒时钟(进程启动起算)。Ping/Pong 两端共用同一口径即可测 RTT。 fn monotonic_ms() -> u64 { use std::sync::OnceLock; static T0: OnceLock = OnceLock::new(); T0.get_or_init(Instant::now).elapsed().as_millis() as u64 } /// 轻量日志出口。阶段 2 接 UniFFI 时替换为 Kotlin 侧回调。 fn log(msg: &str) { eprintln!("[viewer-core] {msg}"); } #[cfg(test)] mod tests { use super::*; #[test] fn frame_from_header_preserves_contract_fields() { let h = VideoFrameHeader { width: 1920, height: 1080, ts_ms: 42, seq: 7, keyframe: true, }; let payload = vec![0u8; 16]; let f: Frame = (h.clone(), payload.clone()).into(); assert_eq!((f.width, f.height), (1920, 1080)); assert_eq!(f.seq, 7); assert!(f.keyframe); assert_eq!(f.payload, payload); } #[test] fn direct_config_defaults_to_insecure_lan_mode() { let c = ConnectConfig::direct("192.168.1.5", 47800, "abc"); assert!(c.insecure_skip_verify); match c.target { PeerTarget::Direct { host, port } => { assert_eq!(host, "192.168.1.5"); assert_eq!(port, 47800); } } } #[tokio::test] async fn strict_verify_mode_is_explicit_unimplemented_error() { // 安全红线不能静默:显式要求校验时必须是明确错误,而不是悄悄放行。 // 不用 unwrap_err():它要求 Ok 类型(Session)实现 Debug,而连接对象 // 的 Debug 输出没有验证价值。 let cfg = ConnectConfig { target: PeerTarget::Direct { host: "127.0.0.1".into(), port: 1, }, code: "x".into(), insecure_skip_verify: false, }; match Session::connect(cfg).await { Err(e) => assert!(e.to_string().contains("证书钉扎"), "实际错误: {e}"), Ok(_) => panic!("insecure_skip_verify=false 必须明确报错,不得静默放行"), } } }