@condorinst/mobile-bridge 0.19.2 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,33 +19,55 @@ in memory until the next reload.
19
19
 
20
20
  ## Stage 0 — set up so you can see what you are doing
21
21
 
22
+ Start from `examples/minimal-home/` — a complete, working client with the
23
+ reference boot sequence, and a build script that produces a single-file bundle.
24
+ Copy it first, then install, so its `package.json` is the one you keep:
25
+
22
26
  ```bash
23
27
  mkdir my-home && cd my-home
24
- npm init -y
25
- npm install @condorinst/mobile-bridge
26
- npm install -D esbuild typescript
28
+ npm pack @condorinst/mobile-bridge@^0.21.0 && tar -xzf condorinst-mobile-bridge-*.tgz
29
+ cp -r package/examples/minimal-home/. . && rm -rf package condorinst-mobile-bridge-*.tgz
30
+ npm install
27
31
  ```
28
32
 
29
- Copy `examples/minimal-home/` from the package as your starting point — it is a
30
- complete, working client in about 160 lines, with a build script that produces
31
- a single-file bundle:
33
+ Or, in a project you already have: `npm install @condorinst/mobile-bridge@^0.21.0`
34
+ and copy only `src/main.ts`, `build.mjs` and `index.html` from
35
+ `node_modules/@condorinst/mobile-bridge/examples/minimal-home/`.
36
+
37
+ Pin the SDK to the minor you are building against. Below `1.0.0`, a minor can
38
+ change the contract — `^0.21.0` gives you patches only, which is what you want.
39
+
40
+ **Run it in the harness**, not in a bare browser tab:
32
41
 
33
42
  ```bash
34
- cp -r node_modules/@condorinst/mobile-bridge/examples/minimal-home/. .
35
- npm install
43
+ npm run build
44
+ npx condor-mobile-bridge dev -i dist/index.html --scenario default
36
45
  ```
37
46
 
38
- Pin the SDK to the minor you are building against. Below `1.0.0`, a minor can
39
- change the contract — `^0.19.0` gives you patches only, which is what you want.
47
+ The harness serves your bundle inside the same sandboxed iframe, shell, policy
48
+ and CSP as the app, with a simulated native side behind it, and a dev panel to
49
+ emit events by hand. It re-reads the file on every reload. Opening
50
+ `dist/index.html` directly does **not** work: there is no shell, so
51
+ `BridgeClient.handshake()` rejects at once.
52
+
53
+ The scenarios reproduce what the device does to you — switch with the selector
54
+ at the top or `--scenario`:
55
+
56
+ | Scenario | Your bundle must… |
57
+ | --- | --- |
58
+ | `default` | render two devices |
59
+ | `cold-start` | leave the loading screen although the first `userDevice` requests are lost |
60
+ | `no-devices` | show an empty state when `userDevice` never answers |
61
+ | `shared-serial` | keep two devices with the same `serialNo` apart |
62
+ | `offline` | explain that history is unavailable without internet |
40
63
 
41
- **Open `dist/index.html` in a browser.** With no native host detected, the SDK
42
- activates mock mode and installs a dev panel in the corner that lets you emit
43
- native events by hand. This is your entire development loop; you do not need
44
- the app to build a Home.
64
+ Add `--native-version null` (or an older version such as `0.15.0`) to test
65
+ your capability branches, and use the **◀ OS back** button at the top to press
66
+ the OS back button.
45
67
 
46
- Remember that in mock mode `nativeVersion` is `null`. That is not a bug in the
47
- mock — it is the same value you get from a real app whose shell is older than
48
- 0.6.0, so your code has to handle it either way.
68
+ If you already have a dev server (Vite), mount the same harness from your own
69
+ page with `createDevHarness()` from `@condorinst/mobile-bridge/dev` — in the
70
+ host page only, never in the bundle (`validate` rejects that).
49
71
 
50
72
  ## Stage 1 — render before you connect
51
73
 
@@ -72,9 +94,9 @@ const canQueryTimeRange = has('0.15.0');
72
94
  const hasLanguageKey = has('0.14.0');
73
95
  ```
74
96
 
75
- Write `cmp` as a plain numeric comparison of the three segments. Do not compare
76
- version strings lexically — `'0.9.0' > '0.16.0'` is `true` as strings and false
77
- as versions.
97
+ Or use the SDK's `versionAtLeast(ack.nativeVersion, '0.16.0')`, which does
98
+ exactly that and returns `false` for `null`. Do not compare version strings
99
+ lexically — `'0.9.0' > '0.16.0'` is `true` as strings and false as versions.
78
100
 
79
101
  **Do not branch on `ack.accepted`.** It is always `true`; the native side never
80
102
  refuses a handshake. Anything you gate on it is ungated.
@@ -89,7 +111,27 @@ BridgeClient.on(BRIDGE_KEYS.userDevice, (p) => { setDevices(p); render(); });
89
111
  BridgeClient.on(BRIDGE_KEYS.systemStatus, (s) => { setStatus(s); render(); });
90
112
  BridgeClient.on(BRIDGE_KEYS.deviceConnection, (c) => { setConnected(c); render(); });
91
113
 
92
- BridgeClient.send('userDevice', { getFromServer: false });
114
+ const result = await BridgeClient.requestUserDevices();
115
+ if (result.status === 'timeout') renderEmptyState(result.hasDevices === false);
116
+ ```
117
+
118
+ **Do not replace the helper with `send('userDevice')`.** The device list is
119
+ served by the app's background service, which starts after your bundle; a
120
+ request sent before it is up is lost, and an account with no device never gets
121
+ a reply. The helper resends with backoff for 30 s and then tells you to show the
122
+ empty state. Keep the `userDevice` subscription: roster changes arrive on it
123
+ later without a request. `docs/troubleshooting.md` has the details.
124
+
125
+ Read device data from the typed items: the list is `devicesConfig`, and the
126
+ name, battery and last sync are in `devicesTelemetry` — join them on
127
+ `deviceId`, never on `serialNo` alone.
128
+
129
+ Then follow the connection state of each device:
130
+
131
+ ```ts
132
+ for (const d of result.devices.devicesConfig) {
133
+ BridgeClient.send('deviceConnection', { type: 'add', serialNo: d.serialNo, deviceId: d.deviceId });
134
+ }
93
135
  ```
94
136
 
95
137
  `on` returns an unsubscribe function. If your UI has views that mount and
@@ -130,6 +172,12 @@ BridgeClient.send('realTime', { serialNo, value: true });
130
172
 
131
173
  `serialNo` is a **number**. A string is dropped in silence.
132
174
 
175
+ Two devices can share a `serialNo` (an ActLumus and an ActLumus Plus). To act on
176
+ one of them, send its `deviceId` **together with** `serialNo`, taken from the
177
+ `userDevice` list: `BridgeClient.send('realTime', { serialNo, deviceId, value })`.
178
+ A command with only the shared `serialNo` is ignored on purpose. Gate this on
179
+ `nativeVersion` ≥ 0.17.0; details in `docs/bridge-contract.md`.
180
+
133
181
  In a raw channel, `null` means the channel does not exist on that hardware
134
182
  generation. It does not mean zero — `0.0` is a valid reading. Coercing `null`
135
183
  to `0` invents data, and has previously led to a confident, wrong conclusion
@@ -151,7 +199,11 @@ Timestamps are **device-local**. Build ranges with local `Date` getters, never
151
199
  `toISOString()` — converting to UTC shifts every series by the timezone offset
152
200
  and produces data that looks entirely plausible.
153
201
 
154
- Paginate with the cursors exactly as received. They are opaque.
202
+ Paginate with the cursors exactly as received. They are opaque. Without a range
203
+ the first page is the **most recent** one (go older with `endingBefore`); with
204
+ `startTime` it is the **oldest** of the range (go newer with `startingAfter`).
205
+ A device writes one log per period — programmable, 60 s by default — so a week
206
+ is about 10 000 logs: use `limit: 1000` and do not assume the cadence.
155
207
 
156
208
  ## Stage 7 — back button
157
209
 
@@ -191,16 +243,22 @@ necessary, not sufficient.
191
243
  (`syncTime`, `deleteMemory`, `logout`) may never ack, because the person
192
244
  can decline.
193
245
  - [ ] No `localStorage`, no `fetch`, no `eval`. Not even behind a `try`.
194
- - [ ] `serialNo` is a number everywhere.
246
+ - [ ] `serialNo` is a number everywhere, and a command for a device whose `serialNo`
247
+ repeats on the account also carries its `deviceId` (`nativeVersion` ≥ 0.17.0).
195
248
  - [ ] Errors are reported through `BridgeClient.send('jsError', …)`, the only
196
249
  channel out.
197
- - [ ] Tested in the browser mock with `nativeVersion === null`.
250
+ - [ ] Every harness scenario passes: `default`, `cold-start`, `no-devices`,
251
+ `shared-serial`, `offline`.
252
+ - [ ] The `nativeVersion === null` branch renders something sensible.
198
253
 
199
254
  ## Delivery
200
255
 
201
256
  Hand Condor the `.zip` along with its size and `sha256-<hex>` hash, both printed
202
257
  by the `build` command. The app verifies the hash against the manifest and
203
- rejects any mismatch. `docs/pilot-onboarding.md` has the full checklist.
258
+ rejects any mismatch. Condor publishes it for your company, which reaches every
259
+ user of that company — test on a development test company first.
260
+ `docs/device-testing.md` explains the path to the phone and how to inspect the
261
+ bundle there; `docs/pilot-onboarding.md` has the full checklist.
204
262
 
205
263
  ## When something does not work
206
264
 
@@ -29,10 +29,10 @@ Four numbers coexist. Only one tells you what the device can do.
29
29
 
30
30
  | Number | Current | What it means |
31
31
  | --- | --- | --- |
32
- | `nativeVersion` | `0.16.0` | **The capability signal.** The bridge schema the app on this device implements. |
32
+ | `nativeVersion` | `0.17.0` | **The capability signal.** The bridge schema the app on this device implements. |
33
33
  | `shellVersion` | `0.6.0` | The Condor shell wrapping your iframe. |
34
34
  | client protocol | `0.5.0` | The postMessage protocol `BridgeClient` speaks. |
35
- | package | `0.19.0` | The npm version you compiled against. |
35
+ | package | `0.21.0` | The npm version you compiled against. |
36
36
 
37
37
  ```ts
38
38
  const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
@@ -44,11 +44,11 @@ Two traps:
44
44
  **`accepted` is always `true`.** There is no version negotiation and the native
45
45
  side never refuses. Branching on it is branching on a constant.
46
46
 
47
- **`nativeVersion` can be `null`.** In a plain browser (mock mode), and in
48
- bundles compiled against SDK ≤ 0.16.x, where the shell did not relay it. Treat
49
- `null` as "assume only the baseline" and keep a working default. Your browser
50
- development environment always hits this path, so it is not a rare case — it is
51
- the one you see most.
47
+ **`nativeVersion` can be `null`.** When the shell could not reach the native
48
+ side, and in bundles compiled against SDK ≤ 0.16.x, where the shell did not
49
+ relay it. Treat `null` as "assume only the baseline" and keep a working
50
+ default. The dev harness reports `0.17.0` by default; `--native-version null`
51
+ exercises this path.
52
52
 
53
53
  ### Capability matrix
54
54
 
@@ -58,6 +58,7 @@ the one you see most.
58
58
  | `language` key and the `language` pull command | `nativeVersion` ≥ 0.14.0 |
59
59
  | `sensorLog` accepts `startTime` / `endTime` | `nativeVersion` ≥ 0.15.0 |
60
60
  | `deviceLightSensors` carries raw `light_sensor_*` channels, and the `actlumus2` variant | `nativeVersion` ≥ 0.16.0 |
61
+ | 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 |
61
62
  | `nativeVersion` reaches the client at all | `shellVersion` ≥ 0.6.0 |
62
63
 
63
64
  `CHANGELOG.md` is the precise record of when each of these landed.
@@ -69,8 +70,8 @@ with `BridgeClient.on(key, cb)`, which returns an unsubscribe function.
69
70
 
70
71
  | Key | Payload type | Emitted when |
71
72
  | --- | --- | --- |
72
- | `pingPong` | `PingPongPayload` | reply to the `ping` command |
73
- | `userDevice` | `UserDevicePayload` | reply to `userDevice`, and on roster changes |
73
+ | `pingPong` | `PingPongPayload` | reply to `ping`, once the app's background service is up |
74
+ | `userDevice` | `UserDevicePayload` | reply to `userDevice` **when the list loads** (not guaranteed — see below), and on roster changes |
74
75
  | `deviceLightSensors` | `DeviceLightSensorsPayload` | real-time stream, only while `realTime` is on for that device |
75
76
  | `deviceConnection` | `DeviceConnectionPayload` | a device connects or disconnects |
76
77
  | `deviceNotification` | `DeviceNotificationPayload` | device-level notice, including errors |
@@ -115,7 +116,8 @@ conclusions before.
115
116
  bluetooth: boolean; internet: boolean; permission: boolean; hasDevices: boolean;
116
117
  missingEssentialPermissions: string[]; // e.g. ["bluetoothScan", "bluetoothConnect"]
117
118
  missingOptionalPermissions: string[]; // e.g. ["notification", "backgroundLocation"]
118
- deviceSyncError: Record<number, string>; // serialNo → message, already localized
119
+ deviceSyncError: Record<number, string>; // serialNo → message, already localized; a shared serialNo is left out
120
+ deviceSyncErrorById?: Record<number, string>; // deviceId → message, every device (nativeVersion ≥ 0.17.0)
119
121
  }
120
122
  ```
121
123
 
@@ -123,6 +125,52 @@ conclusions before.
123
125
  off. Render a blocking card; wire its button to
124
126
  `BridgeClient.requestPermission(name)` with a name taken from that array.
125
127
 
128
+ **`hasDevices` is a hint.** It is `true` only after the app loaded a non-empty
129
+ device list from the server. `false` means "no linked device" **or** "the app
130
+ could not ask" (no internet, session problem). Do not show "you have no device"
131
+ on `false` alone; see the next section.
132
+
133
+ ### `userDevice` in detail
134
+
135
+ **It is not guaranteed to answer.** `userDevice` (and `ping`) are handled by
136
+ the app's background service, which starts after your bundle is running; a
137
+ request sent before it is up is lost. And the app replies only when the list
138
+ loads: an account with no linked device, or a failed request, gets **no reply
139
+ at all** — never an empty list. Use the helper, which resends with backoff and
140
+ tells you when to give up:
141
+
142
+ ```ts
143
+ const r = await BridgeClient.requestUserDevices(); // getFromServer: false
144
+ // { status: 'ok', devices } | { status: 'timeout', hasDevices: boolean | null }
145
+ ```
146
+
147
+ **`getFromServer`.** `false` (default) answers from the app's memory or cache
148
+ while it is fresh, and asks the server otherwise — use it at boot. `true` asks
149
+ the server first and falls back to the cache if that fails — use it for a
150
+ "Refresh" button.
151
+
152
+ **Shape.** Three arrays, typed as `DeviceConfigItem`, `DeviceTelemetryItem` and
153
+ `DeviceUserConfigItem` in `dist/index.d.ts`:
154
+
155
+ | Array | Holds | Notable fields |
156
+ | --- | --- | --- |
157
+ | `devicesConfig` | one item per linked device — **the list to render** | `deviceId`, `serialNo`, `modelId` (`'actlumus'`, `'actlumus_plus'`; absent = ActLumus), `deviceBodyPart` (`'wrist'` \| `'necklace'` \| `null`), `bleConnectionPeriod` (minutes) |
158
+ | `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[]` |
159
+ | `devicesUserConfig` | per-device toggles | `syncTime`, `realTime`, `deleteMemory`, `readAllMemory`, `readAllSystemLog` |
160
+
161
+ The name and battery of a device are in **telemetry**, not in the config. A
162
+ device that never synced may have no telemetry item, or one with every reading
163
+ `null`: render it anyway.
164
+
165
+ **Joining the arrays.** Join on `deviceId`. An item of `devicesTelemetry` or
166
+ `devicesUserConfig` without `deviceId` is legacy data: fall back to `serialNo`
167
+ only when that `serialNo` appears once in `devicesConfig`.
168
+
169
+ **Sync errors.** `devicesTelemetry[].syncErrors` is the per-device history
170
+ (`attemptDate`, `message`, `messageCode`); `systemStatus.deviceSyncErrorById`
171
+ is the current error per device, already localized. Show the current one; use
172
+ the history for detail screens.
173
+
126
174
  ## Commands
127
175
 
128
176
  `BridgeClient.send(command, args)` is **fire-and-forget**: no return value, no
@@ -133,8 +181,8 @@ column.
133
181
  | --- | --- | --- | --- |
134
182
  | `ping` | — | no | `pingPong` |
135
183
  | `userDevice` | `{ getFromServer?: boolean }` | no | `userDevice` |
136
- | `realTime` | `{ serialNo: number, value: boolean }` | no | `realTime` |
137
- | `deviceConnection` | `{ type: 'add' \| 'remove', serialNo: number }` | no | `deviceConnection` |
184
+ | `realTime` | `{ serialNo: number, deviceId?: number, value: boolean }` | no | `realTime` |
185
+ | `deviceConnection` | `{ type: 'add' \| 'remove', serialNo: number, deviceId?: number }` — subscribe to / stop following one device's connection state | no | `deviceConnection` |
138
186
  | `sensorLog` | see below | no | `sensorLog` |
139
187
  | `openSettings` | — | no | — |
140
188
  | `requestPermission` | `{ permission: string }` | no | — (re-emits `systemStatus`) |
@@ -142,14 +190,45 @@ column.
142
190
  | `navigationState` | `{ canGoBack: boolean }` | no | — |
143
191
  | `language` | — | no | `language` |
144
192
  | `jsError` | `{ message, stack?, source?, lineno?, colno? }` | no | — |
145
- | `syncTime` | `{ serialNo: number }` | **yes** | `syncTime` |
146
- | `deleteMemory` | `{ serialNo: number }` | **yes** | `deleteMemory` |
193
+ | `syncTime` | `{ serialNo: number, deviceId?: number }` | **yes** | `syncTime` |
194
+ | `deleteMemory` | `{ serialNo: number, deviceId?: number }` | **yes** | `deleteMemory` |
147
195
  | `logout` | — | **yes** | — |
148
196
  | `dfu` | — | denied | — |
149
197
 
150
198
  `serialNo` is an integer. Passing a string makes the native side drop the
151
199
  message in silence.
152
200
 
201
+ `deviceConnection` **pairs nothing.** `type: 'add'` asks the app to report the
202
+ BLE connection state of a device you already have from `userDevice`; the state
203
+ then arrives on the `deviceConnection` key whenever it changes. `'remove'` stops
204
+ it. Send `add` for each device after the list arrives.
205
+
206
+ ### Two devices with the same `serialNo`
207
+
208
+ An ActLumus and an ActLumus Plus have separate serial sequences, so one account
209
+ can hold both with the **same** `serialNo` (the server `deviceId` is always
210
+ unique). A command that carries only `serialNo` cannot say which one you mean:
211
+ the app **ignores it** instead of guessing, because guessing wrong would act on
212
+ the wrong device (erasing the wrong memory, for instance).
213
+
214
+ - **Send `deviceId` together with `serialNo`**, taken from the `userDevice`
215
+ list (`devicesConfig[].deviceId`). Always keep `serialNo` in the message: an
216
+ app older than 0.17.0 drops in silence a command that lacks it.
217
+ - **Read `deviceId` when it is there.** `deviceConnection`,
218
+ `deviceNotification` and `deviceLightSensors` carry it **only when the
219
+ `serialNo` is shared**; with a unique `serialNo` it is absent and `serialNo`
220
+ alone identifies the device. Never treat a missing `deviceId` as "unknown
221
+ device".
222
+ - **Acks always carry `serialNo`**, plus the `deviceId` you sent.
223
+ - **`systemStatus.deviceSyncError` leaves a shared `serialNo` out**, because no
224
+ single device owns it. Read `deviceSyncErrorById` for every device.
225
+ - **Gate on `nativeVersion` ≥ 0.17.0** before relying on any of this, and
226
+ degrade to `serialNo` only below it (where a shared `serialNo` was already
227
+ unaddressable).
228
+ - `sensorLog` takes no `deviceId` yet: for a shared `serialNo` the backend
229
+ answers with the error code `DEVICE_SERIAL_AMBIGUOUS`. Show your own copy for
230
+ it; do not retry.
231
+
153
232
  ### Commands that need human confirmation
154
233
 
155
234
  `syncTime`, `deleteMemory` and `logout` open a modal owned by the Condor shell.
@@ -164,6 +243,8 @@ timeout on any spinner.
164
243
  Prefer these over raw `send` — they add correlation, timeouts and types:
165
244
 
166
245
  ```ts
246
+ BridgeClient.requestUserDevices({ getFromServer }) // resends until the list arrives, 30 s default
247
+ BridgeClient.waitForService() // resends ping until the background service answers
167
248
  BridgeClient.fetchSensorLog(query, { timeoutMs }) // correlates requestId, 10 s default
168
249
  BridgeClient.requestPermission(name)
169
250
  BridgeClient.reportNavigation(canGoBack)
@@ -197,9 +278,48 @@ series by the timezone offset, producing plausible data and no error at all.
197
278
  **Cursors are opaque.** `startingAfter` and `endingBefore` are mutually
198
279
  exclusive; echo back exactly what you received and never construct one.
199
280
 
200
- With a time range, `hasMore` and `hasPrevious` are computed *within* the
201
- period, so `hasMore: false` means "this period is complete", not "no more data
202
- exists".
281
+ **Sweeping a period.** Keep `startTime` and `endTime` on **every** page and add
282
+ the cursor; the cursors are valid only inside the range they came from:
283
+
284
+ ```ts
285
+ const logs: SensorLogItem[] = [];
286
+ let startingAfter: string | undefined;
287
+ for (;;) {
288
+ const page = await BridgeClient.fetchSensorLog({
289
+ requestId: crypto.randomUUID(), serialNo, limit: 1000,
290
+ startTime: '2026-10-13 00:00:00', endTime: '2026-10-20 00:00:00', // local time
291
+ ...(startingAfter ? { startingAfter } : {}),
292
+ });
293
+ if (!page.success) break; // see the error codes below
294
+ logs.push(...(page.logs ?? []));
295
+ if (!page.hasMore || !page.startingAfter) break;
296
+ startingAfter = page.startingAfter;
297
+ }
298
+ ```
299
+
300
+ **Which page comes first.** `logs` is always chronological ascending inside a
301
+ page. Which page you get first depends on the query:
302
+
303
+ | Query | First page | Next page |
304
+ | --- | --- | --- |
305
+ | no cursor, no range | the **most recent** `limit` logs | older: `endingBefore` = the reply's `endingBefore`, while `hasPrevious` |
306
+ | `startTime` (with or without `endTime`) | the **oldest** logs of the range | newer: `startingAfter` = the reply's `startingAfter`, while `hasMore` |
307
+ | only `endTime` | the most recent logs before it | older, as in the first row |
308
+
309
+ `limit` defaults to 10. With a time range, `hasMore` and `hasPrevious` are
310
+ computed *within* the period, so `hasMore: false` means "this period is
311
+ complete", not "no more data exists".
312
+
313
+ **How many logs to expect.** One per log period, which is **programmable per
314
+ device** (default 60 s, so about 1 440 per day). Do not hard-code it: derive the
315
+ period from consecutive `log_time` values, and page with `limit` up to 1000.
316
+ Pages depend on the previous cursor, so they cannot be fetched in parallel.
317
+
318
+ **Fields.** Each log is a `SensorLogItem`: `log_time`, `body_part`,
319
+ `temperature` (°C), `lux_photopic` and `lux_melanopic` (lux), the raw light
320
+ channels and the activity counters. `null` on a channel means the hardware does
321
+ not have it, never zero. `device_log_time` and `app_log_time` mark the upload
322
+ batch, not the log — do not plot them.
203
323
 
204
324
  ## Error shapes
205
325
 
@@ -209,11 +329,15 @@ There are three, and none of them is a thrown exception.
209
329
  `no-confirmation-handler`, `confirmation-error`, `user-rejected`, `not-allowed`.
210
330
  Visible only as `console.warn`.
211
331
 
212
- **2. `sensorLog` failure** — `{ requestId, success: false, error }`. The codes
213
- are `CONFLICTING_CURSORS`, `INVALID_BODY_PART`, `INVALID_LIMIT`,
332
+ **2. `sensorLog` failure** — `{ requestId, success: false, error }`. The
333
+ contract codes are `CONFLICTING_CURSORS`, `INVALID_BODY_PART`, `INVALID_LIMIT`,
214
334
  `INVALID_START_TIME`, `INVALID_END_TIME`, `INVALID_TIME_RANGE`,
215
- `MISSING_DEVICE_SERIAL_NO`, `invalid session`, `device not found for this user`.
216
- `error` is a **diagnostic code, never user-facing text** — map it to your own
335
+ `MISSING_DEVICE_SERIAL_NO`, `DEVICE_SERIAL_AMBIGUOUS`, `invalid session` and
336
+ `device not found for this user`. Some carry a suffix (`INVALID_LIMIT: 5000`),
337
+ so **match by prefix**, not equality. When the request never reached the
338
+ server (no internet, timeout), `error` is free text from the transport — so
339
+ treat any value outside the list as "history unavailable right now" and offer a
340
+ retry. `error` is a **diagnostic, never user-facing text** — map it to your own
217
341
  copy.
218
342
 
219
343
  **3. Silence** — nothing reaches JavaScript. This is the dominant failure mode.
@@ -224,12 +348,17 @@ The six paths that produce it are enumerated in `docs/troubleshooting.md`.
224
348
  The app owns the OS back button and will delegate it to you only if you ask.
225
349
 
226
350
  ```ts
227
- BridgeClient.reportNavigation(true); // "I have internal history"
228
- BridgeClient.onBack(() => history.back()); // go back exactly one page
351
+ const stack: string[] = ['home']; // keep navigation in memory
352
+ function go(view: string) { stack.push(view); render(); BridgeClient.reportNavigation(stack.length > 1); }
353
+ BridgeClient.onBack(() => { stack.pop(); render(); BridgeClient.reportNavigation(stack.length > 1); });
229
354
  ```
230
355
 
231
- Report `false` when you are at your root, and the app handles back itself.
232
- Report state on every internal navigation, not once at startup.
356
+ Keep the navigation stack in memory rather than in the History API: the
357
+ bundle lives in a sandboxed `srcdoc` iframe, where `history.pushState` is not
358
+ something to rely on. Report `false` when you are at your root, and the app
359
+ handles back itself. Report state on every internal navigation, not once at
360
+ startup. In the harness, the **◀ OS back** button at the top presses the OS
361
+ back button for you.
233
362
 
234
363
  ## Status bar
235
364
 
@@ -61,13 +61,13 @@ Pin the version with a caret:
61
61
  ```jsonc
62
62
  // package.json
63
63
  "dependencies": {
64
- "@condorinst/mobile-bridge": "^0.19.0"
64
+ "@condorinst/mobile-bridge": "^0.21.0"
65
65
  }
66
66
  ```
67
67
 
68
- **Updates.** The SDK is on `0.x` during the pilot. With `^0.19.0`, running
69
- `npm install` (or your CI) auto-picks **patch** releases (`0.8.x`) — bug fixes,
70
- no action needed. A `0.9.0` (a larger change during beta) is an intentional
68
+ **Updates.** The SDK is on `0.x` during the pilot. With `^0.21.0`, running
69
+ `npm install` (or your CI) auto-picks **patch** releases (`0.21.x`) — bug fixes,
70
+ no action needed. A `0.22.0` (a larger change during beta) is an intentional
71
71
  opt-in: you bump the range when you're ready. `1.0.0` will mark the
72
72
  production-stable contract.
73
73
 
@@ -112,7 +112,7 @@ const unsubscribe = BridgeClient.on(BRIDGE_KEYS.deviceConnection, (payload) => {
112
112
  ### Send commands (you → native)
113
113
 
114
114
  ```typescript
115
- BridgeClient.send('userDevice', { getFromServer: true }); // refresh device list
115
+ const devices = await BridgeClient.requestUserDevices(); // device list, resent until it arrives
116
116
  BridgeClient.send('realTime', { serialNo: 1234, value: true }); // start real-time
117
117
  BridgeClient.openSettings(); // open native Settings
118
118
  BridgeClient.requestPermission('bluetoothScan'); // in-app permission dialog
@@ -120,6 +120,12 @@ BridgeClient.requestPermission('bluetoothScan'); // in-app permission
120
120
 
121
121
  > Commands are gated by the app's policy. A command that isn't allowed is
122
122
  > silently ignored — design your UI to not depend on a response you won't get.
123
+ >
124
+ > Ask for the device list with `requestUserDevices()`, not with
125
+ > `send('userDevice')`: the first request after the app starts is often lost,
126
+ > and an account with no device gets no reply at all. The helper resends with
127
+ > backoff and resolves `{ status: 'timeout', hasDevices }` after 30 s, so you can
128
+ > show an empty state. See [Troubleshooting](./troubleshooting.md).
123
129
 
124
130
  ### Handle the system back button
125
131
 
@@ -265,32 +271,34 @@ if you need to branch on it explicitly.
265
271
 
266
272
  ## 4. Develop in your browser (no app needed)
267
273
 
268
- When your code runs in a normal browser (not inside the app), the SDK
269
- auto-activates **mock mode**: the bridge no longer expects a native side, and
270
- you get tooling to simulate it.
274
+ Build your page, then serve it with the harness:
271
275
 
272
- ```typescript
273
- import { Bridge, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
274
-
275
- if (Bridge.isMock) {
276
- // Floating dev panel: inspect sent messages and emit native events by hand.
277
- Bridge.installDevPanel({ position: 'bottom-right', theme: 'dark' });
278
- }
276
+ ```bash
277
+ npx condor-mobile-bridge dev -i dist/index.html
279
278
  ```
280
279
 
281
- The dev panel lets you:
282
- - See every command your UI sends.
283
- - Emit any `BRIDGE_KEYS` event with a JSON payload (simulate the device).
284
- - Enable **auto-respond** so a `send` triggers a canned `emit` — closing the
285
- full loop without manual clicks (provide an `onSendForAutoRespond` policy;
286
- see the [README](../README.md#auto-respond-toggle)).
280
+ Open `http://localhost:5173`. Your page runs inside the same sandboxed iframe,
281
+ shell, policy and CSP as in the app, and a simulated app answers your commands.
282
+ Rebuild and reload — the harness re-reads the file.
283
+
284
+ Pick a **scenario** in the selector at the top (or with `--scenario`) to see
285
+ what the real app does to your page:
286
+
287
+ - `cold-start` — the first device requests are lost while the app starts;
288
+ - `no-devices` — an account with no device: the device list never comes;
289
+ - `shared-serial` — two devices with the same serial number;
290
+ - `offline` — no internet, history unavailable.
291
+
292
+ Your page is ready for a phone when it behaves in all of them. The page's
293
+ console (`[condor dev]` lines) shows each command, reply and dropped request,
294
+ and the dev panel in the corner lets you emit any event by hand.
287
295
 
288
- > In mock mode the handshake does **not** resolve on its own — emit a
289
- > `handshake-ack` (the dev panel and auto-respond can do this for you). This is
290
- > intentional, so your tests are explicit about the native state.
296
+ > Opening `dist/index.html` directly in a browser does not work: there is no
297
+ > shell around it, so `BridgeClient.handshake()` rejects at once with a message
298
+ > pointing to the harness.
291
299
 
292
- The [`examples/minimal-home`](../examples/minimal-home) example is set up to run
293
- this way out of the box.
300
+ The [`examples/minimal-home`](../examples/minimal-home) example runs this way
301
+ out of the box.
294
302
 
295
303
  ---
296
304
 
@@ -364,7 +372,9 @@ bundles and falls back to a default screen, so keep under the limit.
364
372
  Send the resulting `dist/bundle.zip` through the publication channel Condor
365
373
  gave you (the admin upload). The backend validates structure, self-containment,
366
374
  and size on upload, and records the `bundleSize`/`bundleHash`. Once published,
367
- the app downloads, verifies, and renders your Home.
375
+ the app downloads, verifies, and renders your Home — for **every user of your
376
+ company**, so test on a development test company first. See
377
+ [Testing on a device](./device-testing.md).
368
378
 
369
379
  ---
370
380
 
@@ -376,7 +386,8 @@ the app downloads, verifies, and renders your Home.
376
386
  | Images missing | Referenced by path instead of `data:` URI — the CLI inlines local images; remote images are blocked. |
377
387
  | 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. |
378
388
  | Bundle rejected on upload / falls back | Over 2 MB, multiple files at root, or not self-contained. |
379
- | `handshake` never resolves in browser | Expected in mock mode — emit a `handshake-ack` (dev panel / auto-respond). |
389
+ | `handshake` rejects in the browser | The page is not inside the shell. Serve it with `npx condor-mobile-bridge dev`. |
390
+ | 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. |
380
391
  | A command does nothing | The command may not be allowed by the app policy; it's dropped silently. |
381
392
 
382
393
  ---
@@ -0,0 +1,84 @@
1
+ # Testing on a device
2
+
3
+ The harness (`npx condor-mobile-bridge dev`) catches most problems in the
4
+ browser. This page covers the rest: how a bundle reaches a phone, what that
5
+ costs, and how to see what it is doing once it is there.
6
+
7
+ ## How a bundle reaches the screen
8
+
9
+ You do not install a bundle on a phone. The app downloads it:
10
+
11
+ 1. You deliver `bundle.zip`, its `bundleSize` and its `bundleHash` (printed by
12
+ `condor-mobile-bridge build`) to your Condor contact.
13
+ 2. Condor publishes it for **your company**. There is exactly one current
14
+ bundle per company.
15
+ 3. Each copy of the app asks for its company's manifest when the Home opens
16
+ and every time the app returns to the foreground. If the `bundleHash`
17
+ changed, it downloads the `.zip`, checks size and hash, and swaps the Home
18
+ without a restart. Otherwise it keeps the cached copy, also when offline.
19
+
20
+ Consequences:
21
+
22
+ - **Reinstalling the app changes nothing.** The bundle is not inside the app.
23
+ - **Publishing reaches every user of that company** the next time their app
24
+ comes to the foreground. There is no per-user or per-device preview today.
25
+ - **A rejected bundle is invisible to the person.** The app keeps its previous
26
+ bundle, or shows its default screen, if the download fails a gate — which is
27
+ why `validate --strict` must pass before you hand anything over.
28
+
29
+ ## Development and production are separate worlds
30
+
31
+ Condor runs a **development** environment next to production. An app build
32
+ talks to exactly one of them, and accounts, devices and bundles do not cross
33
+ over:
34
+
35
+ - a development build of the app sees only development accounts and bundles;
36
+ - the store app sees only production.
37
+
38
+ So, to test on a phone, ask Condor for three things that match: a **test
39
+ account** in a **test company** in the development environment, a
40
+ **development build** of the app (Android), and to publish your bundle **to that
41
+ test company**. Never iterate on a production company: every publish reaches
42
+ real people.
43
+
44
+ Each iteration on the device is therefore: build → validate → hand over →
45
+ Condor publishes → bring the app to the foreground. That is slow, so do every
46
+ iteration you can in the harness first.
47
+
48
+ ## Seeing what the bundle does on the phone
49
+
50
+ **Release builds of the app cannot be inspected.** Ask Condor for a
51
+ development (debuggable) Android build. Then:
52
+
53
+ 1. Connect the phone by USB with USB debugging on.
54
+ 2. Open `chrome://inspect/#devices` in desktop Chrome and click **inspect** on
55
+ the app's WebView.
56
+ 3. In the DevTools console, switch the context selector from `top` to the
57
+ frame `about:srcdoc` — that is your bundle. The `top` frame is the Condor
58
+ shell.
59
+
60
+ There you see your own `console.*` output, uncaught errors, and the shell's
61
+ refusals: a `nack` reaches you only as a `[BridgeClient] command nacked by
62
+ shell` warning, so this is the one place to find out that a command was denied
63
+ or declined.
64
+
65
+ What you will **not** see from the WebView: the native side. A command dropped
66
+ by the app (wrong argument type, unknown command, a request lost while the
67
+ background service starts) leaves no trace in the console — reproduce it in the
68
+ harness instead, whose console says what was dropped and why.
69
+
70
+ ## Errors after the fact
71
+
72
+ `BridgeClient.send('jsError', { message, stack })` sends an error to Condor's
73
+ logging and crash reporting (rate limited: 20 per 10 s, 4 KB per field). Those
74
+ are Condor's tools: when you need the errors of a test session, ask your Condor
75
+ contact, with the test account and the time window.
76
+
77
+ ## Before you ask for a publish
78
+
79
+ - [ ] `npx condor-mobile-bridge validate -i dist/bundle.zip --strict` exits 0.
80
+ - [ ] Every harness scenario passes (`default`, `cold-start`, `no-devices`,
81
+ `shared-serial`, `offline`).
82
+ - [ ] You know which environment and which company the bundle is for.
83
+ - [ ] You sent `bundleSize` and `bundleHash` with the `.zip`, so the published
84
+ bundle can be checked against what you built.