//! 原生窗口输入到执行 worker 的纯数据传输。Input 只含窗口/会话/版本/Mount 身份及有限坐标。 //! 连续相机和滚动输入出队时可更新帧版本,但不改变捕获目标和所属 VM;离散点击仍保留严格的过期帧语义。 //! 队列有界且当前逐项经本地 HTTP 发送;这不意味着输入已经按显示帧合并,也不是无损无限队列。 //! //! Native input transport. Frame/mount identities and optional logical pointer positions cross to the VM worker. use serde::{Deserialize, Serialize}; use std::{ io::{Read, Write}, net::TcpStream, sync::{ Arc, RwLock, mpsc::{SyncSender, sync_channel}, }, thread, time::Duration, }; use winit::event_loop::EventLoopProxy; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] /// 拖放目标窗口、目标布局版本和插入位置,源标签身份由外层 Input 保存。 pub struct TabDrop { pub window: u64, pub layout_version: u64, pub index: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] /// 原生或 HTTP 输入的纯数据报文;身份与坐标必须同时通过 worker 和能力校验。 pub struct Input { #[serde(default = "default_window")] pub window: u64, pub session: u64, pub layout_version: u64, pub mount: u64, pub kind: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub point: Option<[f32; 2]>, #[serde(default, skip_serializing_if = "Option::is_none")] pub press_point: Option<[f32; 2]>, #[serde(default, skip_serializing_if = "Option::is_none")] pub delta: Option<[f32; 2]>, #[serde(default, skip_serializing_if = "Option::is_none")] pub tab_drop: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub screen_position: Option<[i32; 2]>, } /// 返回默认主窗口身份,供省略窗口字段的兼容输入使用。 fn default_window() -> u64 { crate::window::MAIN_WINDOW } impl Input { /// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。 pub fn validate(&self) -> Result<(), &'static str> { if self.window == 0 { return Err("window identity must be nonzero"); } if self.kind == "tab-drop" { 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(()); } if self.tab_drop.is_some() { return Err("tab_drop is only accepted for tab-drop"); } 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-"); let valid_delta = self.delta.is_some_and(|delta| { 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() { return Err("invalid page camera input"); } 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)) { return Err("page-zoom requires an anchor and positive scale factor"); } return Ok(()); } 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 self.delta.is_some() { return Err("delta is only accepted for scroll input"); } match (self.point, self.press_point) { (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) => { Ok(()) } _ => Err("pointer click requires finite press/release coordinates"), } } } #[derive(Clone)] /// 原生输入的有界发送端,后台线程串行发往本地 API;所有窗口共享权威发布状态。 pub struct Client(SyncSender); impl Client { /// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。 pub fn new( proxy: EventLoopProxy, namespace: Arc>, ) -> anyhow::Result { let (sender, receiver) = sync_channel::(64); thread::Builder::new() .name("tessera-native-events".into()) .spawn(move || { for mut input in receiver { if let Ok(current) = namespace.read() { if !refresh_continuous_input(&mut input, ¤t) { continue; } } else { continue; } if let Err(error) = send(&input) { let _ = proxy.send_event(crate::app::AppEvent::RuntimeFailed { message: error.to_string(), }); } } })?; Ok(Self(sender)) } /// 通过有界原生输入客户端或本地 HTTP 发送纯数据事件;过期版本按既定协议处理。 pub fn send(&self, input: Input) -> bool { self.0.try_send(input).is_ok() } } // Native gestures retain the captured Mount across publications. Refresh only // the version at dequeue, never session, window or target. HTTP callers and // discrete actions still receive strict stale-frame rejection. /// 连续手势出队时只更新有效目标的帧版本;保留原 session、窗口和 Mount,离散事件不自动重试。 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" ) { 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 { return false; }; 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.canvas_viewport } else if input.kind.starts_with("scene-") { mount.scene3d_viewport && mount.camera_controls } else { mount.scroll.is_some() } }) { return false; } input.layout_version = frame.layout_version; true } /// 通过有界原生输入客户端或本地 HTTP 发送纯数据事件;过期版本按既定协议处理。 fn send(input: &Input) -> anyhow::Result<()> { let body = serde_json::to_string(input)?; let mut stream = TcpStream::connect_timeout(&crate::api::API_ADDRESS.parse()?, Duration::from_secs(2))?; stream.set_read_timeout(Some(Duration::from_secs(10)))?; stream.set_write_timeout(Some(Duration::from_secs(2)))?; write!( stream, "POST /v1/namespaces/app.main/events HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", body.len(), body )?; let mut response = String::new(); stream.take(32768).read_to_string(&mut response)?; let status = response.split_whitespace().nth(1).unwrap_or(""); // An old frame's event is deliberately discarded, with no automatic retry. if status == "409" { return Ok(()); } if status != "200" { let body = response .split_once("\r\n\r\n") .map(|(_, body)| body) .unwrap_or(&response); anyhow::bail!( "Native event failed: {}", body.chars().take(1024).collect::() ); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn queued_native_pan_keeps_each_delta_and_rejects_replaced_targets() { 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(); 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, }; let mut current = crate::document::NamespaceState { runtime_session: 912, document, ..Default::default() }; for _ in 0..12 { 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" ); let mut click = original.clone(); click.kind = "click".into(); assert!(refresh_continuous_input(&mut click, ¤t)); assert_eq!(click.layout_version, original.layout_version); current.runtime_session = 913; assert!(!refresh_continuous_input(&mut original.clone(), ¤t)); current.runtime_session = 912; current.document.frame.mounts.clear(); assert!(!refresh_continuous_input(&mut original.clone(), ¤t)); } #[test] fn drop_metadata_is_scoped_to_its_event_kind_and_bounded() { 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()); input.kind = "click".into(); assert!(input.validate().is_err()); input.kind = "tab-drop".into(); input.tab_drop.as_mut().unwrap().index = 65; assert!(input.validate().is_err()); 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] fn legacy_event_window_defaults_but_zero_is_never_a_resource() { 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()); input.window = 0; assert!(input.validate().is_err()); } }