@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bota
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
# Bota SDK for Web
|
|
2
|
+
|
|
3
|
+
`@bota.dev/web-app-sdk` is the foreground browser distribution of the Bota App
|
|
4
|
+
SDK. It combines Web Bluetooth and tenant-scoped browser storage with the shared
|
|
5
|
+
Rust core compiled to WebAssembly. Rust owns protocol sequencing, integrity,
|
|
6
|
+
checkpoints, and stable workflow errors; the application owns authentication,
|
|
7
|
+
backend calls, user consent, and presentation.
|
|
8
|
+
|
|
9
|
+
Historical `@bota.dev/web-sdk@1.2.0-beta.7` is public. The renamed
|
|
10
|
+
`2.0.0-beta.0` candidate retains the foreground implementation; publication
|
|
11
|
+
is pending and physical-device acceptance remains open. Remove the old
|
|
12
|
+
dependency before adding its replacement. Storage namespaces do not change.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
After the synchronized candidate is published, pin its exact version:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install --save-exact @bota.dev/web-app-sdk@2.0.0-beta.0
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Use a secure context in a desktop Chromium browser with Web Bluetooth. The
|
|
23
|
+
initial `connect()` call must run directly from a user gesture because it opens
|
|
24
|
+
the browser picker. A previously authorized exact device may be reconnected
|
|
25
|
+
without a picker only when `navigator.bluetooth.getDevices()` is available.
|
|
26
|
+
|
|
27
|
+
## Create a client and provide backend boundaries
|
|
28
|
+
|
|
29
|
+
Read-only connect and snapshot use require no storage or provider. Durable or
|
|
30
|
+
backend-authorized workflows require a non-empty tenant namespace and the
|
|
31
|
+
applicable host provider. A custom storage adapter must expose the exact same
|
|
32
|
+
namespace.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import type {
|
|
36
|
+
EncryptedUploadV2Material,
|
|
37
|
+
EncryptedUploadV2ProviderContext,
|
|
38
|
+
FirmwareDownloadProvider,
|
|
39
|
+
LegacyUploadContext,
|
|
40
|
+
ProvisioningProvider,
|
|
41
|
+
RecordingControlProvider,
|
|
42
|
+
RecordingUploadProvider,
|
|
43
|
+
UploadRequestTemplate,
|
|
44
|
+
} from '@bota.dev/web-app-sdk'
|
|
45
|
+
import { BotaDeviceClient } from '@bota.dev/web-app-sdk'
|
|
46
|
+
|
|
47
|
+
declare global {
|
|
48
|
+
interface Window {
|
|
49
|
+
BOTA_TEST_HOST_ORIGIN?: string
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const HOST_ORIGIN = window.BOTA_TEST_HOST_ORIGIN ?? 'https://example.invalid'
|
|
54
|
+
|
|
55
|
+
function encodeBytes(value: Uint8Array): string {
|
|
56
|
+
let binary = ''
|
|
57
|
+
for (const byte of value) binary += String.fromCharCode(byte)
|
|
58
|
+
return btoa(binary)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function decodeBytes(value: string): Uint8Array {
|
|
62
|
+
return Uint8Array.from(atob(value), (character) => character.charCodeAt(0))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function postHost<T>(
|
|
66
|
+
path: string,
|
|
67
|
+
body: unknown,
|
|
68
|
+
signal?: AbortSignal,
|
|
69
|
+
): Promise<T> {
|
|
70
|
+
const response = await fetch(new URL(path, HOST_ORIGIN), {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
credentials: 'include',
|
|
73
|
+
headers: { 'content-type': 'application/json' },
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
signal,
|
|
76
|
+
})
|
|
77
|
+
if (!response.ok) throw new Error(`Host callback failed: ${response.status}`)
|
|
78
|
+
if (response.status === 204) return undefined as T
|
|
79
|
+
return await response.json() as T
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function uploadRequest(response: {
|
|
83
|
+
url: string
|
|
84
|
+
headers: Record<string, string>
|
|
85
|
+
}): UploadRequestTemplate {
|
|
86
|
+
return { method: 'PUT', url: response.url, headers: response.headers }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function legacyBody(context: LegacyUploadContext): Record<string, unknown> {
|
|
90
|
+
return {
|
|
91
|
+
operationId: context.operationId,
|
|
92
|
+
serialNumber: context.serialNumber,
|
|
93
|
+
recordingUuid: context.recording.uuid,
|
|
94
|
+
sizeBytes: context.sizeBytes.toString(),
|
|
95
|
+
plaintextSha256Hex: context.plaintextSha256Hex,
|
|
96
|
+
stagedBodySha256Hex: context.stagedBodySha256Hex,
|
|
97
|
+
encrypted: context.encrypted,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function v2Evidence(
|
|
102
|
+
evidence: Parameters<EncryptedUploadV2Material['stagingRequest']>[0],
|
|
103
|
+
): Record<string, unknown> {
|
|
104
|
+
return {
|
|
105
|
+
ciphertextLength: evidence.ciphertextLength.toString(),
|
|
106
|
+
ciphertextSha256Base64: encodeBytes(evidence.ciphertextSha256),
|
|
107
|
+
manifestLength: evidence.manifestLength,
|
|
108
|
+
manifestSha256Base64: encodeBytes(evidence.manifestSha256),
|
|
109
|
+
blockCount: evidence.blockCount,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const provisioning: ProvisioningProvider = {
|
|
114
|
+
async prepare(context) {
|
|
115
|
+
const response = await postHost<{
|
|
116
|
+
materialId: string
|
|
117
|
+
apiEndpointBase64: string
|
|
118
|
+
deviceTokenBase64: string
|
|
119
|
+
mtu: number
|
|
120
|
+
}>('/sdk/provisioning/prepare', {
|
|
121
|
+
attemptId: context.attemptId,
|
|
122
|
+
materialId: context.materialId,
|
|
123
|
+
serialNumber: context.serialNumber,
|
|
124
|
+
nonceBase64: encodeBytes(context.nonce),
|
|
125
|
+
devicePublicKeyBase64: encodeBytes(context.devicePublicKey),
|
|
126
|
+
}, context.signal)
|
|
127
|
+
return {
|
|
128
|
+
materialId: response.materialId,
|
|
129
|
+
apiEndpoint: decodeBytes(response.apiEndpointBase64),
|
|
130
|
+
deviceToken: decodeBytes(response.deviceTokenBase64),
|
|
131
|
+
mtu: response.mtu,
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
async confirm(context) {
|
|
135
|
+
await postHost<void>('/sdk/provisioning/confirm', {
|
|
136
|
+
attemptId: context.attemptId,
|
|
137
|
+
serialNumber: context.serialNumber,
|
|
138
|
+
}, context.signal)
|
|
139
|
+
},
|
|
140
|
+
async abort(context) {
|
|
141
|
+
await postHost<void>('/sdk/provisioning/abort', {
|
|
142
|
+
attemptId: context.attemptId,
|
|
143
|
+
serialNumber: context.serialNumber,
|
|
144
|
+
reason: context.reason,
|
|
145
|
+
}, context.signal)
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const recordingControl: RecordingControlProvider = {
|
|
150
|
+
async prepare(context) {
|
|
151
|
+
const response = await postHost<{ grantBase64: string }>(
|
|
152
|
+
'/sdk/recording-control/prepare',
|
|
153
|
+
{
|
|
154
|
+
operationId: context.operationId,
|
|
155
|
+
serialNumber: context.serialNumber,
|
|
156
|
+
action: context.action,
|
|
157
|
+
authorityId: context.authorityId,
|
|
158
|
+
},
|
|
159
|
+
context.signal,
|
|
160
|
+
)
|
|
161
|
+
return { grant: decodeBytes(response.grantBase64) }
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const firmwareDownload: FirmwareDownloadProvider = {
|
|
166
|
+
async resolve(context) {
|
|
167
|
+
const response = await postHost<{
|
|
168
|
+
url: string
|
|
169
|
+
headers: Record<string, string>
|
|
170
|
+
}>('/sdk/firmware/resolve', {
|
|
171
|
+
operationId: context.operationId,
|
|
172
|
+
serialNumber: context.serialNumber,
|
|
173
|
+
image: context.image,
|
|
174
|
+
}, context.signal)
|
|
175
|
+
return { method: 'GET', url: response.url, headers: response.headers }
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const recordingUpload: RecordingUploadProvider = {
|
|
180
|
+
async prepareLegacyUpload(context) {
|
|
181
|
+
const response = await postHost<{
|
|
182
|
+
uploadId: string
|
|
183
|
+
request: { url: string; headers: Record<string, string> }
|
|
184
|
+
}>(
|
|
185
|
+
'/sdk/recordings/legacy/prepare',
|
|
186
|
+
legacyBody(context),
|
|
187
|
+
context.signal,
|
|
188
|
+
)
|
|
189
|
+
return { uploadId: response.uploadId, request: uploadRequest(response.request) }
|
|
190
|
+
},
|
|
191
|
+
async completeLegacyUpload(context) {
|
|
192
|
+
return await postHost<{ cloudCompletionId: string }>(
|
|
193
|
+
'/sdk/recordings/legacy/complete',
|
|
194
|
+
{ ...legacyBody(context), uploadId: context.uploadId },
|
|
195
|
+
context.signal,
|
|
196
|
+
)
|
|
197
|
+
},
|
|
198
|
+
async reconcileLegacyUpload(context) {
|
|
199
|
+
return await postHost<
|
|
200
|
+
| { state: 'not_uploaded' }
|
|
201
|
+
| { state: 'cloud_completed'; cloudCompletionId: string }
|
|
202
|
+
>('/sdk/recordings/legacy/reconcile', {
|
|
203
|
+
...legacyBody(context),
|
|
204
|
+
uploadId: context.uploadId,
|
|
205
|
+
}, context.signal)
|
|
206
|
+
},
|
|
207
|
+
async prepareEncryptedUploadV2(context: EncryptedUploadV2ProviderContext) {
|
|
208
|
+
const response = await postHost<{
|
|
209
|
+
materialId: string
|
|
210
|
+
recordingId: string
|
|
211
|
+
uploadSessionId: string
|
|
212
|
+
ownerRevision: number
|
|
213
|
+
policy: 'legacy_allowed' | 'v2_preferred' | 'v2_required'
|
|
214
|
+
authorizationBase64: string
|
|
215
|
+
}>('/sdk/recordings/v2/prepare', {
|
|
216
|
+
operationId: context.operationId,
|
|
217
|
+
serialNumber: context.serialNumber,
|
|
218
|
+
recording: {
|
|
219
|
+
uuid: context.recording.uuid,
|
|
220
|
+
generation: context.recording.generation,
|
|
221
|
+
storageFormat: context.recording.storageFormat,
|
|
222
|
+
ciphertextLength: context.recording.ciphertextLength.toString(),
|
|
223
|
+
ciphertextSha256Base64: encodeBytes(context.recording.ciphertextSha256),
|
|
224
|
+
},
|
|
225
|
+
capability: {
|
|
226
|
+
rawValueBase64: encodeBytes(context.capability.rawValue),
|
|
227
|
+
sha256Base64: encodeBytes(context.capability.sha256),
|
|
228
|
+
decoded: context.capability.decoded,
|
|
229
|
+
},
|
|
230
|
+
checkpoint: context.checkpoint && {
|
|
231
|
+
uploadSessionId: context.checkpoint.uploadSessionId,
|
|
232
|
+
ownerRevision: context.checkpoint.ownerRevision,
|
|
233
|
+
checkpointRevision: context.checkpoint.checkpointRevision,
|
|
234
|
+
nextCiphertextOffset: context.checkpoint.nextCiphertextOffset.toString(),
|
|
235
|
+
prefixSha256Base64: encodeBytes(context.checkpoint.prefixSha256),
|
|
236
|
+
transportSessionId: context.checkpoint.transportSessionId.toString(),
|
|
237
|
+
sinkId: context.checkpoint.sinkId,
|
|
238
|
+
windowPackets: context.checkpoint.windowPackets,
|
|
239
|
+
dataPayloadBytes: context.checkpoint.dataPayloadBytes,
|
|
240
|
+
},
|
|
241
|
+
}, context.signal)
|
|
242
|
+
const materialId = response.materialId
|
|
243
|
+
return {
|
|
244
|
+
...response,
|
|
245
|
+
authorization: decodeBytes(response.authorizationBase64),
|
|
246
|
+
async stagingRequest(evidence, signal) {
|
|
247
|
+
const request = await postHost<{
|
|
248
|
+
url: string
|
|
249
|
+
headers: Record<string, string>
|
|
250
|
+
}>('/sdk/recordings/v2/staging-request', {
|
|
251
|
+
materialId,
|
|
252
|
+
evidence: v2Evidence(evidence),
|
|
253
|
+
}, signal)
|
|
254
|
+
return uploadRequest(request)
|
|
255
|
+
},
|
|
256
|
+
async submitManifest(manifest, evidence, signal) {
|
|
257
|
+
await postHost<void>('/sdk/recordings/v2/manifest', {
|
|
258
|
+
materialId,
|
|
259
|
+
manifestBase64: encodeBytes(manifest),
|
|
260
|
+
evidence: v2Evidence(evidence),
|
|
261
|
+
}, signal)
|
|
262
|
+
},
|
|
263
|
+
async finalize(evidence, signal) {
|
|
264
|
+
await postHost<void>('/sdk/recordings/v2/finalize', {
|
|
265
|
+
materialId,
|
|
266
|
+
evidence: v2Evidence(evidence),
|
|
267
|
+
}, signal)
|
|
268
|
+
},
|
|
269
|
+
async completionReceipt(evidence, signal) {
|
|
270
|
+
const receipt = await postHost<{ receiptBase64: string }>(
|
|
271
|
+
'/sdk/recordings/v2/receipt',
|
|
272
|
+
{ materialId, evidence: v2Evidence(evidence) },
|
|
273
|
+
signal,
|
|
274
|
+
)
|
|
275
|
+
return decodeBytes(receipt.receiptBase64)
|
|
276
|
+
},
|
|
277
|
+
async cancel(signal) {
|
|
278
|
+
await postHost<void>(
|
|
279
|
+
'/sdk/recordings/v2/cancel',
|
|
280
|
+
{ materialId },
|
|
281
|
+
signal,
|
|
282
|
+
)
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function createExampleClient(tenant: {
|
|
289
|
+
organizationId: string
|
|
290
|
+
projectId: string
|
|
291
|
+
userId: string
|
|
292
|
+
}) {
|
|
293
|
+
return await BotaDeviceClient.create({
|
|
294
|
+
storageNamespace:
|
|
295
|
+
`${tenant.organizationId}:${tenant.projectId}:${tenant.userId}`,
|
|
296
|
+
providers: {
|
|
297
|
+
provisioning,
|
|
298
|
+
recordingUpload,
|
|
299
|
+
recordingControl,
|
|
300
|
+
firmwareDownload,
|
|
301
|
+
},
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Those application-owned adapters have these exact responsibilities:
|
|
307
|
+
|
|
308
|
+
| Provider | Application responsibility | SDK boundary |
|
|
309
|
+
|---|---|---|
|
|
310
|
+
| `provisioning` | Prepare opaque material for the exact serial, nonce, device public key, and attempt; confirm physical success or abort failure | Material is operation-bound, volatile, and never logged or persisted by the SDK |
|
|
311
|
+
| `recordingUpload` | Resolve short-lived object-storage requests, reconcile ambiguous legacy uploads, complete cloud processing, and issue exact v2 material/receipt | The SDK stages verified bytes, uploads through the returned request, and confirms the device only after durable cloud completion |
|
|
312
|
+
| `recordingControl` | Exchange the serial, action, authority ID, and operation ID for an exact grant | The SDK cannot mint, widen, or reuse a grant for another action |
|
|
313
|
+
| `firmwareDownload` | Resolve stable image identity to a fresh operation-scoped HTTPS `GET` URL and headers | URL and headers stay memory-only; the SDK verifies size, SHA-256, and CRC32 before device mutation |
|
|
314
|
+
|
|
315
|
+
Implement these providers in the application or its backend-client layer. Do
|
|
316
|
+
not put long-lived API credentials, decryption keys, production endpoints, or
|
|
317
|
+
private signing material in browser code. A provider may call an authenticated
|
|
318
|
+
application endpoint such as `https://example.invalid/sdk/provisioning/prepare`;
|
|
319
|
+
the SDK itself never calls the Bota API implicitly. Provider failures must fail
|
|
320
|
+
closed, and provider implementations must not log returned grants, tokens,
|
|
321
|
+
signed documents, presigned URLs, headers, WiFi credentials, receipts, or
|
|
322
|
+
recording content. Every provider callback receives an operation-scoped
|
|
323
|
+
`AbortSignal`; pass it to application I/O. On cancellation the SDK stops
|
|
324
|
+
waiting, observes late settlement, and ignores late results. The SDK sends
|
|
325
|
+
operation-scoped upload and firmware requests
|
|
326
|
+
with redirects disabled; providers must return the exact final HTTPS target
|
|
327
|
+
rather than a redirecting URL.
|
|
328
|
+
|
|
329
|
+
Create one client for the signed-in tenant and keep it for the page lifetime:
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
const bota = await createExampleClient({ organizationId, projectId, userId })
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
## Picker connection, reconnect, and snapshot
|
|
336
|
+
|
|
337
|
+
The expected serial must come from the authenticated application device record.
|
|
338
|
+
An advertised Bluetooth name is display/filter metadata only.
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
connectButton.addEventListener('click', async () => {
|
|
342
|
+
const device = await bota.devices.connect({
|
|
343
|
+
expectedSerialNumber: activeDevice.serialNumber,
|
|
344
|
+
})
|
|
345
|
+
console.log(device.serialNumber)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
// Use after a disconnect or reload. This never opens the picker or probes by name.
|
|
349
|
+
const reconnected = await bota.devices.reconnect({
|
|
350
|
+
expectedSerialNumber: activeDevice.serialNumber,
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
const snapshot = await bota.devices.readSnapshot()
|
|
354
|
+
console.log(reconnected.serialNumber, snapshot.status.batteryPercent)
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
`connect()` publishes a device only after Device Information serial verification.
|
|
358
|
+
`reconnect()` enumerates previously authorized devices, selects only the saved
|
|
359
|
+
browser device ID, waits for prior notification teardown, and verifies the
|
|
360
|
+
serial again. `readSnapshot()` repeats that verification before returning fresh
|
|
361
|
+
identity, status, and capability values.
|
|
362
|
+
|
|
363
|
+
## Recording list and sync
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
const recordings = await bota.recordings.list()
|
|
367
|
+
const recording = recordings[0]
|
|
368
|
+
|
|
369
|
+
if (recording) {
|
|
370
|
+
const profile = recording.encryptedUploadV2
|
|
371
|
+
? 'encrypted_upload_v2'
|
|
372
|
+
: 'legacy'
|
|
373
|
+
|
|
374
|
+
const result = await bota.recordings.sync(recording, {
|
|
375
|
+
profile,
|
|
376
|
+
onProgress: ({ phase, completedBytes, totalBytes }) => {
|
|
377
|
+
renderRecordingProgress(phase, completedBytes, totalBytes)
|
|
378
|
+
},
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
console.log(result.operationId, result.cloudCompletionId)
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Encrypted Upload v2 is selected only from a fresh firmware capability read and
|
|
386
|
+
matching provider material; firmware version strings never enable it. The
|
|
387
|
+
ciphertext remains ciphertext in OPFS and through the staging upload, and
|
|
388
|
+
decryption keys never enter the SDK. Legacy plaintext is tenant-scoped and
|
|
389
|
+
removed after confirmed upload. Both paths send device confirmation only after
|
|
390
|
+
durable cloud completion.
|
|
391
|
+
|
|
392
|
+
Use `listPendingOperations()`, `resume(operationId)`,
|
|
393
|
+
`cancel(operationId)`, and recovery-only `confirm(operationId)` for durable
|
|
394
|
+
operations. `confirm()` is not an arbitrary delete API: it requires a journal
|
|
395
|
+
that proves cloud completion and exact confirmation material.
|
|
396
|
+
|
|
397
|
+
## Provisioning, settings, WiFi, and recording control
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
await bota.provisioning.provision({ attemptId })
|
|
401
|
+
|
|
402
|
+
const settings = await bota.provisioning.readConnectionSettings()
|
|
403
|
+
await bota.provisioning.writeConnectionSettings({
|
|
404
|
+
...settings,
|
|
405
|
+
enabledConnections: { ...settings.enabledConnections, wifi: true },
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
const networks = await bota.wifi.scanNetworks()
|
|
409
|
+
await bota.wifi.configure(
|
|
410
|
+
{ ssid: selectedNetwork.ssid, password: enteredPassword },
|
|
411
|
+
wifiGrant,
|
|
412
|
+
)
|
|
413
|
+
const wifiStatus = await bota.wifi.readStatus()
|
|
414
|
+
const wifiSubscription = await bota.wifi.subscribeToStatus(renderWiFiStatus)
|
|
415
|
+
|
|
416
|
+
await bota.controls.startRecording({ authorityId: recordingAuthorityId })
|
|
417
|
+
await bota.controls.stopRecording({ authorityId: recordingAuthorityId })
|
|
418
|
+
|
|
419
|
+
await wifiSubscription.remove()
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Provisioning closes the prepare/physical-result/confirm-or-abort loop. A
|
|
423
|
+
successful backend prepare is not a completed bind. Remove-only deprovisioning
|
|
424
|
+
uses `bota.provisioning.deprovision({ grant })`; it is non-destructive and must
|
|
425
|
+
not be described as factory reset. WiFi credentials and control grants are
|
|
426
|
+
volatile and are scrubbed on terminal paths where JavaScript permits.
|
|
427
|
+
|
|
428
|
+
## Firmware update and recovery
|
|
429
|
+
|
|
430
|
+
Check the independently reported browser capability before starting:
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
const capabilities = bota.devices.getCapabilities()
|
|
434
|
+
if (!capabilities.firmwareUpdate) {
|
|
435
|
+
throw new Error('Firmware update is unavailable in this browser')
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
await bota.ota.updateFirmware(firmwareImage, {
|
|
439
|
+
operationId: firmwareOperationId,
|
|
440
|
+
onProgress: ({ phase, completedBytes, totalBytes }) => {
|
|
441
|
+
renderFirmwareProgress(phase, completedBytes, totalBytes)
|
|
442
|
+
},
|
|
443
|
+
})
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
The SDK streams the response to OPFS and verifies exact length, SHA-256, and
|
|
447
|
+
CRC32 before the first OTA GATT mutation. Reboot recovery uses only the exact
|
|
448
|
+
previously verified browser device ID and never opens the picker. Continue a
|
|
449
|
+
durable operation with `resumeFirmwareUpdate(operationId)` and stop it with
|
|
450
|
+
`cancelFirmwareUpdate(operationId)`. Reload validates journal, checkpoint, and
|
|
451
|
+
blob compatibility before GATT. A terminal `cleanup_only` journal performs
|
|
452
|
+
only idempotent local cleanup and does not call the provider or device.
|
|
453
|
+
|
|
454
|
+
## Device logs
|
|
455
|
+
|
|
456
|
+
```ts
|
|
457
|
+
const logSubscription = await bota.logs.subscribe(({ message, isBacklog }) => {
|
|
458
|
+
renderDeviceLog({ message, isBacklog })
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
await logSubscription.remove()
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Only one device-log owner is allowed. Rust emits complete sanitized lines; raw
|
|
465
|
+
packets and decoder details do not reach the callback. Explicit removal,
|
|
466
|
+
listener failure, disconnect, and `destroy()` cancel the exact workflow and
|
|
467
|
+
remove its characteristic subscription before ownership is released.
|
|
468
|
+
|
|
469
|
+
## Logout or tenant change
|
|
470
|
+
|
|
471
|
+
```ts
|
|
472
|
+
await bota.destroy()
|
|
473
|
+
await bota.clearPersistedData()
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Use that order. `destroy()` is terminal and idempotent: it rejects new work,
|
|
477
|
+
joins picker/reconnect startup, cancels active owners, removes passive
|
|
478
|
+
subscriptions, and performs one final disconnect. `clearPersistedData()` is
|
|
479
|
+
local-only, BLE-free, and repeatable after destruction; it clears only the
|
|
480
|
+
client's tenant namespace. Do not share a namespace between organizations,
|
|
481
|
+
projects, or users.
|
|
482
|
+
|
|
483
|
+
Manager names exported from the package root are TypeScript instance types.
|
|
484
|
+
Obtain managers from the client; direct manager construction is not public API.
|
|
485
|
+
|
|
486
|
+
## Capability matrix
|
|
487
|
+
|
|
488
|
+
| Capability | Candidate support |
|
|
489
|
+
|---|---|
|
|
490
|
+
| Explicit picker connect, disconnect, exact-identity snapshot | Supported in the foreground |
|
|
491
|
+
| Saved exact-device reconnect | Supported when authorized-device enumeration is available |
|
|
492
|
+
| Recording list, legacy sync, cancellation, recovery | Supported with durable storage and `recordingUpload` |
|
|
493
|
+
| Encrypted Upload v2 | Supported only when freshly advertised and exactly host-authorized |
|
|
494
|
+
| Provisioning and remove-only deprovision | Supported with `provisioning` |
|
|
495
|
+
| Connection settings | Supported |
|
|
496
|
+
| WiFi scan/configure/disconnect/status/subscription | Supported in the foreground |
|
|
497
|
+
| Recording start/stop | Supported with `recordingControl` |
|
|
498
|
+
| OTA download/transfer/reboot/reconnect/reload recovery | Supported with durable storage and `firmwareDownload` |
|
|
499
|
+
| Device logs | Supported as one foreground subscription |
|
|
500
|
+
| Background scan, service-worker Bluetooth, closed-tab work | Unavailable |
|
|
501
|
+
| Live recording streaming | Unavailable |
|
|
502
|
+
| Authenticated destructive factory reset | Unavailable |
|
|
503
|
+
| Safari/iOS fallback or Bluetooth polyfill | Unavailable |
|
|
504
|
+
| Flutter Web and Windows | Unavailable |
|
|
505
|
+
| Built-in Bota API authentication/client | Unavailable; application-owned by design |
|
|
506
|
+
|
|
507
|
+
Browser capabilities are reported independently by
|
|
508
|
+
`bota.devices.getCapabilities()`. An unavailable optional capability fails
|
|
509
|
+
before device mutation with a stable SDK error. Web Bluetooth permission alone
|
|
510
|
+
is never treated as device identity or backend authorization.
|
|
511
|
+
|
|
512
|
+
## Physical acceptance
|
|
513
|
+
|
|
514
|
+
Automated Chromium uses deterministic fake Bluetooth and storage boundaries;
|
|
515
|
+
it is not physical-device evidence. Run the supervised matrix
|
|
516
|
+
in [`docs/testing/web-physical-device.md`](../../docs/testing/web-physical-device.md)
|
|
517
|
+
with one exact device and record the result in the matching release evidence.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { BrowserCapabilities } from './models.ts';
|
|
2
|
+
import type { BrowserBluetoothTransport } from './transport.ts';
|
|
3
|
+
export interface BrowserStorageSupport {
|
|
4
|
+
indexedDB: boolean;
|
|
5
|
+
opfs: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function detectBrowserStorageSupport(): BrowserStorageSupport;
|
|
8
|
+
export declare function detectBrowserCapabilities(transport: BrowserBluetoothTransport, storage?: BrowserStorageSupport): BrowserCapabilities;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function detectBrowserStorageSupport() {
|
|
2
|
+
const storage = typeof navigator === 'undefined' ? undefined : navigator.storage;
|
|
3
|
+
return {
|
|
4
|
+
indexedDB: typeof globalThis.indexedDB !== 'undefined' && globalThis.indexedDB !== null,
|
|
5
|
+
opfs: typeof storage?.getDirectory === 'function',
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
export function detectBrowserCapabilities(transport, storage = detectBrowserStorageSupport()) {
|
|
9
|
+
const bluetooth = transport.isSupported;
|
|
10
|
+
const durableStorage = storage.indexedDB && storage.opfs;
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
bluetooth,
|
|
13
|
+
authorizedDeviceReconnect: bluetooth && transport.supportsAuthorizedDevices,
|
|
14
|
+
durableStorage,
|
|
15
|
+
largeRecordingSync: bluetooth && durableStorage,
|
|
16
|
+
firmwareUpdate: bluetooth
|
|
17
|
+
&& transport.supportsAuthorizedDevices
|
|
18
|
+
&& durableStorage
|
|
19
|
+
&& typeof globalThis.fetch === 'function',
|
|
20
|
+
});
|
|
21
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { CoreLoader } from './core.ts';
|
|
2
|
+
import { ControlManager } from './controlManager.ts';
|
|
3
|
+
import { DeviceManager } from './deviceManager.ts';
|
|
4
|
+
import { LogManager } from './logManager.ts';
|
|
5
|
+
import { OTAManager } from './otaManager.ts';
|
|
6
|
+
import { ProvisioningManager } from './provisioningManager.ts';
|
|
7
|
+
import type { ProvisioningProvider, FirmwareDownloadProvider, RecordingControlProvider, RecordingUploadProvider } from './providers.ts';
|
|
8
|
+
import { RecordingManager } from './recordingManager.ts';
|
|
9
|
+
import { type BrowserSdkStorage } from './storage.ts';
|
|
10
|
+
import type { BrowserBluetoothTransport } from './transport.ts';
|
|
11
|
+
import { WiFiManager } from './wifiManager.ts';
|
|
12
|
+
export interface BotaDeviceClientOptions {
|
|
13
|
+
storageNamespace?: string;
|
|
14
|
+
coreLoader?: CoreLoader;
|
|
15
|
+
transport?: BrowserBluetoothTransport;
|
|
16
|
+
storage?: BrowserSdkStorage;
|
|
17
|
+
providers?: {
|
|
18
|
+
provisioning?: ProvisioningProvider;
|
|
19
|
+
firmwareDownload?: FirmwareDownloadProvider;
|
|
20
|
+
recordingControl?: RecordingControlProvider;
|
|
21
|
+
recordingUpload?: RecordingUploadProvider;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export declare class BotaDeviceClient {
|
|
25
|
+
readonly devices: DeviceManager;
|
|
26
|
+
readonly controls: ControlManager;
|
|
27
|
+
readonly provisioning: ProvisioningManager;
|
|
28
|
+
readonly ota: OTAManager;
|
|
29
|
+
readonly logs: LogManager;
|
|
30
|
+
readonly recordings: RecordingManager;
|
|
31
|
+
readonly wifi: WiFiManager;
|
|
32
|
+
private readonly runtime;
|
|
33
|
+
private readonly storage;
|
|
34
|
+
private destroyPromise;
|
|
35
|
+
private constructor();
|
|
36
|
+
static create(options?: BotaDeviceClientOptions): Promise<BotaDeviceClient>;
|
|
37
|
+
clearPersistedData(): Promise<void>;
|
|
38
|
+
destroy(): Promise<void>;
|
|
39
|
+
}
|