diff --git a/curios/harmony-connector/curio.json b/curios/harmony-connector/curio.json index 0890d9a..73ead85 100644 --- a/curios/harmony-connector/curio.json +++ b/curios/harmony-connector/curio.json @@ -3,7 +3,7 @@ "id": "dev.trove.harmony-connector", "name": "Harmony Connector", "description": "连接鸿蒙设备,收发完整文件夹、管理应用,查看手机画面并通过键鼠操作。", - "version": "0.3.2", + "version": "0.4.0", "engine": { "trove": ">=0.1.0 <1.0.0" }, "icon": "assets/icon.svg", "ui": { diff --git a/curios/harmony-connector/native/Cargo.lock b/curios/harmony-connector/native/Cargo.lock index f823192..e4e504d 100644 --- a/curios/harmony-connector/native/Cargo.lock +++ b/curios/harmony-connector/native/Cargo.lock @@ -204,7 +204,7 @@ dependencies = [ [[package]] name = "harmony-connector" -version = "0.3.2" +version = "0.4.0" dependencies = [ "anyhow", "base64", diff --git a/curios/harmony-connector/native/Cargo.toml b/curios/harmony-connector/native/Cargo.toml index 2cc9eb9..57757ec 100644 --- a/curios/harmony-connector/native/Cargo.toml +++ b/curios/harmony-connector/native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "harmony-connector" -version = "0.3.2" +version = "0.4.0" edition = "2021" publish = false diff --git a/curios/harmony-connector/native/src/hdc.rs b/curios/harmony-connector/native/src/hdc.rs index a0043ad..67915cf 100644 --- a/curios/harmony-connector/native/src/hdc.rs +++ b/curios/harmony-connector/native/src/hdc.rs @@ -2,6 +2,7 @@ mod files; mod screen; mod screen_agent; mod screen_input; +mod screen_lease; mod screen_protocol; use crate::{ command, hdc_data, diff --git a/curios/harmony-connector/native/src/hdc/screen.rs b/curios/harmony-connector/native/src/hdc/screen.rs index 6d68df2..de61f63 100644 --- a/curios/harmony-connector/native/src/hdc/screen.rs +++ b/curios/harmony-connector/native/src/hdc/screen.rs @@ -28,11 +28,22 @@ struct Session { touched: Instant, stream: Arc, } -#[derive(Default)] pub(super) struct Screen { current: Mutex>, next: AtomicU64, lifecycle: Mutex<()>, + directory: PathBuf, +} +impl Default for Screen { + fn default() -> Self { + let directory = env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join("Library/HarmonyConnector/sessions")) + .unwrap_or_else(|| { + env::temp_dir().join(format!("harmony-screen-{}", unsafe { libc::geteuid() })) + }); + Self::new(directory) + } } #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -71,6 +82,14 @@ fn missing() -> Fault { Fault::new("screen.closed", "投屏已停止,请重新开始。") } impl Screen { + pub fn new(directory: PathBuf) -> Self { + Self { + current: Mutex::new(None), + next: AtomicU64::new(0), + lifecycle: Mutex::new(()), + directory, + } + } pub fn close(&self, id: Option) -> Result { let _lifecycle = self.lifecycle.lock().unwrap(); let session = { @@ -84,7 +103,7 @@ impl Screen { }; if let Some(session) = session { session.cancel.store(true, Ordering::Release); - session.stream.stop(); + session.stream.stop()?; } Ok(json!({"stopped":true})) } @@ -100,7 +119,7 @@ impl Screen { return Err(Fault::new("screen.active", "请先停止当前投屏。")); } if let Some(session) = current.take() { - session.stream.stop(); + session.stream.stop()?; } } tool.connected(&target)?; @@ -108,7 +127,7 @@ impl Screen { .shell(&target, "uitest uiInput help") .unwrap_or_default(); let mut supported = screen_input::capabilities(&help); - let stream = Arc::new(screen_agent::Stream::open(&tool, &target)?); + let stream = Arc::new(screen_agent::Stream::open(&tool, &target, &self.directory)?); for input in [ "click", "doubleClick", @@ -184,7 +203,9 @@ impl Screen { let previous = s.frame.as_ref().map_or(0, |f| if f.sequence != frame.sequence {f.sequence} else {f.previous_sequence}); if changed { s.control = false; } let mut value = json!({"session":s.id,"sequence":frame.sequence,"geometry":frame.geometry, - "width":frame.width,"height":frame.height,"frameAgeMs":frame.received.elapsed().as_millis(), + "width":frame.width,"height":frame.height, + "imageWidth":frame.image_width,"imageHeight":frame.image_height, + "captureWidth":frame.width.div_ceil(2),"captureHeight":frame.height.div_ceil(2),"frameAgeMs":frame.received.elapsed().as_millis(), "receivedFps":fps,"mbps":mbps,"control":s.control,"rotated":changed,"unchanged":frame.sequence==request.after}); if frame.sequence != request.after { value["dataUrl"] = format!("data:image/jpeg;base64,{}",STANDARD.encode(frame.jpeg.as_slice())).into(); diff --git a/curios/harmony-connector/native/src/hdc/screen_agent.rs b/curios/harmony-connector/native/src/hdc/screen_agent.rs index e24c39d..fed7ae9 100644 --- a/curios/harmony-connector/native/src/hdc/screen_agent.rs +++ b/curios/harmony-connector/native/src/hdc/screen_agent.rs @@ -14,7 +14,7 @@ use std::{ const AGENT: &[u8] = include_bytes!("../../../../../vendor/harmony-connector/hypium/uitest_agent_v1.2.2.so"); -const SOCKET: &str = "localabstract:uitest_socket"; +use super::screen_lease::{self, Lease as DeviceLease, SOCKET}; const LEASE: Duration = Duration::from_secs(15); fn fault(message: impl Into) -> Fault { @@ -109,6 +109,10 @@ impl Rpc { json!({"api":api,"this":self.driver,"args":args,"message_type":"hypium"}), ) } + fn stop_capture(&mut self) -> Result<()> { + // Captures.stopCaptureScreen has no guaranteed response on this agent. + self.send("Captures", json!({"api":"stopCaptureScreen","args":{}})) + } fn dimensions(&mut self) -> Result<(u32, u32)> { let value = self.driver("Driver.getDisplaySize", json!([]))?; let dimension = |key: &str| { @@ -121,52 +125,121 @@ impl Rpc { Ok((dimension("x")?, dimension("y")?)) } } +fn existing_agents(tool: &Tool, target: &str) -> Result> { + let pids = tool.run_output(&["-t", target, "shell", "pidof uitest"], 2)?; + let mut owned = Vec::new(); + for pid in pids.split_whitespace() { + if !pid.bytes().all(|b| b.is_ascii_digit()) { + return Err(fault("无法检查设备调试会话。")); + } + let cmdline = tool.run_output( + &["-t", target, "shell", &format!("cat /proc/{pid}/cmdline")], + 2, + )?; + if cmdline.contains("No such file") { + continue; + } + let args: Vec<_> = cmdline.split('\0').filter(|s| !s.is_empty()).collect(); + let is_uitest = args + .first() + .is_some_and(|s| Path::new(s).file_name().is_some_and(|n| n == "uitest")); + if is_uitest && args.get(1) != Some(&"start-daemon") { + continue; + } + let name = args + .windows(2) + .find(|pair| pair[0] == "--extension-name") + .map(|pair| pair[1]); + let ours = name + .and_then(|name| name.strip_prefix("trove-screen-")) + .and_then(|name| name.strip_suffix(".so")) + .is_some_and(|name| name.len() == 6 && name.bytes().all(|b| b.is_ascii_alphanumeric())); + if !is_uitest || args.get(2) != Some(&"singleness") || !ours { + return Err(Fault::new( + "screen.debugger_busy", + "设备的 UI 调试服务由其他工具启动,请先结束该调试会话。", + )); + } + owned.push((pid.to_owned(), cmdline)); + } + Ok(owned) +} +fn terminate_agent(tool: &Tool, target: &str, pid: &str, expected: &str) -> Result<()> { + // Recheck identity before signalling: a PID may have been reused. + let command = format!("cat /proc/{pid}/cmdline"); + if tool.run_output(&["-t", target, "shell", &command], 2)? != expected { + return Ok(()); + } + // Official xdevice uses SIGKILL. Commercial firmware may reject SIGTERM. + tool.run(&["-t", target, "shell", &format!("kill -9 {pid}")], 2)?; + let deadline = Instant::now() + Duration::from_secs(1); + while tool.run_output(&["-t", target, "shell", &command], 2)? == expected { + if Instant::now() >= deadline { + return Err(Fault::new( + "screen.cleanup_failed", + "未能结束插件的投屏代理,请重新连接后重试。", + )); + } + thread::sleep(Duration::from_millis(20)); + } + Ok(()) +} struct DeviceSession { tool: Tool, target: String, - name: String, + uploaded: Option, forward: Option, + lease: DeviceLease, } -impl Drop for DeviceSession { - fn drop(&mut self) { +impl DeviceSession { + fn close(&mut self) -> Result<()> { let cleanup = Tool { path: self.tool.path.clone(), cancel: Arc::new(AtomicBool::new(false)), }; - if let Some(local) = &self.forward { - let _ = cleanup.run(&["-t", &self.target, "fport", "rm", local, SOCKET], 2); + let mut failure = None; + if let Some(local) = self.forward.take() { + let result = cleanup + .run(&["-t", &self.target, "fport", "rm", &local, SOCKET], 2) + .and_then(|_| { + if screen_lease::forwards(&cleanup, &self.target)?.contains(&local) { + return Err(fault("未能释放投屏连接,请重试。")); + } + self.lease.record(None) + }); + if let Err(error) = result { + failure = Some(error); + } } - // Never kill all UITest processes or remove a wildcard in /data/local/tmp. - if let Ok(pids) = cleanup.run_output(&["-t", &self.target, "shell", "pidof uitest"], 2) { - for pid in pids - .split_whitespace() - .filter(|p| p.bytes().all(|b| b.is_ascii_digit())) - { - if let Ok(cmdline) = cleanup.run_output( + if let Some(name) = self.uploaded.take() { + let result = (|| { + // Only our exact extension belongs to this session. + for (pid, cmdline) in existing_agents(&cleanup, &self.target)? { + if cmdline.split('\0').any(|arg| arg == name) { + terminate_agent(&cleanup, &self.target, &pid, &cmdline)?; + } + } + cleanup.run( &[ "-t", &self.target, "shell", - &format!("cat /proc/{pid}/cmdline"), + &format!("rm -f /data/local/tmp/{name}"), ], 2, - ) { - if cmdline.split('\0').any(|part| part == self.name) { - let _ = - cleanup.run(&["-t", &self.target, "shell", &format!("kill {pid}")], 2); - } - } + )?; + Ok(()) + })(); + if let Err(error) = result { + failure.get_or_insert(error); } } - let _ = cleanup.run( - &[ - "-t", - &self.target, - "shell", - &format!("rm -f /data/local/tmp/{}", self.name), - ], - 2, - ); + failure.map_or(Ok(()), Err) + } +} +impl Drop for DeviceSession { + fn drop(&mut self) { + let _ = self.close(); } } #[derive(Clone)] @@ -174,6 +247,8 @@ pub(super) struct Frame { pub jpeg: Arc>, pub width: u32, pub height: u32, + pub image_width: u32, + pub image_height: u32, pub sequence: u64, pub geometry: u64, pub received: Instant, @@ -181,6 +256,7 @@ pub(super) struct Frame { struct State { latest: Option, error: Option, + cleanup_error: Option, touched: Instant, samples: VecDeque, bytes: VecDeque<(Instant, usize)>, @@ -193,68 +269,70 @@ struct Shared { pub(super) struct Stream { shared: Arc, rpc: Arc>, - socket: TcpStream, worker: Mutex>>, } impl Stream { - pub fn open(tool: &Tool, target: &str) -> Result { - if !tool - .run_output(&["-t", target, "shell", "pidof uitest"], 2)? - .trim() - .is_empty() - { - return Err(Fault::new( - "screen.debugger_busy", - "设备正在被其他 UI 调试工具使用,请结束该调试会话后再开始投屏。", - )); + pub fn open(tool: &Tool, target: &str, directory: &Path) -> Result { + let lease = DeviceLease::acquire(directory, tool, target)?; + let existing = existing_agents(tool, target)?; + if !screen_lease::forwards(tool, target)?.is_empty() { + return Err(screen_lease::busy()); + } + for (pid, cmdline) in existing { + terminate_agent(tool, target, &pid, &cmdline)?; } - let mut file = tempfile::Builder::new() - .prefix("trove-screen-") - .suffix(".so") - .tempfile()?; - file.write_all(AGENT)?; - let name = file - .path() - .file_name() - .unwrap() - .to_str() - .unwrap() - .to_owned(); let mut resources = DeviceSession { tool: Tool { path: tool.path.clone(), cancel: tool.cancel.clone(), }, target: target.into(), - name: name.clone(), + uploaded: None, forward: None, + lease, }; - tool.run( - &[ - "-t", - target, - "file", - "send", - file.path().to_str().unwrap(), - &format!("/data/local/tmp/{name}"), - ], - 10, - )?; - tool.run( - &[ - "-t", - target, - "shell", - &format!("uitest start-daemon singleness --extension-name {name}"), - ], - 5, - )?; + { + let mut file = tempfile::Builder::new() + .prefix("trove-screen-") + .suffix(".so") + .tempfile()?; + file.write_all(AGENT)?; + let name = file + .path() + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_owned(); + resources.uploaded = Some(name.clone()); + tool.run( + &[ + "-t", + target, + "file", + "send", + file.path().to_str().unwrap(), + &format!("/data/local/tmp/{name}"), + ], + 10, + )?; + tool.run( + &[ + "-t", + target, + "shell", + &format!("uitest start-daemon singleness --extension-name {name}"), + ], + 5, + )?; + } let reservation = TcpListener::bind(("127.0.0.1", 0)).map_err(io)?; let port = reservation.local_addr().map_err(io)?.port(); drop(reservation); let local = format!("tcp:{port}"); tool.run(&["-t", target, "fport", &local, SOCKET], 3)?; - resources.forward = Some(local); + resources.forward = Some(local.clone()); + resources.lease.record(Some(&local))?; let deadline = Instant::now() + Duration::from_secs(5); let (mut control, driver) = loop { if tool.cancel.load(Ordering::Acquire) { @@ -287,12 +365,12 @@ impl Stream { .socket .set_read_timeout(Some(Duration::from_millis(100))) .map_err(io)?; - let socket = video.socket.try_clone().map_err(io)?; let rpc = Arc::new(Mutex::new(control)); let shared = Arc::new(Shared { state: Mutex::new(State { latest: None, error: None, + cleanup_error: None, touched: Instant::now(), samples: VecDeque::new(), bytes: VecDeque::new(), @@ -316,15 +394,16 @@ impl Stream { } worker_shared.cancel.store(true, Ordering::Release); worker_shared.changed.notify_all(); - let _ = video.send("Captures", json!({"api":"stopCaptureScreen","args":{}})); + let _ = video.stop_capture(); let _ = video.socket.shutdown(Shutdown::Both); let _ = worker_rpc.lock().unwrap().socket.shutdown(Shutdown::Both); - drop(resources); + if let Err(error) = resources.close() { + worker_shared.state.lock().unwrap().cleanup_error = Some(error); + } }); Ok(Self { shared, rpc, - socket, worker: Mutex::new(Some(worker)), }) } @@ -384,18 +463,24 @@ impl Stream { self.current()?; self.rpc.lock().unwrap().driver(api, args) } - pub fn stop(&self) { + pub fn stop(&self) -> Result<()> { self.shared.cancel.store(true, Ordering::Release); self.shared.changed.notify_all(); - let _ = self.socket.shutdown(Shutdown::Read); if let Some(worker) = self.worker.lock().unwrap().take() { let _ = worker.join(); } + self.shared + .state + .lock() + .unwrap() + .cleanup_error + .clone() + .map_or(Ok(()), Err) } } impl Drop for Stream { fn drop(&mut self) { - self.stop(); + let _ = self.stop(); } } fn receive( @@ -451,11 +536,14 @@ fn receive( } encoded_size = Some(size); // A display change must be observed before coordinates can be used. - if dimensions.0 / 2 != size.0 || dimensions.1 / 2 != size.1 { + if !capture_size_matches(dimensions, size) { dimensions = control.lock().unwrap().dimensions()?; } - if dimensions.0 / 2 != size.0 || dimensions.1 / 2 != size.1 { - return Err(fault("手机画面与屏幕尺寸不一致,请重新开始投屏。")); + if !capture_size_matches(dimensions, size) { + return Err(fault(format!( + "手机画面与屏幕尺寸不一致:画面 {}×{},屏幕 {}×{}。请重新开始投屏。", + size.0, size.1, dimensions.0, dimensions.1 + ))); } let now = Instant::now(); let mut state = shared.state.lock().unwrap(); @@ -483,6 +571,8 @@ fn receive( jpeg: Arc::new(jpeg), width: dimensions.0, height: dimensions.1, + image_width: size.0, + image_height: size.1, sequence, geometry, received: now, @@ -491,3 +581,14 @@ fn receive( } } } + +// The native capture buffer can include trailing row/column alignment padding. +// Keep the logical half-size viewport separate from the JPEG storage size. +fn capture_size_matches(display: (u32, u32), image: (u32, u32)) -> bool { + [(display.0, image.0), (display.1, image.1)] + .into_iter() + .all(|(screen, encoded)| { + let visible = screen.div_ceil(2); + encoded == visible || encoded == visible.div_ceil(16) * 16 + }) +} diff --git a/curios/harmony-connector/native/src/hdc/screen_lease.rs b/curios/harmony-connector/native/src/hdc/screen_lease.rs new file mode 100644 index 0000000..a5bdd40 --- /dev/null +++ b/curios/harmony-connector/native/src/hdc/screen_lease.rs @@ -0,0 +1,111 @@ +//! A per-device host lock prevents two plugin windows from using the singleton +//! capture service. The journal retains only our forward for crash recovery. +use super::*; +use sha2::{Digest, Sha256}; +use std::{ + fs::{File, OpenOptions}, + io::{Read, Seek, SeekFrom, Write}, + os::{fd::AsRawFd, unix::fs::OpenOptionsExt}, +}; + +pub(super) const SOCKET: &str = "localabstract:uitest_socket"; +pub(super) fn busy() -> Fault { + Fault::new( + "screen.active", + "已有投屏连接正在使用这台设备,请先停止该连接。", + ) +} +pub(super) fn forwards(tool: &Tool, target: &str) -> Result> { + let output = tool.run(&["-t", target, "fport", "ls"], 3)?; + Ok(output + .lines() + .filter_map(|line| { + let words: Vec<_> = line.split_whitespace().collect(); + if words.len() == 4 + && words[0] == target + && words[2] == SOCKET + && words[3] == "[Forward]" + { + Some(words[1].to_owned()) + } else { + None + } + }) + .collect()) +} +pub(super) struct Lease { + file: File, +} +impl Lease { + pub fn acquire(directory: &Path, tool: &Tool, target: &str) -> Result { + fs::create_dir_all(directory)?; + let path = directory.join(format!("{:x}.json", Sha256::digest(target.as_bytes()))); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(path)?; + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { + let error = std::io::Error::last_os_error(); + return Err(if error.kind() == std::io::ErrorKind::WouldBlock { + busy() + } else { + error.into() + }); + } + let mut lease = Self { file }; + let mut previous = String::new(); + (&mut lease.file).take(4097).read_to_string(&mut previous)?; + if previous.len() > 4096 { + return Err(Fault::new( + "screen.invalid_session", + "本机投屏会话记录无效。", + )); + } + if let Ok(value) = serde_json::from_str::(&previous) { + if let Some(local) = value["forward"].as_str().filter(|v| { + v.strip_prefix("tcp:") + .is_some_and(|p| p.parse::().is_ok_and(|p| p > 0)) + }) { + if forwards(tool, target)?.iter().any(|active| active == local) { + tool.run(&["-t", target, "fport", "rm", local, SOCKET], 3)?; + if forwards(tool, target)?.iter().any(|active| active == local) { + return Err(busy()); + } + } + } + } + lease.record(None)?; + Ok(lease) + } + pub fn record(&mut self, local: Option<&str>) -> Result<()> { + let bytes = serde_json::to_vec(&json!({"forward":local})).unwrap(); + self.file.seek(SeekFrom::Start(0))?; + self.file.write_all(&bytes)?; + self.file.set_len(bytes.len() as u64)?; + self.file.sync_data()?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn only_one_owner_per_device_even_across_plugin_instances() { + let directory = tempfile::tempdir().unwrap(); + let tool = Tool { + path: "/unused".into(), + cancel: Arc::new(AtomicBool::new(false)), + }; + let first = Lease::acquire(directory.path(), &tool, "phone").unwrap(); + assert!( + matches!(Lease::acquire(directory.path(),&tool,"phone"),Err(e) if e.code=="screen.active") + ); + assert!(Lease::acquire(directory.path(), &tool, "another-phone").is_ok()); + drop(first); + assert!(Lease::acquire(directory.path(), &tool, "phone").is_ok()); + } +} diff --git a/curios/harmony-connector/native/src/hdc/screen_tests.rs b/curios/harmony-connector/native/src/hdc/screen_tests.rs index ed85df7..2ca0a14 100644 --- a/curios/harmony-connector/native/src/hdc/screen_tests.rs +++ b/curios/harmony-connector/native/src/hdc/screen_tests.rs @@ -321,3 +321,132 @@ fn refuses_to_take_over_another_debugging_session() { ); assert!(directory.path().join("hdc.daemon").exists()); } + +#[test] +fn recovers_its_orphaned_agent_and_reconnects_after_stop() { + let (directory, manager) = phone(); + fs::write( + directory.path().join("hdc.daemon"), + "trove-screen-old123.so", + ) + .unwrap(); + for _ in 0..2 { + let id = start(&manager); + assert!(manager.call("screenFrame", id.clone()).unwrap()["dataUrl"].is_string()); + manager.call("screenStop", id).unwrap(); + assert!(!directory.path().join("hdc.daemon").exists()); + assert!(!directory.path().join("hdc.pid").exists()); + } +} + +#[test] +fn reports_cleanup_failure_without_mislabeling_its_agent_as_another_tool() { + let (directory, manager) = phone(); + let id = start(&manager); + manager.call("screenFrame", id.clone()).unwrap(); + fs::write(directory.path().join("hdc.deny-kill"), b"").unwrap(); + assert!(manager.call("screenStop", id).is_err()); + assert!(directory.path().join("hdc.daemon").exists()); + let error = manager + .call("screenStart", json!({"target":"phone"})) + .unwrap_err(); + assert_ne!(error.code, "screen.debugger_busy"); + fs::remove_file(directory.path().join("hdc.deny-kill")).unwrap(); + let id = start(&manager); + assert!(manager.call("screenFrame", id.clone()).is_ok()); + manager.call("screenStop", id).unwrap(); +} + +#[test] +fn aligned_capture_keeps_physical_coordinates_and_excludes_padding_from_viewport() { + let (directory, manager) = phone(); + frame_file(&directory, 1260, 2844, [30, 80, 120]); + image::RgbImage::from_pixel(640, 1422, image::Rgb([30u8, 80, 120])) + .save(directory.path().join("hdc.jpg")) + .unwrap(); + let id = start(&manager); + let frame = manager.call("screenFrame", id.clone()).unwrap(); + assert_eq!(frame["width"], 1260); + assert_eq!(frame["height"], 2844); + assert_eq!(frame["captureWidth"], 630); + assert_eq!(frame["captureHeight"], 1422); + assert_eq!(frame["imageWidth"], 640); + enable(&manager, &id, &frame); + manager + .call( + "screenInput", + input(&id, &frame, json!({"kind":"click","x":1259,"y":2843})), + ) + .unwrap(); + manager.call("screenStop", id).unwrap(); +} + +#[test] +fn mismatched_capture_is_rejected_before_input_can_be_enabled() { + let (directory, manager) = phone(); + image::RgbImage::from_pixel(600, 300, image::Rgb([30u8, 80, 120])) + .save(directory.path().join("hdc.jpg")) + .unwrap(); + let id = start(&manager); + assert!(manager.call("screenFrame", id.clone()).is_err()); + assert!(manager + .call( + "screenControl", + json!({"session":id["session"],"enabled":true,"geometry":1}) + ) + .is_err()); +} + +#[test] +fn recovers_only_the_forward_recorded_by_its_previous_crashed_session() { + let (directory, manager) = phone(); + let tool = manager.tool().unwrap(); + let socket = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let local = format!("tcp:{}", socket.local_addr().unwrap().port()); + drop(socket); + let mut lease = + screen_lease::Lease::acquire(&directory.path().join("screen-sessions"), &tool, "phone") + .unwrap(); + tool.run(&["-t", "phone", "fport", &local, screen_lease::SOCKET], 3) + .unwrap(); + lease.record(Some(&local)).unwrap(); + drop(lease); + fs::write( + directory.path().join("hdc.daemon"), + "trove-screen-old123.so", + ) + .unwrap(); + let id = start(&manager); + assert!(manager.call("screenFrame", id.clone()).is_ok()); + manager.call("screenStop", id).unwrap(); + assert!(!directory.path().join("hdc.forward").exists()); +} + +#[test] +fn does_not_interrupt_an_active_forward_it_does_not_own() { + let (directory, manager) = phone(); + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let local = format!("tcp:{}", listener.local_addr().unwrap().port()); + // Model another client's live listener and the HDC listing independently. + fs::write(directory.path().join("hdc.forward"), &local).unwrap(); + fs::write( + directory.path().join("hdc.daemon"), + "trove-screen-old123.so", + ) + .unwrap(); + assert_eq!( + manager + .call("screenStart", json!({"target":"phone"})) + .unwrap_err() + .code, + "screen.active" + ); + let calls = fs::read_to_string(directory.path().join("hdc.calls")).unwrap(); + assert!(!calls.contains("shell kill") && !calls.contains("fport rm")); + assert!(directory.path().join("hdc.daemon").exists()); + assert_eq!( + fs::read_to_string(directory.path().join("hdc.forward")).unwrap(), + local + ); + assert!(std::net::TcpStream::connect(listener.local_addr().unwrap()).is_ok()); +} diff --git a/curios/harmony-connector/native/src/hdc/tests.rs b/curios/harmony-connector/native/src/hdc/tests.rs index cefb047..e7239c3 100644 --- a/curios/harmony-connector/native/src/hdc/tests.rs +++ b/curios/harmony-connector/native/src/hdc/tests.rs @@ -152,7 +152,7 @@ esac "#)).unwrap(); fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); let manager = Arc::new(Manager { - screen: screen::Screen::default(), + screen: screen::Screen::new(directory.path().join("screen-sessions")), path: Mutex::new(Some(path)), settings: None, busy: Arc::new(AtomicBool::new(false)), diff --git a/curios/harmony-connector/native/tests/hypium_device.py b/curios/harmony-connector/native/tests/hypium_device.py index 68f39e4..2d5415a 100644 --- a/curios/harmony-connector/native/tests/hypium_device.py +++ b/curios/harmony-connector/native/tests/hypium_device.py @@ -82,15 +82,22 @@ elif args[:2]==['file','send']: print('FileTransfer finish') elif args[0]=='shell' and args[1].startswith('uitest start-daemon'): path('daemon').write_text(args[1].split()[-1]) elif args[0]=='shell' and args[1]=='cat /proc/4242/cmdline': - sys.stdout.write('uitest\0start-daemon\0'+path('daemon').read_text()+'\0') -elif args[:2]==['shell','kill 4242']: path('daemon').unlink(missing_ok=True) + if path('daemon').exists(): sys.stdout.write('uitest\0start-daemon\0singleness\0--extension-name\0'+path('daemon').read_text()+'\0') + else: print('cat: No such file or directory') +elif args[:2]==['shell','kill -9 4242']: + if path('deny-kill').exists(): print('/bin/sh: kill: 4242: Permission denied') + else: path('daemon').unlink(missing_ok=True) elif args[0]=='shell' and args[1].startswith('rm -f /data/local/tmp/'): pass elif args[0]=='shell' and args[1].startswith('uitest uiInput text '): print('completed') +elif args==['fport','ls']: + if path('forward').exists(): print('phone '+path('forward').read_text()+' localabstract:uitest_socket [Forward]') + else: print('[Empty]') elif args[:2]==['fport','rm']: pid=int(path('pid').read_text());os.kill(pid,signal.SIGTERM) - path('pid').unlink();path('ready').unlink(missing_ok=True) + path('pid').unlink();path('ready').unlink(missing_ok=True);path('forward').unlink(missing_ok=True) print('Remove forward ruler success') elif args[0]=='fport': + path('forward').write_text(args[1]) process=subprocess.Popen([sys.executable,__file__,'server',args[1].split(':')[1]], stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True) path('pid').write_text(str(process.pid)) diff --git a/curios/harmony-connector/ui/screen-geometry.js b/curios/harmony-connector/ui/screen-geometry.js index 73c7337..2d2a8a4 100644 --- a/curios/harmony-connector/ui/screen-geometry.js +++ b/curios/harmony-connector/ui/screen-geometry.js @@ -84,3 +84,20 @@ export const screenKeys = { ArrowLeft: "left", ArrowRight: "right", }; + +// Center the visible phone pixels; crop trailing JPEG alignment padding without +// decoding and re-encoding every frame in the native backend. +export function screenImageStyle(rect, frame) { + const width = frame.captureWidth || frame.width; + const height = frame.captureHeight || frame.height; + const imageWidth = frame.imageWidth || width; + const imageHeight = frame.imageHeight || height; + const scale = Math.min(rect.width / width, rect.height / height); + return { + left: `${(rect.width - width * scale) / 2}px`, + top: `${(rect.height - height * scale) / 2}px`, + width: `${imageWidth * scale}px`, + height: `${imageHeight * scale}px`, + clipPath: `inset(0 ${(imageWidth - width) * scale}px ${(imageHeight - height) * scale}px 0)`, + }; +} diff --git a/curios/harmony-connector/ui/screen-geometry.test.js b/curios/harmony-connector/ui/screen-geometry.test.js index 8138b85..3fc5812 100644 --- a/curios/harmony-connector/ui/screen-geometry.test.js +++ b/curios/harmony-connector/ui/screen-geometry.test.js @@ -1,6 +1,11 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { screenPoint, gesture, scrollGesture } from "./screen-geometry.js"; +import { + screenPoint, + screenImageStyle, + gesture, + scrollGesture, +} from "./screen-geometry.js"; test("portrait phone maps the displayed image and ignores side letterboxes", () => { const rect = { left: 20, top: 30, width: 800, height: 600 }; @@ -49,3 +54,30 @@ test("wheel gestures remain inside the physical phone screen", () => { }); assert.equal(scrollGesture({ x: 10, y: 20 }, 0, 0, 600, 1200), null); }); + +test("JPEG padding is clipped while visible corners keep physical input coordinates", () => { + const rect = { left: 0, top: 0, width: 1000, height: 1422 }; + const frame = { + width: 1260, + height: 2844, + captureWidth: 630, + captureHeight: 1422, + imageWidth: 640, + imageHeight: 1422, + }; + assert.deepEqual(screenImageStyle(rect, frame), { + left: "185px", + top: "0px", + width: "640px", + height: "1422px", + clipPath: "inset(0 10px 0px 0)", + }); + assert.deepEqual( + screenPoint(rect, frame.width, frame.height, 814.5, 1421.5), + { x: 1259, y: 2843 }, + ); + assert.equal(screenPoint(rect, frame.width, frame.height, 820, 700), null); + const resized = screenImageStyle({ ...rect, width: 500, height: 711 }, frame); + assert.equal(resized.width, "320px"); + assert.equal(resized.clipPath, "inset(0 5px 0px 0)"); +}); diff --git a/curios/harmony-connector/ui/screen.js b/curios/harmony-connector/ui/screen.js index 3abcbd3..786073d 100644 --- a/curios/harmony-connector/ui/screen.js +++ b/curios/harmony-connector/ui/screen.js @@ -1,5 +1,6 @@ import { screenPoint, + screenImageStyle, gesture, scrollGesture, screenKeys, @@ -32,6 +33,20 @@ export function screen({ call, host, notice, onBusy }) { samples: [], disposed: false, }; + function layoutImage() { + if (state.frame) + Object.assign( + $("screen-image").style, + screenImageStyle( + $("screen-stage").getBoundingClientRect(), + state.frame, + ), + ); + } + const resizeObserver = window.ResizeObserver + ? new window.ResizeObserver(layoutImage) + : null; + resizeObserver?.observe($("screen-stage")); function controls() { $("screen-start").disabled = !state.available || @@ -164,6 +179,7 @@ export function screen({ call, host, notice, onBusy }) { "屏幕方向已变化,请重新启用控制。"; } state.frame = frame; + layoutImage(); if (!changed && controlRevision === state.controlRevision) state.control = !!frame.control; state.displayedAt = Date.now(); @@ -489,6 +505,7 @@ export function screen({ call, host, notice, onBusy }) { }); window.addEventListener("pagehide", () => { state.disposed = true; + resizeObserver?.disconnect(); stop(); }); controls(); diff --git a/curios/harmony-connector/ui/style.css b/curios/harmony-connector/ui/style.css index 74bee47..ae8593d 100644 --- a/curios/harmony-connector/ui/style.css +++ b/curios/harmony-connector/ui/style.css @@ -1005,7 +1005,7 @@ dialog li { position: absolute; width: 100%; height: 100%; - object-fit: contain; + object-fit: fill; pointer-events: none; } #screen-placeholder { diff --git a/docs/harmony-connector.md b/docs/harmony-connector.md index 4ff0603..cecdb60 100644 --- a/docs/harmony-connector.md +++ b/docs/harmony-connector.md @@ -1,6 +1,6 @@ # Harmony Connector -插件 ID:`dev.trove.harmony-connector`。当前版本:`0.3.2`。 +插件 ID:`dev.trove.harmony-connector`。当前版本:`0.4.0`。 macOS 上的鸿蒙设备连接工具。通过 USB 收发文件与完整文件夹;使用官方 HDC 管理应用、保存截图、导出日志和调试应用数据。功能按连接工具、设备实际能力和授权开放,不按 HarmonyOS 大版本或机型名称限制。 @@ -73,7 +73,7 @@ macOS 上的鸿蒙设备连接工具。通过 USB 收发文件与完整文件夹 - 用专用文字框发送文本,支持输入法;不读取系统剪贴板。文字能否进入目标输入框,以手机焦点和输入权限为准。 - 一个屏幕会话只保留一项待发送操作。停止、切换工作区、页面隐藏、断线时清空队列和旧画面,不在重连后重放输入。横竖屏变化后需重新启用控制。 -当前源代码通过 HDC 部署官方 Hypium 代理,持续接收 JPEG 画面,并通过独立连接发送输入。用户无需开发者账号、编译、签名或安装手机应用。拖动在松开时发送完整手势。停止会删除本会话的代理和转发;其他调试工具占用设备时会提示。已发布 0.3.2 仍为逐帧截图,新路径的动态真机验收状态见[开发验证](harmony-realtime-development.md)。 +0.4.0 通过 HDC 部署官方 Hypium 代理,持续接收 JPEG 画面,并通过独立连接发送输入。用户无需开发者账号、编译、签名或安装手机应用。拖动在松开时发送完整手势。停止会删除本会话的代理和转发,再次启动会恢复插件自身的残留会话;其他调试工具占用设备时会提示。真机已通过三轮连续启动、收图、停止及重连;动态性能验收状态见[开发验证](harmony-realtime-development.md)。 JPEG 采用半尺寸采集,坐标按手机原始尺寸计算。画面不变化时保留显示,通过独立控制连接检查存活;方向变化、会话过期或输入失败时会阻止控制。受保护画面和锁屏遵循系统限制,不自动解锁。自动化测试和真机结果分别记录,详见[HDC 持续投屏调研](research/harmony-hdc-only-screen.md)。 @@ -113,6 +113,8 @@ curios/harmony-connector/backend/harmony-connector --check-screen /absolute/path 0.3.2 补上工具安装目录和 Downloads 的自动发现。回归在独立 HOME、空 PATH 工具目录、未保存任何设置的进程中运行真实 `Manager::new → hdc.tool`,验证数字版本选择、未完成解压跳过、手动路径优先和官方解压目录布局。实际 Mac 验证通过 Wire 请求 `hdc.tool`,使用空插件数据目录和系统 PATH,未调用 `configure`;0.3.1 返回 `available: false`,0.3.2 自动找到之前解压的工具并返回 `Ver: 3.2.0f`。USB 目标仍为 Unauthorized。该版 42 项原生测试、30 项 UI/格式/坐标测试及 6 项 USB/协议检查全部通过。 +0.4.0 的 HDC 持续采集与会话恢复通过 54 项原生、33 项 UI/格式/坐标、6 项 USB/协议检查(共 93 项)。真机连续三轮开始、接收并解码 JPEG、停止和重新开始全部成功,每轮均确认代理和转发清理,无需拔线。新增回归覆盖自身孤儿进程恢复、结束失败不误报其他工具、多个窗口的同设备锁、仅恢复自身转发以及 JPEG 对齐填充的显示/输入坐标。浏览器确认填充裁切与放大布局。该轮没有发送真机输入或测量动态性能,详见[开发验证](harmony-realtime-development.md)。 + ## 构建与发布 要求 macOS、Xcode Command Line Tools、Rust/Cargo、Node.js、pnpm、make、pkg-config。固定源码归档与哈希位于 `vendor/harmony-connector/`。 diff --git a/docs/harmony-realtime-development.md b/docs/harmony-realtime-development.md index ff0868d..f490c94 100644 --- a/docs/harmony-realtime-development.md +++ b/docs/harmony-realtime-development.md @@ -4,11 +4,17 @@ ## 当前状态 -2026-09-10:已发布的 0.3.2 仍使用逐张截图,真机原链路为 696、803、802 ms,约 1.3 fps。当前源代码已替换该路径,尚未发布新版本。 +2026-09-10:0.4.0 改为 HDC 持续 JPEG 推送,修复投屏残留会话误报占用及重连失败。0.3.2 的逐张截图链路为 696、803、802 ms,约 1.3 fps;新版本的动态帧率仍须独立测量。 官方 `xdevice-devicetest` 6.1.0.210 的 `uitest_agent_v1.2.2.so` 已在 HUAWEI Pura 70 Ultra 的商业系统上成功加载。独立探针收到真实 JPEG、屏幕尺寸 1260 × 2844,并成功调用尺寸、旋转和唤醒接口。设备端文件为 600245 字节,未改动签名原件。没有安装 HAP,也没有账号或系统共享确认步骤。 -探测期间手机主要处于锁屏或静止状态。黑色首帧、短暂变化及收包时间不能证明动态帧率或端到端延迟达标。接入生产后端时手机变为 Offline,后端的完整真机连通、持续动态画面、输入及重连仍待验收。自动化替身测试不替代这项验收。完整自动化检查通过 85 项:47 项原生、32 项 UI/格式/坐标、6 项 USB 桥接与底座协议检查。 +生产后端已在同一台真机上连续通过三轮“开始 → 接收并解码 → 停止 → 再次开始”,全程未拔 USB,每轮均确认 `pidof uitest` 为空、`fport ls` 无残留。每轮收到 2–3 张独立画面,也验证了静止画面返回 unchanged 的行为。独立探针另收到可查看的实际手机内容;这项连通/重连检查没有发送输入,不能作为动态帧率、跟手程度或端到端延迟达标的证据。 + +本次错误来自插件自身的残留 `trove-screen-*.so` 会话。手机的普通 `kill` 返回 `Permission denied`,旧代码忽略失败并把任意 UITest 进程视作其他工具。官方 xdevice 的清理使用 `kill -9`;当前手机实测接受该命令。修复会检查完整命令行身份,只清理本插件代理并确认进程退出;显式停止时清理失败会返回错误。其他工具的代理和未经本机记录的活动转发保持不动。主机按设备加文件锁,并记录自己的转发供异常退出后恢复。 + +手机正常画面实测为 640 × 1422,逻辑半尺寸为 630 × 1422,右侧含对齐填充。接收器兼容 16 像素边界的尾部填充,保留逻辑视口和 JPEG 存储尺寸;UI 直接裁去填充,不重新编码 JPEG,输入仍映射到 1260 × 2844。旋转或无法匹配的尺寸仍会关闭控制。 + +完整自动化检查通过 93 项:54 项原生、33 项 UI/格式/坐标、6 项 USB 桥接与底座协议检查。浏览器另外确认模拟画面的尾部彩色填充被裁去,放大时可见区域保持居中;这些界面测试不等同于真机性能测试。 之前的 HAP 实验源码和签名流程已移除。本机签名材料不属于插件或发行包。原方案的研究过程保留在[历史记录](research/harmony-realtime-stream.md),当前方案及来源见[HDC 持续投屏调研](research/harmony-hdc-only-screen.md)。 @@ -35,6 +41,6 @@ curios/harmony-connector/backend/harmony-connector --check-screen-stream /absolu - 图像与控制使用两个独立 socket,共用本会话的 HDC 转发。按键和手势通过持久 RPC 发送,文字使用已验证的 `uitest uiInput text`。 - 代理回包包括 JSON 行与 JPEG;解析 JPEG 段长度,避免把 EXIF 数据中的字节误认作图像结尾。单帧上限 2 MiB,JSON 上限 64 KiB。 - 静止画面不视为断线;独立控制连接查询尺寸检查存活。UI 停止请求超过 15 秒会结束会话。屏幕几何变化会关闭输入控制。 -- 每次部署使用唯一文件名,只结束加载该文件的进程,只删除本次转发和文件。发现其他 UITest 会话时提示占用,不接管或结束其他调试工具。 +- 每次部署使用唯一文件名,以完整命令行重新确认进程身份后结束代理,并验证退出。设备锁与转发记录支持异常退出恢复;其他工具的代理和活动转发会阻止启动。停止时删除自己的临时文件和转发。 运行 HDC 不需要完整 SDK:本机独立验证了 `hdc` 与同目录 `libusb_shared.dylib` 的版本命令,总计约 6.4 MiB。完整工具包用于开发构建,不应成为普通投屏用户的依赖。本次尚未新增精简 HDC 发行包;插件继续使用已配置或自动发现的官方工具。 diff --git a/docs/research/harmony-hdc-only-screen.md b/docs/research/harmony-hdc-only-screen.md index d59d155..79e0475 100644 --- a/docs/research/harmony-hdc-only-screen.md +++ b/docs/research/harmony-hdc-only-screen.md @@ -147,3 +147,10 @@ ASCII "_uitestkit_rpc_message_tail_" - 未确认 `Captures/Gestures` 跨版本稳定性;应固定已验收的 agent,报告能力与错误,避免无提示降为旧截图模式。 最终用户流程可以保持“连接 USB → 允许调试 → 开始投屏”。官方 agent 的获取、校验、部署、转发和清理应由插件完成,不能再把开发者账号或 HAP 签名作为该流程的要求。 + + +## 2026-09-10:主任务重连验证补充 + +实际商业手机拒绝普通 `kill `,返回 `Permission denied`;同一身份的 `kill -9 ` 成功,进程随即消失。此前残留代理能响应 Driver RPC,却无法恢复采集,因此不将无条件复用残留代理作为恢复策略。官方 `xdevice-ohos 6.1.0.210` 的 `ohos/proxy/ui_proxy.py::stop_ui_rpc` 与 `proxy_base.py::_kill_uitest` 均使用 `kill -9`;默认销毁代理对象可不杀进程,显式重连则先停止。插件只借鉴结束自己代理的方式,绝不照搬按名称批量结束所有工具进程的逻辑。[官方连接包](https://pypi.org/project/xdevice-ohos/6.1.0.210/) + +生产后端现已连续通过三轮真机开始、解码 JPEG、停止和重新开始,每轮都验证代理和转发清理完成。正常画面 JPEG 宽度实测含尾部对齐填充(640 × 1422 对应逻辑半尺寸 630 × 1422),UI 应裁掉该部分,不能按 JPEG 宽度直接映射物理触点。该结果只证明本轮设备的连通与重连,不代表动态帧率、实时手势或其他固件兼容性全部验收。 diff --git a/source/index.json b/source/index.json index 61c07b1..edb459d 100644 --- a/source/index.json +++ b/source/index.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "trove-official", "name": "Trove 官方软件源", - "generatedAt": "2026-09-10T06:45:52.251Z", + "generatedAt": "2026-09-10T08:42:44.362Z", "curios": [ { "id": "dev.trove.qa.host", @@ -200,6 +200,21 @@ "name": "Harmony Connector", "description": "连接鸿蒙设备,收发完整文件夹、管理应用,查看手机画面并通过键鼠操作。", "versions": [ + { + "version": "0.4.0", + "engine": { + "trove": ">=0.1.0 <1.0.0" + }, + "releasedAt": "2026-09-10T08:42:44.362Z", + "artifacts": [ + { + "target": "aarch64-apple-darwin", + "url": "packages/dev.trove.harmony-connector/0.4.0/aarch64-apple-darwin.curio", + "sha256": "a203071f9d8218e90bc88190c9bc80acb26b12b81774e55497796d6bb257581d", + "size": 3358482 + } + ] + }, { "version": "0.3.2", "engine": { diff --git a/source/packages/dev.trove.harmony-connector/0.4.0/aarch64-apple-darwin.curio b/source/packages/dev.trove.harmony-connector/0.4.0/aarch64-apple-darwin.curio new file mode 100644 index 0000000..d6a03d1 Binary files /dev/null and b/source/packages/dev.trove.harmony-connector/0.4.0/aarch64-apple-darwin.curio differ