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等待自动发现,选择设备并连接