fix(harmony): await device agents and rebuild refused video forwards

This commit is contained in:
2026-09-10 17:37:34 +08:00
parent e53e5050af
commit 34d870cb71
24 changed files with 448 additions and 205 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"id": "dev.trove.harmony-connector",
"name": "Harmony Connector",
"description": "连接鸿蒙设备,收发完整文件夹、管理应用,在独立窗口中投屏并通过键鼠操作。",
"version": "0.5.0",
"version": "0.5.1",
"engine": { "trove": ">=0.1.0 <1.0.0" },
"icon": "assets/icon.svg",
"ui": {
@@ -30,7 +30,10 @@ The native viewer additionally uses the unmodified Huawei components distributed
in `xdevice-devicetest` 26.0.0.500 (Hypium 26):
- `devicetest/res/prototype/native/uitest_agent_v1.2.3.so` for touch control.
- `devicetest/res/recorder/libscrcpy_server_unix_6.6-20260629.z.so` for H.264 video.
- `devicetest/res/recorder/libscrcpy_server_unix_*.z.so` for H.264 video:
6.6-20260629, 6.6-20260418, 6.5-20260313, 6.4-20260113 and 6.3.1-20260113.
These original official variants are tried in that order when a device cannot
start the previous variant; all use a device Unix socket.
Their wheel license is preserved in `hypium/LICENSE-26`; file identities and
SHA-256 checksums are in `hypium/provenance-26.json`. Upstream copyright belongs
+1 -1
View File
@@ -259,7 +259,7 @@ dependencies = [
[[package]]
name = "harmony-connector"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "harmony-connector"
version = "0.5.0"
version = "0.5.1"
edition = "2021"
publish = false
@@ -1,6 +1,7 @@
mod files;
mod screen;
mod screen_agent;
mod screen_daemon;
mod screen_input;
mod screen_lease;
mod screen_protocol;
@@ -138,9 +138,9 @@ impl Rpc {
Ok((dimension("x")?, dimension("y")?))
}
}
pub(super) fn existing_agents(tool: &Tool, target: &str) -> Result<Vec<(String, String)>> {
pub(super) fn processes(tool: &Tool, target: &str) -> Result<Vec<(String, String)>> {
let pids = tool.run_output(&["-t", target, "shell", "pidof uitest xdevice_scrcpy"], 2)?;
let mut owned = Vec::new();
let mut result = Vec::new();
for pid in pids.split_whitespace() {
if !pid.bytes().all(|b| b.is_ascii_digit()) {
return Err(fault("无法检查设备调试会话。"));
@@ -149,9 +149,15 @@ pub(super) fn existing_agents(tool: &Tool, target: &str) -> Result<Vec<(String,
&["-t", target, "shell", &format!("cat /proc/{pid}/cmdline")],
2,
)?;
if cmdline.contains("No such file") {
continue;
if !cmdline.contains("No such file") {
result.push((pid.to_owned(), cmdline));
}
}
Ok(result)
}
pub(super) fn existing_agents(tool: &Tool, target: &str) -> Result<Vec<(String, String)>> {
let mut owned = Vec::new();
for (pid, cmdline) in processes(tool, target)? {
let args: Vec<_> = cmdline.split('\0').filter(|s| !s.is_empty()).collect();
let is_uitest = args
.first()
@@ -0,0 +1,307 @@
//! Device-agent startup is complete only when its process and Unix socket exist.
//! Candidate versions are official ABI variants, not HarmonyOS product versions.
use super::screen_agent::{self, Rpc};
use super::screen_lease::{self, Lease, SOCKET};
use super::*;
use std::{io::Write, net::TcpListener, time::Instant};
const CONTROL: &[u8] =
include_bytes!("../../../../../vendor/harmony-connector/hypium/uitest_agent_v1.2.3.so");
const VIDEO_SOCKET: &str = "localabstract:scrcpy_grpc_socket";
const VIDEO_ARGS: &str =
"-m 0 -scale 2 -frameRate 60 -bitRate 8000000 -iFrameInterval 1000 -screenId 0";
macro_rules! recorder {
($version:literal) => {
(
$version,
include_bytes!(concat!(
"../../../../../vendor/harmony-connector/hypium/libscrcpy_server_unix_",
$version,
".z.so"
)) as &'static [u8],
)
};
}
const VIDEOS: &[(&str, &[u8])] = &[
recorder!("6.6-20260629"),
recorder!("6.6-20260418"),
recorder!("6.5-20260313"),
recorder!("6.4-20260113"),
recorder!("6.3.1-20260113"),
];
pub(super) struct Resources {
pub tool: Tool,
pub target: String,
files: Vec<String>,
forwards: Vec<(String, String)>,
lease: Lease,
video: Option<(usize, String)>,
}
impl Resources {
pub fn open(tool: Tool, target: String) -> Result<Self> {
tool.connected(&target)?;
let root = env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(env::temp_dir)
.join("Library/HarmonyConnector/sessions");
let lease = Lease::acquire(&root, &tool, &target)?;
let existing = screen_agent::existing_agents(&tool, &target)?;
for socket in [SOCKET, VIDEO_SOCKET] {
if !screen_lease::forwards_socket(&tool, &target, socket)?.is_empty() {
return Err(screen_lease::busy());
}
}
for (pid, cmdline) in existing {
screen_agent::terminate_agent(&tool, &target, &pid, &cmdline)?;
}
Ok(Self {
tool,
target,
files: Vec::new(),
forwards: Vec::new(),
lease,
video: None,
})
}
fn launch(&mut self, bytes: &[u8], arguments: &str) -> Result<String> {
let mut file = tempfile::Builder::new()
.prefix("trove-screen-")
.suffix(".so")
.tempfile()?;
file.write_all(bytes)?;
let name = file
.path()
.file_name()
.unwrap()
.to_str()
.unwrap()
.to_owned();
self.files.push(name.clone());
self.tool.run(
&[
"-t",
&self.target,
"file",
"send",
file.path().to_str().unwrap(),
&format!("/data/local/tmp/{name}"),
],
10,
)?;
// Keep loader errors from the daemon; a successful hdc shell exit is not readiness.
let result = self.tool.run_output(&["-t", &self.target, "shell",
&format!("uitest start-daemon singleness --extension-name {name} {arguments} > /data/local/tmp/{name}.log 2>&1")], 5);
match result {
Ok(_) => Ok(name),
Err(e) if e.code == "hdc.command_failed" => Ok(name),
Err(e) => Err(e),
}
}
fn ready(&self, name: &str, socket: &str) -> Result<bool> {
let process = screen_agent::existing_agents(&self.tool, &self.target)?
.iter()
.any(|(_, cmdline)| cmdline.split('\0').any(|arg| arg == name));
if !process {
return Ok(false);
}
let sockets = self
.tool
.run(&["-t", &self.target, "shell", "cat /proc/net/unix"], 2)?;
let name = socket.strip_prefix("localabstract:").unwrap();
Ok(sockets.lines().any(|line| {
line.split_whitespace()
.last()
.is_some_and(|path| path.trim_start_matches('@') == name)
}))
}
fn wait_ready(&self, name: &str, socket: &str, version: &str) -> Result<()> {
let deadline = Instant::now() + Duration::from_secs(3);
loop {
if self.tool.cancel.load(Ordering::Acquire) {
return Err(Fault::new("operation.cancelled", "投屏连接已取消。"));
}
if self.ready(name, socket)? {
return Ok(());
}
if Instant::now() >= deadline {
let output = self
.tool
.run_output(
&[
"-t",
&self.target,
"shell",
&format!("cat /data/local/tmp/{name}.log"),
],
2,
)
.unwrap_or_default();
let detail: String = output
.chars()
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
.take(800)
.collect();
return Err(Fault::new(
"screen.agent_start_failed",
format!("设备投屏组件 {version} 未启动监听。{}", detail.trim()),
));
}
thread::sleep(Duration::from_millis(100));
}
}
fn forward(&mut self, socket: &str) -> Result<u16> {
let listener = TcpListener::bind(("127.0.0.1", 0)).map_err(|e| {
Fault::new(
"screen.forward_failed",
format!("无法分配本机投屏端口:{e}"),
)
})?;
let port = listener
.local_addr()
.map_err(|e| Fault::new("screen.forward_failed", e.to_string()))?
.port();
drop(listener);
let local = format!("tcp:{port}");
self.forwards.push((local.clone(), socket.into()));
self.lease.record_forwards(&self.forwards)?;
self.tool
.run(&["-t", &self.target, "fport", &local, socket], 3)?;
if !screen_lease::forwards_socket(&self.tool, &self.target, socket)?.contains(&local) {
return Err(Fault::new(
"screen.forward_failed",
format!("HDC 未建立投屏转发:{socket}。"),
));
}
Ok(port)
}
fn remove_forward(&mut self, socket: &str) -> Result<()> {
let registered = screen_lease::forwards_socket(&self.tool, &self.target, socket)?;
for (local, remote) in self.forwards.iter().filter(|(_, remote)| remote == socket) {
if registered.contains(local) {
self.tool
.run(&["-t", &self.target, "fport", "rm", local, remote], 3)?;
if screen_lease::forwards_socket(&self.tool, &self.target, remote)?.contains(local)
{
return Err(Fault::new(
"screen.forward_failed",
"HDC 未能释放旧的投屏转发。",
));
}
}
}
self.forwards.retain(|(_, remote)| remote != socket);
self.lease.record_forwards(&self.forwards)
}
fn remove_agent(&mut self, name: &str) -> Result<()> {
// Only the exact file generated by this session can be terminated.
for (pid, cmdline) in screen_agent::processes(&self.tool, &self.target)? {
if cmdline.split('\0').any(|arg| arg == name) {
screen_agent::terminate_agent(&self.tool, &self.target, &pid, &cmdline)?;
}
}
self.tool.run(
&[
"-t",
&self.target,
"shell",
&format!("rm -f /data/local/tmp/{name} /data/local/tmp/{name}.log"),
],
2,
)?;
self.files.retain(|file| file != name);
Ok(())
}
pub fn control(&mut self) -> Result<(u16, Rpc)> {
let name = self.launch(CONTROL, "")?;
self.wait_ready(&name, SOCKET, "UITest 1.2.3")?;
let mut last = None;
for _ in 0..3 {
self.remove_forward(SOCKET)?;
let port = self.forward(SOCKET)?;
let deadline = Instant::now() + Duration::from_secs(1);
loop {
match Rpc::connect(port).and_then(|mut rpc| {
rpc.create_driver()?;
Ok(rpc)
}) {
Ok(rpc) => return Ok((port, rpc)),
Err(e) if e.code == "screen.remote_error" => return Err(e),
Err(e) => last = Some(e),
}
if Instant::now() >= deadline {
break;
}
thread::sleep(Duration::from_millis(100));
}
}
Err(Fault::new(
"screen.control_connect_failed",
format!(
"设备控制组件已启动,但 HDC 控制通道无法连接:{}",
last.unwrap().message
),
))
}
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) {
let name = self.launch(bytes, VIDEO_ARGS)?;
match self.wait_ready(&name, VIDEO_SOCKET, version) {
Ok(()) => {
self.video = Some((index, name));
return self.forward(VIDEO_SOCKET);
}
Err(error) if error.code == "screen.agent_start_failed" => {
failures.push(error.message);
self.remove_agent(&name)?;
}
Err(error) => return Err(error),
}
}
Err(Fault::new(
"screen.video_agent_unavailable",
format!("官方视频组件均未能在设备上启动。{}", failures.join("\n")),
))
}
pub fn reconnect_video(&mut self) -> Result<u16> {
self.remove_forward(VIDEO_SOCKET)?;
let (index, name) = self
.video
.clone()
.ok_or_else(|| Fault::new("screen.closed", "视频组件已停止。"))?;
if self.ready(&name, VIDEO_SOCKET)? {
return self.forward(VIDEO_SOCKET);
}
self.remove_agent(&name)?;
self.video = None;
self.video_from(index + 1)
}
pub fn close(&mut self) -> Result<()> {
if self.files.is_empty() && self.forwards.is_empty() {
return Ok(());
}
// Cleanup must remain possible when the caller cancelled startup.
self.tool.cancel = Arc::new(AtomicBool::new(false));
let mut error = None;
for socket in [VIDEO_SOCKET, SOCKET] {
if let Err(e) = self.remove_forward(socket) {
error.get_or_insert(e);
}
}
for name in self.files.clone() {
if let Err(e) = self.remove_agent(&name) {
error.get_or_insert(e);
}
}
error.map_or(Ok(()), Err)
}
}
impl Drop for Resources {
fn drop(&mut self) {
let _ = self.close();
}
}
@@ -1,146 +1,13 @@
//! Owns the official video and touch daemons as one USB debugging session.
use super::screen_agent::{self, Rpc};
use super::screen_lease::{self, Lease, SOCKET};
use super::screen_agent::Rpc;
use super::screen_daemon::Resources;
use super::*;
use std::{
collections::{HashSet, VecDeque},
io::Write,
net::TcpListener,
sync::Condvar,
time::Instant,
};
const CONTROL: &[u8] =
include_bytes!("../../../../../vendor/harmony-connector/hypium/uitest_agent_v1.2.3.so");
const VIDEO: &[u8] = include_bytes!(
"../../../../../vendor/harmony-connector/hypium/libscrcpy_server_unix_6.6-20260629.z.so"
);
const VIDEO_SOCKET: &str = "localabstract:scrcpy_grpc_socket";
struct Resources {
tool: Tool,
target: String,
files: Vec<String>,
forwards: Vec<(String, String)>,
lease: Lease,
}
impl Resources {
fn upload(&mut self, bytes: &[u8], arguments: &str) -> Result<()> {
let mut file = tempfile::Builder::new()
.prefix("trove-screen-")
.suffix(".so")
.tempfile()?;
file.write_all(bytes)?;
let name = file
.path()
.file_name()
.unwrap()
.to_str()
.unwrap()
.to_owned();
self.files.push(name.clone());
self.tool.run(
&[
"-t",
&self.target,
"file",
"send",
file.path().to_str().unwrap(),
&format!("/data/local/tmp/{name}"),
],
10,
)?;
self.tool.run(
&[
"-t",
&self.target,
"shell",
&format!("uitest start-daemon singleness --extension-name {name} {arguments}"),
],
5,
)?;
Ok(())
}
fn forward(&mut self, socket: &str) -> Result<u16> {
let listener = TcpListener::bind(("127.0.0.1", 0))?;
let port = listener.local_addr()?.port();
drop(listener);
let local = format!("tcp:{port}");
self.forwards.push((local.clone(), socket.into()));
// Record before the command, so a crash between forwarding and return is recoverable.
self.lease.record_forwards(&self.forwards)?;
self.tool
.run(&["-t", &self.target, "fport", &local, socket], 3)?;
Ok(port)
}
fn close(&mut self) -> Result<()> {
if self.files.is_empty() && self.forwards.is_empty() {
return Ok(());
}
let cleanup = Tool {
path: self.tool.path.clone(),
cancel: Arc::new(AtomicBool::new(false)),
};
let mut error = None;
for (local, remote) in &self.forwards {
if let Err(e) = cleanup.run(&["-t", &self.target, "fport", "rm", local, remote], 2) {
error.get_or_insert(e);
}
}
if error.is_none() {
self.forwards.clear();
self.lease.record(None)?;
}
// Inspect full command lines before signalling; never kill a foreign daemon.
let pids = cleanup.run_output(
&["-t", &self.target, "shell", "pidof uitest xdevice_scrcpy"],
2,
)?;
for pid in pids
.split_whitespace()
.filter(|s| s.bytes().all(|b| b.is_ascii_digit()))
{
let cmdline = cleanup.run_output(
&[
"-t",
&self.target,
"shell",
&format!("cat /proc/{pid}/cmdline"),
],
2,
)?;
if cmdline
.split('\0')
.any(|arg| self.files.iter().any(|file| file == arg))
{
if let Err(e) = screen_agent::terminate_agent(&cleanup, &self.target, pid, &cmdline)
{
error.get_or_insert(e);
}
}
}
for name in self.files.drain(..) {
if let Err(e) = cleanup.run(
&[
"-t",
&self.target,
"shell",
&format!("rm -f /data/local/tmp/{name}"),
],
2,
) {
error.get_or_insert(e);
}
}
error.map_or(Ok(()), Err)
}
}
impl Drop for Resources {
fn drop(&mut self) {
let _ = self.close();
}
}
#[derive(Clone, Copy)]
pub(super) struct Dimensions {
pub width: u32,
@@ -159,6 +26,7 @@ pub(super) struct Recorder {
pub cancel: Arc<AtomicBool>,
pub port: u16,
pub keyframe: AtomicBool,
pub ready: AtomicBool,
pub error: Mutex<Option<Fault>>,
dimensions: Arc<Mutex<Dimensions>>,
queue: Arc<Queue>,
@@ -167,51 +35,10 @@ pub(super) struct Recorder {
}
impl Recorder {
pub fn open(tool: Tool, target: String) -> Result<Arc<Self>> {
tool.connected(&target)?;
let root = env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(env::temp_dir)
.join("Library/HarmonyConnector/sessions");
let lease = Lease::acquire(&root, &tool, &target)?;
let existing = screen_agent::existing_agents(&tool, &target)?;
for socket in [SOCKET, VIDEO_SOCKET] {
if !screen_lease::forwards_socket(&tool, &target, socket)?.is_empty() {
return Err(screen_lease::busy());
}
}
for (pid, cmdline) in existing {
screen_agent::terminate_agent(&tool, &target, &pid, &cmdline)?;
}
let mut resources = Resources {
tool,
target,
files: Vec::new(),
forwards: Vec::new(),
lease,
};
resources.upload(CONTROL, "")?;
let control_port = resources.forward(SOCKET)?;
let deadline = Instant::now() + Duration::from_secs(5);
let mut control = loop {
if resources.tool.cancel.load(Ordering::Acquire) {
return Err(Fault::new("screen.cancelled", "投屏连接已取消。"));
}
let attempt = Rpc::connect(control_port).and_then(|mut rpc| {
rpc.create_driver()?;
Ok(rpc)
});
match attempt {
Ok(rpc) => break rpc,
Err(e) if Instant::now() >= deadline => return Err(e),
Err(_) => thread::sleep(Duration::from_millis(100)),
}
};
let mut resources = Resources::open(tool, target)?;
let (control_port, mut control) = resources.control()?;
let (width, height) = control.dimensions()?;
resources.upload(
VIDEO,
"-m 0 -scale 2 -frameRate 60 -bitRate 8000000 -iFrameInterval 1000 -screenId 0",
)?;
let port = resources.forward(VIDEO_SOCKET)?;
let port = resources.video()?;
let tool = Tool {
path: resources.tool.path.clone(),
cancel: Arc::new(AtomicBool::new(false)),
@@ -235,6 +62,7 @@ impl Recorder {
cancel: Arc::new(AtomicBool::new(false)),
port,
keyframe: AtomicBool::new(false),
ready: AtomicBool::new(false),
error: Mutex::new(None),
dimensions: Arc::new(Mutex::new(Dimensions {
width,
@@ -273,6 +101,17 @@ impl Recorder {
*owner.worker.lock().unwrap() = Some(worker);
Ok(owner)
}
pub fn reconnect_video(&self) -> Result<u16> {
if self.cancel.load(Ordering::Acquire) {
return Err(Fault::new("screen.closed", "投屏已停止。"));
}
self.resources
.lock()
.unwrap()
.as_mut()
.ok_or_else(|| Fault::new("screen.closed", "投屏已停止。"))?
.reconnect_video()
}
pub fn dimensions(&self) -> Dimensions {
*self.dimensions.lock().unwrap()
}
@@ -50,10 +50,27 @@ pub(super) fn receive(
) -> Result<()> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.build()
.map_err(fault)?
.block_on(async {
let socket = tokio::net::TcpStream::connect(("127.0.0.1", port)).await?;
socket.set_nodelay(true)?;
let socket = tokio::time::timeout(
Duration::from_secs(2),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await
.map_err(|e| {
Fault::new(
"screen.video_connect_failed",
format!("连接 HDC 视频转发超时:{e}"),
)
})?
.map_err(|e| {
Fault::new(
"screen.video_connect_failed",
format!("无法连接 HDC 视频转发端口 {port}:{e}"),
)
})?;
socket.set_nodelay(true).map_err(fault)?;
let (mut sender, connection) = h2::client::handshake(socket).await.map_err(fault)?;
let connection = tokio::spawn(connection);
let result = async {
@@ -61,6 +61,7 @@ impl Window {
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()
})
})
@@ -74,7 +75,9 @@ impl Window {
return Err(screen_lease::busy());
}
viewer.pipe.send(json!({"kind":"focus"}), &[])?;
return Ok(json!({"active":true,"target":request.target}));
return Ok(
json!({"active":true,"ready":viewer.recorder.ready.load(Ordering::Acquire),"target":request.target}),
);
}
}
if let Some(viewer) = current.take() {
@@ -136,6 +139,8 @@ impl Window {
*reader_recorder.error.lock().unwrap() = Some(error);
break;
}
} else if value["event"] == "presented" {
reader_recorder.ready.store(true, Ordering::Release);
} else if value["event"] == "decoderError" {
reader_recorder.keyframe.store(true, Ordering::Release);
}
@@ -147,10 +152,11 @@ impl Window {
let worker = thread::spawn(move || {
let mut sequence = 0u64;
let mut samples = VecDeque::new();
let startup = Instant::now();
let mut port = worker_recorder.port;
let mut retries = 0;
let result = loop {
let result = super::screen_video::receive(
worker_recorder.port,
port,
&worker_recorder.cancel,
&worker_recorder.keyframe,
|bytes| {
@@ -173,11 +179,16 @@ impl Window {
);
if result.is_ok()
|| sequence > 0
|| startup.elapsed() > Duration::from_secs(5)
|| retries >= 2
|| worker_recorder.cancel.load(Ordering::Acquire)
{
break result;
}
retries += 1;
port = match worker_recorder.reconnect_video() {
Ok(port) => port,
Err(error) => break Err(error),
};
thread::sleep(Duration::from_millis(100));
};
if let Err(error) = result {
@@ -197,7 +208,7 @@ impl Window {
target: request.target.clone(),
worker: Some(worker),
});
Ok(json!({"active":true,"target":request.target}))
Ok(json!({"active":true,"ready":false,"target":request.target}))
}
pub fn stop(&self) -> Result<Value> {
if let Some(viewer) = self.current.lock().unwrap().take() {
+4 -3
View File
@@ -1,19 +1,19 @@
export function screen({ call, notice, onBusy }) {
const $ = (id) => document.getElementById(id);
const state = { target: "", available: false, blocked: false, working: false, active: false, disposed: false };
const state = { target: "", available: false, blocked: false, working: false, active: false, ready: false, disposed: false };
let timer, error;
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 ? "正在独立窗口中投屏 · 默认鼠标控制" : "让手机,来到桌面。";
$("screen-status").textContent = state.active ? state.ready ? "正在独立窗口中投屏 · 默认鼠标控制" : "正在建立视频连接…" : "让手机,来到桌面。";
}
async function status() {
if (state.disposed) return;
try {
const result = await call("screenWindowStatus", {});
if (state.disposed) return;
if (!state.working) state.active = result.active;
if (!state.working) { state.active = result.active; state.ready = !!result.ready; }
if (result.error && result.error.message !== error) {
error = result.error.message;
notice(error);
@@ -30,6 +30,7 @@ export function screen({ call, notice, onBusy }) {
try {
const result = await call(method, args);
state.active = result.active;
state.ready = !!result.ready;
error = undefined;
} catch (error) { notice(error.message || String(error)); }
finally {
+6 -1
View File
@@ -9,11 +9,12 @@ final class ViewerApp: NSObject, NSApplicationDelegate, NSWindowDelegate {
private var pending: (CVPixelBuffer, ScreenFrame)?
private var scheduled = false
private var geometry: UInt64 = 0
private var announced = false
private let deviceName = CommandLine.arguments.dropFirst().first ?? "Harmony"
func applicationDidFinishLaunching(_ notification: Notification) {
window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 380, height: 760), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
window.title = deviceName
window.title = "\(deviceName) · 正在连接视频…"
window.minSize = NSSize(width: 220, height: 260)
window.backgroundColor = .black
window.isReleasedWhenClosed = false
@@ -57,6 +58,10 @@ final class ViewerApp: NSObject, NSApplicationDelegate, NSWindowDelegate {
self.pendingLock.unlock()
guard let (image, frame) = current else { return }
self.view.display(image, frame: frame)
if !self.announced {
self.announced = true
ViewerWire.send(["event": "presented"])
}
if self.geometry != frame.geometry {
let initial = self.geometry == 0
self.geometry = frame.geometry
+3 -1
View File
@@ -1,6 +1,6 @@
# Harmony Connector
插件 ID:`dev.trove.harmony-connector`。当前版本:`0.5.0`。
插件 ID:`dev.trove.harmony-connector`。当前版本:`0.5.1`。
macOS 上的鸿蒙设备连接工具。通过 USB 收发文件与完整文件夹;使用官方 HDC 管理应用、保存截图、导出日志和调试应用数据。功能按连接工具、设备实际能力和授权开放,不按 HarmonyOS 大版本或机型名称限制。
@@ -75,6 +75,8 @@ macOS 上的鸿蒙设备连接工具。通过 USB 收发文件与完整文件夹
0.5.0 使用官方 Hypium 26 的 Unix H.264 recorder 和独立控制代理,经 HDC 转发视频及触摸通道。手机端编码,Mac 端 VideoToolbox 解码并立即显示。无需开发者账号、签名、手机应用或完整 SDK;用户只需官方 HDC 和已授权的设备 USB 调试。编码配置目标为 60 fps、8 Mbps、半尺寸,实际流畅度取决于设备,当前版本尚未进行真机性能验收。
0.5.1 在设备代理进程及 Unix socket 就绪后才创建转发;按官方资源版本依次尝试兼容代理。首次连接失败会重新建立本插件的 HDC 转发。窗口收到首张解码画面之前显示连接中,失败区分组件启动、转发和视频连接,不再把网络错误归为 Mac 文件错误。
组件与传输依据见[官方视频与触控调研](research/harmony-screen-performance.md)。旧 JPEG 后端接口仅保留用于诊断;管理页已使用独立视频窗口。
## 重连问题与 debug
+3
View File
@@ -1,5 +1,8 @@
# Harmony Connector 投屏开发记录
2026-09-10:0.5.1 针对用户报告的 `无法读写 Mac 文件:Connection refused (os error 61)` 修改启动流程。代码可确认该提示来自视频 TCP 连接使用了通用文件 I/O 错误转换;此前没有确认设备代理/Unix socket 就绪,且只重试同一端口。现在增加官方代理候选、就绪检查、仅重建自身转发、启动日志和首帧状态。具体设备上的代理兼容性与连接恢复效果仍等待用户反馈;本轮只构建打包,未运行测试或真机验证。
2026-09-10:0.5.0 改为独立 AppKit 窗口、官方 Unix H.264 视频流和默认实时触控。按用户要求,本轮只完成编码与构建,不运行测试、真机跑分或远程源安装验证。60 fps 是编码配置目标,不能作为已达到的性能结果。官方接口依据见[视频性能调研](research/harmony-screen-performance.md)。
以下为旧版本历史记录。
@@ -227,3 +227,12 @@ macOS 14 及以上可使用该层的 sampleBufferRenderer,保留项目要求
- 已完成:官方 26 包获取、静态解包、资源摘要、启动/选型脚本、proto 对比、Unix 绑定与触控参数的汇编核查。
- 未完成且未声称完成:最新 Unix 原件在用户手机的加载兼容性、连续视频解码结果、流畅度/耗电/延迟测量、旧 1.2.2 agent 的触控全量契约复核。
- 不需要为采用本路径继续下载完整 IDE、创建 HAP 或申请开发者签名;用户的实际前置条件是可用 HDC 与设备调试授权。原件与系统 ABI 的兼容性仍需清楚报告,不能失败后偷偷切回慢截图却继续显示“实时视频”。
## 0.5.1 启动流程修正
用户报告的 `Connection refused` 被 0.5.0 视频 TCP 连接处的通用 `std::io::Error → Fault` 包装成 Mac 文件错误。该代码路径可直接定位,但没有真机复现证据可以把底层拒绝进一步归因为某个代理 ABI。
实现补齐官方 `RecordAgent` 已做的进程/Unix socket 就绪判断,按上表最新至最旧顺序选择能够启动的官方 Unix 原件。视频连接失败时移除并重建自身记录的转发;不会借用未知转发或终止外部进程。HDC 转发包含本机监听与对端激活阶段,发出 `fport` 命令本身不能代替端到端连接。[HDC 官方转发源码](https://github.com/openharmony/developtools_hdc/blob/master/src/common/forward.cpp)
产品错误信息区分设备组件启动、HDC 转发及视频连接;首帧显示前保持“正在建立视频连接”。按用户要求,本轮未跑测试、真机实验或远程安装验证。
+16 -1
View File
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"id": "trove-official",
"name": "Trove 官方软件源",
"generatedAt": "2026-09-10T09:25:53.244Z",
"generatedAt": "2026-09-10T09:37:34.602Z",
"curios": [
{
"id": "dev.trove.qa.host",
@@ -200,6 +200,21 @@
"name": "Harmony Connector",
"description": "连接鸿蒙设备,收发完整文件夹、管理应用,在独立窗口中投屏并通过键鼠操作。",
"versions": [
{
"version": "0.5.1",
"engine": {
"trove": ">=0.1.0 <1.0.0"
},
"releasedAt": "2026-09-10T09:37:34.602Z",
"artifacts": [
{
"target": "aarch64-apple-darwin",
"url": "packages/dev.trove.harmony-connector/0.5.1/aarch64-apple-darwin.curio",
"sha256": "1bfed01377ab519c58c71f0a1597c107edd7242c1d96e288979a44929016cb74",
"size": 4718083
}
]
},
{
"version": "0.5.0",
"engine": {
+2 -2
View File
@@ -69,9 +69,9 @@ window.__TROVE_CONTEXT__ = {
platform: "macos",
};
window.__TROVE_BRIDGE__ = createMockBridge({
"@self.hdc.screenWindowOpen": () => ({ active: (screenActive = true) }),
"@self.hdc.screenWindowOpen": () => ({ active: (screenActive = true), ready: true }),
"@self.hdc.screenWindowStop": () => ({ active: (screenActive = false) }),
"@self.hdc.screenWindowStatus": () => ({ active: screenActive }),
"@self.hdc.screenWindowStatus": () => ({ active: screenActive, ready: screenActive }),
"@self.hdc.status": () => ({ busy: false, job: null }),
"@self.hdc.tool": () => ({
available: true,
+2 -2
View File
@@ -85,10 +85,10 @@ async function app(t) {
if (model.screenError) throw model.screenError;
model.screenActive = true;
model.screenTarget = target;
return { active: true, target };
return { active: true, ready: true, target };
}),
handler("hdc.screenWindowStop", () => ({ active: (model.screenActive = false) })),
handler("hdc.screenWindowStatus", () => ({ active: model.screenActive, target: model.screenTarget, error: model.screenError })),
handler("hdc.screenWindowStatus", () => ({ active: model.screenActive, ready: model.screenActive, target: model.screenTarget, error: model.screenError })),
handler("hdc.status", () => ({ busy: false, job: null })),
handler("hdc.tool", () => ({
available: model.hdcAvailable,
+24
View File
@@ -15,6 +15,30 @@
"member": "devicetest/res/recorder/libscrcpy_server_unix_6.6-20260629.z.so",
"sha256": "b24231188ab0fffa84c2bd71cab6948c8ffad9d661c80301f32eaf580177ce8e",
"modified": false
},
{
"file": "libscrcpy_server_unix_6.6-20260418.z.so",
"member": "devicetest/res/recorder/libscrcpy_server_unix_6.6-20260418.z.so",
"sha256": "98bbc964fef1c4d17b446ec717102b855d09224b23fc1872bf70e1f928d010dd",
"modified": false
},
{
"file": "libscrcpy_server_unix_6.5-20260313.z.so",
"member": "devicetest/res/recorder/libscrcpy_server_unix_6.5-20260313.z.so",
"sha256": "2c8f1ef94d581281bf8e86d54f5939af9b205d5db5aa2cfb27f2562215c87b0a",
"modified": false
},
{
"file": "libscrcpy_server_unix_6.4-20260113.z.so",
"member": "devicetest/res/recorder/libscrcpy_server_unix_6.4-20260113.z.so",
"sha256": "b698b41ae1351337eeea3dcc87bf0160552196d669ba321b4963c342fa5a47f4",
"modified": false
},
{
"file": "libscrcpy_server_unix_6.3.1-20260113.z.so",
"member": "devicetest/res/recorder/libscrcpy_server_unix_6.3.1-20260113.z.so",
"sha256": "3e29e3a1d23ea6dbd4e6fb3b8f094ca5d4b15b4c580f421fba3d0e1f5429e3fe",
"modified": false
}
]
}