From e59ea1da69b039fa274e89caf0e07e53d07c1232 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 23 Jul 2026 15:30:09 -0400 Subject: [PATCH] =?UTF-8?q?fix(mesh):=20never=20show=20mojibake=20device?= =?UTF-8?q?=20names=20=E2=80=94=20strict=20decode=20with=20readable=20fall?= =?UTF-8?q?back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name fields sit at firmware-version-dependent offsets; when an offset lands in binary data, from_utf8_lossy handed the UI replacement-character soup in the mesh modals and settings panel. decode_mesh_name(): strict UTF-8 to the first NUL, no control chars, else a readable fallback (short pubkey / node-). Co-Authored-By: Claude Fable 5 --- core/archipelago/src/mesh/protocol.rs | 40 +++++++++++++++++++-------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/core/archipelago/src/mesh/protocol.rs b/core/archipelago/src/mesh/protocol.rs index cb53f82d..3650c6c9 100644 --- a/core/archipelago/src/mesh/protocol.rs +++ b/core/archipelago/src/mesh/protocol.rs @@ -353,6 +353,28 @@ pub fn build_get_stats() -> Vec { // ─── Response parsers ─────────────────────────────────────────────────── +/// Decode a device/contact name from raw frame bytes, defensively. +/// +/// Name fields sit at firmware-version-dependent offsets; when an offset +/// lands inside binary data (path/pubkey bytes), `from_utf8_lossy` used to +/// hand the UI replacement-character soup ("�\u{618}…"). Strict rules: valid +/// UTF-8 up to the first NUL, no control characters, at least one +/// non-whitespace char — anything else gets the caller's readable fallback. +fn decode_mesh_name(bytes: &[u8], fallback: &str) -> String { + let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); + match std::str::from_utf8(&bytes[..end]) { + Ok(s) => { + let s = s.trim(); + if !s.is_empty() && s.chars().all(|c| !c.is_control()) { + s.to_string() + } else { + fallback.to_string() + } + } + Err(_) => fallback.to_string(), + } +} + /// Parse RESP_DEVICE_INFO (0x0D) response. /// Returns firmware version string and device capabilities. pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> { @@ -384,15 +406,12 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> { let node_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); - // Name follows after fixed fields — find it by scanning for printable ASCII + // Name follows after fixed fields. A firmware whose fixed-field layout + // differs would put binary here — decode defensively so the settings + // panel never shows byte soup. let name_start = 4; let name = if data.len() > name_start { - let name_end = data[name_start..] - .iter() - .position(|&b| b == 0) - .map(|p| name_start + p) - .unwrap_or(data.len()); - String::from_utf8_lossy(&data[name_start..name_end]).to_string() + decode_mesh_name(&data[name_start..], &format!("node-{node_id:08x}")) } else { String::new() }; @@ -453,12 +472,11 @@ pub fn parse_contact(data: &[u8]) -> Result { // name at data[99..131] (32 bytes) let name_start = 99.min(data.len()); let name_end = (name_start + 32).min(data.len()); + let short_id = format!("{}...", &public_key_hex[..8]); let advert_name = if data.len() > name_start { - String::from_utf8_lossy(&data[name_start..name_end]) - .trim_end_matches('\0') - .to_string() + decode_mesh_name(&data[name_start..name_end], &short_id) } else { - format!("{}...", &public_key_hex[..8]) + short_id }; // last_advert at data[131..135]