perf(harmony): open viewer immediately and remember working device agent
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.5.1",
|
"version": "0.5.2",
|
||||||
"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
@@ -259,7 +259,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "harmony-connector"
|
name = "harmony-connector"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "harmony-connector"
|
name = "harmony-connector"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
use super::screen_agent::{self, Rpc};
|
use super::screen_agent::{self, Rpc};
|
||||||
use super::screen_lease::{self, Lease, SOCKET};
|
use super::screen_lease::{self, Lease, SOCKET};
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::{io::Write, net::TcpListener, time::Instant};
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{collections::VecDeque, io::Write, net::TcpListener, time::Instant};
|
||||||
|
|
||||||
const CONTROL: &[u8] =
|
const CONTROL: &[u8] =
|
||||||
include_bytes!("../../../../../vendor/harmony-connector/hypium/uitest_agent_v1.2.3.so");
|
include_bytes!("../../../../../vendor/harmony-connector/hypium/uitest_agent_v1.2.3.so");
|
||||||
@@ -37,6 +38,8 @@ pub(super) struct Resources {
|
|||||||
forwards: Vec<(String, String)>,
|
forwards: Vec<(String, String)>,
|
||||||
lease: Lease,
|
lease: Lease,
|
||||||
video: Option<(usize, String)>,
|
video: Option<(usize, String)>,
|
||||||
|
profile: PathBuf,
|
||||||
|
candidates: VecDeque<usize>,
|
||||||
}
|
}
|
||||||
impl Resources {
|
impl Resources {
|
||||||
pub fn open(tool: Tool, target: String) -> Result<Self> {
|
pub fn open(tool: Tool, target: String) -> Result<Self> {
|
||||||
@@ -55,6 +58,22 @@ impl Resources {
|
|||||||
for (pid, cmdline) in existing {
|
for (pid, cmdline) in existing {
|
||||||
screen_agent::terminate_agent(&tool, &target, &pid, &cmdline)?;
|
screen_agent::terminate_agent(&tool, &target, &pid, &cmdline)?;
|
||||||
}
|
}
|
||||||
|
let profile = root
|
||||||
|
.join("profiles")
|
||||||
|
.join(format!("{:x}.json", Sha256::digest(target.as_bytes())));
|
||||||
|
let preferred = fs::read(&profile)
|
||||||
|
.ok()
|
||||||
|
.filter(|bytes| bytes.len() <= 4096)
|
||||||
|
.and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
|
||||||
|
.and_then(|value| {
|
||||||
|
VIDEOS
|
||||||
|
.iter()
|
||||||
|
.position(|(version, _)| value["videoAgent"] == *version)
|
||||||
|
});
|
||||||
|
let candidates = preferred
|
||||||
|
.into_iter()
|
||||||
|
.chain((0..VIDEOS.len()).filter(|index| Some(*index) != preferred))
|
||||||
|
.collect();
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
tool,
|
tool,
|
||||||
target,
|
target,
|
||||||
@@ -62,6 +81,8 @@ impl Resources {
|
|||||||
forwards: Vec::new(),
|
forwards: Vec::new(),
|
||||||
lease,
|
lease,
|
||||||
video: None,
|
video: None,
|
||||||
|
profile,
|
||||||
|
candidates,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
fn launch(&mut self, bytes: &[u8], arguments: &str) -> Result<String> {
|
fn launch(&mut self, bytes: &[u8], arguments: &str) -> Result<String> {
|
||||||
@@ -244,11 +265,9 @@ impl Resources {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
pub fn video(&mut self) -> Result<u16> {
|
pub fn video(&mut self) -> Result<u16> {
|
||||||
self.video_from(0)
|
|
||||||
}
|
|
||||||
fn video_from(&mut self, start: usize) -> Result<u16> {
|
|
||||||
let mut failures = Vec::new();
|
let mut failures = Vec::new();
|
||||||
for (index, (version, bytes)) in VIDEOS.iter().enumerate().skip(start) {
|
while let Some(index) = self.candidates.pop_front() {
|
||||||
|
let (version, bytes) = VIDEOS[index];
|
||||||
let name = self.launch(bytes, VIDEO_ARGS)?;
|
let name = self.launch(bytes, VIDEO_ARGS)?;
|
||||||
match self.wait_ready(&name, VIDEO_SOCKET, version) {
|
match self.wait_ready(&name, VIDEO_SOCKET, version) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
@@ -269,7 +288,7 @@ impl Resources {
|
|||||||
}
|
}
|
||||||
pub fn reconnect_video(&mut self) -> Result<u16> {
|
pub fn reconnect_video(&mut self) -> Result<u16> {
|
||||||
self.remove_forward(VIDEO_SOCKET)?;
|
self.remove_forward(VIDEO_SOCKET)?;
|
||||||
let (index, name) = self
|
let (_, name) = self
|
||||||
.video
|
.video
|
||||||
.clone()
|
.clone()
|
||||||
.ok_or_else(|| Fault::new("screen.closed", "视频组件已停止。"))?;
|
.ok_or_else(|| Fault::new("screen.closed", "视频组件已停止。"))?;
|
||||||
@@ -278,7 +297,19 @@ impl Resources {
|
|||||||
}
|
}
|
||||||
self.remove_agent(&name)?;
|
self.remove_agent(&name)?;
|
||||||
self.video = None;
|
self.video = None;
|
||||||
self.video_from(index + 1)
|
self.video()
|
||||||
|
}
|
||||||
|
pub fn remember_video(&self) -> Result<()> {
|
||||||
|
let Some((index, _)) = &self.video else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let directory = self.profile.parent().unwrap();
|
||||||
|
fs::create_dir_all(directory)?;
|
||||||
|
let mut file = tempfile::NamedTempFile::new_in(directory)?;
|
||||||
|
file.write_all(&serde_json::to_vec(&json!({"videoAgent":VIDEOS[*index].0})).unwrap())?;
|
||||||
|
file.persist(&self.profile)
|
||||||
|
.map_err(|e| Fault::from(e.error))?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
pub fn close(&mut self) -> Result<()> {
|
pub fn close(&mut self) -> Result<()> {
|
||||||
if self.files.is_empty() && self.forwards.is_empty() {
|
if self.files.is_empty() && self.forwards.is_empty() {
|
||||||
|
|||||||
@@ -34,14 +34,17 @@ pub(super) struct Recorder {
|
|||||||
resources: Mutex<Option<Resources>>,
|
resources: Mutex<Option<Resources>>,
|
||||||
}
|
}
|
||||||
impl Recorder {
|
impl Recorder {
|
||||||
pub fn open(tool: Tool, target: String) -> Result<Arc<Self>> {
|
pub fn open(tool: Tool, target: String, progress: impl Fn(&str)) -> Result<Arc<Self>> {
|
||||||
|
progress("正在连接设备…");
|
||||||
let mut resources = Resources::open(tool, target)?;
|
let mut resources = Resources::open(tool, target)?;
|
||||||
|
progress("正在准备鼠标控制…");
|
||||||
let (control_port, mut control) = resources.control()?;
|
let (control_port, mut control) = resources.control()?;
|
||||||
let (width, height) = control.dimensions()?;
|
let (width, height) = control.dimensions()?;
|
||||||
|
progress("正在连接手机画面…");
|
||||||
let port = resources.video()?;
|
let port = resources.video()?;
|
||||||
let tool = Tool {
|
let tool = Tool {
|
||||||
path: resources.tool.path.clone(),
|
path: resources.tool.path.clone(),
|
||||||
cancel: Arc::new(AtomicBool::new(false)),
|
cancel: resources.tool.cancel.clone(),
|
||||||
};
|
};
|
||||||
let target = resources.target.clone();
|
let target = resources.target.clone();
|
||||||
let help = tool
|
let help = tool
|
||||||
@@ -58,8 +61,9 @@ impl Recorder {
|
|||||||
] {
|
] {
|
||||||
supported.insert(action.into());
|
supported.insert(action.into());
|
||||||
}
|
}
|
||||||
|
let cancel = resources.tool.cancel.clone();
|
||||||
let owner = Arc::new(Self {
|
let owner = Arc::new(Self {
|
||||||
cancel: Arc::new(AtomicBool::new(false)),
|
cancel,
|
||||||
port,
|
port,
|
||||||
keyframe: AtomicBool::new(false),
|
keyframe: AtomicBool::new(false),
|
||||||
ready: AtomicBool::new(false),
|
ready: AtomicBool::new(false),
|
||||||
@@ -101,6 +105,14 @@ impl Recorder {
|
|||||||
*owner.worker.lock().unwrap() = Some(worker);
|
*owner.worker.lock().unwrap() = Some(worker);
|
||||||
Ok(owner)
|
Ok(owner)
|
||||||
}
|
}
|
||||||
|
pub fn mark_ready(&self) {
|
||||||
|
if !self.ready.swap(true, Ordering::AcqRel) {
|
||||||
|
if let Some(resources) = self.resources.lock().unwrap().as_ref() {
|
||||||
|
// This is an optional startup optimization; a cache write must not stop video.
|
||||||
|
let _ = resources.remember_video();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
pub fn reconnect_video(&self) -> Result<u16> {
|
pub fn reconnect_video(&self) -> Result<u16> {
|
||||||
if self.cancel.load(Ordering::Acquire) {
|
if self.cancel.load(Ordering::Acquire) {
|
||||||
return Err(Fault::new("screen.closed", "投屏已停止。"));
|
return Err(Fault::new("screen.closed", "投屏已停止。"));
|
||||||
|
|||||||
@@ -37,10 +37,31 @@ impl Pipe {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
struct Session {
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
recorder: Mutex<Option<Arc<Recorder>>>,
|
||||||
|
phase: Mutex<String>,
|
||||||
|
error: Mutex<Option<Fault>>,
|
||||||
|
cleanup_error: Mutex<Option<Fault>>,
|
||||||
|
}
|
||||||
|
impl Session {
|
||||||
|
fn recorder(&self) -> Option<Arc<Recorder>> {
|
||||||
|
self.recorder.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
fn status(&self, target: &str) -> Value {
|
||||||
|
json!({"active":!self.cancel.load(Ordering::Acquire),"target":target,
|
||||||
|
"ready":self.recorder().is_some_and(|r| r.ready.load(Ordering::Acquire)),
|
||||||
|
"phase":self.phase.lock().unwrap().clone(),"error":self.error.lock().unwrap().clone()})
|
||||||
|
}
|
||||||
|
fn progress(&self, pipe: &Pipe, text: &str) {
|
||||||
|
*self.phase.lock().unwrap() = text.into();
|
||||||
|
let _ = pipe.send(json!({"kind":"progress","message":text}), &[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
struct Viewer {
|
struct Viewer {
|
||||||
child: Child,
|
child: Child,
|
||||||
pipe: Arc<Pipe>,
|
pipe: Arc<Pipe>,
|
||||||
recorder: Arc<Recorder>,
|
session: Arc<Session>,
|
||||||
target: String,
|
target: String,
|
||||||
worker: Option<thread::JoinHandle<()>>,
|
worker: Option<thread::JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
@@ -57,27 +78,23 @@ pub(super) struct Open {
|
|||||||
}
|
}
|
||||||
impl Window {
|
impl Window {
|
||||||
pub fn status(&self) -> Value {
|
pub fn status(&self) -> Value {
|
||||||
let current = self.current.lock().unwrap();
|
self.current
|
||||||
current.as_ref().map_or(json!({"active":false}), |v| {
|
.lock()
|
||||||
json!({
|
.unwrap()
|
||||||
"active":!v.recorder.cancel.load(Ordering::Acquire),"target":v.target,
|
.as_ref()
|
||||||
"ready":v.recorder.ready.load(Ordering::Acquire),
|
.map_or(json!({"active":false}), |v| v.session.status(&v.target))
|
||||||
"error":v.recorder.error.lock().unwrap().clone()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
pub fn open(&self, tool: Tool, request: Open) -> Result<Value> {
|
pub fn open(&self, mut tool: Tool, request: Open) -> Result<Value> {
|
||||||
|
hdc_data::target(&request.target)?;
|
||||||
let mut current = self.current.lock().unwrap();
|
let mut current = self.current.lock().unwrap();
|
||||||
if let Some(viewer) = current.as_mut() {
|
if let Some(viewer) = current.as_mut() {
|
||||||
if !viewer.recorder.cancel.load(Ordering::Acquire) && viewer.child.try_wait()?.is_none()
|
if !viewer.session.cancel.load(Ordering::Acquire) && viewer.child.try_wait()?.is_none()
|
||||||
{
|
{
|
||||||
if viewer.target != request.target {
|
if viewer.target != request.target {
|
||||||
return Err(screen_lease::busy());
|
return Err(screen_lease::busy());
|
||||||
}
|
}
|
||||||
viewer.pipe.send(json!({"kind":"focus"}), &[])?;
|
viewer.pipe.send(json!({"kind":"focus"}), &[])?;
|
||||||
return Ok(
|
return Ok(viewer.session.status(&request.target));
|
||||||
json!({"active":true,"ready":viewer.recorder.ready.load(Ordering::Acquire),"target":request.target}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(viewer) = current.take() {
|
if let Some(viewer) = current.take() {
|
||||||
@@ -90,25 +107,19 @@ impl Window {
|
|||||||
"投屏组件不完整,请更新或重新安装 Harmony Connector。",
|
"投屏组件不完整,请更新或重新安装 Harmony Connector。",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let recorder = Recorder::open(tool, request.target.clone())?;
|
|
||||||
let name = if request.name.trim().is_empty() {
|
let name = if request.name.trim().is_empty() {
|
||||||
"Harmony"
|
"Harmony"
|
||||||
} else {
|
} else {
|
||||||
&request.name
|
&request.name
|
||||||
};
|
};
|
||||||
let started = Command::new(executable)
|
// Show the native window before any USB discovery, upload or compatibility negotiation.
|
||||||
|
let mut child = Command::new(executable)
|
||||||
.arg(name)
|
.arg(name)
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
.spawn();
|
.spawn()
|
||||||
let mut child = match started {
|
.map_err(|e| Fault::new("screen.window_failed", format!("无法打开投屏窗口:{e}")))?;
|
||||||
Ok(child) => child,
|
|
||||||
Err(error) => {
|
|
||||||
let _ = recorder.stop();
|
|
||||||
return Err(error.into());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let stdin = child.stdin.take().unwrap();
|
let stdin = child.stdin.take().unwrap();
|
||||||
let fd = stdin.as_raw_fd();
|
let fd = stdin.as_raw_fd();
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -119,13 +130,21 @@ impl Window {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let pipe = Arc::new(Pipe(Mutex::new(stdin)));
|
let pipe = Arc::new(Pipe(Mutex::new(stdin)));
|
||||||
|
let session = Arc::new(Session {
|
||||||
|
cancel: Arc::new(AtomicBool::new(false)),
|
||||||
|
recorder: Mutex::new(None),
|
||||||
|
phase: Mutex::new("正在连接设备…".into()),
|
||||||
|
error: Mutex::new(None),
|
||||||
|
cleanup_error: Mutex::new(None),
|
||||||
|
});
|
||||||
|
// A viewer's cancellation belongs to that viewer, not the management page's next action.
|
||||||
|
tool.cancel = session.cancel.clone();
|
||||||
let stdout = child.stdout.take().unwrap();
|
let stdout = child.stdout.take().unwrap();
|
||||||
let reader_recorder = recorder.clone();
|
let reader_session = session.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let mut reader = BufReader::new(stdout);
|
let mut reader = BufReader::new(stdout);
|
||||||
loop {
|
loop {
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
// Every viewer message is bounded independently of the peer's newline behavior.
|
|
||||||
match reader.by_ref().take(16385).read_line(&mut line) {
|
match reader.by_ref().take(16385).read_line(&mut line) {
|
||||||
Ok(0) | Err(_) => break,
|
Ok(0) | Err(_) => break,
|
||||||
Ok(_) if line.len() > 16384 || !line.ends_with('\n') => break,
|
Ok(_) if line.len() > 16384 || !line.ends_with('\n') => break,
|
||||||
@@ -134,81 +153,65 @@ impl Window {
|
|||||||
let Ok(value) = serde_json::from_str::<Value>(&line) else {
|
let Ok(value) = serde_json::from_str::<Value>(&line) else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
|
let Some(recorder) = reader_session.recorder() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
if value["event"] == "input" {
|
if value["event"] == "input" {
|
||||||
if let Err(error) = reader_recorder.push(value) {
|
if let Err(error) = recorder.push(value) {
|
||||||
*reader_recorder.error.lock().unwrap() = Some(error);
|
*reader_session.error.lock().unwrap() = Some(error);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else if value["event"] == "presented" {
|
} else if value["event"] == "presented" {
|
||||||
reader_recorder.ready.store(true, Ordering::Release);
|
recorder.mark_ready();
|
||||||
} else if value["event"] == "decoderError" {
|
} else if value["event"] == "decoderError" {
|
||||||
reader_recorder.keyframe.store(true, Ordering::Release);
|
recorder.keyframe.store(true, Ordering::Release);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reader_recorder.cancel.store(true, Ordering::Release);
|
reader_session.cancel.store(true, Ordering::Release);
|
||||||
});
|
});
|
||||||
let worker_recorder = recorder.clone();
|
let worker_session = session.clone();
|
||||||
let worker_pipe = pipe.clone();
|
let worker_pipe = pipe.clone();
|
||||||
|
let target = request.target.clone();
|
||||||
let worker = thread::spawn(move || {
|
let worker = thread::spawn(move || {
|
||||||
let mut sequence = 0u64;
|
let result = (|| {
|
||||||
let mut samples = VecDeque::new();
|
let recorder = Recorder::open(tool, target, |phase| {
|
||||||
let mut port = worker_recorder.port;
|
worker_session.progress(&worker_pipe, phase)
|
||||||
let mut retries = 0;
|
})?;
|
||||||
let result = loop {
|
*worker_session.recorder.lock().unwrap() = Some(recorder.clone());
|
||||||
let result = super::screen_video::receive(
|
if worker_session.cancel.load(Ordering::Acquire) {
|
||||||
port,
|
return Ok(());
|
||||||
&worker_recorder.cancel,
|
|
||||||
&worker_recorder.keyframe,
|
|
||||||
|bytes| {
|
|
||||||
sequence += 1;
|
|
||||||
let now = Instant::now();
|
|
||||||
samples.push_back((now, bytes.len()));
|
|
||||||
while samples.front().is_some_and(|(time, _)| {
|
|
||||||
now.duration_since(*time) > Duration::from_secs(1)
|
|
||||||
}) {
|
|
||||||
samples.pop_front();
|
|
||||||
}
|
|
||||||
let dimensions = worker_recorder.dimensions();
|
|
||||||
worker_pipe.send(json!({"kind":"video","frame":{
|
|
||||||
"sequence":sequence,"geometry":dimensions.geometry,
|
|
||||||
"width":dimensions.width,"height":dimensions.height,
|
|
||||||
"captureWidth":dimensions.width.div_ceil(2),"captureHeight":dimensions.height.div_ceil(2),
|
|
||||||
"receivedFps":samples.len(),"mbps":samples.iter().map(|(_,bytes)| *bytes).sum::<usize>() as f64 * 8.0 / 1_000_000.0
|
|
||||||
}}), &bytes)
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if result.is_ok()
|
|
||||||
|| sequence > 0
|
|
||||||
|| retries >= 2
|
|
||||||
|| worker_recorder.cancel.load(Ordering::Acquire)
|
|
||||||
{
|
|
||||||
break result;
|
|
||||||
}
|
}
|
||||||
retries += 1;
|
worker_session.progress(&worker_pipe, "等待手机画面…");
|
||||||
port = match worker_recorder.reconnect_video() {
|
receive(&recorder, &worker_pipe)
|
||||||
Ok(port) => port,
|
})();
|
||||||
Err(error) => break Err(error),
|
|
||||||
};
|
|
||||||
thread::sleep(Duration::from_millis(100));
|
|
||||||
};
|
|
||||||
if let Err(error) = result {
|
if let Err(error) = result {
|
||||||
worker_recorder.error.lock().unwrap().get_or_insert(error);
|
if error.code != "operation.cancelled" {
|
||||||
|
worker_session.error.lock().unwrap().get_or_insert(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(error) = worker_recorder.error.lock().unwrap().clone() {
|
if let Some(recorder) = worker_session.recorder() {
|
||||||
|
if let Some(error) = recorder.error.lock().unwrap().clone() {
|
||||||
|
worker_session.error.lock().unwrap().get_or_insert(error);
|
||||||
|
}
|
||||||
|
if let Err(error) = recorder.stop() {
|
||||||
|
*worker_session.cleanup_error.lock().unwrap() = Some(error.clone());
|
||||||
|
worker_session.error.lock().unwrap().get_or_insert(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
worker_session.cancel.store(true, Ordering::Release);
|
||||||
|
if let Some(error) = worker_session.error.lock().unwrap().clone() {
|
||||||
let _ = worker_pipe.send(json!({"kind":"error","message":error.message}), &[]);
|
let _ = worker_pipe.send(json!({"kind":"error","message":error.message}), &[]);
|
||||||
}
|
}
|
||||||
if let Err(error) = worker_recorder.stop() {
|
|
||||||
worker_recorder.error.lock().unwrap().get_or_insert(error);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
let status = session.status(&request.target);
|
||||||
*current = Some(Viewer {
|
*current = Some(Viewer {
|
||||||
child,
|
child,
|
||||||
pipe,
|
pipe,
|
||||||
recorder,
|
session,
|
||||||
target: request.target.clone(),
|
target: request.target,
|
||||||
worker: Some(worker),
|
worker: Some(worker),
|
||||||
});
|
});
|
||||||
Ok(json!({"active":true,"ready":false,"target":request.target}))
|
Ok(status)
|
||||||
}
|
}
|
||||||
pub fn stop(&self) -> Result<Value> {
|
pub fn stop(&self) -> Result<Value> {
|
||||||
if let Some(viewer) = self.current.lock().unwrap().take() {
|
if let Some(viewer) = self.current.lock().unwrap().take() {
|
||||||
@@ -217,10 +220,46 @@ impl Window {
|
|||||||
Ok(json!({"active":false}))
|
Ok(json!({"active":false}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fn receive(recorder: &Recorder, pipe: &Pipe) -> Result<()> {
|
||||||
|
let mut sequence = 0u64;
|
||||||
|
let mut samples = VecDeque::new();
|
||||||
|
let mut port = recorder.port;
|
||||||
|
let mut retries = 0;
|
||||||
|
loop {
|
||||||
|
let result = super::screen_video::receive(
|
||||||
|
port,
|
||||||
|
&recorder.cancel,
|
||||||
|
&recorder.keyframe,
|
||||||
|
|bytes| {
|
||||||
|
sequence += 1;
|
||||||
|
let now = Instant::now();
|
||||||
|
samples.push_back((now, bytes.len()));
|
||||||
|
while samples
|
||||||
|
.front()
|
||||||
|
.is_some_and(|(time, _)| now.duration_since(*time) > Duration::from_secs(1))
|
||||||
|
{
|
||||||
|
samples.pop_front();
|
||||||
|
}
|
||||||
|
let dimensions = recorder.dimensions();
|
||||||
|
pipe.send(json!({"kind":"video","frame":{
|
||||||
|
"sequence":sequence,"geometry":dimensions.geometry,"width":dimensions.width,"height":dimensions.height,
|
||||||
|
"captureWidth":dimensions.width.div_ceil(2),"captureHeight":dimensions.height.div_ceil(2),
|
||||||
|
"receivedFps":samples.len(),"mbps":samples.iter().map(|(_,bytes)| *bytes).sum::<usize>() as f64 * 8.0 / 1_000_000.0
|
||||||
|
}}), &bytes)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if result.is_ok() || sequence > 0 || retries >= 2 || recorder.cancel.load(Ordering::Acquire)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
retries += 1;
|
||||||
|
port = recorder.reconnect_video()?;
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
fn close(mut viewer: Viewer) -> Result<()> {
|
fn close(mut viewer: Viewer) -> Result<()> {
|
||||||
viewer.recorder.cancel.store(true, Ordering::Release);
|
viewer.session.cancel.store(true, Ordering::Release);
|
||||||
let _ = viewer.pipe.send(json!({"kind":"close"}), &[]);
|
let _ = viewer.pipe.send(json!({"kind":"close"}), &[]);
|
||||||
let result = viewer.recorder.stop();
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(1);
|
let deadline = Instant::now() + Duration::from_secs(1);
|
||||||
while viewer.child.try_wait()?.is_none() {
|
while viewer.child.try_wait()?.is_none() {
|
||||||
if Instant::now() >= deadline {
|
if Instant::now() >= deadline {
|
||||||
@@ -230,8 +269,10 @@ fn close(mut viewer: Viewer) -> Result<()> {
|
|||||||
thread::sleep(Duration::from_millis(20));
|
thread::sleep(Duration::from_millis(20));
|
||||||
}
|
}
|
||||||
let _ = viewer.child.wait();
|
let _ = viewer.child.wait();
|
||||||
|
// The same worker owns startup and cleanup, including cancellation during an upload.
|
||||||
if let Some(worker) = viewer.worker.take() {
|
if let Some(worker) = viewer.worker.take() {
|
||||||
let _ = worker.join();
|
let _ = worker.join();
|
||||||
}
|
}
|
||||||
result
|
let error = viewer.session.cleanup_error.lock().unwrap().clone();
|
||||||
|
error.map_or(Ok(()), Err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
export function screen({ call, notice, onBusy }) {
|
export function screen({ call, notice, onBusy }) {
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const state = { target: "", available: false, blocked: false, working: false, active: false, ready: false, disposed: false };
|
const state = { target: "", available: false, blocked: false, working: false, active: false, ready: false, phase: "", disposed: false };
|
||||||
let timer, error;
|
let timer, error, reportedBusy = false;
|
||||||
|
function reportBusy() {
|
||||||
|
const busy = state.working || (state.active && !state.ready);
|
||||||
|
if (busy === reportedBusy) return;
|
||||||
|
reportedBusy = busy;
|
||||||
|
onBusy(busy);
|
||||||
|
}
|
||||||
function render() {
|
function render() {
|
||||||
$("screen-start").disabled = state.working || state.blocked || !state.available;
|
$("screen-start").disabled = state.working || state.blocked || !state.available;
|
||||||
$("screen-start").textContent = state.working ? "正在连接…" : state.active ? "显示投屏窗口" : "打开投屏窗口";
|
$("screen-start").textContent = state.working ? "正在连接…" : state.active ? "显示投屏窗口" : "打开投屏窗口";
|
||||||
$("screen-stop").disabled = state.working || !state.active;
|
$("screen-stop").disabled = state.working || !state.active;
|
||||||
$("screen-status").textContent = state.active ? state.ready ? "正在独立窗口中投屏 · 默认鼠标控制" : "正在建立视频连接…" : "让手机,来到桌面。";
|
$("screen-status").textContent = state.active ? state.ready ? "正在独立窗口中投屏 · 默认鼠标控制" : state.phase || "正在建立视频连接…" : "让手机,来到桌面。";
|
||||||
}
|
}
|
||||||
async function status() {
|
async function status() {
|
||||||
if (state.disposed) return;
|
if (state.disposed) return;
|
||||||
try {
|
try {
|
||||||
const result = await call("screenWindowStatus", {});
|
const result = await call("screenWindowStatus", {});
|
||||||
if (state.disposed) return;
|
if (state.disposed) return;
|
||||||
if (!state.working) { state.active = result.active; state.ready = !!result.ready; }
|
if (!state.working) { state.active = result.active; state.ready = !!result.ready; state.phase = result.phase || ""; }
|
||||||
if (result.error && result.error.message !== error) {
|
if (result.error && result.error.message !== error) {
|
||||||
error = result.error.message;
|
error = result.error.message;
|
||||||
notice(error);
|
notice(error);
|
||||||
}
|
}
|
||||||
|
reportBusy();
|
||||||
render();
|
render();
|
||||||
} catch { /* Closing the plugin also closes the native session. */ }
|
} catch { /* Closing the plugin also closes the native session. */ }
|
||||||
if (!state.disposed) timer = setTimeout(status, 1000);
|
if (!state.disposed) timer = setTimeout(status, 1000);
|
||||||
@@ -25,17 +32,18 @@ export function screen({ call, notice, onBusy }) {
|
|||||||
async function action(method, args) {
|
async function action(method, args) {
|
||||||
if (state.working) return;
|
if (state.working) return;
|
||||||
state.working = true;
|
state.working = true;
|
||||||
onBusy(true);
|
reportBusy();
|
||||||
render();
|
render();
|
||||||
try {
|
try {
|
||||||
const result = await call(method, args);
|
const result = await call(method, args);
|
||||||
state.active = result.active;
|
state.active = result.active;
|
||||||
state.ready = !!result.ready;
|
state.ready = !!result.ready;
|
||||||
|
state.phase = result.phase || "";
|
||||||
error = undefined;
|
error = undefined;
|
||||||
} catch (error) { notice(error.message || String(error)); }
|
} catch (error) { notice(error.message || String(error)); }
|
||||||
finally {
|
finally {
|
||||||
state.working = false;
|
state.working = false;
|
||||||
onBusy(false);
|
reportBusy();
|
||||||
if (!state.disposed) render();
|
if (!state.disposed) render();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ final class ViewerApp: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|||||||
private var window: NSWindow!
|
private var window: NSWindow!
|
||||||
private var view: ScreenView!
|
private var view: ScreenView!
|
||||||
private var samples: [TimeInterval] = []
|
private var samples: [TimeInterval] = []
|
||||||
|
private var loading: NSStackView!
|
||||||
|
private let loadingLabel = NSTextField(labelWithString: "正在连接设备…")
|
||||||
|
private let spinner = NSProgressIndicator()
|
||||||
private let pendingLock = NSLock()
|
private let pendingLock = NSLock()
|
||||||
private var pending: (CVPixelBuffer, ScreenFrame)?
|
private var pending: (CVPixelBuffer, ScreenFrame)?
|
||||||
private var scheduled = false
|
private var scheduled = false
|
||||||
@@ -21,6 +24,21 @@ final class ViewerApp: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|||||||
window.delegate = self
|
window.delegate = self
|
||||||
view = ScreenView(frame: window.contentLayoutRect)
|
view = ScreenView(frame: window.contentLayoutRect)
|
||||||
window.contentView = view
|
window.contentView = view
|
||||||
|
spinner.style = .spinning
|
||||||
|
spinner.controlSize = .regular
|
||||||
|
spinner.startAnimation(nil)
|
||||||
|
loadingLabel.textColor = .secondaryLabelColor
|
||||||
|
loadingLabel.font = .systemFont(ofSize: 13)
|
||||||
|
loading = NSStackView(views: [spinner, loadingLabel])
|
||||||
|
loading.orientation = .vertical
|
||||||
|
loading.spacing = 16
|
||||||
|
loading.appearance = NSAppearance(named: .darkAqua)
|
||||||
|
loading.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(loading)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
loading.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
|
loading.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||||
|
])
|
||||||
window.center()
|
window.center()
|
||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
window.makeFirstResponder(view)
|
window.makeFirstResponder(view)
|
||||||
@@ -60,6 +78,8 @@ final class ViewerApp: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|||||||
self.view.display(image, frame: frame)
|
self.view.display(image, frame: frame)
|
||||||
if !self.announced {
|
if !self.announced {
|
||||||
self.announced = true
|
self.announced = true
|
||||||
|
self.loading.isHidden = true
|
||||||
|
self.spinner.stopAnimation(nil)
|
||||||
ViewerWire.send(["event": "presented"])
|
ViewerWire.send(["event": "presented"])
|
||||||
}
|
}
|
||||||
if self.geometry != frame.geometry {
|
if self.geometry != frame.geometry {
|
||||||
@@ -89,12 +109,21 @@ final class ViewerApp: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|||||||
if message.kind == "video", let frame = message.frame {
|
if message.kind == "video", let frame = message.frame {
|
||||||
do { try decoder.decode(bytes, frame: frame) }
|
do { try decoder.decode(bytes, frame: frame) }
|
||||||
catch { decoder.recover() }
|
catch { decoder.recover() }
|
||||||
|
} else if message.kind == "progress" {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self, !self.announced else { return }
|
||||||
|
let text = message.message ?? "正在连接设备…"
|
||||||
|
self.loadingLabel.stringValue = text
|
||||||
|
self.window.title = "\(self.deviceName) · \(text)"
|
||||||
|
}
|
||||||
} else if message.kind == "focus" {
|
} else if message.kind == "focus" {
|
||||||
DispatchQueue.main.async { [weak self] in self?.window.makeKeyAndOrderFront(nil); NSApp.activate(ignoringOtherApps: true) }
|
DispatchQueue.main.async { [weak self] in self?.window.makeKeyAndOrderFront(nil); NSApp.activate(ignoringOtherApps: true) }
|
||||||
} else if message.kind == "error" {
|
} else if message.kind == "error" {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.window.title = message.message ?? "连接已结束"
|
self?.window.title = message.message ?? "连接已结束"
|
||||||
self?.view.readOnly = true
|
self?.view.readOnly = true
|
||||||
|
self?.spinner.stopAnimation(nil)
|
||||||
|
self?.loading.isHidden = true
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.messageText = "投屏已停止"
|
alert.messageText = "投屏已停止"
|
||||||
alert.informativeText = message.message ?? "请检查 USB 连接后重新开始。"
|
alert.informativeText = message.message ?? "请检查 USB 连接后重新开始。"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Harmony Connector
|
# Harmony Connector
|
||||||
|
|
||||||
插件 ID:`dev.trove.harmony-connector`。当前版本:`0.5.1`。
|
插件 ID:`dev.trove.harmony-connector`。当前版本:`0.5.2`。
|
||||||
|
|
||||||
macOS 上的鸿蒙设备连接工具。通过 USB 收发文件与完整文件夹;使用官方 HDC 管理应用、保存截图、导出日志和调试应用数据。功能按连接工具、设备实际能力和授权开放,不按 HarmonyOS 大版本或机型名称限制。
|
macOS 上的鸿蒙设备连接工具。通过 USB 收发文件与完整文件夹;使用官方 HDC 管理应用、保存截图、导出日志和调试应用数据。功能按连接工具、设备实际能力和授权开放,不按 HarmonyOS 大版本或机型名称限制。
|
||||||
|
|
||||||
@@ -77,6 +77,8 @@ macOS 上的鸿蒙设备连接工具。通过 USB 收发文件与完整文件夹
|
|||||||
|
|
||||||
0.5.1 在设备代理进程及 Unix socket 就绪后才创建转发;按官方资源版本依次尝试兼容代理。首次连接失败会重新建立本插件的 HDC 转发。窗口收到首张解码画面之前显示连接中,失败区分组件启动、转发和视频连接,不再把网络错误归为 Mac 文件错误。
|
0.5.1 在设备代理进程及 Unix socket 就绪后才创建转发;按官方资源版本依次尝试兼容代理。首次连接失败会重新建立本插件的 HDC 转发。窗口收到首张解码画面之前显示连接中,失败区分组件启动、转发和视频连接,不再把网络错误归为 Mac 文件错误。
|
||||||
|
|
||||||
|
0.5.2 点击后先打开独立窗口,设备准备在后台进行,并显示连接阶段;准备中可直接关闭窗口取消。收到首张解码画面后,插件在 Mac 上按设备保存成功的代理版本,下次优先使用;原版本失效时仍自动尝试其他官方组件。首次匹配仍可能等待,后续不再从头扫描全部候选。
|
||||||
|
|
||||||
组件与传输依据见[官方视频与触控调研](research/harmony-screen-performance.md)。旧 JPEG 后端接口仅保留用于诊断;管理页已使用独立视频窗口。
|
组件与传输依据见[官方视频与触控调研](research/harmony-screen-performance.md)。旧 JPEG 后端接口仅保留用于诊断;管理页已使用独立视频窗口。
|
||||||
|
|
||||||
## 重连问题与 debug
|
## 重连问题与 debug
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# Harmony Connector 投屏开发记录
|
# Harmony Connector 投屏开发记录
|
||||||
|
|
||||||
|
2026-09-10:用户反馈 0.5.1「很流畅」,但打开耗时较长。0.5.2 将窗口创建提前到设备探测之前,后台准备支持进度显示和取消;只在首张画面成功解码显示后保存设备的可用视频代理,下次优先使用,失败时保留自动回退。启动期间暂停管理页的设备轮询,避免争用。首次匹配耗时未承诺降低到固定数值;本轮按用户要求仅构建打包,未跑测试、真机计时或远程安装验证。
|
||||||
|
|
||||||
|
|
||||||
2026-09-10:0.5.1 针对用户报告的 `无法读写 Mac 文件:Connection refused (os error 61)` 修改启动流程。代码可确认该提示来自视频 TCP 连接使用了通用文件 I/O 错误转换;此前没有确认设备代理/Unix socket 就绪,且只重试同一端口。现在增加官方代理候选、就绪检查、仅重建自身转发、启动日志和首帧状态。具体设备上的代理兼容性与连接恢复效果仍等待用户反馈;本轮只构建打包,未运行测试或真机验证。
|
2026-09-10:0.5.1 针对用户报告的 `无法读写 Mac 文件:Connection refused (os error 61)` 修改启动流程。代码可确认该提示来自视频 TCP 连接使用了通用文件 I/O 错误转换;此前没有确认设备代理/Unix socket 就绪,且只重试同一端口。现在增加官方代理候选、就绪检查、仅重建自身转发、启动日志和首帧状态。具体设备上的代理兼容性与连接恢复效果仍等待用户反馈;本轮只构建打包,未运行测试或真机验证。
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -2,7 +2,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "trove-official",
|
"id": "trove-official",
|
||||||
"name": "Trove 官方软件源",
|
"name": "Trove 官方软件源",
|
||||||
"generatedAt": "2026-09-10T09:37:34.602Z",
|
"generatedAt": "2026-09-10T09:52:52.752Z",
|
||||||
"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.5.2",
|
||||||
|
"engine": {
|
||||||
|
"trove": ">=0.1.0 <1.0.0"
|
||||||
|
},
|
||||||
|
"releasedAt": "2026-09-10T09:52:52.752Z",
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"target": "aarch64-apple-darwin",
|
||||||
|
"url": "packages/dev.trove.harmony-connector/0.5.2/aarch64-apple-darwin.curio",
|
||||||
|
"sha256": "b0b41240ba0a994e61445f77660dee4e4c0aab6b1534128ec08b72acdd71eb58",
|
||||||
|
"size": 4727424
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.5.1",
|
"version": "0.5.1",
|
||||||
"engine": {
|
"engine": {
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user