diff --git a/curios/harmony-connector/curio.json b/curios/harmony-connector/curio.json index 655c586..5bb59c1 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.1", + "version": "0.5.2", "engine": { "trove": ">=0.1.0 <1.0.0" }, "icon": "assets/icon.svg", "ui": { diff --git a/curios/harmony-connector/native/Cargo.lock b/curios/harmony-connector/native/Cargo.lock index f9d8880..822bd88 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.1" +version = "0.5.2" dependencies = [ "anyhow", "base64", diff --git a/curios/harmony-connector/native/Cargo.toml b/curios/harmony-connector/native/Cargo.toml index 6d931a2..ba2c416 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.1" +version = "0.5.2" edition = "2021" publish = false diff --git a/curios/harmony-connector/native/src/hdc/screen_daemon.rs b/curios/harmony-connector/native/src/hdc/screen_daemon.rs index 4b2fa71..97f3f4d 100644 --- a/curios/harmony-connector/native/src/hdc/screen_daemon.rs +++ b/curios/harmony-connector/native/src/hdc/screen_daemon.rs @@ -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, } impl Resources { pub fn open(tool: Tool, target: String) -> Result { @@ -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::(&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 { @@ -244,11 +265,9 @@ impl Resources { )) } 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) { + 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 { 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() { diff --git a/curios/harmony-connector/native/src/hdc/screen_recorder.rs b/curios/harmony-connector/native/src/hdc/screen_recorder.rs index eaa7611..a3498e1 100644 --- a/curios/harmony-connector/native/src/hdc/screen_recorder.rs +++ b/curios/harmony-connector/native/src/hdc/screen_recorder.rs @@ -34,14 +34,17 @@ pub(super) struct Recorder { resources: Mutex>, } impl Recorder { - pub fn open(tool: Tool, target: String) -> Result> { + pub fn open(tool: Tool, target: String, progress: impl Fn(&str)) -> Result> { + 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 { if self.cancel.load(Ordering::Acquire) { return Err(Fault::new("screen.closed", "投屏已停止。")); diff --git a/curios/harmony-connector/native/src/hdc/screen_window.rs b/curios/harmony-connector/native/src/hdc/screen_window.rs index ff23754..edf0cf0 100644 --- a/curios/harmony-connector/native/src/hdc/screen_window.rs +++ b/curios/harmony-connector/native/src/hdc/screen_window.rs @@ -37,10 +37,31 @@ impl Pipe { Ok(()) } } +struct Session { + cancel: Arc, + recorder: Mutex>>, + phase: Mutex, + error: Mutex>, + cleanup_error: Mutex>, +} +impl Session { + fn recorder(&self) -> Option> { + 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, - recorder: Arc, + session: Arc, target: String, worker: Option>, } @@ -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 { + pub fn open(&self, mut tool: Tool, request: Open) -> Result { + 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::(&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::() 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 { 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::() 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) } diff --git a/curios/harmony-connector/ui/screen-window.js b/curios/harmony-connector/ui/screen-window.js index be82b4c..776d7c9 100644 --- a/curios/harmony-connector/ui/screen-window.js +++ b/curios/harmony-connector/ui/screen-window.js @@ -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(); } } diff --git a/curios/harmony-connector/viewer/Main.swift b/curios/harmony-connector/viewer/Main.swift index 049e48a..7efef38 100644 --- a/curios/harmony-connector/viewer/Main.swift +++ b/curios/harmony-connector/viewer/Main.swift @@ -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 连接后重新开始。" diff --git a/docs/harmony-connector.md b/docs/harmony-connector.md index 4011d21..2c87246 100644 --- a/docs/harmony-connector.md +++ b/docs/harmony-connector.md @@ -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 diff --git a/docs/harmony-realtime-development.md b/docs/harmony-realtime-development.md index fbc206a..6e613a0 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「很流畅」,但打开耗时较长。0.5.2 将窗口创建提前到设备探测之前,后台准备支持进度显示和取消;只在首张画面成功解码显示后保存设备的可用视频代理,下次优先使用,失败时保留自动回退。启动期间暂停管理页的设备轮询,避免争用。首次匹配耗时未承诺降低到固定数值;本轮按用户要求仅构建打包,未跑测试、真机计时或远程安装验证。 + + 2026-09-10:0.5.1 针对用户报告的 `无法读写 Mac 文件:Connection refused (os error 61)` 修改启动流程。代码可确认该提示来自视频 TCP 连接使用了通用文件 I/O 错误转换;此前没有确认设备代理/Unix socket 就绪,且只重试同一端口。现在增加官方代理候选、就绪检查、仅重建自身转发、启动日志和首帧状态。具体设备上的代理兼容性与连接恢复效果仍等待用户反馈;本轮只构建打包,未运行测试或真机验证。 diff --git a/source/index.json b/source/index.json index b737a6b..0d7d56d 100644 --- a/source/index.json +++ b/source/index.json @@ -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": { diff --git a/source/packages/dev.trove.harmony-connector/0.5.2/aarch64-apple-darwin.curio b/source/packages/dev.trove.harmony-connector/0.5.2/aarch64-apple-darwin.curio new file mode 100644 index 0000000..7f078e9 Binary files /dev/null and b/source/packages/dev.trove.harmony-connector/0.5.2/aarch64-apple-darwin.curio differ