//! 原生窗口输入到执行 worker 的纯数据传输。Input 只含窗口/会话/版本/Mount 身份及有限坐标。 //! 连续相机和滚动输入出队时可更新帧版本,但不改变捕获目标和所属 VM;离散点击仍保留严格的过期帧语义。 //! 生产端只合并相同目标的相邻绝对指针采样;有序动作是边界。relay 直接进入串行 worker,后台等待确认。 //! //! shell::app 建立 Client 并发送捕获到的 Input;Client 后台 relay 经 api::RuntimeControl::dispatch_native 交给 worker。 //! HTTP 路由也反序列化同一 Input 并调用 validate,但不会走原生 relay 的连续输入版本刷新。 //! 这里只合并同目标相邻 pointermove 的绝对采样;滚动/相机的增量逐条保留,不能用最后一个覆盖累计移动。 use serde::{Deserialize, Serialize}; use std::{ collections::VecDeque, sync::{Arc, Condvar, Mutex, RwLock}, thread, }; use winit::event_loop::EventLoopProxy; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] /// 拖放目标窗口、目标布局版本和插入位置,源标签身份由外层 Input 保存。 pub struct TabDrop { // 目标原生资源 ID,不是 GUI 标签索引或 Page 托管句柄。 pub window: u64, // 事件所依据的已发布布局版本,由执行端校验过期帧;原生连续手势有单独刷新逻辑。 pub layout_version: u64, // 移除前标签序列中的插入缝位置;同窗口向后移动时 dispatch_tab 会做一次下标修正。 pub index: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] /// 原生或 HTTP 输入的纯数据报文;身份与坐标必须同时通过 worker 和能力校验。 pub struct Input { #[serde(default = "default_window")] // 省略仅为兼容旧 HTTP 报文,显式 0 永远无效;原生客户端始终记录真实捕获窗口。 pub window: u64, // 产生事件的运行时会话;重载后即使窗口或 Mount 数值相同也不能沿用旧事件。 pub session: u64, // 产生输入时所见帧的几何版本;同会话内也不能把旧坐标直接用到任意新几何。 pub layout_version: u64, // 捕获的具体出现身份;页面级相机事件使用零,独立视口相机事件要求非零。 pub mount: u64, // 分派标签:click/scroll/相机/标签/工具等语义由 validate 与 UiBridge::dispatch 分工检查。 pub kind: String, #[serde(default, skip_serializing_if = "Option::is_none")] // 事件位置或缩放锚点;click 与 press_point 配对,具体坐标语义由事件 kind 决定。 pub point: Option<[f32; 2]>, #[serde(default, skip_serializing_if = "Option::is_none")] // 高层 click 的按下点,与 point 的释放点一起让 source_hit 确认同一个出现被点击。 pub press_point: Option<[f32; 2]>, #[serde(default, skip_serializing_if = "Option::is_none")] // 平移/滚动增量,或缩放的 [倍率,0];不能无差别接受任意事件携带该字段。 pub delta: Option<[f32; 2]>, #[serde(default, skip_serializing_if = "Option::is_none")] // 仅 tab-drop 可携带,源端身份仍由外层 window/layout_version/mount 指定。 pub tab_drop: Option, #[serde(default, skip_serializing_if = "Option::is_none")] // 拆标签的新窗口位置是屏幕坐标,与页面局部 point 分开;允许多屏环境负坐标。 pub screen_position: Option<[i32; 2]>, } /// serde 在旧报文缺 window 时调用,返回主资源 ID;不会把显式零值改写成合法窗口。 fn default_window() -> u64 { crate::window::MAIN_WINDOW } impl Input { /// API 路由和 UiBridge 分派前调用,验证报文结构;不读取 VM/发布文档,不能证明 session/Mount 仍有效。 pub fn validate(&self) -> Result<(), &'static str> { if self.window == 0 { // 资源身份零保留为非法值,避免丢失 window 的错误报文误投当前窗口。 return Err("window identity must be nonzero"); } // 标签跨窗口放置仅接受目标元信息;禁止同时夹带指针/滚动/屏幕位置,避免混用两套操作。 if self.kind == "tab-drop" { // 缺目标就没有可解释的插入窗口/位置,不从 point 或其他字段推断落点。 let Some(target) = &self.tab_drop else { return Err("tab-drop requires target metadata"); }; if target.window == 0 || target.index > crate::ui_bridge::MAX_PAGES || self.point.is_some() || self.press_point.is_some() || self.delta.is_some() || self.screen_position.is_some() { // 拖放用纯目标元数据,不接受与滚动/点击/拆窗定位混合的多义信封。 return Err("invalid tab drop metadata"); } return Ok(()); } // 非 tab-drop 不得携带目标窗口信息,即使其他字段恰好合法也拒绝。 if self.tab_drop.is_some() { // 非拖放报文即使 kind 本身合法,也不能夹带被另一层意外消费的重排指令。 return Err("tab_drop is only accepted for tab-drop"); } // 屏幕位置只服务 tab-detach 的新窗口定位,允许负数以支持左侧/上方显示器,但限制绝对范围。 if let Some(position) = self.screen_position && (self.kind != "tab-detach" || !position .iter() .all(|value| (-1_000_000..=1_000_000).contains(value))) { return Err("screen_position is only accepted for tab-detach with bounded coordinates"); } if matches!( self.kind.as_str(), "page-pan" | "page-zoom" | "viewport-pan" | "viewport-zoom" | "scene-orbit" | "scene-pan" | "scene-zoom" ) { // 独立视口输入可包含负的局部位置(例如捕获拖动移出视口);页面级锚点要求非负。 let viewport = self.kind.starts_with("viewport-") || self.kind.starts_with("scene-"); // 所有相机操作必须带有限、有界的增量;point 在非缩放操作可省略。 let valid_delta = self.delta.is_some_and(|delta| { // 两个增量分量都要有限,不能只验证 zoom 会使用的第一分量而遗漏非法第二分量。 delta .iter() .all(|v| v.is_finite() && v.abs() <= 1_000_000.0) }); // 非缩放手势可无锚点;存在时仍限制坐标,局部视口可负以支持捕获拖出边界。 let valid_point = self.point.is_none_or(|point| { point .iter() .all(|v| v.is_finite() && v.abs() <= 1_000_000.0 && (viewport || *v >= 0.0)) }); if !valid_delta || !valid_point || (if viewport { self.mount == 0 } else { self.mount != 0 }) || self.press_point.is_some() || self.screen_position.is_some() { // 页面相机 mount 必为零,局部视口必非零,防止把同一增量套到两种目标空间。 return Err("invalid page camera input"); } // 缩放必须给锚点并使用正倍率 0.1..=10;第二分量必须为零,不把缩放与二维平移混合编码。 if self.kind.ends_with("-zoom") && (self.point.is_none() || self .delta .is_none_or(|delta| !(0.1..=10.0).contains(&delta[0]) || delta[1] != 0.0)) { // 这里仅检查倍率/锚点格式;锚点是否仍在实际视口内由 page_input 结合几何检查。 return Err("page-zoom requires an anchor and positive scale factor"); } return Ok(()); } // 滚动需要当前点与二维有限增量,不接受按下点;Mount 能力与身份由执行端进一步核对。 if self.kind == "scroll" { return match (self.point, self.press_point, self.delta) { (Some(point), None, Some(delta)) if point .iter() .all(|v| v.is_finite() && v.abs() <= 1_000_000.0) && delta .iter() .all(|v| v.is_finite() && v.abs() <= 1_000_000.0) => { // 滚动只接点+增量,具体可消费方向、裁剪和嵌套链仍在桥接/滚动模块决定。 Ok(()) } _ => Err("scroll requires point and finite delta, with no press_point"), }; } if matches!( self.kind.as_str(), "pointermove" | "pointerdown" | "pointerup" | "pointerleave" | "pointercancel" ) { // 原始 pointer 报文要求一个明确目标和绝对位置,不允许夹带 click 的成对按下点或增量。 return if self.mount != 0 && self.press_point.is_none() && self.delta.is_none() && self.screen_position.is_none() && self .point .is_some_and(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 1_000_000.)) { // 格式合法后还需检查挂载和 pointer_events;此处不把任意 Mount 变成可交互目标。 Ok(()) } else { // 错误字段或越界坐标统一拒绝,不替发送方删字段修复成另一个事件。 Err("pointer event requires finite point and target") }; } if self.delta.is_some() { // 到这里相机与滚动路径都已返回,其余离散操作不得再消费 delta。 return Err("delta is only accepted for scroll input"); } // 无指针坐标的离散事件可通过此结构校验;若有指针信息,必须是 click 的按下/释放成对坐标。 // 这里不做完整 kind 白名单授权,具体操作仍由后续分发层检查。 match (self.point, self.press_point) { // 无坐标的 focus/标签等离散动作交后续 kind 白名单与对象能力检查。 (None, None) => Ok(()), (Some(point), Some(press)) if self.kind == "click" && point .into_iter() .chain(press) .all(|value| value.is_finite() && value.abs() <= 1_000_000.0) => { // click 必须同时带两个有效端点,避免只在释放点命中就触发按下在别处的按钮。 Ok(()) } _ => Err("pointer click requires finite press/release coordinates"), } } } /// Client 生产端与 relay 共用的有界状态;只保留尚未提交 worker 的 Input,不包含 VM 对象。 struct Queue { // 相邻同目标绝对移动可在队尾覆盖;其他动作是序列屏障,不能越过它们合并采样。 items: VecDeque, // 统计活 Client,而非 Arc 总强引用;后台 relay 自己持 Arc 也不能阻止最后生产者关闭队列。 senders: usize, // 最后生产者释放后置位,receive 醒来退出,不再尝试分派已无人负责的旧输入。 closed: bool, } /// 队列锁与条件变量的共享入口,Client 只短暂加锁,后台等待不占用 GUI 事件循环。 struct Inbox { queue: Mutex, ready: Condvar, } /// shell::app 持有的原生输入发送句柄;后台 relay 负责串行等待 worker 确认,GUI 不等待 VM 执行。 pub struct Client(Arc); impl Clone for Client { /// GUI 多个生产者复制句柄时同步增加逻辑生产者数,保证 Drop 不把仍在用的队列提前关闭。 fn clone(&self) -> Self { self.0.queue.lock().unwrap().senders += 1; Self(self.0.clone()) } } impl Drop for Client { /// 原生生产者离开时减少计数;最后一个关闭、清空并唤醒 relay,不因 relay 自己的 Arc 而挂住线程。 fn drop(&mut self) { if let Ok(mut q) = self.0.queue.lock() { // 锁内计数/关闭与 send/enqueue 互斥,关闭后不留下新的待发报文。 q.senders -= 1; if q.senders == 0 { // 退出丢弃尚未发送输入,不用旧会话的残留交互拖延窗口关闭。 q.closed = true; q.items.clear(); self.0.ready.notify_all(); } } } } /// Client::send 在锁内调用,最多保留 64 条,队尾同目标 pointermove 可覆盖成最新绝对采样。 fn enqueue(queue: &mut VecDeque, input: Input) -> bool { if input.kind == "pointermove" && let Some(last) = queue.back_mut() && last.kind == "pointermove" && (last.window, last.session, last.mount) == (input.window, input.session, input.mount) { // 只覆盖紧邻且同窗/同会话/同 Mount 的移动;按下/抬起或换目标都成为不可跨越的屏障。 *last = input; return true; } if queue.len() >= 64 { // 容量检查在可合并路径之后,因此单一连续移动不会因同类采样填满队列。 return false; } queue.push_back(input); true } impl Inbox { /// relay 的接收循环调用,条件变量等待新事件或关闭,唤醒后再次检查以处理虚假唤醒。 fn receive(&self) -> Option { // 锁损坏视为关闭,不尝试从不可靠队列恢复一条可能重复的输入。 let mut q = self.queue.lock().ok()?; while q.items.is_empty() && !q.closed { // wait 原子放锁并等待;GUI 生产者可入队,通知后重新取得同一状态锁。 q = self.ready.wait(q).ok()?; } if q.closed { None } else { q.items.pop_front() } } } impl Client { /// shell::app 初始化时调用,创建有界 Inbox 与单个 relay;RuntimeControl 直达 worker,不绕 HTTP 回环。 pub fn new( proxy: EventLoopProxy, namespace: Arc>, control: crate::api::RuntimeControl, ) -> anyhow::Result { // 初始生产者就是返回的 Client;receiver 的 Arc 不计入 senders,避免接收线程自保活。 let inbox = Arc::new(Inbox { queue: Mutex::new(Queue { items: VecDeque::new(), senders: 1, closed: false, }), ready: Condvar::new(), }); // move 闭包拥有接收 Arc 与发布状态只读入口,线程不取得 VM 所有权。 let receiver = inbox.clone(); thread::Builder::new() .name("tessera-native-events".into()) .spawn(move || { while let Some(mut input) = receiver.receive() { // 每条输入出队时再看最新权威发布帧,连续采样不沿用排队前已经过时的布局版本。 if let Ok(current) = namespace.read() { if !refresh_continuous_input(&mut input, ¤t) { // 目标消失/换会话时丢弃,不能把旧手势重定向到同位置的新出现。 continue; } } else { // 共享发布状态不可读时不分派缺乏校验的输入,等待下一条正常事件。 continue; } if let Err(error) = control.dispatch_native(input) { // worker 回执错误通过原生事件循环呈现诊断,不直接操作窗口或执行源回调。 let _ = proxy.send_event(crate::app::AppEvent::RuntimeFailed { message: error.to_string(), }); } } })?; Ok(Self(inbox)) } /// shell::app 捕获输入时调用,非阻塞入队/合并后通知 relay;true 不表示 VM 已执行或帧已发布。 pub fn send(&self, input: Input) -> bool { // 锁内仅处理纯数据队列,不把 worker 回执等待放在 GUI 路径。 let Ok(mut q) = self.0.queue.lock() else { return false; }; if q.closed { return false; } // enqueue 的覆盖合并也算接收成功,relay 会读取最终最新样本。 let queued = enqueue(&mut q.items, input); if queued { // 单 relay 只需唤醒一个等待者;关闭路径用 notify_all 保证退出不遗留等待。 self.0.ready.notify_one(); } queued } } /// Client relay 出队时调用,仅给指定类别的原生输入追随同目标新帧版本;不修改捕获目标和会话身份。 /// HTTP 调用不走这个入口;click/标签等其余动作直接保留旧版本,后面仍会严格拒绝过期帧。 pub(crate) fn refresh_continuous_input( input: &mut Input, current: &crate::document::NamespaceState, ) -> bool { if !matches!( input.kind.as_str(), "viewport-pan" | "viewport-zoom" | "scene-pan" | "scene-orbit" | "scene-zoom" | "scroll" | "pointermove" | "pointerdown" | "pointerup" | "pointerleave" | "pointercancel" ) { // 未列入可追随类别的输入既不丢弃也不改写,在执行端按原几何版本验证。 return true; } // 选取事件原窗口的权威帧;窗口已经消失时丢弃,不能把手势改投主窗口。 let frame = if input.window == crate::window::MAIN_WINDOW { Some(¤t.document.frame) } else { current .document .windows .get(&input.window) .map(|document| &document.frame) }; let Some(frame) = frame else { // 原窗口不再有权威帧,不能改选 current/main 去继续应用相机或指针动作。 return false; }; // 必须仍属同一运行时会话、帧无故障且原捕获 Mount 具备对应能力,才可更新布局版本。 if current.runtime_session != input.session || frame.session != input.session || frame.fault.is_some() || !frame.mounts.iter().any(|mount| { mount.id == input.mount && if input.kind.starts_with("viewport-") { // 独立二维相机只要求原 Mount 仍为 CanvasViewport。 mount.canvas_viewport } else if input.kind.starts_with("scene-") { // 3D 还需相机控制器,禁用控制器后旧手势不能继续改模型。 mount.scene3d_viewport && mount.camera_controls } else if input.kind.starts_with("pointer") { // 原始指针必须仍有相应订阅能力,外框存在不足以证明可以继续接收。 mount.pointer_events } else { // 剩余唯一可追随类别是 scroll,需保持滚动能力。 mount.scroll.is_some() } }) { // 保留会话/目标边界,任一权威身份或能力不匹配都不能仅更新版本后继续发送。 return false; } // 只追随新版本,不改 session/window/mount,不重选目标;每条排队增量仍独立发送。 input.layout_version = frame.layout_version; true } #[cfg(test)] mod tests { use super::*; #[test] /// Rust 测试框架调用:用真实持久 VM 反复分派旧帧 pan,验证版本追随不丢增量也不越过会话/目标边界。 fn queued_native_pan_keeps_each_delta_and_rejects_replaced_targets() { // 建立可观测 camera.x 的最小场景,输入目标从实际发布帧选出,不硬编码出现 ID。 let (mut session, document) = crate::endor_host::ReplSession::load( "view=CanvasViewport(200,100,Scene2D(RectBlock(20,20,0)))\npage=Page()\npage.render(view)", "app.main", 1, 912, ).unwrap(); // 找真实 CanvasViewport 出现,确保 refresh_continuous_input 的能力判定有实际帧数据。 let mount = document .frame .mounts .iter() .find(|mount| mount.canvas_viewport) .unwrap() .id; // 捕获初始版本的固定输入,后面每轮都克隆它,模拟输入生产速度超过刷新速度的队列。 let original = Input { window: 1, session: 912, layout_version: document.frame.layout_version, mount, kind: "viewport-pan".into(), point: Some([50., 50.]), delta: Some([3., 0.]), press_point: None, tab_drop: None, screen_position: None, }; // current 表示 relay 每次出队时能读到的最新共享文档,不直接借用 VM 当前内部草稿。 let mut current = crate::document::NamespaceState { runtime_session: 912, document, ..Default::default() }; // 反复从旧帧原始手势克隆输入,验证每条增量都被刷新版本并累计,12×3 应得到 36。 for _ in 0..12 { // 每轮都从最旧版本重建输入,只有 refresh 的行为能让后续 11 条继续有效。 let mut queued = original.clone(); assert!(refresh_continuous_input(&mut queued, ¤t)); current.document = session.dispatch(&queued).unwrap().document.unwrap(); } assert_eq!( session.execute("view.camera.x", "app.main").unwrap().value, "36" ); // 离散点击不得自动刷新版本;随后验证会话替换和目标移除都会拒绝连续手势。 // 同一信封改成离散 click,应只通过“不改写”快路,不享受连续手势的版本追随。 let mut click = original.clone(); click.kind = "click".into(); assert!(refresh_continuous_input(&mut click, ¤t)); assert_eq!(click.layout_version, original.layout_version); // 模拟整程序替换:数字窗口/Mount 即使相同,旧 runtime_session 仍必须使手势失效。 current.runtime_session = 913; assert!(!refresh_continuous_input(&mut original.clone(), ¤t)); current.runtime_session = 912; // 恢复原会话但删掉目标,证明能力/目标检查不只依赖 session 相等。 current.document.frame.mounts.clear(); assert!(!refresh_continuous_input(&mut original.clone(), ¤t)); } #[test] /// Rust 测试框架调用,逐项破坏合法拖放报文,验证种类与辅助字段绑定、标签上限及多屏坐标范围。 fn drop_metadata_is_scoped_to_its_event_kind_and_bounded() { // 从完整合法 JSON 起步,后面的每次修改只触发一种目标限制,避免无关字段同时掩盖失败原因。 let mut input:Input=serde_json::from_str(r#"{"window":1,"session":1,"layout_version":2,"mount":3,"kind":"tab-drop","tab_drop":{"window":2,"layout_version":4,"index":1}}"#).unwrap(); assert!(input.validate().is_ok()); // tab_drop 元数据不可由普通 click 夹带,否则某层可能意外消费成跨窗口移动。 input.kind = "click".into(); assert!(input.validate().is_err()); input.kind = "tab-drop".into(); // 数字 65 超过 MAX_PAGES=64 的结构上界,不需要真实 VM 窗口即可拒绝。 input.tab_drop.as_mut().unwrap().index = 65; assert!(input.validate().is_err()); // 拆窗使用另一种辅助元数据,先去掉 tab_drop,再验证负屏幕坐标合法但极值不合法。 input.kind = "tab-detach".into(); input.tab_drop = None; input.screen_position = Some([-400, 200]); assert!(input.validate().is_ok()); input.screen_position = Some([i32::MIN, 0]); assert!(input.validate().is_err()); } #[test] /// Rust 测试框架调用,区分 serde 的缺字段默认与明确传零,保证兼容不掩盖非法资源身份。 fn legacy_event_window_defaults_but_zero_is_never_a_resource() { // 故意省略 window,检查反序列化确实调用 default_window,而非字段初始零值。 let mut input: Input = serde_json::from_str(r#"{"session":1,"layout_version":2,"mount":3,"kind":"focus"}"#) .unwrap(); assert_eq!(input.window, crate::window::MAIN_WINDOW); assert!(input.validate().is_ok()); // 显式修改为零必须拒绝,不能再次触发 default_window 纠正成主窗口。 input.window = 0; assert!(input.validate().is_err()); } } #[cfg(test)] mod coalescing_tests { use super::*; #[test] /// Rust 测试框架调用,验证生产端高频绝对移动可合并,动作顺序和跨窗口目标切换仍保持屏障。 fn producer_side_motion_coalescing_keeps_action_and_target_boundaries() { // 默认主窗口、固定 session/Mount 的原始绝对位置采样,供后续复制成不同动作。 let template: Input = serde_json::from_str( r#"{"session":1,"layout_version":1,"mount":2,"kind":"pointermove","point":[0,0]}"#, ) .unwrap(); // 直接测试 enqueue 的纯队列规则,不启动 relay 或 VM,避免异步消费影响可观察顺序。 let mut q = VecDeque::new(); for i in 0..1000 { // 即使远超容量 64,相邻同目标 pointermove 也应持续覆盖唯一队尾项。 let mut sample = template.clone(); sample.point = Some([i as f32, 0.]); assert!(enqueue(&mut q, sample)); } assert_eq!(q.len(), 1); assert_eq!(q[0].point.unwrap()[0], 999.); // down 是离散动作屏障,不能被下一次移动覆盖或跨过。 let mut down = template.clone(); down.kind = "pointerdown".into(); enqueue(&mut q, down); // 新移动只能形成 down 后的一项,不能合并回 down 前的旧移动。 let mut moved = template.clone(); moved.point = Some([1200., 0.]); enqueue(&mut q, moved); // up 再次切开动作序列,保证处理器观察先按下、后移动、再抬起。 let mut up = template.clone(); up.kind = "pointerup".into(); enqueue(&mut q, up); // 相同 session/Mount 但换窗口也必须单独成项,目标比较不能漏掉 window。 let mut other = template; other.window = 2; enqueue(&mut q, other); assert_eq!( q.iter().map(|i| i.kind.as_str()).collect::>(), [ "pointermove", "pointerdown", "pointermove", "pointerup", "pointermove" ] ); assert_eq!(q.back().unwrap().window, 2); } }