@condorinst/mobile-bridge 0.21.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.21.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.21.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,20 @@ 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
+
262
+ ## Publishing — ask the human, never run it yourself
263
+
264
+ `npx condor-mobile-bridge canvas publish` puts the bundle live at once for every
265
+ user of a company, and asks for the person's password. **Do not run it.** When
266
+ the bundle is ready, tell the human the exact command — for example
267
+ `npx condor-mobile-bridge canvas publish dist/index.html --version 1.0.1` — and
268
+ recommend publishing to their **test company** first. See
269
+ `docs/device-testing.md`.
270
+
158
271
  ## The validation loop — run this before you call the work done
159
272
 
160
273
  ```bash
@@ -178,293 +291,16 @@ check, so a clean run is necessary, not sufficient.
178
291
  - [ ] Every capability beyond the baseline is guarded by a `nativeVersion` check
179
292
  (`versionAtLeast`), and the page still renders with
180
293
  `--native-version null`.
181
- - [ ] Nothing waits forever on a reply that may never come.
182
-
183
- To put the bundle on a phone, read `docs/device-testing.md`.
184
-
185
- ## Where to read more
186
-
187
- | Question | File |
188
- | --- | --- |
189
- | Every key, command, error shape and capability | `docs/bridge-contract.md` |
190
- | The same, as JSON you can parse | `contract/bridge-contract.json` |
191
- | Step-by-step build of a Home | `docs/agent-guide.md` |
192
- | "My command did nothing" / "the device list never came" | `docs/troubleshooting.md` |
193
- | Getting the bundle onto a device, and seeing its errors | `docs/device-testing.md` |
194
- | Every hard limit, with the code that enforces it | `docs/limits.md` |
195
- | Payload shapes, authoritative | `dist/index.d.ts` (your editor shows them) |
196
- | A working client you can copy | `examples/minimal-home/` |
197
- | All of the above in one file | `llms-full.txt` |
198
-
199
- <!-- ===== docs/agent-guide.md ===== -->
200
-
201
- # Agent guide — building a Canvas client
202
-
203
- A working procedure for an AI agent (or a developer) building the client bundle
204
- that renders as the Home of a Condor Canvas app.
205
-
206
- Read `AGENTS.md` first — it is the contract in one screen. This document is the
207
- procedure: what to do, in what order, and what to check at each stage.
208
-
209
- ## What you are building
210
-
211
- One `.zip` containing one self-contained `index.html`. Condor's app downloads
212
- it, verifies its SHA-256, and renders it inside a sandboxed iframe as the Home
213
- screen. Login, settings, Bluetooth and background collection are the app's job.
214
- Yours is what the person sees when the app opens.
215
-
216
- You are not building a website. You have no storage, no network, and no origin.
217
- Everything you display comes through the bridge, and everything you hold lives
218
- in memory until the next reload.
219
-
220
- ## Stage 0 — set up so you can see what you are doing
221
-
222
- Start from `examples/minimal-home/` — a complete, working client with the
223
- reference boot sequence, and a build script that produces a single-file bundle.
224
- Copy it first, then install, so its `package.json` is the one you keep:
225
-
226
- ```bash
227
- mkdir my-home && cd my-home
228
- npm pack @condorinst/mobile-bridge@^0.21.0 && tar -xzf condorinst-mobile-bridge-*.tgz
229
- cp -r package/examples/minimal-home/. . && rm -rf package condorinst-mobile-bridge-*.tgz
230
- npm install
231
- ```
232
-
233
- Or, in a project you already have: `npm install @condorinst/mobile-bridge@^0.21.0`
234
- and copy only `src/main.ts`, `build.mjs` and `index.html` from
235
- `node_modules/@condorinst/mobile-bridge/examples/minimal-home/`.
236
-
237
- Pin the SDK to the minor you are building against. Below `1.0.0`, a minor can
238
- change the contract — `^0.21.0` gives you patches only, which is what you want.
239
-
240
- **Run it in the harness**, not in a bare browser tab:
241
-
242
- ```bash
243
- npm run build
244
- npx condor-mobile-bridge dev -i dist/index.html --scenario default
245
- ```
246
-
247
- The harness serves your bundle inside the same sandboxed iframe, shell, policy
248
- and CSP as the app, with a simulated native side behind it, and a dev panel to
249
- emit events by hand. It re-reads the file on every reload. Opening
250
- `dist/index.html` directly does **not** work: there is no shell, so
251
- `BridgeClient.handshake()` rejects at once.
252
-
253
- The scenarios reproduce what the device does to you — switch with the selector
254
- at the top or `--scenario`:
255
-
256
- | Scenario | Your bundle must… |
257
- | --- | --- |
258
- | `default` | render two devices |
259
- | `cold-start` | leave the loading screen although the first `userDevice` requests are lost |
260
- | `no-devices` | show an empty state when `userDevice` never answers |
261
- | `shared-serial` | keep two devices with the same `serialNo` apart |
262
- | `offline` | explain that history is unavailable without internet |
263
-
264
- Add `--native-version null` (or an older version such as `0.15.0`) to test
265
- your capability branches, and use the **◀ OS back** button at the top to press
266
- the OS back button.
267
-
268
- If you already have a dev server (Vite), mount the same harness from your own
269
- page with `createDevHarness()` from `@condorinst/mobile-bridge/dev` — in the
270
- host page only, never in the bundle (`validate` rejects that).
271
-
272
- ## Stage 1 — render before you connect
273
-
274
- Write the screen so it paints correctly with **no data at all**: empty roster,
275
- unknown status, default language. Then fill it as events arrive.
276
-
277
- This is not a nicety. Events are emitted on attach, before your bundle exists,
278
- and again after the handshake — so there is always a window where you have
279
- nothing. A Home whose first paint depends on an event shows a blank screen on
280
- every cold start.
281
-
282
- ## Stage 2 — handshake, and learn what this device can do
283
-
284
- ```ts
285
- import { BridgeClient, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
286
-
287
- const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
288
-
289
- const native = ack.nativeVersion; // string | null
290
- const has = (min: string) => native !== null && cmp(native, min) >= 0;
291
-
292
- const canReadRawChannels = has('0.16.0');
293
- const canQueryTimeRange = has('0.15.0');
294
- const hasLanguageKey = has('0.14.0');
295
- ```
296
-
297
- Or use the SDK's `versionAtLeast(ack.nativeVersion, '0.16.0')`, which does
298
- exactly that and returns `false` for `null`. Do not compare version strings
299
- lexically — `'0.9.0' > '0.16.0'` is `true` as strings and false as versions.
300
-
301
- **Do not branch on `ack.accepted`.** It is always `true`; the native side never
302
- refuses a handshake. Anything you gate on it is ungated.
303
-
304
- ## Stage 3 — subscribe, then ask
305
-
306
- Subscribe before sending the command that triggers the reply. The reply can
307
- arrive before your next line runs.
308
-
309
- ```ts
310
- BridgeClient.on(BRIDGE_KEYS.userDevice, (p) => { setDevices(p); render(); });
311
- BridgeClient.on(BRIDGE_KEYS.systemStatus, (s) => { setStatus(s); render(); });
312
- BridgeClient.on(BRIDGE_KEYS.deviceConnection, (c) => { setConnected(c); render(); });
313
-
314
- const result = await BridgeClient.requestUserDevices();
315
- if (result.status === 'timeout') renderEmptyState(result.hasDevices === false);
316
- ```
317
-
318
- **Do not replace the helper with `send('userDevice')`.** The device list is
319
- served by the app's background service, which starts after your bundle; a
320
- request sent before it is up is lost, and an account with no device never gets
321
- a reply. The helper resends with backoff for 30 s and then tells you to show the
322
- empty state. Keep the `userDevice` subscription: roster changes arrive on it
323
- later without a request. `docs/troubleshooting.md` has the details.
324
-
325
- Read device data from the typed items: the list is `devicesConfig`, and the
326
- name, battery and last sync are in `devicesTelemetry` — join them on
327
- `deviceId`, never on `serialNo` alone.
328
-
329
- Then follow the connection state of each device:
330
-
331
- ```ts
332
- for (const d of result.devices.devicesConfig) {
333
- BridgeClient.send('deviceConnection', { type: 'add', serialNo: d.serialNo, deviceId: d.deviceId });
334
- }
335
- ```
336
-
337
- `on` returns an unsubscribe function. If your UI has views that mount and
338
- unmount, call it — otherwise you accumulate handlers that all re-render.
339
-
340
- ## Stage 4 — handle the environment honestly
341
-
342
- `systemStatus` tells you why things are not working. Surface it; do not swallow
343
- it.
344
-
345
- ```ts
346
- if (status.missingEssentialPermissions.length > 0) {
347
- // Background collection is OFF. This is blocking — say so.
348
- renderBlockingCard(status.missingEssentialPermissions, (name) =>
349
- BridgeClient.requestPermission(name));
350
- } else if (!status.bluetooth) {
351
- renderNotice('Bluetooth is off — devices cannot be reached.');
352
- }
353
- ```
354
-
355
- There is no permission-result event. After the person answers the OS prompt,
356
- the native side re-emits `systemStatus`; your card disappears because the array
357
- emptied. If you wait for a dedicated ack, you wait forever.
358
-
359
- ## Stage 5 — real-time, if you need it
360
-
361
- Real-time is **off by default**, per device. Turn it on, wait for the ack, and
362
- only then expect the stream.
363
-
364
- ```ts
365
- BridgeClient.on(BRIDGE_KEYS.realTime, (ack) => setRealTime(ack.serialNo, ack.value));
366
- BridgeClient.on(BRIDGE_KEYS.deviceLightSensors, (p) => {
367
- if (p.type === 'actlumus2') { /* 14 channels */ } else { /* 10 channels */ }
368
- });
369
-
370
- BridgeClient.send('realTime', { serialNo, value: true });
371
- ```
372
-
373
- `serialNo` is a **number**. A string is dropped in silence.
374
-
375
- Two devices can share a `serialNo` (an ActLumus and an ActLumus Plus). To act on
376
- one of them, send its `deviceId` **together with** `serialNo`, taken from the
377
- `userDevice` list: `BridgeClient.send('realTime', { serialNo, deviceId, value })`.
378
- A command with only the shared `serialNo` is ignored on purpose. Gate this on
379
- `nativeVersion` ≥ 0.17.0; details in `docs/bridge-contract.md`.
380
-
381
- In a raw channel, `null` means the channel does not exist on that hardware
382
- generation. It does not mean zero — `0.0` is a valid reading. Coercing `null`
383
- to `0` invents data, and has previously led to a confident, wrong conclusion
384
- about a device.
385
-
386
- ## Stage 6 — history, if you need it
387
-
388
- ```ts
389
- const page = await BridgeClient.fetchSensorLog({
390
- requestId: crypto.randomUUID(),
391
- serialNo,
392
- limit: 200,
393
- });
394
- if (!page.success) return showError(page.error); // resolves, does not reject
395
- renderLogs(page.logs ?? []);
396
- ```
397
-
398
- Timestamps are **device-local**. Build ranges with local `Date` getters, never
399
- `toISOString()` — converting to UTC shifts every series by the timezone offset
400
- and produces data that looks entirely plausible.
401
-
402
- Paginate with the cursors exactly as received. They are opaque. Without a range
403
- the first page is the **most recent** one (go older with `endingBefore`); with
404
- `startTime` it is the **oldest** of the range (go newer with `startingAfter`).
405
- A device writes one log per period — programmable, 60 s by default — so a week
406
- is about 10 000 logs: use `limit: 1000` and do not assume the cadence.
407
-
408
- ## Stage 7 — back button
409
-
410
- ```ts
411
- BridgeClient.onBack(() => goBackOnePage());
412
- // call this on every internal navigation, not once at startup
413
- BridgeClient.reportNavigation(hasInternalHistory);
414
- ```
415
-
416
- If you never report `true`, the OS back button stays with the app — which is
417
- the correct behaviour for a single-screen Home.
418
-
419
- ## Stage 8 — build and validate
420
-
421
- ```bash
422
- npm run build # your bundler → dist/index.html, single chunk
423
- npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
424
- npx condor-mobile-bridge validate -i dist/bundle.zip --strict
425
- ```
426
-
427
- `validate` applies the gates the app applies plus a lint for APIs the sandbox
428
- blocks. A non-zero exit is a build failure. Errors mean the app would reject
429
- the bundle outright; warnings mean it installs and then misbehaves on the
430
- device, which is worse to debug.
431
-
432
- Validation is textual, so it cannot see through minification and it cannot know
433
- whether you checked `nativeVersion` before using a capability. A clean run is
434
- necessary, not sufficient.
435
-
436
- ## Stage 9 — before you call it done
437
-
438
- - [ ] `validate --strict` exits 0.
439
- - [ ] First paint works with no events received.
440
- - [ ] Every capability past the baseline is guarded by a `nativeVersion` check,
441
- and the `null` branch renders something sensible.
442
- - [ ] No spinner can hang forever — the three confirmable commands
443
- (`syncTime`, `deleteMemory`, `logout`) may never ack, because the person
444
- can decline.
445
- - [ ] No `localStorage`, no `fetch`, no `eval`. Not even behind a `try`.
446
- - [ ] `serialNo` is a number everywhere, and a command for a device whose `serialNo`
447
- repeats on the account also carries its `deviceId` (`nativeVersion` ≥ 0.17.0).
448
- - [ ] Errors are reported through `BridgeClient.send('jsError', …)`, the only
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
449
301
  channel out.
450
- - [ ] Every harness scenario passes: `default`, `cold-start`, `no-devices`,
451
- `shared-serial`, `offline`.
452
- - [ ] The `nativeVersion === null` branch renders something sensible.
453
-
454
- ## Delivery
455
302
 
456
- Hand Condor the `.zip` along with its size and `sha256-<hex>` hash, both printed
457
- by the `build` command. The app verifies the hash against the manifest and
458
- rejects any mismatch. Condor publishes it for your company, which reaches every
459
- user of that company — test on a development test company first.
460
- `docs/device-testing.md` explains the path to the phone and how to inspect the
461
- bundle there; `docs/pilot-onboarding.md` has the full checklist.
462
-
463
- ## When something does not work
464
-
465
- `docs/troubleshooting.md`, which starts from the symptom. The most common one
466
- is that a command did nothing and said nothing — there are six distinct causes
467
- for that, and they are enumerated there.
303
+ To put the bundle on a phone, read `docs/device-testing.md`.
468
304
 
469
305
  <!-- ===== docs/bridge-contract.md ===== -->
470
306
 
@@ -497,12 +333,12 @@ protocol for you.
497
333
 
498
334
  Four numbers coexist. Only one tells you what the device can do.
499
335
 
500
- | Number | Current | What it means |
336
+ | Number | Latest | What it means |
501
337
  | --- | --- | --- |
502
- | `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. |
503
339
  | `shellVersion` | `0.6.0` | The Condor shell wrapping your iframe. |
504
340
  | client protocol | `0.5.0` | The postMessage protocol `BridgeClient` speaks. |
505
- | package | `0.21.0` | The npm version you compiled against. |
341
+ | package | `0.23.0` | The npm version you compiled against. |
506
342
 
507
343
  ```ts
508
344
  const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
@@ -517,7 +353,7 @@ side never refuses. Branching on it is branching on a constant.
517
353
  **`nativeVersion` can be `null`.** When the shell could not reach the native
518
354
  side, and in bundles compiled against SDK ≤ 0.16.x, where the shell did not
519
355
  relay it. Treat `null` as "assume only the baseline" and keep a working
520
- default. The dev harness reports `0.17.0` by default; `--native-version null`
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`
521
357
  exercises this path.
522
358
 
523
359
  ### Capability matrix
@@ -529,6 +365,7 @@ exercises this path.
529
365
  | `sensorLog` accepts `startTime` / `endTime` | `nativeVersion` ≥ 0.15.0 |
530
366
  | `deviceLightSensors` carries raw `light_sensor_*` channels, and the `actlumus2` variant | `nativeVersion` ≥ 0.16.0 |
531
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 |
532
369
  | `nativeVersion` reaches the client at all | `shellVersion` ≥ 0.6.0 |
533
370
 
534
371
  `CHANGELOG.md` is the precise record of when each of these landed.
@@ -562,7 +399,24 @@ you; the second is the one you receive. This is why `language` also exists as a
562
399
  pull command — a client that mounts late can ask.
563
400
 
564
401
  Render a skeleton synchronously and fill it as events arrive. Do not make first
565
- 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.
566
420
 
567
421
  ### `deviceLightSensors` is a discriminated union
568
422
 
@@ -574,6 +428,13 @@ if (payload.type === 'actlumus2') {
574
428
  }
575
429
  ```
576
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
+
577
438
  A `null` in a raw channel means **that channel does not exist on this hardware
578
439
  generation** — never "the reading was zero". `0.0` is a valid reading. Do not
579
440
  collapse `null` to `0`: doing so fabricates data, and it has produced wrong
@@ -595,23 +456,144 @@ conclusions before.
595
456
  off. Render a blocking card; wire its button to
596
457
  `BridgeClient.requestPermission(name)` with a name taken from that array.
597
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
+
598
567
  **`hasDevices` is a hint.** It is `true` only after the app loaded a non-empty
599
568
  device list from the server. `false` means "no linked device" **or** "the app
600
- could not ask" (no internet, session problem). Do not show "you have no device"
601
- on `false` alone; see the next section.
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.
602
573
 
603
574
  ### `userDevice` in detail
604
575
 
605
- **It is not guaranteed to answer.** `userDevice` (and `ping`) are handled by
606
- the app's background service, which starts after your bundle is running; a
607
- request sent before it is up is lost. And the app replies only when the list
608
- loads: an account with no linked device, or a failed request, gets **no reply
609
- at all** — never an empty list. Use the helper, which resends with backoff and
610
- tells you when to give up:
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:
611
592
 
612
593
  ```ts
613
594
  const r = await BridgeClient.requestUserDevices(); // getFromServer: false
614
- // { status: 'ok', devices } | { status: 'timeout', hasDevices: boolean | null }
595
+ // { status: 'ok', devices } | { status: 'no-devices' } | { status: 'error', code }
596
+ // | { status: 'timeout', hasDevices: boolean | null }
615
597
  ```
616
598
 
617
599
  **`getFromServer`.** `false` (default) answers from the app's memory or cache
@@ -625,7 +607,7 @@ the server first and falls back to the cache if that fails — use it for a
625
607
  | Array | Holds | Notable fields |
626
608
  | --- | --- | --- |
627
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) |
628
- | `devicesTelemetry` | last state read from each device | `deviceName`, `batteryLevel` (0–100 %), `memoryDataUsage` (0–100 %), `updataDateTime` (epoch ms, last sync — the misspelling is the real name), `deviceVersion`, `isCharging`, `isOnDock`, `syncErrors[]` |
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[]` |
629
611
  | `devicesUserConfig` | per-device toggles | `syncTime`, `realTime`, `deleteMemory`, `readAllMemory`, `readAllSystemLog` |
630
612
 
631
613
  The name and battery of a device are in **telemetry**, not in the config. A
@@ -637,15 +619,82 @@ device that never synced may have no telemetry item, or one with every reading
637
619
  only when that `serialNo` appears once in `devicesConfig`.
638
620
 
639
621
  **Sync errors.** `devicesTelemetry[].syncErrors` is the per-device history
640
- (`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`
641
626
  is the current error per device, already localized. Show the current one; use
642
627
  the history for detail screens.
643
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
+
644
691
  ## Commands
645
692
 
646
693
  `BridgeClient.send(command, args)` is **fire-and-forget**: no return value, no
647
694
  exception, no delivery confirmation. Replies arrive on the key in the last
648
- 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.
649
698
 
650
699
  | Command | Arguments | Confirmation | Replies on |
651
700
  | --- | --- | --- | --- |
@@ -654,7 +703,7 @@ column.
654
703
  | `realTime` | `{ serialNo: number, deviceId?: number, value: boolean }` | no | `realTime` |
655
704
  | `deviceConnection` | `{ type: 'add' \| 'remove', serialNo: number, deviceId?: number }` — subscribe to / stop following one device's connection state | no | `deviceConnection` |
656
705
  | `sensorLog` | see below | no | `sensorLog` |
657
- | `openSettings` | — | no | — |
706
+ | `openSettings` | — (opens the Canvas settings screen: language, notifications, Bluetooth, refresh device list, date format; not the OS settings, no logout) | no | — |
658
707
  | `requestPermission` | `{ permission: string }` | no | — (re-emits `systemStatus`) |
659
708
  | `setStatusBarColor` | `{ color: '#RRGGBB' }` | no | — |
660
709
  | `navigationState` | `{ canGoBack: boolean }` | no | — |
@@ -669,9 +718,46 @@ column.
669
718
  message in silence.
670
719
 
671
720
  `deviceConnection` **pairs nothing.** `type: 'add'` asks the app to report the
672
- BLE connection state of a device you already have from `userDevice`; the state
673
- then arrives on the `deviceConnection` key whenever it changes. `'remove'` stops
674
- it. Send `add` for each device after the list arrives.
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.
675
761
 
676
762
  ### Two devices with the same `serialNo`
677
763
 
@@ -697,7 +783,9 @@ the wrong device (erasing the wrong memory, for instance).
697
783
  unaddressable).
698
784
  - `sensorLog` takes no `deviceId` yet: for a shared `serialNo` the backend
699
785
  answers with the error code `DEVICE_SERIAL_AMBIGUOUS`. Show your own copy for
700
- 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`.
701
789
 
702
790
  ### Commands that need human confirmation
703
791
 
@@ -708,6 +796,13 @@ which reaches you only as a `console.warn`. **The ack simply never arrives.**
708
796
  Design for that: show an optimistic state only if you can undo it, and put a
709
797
  timeout on any spinner.
710
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
+
711
806
  ### Typed helpers
712
807
 
713
808
  Prefer these over raw `send` — they add correlation, timeouts and types:
@@ -726,7 +821,6 @@ BridgeClient.openSettings()
726
821
 
727
822
  ```ts
728
823
  const page = await BridgeClient.fetchSensorLog({
729
- requestId: crypto.randomUUID(),
730
824
  serialNo,
731
825
  bodyPart: 'wrist', // or 'necklace'; omit for no filter
732
826
  limit: 200, // 1..1000, backend default 10
@@ -736,7 +830,15 @@ const page = await BridgeClient.fetchSensorLog({
736
830
  ```
737
831
 
738
832
  It **resolves** with `{ success: false, error }` on failure and only **rejects**
739
- 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.
740
842
 
741
843
  Two things that have caused real bugs:
742
844
 
@@ -744,6 +846,11 @@ Two things that have caused real bugs:
744
846
  device RTC. Build your range with local `Date` getters — `getFullYear()`,
745
847
  `getHours()` — and **never** with `toISOString()`. Converting shifts every
746
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.
747
854
 
748
855
  **Cursors are opaque.** `startingAfter` and `endingBefore` are mutually
749
856
  exclusive; echo back exactly what you received and never construct one.
@@ -756,7 +863,7 @@ const logs: SensorLogItem[] = [];
756
863
  let startingAfter: string | undefined;
757
864
  for (;;) {
758
865
  const page = await BridgeClient.fetchSensorLog({
759
- requestId: crypto.randomUUID(), serialNo, limit: 1000,
866
+ serialNo, limit: 1000,
760
867
  startTime: '2026-10-13 00:00:00', endTime: '2026-10-20 00:00:00', // local time
761
868
  ...(startingAfter ? { startingAfter } : {}),
762
869
  });
@@ -788,9 +895,25 @@ Pages depend on the previous cursor, so they cannot be fetched in parallel.
788
895
  **Fields.** Each log is a `SensorLogItem`: `log_time`, `body_part`,
789
896
  `temperature` (°C), `lux_photopic` and `lux_melanopic` (lux), the raw light
790
897
  channels and the activity counters. `null` on a channel means the hardware does
791
- not have it, never zero. `device_log_time` and `app_log_time` mark the upload
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
792
901
  batch, not the log — do not plot them.
793
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
+
794
917
  ## Error shapes
795
918
 
796
919
  There are three, and none of them is a thrown exception.
@@ -854,10 +977,15 @@ session key. Do not try to detect it; the app handles re-authentication.
854
977
  ## "The device list never came" (stuck on loading)
855
978
 
856
979
  The handshake worked, `systemStatus` arrived, and `userDevice` never answered.
857
- Two different causes produce exactly this symptom, and the bundle cannot tell
858
- them apart.
859
980
 
860
- ### 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
861
989
 
862
990
  `userDevice` and `ping` are not answered by the page that hosts you: the app
863
991
  forwards them to a background service that reads the devices. On a cold start
@@ -866,12 +994,12 @@ request sent at boot is often **lost without a reply**. Nothing re-sends the
866
994
  list when the service comes up. Meanwhile the handshake, `systemStatus` and
867
995
  `language` work from the start, which makes the bridge look ready.
868
996
 
869
- ### Cause 2: the account has no linked device
997
+ ### Cause 2 (below 0.18.0): the account has no linked device
870
998
 
871
999
  The app replies to `userDevice` only when the list loads. When the server
872
1000
  answers that the account has no device — or the request fails for any other
873
- reason (no internet, expired session) — nothing is sent to the bundle. A
874
- `userDevice` with empty arrays never arrives today.
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.
875
1003
 
876
1004
  ### What to do
877
1005
 
@@ -880,16 +1008,19 @@ Use the helper, which handles both:
880
1008
  ```ts
881
1009
  const result = await BridgeClient.requestUserDevices(); // resends with backoff for 30 s
882
1010
  if (result.status === 'ok') render(result.devices);
883
- 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"
884
1013
  ```
885
1014
 
886
1015
  - It resends at 1.5 s, 3 s, 6 s and then every 10 s, and on every
887
1016
  `systemStatus` while it waits. `BridgeClient.waitForService()` does the same
888
1017
  with `ping` if you only need to know the service is up.
889
1018
  - **`systemStatus.hasDevices` is a hint, not a verdict.** It is `true` only
890
- after the app loaded a non-empty list from the server. `false` means either
891
- "no device" or "the app could not ask". Word the empty state so it is true in
892
- both cases, and always offer a retry with `getFromServer: true`.
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`.
893
1024
  - Keep the `userDevice` subscription for the life of the page: roster changes
894
1025
  arrive on it without a request.
895
1026
 
@@ -1015,7 +1146,7 @@ First question: **which browser setup?**
1015
1146
  run every scenario, not just `default`. `cold-start` and `no-devices` are the
1016
1147
  ones that catch real bugs.
1017
1148
 
1018
- 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
1019
1150
  `--native-version null` to reproduce a shell that could not reach the native
1020
1151
  side; keep a branch that renders in that case too.
1021
1152
 
@@ -1077,10 +1208,11 @@ costs, and how to see what it is doing once it is there.
1077
1208
 
1078
1209
  You do not install a bundle on a phone. The app downloads it:
1079
1210
 
1080
- 1. You deliver `bundle.zip`, its `bundleSize` and its `bundleHash` (printed by
1081
- `condor-mobile-bridge build`) to your Condor contact.
1082
- 2. Condor publishes it for **your company**. There is exactly one current
1083
- bundle per company.
1211
+ 1. The bundle is published for **your company** — by you, with
1212
+ `npx condor-mobile-bridge canvas publish` (below), or by your Condor
1213
+ contact. There is exactly one current bundle per company.
1214
+ 2. The server checks it on upload (2 MB, `index.html` at the root,
1215
+ self-contained) and records its `bundleSize` and `bundleHash`.
1084
1216
  3. Each copy of the app asks for its company's manifest when the Home opens
1085
1217
  and every time the app returns to the foreground. If the `bundleHash`
1086
1218
  changed, it downloads the `.zip`, checks size and hash, and swaps the Home
@@ -1095,24 +1227,72 @@ Consequences:
1095
1227
  bundle, or shows its default screen, if the download fails a gate — which is
1096
1228
  why `validate --strict` must pass before you hand anything over.
1097
1229
 
1098
- ## Development and production are separate worlds
1230
+ ## Test on a test company, never on your real one
1231
+
1232
+ You work against production — the store app and the production Condor system.
1233
+ Since a publish reaches every user of the company at once, ask Condor for a
1234
+ **test company** with:
1099
1235
 
1100
- Condor runs a **development** environment next to production. An app build
1101
- talks to exactly one of them, and accounts, devices and bundles do not cross
1102
- over:
1236
+ - a **manager** user for you, to publish (see below);
1237
+ - one or more **test accounts** with devices, to log in to the store app on
1238
+ your phone.
1103
1239
 
1104
- - a development build of the app sees only development accounts and bundles;
1105
- - the store app sees only production.
1240
+ Each iteration on the device is then: build → `canvas publish` to the test
1241
+ company → bring the app to the foreground. Do every iteration you can in the
1242
+ harness first, and publish to your real company only what passed there.
1106
1243
 
1107
- So, to test on a phone, ask Condor for three things that match: a **test
1108
- account** in a **test company** in the development environment, a
1109
- **development build** of the app (Android), and to publish your bundle **to that
1110
- test company**. Never iterate on a production company: every publish reaches
1111
- real people.
1244
+ (Condor also runs a development environment for its own staff; a client does
1245
+ not need it.)
1112
1246
 
1113
- Each iteration on the device is therefore: build → validate → hand over →
1114
- Condor publishes → bring the app to the foreground. That is slow, so do every
1115
- iteration you can in the harness first.
1247
+ ## Publishing from the command line
1248
+
1249
+ ```bash
1250
+ npx condor-mobile-bridge canvas publish dist/index.html --version 1.4.0
1251
+ ```
1252
+
1253
+ It does what the Condor web admin does, with the same login:
1254
+
1255
+ 1. It validates the bundle locally (the same gates as `validate`) and refuses
1256
+ anything the app would reject — before sending anything.
1257
+ 2. It asks for your **company id** and **user id** — the numbers you type in
1258
+ the Condor web login — unless you pass `--company` / `--user` or set
1259
+ `CONDOR_CANVAS_COMPANY` / `CONDOR_CANVAS_USER`.
1260
+ 3. It shows the company, version, size and hash, warns that the bundle goes
1261
+ **live at once for every user of the company**, and asks `y/N`.
1262
+ 4. It asks for your **password** without echo — there is no flag or variable
1263
+ for it, and it is never stored — and, when your company uses two-factor
1264
+ login, for the code sent to your e-mail.
1265
+ 5. It publishes and prints the result.
1266
+
1267
+ Each version can be published once per company: publish a fix as a new
1268
+ version. `canvas list` shows the bundles of the company, and
1269
+ `canvas revoke <id> --fallback-version <v>` takes one back and serves another;
1270
+ revoking the current bundle without a fallback leaves the company on the app's
1271
+ default screen.
1272
+
1273
+ You need a **manager** user of the company. The command needs a terminal (it
1274
+ prompts), so it does not run in CI; and an AI agent must not run it on its own
1275
+ — it publishes to real people and asks for your password.
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.
1116
1296
 
1117
1297
  ## Seeing what the bundle does on the phone
1118
1298
 
@@ -1136,6 +1316,220 @@ by the app (wrong argument type, unknown command, a request lost while the
1136
1316
  background service starts) leaves no trace in the console — reproduce it in the
1137
1317
  harness instead, whose console says what was dropped and why.
1138
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
+
1139
1533
  ## Errors after the fact
1140
1534
 
1141
1535
  `BridgeClient.send('jsError', { message, stack })` sends an error to Condor's
@@ -1143,14 +1537,15 @@ logging and crash reporting (rate limited: 20 per 10 s, 4 KB per field). Those
1143
1537
  are Condor's tools: when you need the errors of a test session, ask your Condor
1144
1538
  contact, with the test account and the time window.
1145
1539
 
1146
- ## Before you ask for a publish
1540
+ ## Before you publish
1147
1541
 
1148
1542
  - [ ] `npx condor-mobile-bridge validate -i dist/bundle.zip --strict` exits 0.
1149
1543
  - [ ] Every harness scenario passes (`default`, `cold-start`, `no-devices`,
1150
1544
  `shared-serial`, `offline`).
1151
- - [ ] You know which environment and which company the bundle is for.
1152
- - [ ] You sent `bundleSize` and `bundleHash` with the `.zip`, so the published
1153
- bundle can be checked against what you built.
1545
+ - [ ] It is going to the **test company** first, with a version number not
1546
+ used before in that company.
1547
+ - [ ] After publishing, the `bundleHash` that `canvas publish` prints matches
1548
+ the one `build` printed.
1154
1549
 
1155
1550
  <!-- ===== docs/limits.md ===== -->
1156
1551
 
@@ -1275,526 +1670,55 @@ layout grow. A two-slot UI with the slots hard-coded is the failure this section
1275
1670
  exists to prevent: it will look correct for as long as the rule holds, and break
1276
1671
  silently on the day it does not.
1277
1672
 
1278
- <!-- ===== docs/client-guide.md ===== -->
1279
-
1280
- # Build your White Label Home
1281
-
1282
- This guide walks you — a Condor White Label partner — through building the web
1283
- Home that runs inside the Condor mobile app, using the
1284
- [`@condorinst/mobile-bridge`](../README.md) SDK.
1285
-
1286
- No Flutter, Android, or iOS knowledge is required. You build a normal web app;
1287
- we package it and the app renders it.
1288
-
1289
- > **This guide is the narrative path, written for a person reading start to
1290
- > finish.** If you are working with an AI agent, point it at
1291
- > [`AGENTS.md`](../AGENTS.md) and [`docs/agent-guide.md`](./agent-guide.md)
1292
- > instead — same platform, organised as a procedure with checks. The normative
1293
- > reference for keys, commands, errors and capabilities is
1294
- > [`docs/bridge-contract.md`](./bridge-contract.md); the hard numbers are in
1295
- > [`docs/limits.md`](./limits.md).
1296
-
1297
- ---
1298
-
1299
- ## 1. What you're building
1300
-
1301
- The app's Home is a **web bundle** that the Condor app renders inside a
1302
- sandboxed WebView (`<iframe sandbox="allow-scripts">`). You deliver a single
1303
- self-contained `index.html`; the app downloads it, verifies it, and shows it.
1304
-
1305
- Your web code talks to the device and the native app through the **bridge** —
1306
- a typed message channel exposed by this SDK. You never touch Bluetooth or
1307
- native APIs directly: you subscribe to keys (device list, connection status,
1308
- real-time readings…) and send commands (refresh, start real-time, open
1309
- settings…).
1310
-
1311
- ```
1312
- ┌────────────────────────────┐
1313
- │ Condor app (native) │
1314
- │ ┌──────────────────────┐ │
1315
- │ │ iframe (your bundle) │ │ ← your index.html runs here
1316
- │ │ BridgeClient ◄────────────► native bridge (devices, BLE, …)
1317
- │ └──────────────────────┘ │
1318
- └────────────────────────────┘
1319
- ```
1320
-
1321
- **Two hard rules** that shape everything below:
1322
- - The bundle must be **one `index.html`**, fully **self-contained** (JS/CSS
1323
- inline, media as `data:` URIs). Relative paths and external URLs do **not**
1324
- load inside the sandbox.
1325
- - The bundle must be **≤ 2 MB** (zipped).
1326
-
1327
- The CLI in this SDK turns your normal build into a compliant bundle for you
1328
- (see §6) — you don't have to inline by hand.
1329
-
1330
- ---
1331
-
1332
- ## 2. Install the SDK
1333
-
1334
- ```bash
1335
- npm install @condorinst/mobile-bridge
1336
- ```
1337
-
1338
- Pin the version with a caret:
1339
-
1340
- ```jsonc
1341
- // package.json
1342
- "dependencies": {
1343
- "@condorinst/mobile-bridge": "^0.21.0"
1344
- }
1345
- ```
1346
-
1347
- **Updates.** The SDK is on `0.x` during the pilot. With `^0.21.0`, running
1348
- `npm install` (or your CI) auto-picks **patch** releases (`0.21.x`) — bug fixes,
1349
- no action needed. A `0.22.0` (a larger change during beta) is an intentional
1350
- opt-in: you bump the range when you're ready. `1.0.0` will mark the
1351
- production-stable contract.
1352
-
1353
- The published package ships prebuilt JS + TypeScript types — no build tooling
1354
- of ours required on your side.
1355
-
1356
- ---
1357
-
1358
- ## 3. Talk to the app — the bridge
1359
-
1360
- Import **`BridgeClient`** (the model for code running inside the app's iframe):
1361
-
1362
- ```typescript
1363
- import { BridgeClient, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
1364
- ```
1365
-
1366
- ### Handshake
1367
-
1368
- Announce yourself once at boot. The ack tells you the bridge is live.
1369
-
1370
- ```typescript
1371
- const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
1372
- // ack.accepted: boolean
1373
- // ack.shellVersion: string
1374
- // ack.nativeVersion: string | null — the native's schema version, relayed by
1375
- // the shell (0.17.0+). `null` means the native has not answered or is absent
1376
- // (browser mock). There is no version negotiation, so this is the only signal
1377
- // telling you whether a capability exists — e.g. the raw `light_sensor_*`
1378
- // channels on `deviceLightSensors` require native >= 0.16.0.
1379
- ```
1380
-
1381
- ### Listen for events (native → you)
1382
-
1383
- ```typescript
1384
- const unsubscribe = BridgeClient.on(BRIDGE_KEYS.deviceConnection, (payload) => {
1385
- // payload is typed: { serialNo: number, isConnect: boolean }
1386
- console.log(payload.serialNo, payload.isConnect);
1387
- });
1388
- // later: unsubscribe();
1389
- ```
1390
-
1391
- ### Send commands (you → native)
1392
-
1393
- ```typescript
1394
- const devices = await BridgeClient.requestUserDevices(); // device list, resent until it arrives
1395
- BridgeClient.send('realTime', { serialNo: 1234, value: true }); // start real-time
1396
- BridgeClient.openSettings(); // open native Settings
1397
- BridgeClient.requestPermission('bluetoothScan'); // in-app permission dialog
1398
- ```
1399
-
1400
- > Commands are gated by the app's policy. A command that isn't allowed is
1401
- > silently ignored — design your UI to not depend on a response you won't get.
1402
- >
1403
- > Ask for the device list with `requestUserDevices()`, not with
1404
- > `send('userDevice')`: the first request after the app starts is often lost,
1405
- > and an account with no device gets no reply at all. The helper resends with
1406
- > backoff and resolves `{ status: 'timeout', hasDevices }` after 30 s, so you can
1407
- > show an empty state. See [Troubleshooting](./troubleshooting.md).
1408
-
1409
- ### Handle the system back button
1410
-
1411
- Your page runs inside the app's Home screen. On Android, the OS back
1412
- button/gesture would otherwise leave the Home — so the app delegates it to you
1413
- when you opt in, letting the back button walk **your** internal navigation
1414
- (e.g. a device detail page → back to the list) before it does anything else.
1415
-
1416
- Two calls wire it up:
1417
-
1418
- ```typescript
1419
- // 1. Tell the app whether you have an internal page to go back to.
1420
- // Call this on EVERY route change (and once at boot).
1421
- BridgeClient.reportNavigation(currentView !== 'home'); // true = you can go back
1422
-
1423
- // 2. React to the OS back button. Called only when you last reported `true`.
1424
- BridgeClient.onBack(() => {
1425
- goToPreviousPage(); // pop one internal page, then reportNavigation(...) again
1426
- });
1427
- ```
1428
-
1429
- How it behaves:
1430
-
1431
- - You reported `true` → the OS back button fires your `onBack` (go back one
1432
- internal page). Nothing else happens — the app stays on Home.
1433
- - You reported `false` (or never adopted this API) → the OS back button is
1434
- ignored and the user stays on Home. **No regression if you skip it.**
1435
-
1436
- Keep `reportNavigation` in sync with your router: the app caches the last value
1437
- and reads it the instant back is pressed. A single choke point (call it at the
1438
- end of your `render`/route function) is the simplest way to never miss a change.
1439
- See `examples/client_demo/src/main.ts` for a worked home↔device example.
1440
-
1441
- ### Key catalog
1442
-
1443
- These are the keys you can subscribe to / send. Payloads are fully typed in
1444
- TypeScript; the table is the quick reference (see the
1445
- [README](../README.md#key-catalog-strictly-typed) for exact shapes).
1446
-
1447
- | Key | Payload (typed) | Direction |
1448
- |---|---|---|
1449
- | `userDevice` | device config / telemetry / user config arrays | native → you (also send to refresh) |
1450
- | `deviceConnection` | `{ serialNo, isConnect }` | native → you (status) |
1451
- | `deviceNotification` | `{ serialNo, isConnect, message, messageCode, isError }` | native → you |
1452
- | `deviceLightSensors` | `{ serialNo, type, data }` — union on `type` (`'actlumus'` \| `'actlumus2'`); `data` has the spectrum plus the raw `light_sensor_*` channels | native → you |
1453
- | `realTime` | `{ serialNo, success, value }` | both (command + state) |
1454
- | `syncTime` | `{ serialNo, success }` | both |
1455
- | `sensorLog` | paginated sensor log envelope | native → you |
1456
- | `systemStatus` | `{ bluetooth, internet, permission, hasDevices, missingOptionalPermissions, missingEssentialPermissions, deviceSyncError }` | native → you (env status) |
1457
- | `deleteMemory` | `{ serialNo, success }` | both |
1458
- | `pingPong` | `{ data: 'ping' \| 'pong' }` | both |
1459
- | `navigationBack` | `{}` (OS back pressed — via `onBack`) | native → you |
1460
- | `language` | `{ deviceLocale, appLanguage, resolvedLanguage }` | native → you (language state) |
1461
-
1462
- > **System back button.** Use the `reportNavigation(canGoBack)` /
1463
- > `onBack(cb)` helpers (see [Handle the system back button](#handle-the-system-back-button))
1464
- > rather than subscribing to `navigationBack` directly — `reportNavigation`
1465
- > sends the `navigationState` command the app needs to route the back button.
1466
-
1467
- **Permissions on `systemStatus`.** Two arrays of `AppPermission` names let you
1468
- render permission prompts from your own UI:
1469
-
1470
- - `missingEssentialPermissions` — **required** for the app to work; background
1471
- collection stays off until granted. Render a **blocking "Required permissions"
1472
- card** (e.g. `["bluetoothScan", "bluetoothConnect"]`).
1473
- - `missingOptionalPermissions` — **recommended** (help background reliability);
1474
- render a **dismissible "Recommended permissions" nudge** (e.g.
1475
- `["notification", "backgroundLocation"]`).
1476
-
1477
- Both are empty when nothing is missing. For the **required** card, wire the
1478
- "Grant" button to `BridgeClient.requestPermission(name)` — it fires the **native
1479
- in-app permission dialog** for that `AppPermission` (falls back to Settings only
1480
- when the OS refuses the prompt). Pass a name straight from
1481
- `missingEssentialPermissions` (e.g. `'bluetoothScan'`). There is no dedicated
1482
- result key: the native side re-emits `systemStatus` once granted, so the card
1483
- disappears when `missingEssentialPermissions` empties. `openSettings` remains
1484
- available for the optional nudge (permissions not grantable by prompt on modern
1485
- Android). See `examples/client_demo/src/render-home.ts` for a worked render.
1486
-
1487
- ### Translate your bundle — the `language` key
1488
-
1489
- The app tells you **two** languages, and the distinction between them is the
1490
- whole point of the channel:
1491
-
1492
- ```typescript
1493
- BridgeClient.on(BRIDGE_KEYS.language, ({ deviceLocale, appLanguage, resolvedLanguage }) => {
1494
- // Your precedence rule. This one honours an explicit choice, else the device.
1495
- const lang = appLanguage ?? pickSupported(deviceLocale) ?? 'en';
1496
- renderIn(lang);
1497
- });
1498
- ```
1499
-
1500
- - **`deviceLocale`** — the raw device locale as a BCP-47 tag (`"pt-BR"`,
1501
- `"es-419"`, `"ja-JP"`), exactly as the OS reports it, region included.
1502
- - **`appLanguage`** — the language the user explicitly picked in the app
1503
- Settings (`"en" | "pt" | "de" | "fr"`), or **`null`** when they never picked
1504
- one.
1505
- - **`resolvedLanguage`** — the language the native chrome (splash, Settings,
1506
- loading/error screens) is rendering right now.
1507
-
1508
- **Why two raw values instead of one answer.** The app itself ships four
1509
- languages and collapses everything else to English. Your bundle has no such
1510
- limit — it may ship Spanish, Italian, Japanese. If we sent you only the resolved
1511
- language, a device set to Spanish would reach you as `en` and you would render
1512
- English while holding a Spanish bundle. The information would have been
1513
- destroyed on our side before reaching you. So we send both and let you decide:
1514
-
1515
- | `deviceLocale` | `appLanguage` | a reasonable decision |
1516
- |---|---|---|
1517
- | `es-ES` | `null` | render **Spanish** — you have it, we don't. Our chrome stays English while your content is Spanish, which is better, not worse |
1518
- | `es-ES` | `"pt"` | render **Portuguese** — an explicit choice outranks the device |
1519
- | `pt-BR` | `null` | render Portuguese |
1520
- | `ja-JP` | `"de"` | render German (the explicit choice), or Japanese if you'd rather honour the device — your call, with both values in hand |
1521
-
1522
- `appLanguage` is `null` when — and only when — the user never chose. It is never
1523
- a fallback in disguise, so `null` is a real signal you can branch on. Use
1524
- `resolvedLanguage` when you'd rather stay visually consistent with the native
1525
- frame around your page than follow the device.
1526
-
1527
- **When it arrives — and how to ask for it.** The app pushes this key on attach,
1528
- again right after the handshake ack, and on every change in the app Settings, so
1529
- a page already loaded re-renders without a restart. But the first push happens
1530
- while the WebView is still being created, before your bundle exists, so **send
1531
- the `language` command once after your handshake** to pull the current state:
1532
-
1533
- ```typescript
1534
- BridgeClient.send('language', {}); // replies with a `language` event
1535
- ```
1536
-
1537
- Without that pull, a first launch would show your default language until the
1538
- user changed it in Settings. Subscribe first, then pull.
1539
-
1540
- Do not read `navigator.language` instead: it follows the system, not the in-app
1541
- choice, and you would end up in Spanish while the native chrome around you is in
1542
- Portuguese.
1543
-
1544
- **Older app versions.** There is no version negotiation. An app older than
1545
- native `0.14.0` never emits this key, so the event simply never arrives — keep a
1546
- default language for that case, and read `nativeVersion` from the handshake ack
1547
- if you need to branch on it explicitly.
1548
-
1549
- ---
1550
-
1551
- ## 4. Develop in your browser (no app needed)
1552
-
1553
- Build your page, then serve it with the harness:
1554
-
1555
- ```bash
1556
- npx condor-mobile-bridge dev -i dist/index.html
1557
- ```
1558
-
1559
- Open `http://localhost:5173`. Your page runs inside the same sandboxed iframe,
1560
- shell, policy and CSP as in the app, and a simulated app answers your commands.
1561
- Rebuild and reload — the harness re-reads the file.
1562
-
1563
- Pick a **scenario** in the selector at the top (or with `--scenario`) to see
1564
- what the real app does to your page:
1565
-
1566
- - `cold-start` — the first device requests are lost while the app starts;
1567
- - `no-devices` — an account with no device: the device list never comes;
1568
- - `shared-serial` — two devices with the same serial number;
1569
- - `offline` — no internet, history unavailable.
1570
-
1571
- Your page is ready for a phone when it behaves in all of them. The page's
1572
- console (`[condor dev]` lines) shows each command, reply and dropped request,
1573
- and the dev panel in the corner lets you emit any event by hand.
1574
-
1575
- > Opening `dist/index.html` directly in a browser does not work: there is no
1576
- > shell around it, so `BridgeClient.handshake()` rejects at once with a message
1577
- > pointing to the harness.
1578
-
1579
- The [`examples/minimal-home`](../examples/minimal-home) example runs this way
1580
- out of the box.
1581
-
1582
- ---
1583
-
1584
- ## 5. Bundle format & constraints
1585
-
1586
- Your final artifact is a `.zip` containing exactly **one self-contained
1587
- `index.html`** at the root. Why the constraints:
1588
-
1589
- - The app injects your HTML via `iframe.srcdoc` with origin `null` and no base
1590
- URL → **relative references don't resolve** (`<script src="app.js">`,
1591
- `<img src="logo.png">` would break).
1592
- - The app's Content-Security-Policy **blocks external fetches** → CDN scripts,
1593
- Google Fonts, remote APIs called at load time won't work.
1594
-
1595
- So the bundle must have:
1596
- - ✅ One `index.html` at the zip root.
1597
- - ✅ JS and CSS **inlined** into the HTML.
1598
- - ✅ Images/icons/fonts as **`data:` URIs**.
1599
- - ✅ Total size **≤ 2 MB** (zipped). Note base64 inflates media ~33%.
1600
- - ❌ No external `https://` script/style/font references loaded at runtime.
1601
- - ❌ No code-splitting / dynamic `import()` across chunks (see below).
1602
-
1603
- ### Single-chunk build
1604
-
1605
- The bundler CLI is post-build and assumes **one JS + one CSS + assets**. If
1606
- your framework code-splits, the split chunks won't resolve in the sandbox.
1607
- Configure a single-file build:
1608
-
1609
- - **Vite:** add [`vite-plugin-singlefile`](https://github.com/richardtallent/vite-plugin-singlefile),
1610
- or set `build.rollupOptions.output.inlineDynamicImports: true` +
1611
- `manualChunks: undefined`.
1612
- - **Create React App:** avoid `React.lazy` / dynamic `import()` in routes so the
1613
- build emits a single bundle.
1614
- - **esbuild:** bundle to a single IIFE/ESM file (see the example's `build.mjs`).
1615
-
1616
- ---
1617
-
1618
- ## 6. Build the bundle
1619
-
1620
- Once your build produces a `dist/index.html` (referencing a local `main.js`,
1621
- CSS, images), run the CLI to inline everything and pack the zip:
1622
-
1623
- ```bash
1624
- npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
1625
- ```
1626
-
1627
- It inlines `<script src>`, `<link rel=stylesheet>`, CSS `url()`, and local
1628
- `<img>`/icons into a single `index.html`, then zips it. Output:
1629
-
1630
- ```
1631
- Built dist/bundle.zip
1632
- entry: index.html (123.4 KB self-contained)
1633
- bundleSize (zip): 45678 bytes
1634
- bundleHash (zip): sha256-<hex>
1635
- ```
1636
-
1637
- | Option | Description |
1638
- |---|---|
1639
- | `-i, --html <path>` | Input HTML. Required. |
1640
- | `-o, --out <path>` | Output `.zip` (default `dist/whitelabel-bundle.zip`). |
1641
- | `--max-bytes <n>` | Warning threshold (default 2 MB). |
1642
-
1643
- The hash is deterministic — the same input always yields the same
1644
- `bundleHash`. The CLI **warns** above 2 MB but the **app rejects** oversized
1645
- bundles and falls back to a default screen, so keep under the limit.
1646
-
1647
- ---
1648
-
1649
- ## 7. Deliver
1650
-
1651
- Send the resulting `dist/bundle.zip` through the publication channel Condor
1652
- gave you (the admin upload). The backend validates structure, self-containment,
1653
- and size on upload, and records the `bundleSize`/`bundleHash`. Once published,
1654
- the app downloads, verifies, and renders your Home — for **every user of your
1655
- company**, so test on a development test company first. See
1656
- [Testing on a device](./device-testing.md).
1657
-
1658
- ---
1659
-
1660
- ## 8. Troubleshooting
1661
-
1662
- | Symptom | Likely cause |
1663
- |---|---|
1664
- | Blank screen / nothing loads | Relative `<script src>` or external URL — must be inlined. Run the build CLI. |
1665
- | Images missing | Referenced by path instead of `data:` URI — the CLI inlines local images; remote images are blocked. |
1666
- | Works in browser, not in app | You relied on a network call or external asset at load time (CSP blocks it), or a code-split chunk didn't resolve. Go single-chunk + self-contained. |
1667
- | Bundle rejected on upload / falls back | Over 2 MB, multiple files at root, or not self-contained. |
1668
- | `handshake` rejects in the browser | The page is not inside the shell. Serve it with `npx condor-mobile-bridge dev`. |
1669
- | Stuck on "loading devices" on the phone | The device request was lost or the account has no device. Use `requestUserDevices()` and test the `cold-start` and `no-devices` scenarios. |
1670
- | A command does nothing | The command may not be allowed by the app policy; it's dropped silently. |
1671
-
1672
- ---
1673
-
1674
- ## Next steps
1675
-
1676
- - Copy [`examples/minimal-home`](../examples/minimal-home) and start from there.
1677
- - Full API reference: the [README](../README.md).
1678
-
1679
1673
  <!-- ===== docs/pilot-onboarding.md ===== -->
1680
1674
 
1681
- # White Label — Pilot Onboarding
1682
-
1683
- Welcome! This is the fast path to building and shipping your **White Label
1684
- Home** — the screen that runs inside the Condor app. You build a normal web app;
1685
- we package and render it. No Flutter / Android / iOS needed.
1675
+ # Canvas — pilot onboarding
1686
1676
 
1687
- For the full reference, see the [Client Guide](./client-guide.md). This page is
1688
- the quick checklist to get a pilot through the whole loop.
1689
-
1690
- ## What you'll end up with
1691
-
1692
- A self-contained `bundle.zip` that Condor publishes to your tenant — your Home
1693
- renders inside the app, talking to the device through the Condor bridge.
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.
1694
1679
 
1695
1680
  ## Who does what
1696
1681
 
1697
- | Condor provides | You build |
1682
+ | Condor provides | You build and run |
1698
1683
  |---|---|
1699
- | The SDK (`@condorinst/mobile-bridge`), guide & example | Your Home web app (HTML/CSS/JS or React) |
1700
- | The app that renders your bundle + the device bridge | The `bundle.zip` (via our CLI) |
1701
- | The publish channel (admin upload) | Delivering the `.zip` to us |
1702
-
1703
- ## Prerequisites
1704
-
1705
- - Node.js 18+
1706
- - npm access (the package is public — no credentials needed)
1707
-
1708
- ## Step-by-step
1709
-
1710
- ### 1. Install the SDK
1711
- ```bash
1712
- npm install @condorinst/mobile-bridge
1713
- ```
1714
- Pin `"@condorinst/mobile-bridge": "^0.21.0"` (beta; auto-updates patches).
1715
-
1716
- ### 2. Start from the example
1717
- Copy [`examples/minimal-home`](../examples/minimal-home) — it already wires the
1718
- bridge, renders devices, and builds a bundle.
1719
-
1720
- ### 3. Build your Home
1721
- Talk to the app via `BridgeClient`:
1722
- ```ts
1723
- import { BridgeClient, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
1724
-
1725
- BridgeClient.on(BRIDGE_KEYS.userDevice, (d) => render(d)); // listen first
1726
- const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' }); // ack.nativeVersion = capabilities
1727
- const r = await BridgeClient.requestUserDevices(); // resends until the list arrives
1728
- if (r.status === 'timeout') renderEmptyState(); // no reply in 30 s
1729
- BridgeClient.openSettings(); // open native settings
1730
- ```
1731
- Never ask for the device list with a single `send('userDevice')`: the first
1732
- request after the app starts is often lost, and an account with no device gets
1733
- no reply. `AGENTS.md` has the full boot sequence.
1734
-
1735
- ### 4. Test in your browser (no app needed)
1736
- ```bash
1737
- npx condor-mobile-bridge dev -i dist/index.html
1738
- ```
1739
- Serves your page inside the same sandbox, shell and policy as the app, with a
1740
- simulated app behind it. Check every scenario in the selector at the top:
1741
- `default`, `cold-start`, `no-devices`, `shared-serial`, `offline`.
1742
-
1743
- ### 5. Build the bundle
1744
- ```bash
1745
- npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
1746
- ```
1747
- This inlines everything into a single self-contained `index.html` and zips it.
1748
- It prints `bundleSize` + `bundleHash`.
1749
-
1750
- ### 5b. Validate before you deliver
1751
- ```bash
1752
- npx condor-mobile-bridge validate -i dist/bundle.zip --strict
1753
- ```
1754
- Applies the same gates the app applies on download, plus a lint for the APIs the
1755
- sandbox and the CSP block at runtime. **A non-zero exit means do not deliver.**
1756
- Errors are outright rejections by the app; warnings install fine and then
1757
- misbehave on the device, which is harder to diagnose.
1758
-
1759
- ### 6. Deliver
1760
- Send `dist/bundle.zip`, with the `bundleSize` and `bundleHash` printed by
1761
- `build`, through the channel Condor gave you. We publish it to your company —
1762
- first to a **development test company**, because a publish reaches every user
1763
- of the company.
1764
-
1765
- ### 7. Verify on a device
1766
- Condor publishes → the app downloads, validates and renders your Home the next
1767
- time it comes to the foreground. [Testing on a device](./device-testing.md)
1768
- explains the environments, how to inspect the WebView, and where errors go.
1769
-
1770
- ## Hard rules (or the app rejects the bundle)
1771
-
1772
- - ✅ Exactly **one `index.html`** at the zip root
1773
- - ✅ JS/CSS **inline**, media as **`data:` URIs**, no external/relative refs loaded at runtime
1774
- - ✅ **≤ 2 MB** (zipped)
1775
- - ✅ **Single-chunk build** (no code-splitting): Vite → `vite-plugin-singlefile`; CRA → no `React.lazy` / dynamic `import()`
1776
-
1777
- The CLI handles the inlining/zipping for you — just keep your build single-file.
1778
-
1779
- ## Reference
1780
-
1781
- - [Client Guide](./client-guide.md) — full walkthrough (bridge API, key catalog, dev harness)
1782
- - [Testing on a device](./device-testing.md) — from a published bundle to an inspected WebView
1783
- - [Troubleshooting](./troubleshooting.md) — starts from the symptom
1784
- - [`examples/minimal-home`](../examples/minimal-home) — runnable minimal client
1785
- - [README](../README.md) — API reference
1786
-
1787
- ## Pilot checklist
1788
-
1789
- - [ ] SDK installed (`npm i @condorinst/mobile-bridge`)
1790
- - [ ] Example runs in the harness (`npx condor-mobile-bridge dev`), in every scenario
1791
- - [ ] Home built with `BridgeClient` (handshake + at least one key + one command)
1792
- - [ ] Constraints respected (1 index.html, ≤2 MB, self-contained, single-chunk)
1793
- - [ ] `bundle.zip` generated by the CLI, and `validate --strict` exits 0
1794
- - [ ] `.zip` delivered to Condor
1795
- - [ ] Home verified on a device
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
1796
1719
  - [ ] Feedback shared with Condor
1797
- </content>
1720
+
1721
+ When something does not work: [`docs/troubleshooting.md`](./troubleshooting.md).
1798
1722
 
1799
1723
  <!-- ===== examples/minimal-home/src/main.ts ===== -->
1800
1724
 
@@ -1833,7 +1757,7 @@ interface DeviceRow {
1833
1757
  realTimeOn: boolean;
1834
1758
  }
1835
1759
 
1836
- type Phase = 'loading' | 'ready' | 'empty' | 'outside-shell';
1760
+ type Phase = 'loading' | 'ready' | 'empty' | 'error' | 'outside-shell';
1837
1761
 
1838
1762
  const state = {
1839
1763
  phase: 'loading' as Phase,
@@ -1875,6 +1799,19 @@ function telemetryFor(
1875
1799
  }
1876
1800
 
1877
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
+ }
1878
1815
  const next = new Map<number, DeviceRow>();
1879
1816
  for (const cfg of payload.devicesConfig) {
1880
1817
  const telemetry = telemetryFor(cfg, payload);
@@ -1941,11 +1878,14 @@ function body(): string {
1941
1878
  case 'loading':
1942
1879
  return '<p class="empty">Loading your devices…</p>';
1943
1880
  case 'empty':
1881
+ // probablyNoDevices: confirmed (0.18.0+) or suggested by an older app.
1944
1882
  return `<p class="empty">${
1945
1883
  state.probablyNoDevices
1946
1884
  ? 'No device is linked to your account yet.'
1947
1885
  : 'Could not load your devices.'
1948
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>';
1949
1889
  case 'ready':
1950
1890
  return [...state.devices.values()].map(renderCard).join('');
1951
1891
  }
@@ -1993,9 +1933,13 @@ BridgeClient.on(BRIDGE_KEYS.realTime, (payload) => {
1993
1933
 
1994
1934
  async function loadDevices(getFromServer: boolean): Promise<void> {
1995
1935
  const result = await BridgeClient.requestUserDevices({ getFromServer });
1996
- 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;
1997
1941
  state.probablyNoDevices = result.hasDevices === false;
1998
- state.phase = 'empty';
1942
+ state.phase = state.probablyNoDevices ? 'empty' : 'error';
1999
1943
  render();
2000
1944
  }
2001
1945