feat(agent-core): ScreenCapturer 新增 pointer_state 默认实现并接入帧头

v5 光标字段在平台无关会话层的接缝(4b/Q4=A:Linux 实装另立循环):

- trait 新增 pointer_state(),默认实现返回 None + 会话期一次性日志——
  与 InputInjector::text() 未实现先例同款纪律(异常路径必须可见,且不逐帧刷屏)
- encode_send 增 cursor 参数,两条发送路径(新帧/静止强制 IDR)都传入
- 静止保活重发分支:split 出的旧帧头沿用会让标记冻住(画面静止≠指针静止),
  与 seq/ts_ms 同规重建 cursor
- 新增单测锁默认实现契约(None 且 panic-free,Linux/X11 端真实形态)
- linux-agent 不强制实现(cargo build --workspace 复验编译通过)
This commit is contained in:
郑博文 2026-11-02 10:36:47 +08:00
parent b1ac32175c
commit ad46fc805b

View File

@ -40,6 +40,22 @@ pub trait ScreenCapturer: Send {
/// 取出最近一帧,BGRA 紧凑排列(已去掉 row pitch),供 openh264 直接编码。 /// 取出最近一帧,BGRA 紧凑排列(已去掉 row pitch),供 openh264 直接编码。
fn copy_frame(&mut self) -> Result<Vec<u8>>; fn copy_frame(&mut self) -> Result<Vec<u8>>;
/// 协议 v5 的光标采样:当前指针在**采集画面内**的物理像素坐标。
/// 语义与折叠规则(隐藏/离屏 → None)见 `common::VideoFrameHeader::cursor`。
///
/// **默认实现刻意返回 None,但必须留下可见痕迹**:Linux 后端本轮未实现光标采样
/// (X11 `query_pointer` 与 Windows `GetCursorInfo` 不同构,且本机无 X11 无法给
/// proof——4b/Q4=A 裁决另立循环)。全局规则要求异常路径有可见后果——安卓用户对着
/// Linux 被控端看不到标记不能是静默的,日志必须说明是没实现。一次性日志防刷屏
/// (本方法每帧都会被调,最高 60Hz)。
fn pointer_state(&self) -> Option<(i16, i16)> {
static LOGGED_UNIMPL: AtomicBool = AtomicBool::new(false);
if !LOGGED_UNIMPL.swap(true, Ordering::Relaxed) {
eprintln!("[agent-core] 光标采样后端未实现:帧头 cursor 将恒为 None");
}
None
}
} }
/// 输入注入后端。实现需可在异步运行时中被多任务共享,故要求 `Send + Sync`。 /// 输入注入后端。实现需可在异步运行时中被多任务共享,故要求 `Send + Sync`。
@ -176,6 +192,7 @@ fn encode_send(
(w, h): (u32, u32), (w, h): (u32, u32),
ts_ms: u64, ts_ms: u64,
seq: u64, seq: u64,
cursor: Option<(i16, i16)>,
tx: &watch::Sender<Option<Vec<u8>>>, tx: &watch::Sender<Option<Vec<u8>>>,
last_frame: &mut Option<Vec<u8>>, last_frame: &mut Option<Vec<u8>>,
) -> bool { ) -> bool {
@ -189,8 +206,7 @@ fn encode_send(
ts_ms, ts_ms,
seq, seq,
keyframe: h264_contains_idr(&payload), keyframe: h264_contains_idr(&payload),
// v5 光标字段:占位 None,本 crate 的真实采样在下一步接入 cursor,
cursor: None,
}; };
let frame = encode_video_frame(&header, &payload); let frame = encode_video_frame(&header, &payload);
if tx.send(Some(frame.clone())).is_err() { if tx.send(Some(frame.clone())).is_err() {
@ -249,7 +265,16 @@ fn capture_session(
encoder.force_intra_frame(); encoder.force_intra_frame();
} }
let ts = start.elapsed().as_millis() as u64; let ts = start.elapsed().as_millis() as u64;
if !encode_send(&mut encoder, &raw, (w, h), ts, seq, tx, &mut last_frame) { if !encode_send(
&mut encoder,
&raw,
(w, h),
ts,
seq,
cap.pointer_state(),
tx,
&mut last_frame,
) {
return LoopEnd::ChannelClosed; // 会话结束 return LoopEnd::ChannelClosed; // 会话结束
} }
seq = seq.wrapping_add(1); seq = seq.wrapping_add(1);
@ -288,7 +313,16 @@ fn capture_session(
encoder.force_intra_frame(); encoder.force_intra_frame();
let raw = last_raw.as_ref().expect("can_key 已保证"); let raw = last_raw.as_ref().expect("can_key 已保证");
let ts = start.elapsed().as_millis() as u64; let ts = start.elapsed().as_millis() as u64;
if !encode_send(&mut encoder, raw, (w, h), ts, seq, tx, &mut last_frame) { if !encode_send(
&mut encoder,
raw,
(w, h),
ts,
seq,
cap.pointer_state(),
tx,
&mut last_frame,
) {
return LoopEnd::ChannelClosed; return LoopEnd::ChannelClosed;
} }
seq = seq.wrapping_add(1); seq = seq.wrapping_add(1);
@ -302,6 +336,9 @@ fn capture_session(
Ok((mut hdr, payload)) => { Ok((mut hdr, payload)) => {
hdr.seq = seq; hdr.seq = seq;
hdr.ts_ms = start.elapsed().as_millis() as u64; hdr.ts_ms = start.elapsed().as_millis() as u64;
// v5 与 seq/ts_ms 同理:重发帧的 cursor 若沿用旧值会让标记
// 冻住(画面静止≠指针静止,触控板用户尤甚)
hdr.cursor = cap.pointer_state();
let frame = encode_video_frame(&hdr, payload); let frame = encode_video_frame(&hdr, payload);
if tx.send(Some(frame)).is_err() { if tx.send(Some(frame)).is_err() {
return LoopEnd::ChannelClosed; return LoopEnd::ChannelClosed;
@ -692,4 +729,25 @@ mod tests {
dispatch_input(&inj, InputEvent::Text { units: vec![] }); dispatch_input(&inj, InputEvent::Text { units: vec![] });
assert_eq!(inj.received.lock().unwrap().clone(), vec![0]); assert_eq!(inj.received.lock().unwrap().clone(), vec![0]);
} }
/// v5:采集后端未实现 `pointer_state` 时必须返回 None 且不 panic——Linux/X11 端
/// 本轮的真实形态(4b/Q4=A:默认 None + 一次性日志,实装另立循环)。
/// 锁的是「默认实现存在且语义为 None」这条契约:若有人把默认实现改成 panic 或
/// 伪造坐标,这条会红。
#[test]
fn pointer_state_default_returns_none_without_panic() {
struct NoPointerCapturer;
impl ScreenCapturer for NoPointerCapturer {
fn size(&self) -> (u32, u32) {
(100, 100)
}
fn acquire(&mut self, _timeout_ms: u32) -> Result<bool> {
Ok(false)
}
fn copy_frame(&mut self) -> Result<Vec<u8>> {
Ok(Vec::new())
}
}
assert_eq!(NoPointerCapturer.pointer_state(), None);
}
} }