diff --git a/curios/harmony-connector/curio.json b/curios/harmony-connector/curio.json index 75cce70..655c586 100644 --- a/curios/harmony-connector/curio.json +++ b/curios/harmony-connector/curio.json @@ -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": { diff --git a/curios/harmony-connector/legal/THIRD-PARTY.md b/curios/harmony-connector/legal/THIRD-PARTY.md index 5f0100d..09bbd06 100644 --- a/curios/harmony-connector/legal/THIRD-PARTY.md +++ b/curios/harmony-connector/legal/THIRD-PARTY.md @@ -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 diff --git a/curios/harmony-connector/native/Cargo.lock b/curios/harmony-connector/native/Cargo.lock index a44a7e4..f9d8880 100644 --- a/curios/harmony-connector/native/Cargo.lock +++ b/curios/harmony-connector/native/Cargo.lock @@ -259,7 +259,7 @@ dependencies = [ [[package]] name = "harmony-connector" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "base64", diff --git a/curios/harmony-connector/native/Cargo.toml b/curios/harmony-connector/native/Cargo.toml index ce2ca9c..6d931a2 100644 --- a/curios/harmony-connector/native/Cargo.toml +++ b/curios/harmony-connector/native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "harmony-connector" -version = "0.5.0" +version = "0.5.1" edition = "2021" publish = false diff --git a/curios/harmony-connector/native/src/hdc.rs b/curios/harmony-connector/native/src/hdc.rs index 3c14ab7..439da87 100644 --- a/curios/harmony-connector/native/src/hdc.rs +++ b/curios/harmony-connector/native/src/hdc.rs @@ -1,6 +1,7 @@ mod files; mod screen; mod screen_agent; +mod screen_daemon; mod screen_input; mod screen_lease; mod screen_protocol; diff --git a/curios/harmony-connector/native/src/hdc/screen_agent.rs b/curios/harmony-connector/native/src/hdc/screen_agent.rs index aa01dcb..bb0e988 100644 --- a/curios/harmony-connector/native/src/hdc/screen_agent.rs +++ b/curios/harmony-connector/native/src/hdc/screen_agent.rs @@ -138,9 +138,9 @@ impl Rpc { Ok((dimension("x")?, dimension("y")?)) } } -pub(super) fn existing_agents(tool: &Tool, target: &str) -> Result> { +pub(super) fn processes(tool: &Tool, target: &str) -> Result> { 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 Result> { + 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() diff --git a/curios/harmony-connector/native/src/hdc/screen_daemon.rs b/curios/harmony-connector/native/src/hdc/screen_daemon.rs new file mode 100644 index 0000000..4b2fa71 --- /dev/null +++ b/curios/harmony-connector/native/src/hdc/screen_daemon.rs @@ -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, + forwards: Vec<(String, String)>, + lease: Lease, + video: Option<(usize, String)>, +} +impl Resources { + pub fn open(tool: Tool, target: String) -> Result { + 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 { + 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 { + 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 { + 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 { + self.video_from(0) + } + fn video_from(&mut self, start: usize) -> Result { + 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 { + 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(); + } +} diff --git a/curios/harmony-connector/native/src/hdc/screen_recorder.rs b/curios/harmony-connector/native/src/hdc/screen_recorder.rs index df95da6..eaa7611 100644 --- a/curios/harmony-connector/native/src/hdc/screen_recorder.rs +++ b/curios/harmony-connector/native/src/hdc/screen_recorder.rs @@ -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, - 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 { - 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, pub port: u16, pub keyframe: AtomicBool, + pub ready: AtomicBool, pub error: Mutex>, dimensions: Arc>, queue: Arc, @@ -167,51 +35,10 @@ pub(super) struct Recorder { } impl Recorder { pub fn open(tool: Tool, target: String) -> Result> { - 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 { + 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() } diff --git a/curios/harmony-connector/native/src/hdc/screen_video.rs b/curios/harmony-connector/native/src/hdc/screen_video.rs index e364e59..813a52f 100644 --- a/curios/harmony-connector/native/src/hdc/screen_video.rs +++ b/curios/harmony-connector/native/src/hdc/screen_video.rs @@ -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 { diff --git a/curios/harmony-connector/native/src/hdc/screen_window.rs b/curios/harmony-connector/native/src/hdc/screen_window.rs index f69a619..ff23754 100644 --- a/curios/harmony-connector/native/src/hdc/screen_window.rs +++ b/curios/harmony-connector/native/src/hdc/screen_window.rs @@ -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 { if let Some(viewer) = self.current.lock().unwrap().take() { diff --git a/curios/harmony-connector/ui/screen-window.js b/curios/harmony-connector/ui/screen-window.js index 581d14e..be82b4c 100644 --- a/curios/harmony-connector/ui/screen-window.js +++ b/curios/harmony-connector/ui/screen-window.js @@ -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 { diff --git a/curios/harmony-connector/viewer/Main.swift b/curios/harmony-connector/viewer/Main.swift index ccacc36..049e48a 100644 --- a/curios/harmony-connector/viewer/Main.swift +++ b/curios/harmony-connector/viewer/Main.swift @@ -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 diff --git a/docs/harmony-connector.md b/docs/harmony-connector.md index eb3b7fa..4011d21 100644 --- a/docs/harmony-connector.md +++ b/docs/harmony-connector.md @@ -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 diff --git a/docs/harmony-realtime-development.md b/docs/harmony-realtime-development.md index 146db30..fbc206a 100644 --- a/docs/harmony-realtime-development.md +++ b/docs/harmony-realtime-development.md @@ -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)。 以下为旧版本历史记录。 diff --git a/docs/research/harmony-screen-performance.md b/docs/research/harmony-screen-performance.md index 3c660bf..a5c39a1 100644 --- a/docs/research/harmony-screen-performance.md +++ b/docs/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 转发及视频连接;首帧显示前保持“正在建立视频连接”。按用户要求,本轮未跑测试、真机实验或远程安装验证。 diff --git a/source/index.json b/source/index.json index 8be10b8..b737a6b 100644 --- a/source/index.json +++ b/source/index.json @@ -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": { diff --git a/source/packages/dev.trove.harmony-connector/0.5.1/aarch64-apple-darwin.curio b/source/packages/dev.trove.harmony-connector/0.5.1/aarch64-apple-darwin.curio new file mode 100644 index 0000000..37fe417 Binary files /dev/null and b/source/packages/dev.trove.harmony-connector/0.5.1/aarch64-apple-darwin.curio differ diff --git a/tools/fixtures/harmony-ui.mjs b/tools/fixtures/harmony-ui.mjs index c039457..18f812f 100644 --- a/tools/fixtures/harmony-ui.mjs +++ b/tools/fixtures/harmony-ui.mjs @@ -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, diff --git a/tools/harmony-ui.test.mjs b/tools/harmony-ui.test.mjs index ba06382..5f61c1b 100644 --- a/tools/harmony-ui.test.mjs +++ b/tools/harmony-ui.test.mjs @@ -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, diff --git a/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.3.1-20260113.z.so b/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.3.1-20260113.z.so new file mode 100644 index 0000000..abd4110 Binary files /dev/null and b/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.3.1-20260113.z.so differ diff --git a/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.4-20260113.z.so b/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.4-20260113.z.so new file mode 100644 index 0000000..203590f Binary files /dev/null and b/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.4-20260113.z.so differ diff --git a/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.5-20260313.z.so b/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.5-20260313.z.so new file mode 100644 index 0000000..25d03d6 Binary files /dev/null and b/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.5-20260313.z.so differ diff --git a/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.6-20260418.z.so b/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.6-20260418.z.so new file mode 100644 index 0000000..43d8e7b Binary files /dev/null and b/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.6-20260418.z.so differ diff --git a/vendor/harmony-connector/hypium/provenance-26.json b/vendor/harmony-connector/hypium/provenance-26.json index 92b77a5..ea69fee 100644 --- a/vendor/harmony-connector/hypium/provenance-26.json +++ b/vendor/harmony-connector/hypium/provenance-26.json @@ -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 } ] }