@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,763 @@
|
|
|
1
|
+
const ERROR_MESSAGES = {
|
|
2
|
+
invalid_input: 'The storage operation contains invalid input.',
|
|
3
|
+
storage_unavailable: 'Durable browser storage is unavailable.',
|
|
4
|
+
storage_quota_exceeded: 'Durable browser storage quota was exceeded.',
|
|
5
|
+
resume_rejected: 'Persisted workflow state is incompatible.',
|
|
6
|
+
};
|
|
7
|
+
export class BrowserStorageError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, options = {}) {
|
|
10
|
+
super(ERROR_MESSAGES[code], { cause: options.cause });
|
|
11
|
+
this.name = 'BrowserStorageError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function mapBrowserStorageError(error) {
|
|
16
|
+
if (error instanceof BrowserStorageError)
|
|
17
|
+
return error;
|
|
18
|
+
if (domExceptionName(error) === 'QuotaExceededError') {
|
|
19
|
+
return new BrowserStorageError('storage_quota_exceeded', { cause: error });
|
|
20
|
+
}
|
|
21
|
+
if (domExceptionName(error) === 'VersionError')
|
|
22
|
+
return resumeRejected();
|
|
23
|
+
return new BrowserStorageError('storage_unavailable', { cause: error });
|
|
24
|
+
}
|
|
25
|
+
export function validateStorageNamespace(namespace) {
|
|
26
|
+
if (typeof namespace !== 'string'
|
|
27
|
+
|| namespace.trim().length === 0
|
|
28
|
+
|| namespace.length > 256
|
|
29
|
+
|| !isWellFormedUtf16(namespace)) {
|
|
30
|
+
throw new BrowserStorageError('invalid_input');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function validateStorageIdentifier(value) {
|
|
34
|
+
if (typeof value !== 'string'
|
|
35
|
+
|| value.length === 0
|
|
36
|
+
|| !isWellFormedUtf16(value)) {
|
|
37
|
+
throw new BrowserStorageError('invalid_input');
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
const DATABASE_NAME = 'bota-app-sdk';
|
|
42
|
+
const DATABASE_VERSION = 1;
|
|
43
|
+
const STORE_NAMES = [
|
|
44
|
+
'verified_devices',
|
|
45
|
+
'workflow_checkpoints',
|
|
46
|
+
'encrypted_upload_v2_checkpoints',
|
|
47
|
+
'recording_journals',
|
|
48
|
+
'provisioning_journals',
|
|
49
|
+
'firmware_journals',
|
|
50
|
+
];
|
|
51
|
+
const RECORDING_PHASES = [
|
|
52
|
+
'prepared',
|
|
53
|
+
'transferring',
|
|
54
|
+
'staged',
|
|
55
|
+
'uploading',
|
|
56
|
+
'cloud_completed',
|
|
57
|
+
'confirmed',
|
|
58
|
+
];
|
|
59
|
+
export class IndexedDbWorkflowStore {
|
|
60
|
+
namespace;
|
|
61
|
+
indexedDB;
|
|
62
|
+
keyRange;
|
|
63
|
+
namespacePrefix;
|
|
64
|
+
databasePromise = null;
|
|
65
|
+
databaseInvalidated = false;
|
|
66
|
+
constructor(namespace, indexedDB, keyRange) {
|
|
67
|
+
validateStorageNamespace(namespace);
|
|
68
|
+
this.namespace = namespace;
|
|
69
|
+
this.indexedDB = indexedDB;
|
|
70
|
+
this.keyRange = keyRange;
|
|
71
|
+
this.namespacePrefix = `v1:${encodeKeyPart(namespace)}:`;
|
|
72
|
+
}
|
|
73
|
+
async loadVerifiedDevice(serialNumber) {
|
|
74
|
+
const value = await this.get('verified_devices', serialNumber);
|
|
75
|
+
if (value === undefined)
|
|
76
|
+
return null;
|
|
77
|
+
const hint = verifiedDevice(value);
|
|
78
|
+
if (hint.serialNumber !== serialNumber)
|
|
79
|
+
throw resumeRejected();
|
|
80
|
+
return hint;
|
|
81
|
+
}
|
|
82
|
+
async saveVerifiedDevice(value) {
|
|
83
|
+
const sanitized = verifiedDevice(value);
|
|
84
|
+
await this.put('verified_devices', sanitized.serialNumber, sanitized);
|
|
85
|
+
}
|
|
86
|
+
async deleteVerifiedDevice(serialNumber) {
|
|
87
|
+
await this.delete('verified_devices', serialNumber);
|
|
88
|
+
}
|
|
89
|
+
async loadWorkflowCheckpoint(operationId) {
|
|
90
|
+
const value = await this.get('workflow_checkpoints', operationId);
|
|
91
|
+
if (value === undefined)
|
|
92
|
+
return null;
|
|
93
|
+
return workflowCheckpoint(value, operationId).checkpoint;
|
|
94
|
+
}
|
|
95
|
+
async saveWorkflowCheckpoint(operationId, checkpoint) {
|
|
96
|
+
const record = {
|
|
97
|
+
schemaVersion: 1,
|
|
98
|
+
operationId: validIdentifier(operationId),
|
|
99
|
+
checkpoint,
|
|
100
|
+
};
|
|
101
|
+
await this.put('workflow_checkpoints', operationId, record);
|
|
102
|
+
}
|
|
103
|
+
async deleteWorkflowCheckpoint(operationId) {
|
|
104
|
+
await this.delete('workflow_checkpoints', operationId);
|
|
105
|
+
}
|
|
106
|
+
async loadEncryptedUploadV2Checkpoint(operationId) {
|
|
107
|
+
const value = await this.get('encrypted_upload_v2_checkpoints', operationId);
|
|
108
|
+
if (value === undefined)
|
|
109
|
+
return null;
|
|
110
|
+
return encryptedUploadV2Checkpoint(value, operationId).checkpoint;
|
|
111
|
+
}
|
|
112
|
+
async saveEncryptedUploadV2Checkpoint(operationId, checkpoint) {
|
|
113
|
+
const record = {
|
|
114
|
+
schemaVersion: 1,
|
|
115
|
+
operationId: validIdentifier(operationId),
|
|
116
|
+
checkpoint,
|
|
117
|
+
};
|
|
118
|
+
await this.put('encrypted_upload_v2_checkpoints', operationId, record);
|
|
119
|
+
}
|
|
120
|
+
async deleteEncryptedUploadV2Checkpoint(operationId) {
|
|
121
|
+
await this.delete('encrypted_upload_v2_checkpoints', operationId);
|
|
122
|
+
}
|
|
123
|
+
async saveEncryptedUploadV2Operation(operationId, checkpoint, journal) {
|
|
124
|
+
const id = validIdentifier(operationId);
|
|
125
|
+
const sanitizedJournal = recordingJournal(journal);
|
|
126
|
+
if (sanitizedJournal.operationId !== id
|
|
127
|
+
|| sanitizedJournal.profile !== 'encrypted_upload_v2'
|
|
128
|
+
|| sanitizedJournal.phase !== 'prepared')
|
|
129
|
+
throw resumeRejected();
|
|
130
|
+
const checkpointRecord = {
|
|
131
|
+
schemaVersion: 1,
|
|
132
|
+
operationId: id,
|
|
133
|
+
checkpoint,
|
|
134
|
+
};
|
|
135
|
+
const key = this.key(id);
|
|
136
|
+
try {
|
|
137
|
+
const database = await this.database();
|
|
138
|
+
const transaction = database.transaction(['encrypted_upload_v2_checkpoints', 'recording_journals'], 'readwrite');
|
|
139
|
+
const completion = transactionCompletion(transaction);
|
|
140
|
+
const checkpointStore = transaction.objectStore('encrypted_upload_v2_checkpoints');
|
|
141
|
+
const journalStore = transaction.objectStore('recording_journals');
|
|
142
|
+
try {
|
|
143
|
+
const [existingCheckpoint, existingJournal] = await Promise.all([
|
|
144
|
+
requestResult(checkpointStore.get(key)),
|
|
145
|
+
requestResult(journalStore.get(key)),
|
|
146
|
+
]);
|
|
147
|
+
if (existingCheckpoint !== undefined || existingJournal !== undefined) {
|
|
148
|
+
throw resumeRejected();
|
|
149
|
+
}
|
|
150
|
+
checkpointStore.put(checkpointRecord, key);
|
|
151
|
+
journalStore.put(sanitizedJournal, key);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
transaction.abort();
|
|
155
|
+
await completion.catch(() => undefined);
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
await completion;
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
throw mapBrowserStorageError(error);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async deleteEncryptedUploadV2Operation(operationId) {
|
|
165
|
+
const key = this.key(operationId);
|
|
166
|
+
try {
|
|
167
|
+
const database = await this.database();
|
|
168
|
+
const transaction = database.transaction(['encrypted_upload_v2_checkpoints', 'recording_journals'], 'readwrite');
|
|
169
|
+
const completion = transactionCompletion(transaction);
|
|
170
|
+
transaction.objectStore('encrypted_upload_v2_checkpoints').delete(key);
|
|
171
|
+
transaction.objectStore('recording_journals').delete(key);
|
|
172
|
+
await completion;
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
throw mapBrowserStorageError(error);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async loadRecordingJournal(operationId) {
|
|
179
|
+
const value = await this.get('recording_journals', operationId);
|
|
180
|
+
if (value === undefined)
|
|
181
|
+
return null;
|
|
182
|
+
const journal = recordingJournal(value);
|
|
183
|
+
if (journal.operationId !== operationId)
|
|
184
|
+
throw resumeRejected();
|
|
185
|
+
return journal;
|
|
186
|
+
}
|
|
187
|
+
async saveRecordingJournal(journal) {
|
|
188
|
+
const sanitized = recordingJournal(journal);
|
|
189
|
+
await this.saveMonotonicJournal('recording_journals', sanitized.operationId, sanitized, (existing) => {
|
|
190
|
+
const previous = recordingJournal(existing);
|
|
191
|
+
if (previous.operationId !== sanitized.operationId
|
|
192
|
+
|| previous.serialNumber !== sanitized.serialNumber
|
|
193
|
+
|| previous.recordingUuid !== sanitized.recordingUuid
|
|
194
|
+
|| previous.profile !== sanitized.profile
|
|
195
|
+
|| previous.sinkId !== sanitized.sinkId) {
|
|
196
|
+
throw resumeRejected();
|
|
197
|
+
}
|
|
198
|
+
assertNondecreasingTimestamp(previous, sanitized);
|
|
199
|
+
const reconciledNotUploaded = previous.profile === 'legacy'
|
|
200
|
+
&& previous.phase === 'uploading'
|
|
201
|
+
&& sanitized.phase === 'staged'
|
|
202
|
+
&& sanitized.uploadId === null
|
|
203
|
+
&& sanitized.cloudCompletionId === null
|
|
204
|
+
&& sanitized.confirmationDigestHex === null;
|
|
205
|
+
if (!reconciledNotUploaded) {
|
|
206
|
+
assertEstablishedEvidence(previous.uploadId, sanitized.uploadId);
|
|
207
|
+
}
|
|
208
|
+
assertEstablishedEvidence(previous.cloudCompletionId, sanitized.cloudCompletionId);
|
|
209
|
+
assertEstablishedEvidence(previous.confirmationDigestHex, sanitized.confirmationDigestHex);
|
|
210
|
+
if (RECORDING_PHASES.indexOf(previous.phase)
|
|
211
|
+
>= RECORDING_PHASES.indexOf('staged')
|
|
212
|
+
&& previous.devicePlaintextSha256Hex
|
|
213
|
+
!== sanitized.devicePlaintextSha256Hex) {
|
|
214
|
+
throw resumeRejected();
|
|
215
|
+
}
|
|
216
|
+
if (!reconciledNotUploaded) {
|
|
217
|
+
assertNextPhase(RECORDING_PHASES, previous.phase, sanitized.phase);
|
|
218
|
+
}
|
|
219
|
+
}, () => {
|
|
220
|
+
if (sanitized.phase !== 'prepared')
|
|
221
|
+
throw resumeRejected();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
async listRecordingJournals() {
|
|
225
|
+
const entries = await this.getAll('recording_journals');
|
|
226
|
+
return entries
|
|
227
|
+
.map(({ key, value }) => {
|
|
228
|
+
const journal = recordingJournal(value);
|
|
229
|
+
if (key !== this.key(journal.operationId))
|
|
230
|
+
throw resumeRejected();
|
|
231
|
+
return journal;
|
|
232
|
+
})
|
|
233
|
+
.sort((left, right) => left.operationId.localeCompare(right.operationId));
|
|
234
|
+
}
|
|
235
|
+
async deleteRecordingJournal(operationId) {
|
|
236
|
+
await this.delete('recording_journals', operationId);
|
|
237
|
+
}
|
|
238
|
+
async loadProvisioningJournal(attemptId) {
|
|
239
|
+
const value = await this.get('provisioning_journals', attemptId);
|
|
240
|
+
if (value === undefined)
|
|
241
|
+
return null;
|
|
242
|
+
const journal = provisioningJournal(value);
|
|
243
|
+
if (journal.attemptId !== attemptId)
|
|
244
|
+
throw resumeRejected();
|
|
245
|
+
return journal;
|
|
246
|
+
}
|
|
247
|
+
async saveProvisioningJournal(journal) {
|
|
248
|
+
const sanitized = provisioningJournal(journal);
|
|
249
|
+
await this.saveMonotonicJournal('provisioning_journals', sanitized.attemptId, sanitized, (existing) => {
|
|
250
|
+
const previous = provisioningJournal(existing);
|
|
251
|
+
if (previous.attemptId !== sanitized.attemptId
|
|
252
|
+
|| previous.materialId !== sanitized.materialId
|
|
253
|
+
|| previous.serialNumber !== sanitized.serialNumber) {
|
|
254
|
+
throw resumeRejected();
|
|
255
|
+
}
|
|
256
|
+
assertNondecreasingTimestamp(previous, sanitized);
|
|
257
|
+
if (previous.phase === sanitized.phase)
|
|
258
|
+
return;
|
|
259
|
+
const permitted = (previous.phase === 'prepared'
|
|
260
|
+
&& (sanitized.phase === 'device_applied' || sanitized.phase === 'aborted'))
|
|
261
|
+
|| (previous.phase === 'device_applied'
|
|
262
|
+
&& sanitized.phase === 'backend_confirmed');
|
|
263
|
+
if (!permitted)
|
|
264
|
+
throw resumeRejected();
|
|
265
|
+
}, () => {
|
|
266
|
+
if (sanitized.phase !== 'prepared')
|
|
267
|
+
throw resumeRejected();
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
async listProvisioningJournals() {
|
|
271
|
+
const entries = await this.getAll('provisioning_journals');
|
|
272
|
+
return entries
|
|
273
|
+
.map(({ key, value }) => {
|
|
274
|
+
const journal = provisioningJournal(value);
|
|
275
|
+
if (key !== this.key(journal.attemptId))
|
|
276
|
+
throw resumeRejected();
|
|
277
|
+
return journal;
|
|
278
|
+
})
|
|
279
|
+
.sort((left, right) => left.attemptId.localeCompare(right.attemptId));
|
|
280
|
+
}
|
|
281
|
+
async deleteProvisioningJournal(attemptId) {
|
|
282
|
+
await this.delete('provisioning_journals', attemptId);
|
|
283
|
+
}
|
|
284
|
+
async loadFirmwareJournal(operationId) {
|
|
285
|
+
const value = await this.get('firmware_journals', operationId);
|
|
286
|
+
if (value === undefined)
|
|
287
|
+
return null;
|
|
288
|
+
const journal = firmwareJournal(value);
|
|
289
|
+
if (journal.operationId !== operationId)
|
|
290
|
+
throw resumeRejected();
|
|
291
|
+
return journal;
|
|
292
|
+
}
|
|
293
|
+
async saveFirmwareJournal(journal) {
|
|
294
|
+
const sanitized = firmwareJournal(journal);
|
|
295
|
+
await this.saveMonotonicJournal('firmware_journals', sanitized.operationId, sanitized, (value) => {
|
|
296
|
+
const existing = firmwareJournal(value);
|
|
297
|
+
if (existing.operationId !== sanitized.operationId
|
|
298
|
+
|| existing.serialNumber !== sanitized.serialNumber
|
|
299
|
+
|| existing.imageId !== sanitized.imageId
|
|
300
|
+
|| existing.downloadId !== sanitized.downloadId
|
|
301
|
+
|| existing.version !== sanitized.version
|
|
302
|
+
|| existing.sizeBytes !== sanitized.sizeBytes
|
|
303
|
+
|| existing.crc32 !== sanitized.crc32
|
|
304
|
+
|| existing.sha256Hex !== sanitized.sha256Hex
|
|
305
|
+
|| existing.blobId !== sanitized.blobId
|
|
306
|
+
|| sanitized.downloadedBytes < existing.downloadedBytes
|
|
307
|
+
|| (existing.verified && !sanitized.verified)
|
|
308
|
+
|| (firmwareJournalState(existing) === 'cleanup_only'
|
|
309
|
+
&& firmwareJournalState(sanitized) !== 'cleanup_only')) {
|
|
310
|
+
throw resumeRejected();
|
|
311
|
+
}
|
|
312
|
+
assertNondecreasingTimestamp(existing, sanitized);
|
|
313
|
+
}, () => {
|
|
314
|
+
if (firmwareJournalState(sanitized) !== 'active')
|
|
315
|
+
throw resumeRejected();
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
async deleteFirmwareJournal(operationId) {
|
|
319
|
+
await this.delete('firmware_journals', operationId);
|
|
320
|
+
}
|
|
321
|
+
async clear() {
|
|
322
|
+
try {
|
|
323
|
+
const database = await this.database();
|
|
324
|
+
const transaction = database.transaction([...STORE_NAMES], 'readwrite');
|
|
325
|
+
const completion = transactionCompletion(transaction);
|
|
326
|
+
const range = this.namespaceRange();
|
|
327
|
+
for (const storeName of STORE_NAMES) {
|
|
328
|
+
transaction.objectStore(storeName).delete(range);
|
|
329
|
+
}
|
|
330
|
+
await completion;
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
throw mapBrowserStorageError(error);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async get(storeName, id) {
|
|
337
|
+
const key = this.key(id);
|
|
338
|
+
try {
|
|
339
|
+
const database = await this.database();
|
|
340
|
+
const transaction = database.transaction(storeName, 'readonly');
|
|
341
|
+
const completion = transactionCompletion(transaction);
|
|
342
|
+
const request = requestResult(transaction.objectStore(storeName).get(key));
|
|
343
|
+
const [value] = await Promise.all([request, completion]);
|
|
344
|
+
return value;
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
throw mapBrowserStorageError(error);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
async getAll(storeName) {
|
|
351
|
+
try {
|
|
352
|
+
const database = await this.database();
|
|
353
|
+
const transaction = database.transaction(storeName, 'readonly');
|
|
354
|
+
const completion = transactionCompletion(transaction);
|
|
355
|
+
const store = transaction.objectStore(storeName);
|
|
356
|
+
const range = this.namespaceRange();
|
|
357
|
+
const [keys, values] = await Promise.all([
|
|
358
|
+
requestResult(store.getAllKeys(range)),
|
|
359
|
+
requestResult(store.getAll(range)),
|
|
360
|
+
completion,
|
|
361
|
+
]);
|
|
362
|
+
if (keys.length !== values.length)
|
|
363
|
+
throw resumeRejected();
|
|
364
|
+
return values.map((value, index) => {
|
|
365
|
+
const key = keys[index];
|
|
366
|
+
if (key === undefined)
|
|
367
|
+
throw resumeRejected();
|
|
368
|
+
return { key, value };
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
throw mapBrowserStorageError(error);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
async put(storeName, id, value) {
|
|
376
|
+
const key = this.key(id);
|
|
377
|
+
try {
|
|
378
|
+
const database = await this.database();
|
|
379
|
+
const transaction = database.transaction(storeName, 'readwrite');
|
|
380
|
+
const completion = transactionCompletion(transaction);
|
|
381
|
+
transaction.objectStore(storeName).put(value, key);
|
|
382
|
+
await completion;
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
throw mapBrowserStorageError(error);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
async delete(storeName, id) {
|
|
389
|
+
const key = this.key(id);
|
|
390
|
+
try {
|
|
391
|
+
const database = await this.database();
|
|
392
|
+
const transaction = database.transaction(storeName, 'readwrite');
|
|
393
|
+
const completion = transactionCompletion(transaction);
|
|
394
|
+
transaction.objectStore(storeName).delete(key);
|
|
395
|
+
await completion;
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
throw mapBrowserStorageError(error);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async saveMonotonicJournal(storeName, id, value, validateExisting, validateInitial) {
|
|
402
|
+
const key = this.key(id);
|
|
403
|
+
try {
|
|
404
|
+
const database = await this.database();
|
|
405
|
+
const transaction = database.transaction(storeName, 'readwrite');
|
|
406
|
+
const completion = transactionCompletion(transaction);
|
|
407
|
+
const store = transaction.objectStore(storeName);
|
|
408
|
+
try {
|
|
409
|
+
const existing = await requestResult(store.get(key));
|
|
410
|
+
if (existing === undefined)
|
|
411
|
+
validateInitial();
|
|
412
|
+
else
|
|
413
|
+
validateExisting(existing);
|
|
414
|
+
store.put(value, key);
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
await completion.catch(() => undefined);
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
await completion;
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
throw mapBrowserStorageError(error);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
key(id) {
|
|
427
|
+
return `${this.namespacePrefix}${encodeKeyPart(validIdentifier(id))}`;
|
|
428
|
+
}
|
|
429
|
+
namespaceRange() {
|
|
430
|
+
return this.keyRange.bound(this.namespacePrefix, `${this.namespacePrefix}\uffff`);
|
|
431
|
+
}
|
|
432
|
+
async database() {
|
|
433
|
+
if (this.databaseInvalidated)
|
|
434
|
+
throw resumeRejected();
|
|
435
|
+
if (!this.databasePromise) {
|
|
436
|
+
this.databasePromise = this.openDatabase().catch((error) => {
|
|
437
|
+
this.databasePromise = null;
|
|
438
|
+
throw error;
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
const database = await this.databasePromise;
|
|
442
|
+
if (this.databaseInvalidated)
|
|
443
|
+
throw resumeRejected();
|
|
444
|
+
return database;
|
|
445
|
+
}
|
|
446
|
+
async openDatabase() {
|
|
447
|
+
try {
|
|
448
|
+
return await new Promise((resolve, reject) => {
|
|
449
|
+
const request = this.indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
|
|
450
|
+
request.addEventListener('upgradeneeded', () => {
|
|
451
|
+
for (const storeName of STORE_NAMES) {
|
|
452
|
+
if (!request.result.objectStoreNames.contains(storeName)) {
|
|
453
|
+
request.result.createObjectStore(storeName);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
request.addEventListener('success', () => {
|
|
458
|
+
const database = request.result;
|
|
459
|
+
try {
|
|
460
|
+
assertCompatibleDatabase(database);
|
|
461
|
+
database.addEventListener('versionchange', () => {
|
|
462
|
+
this.databaseInvalidated = true;
|
|
463
|
+
this.databasePromise = null;
|
|
464
|
+
database.close();
|
|
465
|
+
});
|
|
466
|
+
resolve(database);
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
database.close();
|
|
470
|
+
reject(error);
|
|
471
|
+
}
|
|
472
|
+
}, { once: true });
|
|
473
|
+
request.addEventListener('error', () => reject(request.error), { once: true });
|
|
474
|
+
request.addEventListener('blocked', () => {
|
|
475
|
+
reject(new BrowserStorageError('storage_unavailable'));
|
|
476
|
+
}, { once: true });
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
throw mapBrowserStorageError(error);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
function assertCompatibleDatabase(database) {
|
|
485
|
+
const actualStoreNames = Array.from(database.objectStoreNames);
|
|
486
|
+
if (database.version !== DATABASE_VERSION
|
|
487
|
+
|| actualStoreNames.length !== STORE_NAMES.length
|
|
488
|
+
|| STORE_NAMES.some((storeName) => !database.objectStoreNames.contains(storeName))) {
|
|
489
|
+
throw resumeRejected();
|
|
490
|
+
}
|
|
491
|
+
let transaction;
|
|
492
|
+
try {
|
|
493
|
+
transaction = database.transaction([...STORE_NAMES], 'readonly');
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
throw resumeRejected();
|
|
497
|
+
}
|
|
498
|
+
for (const storeName of STORE_NAMES) {
|
|
499
|
+
const store = transaction.objectStore(storeName);
|
|
500
|
+
if (store.name !== storeName
|
|
501
|
+
|| store.keyPath !== null
|
|
502
|
+
|| store.autoIncrement
|
|
503
|
+
|| store.indexNames.length !== 0) {
|
|
504
|
+
throw resumeRejected();
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
function workflowCheckpoint(value, operationId) {
|
|
509
|
+
const record = versionedRecord(value);
|
|
510
|
+
if (record.operationId !== operationId
|
|
511
|
+
|| !Object.hasOwn(record, 'checkpoint')) {
|
|
512
|
+
throw resumeRejected();
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
schemaVersion: 1,
|
|
516
|
+
operationId,
|
|
517
|
+
checkpoint: record.checkpoint,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
function encryptedUploadV2Checkpoint(value, operationId) {
|
|
521
|
+
return workflowCheckpoint(value, operationId);
|
|
522
|
+
}
|
|
523
|
+
function verifiedDevice(value) {
|
|
524
|
+
const record = versionedRecord(value);
|
|
525
|
+
return {
|
|
526
|
+
schemaVersion: 1,
|
|
527
|
+
serialNumber: recordString(record, 'serialNumber'),
|
|
528
|
+
browserDeviceId: recordString(record, 'browserDeviceId'),
|
|
529
|
+
name: nullableDisplayName(record.name),
|
|
530
|
+
updatedAtEpochMs: safeNonnegativeInteger(record.updatedAtEpochMs),
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
function recordingJournal(value) {
|
|
534
|
+
const record = versionedRecord(value);
|
|
535
|
+
const phase = record.phase;
|
|
536
|
+
const profile = record.profile;
|
|
537
|
+
if (!RECORDING_PHASES.includes(phase)) {
|
|
538
|
+
throw resumeRejected();
|
|
539
|
+
}
|
|
540
|
+
if (profile !== 'legacy' && profile !== 'encrypted_upload_v2') {
|
|
541
|
+
throw resumeRejected();
|
|
542
|
+
}
|
|
543
|
+
const journal = {
|
|
544
|
+
schemaVersion: 1,
|
|
545
|
+
operationId: recordString(record, 'operationId'),
|
|
546
|
+
serialNumber: recordString(record, 'serialNumber'),
|
|
547
|
+
recordingUuid: recordString(record, 'recordingUuid'),
|
|
548
|
+
profile,
|
|
549
|
+
phase: phase,
|
|
550
|
+
sinkId: recordString(record, 'sinkId'),
|
|
551
|
+
uploadId: nullableIdentifier(record.uploadId),
|
|
552
|
+
cloudCompletionId: nullableIdentifier(record.cloudCompletionId),
|
|
553
|
+
confirmationDigestHex: nullableDigest(record.confirmationDigestHex),
|
|
554
|
+
devicePlaintextSha256Hex: Object.hasOwn(record, 'devicePlaintextSha256Hex')
|
|
555
|
+
? nullableDigest(record.devicePlaintextSha256Hex)
|
|
556
|
+
: null,
|
|
557
|
+
updatedAtEpochMs: safeNonnegativeInteger(record.updatedAtEpochMs),
|
|
558
|
+
};
|
|
559
|
+
assertRecordingEvidence(journal);
|
|
560
|
+
return journal;
|
|
561
|
+
}
|
|
562
|
+
function provisioningJournal(value) {
|
|
563
|
+
const record = versionedRecord(value);
|
|
564
|
+
const phase = record.phase;
|
|
565
|
+
if (phase !== 'prepared'
|
|
566
|
+
&& phase !== 'device_applied'
|
|
567
|
+
&& phase !== 'backend_confirmed'
|
|
568
|
+
&& phase !== 'aborted') {
|
|
569
|
+
throw resumeRejected();
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
schemaVersion: 1,
|
|
573
|
+
attemptId: recordString(record, 'attemptId'),
|
|
574
|
+
materialId: record.materialId === undefined || record.materialId === null
|
|
575
|
+
? null
|
|
576
|
+
: recordString(record, 'materialId'),
|
|
577
|
+
serialNumber: recordString(record, 'serialNumber'),
|
|
578
|
+
phase,
|
|
579
|
+
updatedAtEpochMs: safeNonnegativeInteger(record.updatedAtEpochMs),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
function firmwareJournal(value) {
|
|
583
|
+
const record = versionedRecord(value);
|
|
584
|
+
if (typeof record.downloadId !== 'bigint' || record.downloadId < 0n) {
|
|
585
|
+
throw resumeRejected();
|
|
586
|
+
}
|
|
587
|
+
if (typeof record.verified !== 'boolean')
|
|
588
|
+
throw resumeRejected();
|
|
589
|
+
const sizeBytes = safeNonnegativeInteger(record.sizeBytes);
|
|
590
|
+
const downloadedBytes = safeNonnegativeInteger(record.downloadedBytes);
|
|
591
|
+
if (downloadedBytes > sizeBytes)
|
|
592
|
+
throw resumeRejected();
|
|
593
|
+
const state = record.state;
|
|
594
|
+
if (state !== undefined && state !== 'active' && state !== 'cleanup_only') {
|
|
595
|
+
throw resumeRejected();
|
|
596
|
+
}
|
|
597
|
+
if (state === 'cleanup_only'
|
|
598
|
+
&& (!record.verified || downloadedBytes !== sizeBytes)) {
|
|
599
|
+
throw resumeRejected();
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
schemaVersion: 1,
|
|
603
|
+
operationId: recordString(record, 'operationId'),
|
|
604
|
+
serialNumber: recordString(record, 'serialNumber'),
|
|
605
|
+
imageId: recordString(record, 'imageId'),
|
|
606
|
+
downloadId: record.downloadId,
|
|
607
|
+
version: recordString(record, 'version'),
|
|
608
|
+
sizeBytes,
|
|
609
|
+
crc32: unsigned32(record.crc32),
|
|
610
|
+
sha256Hex: digest(record.sha256Hex),
|
|
611
|
+
blobId: recordString(record, 'blobId'),
|
|
612
|
+
downloadedBytes,
|
|
613
|
+
verified: record.verified,
|
|
614
|
+
...(state === undefined ? {} : { state }),
|
|
615
|
+
updatedAtEpochMs: safeNonnegativeInteger(record.updatedAtEpochMs),
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
function firmwareJournalState(journal) {
|
|
619
|
+
return journal.state ?? 'active';
|
|
620
|
+
}
|
|
621
|
+
function versionedRecord(value) {
|
|
622
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
623
|
+
throw resumeRejected();
|
|
624
|
+
}
|
|
625
|
+
const record = value;
|
|
626
|
+
if (record.schemaVersion !== 1)
|
|
627
|
+
throw resumeRejected();
|
|
628
|
+
return record;
|
|
629
|
+
}
|
|
630
|
+
function recordString(record, key) {
|
|
631
|
+
return validPersistedIdentifier(record[key]);
|
|
632
|
+
}
|
|
633
|
+
function validIdentifier(value) {
|
|
634
|
+
return validateStorageIdentifier(value);
|
|
635
|
+
}
|
|
636
|
+
function validPersistedIdentifier(value) {
|
|
637
|
+
if (typeof value !== 'string'
|
|
638
|
+
|| value.length === 0
|
|
639
|
+
|| !isWellFormedUtf16(value)) {
|
|
640
|
+
throw resumeRejected();
|
|
641
|
+
}
|
|
642
|
+
return value;
|
|
643
|
+
}
|
|
644
|
+
function nullableIdentifier(value) {
|
|
645
|
+
if (value === null)
|
|
646
|
+
return null;
|
|
647
|
+
return validPersistedIdentifier(value);
|
|
648
|
+
}
|
|
649
|
+
function nullableDisplayName(value) {
|
|
650
|
+
if (value === null || typeof value === 'string')
|
|
651
|
+
return value;
|
|
652
|
+
throw resumeRejected();
|
|
653
|
+
}
|
|
654
|
+
function nullableDigest(value) {
|
|
655
|
+
return value === null ? null : digest(value);
|
|
656
|
+
}
|
|
657
|
+
function digest(value) {
|
|
658
|
+
if (typeof value !== 'string' || !/^[0-9a-f]{64}$/.test(value)) {
|
|
659
|
+
throw resumeRejected();
|
|
660
|
+
}
|
|
661
|
+
return value;
|
|
662
|
+
}
|
|
663
|
+
function safeNonnegativeInteger(value) {
|
|
664
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
665
|
+
throw resumeRejected();
|
|
666
|
+
}
|
|
667
|
+
return value;
|
|
668
|
+
}
|
|
669
|
+
function unsigned32(value) {
|
|
670
|
+
const result = safeNonnegativeInteger(value);
|
|
671
|
+
if (result > 0xffff_ffff)
|
|
672
|
+
throw resumeRejected();
|
|
673
|
+
return result;
|
|
674
|
+
}
|
|
675
|
+
function assertNextPhase(phases, previous, next) {
|
|
676
|
+
const previousIndex = phases.indexOf(previous);
|
|
677
|
+
const nextIndex = phases.indexOf(next);
|
|
678
|
+
if (nextIndex !== previousIndex && nextIndex !== previousIndex + 1) {
|
|
679
|
+
throw resumeRejected();
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
function assertNondecreasingTimestamp(previous, next) {
|
|
683
|
+
if (next.updatedAtEpochMs < previous.updatedAtEpochMs)
|
|
684
|
+
throw resumeRejected();
|
|
685
|
+
}
|
|
686
|
+
function assertEstablishedEvidence(previous, next) {
|
|
687
|
+
if (previous !== null && previous !== next)
|
|
688
|
+
throw resumeRejected();
|
|
689
|
+
}
|
|
690
|
+
function assertRecordingEvidence(journal) {
|
|
691
|
+
const phaseIndex = RECORDING_PHASES.indexOf(journal.phase);
|
|
692
|
+
const uploadingIndex = RECORDING_PHASES.indexOf('uploading');
|
|
693
|
+
const cloudCompletedIndex = RECORDING_PHASES.indexOf('cloud_completed');
|
|
694
|
+
if (phaseIndex < uploadingIndex && journal.uploadId !== null) {
|
|
695
|
+
throw resumeRejected();
|
|
696
|
+
}
|
|
697
|
+
if (phaseIndex < cloudCompletedIndex
|
|
698
|
+
&& (journal.cloudCompletionId !== null || journal.confirmationDigestHex !== null)) {
|
|
699
|
+
throw resumeRejected();
|
|
700
|
+
}
|
|
701
|
+
if (phaseIndex < RECORDING_PHASES.indexOf('staged')
|
|
702
|
+
&& journal.devicePlaintextSha256Hex !== null) {
|
|
703
|
+
throw resumeRejected();
|
|
704
|
+
}
|
|
705
|
+
if (journal.profile === 'legacy') {
|
|
706
|
+
if (phaseIndex >= uploadingIndex && journal.uploadId === null) {
|
|
707
|
+
throw resumeRejected();
|
|
708
|
+
}
|
|
709
|
+
if (phaseIndex >= cloudCompletedIndex && journal.cloudCompletionId === null) {
|
|
710
|
+
throw resumeRejected();
|
|
711
|
+
}
|
|
712
|
+
if (journal.confirmationDigestHex !== null)
|
|
713
|
+
throw resumeRejected();
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
if (phaseIndex >= cloudCompletedIndex
|
|
717
|
+
&& journal.confirmationDigestHex === null) {
|
|
718
|
+
throw resumeRejected();
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
function encodeKeyPart(value) {
|
|
722
|
+
if (!isWellFormedUtf16(value)) {
|
|
723
|
+
throw new BrowserStorageError('invalid_input');
|
|
724
|
+
}
|
|
725
|
+
const bytes = new TextEncoder().encode(value);
|
|
726
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
727
|
+
return `${bytes.byteLength}:${hex}`;
|
|
728
|
+
}
|
|
729
|
+
function isWellFormedUtf16(value) {
|
|
730
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
731
|
+
const codeUnit = value.charCodeAt(index);
|
|
732
|
+
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
|
733
|
+
const next = value.charCodeAt(index + 1);
|
|
734
|
+
if (!(next >= 0xdc00 && next <= 0xdfff))
|
|
735
|
+
return false;
|
|
736
|
+
index += 1;
|
|
737
|
+
}
|
|
738
|
+
else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return true;
|
|
743
|
+
}
|
|
744
|
+
async function requestResult(request) {
|
|
745
|
+
return await new Promise((resolve, reject) => {
|
|
746
|
+
request.addEventListener('success', () => resolve(request.result), { once: true });
|
|
747
|
+
request.addEventListener('error', () => reject(request.error), { once: true });
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
async function transactionCompletion(transaction) {
|
|
751
|
+
await new Promise((resolve, reject) => {
|
|
752
|
+
transaction.addEventListener('complete', () => resolve(), { once: true });
|
|
753
|
+
transaction.addEventListener('abort', () => reject(transaction.error), { once: true });
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
function resumeRejected() {
|
|
757
|
+
return new BrowserStorageError('resume_rejected');
|
|
758
|
+
}
|
|
759
|
+
function domExceptionName(error) {
|
|
760
|
+
if (typeof error !== 'object' || error === null || !('name' in error))
|
|
761
|
+
return null;
|
|
762
|
+
return typeof error.name === 'string' ? error.name : null;
|
|
763
|
+
}
|