From 6bdcfdb44e35d70d4bd886b43642f2996bb50a5b Mon Sep 17 00:00:00 2001 From: render Date: Thu, 10 Sep 2026 10:57:10 +0800 Subject: [PATCH] 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. --- curios/harmony-connector/curio.json | 2 +- curios/harmony-connector/native/Cargo.lock | 2 +- curios/harmony-connector/native/Cargo.toml | 2 +- curios/harmony-connector/native/build.rs | 1 + .../harmony-connector/native/src/connector.rs | 91 ++++- .../native/src/connector/tests.rs | 141 ++++++++ curios/harmony-connector/native/src/model.rs | 4 +- curios/harmony-connector/native/src/mtp.rs | 46 +++ .../harmony-connector/native/src/mtp_bridge.c | 55 ++- .../native/tests/mtp_bridge_test.c | 64 ++++ curios/harmony-connector/ui/app.js | 209 ++++++++--- curios/harmony-connector/ui/index.html | 9 +- curios/harmony-connector/ui/style.css | 5 + docs/harmony-connector.md | 16 +- package.json | 3 + pnpm-lock.yaml | 333 ++++++++++++++++++ source/index.json | 17 +- .../0.1.1/aarch64-apple-darwin.curio | Bin 0 -> 2334431 bytes tools/fixtures/harmony-ui.mjs | 32 +- tools/harmony-ui.test.mjs | 259 ++++++++++++++ tools/host-check.rs | 4 +- tools/test-harmony.mjs | 14 + 22 files changed, 1227 insertions(+), 82 deletions(-) create mode 100644 curios/harmony-connector/native/tests/mtp_bridge_test.c create mode 100644 source/packages/dev.trove.harmony-connector/0.1.1/aarch64-apple-darwin.curio create mode 100644 tools/harmony-ui.test.mjs diff --git a/curios/harmony-connector/curio.json b/curios/harmony-connector/curio.json index cf3d0dd..7ffe88c 100644 --- a/curios/harmony-connector/curio.json +++ b/curios/harmony-connector/curio.json @@ -3,7 +3,7 @@ "id": "dev.trove.harmony-connector", "name": "Harmony Connector", "description": "通过 USB 连接鸿蒙设备,在 Mac 与手机之间浏览、发送和接收文件。", - "version": "0.1.0", + "version": "0.1.1", "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 f260b91..dc42e38 100644 --- a/curios/harmony-connector/native/Cargo.lock +++ b/curios/harmony-connector/native/Cargo.lock @@ -65,7 +65,7 @@ dependencies = [ [[package]] name = "harmony-connector" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "cc", diff --git a/curios/harmony-connector/native/Cargo.toml b/curios/harmony-connector/native/Cargo.toml index babba13..763520b 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.0" +version = "0.1.1" edition = "2021" publish = false diff --git a/curios/harmony-connector/native/build.rs b/curios/harmony-connector/native/build.rs index 61a861a..ed72123 100644 --- a/curios/harmony-connector/native/build.rs +++ b/curios/harmony-connector/native/build.rs @@ -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"); } diff --git a/curios/harmony-connector/native/src/connector.rs b/curios/harmony-connector/native/src/connector.rs index 7e798c9..5121827 100644 --- a/curios/harmony-connector/native/src/connector.rs +++ b/curios/harmony-connector/native/src/connector.rs @@ -23,6 +23,12 @@ impl Drop for Gate { struct Session { device: Box, } +fn close_in_background(session: Session, gate: Gate) { + thread::spawn(move || { + let _gate = gate; + drop(session); + }); +} struct Job { id: u64, progress: Arc, @@ -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 { *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, + scan: impl FnOnce() -> Result>, + ) -> Result { + 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 { 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) diff --git a/curios/harmony-connector/native/src/connector/tests.rs b/curios/harmony-connector/native/src/connector/tests.rs index a7a9165..deebdc7 100644 --- a/curios/harmony-connector/native/src/connector/tests.rs +++ b/curios/harmony-connector/native/src/connector/tests.rs @@ -11,8 +11,20 @@ struct Memory { truncate: bool, slow: bool, collide: Option, + release: Option>, } struct Device(Arc>); +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> { Ok(vec![Storage { @@ -171,6 +183,16 @@ fn directory_names(directory: &Path) -> Vec { .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); +} diff --git a/curios/harmony-connector/native/src/model.rs b/curios/harmony-connector/native/src/model.rs index 6f06636..4b65b97 100644 --- a/curios/harmony-connector/native/src/model.rs +++ b/curios/harmony-connector/native/src/model.rs @@ -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, } #[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) { diff --git a/curios/harmony-connector/native/src/mtp.rs b/curios/harmony-connector/native/src/mtp.rs index fdceae8..6c6da74 100644 --- a/curios/harmony-connector/native/src/mtp.rs +++ b/curios/harmony-connector/native/src/mtp.rs @@ -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::() + }; + 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 { + 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); // 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 { initialize(); NonNull::new(unsafe { hc_open(device.bus, device.address) }).map(Self) diff --git a/curios/harmony-connector/native/src/mtp_bridge.c b/curios/harmony-connector/native/src/mtp_bridge.c index f45e037..ce8c649 100644 --- a/curios/harmony-connector/native/src/mtp_bridge.c +++ b/curios/harmony-connector/native/src/mtp_bridge.c @@ -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 +#include #include #include #include @@ -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; diff --git a/curios/harmony-connector/native/tests/mtp_bridge_test.c b/curios/harmony-connector/native/tests/mtp_bridge_test.c new file mode 100644 index 0000000..3ed4c4d --- /dev/null +++ b/curios/harmony-connector/native/tests/mtp_bridge_test.c @@ -0,0 +1,64 @@ +/* Hardware-boundary regression: USB identity can differ from libmtp's ID table. */ +#include +#include +#include +#include +#include +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; +} diff --git a/curios/harmony-connector/ui/app.js b/curios/harmony-connector/ui/app.js index ad0e58e..b286415 100644 --- a/curios/harmony-connector/ui/app.js +++ b/curios/harmony-connector/ui/app.js @@ -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,20 +56,26 @@ 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.job?.state === "running" - ? "传输中 · 请保持连接" - : state.connection - ? "已连接 · USB 文件传输" - : "准备就绪"; + : state.backendBusy && !state.connection + ? "设备已断开,正在结束上一项操作…" + : state.job?.state === "running" + ? "传输中 · 请保持连接" + : state.connection + ? "已连接 · USB 文件传输" + : "准备就绪"; } 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) { - state.storage = null; - state.path = []; - state.entries = []; - } else if (!state.connection.storages.some((s) => s.id === state.storage)) { +function resetBrowser() { + state.storage = null; + state.path = []; + state.entries = []; + 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(); diff --git a/curios/harmony-connector/ui/index.html b/curios/harmony-connector/ui/index.html index 4feae8b..67104c3 100644 --- a/curios/harmony-connector/ui/index.html +++ b/curios/harmony-connector/ui/index.html @@ -20,8 +20,9 @@
- 等待连接USB 文件传输 + 等待连接 + + USB 文件传输
@@ -67,7 +68,7 @@
  1. 1解锁手机并连接 USB 数据线
  2. 2在手机上选择「传输文件」
  3. -
  4. 3扫描设备,开始浏览
  5. +
  6. 3等待自动发现,选择设备并连接