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",
|
"id": "dev.trove.harmony-connector",
|
||||||
"name": "Harmony Connector",
|
"name": "Harmony Connector",
|
||||||
"description": "连接鸿蒙设备,收发完整文件夹、管理应用,查看手机画面并通过键鼠操作。",
|
"description": "连接鸿蒙设备,收发完整文件夹、管理应用,查看手机画面并通过键鼠操作。",
|
||||||
"version": "0.3.2",
|
"version": "0.4.0",
|
||||||
"engine": { "trove": ">=0.1.0 <1.0.0" },
|
"engine": { "trove": ">=0.1.0 <1.0.0" },
|
||||||
"icon": "assets/icon.svg",
|
"icon": "assets/icon.svg",
|
||||||
"ui": {
|
"ui": {
|
||||||
|
|||||||
+1
-1
@@ -204,7 +204,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "harmony-connector"
|
name = "harmony-connector"
|
||||||
version = "0.3.2"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "harmony-connector"
|
name = "harmony-connector"
|
||||||
version = "0.3.2"
|
version = "0.4.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ mod files;
|
|||||||
mod screen;
|
mod screen;
|
||||||
mod screen_agent;
|
mod screen_agent;
|
||||||
mod screen_input;
|
mod screen_input;
|
||||||
|
mod screen_lease;
|
||||||
mod screen_protocol;
|
mod screen_protocol;
|
||||||
use crate::{
|
use crate::{
|
||||||
command, hdc_data,
|
command, hdc_data,
|
||||||
|
|||||||
@@ -28,11 +28,22 @@ struct Session {
|
|||||||
touched: Instant,
|
touched: Instant,
|
||||||
stream: Arc<screen_agent::Stream>,
|
stream: Arc<screen_agent::Stream>,
|
||||||
}
|
}
|
||||||
#[derive(Default)]
|
|
||||||
pub(super) struct Screen {
|
pub(super) struct Screen {
|
||||||
current: Mutex<Option<Session>>,
|
current: Mutex<Option<Session>>,
|
||||||
next: AtomicU64,
|
next: AtomicU64,
|
||||||
lifecycle: Mutex<()>,
|
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)]
|
#[derive(Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -71,6 +82,14 @@ fn missing() -> Fault {
|
|||||||
Fault::new("screen.closed", "投屏已停止,请重新开始。")
|
Fault::new("screen.closed", "投屏已停止,请重新开始。")
|
||||||
}
|
}
|
||||||
impl Screen {
|
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> {
|
pub fn close(&self, id: Option<u64>) -> Result<Value> {
|
||||||
let _lifecycle = self.lifecycle.lock().unwrap();
|
let _lifecycle = self.lifecycle.lock().unwrap();
|
||||||
let session = {
|
let session = {
|
||||||
@@ -84,7 +103,7 @@ impl Screen {
|
|||||||
};
|
};
|
||||||
if let Some(session) = session {
|
if let Some(session) = session {
|
||||||
session.cancel.store(true, Ordering::Release);
|
session.cancel.store(true, Ordering::Release);
|
||||||
session.stream.stop();
|
session.stream.stop()?;
|
||||||
}
|
}
|
||||||
Ok(json!({"stopped":true}))
|
Ok(json!({"stopped":true}))
|
||||||
}
|
}
|
||||||
@@ -100,7 +119,7 @@ impl Screen {
|
|||||||
return Err(Fault::new("screen.active", "请先停止当前投屏。"));
|
return Err(Fault::new("screen.active", "请先停止当前投屏。"));
|
||||||
}
|
}
|
||||||
if let Some(session) = current.take() {
|
if let Some(session) = current.take() {
|
||||||
session.stream.stop();
|
session.stream.stop()?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tool.connected(&target)?;
|
tool.connected(&target)?;
|
||||||
@@ -108,7 +127,7 @@ impl Screen {
|
|||||||
.shell(&target, "uitest uiInput help")
|
.shell(&target, "uitest uiInput help")
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let mut supported = screen_input::capabilities(&help);
|
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 [
|
for input in [
|
||||||
"click",
|
"click",
|
||||||
"doubleClick",
|
"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});
|
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; }
|
if changed { s.control = false; }
|
||||||
let mut value = json!({"session":s.id,"sequence":frame.sequence,"geometry":frame.geometry,
|
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});
|
"receivedFps":fps,"mbps":mbps,"control":s.control,"rotated":changed,"unchanged":frame.sequence==request.after});
|
||||||
if frame.sequence != request.after {
|
if frame.sequence != request.after {
|
||||||
value["dataUrl"] = format!("data:image/jpeg;base64,{}",STANDARD.encode(frame.jpeg.as_slice())).into();
|
value["dataUrl"] = format!("data:image/jpeg;base64,{}",STANDARD.encode(frame.jpeg.as_slice())).into();
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use std::{
|
|||||||
|
|
||||||
const AGENT: &[u8] =
|
const AGENT: &[u8] =
|
||||||
include_bytes!("../../../../../vendor/harmony-connector/hypium/uitest_agent_v1.2.2.so");
|
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);
|
const LEASE: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
fn fault(message: impl Into<String>) -> Fault {
|
fn fault(message: impl Into<String>) -> Fault {
|
||||||
@@ -109,6 +109,10 @@ impl Rpc {
|
|||||||
json!({"api":api,"this":self.driver,"args":args,"message_type":"hypium"}),
|
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)> {
|
fn dimensions(&mut self) -> Result<(u32, u32)> {
|
||||||
let value = self.driver("Driver.getDisplaySize", json!([]))?;
|
let value = self.driver("Driver.getDisplaySize", json!([]))?;
|
||||||
let dimension = |key: &str| {
|
let dimension = |key: &str| {
|
||||||
@@ -121,52 +125,121 @@ impl Rpc {
|
|||||||
Ok((dimension("x")?, dimension("y")?))
|
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 {
|
struct DeviceSession {
|
||||||
tool: Tool,
|
tool: Tool,
|
||||||
target: String,
|
target: String,
|
||||||
name: String,
|
uploaded: Option<String>,
|
||||||
forward: Option<String>,
|
forward: Option<String>,
|
||||||
|
lease: DeviceLease,
|
||||||
}
|
}
|
||||||
impl Drop for DeviceSession {
|
impl DeviceSession {
|
||||||
fn drop(&mut self) {
|
fn close(&mut self) -> Result<()> {
|
||||||
let cleanup = Tool {
|
let cleanup = Tool {
|
||||||
path: self.tool.path.clone(),
|
path: self.tool.path.clone(),
|
||||||
cancel: Arc::new(AtomicBool::new(false)),
|
cancel: Arc::new(AtomicBool::new(false)),
|
||||||
};
|
};
|
||||||
if let Some(local) = &self.forward {
|
let mut failure = None;
|
||||||
let _ = cleanup.run(&["-t", &self.target, "fport", "rm", local, SOCKET], 2);
|
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 Some(name) = self.uploaded.take() {
|
||||||
if let Ok(pids) = cleanup.run_output(&["-t", &self.target, "shell", "pidof uitest"], 2) {
|
let result = (|| {
|
||||||
for pid in pids
|
// Only our exact extension belongs to this session.
|
||||||
.split_whitespace()
|
for (pid, cmdline) in existing_agents(&cleanup, &self.target)? {
|
||||||
.filter(|p| p.bytes().all(|b| b.is_ascii_digit()))
|
if cmdline.split('\0').any(|arg| arg == name) {
|
||||||
{
|
terminate_agent(&cleanup, &self.target, &pid, &cmdline)?;
|
||||||
if let Ok(cmdline) = cleanup.run_output(
|
}
|
||||||
|
}
|
||||||
|
cleanup.run(
|
||||||
&[
|
&[
|
||||||
"-t",
|
"-t",
|
||||||
&self.target,
|
&self.target,
|
||||||
"shell",
|
"shell",
|
||||||
&format!("cat /proc/{pid}/cmdline"),
|
&format!("rm -f /data/local/tmp/{name}"),
|
||||||
],
|
],
|
||||||
2,
|
2,
|
||||||
) {
|
)?;
|
||||||
if cmdline.split('\0').any(|part| part == self.name) {
|
Ok(())
|
||||||
let _ =
|
})();
|
||||||
cleanup.run(&["-t", &self.target, "shell", &format!("kill {pid}")], 2);
|
if let Err(error) = result {
|
||||||
}
|
failure.get_or_insert(error);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = cleanup.run(
|
failure.map_or(Ok(()), Err)
|
||||||
&[
|
}
|
||||||
"-t",
|
}
|
||||||
&self.target,
|
impl Drop for DeviceSession {
|
||||||
"shell",
|
fn drop(&mut self) {
|
||||||
&format!("rm -f /data/local/tmp/{}", self.name),
|
let _ = self.close();
|
||||||
],
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -174,6 +247,8 @@ pub(super) struct Frame {
|
|||||||
pub jpeg: Arc<Vec<u8>>,
|
pub jpeg: Arc<Vec<u8>>,
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
|
pub image_width: u32,
|
||||||
|
pub image_height: u32,
|
||||||
pub sequence: u64,
|
pub sequence: u64,
|
||||||
pub geometry: u64,
|
pub geometry: u64,
|
||||||
pub received: Instant,
|
pub received: Instant,
|
||||||
@@ -181,6 +256,7 @@ pub(super) struct Frame {
|
|||||||
struct State {
|
struct State {
|
||||||
latest: Option<Frame>,
|
latest: Option<Frame>,
|
||||||
error: Option<Fault>,
|
error: Option<Fault>,
|
||||||
|
cleanup_error: Option<Fault>,
|
||||||
touched: Instant,
|
touched: Instant,
|
||||||
samples: VecDeque<Instant>,
|
samples: VecDeque<Instant>,
|
||||||
bytes: VecDeque<(Instant, usize)>,
|
bytes: VecDeque<(Instant, usize)>,
|
||||||
@@ -193,68 +269,70 @@ struct Shared {
|
|||||||
pub(super) struct Stream {
|
pub(super) struct Stream {
|
||||||
shared: Arc<Shared>,
|
shared: Arc<Shared>,
|
||||||
rpc: Arc<Mutex<Rpc>>,
|
rpc: Arc<Mutex<Rpc>>,
|
||||||
socket: TcpStream,
|
|
||||||
worker: Mutex<Option<thread::JoinHandle<()>>>,
|
worker: Mutex<Option<thread::JoinHandle<()>>>,
|
||||||
}
|
}
|
||||||
impl Stream {
|
impl Stream {
|
||||||
pub fn open(tool: &Tool, target: &str) -> Result<Self> {
|
pub fn open(tool: &Tool, target: &str, directory: &Path) -> Result<Self> {
|
||||||
if !tool
|
let lease = DeviceLease::acquire(directory, tool, target)?;
|
||||||
.run_output(&["-t", target, "shell", "pidof uitest"], 2)?
|
let existing = existing_agents(tool, target)?;
|
||||||
.trim()
|
if !screen_lease::forwards(tool, target)?.is_empty() {
|
||||||
.is_empty()
|
return Err(screen_lease::busy());
|
||||||
{
|
}
|
||||||
return Err(Fault::new(
|
for (pid, cmdline) in existing {
|
||||||
"screen.debugger_busy",
|
terminate_agent(tool, target, &pid, &cmdline)?;
|
||||||
"设备正在被其他 UI 调试工具使用,请结束该调试会话后再开始投屏。",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
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 {
|
let mut resources = DeviceSession {
|
||||||
tool: Tool {
|
tool: Tool {
|
||||||
path: tool.path.clone(),
|
path: tool.path.clone(),
|
||||||
cancel: tool.cancel.clone(),
|
cancel: tool.cancel.clone(),
|
||||||
},
|
},
|
||||||
target: target.into(),
|
target: target.into(),
|
||||||
name: name.clone(),
|
uploaded: None,
|
||||||
forward: None,
|
forward: None,
|
||||||
|
lease,
|
||||||
};
|
};
|
||||||
tool.run(
|
{
|
||||||
&[
|
let mut file = tempfile::Builder::new()
|
||||||
"-t",
|
.prefix("trove-screen-")
|
||||||
target,
|
.suffix(".so")
|
||||||
"file",
|
.tempfile()?;
|
||||||
"send",
|
file.write_all(AGENT)?;
|
||||||
file.path().to_str().unwrap(),
|
let name = file
|
||||||
&format!("/data/local/tmp/{name}"),
|
.path()
|
||||||
],
|
.file_name()
|
||||||
10,
|
.unwrap()
|
||||||
)?;
|
.to_str()
|
||||||
tool.run(
|
.unwrap()
|
||||||
&[
|
.to_owned();
|
||||||
"-t",
|
resources.uploaded = Some(name.clone());
|
||||||
target,
|
tool.run(
|
||||||
"shell",
|
&[
|
||||||
&format!("uitest start-daemon singleness --extension-name {name}"),
|
"-t",
|
||||||
],
|
target,
|
||||||
5,
|
"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 reservation = TcpListener::bind(("127.0.0.1", 0)).map_err(io)?;
|
||||||
let port = reservation.local_addr().map_err(io)?.port();
|
let port = reservation.local_addr().map_err(io)?.port();
|
||||||
drop(reservation);
|
drop(reservation);
|
||||||
let local = format!("tcp:{port}");
|
let local = format!("tcp:{port}");
|
||||||
tool.run(&["-t", target, "fport", &local, SOCKET], 3)?;
|
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 deadline = Instant::now() + Duration::from_secs(5);
|
||||||
let (mut control, driver) = loop {
|
let (mut control, driver) = loop {
|
||||||
if tool.cancel.load(Ordering::Acquire) {
|
if tool.cancel.load(Ordering::Acquire) {
|
||||||
@@ -287,12 +365,12 @@ impl Stream {
|
|||||||
.socket
|
.socket
|
||||||
.set_read_timeout(Some(Duration::from_millis(100)))
|
.set_read_timeout(Some(Duration::from_millis(100)))
|
||||||
.map_err(io)?;
|
.map_err(io)?;
|
||||||
let socket = video.socket.try_clone().map_err(io)?;
|
|
||||||
let rpc = Arc::new(Mutex::new(control));
|
let rpc = Arc::new(Mutex::new(control));
|
||||||
let shared = Arc::new(Shared {
|
let shared = Arc::new(Shared {
|
||||||
state: Mutex::new(State {
|
state: Mutex::new(State {
|
||||||
latest: None,
|
latest: None,
|
||||||
error: None,
|
error: None,
|
||||||
|
cleanup_error: None,
|
||||||
touched: Instant::now(),
|
touched: Instant::now(),
|
||||||
samples: VecDeque::new(),
|
samples: VecDeque::new(),
|
||||||
bytes: VecDeque::new(),
|
bytes: VecDeque::new(),
|
||||||
@@ -316,15 +394,16 @@ impl Stream {
|
|||||||
}
|
}
|
||||||
worker_shared.cancel.store(true, Ordering::Release);
|
worker_shared.cancel.store(true, Ordering::Release);
|
||||||
worker_shared.changed.notify_all();
|
worker_shared.changed.notify_all();
|
||||||
let _ = video.send("Captures", json!({"api":"stopCaptureScreen","args":{}}));
|
let _ = video.stop_capture();
|
||||||
let _ = video.socket.shutdown(Shutdown::Both);
|
let _ = video.socket.shutdown(Shutdown::Both);
|
||||||
let _ = worker_rpc.lock().unwrap().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 {
|
Ok(Self {
|
||||||
shared,
|
shared,
|
||||||
rpc,
|
rpc,
|
||||||
socket,
|
|
||||||
worker: Mutex::new(Some(worker)),
|
worker: Mutex::new(Some(worker)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -384,18 +463,24 @@ impl Stream {
|
|||||||
self.current()?;
|
self.current()?;
|
||||||
self.rpc.lock().unwrap().driver(api, args)
|
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.cancel.store(true, Ordering::Release);
|
||||||
self.shared.changed.notify_all();
|
self.shared.changed.notify_all();
|
||||||
let _ = self.socket.shutdown(Shutdown::Read);
|
|
||||||
if let Some(worker) = self.worker.lock().unwrap().take() {
|
if let Some(worker) = self.worker.lock().unwrap().take() {
|
||||||
let _ = worker.join();
|
let _ = worker.join();
|
||||||
}
|
}
|
||||||
|
self.shared
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.cleanup_error
|
||||||
|
.clone()
|
||||||
|
.map_or(Ok(()), Err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl Drop for Stream {
|
impl Drop for Stream {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.stop();
|
let _ = self.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn receive(
|
fn receive(
|
||||||
@@ -451,11 +536,14 @@ fn receive(
|
|||||||
}
|
}
|
||||||
encoded_size = Some(size);
|
encoded_size = Some(size);
|
||||||
// A display change must be observed before coordinates can be used.
|
// 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()?;
|
dimensions = control.lock().unwrap().dimensions()?;
|
||||||
}
|
}
|
||||||
if dimensions.0 / 2 != size.0 || dimensions.1 / 2 != size.1 {
|
if !capture_size_matches(dimensions, size) {
|
||||||
return Err(fault("手机画面与屏幕尺寸不一致,请重新开始投屏。"));
|
return Err(fault(format!(
|
||||||
|
"手机画面与屏幕尺寸不一致:画面 {}×{},屏幕 {}×{}。请重新开始投屏。",
|
||||||
|
size.0, size.1, dimensions.0, dimensions.1
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let mut state = shared.state.lock().unwrap();
|
let mut state = shared.state.lock().unwrap();
|
||||||
@@ -483,6 +571,8 @@ fn receive(
|
|||||||
jpeg: Arc::new(jpeg),
|
jpeg: Arc::new(jpeg),
|
||||||
width: dimensions.0,
|
width: dimensions.0,
|
||||||
height: dimensions.1,
|
height: dimensions.1,
|
||||||
|
image_width: size.0,
|
||||||
|
image_height: size.1,
|
||||||
sequence,
|
sequence,
|
||||||
geometry,
|
geometry,
|
||||||
received: now,
|
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());
|
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();
|
"#)).unwrap();
|
||||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
let manager = Arc::new(Manager {
|
let manager = Arc::new(Manager {
|
||||||
screen: screen::Screen::default(),
|
screen: screen::Screen::new(directory.path().join("screen-sessions")),
|
||||||
path: Mutex::new(Some(path)),
|
path: Mutex::new(Some(path)),
|
||||||
settings: None,
|
settings: None,
|
||||||
busy: Arc::new(AtomicBool::new(false)),
|
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'):
|
elif args[0]=='shell' and args[1].startswith('uitest start-daemon'):
|
||||||
path('daemon').write_text(args[1].split()[-1])
|
path('daemon').write_text(args[1].split()[-1])
|
||||||
elif args[0]=='shell' and args[1]=='cat /proc/4242/cmdline':
|
elif args[0]=='shell' and args[1]=='cat /proc/4242/cmdline':
|
||||||
sys.stdout.write('uitest\0start-daemon\0'+path('daemon').read_text()+'\0')
|
if path('daemon').exists(): sys.stdout.write('uitest\0start-daemon\0singleness\0--extension-name\0'+path('daemon').read_text()+'\0')
|
||||||
elif args[:2]==['shell','kill 4242']: path('daemon').unlink(missing_ok=True)
|
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('rm -f /data/local/tmp/'): pass
|
||||||
elif args[0]=='shell' and args[1].startswith('uitest uiInput text '): print('completed')
|
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']:
|
elif args[:2]==['fport','rm']:
|
||||||
pid=int(path('pid').read_text());os.kill(pid,signal.SIGTERM)
|
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')
|
print('Remove forward ruler success')
|
||||||
elif args[0]=='fport':
|
elif args[0]=='fport':
|
||||||
|
path('forward').write_text(args[1])
|
||||||
process=subprocess.Popen([sys.executable,__file__,'server',args[1].split(':')[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)
|
stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True)
|
||||||
path('pid').write_text(str(process.pid))
|
path('pid').write_text(str(process.pid))
|
||||||
|
|||||||
@@ -84,3 +84,20 @@ export const screenKeys = {
|
|||||||
ArrowLeft: "left",
|
ArrowLeft: "left",
|
||||||
ArrowRight: "right",
|
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 { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
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", () => {
|
test("portrait phone maps the displayed image and ignores side letterboxes", () => {
|
||||||
const rect = { left: 20, top: 30, width: 800, height: 600 };
|
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);
|
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 {
|
import {
|
||||||
screenPoint,
|
screenPoint,
|
||||||
|
screenImageStyle,
|
||||||
gesture,
|
gesture,
|
||||||
scrollGesture,
|
scrollGesture,
|
||||||
screenKeys,
|
screenKeys,
|
||||||
@@ -32,6 +33,20 @@ export function screen({ call, host, notice, onBusy }) {
|
|||||||
samples: [],
|
samples: [],
|
||||||
disposed: false,
|
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() {
|
function controls() {
|
||||||
$("screen-start").disabled =
|
$("screen-start").disabled =
|
||||||
!state.available ||
|
!state.available ||
|
||||||
@@ -164,6 +179,7 @@ export function screen({ call, host, notice, onBusy }) {
|
|||||||
"屏幕方向已变化,请重新启用控制。";
|
"屏幕方向已变化,请重新启用控制。";
|
||||||
}
|
}
|
||||||
state.frame = frame;
|
state.frame = frame;
|
||||||
|
layoutImage();
|
||||||
if (!changed && controlRevision === state.controlRevision)
|
if (!changed && controlRevision === state.controlRevision)
|
||||||
state.control = !!frame.control;
|
state.control = !!frame.control;
|
||||||
state.displayedAt = Date.now();
|
state.displayedAt = Date.now();
|
||||||
@@ -489,6 +505,7 @@ export function screen({ call, host, notice, onBusy }) {
|
|||||||
});
|
});
|
||||||
window.addEventListener("pagehide", () => {
|
window.addEventListener("pagehide", () => {
|
||||||
state.disposed = true;
|
state.disposed = true;
|
||||||
|
resizeObserver?.disconnect();
|
||||||
stop();
|
stop();
|
||||||
});
|
});
|
||||||
controls();
|
controls();
|
||||||
|
|||||||
@@ -1005,7 +1005,7 @@ dialog li {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: fill;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
#screen-placeholder {
|
#screen-placeholder {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Harmony Connector
|
# Harmony Connector
|
||||||
|
|
||||||
插件 ID:`dev.trove.harmony-connector`。当前版本:`0.3.2`。
|
插件 ID:`dev.trove.harmony-connector`。当前版本:`0.4.0`。
|
||||||
|
|
||||||
macOS 上的鸿蒙设备连接工具。通过 USB 收发文件与完整文件夹;使用官方 HDC 管理应用、保存截图、导出日志和调试应用数据。功能按连接工具、设备实际能力和授权开放,不按 HarmonyOS 大版本或机型名称限制。
|
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)。
|
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.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/`。
|
要求 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,也没有账号或系统共享确认步骤。
|
官方 `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)。
|
之前的 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`。
|
- 图像与控制使用两个独立 socket,共用本会话的 HDC 转发。按键和手势通过持久 RPC 发送,文字使用已验证的 `uitest uiInput text`。
|
||||||
- 代理回包包括 JSON 行与 JPEG;解析 JPEG 段长度,避免把 EXIF 数据中的字节误认作图像结尾。单帧上限 2 MiB,JSON 上限 64 KiB。
|
- 代理回包包括 JSON 行与 JPEG;解析 JPEG 段长度,避免把 EXIF 数据中的字节误认作图像结尾。单帧上限 2 MiB,JSON 上限 64 KiB。
|
||||||
- 静止画面不视为断线;独立控制连接查询尺寸检查存活。UI 停止请求超过 15 秒会结束会话。屏幕几何变化会关闭输入控制。
|
- 静止画面不视为断线;独立控制连接查询尺寸检查存活。UI 停止请求超过 15 秒会结束会话。屏幕几何变化会关闭输入控制。
|
||||||
- 每次部署使用唯一文件名,只结束加载该文件的进程,只删除本次转发和文件。发现其他 UITest 会话时提示占用,不接管或结束其他调试工具。
|
- 每次部署使用唯一文件名,以完整命令行重新确认进程身份后结束代理,并验证退出。设备锁与转发记录支持异常退出恢复;其他工具的代理和活动转发会阻止启动。停止时删除自己的临时文件和转发。
|
||||||
|
|
||||||
运行 HDC 不需要完整 SDK:本机独立验证了 `hdc` 与同目录 `libusb_shared.dylib` 的版本命令,总计约 6.4 MiB。完整工具包用于开发构建,不应成为普通投屏用户的依赖。本次尚未新增精简 HDC 发行包;插件继续使用已配置或自动发现的官方工具。
|
运行 HDC 不需要完整 SDK:本机独立验证了 `hdc` 与同目录 `libusb_shared.dylib` 的版本命令,总计约 6.4 MiB。完整工具包用于开发构建,不应成为普通投屏用户的依赖。本次尚未新增精简 HDC 发行包;插件继续使用已配置或自动发现的官方工具。
|
||||||
|
|||||||
@@ -147,3 +147,10 @@ ASCII "_uitestkit_rpc_message_tail_"
|
|||||||
- 未确认 `Captures/Gestures` 跨版本稳定性;应固定已验收的 agent,报告能力与错误,避免无提示降为旧截图模式。
|
- 未确认 `Captures/Gestures` 跨版本稳定性;应固定已验收的 agent,报告能力与错误,避免无提示降为旧截图模式。
|
||||||
|
|
||||||
最终用户流程可以保持“连接 USB → 允许调试 → 开始投屏”。官方 agent 的获取、校验、部署、转发和清理应由插件完成,不能再把开发者账号或 HAP 签名作为该流程的要求。
|
最终用户流程可以保持“连接 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,
|
"schemaVersion": 1,
|
||||||
"id": "trove-official",
|
"id": "trove-official",
|
||||||
"name": "Trove 官方软件源",
|
"name": "Trove 官方软件源",
|
||||||
"generatedAt": "2026-09-10T06:45:52.251Z",
|
"generatedAt": "2026-09-10T08:42:44.362Z",
|
||||||
"curios": [
|
"curios": [
|
||||||
{
|
{
|
||||||
"id": "dev.trove.qa.host",
|
"id": "dev.trove.qa.host",
|
||||||
@@ -200,6 +200,21 @@
|
|||||||
"name": "Harmony Connector",
|
"name": "Harmony Connector",
|
||||||
"description": "连接鸿蒙设备,收发完整文件夹、管理应用,查看手机画面并通过键鼠操作。",
|
"description": "连接鸿蒙设备,收发完整文件夹、管理应用,查看手机画面并通过键鼠操作。",
|
||||||
"versions": [
|
"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",
|
"version": "0.3.2",
|
||||||
"engine": {
|
"engine": {
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user