@condorinst/mobile-bridge 0.19.2 → 0.21.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/llms-full.txt ADDED
@@ -0,0 +1,2041 @@
1
+ # @condorinst/mobile-bridge 0.21.0 — full documentation
2
+
3
+ Generated from the files listed below, in reading order. Payload types are
4
+ canonical in dist/index.d.ts; the machine-readable contract is
5
+ contract/bridge-contract.json.
6
+
7
+ - AGENTS.md
8
+ - docs/agent-guide.md
9
+ - docs/bridge-contract.md
10
+ - docs/troubleshooting.md
11
+ - docs/device-testing.md
12
+ - docs/limits.md
13
+ - docs/client-guide.md
14
+ - docs/pilot-onboarding.md
15
+ - examples/minimal-home/src/main.ts
16
+
17
+ <!-- ===== AGENTS.md ===== -->
18
+
19
+ # AGENTS.md — building a Condor Canvas client
20
+
21
+ You are writing the **Home screen** of a Condor Canvas app. It is a single web
22
+ bundle that renders inside a sandboxed iframe in a native mobile app. The app
23
+ owns login, settings and the Bluetooth connection to the devices; you own what
24
+ the person sees on the Home.
25
+
26
+ This file is the contract. Read it before writing code. Everything here is
27
+ enforced, either by a gate in the app or by the browser runtime on a real
28
+ device, where mistakes are expensive to diagnose.
29
+
30
+ ## The one thing that makes this different
31
+
32
+ **Failures are silent.** A malformed command, an oversized payload, a command
33
+ sent before the app is ready, a request for an account with no device: nothing
34
+ happens. No exception, no rejected promise, no console error.
35
+
36
+ So never write code whose correctness you infer from the absence of an error.
37
+ Drive the UI from the events that arrive, give every wait a deadline, and
38
+ always have a screen for "it did not come".
39
+
40
+ ## Hard rules
41
+
42
+ 1. **No storage.** `localStorage`, `sessionStorage`, `indexedDB` and cookies all
43
+ fail — the iframe has a `null` origin. Keep state in memory and accept that
44
+ it is lost on reload. Re-request what you need at startup.
45
+ 2. **No network.** `connect-src 'self'` on a null origin blocks every `fetch`,
46
+ `XMLHttpRequest` and `WebSocket`. All data comes through the bridge.
47
+ 3. **No `eval`, no `new Function`.** The CSP has no `'unsafe-eval'`. Configure
48
+ your bundler so it does not emit eval-based sourcemaps.
49
+ 4. **One self-contained `index.html`.** Every script inline, every stylesheet
50
+ inline, every image and font as a `data:` URI. A `<script src>` or a
51
+ `<link rel="stylesheet">` pointing anywhere else makes the app **reject the
52
+ whole bundle**.
53
+ 5. **Build single-chunk.** No code splitting, no ES module imports between
54
+ chunks — `srcdoc` cannot resolve them. Vite: `vite-plugin-singlefile`.
55
+ 6. **The `.zip` must be at most 2 MB.** `data:` URIs inflate assets by about a
56
+ third.
57
+ 7. **Use `BridgeClient`, never raw `postMessage`.** Hand-rolled messages fail
58
+ schema validation and are dropped without a trace.
59
+ 8. **Never assume a capability.** Read `nativeVersion` from the handshake and
60
+ branch on it. See the capability matrix in `docs/bridge-contract.md`.
61
+ 9. **Never ask once and wait.** Use `BridgeClient.requestUserDevices()` for the
62
+ device list; a single `send('userDevice')` can be lost (see below).
63
+
64
+ ## What you cannot reach
65
+
66
+ `window.parent` properties · `window.top` · `window.open` ·
67
+ `navigator.sendBeacon` (neutralized before your code runs) · service workers ·
68
+ camera, microphone and geolocation (denied unconditionally) · the `dfu` key and
69
+ command (denied by policy).
70
+
71
+ ## The golden path
72
+
73
+ ```bash
74
+ npm install @condorinst/mobile-bridge@^0.21.0
75
+ ```
76
+
77
+ ```ts
78
+ import {
79
+ BridgeClient, BRIDGE_KEYS, versionAtLeast, type UserDevicePayload,
80
+ } from '@condorinst/mobile-bridge';
81
+
82
+ // 1. Subscribe before you ask for anything. Roster changes arrive here too.
83
+ BridgeClient.on(BRIDGE_KEYS.userDevice, (devices: UserDevicePayload) => renderDevices(devices));
84
+ BridgeClient.on(BRIDGE_KEYS.systemStatus, (s) => renderEnvironment(s)); // bluetooth, internet, permissions
85
+
86
+ // 2. Handshake. nativeVersion is the ONLY capability signal; ack.accepted is
87
+ // always true and carries no information — never branch on it.
88
+ const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
89
+ const canAddressById = versionAtLeast(ack.nativeVersion, '0.17.0'); // null → false
90
+
91
+ // 3. Ask for the devices with the helper. It resends until they arrive.
92
+ const result = await BridgeClient.requestUserDevices();
93
+ if (result.status === 'timeout') {
94
+ // No reply in 30 s. hasDevices === false suggests an account with no device;
95
+ // otherwise the app could not load the list. Either way: show an empty state
96
+ // with a "Try again" button that calls requestUserDevices({ getFromServer: true }).
97
+ renderEmpty(result.hasDevices === false);
98
+ } else {
99
+ // 4. Subscribe to the connection state of each device (`add` pairs nothing).
100
+ for (const d of result.devices.devicesConfig) {
101
+ BridgeClient.send('deviceConnection', {
102
+ type: 'add', serialNo: d.serialNo, ...(canAddressById ? { deviceId: d.deviceId } : {}),
103
+ });
104
+ }
105
+ }
106
+
107
+ // 5. History: one page per call; it resolves { success: false, error } on
108
+ // failure and only rejects on timeout.
109
+ const page = await BridgeClient.fetchSensorLog({ requestId: crypto.randomUUID(), serialNo });
110
+ ```
111
+
112
+ **Why the helper.** Device data comes from a background service that the app
113
+ starts *after* your bundle is already running. Anything you send to it before it
114
+ is up is lost without a reply — while the handshake, `systemStatus` and
115
+ `language` already work, so the bridge *looks* ready. And an account with no
116
+ linked device gets no `userDevice` reply at all.
117
+
118
+ **Identify devices by `deviceId`.** `serialNo` can repeat: an ActLumus and an
119
+ ActLumus Plus may share one. Join `devicesConfig`, `devicesTelemetry` and
120
+ `devicesUserConfig` on `deviceId`; send `serialNo` in every command, plus
121
+ `deviceId` when `nativeVersion` ≥ 0.17.0 (`versionAtLeast` does the comparison).
122
+ The typed shapes (`DeviceConfigItem`, `DeviceTelemetryItem`, …) show every field
123
+ and its unit — `devicesConfig` has no name or battery; those are in the
124
+ telemetry.
125
+
126
+ **Timestamps are device-local.** `log_time` is `'YYYY-MM-DD HH:MM:SS'` in the
127
+ device's wall-clock time. Parse and build it with local `Date` getters, never
128
+ with `toISOString()` or a `'Z'` suffix.
129
+
130
+ Three commands open a confirmation dialog owned by Condor: `syncTime`,
131
+ `deleteMemory` and `logout`. If the person declines, you get nothing — the ack
132
+ never arrives. Do not block your UI on them.
133
+
134
+ ## Develop in the harness, not in a bare browser tab
135
+
136
+ ```bash
137
+ npx condor-mobile-bridge dev -i dist/index.html --scenario cold-start
138
+ ```
139
+
140
+ It serves your bundle at `http://localhost:5173` inside the **same sandbox,
141
+ shell and policy as the app**, with a simulated native side. Opening
142
+ `dist/index.html` directly does not work: `BridgeClient.handshake()` rejects at
143
+ once outside the shell. Rebuild and reload; the harness re-reads the file.
144
+
145
+ | Scenario | What it reproduces |
146
+ | --- | --- |
147
+ | `default` | two devices, realistic latency |
148
+ | `cold-start` | the background service takes 4 s; earlier `ping`/`userDevice` are lost |
149
+ | `no-devices` | `userDevice` never answers; `hasDevices: false` |
150
+ | `shared-serial` | an ActLumus and a Plus share serial 7047 |
151
+ | `offline` | no internet; `sensorLog` fails |
152
+
153
+ The harness console (`[condor dev]`) says what was sent (with its arguments),
154
+ answered and dropped. The **◀ OS back** button at the top presses the OS back
155
+ button. `--native-version null` (or `0.15.0`, …) makes the handshake report that
156
+ version, to test your capability branches.
157
+
158
+ ## The validation loop — run this before you call the work done
159
+
160
+ ```bash
161
+ npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
162
+ npx condor-mobile-bridge validate -i dist/bundle.zip --strict
163
+ ```
164
+
165
+ `validate` applies the same gates the app applies on download, plus a lint for
166
+ the APIs the sandbox blocks at runtime. Under `--strict` it exits non-zero on
167
+ warnings too. **Treat a non-zero exit as a build failure.** It is a textual
168
+ check, so a clean run is necessary, not sufficient.
169
+
170
+ ## Definition of done
171
+
172
+ - [ ] `validate --strict` exits 0.
173
+ - [ ] The bundle works in **every** harness scenario: it leaves the loading
174
+ screen in `cold-start`, shows an empty state in `no-devices`, keeps two
175
+ cards apart in `shared-serial`, and explains the failure in `offline`.
176
+ - [ ] The UI renders before any event arrives — first paint must not depend on
177
+ the bridge.
178
+ - [ ] Every capability beyond the baseline is guarded by a `nativeVersion` check
179
+ (`versionAtLeast`), and the page still renders with
180
+ `--native-version null`.
181
+ - [ ] Nothing waits forever on a reply that may never come.
182
+
183
+ To put the bundle on a phone, read `docs/device-testing.md`.
184
+
185
+ ## Where to read more
186
+
187
+ | Question | File |
188
+ | --- | --- |
189
+ | Every key, command, error shape and capability | `docs/bridge-contract.md` |
190
+ | The same, as JSON you can parse | `contract/bridge-contract.json` |
191
+ | Step-by-step build of a Home | `docs/agent-guide.md` |
192
+ | "My command did nothing" / "the device list never came" | `docs/troubleshooting.md` |
193
+ | Getting the bundle onto a device, and seeing its errors | `docs/device-testing.md` |
194
+ | Every hard limit, with the code that enforces it | `docs/limits.md` |
195
+ | Payload shapes, authoritative | `dist/index.d.ts` (your editor shows them) |
196
+ | A working client you can copy | `examples/minimal-home/` |
197
+ | All of the above in one file | `llms-full.txt` |
198
+
199
+ <!-- ===== docs/agent-guide.md ===== -->
200
+
201
+ # Agent guide — building a Canvas client
202
+
203
+ A working procedure for an AI agent (or a developer) building the client bundle
204
+ that renders as the Home of a Condor Canvas app.
205
+
206
+ Read `AGENTS.md` first — it is the contract in one screen. This document is the
207
+ procedure: what to do, in what order, and what to check at each stage.
208
+
209
+ ## What you are building
210
+
211
+ One `.zip` containing one self-contained `index.html`. Condor's app downloads
212
+ it, verifies its SHA-256, and renders it inside a sandboxed iframe as the Home
213
+ screen. Login, settings, Bluetooth and background collection are the app's job.
214
+ Yours is what the person sees when the app opens.
215
+
216
+ You are not building a website. You have no storage, no network, and no origin.
217
+ Everything you display comes through the bridge, and everything you hold lives
218
+ in memory until the next reload.
219
+
220
+ ## Stage 0 — set up so you can see what you are doing
221
+
222
+ Start from `examples/minimal-home/` — a complete, working client with the
223
+ reference boot sequence, and a build script that produces a single-file bundle.
224
+ Copy it first, then install, so its `package.json` is the one you keep:
225
+
226
+ ```bash
227
+ mkdir my-home && cd my-home
228
+ npm pack @condorinst/mobile-bridge@^0.21.0 && tar -xzf condorinst-mobile-bridge-*.tgz
229
+ cp -r package/examples/minimal-home/. . && rm -rf package condorinst-mobile-bridge-*.tgz
230
+ npm install
231
+ ```
232
+
233
+ Or, in a project you already have: `npm install @condorinst/mobile-bridge@^0.21.0`
234
+ and copy only `src/main.ts`, `build.mjs` and `index.html` from
235
+ `node_modules/@condorinst/mobile-bridge/examples/minimal-home/`.
236
+
237
+ Pin the SDK to the minor you are building against. Below `1.0.0`, a minor can
238
+ change the contract — `^0.21.0` gives you patches only, which is what you want.
239
+
240
+ **Run it in the harness**, not in a bare browser tab:
241
+
242
+ ```bash
243
+ npm run build
244
+ npx condor-mobile-bridge dev -i dist/index.html --scenario default
245
+ ```
246
+
247
+ The harness serves your bundle inside the same sandboxed iframe, shell, policy
248
+ and CSP as the app, with a simulated native side behind it, and a dev panel to
249
+ emit events by hand. It re-reads the file on every reload. Opening
250
+ `dist/index.html` directly does **not** work: there is no shell, so
251
+ `BridgeClient.handshake()` rejects at once.
252
+
253
+ The scenarios reproduce what the device does to you — switch with the selector
254
+ at the top or `--scenario`:
255
+
256
+ | Scenario | Your bundle must… |
257
+ | --- | --- |
258
+ | `default` | render two devices |
259
+ | `cold-start` | leave the loading screen although the first `userDevice` requests are lost |
260
+ | `no-devices` | show an empty state when `userDevice` never answers |
261
+ | `shared-serial` | keep two devices with the same `serialNo` apart |
262
+ | `offline` | explain that history is unavailable without internet |
263
+
264
+ Add `--native-version null` (or an older version such as `0.15.0`) to test
265
+ your capability branches, and use the **◀ OS back** button at the top to press
266
+ the OS back button.
267
+
268
+ If you already have a dev server (Vite), mount the same harness from your own
269
+ page with `createDevHarness()` from `@condorinst/mobile-bridge/dev` — in the
270
+ host page only, never in the bundle (`validate` rejects that).
271
+
272
+ ## Stage 1 — render before you connect
273
+
274
+ Write the screen so it paints correctly with **no data at all**: empty roster,
275
+ unknown status, default language. Then fill it as events arrive.
276
+
277
+ This is not a nicety. Events are emitted on attach, before your bundle exists,
278
+ and again after the handshake — so there is always a window where you have
279
+ nothing. A Home whose first paint depends on an event shows a blank screen on
280
+ every cold start.
281
+
282
+ ## Stage 2 — handshake, and learn what this device can do
283
+
284
+ ```ts
285
+ import { BridgeClient, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
286
+
287
+ const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
288
+
289
+ const native = ack.nativeVersion; // string | null
290
+ const has = (min: string) => native !== null && cmp(native, min) >= 0;
291
+
292
+ const canReadRawChannels = has('0.16.0');
293
+ const canQueryTimeRange = has('0.15.0');
294
+ const hasLanguageKey = has('0.14.0');
295
+ ```
296
+
297
+ Or use the SDK's `versionAtLeast(ack.nativeVersion, '0.16.0')`, which does
298
+ exactly that and returns `false` for `null`. Do not compare version strings
299
+ lexically — `'0.9.0' > '0.16.0'` is `true` as strings and false as versions.
300
+
301
+ **Do not branch on `ack.accepted`.** It is always `true`; the native side never
302
+ refuses a handshake. Anything you gate on it is ungated.
303
+
304
+ ## Stage 3 — subscribe, then ask
305
+
306
+ Subscribe before sending the command that triggers the reply. The reply can
307
+ arrive before your next line runs.
308
+
309
+ ```ts
310
+ BridgeClient.on(BRIDGE_KEYS.userDevice, (p) => { setDevices(p); render(); });
311
+ BridgeClient.on(BRIDGE_KEYS.systemStatus, (s) => { setStatus(s); render(); });
312
+ BridgeClient.on(BRIDGE_KEYS.deviceConnection, (c) => { setConnected(c); render(); });
313
+
314
+ const result = await BridgeClient.requestUserDevices();
315
+ if (result.status === 'timeout') renderEmptyState(result.hasDevices === false);
316
+ ```
317
+
318
+ **Do not replace the helper with `send('userDevice')`.** The device list is
319
+ served by the app's background service, which starts after your bundle; a
320
+ request sent before it is up is lost, and an account with no device never gets
321
+ a reply. The helper resends with backoff for 30 s and then tells you to show the
322
+ empty state. Keep the `userDevice` subscription: roster changes arrive on it
323
+ later without a request. `docs/troubleshooting.md` has the details.
324
+
325
+ Read device data from the typed items: the list is `devicesConfig`, and the
326
+ name, battery and last sync are in `devicesTelemetry` — join them on
327
+ `deviceId`, never on `serialNo` alone.
328
+
329
+ Then follow the connection state of each device:
330
+
331
+ ```ts
332
+ for (const d of result.devices.devicesConfig) {
333
+ BridgeClient.send('deviceConnection', { type: 'add', serialNo: d.serialNo, deviceId: d.deviceId });
334
+ }
335
+ ```
336
+
337
+ `on` returns an unsubscribe function. If your UI has views that mount and
338
+ unmount, call it — otherwise you accumulate handlers that all re-render.
339
+
340
+ ## Stage 4 — handle the environment honestly
341
+
342
+ `systemStatus` tells you why things are not working. Surface it; do not swallow
343
+ it.
344
+
345
+ ```ts
346
+ if (status.missingEssentialPermissions.length > 0) {
347
+ // Background collection is OFF. This is blocking — say so.
348
+ renderBlockingCard(status.missingEssentialPermissions, (name) =>
349
+ BridgeClient.requestPermission(name));
350
+ } else if (!status.bluetooth) {
351
+ renderNotice('Bluetooth is off — devices cannot be reached.');
352
+ }
353
+ ```
354
+
355
+ There is no permission-result event. After the person answers the OS prompt,
356
+ the native side re-emits `systemStatus`; your card disappears because the array
357
+ emptied. If you wait for a dedicated ack, you wait forever.
358
+
359
+ ## Stage 5 — real-time, if you need it
360
+
361
+ Real-time is **off by default**, per device. Turn it on, wait for the ack, and
362
+ only then expect the stream.
363
+
364
+ ```ts
365
+ BridgeClient.on(BRIDGE_KEYS.realTime, (ack) => setRealTime(ack.serialNo, ack.value));
366
+ BridgeClient.on(BRIDGE_KEYS.deviceLightSensors, (p) => {
367
+ if (p.type === 'actlumus2') { /* 14 channels */ } else { /* 10 channels */ }
368
+ });
369
+
370
+ BridgeClient.send('realTime', { serialNo, value: true });
371
+ ```
372
+
373
+ `serialNo` is a **number**. A string is dropped in silence.
374
+
375
+ Two devices can share a `serialNo` (an ActLumus and an ActLumus Plus). To act on
376
+ one of them, send its `deviceId` **together with** `serialNo`, taken from the
377
+ `userDevice` list: `BridgeClient.send('realTime', { serialNo, deviceId, value })`.
378
+ A command with only the shared `serialNo` is ignored on purpose. Gate this on
379
+ `nativeVersion` ≥ 0.17.0; details in `docs/bridge-contract.md`.
380
+
381
+ In a raw channel, `null` means the channel does not exist on that hardware
382
+ generation. It does not mean zero — `0.0` is a valid reading. Coercing `null`
383
+ to `0` invents data, and has previously led to a confident, wrong conclusion
384
+ about a device.
385
+
386
+ ## Stage 6 — history, if you need it
387
+
388
+ ```ts
389
+ const page = await BridgeClient.fetchSensorLog({
390
+ requestId: crypto.randomUUID(),
391
+ serialNo,
392
+ limit: 200,
393
+ });
394
+ if (!page.success) return showError(page.error); // resolves, does not reject
395
+ renderLogs(page.logs ?? []);
396
+ ```
397
+
398
+ Timestamps are **device-local**. Build ranges with local `Date` getters, never
399
+ `toISOString()` — converting to UTC shifts every series by the timezone offset
400
+ and produces data that looks entirely plausible.
401
+
402
+ Paginate with the cursors exactly as received. They are opaque. Without a range
403
+ the first page is the **most recent** one (go older with `endingBefore`); with
404
+ `startTime` it is the **oldest** of the range (go newer with `startingAfter`).
405
+ A device writes one log per period — programmable, 60 s by default — so a week
406
+ is about 10 000 logs: use `limit: 1000` and do not assume the cadence.
407
+
408
+ ## Stage 7 — back button
409
+
410
+ ```ts
411
+ BridgeClient.onBack(() => goBackOnePage());
412
+ // call this on every internal navigation, not once at startup
413
+ BridgeClient.reportNavigation(hasInternalHistory);
414
+ ```
415
+
416
+ If you never report `true`, the OS back button stays with the app — which is
417
+ the correct behaviour for a single-screen Home.
418
+
419
+ ## Stage 8 — build and validate
420
+
421
+ ```bash
422
+ npm run build # your bundler → dist/index.html, single chunk
423
+ npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
424
+ npx condor-mobile-bridge validate -i dist/bundle.zip --strict
425
+ ```
426
+
427
+ `validate` applies the gates the app applies plus a lint for APIs the sandbox
428
+ blocks. A non-zero exit is a build failure. Errors mean the app would reject
429
+ the bundle outright; warnings mean it installs and then misbehaves on the
430
+ device, which is worse to debug.
431
+
432
+ Validation is textual, so it cannot see through minification and it cannot know
433
+ whether you checked `nativeVersion` before using a capability. A clean run is
434
+ necessary, not sufficient.
435
+
436
+ ## Stage 9 — before you call it done
437
+
438
+ - [ ] `validate --strict` exits 0.
439
+ - [ ] First paint works with no events received.
440
+ - [ ] Every capability past the baseline is guarded by a `nativeVersion` check,
441
+ and the `null` branch renders something sensible.
442
+ - [ ] No spinner can hang forever — the three confirmable commands
443
+ (`syncTime`, `deleteMemory`, `logout`) may never ack, because the person
444
+ can decline.
445
+ - [ ] No `localStorage`, no `fetch`, no `eval`. Not even behind a `try`.
446
+ - [ ] `serialNo` is a number everywhere, and a command for a device whose `serialNo`
447
+ repeats on the account also carries its `deviceId` (`nativeVersion` ≥ 0.17.0).
448
+ - [ ] Errors are reported through `BridgeClient.send('jsError', …)`, the only
449
+ channel out.
450
+ - [ ] Every harness scenario passes: `default`, `cold-start`, `no-devices`,
451
+ `shared-serial`, `offline`.
452
+ - [ ] The `nativeVersion === null` branch renders something sensible.
453
+
454
+ ## Delivery
455
+
456
+ Hand Condor the `.zip` along with its size and `sha256-<hex>` hash, both printed
457
+ by the `build` command. The app verifies the hash against the manifest and
458
+ rejects any mismatch. Condor publishes it for your company, which reaches every
459
+ user of that company — test on a development test company first.
460
+ `docs/device-testing.md` explains the path to the phone and how to inspect the
461
+ bundle there; `docs/pilot-onboarding.md` has the full checklist.
462
+
463
+ ## When something does not work
464
+
465
+ `docs/troubleshooting.md`, which starts from the symptom. The most common one
466
+ is that a command did nothing and said nothing — there are six distinct causes
467
+ for that, and they are enumerated there.
468
+
469
+ <!-- ===== docs/bridge-contract.md ===== -->
470
+
471
+ # Bridge contract
472
+
473
+ The normative reference for everything the client can observe and everything it
474
+ can ask for. Payload *shapes* are canonical in `dist/index.d.ts` — your editor
475
+ shows them on hover. This document states what a type declaration cannot: what
476
+ is reachable, what needs human consent, what fails in silence, and which native
477
+ version carries which capability.
478
+
479
+ A machine-readable form of this document lives at
480
+ `contract/bridge-contract.json`, importable as `@condorinst/mobile-bridge/contract`.
481
+
482
+ ## Topology
483
+
484
+ ```
485
+ your bundle (iframe, sandbox="allow-scripts", origin null)
486
+ | window.postMessage, schema-validated both ways
487
+ Condor shell (top-level document, opaque origin)
488
+ | native channel, nonce-stamped
489
+ native app (Flutter)
490
+ ```
491
+
492
+ You never talk to the native side directly — the shell deletes the native
493
+ globals before your bundle runs. Use `BridgeClient`; it speaks the shell
494
+ protocol for you.
495
+
496
+ ## Versions and capability detection
497
+
498
+ Four numbers coexist. Only one tells you what the device can do.
499
+
500
+ | Number | Current | What it means |
501
+ | --- | --- | --- |
502
+ | `nativeVersion` | `0.17.0` | **The capability signal.** The bridge schema the app on this device implements. |
503
+ | `shellVersion` | `0.6.0` | The Condor shell wrapping your iframe. |
504
+ | client protocol | `0.5.0` | The postMessage protocol `BridgeClient` speaks. |
505
+ | package | `0.21.0` | The npm version you compiled against. |
506
+
507
+ ```ts
508
+ const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
509
+ // { shellVersion: string, nativeVersion: string | null, accepted: boolean }
510
+ ```
511
+
512
+ Two traps:
513
+
514
+ **`accepted` is always `true`.** There is no version negotiation and the native
515
+ side never refuses. Branching on it is branching on a constant.
516
+
517
+ **`nativeVersion` can be `null`.** When the shell could not reach the native
518
+ side, and in bundles compiled against SDK ≤ 0.16.x, where the shell did not
519
+ relay it. Treat `null` as "assume only the baseline" and keep a working
520
+ default. The dev harness reports `0.17.0` by default; `--native-version null`
521
+ exercises this path.
522
+
523
+ ### Capability matrix
524
+
525
+ | Capability | Requires |
526
+ | --- | --- |
527
+ | Baseline: devices, real-time, acks, `systemStatus`, `sensorLog` | any |
528
+ | `language` key and the `language` pull command | `nativeVersion` ≥ 0.14.0 |
529
+ | `sensorLog` accepts `startTime` / `endTime` | `nativeVersion` ≥ 0.15.0 |
530
+ | `deviceLightSensors` carries raw `light_sensor_*` channels, and the `actlumus2` variant | `nativeVersion` ≥ 0.16.0 |
531
+ | Address one device by `deviceId` when two share a `serialNo` (`realTime`, `syncTime`, `deleteMemory`, `deviceConnection`); `deviceId` in the device events; `systemStatus.deviceSyncErrorById` | `nativeVersion` ≥ 0.17.0 |
532
+ | `nativeVersion` reaches the client at all | `shellVersion` ≥ 0.6.0 |
533
+
534
+ `CHANGELOG.md` is the precise record of when each of these landed.
535
+
536
+ ## Observable keys
537
+
538
+ Thirteen keys exist; **twelve are reachable** by a sandboxed client. Subscribe
539
+ with `BridgeClient.on(key, cb)`, which returns an unsubscribe function.
540
+
541
+ | Key | Payload type | Emitted when |
542
+ | --- | --- | --- |
543
+ | `pingPong` | `PingPongPayload` | reply to `ping`, once the app's background service is up |
544
+ | `userDevice` | `UserDevicePayload` | reply to `userDevice` **when the list loads** (not guaranteed — see below), and on roster changes |
545
+ | `deviceLightSensors` | `DeviceLightSensorsPayload` | real-time stream, only while `realTime` is on for that device |
546
+ | `deviceConnection` | `DeviceConnectionPayload` | a device connects or disconnects |
547
+ | `deviceNotification` | `DeviceNotificationPayload` | device-level notice, including errors |
548
+ | `realTime` | `RealTimeAckPayload` | ack of `realTime` |
549
+ | `syncTime` | `SyncTimeAckPayload` | ack of `syncTime` |
550
+ | `deleteMemory` | `DeleteMemoryAckPayload` | ack of `deleteMemory` |
551
+ | `sensorLog` | `SensorLogPayload` | reply to `sensorLog`; correlate by `requestId` |
552
+ | `systemStatus` | `SystemStatusPayload` | on attach, again right after handshake, and on every change |
553
+ | `language` | `LanguagePayload` | same cadence as `systemStatus`; needs native ≥ 0.14.0 |
554
+ | `navigationBack` | `NavigationBackPayload` (empty) | OS back pressed while you had reported `canGoBack: true` |
555
+ | `dfu` | — | **not reachable.** Firmware traffic. Subscribing is answered with `nack: not-read-only`. |
556
+
557
+ ### Emission cadence you must design around
558
+
559
+ `systemStatus` and `language` are emitted **on attach, before your bundle
560
+ exists**, then again right after the handshake. The first emission is lost to
561
+ you; the second is the one you receive. This is why `language` also exists as a
562
+ pull command — a client that mounts late can ask.
563
+
564
+ Render a skeleton synchronously and fill it as events arrive. Do not make first
565
+ paint depend on any event.
566
+
567
+ ### `deviceLightSensors` is a discriminated union
568
+
569
+ ```ts
570
+ if (payload.type === 'actlumus2') {
571
+ // 14 wavelength channels, 395–800 nm, plus raw light_sensor_f1..f8 / clear / nir
572
+ } else {
573
+ // 'actlumus': 10 channels, 390–750 nm
574
+ }
575
+ ```
576
+
577
+ A `null` in a raw channel means **that channel does not exist on this hardware
578
+ generation** — never "the reading was zero". `0.0` is a valid reading. Do not
579
+ collapse `null` to `0`: doing so fabricates data, and it has produced wrong
580
+ conclusions before.
581
+
582
+ ### `systemStatus` carries the permission state
583
+
584
+ ```ts
585
+ {
586
+ bluetooth: boolean; internet: boolean; permission: boolean; hasDevices: boolean;
587
+ missingEssentialPermissions: string[]; // e.g. ["bluetoothScan", "bluetoothConnect"]
588
+ missingOptionalPermissions: string[]; // e.g. ["notification", "backgroundLocation"]
589
+ deviceSyncError: Record<number, string>; // serialNo → message, already localized; a shared serialNo is left out
590
+ deviceSyncErrorById?: Record<number, string>; // deviceId → message, every device (nativeVersion ≥ 0.17.0)
591
+ }
592
+ ```
593
+
594
+ `missingEssentialPermissions` being non-empty means background collection is
595
+ off. Render a blocking card; wire its button to
596
+ `BridgeClient.requestPermission(name)` with a name taken from that array.
597
+
598
+ **`hasDevices` is a hint.** It is `true` only after the app loaded a non-empty
599
+ device list from the server. `false` means "no linked device" **or** "the app
600
+ could not ask" (no internet, session problem). Do not show "you have no device"
601
+ on `false` alone; see the next section.
602
+
603
+ ### `userDevice` in detail
604
+
605
+ **It is not guaranteed to answer.** `userDevice` (and `ping`) are handled by
606
+ the app's background service, which starts after your bundle is running; a
607
+ request sent before it is up is lost. And the app replies only when the list
608
+ loads: an account with no linked device, or a failed request, gets **no reply
609
+ at all** — never an empty list. Use the helper, which resends with backoff and
610
+ tells you when to give up:
611
+
612
+ ```ts
613
+ const r = await BridgeClient.requestUserDevices(); // getFromServer: false
614
+ // { status: 'ok', devices } | { status: 'timeout', hasDevices: boolean | null }
615
+ ```
616
+
617
+ **`getFromServer`.** `false` (default) answers from the app's memory or cache
618
+ while it is fresh, and asks the server otherwise — use it at boot. `true` asks
619
+ the server first and falls back to the cache if that fails — use it for a
620
+ "Refresh" button.
621
+
622
+ **Shape.** Three arrays, typed as `DeviceConfigItem`, `DeviceTelemetryItem` and
623
+ `DeviceUserConfigItem` in `dist/index.d.ts`:
624
+
625
+ | Array | Holds | Notable fields |
626
+ | --- | --- | --- |
627
+ | `devicesConfig` | one item per linked device — **the list to render** | `deviceId`, `serialNo`, `modelId` (`'actlumus'`, `'actlumus_plus'`; absent = ActLumus), `deviceBodyPart` (`'wrist'` \| `'necklace'` \| `null`), `bleConnectionPeriod` (minutes) |
628
+ | `devicesTelemetry` | last state read from each device | `deviceName`, `batteryLevel` (0–100 %), `memoryDataUsage` (0–100 %), `updataDateTime` (epoch ms, last sync — the misspelling is the real name), `deviceVersion`, `isCharging`, `isOnDock`, `syncErrors[]` |
629
+ | `devicesUserConfig` | per-device toggles | `syncTime`, `realTime`, `deleteMemory`, `readAllMemory`, `readAllSystemLog` |
630
+
631
+ The name and battery of a device are in **telemetry**, not in the config. A
632
+ device that never synced may have no telemetry item, or one with every reading
633
+ `null`: render it anyway.
634
+
635
+ **Joining the arrays.** Join on `deviceId`. An item of `devicesTelemetry` or
636
+ `devicesUserConfig` without `deviceId` is legacy data: fall back to `serialNo`
637
+ only when that `serialNo` appears once in `devicesConfig`.
638
+
639
+ **Sync errors.** `devicesTelemetry[].syncErrors` is the per-device history
640
+ (`attemptDate`, `message`, `messageCode`); `systemStatus.deviceSyncErrorById`
641
+ is the current error per device, already localized. Show the current one; use
642
+ the history for detail screens.
643
+
644
+ ## Commands
645
+
646
+ `BridgeClient.send(command, args)` is **fire-and-forget**: no return value, no
647
+ exception, no delivery confirmation. Replies arrive on the key in the last
648
+ column.
649
+
650
+ | Command | Arguments | Confirmation | Replies on |
651
+ | --- | --- | --- | --- |
652
+ | `ping` | — | no | `pingPong` |
653
+ | `userDevice` | `{ getFromServer?: boolean }` | no | `userDevice` |
654
+ | `realTime` | `{ serialNo: number, deviceId?: number, value: boolean }` | no | `realTime` |
655
+ | `deviceConnection` | `{ type: 'add' \| 'remove', serialNo: number, deviceId?: number }` — subscribe to / stop following one device's connection state | no | `deviceConnection` |
656
+ | `sensorLog` | see below | no | `sensorLog` |
657
+ | `openSettings` | — | no | — |
658
+ | `requestPermission` | `{ permission: string }` | no | — (re-emits `systemStatus`) |
659
+ | `setStatusBarColor` | `{ color: '#RRGGBB' }` | no | — |
660
+ | `navigationState` | `{ canGoBack: boolean }` | no | — |
661
+ | `language` | — | no | `language` |
662
+ | `jsError` | `{ message, stack?, source?, lineno?, colno? }` | no | — |
663
+ | `syncTime` | `{ serialNo: number, deviceId?: number }` | **yes** | `syncTime` |
664
+ | `deleteMemory` | `{ serialNo: number, deviceId?: number }` | **yes** | `deleteMemory` |
665
+ | `logout` | — | **yes** | — |
666
+ | `dfu` | — | denied | — |
667
+
668
+ `serialNo` is an integer. Passing a string makes the native side drop the
669
+ message in silence.
670
+
671
+ `deviceConnection` **pairs nothing.** `type: 'add'` asks the app to report the
672
+ BLE connection state of a device you already have from `userDevice`; the state
673
+ then arrives on the `deviceConnection` key whenever it changes. `'remove'` stops
674
+ it. Send `add` for each device after the list arrives.
675
+
676
+ ### Two devices with the same `serialNo`
677
+
678
+ An ActLumus and an ActLumus Plus have separate serial sequences, so one account
679
+ can hold both with the **same** `serialNo` (the server `deviceId` is always
680
+ unique). A command that carries only `serialNo` cannot say which one you mean:
681
+ the app **ignores it** instead of guessing, because guessing wrong would act on
682
+ the wrong device (erasing the wrong memory, for instance).
683
+
684
+ - **Send `deviceId` together with `serialNo`**, taken from the `userDevice`
685
+ list (`devicesConfig[].deviceId`). Always keep `serialNo` in the message: an
686
+ app older than 0.17.0 drops in silence a command that lacks it.
687
+ - **Read `deviceId` when it is there.** `deviceConnection`,
688
+ `deviceNotification` and `deviceLightSensors` carry it **only when the
689
+ `serialNo` is shared**; with a unique `serialNo` it is absent and `serialNo`
690
+ alone identifies the device. Never treat a missing `deviceId` as "unknown
691
+ device".
692
+ - **Acks always carry `serialNo`**, plus the `deviceId` you sent.
693
+ - **`systemStatus.deviceSyncError` leaves a shared `serialNo` out**, because no
694
+ single device owns it. Read `deviceSyncErrorById` for every device.
695
+ - **Gate on `nativeVersion` ≥ 0.17.0** before relying on any of this, and
696
+ degrade to `serialNo` only below it (where a shared `serialNo` was already
697
+ unaddressable).
698
+ - `sensorLog` takes no `deviceId` yet: for a shared `serialNo` the backend
699
+ answers with the error code `DEVICE_SERIAL_AMBIGUOUS`. Show your own copy for
700
+ it; do not retry.
701
+
702
+ ### Commands that need human confirmation
703
+
704
+ `syncTime`, `deleteMemory` and `logout` open a modal owned by the Condor shell.
705
+ If the person declines, the shell answers `nack` with reason `user-rejected`,
706
+ which reaches you only as a `console.warn`. **The ack simply never arrives.**
707
+
708
+ Design for that: show an optimistic state only if you can undo it, and put a
709
+ timeout on any spinner.
710
+
711
+ ### Typed helpers
712
+
713
+ Prefer these over raw `send` — they add correlation, timeouts and types:
714
+
715
+ ```ts
716
+ BridgeClient.requestUserDevices({ getFromServer }) // resends until the list arrives, 30 s default
717
+ BridgeClient.waitForService() // resends ping until the background service answers
718
+ BridgeClient.fetchSensorLog(query, { timeoutMs }) // correlates requestId, 10 s default
719
+ BridgeClient.requestPermission(name)
720
+ BridgeClient.reportNavigation(canGoBack)
721
+ BridgeClient.onBack(cb)
722
+ BridgeClient.openSettings()
723
+ ```
724
+
725
+ ### `sensorLog` in detail
726
+
727
+ ```ts
728
+ const page = await BridgeClient.fetchSensorLog({
729
+ requestId: crypto.randomUUID(),
730
+ serialNo,
731
+ bodyPart: 'wrist', // or 'necklace'; omit for no filter
732
+ limit: 200, // 1..1000, backend default 10
733
+ startTime: '2026-09-01 00:00:00', // native >= 0.15.0
734
+ endTime: '2026-09-02 00:00:00',
735
+ });
736
+ ```
737
+
738
+ It **resolves** with `{ success: false, error }` on failure and only **rejects**
739
+ on timeout. Check `success` before reading `logs`.
740
+
741
+ Two things that have caused real bugs:
742
+
743
+ **Timestamps are device-local, not UTC.** `log_time` passes through from the
744
+ device RTC. Build your range with local `Date` getters — `getFullYear()`,
745
+ `getHours()` — and **never** with `toISOString()`. Converting shifts every
746
+ series by the timezone offset, producing plausible data and no error at all.
747
+
748
+ **Cursors are opaque.** `startingAfter` and `endingBefore` are mutually
749
+ exclusive; echo back exactly what you received and never construct one.
750
+
751
+ **Sweeping a period.** Keep `startTime` and `endTime` on **every** page and add
752
+ the cursor; the cursors are valid only inside the range they came from:
753
+
754
+ ```ts
755
+ const logs: SensorLogItem[] = [];
756
+ let startingAfter: string | undefined;
757
+ for (;;) {
758
+ const page = await BridgeClient.fetchSensorLog({
759
+ requestId: crypto.randomUUID(), serialNo, limit: 1000,
760
+ startTime: '2026-10-13 00:00:00', endTime: '2026-10-20 00:00:00', // local time
761
+ ...(startingAfter ? { startingAfter } : {}),
762
+ });
763
+ if (!page.success) break; // see the error codes below
764
+ logs.push(...(page.logs ?? []));
765
+ if (!page.hasMore || !page.startingAfter) break;
766
+ startingAfter = page.startingAfter;
767
+ }
768
+ ```
769
+
770
+ **Which page comes first.** `logs` is always chronological ascending inside a
771
+ page. Which page you get first depends on the query:
772
+
773
+ | Query | First page | Next page |
774
+ | --- | --- | --- |
775
+ | no cursor, no range | the **most recent** `limit` logs | older: `endingBefore` = the reply's `endingBefore`, while `hasPrevious` |
776
+ | `startTime` (with or without `endTime`) | the **oldest** logs of the range | newer: `startingAfter` = the reply's `startingAfter`, while `hasMore` |
777
+ | only `endTime` | the most recent logs before it | older, as in the first row |
778
+
779
+ `limit` defaults to 10. With a time range, `hasMore` and `hasPrevious` are
780
+ computed *within* the period, so `hasMore: false` means "this period is
781
+ complete", not "no more data exists".
782
+
783
+ **How many logs to expect.** One per log period, which is **programmable per
784
+ device** (default 60 s, so about 1 440 per day). Do not hard-code it: derive the
785
+ period from consecutive `log_time` values, and page with `limit` up to 1000.
786
+ Pages depend on the previous cursor, so they cannot be fetched in parallel.
787
+
788
+ **Fields.** Each log is a `SensorLogItem`: `log_time`, `body_part`,
789
+ `temperature` (°C), `lux_photopic` and `lux_melanopic` (lux), the raw light
790
+ channels and the activity counters. `null` on a channel means the hardware does
791
+ not have it, never zero. `device_log_time` and `app_log_time` mark the upload
792
+ batch, not the log — do not plot them.
793
+
794
+ ## Error shapes
795
+
796
+ There are three, and none of them is a thrown exception.
797
+
798
+ **1. `nack`** — shell to client, reasons `not-read-only`, `denied`,
799
+ `no-confirmation-handler`, `confirmation-error`, `user-rejected`, `not-allowed`.
800
+ Visible only as `console.warn`.
801
+
802
+ **2. `sensorLog` failure** — `{ requestId, success: false, error }`. The
803
+ contract codes are `CONFLICTING_CURSORS`, `INVALID_BODY_PART`, `INVALID_LIMIT`,
804
+ `INVALID_START_TIME`, `INVALID_END_TIME`, `INVALID_TIME_RANGE`,
805
+ `MISSING_DEVICE_SERIAL_NO`, `DEVICE_SERIAL_AMBIGUOUS`, `invalid session` and
806
+ `device not found for this user`. Some carry a suffix (`INVALID_LIMIT: 5000`),
807
+ so **match by prefix**, not equality. When the request never reached the
808
+ server (no internet, timeout), `error` is free text from the transport — so
809
+ treat any value outside the list as "history unavailable right now" and offer a
810
+ retry. `error` is a **diagnostic, never user-facing text** — map it to your own
811
+ copy.
812
+
813
+ **3. Silence** — nothing reaches JavaScript. This is the dominant failure mode.
814
+ The six paths that produce it are enumerated in `docs/troubleshooting.md`.
815
+
816
+ ## The back button
817
+
818
+ The app owns the OS back button and will delegate it to you only if you ask.
819
+
820
+ ```ts
821
+ const stack: string[] = ['home']; // keep navigation in memory
822
+ function go(view: string) { stack.push(view); render(); BridgeClient.reportNavigation(stack.length > 1); }
823
+ BridgeClient.onBack(() => { stack.pop(); render(); BridgeClient.reportNavigation(stack.length > 1); });
824
+ ```
825
+
826
+ Keep the navigation stack in memory rather than in the History API: the
827
+ bundle lives in a sandboxed `srcdoc` iframe, where `history.pushState` is not
828
+ something to rely on. Report `false` when you are at your root, and the app
829
+ handles back itself. Report state on every internal navigation, not once at
830
+ startup. In the harness, the **◀ OS back** button at the top presses the OS
831
+ back button for you.
832
+
833
+ ## Status bar
834
+
835
+ Two ways, in precedence order:
836
+
837
+ ```html
838
+ <meta name="theme-color" content="#0A2540"> <!-- static, read from the bundle -->
839
+ ```
840
+ ```ts
841
+ BridgeClient.send('setStatusBarColor', { color: '#0A2540' }); // runtime, wins
842
+ ```
843
+
844
+ ## Session
845
+
846
+ When the session token dies, the app shows its own native banner above your
847
+ bundle and keeps rendering you. **You receive no event for this** — there is no
848
+ session key. Do not try to detect it; the app handles re-authentication.
849
+
850
+ <!-- ===== docs/troubleshooting.md ===== -->
851
+
852
+ # Troubleshooting
853
+
854
+ ## "The device list never came" (stuck on loading)
855
+
856
+ The handshake worked, `systemStatus` arrived, and `userDevice` never answered.
857
+ Two different causes produce exactly this symptom, and the bundle cannot tell
858
+ them apart.
859
+
860
+ ### Cause 1: the request was sent before the app's background service was up
861
+
862
+ `userDevice` and `ping` are not answered by the page that hosts you: the app
863
+ forwards them to a background service that reads the devices. On a cold start
864
+ the app creates your WebView first and starts that service afterwards, so a
865
+ request sent at boot is often **lost without a reply**. Nothing re-sends the
866
+ list when the service comes up. Meanwhile the handshake, `systemStatus` and
867
+ `language` work from the start, which makes the bridge look ready.
868
+
869
+ ### Cause 2: the account has no linked device
870
+
871
+ The app replies to `userDevice` only when the list loads. When the server
872
+ answers that the account has no device — or the request fails for any other
873
+ reason (no internet, expired session) — nothing is sent to the bundle. A
874
+ `userDevice` with empty arrays never arrives today.
875
+
876
+ ### What to do
877
+
878
+ Use the helper, which handles both:
879
+
880
+ ```ts
881
+ const result = await BridgeClient.requestUserDevices(); // resends with backoff for 30 s
882
+ if (result.status === 'ok') render(result.devices);
883
+ else renderEmpty({ probablyNoDevices: result.hasDevices === false }); // + a "Try again" button
884
+ ```
885
+
886
+ - It resends at 1.5 s, 3 s, 6 s and then every 10 s, and on every
887
+ `systemStatus` while it waits. `BridgeClient.waitForService()` does the same
888
+ with `ping` if you only need to know the service is up.
889
+ - **`systemStatus.hasDevices` is a hint, not a verdict.** It is `true` only
890
+ after the app loaded a non-empty list from the server. `false` means either
891
+ "no device" or "the app could not ask". Word the empty state so it is true in
892
+ both cases, and always offer a retry with `getFromServer: true`.
893
+ - Keep the `userDevice` subscription for the life of the page: roster changes
894
+ arrive on it without a request.
895
+
896
+ Reproduce both causes in the browser before going to a device:
897
+
898
+ ```bash
899
+ npx condor-mobile-bridge dev -i dist/index.html --scenario cold-start
900
+ npx condor-mobile-bridge dev -i dist/index.html --scenario no-devices
901
+ ```
902
+
903
+ ## "My command did nothing"
904
+
905
+ This is the most common report, and it is usually correct: the command did
906
+ nothing, and nothing told you. The native side drops malformed or unauthorized
907
+ traffic with a log on the device and no reply to JavaScript.
908
+
909
+ There are six paths to silence. Work down the list.
910
+
911
+ ### 1. The payload was over 64 KB
912
+
913
+ The whole JSON message is measured, not just your arguments. Over the limit it
914
+ is discarded before dispatch.
915
+
916
+ **Check:** `JSON.stringify(args).length` at the call site.
917
+
918
+ ### 2. The command name is unknown to this device
919
+
920
+ A typo, or — more often — a command that exists in a newer native version than
921
+ the device runs. The native switch has no default branch that reports back.
922
+
923
+ **Check:** the command against `docs/bridge-contract.md`, then check
924
+ `ack.nativeVersion` from your handshake against the capability matrix. A device
925
+ in the field can be several versions behind your development device.
926
+
927
+ ### 3. The arguments had the wrong shape
928
+
929
+ `realTime`, `syncTime`, `deleteMemory` and `deviceConnection` validate their
930
+ payload natively and drop it on mismatch. `serialNo` is an **integer**, not a
931
+ string; `value` and `canGoBack` are **booleans**, not strings.
932
+
933
+ **Also check:** if the account holds an ActLumus and an ActLumus Plus with the same
934
+ `serialNo`, a command without `deviceId` is ignored (never guessed). Send both
935
+ (`nativeVersion` ≥ 0.17.0).
936
+
937
+ **Check:** `typeof` at the call site. `serialNo: "1234"` fails; `serialNo: 1234`
938
+ works.
939
+
940
+ ### 4. The permission name is not in the native enum
941
+
942
+ `requestPermission` takes a name the native side recognizes. Anything else is
943
+ dropped.
944
+
945
+ **Check:** that the name came from `systemStatus.missingEssentialPermissions`
946
+ or `missingOptionalPermissions` — those arrays are the authoritative list of
947
+ names, so pass them through unchanged and never type one by hand. Show a
948
+ generic label for a name you do not recognise. And note there is
949
+ no permission-result event at all — the outcome reaches you as a fresh
950
+ `systemStatus` emission. If you were waiting for a dedicated ack, you will wait
951
+ forever.
952
+
953
+ ### 5. The command was denied, or the person declined
954
+
955
+ `syncTime`, `deleteMemory` and `logout` open a Condor-owned confirmation modal.
956
+ Declining produces a `nack` with reason `user-rejected`, which `BridgeClient`
957
+ surfaces as a `console.warn` and nothing else — `send` is fire-and-forget.
958
+
959
+ `dfu` is denied outright. Subscribing to a key outside the allowed set produces
960
+ `nack` with reason `not-read-only`.
961
+
962
+ **Check:** the browser console for a warning starting with `[BridgeClient]`. On
963
+ a device that console is only visible through the WebView inspector
964
+ (`docs/device-testing.md`).
965
+ Then re-read whether that command needs confirmation.
966
+
967
+ ### 6. You bypassed `BridgeClient`
968
+
969
+ A hand-rolled `window.parent.postMessage` almost certainly fails the Valibot
970
+ schema at the shell boundary and is dropped without a reply. The shell validates
971
+ every message before it touches the native bridge.
972
+
973
+ **Check:** that every message goes through `BridgeClient`.
974
+
975
+ There is a seventh path you cannot cause by accident but should know about: a
976
+ message that reaches the native channel without the shell's nonce is dropped
977
+ and logged as a security event. This is what happens if you create a nested
978
+ iframe and try to talk to the native side from inside it.
979
+
980
+ ## "My bundle was rejected and the app shows its default screen"
981
+
982
+ The app validates the bundle on download and keeps the previous version — or
983
+ falls back — on any failure.
984
+
985
+ ```bash
986
+ npx condor-mobile-bridge validate -i dist/bundle.zip --strict
987
+ ```
988
+
989
+ This applies the same gates. The failures it reports as **errors** are exactly
990
+ the ones that cause rejection:
991
+
992
+ | Finding | Cause |
993
+ | --- | --- |
994
+ | `too-large` | The `.zip` is over 2 MB. |
995
+ | `entry-missing` | No `.html` document in the archive. |
996
+ | `not-self-contained` | A `<script src>` or `<link rel="stylesheet">` pointing outside a `data:` URI. |
997
+
998
+ If `validate` passes and the app still falls back, the bundle never arrived:
999
+ check the manifest, the `bundleHash` (it must match the SHA-256 of the exact
1000
+ `.zip` bytes you uploaded), and `minAppVersion` — the minimum app build Condor
1001
+ sets when publishing, for a bundle that needs a newer app — against the app build on the
1002
+ device.
1003
+
1004
+ ## "It works in the browser but not on the device"
1005
+
1006
+ First question: **which browser setup?**
1007
+
1008
+ - **A plain tab or your own dev server** (`vite dev`, opening `dist/index.html`):
1009
+ `BridgeClient.handshake()` rejects at once, because there is no shell. If you
1010
+ built a mock of your own to get past that, it is more permissive than the
1011
+ device in every way that matters: real origin (storage works), no CSP (CDN
1012
+ fonts and `fetch` work), instant replies (nothing is lost).
1013
+ - **The harness** (`npx condor-mobile-bridge dev`): same sandbox, shell, policy
1014
+ and CSP as the app. What it cannot reproduce is timing you did not ask for —
1015
+ run every scenario, not just `default`. `cold-start` and `no-devices` are the
1016
+ ones that catch real bugs.
1017
+
1018
+ In the harness `nativeVersion` is `0.17.0` by default. Run it with
1019
+ `--native-version null` to reproduce a shell that could not reach the native
1020
+ side; keep a branch that renders in that case too.
1021
+
1022
+ Run `validate --strict` before concluding that something works, and see
1023
+ `docs/device-testing.md` to inspect the bundle on a phone.
1024
+
1025
+ ## "The screen is blank"
1026
+
1027
+ Almost always the bundle build, not the bridge.
1028
+
1029
+ **Code splitting.** If your bundler emitted more than one chunk, or ES module
1030
+ imports between chunks, `srcdoc` cannot resolve them and nothing executes. Build
1031
+ single-file: Vite with `vite-plugin-singlefile`, or an equivalent.
1032
+
1033
+ **A script that failed to parse.** Inlining a script that contains markup inside
1034
+ JavaScript strings can break if done by a naive tool — use
1035
+ `npx condor-mobile-bridge build`, which handles the escaping.
1036
+
1037
+ **First paint depends on the bridge.** If your render only runs inside an event
1038
+ handler and no event has arrived yet, you get a blank screen. Render a skeleton
1039
+ synchronously; fill it as events arrive.
1040
+
1041
+ ## "An ack never arrived"
1042
+
1043
+ Check, in order: was the command one of the three that need confirmation
1044
+ (the person may have declined); did the command reach the native side at all
1045
+ (the six paths above); and is your `requestId` correlation correct for
1046
+ `sensorLog` — the reply carries back the id you sent, and a mismatch looks
1047
+ exactly like silence.
1048
+
1049
+ Always have a timeout. `BridgeClient.fetchSensorLog` has one built in; anything
1050
+ you correlate by hand needs your own.
1051
+
1052
+ ## Getting an error out of the device
1053
+
1054
+ You cannot reach an external logging service — `connect-src 'self'` blocks it.
1055
+ Use the bridge:
1056
+
1057
+ ```ts
1058
+ BridgeClient.send('jsError', { message: String(err), stack: err?.stack });
1059
+ ```
1060
+
1061
+ That reaches Condor's logging and Crashlytics — **Condor reads them, you do
1062
+ not**; ask your Condor contact for the errors of a test account. It is rate
1063
+ limited to 20 per 10 seconds and truncated at 4 KB per field, so do not use it
1064
+ as a general telemetry channel — it will drop exactly when something is going
1065
+ wrong in a loop. To watch errors live, inspect the WebView: see
1066
+ `docs/device-testing.md`.
1067
+
1068
+ <!-- ===== docs/device-testing.md ===== -->
1069
+
1070
+ # Testing on a device
1071
+
1072
+ The harness (`npx condor-mobile-bridge dev`) catches most problems in the
1073
+ browser. This page covers the rest: how a bundle reaches a phone, what that
1074
+ costs, and how to see what it is doing once it is there.
1075
+
1076
+ ## How a bundle reaches the screen
1077
+
1078
+ You do not install a bundle on a phone. The app downloads it:
1079
+
1080
+ 1. You deliver `bundle.zip`, its `bundleSize` and its `bundleHash` (printed by
1081
+ `condor-mobile-bridge build`) to your Condor contact.
1082
+ 2. Condor publishes it for **your company**. There is exactly one current
1083
+ bundle per company.
1084
+ 3. Each copy of the app asks for its company's manifest when the Home opens
1085
+ and every time the app returns to the foreground. If the `bundleHash`
1086
+ changed, it downloads the `.zip`, checks size and hash, and swaps the Home
1087
+ without a restart. Otherwise it keeps the cached copy, also when offline.
1088
+
1089
+ Consequences:
1090
+
1091
+ - **Reinstalling the app changes nothing.** The bundle is not inside the app.
1092
+ - **Publishing reaches every user of that company** the next time their app
1093
+ comes to the foreground. There is no per-user or per-device preview today.
1094
+ - **A rejected bundle is invisible to the person.** The app keeps its previous
1095
+ bundle, or shows its default screen, if the download fails a gate — which is
1096
+ why `validate --strict` must pass before you hand anything over.
1097
+
1098
+ ## Development and production are separate worlds
1099
+
1100
+ Condor runs a **development** environment next to production. An app build
1101
+ talks to exactly one of them, and accounts, devices and bundles do not cross
1102
+ over:
1103
+
1104
+ - a development build of the app sees only development accounts and bundles;
1105
+ - the store app sees only production.
1106
+
1107
+ So, to test on a phone, ask Condor for three things that match: a **test
1108
+ account** in a **test company** in the development environment, a
1109
+ **development build** of the app (Android), and to publish your bundle **to that
1110
+ test company**. Never iterate on a production company: every publish reaches
1111
+ real people.
1112
+
1113
+ Each iteration on the device is therefore: build → validate → hand over →
1114
+ Condor publishes → bring the app to the foreground. That is slow, so do every
1115
+ iteration you can in the harness first.
1116
+
1117
+ ## Seeing what the bundle does on the phone
1118
+
1119
+ **Release builds of the app cannot be inspected.** Ask Condor for a
1120
+ development (debuggable) Android build. Then:
1121
+
1122
+ 1. Connect the phone by USB with USB debugging on.
1123
+ 2. Open `chrome://inspect/#devices` in desktop Chrome and click **inspect** on
1124
+ the app's WebView.
1125
+ 3. In the DevTools console, switch the context selector from `top` to the
1126
+ frame `about:srcdoc` — that is your bundle. The `top` frame is the Condor
1127
+ shell.
1128
+
1129
+ There you see your own `console.*` output, uncaught errors, and the shell's
1130
+ refusals: a `nack` reaches you only as a `[BridgeClient] command nacked by
1131
+ shell` warning, so this is the one place to find out that a command was denied
1132
+ or declined.
1133
+
1134
+ What you will **not** see from the WebView: the native side. A command dropped
1135
+ by the app (wrong argument type, unknown command, a request lost while the
1136
+ background service starts) leaves no trace in the console — reproduce it in the
1137
+ harness instead, whose console says what was dropped and why.
1138
+
1139
+ ## Errors after the fact
1140
+
1141
+ `BridgeClient.send('jsError', { message, stack })` sends an error to Condor's
1142
+ logging and crash reporting (rate limited: 20 per 10 s, 4 KB per field). Those
1143
+ are Condor's tools: when you need the errors of a test session, ask your Condor
1144
+ contact, with the test account and the time window.
1145
+
1146
+ ## Before you ask for a publish
1147
+
1148
+ - [ ] `npx condor-mobile-bridge validate -i dist/bundle.zip --strict` exits 0.
1149
+ - [ ] Every harness scenario passes (`default`, `cold-start`, `no-devices`,
1150
+ `shared-serial`, `offline`).
1151
+ - [ ] You know which environment and which company the bundle is for.
1152
+ - [ ] You sent `bundleSize` and `bundleHash` with the `.zip`, so the published
1153
+ bundle can be checked against what you built.
1154
+
1155
+ <!-- ===== docs/limits.md ===== -->
1156
+
1157
+ # Limits
1158
+
1159
+ Every number here is enforced in code. The **On violation** column is what you
1160
+ actually observe — which matters more than the number, because most of these
1161
+ fail without telling you.
1162
+
1163
+ Each row cites the file and line in the Canvas app that applies it, as verified
1164
+ against monorepo commit `a6b19cd2` on 2026-09-22. The same values are available
1165
+ as JSON in `contract/bridge-contract.json`, including these citations under
1166
+ `verifiedAgainst.sources`.
1167
+
1168
+ ## Messages
1169
+
1170
+ | Limit | Value | On violation | Enforced at |
1171
+ | --- | --- | --- | --- |
1172
+ | Payload, JS → native | 64 KB (65536 bytes) | **Silent drop.** Nothing reaches the native side and nothing comes back. | `condor_bridge.dart:88,229-238` |
1173
+ | `jsError` rate | 20 per rolling 10 s | Silent drop of the excess. | `condor_bridge.dart:93-94,311-317` |
1174
+ | `jsError` field length | 4 KB each for `message` and `stack` | Truncated, with a marker appended. | `condor_bridge.dart:97,319-320` |
1175
+
1176
+ 64 KB is generous for commands but not for data you might try to push back.
1177
+ There is no chunking protocol: if you need to move something larger, you are
1178
+ solving the wrong problem — the native side is the one that holds data.
1179
+
1180
+ ## Timeouts
1181
+
1182
+ | Operation | Timeout | On expiry |
1183
+ | --- | --- | --- |
1184
+ | `BridgeClient.handshake` | 5 s | Promise **rejects**. |
1185
+ | `BridgeClient.fetchSensorLog` | 10 s (override per call) | Promise **rejects**. This is the only rejection path — a failed query *resolves* with `success: false`. |
1186
+ | `BridgeShell.verifySandbox` | 2 s | Resolves with the failure recorded. |
1187
+ | Relay of `nativeVersion` into the iframe | 2 s | `ack.nativeVersion` comes back `null`. |
1188
+
1189
+ ## Sensor log
1190
+
1191
+ | Parameter | Range | On violation |
1192
+ | --- | --- | --- |
1193
+ | `limit` | 1–1000 (backend default 10) | `success: false`, `error: "INVALID_LIMIT: <value>"` |
1194
+ | `bodyPart` | `wrist` or `necklace` | `error: "INVALID_BODY_PART: <value>"` |
1195
+ | `startingAfter` + `endingBefore` | mutually exclusive | `error: "CONFLICTING_CURSORS"` |
1196
+ | `startTime`, `endTime` | timestamp format, native ≥ 0.15.0 | `error: "INVALID_START_TIME"` / `"INVALID_END_TIME"` |
1197
+
1198
+ Cursors are **opaque**. Echo back exactly what you received; never construct or
1199
+ parse one.
1200
+
1201
+ Verified at `sensor_log_entity.dart:70-85`.
1202
+
1203
+ ## Bundle
1204
+
1205
+ | Limit | Value | On violation |
1206
+ | --- | --- | --- |
1207
+ | `.zip` size | 2 MB (2097152 bytes) | **The app rejects the bundle**, keeps the previous cached version, or falls back to its default screen. |
1208
+ | Entry document | one `index.html` at the archive root | Falls back to the first `.html` found; if there is none, the bundle is rejected. |
1209
+ | External `<script src>` | forbidden | Bundle rejected. |
1210
+ | External `<link rel="stylesheet">` | forbidden | Bundle rejected. |
1211
+ | Presigned download URL | valid 24 h | The app re-fetches the manifest. |
1212
+
1213
+ Verified at `whitelabel_bundle_limits.dart:14` and
1214
+ `whitelabel_bundle_service.dart:227-398`.
1215
+
1216
+ `data:` URIs inflate binary assets by roughly a third over their raw bytes, so
1217
+ budget against the inflated size. `npx condor-mobile-bridge build` prints the
1218
+ final `.zip` size; `validate` fails on it.
1219
+
1220
+ ## Storage: there is none
1221
+
1222
+ | API | Behaviour |
1223
+ | --- | --- |
1224
+ | `localStorage`, `sessionStorage` | throw `SecurityError` |
1225
+ | `indexedDB` | unavailable |
1226
+ | `document.cookie` | writes are silently ignored |
1227
+ | Service workers | cannot be registered |
1228
+ | Cache API | unavailable |
1229
+
1230
+ The shell is loaded with no base URL, giving it an opaque origin; your iframe is
1231
+ `sandbox="allow-scripts"` rendered from `srcdoc`, giving it a `null` origin.
1232
+ Both are deliberate — the opaque origin is what lets the app guarantee that
1233
+ logging out leaves nothing behind.
1234
+
1235
+ Hold state in memory. It is lost when the WebView reloads, which happens when
1236
+ the bundle is updated, when the kill switch fires, and when the person changes
1237
+ the app language while no client bundle is active.
1238
+
1239
+ Verified at `home_page.dart:296-298`.
1240
+
1241
+ ## Network: there is none
1242
+
1243
+ The shell CSP is:
1244
+
1245
+ ```
1246
+ default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';
1247
+ img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-src 'self';
1248
+ object-src 'none'; base-uri 'none'; form-action 'none';
1249
+ ```
1250
+
1251
+ It is inherited by your `srcdoc` iframe and **an iframe cannot relax the policy
1252
+ it inherits**. With a `null` origin, `'self'` matches nothing, so `connect-src
1253
+ 'self'` blocks every outbound request. No analytics, no CDN, no error reporting
1254
+ service.
1255
+
1256
+ For error reporting, use the `jsError` command — it reaches Condor's logging
1257
+ and Crashlytics, within the rate limit above.
1258
+
1259
+ Verified at `shell.template.html:28`.
1260
+
1261
+ ## Devices
1262
+
1263
+ Two things are true at once here, and conflating them is how this goes wrong.
1264
+
1265
+ **The platform enforces nothing.** A search for `maxDevices` or `deviceLimit`
1266
+ across the Canvas app returns nothing. No code path rejects a third device.
1267
+
1268
+ **The current commercial rule caps a user at two linked devices.** That is a
1269
+ product decision, not a platform invariant. It can be raised without any change
1270
+ to this contract, to the app, or to a version number you could check.
1271
+
1272
+ So: design for a short list rather than a virtualised one — two cards will be
1273
+ the common case — but **read the length of the roster you receive** and let the
1274
+ layout grow. A two-slot UI with the slots hard-coded is the failure this section
1275
+ exists to prevent: it will look correct for as long as the rule holds, and break
1276
+ silently on the day it does not.
1277
+
1278
+ <!-- ===== docs/client-guide.md ===== -->
1279
+
1280
+ # Build your White Label Home
1281
+
1282
+ This guide walks you — a Condor White Label partner — through building the web
1283
+ Home that runs inside the Condor mobile app, using the
1284
+ [`@condorinst/mobile-bridge`](../README.md) SDK.
1285
+
1286
+ No Flutter, Android, or iOS knowledge is required. You build a normal web app;
1287
+ we package it and the app renders it.
1288
+
1289
+ > **This guide is the narrative path, written for a person reading start to
1290
+ > finish.** If you are working with an AI agent, point it at
1291
+ > [`AGENTS.md`](../AGENTS.md) and [`docs/agent-guide.md`](./agent-guide.md)
1292
+ > instead — same platform, organised as a procedure with checks. The normative
1293
+ > reference for keys, commands, errors and capabilities is
1294
+ > [`docs/bridge-contract.md`](./bridge-contract.md); the hard numbers are in
1295
+ > [`docs/limits.md`](./limits.md).
1296
+
1297
+ ---
1298
+
1299
+ ## 1. What you're building
1300
+
1301
+ The app's Home is a **web bundle** that the Condor app renders inside a
1302
+ sandboxed WebView (`<iframe sandbox="allow-scripts">`). You deliver a single
1303
+ self-contained `index.html`; the app downloads it, verifies it, and shows it.
1304
+
1305
+ Your web code talks to the device and the native app through the **bridge** —
1306
+ a typed message channel exposed by this SDK. You never touch Bluetooth or
1307
+ native APIs directly: you subscribe to keys (device list, connection status,
1308
+ real-time readings…) and send commands (refresh, start real-time, open
1309
+ settings…).
1310
+
1311
+ ```
1312
+ ┌────────────────────────────┐
1313
+ │ Condor app (native) │
1314
+ │ ┌──────────────────────┐ │
1315
+ │ │ iframe (your bundle) │ │ ← your index.html runs here
1316
+ │ │ BridgeClient ◄────────────► native bridge (devices, BLE, …)
1317
+ │ └──────────────────────┘ │
1318
+ └────────────────────────────┘
1319
+ ```
1320
+
1321
+ **Two hard rules** that shape everything below:
1322
+ - The bundle must be **one `index.html`**, fully **self-contained** (JS/CSS
1323
+ inline, media as `data:` URIs). Relative paths and external URLs do **not**
1324
+ load inside the sandbox.
1325
+ - The bundle must be **≤ 2 MB** (zipped).
1326
+
1327
+ The CLI in this SDK turns your normal build into a compliant bundle for you
1328
+ (see §6) — you don't have to inline by hand.
1329
+
1330
+ ---
1331
+
1332
+ ## 2. Install the SDK
1333
+
1334
+ ```bash
1335
+ npm install @condorinst/mobile-bridge
1336
+ ```
1337
+
1338
+ Pin the version with a caret:
1339
+
1340
+ ```jsonc
1341
+ // package.json
1342
+ "dependencies": {
1343
+ "@condorinst/mobile-bridge": "^0.21.0"
1344
+ }
1345
+ ```
1346
+
1347
+ **Updates.** The SDK is on `0.x` during the pilot. With `^0.21.0`, running
1348
+ `npm install` (or your CI) auto-picks **patch** releases (`0.21.x`) — bug fixes,
1349
+ no action needed. A `0.22.0` (a larger change during beta) is an intentional
1350
+ opt-in: you bump the range when you're ready. `1.0.0` will mark the
1351
+ production-stable contract.
1352
+
1353
+ The published package ships prebuilt JS + TypeScript types — no build tooling
1354
+ of ours required on your side.
1355
+
1356
+ ---
1357
+
1358
+ ## 3. Talk to the app — the bridge
1359
+
1360
+ Import **`BridgeClient`** (the model for code running inside the app's iframe):
1361
+
1362
+ ```typescript
1363
+ import { BridgeClient, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
1364
+ ```
1365
+
1366
+ ### Handshake
1367
+
1368
+ Announce yourself once at boot. The ack tells you the bridge is live.
1369
+
1370
+ ```typescript
1371
+ const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
1372
+ // ack.accepted: boolean
1373
+ // ack.shellVersion: string
1374
+ // ack.nativeVersion: string | null — the native's schema version, relayed by
1375
+ // the shell (0.17.0+). `null` means the native has not answered or is absent
1376
+ // (browser mock). There is no version negotiation, so this is the only signal
1377
+ // telling you whether a capability exists — e.g. the raw `light_sensor_*`
1378
+ // channels on `deviceLightSensors` require native >= 0.16.0.
1379
+ ```
1380
+
1381
+ ### Listen for events (native → you)
1382
+
1383
+ ```typescript
1384
+ const unsubscribe = BridgeClient.on(BRIDGE_KEYS.deviceConnection, (payload) => {
1385
+ // payload is typed: { serialNo: number, isConnect: boolean }
1386
+ console.log(payload.serialNo, payload.isConnect);
1387
+ });
1388
+ // later: unsubscribe();
1389
+ ```
1390
+
1391
+ ### Send commands (you → native)
1392
+
1393
+ ```typescript
1394
+ const devices = await BridgeClient.requestUserDevices(); // device list, resent until it arrives
1395
+ BridgeClient.send('realTime', { serialNo: 1234, value: true }); // start real-time
1396
+ BridgeClient.openSettings(); // open native Settings
1397
+ BridgeClient.requestPermission('bluetoothScan'); // in-app permission dialog
1398
+ ```
1399
+
1400
+ > Commands are gated by the app's policy. A command that isn't allowed is
1401
+ > silently ignored — design your UI to not depend on a response you won't get.
1402
+ >
1403
+ > Ask for the device list with `requestUserDevices()`, not with
1404
+ > `send('userDevice')`: the first request after the app starts is often lost,
1405
+ > and an account with no device gets no reply at all. The helper resends with
1406
+ > backoff and resolves `{ status: 'timeout', hasDevices }` after 30 s, so you can
1407
+ > show an empty state. See [Troubleshooting](./troubleshooting.md).
1408
+
1409
+ ### Handle the system back button
1410
+
1411
+ Your page runs inside the app's Home screen. On Android, the OS back
1412
+ button/gesture would otherwise leave the Home — so the app delegates it to you
1413
+ when you opt in, letting the back button walk **your** internal navigation
1414
+ (e.g. a device detail page → back to the list) before it does anything else.
1415
+
1416
+ Two calls wire it up:
1417
+
1418
+ ```typescript
1419
+ // 1. Tell the app whether you have an internal page to go back to.
1420
+ // Call this on EVERY route change (and once at boot).
1421
+ BridgeClient.reportNavigation(currentView !== 'home'); // true = you can go back
1422
+
1423
+ // 2. React to the OS back button. Called only when you last reported `true`.
1424
+ BridgeClient.onBack(() => {
1425
+ goToPreviousPage(); // pop one internal page, then reportNavigation(...) again
1426
+ });
1427
+ ```
1428
+
1429
+ How it behaves:
1430
+
1431
+ - You reported `true` → the OS back button fires your `onBack` (go back one
1432
+ internal page). Nothing else happens — the app stays on Home.
1433
+ - You reported `false` (or never adopted this API) → the OS back button is
1434
+ ignored and the user stays on Home. **No regression if you skip it.**
1435
+
1436
+ Keep `reportNavigation` in sync with your router: the app caches the last value
1437
+ and reads it the instant back is pressed. A single choke point (call it at the
1438
+ end of your `render`/route function) is the simplest way to never miss a change.
1439
+ See `examples/client_demo/src/main.ts` for a worked home↔device example.
1440
+
1441
+ ### Key catalog
1442
+
1443
+ These are the keys you can subscribe to / send. Payloads are fully typed in
1444
+ TypeScript; the table is the quick reference (see the
1445
+ [README](../README.md#key-catalog-strictly-typed) for exact shapes).
1446
+
1447
+ | Key | Payload (typed) | Direction |
1448
+ |---|---|---|
1449
+ | `userDevice` | device config / telemetry / user config arrays | native → you (also send to refresh) |
1450
+ | `deviceConnection` | `{ serialNo, isConnect }` | native → you (status) |
1451
+ | `deviceNotification` | `{ serialNo, isConnect, message, messageCode, isError }` | native → you |
1452
+ | `deviceLightSensors` | `{ serialNo, type, data }` — union on `type` (`'actlumus'` \| `'actlumus2'`); `data` has the spectrum plus the raw `light_sensor_*` channels | native → you |
1453
+ | `realTime` | `{ serialNo, success, value }` | both (command + state) |
1454
+ | `syncTime` | `{ serialNo, success }` | both |
1455
+ | `sensorLog` | paginated sensor log envelope | native → you |
1456
+ | `systemStatus` | `{ bluetooth, internet, permission, hasDevices, missingOptionalPermissions, missingEssentialPermissions, deviceSyncError }` | native → you (env status) |
1457
+ | `deleteMemory` | `{ serialNo, success }` | both |
1458
+ | `pingPong` | `{ data: 'ping' \| 'pong' }` | both |
1459
+ | `navigationBack` | `{}` (OS back pressed — via `onBack`) | native → you |
1460
+ | `language` | `{ deviceLocale, appLanguage, resolvedLanguage }` | native → you (language state) |
1461
+
1462
+ > **System back button.** Use the `reportNavigation(canGoBack)` /
1463
+ > `onBack(cb)` helpers (see [Handle the system back button](#handle-the-system-back-button))
1464
+ > rather than subscribing to `navigationBack` directly — `reportNavigation`
1465
+ > sends the `navigationState` command the app needs to route the back button.
1466
+
1467
+ **Permissions on `systemStatus`.** Two arrays of `AppPermission` names let you
1468
+ render permission prompts from your own UI:
1469
+
1470
+ - `missingEssentialPermissions` — **required** for the app to work; background
1471
+ collection stays off until granted. Render a **blocking "Required permissions"
1472
+ card** (e.g. `["bluetoothScan", "bluetoothConnect"]`).
1473
+ - `missingOptionalPermissions` — **recommended** (help background reliability);
1474
+ render a **dismissible "Recommended permissions" nudge** (e.g.
1475
+ `["notification", "backgroundLocation"]`).
1476
+
1477
+ Both are empty when nothing is missing. For the **required** card, wire the
1478
+ "Grant" button to `BridgeClient.requestPermission(name)` — it fires the **native
1479
+ in-app permission dialog** for that `AppPermission` (falls back to Settings only
1480
+ when the OS refuses the prompt). Pass a name straight from
1481
+ `missingEssentialPermissions` (e.g. `'bluetoothScan'`). There is no dedicated
1482
+ result key: the native side re-emits `systemStatus` once granted, so the card
1483
+ disappears when `missingEssentialPermissions` empties. `openSettings` remains
1484
+ available for the optional nudge (permissions not grantable by prompt on modern
1485
+ Android). See `examples/client_demo/src/render-home.ts` for a worked render.
1486
+
1487
+ ### Translate your bundle — the `language` key
1488
+
1489
+ The app tells you **two** languages, and the distinction between them is the
1490
+ whole point of the channel:
1491
+
1492
+ ```typescript
1493
+ BridgeClient.on(BRIDGE_KEYS.language, ({ deviceLocale, appLanguage, resolvedLanguage }) => {
1494
+ // Your precedence rule. This one honours an explicit choice, else the device.
1495
+ const lang = appLanguage ?? pickSupported(deviceLocale) ?? 'en';
1496
+ renderIn(lang);
1497
+ });
1498
+ ```
1499
+
1500
+ - **`deviceLocale`** — the raw device locale as a BCP-47 tag (`"pt-BR"`,
1501
+ `"es-419"`, `"ja-JP"`), exactly as the OS reports it, region included.
1502
+ - **`appLanguage`** — the language the user explicitly picked in the app
1503
+ Settings (`"en" | "pt" | "de" | "fr"`), or **`null`** when they never picked
1504
+ one.
1505
+ - **`resolvedLanguage`** — the language the native chrome (splash, Settings,
1506
+ loading/error screens) is rendering right now.
1507
+
1508
+ **Why two raw values instead of one answer.** The app itself ships four
1509
+ languages and collapses everything else to English. Your bundle has no such
1510
+ limit — it may ship Spanish, Italian, Japanese. If we sent you only the resolved
1511
+ language, a device set to Spanish would reach you as `en` and you would render
1512
+ English while holding a Spanish bundle. The information would have been
1513
+ destroyed on our side before reaching you. So we send both and let you decide:
1514
+
1515
+ | `deviceLocale` | `appLanguage` | a reasonable decision |
1516
+ |---|---|---|
1517
+ | `es-ES` | `null` | render **Spanish** — you have it, we don't. Our chrome stays English while your content is Spanish, which is better, not worse |
1518
+ | `es-ES` | `"pt"` | render **Portuguese** — an explicit choice outranks the device |
1519
+ | `pt-BR` | `null` | render Portuguese |
1520
+ | `ja-JP` | `"de"` | render German (the explicit choice), or Japanese if you'd rather honour the device — your call, with both values in hand |
1521
+
1522
+ `appLanguage` is `null` when — and only when — the user never chose. It is never
1523
+ a fallback in disguise, so `null` is a real signal you can branch on. Use
1524
+ `resolvedLanguage` when you'd rather stay visually consistent with the native
1525
+ frame around your page than follow the device.
1526
+
1527
+ **When it arrives — and how to ask for it.** The app pushes this key on attach,
1528
+ again right after the handshake ack, and on every change in the app Settings, so
1529
+ a page already loaded re-renders without a restart. But the first push happens
1530
+ while the WebView is still being created, before your bundle exists, so **send
1531
+ the `language` command once after your handshake** to pull the current state:
1532
+
1533
+ ```typescript
1534
+ BridgeClient.send('language', {}); // replies with a `language` event
1535
+ ```
1536
+
1537
+ Without that pull, a first launch would show your default language until the
1538
+ user changed it in Settings. Subscribe first, then pull.
1539
+
1540
+ Do not read `navigator.language` instead: it follows the system, not the in-app
1541
+ choice, and you would end up in Spanish while the native chrome around you is in
1542
+ Portuguese.
1543
+
1544
+ **Older app versions.** There is no version negotiation. An app older than
1545
+ native `0.14.0` never emits this key, so the event simply never arrives — keep a
1546
+ default language for that case, and read `nativeVersion` from the handshake ack
1547
+ if you need to branch on it explicitly.
1548
+
1549
+ ---
1550
+
1551
+ ## 4. Develop in your browser (no app needed)
1552
+
1553
+ Build your page, then serve it with the harness:
1554
+
1555
+ ```bash
1556
+ npx condor-mobile-bridge dev -i dist/index.html
1557
+ ```
1558
+
1559
+ Open `http://localhost:5173`. Your page runs inside the same sandboxed iframe,
1560
+ shell, policy and CSP as in the app, and a simulated app answers your commands.
1561
+ Rebuild and reload — the harness re-reads the file.
1562
+
1563
+ Pick a **scenario** in the selector at the top (or with `--scenario`) to see
1564
+ what the real app does to your page:
1565
+
1566
+ - `cold-start` — the first device requests are lost while the app starts;
1567
+ - `no-devices` — an account with no device: the device list never comes;
1568
+ - `shared-serial` — two devices with the same serial number;
1569
+ - `offline` — no internet, history unavailable.
1570
+
1571
+ Your page is ready for a phone when it behaves in all of them. The page's
1572
+ console (`[condor dev]` lines) shows each command, reply and dropped request,
1573
+ and the dev panel in the corner lets you emit any event by hand.
1574
+
1575
+ > Opening `dist/index.html` directly in a browser does not work: there is no
1576
+ > shell around it, so `BridgeClient.handshake()` rejects at once with a message
1577
+ > pointing to the harness.
1578
+
1579
+ The [`examples/minimal-home`](../examples/minimal-home) example runs this way
1580
+ out of the box.
1581
+
1582
+ ---
1583
+
1584
+ ## 5. Bundle format & constraints
1585
+
1586
+ Your final artifact is a `.zip` containing exactly **one self-contained
1587
+ `index.html`** at the root. Why the constraints:
1588
+
1589
+ - The app injects your HTML via `iframe.srcdoc` with origin `null` and no base
1590
+ URL → **relative references don't resolve** (`<script src="app.js">`,
1591
+ `<img src="logo.png">` would break).
1592
+ - The app's Content-Security-Policy **blocks external fetches** → CDN scripts,
1593
+ Google Fonts, remote APIs called at load time won't work.
1594
+
1595
+ So the bundle must have:
1596
+ - ✅ One `index.html` at the zip root.
1597
+ - ✅ JS and CSS **inlined** into the HTML.
1598
+ - ✅ Images/icons/fonts as **`data:` URIs**.
1599
+ - ✅ Total size **≤ 2 MB** (zipped). Note base64 inflates media ~33%.
1600
+ - ❌ No external `https://` script/style/font references loaded at runtime.
1601
+ - ❌ No code-splitting / dynamic `import()` across chunks (see below).
1602
+
1603
+ ### Single-chunk build
1604
+
1605
+ The bundler CLI is post-build and assumes **one JS + one CSS + assets**. If
1606
+ your framework code-splits, the split chunks won't resolve in the sandbox.
1607
+ Configure a single-file build:
1608
+
1609
+ - **Vite:** add [`vite-plugin-singlefile`](https://github.com/richardtallent/vite-plugin-singlefile),
1610
+ or set `build.rollupOptions.output.inlineDynamicImports: true` +
1611
+ `manualChunks: undefined`.
1612
+ - **Create React App:** avoid `React.lazy` / dynamic `import()` in routes so the
1613
+ build emits a single bundle.
1614
+ - **esbuild:** bundle to a single IIFE/ESM file (see the example's `build.mjs`).
1615
+
1616
+ ---
1617
+
1618
+ ## 6. Build the bundle
1619
+
1620
+ Once your build produces a `dist/index.html` (referencing a local `main.js`,
1621
+ CSS, images), run the CLI to inline everything and pack the zip:
1622
+
1623
+ ```bash
1624
+ npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
1625
+ ```
1626
+
1627
+ It inlines `<script src>`, `<link rel=stylesheet>`, CSS `url()`, and local
1628
+ `<img>`/icons into a single `index.html`, then zips it. Output:
1629
+
1630
+ ```
1631
+ Built dist/bundle.zip
1632
+ entry: index.html (123.4 KB self-contained)
1633
+ bundleSize (zip): 45678 bytes
1634
+ bundleHash (zip): sha256-<hex>
1635
+ ```
1636
+
1637
+ | Option | Description |
1638
+ |---|---|
1639
+ | `-i, --html <path>` | Input HTML. Required. |
1640
+ | `-o, --out <path>` | Output `.zip` (default `dist/whitelabel-bundle.zip`). |
1641
+ | `--max-bytes <n>` | Warning threshold (default 2 MB). |
1642
+
1643
+ The hash is deterministic — the same input always yields the same
1644
+ `bundleHash`. The CLI **warns** above 2 MB but the **app rejects** oversized
1645
+ bundles and falls back to a default screen, so keep under the limit.
1646
+
1647
+ ---
1648
+
1649
+ ## 7. Deliver
1650
+
1651
+ Send the resulting `dist/bundle.zip` through the publication channel Condor
1652
+ gave you (the admin upload). The backend validates structure, self-containment,
1653
+ and size on upload, and records the `bundleSize`/`bundleHash`. Once published,
1654
+ the app downloads, verifies, and renders your Home — for **every user of your
1655
+ company**, so test on a development test company first. See
1656
+ [Testing on a device](./device-testing.md).
1657
+
1658
+ ---
1659
+
1660
+ ## 8. Troubleshooting
1661
+
1662
+ | Symptom | Likely cause |
1663
+ |---|---|
1664
+ | Blank screen / nothing loads | Relative `<script src>` or external URL — must be inlined. Run the build CLI. |
1665
+ | Images missing | Referenced by path instead of `data:` URI — the CLI inlines local images; remote images are blocked. |
1666
+ | Works in browser, not in app | You relied on a network call or external asset at load time (CSP blocks it), or a code-split chunk didn't resolve. Go single-chunk + self-contained. |
1667
+ | Bundle rejected on upload / falls back | Over 2 MB, multiple files at root, or not self-contained. |
1668
+ | `handshake` rejects in the browser | The page is not inside the shell. Serve it with `npx condor-mobile-bridge dev`. |
1669
+ | Stuck on "loading devices" on the phone | The device request was lost or the account has no device. Use `requestUserDevices()` and test the `cold-start` and `no-devices` scenarios. |
1670
+ | A command does nothing | The command may not be allowed by the app policy; it's dropped silently. |
1671
+
1672
+ ---
1673
+
1674
+ ## Next steps
1675
+
1676
+ - Copy [`examples/minimal-home`](../examples/minimal-home) and start from there.
1677
+ - Full API reference: the [README](../README.md).
1678
+
1679
+ <!-- ===== docs/pilot-onboarding.md ===== -->
1680
+
1681
+ # White Label — Pilot Onboarding
1682
+
1683
+ Welcome! This is the fast path to building and shipping your **White Label
1684
+ Home** — the screen that runs inside the Condor app. You build a normal web app;
1685
+ we package and render it. No Flutter / Android / iOS needed.
1686
+
1687
+ For the full reference, see the [Client Guide](./client-guide.md). This page is
1688
+ the quick checklist to get a pilot through the whole loop.
1689
+
1690
+ ## What you'll end up with
1691
+
1692
+ A self-contained `bundle.zip` that Condor publishes to your tenant — your Home
1693
+ renders inside the app, talking to the device through the Condor bridge.
1694
+
1695
+ ## Who does what
1696
+
1697
+ | Condor provides | You build |
1698
+ |---|---|
1699
+ | The SDK (`@condorinst/mobile-bridge`), guide & example | Your Home web app (HTML/CSS/JS or React) |
1700
+ | The app that renders your bundle + the device bridge | The `bundle.zip` (via our CLI) |
1701
+ | The publish channel (admin upload) | Delivering the `.zip` to us |
1702
+
1703
+ ## Prerequisites
1704
+
1705
+ - Node.js 18+
1706
+ - npm access (the package is public — no credentials needed)
1707
+
1708
+ ## Step-by-step
1709
+
1710
+ ### 1. Install the SDK
1711
+ ```bash
1712
+ npm install @condorinst/mobile-bridge
1713
+ ```
1714
+ Pin `"@condorinst/mobile-bridge": "^0.21.0"` (beta; auto-updates patches).
1715
+
1716
+ ### 2. Start from the example
1717
+ Copy [`examples/minimal-home`](../examples/minimal-home) — it already wires the
1718
+ bridge, renders devices, and builds a bundle.
1719
+
1720
+ ### 3. Build your Home
1721
+ Talk to the app via `BridgeClient`:
1722
+ ```ts
1723
+ import { BridgeClient, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
1724
+
1725
+ BridgeClient.on(BRIDGE_KEYS.userDevice, (d) => render(d)); // listen first
1726
+ const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' }); // ack.nativeVersion = capabilities
1727
+ const r = await BridgeClient.requestUserDevices(); // resends until the list arrives
1728
+ if (r.status === 'timeout') renderEmptyState(); // no reply in 30 s
1729
+ BridgeClient.openSettings(); // open native settings
1730
+ ```
1731
+ Never ask for the device list with a single `send('userDevice')`: the first
1732
+ request after the app starts is often lost, and an account with no device gets
1733
+ no reply. `AGENTS.md` has the full boot sequence.
1734
+
1735
+ ### 4. Test in your browser (no app needed)
1736
+ ```bash
1737
+ npx condor-mobile-bridge dev -i dist/index.html
1738
+ ```
1739
+ Serves your page inside the same sandbox, shell and policy as the app, with a
1740
+ simulated app behind it. Check every scenario in the selector at the top:
1741
+ `default`, `cold-start`, `no-devices`, `shared-serial`, `offline`.
1742
+
1743
+ ### 5. Build the bundle
1744
+ ```bash
1745
+ npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
1746
+ ```
1747
+ This inlines everything into a single self-contained `index.html` and zips it.
1748
+ It prints `bundleSize` + `bundleHash`.
1749
+
1750
+ ### 5b. Validate before you deliver
1751
+ ```bash
1752
+ npx condor-mobile-bridge validate -i dist/bundle.zip --strict
1753
+ ```
1754
+ Applies the same gates the app applies on download, plus a lint for the APIs the
1755
+ sandbox and the CSP block at runtime. **A non-zero exit means do not deliver.**
1756
+ Errors are outright rejections by the app; warnings install fine and then
1757
+ misbehave on the device, which is harder to diagnose.
1758
+
1759
+ ### 6. Deliver
1760
+ Send `dist/bundle.zip`, with the `bundleSize` and `bundleHash` printed by
1761
+ `build`, through the channel Condor gave you. We publish it to your company —
1762
+ first to a **development test company**, because a publish reaches every user
1763
+ of the company.
1764
+
1765
+ ### 7. Verify on a device
1766
+ Condor publishes → the app downloads, validates and renders your Home the next
1767
+ time it comes to the foreground. [Testing on a device](./device-testing.md)
1768
+ explains the environments, how to inspect the WebView, and where errors go.
1769
+
1770
+ ## Hard rules (or the app rejects the bundle)
1771
+
1772
+ - ✅ Exactly **one `index.html`** at the zip root
1773
+ - ✅ JS/CSS **inline**, media as **`data:` URIs**, no external/relative refs loaded at runtime
1774
+ - ✅ **≤ 2 MB** (zipped)
1775
+ - ✅ **Single-chunk build** (no code-splitting): Vite → `vite-plugin-singlefile`; CRA → no `React.lazy` / dynamic `import()`
1776
+
1777
+ The CLI handles the inlining/zipping for you — just keep your build single-file.
1778
+
1779
+ ## Reference
1780
+
1781
+ - [Client Guide](./client-guide.md) — full walkthrough (bridge API, key catalog, dev harness)
1782
+ - [Testing on a device](./device-testing.md) — from a published bundle to an inspected WebView
1783
+ - [Troubleshooting](./troubleshooting.md) — starts from the symptom
1784
+ - [`examples/minimal-home`](../examples/minimal-home) — runnable minimal client
1785
+ - [README](../README.md) — API reference
1786
+
1787
+ ## Pilot checklist
1788
+
1789
+ - [ ] SDK installed (`npm i @condorinst/mobile-bridge`)
1790
+ - [ ] Example runs in the harness (`npx condor-mobile-bridge dev`), in every scenario
1791
+ - [ ] Home built with `BridgeClient` (handshake + at least one key + one command)
1792
+ - [ ] Constraints respected (1 index.html, ≤2 MB, self-contained, single-chunk)
1793
+ - [ ] `bundle.zip` generated by the CLI, and `validate --strict` exits 0
1794
+ - [ ] `.zip` delivered to Condor
1795
+ - [ ] Home verified on a device
1796
+ - [ ] Feedback shared with Condor
1797
+ </content>
1798
+
1799
+ <!-- ===== examples/minimal-home/src/main.ts ===== -->
1800
+
1801
+ ```ts
1802
+ /**
1803
+ * Minimal Canvas Home — the reference boot sequence for @condorinst/mobile-bridge.
1804
+ *
1805
+ * This is the kind of code a Canvas client writes: it runs inside the app's
1806
+ * sandboxed iframe (origin `null`, no network, no storage) and talks to the
1807
+ * native side only through `BridgeClient`.
1808
+ *
1809
+ * Develop it in the browser with the harness, which runs it under the same
1810
+ * sandbox and shell as the app:
1811
+ *
1812
+ * npm run build
1813
+ * npx condor-mobile-bridge dev -i dist/index.html --scenario cold-start
1814
+ *
1815
+ * It must work in every scenario (`default`, `cold-start`, `no-devices`,
1816
+ * `shared-serial`, `offline`) before it goes to a device.
1817
+ */
1818
+ import {
1819
+ BRIDGE_KEYS,
1820
+ BridgeClient,
1821
+ type DeviceConfigItem,
1822
+ type DeviceTelemetryItem,
1823
+ type UserDevicePayload,
1824
+ versionAtLeast,
1825
+ } from '@condorinst/mobile-bridge';
1826
+
1827
+ interface DeviceRow {
1828
+ deviceId: number;
1829
+ serialNo: number;
1830
+ name: string;
1831
+ battery: number | null;
1832
+ connected: boolean;
1833
+ realTimeOn: boolean;
1834
+ }
1835
+
1836
+ type Phase = 'loading' | 'ready' | 'empty' | 'outside-shell';
1837
+
1838
+ const state = {
1839
+ phase: 'loading' as Phase,
1840
+ /** `null` = unknown (older shell, or the native side did not answer). */
1841
+ nativeVersion: null as string | null,
1842
+ /** Whether the last timeout came with `systemStatus.hasDevices === false`. */
1843
+ probablyNoDevices: false,
1844
+ devices: new Map<number, DeviceRow>(),
1845
+ };
1846
+
1847
+ declare global {
1848
+ interface Window {
1849
+ __home: {
1850
+ retry: () => void;
1851
+ settings: () => void;
1852
+ toggle: (deviceId: number) => void;
1853
+ };
1854
+ }
1855
+ }
1856
+
1857
+ // ── Data ───────────────────────────────────────────────────────────────
1858
+
1859
+ /**
1860
+ * Joins the three arrays of `userDevice`. `deviceId` is the key: an ActLumus
1861
+ * and an ActLumus Plus may share a `serialNo`. Fall back to `serialNo` only for
1862
+ * an item without `deviceId` whose `serialNo` is unique.
1863
+ */
1864
+ function telemetryFor(
1865
+ cfg: DeviceConfigItem,
1866
+ payload: UserDevicePayload,
1867
+ ): DeviceTelemetryItem | undefined {
1868
+ const byId = payload.devicesTelemetry.find((t) => t.deviceId === cfg.deviceId);
1869
+ if (byId) return byId;
1870
+ const sameSerial = payload.devicesConfig.filter((c) => c.serialNo === cfg.serialNo);
1871
+ if (sameSerial.length !== 1) return undefined;
1872
+ return payload.devicesTelemetry.find(
1873
+ (t) => t.deviceId === undefined && t.serialNo === cfg.serialNo,
1874
+ );
1875
+ }
1876
+
1877
+ function applyDevices(payload: UserDevicePayload): void {
1878
+ const next = new Map<number, DeviceRow>();
1879
+ for (const cfg of payload.devicesConfig) {
1880
+ const telemetry = telemetryFor(cfg, payload);
1881
+ const previous = state.devices.get(cfg.deviceId);
1882
+ next.set(cfg.deviceId, {
1883
+ deviceId: cfg.deviceId,
1884
+ serialNo: cfg.serialNo,
1885
+ name: telemetry?.deviceName ?? `Device ${cfg.serialNo}`,
1886
+ battery: telemetry?.batteryLevel ?? null,
1887
+ connected: previous?.connected ?? false,
1888
+ realTimeOn: previous?.realTimeOn ?? false,
1889
+ });
1890
+ }
1891
+ state.devices = next;
1892
+ state.phase = next.size > 0 ? 'ready' : 'empty';
1893
+ render();
1894
+ }
1895
+
1896
+ /**
1897
+ * `deviceId` in commands needs nativeVersion >= 0.17.0; `serialNo` always goes.
1898
+ * Below that, a shared serial cannot be addressed at all.
1899
+ */
1900
+ function address(d: DeviceRow): { serialNo: number; deviceId?: number } {
1901
+ return versionAtLeast(state.nativeVersion, '0.17.0')
1902
+ ? { serialNo: d.serialNo, deviceId: d.deviceId }
1903
+ : { serialNo: d.serialNo };
1904
+ }
1905
+
1906
+ /** Events carry `deviceId` only when the serial is shared; resolve both ways. */
1907
+ function findRow(serialNo: number, deviceId?: number): DeviceRow | undefined {
1908
+ if (deviceId !== undefined) return state.devices.get(deviceId);
1909
+ return [...state.devices.values()].find((d) => d.serialNo === serialNo);
1910
+ }
1911
+
1912
+ // ── Render ─────────────────────────────────────────────────────────────
1913
+
1914
+ function esc(value: string): string {
1915
+ return value.replace(
1916
+ /[&<>"]/g,
1917
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] as string,
1918
+ );
1919
+ }
1920
+
1921
+ function render(): void {
1922
+ const el = document.getElementById('app');
1923
+ if (!el) return;
1924
+ el.innerHTML = `
1925
+ <header class="hero">
1926
+ <h1>My Canvas Home</h1>
1927
+ <p class="status">${state.nativeVersion ? `app ${esc(state.nativeVersion)}` : ''}</p>
1928
+ </header>
1929
+ <div class="toolbar">
1930
+ <button onclick="__home.retry()">Refresh</button>
1931
+ <button onclick="__home.settings()">Settings</button>
1932
+ </div>
1933
+ <main>${body()}</main>
1934
+ `;
1935
+ }
1936
+
1937
+ function body(): string {
1938
+ switch (state.phase) {
1939
+ case 'outside-shell':
1940
+ return '<p class="empty">Open this page with <code>npx condor-mobile-bridge dev</code>.</p>';
1941
+ case 'loading':
1942
+ return '<p class="empty">Loading your devices…</p>';
1943
+ case 'empty':
1944
+ return `<p class="empty">${
1945
+ state.probablyNoDevices
1946
+ ? 'No device is linked to your account yet.'
1947
+ : 'Could not load your devices.'
1948
+ }</p><p class="empty"><button onclick="__home.retry()">Try again</button></p>`;
1949
+ case 'ready':
1950
+ return [...state.devices.values()].map(renderCard).join('');
1951
+ }
1952
+ }
1953
+
1954
+ function renderCard(d: DeviceRow): string {
1955
+ return `
1956
+ <section class="card">
1957
+ <div class="card-head">
1958
+ <span class="name">${esc(d.name)} <small>#${d.serialNo}</small></span>
1959
+ <span class="badge ${d.connected ? 'on' : 'off'}">${
1960
+ d.connected ? '● connected' : '○ disconnected'
1961
+ }</span>
1962
+ </div>
1963
+ <p class="meta">Battery: ${d.battery === null ? '—' : `${Math.round(d.battery)}%`}</p>
1964
+ <div class="actions">
1965
+ <button onclick="__home.toggle(${d.deviceId})">${
1966
+ d.realTimeOn ? 'Stop real-time' : 'Start real-time'
1967
+ }</button>
1968
+ </div>
1969
+ </section>
1970
+ `;
1971
+ }
1972
+
1973
+ // ── Subscribers (native → iframe) — before any command ─────────────────
1974
+
1975
+ // Roster changes arrive here too, not only replies to requestUserDevices().
1976
+ BridgeClient.on(BRIDGE_KEYS.userDevice, applyDevices);
1977
+
1978
+ BridgeClient.on(BRIDGE_KEYS.deviceConnection, (payload) => {
1979
+ const d = findRow(payload.serialNo, payload.deviceId);
1980
+ if (!d) return;
1981
+ d.connected = payload.isConnect;
1982
+ render();
1983
+ });
1984
+
1985
+ BridgeClient.on(BRIDGE_KEYS.realTime, (payload) => {
1986
+ const d = findRow(payload.serialNo, payload.deviceId);
1987
+ if (!d || !payload.success) return;
1988
+ d.realTimeOn = payload.value;
1989
+ render();
1990
+ });
1991
+
1992
+ // ── Actions (iframe → native) ───────────────────────────────────────────
1993
+
1994
+ async function loadDevices(getFromServer: boolean): Promise<void> {
1995
+ const result = await BridgeClient.requestUserDevices({ getFromServer });
1996
+ if (result.status === 'ok') return; // applyDevices already rendered it
1997
+ state.probablyNoDevices = result.hasDevices === false;
1998
+ state.phase = 'empty';
1999
+ render();
2000
+ }
2001
+
2002
+ window.__home = {
2003
+ retry: () => {
2004
+ state.phase = 'loading';
2005
+ render();
2006
+ void loadDevices(true);
2007
+ },
2008
+ settings: () => BridgeClient.openSettings(),
2009
+ toggle: (deviceId) => {
2010
+ const d = state.devices.get(deviceId);
2011
+ if (!d) return;
2012
+ BridgeClient.send('realTime', { ...address(d), value: !d.realTimeOn });
2013
+ },
2014
+ };
2015
+
2016
+ // ── Boot ────────────────────────────────────────────────────────────────
2017
+
2018
+ async function boot(): Promise<void> {
2019
+ render();
2020
+ try {
2021
+ const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
2022
+ state.nativeVersion = ack.nativeVersion; // branch on this; the accepted flag is always true
2023
+ } catch (err) {
2024
+ console.error('[home] handshake failed:', err);
2025
+ state.phase = 'outside-shell';
2026
+ render();
2027
+ return;
2028
+ }
2029
+ await loadDevices(false);
2030
+ for (const d of state.devices.values()) {
2031
+ // `add` subscribes to the connection state of one device; it pairs nothing.
2032
+ BridgeClient.send('deviceConnection', { type: 'add', ...address(d) });
2033
+ }
2034
+ }
2035
+
2036
+ if (document.readyState === 'loading') {
2037
+ document.addEventListener('DOMContentLoaded', () => void boot());
2038
+ } else {
2039
+ void boot();
2040
+ }
2041
+ ```