remote/viewer-core/src/lib.rs
郑博文 174a3f8ac0 feat(viewer-core,android-ffi): 主控端文本上行与 JNI 入口
Session::send_text 收 &[u16] 而不是 &str:整条链(Java 字符串 → JNI → 线格式 →
Windows 注入)的原生表示都是 UTF-16,任何一次 UTF-8 往返都有损,故中间层不做转换。
空输入与超限在本层拦掉,超限走 clamp_text_units 并打日志。

nativeSendText 的参数用 char[](JObject 收再转 JCharArray),经 GetCharArrayRegion
按 u16 直接拷贝。不用 JString + get_string:jni 0.21 那条路走 Modified-UTF-8,
IME 提交边界的落单代理项会在往返里丢。JNI 取数失败必须 eprintln,不能静默——
用户对着远端打字最坏的情况是「看起来发送了」。
2026-10-30 11:41:53 +08:00

455 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<String>, port: u16, code: impl Into<String>) -> 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<u8>,
}
impl From<(VideoFrameHeader, Vec<u8>)> for Frame {
fn from((h, payload): (VideoFrameHeader, Vec<u8>)) -> 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<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_certificate: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_certificate: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
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<Command>,
/// 帧接收端。通道关闭 = 会话结束(被控端断开或流错误)。
/// Mutex 包一层是为了让 `next_frame` 取 `&self`:JNI 层里解码线程独占 recv,
/// 输入线程并发调 `send_input`(走无锁的 unbounded tx),两者互不阻塞。
frames: Arc<tokio::sync::Mutex<mpsc::Receiver<Frame>>>,
stats: Arc<std::sync::Mutex<SessionStats>>,
/// 被控端桌面尺寸(AuthOk 携带)。主控端据此在解码前定好视图宽高比——
/// 若等首帧再改尺寸会触发 SurfaceView 重新测量,把刚创建的解码器 surface 打掉。
desktop: (u32, u32),
/// 保持 Endpoint 存活(drop 会销毁连接)
_endpoint: quinn::Endpoint,
conn: quinn::Connection,
}
impl Session {
/// 建立连接并完成认证握手 + 首帧关键帧请求。
pub async fn connect(cfg: ConnectConfig) -> Result<Self> {
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::<Frame>(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::<Command>();
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<Frame> {
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<Instant> = 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 必须明确报错,不得静默放行"),
}
}
}