@condorinst/mobile-bridge 0.22.0 → 0.23.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 CHANGED
@@ -1,16 +1,14 @@
1
- # @condorinst/mobile-bridge 0.22.0 — full documentation
1
+ # @condorinst/mobile-bridge 0.23.0 — full documentation
2
2
 
3
3
  Generated from the files listed below, in reading order. Payload types are
4
4
  canonical in dist/index.d.ts; the machine-readable contract is
5
5
  contract/bridge-contract.json.
6
6
 
7
7
  - AGENTS.md
8
- - docs/agent-guide.md
9
8
  - docs/bridge-contract.md
10
9
  - docs/troubleshooting.md
11
10
  - docs/device-testing.md
12
11
  - docs/limits.md
13
- - docs/client-guide.md
14
12
  - docs/pilot-onboarding.md
15
13
  - examples/minimal-home/src/main.ts
16
14
 
@@ -20,12 +18,25 @@ contract/bridge-contract.json.
20
18
 
21
19
  You are writing the **Home screen** of a Condor Canvas app. It is a single web
22
20
  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.
21
+ owns login, its settings screen, Bluetooth and background collection; you own
22
+ everything the person sees after login, including the way to those settings and
23
+ the way out of the account.
25
24
 
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.
25
+ This file is the procedure: read it before writing code, top to bottom. Each
26
+ subject has one owner, so look things up there instead of guessing:
27
+
28
+ | Subject | Owner |
29
+ | --- | --- |
30
+ | What each key, command, ack and error means | `docs/bridge-contract.md` (and `contract/bridge-contract.json`) |
31
+ | Payload shapes, field by field | `dist/index.d.ts` — your editor shows them |
32
+ | Harness, scripted tests, `validate`, publishing, the phone | `docs/device-testing.md` |
33
+ | A symptom you do not understand | `docs/troubleshooting.md` |
34
+ | Sizes, rates and timeouts | `docs/limits.md` |
35
+ | A working client to copy | `examples/minimal-home/` |
36
+ | All of it in one file | `llms-full.txt` |
37
+
38
+ Everything here is enforced, either by a gate in the app or by the browser
39
+ runtime on a real device, where mistakes are expensive to diagnose.
29
40
 
30
41
  ## The one thing that makes this different
31
42
 
@@ -37,11 +48,34 @@ So never write code whose correctness you infer from the absence of an error.
37
48
  Drive the UI from the events that arrive, give every wait a deadline, and
38
49
  always have a screen for "it did not come".
39
50
 
51
+ ## Your bundle is the whole screen
52
+
53
+ The app draws **nothing** around your page except the status bar strip (its
54
+ color is yours: `setStatusBarColor`) and the system safe areas, which it already
55
+ keeps clear — do not pad for them again. There is no native title, no back
56
+ arrow, no menu. So your bundle must provide:
57
+
58
+ - **a header and navigation** between your screens, with the Android back button
59
+ wired (`reportNavigation` / `onBack`): back goes one screen up, and only on
60
+ your home does it leave;
61
+ - **a way to log out** — `send('logout')` is the **only** way out of the account;
62
+ the app has no logout button of its own;
63
+ - **a way to the app settings** — `openSettings()` opens the Canvas settings
64
+ screen (language, notifications, Bluetooth, refresh the device list, date
65
+ format);
66
+ - **a way to refresh** the device list (`requestUserDevices({ getFromServer: true })`)
67
+ and to grant missing permissions (`requestPermission`).
68
+
69
+ A bundle without these traps the person inside it.
70
+
40
71
  ## Hard rules
41
72
 
42
73
  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.
74
+ fail — the iframe has a `null` origin — and the bridge has no storage
75
+ command: there is no channel to keep anything. Keep state in memory and
76
+ accept that it is lost whenever the app reloads the page (an app restart,
77
+ for one). Re-request what you need at startup; if the person typed
78
+ something, say on screen that it is not kept.
45
79
  2. **No network.** `connect-src 'self'` on a null origin blocks every `fetch`,
46
80
  `XMLHttpRequest` and `WebSocket`. All data comes through the bridge.
47
81
  3. **No `eval`, no `new Function`.** The CSP has no `'unsafe-eval'`. Configure
@@ -60,6 +94,16 @@ always have a screen for "it did not come".
60
94
  branch on it. See the capability matrix in `docs/bridge-contract.md`.
61
95
  9. **Never ask once and wait.** Use `BridgeClient.requestUserDevices()` for the
62
96
  device list; a single `send('userDevice')` can be lost (see below).
97
+ 10. **No `crypto.randomUUID`.** It exists only in a secure context, and the
98
+ app's iframe is probably not one — it works in the harness on `localhost` and
99
+ may be `undefined` on the phone. `fetchSensorLog` creates its `requestId`;
100
+ use `createRequestId()` or `crypto.getRandomValues` for anything else.
101
+ 11. **Update in place; don't rebuild the screen on every event.** Events arrive
102
+ on their own (`systemStatus`, telemetry, a reading per second), and
103
+ replacing the node under the user's finger loses the tap. Rebuild a view
104
+ when the user navigates; on an event, change the text and attributes that
105
+ moved. The example rebuilds with `innerHTML`, which is fine for a screen
106
+ with no live readings, not for one that updates every second.
63
107
 
64
108
  ## What you cannot reach
65
109
 
@@ -68,19 +112,56 @@ always have a screen for "it did not come".
68
112
  camera, microphone and geolocation (denied unconditionally) · the `dfu` key and
69
113
  command (denied by policy).
70
114
 
115
+ **Nothing leaves the screen — by design, for security.** The iframe is
116
+ `sandbox="allow-scripts"` and nothing more, and the bridge has no share,
117
+ open-URL, copy or save command. Do not look for a way around it; if a client
118
+ needs one of these, it is a request to Condor:
119
+
120
+ | You want to | On the phone |
121
+ | --- | --- |
122
+ | Download a file (`<a download>`, a `blob:`/`data:` URL) | blocked: no `allow-downloads`, and the app registers no download handler |
123
+ | Open a link (`target=_blank`, `window.open`, `_top`) | blocked: no `allow-popups`, no `allow-top-navigation`. A plain `<a href="https://…">` navigates **your iframe** away and the user loses your screen — don't render external links as links |
124
+ | `navigator.share` | treat as unavailable: no `allow="web-share"` |
125
+ | `navigator.clipboard.writeText` | unavailable: no `allow="clipboard-write"`, so the permissions policy rejects it with `NotAllowedError` (measured in the harness, which has the same iframe) |
126
+ | `document.execCommand('copy')` on a selected `<textarea>`, inside a tap handler | works in the harness (desktop Chrome); **not measured on a device** |
127
+
128
+ To hand data to the user, show it: a read-only `<textarea>` with the text
129
+ (CSV, a code) that the user can select and copy, plus an `execCommand('copy')`
130
+ button that says when it failed. Say in your README that export is copy-only.
131
+ `img-src` is `'self' data:` (no `blob:`): draw a chart on a `<canvas>` or use
132
+ a `data:` URL, never `URL.createObjectURL`.
133
+
71
134
  ## The golden path
72
135
 
136
+ Start from the example — it already has this boot sequence and a single-file
137
+ build. Copy it, then install, so its `package.json` is the one you keep:
138
+
73
139
  ```bash
74
- npm install @condorinst/mobile-bridge@^0.22.0
140
+ mkdir my-home && cd my-home
141
+ npm pack @condorinst/mobile-bridge@^0.23.0 && tar -xzf condorinst-mobile-bridge-*.tgz
142
+ cp -r package/examples/minimal-home/. . && rm -rf package condorinst-mobile-bridge-*.tgz
143
+ npm install
75
144
  ```
76
145
 
146
+ In a project you already have: `npm install @condorinst/mobile-bridge@^0.23.0`
147
+ (the caret gives you patches only while the package is below 1.0.0) and copy
148
+ `build.mjs` from `node_modules/@condorinst/mobile-bridge/examples/minimal-home/`.
149
+
150
+ **Render first.** Paint the whole screen with no data — empty list, unknown
151
+ status, default language — then fill it as events arrive. Events are emitted
152
+ before your bundle exists, so there is always a window with nothing.
153
+
77
154
  ```ts
78
155
  import {
79
156
  BridgeClient, BRIDGE_KEYS, versionAtLeast, type UserDevicePayload,
80
157
  } from '@condorinst/mobile-bridge';
81
158
 
82
- // 1. Subscribe before you ask for anything. Roster changes arrive here too.
83
- BridgeClient.on(BRIDGE_KEYS.userDevice, (devices: UserDevicePayload) => renderDevices(devices));
159
+ // 1. Subscribe before you ask for anything. Roster changes arrive here too —
160
+ // and, from app 0.18.0, failures: `error` set + empty arrays. Only
161
+ // `NO_DEVICES` means "no devices"; on any other error keep what is on screen.
162
+ BridgeClient.on(BRIDGE_KEYS.userDevice, (devices: UserDevicePayload) => {
163
+ if (devices.error === undefined || devices.error === 'NO_DEVICES') renderDevices(devices);
164
+ });
84
165
  BridgeClient.on(BRIDGE_KEYS.systemStatus, (s) => renderEnvironment(s)); // bluetooth, internet, permissions
85
166
 
86
167
  // 2. Handshake. nativeVersion is the ONLY capability signal; ack.accepted is
@@ -90,11 +171,13 @@ const canAddressById = versionAtLeast(ack.nativeVersion, '0.17.0'); // null →
90
171
 
91
172
  // 3. Ask for the devices with the helper. It resends until they arrive.
92
173
  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);
174
+ if (result.status === 'no-devices') {
175
+ renderEmpty('No device is linked to your account yet.'); // server confirmed (app 0.18.0+)
176
+ } else if (result.status === 'error' || result.status === 'timeout') {
177
+ // error: NETWORK | SESSION | UNKNOWN (app 0.18.0+). timeout: no reply in 30 s,
178
+ // the only failure an older app can produce. Always offer "Try again", which
179
+ // calls requestUserDevices({ getFromServer: true }).
180
+ renderEmpty('Could not load your devices.');
98
181
  } else {
99
182
  // 4. Subscribe to the connection state of each device (`add` pairs nothing).
100
183
  for (const d of result.devices.devicesConfig) {
@@ -106,9 +189,15 @@ if (result.status === 'timeout') {
106
189
 
107
190
  // 5. History: one page per call; it resolves { success: false, error } on
108
191
  // failure and only rejects on timeout.
109
- const page = await BridgeClient.fetchSensorLog({ requestId: crypto.randomUUID(), serialNo });
192
+ const page = await BridgeClient.fetchSensorLog({ serialNo }) // requestId is created for you;
110
193
  ```
111
194
 
195
+ `on` returns an unsubscribe function: call it when a view unmounts, or handlers
196
+ pile up. **Permissions:** when `systemStatus.missingEssentialPermissions` is not
197
+ empty, background collection is off — show a blocking card whose button calls
198
+ `BridgeClient.requestPermission(name)`. There is no result event: the app
199
+ re-emits `systemStatus`, and the card goes when the array empties.
200
+
112
201
  **Why the helper.** Device data comes from a background service that the app
113
202
  starts *after* your bundle is already running. Anything you send to it before it
114
203
  is up is lost without a reply — while the handshake, `systemStatus` and
@@ -129,7 +218,17 @@ with `toISOString()` or a `'Z'` suffix.
129
218
 
130
219
  Three commands open a confirmation dialog owned by Condor: `syncTime`,
131
220
  `deleteMemory` and `logout`. If the person declines, you get nothing — the ack
132
- never arrives. Do not block your UI on them.
221
+ never arrives. Do not block your UI on them, and do not add a second
222
+ confirmation of your own: explain the consequence next to the button instead.
223
+ `logout` never answers — the app unloads your page.
224
+
225
+ **An ActLumus is disconnected most of the time** — it connects for a short sync
226
+ every few minutes. `realTime`, `syncTime` and `deleteMemory` work anyway: the ack
227
+ means "recorded", and the app carries it out at the device's next session
228
+ (`deleteMemory` only after that sync uploaded the data). Never disable them for
229
+ a disconnected device. `deviceConnection` `add` reports only *changes*: start
230
+ each device as "connection unknown". Details: `docs/bridge-contract.md`,
231
+ "What an ack means".
133
232
 
134
233
  ## Develop in the harness, not in a bare browser tab
135
234
 
@@ -144,9 +243,9 @@ once outside the shell. Rebuild and reload; the harness re-reads the file.
144
243
 
145
244
  | Scenario | What it reproduces |
146
245
  | --- | --- |
147
- | `default` | two devices, realistic latency |
246
+ | `default` | two devices, realistic latency; the first starts a BLE session after 3 s, the second stays between sessions |
148
247
  | `cold-start` | the background service takes 4 s; earlier `ping`/`userDevice` are lost |
149
- | `no-devices` | `userDevice` never answers; `hasDevices: false` |
248
+ | `no-devices` | `userDevice` answers `error: 'NO_DEVICES'` (`--native-version 0.17.0`: never answers); `hasDevices: false` |
150
249
  | `shared-serial` | an ActLumus and a Plus share serial 7047 |
151
250
  | `offline` | no internet; `sensorLog` fails |
152
251
 
@@ -155,6 +254,11 @@ answered and dropped. The **◀ OS back** button at the top presses the OS back
155
254
  button. `--native-version null` (or `0.15.0`, …) makes the handshake report that
156
255
  version, to test your capability branches.
157
256
 
257
+ The harness does not change state by itself after load: drive internet loss,
258
+ disconnects, readings and the confirmation dialog from a script through
259
+ `window.condorDev` — what it simulates, the scripted-test recipe and your own
260
+ scenario files are in `docs/device-testing.md`.
261
+
158
262
  ## Publishing — ask the human, never run it yourself
159
263
 
160
264
  `npx condor-mobile-bridge canvas publish` puts the bundle live at once for every
@@ -187,293 +291,16 @@ check, so a clean run is necessary, not sufficient.
187
291
  - [ ] Every capability beyond the baseline is guarded by a `nativeVersion` check
188
292
  (`versionAtLeast`), and the page still renders with
189
293
  `--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
294
+ - [ ] Nothing waits forever on a reply that may never come — the three
295
+ confirmable commands may never ack, because the person can decline.
296
+ - [ ] The person can navigate back, reach the app settings and log out from
297
+ your screens ("Your bundle is the whole screen").
298
+ - [ ] `serialNo` is a number everywhere; commands carry `deviceId` too when
299
+ `nativeVersion` ≥ 0.17.0.
300
+ - [ ] Unexpected errors go to `BridgeClient.send('jsError', …)`, your only
458
301
  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
302
 
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.
303
+ To put the bundle on a phone, read `docs/device-testing.md`.
477
304
 
478
305
  <!-- ===== docs/bridge-contract.md ===== -->
479
306
 
@@ -506,12 +333,12 @@ protocol for you.
506
333
 
507
334
  Four numbers coexist. Only one tells you what the device can do.
508
335
 
509
- | Number | Current | What it means |
336
+ | Number | Latest | What it means |
510
337
  | --- | --- | --- |
511
- | `nativeVersion` | `0.17.0` | **The capability signal.** The bridge schema the app on this device implements. |
338
+ | `nativeVersion` | `0.18.0` | **The capability signal.** The bridge schema the app on this device implements. No Canvas release is in the field yet; the first one implements `0.18.0`. Later releases will coexist with it on phones for a while, so read it at runtime — never assume the latest. |
512
339
  | `shellVersion` | `0.6.0` | The Condor shell wrapping your iframe. |
513
340
  | client protocol | `0.5.0` | The postMessage protocol `BridgeClient` speaks. |
514
- | package | `0.22.0` | The npm version you compiled against. |
341
+ | package | `0.23.0` | The npm version you compiled against. |
515
342
 
516
343
  ```ts
517
344
  const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
@@ -526,7 +353,7 @@ side never refuses. Branching on it is branching on a constant.
526
353
  **`nativeVersion` can be `null`.** When the shell could not reach the native
527
354
  side, and in bundles compiled against SDK ≤ 0.16.x, where the shell did not
528
355
  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`
356
+ default. The dev harness reports `0.18.0` by default (`--native-version 0.17.0` reproduces an app that may stay silent); `--native-version null`
530
357
  exercises this path.
531
358
 
532
359
  ### Capability matrix
@@ -538,6 +365,7 @@ exercises this path.
538
365
  | `sensorLog` accepts `startTime` / `endTime` | `nativeVersion` ≥ 0.15.0 |
539
366
  | `deviceLightSensors` carries raw `light_sensor_*` channels, and the `actlumus2` variant | `nativeVersion` ≥ 0.16.0 |
540
367
  | 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 |
368
+ | `userDevice` always answers (re-sent after a cold start; `error` when the list could not load); `systemStatus.hasDevicesKnown`; payload limited to the contract fields | `nativeVersion` ≥ 0.18.0 |
541
369
  | `nativeVersion` reaches the client at all | `shellVersion` ≥ 0.6.0 |
542
370
 
543
371
  `CHANGELOG.md` is the precise record of when each of these landed.
@@ -571,7 +399,24 @@ you; the second is the one you receive. This is why `language` also exists as a
571
399
  pull command — a client that mounts late can ask.
572
400
 
573
401
  Render a skeleton synchronously and fill it as events arrive. Do not make first
574
- paint depend on any event.
402
+ paint depend on any event. **No order is guaranteed between events.** On the
403
+ phone the post-handshake `systemStatus` usually arrives before the answer to
404
+ your first `userDevice` (that answer is a round trip to the background
405
+ service), but nothing promises it, and `systemStatus` is re-emitted on every
406
+ change. Don't hold a request waiting for `systemStatus`: a `sensorLog` sent
407
+ offline fails at once with an error you already handle.
408
+
409
+ `deviceLightSensors` arrives at the device's pace — about once per second, set
410
+ by the firmware, not throttled by the app (an observation, not a guarantee).
411
+ Treat "no reading for a few seconds" as the device leaving its session, and
412
+ let `deviceConnection` confirm it.
413
+
414
+ **`data.time`** is when the **phone** received the reading: epoch milliseconds
415
+ (UTC) from the phone clock, stamped on arrival — not the device clock, and not
416
+ shifted by the timezone like `log_time`. Each reading is forwarded as it
417
+ arrives, with no queue. To join live readings to the history, compare in the
418
+ same frame: format `data.time` with local `Date` getters (as you build
419
+ `log_time` ranges), or parse `log_time` as local time.
575
420
 
576
421
  ### `deviceLightSensors` is a discriminated union
577
422
 
@@ -583,6 +428,13 @@ if (payload.type === 'actlumus2') {
583
428
  }
584
429
  ```
585
430
 
431
+ **Units.** `photopic` and `melanopic` are lux (photopic illuminance and
432
+ melanopic EDI) — the same as `lux_photopic` / `lux_melanopic` in the history.
433
+ The `wavelength*` and `light_sensor_*` channels have **no physical unit**: they
434
+ are relative values (arbitrary units). Use them for the shape of the spectrum
435
+ and for comparisons on the same device generation; do not label them W/m², µW/cm²
436
+ or lux, and do not compare an ActLumus with an ActLumus Plus channel by channel.
437
+
586
438
  A `null` in a raw channel means **that channel does not exist on this hardware
587
439
  generation** — never "the reading was zero". `0.0` is a valid reading. Do not
588
440
  collapse `null` to `0`: doing so fabricates data, and it has produced wrong
@@ -604,23 +456,144 @@ conclusions before.
604
456
  off. Render a blocking card; wire its button to
605
457
  `BridgeClient.requestPermission(name)` with a name taken from that array.
606
458
 
459
+ The names, for your labels (never send a name you typed — take it from the
460
+ arrays):
461
+
462
+ | Name | Platform | Essential? | What `requestPermission` does |
463
+ | --- | --- | --- | --- |
464
+ | `bluetoothScan`, `bluetoothConnect` | Android | yes | system prompt; app settings if permanently denied |
465
+ | `fineLocation` | Android ≤ 11 | yes | system prompt; app settings if permanently denied |
466
+ | `bluetooth` | iOS | yes | system prompt; app settings as fallback |
467
+ | `notification` | both | no | system prompt (Android) / iOS permission request |
468
+ | `batteryOptimization` | Android | no | battery-optimization exemption screen |
469
+ | `exactAlarm` | Android | no | exact-alarm settings, when battery optimization is not exempt |
470
+ | `backgroundLocation` | Android ≤ 11 | no | system prompt, else app settings |
471
+
472
+ An unknown name is dropped.
473
+
474
+ `deviceSyncErrorById` (and `deviceSyncError`) hold only the **last** error per
475
+ device, already in the app language (the app translates the `messageCode`; an
476
+ error with `messageCode: null` never lands here). The entry is removed when
477
+ newer telemetry of that device arrives — and a failed sync saves telemetry too
478
+ (with the error added to its `syncErrors`), so the entry can vanish seconds
479
+ after it appeared. Use it for a live banner, not as the record of the last
480
+ failure: that is the newest item of `devicesTelemetry[].syncErrors` (compare
481
+ its `attemptDate` with the telemetry's `updataDateTime` to know whether a
482
+ later sync succeeded). `deviceNotification` is the live feed of
483
+ a sync — see the next section.
484
+
485
+ ### Sync progress: `deviceNotification`
486
+
487
+ Every BLE session of a device is one sync, and the app reports it step by step
488
+ — dozens of events per session, for **every** linked device (no subscription
489
+ needed). Payload: `{ serialNo, deviceId?, isConnect, message, messageCode,
490
+ isError }`.
491
+
492
+ - **Branch on `messageCode`**, never on `message`. `message` is the app's log
493
+ line in English (`[Start] Get data log`, `[Error] Send data log: …`), not
494
+ localized and not stable; show your own text per code. Some progress events
495
+ have `messageCode: null` — ignore them.
496
+ - **`isConnect` is not the connection state** here: it is `!isError` on pipeline
497
+ events. Read connection from `deviceConnection`.
498
+ - `deviceId` only when the `serialNo` is shared, as in every device event.
499
+
500
+ A successful session, in order:
501
+
502
+ | `messageCode` | Step |
503
+ | --- | --- |
504
+ | `null`, `message: 'Device connected'` | the session starts |
505
+ | `enable_ble_periodic_notification`, then `…_not_needed_notification` if there was nothing to do | device setup |
506
+ | `set_ble_period_notification`, then `…_not_needed_notification` likewise | device setup |
507
+ | `set_sync_time_notification`, then `set_sync_time_not_needed_notification` likewise | the clock (a pending `syncTime` runs here) |
508
+ | `get_data_log_notification`, `send_data_log_notification`, `set_last_data_log_sent_to_server_notification` | reading and uploading the measurements |
509
+ | `get_system_log_notification`, `send_system_log_notification` | system log |
510
+ | `get_full_charge_log_notification`, `send_full_charge_log_notification` | charge log |
511
+ | `set_erase_memory_notification`, `erase_memory_notification` (or `escape_erase_memory_notification`: postponed) | a pending `deleteMemory`, after the upload |
512
+ | `get_device_telemetry_notification`, `save_device_telemetry_notification` | battery, memory, last sync |
513
+ | **`pipeline_done_notification`** | **the sync succeeded** — the only success marker |
514
+ | `null`, `message: 'Device disconnected'`, `isConnect: false` | the session ends |
515
+
516
+ Every stage emits its `…_notification` code when it **starts**, whether or not
517
+ it has work to do; it does not mean "done". A stage is done when a later stage
518
+ starts without its `…_error`. The three log stages run in parallel: their events
519
+ interleave in any order. Codes may repeat; treat every event as idempotent.
520
+
521
+ **When is a pending `syncTime` / `deleteMemory` done?** Count it done when the
522
+ sync reaches `pipeline_done_notification`. If the sync fails first, it may still
523
+ be pending — the app keeps a request it could not carry out (a clock it could
524
+ not set, an erase postponed by `escape_erase_memory_notification` or by a failed
525
+ upload) and runs it at the next session. Say "will happen at the next sync"
526
+ until then. With a `syncTime` pending, the clock stage never reports
527
+ `set_sync_time_not_needed_notification`: it sets the clock.
528
+
529
+ **History and live readings overlap in time, not in data.** Live readings are
530
+ not logs and never enter the history. While it streams, the device keeps
531
+ logging at its own period; those logs reach the history when a sync uploads
532
+ them (`send_data_log`). So the history ends at the last upload, live readings
533
+ cover the time after it, and the next sync fills that span with logs. Prefer
534
+ the history for any span it covers, and use live readings only after its last
535
+ `log_time`.
536
+
537
+ **A failed sync** has no "failed" event: the failing stage emits
538
+ `<stage>_error` (`isError: true`) and **`pipeline_done_notification` never
539
+ comes**. Every `*_error` ends the sync — `enable_ble_periodic_error`,
540
+ `set_ble_period_error`, `set_sync_time_error`, `get_data_log_error`,
541
+ `send_data_log_error`, `set_last_data_log_sent_to_server_error`,
542
+ `get_system_log_error`, `send_system_log_error`,
543
+ `set_last_system_log_sent_to_server_error`, `get_full_charge_log_error`,
544
+ `send_full_charge_log_error`, `set_erase_memory_error`,
545
+ `get_device_telemetry_error` — except two, which are `isError: true` while the
546
+ sync goes on: `config_write_not_persisted` (a setting did not stick; the sync
547
+ continues to `pipeline_done_notification`) and `save_device_telemetry_error`
548
+ (the final save failed; the outcome is already decided). A failure loading the
549
+ device list ends the sync with `messageCode: null`. After an ending error the
550
+ app mutes that device's **pipeline** events for about 3 s, so a second error
551
+ from a parallel log stage may never reach you; the first one is the cause.
552
+
553
+ **The session start and end are the two events with `messageCode: null` you
554
+ can rely on**, without reading `message`:
555
+ - **end:** `isConnect: false` **and** `isError: false` — no other event has
556
+ that pair (pipeline events have `isConnect === !isError`). It is not muted.
557
+ - **start:** the first event of the device after an end (or after your bundle
558
+ loaded). A `deviceConnection` with `isConnect: true` says the same if you
559
+ subscribed with `add`.
560
+
561
+ Rule for a progress UI: "syncing" from the start, the step from each code,
562
+ **success** on `pipeline_done_notification`, **failure** when the end arrives
563
+ after an ending `*_error` without `pipeline_done_notification`. Keep a time-out
564
+ of your own (a few minutes without events): if the disconnect itself fails, the
565
+ app sends no end event.
566
+
607
567
  **`hasDevices` is a hint.** It is `true` only after the app loaded a non-empty
608
568
  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.
569
+ could not ask" (no internet, session problem). From `nativeVersion` 0.18.0,
570
+ `hasDevicesKnown: true` says `hasDevices` is the server's answer, and `false`
571
+ says the lookup failed. Do not show "you have no device" on `hasDevices: false`
572
+ alone; see the next section.
611
573
 
612
574
  ### `userDevice` in detail
613
575
 
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:
576
+ **From `nativeVersion` 0.18.0 it always answers.** The app re-sends a request
577
+ lost while its background service starts, and a failed lookup arrives as the
578
+ three arrays empty plus `error`:
579
+
580
+ | `error` | Meaning |
581
+ | --- | --- |
582
+ | `NO_DEVICES` | the server says the account has no linked device |
583
+ | `NETWORK` | no answer from the server (offline, timeout) or a 5xx |
584
+ | `SESSION` | no session, or the server ended it |
585
+ | `UNKNOWN` | anything else; treat unknown values the same way |
586
+
587
+ **Below 0.18.0 it is not guaranteed to answer.** `userDevice` (and `ping`) are
588
+ handled by the background service, which starts after your bundle is running;
589
+ a request sent before it is up is lost. And a failed lookup — including an
590
+ account with no device — gets **no reply at all**. Use the helper, which covers
591
+ both versions:
620
592
 
621
593
  ```ts
622
594
  const r = await BridgeClient.requestUserDevices(); // getFromServer: false
623
- // { status: 'ok', devices } | { status: 'timeout', hasDevices: boolean | null }
595
+ // { status: 'ok', devices } | { status: 'no-devices' } | { status: 'error', code }
596
+ // | { status: 'timeout', hasDevices: boolean | null }
624
597
  ```
625
598
 
626
599
  **`getFromServer`.** `false` (default) answers from the app's memory or cache
@@ -634,7 +607,7 @@ the server first and falls back to the cache if that fails — use it for a
634
607
  | Array | Holds | Notable fields |
635
608
  | --- | --- | --- |
636
609
  | `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[]` |
610
+ | `devicesTelemetry` | last state read from each device | `deviceName`, `batteryLevel` (0–100 %), `memoryDataUsage` (0–100 %, the measurement memory — the one to show as "memory used"; `memorySystemUsage` is the device's internal log, diagnostics only), `updataDateTime` (epoch ms, last sync — the misspelling is the real name), `deviceVersion`, `isCharging`, `isOnDock`, `syncErrors[]` |
638
611
  | `devicesUserConfig` | per-device toggles | `syncTime`, `realTime`, `deleteMemory`, `readAllMemory`, `readAllSystemLog` |
639
612
 
640
613
  The name and battery of a device are in **telemetry**, not in the config. A
@@ -646,15 +619,82 @@ device that never synced may have no telemetry item, or one with every reading
646
619
  only when that `serialNo` appears once in `devicesConfig`.
647
620
 
648
621
  **Sync errors.** `devicesTelemetry[].syncErrors` is the per-device history
649
- (`attemptDate`, `message`, `messageCode`); `systemStatus.deviceSyncErrorById`
622
+ (`attemptDate`, `message`, `messageCode`). `messageCode` is the `…_error` code
623
+ of the stage that failed — the same vocabulary as `deviceNotification` (see
624
+ "Sync progress") — or `null`; `message` is the app's English log line, not for
625
+ display. Map the codes to your own words; `systemStatus.deviceSyncErrorById`
650
626
  is the current error per device, already localized. Show the current one; use
651
627
  the history for detail screens.
652
628
 
629
+ ### `language` — which language to render
630
+
631
+ The app tells you **two** languages, and the distinction between them is the
632
+ whole point of the channel:
633
+
634
+ ```typescript
635
+ BridgeClient.on(BRIDGE_KEYS.language, ({ deviceLocale, appLanguage, resolvedLanguage }) => {
636
+ // Your precedence rule. This one honours an explicit choice, else the device.
637
+ const lang = appLanguage ?? pickSupported(deviceLocale) ?? 'en';
638
+ renderIn(lang);
639
+ });
640
+ ```
641
+
642
+ - **`deviceLocale`** — the raw device locale as a BCP-47 tag (`"pt-BR"`,
643
+ `"es-419"`, `"ja-JP"`), exactly as the OS reports it, region included.
644
+ - **`appLanguage`** — the language the user explicitly picked in the app
645
+ Settings (`"en" | "pt" | "de" | "fr"`), or **`null`** when they never picked
646
+ one.
647
+ - **`resolvedLanguage`** — the language the native chrome (splash, Settings,
648
+ loading/error screens) is rendering right now.
649
+
650
+ **Why two raw values instead of one answer.** The app itself ships four
651
+ languages and collapses everything else to English. Your bundle has no such
652
+ limit — it may ship Spanish, Italian, Japanese. If we sent you only the resolved
653
+ language, a device set to Spanish would reach you as `en` and you would render
654
+ English while holding a Spanish bundle. The information would have been
655
+ destroyed on our side before reaching you. So we send both and let you decide:
656
+
657
+ | `deviceLocale` | `appLanguage` | a reasonable decision |
658
+ |---|---|---|
659
+ | `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 |
660
+ | `es-ES` | `"pt"` | render **Portuguese** — an explicit choice outranks the device |
661
+ | `pt-BR` | `null` | render Portuguese |
662
+ | `ja-JP` | `"de"` | render German (the explicit choice), or Japanese if you'd rather honour the device — your call, with both values in hand |
663
+
664
+ `appLanguage` is `null` when — and only when — the user never chose. It is never
665
+ a fallback in disguise, so `null` is a real signal you can branch on. Use
666
+ `resolvedLanguage` when you'd rather stay visually consistent with the native
667
+ frame around your page than follow the device.
668
+
669
+ **When it arrives — and how to ask for it.** The app pushes this key on attach,
670
+ again right after the handshake ack, and on every change in the app Settings, so
671
+ a page already loaded re-renders without a restart. But the first push happens
672
+ while the WebView is still being created, before your bundle exists, so **send
673
+ the `language` command once after your handshake** to pull the current state:
674
+
675
+ ```typescript
676
+ BridgeClient.send('language', {}); // replies with a `language` event
677
+ ```
678
+
679
+ Without that pull, a first launch would show your default language until the
680
+ user changed it in Settings. Subscribe first, then pull.
681
+
682
+ Do not read `navigator.language` instead: it follows the system, not the in-app
683
+ choice, and you would end up in Spanish while the native chrome around you is in
684
+ Portuguese.
685
+
686
+ **Older app versions.** There is no version negotiation. An app older than
687
+ native `0.14.0` never emits this key, so the event simply never arrives — keep a
688
+ default language for that case, and read `nativeVersion` from the handshake ack
689
+ if you need to branch on it explicitly.
690
+
653
691
  ## Commands
654
692
 
655
693
  `BridgeClient.send(command, args)` is **fire-and-forget**: no return value, no
656
694
  exception, no delivery confirmation. Replies arrive on the key in the last
657
- column.
695
+ column. Its arguments are typed per command (`CommandArgs`): a string
696
+ `serialNo`, a missing `value` or an unknown command is a compile error, not a
697
+ silent drop.
658
698
 
659
699
  | Command | Arguments | Confirmation | Replies on |
660
700
  | --- | --- | --- | --- |
@@ -663,7 +703,7 @@ column.
663
703
  | `realTime` | `{ serialNo: number, deviceId?: number, value: boolean }` | no | `realTime` |
664
704
  | `deviceConnection` | `{ type: 'add' \| 'remove', serialNo: number, deviceId?: number }` — subscribe to / stop following one device's connection state | no | `deviceConnection` |
665
705
  | `sensorLog` | see below | no | `sensorLog` |
666
- | `openSettings` | — | no | — |
706
+ | `openSettings` | — (opens the Canvas settings screen: language, notifications, Bluetooth, refresh device list, date format; not the OS settings, no logout) | no | — |
667
707
  | `requestPermission` | `{ permission: string }` | no | — (re-emits `systemStatus`) |
668
708
  | `setStatusBarColor` | `{ color: '#RRGGBB' }` | no | — |
669
709
  | `navigationState` | `{ canGoBack: boolean }` | no | — |
@@ -678,9 +718,46 @@ column.
678
718
  message in silence.
679
719
 
680
720
  `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.
721
+ BLE connection state of a device you already have from `userDevice`. `'remove'`
722
+ stops it. Send `add` for each device after the list arrives.
723
+
724
+ - **`add` does not report the current state.** The app reports **changes**: a
725
+ device already connected (or already disconnected) when you subscribe sends
726
+ nothing until its next BLE session starts or ends. Start every device as
727
+ "connection unknown" and switch on the first event. The one exception: a
728
+ device the app does not know answers `isConnect: false` at once.
729
+ - **"Disconnected" is the normal state.** An ActLumus is not connected all the
730
+ time: it opens a short BLE session to sync every few minutes
731
+ (`devicesConfig[].bleConnectionPeriod`, in minutes) and the app disconnects
732
+ it as soon as the sync ends, successful or not — unless `realTime` is on for
733
+ that device (below).
734
+ Show the last sync time (`devicesTelemetry[].updataDateTime`) rather than a
735
+ red "offline" badge.
736
+
737
+ ### What an ack means
738
+
739
+ `realTime`, `syncTime` and `deleteMemory` do not need the device to be
740
+ connected, and their ack does **not** mean "done":
741
+
742
+ | Command | `success: true` means | When it happens on the device |
743
+ | --- | --- | --- |
744
+ | `syncTime` | the request is recorded | at the next BLE session; the device clock is set to the phone's local time (its time zone included) |
745
+ | `deleteMemory` | the request is recorded | at the end of the next sync, **only after the data was uploaded**; if the upload fails, it stays pending and is retried. Unsent data is never erased. Not reversible once done. |
746
+ | `realTime` (`value: true`) | real time is switched on for that device | readings flow on `deviceLightSensors` while it is connected; it starts by itself at the next session if it is not connected now |
747
+
748
+ - `success: false` means the app could not record the request (for example,
749
+ a shared `serialNo` sent without `deviceId`), never "device not connected".
750
+ - **Do not disable these actions while the device is disconnected.** Say what
751
+ will happen instead: "The clock will be set at the next sync" / "The memory
752
+ will be erased after the next sync uploads its data".
753
+ - **`realTime` keeps the session open.** With it on, the app does not
754
+ disconnect the device after the sync: readings keep flowing until you switch
755
+ `realTime` off (then the session ends) or the BLE link drops. Readings stop
756
+ when the session ends and resume at its next session while `realTime` stays
757
+ on. There is no "last connection" field: the last sync
758
+ (`devicesTelemetry[].updataDateTime`) is the closest one. The app switches `realTime` off for every
759
+ device when it restarts — switch it on again from your screen when you need
760
+ it, and off when the person leaves that screen.
684
761
 
685
762
  ### Two devices with the same `serialNo`
686
763
 
@@ -706,7 +783,9 @@ the wrong device (erasing the wrong memory, for instance).
706
783
  unaddressable).
707
784
  - `sensorLog` takes no `deviceId` yet: for a shared `serialNo` the backend
708
785
  answers with the error code `DEVICE_SERIAL_AMBIGUOUS`. Show your own copy for
709
- it; do not retry.
786
+ it ("history is not available for these two devices yet"); do not retry, and
787
+ do not filter another query's result to work around it. Real time, commands
788
+ and the device list still work for both devices, by `deviceId`.
710
789
 
711
790
  ### Commands that need human confirmation
712
791
 
@@ -717,6 +796,13 @@ which reaches you only as a `console.warn`. **The ack simply never arrives.**
717
796
  Design for that: show an optimistic state only if you can undo it, and put a
718
797
  timeout on any spinner.
719
798
 
799
+ **Do not add your own confirmation dialog in front of these three.** The app's
800
+ dialog is the confirmation; a second one makes the person confirm twice. Explain
801
+ the consequence on your screen, next to the button, before it is pressed.
802
+
803
+ **`logout` has no reply.** Once confirmed, the app returns to its login screen
804
+ and your page is unloaded; nothing comes back. Show nothing that waits for it.
805
+
720
806
  ### Typed helpers
721
807
 
722
808
  Prefer these over raw `send` — they add correlation, timeouts and types:
@@ -735,7 +821,6 @@ BridgeClient.openSettings()
735
821
 
736
822
  ```ts
737
823
  const page = await BridgeClient.fetchSensorLog({
738
- requestId: crypto.randomUUID(),
739
824
  serialNo,
740
825
  bodyPart: 'wrist', // or 'necklace'; omit for no filter
741
826
  limit: 200, // 1..1000, backend default 10
@@ -745,7 +830,15 @@ const page = await BridgeClient.fetchSensorLog({
745
830
  ```
746
831
 
747
832
  It **resolves** with `{ success: false, error }` on failure and only **rejects**
748
- on timeout. Check `success` before reading `logs`.
833
+ on timeout (10 s by default; the app's own server timeout is 50 s, so raise
834
+ `timeoutMs` on slow networks rather than retrying at once). Check `success`
835
+ before reading `logs`. Leave `requestId` out: `fetchSensorLog` creates one
836
+ (`createRequestId()`, which does not need `crypto.randomUUID`).
837
+
838
+ There is no rate limit on the app side, but every page is a server request
839
+ under the person's single session: fetch pages one after another (they depend
840
+ on each other anyway), never several sweeps in parallel, and refresh on a
841
+ human scale — minutes, not seconds.
749
842
 
750
843
  Two things that have caused real bugs:
751
844
 
@@ -753,6 +846,11 @@ Two things that have caused real bugs:
753
846
  device RTC. Build your range with local `Date` getters — `getFullYear()`,
754
847
  `getHours()` — and **never** with `toISOString()`. Converting shifts every
755
848
  series by the timezone offset, producing plausible data and no error at all.
849
+ A "day" is the date part of `log_time` (`log_time.slice(0, 10)`): the
850
+ device's local day, on the clock `syncTime` set to the phone's local time. The
851
+ log carries no timezone, so after the participant changes timezone the logs
852
+ stay on the old clock until the next `syncTime` — say so if your report is per
853
+ day.
756
854
 
757
855
  **Cursors are opaque.** `startingAfter` and `endingBefore` are mutually
758
856
  exclusive; echo back exactly what you received and never construct one.
@@ -765,7 +863,7 @@ const logs: SensorLogItem[] = [];
765
863
  let startingAfter: string | undefined;
766
864
  for (;;) {
767
865
  const page = await BridgeClient.fetchSensorLog({
768
- requestId: crypto.randomUUID(), serialNo, limit: 1000,
866
+ serialNo, limit: 1000,
769
867
  startTime: '2026-10-13 00:00:00', endTime: '2026-10-20 00:00:00', // local time
770
868
  ...(startingAfter ? { startingAfter } : {}),
771
869
  });
@@ -797,9 +895,25 @@ Pages depend on the previous cursor, so they cannot be fetched in parallel.
797
895
  **Fields.** Each log is a `SensorLogItem`: `log_time`, `body_part`,
798
896
  `temperature` (°C), `lux_photopic` and `lux_melanopic` (lux), the raw light
799
897
  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
898
+ not have it, never zero. `device_id` is the server device id — the same value as
899
+ `devicesConfig[].deviceId` — so you can check that a log belongs to the device
900
+ you asked for. `device_log_time` and `app_log_time` mark the upload
801
901
  batch, not the log — do not plot them.
802
902
 
903
+ **Was the device worn?** Read `off_wrist`, and mind its name: it is the
904
+ device's raw wear flag, **`1` = worn, `0` = not worn** (on its dock); treat any
905
+ other value as not worn and `null` as unknown. `body_part` (`'wrist'` or
906
+ `'necklace'`) is where the device was *set* to be worn when the log was uploaded
907
+ — a setting, not a detection; it can differ from today's `deviceBodyPart`.
908
+
909
+ **Time worn per day** (one reasonable recipe, not an official metric): count the
910
+ logs of that local day with `off_wrist === 1` and multiply by the log period
911
+ derived from consecutive `log_time` values. A missing log is time not recorded,
912
+ not time worn. Ask for the day with `startTime` = local midnight and `endTime`
913
+ = the next local midnight: `startTime` is inclusive and `endTime` exclusive.
914
+ At the default 60 s, one day is about 1 440 logs — two pages of 1 000 — and 14
915
+ days about 20 pages per device, so compute once and refresh on a human scale.
916
+
803
917
  ## Error shapes
804
918
 
805
919
  There are three, and none of them is a thrown exception.
@@ -863,10 +977,15 @@ session key. Do not try to detect it; the app handles re-authentication.
863
977
  ## "The device list never came" (stuck on loading)
864
978
 
865
979
  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
980
 
869
- ### Cause 1: the request was sent before the app's background service was up
981
+ **From `nativeVersion` 0.18.0 the app fixes both causes below itself**: it
982
+ re-sends a request lost while its background service starts, and it answers a
983
+ failed lookup with empty arrays and `error` (`NO_DEVICES`, `NETWORK`, `SESSION`,
984
+ `UNKNOWN`). `requestUserDevices()` turns those into `no-devices` and `error`.
985
+ Below 0.18.0 the two causes produce exactly the same silence, and the bundle
986
+ cannot tell them apart.
987
+
988
+ ### Cause 1 (below 0.18.0): the request was sent before the app's background service was up
870
989
 
871
990
  `userDevice` and `ping` are not answered by the page that hosts you: the app
872
991
  forwards them to a background service that reads the devices. On a cold start
@@ -875,12 +994,12 @@ request sent at boot is often **lost without a reply**. Nothing re-sends the
875
994
  list when the service comes up. Meanwhile the handshake, `systemStatus` and
876
995
  `language` work from the start, which makes the bridge look ready.
877
996
 
878
- ### Cause 2: the account has no linked device
997
+ ### Cause 2 (below 0.18.0): the account has no linked device
879
998
 
880
999
  The app replies to `userDevice` only when the list loads. When the server
881
1000
  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.
1001
+ reason (no internet, expired session) — nothing is sent to the bundle. An app
1002
+ below 0.18.0 never sends a `userDevice` with empty arrays.
884
1003
 
885
1004
  ### What to do
886
1005
 
@@ -889,16 +1008,19 @@ Use the helper, which handles both:
889
1008
  ```ts
890
1009
  const result = await BridgeClient.requestUserDevices(); // resends with backoff for 30 s
891
1010
  if (result.status === 'ok') render(result.devices);
892
- else renderEmpty({ probablyNoDevices: result.hasDevices === false }); // + a "Try again" button
1011
+ else if (result.status === 'no-devices') renderEmpty('none linked'); // confirmed (0.18.0+)
1012
+ else renderEmpty('could not load'); // error (0.18.0+) or timeout — always with "Try again"
893
1013
  ```
894
1014
 
895
1015
  - It resends at 1.5 s, 3 s, 6 s and then every 10 s, and on every
896
1016
  `systemStatus` while it waits. `BridgeClient.waitForService()` does the same
897
1017
  with `ping` if you only need to know the service is up.
898
1018
  - **`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`.
1019
+ after the app loaded a non-empty list from the server. From 0.18.0,
1020
+ `systemStatus.hasDevicesKnown` says whether it is the server's answer; below
1021
+ it, `false` means either "no device" or "the app could not ask". Word the
1022
+ empty state so it is true in both cases, and always offer a retry with
1023
+ `getFromServer: true`.
902
1024
  - Keep the `userDevice` subscription for the life of the page: roster changes
903
1025
  arrive on it without a request.
904
1026
 
@@ -1024,7 +1146,7 @@ First question: **which browser setup?**
1024
1146
  run every scenario, not just `default`. `cold-start` and `no-devices` are the
1025
1147
  ones that catch real bugs.
1026
1148
 
1027
- In the harness `nativeVersion` is `0.17.0` by default. Run it with
1149
+ In the harness `nativeVersion` is `0.18.0` by default. Run it with
1028
1150
  `--native-version null` to reproduce a shell that could not reach the native
1029
1151
  side; keep a branch that renders in that case too.
1030
1152
 
@@ -1152,6 +1274,26 @@ You need a **manager** user of the company. The command needs a terminal (it
1152
1274
  prompts), so it does not run in CI; and an AI agent must not run it on its own
1153
1275
  — it publishes to real people and asks for your password.
1154
1276
 
1277
+ ## Loading the bundle from your machine (development builds)
1278
+
1279
+ From `nativeVersion` 0.18.0 a **development** build of the app can skip the
1280
+ published bundle and load yours straight from your computer, on every launch
1281
+ and every return to the foreground — no publish, no other user affected.
1282
+
1283
+ The address is fixed when Condor builds the app, so use one that is the same on
1284
+ every machine: the harness on `localhost`, forwarded over USB (Android).
1285
+
1286
+ 1. Ask Condor for a development build made with
1287
+ `--dart-define=IS_DEV=true --dart-define=WL_BUNDLE_DEV_URL=http://localhost:5173/__condor/client.html`.
1288
+ 2. Serve your bundle with the harness — it inlines it like `build` does:
1289
+ `npx condor-mobile-bridge dev -i dist/index.html`.
1290
+ 3. Forward the port to the phone: `adb reverse tcp:5173 tcp:5173`.
1291
+ 4. Rebuild your bundle, bring the app to the foreground: the new bundle loads.
1292
+
1293
+ The app still applies the 2 MB and self-contained checks; it never caches this
1294
+ bundle, so a normal build shows the published one again. Store builds refuse
1295
+ the flag.
1296
+
1155
1297
  ## Seeing what the bundle does on the phone
1156
1298
 
1157
1299
  **Release builds of the app cannot be inspected.** Ask Condor for a
@@ -1174,6 +1316,220 @@ by the app (wrong argument type, unknown command, a request lost while the
1174
1316
  background service starts) leaves no trace in the console — reproduce it in the
1175
1317
  harness instead, whose console says what was dropped and why.
1176
1318
 
1319
+ ## What `build` does
1320
+
1321
+ Generates the **self-contained `.zip` bundle** that the white-label app
1322
+ consumes. The app injects the client HTML via `iframe.srcdoc` (origin `null`,
1323
+ no base URL) → **relative references do not resolve** and the CSP blocks
1324
+ external fetches; therefore the `.zip` must contain **a single `index.html`**
1325
+ with inline JS/CSS and media as `data:` URIs.
1326
+
1327
+ The CLI does this from your build's `dist/`: it reads `index.html`, **inlines**
1328
+ `<script src>` (escaping `</script>`) and `<link rel=stylesheet>`, resolves CSS
1329
+ `url()` and local `<img>`/icons to `data:` URIs, and packs a `.zip` with
1330
+ `index.html` at the root. External refs (`https://…`) are **preserved** (and
1331
+ blocked by the app's CSP at runtime).
1332
+
1333
+ ```bash
1334
+ npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
1335
+ ```
1336
+
1337
+ | Option | Description |
1338
+ |---|---|
1339
+ | `-i, --html <path>` | Input HTML (e.g. `dist/index.html`). Required. |
1340
+ | `-o, --out <path>` | Output `.zip` (default: `dist/whitelabel-bundle.zip`). |
1341
+ | `--max-bytes <n>` | **Warning** threshold in bytes (default: `2097152` = 2 MB). |
1342
+
1343
+ Output (for registering the bundle with the backend — `bundleSize`/`bundleHash`):
1344
+
1345
+ ```
1346
+ Built dist/bundle.zip
1347
+ entry: index.html (123.4 KB self-contained)
1348
+ bundleSize (zip): 45678 bytes
1349
+ bundleHash (zip): sha256-<hex>
1350
+ ```
1351
+
1352
+ The hash is **deterministic** (fixed mtime on the zip entry) — the same input
1353
+ produces the same `bundleHash`. Above the threshold, the CLI **warns** (does
1354
+ not block): remember that **the app rejects** a `.zip` larger than 2 MB and
1355
+ falls back to the mock.
1356
+
1357
+ > ⚠️ **Constraint: single-chunk build.** The inliner is post-build and assumes
1358
+ > a `dist` with **one JS + one CSS + assets**. Builds with **code-splitting /
1359
+ > ES modules** that `import` across chunks **break** in `srcdoc` (the imports
1360
+ > do not resolve). Configure your build to emit a single file:
1361
+ > - **Vite:** `vite-plugin-singlefile` (or `build.rollupOptions.output.inlineDynamicImports: true` + `manualChunks: undefined`).
1362
+ > - **CRA:** build without code-splitting (no `React.lazy`/dynamic `import()` in routes), single bundle.
1363
+ >
1364
+ > Inlining inflates `data:` media by ~33% (base64) — size against the 2 MB limit.
1365
+
1366
+ ## Scripted tests
1367
+
1368
+ `npx condor-mobile-bridge dev` is also a test target. On the harness page,
1369
+ `window.condorDev` drives the simulated app; your bundle runs in the iframe
1370
+ under it, as on the phone.
1371
+
1372
+ | `window.condorDev.…` | Does |
1373
+ | --- | --- |
1374
+ | `ready` | `true` once the bundle is mounted **and** its handshake was answered — wait for it |
1375
+ | `scenario` | the scenario in use, after every override |
1376
+ | `log` | everything sent, answered and dropped: `{ at, kind, command?, key?, detail, args?, payload? }[]`. `detail` is a shortened line; `args` (sent) and `payload` (reply) are complete |
1377
+ | `setStatus({ internet: false })` | changes `systemStatus` and emits it, as the app does; without internet `sensorLog` fails, and it recovers with `{ internet: true }` |
1378
+ | `setConnected(deviceId, true \| false)` | starts or ends a device's BLE session: `deviceConnection` (if you sent `add`), the `deviceLightSensors` stream (if `realTime` is on) and the sync's `deviceNotification` steps, 200 ms apart, ending in `pipeline_done_notification`. As on the phone, the session then **ends by itself** — unless `realTime` is on for that device, which keeps it open until `realTime` goes off |
1379
+ | `emit(key, payload)` | any native event, typed as in `docs/bridge-contract.md` — e.g. `emit('language', { deviceLocale: 'pt-BR', appLanguage: 'pt', resolvedLanguage: 'pt' })` |
1380
+ | `setConfirmations('accept' \| 'decline' \| 'ask')` | answers the app's confirmation dialog for you |
1381
+ | `pressBack()` | the OS back button |
1382
+
1383
+ URL parameters set the same things before the bundle loads:
1384
+ `?scenario=offline`, `?nativeVersion=0.17.0`, `?confirm=accept`,
1385
+ `?latency=0`, `?panel=0`. A `?scenario=` in the URL **replaces** a
1386
+ `--scenario file.json` given to the CLI (the file is not loaded at all), so
1387
+ with a file leave `scenario` out of the URL. The CLI equivalents are `--latency <ms>` (`0` makes
1388
+ a test suite several times faster) and `--no-panel` (the floating MOCK panel
1389
+ covers the bottom of your page and can swallow clicks).
1390
+
1391
+ **Your own scenario.** `--scenario edge.json` takes a file that extends a named
1392
+ scenario; every field is optional:
1393
+
1394
+ ```json
1395
+ {
1396
+ "extends": "default",
1397
+ "nativeVersion": "0.17.0",
1398
+ "latencyMs": 0,
1399
+ "serviceStartMs": 4000,
1400
+ "logPeriodS": 300,
1401
+ "firstSessionAfterMs": null,
1402
+ "sessionFailAt": "get_data_log",
1403
+ "history": { "days": 14, "dockHours": [7], "gapHours": [2, 3], "gapDates": ["2026-10-18"] },
1404
+ "sensorLogFailOnRequest": 3,
1405
+ "userDeviceError": "NETWORK",
1406
+ "sensorLogError": "INVALID_TIME_RANGE",
1407
+ "systemStatus": { "bluetooth": false, "missingEssentialPermissions": ["bluetoothScan"] },
1408
+ "devices": { "lastUpdate": 0, "devicesConfig": [], "devicesTelemetry": [], "devicesUserConfig": [] }
1409
+ }
1410
+ ```
1411
+
1412
+ `devices` is the `userDevice` payload to answer (`UserDevicePayload`).
1413
+ `firstSessionAfterMs` is when the first device starts a BLE session: `3000` in
1414
+ `default`, absent in every other scenario; `null` turns it off.
1415
+ `systemStatus` is merged into the base scenario's. `sessionFailAt` makes every
1416
+ simulated sync fail at that stage (`<stage>_error`, no
1417
+ `pipeline_done_notification`); without internet syncs fail at
1418
+ `send_data_log` anyway. A pending `syncTime`/`deleteMemory` shows up as its step
1419
+ in the next session. A failed sync also puts an English text in
1420
+ `systemStatus.deviceSyncErrorById` (and `deviceSyncError`) and emits it; the
1421
+ next successful sync of that device clears it.
1422
+
1423
+ `history` shapes the simulated `sensorLog` (`HistoryOptions`): `days` before
1424
+ now, `dockHours` (local hours with `off_wrist: 0` and no light), `gapHours`
1425
+ (local hours with no log, every day) and `gapDates` (whole local dates with no
1426
+ log). `sensorLogFailOnRequest: N` makes the Nth `sensorLog` request fail once
1427
+ with `Connection failed` — a failure in the middle of a sweep.
1428
+
1429
+ **The data the harness serves.** Two devices (`twoDevices()`: ActLumus 4534 /
1430
+ id 9001 set to the wrist, ActLumus Plus 5120 / id 9002 set to the necklace).
1431
+ Their `sensorLog`: `history.days` (14 by default; set it to 30 for a 30-day
1432
+ report) × 24 h back from **now** (not from midnight), one
1433
+ log per `logPeriodS` (60 s by default); `body_part` is the device's
1434
+ `deviceBodyPart`; worn (`off_wrist: 1`) all day except the dock hour 07:00–08:00
1435
+ local (`off_wrist: 0`); light follows a daylight curve on the browser's local
1436
+ clock — deterministic for a given timestamp, so a test can recompute what a
1437
+ chart should show. The `bodyPart` filter of a query is not simulated.
1438
+ `deviceLightSensors` arrives once per second.
1439
+
1440
+ **Other harness behaviour worth knowing in a test:**
1441
+ - `language` is answered from the **browser's** `navigator.language`, with
1442
+ `appLanguage: null`. Fix it in a test with
1443
+ `condorDev.emit('language', …)` before asserting on text.
1444
+ - A declined confirmation (`?confirm=decline`) produces nothing: no ack, no
1445
+ event — as on the phone.
1446
+ - With `nativeVersion` null the handshake resolves only after the shell's 2 s
1447
+ relay timeout, so such a test starts 2 s later.
1448
+ - The harness has no fake clock: a time-out of yours (45 s for a declined
1449
+ confirmation, minutes for a lost sync) belongs in a unit test with your test
1450
+ runner's fake timers, not in a browser test.
1451
+ - Event order is not the phone's: no order between events is guaranteed there
1452
+ either (`docs/bridge-contract.md`, "Emission cadence"), so don't let a test
1453
+ depend on it.
1454
+ - `condorDev.log` keeps the **last 500** entries: read it as you go in a long
1455
+ test. The
1456
+ fixtures are exported from `@condorinst/mobile-bridge/dev` (`twoDevices`,
1457
+ `sharedSerial`, `systemStatus`, `sensorLogPage`, `lightSensorsReading`,
1458
+ `formatLocalTimestamp`) for unit tests without a browser. Pass `sensorLogPage`
1459
+ the device's `deviceId` (`{ deviceId }`); without it `device_id` is `0`.
1460
+
1461
+ **Like the phone, not like `localhost`.** The harness removes
1462
+ `crypto.randomUUID` from your iframe: on `localhost` it would exist, and on the
1463
+ phone it probably does not.
1464
+
1465
+ A test with `puppeteer-core` and the Chrome already installed (`puppeteer`
1466
+ works the same and downloads its own browser):
1467
+
1468
+ ```js
1469
+ import puppeteer from 'puppeteer-core';
1470
+ import { spawn } from 'node:child_process';
1471
+
1472
+ const port = 5180;
1473
+ const cli = 'node_modules/@condorinst/mobile-bridge/dist/cli.js';
1474
+ const dev = spawn('node', [cli, 'dev', '-i', 'dist/index.html',
1475
+ '--port', String(port), '--latency', '0', '--no-panel'], { stdio: 'inherit' });
1476
+ for (let i = 0; i < 50; i++) { // wait for the port, not a fixed sleep
1477
+ try { await fetch(`http://localhost:${port}/`); break; } catch { await new Promise((r) => setTimeout(r, 100)); }
1478
+ }
1479
+
1480
+ const browser = await puppeteer.launch({
1481
+ executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', // your Chrome
1482
+ });
1483
+ const page = await browser.newPage();
1484
+ await page.emulateTimezone('Pacific/Kiritimati'); // log_time is local: test far from UTC
1485
+ await page.goto(`http://localhost:${port}/?confirm=accept`); // the CLI's --scenario applies
1486
+ await page.waitForFunction(() => window.condorDev?.ready);
1487
+
1488
+ const bundle = page.frames().find((f) => f !== page.mainFrame()); // your page
1489
+ await bundle.waitForSelector('[data-device-id]'); // assert on your markup, not on text
1490
+
1491
+ await page.evaluate(() => window.condorDev.setStatus({ internet: false }));
1492
+ await bundle.waitForSelector('[data-offline]');
1493
+
1494
+ const queries = await page.evaluate(() =>
1495
+ window.condorDev.log.filter((e) => e.kind === 'sent' && e.command === 'sensorLog').map((e) => e.args));
1496
+
1497
+ await browser.close();
1498
+ dev.kill(); // node, not npx: no orphan process
1499
+ ```
1500
+
1501
+ The harness does nothing on its own after load, except one session (the first
1502
+ device of the `default` scenario connects after 3 s, syncs, and disconnects
1503
+ about 4 s later unless `realTime` is on). Internet loss, a
1504
+ device disconnecting, or a reading arriving are things your test does through
1505
+ `window.condorDev`.
1506
+
1507
+ ### What `validate` checks
1508
+
1509
+ Errors make the app reject the bundle; warnings are failures with `--strict`.
1510
+ The warnings are **textual**: they match strings and comments too, so do not
1511
+ leave these names in your code even unused.
1512
+
1513
+ | Rule | Severity | Matches |
1514
+ | --- | --- | --- |
1515
+ | `not-self-contained` | error | `<script src>` or `<link rel="stylesheet">` that is not inline |
1516
+ | `too-large` | error | a `.zip` over 2 MB |
1517
+ | `unreadable-zip` | error | a file that is not a valid `.zip` |
1518
+ | `entry-missing` | error | no HTML entry in the `.zip` |
1519
+ | `entry-fallback` | warning | no `index.html` at the root of the `.zip` |
1520
+ | `dev-harness-bundled` | error | the dev harness compiled into the bundle |
1521
+ | `relative-asset` | warning | a relative `<img src>` (use a `data:` URI) |
1522
+ | `web-storage` | warning | `localStorage`, `sessionStorage` |
1523
+ | `indexed-db` | warning | `indexedDB` |
1524
+ | `cookies` | warning | `document.cookie` |
1525
+ | `dynamic-eval` | warning | `eval(`, `new Function(` |
1526
+ | `service-worker` | warning | `serviceWorker` |
1527
+ | `send-beacon` | warning | `sendBeacon` |
1528
+ | `popups` | warning | `window.open(` |
1529
+ | `crypto-randomuuid` | warning | `crypto.randomUUID` |
1530
+ | `top-navigation` | warning | `window.top` |
1531
+ | `external-network` | warning | `fetch(`, `XMLHttpRequest`, `WebSocket(` |
1532
+
1177
1533
  ## Errors after the fact
1178
1534
 
1179
1535
  `BridgeClient.send('jsError', { message, stack })` sends an error to Condor's
@@ -1314,532 +1670,55 @@ layout grow. A two-slot UI with the slots hard-coded is the failure this section
1314
1670
  exists to prevent: it will look correct for as long as the rule holds, and break
1315
1671
  silently on the day it does not.
1316
1672
 
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
1673
  <!-- ===== docs/pilot-onboarding.md ===== -->
1721
1674
 
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.
1675
+ # Canvas — pilot onboarding
1727
1676
 
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.
1677
+ The steps from zero to your Home live for your company, and who does what. The
1678
+ how of each step lives in one place, linked from it.
1735
1679
 
1736
1680
  ## Who does what
1737
1681
 
1738
- | Condor provides | You build |
1682
+ | Condor provides | You build and run |
1739
1683
  |---|---|
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
1684
+ | The SDK (`@condorinst/mobile-bridge`), its documentation and example | Your Home: one self-contained web page |
1685
+ | The Canvas app that shows your page, and the device bridge | The `.zip` bundle, built and checked with the SDK CLI |
1686
+ | A **test company** in production, and a manager user for it | Publishing to the test company, then to your real one |
1687
+
1688
+ You need Node.js 18+. The package is public on npm: no credentials.
1689
+
1690
+ ## Steps
1691
+
1692
+ 1. **Start from the example and the procedure.** Follow
1693
+ [`AGENTS.md`](../AGENTS.md), "The golden path": copy
1694
+ `examples/minimal-home`, install, build.
1695
+ 2. **Build your Home in the harness.**
1696
+ `npx condor-mobile-bridge dev -i dist/index.html` runs your page as the app
1697
+ does. It must work in every scenario — `AGENTS.md`, "Develop in the harness".
1698
+ Remember that your page is the whole screen: navigation, logout and the way
1699
+ to the app settings are yours (`AGENTS.md`).
1700
+ 3. **Build and validate.** `build` writes the `.zip` and prints its size and
1701
+ hash; `validate --strict` must exit 0 — `AGENTS.md`, "The validation loop".
1702
+ 4. **Publish to your test company.**
1703
+ `npx condor-mobile-bridge canvas publish dist/index.html --version 1.0.0`
1704
+ with the manager user of the **test** company. A publish reaches every user
1705
+ of that company at once — [`docs/device-testing.md`](./device-testing.md),
1706
+ "Publishing from the command line".
1707
+ 5. **Check it on a phone** logged in with a test user —
1708
+ [`docs/device-testing.md`](./device-testing.md).
1709
+ 6. **Publish to your company** with a new version number, the same way.
1710
+
1711
+ ## Checklist
1712
+
1713
+ - [ ] The example runs in the harness, in every scenario
1714
+ - [ ] Your Home works in every scenario, with `--native-version null` too
1715
+ - [ ] Back navigation, logout and the way to the app settings are in your screens
1716
+ - [ ] `validate --strict` exits 0 on the `.zip`
1717
+ - [ ] Published to the test company and checked on a phone
1718
+ - [ ] Published to your company
1841
1719
  - [ ] Feedback shared with Condor
1842
- </content>
1720
+
1721
+ When something does not work: [`docs/troubleshooting.md`](./troubleshooting.md).
1843
1722
 
1844
1723
  <!-- ===== examples/minimal-home/src/main.ts ===== -->
1845
1724
 
@@ -1878,7 +1757,7 @@ interface DeviceRow {
1878
1757
  realTimeOn: boolean;
1879
1758
  }
1880
1759
 
1881
- type Phase = 'loading' | 'ready' | 'empty' | 'outside-shell';
1760
+ type Phase = 'loading' | 'ready' | 'empty' | 'error' | 'outside-shell';
1882
1761
 
1883
1762
  const state = {
1884
1763
  phase: 'loading' as Phase,
@@ -1920,6 +1799,19 @@ function telemetryFor(
1920
1799
  }
1921
1800
 
1922
1801
  function applyDevices(payload: UserDevicePayload): void {
1802
+ // From nativeVersion 0.18.0 a failed lookup also arrives here, with `error`
1803
+ // and empty arrays. It is not an empty roster: keep what is on screen.
1804
+ if (payload.error !== undefined) {
1805
+ if (payload.error === 'NO_DEVICES') {
1806
+ state.devices = new Map();
1807
+ state.probablyNoDevices = true;
1808
+ state.phase = 'empty';
1809
+ } else if (state.devices.size === 0) {
1810
+ state.phase = 'error';
1811
+ }
1812
+ render();
1813
+ return;
1814
+ }
1923
1815
  const next = new Map<number, DeviceRow>();
1924
1816
  for (const cfg of payload.devicesConfig) {
1925
1817
  const telemetry = telemetryFor(cfg, payload);
@@ -1986,11 +1878,14 @@ function body(): string {
1986
1878
  case 'loading':
1987
1879
  return '<p class="empty">Loading your devices…</p>';
1988
1880
  case 'empty':
1881
+ // probablyNoDevices: confirmed (0.18.0+) or suggested by an older app.
1989
1882
  return `<p class="empty">${
1990
1883
  state.probablyNoDevices
1991
1884
  ? 'No device is linked to your account yet.'
1992
1885
  : 'Could not load your devices.'
1993
1886
  }</p><p class="empty"><button onclick="__home.retry()">Try again</button></p>`;
1887
+ case 'error':
1888
+ return '<p class="empty">Could not load your devices.</p><p class="empty"><button onclick="__home.retry()">Try again</button></p>';
1994
1889
  case 'ready':
1995
1890
  return [...state.devices.values()].map(renderCard).join('');
1996
1891
  }
@@ -2038,9 +1933,13 @@ BridgeClient.on(BRIDGE_KEYS.realTime, (payload) => {
2038
1933
 
2039
1934
  async function loadDevices(getFromServer: boolean): Promise<void> {
2040
1935
  const result = await BridgeClient.requestUserDevices({ getFromServer });
2041
- if (result.status === 'ok') return; // applyDevices already rendered it
1936
+ // ok, no-devices and error also reached applyDevices through the
1937
+ // subscription, which already rendered them.
1938
+ if (result.status !== 'timeout') return;
1939
+ // An older app gives only a hint; with devices on screen, keep them.
1940
+ if (state.devices.size > 0) return;
2042
1941
  state.probablyNoDevices = result.hasDevices === false;
2043
- state.phase = 'empty';
1942
+ state.phase = state.probablyNoDevices ? 'empty' : 'error';
2044
1943
  render();
2045
1944
  }
2046
1945