From 235b5e170a40ffc5c9601ff663d14c4fd1b3a084 Mon Sep 17 00:00:00 2001 From: kerms Date: Mon, 9 Mar 2026 18:18:22 +0100 Subject: [PATCH 1/6] feat: display SPI pin drive strength and custom app desc raw bytes --- components/app-image-viewer/AppImageViewer.vue | 13 +++++++++++++ lib/app-image/constants.ts | 6 ++++++ lib/app-image/parser.ts | 11 +++++++++++ lib/app-image/types.ts | 2 ++ 4 files changed, 32 insertions(+) diff --git a/components/app-image-viewer/AppImageViewer.vue b/components/app-image-viewer/AppImageViewer.vue index 30c9735..5f69864 100644 --- a/components/app-image-viewer/AppImageViewer.vue +++ b/components/app-image-viewer/AppImageViewer.vue @@ -34,6 +34,10 @@ function formatHex(val: number): string { return '0x' + val.toString(16).toUpperCase(); } +function formatHexDump(data: Uint8Array): string { + return Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(' '); +} + function formatSha256(data: Uint8Array): string { // Check if all zeros (not computed) if (data.every(b => b === 0)) return '(未计算)'; @@ -110,6 +114,7 @@ async function handleOpenFile(file: File): Promise { {{ SPI_FLASH_SIZE_NAMES[imageInfo.header.spiSize] ?? formatHex(imageInfo.header.spiSize) }} {{ imageInfo.header.segmentCount }} {{ formatHex(imageInfo.extendedHeader.wpPin) }} + {{ imageInfo.extendedHeader.spiPinDrv.map(formatHex).join(' / ') }} {{ imageInfo.extendedHeader.minChipRevFull / 100 }} {{ imageInfo.extendedHeader.maxChipRevFull === 0xFFFF ? '不限' : imageInfo.extendedHeader.maxChipRevFull / 100 }} {{ imageInfo.extendedHeader.hashAppended ? '是' : '否' }} @@ -128,6 +133,14 @@ async function handleOpenFile(file: File): Promise { + + + diff --git a/lib/app-image/constants.ts b/lib/app-image/constants.ts index 95126a1..f85cabe 100644 --- a/lib/app-image/constants.ts +++ b/lib/app-image/constants.ts @@ -16,6 +16,12 @@ export const APP_DESC_MAGIC = 0xABCD5432; /** Size of esp_app_desc_t structure */ export const APP_DESC_SIZE = 256; +/** Offset of custom app desc within first segment data (immediately after esp_app_desc_t) */ +export const CUSTOM_DESC_OFFSET_IN_SEGMENT = APP_DESC_SIZE; // 256 + +/** How many raw bytes to extract for the custom app desc dump */ +export const CUSTOM_DESC_DUMP_SIZE = 64; + /** Chip ID to human-readable name */ export const CHIP_ID_NAMES: Record = { 0x0000: 'ESP32', diff --git a/lib/app-image/parser.ts b/lib/app-image/parser.ts index a401740..8604822 100644 --- a/lib/app-image/parser.ts +++ b/lib/app-image/parser.ts @@ -5,6 +5,7 @@ import type { import { IMAGE_MAGIC, IMAGE_HEADER_SIZE, EXTENDED_HEADER_SIZE, SEGMENT_HEADER_SIZE, APP_DESC_MAGIC, APP_DESC_SIZE, CHIP_ID_NAMES, + CUSTOM_DESC_OFFSET_IN_SEGMENT, CUSTOM_DESC_DUMP_SIZE, } from './constants'; /** @@ -93,11 +94,21 @@ export function parseAppImage(data: Uint8Array): AppImageInfo { } } + // ── Custom App Description — fixed offset in first segment ── + let customDescRawBytes: Uint8Array | null = null; + if (segDataOffsets.length > 0) { + const customOff = segDataOffsets[0] + CUSTOM_DESC_OFFSET_IN_SEGMENT; + if (customOff + CUSTOM_DESC_DUMP_SIZE <= data.length) { + customDescRawBytes = new Uint8Array(data.subarray(customOff, customOff + CUSTOM_DESC_DUMP_SIZE)); + } + } + return { header, extendedHeader, segments, appDescription, + customDescRawBytes, 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 index 274cc9d..4367269 100644 --- a/lib/app-image/types.ts +++ b/lib/app-image/types.ts @@ -63,6 +63,8 @@ export interface AppImageInfo { extendedHeader: ExtendedHeader; segments: SegmentHeader[]; appDescription: AppDescription | null; + /** Raw bytes at the custom app desc location (null if first segment too short) */ + customDescRawBytes: Uint8Array | null; valid: boolean; chipName: string; } From 9b572bb9d393ff8415110d88beee20523fa829cc Mon Sep 17 00:00:00 2001 From: kerms Date: Tue, 10 Mar 2026 12:59:46 +0100 Subject: [PATCH 2/6] feat: add hex dump viewer, show appended SHA256, remove custom app desc --- .../app-image-viewer/AppImageViewer.vue | 33 ++++---- components/app-image-viewer/HexDump.vue | 77 +++++++++++++++++++ lib/app-image/constants.ts | 6 -- lib/app-image/parser.ts | 11 --- lib/app-image/types.ts | 2 - 5 files changed, 97 insertions(+), 32 deletions(-) create mode 100644 components/app-image-viewer/HexDump.vue diff --git a/components/app-image-viewer/AppImageViewer.vue b/components/app-image-viewer/AppImageViewer.vue index 5f69864..a40e2a6 100644 --- a/components/app-image-viewer/AppImageViewer.vue +++ b/components/app-image-viewer/AppImageViewer.vue @@ -1,16 +1,19 @@ + + + + diff --git a/lib/app-image/constants.ts b/lib/app-image/constants.ts index f85cabe..95126a1 100644 --- a/lib/app-image/constants.ts +++ b/lib/app-image/constants.ts @@ -16,12 +16,6 @@ export const APP_DESC_MAGIC = 0xABCD5432; /** Size of esp_app_desc_t structure */ export const APP_DESC_SIZE = 256; -/** Offset of custom app desc within first segment data (immediately after esp_app_desc_t) */ -export const CUSTOM_DESC_OFFSET_IN_SEGMENT = APP_DESC_SIZE; // 256 - -/** How many raw bytes to extract for the custom app desc dump */ -export const CUSTOM_DESC_DUMP_SIZE = 64; - /** Chip ID to human-readable name */ export const CHIP_ID_NAMES: Record = { 0x0000: 'ESP32', diff --git a/lib/app-image/parser.ts b/lib/app-image/parser.ts index 8604822..a401740 100644 --- a/lib/app-image/parser.ts +++ b/lib/app-image/parser.ts @@ -5,7 +5,6 @@ import type { import { IMAGE_MAGIC, IMAGE_HEADER_SIZE, EXTENDED_HEADER_SIZE, SEGMENT_HEADER_SIZE, APP_DESC_MAGIC, APP_DESC_SIZE, CHIP_ID_NAMES, - CUSTOM_DESC_OFFSET_IN_SEGMENT, CUSTOM_DESC_DUMP_SIZE, } from './constants'; /** @@ -94,21 +93,11 @@ export function parseAppImage(data: Uint8Array): AppImageInfo { } } - // ── Custom App Description — fixed offset in first segment ── - let customDescRawBytes: Uint8Array | null = null; - if (segDataOffsets.length > 0) { - const customOff = segDataOffsets[0] + CUSTOM_DESC_OFFSET_IN_SEGMENT; - if (customOff + CUSTOM_DESC_DUMP_SIZE <= data.length) { - customDescRawBytes = new Uint8Array(data.subarray(customOff, customOff + CUSTOM_DESC_DUMP_SIZE)); - } - } - return { header, extendedHeader, segments, appDescription, - customDescRawBytes, 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 index 4367269..274cc9d 100644 --- a/lib/app-image/types.ts +++ b/lib/app-image/types.ts @@ -63,8 +63,6 @@ export interface AppImageInfo { extendedHeader: ExtendedHeader; segments: SegmentHeader[]; appDescription: AppDescription | null; - /** Raw bytes at the custom app desc location (null if first segment too short) */ - customDescRawBytes: Uint8Array | null; valid: boolean; chipName: string; } From 0e21d6cb9920d4616fcc4ec54c80a72984795263 Mon Sep 17 00:00:00 2001 From: kerms Date: Tue, 10 Mar 2026 13:17:41 +0100 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20add=20field-range=20map=20for=20hex?= =?UTF-8?q?=20=E2=86=94=20field=20highlighting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app-image/index.ts | 4 ++ lib/app-image/ranges.ts | 151 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 lib/app-image/ranges.ts diff --git a/lib/app-image/index.ts b/lib/app-image/index.ts index 8f44b64..3fdb22b 100644 --- a/lib/app-image/index.ts +++ b/lib/app-image/index.ts @@ -18,3 +18,7 @@ export { // Parser export { parseAppImage } from './parser'; + +// Ranges (field ↔ hex highlighting) +export { computeFieldRanges } from './ranges'; +export type { FieldDef, FieldGroup } from './ranges'; diff --git a/lib/app-image/ranges.ts b/lib/app-image/ranges.ts new file mode 100644 index 0000000..e23e0da --- /dev/null +++ b/lib/app-image/ranges.ts @@ -0,0 +1,151 @@ +import { readU32 } from '../shared/binary-reader'; +import { + IMAGE_HEADER_SIZE, EXTENDED_HEADER_SIZE, SEGMENT_HEADER_SIZE, + APP_DESC_MAGIC, APP_DESC_SIZE, +} from './constants'; +import { + SPI_FLASH_MODE_NAMES, SPI_FLASH_SPEED_NAMES, SPI_FLASH_SIZE_NAMES, +} from './types'; +import type { AppImageInfo } from './types'; + +export interface FieldDef { + label: string; + start: number; + end: number; // exclusive + value: string; +} + +export interface FieldGroup { + label: string; + start: number; + end: number; // exclusive + fields: FieldDef[]; +} + +function h(n: number, pad = 2): string { + return '0x' + n.toString(16).toUpperCase().padStart(pad, '0'); +} + +export function computeFieldRanges(data: Uint8Array, info: AppImageInfo): FieldGroup[] { + const groups: FieldGroup[] = []; + + // ── Image Header (bytes 0–7) ── + const spiSpeedSize = data[3]; + groups.push({ + label: '镜像头 (Image Header)', + start: 0, + end: IMAGE_HEADER_SIZE, + fields: [ + { label: 'magic', start: 0, end: 1, value: h(info.header.magic) }, + { label: 'segmentCount', start: 1, end: 2, value: String(info.header.segmentCount) }, + { label: 'spiMode', start: 2, end: 3, value: SPI_FLASH_MODE_NAMES[info.header.spiMode] ?? h(info.header.spiMode) }, + { + label: 'spiSpeed / spiSize', + start: 3, + end: 4, + value: `${SPI_FLASH_SPEED_NAMES[info.header.spiSpeed] ?? h(spiSpeedSize & 0x0F)} / ${SPI_FLASH_SIZE_NAMES[info.header.spiSize] ?? h((spiSpeedSize >> 4) & 0x0F)}`, + }, + { label: 'entryPoint', start: 4, end: 8, value: h(info.header.entryPoint, 8) }, + ], + }); + + // ── Extended Header (bytes 8–23) ── + const extOff = IMAGE_HEADER_SIZE; + groups.push({ + label: '扩展头 (Extended Header)', + start: extOff, + end: extOff + EXTENDED_HEADER_SIZE, + fields: [ + { label: 'wpPin', start: extOff, end: extOff + 1, value: h(info.extendedHeader.wpPin) }, + { label: 'spiPinDrv[0]', start: extOff + 1, end: extOff + 2, value: h(info.extendedHeader.spiPinDrv[0]) }, + { label: 'spiPinDrv[1]', start: extOff + 2, end: extOff + 3, value: h(info.extendedHeader.spiPinDrv[1]) }, + { label: 'spiPinDrv[2]', start: extOff + 3, end: extOff + 4, value: h(info.extendedHeader.spiPinDrv[2]) }, + { label: 'chipId', start: extOff + 4, end: extOff + 6, value: `${h(info.extendedHeader.chipId, 4)} (${info.chipName})` }, + { label: 'minChipRev', start: extOff + 6, end: extOff + 7, value: h(info.extendedHeader.minChipRev) }, + { label: 'minChipRevFull',start: extOff + 7, end: extOff + 9, value: String(info.extendedHeader.minChipRevFull / 100) }, + { label: 'maxChipRevFull',start: extOff + 9, end: extOff + 11, value: info.extendedHeader.maxChipRevFull === 0xFFFF ? '不限' : String(info.extendedHeader.maxChipRevFull / 100) }, + { label: 'reserved', start: extOff + 11, end: extOff + 15, value: '(reserved)' }, + { label: 'hashAppended', start: extOff + 15, end: extOff + 16, value: info.extendedHeader.hashAppended ? '是' : '否' }, + ], + }); + + // ── Segments ── + let segOff = IMAGE_HEADER_SIZE + EXTENDED_HEADER_SIZE; + for (let i = 0; i < info.segments.length; i++) { + const seg = info.segments[i]; + const hdrEnd = segOff + SEGMENT_HEADER_SIZE; + const dataEnd = hdrEnd + seg.dataLen; + + groups.push({ + label: `段 ${i} 头 (Segment ${i} Header)`, + start: segOff, + end: hdrEnd, + fields: [ + { label: 'loadAddr', start: segOff, end: segOff + 4, value: h(seg.loadAddr, 8) }, + { label: 'dataLen', start: segOff + 4, end: hdrEnd, value: `${seg.dataLen} 字节` }, + ], + }); + + groups.push({ + label: `段 ${i} 数据 (Segment ${i} Data)`, + start: hdrEnd, + end: dataEnd, + fields: [], + }); + + segOff = dataEnd; + } + + // ── App Description — walk segments to find the offset ── + if (info.appDescription) { + let appDescOff: number | null = null; + let scanOff = IMAGE_HEADER_SIZE + EXTENDED_HEADER_SIZE; + for (const seg of info.segments) { + const dataOff = scanOff + SEGMENT_HEADER_SIZE; + if (dataOff + 4 <= data.length && readU32(data, dataOff) === APP_DESC_MAGIC) { + appDescOff = dataOff; + break; + } + scanOff = dataOff + seg.dataLen; + } + + if (appDescOff !== null) { + const o = appDescOff; + const d = info.appDescription; + groups.push({ + label: '应用信息 (App Description)', + start: o, + end: o + APP_DESC_SIZE, + fields: [ + { label: 'magicWord', start: o, end: o + 4, value: h(d.magicWord, 8) }, + { label: 'secureVersion', start: o + 4, end: o + 8, value: String(d.secureVersion) }, + { label: 'version', start: o + 16, end: o + 48, value: d.version }, + { label: 'projectName', start: o + 48, end: o + 80, value: d.projectName }, + { label: 'compileTime', start: o + 80, end: o + 96, value: d.compileTime }, + { label: 'compileDate', start: o + 96, end: o + 112, value: d.compileDate }, + { label: 'idfVersion', start: o + 112, end: o + 144, value: d.idfVersion }, + { label: 'appElfSha256', start: o + 144, end: o + 176, value: Array.from(d.appElfSha256).map(b => b.toString(16).padStart(2, '0')).join('') }, + ], + }); + } + } + + // ── Appended SHA256 ── + if (info.extendedHeader.hashAppended && data.length >= 32) { + groups.push({ + label: '附加 SHA256 (Appended Hash)', + start: data.length - 32, + end: data.length, + fields: [ + { + label: 'sha256', + start: data.length - 32, + end: data.length, + value: Array.from(data.slice(-32)).map(b => b.toString(16).padStart(2, '0')).join(''), + }, + ], + }); + } + + return groups; +} From f7fbf57095037e5c5da1f747ec6fce47806979eb Mon Sep 17 00:00:00 2001 From: kerms Date: Tue, 10 Mar 2026 13:17:47 +0100 Subject: [PATCH 4/6] feat: Wireshark-style hex/field bidirectional highlighting --- .../app-image-viewer/AppImageViewer.vue | 45 ++++- components/app-image-viewer/HexDump.vue | 68 ++++++- components/app-image-viewer/HexFieldPanel.vue | 169 ++++++++++++++++++ 3 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 components/app-image-viewer/HexFieldPanel.vue diff --git a/components/app-image-viewer/AppImageViewer.vue b/components/app-image-viewer/AppImageViewer.vue index a40e2a6..ae2b2b4 100644 --- a/components/app-image-viewer/AppImageViewer.vue +++ b/components/app-image-viewer/AppImageViewer.vue @@ -5,7 +5,9 @@ import { SPI_FLASH_MODE_NAMES, SPI_FLASH_SPEED_NAMES, SPI_FLASH_SIZE_NAMES, parseAppImage, } from '../../lib/app-image'; +import { computeFieldRanges, type FieldGroup } from '../../lib/app-image/ranges'; import HexDump from './HexDump.vue'; +import HexFieldPanel from './HexFieldPanel.vue'; const props = defineProps<{ isDark?: boolean; @@ -17,6 +19,9 @@ const showHex = ref(false); const statusMessage = ref(''); const statusType = ref<'success' | 'error' | 'info'>('info'); const fileName = ref(''); +const fieldGroups = ref([]); +const activeRange = ref<{ start: number; end: number } | null>(null); +const hexDumpRef = ref | null>(null); let statusTimer: ReturnType | null = null; @@ -43,6 +48,27 @@ function formatSha256(data: Uint8Array): string { return Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(''); } +function onByteHover(offset: number | null) { + if (offset === null) { activeRange.value = null; return; } + // Pass 1: named field match (checked across all groups first) + for (const g of fieldGroups.value) { + const f = g.fields.find(f => offset >= f.start && offset < f.end); + if (f) { activeRange.value = { start: f.start, end: f.end }; return; } + } + // Pass 2: data-only group (segment data blobs) + for (const g of fieldGroups.value) { + if (g.fields.length === 0 && offset >= g.start && offset < g.end) { + activeRange.value = { start: g.start, end: g.end }; return; + } + } + activeRange.value = null; +} + +function onFieldSelect(range: { start: number; end: number }) { + activeRange.value = range; + hexDumpRef.value?.scrollTo(range.start); +} + async function handleOpenFile(file: File): Promise { try { const buffer = await file.arrayBuffer(); @@ -53,6 +79,8 @@ async function handleOpenFile(file: File): Promise { } imageInfo.value = parseAppImage(data); rawData.value = data; + fieldGroups.value = computeFieldRanges(data, imageInfo.value); + activeRange.value = null; showHex.value = false; fileName.value = file.name; showStatus(`已加载 ${file.name} (${data.byteLength} 字节)`, 'success'); @@ -146,7 +174,22 @@ async function handleOpenFile(file: File): Promise { {{ showHex ? '隐藏原始字节' : '查看原始字节' }} - + diff --git a/components/app-image-viewer/HexDump.vue b/components/app-image-viewer/HexDump.vue index ffec08b..2e0dece 100644 --- a/components/app-image-viewer/HexDump.vue +++ b/components/app-image-viewer/HexDump.vue @@ -1,11 +1,20 @@