perf(harmony): open viewer immediately and remember working device agent

This commit is contained in:
2026-09-10 17:52:53 +08:00
parent 34d870cb71
commit 07174e35ab
12 changed files with 245 additions and 104 deletions
+1 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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)
} }
+14 -6
View File
@@ -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 连接后重新开始。"
+3 -1
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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": {