Files
simba-rmic-2026/desktop/src-tauri/src/maintenance.rs
T
sloray 896b21fe44
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s
feat(client): RMIC::2026 界面重做、可计算的版本机制与固件版本联动
- feat(client): 界面换成墨黑蓝底 + 单支红的矩形语言,切角全部去掉,仅装甲条保留平行四边形
- feat(client): 自托管 HarmonyOS Sans SC 与 JetBrains Mono,中文与拉丁数字逐字回退
- feat(client): 新增启动序列、首启门、用户端 HUD、裁判端控制台、设置抽屉与固件维护页
- feat(client): 裁判端中缝与下方控制台可拖,鼠标落在哪一路下方就看哪一路
- feat(client): 设置抽屉常驻实时预览,与画面共用同一份准星绘制代码
- feat(client): 动效改用 Material 3 的缓动曲线与时长,逐条替换掉自造的贝塞尔
- feat(version): 版本号与 CHANGELOG 由 scripts/version.mjs 从 git 历史算出,不再手写
- feat(firmware): Hello 报文携带语义化版本、构建号与自报协议号,负载向后兼容
- feat(client): 固件版本显示在绑定列表与裁判端每一格;协议不匹配时给出具体原因
- feat(firmware): 固件包版本与客户端同源,都从 version.json 读
- fix(client): getCurrentWindow() 在没有 Tauri 注入时同步抛异常,浏览器预览整棵树被卸载
- fix(client): 启动序列的淡出 effect 清掉了自己刚设的 setTimeout,界面永远停在启动页
- fix(client): 设置抽屉左侧导航的锚点没挂到 section 上,且用错了坐标系
- fix(protocol): Hello 改用宽松帧头解析,让协议不匹配的板子能被解释而不是静默消失
- fix(build): Windows 上 bindgen 加载不了 libclang 的同目录依赖,构建脚本补上搜索路径
- fix(build): esp-idf-sys 在长路径下拒绝构建,固件构建改用短的目标目录
- build(client): 队标图标,含光学尺寸分级与手工拼的多尺寸 ICO
- build(ci): 守门 job 增加版本一致性检查,产物名改为 rmic-2026-*
- docs: 补充界面语言、版本机制与协议通告布局的说明
2026-10-19 02:40:08 +08:00

1945 lines
61 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 固件维护:串口探测、坏块规避、官方 bundle 烧录。
//!
//! # 为什么不是“找镜像字节刚好可写”
//!
//! 旧 `scripts/flash.sh` 搜索一个 64 KiB 对齐的偏移,使 `0xFADE7` 上的那个镜像字节
//! 恰好能写。这个办法有两个问题:它只对已知坏点有效,而且每次重编译都可能把镜像
//! 挪到另一个字节上,所以要反复重编。
//!
//! 这里换一个思路:**把 App 写到旧 App 之后的空闲槽位,写入后整段回读**。镜像里
//! 的每个字节都实际写进去、再读回来;坏字节只要落在镜像范围内,第一次回读就会暴露。
//! 一个槽位失败就换下一个,全部通过才切换分区表。这样不需要知道坏点的具体位置,
//! 也不需要重编译。
//!
//! # 安全顺序
//!
//! 1. 探测并保存当前分区表(以及旧 App);
//! 2. 在空闲槽位写 App,回读逐字节比较;
//! 3. 只有 App 校验通过,才写 patch 后的分区表;
//! 4. 分区表回读校验;失败或启动失败时,可以用备份恢复旧表,旧 App 从未被覆盖。
//!
//! 串口 I/O 是阻塞的,所有命令都通过 `spawn_blocking` 进入这里;进度用 Tauri 事件
//! `maintenance-progress` 推给界面。
use std::fs::{self, File};
use std::io::Read;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, bail, Context, Result};
use espflash::connection::{Connection, ResetAfterOperation, ResetBeforeOperation};
use espflash::flasher::Flasher;
use espflash::target::ProgressCallbacks;
use md5::{Digest, Md5};
use serde::{Deserialize, Serialize};
use serialport::{FlowControl, SerialPortType, UsbPortInfo};
use sha2::Sha256;
use tauri::{AppHandle, Emitter, Manager, State};
use zip::ZipArchive;
// ---------------------------------------------------------------------------
// DTO
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PortInfo {
pub port_name: String,
pub label: String,
pub vid: Option<u16>,
pub pid: Option<u16>,
pub likely_esp: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PartitionEntry {
pub name: String,
pub kind: u8,
pub subtype: u8,
pub offset: u32,
pub size: u32,
pub flags: u32,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FreeRange {
pub start: u32,
pub size: u32,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppInfo {
pub offset: u32,
pub version: String,
pub project_name: String,
pub build_time: String,
pub idf_version: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceSnapshot {
pub port: String,
pub chip: String,
pub revision: Option<String>,
pub flash_size: u32,
pub flash_size_text: String,
pub mac: Option<String>,
pub features: Vec<String>,
pub secure_boot: bool,
pub secure_download: bool,
pub flash_encryption: bool,
pub security_flags: u32,
pub flash_crypt_cnt: u8,
pub partition_table: Vec<PartitionEntry>,
pub app: Option<AppInfo>,
pub free_ranges: Vec<FreeRange>,
pub table_sha256: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BundleInfo {
pub path: String,
pub source_kind: String,
pub source_path: String,
pub source_sha256: Option<String>,
pub entry_count: Option<u32>,
pub name: String,
pub version: String,
pub chip: String,
pub app_file: String,
pub app_sha256: String,
pub app_size: u32,
pub partition_table_file: String,
pub partition_table_sha256: String,
pub bootloader_file: Option<String>,
pub bootloader_sha256: Option<String>,
pub app_partition_size: u32,
pub flash_size: String,
pub flash_size_bytes: u32,
pub flash_mode: String,
pub flash_frequency: String,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SlotCandidate {
pub offset: u32,
pub size: u32,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FlashPlan {
pub port: String,
pub device: DeviceSnapshot,
pub bundle: BundleInfo,
pub candidates: Vec<SlotCandidate>,
pub target_offset: u32,
pub target_size: u32,
pub app_size: u32,
pub app_partition_size: u32,
pub current_factory_offset: u32,
pub current_factory_size: u32,
pub current_table: Vec<PartitionEntry>,
pub patched_table: Vec<PartitionEntry>,
pub table_sha_before: String,
pub table_sha_after: String,
pub old_app_preserved: bool,
pub warnings: Vec<String>,
pub can_flash: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Mismatch {
pub offset: u32,
pub expected: u8,
pub actual: u8,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SlotAttempt {
pub offset: u32,
pub size: u32,
pub write_ok: bool,
pub verify_ok: bool,
pub mismatches: Vec<Mismatch>,
pub bad_sectors: Vec<u32>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FlashProgress {
pub phase: String,
pub message: String,
pub current: u64,
pub total: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FlashReport {
pub ok: bool,
pub port: String,
pub chip: String,
pub mac: Option<String>,
pub bundle_name: String,
pub bundle_version: String,
pub target_offset: u32,
pub target_size: u32,
pub app_size: u32,
pub app_sha256: String,
pub table_sha_before: String,
pub table_sha_after: String,
pub attempts: Vec<SlotAttempt>,
pub app_verified: bool,
pub table_verified: bool,
pub table_restored: bool,
pub needs_rollback: bool,
pub boot_ok: Option<bool>,
pub boot_log: String,
pub backup_dir: String,
pub note: Option<String>,
pub error: Option<String>,
pub started_at: String,
pub finished_at: String,
}
// ---------------------------------------------------------------------------
// 状态与命令
// ---------------------------------------------------------------------------
static TEMP_DIR: OnceLock<PathBuf> = OnceLock::new();
/// 指定读写 Flash 时用的临时目录。
///
/// 不能盲用 `%TEMP%`:Windows 上它可能被组策略/沙箱拒绝,`espflash::read_flash`
/// 打开临时文件失败时会报成一句极易误导的“Error while connecting to device”。
/// Tauri 启动时指向 app cache;命令行探测指向仓库的 `target/flash-tmp`。
pub fn set_temp_dir(path: PathBuf) {
let _ = TEMP_DIR.set(path);
}
#[derive(Default)]
pub struct MaintenanceState {
busy: AtomicBool,
cancel: AtomicBool,
}
pub struct BusyGuard<'a> {
state: &'a MaintenanceState,
}
impl Drop for BusyGuard<'_> {
fn drop(&mut self) {
self.state.busy.store(false, Ordering::SeqCst);
}
}
impl MaintenanceState {
fn begin(&self) -> Result<BusyGuard<'_>> {
if self
.busy
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
bail!("另一个维护操作正在进行,请等它结束");
}
self.cancel.store(false, Ordering::SeqCst);
Ok(BusyGuard { state: self })
}
fn cancel_requested(&self) -> bool {
self.cancel.load(Ordering::SeqCst)
}
}
async fn run_blocking<T, F>(state: Arc<MaintenanceState>, f: F) -> Result<T, String>
where
T: Send + 'static,
F: FnOnce(&MaintenanceState) -> Result<T> + Send + 'static,
{
tauri::async_runtime::spawn_blocking(move || {
let _guard = state.begin().map_err(|err| format!("{err:#}"))?;
f(&state).map_err(|err| format!("{err:#}"))
})
.await
.map_err(|err| format!("维护任务异常结束:{err}"))?
}
#[tauri::command]
pub fn maintenance_list_ports() -> Result<Vec<PortInfo>, String> {
list_ports().map_err(|err| format!("{err:#}"))
}
#[tauri::command]
pub fn maintenance_load_bundle(path: String) -> Result<BundleInfo, String> {
load_bundle(Path::new(&path))
.map(|bundle| bundle.info)
.map_err(|err| format!("{err:#}"))
}
#[tauri::command]
pub async fn maintenance_probe(
state: State<'_, Arc<MaintenanceState>>,
port: String,
) -> Result<DeviceSnapshot, String> {
let state = Arc::clone(&state);
run_blocking(state, move |_| {
probe_and_reset(&port).map(|data| data.snapshot)
})
.await
}
#[tauri::command]
pub async fn maintenance_plan(
state: State<'_, Arc<MaintenanceState>>,
port: String,
bundle_path: String,
) -> Result<FlashPlan, String> {
let state = Arc::clone(&state);
run_blocking(state, move |_| plan_impl(&port, Path::new(&bundle_path))).await
}
#[tauri::command]
pub async fn maintenance_flash(
app: AppHandle,
state: State<'_, Arc<MaintenanceState>>,
port: String,
bundle_path: String,
backup_app: Option<bool>,
) -> Result<FlashReport, String> {
let state = Arc::clone(&state);
let app_handle = app.clone();
run_blocking(state, move |state| {
flash_impl(
&app_handle,
state,
&port,
Path::new(&bundle_path),
backup_app.unwrap_or(true),
)
})
.await
}
#[tauri::command]
pub async fn maintenance_restore_table(
state: State<'_, Arc<MaintenanceState>>,
port: String,
backup_dir: String,
) -> Result<String, String> {
let state = Arc::clone(&state);
run_blocking(state, move |_| {
restore_table_command(&port, Path::new(&backup_dir))
})
.await
}
#[tauri::command]
pub fn maintenance_cancel(state: State<'_, Arc<MaintenanceState>>) {
state.cancel.store(true, Ordering::SeqCst);
}
// ---------------------------------------------------------------------------
// 串口与探测
// ---------------------------------------------------------------------------
fn list_ports() -> Result<Vec<PortInfo>> {
let mut ports = serialport::available_ports()
.context("无法枚举串口")?
.into_iter()
.map(|port| {
let (vid, pid, label, likely_esp) = match &port.port_type {
SerialPortType::UsbPort(usb) => {
let mut label = String::new();
if let Some(name) = &usb.manufacturer {
label.push_str(name);
}
if let Some(name) = &usb.product {
if !label.is_empty() {
label.push(' ');
}
label.push_str(name);
}
if label.is_empty() {
label = port.port_name.clone();
}
let likely = usb.vid == 0x303A
|| label.to_ascii_lowercase().contains("espressif")
|| label.to_ascii_lowercase().contains("jtag");
(Some(usb.vid), Some(usb.pid), label, likely)
}
_ => (None, None, port.port_name.clone(), false),
};
PortInfo {
port_name: port.port_name,
label,
vid,
pid,
likely_esp,
}
})
.collect::<Vec<_>>();
ports.sort_by(|a, b| {
b.likely_esp
.cmp(&a.likely_esp)
.then_with(|| a.port_name.cmp(&b.port_name))
});
Ok(ports)
}
fn open_flasher(port: &str, verify: bool) -> Result<Flasher> {
let info = serialport::available_ports()
.context("无法枚举串口")?
.into_iter()
.find(|candidate| candidate.port_name == port)
.ok_or_else(|| anyhow!("串口 {port} 不存在"))?;
let usb = match info.port_type {
SerialPortType::UsbPort(usb) => usb,
_ => UsbPortInfo {
vid: 0,
pid: 0,
serial_number: None,
manufacturer: None,
product: None,
},
};
let mut last_error = None;
for baud in [921_600u32, 460_800, 115_200] {
match try_open(port, &usb, verify, baud) {
Ok(flasher) => return Ok(flasher),
Err(err) => last_error = Some(err),
}
}
Err(last_error.unwrap_or_else(|| anyhow!("无法连接 {port}")))
}
fn try_open(port: &str, usb: &UsbPortInfo, verify: bool, baud: u32) -> Result<Flasher> {
let serial = serialport::new(port, 115_200)
.flow_control(FlowControl::None)
.open_native()
.with_context(|| format!("无法打开串口 {port}"))?;
let connection = Connection::new(
*Box::new(serial),
usb.clone(),
ResetAfterOperation::HardReset,
ResetBeforeOperation::DefaultReset,
115_200,
);
Flasher::connect(connection, true, verify, false, None, Some(baud))
.map_err(|err| anyhow!("连接 {port} 失败({baud} baud):{err}"))
}
fn reset_to_app(flasher: &mut Flasher) -> Result<()> {
let chip = flasher.chip();
flasher
.connection()
.reset_after(true, chip)
.context("复位到 App 失败")?;
Ok(())
}
fn best_effort_reset(port: &str) {
if let Ok(mut flasher) = open_flasher(port, true) {
let _ = reset_to_app(&mut flasher);
}
}
pub(crate) struct DeviceData {
pub snapshot: DeviceSnapshot,
pub table: Vec<u8>,
pub factory_offset: u32,
pub factory_size: u32,
}
pub fn probe_snapshot(port: &str) -> Result<DeviceSnapshot> {
probe_and_reset(port).map(|data| data.snapshot)
}
fn probe_and_reset(port: &str) -> Result<DeviceData> {
let mut flasher = open_flasher(port, true)?;
let data = match read_device(&mut flasher, port) {
Ok(data) => data,
Err(err) => {
let _ = reset_to_app(&mut flasher);
return Err(err);
}
};
reset_to_app(&mut flasher)?;
Ok(data)
}
fn read_device(flasher: &mut Flasher, port: &str) -> Result<DeviceData> {
let info = flasher.device_info().context("读取芯片信息失败")?;
let security = flasher.security_info().ok();
let table = read_region(flasher, 0x8000, 0xC00)
.map_err(|err| anyhow!("读取分区表失败:{err:#}"))?;
let parsed = parse_partition_table(&table)?;
let (factory_index, factory) = find_factory(&parsed.entries)
.ok_or_else(|| anyhow!("分区表里没有 factory App 分区"))?;
let _ = factory_index;
let app = read_app_info(flasher, factory.offset).ok().flatten();
let flash_size = info.flash_size.size();
let free = free_ranges(&parsed.entries, flash_size);
let (secure_boot, secure_download, flash_encryption, flags, crypt_cnt) = match security {
Some(sec) => (
sec.flags & 0x1 != 0,
sec.flags & 0x4 != 0,
sec.flash_crypt_cnt != 0,
sec.flags,
sec.flash_crypt_cnt,
),
None => (false, false, false, 0, 0),
};
let revision = info
.revision
.map(|(major, minor)| format!("v{major}.{minor}"));
let snapshot = DeviceSnapshot {
port: port.to_string(),
chip: format!("{:?}", info.chip).to_ascii_lowercase(),
revision,
flash_size,
flash_size_text: info.flash_size.to_string(),
mac: info.mac_address.clone(),
features: info.features.clone(),
secure_boot,
secure_download,
flash_encryption,
security_flags: flags,
flash_crypt_cnt: crypt_cnt,
partition_table: parsed.entries.clone(),
app,
free_ranges: free,
table_sha256: sha256_hex(&table),
};
Ok(DeviceData {
snapshot,
table,
factory_offset: factory.offset,
factory_size: factory.size,
})
}
fn read_app_info(flasher: &mut Flasher, offset: u32) -> Result<Option<AppInfo>> {
let data = read_region(flasher, offset, 0x200)?;
if data.len() < 0x20 + 0xB0 {
return Ok(None);
}
let desc = &data[0x20..0x20 + 0xB0];
let magic = u32::from_le_bytes(desc[0..4].try_into().unwrap());
if magic != 0xABCD_5432 {
return Ok(None);
}
let version = cstr(&desc[0x10..0x30]);
let project_name = cstr(&desc[0x30..0x50]);
let time = cstr(&desc[0x50..0x60]);
let date = cstr(&desc[0x60..0x70]);
let idf_version = cstr(&desc[0x70..0x90]);
Ok(Some(AppInfo {
offset,
version,
project_name,
build_time: format!("{date} {time}").trim().to_string(),
idf_version,
}))
}
fn cstr(bytes: &[u8]) -> String {
let end = bytes.iter().position(|&byte| byte == 0).unwrap_or(bytes.len());
String::from_utf8_lossy(&bytes[..end]).trim().to_string()
}
// ---------------------------------------------------------------------------
// 分区表
// ---------------------------------------------------------------------------
struct PartitionTable {
entries: Vec<PartitionEntry>,
md5_offset: usize,
}
fn parse_partition_table(data: &[u8]) -> Result<PartitionTable> {
if data.len() != 0xC00 {
bail!("分区表长度应为 0xC00,实际 {}", data.len());
}
let mut entries = Vec::new();
for index in 0..(data.len() / 32) {
let entry = &data[index * 32..(index + 1) * 32];
let magic = u16::from_le_bytes([entry[0], entry[1]]);
if magic != 0x50AA && magic != 0xAA50 {
break;
}
let kind = entry[2];
let subtype = entry[3];
let offset = u32::from_le_bytes(entry[4..8].try_into().unwrap());
let size = u32::from_le_bytes(entry[8..12].try_into().unwrap());
let name = cstr(&entry[12..28]);
let flags = u32::from_le_bytes(entry[28..32].try_into().unwrap());
entries.push(PartitionEntry {
name,
kind,
subtype,
offset,
size,
flags,
});
}
if entries.is_empty() {
bail!("分区表里没有有效条目");
}
let entries_end = entries.len() * 32;
let digest = Md5::digest(&data[..entries_end]);
let mut md5_offset = None;
for candidate in [entries_end, entries_end + 16, data.len().saturating_sub(16)] {
if candidate + 16 <= data.len() && data[candidate..candidate + 16] == digest[..] {
md5_offset = Some(candidate);
break;
}
}
let md5_offset = md5_offset.ok_or_else(|| anyhow!("分区表 MD5 位置无法确认"))?;
Ok(PartitionTable {
entries,
md5_offset,
})
}
fn find_factory(entries: &[PartitionEntry]) -> Option<(usize, &PartitionEntry)> {
entries
.iter()
.enumerate()
.find(|(_, entry)| entry.kind == 0 && entry.name == "factory")
.or_else(|| {
entries
.iter()
.enumerate()
.find(|(_, entry)| entry.kind == 0 && entry.subtype == 0)
})
}
fn free_ranges(entries: &[PartitionEntry], flash_size: u32) -> Vec<FreeRange> {
let mut sorted = entries.to_vec();
sorted.sort_by_key(|entry| entry.offset);
let mut ranges = Vec::new();
let mut cursor = 0u32;
for entry in &sorted {
if entry.offset > cursor {
push_free(&mut ranges, cursor, entry.offset);
}
cursor = cursor.max(entry.offset.saturating_add(entry.size));
}
if cursor < flash_size {
push_free(&mut ranges, cursor, flash_size);
}
ranges
}
fn push_free(ranges: &mut Vec<FreeRange>, start: u32, end: u32) {
if end <= start || start < 0x10000 {
return;
}
ranges.push(FreeRange {
start,
size: end - start,
});
}
fn patch_partition_table(
table: &[u8],
factory_index: usize,
new_offset: u32,
new_size: u32,
flash_size: u32,
app_size: u32,
) -> Result<(Vec<u8>, Vec<PartitionEntry>)> {
let parsed = parse_partition_table(table)?;
if new_offset % 0x10000 != 0 {
bail!("App 偏移必须 64 KiB 对齐:0x{new_offset:x}");
}
if new_size % 0x1000 != 0 {
bail!("App 分区大小必须 4 KiB 对齐:0x{new_size:x}");
}
if new_size < app_size {
bail!("App 镜像 {app_size} 字节大于分区大小 {new_size} 字节");
}
if new_offset < 0x10000 || new_offset.saturating_add(new_size) > flash_size {
bail!("App 槽位 0x{new_offset:x}..0x{:x} 超出 Flash", new_offset + new_size);
}
for (index, entry) in parsed.entries.iter().enumerate() {
if index == factory_index {
continue;
}
if new_offset < entry.offset.saturating_add(entry.size)
&& new_offset.saturating_add(new_size) > entry.offset
{
bail!("App 槽位与分区 {} 重叠", entry.name);
}
}
let mut patched = table.to_vec();
let entry = &mut patched[factory_index * 32..(factory_index + 1) * 32];
entry[4..8].copy_from_slice(&new_offset.to_le_bytes());
entry[8..12].copy_from_slice(&new_size.to_le_bytes());
// MD5 覆盖的是全部条目,不是截止到 factory 的那一段。
let entries_end = parsed.entries.len() * 32;
let digest = Md5::digest(&patched[..entries_end]);
patched[parsed.md5_offset..parsed.md5_offset + 16].copy_from_slice(&digest);
let mut entries = parsed.entries;
entries[factory_index].offset = new_offset;
entries[factory_index].size = new_size;
Ok((patched, entries))
}
// ---------------------------------------------------------------------------
// Bundle
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BundleManifest {
format: u32,
name: String,
version: String,
chip: String,
app: String,
app_sha256: String,
partition_table: String,
partition_table_sha256: String,
#[serde(default)]
bootloader: Option<String>,
#[serde(default)]
bootloader_sha256: Option<String>,
app_partition_size: serde_json::Value,
flash_size: String,
flash_mode: String,
flash_frequency: String,
#[serde(default)]
notes: Option<String>,
}
struct BundleLoaded {
info: BundleInfo,
app: Vec<u8>,
manifest_text: String,
}
fn load_bundle(path: &Path) -> Result<BundleLoaded> {
if path.is_dir() {
return load_bundle_from_dir(path);
}
if path.is_file() {
if path.file_name().and_then(|name| name.to_str()) == Some("bundle.json") {
let parent = path
.parent()
.ok_or_else(|| anyhow!("bundle.json 没有父目录"))?;
return load_bundle_from_dir(parent);
}
if is_zip_file(path)? {
return load_bundle_zip(path);
}
bail!("不是有效的固件包(既不是 .zip,也没有 bundle.json)");
}
bail!("固件包路径不存在:{}", path.display())
}
fn load_bundle_from_dir(path: &Path) -> Result<BundleLoaded> {
let manifest_path = if path.is_dir() {
path.join("bundle.json")
} else if path.file_name().and_then(|name| name.to_str()) == Some("bundle.json") {
path.to_path_buf()
} else {
bail!("请选择包含 bundle.json 的官方固件包目录");
};
let manifest_text = fs::read_to_string(&manifest_path)
.with_context(|| format!("读取 {}", manifest_path.display()))?;
let manifest: BundleManifest = serde_json::from_str(&manifest_text)
.with_context(|| format!("解析 {}", manifest_path.display()))?;
if manifest.format != 1 {
bail!("不支持的 bundle 格式版本:{}", manifest.format);
}
let bundle_dir = manifest_path
.parent()
.ok_or_else(|| anyhow!("bundle.json 没有父目录"))?
.to_path_buf();
let bundle_dir = bundle_dir
.canonicalize()
.with_context(|| format!("无法解析目录 {}", bundle_dir.display()))?;
let app_path = resolve_bundle_file(&bundle_dir, &manifest.app)?;
let partition_table_path = resolve_bundle_file(&bundle_dir, &manifest.partition_table)?;
let app = fs::read(&app_path).with_context(|| format!("读取 {}", app_path.display()))?;
let partition_table = fs::read(&partition_table_path)
.with_context(|| format!("读取 {}", partition_table_path.display()))?;
verify_sha256(&app, &manifest.app_sha256, "App")?;
verify_sha256(&partition_table, &manifest.partition_table_sha256, "分区表")?;
if app.is_empty() {
bail!("App 镜像为空");
}
if partition_table.len() != 0xC00 {
bail!("bundle 分区表长度应为 0xC00,实际 {}", partition_table.len());
}
let app_partition_size = parse_size_value(&manifest.app_partition_size)?;
if app_partition_size % 0x1000 != 0 {
bail!("appPartitionSize 必须 4 KiB 对齐");
}
if app.len() as u32 > app_partition_size {
bail!(
"App 镜像 {} 字节大于 appPartitionSize {} 字节",
app.len(),
app_partition_size
);
}
let flash_size_bytes = parse_size(&manifest.flash_size)?;
if app_partition_size > flash_size_bytes {
bail!("appPartitionSize 大于 Flash 容量");
}
let (bootloader_file, bootloader_sha256) = match (&manifest.bootloader, &manifest.bootloader_sha256) {
(Some(file), Some(expected)) => {
let bootloader_path = resolve_bundle_file(&bundle_dir, file)?;
let bootloader = fs::read(&bootloader_path)
.with_context(|| format!("读取 {}", bootloader_path.display()))?;
verify_sha256(&bootloader, expected, "Bootloader")?;
(Some(file.clone()), Some(expected.clone()))
}
(None, None) => (None, None),
_ => bail!("bundle 里 bootloader 与 bootloaderSha256 必须同时出现或同时省略"),
};
let path_text = bundle_dir.display().to_string();
let path_text = path_text
.strip_prefix(r"\\?\")
.unwrap_or(&path_text)
.to_string();
let info = BundleInfo {
path: path_text.clone(),
source_kind: "dir".to_string(),
source_path: path_text,
source_sha256: None,
entry_count: None,
name: manifest.name,
version: manifest.version,
chip: manifest.chip.to_ascii_lowercase(),
app_file: manifest.app,
app_sha256: manifest.app_sha256.to_ascii_lowercase(),
app_size: app.len() as u32,
partition_table_file: manifest.partition_table,
partition_table_sha256: manifest.partition_table_sha256.to_ascii_lowercase(),
bootloader_file,
bootloader_sha256,
app_partition_size,
flash_size: manifest.flash_size,
flash_size_bytes,
flash_mode: manifest.flash_mode,
flash_frequency: manifest.flash_frequency,
notes: manifest.notes,
};
Ok(BundleLoaded {
info,
app,
manifest_text,
})
}
fn resolve_bundle_file(dir: &Path, relative: &str) -> Result<PathBuf> {
let path = dir.join(relative);
let canonical = path
.canonicalize()
.with_context(|| format!("找不到 {}", path.display()))?;
if !canonical.starts_with(dir) {
bail!("bundle 文件越界:{relative}");
}
Ok(canonical)
}
fn is_zip_file(path: &Path) -> Result<bool> {
let mut file = File::open(path).with_context(|| format!("打开 {}", path.display()))?;
let mut magic = [0u8; 4];
match file.read_exact(&mut magic) {
Ok(()) => Ok(magic == *b"PK\x03\x04"),
Err(_) => Ok(false),
}
}
fn load_bundle_zip(path: &Path) -> Result<BundleLoaded> {
let file = File::open(path).with_context(|| format!("打开 {}", path.display()))?;
let mut archive = ZipArchive::new(file)
.with_context(|| format!("解析 zip {}", path.display()))?;
if archive.len() > 32 {
bail!("固件包条目过多({} > 32)", archive.len());
}
let temp = temp_bundle_dir();
fs::create_dir_all(&temp).with_context(|| format!("创建临时目录 {}", temp.display()))?;
let result = (|| -> Result<BundleLoaded> {
let manifest_bytes = read_zip_entry(&mut archive, "bundle.json", 64 * 1024)?;
let manifest_text = String::from_utf8(manifest_bytes)
.context("固件包里的 bundle.json 不是 UTF-8")?;
let manifest: BundleManifest = serde_json::from_str(&manifest_text)
.context("解析固件包里的 bundle.json 失败")?;
if manifest.app == "bundle.json"
|| manifest.partition_table == "bundle.json"
|| manifest.bootloader.as_deref() == Some("bundle.json")
{
bail!("固件包内文件名冲突:bundle.json");
}
let app_partition_size = parse_size_value(&manifest.app_partition_size)?;
let app = read_zip_entry(&mut archive, &manifest.app, app_partition_size as u64)?;
let partition_table =
read_zip_entry(&mut archive, &manifest.partition_table, 0xC00)?;
if let (Some(bootloader), Some(_)) = (&manifest.bootloader, &manifest.bootloader_sha256) {
let bootloader_bytes = read_zip_entry(&mut archive, bootloader, 1024 * 1024)?;
fs::write(temp.join(bootloader), bootloader_bytes)
.with_context(|| format!("写入临时文件 {bootloader}"))?;
}
fs::write(temp.join("bundle.json"), &manifest_text)
.context("写入临时 bundle.json 失败")?;
fs::write(temp.join(&manifest.app), &app)
.with_context(|| format!("写入临时文件 {}", manifest.app))?;
fs::write(temp.join(&manifest.partition_table), &partition_table)
.with_context(|| format!("写入临时文件 {}", manifest.partition_table))?;
let mut loaded = load_bundle_from_dir(&temp)?;
let source = display_path(path);
loaded.info.path = source.clone();
loaded.info.source_kind = "zip".to_string();
loaded.info.source_path = source;
loaded.info.source_sha256 = Some(sha256_file(path)?);
loaded.info.entry_count = Some(archive.len() as u32);
Ok(loaded)
})();
let _ = fs::remove_dir_all(&temp);
result
}
fn read_zip_entry(archive: &mut ZipArchive<File>, name: &str, max: u64) -> Result<Vec<u8>> {
if !safe_zip_name(name) {
bail!("固件包内有非法路径:{name}");
}
let mut entry = archive
.by_name(name)
.with_context(|| format!("固件包缺少 {name}"))?;
if entry.size() > max {
bail!("固件包条目 {name} 过大({} 字节)", entry.size());
}
let mut data = Vec::with_capacity(entry.size().min(max) as usize);
entry
.read_to_end(&mut data)
.with_context(|| format!("读取固件包条目 {name} 失败"))?;
if data.len() as u64 > max {
bail!("固件包条目 {name} 解压后过大");
}
Ok(data)
}
fn safe_zip_name(name: &str) -> bool {
if name.is_empty()
|| name.len() > 128
|| name.contains('/')
|| name.contains('\\')
|| name.contains(':')
{
return false;
}
let mut components = Path::new(name).components();
matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
}
fn temp_bundle_dir() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let base = TEMP_DIR
.get()
.cloned()
.unwrap_or_else(std::env::temp_dir);
let _ = fs::create_dir_all(&base);
base.join(format!(
"cam-desktop-bundle-{}-{nanos}",
std::process::id()
))
}
fn display_path(path: &Path) -> String {
let text = path.display().to_string();
text.strip_prefix(r"\\?\").unwrap_or(&text).to_string()
}
fn sha256_file(path: &Path) -> Result<String> {
let mut file = File::open(path).with_context(|| format!("打开 {}", path.display()))?;
let mut hasher = Sha256::new();
// 64 KiB 在堆上;1 MiB 的栈数组在 Windows 主线程(默认 1 MiB 栈)会直接溢出。
let mut buffer = vec![0u8; 64 * 1024];
loop {
let count = file
.read(&mut buffer)
.with_context(|| format!("读取 {}", path.display()))?;
if count == 0 {
break;
}
hasher.update(&buffer[..count]);
}
Ok(hex::encode(hasher.finalize()))
}
fn verify_sha256(data: &[u8], expected: &str, label: &str) -> Result<()> {
let actual = sha256_hex(data);
if !actual.eq_ignore_ascii_case(expected.trim()) {
bail!("{label} SHA256 不匹配:期望 {expected},实际 {actual}");
}
Ok(())
}
fn parse_size_value(value: &serde_json::Value) -> Result<u32> {
match value {
serde_json::Value::String(text) => parse_size(text),
serde_json::Value::Number(number) => number
.as_u64()
.and_then(|value| u32::try_from(value).ok())
.ok_or_else(|| anyhow!("大小不是有效的 u32")),
_ => bail!("大小必须是字符串或数字"),
}
}
fn parse_size(text: &str) -> Result<u32> {
let text = text.trim().to_ascii_lowercase();
if let Some(hex) = text.strip_prefix("0x") {
return u32::from_str_radix(hex, 16).with_context(|| format!("无效大小 {text}"));
}
if let Some(value) = text.strip_suffix("mb") {
return value
.trim()
.parse::<u32>()
.map(|value| value.saturating_mul(1024 * 1024))
.with_context(|| format!("无效大小 {text}"));
}
if let Some(value) = text.strip_suffix("kb") {
return value
.trim()
.parse::<u32>()
.map(|value| value.saturating_mul(1024))
.with_context(|| format!("无效大小 {text}"));
}
text.parse::<u32>().with_context(|| format!("无效大小 {text}"))
}
fn check_bundle_against_device(bundle: &BundleLoaded, device: &DeviceSnapshot) -> Result<()> {
if bundle.info.chip != device.chip {
bail!(
"固件包是 {},当前设备是 {}",
bundle.info.chip,
device.chip
);
}
if bundle.info.flash_size_bytes != device.flash_size {
bail!(
"固件包要求 {} Flash,当前设备是 {}",
bundle.info.flash_size,
device.flash_size_text
);
}
if device.secure_boot || device.secure_download {
bail!("设备启用了 Secure Boot / Secure Download,本流程不适用");
}
if device.flash_encryption {
bail!("设备启用了 Flash Encryption,本流程不适用");
}
if bundle.info.app_size as u32 > bundle.info.app_partition_size {
bail!("App 镜像大于 appPartitionSize");
}
Ok(())
}
// ---------------------------------------------------------------------------
// 规划
// ---------------------------------------------------------------------------
pub fn inspect_bundle(bundle_path: &Path) -> Result<BundleInfo> {
load_bundle(bundle_path).map(|bundle| bundle.info)
}
pub fn plan_snapshot(port: &str, bundle_path: &Path) -> Result<FlashPlan> {
plan_impl(port, bundle_path)
}
fn plan_impl(port: &str, bundle_path: &Path) -> Result<FlashPlan> {
let device = probe_and_reset(port)?;
let bundle = load_bundle(bundle_path)?;
check_bundle_against_device(&bundle, &device.snapshot)?;
let (factory_index, _) = find_factory(&device.snapshot.partition_table)
.ok_or_else(|| anyhow!("分区表里没有 factory App 分区"))?;
let candidates = choose_candidates(
&device.snapshot.partition_table,
device.factory_offset,
device.factory_size,
bundle.info.app_partition_size,
device.snapshot.flash_size,
)?;
let first = candidates
.first()
.cloned()
.ok_or_else(|| anyhow!("没有可用的空闲 App 槽位"))?;
let (patched, patched_entries) = patch_partition_table(
&device.table,
factory_index,
first.offset,
first.size,
device.snapshot.flash_size,
bundle.info.app_size,
)?;
let mut warnings = Vec::new();
if first.size != device.factory_size {
warnings.push(format!(
"factory 分区大小将从 0x{:x} 调整为 0x{:x}",
device.factory_size, first.size
));
}
if candidates.len() > 1 {
warnings.push(format!(
"第一个槽位校验失败时会自动尝试其余 {} 个候选槽位",
candidates.len() - 1
));
}
if bundle.info.bootloader_file.is_some() {
warnings.push("固件包带 bootloader,但本流程默认不重写 0x0 的 bootloader".to_string());
}
if bundle.info.flash_mode != "dio" {
warnings.push(format!(
"固件包声明 Flash 模式 {},本板当前为 DIO",
bundle.info.flash_mode
));
}
if bundle.info.flash_frequency != "40mhz" {
warnings.push(format!(
"固件包声明 Flash 频率 {},本板当前为 40 MHz",
bundle.info.flash_frequency
));
}
if candidates.len() > 1 {
warnings.push("候选槽位彼此重叠是允许的:分区表只有在某个槽位校验通过后才会切换".to_string());
}
let plan = FlashPlan {
port: port.to_string(),
device: device.snapshot.clone(),
bundle: bundle.info.clone(),
candidates: candidates.clone(),
target_offset: first.offset,
target_size: first.size,
app_size: bundle.info.app_size,
app_partition_size: bundle.info.app_partition_size,
current_factory_offset: device.factory_offset,
current_factory_size: device.factory_size,
current_table: device.snapshot.partition_table.clone(),
patched_table: patched_entries,
table_sha_before: sha256_hex(&device.table),
table_sha_after: sha256_hex(&patched),
old_app_preserved: true,
warnings,
can_flash: true,
};
Ok(plan)
}
/// 只做“写入 + 回读”,不碰分区表。用于实机验证坏块检测链路,设备仍从旧 App 启动。
pub fn write_test(port: &str, bundle_path: &Path, offset: u32) -> Result<SlotAttempt> {
if offset % 0x10000 != 0 {
bail!("测试偏移必须 64 KiB 对齐:0x{offset:x}");
}
let device = probe_and_reset(port)?;
let bundle = load_bundle(bundle_path)?;
let flash_size = device.snapshot.flash_size;
let app_size = bundle.info.app_size;
if offset.saturating_add(app_size) > flash_size {
bail!("测试区超出 Flash");
}
let fits = free_ranges(&device.snapshot.partition_table, flash_size)
.iter()
.any(|range| {
offset >= range.start
&& offset.saturating_add(app_size) <= range.start.saturating_add(range.size)
});
if !fits {
bail!("0x{offset:x} 不在空闲区间;拒绝覆盖已有分区");
}
let candidate = SlotCandidate {
offset,
size: bundle.info.app_partition_size,
};
Ok(try_slot(None, port, &bundle.app, &candidate))
}
fn choose_candidates(
entries: &[PartitionEntry],
factory_offset: u32,
factory_size: u32,
partition_size: u32,
flash_size: u32,
) -> Result<Vec<SlotCandidate>> {
if partition_size == 0 {
bail!("appPartitionSize 不能为 0");
}
let factory_end = factory_offset.saturating_add(factory_size);
let mut all = Vec::new();
for range in free_ranges(entries, flash_size) {
let mut start = align_up(range.start, 0x10000);
let end = range.start.saturating_add(range.size);
while start.saturating_add(partition_size) <= end {
let overlaps_old_app = start < factory_end && start.saturating_add(partition_size) > factory_offset;
if !overlaps_old_app {
all.push(SlotCandidate {
offset: start,
size: partition_size,
});
}
start = start.saturating_add(0x10000);
}
}
if all.is_empty() {
bail!("没有足够的空闲 Flash 放置 0x{partition_size:x} 的 App 分区");
}
all.sort_by_key(|candidate| candidate.offset);
let mut preferred = Vec::new();
preferred.extend(all.iter().filter(|candidate| candidate.offset >= 0x200000).cloned());
preferred.extend(
all.iter()
.filter(|candidate| candidate.offset >= 0x100000 && candidate.offset < 0x200000)
.cloned(),
);
preferred.extend(all.iter().filter(|candidate| candidate.offset < 0x100000).cloned());
preferred.dedup_by_key(|candidate| candidate.offset);
preferred.truncate(4);
Ok(preferred)
}
fn align_up(value: u32, align: u32) -> u32 {
value.saturating_add(align - 1) & !(align - 1)
}
// ---------------------------------------------------------------------------
// 烧录
// ---------------------------------------------------------------------------
fn flash_impl(
app: &AppHandle,
state: &MaintenanceState,
port: &str,
bundle_path: &Path,
backup_app: bool,
) -> Result<FlashReport> {
let started_at = now_iso();
let device = probe_and_reset(port)?;
let bundle = load_bundle(bundle_path)?;
check_bundle_against_device(&bundle, &device.snapshot)?;
let (factory_index, _) = find_factory(&device.snapshot.partition_table)
.ok_or_else(|| anyhow!("分区表里没有 factory App 分区"))?;
let candidates = choose_candidates(
&device.snapshot.partition_table,
device.factory_offset,
device.factory_size,
bundle.info.app_partition_size,
device.snapshot.flash_size,
)?;
let backup_dir = match prepare_backup(app, &device, &bundle, backup_app) {
Ok(dir) => dir,
Err(err) => {
best_effort_reset(port);
return Err(err);
}
};
let mut report = FlashReport {
ok: false,
port: port.to_string(),
chip: device.snapshot.chip.clone(),
mac: device.snapshot.mac.clone(),
bundle_name: bundle.info.name.clone(),
bundle_version: bundle.info.version.clone(),
target_offset: 0,
target_size: 0,
app_size: bundle.info.app_size,
app_sha256: bundle.info.app_sha256.clone(),
table_sha_before: device.snapshot.table_sha256.clone(),
table_sha_after: String::new(),
attempts: Vec::new(),
app_verified: false,
table_verified: false,
table_restored: false,
needs_rollback: false,
boot_ok: None,
boot_log: String::new(),
backup_dir: backup_dir.display().to_string(),
note: None,
error: None,
started_at,
finished_at: String::new(),
};
let mut chosen: Option<SlotCandidate> = None;
for candidate in &candidates {
if state.cancel_requested() {
report.error = Some("已取消".to_string());
break;
}
emit(
app,
"写入应用",
&format!("尝试槽位 0x{:x}", candidate.offset),
0,
bundle.info.app_size as u64,
);
let attempt = try_slot(Some(app), port, &bundle.app, candidate);
let ok = attempt.verify_ok;
report.attempts.push(attempt);
if ok {
chosen = Some(candidate.clone());
break;
}
}
let Some(chosen) = chosen else {
report.error = Some("所有候选槽位都没有通过写入/回读校验".to_string());
report.finished_at = now_iso();
save_report(&backup_dir, &report)?;
best_effort_reset(port);
return Ok(report);
};
report.target_offset = chosen.offset;
report.target_size = chosen.size;
report.app_verified = true;
if state.cancel_requested() {
report.error = Some("已取消".to_string());
report.finished_at = now_iso();
save_report(&backup_dir, &report)?;
best_effort_reset(port);
return Ok(report);
}
let (patched, _patched_entries) = patch_partition_table(
&device.table,
factory_index,
chosen.offset,
chosen.size,
device.snapshot.flash_size,
bundle.info.app_size,
)?;
report.table_sha_after = sha256_hex(&patched);
emit(app, "写入分区表", "切换 factory 到新槽位", 0, patched.len() as u64);
let table_write_result = (|| -> Result<()> {
let mut flasher = open_flasher(port, true)?;
let mut progress = EmitProgress::new(Some(app.clone()), "写入分区表");
flasher.write_bin_to_flash(0x8000, &patched, &mut progress)?;
Ok(())
})();
if let Err(err) = table_write_result {
let restored = restore_table(port, &device.table).is_ok();
report.table_restored = restored;
report.needs_rollback = !restored;
report.error = Some(format!(
"分区表写入失败:{err:#};{}",
if restored { "已恢复旧分区表" } else { "旧表恢复失败,请用备份手动恢复" }
));
report.finished_at = now_iso();
save_report(&backup_dir, &report)?;
best_effort_reset(port);
return Ok(report);
}
let table_check = (|| -> Result<Vec<u8>> {
let mut flasher = open_flasher(port, true)?;
let data = read_region(&mut flasher, 0x8000, 0xC00)?;
let chip = flasher.chip();
let _ = flasher.connection().reset_after(true, chip);
Ok(data)
})();
let table_ok = match table_check {
Ok(data) => data == patched,
Err(err) => {
report.error = Some(format!("分区表回读失败:{err:#}"));
false
}
};
if !table_ok {
let restored = restore_table(port, &device.table).is_ok();
report.table_restored = restored;
report.needs_rollback = !restored;
report.error = Some(format!(
"分区表回读不一致;{}",
if restored { "已恢复旧分区表" } else { "旧表恢复失败,请用备份手动恢复" }
));
report.finished_at = now_iso();
save_report(&backup_dir, &report)?;
best_effort_reset(port);
return Ok(report);
}
report.table_verified = true;
let boot_log = capture_boot_log(port).unwrap_or_default();
let lowered = boot_log.to_ascii_lowercase();
let explicit_failure = lowered.contains("invalid header")
|| lowered.contains("invalid app")
|| lowered.contains("image invalid")
|| lowered.contains("panic")
|| lowered.contains("boot failed")
|| lowered.contains("checksum error")
|| lowered.contains("image checksum");
let explicit_success = lowered.contains("loaded app from partition at offset")
|| lowered.contains("camera")
|| lowered.contains("wifi")
|| lowered.contains("app_init");
let boot_ok = if explicit_failure {
Some(false)
} else if explicit_success {
Some(true)
} else {
None
};
report.boot_ok = boot_ok;
report.boot_log = boot_log;
report.ok = report.app_verified && report.table_verified && boot_ok != Some(false);
report.needs_rollback = !report.ok;
if !report.ok {
report.error = Some(format!(
"分区表已切换,但启动日志显示异常;旧 App 仍在 0x{:x},可恢复旧分区表",
device.factory_offset
));
} else if boot_ok.is_none() {
report.note = Some(format!(
"写入与分区表校验通过;启动日志未捕获到明确成功标志(旧 App 仍保留在 0x{:x})",
device.factory_offset
));
}
report.finished_at = now_iso();
save_report(&backup_dir, &report)?;
Ok(report)
}
fn try_slot(
app: Option<&AppHandle>,
port: &str,
image: &[u8],
candidate: &SlotCandidate,
) -> SlotAttempt {
let mut attempt = SlotAttempt {
offset: candidate.offset,
size: candidate.size,
write_ok: false,
verify_ok: false,
mismatches: Vec::new(),
bad_sectors: Vec::new(),
error: None,
};
let write_result = (|| -> Result<()> {
let mut flasher = open_flasher(port, true)?;
let mut progress =
EmitProgress::new(app.cloned(), &format!("写入 0x{:x}", candidate.offset));
flasher.write_bin_to_flash(candidate.offset, image, &mut progress)?;
Ok(())
})();
if let Err(err) = write_result {
attempt.error = Some(format!("{err:#}"));
return attempt;
}
attempt.write_ok = true;
let read_result = (|| -> Result<Vec<u8>> {
let mut flasher = open_flasher(port, true)?;
let data = read_region(&mut flasher, candidate.offset, image.len() as u32)?;
let chip = flasher.chip();
let _ = flasher.connection().reset_after(true, chip);
Ok(data)
})();
match read_result {
Ok(data) => {
let mut bad_sectors = Vec::new();
for (index, (expected, actual)) in image.iter().zip(data.iter()).enumerate() {
if expected != actual {
let absolute = candidate.offset.saturating_add(index as u32);
if attempt.mismatches.len() < 32 {
attempt.mismatches.push(Mismatch {
offset: absolute,
expected: *expected,
actual: *actual,
});
}
let sector = (absolute / 0x1000) * 0x1000;
if !bad_sectors.contains(&sector) {
bad_sectors.push(sector);
}
}
}
if data.len() != image.len() && attempt.mismatches.len() < 32 {
attempt.mismatches.push(Mismatch {
offset: candidate.offset.saturating_add(data.len() as u32),
expected: 0,
actual: 0,
});
}
attempt.verify_ok = data.len() == image.len() && attempt.mismatches.is_empty();
attempt.bad_sectors = bad_sectors;
}
Err(err) => {
attempt.error = Some(format!("回读失败:{err:#}"));
}
}
attempt
}
fn restore_table_command(port: &str, backup_dir: &Path) -> Result<String> {
let path = backup_dir.join("partition-table.bin");
let table = fs::read(&path).with_context(|| format!("读取 {}", path.display()))?;
if table.len() != 0xC00 {
bail!("备份分区表长度不是 0xC00");
}
parse_partition_table(&table)?;
restore_table(port, &table)?;
Ok(format!("已恢复 {}", path.display()))
}
fn restore_table(port: &str, table: &[u8]) -> Result<()> {
let mut flasher = open_flasher(port, true)?;
let mut progress = EmitProgress::new_noop();
flasher.write_bin_to_flash(0x8000, table, &mut progress)?;
Ok(())
}
fn prepare_backup(
app: &AppHandle,
device: &DeviceData,
bundle: &BundleLoaded,
backup_app: bool,
) -> Result<PathBuf> {
let base = app
.path()
.app_data_dir()
.unwrap_or_else(|_| std::env::temp_dir());
let mac = device
.snapshot
.mac
.clone()
.unwrap_or_else(|| "unknown".to_string())
.replace(':', "");
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string();
let dir = base.join("backups").join(format!("{mac}-{stamp}"));
fs::create_dir_all(&dir).with_context(|| format!("创建备份目录 {}", dir.display()))?;
fs::write(dir.join("partition-table.bin"), &device.table)
.with_context(|| "备份分区表失败")?;
if backup_app {
emit(app, "备份", "读取旧 App 分区", 0, device.factory_size as u64);
let mut flasher = open_flasher(&device.snapshot.port, true)?;
let old_app = read_region(&mut flasher, device.factory_offset, device.factory_size)?;
let chip = flasher.chip();
let _ = flasher.connection().reset_after(true, chip);
fs::write(dir.join("app-old.bin"), old_app).with_context(|| "备份旧 App 失败")?;
}
fs::write(dir.join("bundle.json"), &bundle.manifest_text)
.with_context(|| "备份 bundle.json 失败")?;
Ok(dir)
}
fn save_report(dir: &Path, report: &FlashReport) -> Result<()> {
let json = serde_json::to_string_pretty(report)?;
fs::write(dir.join("report.json"), json).with_context(|| "写入 report.json 失败")?;
Ok(())
}
fn capture_boot_log(port: &str) -> Result<String> {
std::thread::sleep(Duration::from_millis(300));
for _ in 0..12 {
match serialport::new(port, 115_200)
.timeout(Duration::from_millis(100))
.open()
{
Ok(mut serial) => {
let start = Instant::now();
let mut data = Vec::new();
while start.elapsed() < Duration::from_secs(6) {
let mut buffer = [0u8; 512];
match serial.read(&mut buffer) {
Ok(0) => {}
Ok(count) => data.extend_from_slice(&buffer[..count]),
Err(_) => {}
}
}
return Ok(String::from_utf8_lossy(&data).to_string());
}
Err(_) => std::thread::sleep(Duration::from_millis(250)),
}
}
Ok(String::new())
}
fn read_region(flasher: &mut Flasher, offset: u32, size: u32) -> Result<Vec<u8>> {
// espflash 的 read_flash 以 block_size 为粒度;0xC00 这种不足一个 block 的长度
// 在某些版本上会读多/读少。统一向上取整到 4 KiB,读回后再截断。
let read_size = align_up(size, 0x1000);
let path = temp_path();
let result = flasher.read_flash(offset, read_size, 0x1000, 64, path.clone());
let data = fs::read(&path);
let _ = fs::remove_file(&path);
result.with_context(|| format!("读取 Flash 0x{offset:x}..0x{:x} 失败", offset.saturating_add(read_size)))?;
let mut data = data.with_context(|| format!("读取临时文件 {} 失败", path.display()))?;
if data.len() < size as usize {
bail!(
"读取 Flash 0x{offset:x} 返回 {} 字节,少于请求的 {size} 字节",
data.len()
);
}
data.truncate(size as usize);
Ok(data)
}
fn temp_path() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let base = TEMP_DIR
.get()
.cloned()
.unwrap_or_else(std::env::temp_dir);
let _ = fs::create_dir_all(&base);
base.join(format!(
"cam-desktop-flash-{}-{nanos}.bin",
std::process::id()
))
}
fn sha256_hex(data: &[u8]) -> String {
hex::encode(Sha256::digest(data))
}
fn now_iso() -> String {
chrono::Local::now().to_rfc3339()
}
fn emit(app: &AppHandle, phase: &str, message: &str, current: u64, total: u64) {
let _ = app.emit(
"maintenance-progress",
FlashProgress {
phase: phase.to_string(),
message: message.to_string(),
current,
total,
},
);
}
struct EmitProgress {
app: Option<AppHandle>,
phase: String,
total: u64,
current: u64,
last_emit: Instant,
}
impl EmitProgress {
fn new(app: Option<AppHandle>, phase: &str) -> Self {
Self {
app,
phase: phase.to_string(),
total: 0,
current: 0,
last_emit: Instant::now() - Duration::from_secs(1),
}
}
fn new_noop() -> Self {
Self::new(None, "")
}
fn emit(&mut self, force: bool) {
if !force && self.last_emit.elapsed() < Duration::from_millis(120) {
return;
}
self.last_emit = Instant::now();
if let Some(app) = &self.app {
emit(
app,
&self.phase,
&format!("{} / {}", human_size(self.current), human_size(self.total)),
self.current,
self.total,
);
}
}
}
impl ProgressCallbacks for EmitProgress {
fn init(&mut self, _addr: u32, total: usize) {
self.total = total as u64;
self.current = 0;
self.emit(true);
}
fn update(&mut self, current: usize) {
self.current = current as u64;
self.emit(false);
}
fn verifying(&mut self) {
if let Some(app) = &self.app {
emit(app, &self.phase, "回读校验中", self.current, self.total);
}
}
fn finish(&mut self, _skipped: bool) {
self.current = self.total;
self.emit(true);
}
}
fn human_size(value: u64) -> String {
if value >= 1024 * 1024 {
format!("{:.1} MiB", value as f64 / (1024.0 * 1024.0))
} else if value >= 1024 {
format!("{:.1} KiB", value as f64 / 1024.0)
} else {
format!("{value} B")
}
}
#[cfg(test)]
mod tests {
use super::*;
use md5::{Digest, Md5};
fn entry(name: &str, kind: u8, subtype: u8, offset: u32, size: u32) -> [u8; 32] {
let mut entry = [0u8; 32];
entry[0] = 0xAA;
entry[1] = 0x50;
entry[2] = kind;
entry[3] = subtype;
entry[4..8].copy_from_slice(&offset.to_le_bytes());
entry[8..12].copy_from_slice(&size.to_le_bytes());
let label = name.as_bytes();
let label = &label[..label.len().min(16)];
entry[12..12 + label.len()].copy_from_slice(label);
entry
}
fn table(entries: &[(&str, u8, u8, u32, u32)]) -> Vec<u8> {
let mut data = vec![0xFFu8; 0xC00];
for (index, (name, kind, subtype, offset, size)) in entries.iter().enumerate() {
data[index * 32..(index + 1) * 32]
.copy_from_slice(&entry(name, *kind, *subtype, *offset, *size));
}
let end = entries.len() * 32;
let digest = Md5::digest(&data[..end]);
data[end..end + 16].copy_from_slice(&digest);
data
}
fn real_layout() -> Vec<u8> {
table(&[
("nvs", 1, 2, 0x9000, 0x6000),
("phy_init", 1, 1, 0xF000, 0x1000),
("factory", 0, 0, 0x70000, 0x120000),
])
}
#[test]
fn parses_the_real_layout() {
let parsed = parse_partition_table(&real_layout()).unwrap();
assert_eq!(parsed.entries.len(), 3);
assert_eq!(parsed.entries[2].name, "factory");
assert_eq!(parsed.entries[2].offset, 0x70000);
assert_eq!(parsed.entries[2].size, 0x120000);
assert_eq!(parsed.md5_offset, 3 * 32);
}
#[test]
fn patches_factory_and_recomputes_md5() {
let data = real_layout();
let (patched, entries) = patch_partition_table(
&data,
2,
0x200000,
0x140000,
16 * 1024 * 1024,
0x1172D0,
)
.unwrap();
assert_eq!(entries[2].offset, 0x200000);
assert_eq!(entries[2].size, 0x140000);
// 重新解析会校验 MD5;MD5 不对时这里会失败。
let reparsed = parse_partition_table(&patched).unwrap();
assert_eq!(reparsed.entries[2].offset, 0x200000);
assert_eq!(reparsed.entries[2].size, 0x140000);
assert_eq!(reparsed.entries[0].offset, 0x9000);
assert_eq!(reparsed.entries[1].offset, 0xF000);
}
#[test]
fn rejects_overlapping_slots() {
let data = real_layout();
let result = patch_partition_table(
&data,
2,
0x8000,
0x140000,
16 * 1024 * 1024,
0x1172D0,
);
assert!(result.is_err());
}
#[test]
fn picks_a_slot_after_the_old_app() {
let parsed = parse_partition_table(&real_layout()).unwrap();
let candidates = choose_candidates(
&parsed.entries,
0x70000,
0x120000,
0x140000,
16 * 1024 * 1024,
)
.unwrap();
assert_eq!(candidates[0].offset, 0x200000);
assert_eq!(candidates[0].size, 0x140000);
assert!(candidates.iter().all(|candidate| candidate.offset >= 0x200000));
assert!(candidates.iter().all(|candidate| {
candidate.offset + candidate.size <= 16 * 1024 * 1024
}));
}
#[test]
fn parses_sizes() {
assert_eq!(parse_size("0x140000").unwrap(), 0x140000);
assert_eq!(parse_size("16mb").unwrap(), 16 * 1024 * 1024);
assert_eq!(parse_size("1kb").unwrap(), 1024);
assert_eq!(parse_size("1234").unwrap(), 1234);
assert!(parse_size("nope").is_err());
}
#[test]
fn free_ranges_skip_bootloader_and_nvs() {
let parsed = parse_partition_table(&real_layout()).unwrap();
let ranges = free_ranges(&parsed.entries, 16 * 1024 * 1024);
assert_eq!(ranges[0].start, 0x10000);
assert_eq!(ranges[0].size, 0x60000);
assert_eq!(ranges[1].start, 0x190000);
assert_eq!(ranges[1].size, 16 * 1024 * 1024 - 0x190000);
}
#[test]
fn rejects_unsafe_zip_entry_names() {
assert!(safe_zip_name("bundle.json"));
assert!(safe_zip_name("esp32s3-app.bin"));
assert!(!safe_zip_name(""));
assert!(!safe_zip_name("../bundle.json"));
assert!(!safe_zip_name("a/b.bin"));
assert!(!safe_zip_name("a\\b.bin"));
assert!(!safe_zip_name("C:bundle.json"));
assert!(!safe_zip_name(".."));
}
}