@bota.dev/web-app-sdk 2.0.0-beta.0
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/LICENSE +21 -0
- package/README.md +517 -0
- package/dist/capabilities.d.ts +8 -0
- package/dist/capabilities.js +21 -0
- package/dist/client.d.ts +39 -0
- package/dist/client.js +153 -0
- package/dist/controlManager.d.ts +38 -0
- package/dist/controlManager.js +344 -0
- package/dist/core.d.ts +698 -0
- package/dist/core.js +13 -0
- package/dist/deviceManager.d.ts +52 -0
- package/dist/deviceManager.js +491 -0
- package/dist/encryptedUploadV2Host.d.ts +239 -0
- package/dist/encryptedUploadV2Host.js +2136 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.js +230 -0
- package/dist/gatt.d.ts +39 -0
- package/dist/gatt.js +57 -0
- package/dist/generated/bota_device_sdk_core.d.ts +202 -0
- package/dist/generated/bota_device_sdk_core.js +1025 -0
- package/dist/generated/bota_device_sdk_core_bg.wasm +0 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +2 -0
- package/dist/indexedDbWorkflowStore.d.ts +52 -0
- package/dist/indexedDbWorkflowStore.js +763 -0
- package/dist/logManager.d.ts +24 -0
- package/dist/logManager.js +205 -0
- package/dist/models.d.ts +217 -0
- package/dist/models.js +1 -0
- package/dist/opfsBlobStore.d.ts +12 -0
- package/dist/opfsBlobStore.js +250 -0
- package/dist/otaManager.d.ts +49 -0
- package/dist/otaManager.js +951 -0
- package/dist/providerCancellation.d.ts +2 -0
- package/dist/providerCancellation.js +23 -0
- package/dist/providers.d.ts +138 -0
- package/dist/providers.js +1 -0
- package/dist/provisioningManager.d.ts +48 -0
- package/dist/provisioningManager.js +753 -0
- package/dist/recordingManager.d.ts +53 -0
- package/dist/recordingManager.js +1397 -0
- package/dist/storage.d.ts +103 -0
- package/dist/storage.js +60 -0
- package/dist/transport.d.ts +33 -0
- package/dist/transport.js +8 -0
- package/dist/wasmCore.d.ts +3 -0
- package/dist/wasmCore.js +1651 -0
- package/dist/webBluetoothTransport.d.ts +23 -0
- package/dist/webBluetoothTransport.js +369 -0
- package/dist/wifiManager.d.ts +54 -0
- package/dist/wifiManager.js +528 -0
- package/dist/workflowRuntime.d.ts +115 -0
- package/dist/workflowRuntime.js +1508 -0
- package/package.json +46 -0
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
import { verifyActiveDeviceSerial, } from "./deviceManager.js";
|
|
2
|
+
import { BotaSDKError, normalizeCoreError, } from "./errors.js";
|
|
3
|
+
import { awaitProviderCall } from "./providerCancellation.js";
|
|
4
|
+
import { BrowserStorageError, } from "./storage.js";
|
|
5
|
+
import { BrowserTransportError, } from "./transport.js";
|
|
6
|
+
import { createBrowserPersistenceHost, } from "./workflowRuntime.js";
|
|
7
|
+
const MAX_DOWNLOAD_WRITE_BYTES = 64 * 1024;
|
|
8
|
+
const MAX_FIRMWARE_SIZE = 0xffff_ffff;
|
|
9
|
+
const FIRMWARE_CHUNK_SIZE = 500;
|
|
10
|
+
const CONNECTION_TIMEOUT_MS = 15000n;
|
|
11
|
+
const IDENTIFIER_PATTERN = /^\S(?:[\s\S]*\S)?$/u;
|
|
12
|
+
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
13
|
+
export class OTAManager {
|
|
14
|
+
core;
|
|
15
|
+
transport;
|
|
16
|
+
runtime;
|
|
17
|
+
devices;
|
|
18
|
+
storage;
|
|
19
|
+
provider;
|
|
20
|
+
fetcher;
|
|
21
|
+
now;
|
|
22
|
+
activeOperations = new Map();
|
|
23
|
+
destroyed = false;
|
|
24
|
+
destroyPromise = null;
|
|
25
|
+
constructor(options) {
|
|
26
|
+
this.core = options.core;
|
|
27
|
+
this.transport = options.transport;
|
|
28
|
+
this.runtime = options.runtime;
|
|
29
|
+
this.devices = options.devices;
|
|
30
|
+
this.storage = options.storage ?? null;
|
|
31
|
+
this.provider = options.provider ?? null;
|
|
32
|
+
this.fetcher = options.fetcher
|
|
33
|
+
?? ((input, init) => globalThis.fetch(input, init));
|
|
34
|
+
this.now = options.now ?? Date.now;
|
|
35
|
+
}
|
|
36
|
+
async updateFirmware(image, options = {}) {
|
|
37
|
+
this.ensureFirmwareCapability(options.signal);
|
|
38
|
+
validateImage(image);
|
|
39
|
+
const operationId = options.operationId ?? createOperationId();
|
|
40
|
+
validateOperationId(operationId);
|
|
41
|
+
const storage = this.requireStorage();
|
|
42
|
+
this.requireProvider();
|
|
43
|
+
await this.runManagedOperation(operationId, options.signal, async (signal) => {
|
|
44
|
+
const [existingJournal, existingCheckpoint] = await Promise.all([
|
|
45
|
+
storage.loadFirmwareJournal(operationId),
|
|
46
|
+
storage.loadWorkflowCheckpoint(operationId),
|
|
47
|
+
]);
|
|
48
|
+
if (existingJournal || existingCheckpoint) {
|
|
49
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
50
|
+
}
|
|
51
|
+
throwIfAborted(signal);
|
|
52
|
+
const connected = await verifyActiveDeviceSerial(this.devices, 'update_firmware');
|
|
53
|
+
const hint = await this.loadReconnectHint(connected.serialNumber);
|
|
54
|
+
if (hint.browserDeviceId !== connected.id) {
|
|
55
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
56
|
+
}
|
|
57
|
+
const journal = createJournal(operationId, connected.serialNumber, image, this.now);
|
|
58
|
+
await storage.saveFirmwareJournal(journal);
|
|
59
|
+
throwIfAborted(signal);
|
|
60
|
+
await this.runFirmwareWorkflow(journal, hint, signal, options.onProgress);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
async resumeFirmwareUpdate(operationId, options = {}) {
|
|
64
|
+
this.ensureFirmwareCapability(options.signal);
|
|
65
|
+
validateOperationId(operationId);
|
|
66
|
+
const storage = this.requireStorage();
|
|
67
|
+
await this.runManagedOperation(operationId, options.signal, async (signal) => {
|
|
68
|
+
const journal = await storage.loadFirmwareJournal(operationId);
|
|
69
|
+
if (!journal) {
|
|
70
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
71
|
+
}
|
|
72
|
+
validateJournal(journal, operationId);
|
|
73
|
+
const checkpoint = await storage.loadWorkflowCheckpoint(operationId);
|
|
74
|
+
validateCheckpoint(checkpoint, journal);
|
|
75
|
+
throwIfAborted(signal);
|
|
76
|
+
if (firmwareJournalState(journal) === 'cleanup_only') {
|
|
77
|
+
await this.finishFirmwareCleanup(journal);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (!journal.verified)
|
|
81
|
+
this.requireProvider();
|
|
82
|
+
const artifact = this.createArtifactHost(journal);
|
|
83
|
+
if (journal.verified && !(await artifact.hasCompatibleVerifiedBlob())) {
|
|
84
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
85
|
+
}
|
|
86
|
+
const hint = await this.loadReconnectHint(journal.serialNumber);
|
|
87
|
+
const reconnecting = checkpointPhase(checkpoint) === 'reconnecting';
|
|
88
|
+
if (reconnecting) {
|
|
89
|
+
this.runtime.registerDevice({
|
|
90
|
+
id: hint.browserDeviceId,
|
|
91
|
+
name: hint.name,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
let connected;
|
|
96
|
+
if (!this.devices.connectedDevice
|
|
97
|
+
&& !this.runtime.connectedDeviceHandle) {
|
|
98
|
+
connected = await this.recoverExactConnection(journal, hint, signal);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
connected = await verifyActiveDeviceSerial(this.devices, 'update_firmware');
|
|
102
|
+
}
|
|
103
|
+
if (connected.serialNumber !== journal.serialNumber
|
|
104
|
+
|| connected.id !== hint.browserDeviceId) {
|
|
105
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
throwIfAborted(signal);
|
|
109
|
+
await this.runFirmwareWorkflow(journal, hint, signal, options.onProgress, artifact);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async cancelFirmwareUpdate(operationId) {
|
|
113
|
+
if (this.destroyed) {
|
|
114
|
+
throw new BotaSDKError('cancelled', 'update_firmware');
|
|
115
|
+
}
|
|
116
|
+
validateOperationId(operationId);
|
|
117
|
+
const active = this.activeOperations.get(operationId);
|
|
118
|
+
if (active) {
|
|
119
|
+
active.controller.abort();
|
|
120
|
+
await this.runtime.cancel(operationId);
|
|
121
|
+
await active.settled;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const storage = this.requireStorage();
|
|
125
|
+
const journal = await storage.loadFirmwareJournal(operationId);
|
|
126
|
+
if (!journal)
|
|
127
|
+
return;
|
|
128
|
+
validateJournal(journal, operationId);
|
|
129
|
+
if (firmwareJournalState(journal) === 'cleanup_only') {
|
|
130
|
+
await this.finishFirmwareCleanup(journal);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const artifact = this.createArtifactHost(journal);
|
|
134
|
+
if (!journal.verified || !(await artifact.hasCompatibleVerifiedBlob())) {
|
|
135
|
+
await artifact.deleteBlob();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
destroy() {
|
|
139
|
+
if (this.destroyPromise)
|
|
140
|
+
return this.destroyPromise;
|
|
141
|
+
this.destroyed = true;
|
|
142
|
+
this.destroyPromise = (async () => {
|
|
143
|
+
const active = [...this.activeOperations.entries()];
|
|
144
|
+
for (const [operationId, operation] of active) {
|
|
145
|
+
operation.controller.abort();
|
|
146
|
+
await this.runtime.cancel(operationId);
|
|
147
|
+
}
|
|
148
|
+
await Promise.all(active.map(([, operation]) => operation.settled));
|
|
149
|
+
})();
|
|
150
|
+
return this.destroyPromise;
|
|
151
|
+
}
|
|
152
|
+
async runFirmwareWorkflow(journal, hint, signal, onProgress, existingArtifact) {
|
|
153
|
+
const artifact = existingArtifact ?? this.createArtifactHost(journal);
|
|
154
|
+
const cancellationId = randomBytes(16);
|
|
155
|
+
throwIfAborted(signal);
|
|
156
|
+
try {
|
|
157
|
+
await this.runtime.run(journal.operationId, cancellationId, () => this.core.startFirmwareUpdate({
|
|
158
|
+
serialNumber: journal.serialNumber,
|
|
159
|
+
version: journal.version,
|
|
160
|
+
sizeBytes: journal.sizeBytes,
|
|
161
|
+
crc32: journal.crc32,
|
|
162
|
+
downloadId: journal.downloadId,
|
|
163
|
+
reconnectHint: {
|
|
164
|
+
storedPeripheralId: hint.browserDeviceId,
|
|
165
|
+
advertisedAddress: null,
|
|
166
|
+
storedName: hint.name,
|
|
167
|
+
scanTimeoutMs: CONNECTION_TIMEOUT_MS,
|
|
168
|
+
connectionTimeoutMs: CONNECTION_TIMEOUT_MS,
|
|
169
|
+
},
|
|
170
|
+
cancellationId: cancellationId.slice(),
|
|
171
|
+
}), {
|
|
172
|
+
persistence: this.createFirmwarePersistenceHost(journal),
|
|
173
|
+
network: artifact,
|
|
174
|
+
firmwareBlob: artifact,
|
|
175
|
+
}, {
|
|
176
|
+
onNotification: (notification) => {
|
|
177
|
+
if (notification.kind !== 'firmware_progress')
|
|
178
|
+
return;
|
|
179
|
+
onProgress?.({
|
|
180
|
+
phase: notification.phase,
|
|
181
|
+
completedBytes: notification.completedBytes,
|
|
182
|
+
totalBytes: notification.totalBytes,
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
}, async (result) => {
|
|
186
|
+
this.devices.adoptWorkflowConnection(result, journal.serialNumber);
|
|
187
|
+
await this.finishFirmwareCleanup(journal);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
const normalized = otaError(error);
|
|
192
|
+
if (normalized.code === 'integrity_failed') {
|
|
193
|
+
await this.deleteLatestBlob(journal).catch(() => undefined);
|
|
194
|
+
}
|
|
195
|
+
throw normalized;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
createArtifactHost(journal) {
|
|
199
|
+
return new FirmwareArtifactHost({
|
|
200
|
+
core: this.core,
|
|
201
|
+
storage: this.requireStorage(),
|
|
202
|
+
provider: this.provider,
|
|
203
|
+
fetcher: this.fetcher,
|
|
204
|
+
journal,
|
|
205
|
+
now: this.now,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
createFirmwarePersistenceHost(expected) {
|
|
209
|
+
const storage = this.requireStorage();
|
|
210
|
+
const base = createBrowserPersistenceHost(storage, this.now);
|
|
211
|
+
return {
|
|
212
|
+
execute: async (envelope, context) => {
|
|
213
|
+
if (envelope.effect.kind !== 'persistence_delete_checkpoint') {
|
|
214
|
+
return await base.execute(envelope, context);
|
|
215
|
+
}
|
|
216
|
+
const latest = await storage.loadFirmwareJournal(expected.operationId);
|
|
217
|
+
if (!latest) {
|
|
218
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
219
|
+
}
|
|
220
|
+
validateCompatibleJournal(latest, expected);
|
|
221
|
+
if (firmwareJournalState(latest) !== 'cleanup_only') {
|
|
222
|
+
await storage.saveFirmwareJournal({
|
|
223
|
+
...latest,
|
|
224
|
+
state: 'cleanup_only',
|
|
225
|
+
updatedAtEpochMs: Math.max(latest.updatedAtEpochMs, this.now()),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
await storage.deleteWorkflowCheckpoint(expected.operationId);
|
|
229
|
+
return null;
|
|
230
|
+
},
|
|
231
|
+
cancel: async () => {
|
|
232
|
+
await base.cancel();
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
async finishFirmwareCleanup(expected) {
|
|
237
|
+
const storage = this.requireStorage();
|
|
238
|
+
const latest = await storage.loadFirmwareJournal(expected.operationId);
|
|
239
|
+
if (!latest)
|
|
240
|
+
return;
|
|
241
|
+
validateCompatibleJournal(latest, expected);
|
|
242
|
+
if (firmwareJournalState(latest) !== 'cleanup_only') {
|
|
243
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
244
|
+
}
|
|
245
|
+
await storage.deleteWorkflowCheckpoint(expected.operationId);
|
|
246
|
+
await (await storage.openBlob(latest.blobId)).delete();
|
|
247
|
+
await storage.deleteFirmwareJournal(expected.operationId);
|
|
248
|
+
}
|
|
249
|
+
async recoverExactConnection(journal, hint, signal) {
|
|
250
|
+
throwIfAborted(signal);
|
|
251
|
+
let authorized;
|
|
252
|
+
try {
|
|
253
|
+
authorized = await this.transport.getAuthorizedDevices();
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
throw asFirmwareError(error);
|
|
257
|
+
}
|
|
258
|
+
throwIfAborted(signal);
|
|
259
|
+
const exact = authorized.find((device) => device.id === hint.browserDeviceId);
|
|
260
|
+
if (!exact)
|
|
261
|
+
throw new BotaSDKError('picker_required', 'update_firmware');
|
|
262
|
+
this.runtime.registerDevice(exact);
|
|
263
|
+
const cancellationId = randomBytes(16);
|
|
264
|
+
const connectionOperationId = `connect:${bytesHex(cancellationId)}`;
|
|
265
|
+
const cancel = () => {
|
|
266
|
+
void this.runtime.cancel(connectionOperationId).catch(() => undefined);
|
|
267
|
+
};
|
|
268
|
+
const running = this.runtime.run(connectionOperationId, cancellationId, () => this.core.startExactConnection({
|
|
269
|
+
expectedSerialNumber: journal.serialNumber,
|
|
270
|
+
peripheralId: exact.id,
|
|
271
|
+
name: exact.name,
|
|
272
|
+
cancellationId: cancellationId.slice(),
|
|
273
|
+
}), { persistence: createBrowserPersistenceHost(this.requireStorage(), this.now) });
|
|
274
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
275
|
+
if (signal.aborted)
|
|
276
|
+
cancel();
|
|
277
|
+
try {
|
|
278
|
+
const result = await running;
|
|
279
|
+
const connected = this.devices.adoptWorkflowConnection(result, journal.serialNumber);
|
|
280
|
+
return { id: connected.id, serialNumber: connected.serialNumber };
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
throw asFirmwareError(error);
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
signal.removeEventListener('abort', cancel);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
async deleteLatestBlob(expected) {
|
|
290
|
+
const storage = this.requireStorage();
|
|
291
|
+
const latest = await storage.loadFirmwareJournal(expected.operationId);
|
|
292
|
+
if (!latest)
|
|
293
|
+
return;
|
|
294
|
+
validateCompatibleJournal(latest, expected);
|
|
295
|
+
await (await storage.openBlob(latest.blobId)).delete();
|
|
296
|
+
}
|
|
297
|
+
async loadReconnectHint(serialNumber) {
|
|
298
|
+
const hint = await this.requireStorage().loadVerifiedDevice(serialNumber);
|
|
299
|
+
if (!hint || hint.serialNumber !== serialNumber) {
|
|
300
|
+
throw new BotaSDKError('picker_required', 'update_firmware');
|
|
301
|
+
}
|
|
302
|
+
return hint;
|
|
303
|
+
}
|
|
304
|
+
requireConnectedDevice() {
|
|
305
|
+
const connected = this.devices.connectedDevice;
|
|
306
|
+
const runtimeDevice = this.runtime.connectedDeviceHandle;
|
|
307
|
+
if (!connected || !runtimeDevice || connected.id !== runtimeDevice.id) {
|
|
308
|
+
throw new BotaSDKError('device_disconnected', 'update_firmware');
|
|
309
|
+
}
|
|
310
|
+
return { id: connected.id, serialNumber: connected.serialNumber };
|
|
311
|
+
}
|
|
312
|
+
async runManagedOperation(operationId, externalSignal, body) {
|
|
313
|
+
if (this.activeOperations.has(operationId)) {
|
|
314
|
+
throw new BotaSDKError('operation_in_progress', 'update_firmware');
|
|
315
|
+
}
|
|
316
|
+
throwIfAborted(externalSignal);
|
|
317
|
+
const controller = new AbortController();
|
|
318
|
+
const settled = deferredVoid();
|
|
319
|
+
const abort = () => controller.abort();
|
|
320
|
+
const cancelRuntime = () => {
|
|
321
|
+
void this.runtime.cancel(operationId).catch(() => undefined);
|
|
322
|
+
};
|
|
323
|
+
externalSignal?.addEventListener('abort', abort, { once: true });
|
|
324
|
+
controller.signal.addEventListener('abort', cancelRuntime, { once: true });
|
|
325
|
+
if (externalSignal?.aborted)
|
|
326
|
+
controller.abort();
|
|
327
|
+
const active = {
|
|
328
|
+
controller,
|
|
329
|
+
settled: settled.promise,
|
|
330
|
+
settle: settled.resolve,
|
|
331
|
+
removeExternalAbort: () => {
|
|
332
|
+
externalSignal?.removeEventListener('abort', abort);
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
this.activeOperations.set(operationId, active);
|
|
336
|
+
try {
|
|
337
|
+
await body(controller.signal);
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
throw otaError(error);
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
active.removeExternalAbort();
|
|
344
|
+
controller.signal.removeEventListener('abort', cancelRuntime);
|
|
345
|
+
if (this.activeOperations.get(operationId) === active) {
|
|
346
|
+
this.activeOperations.delete(operationId);
|
|
347
|
+
}
|
|
348
|
+
active.settle();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
ensureFirmwareCapability(signal) {
|
|
352
|
+
if (this.destroyed || signal?.aborted) {
|
|
353
|
+
throw new BotaSDKError('cancelled', 'update_firmware');
|
|
354
|
+
}
|
|
355
|
+
if (!this.devices.getCapabilities().firmwareUpdate) {
|
|
356
|
+
throw new BotaSDKError('unsupported_capability', 'update_firmware');
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
requireStorage() {
|
|
360
|
+
if (!this.storage) {
|
|
361
|
+
throw new BotaSDKError('storage_unavailable', 'update_firmware');
|
|
362
|
+
}
|
|
363
|
+
return this.storage;
|
|
364
|
+
}
|
|
365
|
+
requireProvider() {
|
|
366
|
+
if (!this.provider) {
|
|
367
|
+
throw new BotaSDKError('unsupported_capability', 'update_firmware');
|
|
368
|
+
}
|
|
369
|
+
return this.provider;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
class FirmwareArtifactHost {
|
|
373
|
+
core;
|
|
374
|
+
storage;
|
|
375
|
+
provider;
|
|
376
|
+
fetcher;
|
|
377
|
+
journal;
|
|
378
|
+
now;
|
|
379
|
+
fetchAbortController = new AbortController();
|
|
380
|
+
pending = new Set();
|
|
381
|
+
reader = null;
|
|
382
|
+
cancelled = false;
|
|
383
|
+
constructor(options) {
|
|
384
|
+
this.core = options.core;
|
|
385
|
+
this.storage = options.storage;
|
|
386
|
+
this.provider = options.provider;
|
|
387
|
+
this.fetcher = options.fetcher;
|
|
388
|
+
this.journal = { ...options.journal };
|
|
389
|
+
this.now = options.now;
|
|
390
|
+
}
|
|
391
|
+
async execute(envelope, context) {
|
|
392
|
+
switch (envelope.effect.kind) {
|
|
393
|
+
case 'network_download':
|
|
394
|
+
return await this.download(envelope, context);
|
|
395
|
+
case 'firmware_blob_read_chunk':
|
|
396
|
+
return await this.readChunk(envelope, context);
|
|
397
|
+
default:
|
|
398
|
+
throw new BotaSDKError('internal_error', 'update_firmware');
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async cancel() {
|
|
402
|
+
this.cancelled = true;
|
|
403
|
+
this.fetchAbortController.abort();
|
|
404
|
+
const reader = this.reader;
|
|
405
|
+
if (reader) {
|
|
406
|
+
await this.track(reader.cancel()).catch(() => undefined);
|
|
407
|
+
}
|
|
408
|
+
await this.awaitPending();
|
|
409
|
+
const latest = await this.track(this.storage.loadFirmwareJournal(this.journal.operationId)).catch(() => null);
|
|
410
|
+
if (!latest)
|
|
411
|
+
return;
|
|
412
|
+
try {
|
|
413
|
+
validateCompatibleJournal(latest, this.journal);
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (latest.verified
|
|
419
|
+
&& await this.hasCompatibleVerifiedBlob(latest, true))
|
|
420
|
+
return;
|
|
421
|
+
await this.deleteBlob(latest).catch(() => undefined);
|
|
422
|
+
}
|
|
423
|
+
async hasCompatibleVerifiedBlob(candidate = this.journal, allowCancelled = false) {
|
|
424
|
+
try {
|
|
425
|
+
if (this.cancelled && !allowCancelled)
|
|
426
|
+
return false;
|
|
427
|
+
validateCompatibleJournal(candidate, this.journal);
|
|
428
|
+
if (!candidate.verified || candidate.downloadedBytes !== candidate.sizeBytes) {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
const blob = await this.track(this.storage.openBlob(candidate.blobId));
|
|
432
|
+
if (this.cancelled && !allowCancelled)
|
|
433
|
+
return false;
|
|
434
|
+
const size = safeBrowserNumber(await this.track(blob.size()));
|
|
435
|
+
if (this.cancelled && !allowCancelled)
|
|
436
|
+
return false;
|
|
437
|
+
if (size !== candidate.sizeBytes)
|
|
438
|
+
return false;
|
|
439
|
+
const hasher = this.core.createIntegrityHasher();
|
|
440
|
+
let offset = 0;
|
|
441
|
+
while (offset < size) {
|
|
442
|
+
const maximumLength = Math.min(MAX_DOWNLOAD_WRITE_BYTES, size - offset);
|
|
443
|
+
const bytes = await this.track(blob.read(offset, maximumLength));
|
|
444
|
+
if (this.cancelled && !allowCancelled)
|
|
445
|
+
return false;
|
|
446
|
+
if (bytes.byteLength !== maximumLength)
|
|
447
|
+
return false;
|
|
448
|
+
hasher.update(bytes);
|
|
449
|
+
offset = safeBrowserRange(offset, bytes.byteLength);
|
|
450
|
+
}
|
|
451
|
+
return hasher.length() === BigInt(candidate.sizeBytes)
|
|
452
|
+
&& hasher.crc32() === candidate.crc32
|
|
453
|
+
&& bytesHex(hasher.sha256Snapshot()) === candidate.sha256Hex;
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
if (error instanceof BrowserStorageError)
|
|
457
|
+
throw error;
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
async deleteBlob(candidate = this.journal) {
|
|
462
|
+
const blob = await this.track(this.storage.openBlob(candidate.blobId));
|
|
463
|
+
await this.track(blob.delete());
|
|
464
|
+
}
|
|
465
|
+
async download(envelope, context) {
|
|
466
|
+
if (envelope.effect.kind !== 'network_download') {
|
|
467
|
+
throw new BotaSDKError('internal_error', 'update_firmware');
|
|
468
|
+
}
|
|
469
|
+
if (envelope.effect.downloadId !== this.journal.downloadId) {
|
|
470
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
471
|
+
}
|
|
472
|
+
this.throwIfCancelled(context.signal);
|
|
473
|
+
if (await this.hasCompatibleVerifiedBlob()) {
|
|
474
|
+
await context.dispatch({
|
|
475
|
+
requestId: envelope.requestId,
|
|
476
|
+
kind: 'network_download_progress',
|
|
477
|
+
downloadId: this.journal.downloadId,
|
|
478
|
+
completedBytes: BigInt(this.journal.sizeBytes),
|
|
479
|
+
totalBytes: BigInt(this.journal.sizeBytes),
|
|
480
|
+
});
|
|
481
|
+
return {
|
|
482
|
+
requestId: envelope.requestId,
|
|
483
|
+
kind: 'network_download_completed',
|
|
484
|
+
downloadId: this.journal.downloadId,
|
|
485
|
+
crc32: this.journal.crc32,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
this.throwIfCancelled(context.signal);
|
|
489
|
+
const blob = await this.track(this.storage.openBlob(this.journal.blobId));
|
|
490
|
+
this.throwIfCancelled(context.signal);
|
|
491
|
+
await this.track(blob.truncate(0));
|
|
492
|
+
this.throwIfCancelled(context.signal);
|
|
493
|
+
const provider = this.provider;
|
|
494
|
+
if (!provider) {
|
|
495
|
+
throw new BotaSDKError('unsupported_capability', 'update_firmware');
|
|
496
|
+
}
|
|
497
|
+
let request;
|
|
498
|
+
const providerSignal = AbortSignal.any([
|
|
499
|
+
context.signal,
|
|
500
|
+
this.fetchAbortController.signal,
|
|
501
|
+
]);
|
|
502
|
+
try {
|
|
503
|
+
request = await awaitProviderCall(provider.resolve({
|
|
504
|
+
operationId: this.journal.operationId,
|
|
505
|
+
serialNumber: this.journal.serialNumber,
|
|
506
|
+
image: publicImage(this.journal),
|
|
507
|
+
signal: providerSignal,
|
|
508
|
+
}), providerSignal, 'update_firmware');
|
|
509
|
+
this.throwIfCancelled(context.signal);
|
|
510
|
+
validateDownloadRequest(request);
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
if (isCancelled(error, context.signal, this.cancelled)) {
|
|
514
|
+
throw new BotaSDKError('cancelled', 'update_firmware');
|
|
515
|
+
}
|
|
516
|
+
await this.track(blob.delete()).catch(() => undefined);
|
|
517
|
+
throw new BotaSDKError('upload_failed', 'update_firmware');
|
|
518
|
+
}
|
|
519
|
+
let response;
|
|
520
|
+
try {
|
|
521
|
+
response = await this.track(this.fetcher(request.url, {
|
|
522
|
+
method: 'GET',
|
|
523
|
+
headers: { ...request.headers },
|
|
524
|
+
signal: this.fetchAbortController.signal,
|
|
525
|
+
redirect: 'error',
|
|
526
|
+
}));
|
|
527
|
+
this.throwIfCancelled(context.signal);
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
if (isCancelled(error, context.signal, this.cancelled)) {
|
|
531
|
+
throw new BotaSDKError('cancelled', 'update_firmware');
|
|
532
|
+
}
|
|
533
|
+
await this.track(blob.delete()).catch(() => undefined);
|
|
534
|
+
throw new BotaSDKError('upload_failed', 'update_firmware');
|
|
535
|
+
}
|
|
536
|
+
try {
|
|
537
|
+
if (!response.ok) {
|
|
538
|
+
throw new BotaSDKError('upload_failed', 'update_firmware');
|
|
539
|
+
}
|
|
540
|
+
if (response.url)
|
|
541
|
+
validateDownloadUrl(response.url);
|
|
542
|
+
validateContentLength(response.headers.get('Content-Length'), this.journal);
|
|
543
|
+
if (!response.body) {
|
|
544
|
+
throw new BotaSDKError('upload_failed', 'update_firmware');
|
|
545
|
+
}
|
|
546
|
+
const reader = response.body.getReader();
|
|
547
|
+
this.reader = reader;
|
|
548
|
+
const hasher = this.core.createIntegrityHasher();
|
|
549
|
+
let offset = 0;
|
|
550
|
+
while (true) {
|
|
551
|
+
const next = await this.track(reader.read());
|
|
552
|
+
this.throwIfCancelled(context.signal);
|
|
553
|
+
if (next.done)
|
|
554
|
+
break;
|
|
555
|
+
const value = next.value;
|
|
556
|
+
for (let chunkOffset = 0; chunkOffset < value.byteLength; chunkOffset += MAX_DOWNLOAD_WRITE_BYTES) {
|
|
557
|
+
const chunk = value.subarray(chunkOffset, Math.min(chunkOffset + MAX_DOWNLOAD_WRITE_BYTES, value.byteLength));
|
|
558
|
+
const durable = safeBrowserRange(offset, chunk.byteLength);
|
|
559
|
+
if (durable > this.journal.sizeBytes) {
|
|
560
|
+
throw new BotaSDKError('integrity_failed', 'update_firmware');
|
|
561
|
+
}
|
|
562
|
+
await this.track(blob.write(offset, chunk));
|
|
563
|
+
hasher.update(chunk);
|
|
564
|
+
offset = durable;
|
|
565
|
+
this.throwIfCancelled(context.signal);
|
|
566
|
+
await context.dispatch({
|
|
567
|
+
requestId: envelope.requestId,
|
|
568
|
+
kind: 'network_download_progress',
|
|
569
|
+
downloadId: this.journal.downloadId,
|
|
570
|
+
completedBytes: BigInt(offset),
|
|
571
|
+
totalBytes: BigInt(this.journal.sizeBytes),
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
this.reader = null;
|
|
576
|
+
validateDownloadedArtifact(hasher, offset, this.journal);
|
|
577
|
+
const verified = {
|
|
578
|
+
...this.journal,
|
|
579
|
+
downloadedBytes: this.journal.sizeBytes,
|
|
580
|
+
verified: true,
|
|
581
|
+
updatedAtEpochMs: Math.max(this.journal.updatedAtEpochMs, this.now()),
|
|
582
|
+
};
|
|
583
|
+
await this.track(this.storage.saveFirmwareJournal(verified));
|
|
584
|
+
this.throwIfCancelled(context.signal);
|
|
585
|
+
return {
|
|
586
|
+
requestId: envelope.requestId,
|
|
587
|
+
kind: 'network_download_completed',
|
|
588
|
+
downloadId: this.journal.downloadId,
|
|
589
|
+
crc32: hasher.crc32(),
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
catch (error) {
|
|
593
|
+
this.reader = null;
|
|
594
|
+
if (isCancelled(error, context.signal, this.cancelled)) {
|
|
595
|
+
throw new BotaSDKError('cancelled', 'update_firmware');
|
|
596
|
+
}
|
|
597
|
+
await this.track(blob.delete()).catch(() => undefined);
|
|
598
|
+
if (error instanceof BotaSDKError)
|
|
599
|
+
throw error;
|
|
600
|
+
throw otaError(error);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
async readChunk(envelope, context) {
|
|
604
|
+
if (envelope.effect.kind !== 'firmware_blob_read_chunk') {
|
|
605
|
+
throw new BotaSDKError('internal_error', 'update_firmware');
|
|
606
|
+
}
|
|
607
|
+
const effect = envelope.effect;
|
|
608
|
+
if (effect.downloadId !== this.journal.downloadId
|
|
609
|
+
|| !Number.isSafeInteger(effect.maxLength)
|
|
610
|
+
|| effect.maxLength <= 0
|
|
611
|
+
|| effect.maxLength > FIRMWARE_CHUNK_SIZE) {
|
|
612
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
613
|
+
}
|
|
614
|
+
const latest = await this.track(this.storage.loadFirmwareJournal(this.journal.operationId));
|
|
615
|
+
if (!latest)
|
|
616
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
617
|
+
validateCompatibleJournal(latest, this.journal);
|
|
618
|
+
if (!latest.verified || latest.downloadedBytes !== latest.sizeBytes) {
|
|
619
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
620
|
+
}
|
|
621
|
+
const offset = safeBrowserBigint(effect.offset);
|
|
622
|
+
const end = safeBrowserRange(offset, effect.maxLength);
|
|
623
|
+
if (offset >= latest.sizeBytes || end > latest.sizeBytes) {
|
|
624
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
625
|
+
}
|
|
626
|
+
this.throwIfCancelled(context.signal);
|
|
627
|
+
const blob = await this.track(this.storage.openBlob(latest.blobId));
|
|
628
|
+
this.throwIfCancelled(context.signal);
|
|
629
|
+
const bytes = await this.track(blob.read(offset, effect.maxLength));
|
|
630
|
+
this.throwIfCancelled(context.signal);
|
|
631
|
+
if (bytes.byteLength !== effect.maxLength) {
|
|
632
|
+
throw new BotaSDKError('integrity_failed', 'update_firmware');
|
|
633
|
+
}
|
|
634
|
+
return {
|
|
635
|
+
requestId: envelope.requestId,
|
|
636
|
+
kind: 'firmware_chunk_read',
|
|
637
|
+
downloadId: effect.downloadId,
|
|
638
|
+
offset: effect.offset,
|
|
639
|
+
bytes,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
track(promise) {
|
|
643
|
+
let settlement;
|
|
644
|
+
settlement = promise.then(() => undefined, () => undefined).finally(() => {
|
|
645
|
+
this.pending.delete(settlement);
|
|
646
|
+
});
|
|
647
|
+
this.pending.add(settlement);
|
|
648
|
+
return promise;
|
|
649
|
+
}
|
|
650
|
+
async awaitPending() {
|
|
651
|
+
while (this.pending.size > 0) {
|
|
652
|
+
await Promise.allSettled([...this.pending]);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
throwIfCancelled(signal) {
|
|
656
|
+
if (this.cancelled || signal.aborted) {
|
|
657
|
+
throw new BotaSDKError('cancelled', 'update_firmware');
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
function createJournal(operationId, serialNumber, image, now) {
|
|
662
|
+
const downloadId = randomDownloadId();
|
|
663
|
+
return {
|
|
664
|
+
schemaVersion: 1,
|
|
665
|
+
operationId,
|
|
666
|
+
serialNumber,
|
|
667
|
+
imageId: image.imageId,
|
|
668
|
+
downloadId,
|
|
669
|
+
version: image.version,
|
|
670
|
+
sizeBytes: image.sizeBytes,
|
|
671
|
+
crc32: image.crc32,
|
|
672
|
+
sha256Hex: image.sha256Hex,
|
|
673
|
+
blobId: firmwareBlobId(operationId, image, downloadId),
|
|
674
|
+
downloadedBytes: 0,
|
|
675
|
+
verified: false,
|
|
676
|
+
state: 'active',
|
|
677
|
+
updatedAtEpochMs: now(),
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
function validateImage(image) {
|
|
681
|
+
if (typeof image !== 'object'
|
|
682
|
+
|| image === null
|
|
683
|
+
|| !validIdentifier(image.imageId)
|
|
684
|
+
|| !validIdentifier(image.version)
|
|
685
|
+
|| !Number.isSafeInteger(image.sizeBytes)
|
|
686
|
+
|| image.sizeBytes <= 0
|
|
687
|
+
|| image.sizeBytes > MAX_FIRMWARE_SIZE
|
|
688
|
+
|| !Number.isInteger(image.crc32)
|
|
689
|
+
|| image.crc32 < 0
|
|
690
|
+
|| image.crc32 > 0xffff_ffff
|
|
691
|
+
|| !SHA256_PATTERN.test(image.sha256Hex)) {
|
|
692
|
+
throw new BotaSDKError('invalid_input', 'update_firmware');
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
function validateJournal(journal, operationId) {
|
|
696
|
+
const image = publicImage(journal);
|
|
697
|
+
validateImage(image);
|
|
698
|
+
if (journal.schemaVersion !== 1
|
|
699
|
+
|| journal.operationId !== operationId
|
|
700
|
+
|| !validIdentifier(journal.operationId)
|
|
701
|
+
|| !validIdentifier(journal.serialNumber)
|
|
702
|
+
|| typeof journal.downloadId !== 'bigint'
|
|
703
|
+
|| journal.downloadId < 0n
|
|
704
|
+
|| journal.downloadId > 0xffffffffffffffffn
|
|
705
|
+
|| journal.blobId !== firmwareBlobId(operationId, image, journal.downloadId)
|
|
706
|
+
|| !Number.isSafeInteger(journal.downloadedBytes)
|
|
707
|
+
|| journal.downloadedBytes < 0
|
|
708
|
+
|| journal.downloadedBytes > journal.sizeBytes
|
|
709
|
+
|| typeof journal.verified !== 'boolean'
|
|
710
|
+
|| (journal.state !== undefined
|
|
711
|
+
&& journal.state !== 'active'
|
|
712
|
+
&& journal.state !== 'cleanup_only')
|
|
713
|
+
|| !Number.isSafeInteger(journal.updatedAtEpochMs)
|
|
714
|
+
|| journal.updatedAtEpochMs < 0
|
|
715
|
+
|| (journal.verified && journal.downloadedBytes !== journal.sizeBytes)
|
|
716
|
+
|| (firmwareJournalState(journal) === 'cleanup_only'
|
|
717
|
+
&& (!journal.verified || journal.downloadedBytes !== journal.sizeBytes))) {
|
|
718
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
function firmwareJournalState(journal) {
|
|
722
|
+
return journal.state ?? 'active';
|
|
723
|
+
}
|
|
724
|
+
function validateCompatibleJournal(actual, expected) {
|
|
725
|
+
validateJournal(actual, expected.operationId);
|
|
726
|
+
if (actual.serialNumber !== expected.serialNumber
|
|
727
|
+
|| actual.imageId !== expected.imageId
|
|
728
|
+
|| actual.downloadId !== expected.downloadId
|
|
729
|
+
|| actual.version !== expected.version
|
|
730
|
+
|| actual.sizeBytes !== expected.sizeBytes
|
|
731
|
+
|| actual.crc32 !== expected.crc32
|
|
732
|
+
|| actual.sha256Hex !== expected.sha256Hex
|
|
733
|
+
|| actual.blobId !== expected.blobId) {
|
|
734
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
function validateCheckpoint(checkpoint, journal) {
|
|
738
|
+
if (checkpoint === null)
|
|
739
|
+
return;
|
|
740
|
+
if (typeof checkpoint !== 'object' || checkpoint === null) {
|
|
741
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
742
|
+
}
|
|
743
|
+
const value = checkpoint;
|
|
744
|
+
if (value.workflow !== 'firmware_update'
|
|
745
|
+
|| value.operation !== 'update_firmware'
|
|
746
|
+
|| value.serialNumber !== journal.serialNumber
|
|
747
|
+
|| value.firmwareVersion !== journal.version
|
|
748
|
+
|| (value.phase !== 'transferring'
|
|
749
|
+
&& value.phase !== 'verifying'
|
|
750
|
+
&& value.phase !== 'reconnecting')
|
|
751
|
+
|| typeof value.completedUnits !== 'bigint'
|
|
752
|
+
|| value.completedUnits < 0n
|
|
753
|
+
|| value.completedUnits > BigInt(journal.sizeBytes)) {
|
|
754
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
755
|
+
}
|
|
756
|
+
if (!journal.verified || journal.downloadedBytes !== journal.sizeBytes) {
|
|
757
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
function checkpointPhase(checkpoint) {
|
|
761
|
+
if (typeof checkpoint !== 'object' || checkpoint === null)
|
|
762
|
+
return null;
|
|
763
|
+
const phase = checkpoint.phase;
|
|
764
|
+
return typeof phase === 'string' ? phase : null;
|
|
765
|
+
}
|
|
766
|
+
function publicImage(journal) {
|
|
767
|
+
return {
|
|
768
|
+
imageId: journal.imageId,
|
|
769
|
+
version: journal.version,
|
|
770
|
+
sizeBytes: journal.sizeBytes,
|
|
771
|
+
crc32: journal.crc32,
|
|
772
|
+
sha256Hex: journal.sha256Hex,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
function firmwareBlobId(operationId, image, downloadId) {
|
|
776
|
+
return [
|
|
777
|
+
'firmware',
|
|
778
|
+
encodeURIComponent(operationId),
|
|
779
|
+
encodeURIComponent(image.imageId),
|
|
780
|
+
encodeURIComponent(image.version),
|
|
781
|
+
image.sizeBytes,
|
|
782
|
+
image.crc32,
|
|
783
|
+
image.sha256Hex,
|
|
784
|
+
downloadId.toString(16),
|
|
785
|
+
].join(':');
|
|
786
|
+
}
|
|
787
|
+
function validateDownloadRequest(request) {
|
|
788
|
+
if (typeof request !== 'object'
|
|
789
|
+
|| request === null
|
|
790
|
+
|| request.method !== 'GET'
|
|
791
|
+
|| typeof request.url !== 'string'
|
|
792
|
+
|| typeof request.headers !== 'object'
|
|
793
|
+
|| request.headers === null) {
|
|
794
|
+
throw new BotaSDKError('upload_failed', 'update_firmware');
|
|
795
|
+
}
|
|
796
|
+
validateDownloadUrl(request.url);
|
|
797
|
+
try {
|
|
798
|
+
const headers = new Headers();
|
|
799
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
800
|
+
if (typeof value !== 'string' || /[\r\n]/.test(value)) {
|
|
801
|
+
throw new Error('invalid header');
|
|
802
|
+
}
|
|
803
|
+
headers.set(name, value);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
throw new BotaSDKError('upload_failed', 'update_firmware');
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
function validateDownloadUrl(value) {
|
|
811
|
+
let url;
|
|
812
|
+
try {
|
|
813
|
+
url = new URL(value);
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
throw new BotaSDKError('upload_failed', 'update_firmware');
|
|
817
|
+
}
|
|
818
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback(url))) {
|
|
819
|
+
throw new BotaSDKError('upload_failed', 'update_firmware');
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
function isLoopback(url) {
|
|
823
|
+
const hostname = url.hostname.toLowerCase();
|
|
824
|
+
return hostname === 'localhost'
|
|
825
|
+
|| hostname === '127.0.0.1'
|
|
826
|
+
|| hostname === '[::1]';
|
|
827
|
+
}
|
|
828
|
+
function validateContentLength(value, journal) {
|
|
829
|
+
if (value === null)
|
|
830
|
+
return;
|
|
831
|
+
if (!/^(0|[1-9][0-9]*)$/.test(value)) {
|
|
832
|
+
throw new BotaSDKError('integrity_failed', 'update_firmware');
|
|
833
|
+
}
|
|
834
|
+
const length = Number(value);
|
|
835
|
+
if (!Number.isSafeInteger(length) || length !== journal.sizeBytes) {
|
|
836
|
+
throw new BotaSDKError('integrity_failed', 'update_firmware');
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
function validateDownloadedArtifact(hasher, size, journal) {
|
|
840
|
+
if (size !== journal.sizeBytes
|
|
841
|
+
|| hasher.length() !== BigInt(journal.sizeBytes)
|
|
842
|
+
|| hasher.crc32() !== journal.crc32
|
|
843
|
+
|| bytesHex(hasher.sha256Snapshot()) !== journal.sha256Hex) {
|
|
844
|
+
throw new BotaSDKError('integrity_failed', 'update_firmware');
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
function createOperationId() {
|
|
848
|
+
return `update_firmware:${bytesHex(randomBytes(16))}`;
|
|
849
|
+
}
|
|
850
|
+
function randomDownloadId() {
|
|
851
|
+
const bytes = randomBytes(8);
|
|
852
|
+
let value = 0n;
|
|
853
|
+
for (const byte of bytes)
|
|
854
|
+
value = (value << 8n) | BigInt(byte);
|
|
855
|
+
return value;
|
|
856
|
+
}
|
|
857
|
+
function randomBytes(length) {
|
|
858
|
+
const value = new Uint8Array(length);
|
|
859
|
+
globalThis.crypto.getRandomValues(value);
|
|
860
|
+
return value;
|
|
861
|
+
}
|
|
862
|
+
function validateOperationId(operationId) {
|
|
863
|
+
if (!validIdentifier(operationId)) {
|
|
864
|
+
throw new BotaSDKError('invalid_input', 'update_firmware');
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
function validIdentifier(value) {
|
|
868
|
+
return typeof value === 'string'
|
|
869
|
+
&& value.length > 0
|
|
870
|
+
&& value.length <= 1024
|
|
871
|
+
&& IDENTIFIER_PATTERN.test(value)
|
|
872
|
+
&& isWellFormedUtf16(value);
|
|
873
|
+
}
|
|
874
|
+
function isWellFormedUtf16(value) {
|
|
875
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
876
|
+
const code = value.charCodeAt(index);
|
|
877
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
878
|
+
const next = value.charCodeAt(index + 1);
|
|
879
|
+
if (next < 0xdc00 || next > 0xdfff)
|
|
880
|
+
return false;
|
|
881
|
+
index += 1;
|
|
882
|
+
}
|
|
883
|
+
else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return true;
|
|
888
|
+
}
|
|
889
|
+
function safeBrowserBigint(value) {
|
|
890
|
+
if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
891
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
892
|
+
}
|
|
893
|
+
return Number(value);
|
|
894
|
+
}
|
|
895
|
+
function safeBrowserNumber(value) {
|
|
896
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
897
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
898
|
+
}
|
|
899
|
+
return value;
|
|
900
|
+
}
|
|
901
|
+
function safeBrowserRange(offset, length) {
|
|
902
|
+
if (!Number.isSafeInteger(length) || length < 0) {
|
|
903
|
+
throw new BotaSDKError('resume_rejected', 'update_firmware');
|
|
904
|
+
}
|
|
905
|
+
return safeBrowserNumber(offset + length);
|
|
906
|
+
}
|
|
907
|
+
function throwIfAborted(signal) {
|
|
908
|
+
if (signal?.aborted) {
|
|
909
|
+
throw new BotaSDKError('cancelled', 'update_firmware');
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
function isCancelled(error, signal, cancelled) {
|
|
913
|
+
return cancelled
|
|
914
|
+
|| signal.aborted
|
|
915
|
+
|| (error instanceof BotaSDKError && error.code === 'cancelled');
|
|
916
|
+
}
|
|
917
|
+
function otaError(error) {
|
|
918
|
+
if (error instanceof BotaSDKError)
|
|
919
|
+
return error;
|
|
920
|
+
if (error instanceof BrowserStorageError) {
|
|
921
|
+
return new BotaSDKError(error.code, 'update_firmware');
|
|
922
|
+
}
|
|
923
|
+
if (error instanceof BrowserTransportError) {
|
|
924
|
+
const code = error.code === 'disconnected'
|
|
925
|
+
? 'device_disconnected'
|
|
926
|
+
: error.code === 'permission_denied'
|
|
927
|
+
? 'permission_denied'
|
|
928
|
+
: 'bluetooth_unavailable';
|
|
929
|
+
return new BotaSDKError(code, 'update_firmware');
|
|
930
|
+
}
|
|
931
|
+
return normalizeCoreError(error, 'update_firmware');
|
|
932
|
+
}
|
|
933
|
+
function asFirmwareError(error) {
|
|
934
|
+
const normalized = otaError(error);
|
|
935
|
+
if (normalized.operation === 'update_firmware')
|
|
936
|
+
return normalized;
|
|
937
|
+
return new BotaSDKError(normalized.code, 'update_firmware', {
|
|
938
|
+
retryable: normalized.retryable,
|
|
939
|
+
protocolStatus: normalized.protocolStatus,
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
function bytesHex(value) {
|
|
943
|
+
return Array.from(value, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
944
|
+
}
|
|
945
|
+
function deferredVoid() {
|
|
946
|
+
let resolve;
|
|
947
|
+
const promise = new Promise((resolvePromise) => {
|
|
948
|
+
resolve = resolvePromise;
|
|
949
|
+
});
|
|
950
|
+
return { promise, resolve };
|
|
951
|
+
}
|