@condorinst/mobile-bridge 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.22.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.22.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.22.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`:
40
55
 
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.
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 |
45
63
 
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.
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.
67
+
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
@@ -157,7 +199,11 @@ Timestamps are **device-local**. Build ranges with local `Date` getters, never
157
199
  `toISOString()` — converting to UTC shifts every series by the timezone offset
158
200
  and produces data that looks entirely plausible.
159
201
 
160
- 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.
161
207
 
162
208
  ## Stage 7 — back button
163
209
 
@@ -201,13 +247,18 @@ necessary, not sufficient.
201
247
  repeats on the account also carries its `deviceId` (`nativeVersion` ≥ 0.17.0).
202
248
  - [ ] Errors are reported through `BridgeClient.send('jsError', …)`, the only
203
249
  channel out.
204
- - [ ] 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.
205
253
 
206
254
  ## Delivery
207
255
 
208
256
  Hand Condor the `.zip` along with its size and `sha256-<hex>` hash, both printed
209
257
  by the `build` command. The app verifies the hash against the manifest and
210
- 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.
211
262
 
212
263
  ## When something does not work
213
264
 
@@ -32,7 +32,7 @@ Four numbers coexist. Only one tells you what the device can do.
32
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.20.0` | The npm version you compiled against. |
35
+ | package | `0.22.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
 
@@ -70,8 +70,8 @@ with `BridgeClient.on(key, cb)`, which returns an unsubscribe function.
70
70
 
71
71
  | Key | Payload type | Emitted when |
72
72
  | --- | --- | --- |
73
- | `pingPong` | `PingPongPayload` | reply to the `ping` command |
74
- | `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 |
75
75
  | `deviceLightSensors` | `DeviceLightSensorsPayload` | real-time stream, only while `realTime` is on for that device |
76
76
  | `deviceConnection` | `DeviceConnectionPayload` | a device connects or disconnects |
77
77
  | `deviceNotification` | `DeviceNotificationPayload` | device-level notice, including errors |
@@ -125,6 +125,52 @@ conclusions before.
125
125
  off. Render a blocking card; wire its button to
126
126
  `BridgeClient.requestPermission(name)` with a name taken from that array.
127
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
+
128
174
  ## Commands
129
175
 
130
176
  `BridgeClient.send(command, args)` is **fire-and-forget**: no return value, no
@@ -136,7 +182,7 @@ column.
136
182
  | `ping` | — | no | `pingPong` |
137
183
  | `userDevice` | `{ getFromServer?: boolean }` | no | `userDevice` |
138
184
  | `realTime` | `{ serialNo: number, deviceId?: number, value: boolean }` | no | `realTime` |
139
- | `deviceConnection` | `{ type: 'add' \| 'remove', serialNo: number, deviceId?: number }` | no | `deviceConnection` |
185
+ | `deviceConnection` | `{ type: 'add' \| 'remove', serialNo: number, deviceId?: number }` — subscribe to / stop following one device's connection state | no | `deviceConnection` |
140
186
  | `sensorLog` | see below | no | `sensorLog` |
141
187
  | `openSettings` | — | no | — |
142
188
  | `requestPermission` | `{ permission: string }` | no | — (re-emits `systemStatus`) |
@@ -152,6 +198,11 @@ column.
152
198
  `serialNo` is an integer. Passing a string makes the native side drop the
153
199
  message in silence.
154
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
+
155
206
  ### Two devices with the same `serialNo`
156
207
 
157
208
  An ActLumus and an ActLumus Plus have separate serial sequences, so one account
@@ -192,6 +243,8 @@ timeout on any spinner.
192
243
  Prefer these over raw `send` — they add correlation, timeouts and types:
193
244
 
194
245
  ```ts
246
+ BridgeClient.requestUserDevices({ getFromServer }) // resends until the list arrives, 30 s default
247
+ BridgeClient.waitForService() // resends ping until the background service answers
195
248
  BridgeClient.fetchSensorLog(query, { timeoutMs }) // correlates requestId, 10 s default
196
249
  BridgeClient.requestPermission(name)
197
250
  BridgeClient.reportNavigation(canGoBack)
@@ -225,9 +278,48 @@ series by the timezone offset, producing plausible data and no error at all.
225
278
  **Cursors are opaque.** `startingAfter` and `endingBefore` are mutually
226
279
  exclusive; echo back exactly what you received and never construct one.
227
280
 
228
- With a time range, `hasMore` and `hasPrevious` are computed *within* the
229
- period, so `hasMore: false` means "this period is complete", not "no more data
230
- 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.
231
323
 
232
324
  ## Error shapes
233
325
 
@@ -237,11 +329,15 @@ There are three, and none of them is a thrown exception.
237
329
  `no-confirmation-handler`, `confirmation-error`, `user-rejected`, `not-allowed`.
238
330
  Visible only as `console.warn`.
239
331
 
240
- **2. `sensorLog` failure** — `{ requestId, success: false, error }`. The codes
241
- 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`,
242
334
  `INVALID_START_TIME`, `INVALID_END_TIME`, `INVALID_TIME_RANGE`,
243
- `MISSING_DEVICE_SERIAL_NO`, `invalid session`, `device not found for this user`.
244
- `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
245
341
  copy.
246
342
 
247
343
  **3. Silence** — nothing reaches JavaScript. This is the dominant failure mode.
@@ -252,12 +348,17 @@ The six paths that produce it are enumerated in `docs/troubleshooting.md`.
252
348
  The app owns the OS back button and will delegate it to you only if you ask.
253
349
 
254
350
  ```ts
255
- BridgeClient.reportNavigation(true); // "I have internal history"
256
- 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); });
257
354
  ```
258
355
 
259
- Report `false` when you are at your root, and the app handles back itself.
260
- 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.
261
362
 
262
363
  ## Status bar
263
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.22.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.22.0`, running
69
+ `npm install` (or your CI) auto-picks **patch** releases (`0.22.x`) — bug fixes,
70
+ no action needed. A `0.23.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
 
@@ -361,10 +369,14 @@ bundles and falls back to a default screen, so keep under the limit.
361
369
 
362
370
  ## 7. Deliver
363
371
 
364
- Send the resulting `dist/bundle.zip` through the publication channel Condor
372
+ Publish it yourself with a manager user of your company —
373
+ `npx condor-mobile-bridge canvas publish dist/index.html --version 1.0.0` — or
374
+ send the resulting `dist/bundle.zip` through the publication channel Condor
365
375
  gave you (the admin upload). The backend validates structure, self-containment,
366
376
  and size on upload, and records the `bundleSize`/`bundleHash`. Once published,
367
- the app downloads, verifies, and renders your Home.
377
+ the app downloads, verifies, and renders your Home — for **every user of your
378
+ company**, so test on a development test company first. See
379
+ [Testing on a device](./device-testing.md).
368
380
 
369
381
  ---
370
382
 
@@ -376,7 +388,8 @@ the app downloads, verifies, and renders your Home.
376
388
  | Images missing | Referenced by path instead of `data:` URI — the CLI inlines local images; remote images are blocked. |
377
389
  | 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
390
  | 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). |
391
+ | `handshake` rejects in the browser | The page is not inside the shell. Serve it with `npx condor-mobile-bridge dev`. |
392
+ | 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
393
  | A command does nothing | The command may not be allowed by the app policy; it's dropped silently. |
381
394
 
382
395
  ---
@@ -0,0 +1,114 @@
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. The bundle is published for **your company** — by you, with
12
+ `npx condor-mobile-bridge canvas publish` (below), or by your Condor
13
+ contact. There is exactly one current bundle per company.
14
+ 2. The server checks it on upload (2 MB, `index.html` at the root,
15
+ self-contained) and records its `bundleSize` and `bundleHash`.
16
+ 3. Each copy of the app asks for its company's manifest when the Home opens
17
+ and every time the app returns to the foreground. If the `bundleHash`
18
+ changed, it downloads the `.zip`, checks size and hash, and swaps the Home
19
+ without a restart. Otherwise it keeps the cached copy, also when offline.
20
+
21
+ Consequences:
22
+
23
+ - **Reinstalling the app changes nothing.** The bundle is not inside the app.
24
+ - **Publishing reaches every user of that company** the next time their app
25
+ comes to the foreground. There is no per-user or per-device preview today.
26
+ - **A rejected bundle is invisible to the person.** The app keeps its previous
27
+ bundle, or shows its default screen, if the download fails a gate — which is
28
+ why `validate --strict` must pass before you hand anything over.
29
+
30
+ ## Test on a test company, never on your real one
31
+
32
+ You work against production — the store app and the production Condor system.
33
+ Since a publish reaches every user of the company at once, ask Condor for a
34
+ **test company** with:
35
+
36
+ - a **manager** user for you, to publish (see below);
37
+ - one or more **test accounts** with devices, to log in to the store app on
38
+ your phone.
39
+
40
+ Each iteration on the device is then: build → `canvas publish` to the test
41
+ company → bring the app to the foreground. Do every iteration you can in the
42
+ harness first, and publish to your real company only what passed there.
43
+
44
+ (Condor also runs a development environment for its own staff; a client does
45
+ not need it.)
46
+
47
+ ## Publishing from the command line
48
+
49
+ ```bash
50
+ npx condor-mobile-bridge canvas publish dist/index.html --version 1.4.0
51
+ ```
52
+
53
+ It does what the Condor web admin does, with the same login:
54
+
55
+ 1. It validates the bundle locally (the same gates as `validate`) and refuses
56
+ anything the app would reject — before sending anything.
57
+ 2. It asks for your **company id** and **user id** — the numbers you type in
58
+ the Condor web login — unless you pass `--company` / `--user` or set
59
+ `CONDOR_CANVAS_COMPANY` / `CONDOR_CANVAS_USER`.
60
+ 3. It shows the company, version, size and hash, warns that the bundle goes
61
+ **live at once for every user of the company**, and asks `y/N`.
62
+ 4. It asks for your **password** without echo — there is no flag or variable
63
+ for it, and it is never stored — and, when your company uses two-factor
64
+ login, for the code sent to your e-mail.
65
+ 5. It publishes and prints the result.
66
+
67
+ Each version can be published once per company: publish a fix as a new
68
+ version. `canvas list` shows the bundles of the company, and
69
+ `canvas revoke <id> --fallback-version <v>` takes one back and serves another;
70
+ revoking the current bundle without a fallback leaves the company on the app's
71
+ default screen.
72
+
73
+ You need a **manager** user of the company. The command needs a terminal (it
74
+ prompts), so it does not run in CI; and an AI agent must not run it on its own
75
+ — it publishes to real people and asks for your password.
76
+
77
+ ## Seeing what the bundle does on the phone
78
+
79
+ **Release builds of the app cannot be inspected.** Ask Condor for a
80
+ development (debuggable) Android build. Then:
81
+
82
+ 1. Connect the phone by USB with USB debugging on.
83
+ 2. Open `chrome://inspect/#devices` in desktop Chrome and click **inspect** on
84
+ the app's WebView.
85
+ 3. In the DevTools console, switch the context selector from `top` to the
86
+ frame `about:srcdoc` — that is your bundle. The `top` frame is the Condor
87
+ shell.
88
+
89
+ There you see your own `console.*` output, uncaught errors, and the shell's
90
+ refusals: a `nack` reaches you only as a `[BridgeClient] command nacked by
91
+ shell` warning, so this is the one place to find out that a command was denied
92
+ or declined.
93
+
94
+ What you will **not** see from the WebView: the native side. A command dropped
95
+ by the app (wrong argument type, unknown command, a request lost while the
96
+ background service starts) leaves no trace in the console — reproduce it in the
97
+ harness instead, whose console says what was dropped and why.
98
+
99
+ ## Errors after the fact
100
+
101
+ `BridgeClient.send('jsError', { message, stack })` sends an error to Condor's
102
+ logging and crash reporting (rate limited: 20 per 10 s, 4 KB per field). Those
103
+ are Condor's tools: when you need the errors of a test session, ask your Condor
104
+ contact, with the test account and the time window.
105
+
106
+ ## Before you publish
107
+
108
+ - [ ] `npx condor-mobile-bridge validate -i dist/bundle.zip --strict` exits 0.
109
+ - [ ] Every harness scenario passes (`default`, `cold-start`, `no-devices`,
110
+ `shared-serial`, `offline`).
111
+ - [ ] It is going to the **test company** first, with a version number not
112
+ used before in that company.
113
+ - [ ] After publishing, the `bundleHash` that `canvas publish` prints matches
114
+ the one `build` printed.