fix(harmony): recover owned screen sessions and release 0.4.0
This commit is contained in:
@@ -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
@@ -204,7 +204,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "harmony-connector"
|
||||
version = "0.3.2"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1005,7 +1005,7 @@ dialog li {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-fit: fill;
|
||||
pointer-events: none;
|
||||
}
|
||||
#screen-placeholder {
|
||||
|
||||
@@ -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/`。
|
||||
|
||||
@@ -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 发行包;插件继续使用已配置或自动发现的官方工具。
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user