diff --git a/README.md b/README.md index 6c23e5e..83acd67 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Trove 官方插件库。发现喜欢的 Curio,装进你的工作台,让常 ### Harmony Connector -让文件,自在往来。一根 USB 数据线,连接 Mac 与鸿蒙设备;浏览文件、收发文档,把照片与灵感带到更大的屏幕。 +让设备,更近一步。连接 Mac 与鸿蒙手机,整夹收发照片与文档,随时掌握传输速度;管理应用、保存截图,让日常操作在桌面从容完成。 ## 按你的习惯组合 diff --git a/curios/harmony-connector/curio.json b/curios/harmony-connector/curio.json index 7ffe88c..578907f 100644 --- a/curios/harmony-connector/curio.json +++ b/curios/harmony-connector/curio.json @@ -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": { diff --git a/curios/harmony-connector/native/Cargo.lock b/curios/harmony-connector/native/Cargo.lock index dc42e38..aa488ac 100644 --- a/curios/harmony-connector/native/Cargo.lock +++ b/curios/harmony-connector/native/Cargo.lock @@ -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", +] diff --git a/curios/harmony-connector/native/Cargo.toml b/curios/harmony-connector/native/Cargo.toml index 763520b..b257187 100644 --- a/curios/harmony-connector/native/Cargo.toml +++ b/curios/harmony-connector/native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "harmony-connector" -version = "0.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" diff --git a/curios/harmony-connector/native/src/command.rs b/curios/harmony-connector/native/src/command.rs new file mode 100644 index 0000000..02d6fe4 --- /dev/null +++ b/curios/harmony-connector/native/src/command.rs @@ -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, limit: usize) -> Result { + 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 { + 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::(), + )); + } + 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::(), + )); + } + 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 + ); + } +} diff --git a/curios/harmony-connector/native/src/connector.rs b/curios/harmony-connector/native/src/connector.rs index 5121827..0c7cab4 100644 --- a/curios/harmony-connector/native/src/connector.rs +++ b/curios/harmony-connector/native/src/connector.rs @@ -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, } pub struct Connector { + management: Arc, gate: Arc, session: Mutex>, connection: Mutex, @@ -80,12 +80,6 @@ fn params(value: Value) -> Result { 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 { 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, method: &str, value: Value) -> Result { + 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) -> Arc { @@ -412,118 +420,5 @@ impl Connector { } } -fn run_transfer( - device: &mut dyn DeviceIo, - transfer: Transfer, - progress: &Progress, -) -> Result { - 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; diff --git a/curios/harmony-connector/native/src/connector/tests.rs b/curios/harmony-connector/native/src/connector/tests.rs index deebdc7..4074021 100644 --- a/curios/harmony-connector/native/src/connector/tests.rs +++ b/curios/harmony-connector/native/src/connector/tests.rs @@ -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 { - 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, Arc>) { @@ -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 = (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, Arc>) { + 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()); + } +} diff --git a/curios/harmony-connector/native/src/diagnostics.rs b/curios/harmony-connector/native/src/diagnostics.rs new file mode 100644 index 0000000..4582152 --- /dev/null +++ b/curios/harmony-connector/native/src/diagnostics.rs @@ -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(()) +} diff --git a/curios/harmony-connector/native/src/file_transfer.rs b/curios/harmony-connector/native/src/file_transfer.rs new file mode 100644 index 0000000..7b2e0fe --- /dev/null +++ b/curios/harmony-connector/native/src/file_transfer.rs @@ -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, + 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::>>()?; + 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, + seen: &mut HashSet, + 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 { + 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 { + 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 { + 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(()) +} diff --git a/curios/harmony-connector/native/src/hdc.rs b/curios/harmony-connector/native/src/hdc.rs new file mode 100644 index 0000000..d05f171 --- /dev/null +++ b/curios/harmony-connector/native/src/hdc.rs @@ -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); +impl Drop for Guard { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} +pub struct Manager { + path: Mutex>, + settings: Option, + busy: Arc, + job: Mutex, + cancel: Arc, + 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(value: Value) -> Result { + 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 { + 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, +} +impl Tool { + fn run(&self, args: &[&str], seconds: u64) -> Result { + let output = self.run_output(args, seconds)?; + command::check_remote_errors(&output)?; + Ok(output) + } + fn run_output(&self, args: &[&str], seconds: u64) -> Result { + command::run( + &self.path, + &args.iter().map(|s| (*s).into()).collect::>(), + Duration::from_secs(seconds), + &self.cancel, + 16 * 1024 * 1024, + ) + } + fn shell(&self, target: &str, command: &str) -> Result { + 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> { + 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> { + hdc_data::apps(&self.shell(target, "bm dump -a")?) + } + fn app(&self, target: &str, bundle: &str) -> Result { + let bundle = hdc_data::identifier(bundle)?; + hdc_data::app( + &self.shell(target, &format!("bm dump -n {bundle}"))?, + bundle, + ) + } +} +impl Manager { + pub fn new() -> Arc { + 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::(&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 { + 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 { + 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, method: &str, value: Value) -> Result { + 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 { + 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, ©)?; + let metadata = files::package(©)?; + 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; diff --git a/curios/harmony-connector/native/src/hdc/files.rs b/curios/harmony-connector/native/src/hdc/files.rs new file mode 100644 index 0000000..f02a918 --- /dev/null +++ b/curios/harmony-connector/native/src/hdc/files.rs @@ -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 { + let mut file = fs::File::open(path)?; + hash_reader(&mut file) +} +fn hash_reader(file: &mut impl Read) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + 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 { + 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()); + } +} diff --git a/curios/harmony-connector/native/src/hdc/tests.rs b/curios/harmony-connector/native/src/hdc/tests.rs new file mode 100644 index 0000000..4cee02e --- /dev/null +++ b/curios/harmony-connector/native/src/hdc/tests.rs @@ -0,0 +1,148 @@ +use super::*; +use std::{io::Write, time::Instant}; + +fn fixture(debug: bool) -> (tempfile::TempDir, Arc) { + 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, 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")); + } + } +} diff --git a/curios/harmony-connector/native/src/hdc_data.rs b/curios/harmony-connector/native/src/hdc_data.rs new file mode 100644 index 0000000..d6ebb1f --- /dev/null +++ b/curios/harmony-connector/native/src/hdc_data.rs @@ -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> { + 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> { + 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::() + .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 { + 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()); + } +} diff --git a/curios/harmony-connector/native/src/main.rs b/curios/harmony-connector/native/src/main.rs index d7de006..ee056af 100644 --- a/curios/harmony-connector/native/src/main.rs +++ b/curios/harmony-connector/native/src/main.rs @@ -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()) } diff --git a/curios/harmony-connector/native/src/model.rs b/curios/harmony-connector/native/src/model.rs index 4b65b97..b838ebd 100644 --- a/curios/harmony-connector/native/src/model.rs +++ b/curios/harmony-connector/native/src/model.rs @@ -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 for Fault { } pub type Result = std::result::Result; -#[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 { diff --git a/curios/harmony-connector/native/src/progress.rs b/curios/harmony-connector/native/src/progress.rs new file mode 100644 index 0000000..5bf2c90 --- /dev/null +++ b/curios/harmony-connector/native/src/progress.rs @@ -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, + 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) { + 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, +} +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"] + ); + } +} diff --git a/curios/harmony-connector/ui/app.js b/curios/harmony-connector/ui/app.js index b286415..c85ed1c 100644 --- a/curios/harmony-connector/ui/app.js +++ b/curios/harmony-connector/ui/app.js @@ -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++; diff --git a/curios/harmony-connector/ui/format.js b/curios/harmony-connector/ui/format.js index bd58d7b..8ac616a 100644 --- a/curios/harmony-connector/ui/format.js +++ b/curios/harmony-connector/ui/format.js @@ -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(" · "); +} diff --git a/curios/harmony-connector/ui/format.test.js b/curios/harmony-connector/ui/format.test.js index 55467a3..bf337e9 100644 --- a/curios/harmony-connector/ui/format.test.js +++ b/curios/harmony-connector/ui/format.test.js @@ -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, "