feat(harmony): add folder transfers and HDC device management

This commit is contained in:
2026-09-10 11:56:09 +08:00
parent 6bdcfdb44e
commit 44d8cf5c92
30 changed files with 3881 additions and 299 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ Trove 官方插件库。发现喜欢的 Curio,装进你的工作台,让常
### Harmony Connector
让文件,自在往来。一根 USB 数据线,连接 Mac 与鸿蒙设备;浏览文件、收发文档,把照片与灵感带到更大的屏幕。
让设备,更近一步。连接 Mac 与鸿蒙手机,整夹收发照片与文档,随时掌握传输速度;管理应用、保存截图,让日常操作在桌面从容完成。
## 按你的习惯组合
+2 -2
View File
@@ -2,8 +2,8 @@
"schemaVersion": 1,
"id": "dev.trove.harmony-connector",
"name": "Harmony Connector",
"description": "通过 USB 连接鸿蒙设备,在 Mac 与手机之间浏览、发送和接收文件。",
"version": "0.1.1",
"description": "连接鸿蒙设备,收发完整文件夹、管理应用,轻松保存截图和日志。",
"version": "0.2.0",
"engine": { "trove": ">=0.1.0 <1.0.0" },
"icon": "assets/icon.svg",
"ui": {
+248 -2
View File
@@ -2,18 +2,48 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cc"
version = "1.4.5"
@@ -30,6 +60,61 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
@@ -46,12 +131,42 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide 0.9.1",
"zlib-rs",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -65,13 +180,33 @@ dependencies = [
[[package]]
name = "harmony-connector"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"anyhow",
"cc",
"libc",
"png",
"serde",
"serde_json",
"sha2",
"tempfile",
"zip",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
@@ -92,18 +227,57 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -168,7 +342,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.5",
]
[[package]]
@@ -184,12 +358,40 @@ dependencies = [
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.5"
@@ -214,12 +416,24 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -235,8 +449,40 @@ dependencies = [
"windows-link",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"flate2",
"indexmap",
"memchr",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
+5 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "harmony-connector"
version = "0.1.1"
version = "0.2.0"
edition = "2021"
publish = false
@@ -9,6 +9,10 @@ anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tempfile = "3"
libc = "0.2"
sha2 = "0.10"
zip = { version = "4.6", default-features = false, features = ["deflate"] }
png = "0.18"
[build-dependencies]
cc = "1"
@@ -0,0 +1,186 @@
use crate::model::{Fault, Result};
use std::{
io::{ErrorKind, Read},
os::{fd::AsRawFd, unix::process::CommandExt},
path::Path,
process::{Command, Stdio},
sync::atomic::{AtomicBool, Ordering},
thread,
time::{Duration, Instant},
};
fn nonblocking(stream: &impl AsRawFd) -> std::io::Result<()> {
let fd = stream.as_raw_fd();
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
fn drain(stream: &mut impl Read, output: &mut Vec<u8>, limit: usize) -> Result<bool> {
let mut buffer = [0; 32 * 1024];
loop {
match stream.read(&mut buffer) {
Ok(0) => return Ok(true),
Ok(size) => {
if output.len().saturating_add(size) > limit {
return Err(Fault::new(
"hdc.output_too_large",
"工具输出超过限制,操作已停止。",
));
}
output.extend_from_slice(&buffer[..size]);
}
Err(e) if e.kind() == ErrorKind::WouldBlock => return Ok(false),
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
Err(e) => return Err(e.into()),
}
}
}
pub fn run(
executable: &Path,
args: &[String],
timeout: Duration,
cancel: &AtomicBool,
limit: usize,
) -> Result<String> {
if cancel.load(Ordering::Acquire) {
return Err(Fault::new("operation.cancelled", "操作已取消。"));
}
let mut child = Command::new(executable)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.process_group(0)
.spawn()
.map_err(|e| {
Fault::new(
"hdc.tool_unavailable",
format!("无法运行工具:{e}。请保留官方工具旁的依赖文件。"),
)
})?;
let result = (|| {
let mut out = child.stdout.take().unwrap();
let mut err = child.stderr.take().unwrap();
nonblocking(&out)?;
nonblocking(&err)?;
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
let started = Instant::now();
let status = loop {
if cancel.load(Ordering::Acquire) {
return Err(Fault::new(
"operation.cancelled",
"操作已取消;设备端已完成的更改不会自动撤销。",
));
}
if started.elapsed() >= timeout {
return Err(Fault::new(
"hdc.timeout",
"设备操作超时,请检查手机授权和连接状态。",
));
}
let out_done = drain(&mut out, &mut stdout, limit)?;
let err_done = drain(&mut err, &mut stderr, limit)?;
if let Some(status) = child.try_wait()? {
if out_done && err_done {
break status;
}
}
thread::sleep(Duration::from_millis(20));
};
let stdout = String::from_utf8_lossy(&stdout).into_owned();
let stderr = String::from_utf8_lossy(&stderr);
if !status.success() {
return Err(Fault::new(
"hdc.command_failed",
format!("{}{}", stdout, stderr)
.chars()
.take(4000)
.collect::<String>(),
));
}
check_remote_errors(&stderr)?;
Ok(stdout)
})();
if result.is_err() {
unsafe {
libc::kill(-(child.id() as i32), libc::SIGKILL);
}
let _ = child.wait();
}
result
}
// HDC does not consistently propagate remote exit codes. Inspect structured command output,
// but do not interpret log contents as commands failing.
pub fn check_remote_errors(output: &str) -> Result<()> {
if output.lines().any(|line| {
let line = line.trim().to_lowercase();
line.starts_with("[fail]")
|| line.starts_with("error:")
|| line.starts_with("failed ")
|| line.starts_with("/bin/sh:")
|| line.starts_with("sh:")
|| line == "permission denied"
}) {
return Err(Fault::new(
"hdc.command_failed",
output.chars().take(4000).collect::<String>(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn script(text: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tool");
std::fs::write(&path, format!("#!/bin/sh\n{text}\n")).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
(dir, path)
}
#[test]
fn bounds_output_timeout_and_remote_errors_without_a_phone() {
let cancel = AtomicBool::new(false);
let (_dir, path) = script("printf '[Fail] device unauthorized'; exit 0");
assert_eq!(
check_remote_errors(&run(&path, &[], Duration::from_secs(1), &cancel, 1024).unwrap())
.unwrap_err()
.code,
"hdc.command_failed"
);
let (_dir, path) = script("sleep 10 & exit 0");
assert_eq!(
run(&path, &[], Duration::from_millis(80), &cancel, 1024)
.unwrap_err()
.code,
"hdc.timeout"
);
let (_dir, path) = script("while true; do printf '0123456789'; done");
assert_eq!(
run(&path, &[], Duration::from_secs(5), &cancel, 32)
.unwrap_err()
.code,
"hdc.output_too_large"
);
}
#[test]
fn passes_arguments_literally_without_a_host_shell() {
let (_dir, path) = script("printf '%s' \"$1\"");
let value = "a; echo surprise $(pwd)".to_string();
assert_eq!(
run(
&path,
&[value.clone()],
Duration::from_secs(1),
&AtomicBool::new(false),
1024
)
.unwrap(),
value
);
}
}
+19 -124
View File
@@ -5,7 +5,6 @@ use crate::{
use serde::{de::DeserializeOwned, Deserialize};
use serde_json::{json, Value};
use std::{
fs::File,
path::{Component, Path},
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
@@ -37,6 +36,7 @@ struct Job {
error: Option<Fault>,
}
pub struct Connector {
management: Arc<crate::hdc::Manager>,
gate: Arc<AtomicBool>,
session: Mutex<Option<Session>>,
connection: Mutex<Value>,
@@ -80,12 +80,6 @@ fn params<T: DeserializeOwned>(value: Value) -> Result<T> {
fn disconnected() -> Fault {
Fault::new("device.disconnected", "请先连接设备。")
}
fn cancelled() -> Fault {
Fault::new(
"transfer.cancelled",
"传输已取消。发送到手机的未完成文件可能需要在手机上清理。",
)
}
pub fn valid_name(name: &str) -> Result<()> {
if name.is_empty()
|| name.len() > 255
@@ -103,7 +97,7 @@ pub fn valid_name(name: &str) -> Result<()> {
}
Ok(())
}
fn parent_exists(device: &mut dyn DeviceIo, storage: u32, parent: u32) -> Result<()> {
pub(crate) fn parent_exists(device: &mut dyn DeviceIo, storage: u32, parent: u32) -> Result<()> {
if !device.storages()?.iter().any(|s| s.id == storage) {
return Err(Fault::new(
"storage.not_found",
@@ -121,7 +115,12 @@ fn parent_exists(device: &mut dyn DeviceIo, storage: u32, parent: u32) -> Result
}
Ok(())
}
fn available_name(device: &mut dyn DeviceIo, storage: u32, parent: u32, name: &str) -> Result<()> {
pub(crate) fn available_name(
device: &mut dyn DeviceIo,
storage: u32,
parent: u32,
name: &str,
) -> Result<()> {
// Case-insensitive comparison also protects stores backed by case-insensitive filesystems.
if device
.list(storage, parent)?
@@ -139,6 +138,7 @@ fn available_name(device: &mut dyn DeviceIo, storage: u32, parent: u32, name: &s
impl Connector {
pub fn new() -> Arc<Self> {
Arc::new(Self {
management: crate::hdc::Manager::new(),
gate: Arc::new(AtomicBool::new(false)),
session: Mutex::new(None),
connection: Mutex::new(Value::Null),
@@ -164,6 +164,7 @@ impl Connector {
drop(self.detach());
}
pub fn stop(&self) {
self.management.stop();
self.stopped.store(true, Ordering::Release);
if let Some(job) = self.job.lock().unwrap().as_ref() {
job.progress.cancel.store(true, Ordering::Release);
@@ -180,6 +181,12 @@ impl Connector {
self.gate.load(Ordering::Acquire)
}
pub fn call(self: &Arc<Self>, method: &str, value: Value) -> Result<Value> {
if let Some(method) = method.strip_prefix("hdc.") {
if self.stopped.load(Ordering::Acquire) {
return Err(Fault::new("device.stopping", "连接正在关闭。"));
}
return self.management.call(method, value);
}
match method {
"info" => {
return Ok(
@@ -357,12 +364,13 @@ impl Connector {
}
let mut result = {
let mut session = connector.session.lock().unwrap();
run_transfer(
crate::file_transfer::run(
session.as_mut().unwrap().device.as_mut(),
transfer,
&progress,
)
};
progress.finish();
if progress.disconnected.load(Ordering::Acquire) && result.is_err() {
result = Err(Fault::new(
"device.disconnected",
@@ -402,7 +410,7 @@ impl Connector {
Ok(json!({"id":id}))
}
fn status(&self) -> Value {
self.job.lock().unwrap().as_ref().map(|job| json!({"id":job.id,"state":job.state,"sent":job.progress.sent.load(Ordering::Relaxed),"total":job.progress.total.load(Ordering::Relaxed),"cancelling":job.progress.cancelled(),"result":job.result,"error":job.error})).unwrap_or(Value::Null)
self.job.lock().unwrap().as_ref().map(|job| json!({"id":job.id,"state":job.state,"sent":job.progress.sent.load(Ordering::Relaxed),"total":job.progress.total.load(Ordering::Relaxed),"cancelling":job.progress.cancelled(),"statistics":job.progress.statistics(),"result":job.result,"error":job.error})).unwrap_or(Value::Null)
}
#[cfg(test)]
pub fn with_device(device: Box<dyn DeviceIo>) -> Arc<Self> {
@@ -412,118 +420,5 @@ impl Connector {
}
}
fn run_transfer(
device: &mut dyn DeviceIo,
transfer: Transfer,
progress: &Progress,
) -> Result<Value> {
if progress.cancelled() {
return Err(cancelled());
}
match transfer {
Transfer::Upload {
path,
storage,
parent,
} => {
let path = Path::new(&path);
if !path.is_absolute() {
return Err(Fault::new("file.invalid_path", "请选择 Mac 上的文件。"));
}
let name = path
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| Fault::new("file.invalid_name", "文件名不是有效的 UTF-8。"))?;
valid_name(name)?;
let file = File::open(path)?;
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(Fault::new(
"file.not_regular",
"一次只能发送一个普通文件。文件夹请先压缩。",
));
}
parent_exists(device, storage, parent)?;
available_name(device, storage, parent, name)?;
let capacity = device
.storages()?
.into_iter()
.find(|s| s.id == storage)
.ok_or_else(disconnected)?;
if capacity.free != u64::MAX && metadata.len() > capacity.free {
return Err(Fault::new("storage.full", "手机剩余空间不足。"));
}
progress.update(0, metadata.len());
if progress.cancelled() {
return Err(cancelled());
}
let outcome = device.upload(&file, name, metadata.len(), storage, parent, progress);
if progress.cancelled() {
return Err(cancelled());
}
let object = outcome?;
let received = device.metadata(object)?;
if received.size != metadata.len() || file.metadata()?.len() != metadata.len() {
return Err(Fault::new(
"mtp.size_mismatch",
"传输后文件大小不一致,请检查手机上的文件后重试。",
));
}
progress.update(metadata.len(), metadata.len());
Ok(json!({"object":object,"name":name,"bytes":metadata.len()}))
}
Transfer::Download { object, directory } => {
let entry = device.metadata(object)?;
if entry.folder {
return Err(Fault::new("file.is_folder", "请选择单个文件接收。"));
}
valid_name(&entry.name)?;
let directory = Path::new(&directory);
if !directory.is_absolute() || !directory.is_dir() {
return Err(Fault::new(
"file.invalid_directory",
"请选择 Mac 上的接收文件夹。",
));
}
let destination = directory.join(&entry.name);
if destination.symlink_metadata().is_ok() {
return Err(Fault::new(
"file.exists",
"Mac 上已有同名文件。请选择其他文件夹。",
));
}
let temporary = tempfile::Builder::new()
.prefix(".harmony-")
.suffix(".part")
.tempfile_in(directory)?;
progress.update(0, entry.size);
if progress.cancelled() {
return Err(cancelled());
}
let outcome = device.download(object, temporary.as_file(), progress);
if progress.cancelled() {
return Err(cancelled());
}
outcome?;
if temporary.as_file().metadata()?.len() != entry.size {
return Err(Fault::new(
"mtp.size_mismatch",
"接收到的文件不完整,请重新连接后重试。",
));
}
temporary.as_file().sync_all()?;
// Atomic no-clobber also closes the race where another app creates the destination mid-transfer.
temporary.persist_noclobber(&destination).map_err(|e| {
Fault::new(
"file.save_failed",
format!("无法保存文件(不会覆盖已有文件):{}", e.error),
)
})?;
progress.update(entry.size, entry.size);
Ok(json!({"path":destination,"name":entry.name,"bytes":entry.size}))
}
}
}
#[cfg(test)]
mod tests;
@@ -1,5 +1,6 @@
use super::*;
use std::{
fs::File,
io::{Read, Write},
time::{Duration, Instant},
};
@@ -67,9 +68,18 @@ impl DeviceIo for Device {
let mut bytes = vec![];
file.try_clone()?.read_to_end(&mut bytes)?;
progress.update(bytes.len() as u64, size);
self.0.lock().unwrap().entries.push((
let mut memory = self.0.lock().unwrap();
let id = memory
.entries
.iter()
.map(|(e, _)| e.id)
.max()
.unwrap_or(0)
.max(999)
+ 1;
memory.entries.push((
Entry {
id: 1000,
id,
parent,
storage,
name: name.into(),
@@ -79,7 +89,7 @@ impl DeviceIo for Device {
},
bytes,
));
Ok(1000)
Ok(id)
}
fn download(&mut self, id: u32, file: &File, progress: &Progress) -> Result<()> {
let (bytes, fail, truncate, slow, collide) = {
@@ -121,9 +131,18 @@ impl DeviceIo for Device {
Ok(())
}
fn mkdir(&mut self, name: &str, storage: u32, parent: u32) -> Result<u32> {
self.0.lock().unwrap().entries.push((
let mut memory = self.0.lock().unwrap();
let id = memory
.entries
.iter()
.map(|(e, _)| e.id)
.max()
.unwrap_or(0)
.max(998)
+ 1;
memory.entries.push((
Entry {
id: 999,
id,
parent,
storage,
name: name.into(),
@@ -133,7 +152,7 @@ impl DeviceIo for Device {
},
vec![],
));
Ok(999)
Ok(id)
}
}
fn setup(name: &str, bytes: &[u8]) -> (Arc<Connector>, Arc<Mutex<Memory>>) {
@@ -558,3 +577,136 @@ fn disconnect_returns_and_clears_state_before_slow_usb_release() {
assert_eq!(busy, "device.busy");
wait_idle(&connector);
}
#[test]
fn transfers_whole_folders_preserving_tree_binary_content_and_empty_directories() {
let source = tempfile::tempdir().unwrap();
let selected = source.path().join("资料 🎨");
std::fs::create_dir_all(selected.join("嵌套/空文件夹")).unwrap();
std::fs::write(selected.join("empty.txt"), []).unwrap();
let payload: Vec<u8> = (0..80_001).map(|i| (i % 251) as u8).collect();
std::fs::write(selected.join("嵌套/binary.bin"), &payload).unwrap();
let (connector, _) = setup("other", &[]);
connector
.call(
"transfer",
json!({"direction":"upload","path":selected,"storage":1,"parent":ROOT}),
)
.unwrap();
let uploaded = wait(&connector);
assert_eq!(uploaded["state"], "completed", "{uploaded}");
assert_eq!(uploaded["statistics"]["filesCompleted"], 2);
assert_eq!(uploaded["sent"], payload.len());
let destination = tempfile::tempdir().unwrap();
connector.call("transfer", json!({"direction":"download","object":uploaded["result"]["object"],"directory":destination.path()})).unwrap();
let downloaded = wait(&connector);
assert_eq!(downloaded["state"], "completed", "{downloaded}");
let result = destination.path().join("资料 🎨");
assert!(result.join("嵌套/空文件夹").is_dir());
assert_eq!(std::fs::read(result.join("empty.txt")).unwrap(), b"");
assert_eq!(
std::fs::read(result.join("嵌套/binary.bin")).unwrap(),
payload
);
assert_eq!(directory_names(destination.path()), ["资料 🎨"]);
}
#[test]
fn preflights_folder_upload_before_writing_and_never_follows_symlinks() {
let source = tempfile::tempdir().unwrap();
let selected = source.path().join("folder");
std::fs::create_dir(&selected).unwrap();
std::fs::write(selected.join("a.txt"), b"data").unwrap();
std::os::unix::fs::symlink(source.path(), selected.join("cycle")).unwrap();
let (connector, memory) = setup("other", &[]);
connector
.call(
"transfer",
json!({"direction":"upload","path":selected,"storage":1,"parent":ROOT}),
)
.unwrap();
assert_eq!(wait(&connector)["error"]["code"], "file.not_regular");
assert_eq!(memory.lock().unwrap().entries.len(), 1);
}
fn setup_folder() -> (Arc<Connector>, Arc<Mutex<Memory>>) {
let (connector, memory) = setup("folder", &[]);
{
let mut state = memory.lock().unwrap();
state.entries[0].0.folder = true;
state.entries.push((
Entry {
id: 2,
parent: 1,
storage: 1,
name: "data.bin".into(),
size: 128 * 1024,
modified: 0,
folder: false,
},
vec![7; 128 * 1024],
));
}
(connector, memory)
}
#[test]
fn folder_download_failure_and_cancel_leave_no_published_or_partial_tree() {
for cancel in [false, true] {
let (connector, memory) = setup_folder();
{
let mut m = memory.lock().unwrap();
m.fail = !cancel;
m.slow = cancel;
}
let destination = tempfile::tempdir().unwrap();
let job = connector
.call(
"transfer",
json!({"direction":"download","object":1,"directory":destination.path()}),
)
.unwrap();
if cancel {
connector.call("cancelTransfer", job).unwrap();
}
assert_eq!(
wait(&connector)["state"],
if cancel { "cancelled" } else { "failed" }
);
assert!(directory_names(destination.path()).is_empty());
}
}
#[test]
fn folder_download_does_not_merge_existing_or_racing_destination() {
for racing in [false, true] {
let (connector, memory) = setup_folder();
let destination = tempfile::tempdir().unwrap();
let output = destination.path().join("folder");
if racing {
memory.lock().unwrap().collide = Some(output.clone());
} else {
std::fs::create_dir(&output).unwrap();
std::fs::write(output.join("original"), b"keep").unwrap();
}
assert_eq!(download(&connector, destination.path())["state"], "failed");
assert_eq!(directory_names(destination.path()), ["folder"]);
if racing {
assert_eq!(std::fs::read(output).unwrap(), b"existing file");
} else {
assert_eq!(directory_names(&output), ["original"]);
}
}
}
#[test]
fn rejects_invalid_remote_folder_tree_before_creating_local_content() {
for invalid_name in ["../escape", "folder"] {
let (connector, memory) = setup_folder();
{
let mut state = memory.lock().unwrap();
state.entries[1].0.name = invalid_name.into();
if invalid_name == "folder" {
state.entries[1].0.id = 1;
}
}
let destination = tempfile::tempdir().unwrap();
assert_eq!(download(&connector, destination.path())["state"], "failed");
assert!(directory_names(destination.path()).is_empty());
}
}
@@ -0,0 +1,41 @@
use crate::mtp::{self, DeviceIo, NativeDevice, ROOT};
use serde_json::json;
use std::time::Instant;
// Explicitly invoked, read-only session exercise. Never run during a user's plugin session.
pub fn reconnect() -> anyhow::Result<()> {
for cycle in 1..=3 {
let devices = mtp::scan()?;
anyhow::ensure!(
devices.len() == 1,
"重连验收需要恰好一台可用的 USB 文件传输设备,当前发现 {} 台。",
devices.len()
);
println!(
"{}",
json!({"cycle":cycle,"stage":"opening","device":devices[0].id})
);
let opened = Instant::now();
let mut device = NativeDevice::open(&devices[0])?;
let storages = device.storages()?;
anyhow::ensure!(!storages.is_empty(), "设备没有返回存储位置。");
let mut entries = 0;
for storage in &storages {
entries += device.list(storage.id, ROOT)?.len();
}
println!(
"{}",
json!({"cycle":cycle,"stage":"readable","storages":storages.len(),"rootEntries":entries,"seconds":opened.elapsed().as_secs_f64()})
);
println!("{}", json!({"cycle":cycle,"stage":"closing"}));
let closed = Instant::now();
drop(device);
println!(
"{}",
json!({"cycle":cycle,"stage":"closed","seconds":closed.elapsed().as_secs_f64()})
);
std::thread::sleep(std::time::Duration::from_secs(1));
}
println!("{}", json!({"passed":true,"cycles":3,"writes":false}));
Ok(())
}
@@ -0,0 +1,366 @@
use crate::connector::{available_name, parent_exists, valid_name};
use crate::{model::*, mtp::DeviceIo};
use serde_json::{json, Value};
use std::{
collections::{HashMap, HashSet},
ffi::CString,
fs::{self, OpenOptions},
os::{unix::ffi::OsStrExt, unix::fs::OpenOptionsExt},
path::{Path, PathBuf},
};
const MAX_ENTRIES: usize = 100_000;
const MAX_DEPTH: usize = 128;
struct Node {
relative: PathBuf,
folder: bool,
size: u64,
object: u32,
}
fn cancelled(progress: &Progress) -> Result<()> {
if progress.cancelled() {
Err(Fault::new(
"transfer.cancelled",
"传输已取消。手机可能保留已发送的文件,请检查后再重试。",
))
} else {
Ok(())
}
}
fn bound(nodes: &[Node], depth: usize) -> Result<()> {
if nodes.len() >= MAX_ENTRIES || depth > MAX_DEPTH {
return Err(Fault::new(
"folder.too_large",
"文件夹条目过多或层级过深,请分批传输。",
));
}
Ok(())
}
fn name(path: &Path) -> Result<&str> {
let name = path
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| Fault::new("file.invalid_name", "文件名不是有效的 UTF-8。"))?;
valid_name(name)?;
Ok(name)
}
fn local_plan(
root: &Path,
relative: &Path,
nodes: &mut Vec<Node>,
progress: &Progress,
) -> Result<()> {
cancelled(progress)?;
bound(nodes, relative.components().count())?;
let path = if relative.as_os_str().is_empty() {
root.to_path_buf()
} else {
root.join(relative)
};
let metadata = path.symlink_metadata()?;
if !metadata.is_file() && !metadata.is_dir() {
return Err(Fault::new(
"file.not_regular",
"文件夹包含符号链接或特殊文件,请移除这些条目后再传输。",
));
}
if !relative.as_os_str().is_empty() {
name(relative)?;
}
nodes.push(Node {
relative: relative.into(),
folder: metadata.is_dir(),
size: if metadata.is_file() {
metadata.len()
} else {
0
},
object: 0,
});
if metadata.is_dir() {
let mut children = fs::read_dir(path)?.collect::<std::io::Result<Vec<_>>>()?;
children.sort_by_key(|entry| entry.file_name());
let mut names = HashSet::new();
for child in children {
let relative = relative.join(child.file_name());
if !names.insert(name(&relative)?.to_lowercase()) {
return Err(Fault::new(
"file.exists",
"文件夹中有仅大小写不同的同名条目,无法安全传输。",
));
}
local_plan(root, &relative, nodes, progress)?;
}
}
Ok(())
}
fn remote_plan(
device: &mut dyn DeviceIo,
entry: Entry,
relative: PathBuf,
nodes: &mut Vec<Node>,
seen: &mut HashSet<u32>,
progress: &Progress,
) -> Result<()> {
cancelled(progress)?;
bound(nodes, relative.components().count())?;
valid_name(&entry.name)?;
if !seen.insert(entry.id) {
return Err(Fault::new(
"folder.invalid_tree",
"设备返回了重复或循环的文件夹条目。",
));
}
nodes.push(Node {
relative: relative.clone(),
folder: entry.folder,
size: if entry.folder { 0 } else { entry.size },
object: entry.id,
});
if entry.folder {
let mut names = HashSet::new();
for child in device.list(entry.storage, entry.id)? {
valid_name(&child.name)?;
if child.parent != entry.id
|| child.storage != entry.storage
|| !names.insert(child.name.to_lowercase())
{
return Err(Fault::new(
"folder.invalid_tree",
"设备返回了冲突或不属于当前目录的条目。",
));
}
let path = relative.join(&child.name);
remote_plan(device, child, path, nodes, seen, progress)?;
}
}
Ok(())
}
fn totals(nodes: &[Node]) -> Result<(u64, u64)> {
let total = nodes
.iter()
.try_fold(0u64, |sum, n| sum.checked_add(n.size))
.ok_or_else(|| Fault::new("folder.too_large", "文件夹总大小超出支持范围。"))?;
Ok((total, nodes.iter().filter(|n| !n.folder).count() as u64))
}
pub fn run(device: &mut dyn DeviceIo, transfer: Transfer, progress: &Progress) -> Result<Value> {
cancelled(progress)?;
match transfer {
Transfer::Upload {
path,
storage,
parent,
} => upload(device, Path::new(&path), storage, parent, progress),
Transfer::Download { object, directory } => {
download(device, object, Path::new(&directory), progress)
}
}
}
fn upload(
device: &mut dyn DeviceIo,
selected: &Path,
storage: u32,
parent: u32,
progress: &Progress,
) -> Result<Value> {
if !selected.is_absolute() {
return Err(Fault::new(
"file.invalid_path",
"请选择 Mac 上的文件或文件夹。",
));
}
let label = name(selected)?.to_owned();
if selected.symlink_metadata()?.file_type().is_symlink() {
return Err(Fault::new(
"file.not_regular",
"请选择原始文件或文件夹,不支持符号链接。",
));
}
let source = selected.canonicalize()?;
parent_exists(device, storage, parent)?;
available_name(device, storage, parent, &label)?;
let mut nodes = Vec::new();
local_plan(&source, Path::new(""), &mut nodes, progress)?;
let (total, files) = totals(&nodes)?;
let capacity = device
.storages()?
.into_iter()
.find(|s| s.id == storage)
.ok_or_else(|| Fault::new("storage.not_found", "存储位置已不可用。"))?;
if capacity.free != u64::MAX && total > capacity.free {
return Err(Fault::new("storage.full", "手机剩余空间不足。"));
}
progress.prepare(total, files);
let mut folders = HashMap::new();
let mut root_object = 0;
for node in &nodes {
cancelled(progress)?;
let is_root = node.relative.as_os_str().is_empty();
let target_parent = if is_root {
parent
} else {
*folders
.get(node.relative.parent().unwrap())
.ok_or_else(|| Fault::new("folder.invalid_tree", "无法定位目标文件夹。"))?
};
let label = if is_root {
label.as_str()
} else {
name(&node.relative)?
};
// Check each destination again immediately before mutation; never merge existing trees.
available_name(device, storage, target_parent, label)?;
let object = if node.folder {
let id = device.mkdir(label, storage, target_parent)?;
folders.insert(node.relative.clone(), id);
id
} else {
let path = if is_root {
source.clone()
} else {
source.join(&node.relative)
};
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW_ANY)
.open(path)?;
let metadata = file.metadata()?;
if !metadata.is_file() || metadata.len() != node.size {
return Err(Fault::new("file.changed", "源文件已改变,请重新开始传输。"));
}
progress.begin_file(&if is_root {
label.to_owned()
} else {
node.relative.display().to_string()
});
let outcome = device.upload(&file, label, node.size, storage, target_parent, progress);
cancelled(progress)?;
let object = outcome?;
if device.metadata(object)?.size != node.size || file.metadata()?.len() != node.size {
return Err(Fault::new(
"mtp.size_mismatch",
"传输后文件大小不一致,请检查手机上的文件。",
));
}
progress.update(node.size, node.size);
progress.complete_file();
object
};
if is_root {
root_object = object;
}
}
Ok(
json!({"object":root_object,"name":label,"bytes":total,"files":files,"folder":nodes[0].folder}),
)
}
fn save_file(
device: &mut dyn DeviceIo,
node: &Node,
destination: &Path,
progress: &Progress,
) -> Result<()> {
cancelled(progress)?;
let temporary = tempfile::Builder::new()
.prefix(".harmony-")
.suffix(".part")
.tempfile_in(destination.parent().unwrap())?;
progress.begin_file(&node.relative.display().to_string());
let outcome = device.download(node.object, temporary.as_file(), progress);
cancelled(progress)?;
outcome?;
if temporary.as_file().metadata()?.len() != node.size {
return Err(Fault::new(
"mtp.size_mismatch",
"接收到的文件不完整,请重新连接后重试。",
));
}
temporary.as_file().sync_all()?;
temporary.persist_noclobber(destination).map_err(|e| {
Fault::new(
"file.save_failed",
format!("无法保存文件(不会覆盖已有文件):{}", e.error),
)
})?;
progress.update(node.size, node.size);
progress.complete_file();
Ok(())
}
fn download(
device: &mut dyn DeviceIo,
object: u32,
selected: &Path,
progress: &Progress,
) -> Result<Value> {
if !selected.is_absolute() || !selected.is_dir() {
return Err(Fault::new(
"file.invalid_directory",
"请选择 Mac 上的接收文件夹。",
));
}
let directory = selected.canonicalize()?;
let entry = device.metadata(object)?;
valid_name(&entry.name)?;
let destination = directory.join(&entry.name);
if destination.symlink_metadata().is_ok() {
return Err(Fault::new(
"file.exists",
"Mac 上已有同名条目。请选择其他接收文件夹。",
));
}
let folder = entry.folder;
let label = entry.name.clone();
let mut nodes = Vec::new();
remote_plan(
device,
entry,
if folder {
PathBuf::new()
} else {
PathBuf::from(&label)
},
&mut nodes,
&mut HashSet::new(),
progress,
)?;
let (total, files) = totals(&nodes)?;
progress.prepare(total, files);
if folder {
// Publish only a complete directory. Failure/cancellation drops this staging tree.
let staging = tempfile::Builder::new()
.prefix(".harmony-")
.suffix(".part")
.tempdir_in(&directory)?;
for node in nodes.iter().skip(1) {
cancelled(progress)?;
let path = staging.path().join(&node.relative);
if node.folder {
fs::create_dir(&path)?;
} else {
save_file(device, node, &path, progress)?;
}
}
cancelled(progress)?;
publish_directory(staging.path(), &destination)?;
} else {
save_file(device, &nodes[0], &destination, progress)?;
}
Ok(json!({"path":destination,"name":label,"bytes":total,"files":files,"folder":folder}))
}
pub fn publish_directory(source: &Path, destination: &Path) -> Result<()> {
let from = CString::new(source.as_os_str().as_bytes())
.map_err(|_| Fault::new("file.invalid_path", "路径无效。"))?;
let to = CString::new(destination.as_os_str().as_bytes())
.map_err(|_| Fault::new("file.invalid_path", "路径无效。"))?;
if unsafe { libc::renamex_np(from.as_ptr(), to.as_ptr(), libc::RENAME_EXCL) } != 0 {
return Err(Fault::new(
"file.save_failed",
format!(
"无法保存文件夹(不会覆盖已有条目):{}",
std::io::Error::last_os_error()
),
));
}
Ok(())
}
+526
View File
@@ -0,0 +1,526 @@
mod files;
use crate::{
command, hdc_data,
model::{Fault, Result},
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::{
env, fs,
os::unix::fs::PermissionsExt,
path::{Path, PathBuf},
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Mutex,
},
thread,
time::Duration,
};
struct Guard(Arc<AtomicBool>);
impl Drop for Guard {
fn drop(&mut self) {
self.0.store(false, Ordering::Release);
}
}
pub struct Manager {
path: Mutex<Option<PathBuf>>,
settings: Option<PathBuf>,
busy: Arc<AtomicBool>,
job: Mutex<Value>,
cancel: Arc<AtomicBool>,
next: AtomicU64,
stopped: AtomicBool,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Target {
target: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Application {
target: String,
bundle: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LocalPath {
path: String,
}
#[derive(Clone, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
enum Action {
Install {
target: String,
path: String,
sha256: String,
},
Uninstall {
target: String,
bundle: String,
},
Launch {
target: String,
bundle: String,
module: String,
ability: String,
},
Stop {
target: String,
bundle: String,
},
Screenshot {
target: String,
directory: String,
},
Logs {
target: String,
directory: String,
},
Report {
target: String,
directory: String,
},
ExportDebug {
target: String,
bundle: String,
directory: String,
},
}
fn parse<T: serde::de::DeserializeOwned>(value: Value) -> Result<T> {
serde_json::from_value(value)
.map_err(|_| Fault::new("request.invalid_params", "管理操作参数无效。"))
}
fn executable(path: &Path) -> bool {
path.is_absolute()
&& path.is_file()
&& path
.metadata()
.is_ok_and(|m| m.permissions().mode() & 0o111 != 0)
}
fn discover() -> Option<PathBuf> {
let mut candidates = Vec::new();
if let Some(path) = env::var_os("PATH") {
candidates.extend(env::split_paths(&path).map(|p| p.join("hdc")));
}
for root in [
Some(PathBuf::from("/Applications")),
env::var_os("HOME").map(|p| PathBuf::from(p).join("Applications")),
]
.into_iter()
.flatten()
{
if let Ok(entries) = fs::read_dir(root) {
for entry in entries.flatten().filter(|e| {
e.file_name()
.to_string_lossy()
.to_lowercase()
.contains("deveco")
}) {
candidates.push(
entry
.path()
.join("Contents/sdk/default/openharmony/toolchains/hdc"),
);
}
}
}
candidates
.into_iter()
.find(|p| executable(p))
.and_then(|p| p.canonicalize().ok())
}
struct Tool {
path: PathBuf,
cancel: Arc<AtomicBool>,
}
impl Tool {
fn run(&self, args: &[&str], seconds: u64) -> Result<String> {
let output = self.run_output(args, seconds)?;
command::check_remote_errors(&output)?;
Ok(output)
}
fn run_output(&self, args: &[&str], seconds: u64) -> Result<String> {
command::run(
&self.path,
&args.iter().map(|s| (*s).into()).collect::<Vec<_>>(),
Duration::from_secs(seconds),
&self.cancel,
16 * 1024 * 1024,
)
}
fn shell(&self, target: &str, command: &str) -> Result<String> {
let output = self.run(&["-t", hdc_data::target(target)?, "shell", command], 15)?;
if output.len() > 2 * 1024 * 1024 {
return Err(Fault::new("hdc.output_too_large", "设备信息超过支持范围。"));
}
Ok(output)
}
fn targets(&self) -> Result<Vec<Value>> {
hdc_data::devices(&self.run(&["list", "targets", "-v"], 10)?)
}
fn connected(&self, target: &str) -> Result<()> {
hdc_data::target(target)?;
let device = self
.targets()?
.into_iter()
.find(|d| d["id"] == target)
.ok_or_else(|| Fault::new("hdc.device_missing", "管理设备已断开,请刷新列表。"))?;
if device["state"] == "Unauthorized" {
return Err(Fault::new(
"hdc.unauthorized",
"请解锁手机并允许这台 Mac 的 USB 调试。",
));
}
if device["state"] != "Connected" {
return Err(Fault::new(
"hdc.device_offline",
"管理设备尚未就绪,请检查手机调试连接。",
));
}
Ok(())
}
fn apps(&self, target: &str) -> Result<Vec<String>> {
hdc_data::apps(&self.shell(target, "bm dump -a")?)
}
fn app(&self, target: &str, bundle: &str) -> Result<Value> {
let bundle = hdc_data::identifier(bundle)?;
hdc_data::app(
&self.shell(target, &format!("bm dump -n {bundle}"))?,
bundle,
)
}
}
impl Manager {
pub fn new() -> Arc<Self> {
let settings =
env::var_os("TROVE_DATA_DIR").map(|p| PathBuf::from(p).join("harmony-tools.json"));
let path = settings
.as_ref()
.and_then(|p| fs::read(p).ok())
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|v| v["hdcPath"].as_str().map(PathBuf::from))
.filter(|p| executable(p))
.or_else(discover);
Arc::new(Self {
path: Mutex::new(path),
settings,
busy: Arc::new(AtomicBool::new(false)),
job: Mutex::new(Value::Null),
cancel: Arc::new(AtomicBool::new(false)),
next: AtomicU64::new(1),
stopped: AtomicBool::new(false),
})
}
fn tool(&self) -> Result<Tool> {
let path = self
.path
.lock()
.unwrap()
.clone()
.filter(|p| executable(p))
.ok_or_else(|| {
Fault::new(
"hdc.not_configured",
"请先选择 DevEco Studio 或官方 Command Line Tools 中的 hdc。",
)
})?;
Ok(Tool {
path,
cancel: self.cancel.clone(),
})
}
fn enter(&self) -> Result<Guard> {
self.busy
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
.map_err(|_| Fault::new("hdc.busy", "上一项管理操作尚未结束。"))?;
let guard = Guard(self.busy.clone());
self.cancel.store(false, Ordering::Release);
if self.stopped.load(Ordering::Acquire) {
self.cancel.store(true, Ordering::Release);
return Err(Fault::new("device.stopping", "管理连接正在关闭。"));
}
Ok(guard)
}
pub fn stop(&self) {
self.stopped.store(true, Ordering::Release);
self.cancel.store(true, Ordering::Release);
}
pub fn call(self: &Arc<Self>, method: &str, value: Value) -> Result<Value> {
if method == "status" {
return Ok(
json!({"busy":self.busy.load(Ordering::Acquire),"job":self.job.lock().unwrap().clone()}),
);
}
if method == "cancel" {
let id = value["id"]
.as_u64()
.ok_or_else(|| Fault::new("request.invalid_params", "缺少任务标识。"))?;
let job = self.job.lock().unwrap();
if job["id"] != id {
return Err(Fault::new("operation.not_found", "任务已不存在。"));
}
if job["state"] == "running" {
self.cancel.store(true, Ordering::Release);
}
return Ok(json!({"requested":job["state"] == "running"}));
}
let guard = self.enter()?;
match method {
"tool" => {
let path = self.path.lock().unwrap().clone();
let Some(path) = path else {
return Ok(json!({"available":false}));
};
let version = command::run(
&path,
&["-v".into()],
Duration::from_secs(5),
&self.cancel,
8192,
)?;
Ok(json!({"available":true,"path":path,"version":version.trim()}))
}
"configure" => {
let selected: LocalPath = parse(value)?;
let path = PathBuf::from(selected.path);
if !executable(&path) {
return Err(Fault::new(
"hdc.invalid_tool",
"请选择可执行的官方 hdc 文件。",
));
}
let path = path.canonicalize()?;
let version = command::run(
&path,
&["-v".into()],
Duration::from_secs(5),
&self.cancel,
8192,
)?;
if !version.contains("Ver:") && !version.to_lowercase().contains("hdc") {
return Err(Fault::new(
"hdc.invalid_tool",
"所选文件没有返回 HDC 版本信息。",
));
}
if let Some(settings) = &self.settings {
fs::create_dir_all(settings.parent().unwrap())?;
let temporary = settings.with_extension("tmp");
fs::write(
&temporary,
serde_json::to_vec(&json!({"hdcPath":path})).unwrap(),
)?;
fs::rename(temporary, settings)?;
}
*self.path.lock().unwrap() = Some(path.clone());
Ok(json!({"available":true,"path":path,"version":version.trim()}))
}
"devices" => Ok(json!({"devices":self.tool()?.targets()?})),
"inspect" => {
let request: Target = parse(value)?;
let tool = self.tool()?;
tool.connected(&request.target)?;
let mut capabilities = serde_json::Map::new();
for (key, cmd, needle) in [
("apps", "bm help", "dump"),
("launch", "aa help", "start"),
("screenshot", "uitest help", "screenCap"),
("logs", "hilog -h", "-x"),
] {
let result = tool.shell(&request.target, cmd);
let (available, reason) = match result {
Ok(text) if text.contains(needle) => (true, String::new()),
Ok(_) => (false, "设备工具未声明此能力".into()),
Err(e) => (false, e.message),
};
capabilities.insert(key.into(), json!({"available":available,"reason":reason}));
}
let info = tool.shell(&request.target, "hidumper -c base");
let info = match info {
Ok(text) if !text.trim().is_empty() => json!({"available":true,"text":text}),
Ok(_) => json!({"available":false,"reason":"设备未返回基本信息"}),
Err(e) => json!({"available":false,"reason":e.message}),
};
let report_help = tool.run(&["-h", "verbose"], 5).unwrap_or_default();
capabilities.insert(
"report".into(),
json!({"available":report_help.contains("bugreport"),"reason":""}),
);
Ok(json!({"target":request.target,"capabilities":capabilities,"info":info}))
}
"apps" => {
let r: Target = parse(value)?;
let tool = self.tool()?;
tool.connected(&r.target)?;
Ok(json!({"apps":tool.apps(&r.target)?}))
}
"app" => {
let r: Application = parse(value)?;
let tool = self.tool()?;
tool.connected(&r.target)?;
tool.app(&r.target, &r.bundle)
}
"package" => {
let r: LocalPath = parse(value)?;
files::package(Path::new(&r.path))
}
"start" => {
let action: Action = parse(value)?;
let tool = self.tool()?;
let id = self.next.fetch_add(1, Ordering::Relaxed);
*self.job.lock().unwrap() =
json!({"id":id,"state":"running","kind":value_kind(&action)});
let owner = self.clone();
thread::spawn(move || {
let _guard = guard;
let result = tool.execute(action);
*owner.job.lock().unwrap() = match result {
Ok(value) => json!({"id":id,"state":"completed","result":value}),
Err(error) => {
json!({"id":id,"state":if error.code == "operation.cancelled" {"cancelled"} else {"failed"},"error":error})
}
};
});
Ok(json!({"id":id}))
}
_ => Err(Fault::new("service.method_not_found", "未知管理操作。")),
}
}
}
fn value_kind(action: &Action) -> &'static str {
match action {
Action::Install { .. } => "install",
Action::Uninstall { .. } => "uninstall",
Action::Launch { .. } => "launch",
Action::Stop { .. } => "stop",
Action::Screenshot { .. } => "screenshot",
Action::Logs { .. } => "logs",
Action::Report { .. } => "report",
Action::ExportDebug { .. } => "exportDebug",
}
}
impl Tool {
fn execute(&self, action: Action) -> Result<Value> {
let target = match &action {
Action::Install { target, .. }
| Action::Uninstall { target, .. }
| Action::Launch { target, .. }
| Action::Stop { target, .. }
| Action::Screenshot { target, .. }
| Action::Logs { target, .. }
| Action::Report { target, .. }
| Action::ExportDebug { target, .. } => target,
};
self.connected(target)?;
match action {
Action::Install {
target,
path,
sha256,
} => {
let snapshot = tempfile::Builder::new()
.prefix("harmony-install-")
.tempdir()?;
let source = Path::new(&path);
files::package(source)?;
let copy = snapshot.path().join("application.hap");
fs::copy(source, &copy)?;
let metadata = files::package(&copy)?;
if metadata["sha256"] != sha256 {
return Err(Fault::new(
"file.changed",
"安装包已改变,请重新选择并确认。",
));
}
self.run(&["-t", &target, "install", copy.to_str().unwrap()], 300)?;
let bundle = metadata["bundle"].as_str().unwrap();
let installed = self.app(&target, bundle)?;
if installed["versionCode"] != metadata["versionCode"] {
return Err(Fault::new(
"hdc.install_unverified",
"安装后版本与所选包不一致,请检查手机上的实际状态。",
));
}
Ok(json!({"message":"应用已安装","application":installed}))
}
Action::Uninstall { target, bundle } => {
let details = self.app(&target, &bundle)?;
if details["details"]["applicationInfo"]["isSystemApp"] == true {
return Err(Fault::new("hdc.system_app", "不支持移除系统应用。"));
}
self.run(
&["-t", &target, "uninstall", hdc_data::identifier(&bundle)?],
90,
)?;
if self.apps(&target)?.contains(&bundle) {
return Err(Fault::new(
"hdc.uninstall_unverified",
"应用仍出现在设备列表中,请检查手机上的状态。",
));
}
Ok(json!({"message":"应用已卸载","bundle":bundle}))
}
Action::Launch {
target,
bundle,
module,
ability,
} => {
hdc_data::identifier(&module)?;
hdc_data::identifier(&ability)?;
let details = self.app(&target, &bundle)?;
if !details["abilities"]
.as_array()
.unwrap()
.iter()
.any(|a| a["name"] == ability && a["module"] == module)
{
return Err(Fault::new(
"hdc.ability_unavailable",
"该应用未开放所选启动组件。",
));
}
let output = self.shell(
&target,
&format!("aa start -b {bundle} -a {ability} -m {module}"),
)?;
if !output.to_lowercase().contains("success") {
return Err(Fault::new(
"hdc.launch_unverified",
format!("设备未确认启动成功:{output}"),
));
}
Ok(json!({"message":"启动命令已成功执行","bundle":bundle}))
}
Action::Stop { target, bundle } => {
hdc_data::identifier(&bundle)?;
self.app(&target, &bundle)?;
let output = self.shell(&target, &format!("aa force-stop {bundle}"))?;
if !output.to_lowercase().contains("success") {
return Err(Fault::new(
"hdc.stop_unverified",
format!("设备未确认停止成功:{output}"),
));
}
Ok(json!({"message":"应用已强制停止","bundle":bundle}))
}
Action::Screenshot { target, directory } => {
files::screenshot(self, &target, Path::new(&directory))
}
Action::Logs { target, directory } => files::logs(self, &target, Path::new(&directory)),
Action::Report { target, directory } => {
files::report(self, &target, Path::new(&directory))
}
Action::ExportDebug {
target,
bundle,
directory,
} => files::export_debug(self, &target, &bundle, Path::new(&directory)),
}
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,298 @@
use super::*;
use sha2::{Digest, Sha256};
use std::{
io::{Read, Seek, Write},
time::{SystemTime, UNIX_EPOCH},
};
fn hash(path: &Path) -> Result<String> {
let mut file = fs::File::open(path)?;
hash_reader(&mut file)
}
fn hash_reader(file: &mut impl Read) -> Result<String> {
let mut digest = Sha256::new();
let mut buffer = [0u8; 64 * 1024];
loop {
let size = file.read(&mut buffer)?;
if size == 0 {
break;
}
digest.update(&buffer[..size]);
}
Ok(format!("{:x}", digest.finalize()))
}
pub(super) fn package(path: &Path) -> Result<Value> {
if !path.is_absolute()
|| !path.is_file()
|| path
.extension()
.and_then(|v| v.to_str())
.is_none_or(|v| !v.eq_ignore_ascii_case("hap"))
{
return Err(Fault::new(
"hdc.invalid_package",
"请选择已签名的 .hap 安装包。",
));
}
let mut file = fs::File::open(path)?;
let size = file.metadata()?.len();
let digest = hash_reader(&mut file)?;
file.rewind()?;
let mut zip = zip::ZipArchive::new(file)
.map_err(|_| Fault::new("hdc.invalid_package", "安装包不是有效的 HAP 归档。"))?;
let module = zip
.by_name("module.json")
.map_err(|_| Fault::new("hdc.invalid_package", "HAP 中没有可识别的 module.json。"))?;
if module.size() > 1024 * 1024 {
return Err(Fault::new("hdc.invalid_package", "安装包清单过大。"));
}
let mut bytes = Vec::new();
module.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
let metadata: Value = serde_json::from_slice(&bytes)
.map_err(|_| Fault::new("hdc.invalid_package", "无法读取 HAP 应用清单。"))?;
let app = &metadata["app"];
let bundle = app["bundleName"]
.as_str()
.ok_or_else(|| Fault::new("hdc.invalid_package", "HAP 缺少应用包名。"))?;
hdc_data::identifier(bundle)?;
let version = app["versionCode"]
.as_u64()
.ok_or_else(|| Fault::new("hdc.invalid_package", "HAP 缺少版本号。"))?;
Ok(
json!({"bundle":bundle,"versionCode":version,"versionName":app["versionName"],"sha256":digest,"size":size}),
)
}
fn directory(path: &Path) -> Result<PathBuf> {
if !path.is_absolute() || !path.is_dir() {
return Err(Fault::new(
"file.invalid_directory",
"请选择 Mac 上的保存文件夹。",
));
}
Ok(path.canonicalize()?)
}
fn stamp() -> String {
format!(
"{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
)
}
fn save(mut temporary: tempfile::NamedTempFile, destination: &Path) -> Result<Value> {
let size = temporary.as_file().metadata()?.len();
if size == 0 {
return Err(Fault::new(
"hdc.empty_result",
"设备没有返回文件内容,未生成成功记录。",
));
}
temporary.flush()?;
temporary.as_file().sync_all()?;
let digest = hash(temporary.path())?;
temporary.persist_noclobber(destination).map_err(|e| {
Fault::new(
"file.save_failed",
format!("无法保存文件(不会覆盖已有文件):{}", e.error),
)
})?;
Ok(json!({"path":destination,"bytes":size,"sha256":digest}))
}
pub(super) fn logs(tool: &Tool, target: &str, selected: &Path) -> Result<Value> {
let directory = directory(selected)?;
let content = tool.run_output(&["-t", target, "shell", "hilog -x"], 15)?;
let mut temporary = tempfile::NamedTempFile::new_in(&directory)?;
temporary.write_all(content.as_bytes())?;
save(
temporary,
&directory.join(format!("harmony-logs-{}.txt", stamp())),
)
}
pub(super) fn report(tool: &Tool, target: &str, selected: &Path) -> Result<Value> {
let directory = directory(selected)?;
let temporary = tempfile::NamedTempFile::new_in(&directory)?;
tool.run(
&[
"-t",
target,
"bugreport",
temporary.path().to_str().unwrap(),
],
180,
)?;
save(
temporary,
&directory.join(format!("harmony-report-{}.txt", stamp())),
)
}
pub(super) fn screenshot(tool: &Tool, target: &str, selected: &Path) -> Result<Value> {
let directory = directory(selected)?;
let temporary = tempfile::NamedTempFile::new_in(&directory)?;
// Tempfile's random suffix is also used to avoid clobbering a preexisting device screenshot.
let nonce = temporary
.path()
.file_name()
.unwrap()
.to_string_lossy()
.replace('.', "");
let remote = format!("/data/local/tmp/harmony-{}-{nonce}.png", stamp());
let result = (|| {
tool.shell(target, &format!("uitest screenCap -p {remote}"))?;
tool.run(
&[
"-t",
target,
"file",
"recv",
&remote,
temporary.path().to_str().unwrap(),
],
90,
)?;
let decoder = png::Decoder::new(std::io::BufReader::new(fs::File::open(temporary.path())?));
let mut reader = decoder
.read_info()
.map_err(|_| Fault::new("hdc.invalid_screenshot", "设备没有返回有效 PNG 截图。"))?;
let size = reader
.output_buffer_size()
.filter(|size| *size <= 64 * 1024 * 1024)
.ok_or_else(|| Fault::new("hdc.invalid_screenshot", "截图尺寸超过支持范围。"))?;
reader.next_frame(&mut vec![0; size]).map_err(|_| {
Fault::new(
"hdc.invalid_screenshot",
"截图数据不完整,未保存为成功结果。",
)
})?;
save(
temporary,
&directory.join(format!("harmony-screenshot-{}.png", stamp())),
)
})();
// Cleanup only the file created by this operation. Cancellation must not block cleanup.
let cleanup = Tool {
path: tool.path.clone(),
cancel: Arc::new(AtomicBool::new(false)),
};
let _ = cleanup.shell(target, &format!("rm -f {remote}"));
result
}
fn manifest_files(
root: &Path,
path: &Path,
entries: &mut Vec<Value>,
cancel: &AtomicBool,
) -> Result<()> {
if cancel.load(Ordering::Acquire) {
return Err(Fault::new("operation.cancelled", "数据导出已取消。"));
}
if entries.len() >= 100_000 || path.strip_prefix(root).unwrap().components().count() > 128 {
return Err(Fault::new(
"hdc.backup_too_large",
"数据条目过多,请改用按文件夹导出。",
));
}
let metadata = path.symlink_metadata()?;
if metadata.is_dir() {
entries.push(json!({"path":path.strip_prefix(root).unwrap().to_string_lossy(),"folder":true,"size":0}));
for entry in fs::read_dir(path)? {
manifest_files(root, &entry?.path(), entries, cancel)?;
}
} else if metadata.is_file() {
entries.push(json!({"path":path.strip_prefix(root).unwrap().to_string_lossy(),"size":metadata.len(),"sha256":hash(path)?}));
} else {
return Err(Fault::new(
"hdc.invalid_backup",
"导出结果包含符号链接或特殊文件,未保存为完整副本。",
));
}
Ok(())
}
pub(super) fn export_debug(
tool: &Tool,
target: &str,
bundle: &str,
selected: &Path,
) -> Result<Value> {
let directory = directory(selected)?;
let application = tool.app(target, bundle)?;
if application["debug"] != true {
return Err(Fault::new(
"hdc.backup_unavailable",
"该应用不是调试签名应用,官方 HDC 不开放其私有数据。可在手机系统备份中处理。",
));
}
const REMOTE: &str = "data/storage/el2/base";
tool.run(
&[
"-t",
target,
"shell",
"-b",
bundle,
"ls data/storage/el2/base/",
],
15,
)?;
let staging = tempfile::Builder::new()
.prefix(".harmony-backup-")
.tempdir_in(&directory)?;
tool.run(
&[
"-t",
target,
"file",
"recv",
"-b",
bundle,
REMOTE,
staging.path().to_str().unwrap(),
],
600,
)?;
let content = staging.path().join("base");
if !content.is_dir() {
return Err(Fault::new(
"hdc.invalid_backup",
"未收到完整的应用数据目录,未生成备份。",
));
}
let mut entries = Vec::new();
manifest_files(staging.path(), &content, &mut entries, &tool.cancel)?;
entries.sort_by(|a, b| a["path"].as_str().cmp(&b["path"].as_str()));
let bytes: u64 = entries.iter().map(|v| v["size"].as_u64().unwrap()).sum();
let manifest = json!({"schemaVersion":1,"kind":"harmony-debug-data-export","target":target,"application":application,"sourcePath":REMOTE,"createdAtUnixMillis":stamp(),"consistency":"live-file-copy","files":entries});
fs::write(
staging.path().join("manifest.json"),
serde_json::to_vec_pretty(&manifest).unwrap(),
)?;
let destination = directory.join(format!("{bundle}-debug-data-{}", stamp()));
crate::file_transfer::publish_directory(staging.path(), &destination)?;
Ok(
json!({"path":destination,"bytes":bytes,"files":entries.iter().filter(|e| e["folder"] != true).count(),"message":"调试应用数据副本已保存;不包含全部应用状态,不保证运行中数据库的一致性。"}),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_hap_identity_before_installation_and_rejects_unrelated_archives() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("app.hap");
let mut zip = zip::ZipWriter::new(fs::File::create(&path).unwrap());
zip.start_file("module.json", zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(
br#"{"app":{"bundleName":"com.example.app","versionCode":3,"versionName":"1.2"}}"#,
)
.unwrap();
zip.finish().unwrap();
let metadata = package(&path).unwrap();
assert_eq!(metadata["bundle"], "com.example.app");
assert_eq!(metadata["versionCode"], 3);
assert_eq!(metadata["sha256"].as_str().unwrap().len(), 64);
fs::write(&path, b"not a hap").unwrap();
assert!(package(&path).is_err());
}
}
@@ -0,0 +1,148 @@
use super::*;
use std::{io::Write, time::Instant};
fn fixture(debug: bool) -> (tempfile::TempDir, Arc<Manager>) {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("hdc");
let app = json!({"name":"com.example.app","versionCode":3,"versionName":"1.2","applicationInfo":{"appProvisionType":if debug {"debug"} else {"release"}},"hapModuleInfos":[{"name":"entry","abilityInfos":[{"bundleName":"com.example.app","moduleName":"entry","name":"Home","type":1,"visible":true}]}]});
fs::write(&path, format!(r#"#!/bin/sh
printf '%s\n' "$*" >> "$0.calls"
case "$*" in
'list targets -v') printf 'phone USB Connected device\n' ;;
'-t phone shell bm dump -a') printf 'ID: 100:\n com.example.app\n' ;;
'-t phone shell bm dump -n com.example.app') printf '%s\n' 'com.example.app:' '{app}' ;;
'-t phone shell aa start -b com.example.app -a Home -m entry') printf 'start ability successfully.\n' ;;
'-t phone shell aa force-stop com.example.app') printf 'force stop process successfully.\n' ;;
'-t phone shell hilog -x') printf 'error: user log content, not command failure\n' ;;
'-t phone install '*|'-t phone uninstall '*) printf 'success\n' ;;
'-t phone shell -b com.example.app ls data/storage/el2/base/') printf 'files\n' ;;
'-t phone file recv -b '*) printf 'success but no actual files\n' ;;
*) printf '[Fail] unsupported\n' ;;
esac
"#)).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
let manager = Arc::new(Manager {
path: Mutex::new(Some(path)),
settings: None,
busy: Arc::new(AtomicBool::new(false)),
job: Mutex::new(Value::Null),
cancel: Arc::new(AtomicBool::new(false)),
next: AtomicU64::new(1),
stopped: AtomicBool::new(false),
});
(directory, manager)
}
fn wait(manager: &Arc<Manager>, action: Value) -> Value {
manager.call("start", action).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let status = manager.call("status", json!({})).unwrap();
if status["busy"] == false {
return status["job"].clone();
}
assert!(Instant::now() < deadline);
thread::sleep(Duration::from_millis(5));
}
}
fn hap(path: &Path) {
let mut zip = zip::ZipWriter::new(fs::File::create(path).unwrap());
zip.start_file("module.json", zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(
br#"{"app":{"bundleName":"com.example.app","versionCode":3,"versionName":"1.2"}}"#,
)
.unwrap();
zip.finish().unwrap();
}
#[test]
fn installs_confirmed_package_and_verifies_actual_device_version() {
let (directory, manager) = fixture(false);
let path = directory.path().join("signed.hap");
hap(&path);
let package = manager.call("package", json!({"path":path})).unwrap();
let action = json!({"kind":"install","target":"phone","path":path,"sha256":package["sha256"]});
let result = wait(&manager, action.clone());
assert_eq!(result["state"], "completed", "{result}");
assert_eq!(result["result"]["application"]["versionCode"], 3);
fs::write(&path, b"changed package").unwrap();
assert_eq!(wait(&manager, action)["state"], "failed");
let calls = fs::read_to_string(directory.path().join("hdc.calls")).unwrap();
assert_eq!(
calls
.lines()
.filter(|line| line.starts_with("-t phone install "))
.count(),
1
);
}
#[test]
fn failed_uninstall_postcondition_is_not_reported_as_success() {
let (_directory, manager) = fixture(false);
let result = wait(
&manager,
json!({"kind":"uninstall","target":"phone","bundle":"com.example.app"}),
);
assert_eq!(result["state"], "failed");
assert_eq!(result["error"]["code"], "hdc.uninstall_unverified");
}
#[test]
fn launch_uses_real_exported_component_and_rejects_invented_or_injected_names() {
let (directory, manager) = fixture(false);
let action = |ability| json!({"kind":"launch","target":"phone","bundle":"com.example.app","module":"entry","ability":ability});
assert_eq!(wait(&manager, action("Home"))["state"], "completed");
assert_eq!(
wait(&manager, action("EntryAbility"))["error"]["code"],
"hdc.ability_unavailable"
);
assert_eq!(wait(&manager, action("Home;reboot"))["state"], "failed");
let calls = fs::read_to_string(directory.path().join("hdc.calls")).unwrap();
assert_eq!(
calls
.lines()
.filter(|line| line.contains("aa start"))
.count(),
1
);
}
#[test]
fn exports_log_content_without_treating_log_messages_as_remote_exit_status() {
let (directory, manager) = fixture(false);
let destination = tempfile::tempdir().unwrap();
let result = wait(
&manager,
json!({"kind":"logs","target":"phone","directory":destination.path()}),
);
assert_eq!(result["state"], "completed", "{result}");
assert!(
fs::read_to_string(result["result"]["path"].as_str().unwrap())
.unwrap()
.starts_with("error: user log")
);
assert!(directory.path().join("hdc.calls").is_file());
}
#[test]
fn blocks_release_app_private_export_and_rejects_zero_exit_without_received_data() {
for debug in [false, true] {
let (directory, manager) = fixture(debug);
let destination = tempfile::tempdir().unwrap();
let result = wait(
&manager,
json!({"kind":"exportDebug","target":"phone","bundle":"com.example.app","directory":destination.path()}),
);
assert_eq!(result["state"], "failed");
assert_eq!(
result["error"]["code"],
if debug {
"hdc.invalid_backup"
} else {
"hdc.backup_unavailable"
}
);
assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0);
if !debug {
assert!(!fs::read_to_string(directory.path().join("hdc.calls"))
.unwrap()
.contains("file recv"));
}
}
}
@@ -0,0 +1,178 @@
use crate::model::{Fault, Result};
use serde_json::{json, Value};
pub fn identifier(value: &str) -> Result<&str> {
if value.is_empty()
|| value.len() > 256
|| value.starts_with('-')
|| !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
{
return Err(Fault::new("request.invalid_params", "应用或组件标识无效。"));
}
Ok(value)
}
pub fn target(value: &str) -> Result<&str> {
if value.is_empty()
|| value.len() > 256
|| value.starts_with('-')
|| !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._-:[]".contains(&b))
{
return Err(Fault::new("request.invalid_params", "设备标识无效。"));
}
Ok(value)
}
pub fn devices(output: &str) -> Result<Vec<Value>> {
if output.trim() == "[Empty]" {
return Ok(Vec::new());
}
let mut rows = Vec::new();
for line in output.lines().filter(|line| !line.trim().is_empty()) {
let fields: Vec<_> = line.split_whitespace().collect();
if fields.len() < 3 || !matches!(fields[1], "USB" | "TCP") {
return Err(Fault::new(
"hdc.invalid_output",
"无法识别设备列表,请检查 HDC 版本。",
));
}
target(fields[0])?;
let status = fields
.iter()
.find(|s| {
matches!(
**s,
"Unknown" | "Ready" | "Connected" | "Offline" | "Unauthorized"
)
})
.ok_or_else(|| Fault::new("hdc.invalid_output", "设备列表缺少连接状态。"))?;
rows.push(
json!({"id":fields[0],"transport":fields[1],"state":status,"description":line.trim()}),
);
}
if rows.is_empty() {
return Err(Fault::new(
"hdc.invalid_output",
"HDC 没有返回可识别的设备状态。",
));
}
Ok(rows)
}
pub fn apps(output: &str) -> Result<Vec<String>> {
let mut rows = Vec::new();
let mut header = false;
for line in output
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
if let Some(id) = line.strip_prefix("ID:") {
if id
.trim()
.trim_end_matches(':')
.trim()
.parse::<u32>()
.is_ok()
{
header = true;
continue;
}
}
identifier(line).map_err(|_| {
Fault::new(
"hdc.invalid_output",
"无法识别应用列表,未将错误输出作为空列表。",
)
})?;
if !line.contains('.') {
return Err(Fault::new("hdc.invalid_output", "应用列表返回了未知格式。"));
}
rows.push(line.into());
}
if rows.is_empty() && !header {
return Err(Fault::new("hdc.invalid_output", "设备没有返回应用列表。"));
}
rows.sort();
rows.dedup();
Ok(rows)
}
pub fn app(output: &str, bundle: &str) -> Result<Value> {
identifier(bundle)?;
let start = output
.find('{')
.ok_or_else(|| Fault::new("hdc.invalid_output", "应用详情没有返回 JSON。"))?;
let raw: Value = serde_json::from_str(output[start..].trim())
.map_err(|_| Fault::new("hdc.invalid_output", "无法解析应用详情。"))?;
if raw["name"] != bundle {
return Err(Fault::new(
"hdc.invalid_output",
"应用详情与所选应用不一致。",
));
}
let mut abilities = Vec::new();
if let Some(modules) = raw["hapModuleInfos"].as_array() {
for module in modules {
for ability in module["abilityInfos"].as_array().into_iter().flatten() {
if ability["type"] != 1
|| ability["visible"] != true
|| ability["enabled"] == false
|| ability["bundleName"] != bundle
{
continue;
}
let Some(name) = ability["name"].as_str().filter(|s| identifier(s).is_ok()) else {
continue;
};
let Some(module_name) = ability["moduleName"]
.as_str()
.or_else(|| module["moduleName"].as_str())
.filter(|s| identifier(s).is_ok())
else {
continue;
};
abilities.push(json!({"name":name,"module":module_name}));
}
}
}
Ok(
json!({"bundle":bundle,"versionName":raw["versionName"],"versionCode":raw["versionCode"],"debug":raw["applicationInfo"]["appProvisionType"] == "debug","abilities":abilities,"details":raw}),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn distinguishes_empty_unauthorized_and_unrecognized_device_lists() {
assert!(devices("[Empty]\n").unwrap().is_empty());
assert_eq!(
devices("abc123 USB Unauthorized HUAWEI\n").unwrap()[0]["state"],
"Unauthorized"
);
assert_eq!(
devices("127.0.0.1:5555 TCP Connected hdc3\n").unwrap()[0]["transport"],
"TCP"
);
assert!(devices("unknown future format").is_err());
assert!(devices("").is_err());
}
#[test]
fn parses_bundle_manager_formats_and_does_not_invent_launch_components() {
assert_eq!(
apps("ID: 100:\n\tcom.example.a\n\tcom.example.b\n").unwrap(),
vec!["com.example.a", "com.example.b"]
);
assert!(apps("permission denied").is_err());
let text = r#"com.example.a:
{"name":"com.example.a","applicationInfo":{"appProvisionType":"debug"},"hapModuleInfos":[{"moduleName":"entry","abilityInfos":[{"name":"Main","bundleName":"com.example.a","type":1,"visible":true},{"name":"Private","bundleName":"com.example.a","type":1,"visible":false}]}]}"#;
let result = app(text, "com.example.a").unwrap();
assert_eq!(result["debug"], true);
assert_eq!(result["abilities"].as_array().unwrap().len(), 1);
assert_eq!(result["abilities"][0]["name"], "Main");
assert!(app(text, "com.example.b").is_err());
assert!(identifier("com.app;rm").is_err());
assert!(target("dev$(id)").is_err());
}
}
@@ -1,6 +1,12 @@
mod command;
mod connector;
mod diagnostics;
mod file_transfer;
mod hdc;
mod hdc_data;
mod model;
mod mtp;
mod progress;
mod wire;
fn main() -> anyhow::Result<()> {
if std::env::args().any(|arg| arg == "--diagnose") {
@@ -10,6 +16,9 @@ fn main() -> anyhow::Result<()> {
);
return Ok(());
}
if std::env::args().any(|arg| arg == "--check-reconnect") {
return diagnostics::reconnect();
}
let _ = mtp::version();
wire::run(connector::Connector::new())
}
+1 -17
View File
@@ -1,5 +1,5 @@
pub use crate::progress::Progress;
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -58,22 +58,6 @@ impl From<std::io::Error> for Fault {
}
pub type Result<T> = std::result::Result<T, Fault>;
#[derive(Default)]
pub struct Progress {
pub sent: AtomicU64,
pub total: AtomicU64,
pub cancel: AtomicBool,
pub disconnected: AtomicBool,
}
impl Progress {
pub fn update(&self, sent: u64, total: u64) {
self.sent.store(sent, Ordering::Relaxed);
self.total.store(total, Ordering::Relaxed);
}
pub fn cancelled(&self) -> bool {
self.cancel.load(Ordering::Relaxed)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "direction", rename_all = "lowercase", deny_unknown_fields)]
pub enum Transfer {
@@ -0,0 +1,154 @@
use serde_json::{json, Value};
use std::{
collections::VecDeque,
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Mutex,
},
time::{Duration, Instant},
};
struct Meter {
started: Instant,
finished: Option<Duration>,
samples: VecDeque<(Duration, u64)>,
name: String,
}
impl Default for Meter {
fn default() -> Self {
Self {
started: Instant::now(),
finished: None,
samples: VecDeque::from([(Duration::ZERO, 0)]),
name: String::new(),
}
}
}
impl Meter {
fn sample(&mut self, time: Duration, sent: u64) {
if self.samples.len() > 1
&& time.as_millis() / 200 == self.samples.back().unwrap().0.as_millis() / 200
{
self.samples.pop_back();
}
self.samples.push_back((time, sent));
self.trim(time);
}
fn trim(&mut self, time: Duration) {
let cutoff = time.saturating_sub(Duration::from_secs(3));
while self.samples.len() > 1 && self.samples[1].0 <= cutoff {
self.samples.pop_front();
}
}
fn rates(&mut self, time: Duration, sent: u64, total: u64) -> (f64, f64, Option<f64>) {
self.trim(time);
let (at, bytes) = self.samples.front().copied().unwrap_or_default();
let seconds = time.saturating_sub(at).as_secs_f64();
let speed = if seconds >= 0.1 {
sent.saturating_sub(bytes) as f64 / seconds
} else {
0.0
};
let average = if time.as_secs_f64() >= 0.1 {
sent as f64 / time.as_secs_f64()
} else {
0.0
};
let remaining = (speed > 0.0 && total > sent).then(|| (total - sent) as f64 / speed);
(speed, average, remaining)
}
}
#[derive(Default)]
pub struct Progress {
pub sent: AtomicU64,
pub total: AtomicU64,
pub cancel: AtomicBool,
pub disconnected: AtomicBool,
base: AtomicU64,
files: AtomicU64,
done: AtomicU64,
meter: Mutex<Meter>,
}
impl Progress {
pub fn prepare(&self, total: u64, files: u64) {
self.total.store(total, Ordering::Relaxed);
self.files.store(files, Ordering::Relaxed);
*self.meter.lock().unwrap() = Meter::default();
}
pub fn begin_file(&self, name: &str) {
self.base
.store(self.sent.load(Ordering::Relaxed), Ordering::Relaxed);
self.meter.lock().unwrap().name = name.into();
}
pub fn update(&self, sent: u64, _file_total: u64) {
let sent = self
.base
.load(Ordering::Relaxed)
.saturating_add(sent)
.min(self.total.load(Ordering::Relaxed));
self.sent.store(sent, Ordering::Relaxed);
let mut meter = self.meter.lock().unwrap();
let elapsed = meter.started.elapsed();
meter.sample(elapsed, sent);
}
pub fn complete_file(&self) {
self.done.fetch_add(1, Ordering::Relaxed);
}
pub fn finish(&self) {
let mut meter = self.meter.lock().unwrap();
meter.finished = Some(meter.started.elapsed());
}
pub fn cancelled(&self) -> bool {
self.cancel.load(Ordering::Relaxed)
}
pub fn statistics(&self) -> Value {
let sent = self.sent.load(Ordering::Relaxed);
let total = self.total.load(Ordering::Relaxed);
let mut meter = self.meter.lock().unwrap();
let elapsed = meter.finished.unwrap_or_else(|| meter.started.elapsed());
let (speed, average, remaining) = meter.rates(elapsed, sent, total);
json!({"bytesPerSecond":speed,"averageBytesPerSecond":average,"elapsedSeconds":elapsed.as_secs_f64(),"remainingSeconds":remaining,"currentFile":meter.name,"filesCompleted":self.done.load(Ordering::Relaxed),"filesTotal":self.files.load(Ordering::Relaxed)})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn measures_rate_and_eta_and_stops_claiming_throughput_when_stalled() {
let mut meter = Meter::default();
meter.sample(Duration::from_secs(1), 1024);
meter.sample(Duration::from_secs(2), 2048);
assert_eq!(
meter.rates(Duration::from_secs(2), 2048, 4096),
(1024.0, 1024.0, Some(2.0))
);
let (speed, _, remaining) = meter.rates(Duration::from_secs(6), 2048, 4096);
assert_eq!(speed, 0.0);
assert_eq!(remaining, None);
assert_eq!(
Meter::default().rates(Duration::ZERO, 0, 0),
(0.0, 0.0, None)
);
}
#[test]
fn folder_progress_accumulates_file_offsets_and_counts_empty_files() {
let progress = Progress::default();
progress.prepare(150, 3);
for (name, size) in [("a", 100), ("b", 0), ("c", 50)] {
progress.begin_file(name);
progress.update(size, size);
progress.complete_file();
}
progress.finish();
assert_eq!(progress.sent.load(Ordering::Relaxed), 150);
let stats = progress.statistics();
assert_eq!(stats["filesCompleted"], 3);
assert_eq!(stats["currentFile"], "c");
assert_eq!(
stats["elapsedSeconds"],
progress.statistics()["elapsedSeconds"]
);
}
}
+51 -6
View File
@@ -1,8 +1,17 @@
import { management } from "./management.js";
import { trove } from "./sdk/index.js";
import { ROOT, bytes, date, percent, filterEntries } from "./format.js";
import {
ROOT,
bytes,
date,
percent,
filterEntries,
transferMetrics,
} from "./format.js";
const $ = (id) => document.getElementById(id);
const state = {
view: "files",
connection: null,
storage: null,
path: [],
@@ -49,6 +58,7 @@ function controls() {
"refresh",
"mkdir",
"upload",
"upload-folder",
"load-more",
])
$(id).disabled = locked;
@@ -89,7 +99,7 @@ function renderConnection() {
const connection = state.connection;
$("welcome").hidden = !!connection;
$("browser").hidden = !connection;
$("scan").hidden = !!connection;
$("scan").hidden = !!connection || state.view !== "files";
$("disconnect").hidden = !connection;
$("device-name").textContent =
connection?.device.name || (connection ? "鸿蒙设备" : "等待连接");
@@ -101,9 +111,12 @@ function renderConnection() {
connection.device.model === connection.device.name;
$("device-state").textContent = connection ? "已连接 · USB" : "USB 文件传输";
$("device-dot").classList.toggle("connected", !!connection);
$("page-title").textContent = connection
? "设备中的文件"
: "连接你的鸿蒙设备";
$("page-title").textContent =
state.view === "management"
? "设备与应用"
: connection
? "设备中的文件"
: "连接你的鸿蒙设备";
$("storages").replaceChildren();
if (!connection) {
$("storages").append(text("p", "连接后查看可用存储", "muted"));
@@ -309,7 +322,7 @@ function renderFiles() {
name.append(icon(entry.folder), text("span", entry.name));
nameCell.append(name);
const operations = document.createElement("td");
if (!entry.folder) {
{
const receive = button("接收", "download-button", () =>
action(async () => {
const { path } = await host("openDirectory", {
@@ -373,6 +386,10 @@ function renderTransfer() {
: job.total
? `${bytes(job.sent)} / ${bytes(job.total)}`
: "正在准备文件…");
$("transfer-metrics").textContent = transferMetrics(job);
$("transfer-current").textContent = running
? job.statistics?.currentFile || ""
: "";
$("cancel").hidden = !running;
$("cancel").disabled = !!job.cancelling;
$("progress").value =
@@ -450,6 +467,20 @@ $("upload").addEventListener("click", () =>
});
}),
);
$("upload-folder").addEventListener("click", () =>
action(async () => {
const { path } = await host("openDirectory", {
title: "选择要发送到手机的文件夹",
});
if (path)
await start({
direction: "upload",
path,
storage: state.storage,
parent: parent(),
});
}),
);
$("cancel").addEventListener("click", async () => {
$("cancel").disabled = true;
try {
@@ -489,6 +520,20 @@ $("folder-form").addEventListener("submit", (event) => {
await load();
});
});
const openManagement = management({ native, host, notice });
for (const view of ["files", "management"])
$("tab-" + view).onclick = () => {
state.view = view;
$("file-panel").hidden = view !== "files";
$("management").hidden = view !== "management";
for (const tab of ["files", "management"])
$("tab-" + tab).setAttribute(
"aria-current",
tab === view ? "page" : "false",
);
renderConnection();
openManagement(view === "management");
};
window.addEventListener("pagehide", () => {
state.disposed = true;
state.revision++;
+27
View File
@@ -22,3 +22,30 @@ export function filterEntries(entries, query) {
entry.name.toLocaleLowerCase().includes(term),
);
}
export function duration(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return "—";
const value = Math.ceil(seconds);
if (value < 60) return `${value} 秒`;
if (value < 3600) return `${Math.floor(value / 60)} 分 ${value % 60} 秒`;
return `${Math.floor(value / 3600)} 小时 ${Math.floor((value % 3600) / 60)} 分`;
}
export function transferMetrics(job) {
const stats = job?.statistics;
if (!stats) return "正在计算速度…";
const parts =
job.state === "running"
? [
`${bytes(stats.bytesPerSecond)}/s`,
`平均 ${bytes(stats.averageBytesPerSecond)}/s`,
`已用 ${duration(stats.elapsedSeconds)}`,
]
: [
`平均 ${bytes(stats.averageBytesPerSecond)}/s`,
`用时 ${duration(stats.elapsedSeconds)}`,
];
if (job.state === "running" && stats.remainingSeconds != null)
parts.push(`剩余约 ${duration(stats.remainingSeconds)}`);
if (stats.filesTotal != null)
parts.push(`${stats.filesCompleted} / ${stats.filesTotal} 个文件`);
return parts.join(" · ");
}
+28 -1
View File
@@ -1,6 +1,13 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { bytes, date, percent, filterEntries } from "./format.js";
import {
bytes,
date,
percent,
filterEntries,
duration,
transferMetrics,
} from "./format.js";
test("renders byte counts, large files, unknown capacity and empty transfers", () => {
assert.equal(bytes(0), "0 B");
assert.equal(bytes(5 * 1024 ** 3), "5.0 GB");
@@ -17,3 +24,23 @@ test("filters Unicode and case-insensitive names without changing originals", ()
assert.equal(filterEntries(entries, "<script>")[0].name, "<script>.txt");
assert.equal(entries.length, 2);
});
test("shows transfer speed, elapsed time and remaining estimates with an unknown fallback", () => {
assert.equal(duration(null), "—");
assert.equal(duration(61), "1 分 1 秒");
const job = {
state: "running",
statistics: {
bytesPerSecond: 1024,
averageBytesPerSecond: 512,
elapsedSeconds: 10,
remainingSeconds: 20,
filesCompleted: 1,
filesTotal: 3,
},
};
assert.match(transferMetrics(job), /1 KB\/s/);
assert.match(transferMetrics(job), /20 秒/);
assert.doesNotMatch(transferMetrics({ ...job, state: "completed" }), /剩余/);
assert.match(transferMetrics({ state: "running" }), /计算/);
});
+156 -74
View File
@@ -32,7 +32,7 @@
<p class="muted">连接后查看可用存储</p>
</nav>
<div class="sidebar-bottom">
<span class="small-badge">HarmonyOS 6</span>
<span class="small-badge">USB + HDC</span>
<p>文件留在你的设备之间。<br />通过 USB 直接传输。</p>
<button id="guide-open" class="text-button">
连接与使用帮助 <span aria-hidden="true">↗</span>
@@ -51,84 +51,167 @@
<span id="notice-text"></span
><button id="notice-close" aria-label="关闭提示">×</button>
</div>
<section id="welcome" class="welcome">
<div class="connection-art" aria-hidden="true">
<div class="mac">
<div><span>↗</span></div>
<i></i>
<nav class="workspace-tabs" aria-label="工作区">
<button id="tab-files" aria-current="page">文件</button>
<button id="tab-management">设备与应用</button>
</nav>
<div id="file-panel">
<section id="welcome" class="welcome">
<div class="connection-art" aria-hidden="true">
<div class="mac">
<div><span>↗</span></div>
<i></i>
</div>
<div class="connection-line"><i></i><i></i><i></i></div>
<div class="phone"><span></span><b>↙</b><i></i></div>
</div>
<div class="connection-line"><i></i><i></i><i></i></div>
<div class="phone"><span></span><b>↙</b><i></i></div>
</div>
<span class="small-badge">MAC ↔ HARMONY</span>
<h2>一根数据线,<br />连接两个世界。</h2>
<p class="intro">
照片、文档、灵感碎片。<br />在 Mac 与鸿蒙设备之间,轻松收发。
</p>
<ol class="steps">
<li><b>1</b>解锁手机并连接 USB 数据线</li>
<li><b>2</b>在手机上选择「传输文件」</li>
<li><b>3</b>等待自动发现,选择设备并连接</li>
</ol>
<div id="device-picker" hidden>
<label for="device-select">选择设备</label>
<div class="device-picker-row">
<select id="device-select"></select
><button id="connect" class="primary">连接设备</button>
</div>
</div>
<p id="scan-status" class="muted" role="status">
无需开发者模式,也无需安装手机端应用。
</p>
</section>
<section id="browser" class="browser" hidden>
<div class="toolbar">
<nav id="breadcrumbs" aria-label="当前位置"></nav>
<div class="actions">
<button id="refresh" class="secondary" title="刷新当前文件夹">
刷新</button
><button id="mkdir" class="secondary">新建文件夹</button
><button id="upload" class="primary">↑ 发送文件</button>
</div>
</div>
<div class="list-heading">
<label class="search"
><span aria-hidden="true">⌕</span
><input
id="search"
type="search"
placeholder="筛选已加载的文件"
aria-label="筛选已加载的文件" /></label
><span id="file-count" class="muted"></span>
</div>
<div class="file-table-wrap">
<table>
<thead>
<tr>
<th>名称</th>
<th>大小</th>
<th>修改时间</th>
<th><span class="sr-only">操作</span></th>
</tr>
</thead>
<tbody id="files"></tbody>
</table>
<p id="empty" class="empty" hidden>
这个文件夹还是空的,发送第一个文件吧。
<span class="small-badge">MAC ↔ HARMONY</span>
<h2>一根数据线,<br />连接两个世界。</h2>
<p class="intro">
照片、文档、灵感碎片。<br />在 Mac 与鸿蒙设备之间,轻松收发。
</p>
<button id="load-more" class="secondary load-more" hidden>
加载更多
</button>
<ol class="steps">
<li><b>1</b>解锁手机并连接 USB 数据线</li>
<li><b>2</b>在手机上选择「传输文件」</li>
<li><b>3</b>等待自动发现,选择设备并连接</li>
</ol>
<div id="device-picker" hidden>
<label for="device-select">选择设备</label>
<div class="device-picker-row">
<select id="device-select"></select
><button id="connect" class="primary">连接设备</button>
</div>
</div>
<p id="scan-status" class="muted" role="status">
无需开发者模式,也无需安装手机端应用。
</p>
</section>
<section id="browser" class="browser" hidden>
<div class="toolbar">
<nav id="breadcrumbs" aria-label="当前位置"></nav>
<div class="actions">
<button id="refresh" class="secondary" title="刷新当前文件夹">
刷新</button
><button id="mkdir" class="secondary">新建文件夹</button
><button id="upload-folder" class="secondary">发送文件夹</button
><button id="upload" class="primary">↑ 发送文件</button>
</div>
</div>
<div class="list-heading">
<label class="search"
><span aria-hidden="true">⌕</span
><input
id="search"
type="search"
placeholder="筛选已加载的文件"
aria-label="筛选已加载的文件" /></label
><span id="file-count" class="muted"></span>
</div>
<div class="file-table-wrap">
<table>
<thead>
<tr>
<th>名称</th>
<th>大小</th>
<th>修改时间</th>
<th><span class="sr-only">操作</span></th>
</tr>
</thead>
<tbody id="files"></tbody>
</table>
<p id="empty" class="empty" hidden>
这个文件夹还是空的,发送第一个文件吧。
</p>
<button id="load-more" class="secondary load-more" hidden>
加载更多
</button>
</div>
<p class="browser-footnote">
支持完整文件夹,保留目录结构。同名条目不会被覆盖。
</p>
</section>
</div>
<section id="management" class="management" hidden>
<div class="management-card">
<div class="management-heading">
<h2>设备管理</h2>
<button id="hdc-configure" class="secondary">
选择 HDC 工具
</button>
</div>
<p id="hdc-tool-status" class="muted">正在检查官方连接工具…</p>
<p class="muted">
在手机开发者选项中启用 USB 调试并信任这台 Mac。请保留 HDC
同目录中的依赖文件。文件传输无需启用调试。
</p>
<div class="device-picker-row">
<label for="hdc-target" class="sr-only">管理设备</label
><select id="hdc-target"></select
><button id="hdc-refresh" class="secondary">刷新连接</button>
</div>
<p id="hdc-connection-status" role="status">等待管理连接</p>
<div id="hdc-capabilities" class="capabilities"></div>
<div class="management-actions">
<button id="device-screenshot" class="secondary">保存截图</button
><button id="device-logs" class="secondary">导出日志</button
><button id="device-report" class="secondary">诊断报告</button>
</div>
<details>
<summary>设备信息</summary>
<pre id="device-info"></pre>
</details>
</div>
<div class="management-card">
<div class="management-heading">
<h2>应用</h2>
<div class="management-actions">
<button id="apps-refresh" class="secondary">刷新应用</button
><button id="app-install" class="primary">安装 HAP</button>
</div>
</div>
<div class="list-heading">
<label class="search"
><span aria-hidden="true">⌕</span
><input
id="apps-search"
type="search"
placeholder="按应用包名筛选"
aria-label="筛选应用" /></label
><span id="apps-count" class="muted"></span>
</div>
<div class="application-layout">
<div id="apps-list" class="apps-list" aria-label="应用列表"></div>
<div id="app-detail" class="app-detail" hidden>
<h3 id="app-bundle"></h3>
<p id="app-version" class="muted"></p>
<label id="app-ability-label" for="app-ability"
>启动组件<select id="app-ability"></select
></label>
<div class="management-actions">
<button id="app-launch" class="secondary">启动</button
><button id="app-stop" class="secondary">强制停止</button
><button id="app-uninstall" class="secondary">卸载</button>
</div>
<p id="app-export-note" class="muted"></p>
<button id="app-export" class="secondary">导出调试数据</button>
</div>
</div>
</div>
<div id="hdc-job" class="management-card" role="status" hidden>
<div class="management-heading">
<strong id="hdc-job-title"></strong
><button id="hdc-cancel" class="secondary">取消操作</button>
</div>
<p id="hdc-job-detail"></p>
</div>
<p class="browser-footnote">
接收保留原文件名,同名文件不会被覆盖。一次传输一个文件。
</p>
</section>
<section id="transfer" class="transfer" aria-label="文件传输" hidden>
<div class="transfer-top">
<div>
<strong id="transfer-title">正在传输</strong>
<p id="transfer-detail" role="status"></p>
<p id="transfer-current" class="muted"></p>
<p id="transfer-metrics" class="muted"></p>
</div>
<button id="cancel" class="secondary">取消传输</button>
</div>
@@ -150,7 +233,7 @@
<h2>连接,开始传输</h2>
<button id="guide-close" aria-label="关闭帮助">×</button>
</div>
<p>适用于 HarmonyOS 6 开放的 USB 文件传输存储。</p>
<p>适用于设备开放的 USB 文件传输存储,管理功能通过官方 HDC 连接。</p>
<ol>
<li>使用支持数据传输的 USB 线连接 Mac 与手机,并保持手机解锁。</li>
<li>在手机 USB 选项中选择「传输文件」,按提示允许访问。</li>
@@ -165,8 +248,7 @@
</p>
<h3>照片与图库</h3>
<p>
HarmonyOS 6
的「传输文件」模式可导出图库内容;导入图库需「传输图片」模式。当前版本仅支持文件传输模式,向普通文件夹发送照片不等于导入图库。
设备的「传输文件」与「传输图片」模式可能开放不同目录。向普通文件夹发送照片不等于导入图库,是否可见以设备提供的目录为准。
</p>
<h3>取消与关闭</h3>
<p>
@@ -174,7 +256,7 @@
接收文件夹可能留下 .harmony-*.part 临时文件。
</p>
<p class="muted">
文件夹请先压缩后发送。不同机型开放的目录与访问权限以手机为准。
支持整个文件夹的发送与接收,包括空文件夹;不合并同名目录,不跟随符号链接。不同设备开放的目录与访问权限以手机为准。
</p>
</dialog>
<dialog id="folder-dialog">
+400
View File
@@ -0,0 +1,400 @@
import { bytes } from "./format.js";
export function management({ native, host, notice }) {
const $ = (id) => document.getElementById(id);
const call = (method, params = {}) => native(`hdc.${method}`, params);
const state = {
visible: false,
available: false,
monitorTimer: null,
ready: false,
working: false,
busy: false,
devices: [],
target: "",
capabilities: {},
apps: [],
app: null,
job: null,
timer: null,
disposed: false,
};
const label = (tag, value) => {
const el = document.createElement(tag);
el.textContent = value;
return el;
};
function controls() {
const locked = state.working || state.busy;
for (const button of $("management").querySelectorAll("button"))
button.disabled = locked;
$("hdc-target").disabled = locked;
const connected = state.devices.some(
(d) => d.id === state.target && d.state === "Connected",
);
for (const [id, capability] of [
["apps-refresh", "apps"],
["app-install", "apps"],
["device-screenshot", "screenshot"],
["device-logs", "logs"],
["device-report", "report"],
]) {
$(id).disabled =
locked || !connected || !state.capabilities[capability]?.available;
$(id).title = state.capabilities[capability]?.reason || "";
}
$("app-launch").disabled =
locked ||
!state.app ||
!$("app-ability").value ||
!state.capabilities.launch?.available;
$("app-stop").disabled =
locked || !state.app || !state.capabilities.launch?.available;
$("app-uninstall").disabled =
locked || !state.app || !!state.app.details?.applicationInfo?.isSystemApp;
$("app-export").disabled = locked || !state.app?.debug;
$("hdc-cancel").disabled =
state.job?.state !== "running" || !!state.job?.cancelling;
}
async function action(fn) {
if (state.working || state.busy || state.disposed) return;
state.working = true;
controls();
notice("");
try {
await fn();
} catch (error) {
notice(error.message || String(error));
} finally {
state.working = false;
controls();
}
}
function clearDevice() {
state.app = null;
state.apps = [];
state.capabilities = {};
$("app-detail").hidden = true;
$("apps-list").replaceChildren();
$("apps-count").textContent = "";
$("device-info").textContent = "";
$("hdc-capabilities").replaceChildren();
}
function showTool(tool) {
state.available = !!tool.available;
$("hdc-tool-status").textContent = tool.available
? `${tool.version} · ${tool.path}`
: "未发现 HDC。选择 DevEco Studio 或官方 Command Line Tools 中的 hdc 文件。";
}
function renderApps() {
const filter = $("apps-search").value.trim().toLowerCase();
const apps = state.apps.filter((bundle) =>
bundle.toLowerCase().includes(filter),
);
$("apps-count").textContent =
`${apps.length} / ${state.apps.length} 个应用`;
$("apps-list").replaceChildren();
for (const bundle of apps) {
const button = label("button", bundle);
button.className = "app-row";
button.title = bundle;
button.setAttribute("aria-pressed", String(state.app?.bundle === bundle));
button.onclick = () =>
action(async () => {
state.app = null;
$("app-detail").hidden = true;
state.app = await call("app", { target: state.target, bundle });
$("app-bundle").textContent = state.app.bundle;
$("app-version").textContent =
`${state.app.versionName || "版本未知"} (${state.app.versionCode ?? "—"}) · ${state.app.debug ? "调试签名" : "发布应用"}`;
$("app-ability").replaceChildren();
for (const [index, ability] of state.app.abilities.entries()) {
const option = label(
"option",
`${ability.module} / ${ability.name}`,
);
option.value = String(index);
$("app-ability").append(option);
}
$("app-ability-label").hidden = !state.app.abilities.length;
$("app-export-note").textContent = state.app.debug
? "可尝试导出已启动应用的调试数据;能否读取以设备授权为准。"
: "发布应用的私有数据未开放导出。此处不提供整机或全应用备份。";
$("app-detail").hidden = false;
renderApps();
});
$("apps-list").append(button);
}
controls();
}
async function loadApps() {
state.apps = (await call("apps", { target: state.target })).apps;
renderApps();
}
async function inspect() {
clearDevice();
const device = state.devices.find((d) => d.id === state.target);
if (device?.state !== "Connected") {
$("hdc-connection-status").textContent =
device?.state === "Unauthorized"
? "请解锁手机并允许这台 Mac 的 USB 调试。"
: "尚无可用的管理连接。";
return;
}
$("hdc-connection-status").textContent = "正在读取设备能力…";
const result = await call("inspect", { target: state.target });
state.capabilities = result.capabilities;
$("device-info").textContent =
result.info.text || result.info.reason || "设备未返回基本信息";
const names = {
apps: "应用管理",
launch: "应用启动",
screenshot: "截图",
logs: "日志",
report: "诊断报告",
};
for (const [key, value] of Object.entries(state.capabilities)) {
const badge = label(
"span",
`${names[key] || key} · ${value.available ? "可用" : "未开放"}`,
);
badge.className = "small-badge";
badge.title = value.reason || "";
$("hdc-capabilities").append(badge);
}
$("hdc-connection-status").textContent =
`已连接 ${state.target} · 操作权限以设备响应为准`;
if (state.capabilities.apps?.available) await loadApps();
}
async function refresh(devices) {
clearDevice();
state.devices = devices || (await call("devices")).devices;
$("hdc-target").replaceChildren();
for (const device of state.devices) {
const option = label(
"option",
`${device.id} · ${device.transport} · ${device.state}`,
);
option.value = device.id;
$("hdc-target").append(option);
}
if (state.devices.some((d) => d.id === state.target))
$("hdc-target").value = state.target;
state.target = $("hdc-target").value;
await inspect();
}
function scheduleMonitor() {
clearTimeout(state.monitorTimer);
if (!state.disposed && state.visible)
state.monitorTimer = setTimeout(monitor, 3000);
}
async function monitor() {
if (state.disposed || !state.visible) return;
if (!state.working && !state.busy && state.available) {
state.working = true;
controls();
try {
const devices = (await call("devices")).devices;
if (
!state.disposed &&
JSON.stringify(devices) !== JSON.stringify(state.devices)
)
await refresh(devices);
} catch (error) {
clearDevice();
state.devices = [];
state.target = "";
$("hdc-target").replaceChildren();
$("hdc-connection-status").textContent =
`连接检测失败:${error.message || String(error)}`;
} finally {
state.working = false;
controls();
}
}
scheduleMonitor();
}
function renderJob() {
const job = state.job;
$("hdc-job").hidden = !job;
if (!job) return;
$("hdc-job-title").textContent = job.cancelling
? "正在取消…"
: {
running: "正在执行,请保持连接…",
completed: "操作完成",
cancelled: "操作已取消",
failed: "操作未完成",
}[job.state];
$("hdc-job-detail").textContent =
job.error?.message ||
[
job.result?.message,
job.result?.path,
job.result?.bytes != null ? bytes(job.result.bytes) : "",
]
.filter(Boolean)
.join(" · ");
$("hdc-cancel").hidden = job.state !== "running";
controls();
}
function schedule() {
clearTimeout(state.timer);
if (!state.disposed) state.timer = setTimeout(poll, 500);
}
async function poll() {
try {
const snapshot = await call("status");
if (state.disposed) return;
state.busy = snapshot.busy;
state.job = snapshot.job
? {
...snapshot.job,
cancelling:
snapshot.job.state === "running" && state.job?.cancelling,
}
: null;
renderJob();
if (state.busy || state.job?.state === "running") schedule();
else if (state.job?.state === "completed") await action(() => refresh());
} catch (error) {
if (!state.disposed) {
state.busy = false;
notice(error.message || String(error));
controls();
}
}
}
async function start(kind, extra = {}) {
const job = await call("start", { kind, target: state.target, ...extra });
state.job = { ...job, state: "running" };
state.busy = true;
renderJob();
schedule();
}
const confirm = async (title, message) =>
(await host("confirm", { title, message })).confirmed;
$("hdc-configure").onclick = () =>
action(async () => {
const { path } = await host("openFile", {
title: "选择官方 SDK 中的 hdc(保留同目录依赖)",
});
if (path) {
showTool(await call("configure", { path }));
await refresh();
}
});
$("hdc-refresh").onclick = () => action(() => refresh());
$("hdc-target").onchange = () =>
action(async () => {
state.target = $("hdc-target").value;
await inspect();
});
$("apps-search").oninput = renderApps;
$("apps-refresh").onclick = () => action(loadApps);
$("app-ability").onchange = controls;
$("app-install").onclick = () =>
action(async () => {
const { path } = await host("openFile", {
title: "选择已签名的 .hap 安装包",
});
if (!path) return;
const pkg = await call("package", { path });
if (
await confirm(
"安装或更新应用",
`设备:${state.target}\n应用:${pkg.bundle}\n版本:${pkg.versionName} (${pkg.versionCode})\n大小:${bytes(pkg.size)}\nSHA-256:${pkg.sha256}\n\n同包名应用将被更新,可能改变现有应用数据。继续安装?`,
)
)
await start("install", { path, sha256: pkg.sha256 });
});
$("app-launch").onclick = () =>
action(async () => {
const ability = state.app.abilities[Number($("app-ability").value)];
if (ability)
await start("launch", {
bundle: state.app.bundle,
module: ability.module,
ability: ability.name,
});
});
$("app-stop").onclick = () =>
action(async () => {
const bundle = state.app.bundle;
if (
await confirm(
"强制停止应用",
`在 ${state.target} 上停止 ${bundle}?未保存的内容可能丢失。`,
)
)
await start("stop", { bundle });
});
$("app-uninstall").onclick = () =>
action(async () => {
const bundle = state.app.bundle;
if (
await confirm(
"卸载应用",
`从 ${state.target} 卸载 ${bundle}?应用及其本地数据将被删除。`,
)
)
await start("uninstall", { bundle });
});
$("app-export").onclick = () =>
action(async () => {
const bundle = state.app.bundle;
if (
!(await confirm(
"导出调试应用数据",
`从 ${state.target} 导出 ${bundle} 的调试数据?\n仅复制当前可访问的数据文件,包含校验清单。运行中数据库可能不一致;这不是可直接恢复的整机备份。`,
))
)
return;
const { path } = await host("openDirectory", {
title: "保存调试数据到…",
});
if (path) await start("exportDebug", { bundle, directory: path });
});
for (const kind of ["screenshot", "logs", "report"])
$("device-" + kind).onclick = () =>
action(async () => {
const { path } = await host("openDirectory", { title: "选择保存位置" });
if (path) await start(kind, { directory: path });
});
$("hdc-cancel").onclick = async () => {
try {
await call("cancel", { id: state.job.id });
state.job.cancelling = true;
renderJob();
} catch (error) {
notice(error.message || String(error));
}
};
window.addEventListener("pagehide", () => {
state.disposed = true;
clearTimeout(state.timer);
clearTimeout(state.monitorTimer);
});
controls();
return (visible) => {
state.visible = visible;
clearTimeout(state.monitorTimer);
if (!visible) return;
action(async () => {
if (state.ready) return;
const snapshot = await call("status");
state.busy = snapshot.busy;
state.job = snapshot.job;
renderJob();
if (state.busy) {
state.ready = true;
schedule();
return;
}
const tool = await call("tool");
showTool(tool);
state.ready = true;
if (tool.available) await refresh();
}).finally(scheduleMonitor);
};
}
+122
View File
@@ -811,3 +811,125 @@ dialog li {
transform: none;
}
}
.workspace-tabs {
display: flex;
gap: 8px;
padding: 0 32px 16px;
border-bottom: 1px solid var(--line);
}
.workspace-tabs button {
background: transparent;
color: var(--muted);
border: 0;
padding: 8px 18px;
border-radius: 9px;
}
.workspace-tabs button[aria-current="page"] {
color: var(--blue);
background: var(--soft);
}
.management {
padding: 24px 32px;
}
.management-card {
padding: 20px;
margin-bottom: 18px;
border: 1px solid var(--line);
border-radius: 14px;
}
.management-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.management-heading h2 {
margin: 0;
font-size: 19px;
}
.management p {
font-size: 13px;
line-height: 1.65;
overflow-wrap: anywhere;
}
.management-actions,
.capabilities {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 12px 0;
}
.management pre {
font:
12px/1.6 ui-monospace,
monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 220px;
overflow: auto;
}
.management summary {
cursor: pointer;
font-size: 13px;
color: var(--muted);
}
.application-layout {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(200px, 1fr);
gap: 16px;
}
.apps-list {
max-height: 340px;
overflow: auto;
}
.app-row {
display: block;
width: 100%;
text-align: left;
padding: 12px;
background: transparent;
border: 0;
border-bottom: 1px solid var(--line);
overflow-wrap: anywhere;
}
.app-row:hover,
.app-row[aria-pressed="true"] {
background: var(--soft);
color: var(--blue);
}
.app-detail {
padding: 0 12px;
}
.app-detail h3 {
font-size: 15px;
overflow-wrap: anywhere;
}
.app-detail label {
font-size: 12px;
color: var(--muted);
}
.app-detail select {
display: block;
width: 100%;
margin-top: 8px;
}
#transfer-metrics {
font-variant-numeric: tabular-nums;
}
@media (max-width: 850px) {
.management {
padding: 20px;
}
.application-layout {
grid-template-columns: 1fr;
}
.app-detail {
border-top: 1px solid var(--line);
}
.actions {
flex-wrap: wrap;
justify-content: flex-end;
}
}
+73 -62
View File
@@ -1,46 +1,89 @@
# Harmony Connector
插件 ID:`dev.trove.harmony-connector`。版本:`0.1.1`。
插件 ID:`dev.trove.harmony-connector`。当前版本:`0.2.0`。
面向 HarmonyOS 6 的 USB 文件传输插件,提供设备选择、存储与目录浏览、分页、名称筛选、新建目录,以及单文件发送/接收、字节进度与取消。使用 libmtp 1.1.23 和 libusb 1.0.30,无需手机开发者模式、HDC、ADB 或 Homebrew 运行依赖。
macOS 上的鸿蒙设备连接工具。通过 USB 收发文件与完整文件夹;使用官方 HDC 管理应用、保存截图、导出日志和调试应用数据。功能按连接工具、设备实际能力和授权开放,不按 HarmonyOS 大版本或机型名称限制。
## 安装与使用
## 安装
在 Trove 添加 Git 软件源:
在 Trove 添加 Git 软件源:仓库 `https://git.render.ink/trove/curios.git`,唯一 ID `trove-official`(对应 `source/index.json` 顶层 `id`),显示名称可自行填写。刷新后安装 Harmony Connector。私有仓库使用本机 Git 凭据。
- 仓库地址:`https://git.render.ink/trove/curios.git`
- 唯一 ID:`trove-official`(仓库 `source/index.json` 顶层 `id`)。
- 显示名称:可以自定义,例如「Trove 官方软件源」。
发布包适用于 Apple Silicon、macOS 13 及以上,内含 libmtp 1.1.23 和 libusb 1.0.30。Intel 需在对应 Mac 构建并单独验证后发布,当前未提供 Intel 包。
刷新源后安装 **Harmony Connector**。私有仓库使用本机 Git 凭据。当前发布的是 Apple Silicon (`aarch64-apple-darwin`) 包,部署目标为 macOS 13 及以上;Intel 需要在对应 Mac 上构建并追加该架构产物,未发布未经验证的 Intel 包。
## 文件与文件夹
1. 解锁手机,用支持数据传输的 USB 线连接 Mac。
2. 在手机 USB 选项中选择「传输文件」,允许访问。
3. 等待插件自动发现设备,选择并连接,打开目标文件夹。也可手动点击「扫描设备」。
4. 点击「发送文件」从 Mac 选择一个文件;点击文件旁的「接收」选择 Mac 接收文件夹。
1. 解锁手机,用支持数据传输的 USB 线连接 Mac,在手机上选择「传输文件」并允许访问。
2. 插件启动后自动发现设备,选择手机并连接。也可手动扫描。
3. 选择存储位置,进入目标目录。「发送文件」选择文件,「发送文件夹」选择整个目录;文件和文件夹旁的「接收」均可下载到 Mac。
本插件只访问手机通过 MTP 开放的存储。目录来自设备对象 ID,不映射为 Android 路径。HarmonyOS 6 在文件传输模式下允许导出图库内容,导入图库需要图片传输模式;本版未实现 PTP 图库导入。[华为 HarmonyOS 6 官方说明](https://consumer.huawei.com/cn/support/content/zh-cn16090699/)
文件功能不需要 HDC、ADB、Homebrew 或开发者模式。设备每约 2 秒自动检测一次;连接后仅检查 USB 存在性,不重复打开会话。手动断开后不会自行重新连接。
没有发现设备时,检查数据线和 USB 模式、保持手机解锁,退出占用手机的其他传输应用后重试。取消或 USB 操作失败会释放会话,请重新连接。取消是协作式的,需要等待手机或 USB 超时返回,不保证立即完成。
文件夹会保留层级、中文名称、空文件和空目录。整棵目录先检查再传输;总进度汇总全部文件,显示当前文件、已完成文件数、当前/平均速度、耗时和预计剩余时间。速度来自实际字节进度,不是预设数值。
插件打开时立即扫描,随后在空闲时约每 2 秒检测一次。已连接时只检查 USB 设备是否仍存在,不重复打开 MTP 会话;传输期间也检测拔线并请求结束传输。发现设备后仍由用户选择连接,手动断开不会立即自动重连。
- 不覆盖或合并同名条目。上传还会检查设备剩余容量,每次写入前再次检查目标名称。
- 下载先写入所选目录下的隐藏临时文件夹,全部文件长度校验成功后,原子保存为最终文件夹。失败或正常取消会清理此次临时内容。
- 不跟随符号链接,不传输特殊文件;拒绝路径穿越、循环、重名目录和超过 100,000 条目或 128 层的目录树。
- 上传中断时手机可能保留已创建的目录和文件。不会自动删除这些内容,请在手机上检查后再重试。底层 USB 事务取消需等待设备响应。
- 长度校验不等于端到端内容哈希校验;实际验收应将上传文件下载回来比较 SHA-256。
- 强制结束进程或断电可能留下 `.harmony-*.part` 临时内容。插件不会扫描或删除用户目录中的历史临时文件。
手动断开会立即清空文件和连接界面,USB 会话在后台释放;清理期间显示提示并暂时禁用连接,完成后继续自动发现。设备不响应 MTP 关闭请求时,底层释放仍可能需要等待 USB 超时。
手机只开放 MTP 可见的存储,实际目录、访问权限和图库行为由设备决定;向普通目录发送照片不保证导入图库。本版不提供断点续传、无线 MTP、图库导入或全盘访问。
名称在扫描阶段来自 USB 描述符;连接后优先使用设备通过 MTP 返回的友好名称,并单独显示设备报告的型号。友好名称由固件提供,不一定等于系统设置中的自定义设备名;缺失时回退到型号或 USB 名称。libmtp 的静态设备匹配表只用于协议兼容,不作为型号展示依据;0.1.0 曾把其中的 `Nova14` 兼容条目误用为设备名称,0.1.1 已修复。
扫描名称取自 USB 描述符;连接后使用 MTP 友好名称并单独显示设备报告的型号。它可能不同于手机设置中的自定义名称。0.1.0 将 libmtp 匹配表中的 `Nova14` 误用于展示,0.1.1 已修复,不再把驱动匹配名称当作机型。
## 文件保护与边界
## 设备与应用
- 一次传输一个普通文件;目录请先压缩。不提供删除手机内容的操作。
- 拒绝同名覆盖。接收使用目标目录内的隐藏临时文件,校验长度并同步后通过原子无覆盖操作保存。发送会校验目标名称、存储空间和传输后设备报告的长度。
- 长度校验不是内容哈希验证。实际设备验收需回传文件并比较 SHA-256。
- 接收失败或正常取消会清理本次临时文件。手机端可能保留未完成的上传对象,需要在手机上检查处理。
- 不在传输过程中关闭插件。强制结束进程、USB 长时间阻塞或断电时可能留下 `.harmony-*.part`;正常退出请求最多等待约 3.5 秒,再由底座回收进程。重新启动不会扫描或删除用户目录里的历史临时文件。
- 目前不提供无线、批量/递归目录传输、断点续传或图库导入。跨存储、机型文件权限与大文件限制以实际设备为准。
在「设备与应用」选择官方 SDK 的 `hdc` 可执行文件。插件会尝试发现 PATH 或 `/Applications`、`~/Applications` 中 DevEco Studio 的内置工具。典型路径:
## 构建、验证与发布
```text
/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc
```
要求 macOS、Xcode Command Line Tools、Rust/Cargo、Node.js、pnpm、make、pkg-config。源码归档及哈希固定在 `vendor/harmony-connector/`。构建不需要另装 libmtp。
也可使用华为官方 Command Line Tools。保留 HDC 同目录的依赖库,插件不重打包官方 SDK。选择路径保存在插件自己的数据目录中。手机需启用开发者模式与 USB 调试,并信任这台 Mac。管理页明确选择 HDC 设备,不根据 MTP USB 地址猜测二者对应关系。
| 操作 | 行为与边界 |
| --- | --- |
| 设备连接与信息 | 显示 HDC 目标、USB/TCP 类型、授权/连接状态;检查 `bm`、`aa`、`uitest`、`hilog` 等实际工具帮助。未知格式报错,不伪装成无设备。 |
| 应用列表与详情 | 按包名筛选,读取版本、签名类型和设备公开的可启动组件。不根据包名猜测 `EntryAbility`。 |
| 安装/更新 | 选择已签名 `.hap`;展示包名、版本、大小、SHA-256 并确认。执行前复制到临时快照并重新校验哈希;安装后读取实际版本。签名或权限错误不会触发自动卸载。 |
| 卸载 | 用户确认后操作;不移除系统应用,结束后检查应用是否确实消失。 |
| 启动/强制停止 | 从实际公开的组件选择启动入口;强制停止需要确认,报告设备返回结果。 |
| 截图 | 使用 UITest 捕获,下载并解码验证 PNG 后保存。只清理本次创建的远端临时图片。 |
| 日志 | 导出 `hilog -x` 快照到用户选择的目录。日志正文中的错误信息不会被误判为命令失败。 |
| 诊断报告 | 使用 HDC `bugreport` 保存报告,拒绝把空结果作为成功。 |
| 调试数据导出 | 仅对调试签名且设备允许访问的应用开放;复制 `data/storage/el2/base`,附带路径、大小、SHA-256 清单。应用通常需已启动。 |
调试数据导出是运行中文件的副本,不保证数据库事务一致性,不包含全部应用状态,不提供一键恢复。HDC 对空目录及空子目录的行为也不同于 MTP 目录传输。普通发布应用私有数据、整机备份/恢复和受保护系统数据没有可供该插件普遍使用的公开权限;不能因为 `backup_tool` 退出码为 0 就宣称备份成功。
管理操作使用独立串行任务,支持取消与超时;取消不回滚设备端已经完成的更改。操作开始前重新确认所选设备在线。功能有命令支持仍可能被单个应用、对象或系统权限拒绝,失败会显示设备返回的原因。
## 投屏和控制调研
**本版本尚未实现实时投屏或键鼠控制。** 官方 scrcpy 的设备端依赖 Android、ADB、`app_process` 和 Android 屏幕/输入 API,不能直接连接原生鸿蒙。仅能运行 APK 并不足以证明 scrcpy 兼容。[scrcpy 官方说明](https://github.com/Genymobile/scrcpy)
最新 DevEco Studio 与 DevEco Testing 已提供官方 Mac 投屏功能;目前未找到可嵌入本插件的公开投屏协议。可行的插件路线是先以 HDC + UITest 实现截图预览和明确的点击/滑动输入,再单独验证用户授权的鸿蒙采集端、视频编码和传输。连续视频方案需要设备端适配,不能把截图轮询宣传成 scrcpy 视频流。详见[投屏与键鼠控制调研](research/harmony-screen-control.md)。
## 重连问题与 debug
0.1.1 真机记录:HUAWEI Pura 70 Ultra,USB `12d1:1101`,USB 名称 `HDC Device`,MTP 友好名称 `HUAWEI Pura 70 Ultra`,型号代码 `HBP-AL00`。设备未通过 MTP 返回自定义名称 `daniel`。连接后可读取存储,断开请求约 0.27 毫秒返回,USB 释放约 61 秒后结束;**这不证明随后能重新打开会话**。用户报告仍需拔线。
0.2.0 没有将未经真机验证的 USB reset 或超时补丁当作重连修复。调研确认当前 libmtp 标志已经执行 reset,新增同样 reset 不能作为修复依据。须区分 CloseSession、接口释放、USB reset、重新枚举和设备端会话状态。见[会话恢复调研](research/harmony-mtp-session.md)。本轮诊断没有发现 USB 设备,故重连仍待真机复现和修复。
关闭插件窗口,保留一台解锁且处于「传输文件」模式的手机,然后执行:
```sh
# 仅枚举
curios/harmony-connector/backend/harmony-connector --diagnose
# 只读执行三轮打开、存储/目录读取、关闭与重新打开;不要在插件占用 USB 时运行
curios/harmony-connector/backend/harmony-connector --check-reconnect
```
`--check-reconnect` 逐阶段输出 JSON、耗时和实际目录读取结果,不修改手机文件。没有恰好一台设备或任何一轮失败时以非零状态结束;原生 CloseSession 仍可能等待 USB 超时。不能仅凭扫描发现设备、按钮可用或 reset 返回成功判定验收通过。
## 构建与发布
要求 macOS、Xcode Command Line Tools、Rust/Cargo、Node.js、pnpm、make、pkg-config。固定源码归档与哈希位于 `vendor/harmony-connector/`。
```sh
pnpm install --frozen-lockfile
@@ -50,42 +93,10 @@ pnpm run harmony:pack
pnpm run release:prepare --entries dist/harmony-release.json
```
以上命令只构建 Harmony Connector,发布时保留生产索引中其他插件和历史版本。索引和不可变 `.curio` 包必须同一提交推送,不能覆盖已发布的同版本同架构文件。
发布创建新的不可变版本产物,保留旧版本、哈希和生产源的已有条目。推送后必须以 Git 仓库为源执行安装验证。生产 README 保持产品介绍;技术约束与验收记录在本文和 `docs/research/`。
推送后验证真实 Git 软件源:
代码职责:`native/src/file_transfer.rs` 管理目录计划与落盘,`progress.rs` 统计进度,`connector.rs` 管理 MTP 会话与任务,`mtp.rs` / `mtp_bridge.c` 封装固定依赖,`hdc.rs` / `hdc_data.rs` / `hdc/files.rs` 管理官方工具与输出校验,`command.rs` 控制进程、输出、取消和时限。UI 的文件与管理工作区分开实现。
```sh
TROVE_APP_DIR=/absolute/path/to/app \
pnpm run verify --remote-source https://git.render.ink/trove/curios.git \
--source-id trove-official --source-kind git
```
验证包括文件夹往返与失败清理、会话状态、工具命令和解析、安装确认及后置条件、页面交互、USB 桥接和底座协议。HDC 测试使用明确的工具替身,不代表商业手机已经完成安装、备份或截图验收。实际文件吞吐量和 USB 往返 SHA-256 仍需真机记录。
界面测试使用独立的显式模拟设备,预览脚本和数据不进入安装包:
```sh
pnpm run harmony:build
node tools/preview-harmony.mjs
# 打开 http://127.0.0.1:18770
```
## 代码与接口
| 位置 | 职责 |
| --- | --- |
| `curios/harmony-connector/ui/` | 独立界面,通过版本化 Trove SDK 调用 Host 文件选择器与 `@self` |
| `native/src/connector.rs` | 会话串行化、文件校验、后台传输任务、取消及完成状态 |
| `native/src/mtp.rs`、`mtp_bridge.c` | libmtp 所有权、结构转换、文件描述符传输 |
| `native/src/wire.rs` | Trove Wire v1 认证握手、请求分发与退出 |
| `build.mjs`、`legal/` | 固定依赖构建、动态库路径修正、签名与第三方声明 |
`@self` 接口:`info`、`devices`、`monitor`、`connect({id})`、`disconnect`、`list({storage,parent,offset?})`、`mkdir({storage,parent,name})`、`transfer(...)`、`transferStatus`、`cancelTransfer({id})`。根目录为 `0xffffffff`;目录响应每页最多 500 个条目,传输异步返回任务 ID,界面轮询实际进度。后端同一时刻仅执行一个 MTP 操作;进度查询、取消及 USB 存在性检查不等待 MTP 锁。重新连接后必须重新获取对象列表。
## 验证记录与真机验收
自动测试包含 16 项传输与会话行为测试、10 项完整界面回归测试、2 项界面数据测试、3 项原生 USB 边界检查,以及 Trove 的握手、并发响应、退出协议测试。传输行为测试使用独立内存设备并实际读写临时目录;界面回归测试加载真实页面和 SDK,在模拟硬件边界下验证自动发现、名称、手动断开、慢速 USB 释放、拔线、旧响应与页面关闭。原生边界检查复现静态表 `Nova14` 与设备实际报告信息不同的场景,并验证友好名称和 USB 存在性检测。
2026-09-10,HUAWEI Pura 70 Ultra 的实机只读连接测试返回 USB ID `12d1:1101`、USB 名称 `HDC Device`、MTP 友好名称 `HUAWEI Pura 70 Ultra`、型号代码 `HBP-AL00`,并成功读取内部存储信息。该设备的 MTP 返回值未包含系统设置中的自定义名称。实机还复现了关闭 MTP 会话时的长时间等待;原生调用栈停在等待 `CloseSession` 响应的位置。0.1.1 实测断开请求约 0.27 毫秒返回并清空连接状态,后台释放约 61 秒后结束,随后恢复可操作状态。
**这些结果不能代替 HarmonyOS 6 真机兼容性验收。** 当前未记录任何机型的真实 USB 往返或吞吐量。连接实际手机后,按[调研文档验收清单](research/harmony-connector.md#验收与尚未证明的部分)补充完整机型/系统版本、空文件/中文/二进制/大文件往返 SHA-256、图库导出、取消、拔线、重新连接及退出的结果。
调研依据与备选 HDC 方案见[技术调研](research/harmony-connector.md)。依赖构建与替换说明见[第三方声明](../curios/harmony-connector/legal/THIRD-PARTY.md)。
参考:[设备管理能力](research/harmony-device-management.md)、[会话恢复](research/harmony-mtp-session.md)、[投屏与控制](research/harmony-screen-control.md)、[第三方声明](../curios/harmony-connector/legal/THIRD-PARTY.md)。
+268
View File
@@ -0,0 +1,268 @@
# Harmony Connector 手机管理能力调研
调研日期:2026-09-10。承接 [USB 文件传输调研](harmony-connector.md),评估 Harmony Connector 从文件工具扩展为手机助手。以调研时的最新官方文档设计能力,不以 HarmonyOS 6 为硬上限;HUAWEI Pura 70 Ultra 是待验收设备,不能凭系统名称或文档命令表宣称真机已支持。
本文只做资料核查和本机工具定位,没有连接手机、安装应用、读取手机数据或改变设备状态。华为商业 HarmonyOS 文档与 OpenHarmony 开源资料分别标注。后者能证明对应源码/命令契约,不能直接证明商业固件的编译选项、授权策略与运行行为。
## 结论与实现优先级
建议保留 MTP 用户文件通道,并增加可选的本机官方 HDC 通道。HDC 支持 Mac,适合应用管理、调试文件和诊断;华为要求先开启并授权 USB/无线调试。普通文件传输不应因为新增管理能力而强制进入开发者模式。[华为 HDC](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc)、[华为开发者选项](https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/ide-developer-mode)、[华为 USB 文件传输](https://consumer.huawei.com/cn/support/content/zh-cn16090699/)
以下优先级是本项目的工程建议,表示可实现范围,尚非真机验收结果:
| 优先级 | 功能 | 启用条件与产品表达 |
| --- | --- | --- |
| P0 | HDC 工具选择、版本和连接状态;设备基本信息 | 找到可运行工具,用户选择目标,授权成功;失败分清工具缺失、未授权、离线、命令不支持。 |
| P0 | 应用列表、应用详情、签名 HAP 安装、单应用卸载 | 包管理命令可用;安装预览包信息和覆盖影响;卸载显示具体包和数据损失。 |
| P0 | 日志快照、系统报告导出 | 用户触发,限时/限量,保存到用户选择的位置。 |
| P1 | 截图、应用启动、强制停止 | 先探测设备工具和真实组件;动作只针对用户选定对象。 |
| P1 | 调试应用数据导出;导出本机已有的安装包 | 沙箱导出逐应用检查调试签名与权限;安装包归档只处理用户拥有的原包,不等同从设备提取所有应用。 |
| P1 | 用户文件备份任务 | 明确选择范围、清单、校验和、失败项;仅称“所选文件备份”。 |
| P2 | 调试应用数据恢复、无线调试、实时日志/进程、UI 控制 | 探测成功后开放;恢复/控制/无线切换属于改变设备状态的动作。 |
| 暂不承诺 | 全机镜像、所有第三方应用数据备份、联系人/短信数据库直接读写、任意已安装应用导出并可恢复 | 未找到面向普通第三方 macOS 助手的官方公共接口;不能由 MTP 或 HDC shell 推导得到。 |
## 工具获取、发现和授权
华为官方获取路径为 DevEco Studio 所带 SDK,或 Command Line Tools 的 SDK;macOS 下典型位置为 `DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc`。独立运行还需保留相邻 `libusb_shared` 依赖。这说明可以使用已安装的官方工具,不能据此推断可以重新打包分发华为整个 SDK。[华为 HDC 环境准备](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc)、[官方工具下载入口](https://developer.huawei.com/consumer/cn/download/)
实现建议:先读用户保存的绝对路径,再查应用自身继承的 `PATH`,最后检查 `/Applications`、`~/Applications` 的 DevEco Studio 应用包及用户选定的 Command Line Tools 目录。解析软链接,验证是文件且可执行,显示最终路径。不要全盘扫描、执行下载来的未知工具或自动修改 shell 配置;GUI 的 PATH 不一定与交互终端相同。
本次本机只读发现:`PATH` 中无 `hdc`;在 `/Applications`、`~/Applications` 和存在的 `~/Library/OpenHarmony`、`~/Library/Huawei`、`~/Library/DevEcoStudio`、`~/ohos-sdk` 范围未找到候选工具。这不是“整台电脑绝无 HDC”的结论。没有安装 SDK,也没有执行设备命令。
首次开启开发者选项与随后打开调试开关是不同步骤:华为手机/平板指引要求在设备名称页连续点击软件版本七次,确认并按需输入 PIN,首次开启可能触发重启。之后 USB/无线调试需在手机确认计算机授权。插件应引导用户操作设备,不绕过锁屏、信任弹窗、企业调试管控。[华为开发者选项](https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/ide-developer-mode)
OpenHarmony HDC 仓库采用 Apache-2.0。若未来需要随插件交付 HDC,可评估从确切开源提交自行构建并保留许可证、版权与依赖声明;商业 Huawei 下载包的分发权需核对其实际许可,本次未确认该授权。不能把仓库许可扩展至 SDK 的其他组件或使用华为商标暗示官方产品。[OpenHarmony HDC LICENSE](https://github.com/openharmony/developtools_hdc/blob/e93f663afa159d3e601cad1e1869d70326731af6/LICENSE)
## CLI 调用契约
以下表格中 `key` 是用户选定的 `connect-key`,`bundle`/`ability`/`module` 来自设备已返回且验证过的数据;`local` 是用户选择的绝对本地路径。数组是传给 `Command::new(hdc).args(...)` 的 argv,省略可执行文件本身。带尖括号的值只作占位,不能原样执行。
宿主不要使用 `sh -c` 拼接 HDC 命令。`hdc shell` 的最后一个参数仍会由设备 shell 解释,必须用固定模板与严格参数验证/单引号转义;宿主 argv 安全不等于远端 shell 安全。文件名、应用名和设备返回内容都不是可信 shell 代码。
### 只读探测与设备信息
| 功能 | argv | 证据与处理 |
| --- | --- | --- |
| 客户端版本 | `["-v"]` | 解析 `Ver:` 字段,保留未知版本原文。 |
| 客户端/服务端版本 | `["checkserver"]` | 记录两端版本,不能认为刚选择的二进制已经替换后台旧服务。 |
| 设备状态 | `["list", "targets", "-v"]` | 识别 `USB/TCP` 和 `Unknown/Ready/Connected/Offline/Unauthorized`;`[Empty]` 表示没有目标。 |
| HDC 帮助 | `["-h", "verbose"]` | 作为本机命令存在性的证据。 |
| 包管理能力 | `["-t", key, "shell", "bm help"]` | 成功识别帮助内容后再开放相关操作。 |
| 组件管理能力 | `["-t", key, "shell", "aa help"]` | 不通过实际启动/停止应用来探测。 |
| 截图工具能力 | `["-t", key, "shell", "uitest help"]` | 要求返回可识别 `screenCap` 帮助。 |
| 日志工具能力 | `["-t", key, "shell", "hilog -h"]` | 确认 `-x` 可用。 |
| 基本设备信息 | `["-t", key, "shell", "hidumper -c base"]` | 华为文档展示 BuildId、ReleaseType、OsVersion、DeviceType 等字段。 |
| 参数读取 | `["-t", key, "shell", "param get <name>"]` | 单项读取,键需要固件实际返回/探测;未知键不应显示成空字符串的正常值。 |
HDC 命令和状态依据 [华为 HDC](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc);设备信息依据 [华为 HiDumper](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/hidumper);`param get` 语法依据 [OpenHarmony param 文档](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/tools/param-tool.md)。`hdc -t key shell hdcd -v` 是守护程序版本探测,不能当作手机完整系统版本。
实现应把“工具不存在”“没有授权”“权限不足”“输出无法识别”“版本不支持”与“数据为空”区分开。每个能力使用 `unknown / available / unavailable` 加原因;仅凭全局 HDC 连接成功不能一次性点亮所有管理功能。连接阶段不要执行 `kill -r`、`reconnect`、`tmode`、重启或任何包管理写操作。
### 应用列表、安装、卸载与运行
| 功能 | argv | 条件 |
| --- | --- | --- |
| 包名列表 | `["-t", key, "shell", "bm dump -a"]` | 华为官方 FAQ 直接采用该命令查询已安装应用。 |
| 包详情 | `["-t", key, "shell", "bm dump -n <bundle>"]` | 华为 HiDumper 指引用详情中的 `appProvisionType` 判断 debug。 |
| 调试包列表 | `["-t", key, "shell", "bm dump -g"]` | OpenHarmony 最新文档支持;需以本机帮助/实际输出确认。 |
| 应用显示名 | `["-t", key, "shell", "bm dump -n <bundle> -l"]` | OpenHarmony 文档标注 API 20 起;失败仍可显示包名。 |
| 安装签名 HAP | `["-t", key, "install", local]` | 本地绝对路径,成功后重新查询包版本。 |
| 单应用卸载 | `["-t", key, "uninstall", bundle]` | 指定包名;拒绝自动级联卸载依赖。 |
| 启动组件 | `["-t", key, "shell", "aa start -b <bundle> -a <ability> -m <module>"]` | 从详情找可启动、已导出的真实组件;不能硬编码所有应用都有 `EntryAbility`。 |
| 强制停止 | `["-t", key, "shell", "aa force-stop <bundle>"]` | 与正常退出不同,可能丢失尚未保存的工作。 |
查询依据:[华为应用市场错误排查](https://developer.huawei.com/consumer/en/doc/harmonyos-references/errorcode-appgallery)、[华为 HiDumper](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/hidumper)。`bm` 详细参数与 user/root 限制依据 [OpenHarmony bm 文档](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/tools/bm-tool.md)。`aa` 的显式启动、exported 限制、强停和响应依据 [OpenHarmony aa 文档](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/tools/aa-tool.md);商业华为 HDC 将 aa 列为配套工具,但本次华为 aa 独立页面未成功抓取,故实际能力仍需设备探测。
安装不是“有 HAP 就能装”:华为要求真实设备使用签名包,自动签名会绑定调试设备信息。debug/release 签名、应用标识、版本和多模块一致性受安装器校验;不能把 OpenHarmony 的公开测试证书当作商业设备通用签名,也不能从 HDC 命令存在推导任意 release 包均可安装。[华为开发入门](https://developer.huawei.com/consumer/cn/develop-novice-guide/)、[华为自动签名](https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/ide-signing-auto)、[华为安装与更新一致性校验](https://developer.huawei.com/consumer/cn/doc/doccenter-getting-started/install-and-update-consistency-verification)
华为 HDC 说明 `install` 默认就是覆盖安装,省略 `-r` 不能防止覆盖;安装路径支持 HAP/应用内 HSP,APP 包从 API 22 起。`-w` 等组合参数有特殊解析约定,初版建议先只用必要参数。用户操作之前展示即将安装的应用与已装版本;签名冲突或系统拒绝时保留错误码,不能自动先卸载再安装。卸载共享库可能因其他应用依赖而被拒绝。[华为 HDC 安装说明](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc)、[华为共享包依赖错误 9568312](https://developer.huawei.com/consumer/cn/doc/doccenter-dev-faq/faqs-technology-32)
包名清单是行文本,详情可能有标签/前缀加 JSON 内容,不能将所有 `bm` 命令输出当作同一个稳定 JSON API。建议仅接受明确命令对应的格式:列表验证包名,详情解析完整 JSON 对象并校验预期字段与查询包名;无法解析则显示原始诊断并禁用依赖该字段的操作。显示名不能作为包名、权限或可卸载性的替代。安装和卸载需同时判断进程结果、工具成功/失败信息及操作后的目标状态,不能只看是否含 `AppMod finish`。
### 截图、日志和系统报告
1. 探测 `uitest help` 后,为每次截图生成不可预测且只含安全字符的文件名。
2. 调用 `["-t", key, "shell", "uitest screenCap -p /data/local/tmp/curios-<nonce>.png"]`。
3. 调用 `["-t", key, "file", "recv", "/data/local/tmp/curios-<nonce>.png", localTemp]`。
4. 校验下载文件确实是可解码 PNG,再提交到用户目标位置。只删除本次创建的远端文件;失败时不要清理整个临时目录。
`uitest screenCap -p`、只能存到 `/data/local/tmp/` 的限制,以及 `-d` 多屏参数从 API 20 开始,均来自 [OpenHarmony UITest 官方文档](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/application-test/uitest-guidelines.md)。华为官方也提供 HiSmartPerf 的截图采集/文件导出流程,但本次没有将 `snapshot_display` 的商业固件可用性或任何投屏帧率作为已确认能力。[华为 HiSmartPerf](https://developer.huawei.com/consumer/cn/doc/doccenter-testing/smartperf-guidelines)
| 导出 | argv | 实现建议 |
| --- | --- | --- |
| 日志快照 | `["-t", key, "shell", "hilog -x"]` | `-x` 非阻塞读完退出,宿主将 stdout 写到独立临时文件。 |
| 实时日志 | `["-t", key, "shell", "hilog"]` | 持续读取,提供停止按钮和大小限制;不要视为普通短请求。 |
| 系统报告 | `["-t", key, "bugreport", localTemp]` | FILE 是本地路径;内容可能较多,预留更长但有限的超时。 |
| 已有落盘日志 | `["-t", key, "file", "recv", "/data/log/hilog", localDir]` | 只有权限与路径确认后才能导出,不需先开启新的落盘任务。 |
`hilog -x` 语义依据 [OpenHarmony HiLog](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/dfx/hilog.md);`bugreport` 与现有落盘日志路径依据 [华为 HDC](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc)。日志与系统报告可能包含应用活动和设备标识;默认只本地保存,不自动上传。不要为普通日志导出自动执行清日志、关闭隐私过滤或开启持续日志落盘。
## 备份和恢复:需要分别命名的三种能力
### 用户文件副本
MTP 面向设备公开的存储对象。选择文件夹后递归复制、生成大小/校验和清单、记录失败项,是可以实现的“所选文件备份”;没有通过 MTP 暴露的沙箱、系统设置、账号和数据库不在范围内。图库模式限制沿用原调研,不能把普通文件夹可写推导为可任意恢复图库数据库。[libmtp 对象/存储 API](https://github.com/libmtp/libmtp/blob/v1.1.23/src/libmtp.h.in)、[华为 USB 文件传输](https://consumer.huawei.com/cn/support/content/zh-cn16090699/)
### 调试应用数据导出与导入
华为明确 `shell -b`、`file send/recv -b` 只用于“调试证书签名且已经在设备上启动”的应用;不是任意应用的沙箱读取入口。`-b` 从 API 15 引入。先查询 `appProvisionType`,再对具体目录进行只读访问探测,不应单凭包名出现在应用列表就启用备份。[华为 HDC 沙箱条件](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc)、[华为 HiDumper 调试类型判定](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/hidumper)
```text
查询指定调试应用目录:
["-t", key, "shell", "-b", bundle, "ls data/storage/el2/base/"]
导出用户选择的沙箱文件或目录:
["-t", key, "file", "recv", "-b", bundle, "<verified-remote-path>", "<new-local-directory>"]
向同一调试应用导入已选择的数据:
["-t", key, "file", "send", "-b", bundle, "<local-file>", "<verified-remote-path>"]
```
这些是官方 `-b` 文件操作语法,remote path 应在该应用实际可见的根目录内,不使用 `/data/app/...` 猜测另一个应用的路径,不接受越界 `..`。导出清单应记录设备/系统、包名、版本、签名类型、所选路径、时间、实际复制文件及校验结果。正在运行的应用可能继续写入数据库;原始文件副本不自动获得事务一致性,也不保证跨设备的密钥、登录状态或应用版本可恢复。建议界面名称为“导出调试应用数据”,完整备份成功必须有明确的范围与验证。
恢复是覆盖写入,不应在枚举或探测时尝试。只允许用户选择明确的备份和目标应用,核对包名/版本/范围,展示将修改的文件后执行。未经该应用可恢复性验证,不提供一键“全部应用恢复”。不要通过重新签名现有商业应用或卸载重装来制造沙箱访问权限。
### 官方系统备份
华为应用备份框架允许应用接入 `BackupExtensionAbility` 并声明可备份范围,但普通应用自身不能触发系统备份;触发功能面向系统应用。官方最新验证指引使用数据克隆,由新旧设备配对触发。应用可不接入,也可限制备份内容。因此额外装一个普通手机伴随应用,并不自然获得其他应用全部数据的备份权。[华为备份概述](https://developer.huawei.com/consumer/en/doc/harmonyos-guides-V13/app-file-backup-overview-V13)、[华为应用接入](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/app-file-backup-extension)、[华为数据克隆验证](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/app-file-backup-dataclone)
特别检查了 OpenHarmony `filemanagement_app_file_service` 提交 `589be5d84d3c23967be62ca7f0317690237a0041`:
- `backup_tool/BUILD.gn` 仅在 `build_variant == "root"` 编入真实操作,且 `install_enable = false`。
- 未定义 `BACKUP_TOOL_ENABLE` 时,`main()` 直接返回 0,不执行备份。
- 备份服务检查 BACKUP 权限;HAP 调用还检查系统应用身份,shell 调用亦受 UID 条件限制。
这是开源实现证据,不能证明商业设备使用完全相同实现;却直接说明“找到 backup_tool 且退出码为 0”不是备份成功证据。本轮不将该工具作为商业设备备份后端,也不自动尝试备份/恢复命令。[OpenHarmony 构建约束](https://github.com/openharmony/filemanagement_app_file_service/blob/589be5d84d3c23967be62ca7f0317690237a0041/tools/backup_tool/BUILD.gn)、[无操作入口](https://github.com/openharmony/filemanagement_app_file_service/blob/589be5d84d3c23967be62ca7f0317690237a0041/tools/backup_tool/src/main.cpp)、[服务权限检查](https://github.com/openharmony/filemanagement_app_file_service/blob/589be5d84d3c23967be62ca7f0317690237a0041/services/backup_sa/src/module_ipc/service.cpp)
## 与官方助手的能力对照
| 官方路线 | 已确认范围 | 对本插件的结论 |
| --- | --- | --- |
| HiSuite / 华为手机助手 | 当前官网针对 HarmonyOS 5+ 列出基础数据、部分第三方应用及数据、部分设置备份;受系统/应用兼容性限制。明确 5+ 暂不支持 macOS 连接。 | Windows 官方能力不等于 macOS 第三方插件可调用的协议/API。不能用旧 Android/HDB 教程保证原生鸿蒙兼容。 |
| 手机“备份和恢复 → 外部存储” | 官方 5+ 指引支持第三方应用和部分应用数据等,并需备份密码;范围以实际设备为准。 | 可以提供官方操作指引,或归档用户已经生成的备份文件;尚无公开证据支持插件在 Mac 直接触发/解密其备份。 |
| 数据克隆 | 最新开发者指引通过两台设备触发已接入应用的数据迁移验证。 | 是系统侧迁移流程,不是通用电脑备份 CLI。 |
| 华为分享 + 鸿蒙星河互联 | 官方提供与 Mac 的图片、视频、文档无线互传流程。 | 可链接官方方案;没有据此获得可嵌入分享 SDK、应用安装或全机备份能力。 |
分别依据:[华为 HiSuite 当前支持范围](https://consumer.huawei.com/cn/support/hisuite/)、[华为外部存储备份](https://consumer.huawei.com/cn/support/content/zh-cn16024725/)、[华为数据克隆验证](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/app-file-backup-dataclone)、[华为与 Mac 互传](https://consumer.huawei.com/cn/support/content/zh-cn16093657/)。产品可以覆盖“文件、应用、设备、诊断、备份”模块;不同模块应展示实际通道与数据范围,不把官方系统级功能画成已经可执行的按钮。
## 无线与其他能力
用户先在手机开启无线调试,输入手机显示的 IP 和端口,然后调用 `["tconn", "<ip>:<port>"]`;断开用 `["tconn", "<ip>:<port>", "-remove"]`。官方仍将 TCP 调试标为测试场景。`tmode port` 会重启设备守护进程并断开会话,连接探测不应使用它。端口转发、重启、UI 输入可以作为后续显式操作,不默认启用。[华为 HDC 无线和连接管理](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc)
最新文档版本提示用于解释能力差异,不作为 HarmonyOS 大版本硬编码:`-b` 与 API 15/HDC 3.1.0e 配套;API 20/HDC 3.2.0b 增加部分网络能力;APP 安装 API 22;安装自动授权 `-g` API 24;沙箱无命令交互式 shell、reconnect、子服务器等 API 26 新能力仍需两端支持。实现按本机帮助和设备实际结果逐项判定,不自动调用新命令来“修复”连接。[华为 HDC 版本表](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc)
## 输出解析、操作风险与验收
建议为 HDC 建立独立执行适配器:固定可执行路径、每次携带目标、有限并发、逐设备串行写入、进程组取消、stdout/stderr 大小上限、操作超时。长日志和文件任务走流式输出,UI 接收结构化事件,不在前端解释 shell 字符串。不要为了隔离无条件另开 USB server,也不要在退出插件时杀掉用户 DevEco Studio 的共享 HDC 服务。
最小验收应覆盖可观察行为:
- 无工具、工具依赖缺失、未连接、拒绝授权、多设备、未知输出格式、版本不匹配,都不得显示假空列表或操作成功。
- 应用列表中中文显示名与包名分离;详情解析失败不影响其他条目;安装拒绝/签名冲突/包格式错误保留真实结果;成功安装/卸载后刷新验证。
- 签名 HAP 安装只能使用用户专门选取的测试包;卸载只能操作明确选择的应用。验证不得卸载用户日常应用来测试失败路径。
- 截图下载后能解码,报告文件非空且包含实际内容,日志导出有限且可取消;中断不会把临时文件报告为完成。
- 调试沙箱必须验证 release 应用被限制、debug 未启动的处理、权限失败、部分传输、中文/空格路径;导出实际文件校验和对应清单。恢复只在专门测试应用验证,并检查恢复后的应用行为。
- 系统备份无法实现时明确展示边界,不执行空壳 `backup_tool`,不以退出码 0、模拟输出、无设备测试或构建通过替代真机验收。
本调研最终产物仅为本文。文档命令来自第一方资料,设备兼容性仍需按实际固件验证;本机未找到 HDC 的范围见上文,未对手机执行任何命令。
## 补充:应用输出解析与真实入口选择
这一节按 2026-09-10 获取的 OpenHarmony 源码追踪输出生产链:Bundle Framework `e0b9be9be36186e040385895f7f764e6e111a809` 与 bm CLI `6b9b9ad316b764f472194a962081c0fb99c32fdf`。下面是**由源码生成规则还原、省略无关字段的代表样例**,不是 HUAWEI Pura 70 Ultra 的实测输出。商业设备返回新结构时应报告格式未知,不能假装已经得到相同数据。
### `bm dump -a` / `bm dump -g`
成功输出是按用户分组的纯文本,不是 JSON;当前 CLI 直接转发服务结果,没有额外 `all bundle names:` 标题。样例中的应用名是占位数据,缩进实际为制表符。服务支持多个用户段;CLI 默认查询当前用户,指定不活跃 `-u` 可能产生前置警告并退回当前用户。[BMS 列表生成](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/services/bundlemgr/src/bundle_mgr_host_impl.cpp#L2999)、[bm CLI 查询和转发](https://github.com/openharmony/bundlemanager_bundle_tool/blob/6b9b9ad316b764f472194a962081c0fb99c32fdf/frameworks/src/bundle_command.cpp#L1153)
```text
ID: 100:
com.example.notes
com.example.reader
```
解析建议:保留 userId 分组;只在已识别 `ID: <integer>:` 段内读取并验证包名;允许空段和空行;未知诊断行不能被当作应用。不要把任意含点字符串提取成包名,也不要把错误文本归一化成零应用。空结果或查询失败时,当前 CLI 会输出下面的错误,且 dump 分支仍可能返回成功退出码。[bm 空结果处理](https://github.com/openharmony/bundlemanager_bundle_tool/blob/6b9b9ad316b764f472194a962081c0fb99c32fdf/frameworks/src/bundle_command.cpp#L1246)、[缺包查询官方系统测试](https://github.com/openharmony/bundlemanager_bundle_tool/blob/6b9b9ad316b764f472194a962081c0fb99c32fdf/test/systemtest/bm/bm_command_dump_system_test.cpp#L106)
```text
error: failed to get information and the parameters may be wrong.
```
### `bm dump -n <bundle>` 的字段
服务先输出包名和冒号,再输出完整 JSON 对象。它显式移除顶层 `abilityInfos`,组件应从 `hapModuleInfos[].abilityInfos[]` 读取;不能把 manifest 的 `abilities` 字段直接当作 dump 格式。[BMS 详情组装](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/services/bundlemgr/src/bundle_mgr_host_impl.cpp#L3094)、[模块序列化](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/interfaces/inner_api/appexecfwk_base/src/hap_module_info.cpp#L927)
```text
com.example.notes:
{
"name": "com.example.notes",
"versionCode": 1000000,
"versionName": "1.0.0",
"entryModuleName": "entry",
"applicationInfo": {
"bundleName": "com.example.notes",
"appProvisionType": "debug"
},
"hapModuleInfos": [
{
"moduleName": "entry",
"mainAbility": "NotesAbility",
"mainElementName": "NotesAbility",
"isStageBasedModel": true,
"abilityInfos": [
{
"bundleName": "com.example.notes",
"moduleName": "entry",
"name": "NotesAbility",
"type": 1,
"visible": true,
"enabled": true,
"isStageBasedModel": true,
"isLauncherAbility": false,
"skills": [
{
"actions": ["ohos.want.action.home"],
"entities": ["entity.system.home"]
}
]
}
]
}
]
}
```
准确映射与限制:
| 需要的信息 | dump 字段与解释 |
| --- | --- |
| 应用身份/版本 | 顶层 `name`、`versionCode`、`versionName`;并校验 `applicationInfo.bundleName` 与查询目标一致。 |
| 调试签名 | `applicationInfo.appProvisionType`;调试值 `debug`,缺失是未知。 |
| 入口模块 | 顶层 `entryModuleName`;模块使用 `moduleName`,不是模块显示用的 `name`。 |
| 模块主组件 | `hapModuleInfos[].mainElementName`、`mainAbility`;当前 Stage 解析都来源于 manifest 的 `mainElement`。 |
| 实际组件 | `hapModuleInfos[].abilityInfos[]`,每项携带 `bundleName`、`moduleName`、`name`。 |
| 可见/导出状态 | dump 使用布尔 `visible`。Stage manifest 的 `exported` 写入内部 `visible`,且比旧 `visible` 配置优先;dump 不使用 `exported` 字段。 |
| UI 组件类型 | `type` 是数字枚举:`UNKNOWN=0`、`PAGE=1`、`SERVICE=2`、`DATA=3`、`FORM=4`、`EXTENSION=5`;Stage UIAbility 解析为 PAGE。 |
| 是否可用 | `enabled` 是布尔;显式 false 时不能作为启动候选。缺字段不应凭空生成 true。 |
| 桌面入口 skill | 同一项 `skills[]` 中,`actions` 含 `ohos.want.action.home` 或 `action.system.home`,且 `entities` 含 `entity.system.home`。 |
| `isLauncherAbility` | 不是普通应用“可从桌面启动”的通用标志。当前实现只在特殊 launcher entity、预装且系统应用等条件下设置。不能要求普通入口它必须为 true。 |
这些映射依据:[BundleInfo 序列化](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/interfaces/inner_api/appexecfwk_base/src/bundle_info.cpp#L898)、[ApplicationInfo 序列化](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/interfaces/inner_api/appexecfwk_base/src/application_info.cpp#L1064)、[组件 JSON 字段](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/interfaces/inner_api/appexecfwk_base/include/common_json_converter.h#L669)、[组件类型枚举](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/interfaces/inner_api/appexecfwk_base/include/ability_info.h#L57)、[exported 解析](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/services/bundlemgr/src/module_profile.cpp#L636)、[Stage 类型赋值](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/services/bundlemgr/src/module_profile.cpp#L2702)、[模块入口赋值](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/services/bundlemgr/src/inner_bundle_info.cpp#L1800)、[HOME skill 与 launcher 判定](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/services/bundlemgr/src/module_profile.cpp#L3005)、[HOME 字符串常量](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/interfaces/inner_api/appexecfwk_base/include/bundle_constants.h#L174)。
实现建议:从合法详情中选择 `type == 1 && visible == true`,排除明确禁用项,并校验组件包名/模块名;优先具有 HOME skill 的组件,再用入口模块和主组件字段消除歧义。没有唯一可确定入口时展示实际组件选择,不自动启动任意 exported 页面。`visible`、`type` 缺失时不能直接宣称可启动;旧格式适配应增加明确来源与 fixture。权限仍由系统决定,元数据筛选通过不保证启动一定成功。以上代表样例对应 argv 为 `["-t", key, "shell", "aa start -b com.example.notes -a NotesAbility -m entry"]`。
解析 JSON 时应先识别查询包名的前缀,再对完整对象解析;校验身份和字段类型,限制输入大小/嵌套深度。不要从错误文本中随意截取第一个到最后一个花括号,或在详情中递归搜同名 `name` 来猜应用身份。没有完整有效详情时保留应用条目和错误,禁用依赖组件/调试状态的操作。
## 补充:`hdc file recv -b` 目录层级
OpenHarmony HDC `e93f663afa159d3e601cad1e1869d70326731af6` 的收件端根据**传输开始时本地目标是否存在**决定是否保留源目录 basename;`-b` 在设备端解析沙箱路径,本地主机仍走同一文件接收逻辑。以下以已确认可读的 `data/storage/el2/base` 目录中存在 `files/a.txt` 为例。[HDC 源目录任务生成及沙箱处理](https://github.com/openharmony/developtools_hdc/blob/e93f663afa159d3e601cad1e1869d70326731af6/src/common/file.cpp#L266)、[接收端目标目录和文件名处理](https://github.com/openharmony/developtools_hdc/blob/e93f663afa159d3e601cad1e1869d70326731af6/src/common/transfer.cpp#L724)
| 调用前本地状态 | 接收目标 argv 最后一个参数 | 最终文件位置 |
| --- | --- | --- |
| `/exports/job` 已存在且为目录 | `/exports/job` | `/exports/job/base/files/a.txt` |
| `/exports/job` 不存在 | `/exports/job` | `/exports/job/files/a.txt` |
| `/exports/job` 已存在且为普通文件 | `/exports/job` | 目录模式报错,不能作为合法备份目标。 |
源路径加尾部 `/` 不构成“只复制内容”的开关;源 basename 提取前会去除尾部分隔符。目标不存在时接收器先建目标,再从相对任务路径剥掉第一层;目标已经存在则保留第一层。不要用 rsync 的尾斜杠规则推断 HDC。[basename 实现](https://github.com/openharmony/developtools_hdc/blob/e93f663afa159d3e601cad1e1869d70326731af6/src/common/base.cpp#L1823)、[CheckFilename 首层处理](https://github.com/openharmony/developtools_hdc/blob/e93f663afa159d3e601cad1e1869d70326731af6/src/common/transfer.cpp#L801)
建议先创建唯一、空的 staging 父目录,例如 `/exports/.curios-job-<nonce>`,再传 `["-t", key, "file", "recv", "-b", bundle, "data/storage/el2/base", "/exports/.curios-job-<nonce>"]`;验收期望位于 `staging/base/...` 的输出,记录 `remoteRoot` 与 `localRelativeRoot: "base"`。完成后按清单提交用户目标。恢复必须依清单映射,不能将整个 `base` 再送入设备已经存在的 `base`,制造 `base/base`。若想把目录内容直接放入目标,可使用尚不存在的目标路径,但实现必须控制初始状态,并在对应本机 HDC 版本做行为验收。
该源码递归任务以文件为单位,纯空源目录直接报 `Operation failed, because the source folder is empty.`;空子目录也不能按文件任务数量证明完整保留。不能把这类报错改写为完整备份成功。若产品承诺保留空目录,应另行枚举并记录空目录、处理权限失败,或将“不保留空目录”列入导出范围。源码不替代当前华为 SDK 二进制的行为测试。[目录遍历实现](https://github.com/openharmony/developtools_hdc/blob/e93f663afa159d3e601cad1e1869d70326731af6/src/common/transfer.cpp#L658)、[空目录官方测试](https://github.com/openharmony/developtools_hdc/blob/e93f663afa159d3e601cad1e1869d70326731af6/test/scripts/testModule/test_hdc_file.py#L502)
+116
View File
@@ -0,0 +1,116 @@
# Harmony Connector:MTP 会话恢复与能力探测
调研日期:2026-09-10。本文补充 [初始技术调研](harmony-connector.md),只记录源码、公开问题和建议验证路线,不声称完成了真机修复。支持范围按实际 MTP 能力判定,不以 HarmonyOS 版本、机型字符串或 USB product 字符串限制。
本次任务提供的现象:macOS、libmtp 1.1.23、libusb 1.0.30;Pura 70 Ultra 的 USB 标识为 `12d1:1101`,USB product 为 `HDC Device`,MTP friendly name 为 `HUAWEI Pura70Ultra`,MTP model 为 `HBP-AL00`。0.1.1 在 `LIBMTP_Release_Device` 的 CloseSession 等待约 61 秒后超时;界面已异步处理断开,但用户报告需要拔插才能重连。上述为主任务提供的真机信息,本研究代理未操作 USB。
## 可用于实现决策的结论
1. **libmtp 现有路径已经会 reset,不能把“新增 reset”当成修复。** `12d1:1101` 表项使用 `DEVICE_FLAGS_ANDROID_BUGS`,其中同时包含 `LONG_TIMEOUT` 和 `FORCE_RESET_ON_CLOSE`。前者选择 60 秒 USB 超时,后者在释放接口后、关闭 USB handle 前 reset。约 61 秒现象与该等待路径吻合,但仍须日志确认实际失败阶段。[设备表](https://github.com/libmtp/libmtp/blob/v1.1.23/src/music-players.h#L3290)、[标志定义](https://github.com/libmtp/libmtp/blob/v1.1.23/src/device-flags.h#L320)、[USB 实现](https://github.com/libmtp/libmtp/blob/v1.1.23/src/libusb1-glue.c#L2087)
2. **OpenHarmony 开源代码存在能产生 CloseSession 无响应的明确控制流。** 它把协议成功码 `0x2001` 当成内部函数返回值,但组包调用方要求 `0`;因此在写响应前退出。该路径也未清除会话状态。它能解释同类症状,尚不能证明这台商业固件包含相同代码。完整链条见下节。
3. **`LIBMTP_Reset_Device` 不是 USB reset。** 它仅在设备广告 `ResetDevice(0x1010)` 时发送 MTP 操作;上述 OpenHarmony 实现又把该操作路由到 CloseSession 的同一实现。不能用它替代 USB 层恢复。[libmtp 实现](https://github.com/libmtp/libmtp/blob/v1.1.23/src/libmtp.c#L3633)、[OpenHarmony 路由](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/src/mtp_operation.cpp#L249)
4. **建议优先验证“短时限关闭 → 释放接口 → 一次 USB reset → 关闭 handle → 新枚举重连”。** 必须记录每阶段结果,不能静默保留会话并显示已断开。单纯更换 session ID、再加既有标志或一直重试,均没有充分依据。
## OpenHarmony CloseSession 的具体问题
此次 GitHub API 查询到的 `openharmony/multimedia_media_library` 当前提交为 `4a411de74cfaf694c14820a51a0e900b04241fe6`。以下结论固定到该提交,描述其开源实现,不将 OpenHarmony 与商业 HarmonyOS 固件等同。
### 响应没有写入的调用链
| 层次 | 所查行为 | 影响 |
| --- | --- | --- |
| 内部成功码 | `MTP_SUCCESS = 0`。 | C++ 内部处理成功应返回 0。[定义](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/frameworks/utils/include/medialibrary_errno.h#L30) |
| 协议成功码 | `MTP_OK_CODE = 0x2001`。 | 这是 MTP 响应容器的 response code。[定义](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/include/mtp_constants.h#L413) |
| CloseSession payload | `Parser` 在 session 已开时返回 `MTP_OK_CODE`;`Maker` 写入该数值后也返回 `MTP_OK_CODE`。 | 返回值与调用层的内部成功码约定不一致。[实现](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/src/payload_data/close_session_data.cpp#L34) |
| MtpPacket | `ParserPayload` 传播返回值;`MakerPayload`、`Maker` 只有返回 `MTP_SUCCESS` 才继续。 | CloseSession 的组包结果为 `0x2001`,被当作失败返回。[实现](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/src/mtp_packet.cpp#L166) |
| MtpOperation | 收包失败也尝试 `SendMakeResponsePacket`,但该方法在 `responsePacketPtr_->Maker(false) != MTP_SUCCESS` 时提前返回。 | 无论从正常还是错误响应路径进入,都会在 `Write` 之前结束。[实现](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/src/mtp_operation.cpp#L129) |
这是静态控制流分析:在所查代码和正常对象初始化前提下,可以追踪到“收到关闭请求,主机却收不到关闭响应”的路径;并非手机端日志或固件反编译结果。`CalculateSize` 也调用这个返回协议码的 `Maker`,不过无需依赖长度问题,调用方对返回值的检查已足以解释写入被跳过。
### 会话状态与重新打开
`DealRequest` 在 OpenSession 时检查 `sessionOpen`,未打开时保存参数中的 `tempSessionID`,已打开时返回 SessionAlreadyOpen;`GetOpenSession` 在成功响应时设置 `sessionOpen=true`。CloseSession 的 `Parser` 只检查已有会话,`GetCloseSession` 只创建 payload,没有将 `sessionOpen` 设为 false 或将 `sessionID` 清零;`ResetOperation` 只重置当前命令、transaction ID 和数据阶段。所查 `services/media_mtp` 源文件未找到关闭时清除这些会话字段的其他赋值。[打开处理与每次操作重置](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/src/mtp_operation.cpp#L213)、[会话响应处理](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/src/mtp_operation_utils.cpp#L668)、[初始状态](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/include/mtp_operation_context.h#L49)
因此,新的非零 session ID 不会自动绕过 `sessionOpen` 检查。设备端 `ResetDevice` 和 CloseSession 又使用同一个 payload 与响应处理,不能从 OperationsSupported 含 `0x1010` 推断它真的重置了会话。[payload 工厂](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/src/packet_payload_factory.cpp#L65)
## libmtp 的关闭、打开与身份语义
1.1.23 的 `LIBMTP_Release_Device` 返回类型是 `void`:它调用 `close_device`,然后清错误栈、释放 `PTP_USB`、`PTPParams`、存储和 device 对象。因而 Release 返回后,旧 `device`、`usbinfo` 和 USB handle 都不可继续使用;仅靠该 API 的返回值也拿不到关闭是否成功。[释放实现](https://github.com/libmtp/libmtp/blob/v1.1.23/src/libmtp.c#L2513)
关闭顺序为 `ptp_closesession → close_usb`;后者默认执行 `clear_stall → libusb_release_interface → 按标志 libusb_reset_device → libusb_close`。reset 和 release 的返回值在此没有向调用者报告。连接路径先以 5 秒时限尝试 OpenSession,遇 `PTP_ERROR_IO` 已会 reset、关闭 USB、重新初始化并重试;接受 `OK` 和 `SessionAlreadyOpened`。若回到 retry,正常设备超时可能又是 60 秒。[1.1.23 USB 流程](https://github.com/libmtp/libmtp/blob/v1.1.23/src/libusb1-glue.c#L2304)
截至本次调研的 libmtp 当前提交 `9de46078d15d290ac5c46039937b1164b918dbf0`,CloseSession 和 OpenSession 的上述恢复路径仍存在,未发现可通过直接升级主分支获得的对应修复。[当前代码](https://github.com/libmtp/libmtp/blob/9de46078d15d290ac5c46039937b1164b918dbf0/src/libusb1-glue.c)
libmtp 使用 session 参数 `1`。`ptp_opensession` 自行将 OpenSession transaction ID 归零,并在事务后将本地 session ID 设为传入值。修改随机 ID 必须连同调用约定理解;它不会修复无响应或设备端 `sessionOpen` 不清除。代码中 InvalidTransactionID 分支先加 10,再调用会归零的 `ptp_opensession`,因此也不能把该分支视为可靠的 ID 恢复保证。[PTP 实现](https://github.com/libmtp/libmtp/blob/v1.1.23/src/ptp.c#L1364)
`12d1:1101` 的上游记录来自 Nova14 的 ID 报告,只证明这个 USB ID 已入表;它不是对 Pura 70 Ultra 的型号鉴定。USB product `HDC Device` 也不否定同一复合设备上的 MTP 接口。应用应展示实际 MTP friendly/model 信息,使用选中 USB 实例及实际接口建立会话;不能将表中的 Nova14 名称硬编码为真实机型。[上游 issue #334](https://github.com/libmtp/libmtp/issues/334)、[设备表](https://github.com/libmtp/libmtp/blob/v1.1.23/src/music-players.h#L3290)
libmtp README 本身记录了设备 CloseSession 实现不佳、首次打开成功而后续失败的历史问题;这是固件兼容问题的先例,不是当前设备根因的独立证明。[libmtp README](https://github.com/libmtp/libmtp/blob/v1.1.23/README)
## macOS USB reset 的限制与调用位置
`libusb_reset_device` 的公开契约是 USB port reset,并尝试恢复先前 configuration/alternate settings;如果重枚举后的设备改变而返回 `LIBUSB_ERROR_NOT_FOUND`,调用者应关闭旧 handle 并重新发现设备。它不是“手机已经接受新的 MTP 会话”的保证。[libusb API](https://libusb.sourceforge.io/api-1.0/group__libusb__dev.html#gafee9c4638f1713ca5faa867948878111)
libusb 1.0.30 Darwin 后端在普通、未 capture 的 macOS 路径调用 `USBDeviceReEnumerate`,等待重枚举,再恢复状态;源码说明 10.11 起旧 `ResetDevice` 路径不再有效。它有专门的重枚举等待和返回值,不能省略检查,也不能与另一线程的 close/open/reset 并发。[Darwin reset 实现](https://github.com/libusb/libusb/blob/v1.0.30/libusb/os/darwin_usb.c#L2139)
2026-08 的上游 [issue #1923](https://github.com/libusb/libusb/issues/1923) 报告 Darwin 在接口仍 claimed 时 reset、再 claim 的旧接口泄漏,以及 restore 失败后 claim 位丢失。对应 [PR #1942](https://github.com/libusb/libusb/pull/1942) 在本次查询时仍未合并。报告包含 macOS 实验,但设备是 SEGGER J-Link、部分失败来自故障注入,不能当成本项目手机已命中该问题。它足以说明:**不能因为其他库先 reset 就无条件把本项目 reset 移到 release_interface 之前。**
建议的调用位置:
| 位置 | 判定 |
| --- | --- |
| 传输中、另一个线程仍执行 MTP 时 | 不使用;先串行收束当前事务,避免重置仍在使用的句柄。 |
| CloseSession 失败后、USB handle 尚有效 | 可以由拥有该 handle 的底层实现处理;优先释放接口后 reset,检查结果,再 close。 |
| `LIBMTP_Release_Device` 返回后,拿旧 `usbinfo` reset | 错误:对象和 handle 已释放。 |
| Release 完成后,新枚举并打开同一 USB 实例的全新 libusb handle,再 reset | 可作为受控恢复路线;这时不要先 OpenSession 再 reset。需要准确定位设备、检查返回值并重新枚举,不能仅按 VID/PID 匹配第一台。 |
| 正常关闭也无条件反复 reset | 不作为额外策略;现有 quirk 已可能 reset,重复执行增加重枚举和占用干扰。 |
在本项目固定版本的 C 边界内,关闭超时和原始 reset 状态若无法经公开 API取得,可用小范围、可重现的依赖补丁返回状态;避免在 Rust/前端按内存偏移解包私有结构。若用新 handle 做恢复,不应复用旧 bus/device address 作为永久身份;重枚举可能使它失效。[libusb 枚举与 handle 说明](https://libusb.sourceforge.io/api-1.0/group__libusb__dev.html)
## 其他 macOS MTP 工具能提供什么证据
| 实现 | 观察 | 可借鉴与边界 |
| --- | --- | --- |
| OpenMTP 的 go-mtpx/go-mtpfs | `go-mtpx` 固定到 `go-mtpfs` 提交 `1c3302b3c476`。`Close` 遇 CloseSession 错误会先 USB Reset,再 ReleaseInterface、Close;`Configure` 遇 SessionAlreadyOpened 先尝试 CloseSession/OpenSession,其余失败 reset、close、等约一秒、重新打开。 | 证明一种现实恢复路线存在,但不是这台设备或当前 libusb 1.0.30 的验证。不要直接继承“约一秒足够”的假设。[依赖](https://github.com/ganeshrvel/go-mtpx/blob/18f12db021cc4256396748dbd2996d15853d7033/go.mod)、[关闭和配置](https://github.com/ganeshrvel/go-mtpfs/blob/1c3302b3c476f69e787a0339122a804dd518a007/mtp/mtp.go#L94) |
| go-mtpfs session ID | 每次选择非零、非 `0xffffffff` 的随机 session ID。 | 是它自身的实现选择;没有证明随机化能解决 OpenHarmony 的状态未清除。[实现](https://github.com/ganeshrvel/go-mtpfs/blob/1c3302b3c476f69e787a0339122a804dd518a007/mtp/ops.go#L16) |
| Android File Transfer for Linux/macOS(whoozle) | Session Close 发 CloseSession 并等响应;通用 Device Open 有可选 reset,但其 Darwin backend 的 `Device::Reset()` 是空实现。 | 不能从工具支持 macOS 或方法名推断它进行了有效 USB reset。这是开源独立项目,非 Google 的同名 Android File Transfer。[项目](https://github.com/whoozle/android-file-transfer-linux)、[session](https://github.com/whoozle/android-file-transfer-linux/blob/88926930db41238c7b4d7237fc5849b9586cc7b8/mtp/ptp/Session.cpp#L105)、[Darwin 空实现](https://github.com/whoozle/android-file-transfer-linux/blob/88926930db41238c7b4d7237fc5849b9586cc7b8/mtp/backend/darwin/usb/Device.cpp#L38) |
本次未取得 Google 闭源 Android File Transfer 的第一方 host 源码,因此不声称其关闭顺序或私有恢复方式。OpenMTP 的 Huawei P30 Pro [公开问题 #452](https://github.com/ganeshrvel/openmtp/issues/452) 报告的是目录/传输故障,也不能作为 HarmonyOS 当前设备的重连成功证明。
## 按实际设备能力开放功能
建立会话后读取设备 `OperationsSupported`、`DevicePropertiesSupported`,对象功能还要结合格式、属性描述、存储写权限和操作实际返回值。广告支持不等于本次操作成功,也不等于所有对象类型都可用。OpenHarmony 所查代码声明了 delete、move、copy、thumbnail、battery 和对象属性,但实际商业设备返回值才是应用开关依据。[OpenHarmony DeviceInfo 声明](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/src/payload_data/get_device_info_data.cpp#L32)
以下为 libmtp 1.1.23 的公开 API 映射;操作码由其 PTP 定义核对。[公开头文件](https://github.com/libmtp/libmtp/blob/v1.1.23/src/libmtp.h.in)、[PTP 操作定义](https://github.com/libmtp/libmtp/blob/v1.1.23/src/ptp.h)
| 功能 | 公开 API | 探测与实现边界 |
| --- | --- | --- |
| 改名 | `LIBMTP_Set_File_Name`、`LIBMTP_Set_Folder_Name` | 按实际格式查询 `ObjectFileName(0xdc07)`;要求 `GetObjectPropDesc(0x9802)` 的 GetSet 为可写,并有可用的 `SetObjectPropValue(0x9804)` 或 SetObjectPropList。库的改名 helper 自己检查 GetSet。不要新用已弃用的 `LIBMTP_Set_Object_Filename`。 |
| 删除 | `LIBMTP_Delete_Object` | `DeleteObject(0x100b)`;仍处理 ObjectWriteProtected、AccessDenied 等对象级拒绝。目录递归由应用计划与执行,库文档不保证删除目录会删除全部子对象。 |
| 移动 | `LIBMTP_Check_Capability(...MoveObject)`、`LIBMTP_Move_Object` | `MoveObject(0x1019)`;目标根 parent 参数是 `0`。目录和跨存储行为须验证。标准命令无字节进度,不显示虚假百分比。 |
| 设备内复制 | `LIBMTP_Check_Capability(...CopyObject)`、`LIBMTP_Copy_Object` | `CopyObject(0x101a)`;无字节进度;API 仅返回成功/失败,不返回新对象 ID,完成后重新枚举目标目录。不支持时不可把移动偷偷变成复制后删除。 |
| 缩略图 | `LIBMTP_Get_Thumbnail` | `GetThumb(0x100a)`,并按对象是否有缩略图处理。失败显示普通图标;它不是整图预览保证。RepresentativeSample 是另一组属性/API。 |
| 电量 | `LIBMTP_Get_Batterylevel` | `BatteryLevel(0x5001)` 在设备属性列表中;API还检查 broken battery quirk。只有调用成功且最大值有效时换算,失败/不支持显示未知,不能显示 0%。 |
| 对象属性读取 | `LIBMTP_Is_Property_Supported`、`LIBMTP_Get_String_From_Object`、`LIBMTP_Get_u64/u32/u16/u8_From_Object` | `GetObjectPropsSupported(0x9801)` 按格式,`GetObjectPropDesc(0x9802)`、`GetObjectPropValue(0x9803)` 按具体属性。`Is_Property_Supported` 返回正数/0/负数分别为支持/不支持/查询失败,不能把 -1 当成 true。 |
| 对象属性写入 | `LIBMTP_Set_Object_String/u32/u16/u8`、`LIBMTP_Get_Allowed_Property_Values` | 存在不等于可写;遵循 GetSet、数据类型、枚举/范围。后一个 API 返回合法值,不直接暴露 GetSet;不能据它推断写权限。 |
| 目录下载 | `LIBMTP_Get_Files_And_Folders` + `LIBMTP_Get_File_To_File_Descriptor` | 主机遍历对象树,使用各 storage ID/parent ID;不是单一“递归传输”操作。按实际项汇总大小、进度和失败。 |
| 目录上传 | `LIBMTP_Create_Folder` + `LIBMTP_Send_File_From_File_Descriptor` | 以 association/folder 对象建立层级,再逐文件传输;基础操作为 SendObjectInfo/SendObject 或设备支持的 SendObjectPropList 路径。保留空目录、逐项处理重名和失败。 |
公开 `LIBMTP_Check_Capability` 仅覆盖 GetPartialObject、SendPartialObject、EditObjects、MoveObject、CopyObject,不能拿它询问任意操作码。若前端需要全部 OperationsSupported,应在与固定 libmtp 一起编译的 C 桥接层提供只读投影,或给依赖增加受控 API,不从 UI 解析 `mtp-detect` 文本。[能力实现](https://github.com/libmtp/libmtp/blob/v1.1.23/src/libmtp.c#L4132)
GetObjectPropList 也要遵循 libmtp 的已知兼容标志,不能为了“一次取全树”强制启用。对于当前 ID,上游已有 BROKEN_MTPGETOBJPROPLIST/BROKEN_SET_OBJECT_PROPLIST/BROKEN_SEND_OBJECT_PROPLIST;这些是库的兼容策略,不表示设备完全不能改名或传输。[兼容标志](https://github.com/libmtp/libmtp/blob/v1.1.23/src/device-flags.h#L320)
递归删除宜先枚举用户选中的子树,再从叶到根执行。复制/移动目录时,不能仅凭标准操作支持就承诺任意层级、跨存储、同名覆盖或取消的原子性。libmtp 文档明确警告目录操作语义与耗时;应用级递归必须保留部分成功状态,不能自动删除原文件作为未说明的降级。[删除/移动/复制契约](https://github.com/libmtp/libmtp/blob/v1.1.23/src/libmtp.c#L6922)
## 最小验证路线
以下是建议给主任务执行的实验,不是本文已经执行的验证。
1. 串行记录一次连接、只读目录、断开、重连全过程。区分 CloseSession 写请求、等响应、release_interface、USB reset、USB close 的耗时与返回码;确认旧后端进程/线程是否退出、旧 handle 是否完成释放。不要把设备名可枚举当成 MTP 会话已恢复。
2. 将关闭独立于正常传输超时:CloseSession 超时后继续清理,避免反复调用它;不改变大文件传输的合理超时。先保留当前 release→reset 顺序,检查这次 reset 是否真的完成。若产品需要状态回传,在固定库边界内显式提供结果。
3. 旧 handle 全部关闭后,按新枚举结果重连,实际完成 GetStorageIDs 与至少一次目录读取。只观察 UI 按钮恢复、扫描返回设备、reset 返回 0 均不足以通过。
4. 只在基线有完整日志后比较候选方案:释放接口后 reset;必要时用全新 raw USB handle 做独立恢复。一次实验只变一处,避免短超时、随机 ID、不同 reset 顺序、额外库补丁同时变化而无法定位。
5. 若 reset 返回 NOT_FOUND,按 API 重新发现设备;若 reset 无响应或设备不重新出现,显示恢复失败和真实状态,不无限循环。硬件重新插拔能恢复,不足以证明软件重枚举一定具备等价效果。
6. 修复通过后,在同一 USB 插接周期连续完成多次 connect→browse→disconnect→connect,并包含插件关闭/退出再打开。写操作能力在专用测试目录验证:改名、删除、移动、复制、中文/emoji、空文件、空目录、嵌套传输,下载回传比对哈希;缩略图和电量失败应保留其余浏览能力。
本研究实际完成:读取项目固定依赖源码;在线查询并固定第一方仓库提交;核对 OpenHarmony 会话调用链、libmtp API、libusb Darwin reset、OpenMTP 与 whoozle 实现;查询相关公开 issue/PR 状态。未修改实现、未打开或重置 USB、未把开源静态分析标为商业设备实测根因。
+134
View File
@@ -0,0 +1,134 @@
# Harmony Connector 投屏与键鼠控制调研
调研日期:2026-09-10。范围是 macOS 上的 Harmony Connector,目标包括原生 HarmonyOS 和 OpenHarmony,按最新公开资料评估,不以某个系统大版本作硬上限。承接 [设备管理调研](harmony-device-management.md)。本轮仅阅读官方文档及项目源码,未安装工具、执行手机命令或改变设备状态;以下命令是实现契约/待验证方案,不是真机验收记录。
## 结论
**官方 scrcpy 不能直接作为原生鸿蒙投屏后端。** 它的设备端是 Android framework 上的 Java/Dex 程序,经 ADB 启动;把可执行文件名从 adb 换成 hdc,不能替代 Android 运行时、屏幕采集和输入 API。官方 scrcpy 当前 README 为 v4.1,设备要求 Android API 21+,macOS 是支持的电脑平台。只有设备确实提供兼容 Android 的运行时、ADB shell 及相应屏幕/输入能力时,才适合另外启用 scrcpy 通道;手机品牌、能运行某些 APK 或存在兼容容器都不是充分条件。[scrcpy 官方项目](https://github.com/Genymobile/scrcpy)、[服务端架构](https://github.com/Genymobile/scrcpy/blob/master/doc/develop.md)
本项目可按以下顺序推进,优先级是工程建议:
| 路线 | 画面与输入 | 本项目建议 |
| --- | --- | --- |
| HDC + UITest CLI | 单次截图;可轮询预览。点击、长按、滑动、指定文本、常用键。 | P0:复用现有 HDC 后端;显示“屏幕预览与操作”,不承诺视频级流畅度。 |
| 官方 DevEco Studio / DevEco Testing | 官方已提供设备投屏、点击/滑动/字符输入等。 | P0:提供官方工具入口及指引;未查到可嵌入第三方插件的公开投屏协议。 |
| 官方系统录屏 CLI | 录制后导出 MP4。 | P1:作为“录屏导出”独立功能,不把 MP4 文件导出当实时镜像流。 |
| 自建正常签名的鸿蒙伴随应用 + AVScreenCapture + HDC 转发 | 经手机用户同意采集,编码后持续传输;输入可另走 UITest。 | P2:真实视频流的优先独立原型,必须验证商业设备授权、后台持续采集和编码器。 |
| OpenHarmony UITest addon | 自建 native 扩展,JPEG 帧回调及原子输入 API。 | P2:开源实现可研究,商业固件可加载性/ABI 未验证。 |
| 鸿镜 / OpenHarmony_Scrcpy | 已有连续编码视频及控制实现。 | 面向可配置 OpenHarmony 系统的参考,当前系统权限/root 要求不适合作为普通华为手机的默认方案。 |
| 官方 scrcpy | Android 视频流及键鼠控制。 | 仅为已验证 Android/ADB 设备提供独立可选入口。 |
## scrcpy 依赖与直接集成边界
scrcpy 客户端推送 server 到设备并启动,视频、音频、控制使用分离 socket。server 使用 Android `app_process` 加载 Dex,屏幕关联 Surface 经 `MediaCodec` 编码,输入通过 Android `InputManager.injectInputEvent()` 等方法处理;其中包含通过反射调用的隐藏 API。客户端解码使用 FFmpeg、窗口输入使用 SDL。项目明确客户端和 server 必须版本一致,其内部协议不保证跨版本兼容。[官方开发说明](https://github.com/Genymobile/scrcpy/blob/master/doc/develop.md)
因此 `hdc fport` 只能替代传输中的一小部分,不能运行 Android server;要复用 scrcpy UI/解码需要替换连接、启动器、设备端采集/编码/输入,并协调协议,属于移植工程。不能声称下载官方 scrcpy 后便支持原生 HarmonyOS。对具有 Android 兼容能力的设备,按实际授权 ADB 目标识别,运行受控的原生 scrcpy 会话验收;不要根据 HarmonyOS 名称硬编码“可用/不可用”。[scrcpy 连接方式](https://github.com/Genymobile/scrcpy/blob/master/doc/connection.md)、[服务端约束](https://github.com/Genymobile/scrcpy/blob/master/doc/develop.md)
若用户已经安装官方 scrcpy 和 adb,候选 Android 通道的只读预检可使用 `scrcpy --version`、`adb devices -l`、`adb -s <serial> shell getprop ro.build.version.sdk`。实际会话由用户点击后执行以下 argv;macOS 上优先启动独立窗口,减少嵌入原生渲染的成本。此处参数组合是本项目建议,不是鸿蒙兼容证明。[官方连接参数](https://github.com/Genymobile/scrcpy/blob/master/doc/connection.md)、[视频参数](https://github.com/Genymobile/scrcpy/blob/master/doc/video.md)、[控制参数](https://github.com/Genymobile/scrcpy/blob/master/doc/control.md)
```text
scrcpy argv: ["--serial", serial, "--no-audio", "--max-size=1280", "--max-fps=30"]
仅观看时再添加: ["--no-control"]
```
scrcpy 的 `--otg` 通过 Android Open Accessory 模拟 USB HID,不依赖 ADB,但它**没有投屏**。不能将 OTG 模式宣传成绕开鸿蒙设备端视频支持的办法,也不能未验证便承诺手机实现相应 USB accessory 协议。[官方 OTG 文档](https://github.com/Genymobile/scrcpy/blob/master/doc/otg.md)
macOS 官方文档提供预编译包和包管理方式。集成可优先使用用户已安装工具;本轮没有下载运行任何发布二进制。scrcpy 为 Apache-2.0,重新分发还需审核所选构建中的第三方依赖及其声明;这不授予 Huawei SDK 的重打包许可。[macOS 获取方式](https://github.com/Genymobile/scrcpy/blob/master/doc/macos.md)、[scrcpy LICENSE](https://github.com/Genymobile/scrcpy/blob/master/LICENSE)
## 可立即实现:HDC 屏幕预览和操作
HDC 需设备开启开发者调试并信任电脑。以下固定使用用户选择的 `key`;宿主用 argv 调用,不执行本机 `sh -c`。设备端 shell 参数仍需严格转义,坐标和速度仅接受有界数字。[华为 HDC](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc)
| 操作 | HDC argv(省略 hdc 本身) |
| --- | --- |
| 版本 | `["-t", key, "shell", "uitest --version"]` |
| 工具帮助 | `["-t", key, "shell", "uitest help"]` |
| 输入帮助 | `["-t", key, "shell", "uitest uiInput help"]` |
| 截图 | `["-t", key, "shell", "uitest screenCap -p /data/local/tmp/curios-screen-<nonce>.png"]` |
| 下载截图 | `["-t", key, "file", "recv", remoteTemp, localTemp]` |
| 点击 | `["-t", key, "shell", "uitest uiInput click 160 240"]` |
| 长按 | `["-t", key, "shell", "uitest uiInput longClick 160 240"]` |
| 滑动 | `["-t", key, "shell", "uitest uiInput swipe 160 720 160 320 600"]` |
| 拖拽 | `["-t", key, "shell", "uitest uiInput drag 160 240 480 640 600"]` |
| 当前焦点文本 | `["-t", key, "shell", "uitest uiInput text <escaped-text>"]` |
| 指定输入框文本 | `["-t", key, "shell", "uitest uiInput inputText 160 240 <escaped-text>"]` |
| 返回/主页 | `["-t", key, "shell", "uitest uiInput keyEvent Back"]` / `Home` |
精确 CLI 来源是 [OpenHarmony UITest 指引](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/application-test/uitest-guidelines.md)。截图 `-p` 只支持 `/data/local/tmp/`;`-d <displayId>` 从 API 20 起。`swipe`/`drag` 最后参数是速度 px/s,范围 200–40000,**不是毫秒时长**。`text` 从 API 18 起,只有当前焦点支持输入时才有实际效果。键码取 OpenHarmony `KeyCode`,不能照搬 Android keycode 或 macOS keycode;特殊 `Back/Home/Power` 不支持组合键。[UITest 操作定义](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/README_zh.md)、[OpenHarmony KeyCode](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/reference/apis-input-kit/js-apis-keycode.md)
工程实现建议:
- 查看预览和启用控制分开;进入页面不自动注入按键。开始预览后串行截图、下载、解码,完成一帧再请求下一帧,避免积压。可先设置约 1 秒的刷新目标,实际显示采集时间,不宣传固定 FPS。
- 按当前 PNG 真实尺寸和画面显示区域计算坐标,排除留白;旋转/折叠后刷新尺寸,暂停旧坐标输入。鼠标拖动收集起终点后发送一次 swipe/drag,不能伪装成实时多点触控。
- 每个目标限制一个屏幕会话,输入队列有上限;用户停止、窗口隐藏、设备断开或授权失效时立即停止排队,避免恢复连接后补发旧输入。文本使用单独输入框和发送按钮,不全局捕获电脑键盘。
- 对中文、空格、引号、emoji、新行和输入法组合做真实验证。不要为此同步读取剪贴板或关闭密码键盘。输出可解析/退出码正常只能表示命令执行结果,截图刷新或实际界面变化才是操作效果证据。
- 保存截图使用临时文件并校验 PNG;默认帧只在本机短暂缓存。清理只删除本会话生成的远端/本地文件,不碰用户相册。锁屏和受保护画面保持系统行为,不自动解锁或关闭隐私保护。
`uiRecord record` 是操作记录,不是视频录屏;其记录可写到 CSV,并不产生 H.264 视频。不要把它接成“录制投屏”。[UITest uiRecord](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/application-test/uitest-guidelines.md)
## 官方工具:确有投屏,但没有可确认的第三方嵌入协议
DevEco Studio 官网当前明确支持连接设备投屏、点击、滑动、字符输入及拖拽包/文件;26.0.0 Release 的官方说明将设备投屏列为新增能力。产品提供 macOS Intel/ARM 版本。它是真实设备投屏能力,不能与“鼠标控制模拟器”的教程混用。[DevEco Studio 官网](https://developer.huawei.com/consumer/cn/deveco-studio)、[26.0.0 官方版本概览](https://developer.huawei.com/consumer/cn/doc/doccenter-release-notes/overview-2600)、[仅针对模拟器的操控屏幕文档](https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/ide-emulator-control-screen)
DevEco Testing 官方 FAQ 确认 Windows/Mac 客户端、设备投屏等测试工具;要求 HarmonyOS NEXT 设备开启开发者模式和 USB 调试,并能被 HDC 识别。官方工具介绍明确其投屏支持在 PC 上操作设备。最新 26.0.0 兼容表仍将“设备投屏”的模拟器列标为不支持;不要把云手机特定接入指引外推到所有本地模拟器。[DevEco Testing FAQ](https://devecotesting.huawei.com/userPortal/help?jobType=100000102)、[官方工具介绍](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V14/devecotesting-V14)、[最新兼容表](https://developer.huawei.com/consumer/cn/doc/doccenter-release-notes/deveco-testing-releasenotes-2600)
本轮没有从这些第一方资料确认可供第三方插件使用的投屏 SDK、稳定 socket 协议、可重新分发的 device agent 或指定设备投屏 deep link。因此可以引导用户打开已安装的官方应用并选择设备;不能提取其私有 agent 并当作插件依赖。`Device Manager` 只是工具内管理入口的名称,不能据二手教程编造其稳定 CLI。本轮未检视商业工具安装包,不判断其内部实现。
## 官方录屏 CLI:结束后导出视频
华为官方录屏文档要求设备解锁;录制中锁屏会退出,文档不支持模拟器录屏。其命令流程是启动系统录屏组件、停止、定位媒体、必要时复制到可下载目录、下载文件,未给出实时 stdout 视频流协议。[华为录屏文档](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ide-screen-recording)
```text
开始:
["-t", key, "shell", "aa start -b com.huawei.hmos.screenrecorder -a com.huawei.hmos.screenrecorder.ServiceExtAbility --ps CustomizedFileName curios-record-<nonce>.mp4"]
停止(官方写法仍为 aa start,同组件但不传文件名):
["-t", key, "shell", "aa start -b com.huawei.hmos.screenrecorder -a com.huawei.hmos.screenrecorder.ServiceExtAbility"]
定位录制结果:
["-t", key, "shell", "mediatool query curios-record-<nonce>.mp4 -u"]
查询含 uri 时,用查询返回且已验证的 URI 导出:
["-t", key, "shell", "mediatool recv <escaped-returned-uri> /data/local/tmp"]
下载工具实际返回的路径:
["-t", key, "file", "recv", returnedRecordFile, localTemp]
```
官方说明有 URI 时不能直接下载查询结果里的原始媒体路径;需 `mediatool recv` 得到可下载路径。实现不要只盲取“第几行”,应验证 URI、返回路径和唯一文件名。开始/停止是设备写操作,不能作为能力探测;录屏组件的停止写法也不能无限重试。用会话状态管理、手机通知和最终可解码文件验收,未知状态引导在手机停止。本功能会生成设备媒体文件,默认不能顺手删除相册原件。[华为录屏文件导出流程](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ide-screen-recording)
## 真正视频流:自建伴随应用路线
AVScreenCapture 支持将采集结果作为码流交给其他模块,以实现桌面共享。最新 OpenHarmony 指引区分文件输出和码流输出,并说明码流录制开始会出现隐私选择弹窗;受保护内容可被屏蔽。华为最新窗口级录屏文档亦明确 Phone/Tablet 的 Picker 能力和权限条件。因此“普通应用一概不能录屏”不正确;需要区别用户授权录屏与系统免授权采集/输入注入。[OpenHarmony 录屏基础流程](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/media/media/avscreencapture-c-basic-process.md)、[华为窗口级录屏与授权](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/using-avscreencapture-for-file-with-window)
建议独立原型链路为:用户启动已签名 HAP并在手机允许共享 → `OH_AVScreenCapture_Create/Init` → 绑定编码器输入 Surface 后 `OH_AVScreenCapture_StartScreenCaptureWithSurface` → `OH_VideoEncoder` 输出 H.264 → 应用内 loopback TCP → HDC fport → macOS VideoToolbox/受控播放器。采集、编码和传输分别处理错误与背压;音频初版关闭。停止使用 `OH_AVScreenCapture_StopScreenCapture/Release`,并关闭编码器和本会话转发。详细 API 配置从配套 SDK 的官方示例实现,不复制系统免授权权限。[官方采集到编码器 Surface 示例](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/media/media/avscreencapture-c-custom-scenarios.md)
传输示例为 `["-t", key, "fport", "tcp:<hostPort>", "tcp:<companionPort>"]`,停止后仅移除本次建立的转发 `["-t", key, "fport", "rm", "tcp:<hostPort>", "tcp:<companionPort>"]`;两个端口是自建协议配置,HDC 本身不会生成视频。使用 loopback、每会话随机 token、长度上限和协议版本;需要网络直连时另行设计用户配对与认证,不默认监听 `0.0.0.0`。手机端普通 HAP 不由录屏授权获得任意输入注入权限;键鼠仍可通过已授权 HDC 的 UITest 命令完成。此路线需新建手机工程、签名与真机验证,当前插件未具备。[华为 HDC 端口转发](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc)
## OpenHarmony UITest addon:可研究的 native 扩展
检查官方 `testfwk_arkxtest` 提交 `b04e30c40cf266c9abfbd52933e43a0456eb99a2`:`uitest/addon/extension_c_api.h` 定义 `UiTestExtension_OnInit`、`UiTestExtension_OnRun`,通过 `UiTestPort.initLowLevelFunctions` 获取 `startCapture/stopCapture`、`atomicTouch`、`atomicMouseAction` 等函数。`startCapture` 名称 `copyScreen` 接收 JSON 选项 `scale`(严格 0 到 1 之间)、`displayId`;回调提供 JPEG 字节,当前开源实现反复 `GetScreenshot` 并编码变化帧,不是 H.264 编码流。[扩展 C ABI](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/extension_c_api.h)、[调用实现](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/extension_executor.cpp)、[JPEG 采集实现](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/screen_copy.cpp)
源码可导出的启动契约是 `uitest start-daemon singleness --extension-name curios-screen-<nonce>.so`,加载 `/data/local/tmp/` 下相应文件,默认名 `agent.so`。扩展须自行实现有界传输、停止以及主机认证。**这是源码入口,不是本次确认的商业公共 CLI。** 官方仓库 README 将 start-daemon 标为测试端注入用途;商业固件可能改变加载策略、系统库、ABI 或采集实现。先在自己可控制的 OpenHarmony 测试镜像验证,再专门评估真实商业设备,不应默认为任何手机加载 native 代码。[加载器](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/extension_executor.cpp#L354)、[命令分派](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/server/server_main.cpp#L471)、[官方 README](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/README_zh.md)
不能从论坛提到的 `xdevice_scrcpy` 或 `DevEcoScrcpy` 日志推断商业工具私有协议已公开,也不能把上面的开源 JPEG 路径描述成与商业 H.264 路径完全相同。
## 已核查的开源成品
| 项目与锁定提交 | 真实机制、macOS、授权边界 | 结论 |
| --- | --- | --- |
| [鸿镜 ohos-scrcpy-app](https://github.com/guoxiucai/ohos-scrcpy-app/tree/1d470ea8f571f19fcbb6883cc6118fafed6fe14f) | Flutter macOS/Windows;H.264、VideoToolbox、鼠标/触摸/按键、录制,编码失败可降级 JPEG。设备端为系统 HAP,作者明确要求 Full SDK、目标系统信任签名和权限白名单。 | 可参考本机解码与协议设计;不能普通签名安装到商业手机便承诺工作。 |
| [OpenHarmony_Scrcpy](https://github.com/luodh0157/OpenHarmony_Scrcpy/tree/85890e693fb5b3e4b3ef50a16af688787ca85070) | 项目声明跨桌面平台,设备端明确要求 OpenHarmony 5+ root 版本;原生 AVScreenCapture + H.264/H.265。安装脚本 remount 并写 `/system/bin` 和 `/system/etc/init`,服务具媒体采集权限。 | 只适合受控开发镜像;不用于本项目普通手机自动安装。 |
| [scrcpy-ohos](https://github.com/l376571926/scrcpy-ohos/tree/5d938ac1d125406705c764d7061192d91c33b9d1) | JavaFX/JDK11;源码 `snapshot_display` 截图下载,操作后刷新,uinput 模拟事件;README 明确非实时。未找到根 LICENSE。 | 不是官方 scrcpy 的鸿蒙兼容层,也不提供连续视频流;可看思路,不能据公开代码便假定可复制分发。 |
鸿镜设备权限包括 `CAPTURE_SCREEN`、`EXEMPT_CAPTURE_SCREEN_AUTHORIZE`、`INJECT_INPUT_EVENT`,其文档明确需 ACL、系统签名和指纹白名单。仅将 HAP 换成自己的普通调试证书不解决这些限制。[鸿镜签名要求](https://github.com/guoxiucai/ohos-scrcpy-app/blob/1d470ea8f571f19fcbb6883cc6118fafed6fe14f/README.md#签名与系统权限)、[设备权限声明](https://github.com/guoxiucai/ohos-scrcpy-app/blob/1d470ea8f571f19fcbb6883cc6118fafed6fe14f/scrcpy_server/entry/src/main/module.json5)
鸿镜可复核的传输为 `hdc fport tcp:<localPort> tcp:53535`,设备只监听 `127.0.0.1:53535`。它使用自定义包:大端 u32 类型 + 大端 u32 payload 长度;`0x02` 配置、`0x03` 视频帧、`0x10` 控制。H.264 帧 payload 是 flags u8、PTS u64、NAL 数据;控制下含触摸/键鼠子类型。它与官方 scrcpy wire protocol 不兼容,不能拿其 server 对接官方 scrcpy client。[鸿镜协议](https://github.com/guoxiucai/ohos-scrcpy-app/blob/1d470ea8f571f19fcbb6883cc6118fafed6fe14f/scrcpy_client_flutter/lib/net/protocol.dart)、[loopback 监听](https://github.com/guoxiucai/ohos-scrcpy-app/blob/1d470ea8f571f19fcbb6883cc6118fafed6fe14f/scrcpy_server/entry/src/main/cpp/TcpServer.cpp)
许可分别是鸿镜 MIT、OpenHarmony_Scrcpy Apache-2.0;仍需保留声明、审查依赖和自行构建的分发物。root 项目的安装脚本会遍历目标设备并更改系统分区,本项目不能直接调用它。[鸿镜 LICENSE](https://github.com/guoxiucai/ohos-scrcpy-app/blob/1d470ea8f571f19fcbb6883cc6118fafed6fe14f/LICENSE)、[OpenHarmony_Scrcpy LICENSE](https://github.com/luodh0157/OpenHarmony_Scrcpy/blob/85890e693fb5b3e4b3ef50a16af688787ca85070/LICENSE)、[root 服务要求](https://github.com/luodh0157/OpenHarmony_Scrcpy/blob/85890e693fb5b3e4b3ef50a16af688787ca85070/Server/README.md)、[系统分区安装脚本](https://github.com/luodh0157/OpenHarmony_Scrcpy/blob/85890e693fb5b3e4b3ef50a16af688787ca85070/Server/install_ohscrcpy_server.sh)
## 验收与后续决策
先交付可探测的屏幕预览和明确输入,再进行视频流原型。每条能力分别保存 available/unavailable/unknown 及原因;不把 HDC 已连接等同全部投屏/输入可用。最小真机验收需要覆盖截图解码、旋转坐标、点击/滑动效果、中文输入、断线取消、锁屏、拒绝授权、受保护画面、停止采集后不再传帧和本会话文件/转发清理。视频流另外测首帧时延、实际 FPS、CPU/温度、背压、编码器重建与应用后台状态;完成这些前不使用“低延迟 60 FPS”等宣传。
本轮明确未验证:Pura 70 Ultra 当前固件的 UITest 输入效果、HDC 版本、官方工具真机投屏、伴随应用采集权限、UITest addon 加载、任何社区 HAP/二进制。已确认的是公开能力与实现边界;没有执行设备动作,也没有修改插件代码。
+17 -2
View File
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"id": "trove-official",
"name": "Trove 官方软件源",
"generatedAt": "2026-09-10T02:56:51.048Z",
"generatedAt": "2026-09-10T03:55:38.730Z",
"curios": [
{
"id": "dev.trove.qa.host",
@@ -198,8 +198,23 @@
{
"id": "dev.trove.harmony-connector",
"name": "Harmony Connector",
"description": "通过 USB 连接鸿蒙设备,在 Mac 与手机之间浏览、发送和接收文件。",
"description": "连接鸿蒙设备,收发完整文件夹、管理应用,轻松保存截图和日志。",
"versions": [
{
"version": "0.2.0",
"engine": {
"trove": ">=0.1.0 <1.0.0"
},
"releasedAt": "2026-09-10T03:55:38.730Z",
"artifacts": [
{
"target": "aarch64-apple-darwin",
"url": "packages/dev.trove.harmony-connector/0.2.0/aarch64-apple-darwin.curio",
"sha256": "408dda2e5b0f9525f16bbab9366a6ffc74a0a47ea78d8e55c3f98707cf45aa46",
"size": 2742772
}
]
},
{
"version": "0.1.1",
"engine": {
+41 -1
View File
@@ -63,11 +63,42 @@ const entries = [
];
window.__TROVE_CONTEXT__ = {
curioId: "dev.trove.harmony-connector",
version: "0.1.1",
version: "0.2.0",
instanceId: "ui-fixture",
platform: "macos",
};
window.__TROVE_BRIDGE__ = createMockBridge({
"@self.hdc.status": () => ({ busy: false, job: null }),
"@self.hdc.tool": () => ({
available: true,
version: "HDC · 模拟数据",
path: "/fixture/official-sdk/hdc",
}),
"@self.hdc.devices": () => ({
devices: attached
? [{ id: "fixture-device", transport: "USB", state: "Connected" }]
: [],
}),
"@self.hdc.inspect": () => ({
capabilities: Object.fromEntries(
["apps", "launch", "screenshot", "logs", "report"].map((key) => [
key,
{ available: true },
]),
),
info: { text: "HUAWEI Pura 70 Ultra · 界面测试数据" },
}),
"@self.hdc.apps": () => ({
apps: ["com.example.gallery", "com.example.notes", "com.example.reader"],
}),
"@self.hdc.app": ({ bundle }) => ({
bundle,
versionName: "1.2.0",
versionCode: 12,
debug: bundle.endsWith("notes"),
abilities: [{ name: "Home", module: "entry" }],
details: {},
}),
"@self.info": () => ({ connection, busy: false }),
"@self.devices": () => ({ devices: attached ? [device] : [] }),
"@self.monitor": () => ({
@@ -104,6 +135,15 @@ window.__TROVE_BRIDGE__ = createMockBridge({
"@self.transferStatus": () => {
if (job?.state === "running") {
job.sent += 524288;
job.statistics = {
bytesPerSecond: 1310720,
averageBytesPerSecond: 1210720,
elapsedSeconds: job.sent / 1310720,
remainingSeconds: (job.total - job.sent) / 1310720,
filesTotal: 3,
filesCompleted: Math.floor((job.sent / job.total) * 3),
currentFile: "Documents/设计稿.zip",
};
if (job.sent >= job.total) {
job.state = "completed";
job.result = { name: "界面测试文件", bytes: job.total };
+108
View File
@@ -43,6 +43,10 @@ async function app(t) {
};
const storages = [{ id: 1, name: "内部存储", free: 1e9, capacity: 2e9 }];
const model = {
hdcAvailable: false,
hdcConnected: true,
confirmed: false,
parameters: [],
attached: true,
connection: null,
calls: [],
@@ -57,6 +61,7 @@ async function app(t) {
`@self.${method}`,
async (params) => {
model.calls.push(method);
model.parameters.push([method, structuredClone(params)]);
return structuredClone(await fn(params));
},
];
@@ -68,6 +73,45 @@ async function app(t) {
};
window.__TROVE_BRIDGE__ = createMockBridge(
Object.fromEntries([
handler("hdc.status", () => ({ busy: false, job: null })),
handler("hdc.tool", () => ({
available: model.hdcAvailable,
version: "HDC test",
path: "/fixture/hdc",
})),
handler("hdc.devices", () => ({
devices: model.hdcConnected
? [{ id: "phone", transport: "USB", state: "Connected" }]
: [],
})),
handler("hdc.inspect", () => ({
capabilities: Object.fromEntries(
["apps", "launch", "logs", "report", "screenshot"].map((key) => [
key,
{ available: true },
]),
),
info: { text: "Test device" },
})),
handler("hdc.apps", () => ({ apps: ["com.example.app"] })),
handler("hdc.app", () => ({
bundle: "com.example.app",
versionCode: 1,
versionName: "1.0",
debug: false,
abilities: [{ name: "Home", module: "entry" }],
details: {},
})),
handler("hdc.package", () => ({
bundle: "com.example.app",
versionCode: 2,
versionName: "2.0",
sha256: "confirmed-sha256",
size: 100,
})),
handler("hdc.start", () => ({ id: 9 })),
["host.dialog.confirm", () => ({ confirmed: model.confirmed })],
["host.dialog.openDirectory", () => ({ path: "/fixture/folder" })],
handler("info", () => ({ connection: model.connection, busy: false })),
handler("devices", () => ({ devices: model.attached ? [device] : [] })),
handler("connect", () => (model.connection = { device, storages })),
@@ -257,3 +301,67 @@ test("manual disconnect keeps the UI responsive while USB cleanup is pending", a
assert.equal($("connect").disabled, false);
assert.equal($("browser").hidden, true);
});
test("whole folder upload uses the directory picker and exposes transfer metrics", async (t) => {
const { $, click, connect, model, tick } = await app(t);
await connect();
await click("upload-folder");
assert.equal(
model.parameters.find(([name]) => name === "transfer")[1].path,
"/fixture/folder",
);
model.job.statistics = {
bytesPerSecond: 1024,
averageBytesPerSecond: 1024,
elapsedSeconds: 2,
remainingSeconds: 3,
filesCompleted: 1,
filesTotal: 4,
currentFile: "nested/data.bin",
};
await tick();
assert.match($("transfer-current").textContent, /nested\/data.bin/);
assert.match($("transfer-metrics").textContent, /KB\/s/);
});
test("management is optional and missing HDC does not disable file transfers", async (t) => {
const { $, click, connect, model } = await app(t);
assert.ok(!model.calls.some((name) => name.startsWith("hdc.")));
await click("tab-management");
assert.match($("hdc-tool-status").textContent, /未发现/);
assert.equal($("app-install").disabled, true);
await click("tab-files");
await connect();
assert.equal($("browser").hidden, false);
});
test("application installation requires confirmation and pins the reviewed package hash", async (t) => {
const { $, click, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
await click("app-install");
assert.ok(!model.calls.includes("hdc.start"));
model.confirmed = true;
await click("app-install");
const params = model.parameters.find(([name]) => name === "hdc.start")[1];
assert.deepEqual(params, {
kind: "install",
target: "phone",
path: "/fixture/file.txt",
sha256: "confirmed-sha256",
});
assert.equal($("app-install").disabled, true);
});
test("device disconnect clears app details and prevents stale target operations", async (t) => {
const { $, click, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
$("apps-list").querySelector("button").click();
await settle();
assert.equal($("app-detail").hidden, false);
assert.equal($("app-export").disabled, true);
model.hdcConnected = false;
t.mock.timers.tick(3500);
await settle();
assert.equal($("app-detail").hidden, true);
assert.equal($("app-install").disabled, true);
assert.equal($("app-launch").disabled, true);
});