diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5034043
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+# AI
+/.claude
\ No newline at end of file
diff --git a/components/app-image-viewer/AppImageViewer.vue b/components/app-image-viewer/AppImageViewer.vue
new file mode 100644
index 0000000..30c9735
--- /dev/null
+++ b/components/app-image-viewer/AppImageViewer.vue
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 打开固件文件
+
+
+
+
+
+
+ 应用信息
+
+ {{ imageInfo.appDescription.projectName }}
+ {{ imageInfo.appDescription.version }}
+ {{ imageInfo.appDescription.idfVersion }}
+ {{ imageInfo.appDescription.secureVersion }}
+ {{ imageInfo.appDescription.compileDate }}
+ {{ imageInfo.appDescription.compileTime }}
+
+
+ {{ formatSha256(imageInfo.appDescription.appElfSha256) }}
+
+
+
+
+
+
+ 镜像头
+
+ {{ imageInfo.chipName }}
+ {{ formatHex(imageInfo.header.entryPoint) }}
+ {{ SPI_FLASH_MODE_NAMES[imageInfo.header.spiMode] ?? formatHex(imageInfo.header.spiMode) }}
+ {{ SPI_FLASH_SPEED_NAMES[imageInfo.header.spiSpeed] ?? formatHex(imageInfo.header.spiSpeed) }}
+ {{ SPI_FLASH_SIZE_NAMES[imageInfo.header.spiSize] ?? formatHex(imageInfo.header.spiSize) }}
+ {{ imageInfo.header.segmentCount }}
+ {{ formatHex(imageInfo.extendedHeader.wpPin) }}
+ {{ imageInfo.extendedHeader.minChipRevFull / 100 }}
+ {{ imageInfo.extendedHeader.maxChipRevFull === 0xFFFF ? '不限' : imageInfo.extendedHeader.maxChipRevFull / 100 }}
+ {{ imageInfo.extendedHeader.hashAppended ? '是' : '否' }}
+
+
+
+ 段列表
+
+
+
+ {{ formatHex(row.loadAddr) }}
+
+
+
+ {{ row.dataLen }} 字节 ({{ (row.dataLen / 1024).toFixed(1) }} KB)
+
+
+
+
+
+
+
+
+
+
diff --git a/lib/app-image/constants.ts b/lib/app-image/constants.ts
new file mode 100644
index 0000000..95126a1
--- /dev/null
+++ b/lib/app-image/constants.ts
@@ -0,0 +1,32 @@
+/** Magic byte at start of image header */
+export const IMAGE_MAGIC = 0xE9;
+
+/** Image header size in bytes (magic, segment_count, spi_mode, spi_speed_size, entry_point, padding) */
+export const IMAGE_HEADER_SIZE = 8;
+
+/** Extended header size in bytes */
+export const EXTENDED_HEADER_SIZE = 16;
+
+/** Segment header size in bytes */
+export const SEGMENT_HEADER_SIZE = 8;
+
+/** Magic word for esp_app_desc_t */
+export const APP_DESC_MAGIC = 0xABCD5432;
+
+/** Size of esp_app_desc_t structure */
+export const APP_DESC_SIZE = 256;
+
+/** Chip ID to human-readable name */
+export const CHIP_ID_NAMES: Record = {
+ 0x0000: 'ESP32',
+ 0x0002: 'ESP32-S2',
+ 0x0005: 'ESP32-C3',
+ 0x0009: 'ESP32-S3',
+ 0x000C: 'ESP32-C2',
+ 0x000D: 'ESP32-C6',
+ 0x0010: 'ESP32-H2',
+ 0x0012: 'ESP32-P4',
+ 0x0011: 'ESP32-C61',
+ 0x0014: 'ESP32-C5',
+ 0x0016: 'ESP32-H4',
+};
diff --git a/lib/app-image/index.ts b/lib/app-image/index.ts
new file mode 100644
index 0000000..8f44b64
--- /dev/null
+++ b/lib/app-image/index.ts
@@ -0,0 +1,20 @@
+// Types
+export type {
+ ImageHeader, ExtendedHeader, SegmentHeader,
+ AppDescription, AppImageInfo,
+} from './types';
+
+export {
+ SpiFlashMode, SpiFlashSpeed, SpiFlashSize,
+ SPI_FLASH_MODE_NAMES, SPI_FLASH_SPEED_NAMES, SPI_FLASH_SIZE_NAMES,
+} from './types';
+
+// Constants
+export {
+ IMAGE_MAGIC, IMAGE_HEADER_SIZE, EXTENDED_HEADER_SIZE,
+ SEGMENT_HEADER_SIZE, APP_DESC_MAGIC, APP_DESC_SIZE,
+ CHIP_ID_NAMES,
+} from './constants';
+
+// Parser
+export { parseAppImage } from './parser';
diff --git a/lib/app-image/parser.ts b/lib/app-image/parser.ts
new file mode 100644
index 0000000..88a1a1f
--- /dev/null
+++ b/lib/app-image/parser.ts
@@ -0,0 +1,104 @@
+import { readU8, readU16, readU32, readNullTermString } from '../shared/binary-reader';
+import {
+ AppImageInfo, ImageHeader, ExtendedHeader, SegmentHeader, AppDescription,
+} from './types';
+import {
+ IMAGE_MAGIC, IMAGE_HEADER_SIZE, EXTENDED_HEADER_SIZE,
+ SEGMENT_HEADER_SIZE, APP_DESC_MAGIC, APP_DESC_SIZE, CHIP_ID_NAMES,
+} from './constants';
+
+/**
+ * Parse an ESP32 app image binary.
+ * Read-only parser — extracts headers, segment info, and app description.
+ *
+ * @param data Raw binary data of the app image
+ */
+export function parseAppImage(data: Uint8Array): AppImageInfo {
+ if (data.length < IMAGE_HEADER_SIZE + EXTENDED_HEADER_SIZE) {
+ throw new Error(`数据太短: ${data.length} 字节 (最少需要 ${IMAGE_HEADER_SIZE + EXTENDED_HEADER_SIZE} 字节)`);
+ }
+
+ // ── Image Header (8 bytes: magic + segments + spi_mode + spi_speed_size + entry_addr) ──
+ const magic = readU8(data, 0);
+ if (magic !== IMAGE_MAGIC) {
+ throw new Error(`无效的魔数: 0x${magic.toString(16)} (应为 0xE9)`);
+ }
+
+ const segmentCount = readU8(data, 1);
+ const spiModeRaw = readU8(data, 2);
+ const spiSpeedSize = readU8(data, 3);
+ const entryPoint = readU32(data, 4);
+
+ const header: ImageHeader = {
+ magic,
+ segmentCount,
+ spiMode: spiModeRaw,
+ spiSpeed: spiSpeedSize & 0x0F,
+ spiSize: (spiSpeedSize >> 4) & 0x0F,
+ entryPoint,
+ };
+
+ // ── Extended Header (16 bytes at offset 8) ──
+ const extOff = IMAGE_HEADER_SIZE;
+ const wpPin = readU8(data, extOff); // +0 = offset 8
+ const chipId = readU16(data, extOff + 4); // +4 = offset 12
+ const minChipRev = readU8(data, extOff + 6); // +6 = offset 14
+ const minChipRevFull = readU16(data, extOff + 7); // +7 = offset 15 (packed)
+ const maxChipRevFull = readU16(data, extOff + 9); // +9 = offset 17 (packed)
+ const hashAppended = readU8(data, extOff + 15) === 1; // +15 = offset 23
+
+ const extendedHeader: ExtendedHeader = {
+ wpPin,
+ spiPinDrv: [readU8(data, extOff + 1), readU8(data, extOff + 2), readU8(data, extOff + 3)],
+ chipId,
+ minChipRev,
+ minChipRevFull,
+ maxChipRevFull,
+ hashAppended,
+ };
+
+ // ── Segment Headers ──
+ const segments: SegmentHeader[] = [];
+ // Track each segment's data start offset internally (not exposed in public API)
+ const segDataOffsets: number[] = [];
+ let segOff = IMAGE_HEADER_SIZE + EXTENDED_HEADER_SIZE;
+
+ for (let i = 0; i < segmentCount && segOff + SEGMENT_HEADER_SIZE <= data.length; i++) {
+ const loadAddr = readU32(data, segOff);
+ const dataLen = readU32(data, segOff + 4);
+ const dataOffset = segOff + SEGMENT_HEADER_SIZE;
+ if (dataOffset + dataLen > data.length) break; // truncated image
+ segments.push({ loadAddr, dataLen });
+ segDataOffsets.push(dataOffset);
+ segOff = dataOffset + dataLen; // advance past segment data
+ }
+
+ // ── App Description — scan all segments for APP_DESC_MAGIC ──
+ let appDescription: AppDescription | null = null;
+ for (const off of segDataOffsets) {
+ if (off + 4 > data.length) continue;
+ const descMagic = readU32(data, off);
+ if (descMagic === APP_DESC_MAGIC && off + APP_DESC_SIZE <= data.length) {
+ appDescription = {
+ magicWord: descMagic,
+ secureVersion: readU32(data, off + 4),
+ version: readNullTermString(data, off + 16, 32),
+ projectName: readNullTermString(data, off + 48, 32),
+ compileTime: readNullTermString(data, off + 80, 16),
+ compileDate: readNullTermString(data, off + 96, 16),
+ idfVersion: readNullTermString(data, off + 112, 32),
+ appElfSha256: new Uint8Array(data.subarray(off + 144, off + 176)),
+ };
+ break;
+ }
+ }
+
+ return {
+ header,
+ extendedHeader,
+ segments,
+ appDescription,
+ valid: segments.length === segmentCount, // false if image was truncated mid-segment
+ chipName: CHIP_ID_NAMES[chipId] ?? `Unknown (0x${chipId.toString(16)})`,
+ };
+}
diff --git a/lib/app-image/types.ts b/lib/app-image/types.ts
new file mode 100644
index 0000000..274cc9d
--- /dev/null
+++ b/lib/app-image/types.ts
@@ -0,0 +1,93 @@
+export enum SpiFlashMode {
+ QIO = 0,
+ QOUT = 1,
+ DIO = 2,
+ DOUT = 3,
+}
+
+export enum SpiFlashSpeed {
+ SPEED_40M = 0x0,
+ SPEED_26M = 0x1,
+ SPEED_20M = 0x2,
+ SPEED_80M = 0xF,
+}
+
+export enum SpiFlashSize {
+ SIZE_1MB = 0x0,
+ SIZE_2MB = 0x1,
+ SIZE_4MB = 0x2,
+ SIZE_8MB = 0x3,
+ SIZE_16MB = 0x4,
+ SIZE_32MB = 0x5,
+ SIZE_64MB = 0x6,
+ SIZE_128MB = 0x7,
+}
+
+export interface ImageHeader {
+ magic: number;
+ segmentCount: number;
+ spiMode: SpiFlashMode;
+ spiSpeed: SpiFlashSpeed;
+ spiSize: SpiFlashSize;
+ entryPoint: number;
+}
+
+export interface ExtendedHeader {
+ wpPin: number;
+ spiPinDrv: [number, number, number];
+ chipId: number;
+ minChipRev: number;
+ minChipRevFull: number;
+ maxChipRevFull: number;
+ hashAppended: boolean;
+}
+
+export interface SegmentHeader {
+ loadAddr: number;
+ dataLen: number;
+}
+
+export interface AppDescription {
+ magicWord: number;
+ secureVersion: number;
+ version: string;
+ projectName: string;
+ compileTime: string;
+ compileDate: string;
+ idfVersion: string;
+ appElfSha256: Uint8Array;
+}
+
+export interface AppImageInfo {
+ header: ImageHeader;
+ extendedHeader: ExtendedHeader;
+ segments: SegmentHeader[];
+ appDescription: AppDescription | null;
+ valid: boolean;
+ chipName: string;
+}
+
+export const SPI_FLASH_MODE_NAMES: Record = {
+ [SpiFlashMode.QIO]: 'QIO',
+ [SpiFlashMode.QOUT]: 'QOUT',
+ [SpiFlashMode.DIO]: 'DIO',
+ [SpiFlashMode.DOUT]: 'DOUT',
+};
+
+export const SPI_FLASH_SPEED_NAMES: Record = {
+ [SpiFlashSpeed.SPEED_40M]: '40MHz',
+ [SpiFlashSpeed.SPEED_26M]: '26MHz',
+ [SpiFlashSpeed.SPEED_20M]: '20MHz',
+ [SpiFlashSpeed.SPEED_80M]: '80MHz',
+};
+
+export const SPI_FLASH_SIZE_NAMES: Record = {
+ [SpiFlashSize.SIZE_1MB]: '1MB',
+ [SpiFlashSize.SIZE_2MB]: '2MB',
+ [SpiFlashSize.SIZE_4MB]: '4MB',
+ [SpiFlashSize.SIZE_8MB]: '8MB',
+ [SpiFlashSize.SIZE_16MB]: '16MB',
+ [SpiFlashSize.SIZE_32MB]: '32MB',
+ [SpiFlashSize.SIZE_64MB]: '64MB',
+ [SpiFlashSize.SIZE_128MB]: '128MB',
+};
diff --git a/lib/shared/binary-reader.ts b/lib/shared/binary-reader.ts
new file mode 100644
index 0000000..6c3c90e
--- /dev/null
+++ b/lib/shared/binary-reader.ts
@@ -0,0 +1,46 @@
+// ── Little-endian read helpers ─────────────────────────────────────
+
+export function readU8(buf: Uint8Array, off: number): number {
+ return buf[off];
+}
+
+export function readU16(buf: Uint8Array, off: number): number {
+ return buf[off] | (buf[off + 1] << 8);
+}
+
+export function readU32(buf: Uint8Array, off: number): number {
+ return (buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24)) >>> 0;
+}
+
+export function readI8(buf: Uint8Array, off: number): number {
+ const v = buf[off];
+ return v > 127 ? v - 256 : v;
+}
+
+export function readI16(buf: Uint8Array, off: number): number {
+ const v = readU16(buf, off);
+ return v > 32767 ? v - 65536 : v;
+}
+
+export function readI32(buf: Uint8Array, off: number): number {
+ return buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24);
+}
+
+export function readU64(buf: Uint8Array, off: number): bigint {
+ const lo = BigInt(readU32(buf, off));
+ const hi = BigInt(readU32(buf, off + 4));
+ return (hi << 32n) | lo;
+}
+
+export function readI64(buf: Uint8Array, off: number): bigint {
+ const v = readU64(buf, off);
+ return v > 0x7FFFFFFFFFFFFFFFn ? v - 0x10000000000000000n : v;
+}
+
+/** Read null-terminated ASCII string of max `maxLen` bytes */
+export function readNullTermString(buf: Uint8Array, off: number, maxLen: number): string {
+ let end = off;
+ while (end < off + maxLen && buf[end] !== 0) end++;
+ const decoder = new TextDecoder('ascii');
+ return decoder.decode(buf.subarray(off, end));
+}
diff --git a/lib/shared/binary-writer.ts b/lib/shared/binary-writer.ts
new file mode 100644
index 0000000..a586dd9
--- /dev/null
+++ b/lib/shared/binary-writer.ts
@@ -0,0 +1,50 @@
+// ── Little-endian write helpers ────────────────────────────────────
+
+export function writeU8(buf: Uint8Array, off: number, val: number) {
+ buf[off] = val & 0xFF;
+}
+
+export function writeU16(buf: Uint8Array, off: number, val: number) {
+ buf[off] = val & 0xFF;
+ buf[off + 1] = (val >> 8) & 0xFF;
+}
+
+export function writeU32(buf: Uint8Array, off: number, val: number) {
+ buf[off] = val & 0xFF;
+ buf[off + 1] = (val >> 8) & 0xFF;
+ buf[off + 2] = (val >> 16) & 0xFF;
+ buf[off + 3] = (val >> 24) & 0xFF;
+}
+
+export function writeI8(buf: Uint8Array, off: number, val: number) {
+ buf[off] = val < 0 ? val + 256 : val;
+}
+
+export function writeI16(buf: Uint8Array, off: number, val: number) {
+ const u = val < 0 ? val + 65536 : val;
+ writeU16(buf, off, u);
+}
+
+export function writeI32(buf: Uint8Array, off: number, val: number) {
+ writeU32(buf, off, val < 0 ? val + 0x100000000 : val);
+}
+
+export function writeU64(buf: Uint8Array, off: number, val: bigint) {
+ const lo = Number(val & 0xFFFFFFFFn);
+ const hi = Number((val >> 32n) & 0xFFFFFFFFn);
+ writeU32(buf, off, lo);
+ writeU32(buf, off + 4, hi);
+}
+
+export function writeI64(buf: Uint8Array, off: number, val: bigint) {
+ const u = val < 0n ? val + 0x10000000000000000n : val;
+ writeU64(buf, off, u);
+}
+
+/** Write null-terminated ASCII string padded to `fieldSize` bytes */
+export function writeNullTermString(buf: Uint8Array, off: number, str: string, fieldSize: number) {
+ buf.fill(0, off, off + fieldSize);
+ const encoder = new TextEncoder();
+ const strBytes = encoder.encode(str);
+ buf.set(strBytes.subarray(0, fieldSize - 1), off); // leave room for null
+}
diff --git a/lib/shared/crc32.ts b/lib/shared/crc32.ts
new file mode 100644
index 0000000..95da4f9
--- /dev/null
+++ b/lib/shared/crc32.ts
@@ -0,0 +1,24 @@
+/**
+ * Pre-computed CRC32 lookup table using reflected polynomial 0xEDB88320.
+ * Compatible with zlib.crc32() used by ESP-IDF.
+ */
+const CRC32_TABLE: Uint32Array = (() => {
+ const table = new Uint32Array(256);
+ for (let i = 0; i < 256; i++) {
+ let crc = i;
+ for (let j = 0; j < 8; j++) {
+ crc = (crc & 1) ? ((crc >>> 1) ^ 0xEDB88320) : (crc >>> 1);
+ }
+ table[i] = crc;
+ }
+ return table;
+})();
+
+/** Compute CRC32 of a Uint8Array. Returns unsigned 32-bit integer. */
+export function crc32(data: Uint8Array): number {
+ let crc = 0xFFFFFFFF;
+ for (let i = 0; i < data.length; i++) {
+ crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ data[i]) & 0xFF];
+ }
+ return (crc ^ 0xFFFFFFFF) >>> 0;
+}
diff --git a/lib/shared/index.ts b/lib/shared/index.ts
new file mode 100644
index 0000000..ffe1733
--- /dev/null
+++ b/lib/shared/index.ts
@@ -0,0 +1,3 @@
+export * from './binary-reader';
+export * from './binary-writer';
+export { crc32 } from './crc32';