fix(harmony): recover owned screen sessions and release 0.4.0

This commit is contained in:
2026-09-10 16:43:25 +08:00
parent 3ce918564d
commit 0a1cefc9fa
19 changed files with 570 additions and 104 deletions
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -204,7 +204,7 @@ dependencies = [
[[package]]
name = "harmony-connector"
version = "0.3.2"
version = "0.4.0"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "harmony-connector"
version = "0.3.2"
version = "0.4.0"
edition = "2021"
publish = false
@@ -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,
@@ -28,11 +28,22 @@ struct Session {
touched: Instant,
stream: Arc<screen_agent::Stream>,
}
#[derive(Default)]
pub(super) struct Screen {
current: Mutex<Option<Session>>,
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<u64>) -> Result<Value> {
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();
@@ -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<String>) -> 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<Vec<(String, String)>> {
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<String>,
forward: Option<String>,
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("未能释放投屏连接,请重试。"));
}
// 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(
self.lease.record(None)
});
if let Err(error) = result {
failure = Some(error);
}
}
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);
}
}
failure.map_or(Ok(()), Err)
}
}
let _ = cleanup.run(
&[
"-t",
&self.target,
"shell",
&format!("rm -f /data/local/tmp/{}", self.name),
],
2,
);
}
impl Drop for DeviceSession {
fn drop(&mut self) {
let _ = self.close();
}
}
#[derive(Clone)]
@@ -174,6 +247,8 @@ pub(super) struct Frame {
pub jpeg: Arc<Vec<u8>>,
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<Frame>,
error: Option<Fault>,
cleanup_error: Option<Fault>,
touched: Instant,
samples: VecDeque<Instant>,
bytes: VecDeque<(Instant, usize)>,
@@ -193,21 +269,29 @@ struct Shared {
pub(super) struct Stream {
shared: Arc<Shared>,
rpc: Arc<Mutex<Rpc>>,
socket: TcpStream,
worker: Mutex<Option<thread::JoinHandle<()>>>,
}
impl Stream {
pub fn open(tool: &Tool, target: &str) -> Result<Self> {
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<Self> {
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 resources = DeviceSession {
tool: Tool {
path: tool.path.clone(),
cancel: tool.cancel.clone(),
},
target: target.into(),
uploaded: None,
forward: None,
lease,
};
{
let mut file = tempfile::Builder::new()
.prefix("trove-screen-")
.suffix(".so")
@@ -220,15 +304,7 @@ impl Stream {
.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(),
forward: None,
};
resources.uploaded = Some(name.clone());
tool.run(
&[
"-t",
@@ -249,12 +325,14 @@ impl Stream {
],
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
})
}
@@ -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<Vec<String>> {
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<Self> {
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::<Value>(&previous) {
if let Some(local) = value["forward"].as_str().filter(|v| {
v.strip_prefix("tcp:")
.is_some_and(|p| p.parse::<u16>().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());
}
}
@@ -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());
}
@@ -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)),
@@ -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))
@@ -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)`,
};
}
@@ -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)");
});
+17
View File
@@ -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();
+1 -1
View File
@@ -1005,7 +1005,7 @@ dialog li {
position: absolute;
width: 100%;
height: 100%;
object-fit: contain;
object-fit: fill;
pointer-events: none;
}
#screen-placeholder {
+4 -2
View File
@@ -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/`。
+9 -3
View File
@@ -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 发行包;插件继续使用已配置或自动发现的官方工具。
+7
View File
@@ -147,3 +147,10 @@ ASCII "_uitestkit_rpc_message_tail_"
- 未确认 `Captures/Gestures` 跨版本稳定性;应固定已验收的 agent,报告能力与错误,避免无提示降为旧截图模式。
最终用户流程可以保持“连接 USB → 允许调试 → 开始投屏”。官方 agent 的获取、校验、部署、转发和清理应由插件完成,不能再把开发者账号或 HAP 签名作为该流程的要求。
## 2026-09-10:主任务重连验证补充
实际商业手机拒绝普通 `kill <pid>`,返回 `Permission denied`;同一身份的 `kill -9 <pid>` 成功,进程随即消失。此前残留代理能响应 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 宽度直接映射物理触点。该结果只证明本轮设备的连通与重连,不代表动态帧率、实时手势或其他固件兼容性全部验收。
+16 -1
View File
@@ -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": {