perf(harmony): open viewer immediately and remember working device agent
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
"id": "dev.trove.harmony-connector",
|
||||
"name": "Harmony Connector",
|
||||
"description": "连接鸿蒙设备,收发完整文件夹、管理应用,在独立窗口中投屏并通过键鼠操作。",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.2",
|
||||
"engine": { "trove": ">=0.1.0 <1.0.0" },
|
||||
"icon": "assets/icon.svg",
|
||||
"ui": {
|
||||
|
||||
+1
-1
@@ -259,7 +259,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "harmony-connector"
|
||||
version = "0.5.1"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "harmony-connector"
|
||||
version = "0.5.1"
|
||||
version = "0.5.2"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
use super::screen_agent::{self, Rpc};
|
||||
use super::screen_lease::{self, Lease, SOCKET};
|
||||
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] =
|
||||
include_bytes!("../../../../../vendor/harmony-connector/hypium/uitest_agent_v1.2.3.so");
|
||||
@@ -37,6 +38,8 @@ pub(super) struct Resources {
|
||||
forwards: Vec<(String, String)>,
|
||||
lease: Lease,
|
||||
video: Option<(usize, String)>,
|
||||
profile: PathBuf,
|
||||
candidates: VecDeque<usize>,
|
||||
}
|
||||
impl Resources {
|
||||
pub fn open(tool: Tool, target: String) -> Result<Self> {
|
||||
@@ -55,6 +58,22 @@ impl Resources {
|
||||
for (pid, cmdline) in existing {
|
||||
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 {
|
||||
tool,
|
||||
target,
|
||||
@@ -62,6 +81,8 @@ impl Resources {
|
||||
forwards: Vec::new(),
|
||||
lease,
|
||||
video: None,
|
||||
profile,
|
||||
candidates,
|
||||
})
|
||||
}
|
||||
fn launch(&mut self, bytes: &[u8], arguments: &str) -> Result<String> {
|
||||
@@ -244,11 +265,9 @@ impl Resources {
|
||||
))
|
||||
}
|
||||
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();
|
||||
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)?;
|
||||
match self.wait_ready(&name, VIDEO_SOCKET, version) {
|
||||
Ok(()) => {
|
||||
@@ -269,7 +288,7 @@ impl Resources {
|
||||
}
|
||||
pub fn reconnect_video(&mut self) -> Result<u16> {
|
||||
self.remove_forward(VIDEO_SOCKET)?;
|
||||
let (index, name) = self
|
||||
let (_, name) = self
|
||||
.video
|
||||
.clone()
|
||||
.ok_or_else(|| Fault::new("screen.closed", "视频组件已停止。"))?;
|
||||
@@ -278,7 +297,19 @@ impl Resources {
|
||||
}
|
||||
self.remove_agent(&name)?;
|
||||
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<()> {
|
||||
if self.files.is_empty() && self.forwards.is_empty() {
|
||||
|
||||
@@ -34,14 +34,17 @@ pub(super) struct Recorder {
|
||||
resources: Mutex<Option<Resources>>,
|
||||
}
|
||||
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)?;
|
||||
progress("正在准备鼠标控制…");
|
||||
let (control_port, mut control) = resources.control()?;
|
||||
let (width, height) = control.dimensions()?;
|
||||
progress("正在连接手机画面…");
|
||||
let port = resources.video()?;
|
||||
let tool = Tool {
|
||||
path: resources.tool.path.clone(),
|
||||
cancel: Arc::new(AtomicBool::new(false)),
|
||||
cancel: resources.tool.cancel.clone(),
|
||||
};
|
||||
let target = resources.target.clone();
|
||||
let help = tool
|
||||
@@ -58,8 +61,9 @@ impl Recorder {
|
||||
] {
|
||||
supported.insert(action.into());
|
||||
}
|
||||
let cancel = resources.tool.cancel.clone();
|
||||
let owner = Arc::new(Self {
|
||||
cancel: Arc::new(AtomicBool::new(false)),
|
||||
cancel,
|
||||
port,
|
||||
keyframe: AtomicBool::new(false),
|
||||
ready: AtomicBool::new(false),
|
||||
@@ -101,6 +105,14 @@ impl Recorder {
|
||||
*owner.worker.lock().unwrap() = Some(worker);
|
||||
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> {
|
||||
if self.cancel.load(Ordering::Acquire) {
|
||||
return Err(Fault::new("screen.closed", "投屏已停止。"));
|
||||
|
||||
@@ -37,10 +37,31 @@ impl Pipe {
|
||||
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 {
|
||||
child: Child,
|
||||
pipe: Arc<Pipe>,
|
||||
recorder: Arc<Recorder>,
|
||||
session: Arc<Session>,
|
||||
target: String,
|
||||
worker: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
@@ -57,27 +78,23 @@ pub(super) struct Open {
|
||||
}
|
||||
impl Window {
|
||||
pub fn status(&self) -> Value {
|
||||
let current = self.current.lock().unwrap();
|
||||
current.as_ref().map_or(json!({"active":false}), |v| {
|
||||
json!({
|
||||
"active":!v.recorder.cancel.load(Ordering::Acquire),"target":v.target,
|
||||
"ready":v.recorder.ready.load(Ordering::Acquire),
|
||||
"error":v.recorder.error.lock().unwrap().clone()
|
||||
})
|
||||
})
|
||||
self.current
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map_or(json!({"active":false}), |v| v.session.status(&v.target))
|
||||
}
|
||||
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();
|
||||
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 {
|
||||
return Err(screen_lease::busy());
|
||||
}
|
||||
viewer.pipe.send(json!({"kind":"focus"}), &[])?;
|
||||
return Ok(
|
||||
json!({"active":true,"ready":viewer.recorder.ready.load(Ordering::Acquire),"target":request.target}),
|
||||
);
|
||||
return Ok(viewer.session.status(&request.target));
|
||||
}
|
||||
}
|
||||
if let Some(viewer) = current.take() {
|
||||
@@ -90,25 +107,19 @@ impl Window {
|
||||
"投屏组件不完整,请更新或重新安装 Harmony Connector。",
|
||||
));
|
||||
}
|
||||
let recorder = Recorder::open(tool, request.target.clone())?;
|
||||
let name = if request.name.trim().is_empty() {
|
||||
"Harmony"
|
||||
} else {
|
||||
&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)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
let mut child = match started {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
let _ = recorder.stop();
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
.spawn()
|
||||
.map_err(|e| Fault::new("screen.window_failed", format!("无法打开投屏窗口:{e}")))?;
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let fd = stdin.as_raw_fd();
|
||||
unsafe {
|
||||
@@ -119,13 +130,21 @@ impl Window {
|
||||
);
|
||||
}
|
||||
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 reader_recorder = recorder.clone();
|
||||
let reader_session = session.clone();
|
||||
thread::spawn(move || {
|
||||
let mut reader = BufReader::new(stdout);
|
||||
loop {
|
||||
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) {
|
||||
Ok(0) | Err(_) => 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 {
|
||||
break;
|
||||
};
|
||||
let Some(recorder) = reader_session.recorder() else {
|
||||
continue;
|
||||
};
|
||||
if value["event"] == "input" {
|
||||
if let Err(error) = reader_recorder.push(value) {
|
||||
*reader_recorder.error.lock().unwrap() = Some(error);
|
||||
if let Err(error) = recorder.push(value) {
|
||||
*reader_session.error.lock().unwrap() = Some(error);
|
||||
break;
|
||||
}
|
||||
} else if value["event"] == "presented" {
|
||||
reader_recorder.ready.store(true, Ordering::Release);
|
||||
recorder.mark_ready();
|
||||
} 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 target = request.target.clone();
|
||||
let worker = thread::spawn(move || {
|
||||
let mut sequence = 0u64;
|
||||
let mut samples = VecDeque::new();
|
||||
let mut port = worker_recorder.port;
|
||||
let mut retries = 0;
|
||||
let result = loop {
|
||||
let result = super::screen_video::receive(
|
||||
port,
|
||||
&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;
|
||||
let result = (|| {
|
||||
let recorder = Recorder::open(tool, target, |phase| {
|
||||
worker_session.progress(&worker_pipe, phase)
|
||||
})?;
|
||||
*worker_session.recorder.lock().unwrap() = Some(recorder.clone());
|
||||
if worker_session.cancel.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
}
|
||||
retries += 1;
|
||||
port = match worker_recorder.reconnect_video() {
|
||||
Ok(port) => port,
|
||||
Err(error) => break Err(error),
|
||||
};
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
};
|
||||
worker_session.progress(&worker_pipe, "等待手机画面…");
|
||||
receive(&recorder, &worker_pipe)
|
||||
})();
|
||||
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}), &[]);
|
||||
}
|
||||
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 {
|
||||
child,
|
||||
pipe,
|
||||
recorder,
|
||||
target: request.target.clone(),
|
||||
session,
|
||||
target: request.target,
|
||||
worker: Some(worker),
|
||||
});
|
||||
Ok(json!({"active":true,"ready":false,"target":request.target}))
|
||||
Ok(status)
|
||||
}
|
||||
pub fn stop(&self) -> Result<Value> {
|
||||
if let Some(viewer) = self.current.lock().unwrap().take() {
|
||||
@@ -217,10 +220,46 @@ impl Window {
|
||||
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<()> {
|
||||
viewer.recorder.cancel.store(true, Ordering::Release);
|
||||
viewer.session.cancel.store(true, Ordering::Release);
|
||||
let _ = viewer.pipe.send(json!({"kind":"close"}), &[]);
|
||||
let result = viewer.recorder.stop();
|
||||
let deadline = Instant::now() + Duration::from_secs(1);
|
||||
while viewer.child.try_wait()?.is_none() {
|
||||
if Instant::now() >= deadline {
|
||||
@@ -230,8 +269,10 @@ fn close(mut viewer: Viewer) -> Result<()> {
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let _ = viewer.child.wait();
|
||||
// The same worker owns startup and cleanup, including cancellation during an upload.
|
||||
if let Some(worker) = viewer.worker.take() {
|
||||
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 }) {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const state = { target: "", available: false, blocked: false, working: false, active: false, ready: false, disposed: false };
|
||||
let timer, error;
|
||||
const state = { target: "", available: false, blocked: false, working: false, active: false, ready: false, phase: "", disposed: false };
|
||||
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() {
|
||||
$("screen-start").disabled = state.working || state.blocked || !state.available;
|
||||
$("screen-start").textContent = 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() {
|
||||
if (state.disposed) return;
|
||||
try {
|
||||
const result = await call("screenWindowStatus", {});
|
||||
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) {
|
||||
error = result.error.message;
|
||||
notice(error);
|
||||
}
|
||||
reportBusy();
|
||||
render();
|
||||
} catch { /* Closing the plugin also closes the native session. */ }
|
||||
if (!state.disposed) timer = setTimeout(status, 1000);
|
||||
@@ -25,17 +32,18 @@ export function screen({ call, notice, onBusy }) {
|
||||
async function action(method, args) {
|
||||
if (state.working) return;
|
||||
state.working = true;
|
||||
onBusy(true);
|
||||
reportBusy();
|
||||
render();
|
||||
try {
|
||||
const result = await call(method, args);
|
||||
state.active = result.active;
|
||||
state.ready = !!result.ready;
|
||||
state.phase = result.phase || "";
|
||||
error = undefined;
|
||||
} catch (error) { notice(error.message || String(error)); }
|
||||
finally {
|
||||
state.working = false;
|
||||
onBusy(false);
|
||||
reportBusy();
|
||||
if (!state.disposed) render();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ final class ViewerApp: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
||||
private var window: NSWindow!
|
||||
private var view: ScreenView!
|
||||
private var samples: [TimeInterval] = []
|
||||
private var loading: NSStackView!
|
||||
private let loadingLabel = NSTextField(labelWithString: "正在连接设备…")
|
||||
private let spinner = NSProgressIndicator()
|
||||
private let pendingLock = NSLock()
|
||||
private var pending: (CVPixelBuffer, ScreenFrame)?
|
||||
private var scheduled = false
|
||||
@@ -21,6 +24,21 @@ final class ViewerApp: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
||||
window.delegate = self
|
||||
view = ScreenView(frame: window.contentLayoutRect)
|
||||
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.makeKeyAndOrderFront(nil)
|
||||
window.makeFirstResponder(view)
|
||||
@@ -60,6 +78,8 @@ final class ViewerApp: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
||||
self.view.display(image, frame: frame)
|
||||
if !self.announced {
|
||||
self.announced = true
|
||||
self.loading.isHidden = true
|
||||
self.spinner.stopAnimation(nil)
|
||||
ViewerWire.send(["event": "presented"])
|
||||
}
|
||||
if self.geometry != frame.geometry {
|
||||
@@ -89,12 +109,21 @@ final class ViewerApp: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
||||
if message.kind == "video", let frame = message.frame {
|
||||
do { try decoder.decode(bytes, frame: frame) }
|
||||
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" {
|
||||
DispatchQueue.main.async { [weak self] in self?.window.makeKeyAndOrderFront(nil); NSApp.activate(ignoringOtherApps: true) }
|
||||
} else if message.kind == "error" {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.window.title = message.message ?? "连接已结束"
|
||||
self?.view.readOnly = true
|
||||
self?.spinner.stopAnimation(nil)
|
||||
self?.loading.isHidden = true
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "投屏已停止"
|
||||
alert.informativeText = message.message ?? "请检查 USB 连接后重新开始。"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Harmony Connector
|
||||
|
||||
插件 ID:`dev.trove.harmony-connector`。当前版本:`0.5.1`。
|
||||
插件 ID:`dev.trove.harmony-connector`。当前版本:`0.5.2`。
|
||||
|
||||
macOS 上的鸿蒙设备连接工具。通过 USB 收发文件与完整文件夹;使用官方 HDC 管理应用、保存截图、导出日志和调试应用数据。功能按连接工具、设备实际能力和授权开放,不按 HarmonyOS 大版本或机型名称限制。
|
||||
|
||||
@@ -77,6 +77,8 @@ macOS 上的鸿蒙设备连接工具。通过 USB 收发文件与完整文件夹
|
||||
|
||||
0.5.1 在设备代理进程及 Unix socket 就绪后才创建转发;按官方资源版本依次尝试兼容代理。首次连接失败会重新建立本插件的 HDC 转发。窗口收到首张解码画面之前显示连接中,失败区分组件启动、转发和视频连接,不再把网络错误归为 Mac 文件错误。
|
||||
|
||||
0.5.2 点击后先打开独立窗口,设备准备在后台进行,并显示连接阶段;准备中可直接关闭窗口取消。收到首张解码画面后,插件在 Mac 上按设备保存成功的代理版本,下次优先使用;原版本失效时仍自动尝试其他官方组件。首次匹配仍可能等待,后续不再从头扫描全部候选。
|
||||
|
||||
组件与传输依据见[官方视频与触控调研](research/harmony-screen-performance.md)。旧 JPEG 后端接口仅保留用于诊断;管理页已使用独立视频窗口。
|
||||
|
||||
## 重连问题与 debug
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# 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 就绪,且只重试同一端口。现在增加官方代理候选、就绪检查、仅重建自身转发、启动日志和首帧状态。具体设备上的代理兼容性与连接恢复效果仍等待用户反馈;本轮只构建打包,未运行测试或真机验证。
|
||||
|
||||
|
||||
|
||||
+16
-1
@@ -2,7 +2,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "trove-official",
|
||||
"name": "Trove 官方软件源",
|
||||
"generatedAt": "2026-09-10T09:37:34.602Z",
|
||||
"generatedAt": "2026-09-10T09:52:52.752Z",
|
||||
"curios": [
|
||||
{
|
||||
"id": "dev.trove.qa.host",
|
||||
@@ -200,6 +200,21 @@
|
||||
"name": "Harmony Connector",
|
||||
"description": "连接鸿蒙设备,收发完整文件夹、管理应用,在独立窗口中投屏并通过键鼠操作。",
|
||||
"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",
|
||||
"engine": {
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user