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