diff --git a/assets/auth-firmware/t5ai/auth-firmware-t5ai-1.1.0.bin b/assets/auth-firmware/t5ai/auth-firmware-t5ai-1.1.0.bin index e31f7b9b..7ea44501 100644 Binary files a/assets/auth-firmware/t5ai/auth-firmware-t5ai-1.1.0.bin and b/assets/auth-firmware/t5ai/auth-firmware-t5ai-1.1.0.bin differ diff --git a/crates/tyutool-cli/src/main.rs b/crates/tyutool-cli/src/main.rs index 00ecdd1e..42df10a8 100644 --- a/crates/tyutool-cli/src/main.rs +++ b/crates/tyutool-cli/src/main.rs @@ -470,6 +470,7 @@ fn main() -> Result<(), Box> { firmware_path: None, authorize_uuid: uuid, authorize_key: authkey, + authorize_storage: None, confirm_overwrite: None, }; let reporter = CliReporter::new(force_plain); @@ -519,6 +520,7 @@ fn main() -> Result<(), Box> { firmware_path: Some(file), authorize_uuid: None, authorize_key: None, + authorize_storage: None, confirm_overwrite: None, }; run_job(&job, &cancel, reporter.callback()) @@ -561,6 +563,7 @@ fn main() -> Result<(), Box> { firmware_path: None, authorize_uuid: None, authorize_key: None, + authorize_storage: None, confirm_overwrite: None, }; run_job(&job, &cancel, reporter.callback()) @@ -602,6 +605,7 @@ fn main() -> Result<(), Box> { firmware_path: None, authorize_uuid: None, authorize_key: None, + authorize_storage: None, confirm_overwrite: None, }; run_job(&job, &cancel, reporter.callback()) diff --git a/crates/tyutool-core/src/authorize.rs b/crates/tyutool-core/src/authorize.rs index 8348e7cc..4a0f09f9 100644 --- a/crates/tyutool-core/src/authorize.rs +++ b/crates/tyutool-core/src/authorize.rs @@ -47,6 +47,15 @@ use crate::job::FlashJob; const BAUD: u32 = 115_200; /// Per-command absolute read deadline (hard ceiling regardless of idle). const CMD_TIMEOUT: Duration = Duration::from_secs(3); +/// Total upper bound for the `auth-otp-lock` response wait. +/// eFuse burning is a physical write that may take significantly longer +/// than a normal shell command. This MUST be confirmed against real +/// hardware before release (see hardware verification scenario 7 in the spec). +const AUTH_OTP_LOCK_TIMEOUT: Duration = Duration::from_secs(2); +/// Idle window — wait this long after the last byte before declaring the +/// response complete. Set deliberately longer than `cmd_idle_timeout` to +/// avoid premature termination during eFuse settling. +const AUTH_OTP_LOCK_IDLE: Duration = Duration::from_millis(500); /// Drain: give up after this long regardless. const DRAIN_MAX: Duration = Duration::from_secs(5); /// Devices shipped un-authorized carry this placeholder UUID. @@ -456,9 +465,55 @@ impl AuthSession { self.read_response_timed(CMD_TIMEOUT, idle) } - /// Send `auth-read` and return `(uuid, authkey)` or `None`. - fn auth_read(&mut self) -> Option<(String, String)> { - self.send_cmd("auth-read").ok()?; + /// Send `auth-otp-lock` and parse the response. + /// + /// Returns `Ok(())` when the firmware confirms with + /// `"Authorization otp lock succeeds."`, `Err(FlashError::Plugin)` + /// otherwise (including explicit `"Authorization otp lock failure."`, + /// no response, and any other unrecognised output). + /// + /// **WARNING**: this command burns the eFuse and is irreversible. + /// Callers must gate it behind an explicit user opt-in. + /// + /// Uses [`AUTH_OTP_LOCK_TIMEOUT`] / [`AUTH_OTP_LOCK_IDLE`] rather than + /// the default shell-command timing because eFuse settling may delay + /// the response beyond the standard 50ms idle window. + fn auth_otp_lock(&mut self) -> Result<(), FlashError> { + self.send_cmd("auth-otp-lock") + .map_err(|e| FlashError::Plugin(format!("auth-otp-lock send failed: {e}")))?; + let lines = self.read_response_timed(AUTH_OTP_LOCK_TIMEOUT, AUTH_OTP_LOCK_IDLE); + + let mut saw_success = false; + let mut saw_failure = false; + for line in &lines { + let lower = line.to_lowercase(); + let trimmed = lower.trim(); + if trimmed.starts_with("authorization otp lock succeeds") { + saw_success = true; + } else if trimmed.starts_with("authorization otp lock failure") { + saw_failure = true; + } + } + + match (saw_success, saw_failure) { + (true, _) => Ok(()), + (false, true) => Err(FlashError::Plugin( + "auth-otp-lock: device returned failure".into(), + )), + (false, false) => Err(FlashError::Plugin( + "auth-otp-lock: no recognisable response".into(), + )), + } + } + + /// Send `auth-read` (or `auth-read ` for non-KV storage) and return `(uuid, authkey)` or `None`. + fn auth_read(&mut self, storage: AuthStorage) -> Option<(String, String)> { + let cmd = if storage == AuthStorage::Kv { + "auth-read".to_string() + } else { + format!("auth-read {}", storage.as_u8()) + }; + self.send_cmd(&cmd).ok()?; let lines = self.read_response(); let relevant: Vec<&str> = lines .iter() @@ -487,11 +542,19 @@ impl AuthSession { /// Callers must verify success via [`Self::auth_read`] rather than /// inspecting the returned lines, since not all firmware versions print /// `"Authorization write succeeds."` before rebooting. - fn auth_write(&mut self, uuid: &str, authkey: &str, idle: Duration) -> Vec { - if self - .send_cmd(&format!("auth {} {}", uuid, authkey)) - .is_err() - { + fn auth_write( + &mut self, + uuid: &str, + authkey: &str, + storage: AuthStorage, + idle: Duration, + ) -> Vec { + let cmd = if storage == AuthStorage::Kv { + format!("auth {} {}", uuid, authkey) + } else { + format!("auth {} {} {}", uuid, authkey, storage.as_u8()) + }; + if self.send_cmd(&cmd).is_err() { return vec![]; } self.read_response_idle(idle) @@ -674,8 +737,22 @@ pub enum BatchAuthSlotResult { /// `existing_uuid` is the UUID already on the device, so the caller can /// find and confirm that Excel row. Skipped { mac: String, existing_uuid: String }, + /// No auth code available in Excel — device was probed but not written. + InsufficientCodes { mac: String }, + /// Auth was written and verified successfully, but the subsequent + /// `auth-otp-lock` command failed. The credential **has been written + /// to the device's OTP region**, so callers must mark the allocated + /// Excel row as used (NOT release it) to avoid handing the same + /// UUID/Key out to another device. + LockFailed { mac: String, lock_error: String }, /// Operation was cancelled. Cancelled, + /// `auth_write` was sent to the device but the slot was cancelled + /// before verify could confirm. The credential MAY be on the device + /// (KV: overwritable; OTP: permanently written). The caller MUST + /// `confirm_row` (NOT release) to prevent the same UUID/Key from + /// being handed out to another device. + CancelledAfterWrite { mac: String, uuid: String }, } /// What to do when device already has conflicting auth. @@ -685,6 +762,27 @@ pub enum ConflictPolicy { Overwrite, } +/// Where to store the authorization credentials. +/// Passed as the third argument to the `auth` command (0 = KV, 1 = OTP). +/// `None` / `Kv` is the default and preserves existing behavior. +/// OTP writes are irreversible — the UI must warn the user before selecting it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AuthStorage { + #[default] + Kv, + Otp, +} + +impl AuthStorage { + fn as_u8(self) -> u8 { + match self { + AuthStorage::Kv => 0, + AuthStorage::Otp => 1, + } + } +} + /// Per-step progress marker emitted during a batch auth slot. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "snake_case")] @@ -749,13 +847,14 @@ where FirmwareKind::New(_) => { // ── New firmware: 2 retries / 200ms to absorb single-frame noise ── log::info!("flash.log.auth.readDeviceAuth"); + let storage = job.authorize_storage.unwrap_or_default(); let existing_auth = { let mut auth = None; for _ in 0..2u32 { if cancel.load(Ordering::Relaxed) { return Err(FlashError::Cancelled); } - auth = sess.auth_read(); + auth = sess.auth_read(storage); if auth.is_some() { break; } @@ -810,7 +909,7 @@ where // Keep 2000ms idle: device may reboot after writing auth on some // firmware builds; a short idle would exit before the reboot banner // and leave drain_boot_output unable to clear it in time. - let _lines = sess.auth_write(&uuid, &authkey, Duration::from_millis(2000)); + let _lines = sess.auth_write(&uuid, &authkey, storage, Duration::from_millis(2000)); if cancel.load(Ordering::Relaxed) { return Err(FlashError::Cancelled); @@ -832,7 +931,7 @@ where if cancel.load(Ordering::Relaxed) { return Err(FlashError::Cancelled); } - result = sess.auth_read(); + result = sess.auth_read(storage); if result.is_some() { break; } @@ -866,12 +965,13 @@ where // Optional read: skip write if device already matches log::info!("flash.log.auth.readDeviceAuth"); + let storage = job.authorize_storage.unwrap_or_default(); let mut existing_auth: Option<(String, String)> = None; for _attempt in 1..=5u32 { if cancel.load(Ordering::Relaxed) { return Err(FlashError::Cancelled); } - existing_auth = sess.auth_read(); + existing_auth = sess.auth_read(storage); if existing_auth.is_some() { break; } @@ -921,7 +1021,7 @@ where } log::info!("flash.log.auth.writeStart"); - let _lines = sess.auth_write(&uuid, &authkey, Duration::from_millis(2000)); + let _lines = sess.auth_write(&uuid, &authkey, storage, Duration::from_millis(2000)); if cancel.load(Ordering::Relaxed) { return Err(FlashError::Cancelled); @@ -945,7 +1045,7 @@ where } log::info!("flash.log.auth.verify"); - match sess.auth_read() { + match sess.auth_read(storage) { Some((rb_uuid, rb_key)) if rb_uuid == uuid && rb_key == authkey => { log::info!("flash.log.auth.verifyOk"); Ok(()) @@ -968,7 +1068,7 @@ where } else { // ── Read-only flow ──────────────────────────────────────────────────── log::info!("flash.log.auth.readCurrent"); - match sess.auth_read() { + match sess.auth_read(AuthStorage::default()) { Some((existing_uuid, existing_key)) => { if existing_uuid == PLACEHOLDER_UUID { progress(FlashEvent::Milestone { @@ -1005,21 +1105,29 @@ where /// Single-device batch authorization slot: open UART, read MAC, read/write auth, verify. /// /// The caller pre-allocates `uuid`/`authkey` from an Excel row. On return: -/// - `Done`/`AlreadyDone` → caller should confirm the Excel row (mark USED). -/// - `Skipped`/`Err`/`Cancelled` → caller should release the Excel row. -#[allow(clippy::too_many_arguments)] -pub fn run_batch_auth_slot( +/// - `Done` → caller should confirm the Excel row (mark USED). +/// - `AlreadyDone` → caller should release the Excel row (allocated but not consumed). +/// - `Skipped` → no row was allocated; caller should find-and-confirm the existing row. +/// - `InsufficientCodes` → no row was allocated; nothing to release. +/// - `Err`/`Cancelled` → caller should release the Excel row if one was allocated. +/// - `LockFailed` → auth WAS written to device's OTP region but the +/// subsequent `auth-otp-lock` failed; caller MUST `confirm_row` (NOT +/// release) to prevent the same UUID/Key from being handed to another +/// device whose OTP is still writable. +pub fn run_batch_auth_slot( port: &str, chip_id: &str, - uuid: &str, - authkey: &str, + get_code: G, auth_baud_rate: u32, conflict_policy: ConflictPolicy, + auth_storage: AuthStorage, + lock_otp: bool, cancel: &AtomicBool, progress: F, ) -> Result where F: Fn(BatchAuthStep), + G: FnOnce() -> Option<(String, String)>, { macro_rules! check_cancel { () => { @@ -1029,7 +1137,7 @@ where }; } - log::info!("[batch-auth] slot start port={port} chip={chip_id} uuid={uuid}"); + log::info!("[batch-auth] slot start port={port} chip={chip_id}"); let timing = AuthTiming::for_chip(chip_id); let mut sess = AuthSession::open(port, timing, auth_baud_rate)?; check_cancel!(); @@ -1062,7 +1170,7 @@ where let mut auth = None; for _ in 0..sess.timing.auth_read_retries { check_cancel!(); - auth = sess.auth_read(); + auth = sess.auth_read(auth_storage); if auth.is_some() { break; } @@ -1071,43 +1179,73 @@ where auth }; - // Conflict check + // Conflict check: if policy=Skip and device already has auth, skip without allocating. + if let Some((ref ex_uuid, _)) = existing_auth { + if ex_uuid != PLACEHOLDER_UUID && conflict_policy == ConflictPolicy::Skip { + log::info!( + "[batch-auth] skipped port={port} mac={mac} existing_uuid={ex_uuid}" + ); + return Ok(BatchAuthSlotResult::Skipped { + mac, + existing_uuid: ex_uuid.clone(), + }); + } + } + + // Lazily allocate an auth code — only now that we know the device needs one. + let (uuid, authkey) = match get_code() { + Some(c) => c, + None => { + log::info!("[batch-auth] no-code port={port} mac={mac}"); + return Ok(BatchAuthSlotResult::InsufficientCodes { mac }); + } + }; + log::info!("[batch-auth] allocated port={port} mac={mac} uuid={uuid}"); + + // AlreadyDone: device has the exact credentials we just allocated. if let Some((ref ex_uuid, ref ex_key)) = existing_auth { - if ex_uuid != PLACEHOLDER_UUID { - if ex_uuid == uuid && ex_key == authkey { - log::info!("[batch-auth] already-done port={port} mac={mac} uuid={uuid}"); - return Ok(BatchAuthSlotResult::AlreadyDone { mac }); - } - if conflict_policy == ConflictPolicy::Skip { - log::info!( - "[batch-auth] skipped port={port} mac={mac} existing_uuid={ex_uuid}" - ); - return Ok(BatchAuthSlotResult::Skipped { - mac, - existing_uuid: ex_uuid.clone(), - }); - } + if ex_uuid != PLACEHOLDER_UUID && ex_uuid == &uuid && ex_key == &authkey { + log::info!("[batch-auth] already-done port={port} mac={mac} uuid={uuid}"); + return Ok(BatchAuthSlotResult::AlreadyDone { mac }); } } // Write progress(BatchAuthStep::WritingAuth); log::info!("[batch-auth] writing port={port} mac={mac} uuid={uuid}"); - let _lines = sess.auth_write(uuid, authkey, Duration::from_millis(2000)); - check_cancel!(); + let _lines = + sess.auth_write(&uuid, &authkey, auth_storage, Duration::from_millis(2000)); + // auth_write has no failure indication; assume the command was delivered. + let wrote = true; + if cancel.load(Ordering::Relaxed) { + return Ok(BatchAuthSlotResult::CancelledAfterWrite { + mac: mac.clone(), + uuid: uuid.clone(), + }); + } // No 3s settle for new firmware sess.drain_boot_output(); sess.wake_shell(); - check_cancel!(); + if cancel.load(Ordering::Relaxed) { + return Ok(BatchAuthSlotResult::CancelledAfterWrite { + mac: mac.clone(), + uuid: uuid.clone(), + }); + } // Verify progress(BatchAuthStep::Verifying); let verify_result = { let mut result = None; for _ in 0..sess.timing.auth_read_retries { - check_cancel!(); - result = sess.auth_read(); + if cancel.load(Ordering::Relaxed) { + return Ok(BatchAuthSlotResult::CancelledAfterWrite { + mac: mac.clone(), + uuid: uuid.clone(), + }); + } + result = sess.auth_read(auth_storage); if result.is_some() { break; } @@ -1115,10 +1253,37 @@ where } result }; + // wrote is set above; suppress unused-variable warning. + let _ = wrote; match verify_result { Some((rb_uuid, rb_key)) if rb_uuid == uuid && rb_key == authkey => { + log::info!("[batch-auth] verify ok port={port} mac={mac} uuid={uuid}"); + if lock_otp { + log::warn!( + "[batch-auth] sending auth-otp-lock (irreversible) port={port} mac={mac}" + ); + match sess.auth_otp_lock() { + Ok(()) => { + log::info!( + "[batch-auth] otp-lock succeeded port={port} mac={mac}" + ); + } + Err(e) => { + let lock_error = e.to_string(); + log::warn!( + "[batch-auth] otp-lock failed port={port} mac={mac} err={lock_error}" + ); + let _ = sess.hardware_reset(); + return Ok(BatchAuthSlotResult::LockFailed { mac, lock_error }); + } + } + } log::info!("[batch-auth] done port={port} mac={mac} uuid={uuid}"); - sess.hardware_reset()?; + if let Err(e) = sess.hardware_reset() { + log::warn!( + "[batch-auth] hardware_reset after Done failed (eFuse already burned) port={port} mac={mac} err={e}" + ); + } Ok(BatchAuthSlotResult::Done { mac }) } Some((rb_uuid, rb_key)) => { @@ -1129,7 +1294,7 @@ where } None => { log::warn!( - "[batch-auth] verify-fail port={port} mac={mac} reason=no-response" + "[batch-auth] verify-fail port={port} mac={mac} uuid={uuid} reason=no-response" ); Err(FlashError::Plugin( "Verification failed: no response from auth-read".into(), @@ -1162,7 +1327,7 @@ where let old_retry_ms = sess.timing.auth_read_retry_ms * 4; for _ in 0..sess.timing.auth_read_retries + 1 { check_cancel!(); - auth = sess.auth_read(); + auth = sess.auth_read(auth_storage); if auth.is_some() { break; } @@ -1171,49 +1336,87 @@ where auth }; + // Conflict check: if policy=Skip and device already has auth, skip without allocating. + if let Some((ref ex_uuid, _)) = existing_auth { + if ex_uuid != PLACEHOLDER_UUID && conflict_policy == ConflictPolicy::Skip { + log::info!("[batch-auth] skipped (old fw) port={port} mac={mac} existing_uuid={ex_uuid}"); + return Ok(BatchAuthSlotResult::Skipped { + mac, + existing_uuid: ex_uuid.clone(), + }); + } + } + + // Lazily allocate an auth code — only now that we know the device needs one. + let (uuid, authkey) = match get_code() { + Some(c) => c, + None => { + log::info!("[batch-auth] no-code (old fw) port={port} mac={mac}"); + return Ok(BatchAuthSlotResult::InsufficientCodes { mac }); + } + }; + log::info!("[batch-auth] allocated (old fw) port={port} mac={mac} uuid={uuid}"); + + // AlreadyDone: device has the exact credentials we just allocated. if let Some((ref ex_uuid, ref ex_key)) = existing_auth { - if ex_uuid != PLACEHOLDER_UUID { - if ex_uuid == uuid && ex_key == authkey { - log::info!( - "[batch-auth] already-done (old fw) port={port} mac={mac} uuid={uuid}" - ); - return Ok(BatchAuthSlotResult::AlreadyDone { mac }); - } - if conflict_policy == ConflictPolicy::Skip { - log::info!("[batch-auth] skipped (old fw) port={port} mac={mac} existing_uuid={ex_uuid}"); - return Ok(BatchAuthSlotResult::Skipped { - mac, - existing_uuid: ex_uuid.clone(), - }); - } + if ex_uuid != PLACEHOLDER_UUID && ex_uuid == &uuid && ex_key == &authkey { + log::info!( + "[batch-auth] already-done (old fw) port={port} mac={mac} uuid={uuid}" + ); + return Ok(BatchAuthSlotResult::AlreadyDone { mac }); } } progress(BatchAuthStep::WritingAuth); log::info!("[batch-auth] writing (old fw) port={port} mac={mac} uuid={uuid}"); - let _lines = sess.auth_write(uuid, authkey, Duration::from_millis(2000)); - check_cancel!(); + let _lines = + sess.auth_write(&uuid, &authkey, auth_storage, Duration::from_millis(2000)); + // auth_write has no failure indication; assume the command was delivered. + let wrote = true; + if cancel.load(Ordering::Relaxed) { + return Ok(BatchAuthSlotResult::CancelledAfterWrite { + mac: mac.clone(), + uuid: uuid.clone(), + }); + } let settle_wait = sess.timing.write_settle_wait; let wait_end = Instant::now() + settle_wait; while Instant::now() < wait_end { - check_cancel!(); + if cancel.load(Ordering::Relaxed) { + return Ok(BatchAuthSlotResult::CancelledAfterWrite { + mac: mac.clone(), + uuid: uuid.clone(), + }); + } std::thread::sleep(Duration::from_millis(200)); } sess.drain_boot_output(); sess.wake_shell(); - check_cancel!(); + if cancel.load(Ordering::Relaxed) { + return Ok(BatchAuthSlotResult::CancelledAfterWrite { + mac: mac.clone(), + uuid: uuid.clone(), + }); + } progress(BatchAuthStep::Verifying); let mut verify_result = None; for _ in 0..sess.timing.auth_read_retries { - check_cancel!(); - verify_result = sess.auth_read(); + if cancel.load(Ordering::Relaxed) { + return Ok(BatchAuthSlotResult::CancelledAfterWrite { + mac: mac.clone(), + uuid: uuid.clone(), + }); + } + verify_result = sess.auth_read(auth_storage); if verify_result.is_some() { break; } std::thread::sleep(sess.timing.auth_read_retry_ms); } + // wrote is set above; suppress unused-variable warning. + let _ = wrote; match verify_result { Some((rb_uuid, rb_key)) if rb_uuid == uuid && rb_key == authkey => { log::info!("[batch-auth] done (old fw) port={port} mac={mac} uuid={uuid}"); @@ -1236,6 +1439,92 @@ where } } +// ── Read-only probe ─────────────────────────────────────────────────────── + +/// Result of a read-only auth probe (no write). +pub struct ReadAuthProbeResult { + /// MAC address as returned by `read_mac`, e.g. `"AA:BB:CC:DD:EE:FF"`. + /// `None` if the device did not respond. + pub mac: Option, + /// UUID from `auth-read`, or `None` when the device is un-authorized + /// (including the placeholder `"uuidxxxxxxxxxxxxxxxx"`). + pub uuid: Option, +} + +/// Open a serial connection to `port`, reset the device, and read its MAC +/// address and existing authorization UUID without writing anything. +/// +/// Mirrors the first half of [`run_batch_auth_slot`] but skips allocation +/// and all write steps. +pub fn read_auth_probe( + port: &str, + chip_id: &str, + baud_rate: u32, + storage: AuthStorage, + cancel: &AtomicBool, +) -> Result { + macro_rules! check_cancel { + () => { + if cancel.load(Ordering::Relaxed) { + return Err(FlashError::Cancelled); + } + }; + } + + log::info!("[batch-auth] read-probe start port={port} chip={chip_id}"); + let timing = AuthTiming::for_chip(chip_id); + let mut sess = AuthSession::open(port, timing, baud_rate)?; + check_cancel!(); + sess.drain_boot_output(); + check_cancel!(); + let firmware = sess.detect_firmware(cancel)?; + check_cancel!(); + + let old_fw = matches!(firmware, FirmwareKind::Old); + + // Read MAC with retries + let mut mac_opt: Option = None; + for _ in 0..sess.timing.mac_read_retries { + check_cancel!(); + mac_opt = sess.read_mac(); + if mac_opt.is_some() { + break; + } + std::thread::sleep(sess.timing.mac_read_retry_ms); + } + log::info!("[batch-auth] read-probe mac port={port} mac={mac_opt:?}"); + + // Read auth with retries (old firmware uses slower intervals) + let retry_ms = if old_fw { + sess.timing.auth_read_retry_ms * 4 + } else { + sess.timing.auth_read_retry_ms + }; + let retries = if old_fw { + sess.timing.auth_read_retries + 1 + } else { + sess.timing.auth_read_retries + }; + let mut auth: Option<(String, String)> = None; + for _ in 0..retries { + check_cancel!(); + auth = sess.auth_read(storage); + if auth.is_some() { + break; + } + std::thread::sleep(retry_ms); + } + log::info!( + "[batch-auth] read-probe auth port={port} has_auth={}", + auth.is_some() + ); + + // Filter out the "un-authorized" placeholder UUID + let uuid = auth.filter(|(u, _)| u != PLACEHOLDER_UUID).map(|(u, _)| u); + + Ok(ReadAuthProbeResult { mac: mac_opt, uuid }) +} + // ── Unit tests ──────────────────────────────────────────────────────────── #[cfg(test)] @@ -1331,7 +1620,7 @@ mod tests { ); let mut sess = session(mock); assert_eq!( - sess.auth_read(), + sess.auth_read(AuthStorage::Kv), Some(( "uuid12345678901234".to_string(), "keyabcdefghijklmnopqrstuvwxyz012".to_string() @@ -1349,7 +1638,7 @@ mod tests { ); let mut sess = session(mock); assert_eq!( - sess.auth_read(), + sess.auth_read(AuthStorage::Kv), Some(( "uuid12345678901234".to_string(), "keyabcdefghijklmnopqrstuvwxyz012".to_string() @@ -1363,7 +1652,7 @@ mod tests { mock.add_response("auth-read\r\n\x1b[32muuid12345678901234\x1b[0m\r\nkeyabcdefghijklmnopqrstuvwxyz012\r\n"); let mut sess = session(mock); assert_eq!( - sess.auth_read(), + sess.auth_read(AuthStorage::Kv), Some(( "uuid12345678901234".to_string(), "keyabcdefghijklmnopqrstuvwxyz012".to_string() @@ -1377,7 +1666,7 @@ mod tests { // Only one relevant line after filtering — not enough for a pair. mock.add_response("auth-read\r\nuuid12345678901234\r\ntuya>\r\n"); let mut sess = session(mock); - assert_eq!(sess.auth_read(), None); + assert_eq!(sess.auth_read(AuthStorage::Kv), None); } #[test] @@ -1385,7 +1674,7 @@ mod tests { let mut mock = MockAuthIo::new(); mock.add_response(""); let mut sess = session(mock); - assert_eq!(sess.auth_read(), None); + assert_eq!(sess.auth_read(AuthStorage::Kv), None); } #[test] @@ -1393,7 +1682,7 @@ mod tests { let mut mock = MockAuthIo::new(); mock.add_response("auth-read\r\n[04-24 10:30:00] [INFO] only logs\r\ntuya>\r\n"); let mut sess = session(mock); - assert_eq!(sess.auth_read(), None); + assert_eq!(sess.auth_read(AuthStorage::Kv), None); } #[test] @@ -1407,7 +1696,7 @@ mod tests { )); let mut sess = session(mock); assert_eq!( - sess.auth_read(), + sess.auth_read(AuthStorage::Kv), Some(( PLACEHOLDER_UUID.to_string(), "keyabcdefghijklmnopqrstuvwxyz012".to_string() @@ -1449,7 +1738,12 @@ mod tests { let mut mock = MockAuthIo::new(); mock.add_response("auth uuid key\r\nAuthorization write succeeds.\r\n"); let mut sess = session(mock); - let _ = sess.auth_write("myuuid", "mykey", Duration::from_millis(200)); + let _ = sess.auth_write( + "myuuid", + "mykey", + AuthStorage::Kv, + Duration::from_millis(200), + ); assert!(sess.port.sent_str().contains("auth myuuid mykey\r\n")); } @@ -1661,6 +1955,7 @@ mod tests { firmware_path: None, authorize_uuid: Some("testuuid12345678901".into()), authorize_key: Some("testkey1234567890123456789012".into()), + authorize_storage: None, confirm_overwrite: None, }; // Call inner logic directly via a helper (see note below) @@ -1671,18 +1966,19 @@ mod tests { let firmware = sess.detect_firmware(&cancel2).unwrap(); assert_eq!(firmware, FirmwareKind::New(CliVersion(1, 0, 0))); // auth_read → None (fresh device) - let existing = sess.auth_read(); + let existing = sess.auth_read(AuthStorage::Kv); assert!(existing.is_none()); // auth_write let _lines = sess.auth_write( "testuuid12345678901", "testkey1234567890123456789012", + AuthStorage::Kv, Duration::from_millis(200), ); sess.drain_boot_output(); sess.wake_shell(); // no timing arg — uses self.timing // verify - let verified = sess.auth_read(); + let verified = sess.auth_read(AuthStorage::Kv); assert_eq!( verified, Some(( @@ -1715,7 +2011,7 @@ mod tests { let cancel = AtomicBool::new(false); let firmware = sess.detect_firmware(&cancel).unwrap(); assert!(matches!(firmware, FirmwareKind::New(_))); - let existing = sess.auth_read(); + let existing = sess.auth_read(AuthStorage::Kv); assert!(existing.is_some()); let (ex_u, _ex_k) = existing.unwrap(); assert_eq!(ex_u, "existinguuid1234567"); @@ -1723,4 +2019,136 @@ mod tests { let confirmed = false; assert!(!confirmed, "should cancel when user declines"); } + + #[test] + fn auth_write_kv_omits_storage_param() { + let mut mock = MockAuthIo::new(); + mock.add_response("Authorization write succeeds.\r\n"); + let mut sess = session(mock); + let _ = sess.auth_write( + "myuuid", + "mykey", + AuthStorage::Kv, + Duration::from_millis(200), + ); + // KV is default — must NOT append "0" to stay backward-compatible + assert!(sess.port.sent_str().contains("auth myuuid mykey\r\n")); + assert!(!sess.port.sent_str().contains("auth myuuid mykey 0\r\n")); + } + + #[test] + fn auth_write_otp_appends_storage_param() { + let mut mock = MockAuthIo::new(); + mock.add_response("Authorization write succeeds.\r\n"); + let mut sess = session(mock); + let _ = sess.auth_write( + "myuuid", + "mykey", + AuthStorage::Otp, + Duration::from_millis(200), + ); + assert!(sess.port.sent_str().contains("auth myuuid mykey 1\r\n")); + } + + #[test] + fn auth_read_kv_omits_storage_param() { + let mut mock = MockAuthIo::new(); + mock.add_response( + "auth-read\r\nuuid12345678901234\r\nkeyabcdefghijklmnopqrstuvwxyz012\r\n", + ); + let mut sess = session(mock); + let _ = sess.auth_read(AuthStorage::Kv); + assert!(sess.port.sent_str().contains("auth-read\r\n")); + assert!(!sess.port.sent_str().contains("auth-read 0\r\n")); + } + + #[test] + fn auth_read_otp_appends_storage_param() { + let mut mock = MockAuthIo::new(); + mock.add_response( + "auth-read 1\r\nuuid12345678901234\r\nkeyabcdefghijklmnopqrstuvwxyz012\r\n", + ); + let mut sess = session(mock); + let result = sess.auth_read(AuthStorage::Otp); + assert!(sess.port.sent_str().contains("auth-read 1\r\n")); + assert_eq!( + result, + Some(( + "uuid12345678901234".to_string(), + "keyabcdefghijklmnopqrstuvwxyz012".to_string() + )) + ); + } + + // ── auth_otp_lock ────────────────────────────────────────────────── + + #[test] + fn auth_otp_lock_succeeds_on_success_line() { + let mut mock = MockAuthIo::new(); + mock.add_response("auth-otp-lock\r\nAuthorization otp lock succeeds.\r\ntuya> \r\n"); + let mut sess = session(mock); + assert!(sess.auth_otp_lock().is_ok()); + } + + #[test] + fn auth_otp_lock_fails_on_failure_line() { + let mut mock = MockAuthIo::new(); + mock.add_response("auth-otp-lock\r\nAuthorization otp lock failure.\r\ntuya> \r\n"); + let mut sess = session(mock); + let err = sess.auth_otp_lock().unwrap_err(); + match err { + FlashError::Plugin(msg) => assert!( + msg.contains("device returned failure"), + "unexpected message: {msg}" + ), + other => panic!("expected Plugin error, got {other:?}"), + } + } + + #[test] + fn auth_otp_lock_fails_on_no_response() { + let mut mock = MockAuthIo::new(); + mock.add_response(""); + let mut sess = session(mock); + let err = sess.auth_otp_lock().unwrap_err(); + match err { + FlashError::Plugin(msg) => assert!( + msg.contains("no recognisable response"), + "unexpected message: {msg}" + ), + other => panic!("expected Plugin error, got {other:?}"), + } + } + + #[test] + fn auth_otp_lock_is_case_insensitive() { + let mut mock = MockAuthIo::new(); + mock.add_response("auth-otp-lock\r\nAUTHORIZATION OTP LOCK SUCCEEDS.\r\ntuya> \r\n"); + let mut sess = session(mock); + assert!(sess.auth_otp_lock().is_ok()); + } + + #[test] + fn auth_otp_lock_ignores_unrelated_log_lines() { + let mut mock = MockAuthIo::new(); + mock.add_response( + "auth-otp-lock\r\n[04-24 10:30:00] [INFO] efuse settling\r\nAuthorization otp lock succeeds.\r\n[04-24 10:30:01] noise\r\ntuya> \r\n", + ); + let mut sess = session(mock); + assert!(sess.auth_otp_lock().is_ok()); + } + + #[test] + fn auth_otp_lock_treats_mixed_response_as_success() { + // Documents the policy: when both lines appear (e.g. echo residue), + // we trust the success line. The device's authoritative state is the + // last line printed; we don't expose the parser's order-sensitivity + // unless hardware verification reveals a real failure mode. + let mut mock = MockAuthIo::new(); + mock.add_response( + "auth-otp-lock\r\nAuthorization otp lock failure.\r\nAuthorization otp lock succeeds.\r\ntuya> \r\n", + ); + let mut sess = session(mock); + assert!(sess.auth_otp_lock().is_ok()); + } } diff --git a/crates/tyutool-core/src/flash_event.rs b/crates/tyutool-core/src/flash_event.rs index b26839f7..b2815fd9 100644 --- a/crates/tyutool-core/src/flash_event.rs +++ b/crates/tyutool-core/src/flash_event.rs @@ -278,6 +278,7 @@ mod tests { firmware_path: None, authorize_uuid: Some("u".into()), authorize_key: None, + authorize_storage: None, confirm_overwrite: None, }; let s = JobSummary::from_job(&job); @@ -315,6 +316,7 @@ mod tests { firmware_path: None, authorize_uuid: None, authorize_key: None, + authorize_storage: None, confirm_overwrite: None, }; let s = JobSummary::from_job(&job); diff --git a/crates/tyutool-core/src/job.rs b/crates/tyutool-core/src/job.rs index e2ef4bb2..00064658 100644 --- a/crates/tyutool-core/src/job.rs +++ b/crates/tyutool-core/src/job.rs @@ -1,3 +1,4 @@ +use crate::authorize::AuthStorage; use serde::{Deserialize, Serialize}; /// Mirrors Python `FlashArgv.mode` (write / read); extended for GUI tabs. @@ -41,6 +42,7 @@ pub struct FlashJob { pub firmware_path: Option, pub authorize_uuid: Option, pub authorize_key: Option, + pub authorize_storage: Option, /// Called when a conflicting credential is found on-device during authorize. /// Returns `true` to proceed with overwrite, `false` to abort. /// `None` in CLI mode — conflict is always overwritten without prompting. @@ -72,6 +74,7 @@ impl std::fmt::Debug for FlashJob { .field("firmware_path", &self.firmware_path) .field("authorize_uuid", &self.authorize_uuid) .field("authorize_key", &self.authorize_key) + .field("authorize_storage", &self.authorize_storage) .field( "confirm_overwrite", &self.confirm_overwrite.as_ref().map(|_| ""), @@ -98,6 +101,7 @@ impl Clone for FlashJob { firmware_path: self.firmware_path.clone(), authorize_uuid: self.authorize_uuid.clone(), authorize_key: self.authorize_key.clone(), + authorize_storage: self.authorize_storage, confirm_overwrite: None, // closures are not Clone } } diff --git a/crates/tyutool-core/src/lib.rs b/crates/tyutool-core/src/lib.rs index 95d03dc3..efefe7ad 100644 --- a/crates/tyutool-core/src/lib.rs +++ b/crates/tyutool-core/src/lib.rs @@ -13,7 +13,10 @@ mod serial_debug; mod tuya_dev_usb; mod usb_port_survey; -pub use authorize::{run_batch_auth_slot, BatchAuthSlotResult, BatchAuthStep, ConflictPolicy}; +pub use authorize::{ + read_auth_probe, run_batch_auth_slot, AuthStorage, BatchAuthSlotResult, BatchAuthStep, + ConflictPolicy, ReadAuthProbeResult, +}; pub use error::FlashError; pub use flash_event::{ FlashEvent, FlashMilestone, FlashPhase, FlashResult, JobDetails, JobSummary, diff --git a/crates/tyutool-core/src/registry.rs b/crates/tyutool-core/src/registry.rs index a0a349a2..6f77ae24 100644 --- a/crates/tyutool-core/src/registry.rs +++ b/crates/tyutool-core/src/registry.rs @@ -237,6 +237,7 @@ mod tests { firmware_path: None, authorize_uuid: None, authorize_key: None, + authorize_storage: None, confirm_overwrite: None, }; let cancel = AtomicBool::new(false); @@ -267,6 +268,7 @@ mod tests { firmware_path: None, authorize_uuid: None, authorize_key: None, + authorize_storage: None, confirm_overwrite: None, }; let cancel = AtomicBool::new(false); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a1351569..03ee6316 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -286,6 +286,16 @@ struct BatchAuthStartConfig { flash_end_hex: Option, excel_path: String, conflict_policy: String, + auth_storage: Option, + lock_otp_after_auth: Option, +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct BatchAuthReadConfig { + chip_id: String, + baud_rate: u32, + auth_storage: Option, } #[tauri::command] @@ -353,6 +363,7 @@ fn batch_flash_start( read_file_path: None, authorize_uuid: None, authorize_key: None, + authorize_storage: None, confirm_overwrite: None, }; @@ -404,10 +415,10 @@ fn batch_flash_cancel_all(state: State<'_, BatchFlashState>) -> Result<(), Strin fn validate_excel_file(path: &str) -> Result<&std::path::Path, String> { let p = std::path::Path::new(path); if !p.exists() { - return Err("文件不存在".into()); + return Err("excel.fileNotFound".into()); } if p.extension().and_then(|e| e.to_str()) != Some("xlsx") { - return Err("请选择 .xlsx 格式文件".into()); + return Err("excel.notXlsxFormat".into()); } Ok(p) } @@ -430,6 +441,22 @@ fn batch_auth_start( "overwrite" => tyutool_core::ConflictPolicy::Overwrite, _ => tyutool_core::ConflictPolicy::Skip, }; + let auth_storage = match config.auth_storage.as_deref() { + Some("otp") => tyutool_core::AuthStorage::Otp, + _ => tyutool_core::AuthStorage::Kv, + }; + + let lock_otp_after_auth = config.lock_otp_after_auth.unwrap_or(false) + && config.chip_id.eq_ignore_ascii_case("t5ai") + && matches!(auth_storage, tyutool_core::AuthStorage::Otp); + + if config.lock_otp_after_auth.unwrap_or(false) && !lock_otp_after_auth { + log::warn!( + "[batch-auth] lock_otp_after_auth requested but suppressed (chip={} storage={:?}); request will not burn eFuse", + config.chip_id, + auth_storage + ); + } let allocator = { let path = std::path::Path::new(&config.excel_path); @@ -475,38 +502,27 @@ fn batch_auth_start( } } - // Phase 3: allocate rows and spawn new threads. + // Phase 3: spawn threads — auth code allocation happens lazily inside each thread, + // after reading the device's existing auth status. for port in ports { - // Allocate row (outside lock) - let row = match allocator.allocate_row() { - Ok(r) => r, - Err(e) => { - let _ = app.emit( - "batch-auth-progress", - serde_json::json!({ - "port": port, - "step": "failed", - "error": e - }), - ); - continue; - } - }; - - // 4. Set up cancel + spawn thread + // Set up cancel + spawn thread let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let cancel_clone = cancel.clone(); let app_clone = app.clone(); let port_clone = port.clone(); let config_clone = config.clone(); let alloc_clone = allocator.clone(); - let row_idx = row.row_idx; - let uuid = row.uuid.clone(); - let authkey = row.authkey.clone(); + + // Tracks which Excel row was allocated inside the slot (None = no allocation yet). + let allocated_row: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(None)); + let allocated_row_clone = allocated_row.clone(); let handle = std::thread::spawn(move || { - log::info!("[batch-auth] slot begin port={port_clone} chip={} excel_row={row_idx} uuid={uuid}", - config_clone.chip_id); + log::info!( + "[batch-auth] slot begin port={port_clone} chip={}", + config_clone.chip_id + ); if let Some(ref fw_path) = config_clone.firmware_path { if !fw_path.is_empty() { let job = tyutool_core::FlashJob { @@ -525,6 +541,7 @@ fn batch_auth_start( read_file_path: None, authorize_uuid: None, authorize_key: None, + authorize_storage: None, confirm_overwrite: None, }; let app2 = app_clone.clone(); @@ -540,7 +557,6 @@ fn batch_auth_start( ); }); if flash_result.is_err() || cancel_clone.load(Ordering::Relaxed) { - alloc_clone.release_row(row_idx); let _ = app_clone.emit( "batch-auth-progress", serde_json::json!({ @@ -555,13 +571,28 @@ fn batch_auth_start( } } + // Lazy allocation closure: called by run_batch_auth_slot only when the device + // actually needs a new auth code (after probe confirms no existing auth / overwrite). + let alloc_for_get = alloc_clone.clone(); + let row_cell = allocated_row_clone.clone(); + let get_code = move || -> Option<(String, String)> { + match alloc_for_get.allocate_row() { + Ok(row) => { + *row_cell.lock().unwrap() = Some(row.row_idx); + Some((row.uuid, row.authkey)) + } + Err(_) => None, + } + }; + let result = tyutool_core::run_batch_auth_slot( &port_clone, &config_clone.chip_id, - &uuid, - &authkey, + get_code, config_clone.auth_baud_rate, conflict_policy, + auth_storage, + lock_otp_after_auth, &cancel_clone, |step| { let step_str = match step { @@ -577,14 +608,22 @@ fn batch_auth_start( }, ); + let row_idx = *allocated_row_clone.lock().unwrap(); + match result { - Ok(tyutool_core::BatchAuthSlotResult::Done { mac }) - | Ok(tyutool_core::BatchAuthSlotResult::AlreadyDone { mac }) => { - log::info!("[batch-auth] slot done port={port_clone} mac={mac} uuid={uuid} excel_row={row_idx}"); - let excel_err = alloc_clone.confirm_row(row_idx, mac.clone()).err(); - if let Some(ref e) = excel_err { - log::error!("[batch-auth] excel-write-failed port={port_clone} row={row_idx} error={e}"); - } + Ok(tyutool_core::BatchAuthSlotResult::Done { mac }) => { + let excel_err = if let Some(idx) = row_idx { + log::info!( + "[batch-auth] slot done port={port_clone} mac={mac} excel_row={idx}" + ); + let err = alloc_clone.confirm_row(idx, mac.clone()).err(); + if let Some(ref e) = err { + log::error!("[batch-auth] excel-write-failed port={port_clone} row={idx} error={e}"); + } + err + } else { + None + }; let mut payload = serde_json::json!({ "port": port_clone, "step": "done", "mac": mac }); if let Some(e) = excel_err { @@ -592,12 +631,31 @@ fn batch_auth_start( } let _ = app_clone.emit("batch-auth-progress", payload); } + Ok(tyutool_core::BatchAuthSlotResult::AlreadyDone { mac }) => { + // Row was allocated but the device already had these exact credentials; + // release it so it remains available for other devices. + if let Some(idx) = row_idx { + log::info!("[batch-auth] slot already-done port={port_clone} mac={mac} excel_row={idx}"); + alloc_clone.release_row(idx); + } + let _ = app_clone.emit( + "batch-auth-progress", + serde_json::json!({ "port": port_clone, "step": "done", "mac": mac }), + ); + } + Ok(tyutool_core::BatchAuthSlotResult::InsufficientCodes { mac }) => { + // No row was allocated — device was probed but there are no codes left. + log::info!("[batch-auth] slot no-code port={port_clone} mac={mac}"); + let _ = app_clone.emit( + "batch-auth-progress", + serde_json::json!({ "port": port_clone, "step": "no_code", "mac": mac }), + ); + } Ok(tyutool_core::BatchAuthSlotResult::Skipped { mac, existing_uuid }) => { - log::info!("[batch-auth] slot skipped port={port_clone} mac={mac} existing_uuid={existing_uuid} new_row={row_idx}"); - // Release the newly-allocated row (we won't write a new auth code). - alloc_clone.release_row(row_idx); + // No row was allocated (skip decision made before get_code was called). // Mark the device's existing auth-code row as Used so the same // code isn't handed out to another device. + log::info!("[batch-auth] slot skipped port={port_clone} mac={mac} existing_uuid={existing_uuid}"); let excel_err = alloc_clone .find_and_confirm_by_uuid(&existing_uuid, mac.clone()) .err(); @@ -612,18 +670,83 @@ fn batch_auth_start( let _ = app_clone.emit("batch-auth-progress", payload); } Ok(tyutool_core::BatchAuthSlotResult::Cancelled) => { - log::info!( - "[batch-auth] slot cancelled port={port_clone} excel_row={row_idx}" - ); - alloc_clone.release_row(row_idx); + if let Some(idx) = row_idx { + log::info!( + "[batch-auth] slot cancelled port={port_clone} excel_row={idx}" + ); + alloc_clone.release_row(idx); + } let _ = app_clone.emit( "batch-auth-progress", serde_json::json!({ "port": port_clone, "step": "cancelled" }), ); } + Ok(tyutool_core::BatchAuthSlotResult::CancelledAfterWrite { mac, uuid }) => { + let excel_err = if let Some(idx) = row_idx { + log::warn!( + "[batch-auth] slot cancelled AFTER auth_write port={port_clone} mac={mac} uuid={uuid} excel_row={idx}" + ); + let err = alloc_clone.confirm_row(idx, mac.clone()).err(); + if let Some(ref e) = err { + log::error!("[batch-auth] excel-confirm-after-cancel-write failed port={port_clone} row={idx} error={e}"); + } + err + } else { + log::warn!( + "[batch-auth] slot cancelled AFTER auth_write (no row alloc) port={port_clone} mac={mac} uuid={uuid}" + ); + None + }; + let mut payload = serde_json::json!({ + "port": port_clone, + "step": "cancelled_after_write", + "mac": mac, + "uuid": uuid, + }); + if let Some(e) = excel_err { + payload["excelError"] = serde_json::Value::String(e); + } + let _ = app_clone.emit("batch-auth-progress", payload); + } + Ok(tyutool_core::BatchAuthSlotResult::LockFailed { mac, lock_error }) => { + let excel_err = if let Some(idx) = row_idx { + log::warn!( + "[batch-auth] slot lock-failed port={port_clone} mac={mac} excel_row={idx} lock_err={lock_error}" + ); + let err = alloc_clone.confirm_row(idx, mac.clone()).err(); + if let Some(ref e) = err { + log::error!("[batch-auth] excel-confirm-after-lock-fail failed port={port_clone} row={idx} error={e}"); + } + err + } else { + log::warn!( + "[batch-auth] slot lock-failed (no row alloc) port={port_clone} mac={mac} lock_err={lock_error}" + ); + None + }; + let mut payload = serde_json::json!({ + "port": port_clone, + "step": "failed", + "mac": mac, + "error": lock_error, + "lockFailed": true, + }); + if let Some(e) = excel_err { + payload["excelError"] = serde_json::Value::String(e); + } + let _ = app_clone.emit("batch-auth-progress", payload); + } Err(e) => { - log::warn!("[batch-auth] slot failed port={port_clone} uuid={uuid} excel_row={row_idx} error={e}"); - alloc_clone.release_row(row_idx); + if let Some(idx) = row_idx { + log::warn!( + "[batch-auth] slot failed port={port_clone} excel_row={idx} error={e}" + ); + alloc_clone.release_row(idx); + } else { + log::warn!( + "[batch-auth] slot failed (pre-alloc) port={port_clone} error={e}" + ); + } let _ = app_clone.emit( "batch-auth-progress", serde_json::json!({ @@ -670,6 +793,107 @@ fn batch_auth_cancel_all(state: State<'_, BatchAuthState>) -> Result<(), String> Ok(()) } +#[tauri::command] +fn batch_auth_read_ports( + app: AppHandle, + state: State<'_, BatchAuthState>, + config: BatchAuthReadConfig, + ports: Vec, +) -> Result<(), String> { + let auth_storage = match config.auth_storage.as_deref() { + Some("otp") => tyutool_core::AuthStorage::Otp, + _ => tyutool_core::AuthStorage::Kv, + }; + + // Phase 1: cancel any running slot on each port and collect join receivers. + let mut old_join_rxs: Vec<(String, std::sync::mpsc::Receiver<()>)> = Vec::new(); + for port in &ports { + let old_slot = { + let mut slots = state.slots.lock().map_err(|e| e.to_string())?; + slots.remove(port) + }; + if let Some(old) = old_slot { + old.cancel.store(true, Ordering::SeqCst); + let (tx, rx) = std::sync::mpsc::channel::<()>(); + std::thread::spawn(move || { + let _ = old.thread.join(); + let _ = tx.send(()); + }); + old_join_rxs.push((port.clone(), rx)); + } + } + + // Phase 2: wait for old threads with a shared 3-second deadline. + if !old_join_rxs.is_empty() { + let deadline = std::time::Instant::now() + Duration::from_secs(3); + for (port, rx) in &old_join_rxs { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if rx.recv_timeout(remaining).is_err() { + return Err(format!("port {} not stopped; retry in a few seconds", port)); + } + } + } + + // Phase 3: spawn one thread per port. + for port in ports { + let cancel = Arc::new(AtomicBool::new(false)); + let cancel_clone = cancel.clone(); + let app_clone = app.clone(); + let port_clone = port.clone(); + let config_clone = config.clone(); + + let handle = std::thread::spawn(move || { + let result = tyutool_core::read_auth_probe( + &port_clone, + &config_clone.chip_id, + config_clone.baud_rate, + auth_storage, + &cancel_clone, + ); + match result { + Ok(r) => { + let _ = app_clone.emit( + "batch-auth-read-progress", + serde_json::json!({ + "port": port_clone, + "step": "done", + "mac": r.mac, + "uuid": r.uuid, + }), + ); + } + Err(tyutool_core::FlashError::Cancelled) => { + let _ = app_clone.emit( + "batch-auth-read-progress", + serde_json::json!({ "port": port_clone, "step": "cancelled" }), + ); + } + Err(e) => { + let _ = app_clone.emit( + "batch-auth-read-progress", + serde_json::json!({ + "port": port_clone, + "step": "failed", + "error": e.to_string(), + }), + ); + } + } + }); + + let mut slots = state.slots.lock().map_err(|e| e.to_string())?; + slots.insert( + port, + BatchSlot { + cancel, + thread: handle, + }, + ); + } + + Ok(()) +} + #[tauri::command] fn flash_cancel(state: State<'_, FlashState>, confirm_state: State<'_, ConfirmState>) { log::info!("[Flash] User cancelled operation"); @@ -1470,6 +1694,7 @@ pub fn run() { batch_auth_start, batch_auth_cancel_port, batch_auth_cancel_all, + batch_auth_read_ports, device_reset_cmd, get_file_size, check_port_available_cmd, @@ -1768,7 +1993,7 @@ mod validate_excel_tests { let dir = tempfile::tempdir().unwrap(); let missing = dir.path().join("nope.xlsx"); let err = validate_excel_file(missing.to_str().unwrap()).unwrap_err(); - assert_eq!(err, "文件不存在"); + assert_eq!(err, "excel.fileNotFound"); } #[test] @@ -1777,7 +2002,7 @@ mod validate_excel_tests { let p = dir.path().join("data.csv"); std::fs::write(&p, b"x").unwrap(); let err = validate_excel_file(p.to_str().unwrap()).unwrap_err(); - assert_eq!(err, "请选择 .xlsx 格式文件"); + assert_eq!(err, "excel.notXlsxFormat"); } #[test] diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue index 7c64732c..71d1f44f 100644 --- a/src/components/AppShell.vue +++ b/src/components/AppShell.vue @@ -12,6 +12,9 @@ const route = useRoute(); const { t } = useI18n(); const fullBleedMain = computed(() => route.meta.layout === "fullBleed"); +const fullBleedScrollMain = computed( + () => route.meta.layout === "fullBleedScroll", +); const hideChrome = computed(() => route.meta.chrome === "none"); const nav = computed(() => @@ -103,7 +106,10 @@ function toggleLang() { class="size-5 shrink-0" aria-hidden="true" /> - {{ item.label }} + {{ item.label }} @@ -140,7 +146,9 @@ function toggleLang() { :class=" fullBleedMain ? 'flex flex-col px-3 py-2 sm:px-4 sm:py-3 md:px-5 md:py-3 max-lg:overflow-y-auto lg:overflow-hidden' - : 'overflow-y-auto px-4 py-5 sm:px-6 sm:py-6 md:px-8 md:py-8' + : fullBleedScrollMain + ? 'overflow-y-auto px-3 py-2 sm:px-4 sm:py-3 md:px-5 md:py-3' + : 'overflow-y-auto px-4 py-5 sm:px-6 sm:py-6 md:px-8 md:py-8' " role="main" tabindex="-1" @@ -148,7 +156,11 @@ function toggleLang() {
{ await store.loadPersistedData(); await store.ensureListener(); + await store.autoAssign(); }); onUnmounted(() => { @@ -36,7 +37,7 @@ onUnmounted(() => { diff --git a/src/features/batch-flash-auth/components/BatchAuthConfig.vue b/src/features/batch-flash-auth/components/BatchAuthConfig.vue index 3942fdd2..c0f7878e 100644 --- a/src/features/batch-flash-auth/components/BatchAuthConfig.vue +++ b/src/features/batch-flash-auth/components/BatchAuthConfig.vue @@ -1,17 +1,12 @@