esp32tool 1.6.6 → 1.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apple-touch-icon.png +0 -0
- package/css/style.css +325 -25
- package/dist/util/console-color.js +3 -3
- package/dist/util/timestamp-transformer.js +24 -1
- package/electron/cli-main.cjs +19 -19
- package/electron/main.cjs +167 -148
- package/electron/preload.js +16 -18
- package/icons/icon-128.png +0 -0
- package/icons/icon-144.png +0 -0
- package/icons/icon-152.png +0 -0
- package/icons/icon-192.png +0 -0
- package/icons/icon-384.png +0 -0
- package/icons/icon-512.png +0 -0
- package/icons/icon-72.png +0 -0
- package/icons/icon-96.png +0 -0
- package/js/console.js +21 -12
- package/js/hex-editor.js +216 -163
- package/js/improv.js +59 -21
- package/js/nvs-editor.js +1189 -182
- package/js/script.js +1048 -845
- package/js/util/console-color.js +3 -3
- package/js/util/timestamp-transformer.js +24 -1
- package/js/webusb-serial.js +1075 -950
- package/package.cli.json +2 -2
- package/package.json +11 -12
- package/screenshots/desktop.png +0 -0
- package/screenshots/mobile.png +0 -0
- package/src/util/console-color.ts +3 -3
- package/src/util/timestamp-transformer.ts +27 -1
- package/sw.js +1 -1
package/js/nvs-editor.js
CHANGED
|
@@ -12,7 +12,29 @@
|
|
|
12
12
|
* - Page state indicator
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { HexEditor } from
|
|
15
|
+
import { HexEditor } from "./hex-editor.js";
|
|
16
|
+
|
|
17
|
+
// ─────── NVS partition layout constants ───────
|
|
18
|
+
const NVS_SECTOR_SIZE = 4096;
|
|
19
|
+
const MAX_ENTRY_COUNT = 126;
|
|
20
|
+
const NVS_PAGE_STATE = {
|
|
21
|
+
UNINIT: 0xffffffff,
|
|
22
|
+
ACTIVE: 0xfffffffe,
|
|
23
|
+
FULL: 0xfffffffc,
|
|
24
|
+
FREEING: 0xfffffff8,
|
|
25
|
+
CORRUPT: 0xfffffff0,
|
|
26
|
+
};
|
|
27
|
+
const NVS_PAGE_STATE_NAME = {
|
|
28
|
+
[NVS_PAGE_STATE.UNINIT]: "UNINIT",
|
|
29
|
+
[NVS_PAGE_STATE.ACTIVE]: "ACTIVE",
|
|
30
|
+
[NVS_PAGE_STATE.FULL]: "FULL",
|
|
31
|
+
[NVS_PAGE_STATE.FREEING]: "FREEING",
|
|
32
|
+
[NVS_PAGE_STATE.CORRUPT]: "CORRUPT",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function pageStateName(stateValue) {
|
|
36
|
+
return NVS_PAGE_STATE_NAME[stateValue >>> 0] || "UNKNOWN";
|
|
37
|
+
}
|
|
16
38
|
|
|
17
39
|
export class NVSEditor {
|
|
18
40
|
/**
|
|
@@ -24,9 +46,9 @@ export class NVSEditor {
|
|
|
24
46
|
this.data = null;
|
|
25
47
|
/** @type {Uint8Array|null} original snapshot for diff */
|
|
26
48
|
this.originalData = null;
|
|
27
|
-
this.baseAddress = 0;
|
|
49
|
+
this.baseAddress = 0; // flash offset of the NVS partition
|
|
28
50
|
this.partitionSize = 0;
|
|
29
|
-
this.partitionName =
|
|
51
|
+
this.partitionName = "";
|
|
30
52
|
|
|
31
53
|
/** Parsed pages with items */
|
|
32
54
|
this.pages = [];
|
|
@@ -44,7 +66,7 @@ export class NVSEditor {
|
|
|
44
66
|
this._progressBarInner = null;
|
|
45
67
|
|
|
46
68
|
// Filter state
|
|
47
|
-
this._filterText =
|
|
69
|
+
this._filterText = "";
|
|
48
70
|
|
|
49
71
|
// Sub hex-editor for large entries
|
|
50
72
|
this._hexEditorInstance = null;
|
|
@@ -57,7 +79,7 @@ export class NVSEditor {
|
|
|
57
79
|
for (let i = 0; i < 8; i++) {
|
|
58
80
|
const bit = d & 1;
|
|
59
81
|
crc ^= bit;
|
|
60
|
-
crc =
|
|
82
|
+
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
|
61
83
|
d >>>= 1;
|
|
62
84
|
}
|
|
63
85
|
return crc >>> 0;
|
|
@@ -69,51 +91,596 @@ export class NVSEditor {
|
|
|
69
91
|
for (let i = 0; i < len; i++) {
|
|
70
92
|
crc = NVSEditor.crc32Byte(crc, data[offset + i]);
|
|
71
93
|
}
|
|
72
|
-
return
|
|
94
|
+
return ~crc >>> 0;
|
|
73
95
|
}
|
|
74
96
|
|
|
97
|
+
/** Entry header CRC: covers bytes [+0..+3] and [+8..+31], stored at [+4..+7]. */
|
|
75
98
|
static crc32Header(data, offset = 0) {
|
|
76
99
|
const buf = new Uint8Array(0x20 - 4);
|
|
77
100
|
buf.set(data.subarray(offset, offset + 4), 0);
|
|
78
101
|
buf.set(data.subarray(offset + 8, offset + 8 + 0x18), 4);
|
|
79
|
-
return NVSEditor.crc32(buf, 0,
|
|
102
|
+
return NVSEditor.crc32(buf, 0, 0x1c);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Page header CRC: covers bytes [+4..+27] (seqNum..reserved), stored at [+28..+31].
|
|
106
|
+
* State field [+0..+3] excluded — matches nvs_page.cpp Header::calculateCrc32(). */
|
|
107
|
+
static crc32PageHeader(data, offset = 0) {
|
|
108
|
+
return NVSEditor.crc32(data, offset + 4, 24);
|
|
80
109
|
}
|
|
81
110
|
|
|
82
|
-
static bytesToHex(bytes, separator =
|
|
111
|
+
static bytesToHex(bytes, separator = "") {
|
|
83
112
|
return Array.from(bytes)
|
|
84
|
-
.map(b => b.toString(16).padStart(2,
|
|
113
|
+
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
|
|
85
114
|
.join(separator);
|
|
86
115
|
}
|
|
87
116
|
|
|
117
|
+
// ─────── Blob helpers (from Berry nvs.be) ───────
|
|
118
|
+
|
|
119
|
+
static isBlob(item) {
|
|
120
|
+
return item.datatype === 0x42 || item.datatype === 0x48;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
static isBlobData(item) {
|
|
124
|
+
return item.datatype === 0x42;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
static blobKey(item) {
|
|
128
|
+
return item.key;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Namespace-qualified blob ID to avoid cross-namespace collisions. */
|
|
132
|
+
static getQualifiedBlobId(item) {
|
|
133
|
+
const ns = item.namespace || `ns_${item.nsIndex}`;
|
|
134
|
+
return `${ns}::${item.key}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
static blobTotalSize(item) {
|
|
138
|
+
if (item.datatype === 0x48 && item.totalSize !== undefined) {
|
|
139
|
+
return item.totalSize;
|
|
140
|
+
}
|
|
141
|
+
if (item.datatype === 0x42 && item.size !== undefined) {
|
|
142
|
+
return item.size;
|
|
143
|
+
}
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
static blobExpectedChunks(item) {
|
|
148
|
+
if (item.datatype === 0x48 && item.chunkCount !== undefined) {
|
|
149
|
+
return item.chunkCount;
|
|
150
|
+
}
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─────── Hex dump function (from Berry nvs.be) ───────
|
|
155
|
+
|
|
156
|
+
static hexDump(data, offset = 0) {
|
|
157
|
+
const width = 16;
|
|
158
|
+
let result = "";
|
|
159
|
+
|
|
160
|
+
for (let i = 0; i < data.length; i += width) {
|
|
161
|
+
const lineOffset = offset + i;
|
|
162
|
+
const hexLine = [];
|
|
163
|
+
const asciiLine = [];
|
|
164
|
+
|
|
165
|
+
for (let j = 0; j < width; j++) {
|
|
166
|
+
if (i + j < data.length) {
|
|
167
|
+
const byte = data[i + j];
|
|
168
|
+
hexLine.push(byte.toString(16).padStart(2, "0").toUpperCase());
|
|
169
|
+
asciiLine.push(
|
|
170
|
+
byte >= 32 && byte <= 126 ? String.fromCharCode(byte) : ".",
|
|
171
|
+
);
|
|
172
|
+
} else {
|
|
173
|
+
hexLine.push(" ");
|
|
174
|
+
asciiLine.push(" ");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
result += `${lineOffset.toString(16).padStart(6, "0").toUpperCase()} ${hexLine.join(" ")}\t ${asciiLine.join("")}\n`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─────── Integrity checking and statistics (from Berry nvs.be) ───────
|
|
185
|
+
|
|
186
|
+
getStatistics() {
|
|
187
|
+
const stats = {
|
|
188
|
+
pages_total: 0,
|
|
189
|
+
pages_active: 0,
|
|
190
|
+
pages_full: 0,
|
|
191
|
+
pages_empty: 0,
|
|
192
|
+
pages_freeing: 0,
|
|
193
|
+
pages_corrupted: 0,
|
|
194
|
+
pages_bad_header_crc: 0,
|
|
195
|
+
entries_written: 0,
|
|
196
|
+
entries_erased: 0,
|
|
197
|
+
entries_empty: 0,
|
|
198
|
+
entries_bad_header_crc: 0,
|
|
199
|
+
entries_bad_data_crc: 0,
|
|
200
|
+
blobs_complete: 0,
|
|
201
|
+
blobs_incomplete: 0,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// Build lookup from sector offset → parsed page for entry-level CRC checks.
|
|
205
|
+
const parsedPagesByOffset = new Map();
|
|
206
|
+
for (const p of this.pages) parsedPagesByOffset.set(p.offset, p);
|
|
207
|
+
|
|
208
|
+
for (let secOff = 0; secOff < this.data.length; secOff += NVS_SECTOR_SIZE) {
|
|
209
|
+
if (secOff + 64 > this.data.length) break;
|
|
210
|
+
stats.pages_total++;
|
|
211
|
+
const stateValue = this._u32(secOff);
|
|
212
|
+
const stateName = pageStateName(stateValue);
|
|
213
|
+
|
|
214
|
+
if (stateName === "ACTIVE") stats.pages_active++;
|
|
215
|
+
else if (stateName === "FULL") stats.pages_full++;
|
|
216
|
+
else if (stateName === "UNINIT") stats.pages_empty++;
|
|
217
|
+
else if (stateName === "FREEING") stats.pages_freeing++;
|
|
218
|
+
else if (stateName === "CORRUPT" || stateName === "UNKNOWN")
|
|
219
|
+
stats.pages_corrupted++;
|
|
220
|
+
|
|
221
|
+
// UNINIT sectors have no header CRC or entries.
|
|
222
|
+
if (stateName === "UNINIT") continue;
|
|
223
|
+
|
|
224
|
+
// Page header CRC (covers bytes +4..+27, stored at +28).
|
|
225
|
+
const pageCrcCalc = NVSEditor.crc32PageHeader(this.data, secOff);
|
|
226
|
+
const pageCrcStored = this._u32(secOff + 28);
|
|
227
|
+
if (pageCrcCalc !== pageCrcStored) stats.pages_bad_header_crc++;
|
|
228
|
+
|
|
229
|
+
// Corrupt/unknown pages have no parseable bitmap or entries.
|
|
230
|
+
if (stateName === "CORRUPT" || stateName === "UNKNOWN") continue;
|
|
231
|
+
|
|
232
|
+
// Iterate ALL slots in the page bitmap to count erased/empty/written.
|
|
233
|
+
// NVS bitmap encoding: 0b00=Erased, 0b10=Written, 0b11=Empty, 0b01=Invalid
|
|
234
|
+
const stateBitmap = this.data.slice(secOff + 32, secOff + 64);
|
|
235
|
+
for (let slotIndex = 0; slotIndex < MAX_ENTRY_COUNT; slotIndex++) {
|
|
236
|
+
const slotState = this._getNVSItemState(stateBitmap, slotIndex);
|
|
237
|
+
if (slotState === 0) stats.entries_erased++;
|
|
238
|
+
else if (slotState === 2) stats.entries_written++;
|
|
239
|
+
else if (slotState === 3) stats.entries_empty++;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Check entry header / data CRC for parsed (WRITTEN) items.
|
|
243
|
+
const parsedPage = parsedPagesByOffset.get(secOff);
|
|
244
|
+
if (parsedPage) {
|
|
245
|
+
for (const item of parsedPage.items) {
|
|
246
|
+
if (!item.headerCrcValid) stats.entries_bad_header_crc++;
|
|
247
|
+
if (item.dataCrcValid !== undefined && !item.dataCrcValid)
|
|
248
|
+
stats.entries_bad_data_crc++;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const blobIntegrity = this.checkBlobIntegrity(this.getBlobs());
|
|
254
|
+
stats.blobs_complete = blobIntegrity.complete;
|
|
255
|
+
stats.blobs_incomplete = blobIntegrity.incomplete;
|
|
256
|
+
|
|
257
|
+
return stats;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Collect detailed integrity issues for diagnostics.
|
|
262
|
+
* Mirrors the kind of info `nvs.be` prints at higher loglevels.
|
|
263
|
+
* @returns {{
|
|
264
|
+
* corruptedPages: Array,
|
|
265
|
+
* pagesBadHeaderCrc: Array,
|
|
266
|
+
* entriesBadHeaderCrc: Array,
|
|
267
|
+
* entriesBadDataCrc: Array,
|
|
268
|
+
* incompleteBlobs: Array
|
|
269
|
+
* }}
|
|
270
|
+
*/
|
|
271
|
+
getIntegrityIssues() {
|
|
272
|
+
const issues = {
|
|
273
|
+
corruptedPages: [],
|
|
274
|
+
pagesBadHeaderCrc: [],
|
|
275
|
+
malformedEntries: [], // WRITTEN slots that the parser drops or that overflow
|
|
276
|
+
entriesBadHeaderCrc: [],
|
|
277
|
+
entriesBadDataCrc: [],
|
|
278
|
+
incompleteBlobs: [],
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// Helper: index parsed pages by sector offset for quick lookup.
|
|
282
|
+
const parsedPagesByOffset = new Map();
|
|
283
|
+
for (const p of this.pages) parsedPagesByOffset.set(p.offset, p);
|
|
284
|
+
|
|
285
|
+
// ── Scan ALL sectors of the partition (independent of this.pages) ──
|
|
286
|
+
for (let secOff = 0; secOff < this.data.length; secOff += NVS_SECTOR_SIZE) {
|
|
287
|
+
if (secOff + 64 > this.data.length) break;
|
|
288
|
+
const pageIndex = Math.floor(secOff / NVS_SECTOR_SIZE);
|
|
289
|
+
const stateValue = this._u32(secOff);
|
|
290
|
+
const stateName = pageStateName(stateValue);
|
|
291
|
+
|
|
292
|
+
// Corrupted or unknown state pages
|
|
293
|
+
if (stateName === "CORRUPT" || stateName === "UNKNOWN") {
|
|
294
|
+
issues.corruptedPages.push({
|
|
295
|
+
pageIndex,
|
|
296
|
+
offset: secOff,
|
|
297
|
+
state: stateName,
|
|
298
|
+
stateValue: stateValue >>> 0,
|
|
299
|
+
});
|
|
300
|
+
continue; // don't iterate entries on a corrupt/unknown page
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// UNINIT pages have no meaningful CRC/entries
|
|
304
|
+
if (stateName === "UNINIT") continue;
|
|
305
|
+
|
|
306
|
+
// Check page header CRC
|
|
307
|
+
const pageCrcCalc = NVSEditor.crc32PageHeader(this.data, secOff);
|
|
308
|
+
const pageCrcStored = this._u32(secOff + 28);
|
|
309
|
+
if (pageCrcCalc !== pageCrcStored) {
|
|
310
|
+
issues.pagesBadHeaderCrc.push({
|
|
311
|
+
pageIndex,
|
|
312
|
+
offset: secOff,
|
|
313
|
+
stored: pageCrcStored,
|
|
314
|
+
calculated: pageCrcCalc,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Walk every slot in the bitmap — find malformed WRITTEN entries
|
|
319
|
+
// (these are silently dropped by _parse and would otherwise be invisible).
|
|
320
|
+
const stateBitmap = this.data.slice(secOff + 32, secOff + 64);
|
|
321
|
+
const parsedPage = parsedPagesByOffset.get(secOff);
|
|
322
|
+
const parsedItemsBySlot = new Map();
|
|
323
|
+
if (parsedPage) {
|
|
324
|
+
for (const item of parsedPage.items) {
|
|
325
|
+
const slot = (item.offset - secOff - 64) / 32;
|
|
326
|
+
parsedItemsBySlot.set(slot, item);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
for (let slot = 0; slot < MAX_ENTRY_COUNT; slot++) {
|
|
331
|
+
const slotState = this._getNVSItemState(stateBitmap, slot);
|
|
332
|
+
|
|
333
|
+
// Invalid bitmap state value (0b01)
|
|
334
|
+
if (slotState === 1) {
|
|
335
|
+
issues.malformedEntries.push({
|
|
336
|
+
pageIndex,
|
|
337
|
+
pageOffset: secOff,
|
|
338
|
+
slot,
|
|
339
|
+
entryOffset: secOff + 64 + slot * 32,
|
|
340
|
+
reason: "Invalid bitmap state (0b01)",
|
|
341
|
+
key: "<unknown>",
|
|
342
|
+
namespace: "<unknown>",
|
|
343
|
+
type: "<unknown>",
|
|
344
|
+
});
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (slotState !== 2) continue; // only WRITTEN slots can be malformed entries
|
|
348
|
+
|
|
349
|
+
const eOff = secOff + 64 + slot * 32;
|
|
350
|
+
if (eOff + 32 > this.data.length) {
|
|
351
|
+
issues.malformedEntries.push({
|
|
352
|
+
pageIndex,
|
|
353
|
+
pageOffset: secOff,
|
|
354
|
+
slot,
|
|
355
|
+
entryOffset: eOff,
|
|
356
|
+
reason: "Entry header truncated by partition end",
|
|
357
|
+
key: "<unknown>",
|
|
358
|
+
namespace: "<unknown>",
|
|
359
|
+
type: "<unknown>",
|
|
360
|
+
});
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const nsIndex = this._u8(eOff);
|
|
365
|
+
const datatype = this._u8(eOff + 1);
|
|
366
|
+
const span = this._u8(eOff + 2);
|
|
367
|
+
const typeName = this._getNVSTypeName(datatype);
|
|
368
|
+
|
|
369
|
+
const reasons = [];
|
|
370
|
+
if (span === 0 || span > 126) reasons.push(`Invalid span (${span})`);
|
|
371
|
+
if (datatype === 0xff || datatype === 0x00)
|
|
372
|
+
reasons.push(`Invalid datatype (0x${datatype.toString(16)})`);
|
|
373
|
+
if (nsIndex === 0xff) reasons.push("Invalid namespace index (0xFF)");
|
|
374
|
+
const key = this._readString(eOff + 8, 16);
|
|
375
|
+
if (nsIndex !== 0 && (!key || key.length === 0))
|
|
376
|
+
reasons.push("Missing/empty key");
|
|
377
|
+
if (span > 0 && span <= 126) {
|
|
378
|
+
const lastEntryEnd = eOff + span * 32;
|
|
379
|
+
if (lastEntryEnd > secOff + NVS_SECTOR_SIZE) {
|
|
380
|
+
reasons.push(
|
|
381
|
+
`Span overflows page (${span} entries from slot ${slot})`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const parsed = parsedItemsBySlot.get(slot);
|
|
387
|
+
if (parsed) {
|
|
388
|
+
// String/blob whose declared size doesn't fit the partition data
|
|
389
|
+
if (
|
|
390
|
+
parsed.value === "<invalid string>" ||
|
|
391
|
+
parsed.value === "<invalid blob>"
|
|
392
|
+
) {
|
|
393
|
+
reasons.push(
|
|
394
|
+
`Variable-length payload truncated/invalid (${typeName})`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (reasons.length) {
|
|
400
|
+
issues.malformedEntries.push({
|
|
401
|
+
pageIndex,
|
|
402
|
+
pageOffset: secOff,
|
|
403
|
+
slot,
|
|
404
|
+
entryOffset: eOff,
|
|
405
|
+
reason: reasons.join("; "),
|
|
406
|
+
key: key || "<unknown>",
|
|
407
|
+
namespace: nsIndex === 0 ? "<ns def>" : `ns_${nsIndex}`,
|
|
408
|
+
type: typeName,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (span > 1) slot += span - 1;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Per-item CRC checks for entries the parser DID accept
|
|
416
|
+
if (parsedPage) {
|
|
417
|
+
for (const item of parsedPage.items) {
|
|
418
|
+
const slot = (item.offset - secOff - 64) / 32;
|
|
419
|
+
const baseInfo = {
|
|
420
|
+
pageIndex,
|
|
421
|
+
pageOffset: secOff,
|
|
422
|
+
slot,
|
|
423
|
+
entryOffset: item.offset,
|
|
424
|
+
key: item.key || "<no key>",
|
|
425
|
+
namespace: item.namespace || `ns_${item.nsIndex}`,
|
|
426
|
+
type: item.typeName,
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
if (!item.headerCrcValid) {
|
|
430
|
+
issues.entriesBadHeaderCrc.push({
|
|
431
|
+
...baseInfo,
|
|
432
|
+
stored: item.crc32 >>> 0,
|
|
433
|
+
calculated: item.headerCrcCalc >>> 0,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
if (item.dataCrcValid === false) {
|
|
437
|
+
issues.entriesBadDataCrc.push({
|
|
438
|
+
...baseInfo,
|
|
439
|
+
size: item.size,
|
|
440
|
+
stored: item.dataCrcStored >>> 0,
|
|
441
|
+
calculated: item.dataCrcCalc >>> 0,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Blob completeness diagnostics
|
|
449
|
+
const blobs = this.getBlobs();
|
|
450
|
+
for (const [id, blob] of blobs) {
|
|
451
|
+
const presentSet = new Set(blob.chunks.map((c) => c.index));
|
|
452
|
+
const present = presentSet.size;
|
|
453
|
+
const expected = blob.expectedChunks;
|
|
454
|
+
const presentIndices = Array.from(presentSet).sort((a, b) => a - b);
|
|
455
|
+
|
|
456
|
+
let isIncomplete = false;
|
|
457
|
+
let missing = [];
|
|
458
|
+
if (expected > 0) {
|
|
459
|
+
if (present < expected) {
|
|
460
|
+
isIncomplete = true;
|
|
461
|
+
// Determine starting chunk index from blob_index (chunkStart) when available
|
|
462
|
+
const start =
|
|
463
|
+
blob.indexEntry && typeof blob.indexEntry.chunkStart === "number"
|
|
464
|
+
? blob.indexEntry.chunkStart
|
|
465
|
+
: 0;
|
|
466
|
+
for (let i = 0; i < expected; i++) {
|
|
467
|
+
const idx = start + i;
|
|
468
|
+
if (!presentSet.has(idx)) missing.push(idx);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
} else if (present === 0) {
|
|
472
|
+
isIncomplete = true;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (isIncomplete) {
|
|
476
|
+
issues.incompleteBlobs.push({
|
|
477
|
+
key: `${blob.namespace}::${blob.key}`,
|
|
478
|
+
present,
|
|
479
|
+
expected,
|
|
480
|
+
presentIndices,
|
|
481
|
+
missingIndices: missing,
|
|
482
|
+
totalSize: blob.totalSize,
|
|
483
|
+
indexEntryOffset: blob.indexEntry ? blob.indexEntry.offset : null,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return issues;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ─────── Blob integrity checking (from Berry nvs.be) ───────
|
|
492
|
+
|
|
493
|
+
getBlobs() {
|
|
494
|
+
// First pass: collect blob_index and blob_data entries separately per qualified id.
|
|
495
|
+
const indexEntriesById = new Map(); // qualifiedId → blob_index items[]
|
|
496
|
+
const dataEntriesById = new Map(); // qualifiedId → blob_data items[]
|
|
497
|
+
|
|
498
|
+
for (const page of this.pages) {
|
|
499
|
+
for (const item of page.items) {
|
|
500
|
+
if (!NVSEditor.isBlob(item) || !item.key) continue;
|
|
501
|
+
const id = NVSEditor.getQualifiedBlobId(item);
|
|
502
|
+
if (item.datatype === 0x48) {
|
|
503
|
+
if (!indexEntriesById.has(id)) indexEntriesById.set(id, []);
|
|
504
|
+
indexEntriesById.get(id).push(item);
|
|
505
|
+
} else if (item.datatype === 0x42) {
|
|
506
|
+
if (!dataEntriesById.has(id)) dataEntriesById.set(id, []);
|
|
507
|
+
dataEntriesById.get(id).push(item);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Build a chunk array for data items, filtering by the index entry's expected range
|
|
513
|
+
// [chunkStart, chunkStart + expectedChunks) when expectedChunks > 0.
|
|
514
|
+
const buildChunks = (items, expectedChunks, chunkStart) => {
|
|
515
|
+
const chunks = [];
|
|
516
|
+
for (const item of items) {
|
|
517
|
+
if (item.size <= 0) continue;
|
|
518
|
+
const chunkIndex = item.chunkIndex; // raw; must match blob_index chunkStart space
|
|
519
|
+
if (expectedChunks > 0) {
|
|
520
|
+
const rel = chunkIndex - chunkStart;
|
|
521
|
+
if (rel < 0 || rel >= expectedChunks) continue;
|
|
522
|
+
}
|
|
523
|
+
if (item.span > 1) {
|
|
524
|
+
const payloadLength = (item.span - 1) * 32;
|
|
525
|
+
chunks.push({
|
|
526
|
+
offset: item.offset + 32,
|
|
527
|
+
length: Math.min(payloadLength, item.size),
|
|
528
|
+
index: chunkIndex,
|
|
529
|
+
});
|
|
530
|
+
} else {
|
|
531
|
+
// span === 1: inline data stored immediately after the 32-byte header.
|
|
532
|
+
chunks.push({
|
|
533
|
+
offset: item.offset + 32,
|
|
534
|
+
length: item.size,
|
|
535
|
+
index: chunkIndex,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return chunks;
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const blobs = new Map();
|
|
543
|
+
|
|
544
|
+
// Blobs with a blob_index entry: pick the best index entry, attach only its chunks.
|
|
545
|
+
for (const [id, indices] of indexEntriesById) {
|
|
546
|
+
// Prefer the first index entry that carries non-zero metadata.
|
|
547
|
+
let best = indices[0];
|
|
548
|
+
for (const idx of indices) {
|
|
549
|
+
if (
|
|
550
|
+
NVSEditor.blobTotalSize(idx) > 0 ||
|
|
551
|
+
NVSEditor.blobExpectedChunks(idx) > 0
|
|
552
|
+
) {
|
|
553
|
+
best = idx;
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const expectedChunks = NVSEditor.blobExpectedChunks(best);
|
|
558
|
+
const chunkStart =
|
|
559
|
+
typeof best.chunkStart === "number" ? best.chunkStart : 0;
|
|
560
|
+
blobs.set(id, {
|
|
561
|
+
id,
|
|
562
|
+
key: best.key,
|
|
563
|
+
namespace: best.namespace || `ns_${best.nsIndex}`,
|
|
564
|
+
totalSize: NVSEditor.blobTotalSize(best),
|
|
565
|
+
expectedChunks,
|
|
566
|
+
chunks: buildChunks(
|
|
567
|
+
dataEntriesById.get(id) || [],
|
|
568
|
+
expectedChunks,
|
|
569
|
+
chunkStart,
|
|
570
|
+
),
|
|
571
|
+
indexEntry: best,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Legacy blobs: blob_data entries with no corresponding blob_index entry.
|
|
576
|
+
for (const [id, items] of dataEntriesById) {
|
|
577
|
+
if (blobs.has(id)) continue;
|
|
578
|
+
const first = items[0];
|
|
579
|
+
blobs.set(id, {
|
|
580
|
+
id,
|
|
581
|
+
key: first.key,
|
|
582
|
+
namespace: first.namespace || `ns_${first.nsIndex}`,
|
|
583
|
+
totalSize: NVSEditor.blobTotalSize(first),
|
|
584
|
+
expectedChunks: 0,
|
|
585
|
+
chunks: buildChunks(items, 0, 0),
|
|
586
|
+
indexEntry: first,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return blobs;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
checkBlobIntegrity(blobs) {
|
|
594
|
+
let complete = 0;
|
|
595
|
+
let incomplete = 0;
|
|
596
|
+
|
|
597
|
+
for (const [key, blob] of blobs) {
|
|
598
|
+
const present = new Set(blob.chunks.map((c) => c.index)).size;
|
|
599
|
+
const expected = blob.expectedChunks;
|
|
600
|
+
|
|
601
|
+
if (expected > 0) {
|
|
602
|
+
if (present >= expected) {
|
|
603
|
+
complete++;
|
|
604
|
+
} else {
|
|
605
|
+
incomplete++;
|
|
606
|
+
}
|
|
607
|
+
} else {
|
|
608
|
+
// Legacy/inline blob - no expected chunk count
|
|
609
|
+
if (present > 0) {
|
|
610
|
+
complete++;
|
|
611
|
+
} else {
|
|
612
|
+
incomplete++;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return { complete, incomplete };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
getBlobData(blob) {
|
|
621
|
+
// Sort chunks by index
|
|
622
|
+
const sortedChunks = [...blob.chunks].sort((a, b) => a.index - b.index);
|
|
623
|
+
|
|
624
|
+
// Assemble blob data
|
|
625
|
+
const totalSize = sortedChunks.reduce(
|
|
626
|
+
(sum, chunk) => sum + chunk.length,
|
|
627
|
+
0,
|
|
628
|
+
);
|
|
629
|
+
const result = new Uint8Array(totalSize);
|
|
630
|
+
let offset = 0;
|
|
631
|
+
|
|
632
|
+
for (const chunk of sortedChunks) {
|
|
633
|
+
const chunkData = this.data.slice(
|
|
634
|
+
chunk.offset,
|
|
635
|
+
chunk.offset + chunk.length,
|
|
636
|
+
);
|
|
637
|
+
result.set(chunkData, offset);
|
|
638
|
+
offset += chunk.length;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return result;
|
|
642
|
+
}
|
|
643
|
+
|
|
88
644
|
// ─────── Public API ───────
|
|
89
645
|
|
|
646
|
+
/** HTML for a progress overlay; reused in initProgressUI() and _buildUI(). */
|
|
647
|
+
static _progressOverlayHtml(extraClass = "", initialText = "Loading...") {
|
|
648
|
+
return `
|
|
649
|
+
<div class="nvseditor-progress-overlay${extraClass ? " " + extraClass : ""}" id="nvsProgress">
|
|
650
|
+
<div class="progress-text" id="nvsProgressText">${initialText}</div>
|
|
651
|
+
<div class="progress-bar-outer">
|
|
652
|
+
<div class="progress-bar-inner" id="nvsProgressBar"></div>
|
|
653
|
+
</div>
|
|
654
|
+
</div>`;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Cache references to the progress overlay DOM elements. */
|
|
658
|
+
_cacheProgressEls() {
|
|
659
|
+
this._progressOverlay = this.container.querySelector("#nvsProgress");
|
|
660
|
+
this._progressText = this.container.querySelector("#nvsProgressText");
|
|
661
|
+
this._progressBarInner = this.container.querySelector("#nvsProgressBar");
|
|
662
|
+
}
|
|
663
|
+
|
|
90
664
|
/** Show a progress overlay (before open()) */
|
|
91
665
|
initProgressUI() {
|
|
92
666
|
this.container.innerHTML = `
|
|
93
667
|
<div class="nvseditor-body" style="flex:1;display:flex;align-items:center;justify-content:center;">
|
|
94
|
-
|
|
95
|
-
<div class="progress-text" id="nvsProgressText">Initiating...</div>
|
|
96
|
-
<div class="progress-bar-outer">
|
|
97
|
-
<div class="progress-bar-inner" id="nvsProgressBar"></div>
|
|
98
|
-
</div>
|
|
99
|
-
</div>
|
|
668
|
+
${NVSEditor._progressOverlayHtml("", "Initiating...")}
|
|
100
669
|
</div>`;
|
|
101
|
-
this.
|
|
102
|
-
this._progressText = this.container.querySelector('#nvsProgressText');
|
|
103
|
-
this._progressBarInner = this.container.querySelector('#nvsProgressBar');
|
|
670
|
+
this._cacheProgressEls();
|
|
104
671
|
}
|
|
105
672
|
|
|
106
673
|
showProgress(text, percent) {
|
|
107
674
|
if (this._progressOverlay) {
|
|
108
|
-
this._progressOverlay.classList.remove(
|
|
675
|
+
this._progressOverlay.classList.remove("hidden");
|
|
109
676
|
this._progressText.textContent = text;
|
|
110
|
-
this._progressBarInner.style.width = percent +
|
|
677
|
+
this._progressBarInner.style.width = percent + "%";
|
|
111
678
|
}
|
|
112
679
|
}
|
|
113
680
|
|
|
114
681
|
hideProgress() {
|
|
115
682
|
if (this._progressOverlay) {
|
|
116
|
-
this._progressOverlay.classList.add(
|
|
683
|
+
this._progressOverlay.classList.add("hidden");
|
|
117
684
|
}
|
|
118
685
|
}
|
|
119
686
|
|
|
@@ -128,33 +695,35 @@ export class NVSEditor {
|
|
|
128
695
|
this.originalData = new Uint8Array(data);
|
|
129
696
|
this.baseAddress = baseAddress;
|
|
130
697
|
this.partitionSize = data.length;
|
|
131
|
-
this.partitionName = name ||
|
|
698
|
+
this.partitionName = name || "nvs";
|
|
132
699
|
this.modified = false;
|
|
133
|
-
this._filterText =
|
|
700
|
+
this._filterText = "";
|
|
134
701
|
|
|
135
702
|
this.pages = this._parse();
|
|
136
703
|
this._buildUI();
|
|
137
704
|
|
|
138
|
-
this.container.classList.remove(
|
|
139
|
-
document.body.classList.add(
|
|
705
|
+
this.container.classList.remove("hidden");
|
|
706
|
+
document.body.classList.add("nvseditor-active");
|
|
140
707
|
}
|
|
141
708
|
|
|
142
709
|
close() {
|
|
143
710
|
if (this._hexEditorInstance) {
|
|
144
711
|
this._hexEditorInstance.onClose = null; // prevent re-render on already-closing editor
|
|
145
|
-
try {
|
|
712
|
+
try {
|
|
713
|
+
this._hexEditorInstance.close();
|
|
714
|
+
} catch (_) {}
|
|
146
715
|
this._hexEditorInstance = null;
|
|
147
716
|
}
|
|
148
|
-
this.container.classList.add(
|
|
149
|
-
document.body.classList.remove(
|
|
150
|
-
this.container.innerHTML =
|
|
717
|
+
this.container.classList.add("hidden");
|
|
718
|
+
document.body.classList.remove("nvseditor-active");
|
|
719
|
+
this.container.innerHTML = "";
|
|
151
720
|
if (this.onClose) this.onClose();
|
|
152
721
|
}
|
|
153
722
|
|
|
154
723
|
// ─────── NVS parsing (synchronous, operates on Uint8Array) ───────
|
|
155
724
|
|
|
156
725
|
_readString(offset, maxLen) {
|
|
157
|
-
let r =
|
|
726
|
+
let r = "";
|
|
158
727
|
for (let i = 0; i < maxLen; i++) {
|
|
159
728
|
const b = this.data[offset + i];
|
|
160
729
|
if (b === 0) break;
|
|
@@ -164,22 +733,46 @@ export class NVSEditor {
|
|
|
164
733
|
return r;
|
|
165
734
|
}
|
|
166
735
|
|
|
167
|
-
_u8(off) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
736
|
+
_u8(off) {
|
|
737
|
+
return this.data[off];
|
|
738
|
+
}
|
|
739
|
+
_u16(off) {
|
|
740
|
+
return this.data[off] | (this.data[off + 1] << 8);
|
|
741
|
+
}
|
|
742
|
+
_u32(off) {
|
|
743
|
+
return (
|
|
744
|
+
(this.data[off] |
|
|
745
|
+
(this.data[off + 1] << 8) |
|
|
746
|
+
(this.data[off + 2] << 16) |
|
|
747
|
+
(this.data[off + 3] << 24)) >>>
|
|
748
|
+
0
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
_i32(off) {
|
|
752
|
+
return this._u32(off) | 0;
|
|
753
|
+
}
|
|
171
754
|
_u64(off) {
|
|
172
755
|
const lo = this._u32(off);
|
|
173
756
|
const hi = this._u32(off + 4);
|
|
174
757
|
return (BigInt(hi) << 32n) | BigInt(lo);
|
|
175
758
|
}
|
|
176
|
-
_i64(off) {
|
|
759
|
+
_i64(off) {
|
|
760
|
+
return BigInt.asIntN(64, this._u64(off));
|
|
761
|
+
}
|
|
177
762
|
|
|
178
763
|
_getNVSTypeName(dt) {
|
|
179
764
|
const m = {
|
|
180
|
-
0x01:
|
|
181
|
-
|
|
182
|
-
|
|
765
|
+
0x01: "U8",
|
|
766
|
+
0x02: "U16",
|
|
767
|
+
0x04: "U32",
|
|
768
|
+
0x08: "U64",
|
|
769
|
+
0x11: "I8",
|
|
770
|
+
0x12: "I16",
|
|
771
|
+
0x14: "I32",
|
|
772
|
+
0x18: "I64",
|
|
773
|
+
0x21: "String",
|
|
774
|
+
0x42: "Blob",
|
|
775
|
+
0x48: "Blob Index",
|
|
183
776
|
};
|
|
184
777
|
return m[dt] || `0x${dt.toString(16)}`;
|
|
185
778
|
}
|
|
@@ -194,58 +787,57 @@ export class NVSEditor {
|
|
|
194
787
|
const bmpIdx = Math.floor(index / 4);
|
|
195
788
|
const bmpBit = (index % 4) * 2;
|
|
196
789
|
bitmap[bmpIdx] &= ~(3 << bmpBit);
|
|
197
|
-
bitmap[bmpIdx] |=
|
|
790
|
+
bitmap[bmpIdx] |= state << bmpBit;
|
|
198
791
|
}
|
|
199
792
|
|
|
200
793
|
_parse() {
|
|
201
|
-
const NVS_SECTOR_SIZE = 4096;
|
|
202
|
-
const MAX_ENTRY_COUNT = 126;
|
|
203
|
-
const NVS_PAGE_STATE = {
|
|
204
|
-
UNINIT: 0xFFFFFFFF, ACTIVE: 0xFFFFFFFE,
|
|
205
|
-
FULL: 0xFFFFFFFC, FREEING: 0xFFFFFFF8, CORRUPT: 0xFFFFFFF0
|
|
206
|
-
};
|
|
207
|
-
|
|
208
794
|
const pages = [];
|
|
209
795
|
const namespaces = new Map();
|
|
210
|
-
namespaces.set(0,
|
|
211
|
-
|
|
212
|
-
// ── Pass 1: collect all namespace definitions ──────────────────────────
|
|
213
|
-
for (let secOff = 0; secOff < this.data.length; secOff += NVS_SECTOR_SIZE) {
|
|
214
|
-
if (secOff + 64 > this.data.length) break;
|
|
215
|
-
const state = this._u32(secOff);
|
|
216
|
-
if (state === NVS_PAGE_STATE.UNINIT || state === NVS_PAGE_STATE.CORRUPT) continue;
|
|
217
|
-
const stateBitmap = this.data.slice(secOff + 32, secOff + 64);
|
|
218
|
-
for (let entry = 0; entry < MAX_ENTRY_COUNT; entry++) {
|
|
219
|
-
if (this._getNVSItemState(stateBitmap, entry) !== 2) continue;
|
|
220
|
-
const eOff = secOff + 64 + entry * 32;
|
|
221
|
-
if (eOff + 32 > this.data.length) break;
|
|
222
|
-
const span = this._u8(eOff + 2);
|
|
223
|
-
if (this._u8(eOff) === 0 && this._u8(eOff + 1) !== 0xFF && this._u8(eOff + 1) !== 0x00) {
|
|
224
|
-
namespaces.set(this._u8(eOff + 24), this._readString(eOff + 8, 16));
|
|
225
|
-
}
|
|
226
|
-
if (span > 1) entry += span - 1;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
796
|
+
namespaces.set(0, "");
|
|
229
797
|
|
|
798
|
+
// ── Pass 1: collect all namespace definitions ──────────────────────────
|
|
230
799
|
for (let secOff = 0; secOff < this.data.length; secOff += NVS_SECTOR_SIZE) {
|
|
231
800
|
if (secOff + 64 > this.data.length) break;
|
|
232
801
|
const state = this._u32(secOff);
|
|
802
|
+
if (state === NVS_PAGE_STATE.UNINIT || state === NVS_PAGE_STATE.CORRUPT)
|
|
803
|
+
continue;
|
|
804
|
+
const stateBitmap = this.data.slice(secOff + 32, secOff + 64);
|
|
805
|
+
for (let entry = 0; entry < MAX_ENTRY_COUNT; entry++) {
|
|
806
|
+
if (this._getNVSItemState(stateBitmap, entry) !== 2) continue;
|
|
807
|
+
const eOff = secOff + 64 + entry * 32;
|
|
808
|
+
if (eOff + 32 > this.data.length) break;
|
|
809
|
+
const span = this._u8(eOff + 2);
|
|
810
|
+
if (
|
|
811
|
+
this._u8(eOff) === 0 &&
|
|
812
|
+
this._u8(eOff + 1) !== 0xff &&
|
|
813
|
+
this._u8(eOff + 1) !== 0x00
|
|
814
|
+
) {
|
|
815
|
+
namespaces.set(this._u8(eOff + 24), this._readString(eOff + 8, 16));
|
|
816
|
+
}
|
|
817
|
+
if (span > 1) entry += span - 1;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
233
820
|
|
|
234
|
-
|
|
235
|
-
if (
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
else if (state === NVS_PAGE_STATE.FREEING) { stateName = 'FREEING'; }
|
|
239
|
-
else if (state === NVS_PAGE_STATE.CORRUPT) { stateName = 'CORRUPT'; }
|
|
821
|
+
for (let secOff = 0; secOff < this.data.length; secOff += NVS_SECTOR_SIZE) {
|
|
822
|
+
if (secOff + 64 > this.data.length) break;
|
|
823
|
+
const state = this._u32(secOff);
|
|
824
|
+
const stateName = pageStateName(state);
|
|
240
825
|
|
|
241
|
-
if (stateName ===
|
|
826
|
+
if (stateName === "UNINIT" || stateName === "CORRUPT") continue;
|
|
242
827
|
|
|
243
828
|
const seq = this._u32(secOff + 4);
|
|
244
829
|
const version = this._u8(secOff + 8);
|
|
245
830
|
const crc32 = this._u32(secOff + 28);
|
|
246
831
|
const stateBitmap = this.data.slice(secOff + 32, secOff + 64);
|
|
247
832
|
|
|
248
|
-
const page = {
|
|
833
|
+
const page = {
|
|
834
|
+
offset: secOff,
|
|
835
|
+
state: stateName,
|
|
836
|
+
seq,
|
|
837
|
+
version,
|
|
838
|
+
crc32,
|
|
839
|
+
items: [],
|
|
840
|
+
};
|
|
249
841
|
|
|
250
842
|
for (let entry = 0; entry < MAX_ENTRY_COUNT; entry++) {
|
|
251
843
|
const itemState = this._getNVSItemState(stateBitmap, entry);
|
|
@@ -260,8 +852,8 @@ export class NVSEditor {
|
|
|
260
852
|
const chunkIndex = this._u8(eOff + 3);
|
|
261
853
|
|
|
262
854
|
if (span === 0 || span > 126) continue;
|
|
263
|
-
if (datatype ===
|
|
264
|
-
if (nsIndex ===
|
|
855
|
+
if (datatype === 0xff || datatype === 0x00) continue;
|
|
856
|
+
if (nsIndex === 0xff) continue;
|
|
265
857
|
|
|
266
858
|
const crc = this._u32(eOff + 4);
|
|
267
859
|
const key = this._readString(eOff + 8, 16);
|
|
@@ -271,16 +863,19 @@ export class NVSEditor {
|
|
|
271
863
|
const headerCrcCalc = NVSEditor.crc32Header(this.data, eOff);
|
|
272
864
|
|
|
273
865
|
const item = {
|
|
274
|
-
nsIndex,
|
|
866
|
+
nsIndex,
|
|
867
|
+
datatype,
|
|
868
|
+
span,
|
|
869
|
+
chunkIndex,
|
|
275
870
|
crc32: crc >>> 0,
|
|
276
871
|
headerCrcCalc: headerCrcCalc >>> 0,
|
|
277
|
-
headerCrcValid:
|
|
872
|
+
headerCrcValid: crc >>> 0 === headerCrcCalc >>> 0,
|
|
278
873
|
key,
|
|
279
874
|
value: null,
|
|
280
875
|
typeName: this._getNVSTypeName(datatype),
|
|
281
876
|
offset: eOff,
|
|
282
877
|
entrySize: 32,
|
|
283
|
-
pageOffset: secOff
|
|
878
|
+
pageOffset: secOff,
|
|
284
879
|
};
|
|
285
880
|
|
|
286
881
|
// Namespace definition
|
|
@@ -291,27 +886,61 @@ export class NVSEditor {
|
|
|
291
886
|
namespaces.set(namespaceIndex, key);
|
|
292
887
|
} else {
|
|
293
888
|
switch (datatype) {
|
|
294
|
-
case 0x01:
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
case
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
case
|
|
301
|
-
|
|
302
|
-
|
|
889
|
+
case 0x01:
|
|
890
|
+
item.value = this._u8(eOff + 24);
|
|
891
|
+
break;
|
|
892
|
+
case 0x02:
|
|
893
|
+
item.value = this._u16(eOff + 24);
|
|
894
|
+
break;
|
|
895
|
+
case 0x04:
|
|
896
|
+
item.value = this._u32(eOff + 24);
|
|
897
|
+
break;
|
|
898
|
+
case 0x08:
|
|
899
|
+
item.value = this._u64(eOff + 24).toString();
|
|
900
|
+
break;
|
|
901
|
+
case 0x11:
|
|
902
|
+
item.value =
|
|
903
|
+
this._u8(eOff + 24) > 127
|
|
904
|
+
? this._u8(eOff + 24) - 256
|
|
905
|
+
: this._u8(eOff + 24);
|
|
906
|
+
break;
|
|
907
|
+
case 0x12: {
|
|
908
|
+
const v = this._u16(eOff + 24);
|
|
909
|
+
item.value = v > 32767 ? v - 65536 : v;
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
case 0x14:
|
|
913
|
+
item.value = this._i32(eOff + 24);
|
|
914
|
+
break;
|
|
915
|
+
case 0x18:
|
|
916
|
+
item.value = this._i64(eOff + 24).toString();
|
|
917
|
+
break;
|
|
918
|
+
case 0x21: {
|
|
919
|
+
// String
|
|
303
920
|
const strSize = this._u16(eOff + 24);
|
|
304
921
|
const strCrc = this._u32(eOff + 28);
|
|
305
|
-
if (
|
|
922
|
+
if (
|
|
923
|
+
strSize > 0 &&
|
|
924
|
+
strSize < 4096 &&
|
|
925
|
+
eOff + 32 + strSize <= this.data.length
|
|
926
|
+
) {
|
|
306
927
|
const strData = this.data.slice(eOff + 32, eOff + 32 + strSize);
|
|
307
|
-
const allErased = strData.every(b => b ===
|
|
928
|
+
const allErased = strData.every((b) => b === 0xff);
|
|
308
929
|
// Find first NUL byte and decode as UTF-8
|
|
309
930
|
let nullIndex = strData.length;
|
|
310
931
|
for (let i = 0; i < strData.length; i++) {
|
|
311
|
-
if (strData[i] === 0) {
|
|
932
|
+
if (strData[i] === 0) {
|
|
933
|
+
nullIndex = i;
|
|
934
|
+
break;
|
|
935
|
+
}
|
|
312
936
|
}
|
|
313
|
-
const sv =
|
|
314
|
-
|
|
937
|
+
const sv =
|
|
938
|
+
nullIndex > 0
|
|
939
|
+
? new TextDecoder("utf-8").decode(
|
|
940
|
+
strData.subarray(0, nullIndex),
|
|
941
|
+
)
|
|
942
|
+
: "";
|
|
943
|
+
item.value = allErased ? "<erased>" : sv;
|
|
315
944
|
item.rawValue = strData;
|
|
316
945
|
item.dataCrcStored = strCrc >>> 0;
|
|
317
946
|
item.dataCrcCalc = NVSEditor.crc32(strData, 0, strSize) >>> 0;
|
|
@@ -319,18 +948,28 @@ export class NVSEditor {
|
|
|
319
948
|
item.size = strSize;
|
|
320
949
|
item.entrySize = 32 + strSize;
|
|
321
950
|
} else {
|
|
322
|
-
item.value =
|
|
951
|
+
item.value = "<invalid string>";
|
|
323
952
|
item.size = 0;
|
|
324
953
|
}
|
|
325
954
|
break;
|
|
326
955
|
}
|
|
327
|
-
case 0x42: {
|
|
956
|
+
case 0x42: {
|
|
957
|
+
// Blob
|
|
328
958
|
const blobSize = this._u16(eOff + 24);
|
|
329
959
|
const blobCrc = this._u32(eOff + 28);
|
|
330
|
-
if (
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
960
|
+
if (
|
|
961
|
+
blobSize > 0 &&
|
|
962
|
+
blobSize < 4096 &&
|
|
963
|
+
eOff + 32 + blobSize <= this.data.length
|
|
964
|
+
) {
|
|
965
|
+
const blobData = this.data.slice(
|
|
966
|
+
eOff + 32,
|
|
967
|
+
eOff + 32 + blobSize,
|
|
968
|
+
);
|
|
969
|
+
const allErased = blobData.every((b) => b === 0xff);
|
|
970
|
+
item.value = allErased
|
|
971
|
+
? "<erased>"
|
|
972
|
+
: NVSEditor.bytesToHex(blobData, " ");
|
|
334
973
|
item.rawValue = blobData;
|
|
335
974
|
item.dataCrcStored = blobCrc >>> 0;
|
|
336
975
|
item.dataCrcCalc = NVSEditor.crc32(blobData, 0, blobSize) >>> 0;
|
|
@@ -338,12 +977,13 @@ export class NVSEditor {
|
|
|
338
977
|
item.size = blobSize;
|
|
339
978
|
item.entrySize = 32 + blobSize;
|
|
340
979
|
} else {
|
|
341
|
-
item.value =
|
|
980
|
+
item.value = "<invalid blob>";
|
|
342
981
|
item.size = 0;
|
|
343
982
|
}
|
|
344
983
|
break;
|
|
345
984
|
}
|
|
346
|
-
case 0x48: {
|
|
985
|
+
case 0x48: {
|
|
986
|
+
// Blob Index
|
|
347
987
|
item.totalSize = this._u32(eOff + 24);
|
|
348
988
|
item.chunkCount = this._u8(eOff + 28);
|
|
349
989
|
item.chunkStart = this._u8(eOff + 29);
|
|
@@ -359,11 +999,11 @@ export class NVSEditor {
|
|
|
359
999
|
|
|
360
1000
|
pages.push(page);
|
|
361
1001
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
1002
|
+
// Resolve all item namespaces after both passes
|
|
1003
|
+
for (const page of pages)
|
|
1004
|
+
for (const it of page.items)
|
|
1005
|
+
if (it.nsIndex && it.nsIndex !== 0)
|
|
1006
|
+
it.namespace = namespaces.get(it.nsIndex) || `ns_${it.nsIndex}`;
|
|
367
1007
|
|
|
368
1008
|
return pages;
|
|
369
1009
|
}
|
|
@@ -379,7 +1019,7 @@ export class NVSEditor {
|
|
|
379
1019
|
|
|
380
1020
|
for (let s = 0; s < item.span; s++) {
|
|
381
1021
|
const off = item.offset + s * 32;
|
|
382
|
-
this.data.fill(
|
|
1022
|
+
this.data.fill(0xff, off, off + 32);
|
|
383
1023
|
this._setNVSItemState(stateBitmap, entryIdx + s, 0); // ERASED = 0
|
|
384
1024
|
}
|
|
385
1025
|
// Write back bitmap
|
|
@@ -390,11 +1030,12 @@ export class NVSEditor {
|
|
|
390
1030
|
// ─────── UI ───────
|
|
391
1031
|
|
|
392
1032
|
_buildUI() {
|
|
393
|
-
const sizeStr =
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
1033
|
+
const sizeStr =
|
|
1034
|
+
this.partitionSize >= 1024 * 1024
|
|
1035
|
+
? (this.partitionSize / (1024 * 1024)).toFixed(1) + " MB"
|
|
1036
|
+
: this.partitionSize >= 1024
|
|
1037
|
+
? (this.partitionSize / 1024).toFixed(1) + " KB"
|
|
1038
|
+
: this.partitionSize + " B";
|
|
398
1039
|
|
|
399
1040
|
const totalItems = this.pages.reduce((s, p) => s + p.items.length, 0);
|
|
400
1041
|
|
|
@@ -411,17 +1052,14 @@ export class NVSEditor {
|
|
|
411
1052
|
<div class="nvseditor-filter">
|
|
412
1053
|
<input id="nvsFilter" type="text" placeholder="Filter by namespace or key..." />
|
|
413
1054
|
</div>
|
|
1055
|
+
<button id="nvsStats" title="Show statistics and integrity report">📊 Stats</button>
|
|
1056
|
+
<button id="nvsBlobs" title="Show blob information">📦 Blobs</button>
|
|
414
1057
|
<button id="nvsRefresh" title="Re-parse data">Refresh</button>
|
|
415
1058
|
<button id="nvsWrite" class="primary" disabled>Write to Flash</button>
|
|
416
1059
|
<button id="nvsClose">Close</button>
|
|
417
1060
|
</div>
|
|
418
1061
|
<div class="nvseditor-body">
|
|
419
|
-
|
|
420
|
-
<div class="progress-text" id="nvsProgressText">Loading...</div>
|
|
421
|
-
<div class="progress-bar-outer">
|
|
422
|
-
<div class="progress-bar-inner" id="nvsProgressBar"></div>
|
|
423
|
-
</div>
|
|
424
|
-
</div>
|
|
1062
|
+
${NVSEditor._progressOverlayHtml("hidden", "Loading...")}
|
|
425
1063
|
<div class="nvseditor-content" id="nvsContent"></div>
|
|
426
1064
|
</div>
|
|
427
1065
|
<div class="nvseditor-statusbar">
|
|
@@ -429,35 +1067,35 @@ export class NVSEditor {
|
|
|
429
1067
|
</div>
|
|
430
1068
|
<div id="nvsHexEditorContainer" class="hexeditor-container hidden"></div>`;
|
|
431
1069
|
|
|
432
|
-
this._hexEditorContainer = this.container.querySelector(
|
|
1070
|
+
this._hexEditorContainer = this.container.querySelector(
|
|
1071
|
+
"#nvsHexEditorContainer",
|
|
1072
|
+
);
|
|
433
1073
|
|
|
434
|
-
this.
|
|
435
|
-
this._progressText = this.container.querySelector('#nvsProgressText');
|
|
436
|
-
this._progressBarInner = this.container.querySelector('#nvsProgressBar');
|
|
1074
|
+
this._cacheProgressEls();
|
|
437
1075
|
|
|
438
1076
|
// Close
|
|
439
|
-
this.container.querySelector(
|
|
1077
|
+
this.container.querySelector("#nvsClose").addEventListener("click", () => {
|
|
440
1078
|
if (this.modified) {
|
|
441
|
-
if (!confirm(
|
|
1079
|
+
if (!confirm("You have unsaved modifications. Close anyway?")) return;
|
|
442
1080
|
}
|
|
443
1081
|
this.close();
|
|
444
1082
|
});
|
|
445
1083
|
|
|
446
1084
|
// Write
|
|
447
|
-
const butWrite = this.container.querySelector(
|
|
448
|
-
butWrite.addEventListener(
|
|
1085
|
+
const butWrite = this.container.querySelector("#nvsWrite");
|
|
1086
|
+
butWrite.addEventListener("click", async () => {
|
|
449
1087
|
if (!this.onWriteFlash) return;
|
|
450
1088
|
butWrite.disabled = true;
|
|
451
1089
|
try {
|
|
452
|
-
this.showProgress(
|
|
1090
|
+
this.showProgress("Writing NVS to flash...", 0);
|
|
453
1091
|
await this.onWriteFlash(this.data);
|
|
454
1092
|
this.originalData = new Uint8Array(this.data);
|
|
455
1093
|
this.modified = false;
|
|
456
1094
|
butWrite.disabled = true;
|
|
457
|
-
this.showProgress(
|
|
1095
|
+
this.showProgress("Write complete!", 100);
|
|
458
1096
|
setTimeout(() => this.hideProgress(), 1000);
|
|
459
1097
|
} catch (e) {
|
|
460
|
-
alert(
|
|
1098
|
+
alert("Write failed: " + e);
|
|
461
1099
|
this.hideProgress();
|
|
462
1100
|
} finally {
|
|
463
1101
|
butWrite.disabled = this.modified === false;
|
|
@@ -465,33 +1103,56 @@ export class NVSEditor {
|
|
|
465
1103
|
});
|
|
466
1104
|
|
|
467
1105
|
// Refresh
|
|
468
|
-
this.container
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
1106
|
+
this.container
|
|
1107
|
+
.querySelector("#nvsRefresh")
|
|
1108
|
+
.addEventListener("click", () => {
|
|
1109
|
+
this.pages = this._parse();
|
|
1110
|
+
this._renderContent();
|
|
1111
|
+
});
|
|
472
1112
|
|
|
473
1113
|
// Filter
|
|
474
|
-
this.container
|
|
475
|
-
|
|
476
|
-
|
|
1114
|
+
this.container
|
|
1115
|
+
.querySelector("#nvsFilter")
|
|
1116
|
+
.addEventListener("input", (e) => {
|
|
1117
|
+
this._filterText = e.target.value.toLowerCase();
|
|
1118
|
+
this._renderContent();
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
// Stats button
|
|
1122
|
+
this.container.querySelector("#nvsStats").addEventListener("click", () => {
|
|
1123
|
+
this._showStats();
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
// Blobs button
|
|
1127
|
+
this.container.querySelector("#nvsBlobs").addEventListener("click", () => {
|
|
1128
|
+
this._showBlobs();
|
|
477
1129
|
});
|
|
478
1130
|
|
|
479
1131
|
this._renderContent();
|
|
480
1132
|
}
|
|
481
1133
|
|
|
482
1134
|
_esc(s) {
|
|
483
|
-
const d = document.createElement(
|
|
1135
|
+
const d = document.createElement("span");
|
|
484
1136
|
d.textContent = s;
|
|
485
1137
|
return d.innerHTML;
|
|
486
1138
|
}
|
|
487
1139
|
|
|
1140
|
+
_matchesFilter(ns, item, filter) {
|
|
1141
|
+
if (!filter) return true;
|
|
1142
|
+
return (
|
|
1143
|
+
ns.toLowerCase().includes(filter) ||
|
|
1144
|
+
item.key.toLowerCase().includes(filter) ||
|
|
1145
|
+
String(item.value).toLowerCase().includes(filter)
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
488
1149
|
_renderContent() {
|
|
489
|
-
const content = this.container.querySelector(
|
|
1150
|
+
const content = this.container.querySelector("#nvsContent");
|
|
490
1151
|
if (!content) return;
|
|
491
1152
|
|
|
492
1153
|
const filter = this._filterText;
|
|
493
1154
|
|
|
494
|
-
let html =
|
|
1155
|
+
let html = "";
|
|
495
1156
|
|
|
496
1157
|
for (const page of this.pages) {
|
|
497
1158
|
// Group items by namespace
|
|
@@ -512,42 +1173,47 @@ export class NVSEditor {
|
|
|
512
1173
|
let hasVisibleItems = false;
|
|
513
1174
|
if (filter) {
|
|
514
1175
|
for (const [ns, items] of nsGroups) {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
);
|
|
520
|
-
if (filtered.length > 0) hasVisibleItems = true;
|
|
1176
|
+
if (items.some((it) => this._matchesFilter(ns, it, filter))) {
|
|
1177
|
+
hasVisibleItems = true;
|
|
1178
|
+
break;
|
|
1179
|
+
}
|
|
521
1180
|
}
|
|
522
1181
|
// Also check namespace defs
|
|
523
|
-
|
|
524
|
-
|
|
1182
|
+
if (!hasVisibleItems) {
|
|
1183
|
+
for (const nd of nsDefs) {
|
|
1184
|
+
if (nd.key.toLowerCase().includes(filter)) {
|
|
1185
|
+
hasVisibleItems = true;
|
|
1186
|
+
break;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
525
1189
|
}
|
|
526
1190
|
if (!hasVisibleItems) continue;
|
|
527
1191
|
} else {
|
|
528
1192
|
hasVisibleItems = true;
|
|
529
1193
|
}
|
|
530
1194
|
|
|
531
|
-
const stateClass =
|
|
532
|
-
|
|
533
|
-
|
|
1195
|
+
const stateClass =
|
|
1196
|
+
page.state === "ACTIVE"
|
|
1197
|
+
? "state-active"
|
|
1198
|
+
: page.state === "FULL"
|
|
1199
|
+
? "state-full"
|
|
1200
|
+
: page.state === "FREEING"
|
|
1201
|
+
? "state-freeing"
|
|
1202
|
+
: "state-other";
|
|
534
1203
|
|
|
535
|
-
html += `<div class="nvs-page">
|
|
1204
|
+
html += `<div class="nvs-page" data-page-offset="${page.offset}">
|
|
536
1205
|
<div class="nvs-page-header ${stateClass}">
|
|
537
1206
|
<span class="nvs-page-state">${page.state}</span>
|
|
538
1207
|
<span>Page @ 0x${page.offset.toString(16).toUpperCase()}</span>
|
|
539
1208
|
<span>Seq: ${page.seq}</span>
|
|
540
|
-
<span>Version: ${page.version ===
|
|
1209
|
+
<span>Version: ${page.version === 0xff ? "v1" : page.version === 0xfe ? "v2" : page.version}</span>
|
|
541
1210
|
<span>${page.items.length} entries</span>
|
|
542
1211
|
</div>`;
|
|
543
1212
|
|
|
544
1213
|
// Render namespace groups
|
|
545
1214
|
for (const [ns, items] of nsGroups) {
|
|
546
1215
|
const filteredItems = filter
|
|
547
|
-
? items.filter(it =>
|
|
548
|
-
ns.toLowerCase().includes(filter) ||
|
|
549
|
-
it.key.toLowerCase().includes(filter) ||
|
|
550
|
-
String(it.value).toLowerCase().includes(filter))
|
|
1216
|
+
? items.filter((it) => this._matchesFilter(ns, it, filter))
|
|
551
1217
|
: items;
|
|
552
1218
|
if (filteredItems.length === 0) continue;
|
|
553
1219
|
|
|
@@ -565,23 +1231,25 @@ export class NVSEditor {
|
|
|
565
1231
|
|
|
566
1232
|
for (const item of filteredItems) {
|
|
567
1233
|
const crcOk = item.headerCrcValid !== false;
|
|
568
|
-
const dataCrcOk =
|
|
569
|
-
|
|
570
|
-
const
|
|
1234
|
+
const dataCrcOk =
|
|
1235
|
+
item.dataCrcValid !== undefined ? item.dataCrcValid : true;
|
|
1236
|
+
const crcClass = crcOk && dataCrcOk ? "crc-ok" : "crc-bad";
|
|
1237
|
+
const crcText = crcOk && dataCrcOk ? "✓" : "✗";
|
|
571
1238
|
|
|
572
|
-
let displayValue = String(item.value ??
|
|
573
|
-
if (displayValue.length > 120)
|
|
1239
|
+
let displayValue = String(item.value ?? "");
|
|
1240
|
+
if (displayValue.length > 120)
|
|
1241
|
+
displayValue = displayValue.substring(0, 120) + "…";
|
|
574
1242
|
|
|
575
1243
|
const editable = true;
|
|
576
1244
|
|
|
577
|
-
html += `<tr>
|
|
1245
|
+
html += `<tr data-offset="${item.offset}">
|
|
578
1246
|
<td class="nvs-key" title="${this._esc(item.key)}">${this._esc(item.key)}</td>
|
|
579
1247
|
<td class="nvs-type">${this._esc(item.typeName)}</td>
|
|
580
|
-
<td class="nvs-value" title="${this._esc(String(item.value ??
|
|
1248
|
+
<td class="nvs-value" title="${this._esc(String(item.value ?? ""))}">${this._esc(displayValue)}</td>
|
|
581
1249
|
<td class="nvs-crc ${crcClass}">${crcText}</td>
|
|
582
1250
|
<td class="nvs-offset">0x${(this.baseAddress + item.offset).toString(16).toUpperCase()}</td>
|
|
583
1251
|
<td class="nvs-actions">
|
|
584
|
-
${editable ? `<button class="nvs-btn-edit" data-offset="${item.offset}" title="Edit value">✎</button>` :
|
|
1252
|
+
${editable ? `<button class="nvs-btn-edit" data-offset="${item.offset}" title="Edit value">✎</button>` : ""}
|
|
585
1253
|
<button class="nvs-btn-delete" data-offset="${item.offset}" title="Delete entry">✕</button>
|
|
586
1254
|
</td>
|
|
587
1255
|
</tr>`;
|
|
@@ -593,23 +1261,26 @@ export class NVSEditor {
|
|
|
593
1261
|
html += `</div>`;
|
|
594
1262
|
}
|
|
595
1263
|
|
|
596
|
-
if (html ===
|
|
597
|
-
html =
|
|
1264
|
+
if (html === "") {
|
|
1265
|
+
html =
|
|
1266
|
+
'<div class="nvs-empty">No NVS entries found' +
|
|
1267
|
+
(filter ? " matching filter" : "") +
|
|
1268
|
+
"</div>";
|
|
598
1269
|
}
|
|
599
1270
|
|
|
600
1271
|
content.innerHTML = html;
|
|
601
1272
|
|
|
602
1273
|
// Bind edit buttons
|
|
603
|
-
content.querySelectorAll(
|
|
604
|
-
btn.addEventListener(
|
|
1274
|
+
content.querySelectorAll(".nvs-btn-edit").forEach((btn) => {
|
|
1275
|
+
btn.addEventListener("click", () => {
|
|
605
1276
|
const off = parseInt(btn.dataset.offset, 10);
|
|
606
1277
|
this._editItem(off);
|
|
607
1278
|
});
|
|
608
1279
|
});
|
|
609
1280
|
|
|
610
1281
|
// Bind delete buttons
|
|
611
|
-
content.querySelectorAll(
|
|
612
|
-
btn.addEventListener(
|
|
1282
|
+
content.querySelectorAll(".nvs-btn-delete").forEach((btn) => {
|
|
1283
|
+
btn.addEventListener("click", () => {
|
|
613
1284
|
const off = parseInt(btn.dataset.offset, 10);
|
|
614
1285
|
this._deleteItemUI(off);
|
|
615
1286
|
});
|
|
@@ -618,6 +1289,32 @@ export class NVSEditor {
|
|
|
618
1289
|
this._updateWriteButton();
|
|
619
1290
|
}
|
|
620
1291
|
|
|
1292
|
+
/** Scroll an entry row into view and highlight it briefly. */
|
|
1293
|
+
_scrollToEntry(offset) {
|
|
1294
|
+
const content = this.container.querySelector("#nvsContent");
|
|
1295
|
+
if (!content) return false;
|
|
1296
|
+
const row = content.querySelector(`tr[data-offset="${offset}"]`);
|
|
1297
|
+
if (!row) return false;
|
|
1298
|
+
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
1299
|
+
row.classList.add("nvs-row-highlight");
|
|
1300
|
+
setTimeout(() => row.classList.remove("nvs-row-highlight"), 2200);
|
|
1301
|
+
return true;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/** Scroll a page section into view and highlight it briefly. */
|
|
1305
|
+
_scrollToPage(pageOffset) {
|
|
1306
|
+
const content = this.container.querySelector("#nvsContent");
|
|
1307
|
+
if (!content) return false;
|
|
1308
|
+
const pageEl = content.querySelector(
|
|
1309
|
+
`.nvs-page[data-page-offset="${pageOffset}"]`,
|
|
1310
|
+
);
|
|
1311
|
+
if (!pageEl) return false;
|
|
1312
|
+
pageEl.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
1313
|
+
pageEl.classList.add("nvs-page-highlight");
|
|
1314
|
+
setTimeout(() => pageEl.classList.remove("nvs-page-highlight"), 2200);
|
|
1315
|
+
return true;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
621
1318
|
_findItem(offset) {
|
|
622
1319
|
for (const page of this.pages) {
|
|
623
1320
|
for (const item of page.items) {
|
|
@@ -653,7 +1350,10 @@ export class NVSEditor {
|
|
|
653
1350
|
dataOffset = off + 32;
|
|
654
1351
|
dataSize = item.size || (item.rawValue ? item.rawValue.length : 0);
|
|
655
1352
|
maxSize = (item.span - 1) * 32;
|
|
656
|
-
if (dataSize <= 0) {
|
|
1353
|
+
if (dataSize <= 0) {
|
|
1354
|
+
alert("No data to edit");
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
657
1357
|
}
|
|
658
1358
|
|
|
659
1359
|
const entryData = this.data.slice(dataOffset, dataOffset + dataSize);
|
|
@@ -662,18 +1362,23 @@ export class NVSEditor {
|
|
|
662
1362
|
this._hexEditorInstance = new HexEditor(this._hexEditorContainer);
|
|
663
1363
|
}
|
|
664
1364
|
|
|
665
|
-
this._hexEditorContainer.classList.remove(
|
|
1365
|
+
this._hexEditorContainer.classList.remove("hidden");
|
|
666
1366
|
this._hexEditorInstance.open(entryData, 0);
|
|
667
1367
|
|
|
668
1368
|
// Relabel button and show entry info
|
|
669
|
-
const writeBtn = this._hexEditorContainer.querySelector(
|
|
670
|
-
if (writeBtn) writeBtn.textContent =
|
|
1369
|
+
const writeBtn = this._hexEditorContainer.querySelector("#hexedWrite");
|
|
1370
|
+
if (writeBtn) writeBtn.textContent = "Apply Changes";
|
|
671
1371
|
|
|
672
|
-
this._hexEditorInstance.onWriteFlash = async (
|
|
1372
|
+
this._hexEditorInstance.onWriteFlash = async (
|
|
1373
|
+
editedData,
|
|
1374
|
+
modifiedOffsets,
|
|
1375
|
+
) => {
|
|
673
1376
|
if (modifiedOffsets.size === 0) return;
|
|
674
1377
|
|
|
675
1378
|
if (editedData.length > maxSize) {
|
|
676
|
-
alert(
|
|
1379
|
+
alert(
|
|
1380
|
+
"Edited data exceeds available slot size (" + maxSize + " bytes)",
|
|
1381
|
+
);
|
|
677
1382
|
return;
|
|
678
1383
|
}
|
|
679
1384
|
|
|
@@ -682,11 +1387,11 @@ export class NVSEditor {
|
|
|
682
1387
|
this.data.set(editedData.slice(0, 8), dataOffset);
|
|
683
1388
|
} else {
|
|
684
1389
|
// Clear payload area, then write
|
|
685
|
-
this.data.fill(
|
|
1390
|
+
this.data.fill(0xff, dataOffset, dataOffset + maxSize);
|
|
686
1391
|
this.data.set(editedData, dataOffset);
|
|
687
1392
|
// Update size field
|
|
688
|
-
this.data[off + 24] = editedData.length &
|
|
689
|
-
this.data[off + 25] = (editedData.length >> 8) &
|
|
1393
|
+
this.data[off + 24] = editedData.length & 0xff;
|
|
1394
|
+
this.data[off + 25] = (editedData.length >> 8) & 0xff;
|
|
690
1395
|
// Update data CRC
|
|
691
1396
|
const crc = NVSEditor.crc32(editedData);
|
|
692
1397
|
const dv = new DataView(this.data.buffer, off + 28, 4);
|
|
@@ -700,13 +1405,13 @@ export class NVSEditor {
|
|
|
700
1405
|
|
|
701
1406
|
this.modified = true;
|
|
702
1407
|
|
|
703
|
-
this._hexEditorInstance.showProgress(
|
|
704
|
-
await new Promise(r => setTimeout(r, 500));
|
|
1408
|
+
this._hexEditorInstance.showProgress("Applied to NVS!", 100);
|
|
1409
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
705
1410
|
this._hexEditorInstance.hideProgress();
|
|
706
1411
|
};
|
|
707
1412
|
|
|
708
1413
|
this._hexEditorInstance.onClose = () => {
|
|
709
|
-
this._hexEditorContainer.classList.add(
|
|
1414
|
+
this._hexEditorContainer.classList.add("hidden");
|
|
710
1415
|
this._hexEditorInstance = null;
|
|
711
1416
|
this.pages = this._parse();
|
|
712
1417
|
this._renderContent();
|
|
@@ -726,7 +1431,309 @@ export class NVSEditor {
|
|
|
726
1431
|
}
|
|
727
1432
|
|
|
728
1433
|
_updateWriteButton() {
|
|
729
|
-
const btn = this.container.querySelector(
|
|
1434
|
+
const btn = this.container.querySelector("#nvsWrite");
|
|
730
1435
|
if (btn) btn.disabled = !this.modified;
|
|
731
1436
|
}
|
|
1437
|
+
|
|
1438
|
+
// ─────── UI dialogs for new features ───────
|
|
1439
|
+
|
|
1440
|
+
/** Create a modal dialog from `html`, append to <body>, wire up close handlers. */
|
|
1441
|
+
_createDialog(html) {
|
|
1442
|
+
const dialogContainer = document.createElement("div");
|
|
1443
|
+
dialogContainer.innerHTML = html;
|
|
1444
|
+
document.body.appendChild(dialogContainer);
|
|
1445
|
+
|
|
1446
|
+
const overlay = dialogContainer.querySelector(".nvs-dialog-overlay");
|
|
1447
|
+
dialogContainer
|
|
1448
|
+
.querySelector(".nvs-dialog-close")
|
|
1449
|
+
.addEventListener("click", () => {
|
|
1450
|
+
dialogContainer.remove();
|
|
1451
|
+
});
|
|
1452
|
+
overlay.addEventListener("click", (e) => {
|
|
1453
|
+
if (e.target === overlay) dialogContainer.remove();
|
|
1454
|
+
});
|
|
1455
|
+
return dialogContainer;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
_showStats() {
|
|
1459
|
+
const stats = this.getStatistics();
|
|
1460
|
+
const blobs = this.getBlobs();
|
|
1461
|
+
const blobIntegrity = this.checkBlobIntegrity(blobs);
|
|
1462
|
+
const issues = this.getIntegrityIssues();
|
|
1463
|
+
|
|
1464
|
+
const ok =
|
|
1465
|
+
stats.entries_bad_header_crc === 0 &&
|
|
1466
|
+
stats.entries_bad_data_crc === 0 &&
|
|
1467
|
+
stats.pages_bad_header_crc === 0 &&
|
|
1468
|
+
stats.pages_corrupted === 0 &&
|
|
1469
|
+
blobIntegrity.incomplete === 0 &&
|
|
1470
|
+
issues.corruptedPages.length === 0 &&
|
|
1471
|
+
issues.malformedEntries.length === 0;
|
|
1472
|
+
|
|
1473
|
+
const hex = (n) =>
|
|
1474
|
+
"0x" + (n >>> 0).toString(16).toUpperCase().padStart(8, "0");
|
|
1475
|
+
const esc = (s) => this._esc(String(s));
|
|
1476
|
+
|
|
1477
|
+
const pageActions = (pageOffset) => `
|
|
1478
|
+
<span class="nvs-issue-actions">
|
|
1479
|
+
<button class="nvs-issue-btn nvs-issue-goto-page" data-page-offset="${pageOffset}" title="Jump to page in editor">↪ Go to page</button>
|
|
1480
|
+
</span>`;
|
|
1481
|
+
const entryActions = (entryOffset) => `
|
|
1482
|
+
<span class="nvs-issue-actions">
|
|
1483
|
+
<button class="nvs-issue-btn nvs-issue-goto-entry" data-offset="${entryOffset}" title="Jump to entry in editor">↪ Go to</button>
|
|
1484
|
+
<button class="nvs-issue-btn nvs-issue-edit" data-offset="${entryOffset}" title="Edit entry">✎ Edit</button>
|
|
1485
|
+
<button class="nvs-issue-btn nvs-issue-delete" data-offset="${entryOffset}" title="Delete entry">✕ Delete</button>
|
|
1486
|
+
</span>`;
|
|
1487
|
+
|
|
1488
|
+
let detailsHtml = "";
|
|
1489
|
+
if (!ok) {
|
|
1490
|
+
detailsHtml += "<h4>🔍 Issue Details</h4>";
|
|
1491
|
+
|
|
1492
|
+
if (issues.corruptedPages.length) {
|
|
1493
|
+
detailsHtml +=
|
|
1494
|
+
'<div class="nvs-issue-group"><div class="nvs-issue-title nvs-error">Corrupted / unknown-state pages</div>';
|
|
1495
|
+
for (const p of issues.corruptedPages) {
|
|
1496
|
+
detailsHtml += `<div class="nvs-issue-row"><pre class="nvs-issue">Page #${p.pageIndex} offset=${hex(p.offset)} state=${esc(p.state)} stateValue=${hex(p.stateValue)}</pre>${pageActions(p.offset)}</div>`;
|
|
1497
|
+
}
|
|
1498
|
+
detailsHtml += "</div>";
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
if (issues.malformedEntries.length) {
|
|
1502
|
+
detailsHtml +=
|
|
1503
|
+
'<div class="nvs-issue-group"><div class="nvs-issue-title nvs-warn">Malformed WRITTEN entries (parser-rejected)</div>';
|
|
1504
|
+
for (const e of issues.malformedEntries) {
|
|
1505
|
+
detailsHtml += `<div class="nvs-issue-row"><pre class="nvs-issue">Page #${e.pageIndex} slot ${e.slot} ${esc(e.namespace)}::${esc(e.key)} type=${esc(e.type)} offset=${hex(e.entryOffset)} reason: ${esc(e.reason)}</pre>${pageActions(e.pageOffset)}</div>`;
|
|
1506
|
+
}
|
|
1507
|
+
detailsHtml += "</div>";
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
if (issues.pagesBadHeaderCrc.length) {
|
|
1511
|
+
detailsHtml +=
|
|
1512
|
+
'<div class="nvs-issue-group"><div class="nvs-issue-title nvs-warn">Pages with BAD header CRC</div>';
|
|
1513
|
+
for (const p of issues.pagesBadHeaderCrc) {
|
|
1514
|
+
detailsHtml += `<div class="nvs-issue-row"><pre class="nvs-issue">Page #${p.pageIndex} offset=${hex(p.offset)} stored=${hex(p.stored)} calculated=${hex(p.calculated)}</pre>${pageActions(p.offset)}</div>`;
|
|
1515
|
+
}
|
|
1516
|
+
detailsHtml += "</div>";
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
if (issues.entriesBadHeaderCrc.length) {
|
|
1520
|
+
detailsHtml +=
|
|
1521
|
+
'<div class="nvs-issue-group"><div class="nvs-issue-title nvs-warn">Entries with BAD header CRC</div>';
|
|
1522
|
+
for (const e of issues.entriesBadHeaderCrc) {
|
|
1523
|
+
detailsHtml += `<div class="nvs-issue-row"><pre class="nvs-issue">Page #${e.pageIndex} slot ${e.slot} ${esc(e.namespace)}::${esc(e.key)} type=${esc(e.type)} offset=${hex(e.entryOffset)} stored=${hex(e.stored)} calculated=${hex(e.calculated)}</pre>${entryActions(e.entryOffset)}</div>`;
|
|
1524
|
+
}
|
|
1525
|
+
detailsHtml += "</div>";
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
if (issues.entriesBadDataCrc.length) {
|
|
1529
|
+
detailsHtml +=
|
|
1530
|
+
'<div class="nvs-issue-group"><div class="nvs-issue-title nvs-warn">Entries with BAD data CRC</div>';
|
|
1531
|
+
for (const e of issues.entriesBadDataCrc) {
|
|
1532
|
+
detailsHtml += `<div class="nvs-issue-row"><pre class="nvs-issue">Page #${e.pageIndex} slot ${e.slot} ${esc(e.namespace)}::${esc(e.key)} type=${esc(e.type)} size=${e.size} offset=${hex(e.entryOffset)} stored=${hex(e.stored)} calculated=${hex(e.calculated)}</pre>${entryActions(e.entryOffset)}</div>`;
|
|
1533
|
+
}
|
|
1534
|
+
detailsHtml += "</div>";
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
if (issues.incompleteBlobs.length) {
|
|
1538
|
+
detailsHtml +=
|
|
1539
|
+
'<div class="nvs-issue-group"><div class="nvs-issue-title nvs-warn">Incomplete blobs</div>';
|
|
1540
|
+
for (const b of issues.incompleteBlobs) {
|
|
1541
|
+
const missing = b.missingIndices.length
|
|
1542
|
+
? ` missing chunks: [${b.missingIndices.join(", ")}]`
|
|
1543
|
+
: "";
|
|
1544
|
+
const present = b.presentIndices.length
|
|
1545
|
+
? ` present chunks: [${b.presentIndices.join(", ")}]`
|
|
1546
|
+
: " no chunks present";
|
|
1547
|
+
const actions =
|
|
1548
|
+
b.indexEntryOffset != null ? entryActions(b.indexEntryOffset) : "";
|
|
1549
|
+
detailsHtml += `<div class="nvs-issue-row"><pre class="nvs-issue">${esc(b.key)} ${b.present}/${b.expected || "?"} chunks totalSize=${b.totalSize}${present}${missing}</pre>${actions}</div>`;
|
|
1550
|
+
}
|
|
1551
|
+
detailsHtml += "</div>";
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
const html = `
|
|
1556
|
+
<div class="nvs-dialog-overlay" id="nvsStatsDialog">
|
|
1557
|
+
<div class="nvs-dialog">
|
|
1558
|
+
<div class="nvs-dialog-header">
|
|
1559
|
+
<h3>📊 NVS Statistics & Integrity Report</h3>
|
|
1560
|
+
<button class="nvs-dialog-close">×</button>
|
|
1561
|
+
</div>
|
|
1562
|
+
<div class="nvs-dialog-body">
|
|
1563
|
+
<h4>Pages</h4>
|
|
1564
|
+
<pre>Total: ${stats.pages_total} | Active: ${stats.pages_active} | Full: ${stats.pages_full} | Empty: ${stats.pages_empty} | Erasing: ${stats.pages_freeing} | Corrupted: ${stats.pages_corrupted}</pre>
|
|
1565
|
+
${stats.pages_bad_header_crc > 0 ? `<pre class="nvs-warn">Pages with BAD header CRC: ${stats.pages_bad_header_crc}</pre>` : ""}
|
|
1566
|
+
|
|
1567
|
+
<h4>Entries</h4>
|
|
1568
|
+
<pre>Written: ${stats.entries_written} | Erased: ${stats.entries_erased} | Empty: ${stats.entries_empty}</pre>
|
|
1569
|
+
${stats.entries_bad_header_crc > 0 ? `<pre class="nvs-warn">Entries with BAD header CRC: ${stats.entries_bad_header_crc}</pre>` : ""}
|
|
1570
|
+
${stats.entries_bad_data_crc > 0 ? `<pre class="nvs-warn">Entries with BAD data CRC: ${stats.entries_bad_data_crc}</pre>` : ""}
|
|
1571
|
+
|
|
1572
|
+
<h4>Blobs</h4>
|
|
1573
|
+
<pre>Complete: ${blobIntegrity.complete} | Incomplete: ${blobIntegrity.incomplete}</pre>
|
|
1574
|
+
|
|
1575
|
+
<h4>Overall Integrity</h4>
|
|
1576
|
+
<pre class="${ok ? "nvs-ok" : "nvs-error"}">${ok ? "✓ NVS integrity: OK" : "✗ NVS integrity: ISSUES DETECTED"}</pre>
|
|
1577
|
+
|
|
1578
|
+
${detailsHtml}
|
|
1579
|
+
</div>
|
|
1580
|
+
</div>
|
|
1581
|
+
</div>`;
|
|
1582
|
+
|
|
1583
|
+
const dialogContainer = this._createDialog(html);
|
|
1584
|
+
|
|
1585
|
+
// Issue action buttons: jump / edit / delete
|
|
1586
|
+
dialogContainer.querySelectorAll(".nvs-issue-goto-page").forEach((btn) => {
|
|
1587
|
+
btn.addEventListener("click", () => {
|
|
1588
|
+
const off = parseInt(btn.dataset.pageOffset, 10);
|
|
1589
|
+
dialogContainer.remove();
|
|
1590
|
+
this._scrollToPage(off);
|
|
1591
|
+
});
|
|
1592
|
+
});
|
|
1593
|
+
dialogContainer.querySelectorAll(".nvs-issue-goto-entry").forEach((btn) => {
|
|
1594
|
+
btn.addEventListener("click", () => {
|
|
1595
|
+
const off = parseInt(btn.dataset.offset, 10);
|
|
1596
|
+
dialogContainer.remove();
|
|
1597
|
+
this._scrollToEntry(off);
|
|
1598
|
+
});
|
|
1599
|
+
});
|
|
1600
|
+
dialogContainer.querySelectorAll(".nvs-issue-edit").forEach((btn) => {
|
|
1601
|
+
btn.addEventListener("click", () => {
|
|
1602
|
+
const off = parseInt(btn.dataset.offset, 10);
|
|
1603
|
+
dialogContainer.remove();
|
|
1604
|
+
this._scrollToEntry(off);
|
|
1605
|
+
this._editItem(off);
|
|
1606
|
+
});
|
|
1607
|
+
});
|
|
1608
|
+
dialogContainer.querySelectorAll(".nvs-issue-delete").forEach((btn) => {
|
|
1609
|
+
btn.addEventListener("click", () => {
|
|
1610
|
+
const off = parseInt(btn.dataset.offset, 10);
|
|
1611
|
+
dialogContainer.remove();
|
|
1612
|
+
this._scrollToEntry(off);
|
|
1613
|
+
this._deleteItemUI(off);
|
|
1614
|
+
});
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
_showBlobs() {
|
|
1619
|
+
const blobs = this.getBlobs();
|
|
1620
|
+
const blobIntegrity = this.checkBlobIntegrity(blobs);
|
|
1621
|
+
|
|
1622
|
+
let html = "";
|
|
1623
|
+
if (blobs.size > 0) {
|
|
1624
|
+
for (const [id, blob] of blobs) {
|
|
1625
|
+
const present = blob.chunks.length;
|
|
1626
|
+
const expected = blob.expectedChunks;
|
|
1627
|
+
let status;
|
|
1628
|
+
if (expected > 0) {
|
|
1629
|
+
status =
|
|
1630
|
+
present >= expected ? "OK" : `INCOMPLETE(${present}/${expected})`;
|
|
1631
|
+
} else {
|
|
1632
|
+
status = present > 0 ? "OK" : "EMPTY";
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
html += `
|
|
1636
|
+
<div class="nvs-blob-item">
|
|
1637
|
+
<div><strong>Namespace:</strong> ${this._esc(blob.namespace)}</div>
|
|
1638
|
+
<div><strong>Key:</strong> ${this._esc(blob.key)}</div>
|
|
1639
|
+
<div><strong>Total Size:</strong> ${blob.totalSize} bytes</div>
|
|
1640
|
+
<div><strong>Chunks:</strong> ${present}/${expected}</div>
|
|
1641
|
+
<div><strong>Status:</strong> <span class="${status === "OK" ? "nvs-ok" : "nvs-warn"}">${status}</span></div>
|
|
1642
|
+
<button class="nvs-blob-dump" data-key="${this._esc(id)}" title="Show hex dump">📄 Hex Dump</button>
|
|
1643
|
+
<button class="nvs-blob-download" data-key="${this._esc(id)}" title="Download blob">⬇️ Download</button>
|
|
1644
|
+
</div>`;
|
|
1645
|
+
}
|
|
1646
|
+
} else {
|
|
1647
|
+
html = '<div class="nvs-empty">No blob entries found.</div>';
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
const dialogHtml = `
|
|
1651
|
+
<div class="nvs-dialog-overlay" id="nvsBlobsDialog">
|
|
1652
|
+
<div class="nvs-dialog">
|
|
1653
|
+
<div class="nvs-dialog-header">
|
|
1654
|
+
<h3>📦 Blobs Found (${blobs.size})</h3>
|
|
1655
|
+
<button class="nvs-dialog-close">×</button>
|
|
1656
|
+
</div>
|
|
1657
|
+
<div class="nvs-dialog-body">
|
|
1658
|
+
${html}
|
|
1659
|
+
</div>
|
|
1660
|
+
</div>
|
|
1661
|
+
</div>`;
|
|
1662
|
+
|
|
1663
|
+
const dialogContainer = this._createDialog(dialogHtml);
|
|
1664
|
+
|
|
1665
|
+
// Blob dump buttons
|
|
1666
|
+
dialogContainer.querySelectorAll(".nvs-blob-dump").forEach((btn) => {
|
|
1667
|
+
btn.addEventListener("click", () => {
|
|
1668
|
+
const id = btn.dataset.key;
|
|
1669
|
+
const blob = blobs.get(id);
|
|
1670
|
+
if (!blob) return;
|
|
1671
|
+
const blobData = this.getBlobData(blob);
|
|
1672
|
+
const hexDump = NVSEditor.hexDump(blobData);
|
|
1673
|
+
|
|
1674
|
+
const dialogBody = dialogContainer.querySelector(".nvs-dialog-body");
|
|
1675
|
+
if (!dialogBody) return;
|
|
1676
|
+
|
|
1677
|
+
// Reuse a single hex-dump pane per qualified blob id inside this dialog.
|
|
1678
|
+
// Append a djb2 hash of the original id to avoid collisions when two
|
|
1679
|
+
// different ids sanitize to the same string.
|
|
1680
|
+
let _h = 5381;
|
|
1681
|
+
for (let _i = 0; _i < id.length; _i++)
|
|
1682
|
+
_h = ((_h << 5) + _h) ^ id.charCodeAt(_i);
|
|
1683
|
+
const idHash = (_h >>> 0).toString(16).padStart(8, "0");
|
|
1684
|
+
const paneId = `nvs-hex-dump-${id.replace(/[^A-Za-z0-9_-]/g, "_")}-${idHash}`;
|
|
1685
|
+
let pre = dialogBody.querySelector(`#${CSS.escape(paneId)}`);
|
|
1686
|
+
if (!pre) {
|
|
1687
|
+
pre = document.createElement("pre");
|
|
1688
|
+
pre.id = paneId;
|
|
1689
|
+
pre.className = "nvs-hex-dump";
|
|
1690
|
+
pre.style.fontFamily =
|
|
1691
|
+
'"SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
|
|
1692
|
+
pre.style.fontSize = "12px";
|
|
1693
|
+
pre.style.background = "#f5f5f5";
|
|
1694
|
+
pre.style.border = "1px solid #e0e0e0";
|
|
1695
|
+
pre.style.borderRadius = "6px";
|
|
1696
|
+
pre.style.padding = "10px";
|
|
1697
|
+
pre.style.maxHeight = "400px";
|
|
1698
|
+
pre.style.overflow = "auto";
|
|
1699
|
+
pre.style.whiteSpace = "pre";
|
|
1700
|
+
pre.style.margin = "8px 0 18px 0";
|
|
1701
|
+
dialogBody.appendChild(pre);
|
|
1702
|
+
}
|
|
1703
|
+
// textContent escapes HTML by default
|
|
1704
|
+
pre.textContent = `Hex dump for ${id}:\n\n${hexDump}`;
|
|
1705
|
+
pre.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
1706
|
+
});
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
// Blob download buttons
|
|
1710
|
+
dialogContainer.querySelectorAll(".nvs-blob-download").forEach((btn) => {
|
|
1711
|
+
btn.addEventListener("click", () => {
|
|
1712
|
+
const id = btn.dataset.key;
|
|
1713
|
+
const blob = blobs.get(id);
|
|
1714
|
+
if (!blob) return;
|
|
1715
|
+
const blobData = this.getBlobData(blob);
|
|
1716
|
+
const fileBlob = new Blob([blobData], {
|
|
1717
|
+
type: "application/octet-stream",
|
|
1718
|
+
});
|
|
1719
|
+
const url = URL.createObjectURL(fileBlob);
|
|
1720
|
+
const a = document.createElement("a");
|
|
1721
|
+
a.href = url;
|
|
1722
|
+
// Filename uses namespace + key to avoid collisions when downloading multiple blobs
|
|
1723
|
+
const safeName = `${blob.namespace}_${blob.key}`.replace(
|
|
1724
|
+
/[^A-Za-z0-9._-]/g,
|
|
1725
|
+
"_",
|
|
1726
|
+
);
|
|
1727
|
+
a.download = `${safeName}.bin`;
|
|
1728
|
+
a.style.display = "none";
|
|
1729
|
+
document.body.appendChild(a);
|
|
1730
|
+
a.click();
|
|
1731
|
+
// Defer cleanup so the download has time to start before the URL is revoked
|
|
1732
|
+
setTimeout(() => {
|
|
1733
|
+
URL.revokeObjectURL(url);
|
|
1734
|
+
a.remove();
|
|
1735
|
+
}, 0);
|
|
1736
|
+
});
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
732
1739
|
}
|