fix(harmony): discover devices and release disconnected sessions asynchronously
Use USB descriptors and MTP identity instead of the static libmtp product label. Monitor attachment, discard stale UI responses, clear disconnected views, and release slow MTP sessions in the background while preserving operation serialization. Publish 0.1.1 with immutable source history. Cover discovery, names, unplugging and slow disconnect with native and UI regressions. Verify real Pura 70 Ultra identity and prompt disconnect; run the newest published backend during Git-source checks.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
"id": "dev.trove.harmony-connector",
|
||||
"name": "Harmony Connector",
|
||||
"description": "通过 USB 连接鸿蒙设备,在 Mac 与手机之间浏览、发送和接收文件。",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"engine": { "trove": ">=0.1.0 <1.0.0" },
|
||||
"icon": "assets/icon.svg",
|
||||
"ui": {
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "harmony-connector"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cc",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "harmony-connector"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ fn main() {
|
||||
prefix.join("lib").display()
|
||||
);
|
||||
println!("cargo:rustc-link-lib=dylib=mtp");
|
||||
println!("cargo:rustc-link-lib=dylib=usb-1.0");
|
||||
println!("cargo:rerun-if-env-changed=HARMONY_NATIVE_PREFIX");
|
||||
println!("cargo:rerun-if-changed=src/mtp_bridge.c");
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ impl Drop for Gate {
|
||||
struct Session {
|
||||
device: Box<dyn DeviceIo>,
|
||||
}
|
||||
fn close_in_background(session: Session, gate: Gate) {
|
||||
thread::spawn(move || {
|
||||
let _gate = gate;
|
||||
drop(session);
|
||||
});
|
||||
}
|
||||
struct Job {
|
||||
id: u64,
|
||||
progress: Arc<Progress>,
|
||||
@@ -150,9 +156,12 @@ impl Connector {
|
||||
.map_err(|_| Fault::new("device.busy", "设备正在处理上一项操作,请稍候。"))?;
|
||||
Ok(Gate(self.gate.clone()))
|
||||
}
|
||||
fn clear(&self) {
|
||||
*self.session.lock().unwrap() = None;
|
||||
fn detach(&self) -> Option<Session> {
|
||||
*self.connection.lock().unwrap() = Value::Null;
|
||||
self.session.lock().unwrap().take()
|
||||
}
|
||||
fn clear(&self) {
|
||||
drop(self.detach());
|
||||
}
|
||||
pub fn stop(&self) {
|
||||
self.stopped.store(true, Ordering::Release);
|
||||
@@ -178,6 +187,7 @@ impl Connector {
|
||||
)
|
||||
}
|
||||
"transferStatus" => return Ok(self.status()),
|
||||
"monitor" => return self.monitor_devices(mtp::present, mtp::scan),
|
||||
"cancelTransfer" => {
|
||||
let id: JobId = params(value)?;
|
||||
let jobs = self.job.lock().unwrap();
|
||||
@@ -193,6 +203,15 @@ impl Connector {
|
||||
_ => {}
|
||||
}
|
||||
let gate = self.enter()?;
|
||||
if method == "disconnect" {
|
||||
let session = self.detach();
|
||||
*self.job.lock().unwrap() = None;
|
||||
let busy = session.is_some();
|
||||
if let Some(session) = session {
|
||||
close_in_background(session, gate);
|
||||
}
|
||||
return Ok(json!({"disconnected":true,"busy":busy}));
|
||||
}
|
||||
if method == "transfer" {
|
||||
return self.start(params(value)?, gate);
|
||||
}
|
||||
@@ -206,22 +225,70 @@ impl Connector {
|
||||
}
|
||||
result
|
||||
}
|
||||
fn monitor_devices(
|
||||
&self,
|
||||
present: impl FnOnce(&DeviceInfo) -> Result<bool>,
|
||||
scan: impl FnOnce() -> Result<Vec<DeviceInfo>>,
|
||||
) -> Result<Value> {
|
||||
let snapshot = self.connection.lock().unwrap().clone();
|
||||
if !snapshot.is_null() {
|
||||
let info: DeviceInfo = serde_json::from_value(snapshot["device"].clone())
|
||||
.map_err(|_| Fault::new("device.invalid_state", "连接信息无效,请重新连接。"))?;
|
||||
if !present(&info)? {
|
||||
let mut current = self.connection.lock().unwrap();
|
||||
// A newer session must not be invalidated by an older USB snapshot.
|
||||
if *current == snapshot {
|
||||
*current = Value::Null;
|
||||
if let Some(job) = self
|
||||
.job
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.filter(|job| job.state == "running")
|
||||
{
|
||||
job.progress.disconnected.store(true, Ordering::Release);
|
||||
job.progress.cancel.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let connection = self.connection.lock().unwrap().clone();
|
||||
if !connection.is_null() || self.busy() {
|
||||
return Ok(json!({"connection":connection,"devices":[],"busy":self.busy()}));
|
||||
}
|
||||
let gate = match self.enter() {
|
||||
Ok(gate) => gate,
|
||||
Err(error) if error.code == "device.busy" => {
|
||||
return Ok(
|
||||
json!({"connection":self.connection.lock().unwrap().clone(),"devices":[],"busy":true}),
|
||||
)
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
// Recheck after acquiring the gate: connect may have completed between the two snapshots.
|
||||
let connection = self.connection.lock().unwrap().clone();
|
||||
if !connection.is_null() {
|
||||
return Ok(json!({"connection":connection,"devices":[],"busy":false}));
|
||||
}
|
||||
if let Some(session) = self.detach() {
|
||||
close_in_background(session, gate);
|
||||
return Ok(json!({"connection":null,"devices":[],"busy":true}));
|
||||
}
|
||||
Ok(json!({"connection":null,"devices":scan()?,"busy":false}))
|
||||
}
|
||||
fn operation(&self, method: &str, value: Value) -> Result<Value> {
|
||||
if method == "devices" {
|
||||
return Ok(json!({"devices":mtp::scan()?}));
|
||||
}
|
||||
if method == "disconnect" {
|
||||
self.clear();
|
||||
return Ok(json!({"disconnected":true}));
|
||||
}
|
||||
if method == "connect" {
|
||||
let id: DeviceId = params(value)?;
|
||||
self.clear();
|
||||
let info = mtp::scan()?
|
||||
let mut info = mtp::scan()?
|
||||
.into_iter()
|
||||
.find(|d| d.id == id.id)
|
||||
.ok_or_else(|| Fault::new("device.not_found", "设备已断开,请重新扫描。"))?;
|
||||
let mut device = NativeDevice::open(&info)?;
|
||||
device.identify(&mut info);
|
||||
let storages = device.storages()?;
|
||||
if storages.is_empty() {
|
||||
return Err(Fault::new(
|
||||
@@ -229,7 +296,7 @@ impl Connector {
|
||||
"手机尚未开放存储。请解锁手机并选择“传输文件”。",
|
||||
));
|
||||
}
|
||||
let connection = json!({"device":info,"storages":storages});
|
||||
let connection = json!({"device":info,"storages":storages,"generation":self.next_id.fetch_add(1, Ordering::Relaxed)});
|
||||
*self.session.lock().unwrap() = Some(Session {
|
||||
device: Box::new(device),
|
||||
});
|
||||
@@ -288,7 +355,7 @@ impl Connector {
|
||||
if connector.stopped.load(Ordering::Acquire) {
|
||||
progress.cancel.store(true, Ordering::Release);
|
||||
}
|
||||
let result = {
|
||||
let mut result = {
|
||||
let mut session = connector.session.lock().unwrap();
|
||||
run_transfer(
|
||||
session.as_mut().unwrap().device.as_mut(),
|
||||
@@ -296,6 +363,12 @@ impl Connector {
|
||||
&progress,
|
||||
)
|
||||
};
|
||||
if progress.disconnected.load(Ordering::Acquire) && result.is_err() {
|
||||
result = Err(Fault::new(
|
||||
"device.disconnected",
|
||||
"设备已断开,传输未完成。请重新连接后重试。",
|
||||
));
|
||||
}
|
||||
// Cancelled/failed USB transactions cannot safely reuse object handles or the session.
|
||||
if progress.cancelled()
|
||||
|| connector.stopped.load(Ordering::Acquire)
|
||||
|
||||
@@ -11,8 +11,20 @@ struct Memory {
|
||||
truncate: bool,
|
||||
slow: bool,
|
||||
collide: Option<std::path::PathBuf>,
|
||||
release: Option<std::sync::mpsc::Receiver<()>>,
|
||||
}
|
||||
struct Device(Arc<Mutex<Memory>>);
|
||||
impl Drop for Device {
|
||||
fn drop(&mut self) {
|
||||
let release = self.0.lock().unwrap().release.take();
|
||||
if let Some(release) = release {
|
||||
let _ = release.recv();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn connection_info() -> Value {
|
||||
json!({"device":{"id":"7:3","bus":7,"address":3,"vendor":4817,"product":4353,"manufacturer":"HUAWEI","name":"daniel","model":"HUAWEI Pura 70 Ultra"},"storages":[{"id":1,"name":"内部存储","free":1000000,"capacity":2000000}],"generation":1})
|
||||
}
|
||||
impl DeviceIo for Device {
|
||||
fn storages(&mut self) -> Result<Vec<Storage>> {
|
||||
Ok(vec![Storage {
|
||||
@@ -171,6 +183,16 @@ fn directory_names(directory: &Path) -> Vec<String> {
|
||||
.map(|f| f.unwrap().file_name().to_string_lossy().into())
|
||||
.collect()
|
||||
}
|
||||
fn wait_idle(connector: &Connector) {
|
||||
let deadline = Instant::now() + Duration::from_secs(1);
|
||||
while connector.busy() {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"USB cleanup did not release the operation gate"
|
||||
);
|
||||
thread::yield_now();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_binary_unicode_and_empty_files() {
|
||||
@@ -417,3 +439,122 @@ fn shutdown_cancels_transfer_and_rejects_new_operations() {
|
||||
);
|
||||
assert!(directory_names(directory.path()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitoring_an_attached_session_does_not_reopen_or_rescan_mtp() {
|
||||
let (connector, _) = setup("file.txt", b"data");
|
||||
*connector.connection.lock().unwrap() = connection_info();
|
||||
let snapshot = connector
|
||||
.monitor_devices(
|
||||
|device| {
|
||||
assert_eq!(device.name, "daniel");
|
||||
Ok(true)
|
||||
},
|
||||
|| panic!("A connected session must not be rescanned"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(snapshot["connection"]["device"]["name"], "daniel");
|
||||
assert_eq!(snapshot["busy"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitoring_unplug_clears_the_cached_session_and_stale_handles() {
|
||||
let (connector, _) = setup("file.txt", b"data");
|
||||
*connector.connection.lock().unwrap() = connection_info();
|
||||
let snapshot = connector
|
||||
.monitor_devices(|_| Ok(false), || Ok(vec![]))
|
||||
.unwrap();
|
||||
assert!(snapshot["connection"].is_null());
|
||||
wait_idle(&connector);
|
||||
assert_eq!(
|
||||
connector
|
||||
.call("list", json!({"storage":1,"parent":ROOT}))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"device.disconnected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unplug_during_transfer_reports_disconnection_and_removes_partial_file() {
|
||||
let (connector, memory) = setup("large.bin", &[3; 512 * 1024]);
|
||||
*connector.connection.lock().unwrap() = connection_info();
|
||||
memory.lock().unwrap().slow = true;
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
connector
|
||||
.call(
|
||||
"transfer",
|
||||
json!({"direction":"download","object":1,"directory":directory.path()}),
|
||||
)
|
||||
.unwrap();
|
||||
let snapshot = connector
|
||||
.monitor_devices(|_| Ok(false), || Ok(vec![]))
|
||||
.unwrap();
|
||||
assert!(snapshot["connection"].is_null());
|
||||
let status = wait(&connector);
|
||||
assert_eq!(status["state"], "failed");
|
||||
assert_eq!(status["error"]["code"], "device.disconnected");
|
||||
assert!(directory_names(directory.path()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usb_query_failure_does_not_claim_that_the_device_was_unplugged() {
|
||||
let (connector, _) = setup("file.txt", b"data");
|
||||
*connector.connection.lock().unwrap() = connection_info();
|
||||
assert!(connector
|
||||
.monitor_devices(
|
||||
|_| Err(Fault::new("usb.discovery_failed", "unavailable")),
|
||||
|| Ok(vec![])
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
connector.call("info", json!({})).unwrap()["connection"]["device"]["name"],
|
||||
"daniel"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_presence_snapshot_cannot_invalidate_a_new_session() {
|
||||
let (connector, _) = setup("file.txt", b"data");
|
||||
*connector.connection.lock().unwrap() = connection_info();
|
||||
let snapshot = connector
|
||||
.monitor_devices(
|
||||
|_| {
|
||||
let mut new_session = connection_info();
|
||||
new_session["generation"] = json!(2);
|
||||
*connector.connection.lock().unwrap() = new_session;
|
||||
Ok(false)
|
||||
},
|
||||
|| panic!("New session must be retained"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(snapshot["connection"]["generation"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_returns_and_clears_state_before_slow_usb_release() {
|
||||
let (connector, memory) = setup("file.txt", b"data");
|
||||
*connector.connection.lock().unwrap() = connection_info();
|
||||
let (release, wait_release) = std::sync::mpsc::channel();
|
||||
memory.lock().unwrap().release = Some(wait_release);
|
||||
let (response, receive) = std::sync::mpsc::channel();
|
||||
let worker = {
|
||||
let connector = connector.clone();
|
||||
thread::spawn(move || {
|
||||
response
|
||||
.send(connector.call("disconnect", json!({})))
|
||||
.unwrap()
|
||||
})
|
||||
};
|
||||
let response = receive.recv_timeout(Duration::from_millis(200));
|
||||
let info = connector.call("info", json!({})).unwrap();
|
||||
let busy = connector.call("devices", json!({})).unwrap_err().code;
|
||||
release.send(()).unwrap();
|
||||
worker.join().unwrap();
|
||||
assert!(response.is_ok(), "Disconnect waited for USB release");
|
||||
assert_eq!(response.unwrap().unwrap()["disconnected"], true);
|
||||
assert!(info["connection"].is_null());
|
||||
assert_eq!(info["busy"], true);
|
||||
assert_eq!(busy, "device.busy");
|
||||
wait_idle(&connector);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeviceInfo {
|
||||
pub id: String,
|
||||
@@ -11,6 +11,7 @@ pub struct DeviceInfo {
|
||||
pub product: u16,
|
||||
pub manufacturer: String,
|
||||
pub name: String,
|
||||
pub model: Option<String>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Storage {
|
||||
@@ -62,6 +63,7 @@ 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) {
|
||||
|
||||
@@ -28,10 +28,13 @@ type DeviceCallback = extern "C" fn(*mut c_void, u32, u8, u16, u16, *const c_cha
|
||||
type StorageCallback = extern "C" fn(*mut c_void, u32, *const c_char, u64, u64);
|
||||
type FileCallback = extern "C" fn(*mut c_void, u32, u32, u32, *const c_char, u64, i64, c_int);
|
||||
type ProgressCallback = extern "C" fn(u64, u64, *const c_void) -> c_int;
|
||||
type IdentityCallback = extern "C" fn(*mut c_void, *const c_char, *const c_char, *const c_char);
|
||||
extern "C" {
|
||||
fn hc_init();
|
||||
fn hc_version() -> *const c_char;
|
||||
fn hc_scan(callback: DeviceCallback, context: *mut c_void) -> c_int;
|
||||
fn hc_present(bus: u32, address: u8, vendor: u16, product: u16) -> c_int;
|
||||
fn hc_identity(device: *mut c_void, callback: IdentityCallback, context: *mut c_void);
|
||||
fn hc_open(bus: u32, address: u8) -> *mut c_void;
|
||||
fn hc_close(device: *mut c_void);
|
||||
fn hc_error(device: *mut c_void, buffer: *mut c_char, length: usize);
|
||||
@@ -99,8 +102,48 @@ extern "C" fn device(
|
||||
product,
|
||||
manufacturer: text(manufacturer),
|
||||
name: text(name),
|
||||
model: None,
|
||||
});
|
||||
}
|
||||
extern "C" fn identity(
|
||||
context: *mut c_void,
|
||||
friendly: *const c_char,
|
||||
model: *const c_char,
|
||||
manufacturer: *const c_char,
|
||||
) {
|
||||
let info = unsafe { &mut *(context as *mut DeviceInfo) };
|
||||
let clean = |value| {
|
||||
text(value)
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.collect::<String>()
|
||||
};
|
||||
let (friendly, model, manufacturer) = (clean(friendly), clean(model), clean(manufacturer));
|
||||
if !model.is_empty() {
|
||||
info.model = Some(model.clone());
|
||||
}
|
||||
if !manufacturer.is_empty() {
|
||||
info.manufacturer = manufacturer;
|
||||
}
|
||||
if !friendly.is_empty() {
|
||||
info.name = friendly;
|
||||
} else if !model.is_empty() {
|
||||
info.name = model;
|
||||
}
|
||||
}
|
||||
// Presence checks enumerate descriptors only, on a separate libusb context; no MTP handle is accessed.
|
||||
pub fn present(info: &DeviceInfo) -> Result<bool> {
|
||||
initialize();
|
||||
match unsafe { hc_present(info.bus, info.address, info.vendor, info.product) } {
|
||||
0 => Ok(false),
|
||||
1 => Ok(true),
|
||||
_ => Err(Fault::new(
|
||||
"usb.discovery_failed",
|
||||
"暂时无法检查 USB 连接。",
|
||||
)),
|
||||
}
|
||||
}
|
||||
extern "C" fn storage(
|
||||
context: *mut c_void,
|
||||
id: u32,
|
||||
@@ -161,6 +204,9 @@ pub struct NativeDevice(NonNull<c_void>);
|
||||
// The C handle has one owner. The connector gate and session mutex serialize every access.
|
||||
unsafe impl Send for NativeDevice {}
|
||||
impl NativeDevice {
|
||||
pub fn identify(&mut self, info: &mut DeviceInfo) {
|
||||
unsafe { hc_identity(self.0.as_ptr(), identity, info as *mut _ as *mut c_void) };
|
||||
}
|
||||
pub fn open(device: &DeviceInfo) -> Result<Self> {
|
||||
initialize();
|
||||
NonNull::new(unsafe { hc_open(device.bus, device.address) }).map(Self)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* Thin ownership boundary over libmtp. All calls are serialized by the Rust session. */
|
||||
/* MTP calls are serialized by the Rust session; USB presence uses its own context. */
|
||||
#include <libmtp.h>
|
||||
#include <libusb-1.0/libusb.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
@@ -8,24 +9,72 @@
|
||||
typedef void (*device_cb)(void *, uint32_t, uint8_t, uint16_t, uint16_t, const char *, const char *);
|
||||
typedef void (*storage_cb)(void *, uint32_t, const char *, uint64_t, uint64_t);
|
||||
typedef void (*file_cb)(void *, uint32_t, uint32_t, uint32_t, const char *, uint64_t, int64_t, int);
|
||||
typedef void (*identity_cb)(void *, const char *, const char *, const char *);
|
||||
|
||||
void hc_init(void) { LIBMTP_Init(); }
|
||||
const char *hc_version(void) { return LIBMTP_VERSION_STRING; }
|
||||
|
||||
/* Enumeration does not claim interfaces or open an MTP session. It also works while a transfer is active. */
|
||||
static int hc_usb(uint32_t bus, uint8_t address, uint16_t vendor, uint16_t product,
|
||||
char *manufacturer, char *name, size_t length) {
|
||||
libusb_context *context = NULL;
|
||||
if (libusb_init(&context) != 0) return -1;
|
||||
libusb_device **devices = NULL;
|
||||
ssize_t count = libusb_get_device_list(context, &devices);
|
||||
int found = count < 0 ? -1 : 0;
|
||||
for (ssize_t i = 0; i < count; i++) {
|
||||
struct libusb_device_descriptor descriptor;
|
||||
if (libusb_get_bus_number(devices[i]) != bus || libusb_get_device_address(devices[i]) != address ||
|
||||
libusb_get_device_descriptor(devices[i], &descriptor) != 0 ||
|
||||
descriptor.idVendor != vendor || descriptor.idProduct != product) continue;
|
||||
found = 1;
|
||||
if (name && manufacturer) {
|
||||
libusb_device_handle *handle = NULL;
|
||||
if (libusb_open(devices[i], &handle) == 0) {
|
||||
int size = descriptor.iProduct ? libusb_get_string_descriptor_ascii(handle, descriptor.iProduct, (unsigned char *)name, (int)length - 1) : -1;
|
||||
name[size > 0 && (size_t)size < length ? (size_t)size : 0] = 0;
|
||||
size = descriptor.iManufacturer ? libusb_get_string_descriptor_ascii(handle, descriptor.iManufacturer, (unsigned char *)manufacturer, (int)length - 1) : -1;
|
||||
manufacturer[size > 0 && (size_t)size < length ? (size_t)size : 0] = 0;
|
||||
libusb_close(handle);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (devices) libusb_free_device_list(devices, 1);
|
||||
libusb_exit(context);
|
||||
return found;
|
||||
}
|
||||
int hc_present(uint32_t bus, uint8_t address, uint16_t vendor, uint16_t product) {
|
||||
return hc_usb(bus, address, vendor, product, NULL, NULL, 0);
|
||||
}
|
||||
|
||||
int hc_scan(device_cb callback, void *context) {
|
||||
LIBMTP_raw_device_t *devices = NULL;
|
||||
int count = 0;
|
||||
LIBMTP_error_number_t result = LIBMTP_Detect_Raw_Devices(&devices, &count);
|
||||
if (result == LIBMTP_ERROR_NO_DEVICE_ATTACHED) return 0;
|
||||
if (result == LIBMTP_ERROR_NO_DEVICE_ATTACHED) { free(devices); return 0; }
|
||||
if (result != LIBMTP_ERROR_NONE) { free(devices); return -1; }
|
||||
for (int i = 0; i < count; i++) {
|
||||
LIBMTP_raw_device_t *raw = &devices[i];
|
||||
char manufacturer[256] = {0}, name[256] = {0};
|
||||
hc_usb(raw->bus_location, raw->devnum, raw->device_entry.vendor_id,
|
||||
raw->device_entry.product_id, manufacturer, name, sizeof(name));
|
||||
/* An ID table entry is a driver match, not a trustworthy model name. */
|
||||
callback(context, raw->bus_location, raw->devnum, raw->device_entry.vendor_id,
|
||||
raw->device_entry.product_id, raw->device_entry.vendor, raw->device_entry.product);
|
||||
raw->device_entry.product_id, *manufacturer ? manufacturer : raw->device_entry.vendor,
|
||||
*name ? name : "MTP 设备");
|
||||
}
|
||||
free(devices);
|
||||
return 0;
|
||||
}
|
||||
void hc_identity(void *device, identity_cb callback, void *context) {
|
||||
char *friendly = LIBMTP_Get_Friendlyname(device);
|
||||
char *model = LIBMTP_Get_Modelname(device);
|
||||
char *manufacturer = LIBMTP_Get_Manufacturername(device);
|
||||
callback(context, friendly, model, manufacturer);
|
||||
free(friendly); free(model); free(manufacturer);
|
||||
LIBMTP_Clear_Errorstack(device);
|
||||
}
|
||||
|
||||
void *hc_open(uint32_t bus, uint8_t address) {
|
||||
LIBMTP_raw_device_t *devices = NULL;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/* Hardware-boundary regression: USB identity can differ from libmtp's ID table. */
|
||||
#include <libmtp.h>
|
||||
#include <libusb-1.0/libusb.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
static int attached = 1;
|
||||
int libusb_init(libusb_context **context) { *context = (void *)1; return 0; }
|
||||
void libusb_exit(libusb_context *context) { (void)context; }
|
||||
ssize_t libusb_get_device_list(libusb_context *context, libusb_device ***list) {
|
||||
(void)context;
|
||||
*list = calloc(2, sizeof(**list));
|
||||
if (attached) (*list)[0] = (void *)2;
|
||||
return attached;
|
||||
}
|
||||
void libusb_free_device_list(libusb_device **list, int unref) { (void)unref; free(list); }
|
||||
uint8_t libusb_get_bus_number(libusb_device *device) { (void)device; return 7; }
|
||||
uint8_t libusb_get_device_address(libusb_device *device) { (void)device; return 3; }
|
||||
int libusb_get_device_descriptor(libusb_device *device, struct libusb_device_descriptor *out) {
|
||||
(void)device; memset(out, 0, sizeof(*out)); out->idVendor = 0x12d1; out->idProduct = 0x1101; out->iManufacturer = 1; out->iProduct = 2; return 0;
|
||||
}
|
||||
int libusb_open(libusb_device *device, libusb_device_handle **handle) { (void)device; *handle = (void *)3; return 0; }
|
||||
void libusb_close(libusb_device_handle *handle) { (void)handle; }
|
||||
int libusb_get_string_descriptor_ascii(libusb_device_handle *handle, uint8_t index, unsigned char *out, int length) {
|
||||
(void)handle; return snprintf((char *)out, length, "%s", index == 1 ? "HUAWEI" : "HUAWEI Pura 70 Ultra");
|
||||
}
|
||||
LIBMTP_error_number_t LIBMTP_Detect_Raw_Devices(LIBMTP_raw_device_t **devices, int *count) {
|
||||
*devices = calloc(1, sizeof(**devices)); *count = 1;
|
||||
**devices = (LIBMTP_raw_device_t) { .bus_location = 7, .devnum = 3,
|
||||
.device_entry = { .vendor = "Huawei", .vendor_id = 0x12d1, .product = "Nova14", .product_id = 0x1101 } };
|
||||
return LIBMTP_ERROR_NONE;
|
||||
}
|
||||
char *LIBMTP_Get_Friendlyname(LIBMTP_mtpdevice_t *device) { (void)device; return strdup("daniel"); }
|
||||
char *LIBMTP_Get_Modelname(LIBMTP_mtpdevice_t *device) { (void)device; return strdup("HUAWEI Pura 70 Ultra"); }
|
||||
char *LIBMTP_Get_Manufacturername(LIBMTP_mtpdevice_t *device) { (void)device; return strdup("HUAWEI"); }
|
||||
void LIBMTP_Clear_Errorstack(LIBMTP_mtpdevice_t *device) { (void)device; }
|
||||
static char name[256];
|
||||
static void scanned(void *ctx, uint32_t bus, uint8_t address, uint16_t vendor, uint16_t product, const char *manufacturer, const char *value) {
|
||||
(void)ctx; (void)bus; (void)address; (void)vendor; (void)product; (void)manufacturer;
|
||||
snprintf(name, sizeof(name), "%s", value);
|
||||
}
|
||||
extern int hc_scan(void (*callback)(void *, uint32_t, uint8_t, uint16_t, uint16_t, const char *, const char *), void *);
|
||||
extern int hc_present(uint32_t, uint8_t, uint16_t, uint16_t);
|
||||
extern void hc_identity(void *, void (*)(void *, const char *, const char *, const char *), void *);
|
||||
static int identity_ok;
|
||||
static void identity(void *context, const char *friendly, const char *model, const char *manufacturer) {
|
||||
(void)context;
|
||||
identity_ok = !strcmp(friendly, "daniel") && !strcmp(model, "HUAWEI Pura 70 Ultra") && !strcmp(manufacturer, "HUAWEI");
|
||||
}
|
||||
int main(void) {
|
||||
if (hc_scan(scanned, NULL) != 0) return 1;
|
||||
if (strcmp(name, "HUAWEI Pura 70 Ultra")) {
|
||||
fprintf(stderr, "FAIL scan name: expected device USB identity HUAWEI Pura 70 Ultra; got %s\n", name); return 1;
|
||||
}
|
||||
puts("PASS scan uses device identity instead of libmtp model table");
|
||||
hc_identity(NULL, identity, NULL);
|
||||
if (!identity_ok) { fputs("FAIL actual friendly name/model\n", stderr); return 1; }
|
||||
puts("PASS connected device reports daniel and its actual model");
|
||||
if (hc_present(7, 3, 0x12d1, 0x1101) != 1 || hc_present(7, 3, 0xabcd, 0x1101) != 0) return 1;
|
||||
attached = 0;
|
||||
if (hc_present(7, 3, 0x12d1, 0x1101) != 0) return 1;
|
||||
puts("PASS unplug is detected without opening an MTP session");
|
||||
return 0;
|
||||
}
|
||||
@@ -11,6 +11,12 @@ const state = {
|
||||
busy: false,
|
||||
job: null,
|
||||
timer: null,
|
||||
monitorTimer: null,
|
||||
monitorTask: null,
|
||||
backendBusy: false,
|
||||
devices: [],
|
||||
revision: 0,
|
||||
disposed: false,
|
||||
};
|
||||
const native = (method, params = {}) =>
|
||||
trove.self.call(method, params, { timeoutMs: 120_000 });
|
||||
@@ -34,7 +40,8 @@ function button(label, className, action) {
|
||||
return element;
|
||||
}
|
||||
function controls() {
|
||||
const locked = state.busy || state.job?.state === "running";
|
||||
const locked =
|
||||
state.busy || state.backendBusy || state.job?.state === "running";
|
||||
for (const id of [
|
||||
"scan",
|
||||
"connect",
|
||||
@@ -49,8 +56,11 @@ function controls() {
|
||||
for (const button of $(container).querySelectorAll("button"))
|
||||
button.disabled = locked;
|
||||
$("device-select").disabled = locked;
|
||||
$("connect").disabled = locked || !$("device-select").value;
|
||||
$("footer-status").textContent = state.busy
|
||||
? "正在与设备通信…"
|
||||
: state.backendBusy && !state.connection
|
||||
? "设备已断开,正在结束上一项操作…"
|
||||
: state.job?.state === "running"
|
||||
? "传输中 · 请保持连接"
|
||||
: state.connection
|
||||
@@ -58,11 +68,14 @@ function controls() {
|
||||
: "准备就绪";
|
||||
}
|
||||
async function action(operation) {
|
||||
if (state.busy || state.job?.state === "running") return;
|
||||
if (state.busy || state.backendBusy || state.job?.state === "running") return;
|
||||
state.revision++;
|
||||
state.busy = true;
|
||||
controls();
|
||||
notice("");
|
||||
try {
|
||||
await state.monitorTask;
|
||||
if (state.disposed) return;
|
||||
await operation();
|
||||
} catch (error) {
|
||||
notice(error.message || String(error));
|
||||
@@ -80,6 +93,12 @@ function renderConnection() {
|
||||
$("disconnect").hidden = !connection;
|
||||
$("device-name").textContent =
|
||||
connection?.device.name || (connection ? "鸿蒙设备" : "等待连接");
|
||||
$("device-name").title = connection?.device.name || "";
|
||||
$("device-model").textContent = connection?.device.model || "";
|
||||
$("device-model").title = connection?.device.model || "";
|
||||
$("device-model").hidden =
|
||||
!connection?.device.model ||
|
||||
connection.device.model === connection.device.name;
|
||||
$("device-state").textContent = connection ? "已连接 · USB" : "USB 文件传输";
|
||||
$("device-dot").classList.toggle("connected", !!connection);
|
||||
$("page-title").textContent = connection
|
||||
@@ -115,20 +134,114 @@ function renderConnection() {
|
||||
$("storages").append(item);
|
||||
}
|
||||
}
|
||||
async function syncConnection() {
|
||||
const info = await native("info");
|
||||
state.connection = info.connection;
|
||||
if (!state.connection) {
|
||||
function resetBrowser() {
|
||||
state.storage = null;
|
||||
state.path = [];
|
||||
state.entries = [];
|
||||
} else if (!state.connection.storages.some((s) => s.id === state.storage)) {
|
||||
state.total = 0;
|
||||
$("search").value = "";
|
||||
$("files").replaceChildren();
|
||||
$("breadcrumbs").replaceChildren();
|
||||
$("file-count").textContent = "";
|
||||
$("load-more").hidden = true;
|
||||
if ($("folder-dialog").open) $("folder-dialog").close();
|
||||
}
|
||||
function applyConnection(connection) {
|
||||
if (!connection && state.job?.state === "completed") {
|
||||
state.job = null;
|
||||
renderTransfer();
|
||||
}
|
||||
if (
|
||||
!connection ||
|
||||
connection.generation !== state.connection?.generation ||
|
||||
connection.device.id !== state.connection?.device.id
|
||||
)
|
||||
resetBrowser();
|
||||
if (state.connection && !connection) renderDevices([]);
|
||||
state.connection = connection;
|
||||
if (connection && !connection.storages.some((s) => s.id === state.storage)) {
|
||||
state.storage = state.connection.storages[0]?.id;
|
||||
state.path = [];
|
||||
}
|
||||
renderConnection();
|
||||
controls();
|
||||
}
|
||||
async function syncConnection(revision = state.revision) {
|
||||
const info = await native("info");
|
||||
if (state.disposed || revision !== state.revision) return;
|
||||
state.backendBusy = info.busy;
|
||||
applyConnection(info.connection);
|
||||
}
|
||||
function renderDevices(devices) {
|
||||
const previous = $("device-select").value;
|
||||
if (JSON.stringify(devices) !== JSON.stringify(state.devices)) {
|
||||
$("device-select").replaceChildren();
|
||||
for (const device of devices) {
|
||||
const option = text(
|
||||
"option",
|
||||
`${device.name || device.manufacturer || "MTP 设备"} · ${device.id}`,
|
||||
);
|
||||
option.value = device.id;
|
||||
$("device-select").append(option);
|
||||
}
|
||||
if (devices.some((device) => device.id === previous))
|
||||
$("device-select").value = previous;
|
||||
}
|
||||
state.devices = devices;
|
||||
$("device-picker").hidden = !devices.length;
|
||||
$("scan-status").textContent = devices.length
|
||||
? `发现 ${devices.length} 台设备,选择后连接。`
|
||||
: "正在自动寻找设备。请解锁手机并选择「传输文件」。";
|
||||
controls();
|
||||
}
|
||||
async function scan() {
|
||||
$("scan-status").textContent = "正在扫描 USB 设备…";
|
||||
const { devices } = await native("devices");
|
||||
if (!state.disposed) renderDevices(devices);
|
||||
}
|
||||
function scheduleMonitor() {
|
||||
clearTimeout(state.monitorTimer);
|
||||
if (!state.disposed) state.monitorTimer = setTimeout(monitor, 2000);
|
||||
}
|
||||
async function monitor() {
|
||||
if (state.disposed) return;
|
||||
if (state.busy || state.monitorTask) {
|
||||
scheduleMonitor();
|
||||
return;
|
||||
}
|
||||
const revision = state.revision;
|
||||
state.monitorTask = (async () => {
|
||||
try {
|
||||
const snapshot = await native("monitor");
|
||||
if (state.disposed || revision !== state.revision) return;
|
||||
state.backendBusy = snapshot.busy;
|
||||
if (
|
||||
JSON.stringify(snapshot.connection) !== JSON.stringify(state.connection)
|
||||
)
|
||||
applyConnection(snapshot.connection);
|
||||
if (!snapshot.connection) renderDevices(snapshot.devices);
|
||||
controls();
|
||||
} catch (error) {
|
||||
if (state.disposed || revision !== state.revision) return;
|
||||
$("scan-status").textContent =
|
||||
`自动检测暂时不可用:${error.message || String(error)}`;
|
||||
if (
|
||||
error.code?.startsWith("bridge.") ||
|
||||
error.code?.startsWith("provider.")
|
||||
) {
|
||||
state.backendBusy = false;
|
||||
applyConnection(null);
|
||||
renderDevices([]);
|
||||
}
|
||||
}
|
||||
})();
|
||||
try {
|
||||
await state.monitorTask;
|
||||
} finally {
|
||||
state.monitorTask = null;
|
||||
scheduleMonitor();
|
||||
}
|
||||
}
|
||||
function breadcrumbs() {
|
||||
$("breadcrumbs").replaceChildren();
|
||||
const root =
|
||||
@@ -269,18 +382,25 @@ function renderTransfer() {
|
||||
}
|
||||
function schedulePoll() {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = setTimeout(poll, 400);
|
||||
if (!state.disposed) state.timer = setTimeout(poll, 400);
|
||||
}
|
||||
async function poll() {
|
||||
const revision = state.revision;
|
||||
try {
|
||||
state.job = await native("transferStatus");
|
||||
const job = await native("transferStatus");
|
||||
if (state.disposed || revision !== state.revision) return;
|
||||
state.job = job;
|
||||
renderTransfer();
|
||||
if (state.job?.state === "running") schedulePoll();
|
||||
else {
|
||||
await syncConnection();
|
||||
await syncConnection(revision);
|
||||
if (state.disposed || revision !== state.revision) return;
|
||||
if (state.connection) await action(() => load());
|
||||
}
|
||||
} catch (error) {
|
||||
if (state.disposed || revision !== state.revision) return;
|
||||
state.backendBusy = false;
|
||||
applyConnection(null);
|
||||
state.job = {
|
||||
...state.job,
|
||||
state: "failed",
|
||||
@@ -290,50 +410,27 @@ async function poll() {
|
||||
notice(error.message || String(error));
|
||||
}
|
||||
}
|
||||
$("scan").addEventListener("click", () =>
|
||||
action(async () => {
|
||||
$("scan-status").textContent = "正在扫描 USB 设备…";
|
||||
$("device-picker").hidden = true;
|
||||
try {
|
||||
const { devices } = await native("devices");
|
||||
$("device-select").replaceChildren();
|
||||
for (const device of devices) {
|
||||
const option = text(
|
||||
"option",
|
||||
`${device.name || device.manufacturer || "MTP 设备"} · ${device.id}`,
|
||||
);
|
||||
option.value = device.id;
|
||||
$("device-select").append(option);
|
||||
}
|
||||
$("device-picker").hidden = !devices.length;
|
||||
$("scan-status").textContent = devices.length
|
||||
? `发现 ${devices.length} 台设备,选择后连接。`
|
||||
: "未发现设备。请检查数据线,解锁手机并选择「传输文件」。";
|
||||
} catch (error) {
|
||||
$("scan-status").textContent = "扫描未完成,请检查连接后重试。";
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
$("scan").addEventListener("click", () => action(scan));
|
||||
$("connect").addEventListener("click", () =>
|
||||
action(async () => {
|
||||
state.connection = await native("connect", {
|
||||
const connection = await native("connect", {
|
||||
id: $("device-select").value,
|
||||
});
|
||||
state.storage = state.connection.storages[0].id;
|
||||
state.path = [];
|
||||
applyConnection(connection);
|
||||
state.job = null;
|
||||
renderConnection();
|
||||
renderTransfer();
|
||||
await load();
|
||||
}),
|
||||
);
|
||||
$("disconnect").addEventListener("click", () =>
|
||||
action(async () => {
|
||||
await native("disconnect");
|
||||
await syncConnection();
|
||||
$("device-picker").hidden = true;
|
||||
$("scan-status").textContent = "连接已断开,可重新扫描设备。";
|
||||
const result = await native("disconnect");
|
||||
clearTimeout(state.timer);
|
||||
state.job = null;
|
||||
state.backendBusy = !!result.busy;
|
||||
applyConnection(null);
|
||||
renderTransfer();
|
||||
if (!state.backendBusy) await scan();
|
||||
}),
|
||||
);
|
||||
$("refresh").addEventListener("click", () => action(() => load()));
|
||||
@@ -392,12 +489,20 @@ $("folder-form").addEventListener("submit", (event) => {
|
||||
await load();
|
||||
});
|
||||
});
|
||||
window.addEventListener("pagehide", () => clearTimeout(state.timer));
|
||||
window.addEventListener("pagehide", () => {
|
||||
state.disposed = true;
|
||||
state.revision++;
|
||||
clearTimeout(state.timer);
|
||||
clearTimeout(state.monitorTimer);
|
||||
});
|
||||
await action(async () => {
|
||||
await trove.ready();
|
||||
await syncConnection();
|
||||
state.job = await native("transferStatus");
|
||||
if (!state.connection && state.job?.state === "completed") state.job = null;
|
||||
renderTransfer();
|
||||
if (state.job?.state === "running") schedulePoll();
|
||||
else if (state.connection) await load();
|
||||
else await scan();
|
||||
});
|
||||
scheduleMonitor();
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
<div class="device-card">
|
||||
<span class="device-icon" aria-hidden="true">▯</span>
|
||||
<div>
|
||||
<strong id="device-name">等待连接</strong
|
||||
><span id="device-state">USB 文件传输</span>
|
||||
<strong id="device-name">等待连接</strong>
|
||||
<span id="device-model" hidden></span>
|
||||
<span id="device-state">USB 文件传输</span>
|
||||
</div>
|
||||
<i id="device-dot"></i>
|
||||
</div>
|
||||
@@ -67,7 +68,7 @@
|
||||
<ol class="steps">
|
||||
<li><b>1</b>解锁手机并连接 USB 数据线</li>
|
||||
<li><b>2</b>在手机上选择「传输文件」</li>
|
||||
<li><b>3</b>扫描设备,开始浏览</li>
|
||||
<li><b>3</b>等待自动发现,选择设备并连接</li>
|
||||
</ol>
|
||||
<div id="device-picker" hidden>
|
||||
<label for="device-select">选择设备</label>
|
||||
@@ -154,7 +155,7 @@
|
||||
<li>使用支持数据传输的 USB 线连接 Mac 与手机,并保持手机解锁。</li>
|
||||
<li>在手机 USB 选项中选择「传输文件」,按提示允许访问。</li>
|
||||
<li>
|
||||
点击「扫描设备」,选择手机并连接。打开文件夹后可发送文件,或点击文件旁的「接收」。
|
||||
插件会自动发现设备,选择手机并连接。也可点击「扫描设备」手动刷新。打开文件夹后可发送文件,或点击文件旁的「接收」。
|
||||
</li>
|
||||
</ol>
|
||||
<h3>没有发现设备?</h3>
|
||||
|
||||
@@ -161,6 +161,11 @@ button:hover:not(:disabled) {
|
||||
line-height: 1;
|
||||
color: var(--blue);
|
||||
}
|
||||
#device-model {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.device-card i,
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Harmony Connector
|
||||
|
||||
插件 ID:`dev.trove.harmony-connector`。版本:`0.1.0`。
|
||||
插件 ID:`dev.trove.harmony-connector`。版本:`0.1.1`。
|
||||
|
||||
面向 HarmonyOS 6 的 USB 文件传输插件,提供设备选择、存储与目录浏览、分页、名称筛选、新建目录,以及单文件发送/接收、字节进度与取消。使用 libmtp 1.1.23 和 libusb 1.0.30,无需手机开发者模式、HDC、ADB 或 Homebrew 运行依赖。
|
||||
|
||||
@@ -16,13 +16,19 @@
|
||||
|
||||
1. 解锁手机,用支持数据传输的 USB 线连接 Mac。
|
||||
2. 在手机 USB 选项中选择「传输文件」,允许访问。
|
||||
3. 扫描、选择设备并连接,打开目标文件夹。
|
||||
3. 等待插件自动发现设备,选择并连接,打开目标文件夹。也可手动点击「扫描设备」。
|
||||
4. 点击「发送文件」从 Mac 选择一个文件;点击文件旁的「接收」选择 Mac 接收文件夹。
|
||||
|
||||
本插件只访问手机通过 MTP 开放的存储。目录来自设备对象 ID,不映射为 Android 路径。HarmonyOS 6 在文件传输模式下允许导出图库内容,导入图库需要图片传输模式;本版未实现 PTP 图库导入。[华为 HarmonyOS 6 官方说明](https://consumer.huawei.com/cn/support/content/zh-cn16090699/)
|
||||
|
||||
没有发现设备时,检查数据线和 USB 模式、保持手机解锁,退出占用手机的其他传输应用后重试。取消或 USB 操作失败会释放会话,请重新连接。取消是协作式的,需要等待手机或 USB 超时返回,不保证立即完成。
|
||||
|
||||
插件打开时立即扫描,随后在空闲时约每 2 秒检测一次。已连接时只检查 USB 设备是否仍存在,不重复打开 MTP 会话;传输期间也检测拔线并请求结束传输。发现设备后仍由用户选择连接,手动断开不会立即自动重连。
|
||||
|
||||
手动断开会立即清空文件和连接界面,USB 会话在后台释放;清理期间显示提示并暂时禁用连接,完成后继续自动发现。设备不响应 MTP 关闭请求时,底层释放仍可能需要等待 USB 超时。
|
||||
|
||||
名称在扫描阶段来自 USB 描述符;连接后优先使用设备通过 MTP 返回的友好名称,并单独显示设备报告的型号。友好名称由固件提供,不一定等于系统设置中的自定义设备名;缺失时回退到型号或 USB 名称。libmtp 的静态设备匹配表只用于协议兼容,不作为型号展示依据;0.1.0 曾把其中的 `Nova14` 兼容条目误用为设备名称,0.1.1 已修复。
|
||||
|
||||
## 文件保护与边界
|
||||
|
||||
- 一次传输一个普通文件;目录请先压缩。不提供删除手机内容的操作。
|
||||
@@ -72,11 +78,13 @@ node tools/preview-harmony.mjs
|
||||
| `native/src/wire.rs` | Trove Wire v1 认证握手、请求分发与退出 |
|
||||
| `build.mjs`、`legal/` | 固定依赖构建、动态库路径修正、签名与第三方声明 |
|
||||
|
||||
`@self` 接口:`info`、`devices`、`connect({id})`、`disconnect`、`list({storage,parent,offset?})`、`mkdir({storage,parent,name})`、`transfer(...)`、`transferStatus`、`cancelTransfer({id})`。根目录为 `0xffffffff`;目录响应每页最多 500 个条目,传输异步返回任务 ID,界面轮询实际进度。后端同一时刻仅执行一个 USB 操作;进度查询和取消不等待 USB 锁。重新连接后必须重新获取对象列表。
|
||||
`@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 锁。重新连接后必须重新获取对象列表。
|
||||
|
||||
## 验证记录与真机验收
|
||||
|
||||
开发机的原生 USB 扫描正常返回空设备列表。完成的自动测试包含 10 项传输行为测试、2 项界面数据测试,以及 Trove 的握手、并发响应、退出协议测试;传输行为测试使用独立内存设备并实际读写临时目录。浏览器中验证模拟设备选择、导航、中文目录、筛选、不可信文件名显示、进度、完成和取消状态。
|
||||
自动测试包含 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、图库导出、取消、拔线、重新连接及退出的结果。
|
||||
|
||||
|
||||
@@ -18,5 +18,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@trove/plugin-sdk": "file:vendor/trove-plugin-sdk-0.1.0.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "26.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+333
@@ -11,13 +11,346 @@ importers:
|
||||
'@trove/plugin-sdk':
|
||||
specifier: file:vendor/trove-plugin-sdk-0.1.0.tgz
|
||||
version: file:vendor/trove-plugin-sdk-0.1.0.tgz
|
||||
devDependencies:
|
||||
jsdom:
|
||||
specifier: 26.1.0
|
||||
version: 26.1.0
|
||||
|
||||
packages:
|
||||
|
||||
'@asamuzakjp/css-color@3.2.0':
|
||||
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@csstools/css-calc@2.1.4':
|
||||
resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^3.0.5
|
||||
'@csstools/css-tokenizer': ^3.0.4
|
||||
|
||||
'@csstools/css-color-parser@3.1.0':
|
||||
resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^3.0.5
|
||||
'@csstools/css-tokenizer': ^3.0.4
|
||||
|
||||
'@csstools/css-parser-algorithms@3.0.5':
|
||||
resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@csstools/css-tokenizer': ^3.0.4
|
||||
|
||||
'@csstools/css-tokenizer@3.0.4':
|
||||
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@trove/plugin-sdk@file:vendor/trove-plugin-sdk-0.1.0.tgz':
|
||||
resolution: {integrity: sha512-jd6JJO3FGogsGkoFHlNYTDDkLu++HUctWI0sUV05Ry7JxfJEgQkz8BjHVs58lU/euoLAVTUh3itGM/gnjwRI+A==, tarball: file:vendor/trove-plugin-sdk-0.1.0.tgz}
|
||||
version: 0.1.0
|
||||
|
||||
agent-base@7.1.4:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
cssstyle@4.6.0:
|
||||
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
data-urls@5.0.0:
|
||||
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
entities@6.0.1:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
html-encoding-sniffer@4.0.0:
|
||||
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-potential-custom-element-name@1.0.1:
|
||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||
|
||||
jsdom@26.1.0:
|
||||
resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
canvas: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
canvas:
|
||||
optional: true
|
||||
|
||||
lru-cache@10.4.3:
|
||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
nwsapi@2.2.27:
|
||||
resolution: {integrity: sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==}
|
||||
|
||||
parse5@7.3.0:
|
||||
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
rrweb-cssom@0.8.0:
|
||||
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
saxes@6.0.0:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
|
||||
symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
tldts-core@6.1.86:
|
||||
resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==}
|
||||
|
||||
tldts@6.1.86:
|
||||
resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==}
|
||||
hasBin: true
|
||||
|
||||
tough-cookie@5.1.2:
|
||||
resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
tr46@5.1.1:
|
||||
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
webidl-conversions@7.0.0:
|
||||
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
whatwg-encoding@3.1.1:
|
||||
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
|
||||
engines: {node: '>=18'}
|
||||
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
|
||||
|
||||
whatwg-mimetype@4.0.0:
|
||||
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
whatwg-url@14.2.0:
|
||||
resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
ws@8.21.3:
|
||||
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xml-name-validator@5.0.0:
|
||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
xmlchars@2.2.0:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@asamuzakjp/css-color@3.2.0':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
lru-cache: 10.4.3
|
||||
|
||||
'@csstools/color-helpers@5.1.0': {}
|
||||
|
||||
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
dependencies:
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
|
||||
'@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
dependencies:
|
||||
'@csstools/color-helpers': 5.1.0
|
||||
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
|
||||
'@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)':
|
||||
dependencies:
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
|
||||
'@csstools/css-tokenizer@3.0.4': {}
|
||||
|
||||
'@trove/plugin-sdk@file:vendor/trove-plugin-sdk-0.1.0.tgz': {}
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
cssstyle@4.6.0:
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 3.2.0
|
||||
rrweb-cssom: 0.8.0
|
||||
|
||||
data-urls@5.0.0:
|
||||
dependencies:
|
||||
whatwg-mimetype: 4.0.0
|
||||
whatwg-url: 14.2.0
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
html-encoding-sniffer@4.0.0:
|
||||
dependencies:
|
||||
whatwg-encoding: 3.1.1
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
is-potential-custom-element-name@1.0.1: {}
|
||||
|
||||
jsdom@26.1.0:
|
||||
dependencies:
|
||||
cssstyle: 4.6.0
|
||||
data-urls: 5.0.0
|
||||
decimal.js: 10.6.0
|
||||
html-encoding-sniffer: 4.0.0
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
nwsapi: 2.2.27
|
||||
parse5: 7.3.0
|
||||
rrweb-cssom: 0.8.0
|
||||
saxes: 6.0.0
|
||||
symbol-tree: 3.2.4
|
||||
tough-cookie: 5.1.2
|
||||
w3c-xmlserializer: 5.0.0
|
||||
webidl-conversions: 7.0.0
|
||||
whatwg-encoding: 3.1.1
|
||||
whatwg-mimetype: 4.0.0
|
||||
whatwg-url: 14.2.0
|
||||
ws: 8.21.3
|
||||
xml-name-validator: 5.0.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
lru-cache@10.4.3: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nwsapi@2.2.27: {}
|
||||
|
||||
parse5@7.3.0:
|
||||
dependencies:
|
||||
entities: 6.0.1
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
rrweb-cssom@0.8.0: {}
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
saxes@6.0.0:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
tldts-core@6.1.86: {}
|
||||
|
||||
tldts@6.1.86:
|
||||
dependencies:
|
||||
tldts-core: 6.1.86
|
||||
|
||||
tough-cookie@5.1.2:
|
||||
dependencies:
|
||||
tldts: 6.1.86
|
||||
|
||||
tr46@5.1.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
webidl-conversions@7.0.0: {}
|
||||
|
||||
whatwg-encoding@3.1.1:
|
||||
dependencies:
|
||||
iconv-lite: 0.6.3
|
||||
|
||||
whatwg-mimetype@4.0.0: {}
|
||||
|
||||
whatwg-url@14.2.0:
|
||||
dependencies:
|
||||
tr46: 5.1.1
|
||||
webidl-conversions: 7.0.0
|
||||
|
||||
ws@8.21.3: {}
|
||||
|
||||
xml-name-validator@5.0.0: {}
|
||||
|
||||
xmlchars@2.2.0: {}
|
||||
|
||||
+16
-1
@@ -2,7 +2,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "trove-official",
|
||||
"name": "Trove 官方软件源",
|
||||
"generatedAt": "2026-09-10T02:03:28.662Z",
|
||||
"generatedAt": "2026-09-10T02:56:51.048Z",
|
||||
"curios": [
|
||||
{
|
||||
"id": "dev.trove.qa.host",
|
||||
@@ -200,6 +200,21 @@
|
||||
"name": "Harmony Connector",
|
||||
"description": "通过 USB 连接鸿蒙设备,在 Mac 与手机之间浏览、发送和接收文件。",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.1",
|
||||
"engine": {
|
||||
"trove": ">=0.1.0 <1.0.0"
|
||||
},
|
||||
"releasedAt": "2026-09-10T02:56:51.048Z",
|
||||
"artifacts": [
|
||||
{
|
||||
"target": "aarch64-apple-darwin",
|
||||
"url": "packages/dev.trove.harmony-connector/0.1.1/aarch64-apple-darwin.curio",
|
||||
"sha256": "180a0523d6f73874443777ae77acf0612c30e1fa4083026fb0d9a2a36ab48ad5",
|
||||
"size": 2334431
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"engine": {
|
||||
|
||||
Binary file not shown.
@@ -3,13 +3,15 @@ import { createMockBridge } from "./sdk/index.js";
|
||||
const root = 0xffffffff;
|
||||
const device = {
|
||||
id: "fixture",
|
||||
name: "HarmonyOS 6 · 界面测试",
|
||||
name: "daniel",
|
||||
model: "HUAWEI Pura 70 Ultra",
|
||||
manufacturer: "模拟设备",
|
||||
};
|
||||
const storages = [
|
||||
{ id: 1, name: "内部存储", capacity: 256 * 1024 ** 3, free: 128 * 1024 ** 3 },
|
||||
];
|
||||
let connection = null,
|
||||
let attached = true,
|
||||
connection = null,
|
||||
job = null,
|
||||
next = 30;
|
||||
const entries = [
|
||||
@@ -61,16 +63,22 @@ const entries = [
|
||||
];
|
||||
window.__TROVE_CONTEXT__ = {
|
||||
curioId: "dev.trove.harmony-connector",
|
||||
version: "0.1.0",
|
||||
version: "0.1.1",
|
||||
instanceId: "ui-fixture",
|
||||
platform: "macos",
|
||||
};
|
||||
window.__TROVE_BRIDGE__ = createMockBridge({
|
||||
"@self.info": () => ({ connection, busy: false }),
|
||||
"@self.devices": () => ({ devices: [device] }),
|
||||
"@self.devices": () => ({ devices: attached ? [device] : [] }),
|
||||
"@self.monitor": () => ({
|
||||
connection,
|
||||
devices: attached ? [device] : [],
|
||||
busy: job?.state === "running",
|
||||
}),
|
||||
"@self.connect": () => (connection = { device, storages }),
|
||||
"@self.disconnect": () => {
|
||||
connection = null;
|
||||
job = null;
|
||||
return { disconnected: true };
|
||||
},
|
||||
"@self.list": ({ parent, offset = 0 }) => {
|
||||
@@ -117,4 +125,20 @@ badge.textContent = "界面测试 · 模拟设备,不执行真实文件传输"
|
||||
badge.style.cssText =
|
||||
"position:fixed;bottom:3px;left:240px;z-index:10;background:#805800;color:white;padding:4px 8px;border-radius:5px;font-size:10px";
|
||||
document.body.append(badge);
|
||||
const toggle = document.createElement("button");
|
||||
toggle.textContent = "模拟拔掉 USB";
|
||||
toggle.style.cssText =
|
||||
"position:fixed;bottom:30px;left:16px;z-index:10;background:#805800;color:white";
|
||||
toggle.addEventListener("click", () => {
|
||||
attached = !attached;
|
||||
if (!attached) {
|
||||
connection = null;
|
||||
if (job?.state === "running") {
|
||||
job.state = "failed";
|
||||
job.error = { message: "设备已断开(模拟)" };
|
||||
}
|
||||
}
|
||||
toggle.textContent = attached ? "模拟拔掉 USB" : "模拟接入 USB";
|
||||
});
|
||||
document.body.append(toggle);
|
||||
await import("./app.js");
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { test, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, cp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL, fileURLToPath } from "node:url";
|
||||
import { setImmediate as flush } from "node:timers/promises";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const root = fileURLToPath(new URL("../", import.meta.url));
|
||||
let cleanup;
|
||||
afterEach(async () => {
|
||||
if (cleanup) {
|
||||
await cleanup();
|
||||
cleanup = null;
|
||||
}
|
||||
});
|
||||
async function settle() {
|
||||
for (let i = 0; i < 6; i++) await flush();
|
||||
}
|
||||
async function app(t) {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "harmony-ui-"));
|
||||
await cp(path.join(root, "curios/harmony-connector/ui"), directory, {
|
||||
recursive: true,
|
||||
});
|
||||
await cp(
|
||||
path.dirname(fileURLToPath(import.meta.resolve("@trove/plugin-sdk"))),
|
||||
path.join(directory, "sdk"),
|
||||
{ recursive: true },
|
||||
);
|
||||
const dom = new JSDOM(
|
||||
await readFile(path.join(directory, "index.html"), "utf8"),
|
||||
{ url: "https://curio.test" },
|
||||
);
|
||||
globalThis.window = dom.window;
|
||||
globalThis.document = dom.window.document;
|
||||
const device = {
|
||||
id: "7:3",
|
||||
name: "daniel",
|
||||
model: "HUAWEI Pura 70 Ultra",
|
||||
manufacturer: "HUAWEI",
|
||||
};
|
||||
const storages = [{ id: 1, name: "内部存储", free: 1e9, capacity: 2e9 }];
|
||||
const model = {
|
||||
attached: true,
|
||||
connection: null,
|
||||
calls: [],
|
||||
job: null,
|
||||
busy: false,
|
||||
monitorWait: null,
|
||||
};
|
||||
const { createMockBridge } = await import(
|
||||
pathToFileURL(path.join(directory, "sdk/index.js"))
|
||||
);
|
||||
const handler = (method, fn) => [
|
||||
`@self.${method}`,
|
||||
async (params) => {
|
||||
model.calls.push(method);
|
||||
return structuredClone(await fn(params));
|
||||
},
|
||||
];
|
||||
window.__TROVE_CONTEXT__ = {
|
||||
curioId: "dev.trove.harmony-connector",
|
||||
version: "0.1.0",
|
||||
instanceId: "test",
|
||||
platform: "macos",
|
||||
};
|
||||
window.__TROVE_BRIDGE__ = createMockBridge(
|
||||
Object.fromEntries([
|
||||
handler("info", () => ({ connection: model.connection, busy: false })),
|
||||
handler("devices", () => ({ devices: model.attached ? [device] : [] })),
|
||||
handler("connect", () => (model.connection = { device, storages })),
|
||||
handler("disconnect", () => {
|
||||
model.connection = null;
|
||||
return { disconnected: true, busy: model.busy };
|
||||
}),
|
||||
handler("list", () => ({
|
||||
entries: [
|
||||
{
|
||||
id: 2,
|
||||
parent: 0xffffffff,
|
||||
storage: 1,
|
||||
name: "旅行.pdf",
|
||||
size: 100,
|
||||
folder: false,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})),
|
||||
handler("transferStatus", () => model.job),
|
||||
handler("transfer", () => {
|
||||
model.job = { id: 1, state: "running", sent: 0, total: 100 };
|
||||
return { id: 1 };
|
||||
}),
|
||||
["host.dialog.openFile", () => ({ path: "/fixture/file.txt" })],
|
||||
handler("monitor", async () => {
|
||||
const snapshot = {
|
||||
connection: model.connection,
|
||||
devices: model.attached ? [device] : [],
|
||||
busy: model.busy,
|
||||
};
|
||||
if (!model.attached) model.connection = null;
|
||||
if (model.monitorWait) {
|
||||
await model.monitorWait;
|
||||
return snapshot;
|
||||
}
|
||||
return { ...snapshot, connection: model.connection };
|
||||
}),
|
||||
]),
|
||||
);
|
||||
cleanup = async () => {
|
||||
dom.window.dispatchEvent(new dom.window.Event("pagehide"));
|
||||
dom.window.close();
|
||||
delete globalThis.window;
|
||||
delete globalThis.document;
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
};
|
||||
await import(pathToFileURL(path.join(directory, "app.js")));
|
||||
await settle();
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const click = async (id) => {
|
||||
$(id).click();
|
||||
await settle();
|
||||
};
|
||||
const tick = async () => {
|
||||
t.mock.timers.tick(2500);
|
||||
await settle();
|
||||
};
|
||||
const connect = async () => {
|
||||
await click("scan");
|
||||
await click("connect");
|
||||
assert.equal($("browser").hidden, false);
|
||||
};
|
||||
return { $, click, tick, connect, model };
|
||||
}
|
||||
test("opening the plugin discovers already attached devices without clicking scan", async (t) => {
|
||||
const { $ } = await app(t);
|
||||
assert.equal($("device-picker").hidden, false);
|
||||
assert.equal($("device-select").options.length, 1);
|
||||
});
|
||||
test("plugging in and unplugging refreshes the picker without manual scanning", async (t) => {
|
||||
const { $, tick, model } = await app(t);
|
||||
model.attached = false;
|
||||
await tick();
|
||||
assert.equal($("device-picker").hidden, true);
|
||||
model.attached = true;
|
||||
await tick();
|
||||
assert.equal($("device-picker").hidden, false);
|
||||
model.attached = false;
|
||||
await tick();
|
||||
assert.equal($("device-select").options.length, 0);
|
||||
});
|
||||
test("unplugging an idle connected phone clears the browser and connected badge", async (t) => {
|
||||
const { $, connect, tick, model } = await app(t);
|
||||
await connect();
|
||||
model.attached = false;
|
||||
await tick();
|
||||
assert.equal($("browser").hidden, true);
|
||||
assert.equal($("device-dot").classList.contains("connected"), false);
|
||||
assert.equal($("files").children.length, 0);
|
||||
assert.equal($("device-select").options.length, 0);
|
||||
});
|
||||
test("manual disconnect clears files, filters and completed transfers", async (t) => {
|
||||
const { $, connect, click, tick, model } = await app(t);
|
||||
await connect();
|
||||
await click("upload");
|
||||
model.job = {
|
||||
id: 1,
|
||||
state: "completed",
|
||||
result: { name: "旅行.pdf", bytes: 100 },
|
||||
};
|
||||
await tick();
|
||||
assert.equal($("transfer").hidden, false);
|
||||
$("search").value = "旅行";
|
||||
await click("disconnect");
|
||||
await tick();
|
||||
assert.equal($("browser").hidden, true);
|
||||
assert.equal($("files").children.length, 0);
|
||||
assert.equal($("search").value, "");
|
||||
assert.equal($("transfer").hidden, true);
|
||||
assert.equal($("footer-status").textContent.includes("已连接"), false);
|
||||
});
|
||||
test("unplugging after a completed transfer clears the old success panel", async (t) => {
|
||||
const { $, connect, click, tick, model } = await app(t);
|
||||
await connect();
|
||||
await click("upload");
|
||||
model.job = {
|
||||
id: 1,
|
||||
state: "completed",
|
||||
result: { name: "旅行.pdf", bytes: 100 },
|
||||
};
|
||||
await tick();
|
||||
assert.equal($("transfer").hidden, false);
|
||||
model.attached = false;
|
||||
await tick();
|
||||
assert.equal($("transfer").hidden, true);
|
||||
});
|
||||
test("connected device displays the friendly name and actual model separately", async (t) => {
|
||||
const { $, connect } = await app(t);
|
||||
await connect();
|
||||
assert.equal($("device-name").textContent, "daniel");
|
||||
assert.equal($("device-model").textContent, "HUAWEI Pura 70 Ultra");
|
||||
assert.equal($("device-model").hidden, false);
|
||||
});
|
||||
test("an in-flight USB snapshot cannot restore a manually disconnected session", async (t) => {
|
||||
const { $, connect, click, tick, model } = await app(t);
|
||||
await connect();
|
||||
let resolve;
|
||||
model.monitorWait = new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
await tick();
|
||||
const disconnect = click("disconnect");
|
||||
resolve();
|
||||
await disconnect;
|
||||
await settle();
|
||||
assert.equal($("browser").hidden, true);
|
||||
assert.equal($("files").children.length, 0);
|
||||
});
|
||||
test("closing the UI stops automatic scanning", async (t) => {
|
||||
const { tick, model } = await app(t);
|
||||
const before = model.calls.length;
|
||||
window.dispatchEvent(new window.Event("pagehide"));
|
||||
await tick();
|
||||
await tick();
|
||||
assert.equal(model.calls.length, before);
|
||||
});
|
||||
test("USB disappearance during a transfer clears the browser while cleanup is pending", async (t) => {
|
||||
const { $, connect, click, tick, model } = await app(t);
|
||||
await connect();
|
||||
await click("upload");
|
||||
model.attached = false;
|
||||
model.busy = true;
|
||||
await tick();
|
||||
assert.equal($("browser").hidden, true);
|
||||
assert.equal($("device-dot").classList.contains("connected"), false);
|
||||
assert.equal($("scan").disabled, true);
|
||||
assert.equal($("footer-status").textContent.includes("设备已断开"), true);
|
||||
});
|
||||
test("manual disconnect keeps the UI responsive while USB cleanup is pending", async (t) => {
|
||||
const { $, connect, click, tick, model } = await app(t);
|
||||
await connect();
|
||||
model.busy = true;
|
||||
const scans = model.calls.filter((method) => method === "devices").length;
|
||||
await click("disconnect");
|
||||
assert.equal($("browser").hidden, true);
|
||||
assert.equal($("files").children.length, 0);
|
||||
assert.equal($("scan").disabled, true);
|
||||
assert.equal($("footer-status").textContent.includes("设备已断开"), true);
|
||||
assert.equal(
|
||||
model.calls.filter((method) => method === "devices").length,
|
||||
scans,
|
||||
);
|
||||
model.busy = false;
|
||||
await tick();
|
||||
assert.equal($("connect").disabled, false);
|
||||
assert.equal($("browser").hidden, true);
|
||||
});
|
||||
+3
-1
@@ -520,7 +520,9 @@ async fn remote_source_check(url: &str, source_id: &str, kind: &str) -> Result<(
|
||||
.await;
|
||||
if rows.iter().all(|row| row["status"] == "pass") {
|
||||
for entry in packages.catalog() {
|
||||
for version in entry.package.versions {
|
||||
// Our publication index puts new releases first. Install history oldest
|
||||
// first so the runtime check below exercises the newest published build.
|
||||
for version in entry.package.versions.into_iter().rev() {
|
||||
check(&mut rows, format!("远程安装 {}@{}", entry.package.id, version.version), async {
|
||||
let package = packages.install_from_source(source_id, &entry.package.id, &version.version, true).await?;
|
||||
ensure!(package.source_id.as_deref() == Some(source_id), "Installation wasn't bound to the remote source");
|
||||
|
||||
@@ -19,6 +19,20 @@ run(
|
||||
},
|
||||
);
|
||||
run(process.execPath, ["--test", path.join(plugin, "ui/format.test.js")]);
|
||||
run(process.execPath, ["--test", path.join(root, "tools/harmony-ui.test.mjs")]);
|
||||
const prefix = path.join(plugin, "dist/native-deps/install");
|
||||
const bridgeTest = path.join(plugin, "dist/mtp-bridge-test");
|
||||
run("cc", [
|
||||
"-I" + path.join(prefix, "include"),
|
||||
path.join(plugin, "native/tests/mtp_bridge_test.c"),
|
||||
path.join(plugin, "native/src/mtp_bridge.c"),
|
||||
"-L" + path.join(prefix, "lib"),
|
||||
"-lmtp",
|
||||
"-lusb-1.0",
|
||||
"-o",
|
||||
bridgeTest,
|
||||
]);
|
||||
run(bridgeTest, []);
|
||||
run(troveBin, [
|
||||
"protocol",
|
||||
"test",
|
||||
|
||||
Reference in New Issue
Block a user