@condorinst/mobile-bridge 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +133 -34
- package/CHANGELOG.md +175 -0
- package/README.md +30 -481
- package/contract/bridge-contract.json +32 -21
- package/dist/cli.cjs +126 -29
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +126 -29
- package/dist/cli.js.map +1 -1
- package/dist/dev-harness.global.js +388 -55
- package/dist/dev.cjs +389 -55
- package/dist/dev.cjs.map +1 -1
- package/dist/dev.d.cts +239 -9
- package/dist/dev.d.ts +239 -9
- package/dist/dev.js +388 -55
- package/dist/dev.js.map +1 -1
- package/dist/index.cjs +30 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +154 -26
- package/dist/index.d.ts +154 -26
- package/dist/index.js +29 -8
- package/dist/index.js.map +1 -1
- package/docs/agent-guide.md +9 -265
- package/docs/bridge-contract.md +313 -26
- package/docs/client-guide.md +34 -400
- package/docs/device-testing.md +234 -0
- package/docs/pilot-onboarding.md +41 -115
- package/docs/shell-integration.md +292 -0
- package/docs/troubleshooting.md +19 -11
- package/examples/minimal-home/README.md +2 -2
- package/examples/minimal-home/package.json +1 -1
- package/examples/minimal-home/src/main.ts +23 -3
- package/llms-full.txt +764 -865
- package/llms.txt +13 -15
- package/package.json +1 -1
package/llms-full.txt
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
# @condorinst/mobile-bridge 0.
|
|
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
|
|
24
|
-
the person sees
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 === '
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
|
|
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({
|
|
192
|
+
const page = await BridgeClient.fetchSensorLog({ serialNo }) // requestId is created for you;
|
|
110
193
|
```
|
|
111
194
|
|
|
195
|
+
`on` returns an unsubscribe function: call it when a view unmounts, or handlers
|
|
196
|
+
pile up. **Permissions:** when `systemStatus.missingEssentialPermissions` is not
|
|
197
|
+
empty, background collection is off — show a blocking card whose button calls
|
|
198
|
+
`BridgeClient.requestPermission(name)`. There is no result event: the app
|
|
199
|
+
re-emits `systemStatus`, and the card goes when the array empties.
|
|
200
|
+
|
|
112
201
|
**Why the helper.** Device data comes from a background service that the app
|
|
113
202
|
starts *after* your bundle is already running. Anything you send to it before it
|
|
114
203
|
is up is lost without a reply — while the handshake, `systemStatus` and
|
|
@@ -129,7 +218,17 @@ with `toISOString()` or a `'Z'` suffix.
|
|
|
129
218
|
|
|
130
219
|
Three commands open a confirmation dialog owned by Condor: `syncTime`,
|
|
131
220
|
`deleteMemory` and `logout`. If the person declines, you get nothing — the ack
|
|
132
|
-
never arrives. Do not block your UI on them
|
|
221
|
+
never arrives. Do not block your UI on them, and do not add a second
|
|
222
|
+
confirmation of your own: explain the consequence next to the button instead.
|
|
223
|
+
`logout` never answers — the app unloads your page.
|
|
224
|
+
|
|
225
|
+
**An ActLumus is disconnected most of the time** — it connects for a short sync
|
|
226
|
+
every few minutes. `realTime`, `syncTime` and `deleteMemory` work anyway: the ack
|
|
227
|
+
means "recorded", and the app carries it out at the device's next session
|
|
228
|
+
(`deleteMemory` only after that sync uploaded the data). Never disable them for
|
|
229
|
+
a disconnected device. `deviceConnection` `add` reports only *changes*: start
|
|
230
|
+
each device as "connection unknown". Details: `docs/bridge-contract.md`,
|
|
231
|
+
"What an ack means".
|
|
133
232
|
|
|
134
233
|
## Develop in the harness, not in a bare browser tab
|
|
135
234
|
|
|
@@ -144,9 +243,9 @@ once outside the shell. Rebuild and reload; the harness re-reads the file.
|
|
|
144
243
|
|
|
145
244
|
| Scenario | What it reproduces |
|
|
146
245
|
| --- | --- |
|
|
147
|
-
| `default` | two devices, realistic latency |
|
|
246
|
+
| `default` | two devices, realistic latency; the first starts a BLE session after 3 s, the second stays between sessions |
|
|
148
247
|
| `cold-start` | the background service takes 4 s; earlier `ping`/`userDevice` are lost |
|
|
149
|
-
| `no-devices` | `userDevice` never answers; `hasDevices: false` |
|
|
248
|
+
| `no-devices` | `userDevice` answers `error: 'NO_DEVICES'` (`--native-version 0.17.0`: never answers); `hasDevices: false` |
|
|
150
249
|
| `shared-serial` | an ActLumus and a Plus share serial 7047 |
|
|
151
250
|
| `offline` | no internet; `sensorLog` fails |
|
|
152
251
|
|
|
@@ -155,6 +254,11 @@ answered and dropped. The **◀ OS back** button at the top presses the OS back
|
|
|
155
254
|
button. `--native-version null` (or `0.15.0`, …) makes the handshake report that
|
|
156
255
|
version, to test your capability branches.
|
|
157
256
|
|
|
257
|
+
The harness does not change state by itself after load: drive internet loss,
|
|
258
|
+
disconnects, readings and the confirmation dialog from a script through
|
|
259
|
+
`window.condorDev` — what it simulates, the scripted-test recipe and your own
|
|
260
|
+
scenario files are in `docs/device-testing.md`.
|
|
261
|
+
|
|
158
262
|
## Publishing — ask the human, never run it yourself
|
|
159
263
|
|
|
160
264
|
`npx condor-mobile-bridge canvas publish` puts the bundle live at once for every
|
|
@@ -187,293 +291,16 @@ check, so a clean run is necessary, not sufficient.
|
|
|
187
291
|
- [ ] Every capability beyond the baseline is guarded by a `nativeVersion` check
|
|
188
292
|
(`versionAtLeast`), and the page still renders with
|
|
189
293
|
`--native-version null`.
|
|
190
|
-
- [ ] Nothing waits forever on a reply that may never come
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
| --- | --- |
|
|
198
|
-
| Every key, command, error shape and capability | `docs/bridge-contract.md` |
|
|
199
|
-
| The same, as JSON you can parse | `contract/bridge-contract.json` |
|
|
200
|
-
| Step-by-step build of a Home | `docs/agent-guide.md` |
|
|
201
|
-
| "My command did nothing" / "the device list never came" | `docs/troubleshooting.md` |
|
|
202
|
-
| Getting the bundle onto a device, and seeing its errors | `docs/device-testing.md` |
|
|
203
|
-
| Every hard limit, with the code that enforces it | `docs/limits.md` |
|
|
204
|
-
| Payload shapes, authoritative | `dist/index.d.ts` (your editor shows them) |
|
|
205
|
-
| A working client you can copy | `examples/minimal-home/` |
|
|
206
|
-
| All of the above in one file | `llms-full.txt` |
|
|
207
|
-
|
|
208
|
-
<!-- ===== docs/agent-guide.md ===== -->
|
|
209
|
-
|
|
210
|
-
# Agent guide — building a Canvas client
|
|
211
|
-
|
|
212
|
-
A working procedure for an AI agent (or a developer) building the client bundle
|
|
213
|
-
that renders as the Home of a Condor Canvas app.
|
|
214
|
-
|
|
215
|
-
Read `AGENTS.md` first — it is the contract in one screen. This document is the
|
|
216
|
-
procedure: what to do, in what order, and what to check at each stage.
|
|
217
|
-
|
|
218
|
-
## What you are building
|
|
219
|
-
|
|
220
|
-
One `.zip` containing one self-contained `index.html`. Condor's app downloads
|
|
221
|
-
it, verifies its SHA-256, and renders it inside a sandboxed iframe as the Home
|
|
222
|
-
screen. Login, settings, Bluetooth and background collection are the app's job.
|
|
223
|
-
Yours is what the person sees when the app opens.
|
|
224
|
-
|
|
225
|
-
You are not building a website. You have no storage, no network, and no origin.
|
|
226
|
-
Everything you display comes through the bridge, and everything you hold lives
|
|
227
|
-
in memory until the next reload.
|
|
228
|
-
|
|
229
|
-
## Stage 0 — set up so you can see what you are doing
|
|
230
|
-
|
|
231
|
-
Start from `examples/minimal-home/` — a complete, working client with the
|
|
232
|
-
reference boot sequence, and a build script that produces a single-file bundle.
|
|
233
|
-
Copy it first, then install, so its `package.json` is the one you keep:
|
|
234
|
-
|
|
235
|
-
```bash
|
|
236
|
-
mkdir my-home && cd my-home
|
|
237
|
-
npm pack @condorinst/mobile-bridge@^0.22.0 && tar -xzf condorinst-mobile-bridge-*.tgz
|
|
238
|
-
cp -r package/examples/minimal-home/. . && rm -rf package condorinst-mobile-bridge-*.tgz
|
|
239
|
-
npm install
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
Or, in a project you already have: `npm install @condorinst/mobile-bridge@^0.22.0`
|
|
243
|
-
and copy only `src/main.ts`, `build.mjs` and `index.html` from
|
|
244
|
-
`node_modules/@condorinst/mobile-bridge/examples/minimal-home/`.
|
|
245
|
-
|
|
246
|
-
Pin the SDK to the minor you are building against. Below `1.0.0`, a minor can
|
|
247
|
-
change the contract — `^0.22.0` gives you patches only, which is what you want.
|
|
248
|
-
|
|
249
|
-
**Run it in the harness**, not in a bare browser tab:
|
|
250
|
-
|
|
251
|
-
```bash
|
|
252
|
-
npm run build
|
|
253
|
-
npx condor-mobile-bridge dev -i dist/index.html --scenario default
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
The harness serves your bundle inside the same sandboxed iframe, shell, policy
|
|
257
|
-
and CSP as the app, with a simulated native side behind it, and a dev panel to
|
|
258
|
-
emit events by hand. It re-reads the file on every reload. Opening
|
|
259
|
-
`dist/index.html` directly does **not** work: there is no shell, so
|
|
260
|
-
`BridgeClient.handshake()` rejects at once.
|
|
261
|
-
|
|
262
|
-
The scenarios reproduce what the device does to you — switch with the selector
|
|
263
|
-
at the top or `--scenario`:
|
|
264
|
-
|
|
265
|
-
| Scenario | Your bundle must… |
|
|
266
|
-
| --- | --- |
|
|
267
|
-
| `default` | render two devices |
|
|
268
|
-
| `cold-start` | leave the loading screen although the first `userDevice` requests are lost |
|
|
269
|
-
| `no-devices` | show an empty state when `userDevice` never answers |
|
|
270
|
-
| `shared-serial` | keep two devices with the same `serialNo` apart |
|
|
271
|
-
| `offline` | explain that history is unavailable without internet |
|
|
272
|
-
|
|
273
|
-
Add `--native-version null` (or an older version such as `0.15.0`) to test
|
|
274
|
-
your capability branches, and use the **◀ OS back** button at the top to press
|
|
275
|
-
the OS back button.
|
|
276
|
-
|
|
277
|
-
If you already have a dev server (Vite), mount the same harness from your own
|
|
278
|
-
page with `createDevHarness()` from `@condorinst/mobile-bridge/dev` — in the
|
|
279
|
-
host page only, never in the bundle (`validate` rejects that).
|
|
280
|
-
|
|
281
|
-
## Stage 1 — render before you connect
|
|
282
|
-
|
|
283
|
-
Write the screen so it paints correctly with **no data at all**: empty roster,
|
|
284
|
-
unknown status, default language. Then fill it as events arrive.
|
|
285
|
-
|
|
286
|
-
This is not a nicety. Events are emitted on attach, before your bundle exists,
|
|
287
|
-
and again after the handshake — so there is always a window where you have
|
|
288
|
-
nothing. A Home whose first paint depends on an event shows a blank screen on
|
|
289
|
-
every cold start.
|
|
290
|
-
|
|
291
|
-
## Stage 2 — handshake, and learn what this device can do
|
|
292
|
-
|
|
293
|
-
```ts
|
|
294
|
-
import { BridgeClient, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
|
|
295
|
-
|
|
296
|
-
const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
|
|
297
|
-
|
|
298
|
-
const native = ack.nativeVersion; // string | null
|
|
299
|
-
const has = (min: string) => native !== null && cmp(native, min) >= 0;
|
|
300
|
-
|
|
301
|
-
const canReadRawChannels = has('0.16.0');
|
|
302
|
-
const canQueryTimeRange = has('0.15.0');
|
|
303
|
-
const hasLanguageKey = has('0.14.0');
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
Or use the SDK's `versionAtLeast(ack.nativeVersion, '0.16.0')`, which does
|
|
307
|
-
exactly that and returns `false` for `null`. Do not compare version strings
|
|
308
|
-
lexically — `'0.9.0' > '0.16.0'` is `true` as strings and false as versions.
|
|
309
|
-
|
|
310
|
-
**Do not branch on `ack.accepted`.** It is always `true`; the native side never
|
|
311
|
-
refuses a handshake. Anything you gate on it is ungated.
|
|
312
|
-
|
|
313
|
-
## Stage 3 — subscribe, then ask
|
|
314
|
-
|
|
315
|
-
Subscribe before sending the command that triggers the reply. The reply can
|
|
316
|
-
arrive before your next line runs.
|
|
317
|
-
|
|
318
|
-
```ts
|
|
319
|
-
BridgeClient.on(BRIDGE_KEYS.userDevice, (p) => { setDevices(p); render(); });
|
|
320
|
-
BridgeClient.on(BRIDGE_KEYS.systemStatus, (s) => { setStatus(s); render(); });
|
|
321
|
-
BridgeClient.on(BRIDGE_KEYS.deviceConnection, (c) => { setConnected(c); render(); });
|
|
322
|
-
|
|
323
|
-
const result = await BridgeClient.requestUserDevices();
|
|
324
|
-
if (result.status === 'timeout') renderEmptyState(result.hasDevices === false);
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
**Do not replace the helper with `send('userDevice')`.** The device list is
|
|
328
|
-
served by the app's background service, which starts after your bundle; a
|
|
329
|
-
request sent before it is up is lost, and an account with no device never gets
|
|
330
|
-
a reply. The helper resends with backoff for 30 s and then tells you to show the
|
|
331
|
-
empty state. Keep the `userDevice` subscription: roster changes arrive on it
|
|
332
|
-
later without a request. `docs/troubleshooting.md` has the details.
|
|
333
|
-
|
|
334
|
-
Read device data from the typed items: the list is `devicesConfig`, and the
|
|
335
|
-
name, battery and last sync are in `devicesTelemetry` — join them on
|
|
336
|
-
`deviceId`, never on `serialNo` alone.
|
|
337
|
-
|
|
338
|
-
Then follow the connection state of each device:
|
|
339
|
-
|
|
340
|
-
```ts
|
|
341
|
-
for (const d of result.devices.devicesConfig) {
|
|
342
|
-
BridgeClient.send('deviceConnection', { type: 'add', serialNo: d.serialNo, deviceId: d.deviceId });
|
|
343
|
-
}
|
|
344
|
-
```
|
|
345
|
-
|
|
346
|
-
`on` returns an unsubscribe function. If your UI has views that mount and
|
|
347
|
-
unmount, call it — otherwise you accumulate handlers that all re-render.
|
|
348
|
-
|
|
349
|
-
## Stage 4 — handle the environment honestly
|
|
350
|
-
|
|
351
|
-
`systemStatus` tells you why things are not working. Surface it; do not swallow
|
|
352
|
-
it.
|
|
353
|
-
|
|
354
|
-
```ts
|
|
355
|
-
if (status.missingEssentialPermissions.length > 0) {
|
|
356
|
-
// Background collection is OFF. This is blocking — say so.
|
|
357
|
-
renderBlockingCard(status.missingEssentialPermissions, (name) =>
|
|
358
|
-
BridgeClient.requestPermission(name));
|
|
359
|
-
} else if (!status.bluetooth) {
|
|
360
|
-
renderNotice('Bluetooth is off — devices cannot be reached.');
|
|
361
|
-
}
|
|
362
|
-
```
|
|
363
|
-
|
|
364
|
-
There is no permission-result event. After the person answers the OS prompt,
|
|
365
|
-
the native side re-emits `systemStatus`; your card disappears because the array
|
|
366
|
-
emptied. If you wait for a dedicated ack, you wait forever.
|
|
367
|
-
|
|
368
|
-
## Stage 5 — real-time, if you need it
|
|
369
|
-
|
|
370
|
-
Real-time is **off by default**, per device. Turn it on, wait for the ack, and
|
|
371
|
-
only then expect the stream.
|
|
372
|
-
|
|
373
|
-
```ts
|
|
374
|
-
BridgeClient.on(BRIDGE_KEYS.realTime, (ack) => setRealTime(ack.serialNo, ack.value));
|
|
375
|
-
BridgeClient.on(BRIDGE_KEYS.deviceLightSensors, (p) => {
|
|
376
|
-
if (p.type === 'actlumus2') { /* 14 channels */ } else { /* 10 channels */ }
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
BridgeClient.send('realTime', { serialNo, value: true });
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
`serialNo` is a **number**. A string is dropped in silence.
|
|
383
|
-
|
|
384
|
-
Two devices can share a `serialNo` (an ActLumus and an ActLumus Plus). To act on
|
|
385
|
-
one of them, send its `deviceId` **together with** `serialNo`, taken from the
|
|
386
|
-
`userDevice` list: `BridgeClient.send('realTime', { serialNo, deviceId, value })`.
|
|
387
|
-
A command with only the shared `serialNo` is ignored on purpose. Gate this on
|
|
388
|
-
`nativeVersion` ≥ 0.17.0; details in `docs/bridge-contract.md`.
|
|
389
|
-
|
|
390
|
-
In a raw channel, `null` means the channel does not exist on that hardware
|
|
391
|
-
generation. It does not mean zero — `0.0` is a valid reading. Coercing `null`
|
|
392
|
-
to `0` invents data, and has previously led to a confident, wrong conclusion
|
|
393
|
-
about a device.
|
|
394
|
-
|
|
395
|
-
## Stage 6 — history, if you need it
|
|
396
|
-
|
|
397
|
-
```ts
|
|
398
|
-
const page = await BridgeClient.fetchSensorLog({
|
|
399
|
-
requestId: crypto.randomUUID(),
|
|
400
|
-
serialNo,
|
|
401
|
-
limit: 200,
|
|
402
|
-
});
|
|
403
|
-
if (!page.success) return showError(page.error); // resolves, does not reject
|
|
404
|
-
renderLogs(page.logs ?? []);
|
|
405
|
-
```
|
|
406
|
-
|
|
407
|
-
Timestamps are **device-local**. Build ranges with local `Date` getters, never
|
|
408
|
-
`toISOString()` — converting to UTC shifts every series by the timezone offset
|
|
409
|
-
and produces data that looks entirely plausible.
|
|
410
|
-
|
|
411
|
-
Paginate with the cursors exactly as received. They are opaque. Without a range
|
|
412
|
-
the first page is the **most recent** one (go older with `endingBefore`); with
|
|
413
|
-
`startTime` it is the **oldest** of the range (go newer with `startingAfter`).
|
|
414
|
-
A device writes one log per period — programmable, 60 s by default — so a week
|
|
415
|
-
is about 10 000 logs: use `limit: 1000` and do not assume the cadence.
|
|
416
|
-
|
|
417
|
-
## Stage 7 — back button
|
|
418
|
-
|
|
419
|
-
```ts
|
|
420
|
-
BridgeClient.onBack(() => goBackOnePage());
|
|
421
|
-
// call this on every internal navigation, not once at startup
|
|
422
|
-
BridgeClient.reportNavigation(hasInternalHistory);
|
|
423
|
-
```
|
|
424
|
-
|
|
425
|
-
If you never report `true`, the OS back button stays with the app — which is
|
|
426
|
-
the correct behaviour for a single-screen Home.
|
|
427
|
-
|
|
428
|
-
## Stage 8 — build and validate
|
|
429
|
-
|
|
430
|
-
```bash
|
|
431
|
-
npm run build # your bundler → dist/index.html, single chunk
|
|
432
|
-
npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
|
|
433
|
-
npx condor-mobile-bridge validate -i dist/bundle.zip --strict
|
|
434
|
-
```
|
|
435
|
-
|
|
436
|
-
`validate` applies the gates the app applies plus a lint for APIs the sandbox
|
|
437
|
-
blocks. A non-zero exit is a build failure. Errors mean the app would reject
|
|
438
|
-
the bundle outright; warnings mean it installs and then misbehaves on the
|
|
439
|
-
device, which is worse to debug.
|
|
440
|
-
|
|
441
|
-
Validation is textual, so it cannot see through minification and it cannot know
|
|
442
|
-
whether you checked `nativeVersion` before using a capability. A clean run is
|
|
443
|
-
necessary, not sufficient.
|
|
444
|
-
|
|
445
|
-
## Stage 9 — before you call it done
|
|
446
|
-
|
|
447
|
-
- [ ] `validate --strict` exits 0.
|
|
448
|
-
- [ ] First paint works with no events received.
|
|
449
|
-
- [ ] Every capability past the baseline is guarded by a `nativeVersion` check,
|
|
450
|
-
and the `null` branch renders something sensible.
|
|
451
|
-
- [ ] No spinner can hang forever — the three confirmable commands
|
|
452
|
-
(`syncTime`, `deleteMemory`, `logout`) may never ack, because the person
|
|
453
|
-
can decline.
|
|
454
|
-
- [ ] No `localStorage`, no `fetch`, no `eval`. Not even behind a `try`.
|
|
455
|
-
- [ ] `serialNo` is a number everywhere, and a command for a device whose `serialNo`
|
|
456
|
-
repeats on the account also carries its `deviceId` (`nativeVersion` ≥ 0.17.0).
|
|
457
|
-
- [ ] Errors are reported through `BridgeClient.send('jsError', …)`, the only
|
|
294
|
+
- [ ] Nothing waits forever on a reply that may never come — the three
|
|
295
|
+
confirmable commands may never ack, because the person can decline.
|
|
296
|
+
- [ ] The person can navigate back, reach the app settings and log out from
|
|
297
|
+
your screens ("Your bundle is the whole screen").
|
|
298
|
+
- [ ] `serialNo` is a number everywhere; commands carry `deviceId` too when
|
|
299
|
+
`nativeVersion` ≥ 0.17.0.
|
|
300
|
+
- [ ] Unexpected errors go to `BridgeClient.send('jsError', …)`, your only
|
|
458
301
|
channel out.
|
|
459
|
-
- [ ] Every harness scenario passes: `default`, `cold-start`, `no-devices`,
|
|
460
|
-
`shared-serial`, `offline`.
|
|
461
|
-
- [ ] The `nativeVersion === null` branch renders something sensible.
|
|
462
302
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
Hand Condor the `.zip` along with its size and `sha256-<hex>` hash, both printed
|
|
466
|
-
by the `build` command. The app verifies the hash against the manifest and
|
|
467
|
-
rejects any mismatch. Condor publishes it for your company, which reaches every
|
|
468
|
-
user of that company — test on a development test company first.
|
|
469
|
-
`docs/device-testing.md` explains the path to the phone and how to inspect the
|
|
470
|
-
bundle there; `docs/pilot-onboarding.md` has the full checklist.
|
|
471
|
-
|
|
472
|
-
## When something does not work
|
|
473
|
-
|
|
474
|
-
`docs/troubleshooting.md`, which starts from the symptom. The most common one
|
|
475
|
-
is that a command did nothing and said nothing — there are six distinct causes
|
|
476
|
-
for that, and they are enumerated there.
|
|
303
|
+
To put the bundle on a phone, read `docs/device-testing.md`.
|
|
477
304
|
|
|
478
305
|
<!-- ===== docs/bridge-contract.md ===== -->
|
|
479
306
|
|
|
@@ -506,12 +333,12 @@ protocol for you.
|
|
|
506
333
|
|
|
507
334
|
Four numbers coexist. Only one tells you what the device can do.
|
|
508
335
|
|
|
509
|
-
| Number |
|
|
336
|
+
| Number | Latest | What it means |
|
|
510
337
|
| --- | --- | --- |
|
|
511
|
-
| `nativeVersion` | `0.
|
|
338
|
+
| `nativeVersion` | `0.18.0` | **The capability signal.** The bridge schema the app on this device implements. No Canvas release is in the field yet; the first one implements `0.18.0`. Later releases will coexist with it on phones for a while, so read it at runtime — never assume the latest. |
|
|
512
339
|
| `shellVersion` | `0.6.0` | The Condor shell wrapping your iframe. |
|
|
513
340
|
| client protocol | `0.5.0` | The postMessage protocol `BridgeClient` speaks. |
|
|
514
|
-
| package | `0.
|
|
341
|
+
| package | `0.23.0` | The npm version you compiled against. |
|
|
515
342
|
|
|
516
343
|
```ts
|
|
517
344
|
const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
|
|
@@ -526,7 +353,7 @@ side never refuses. Branching on it is branching on a constant.
|
|
|
526
353
|
**`nativeVersion` can be `null`.** When the shell could not reach the native
|
|
527
354
|
side, and in bundles compiled against SDK ≤ 0.16.x, where the shell did not
|
|
528
355
|
relay it. Treat `null` as "assume only the baseline" and keep a working
|
|
529
|
-
default. The dev harness reports `0.
|
|
356
|
+
default. The dev harness reports `0.18.0` by default (`--native-version 0.17.0` reproduces an app that may stay silent); `--native-version null`
|
|
530
357
|
exercises this path.
|
|
531
358
|
|
|
532
359
|
### Capability matrix
|
|
@@ -538,6 +365,7 @@ exercises this path.
|
|
|
538
365
|
| `sensorLog` accepts `startTime` / `endTime` | `nativeVersion` ≥ 0.15.0 |
|
|
539
366
|
| `deviceLightSensors` carries raw `light_sensor_*` channels, and the `actlumus2` variant | `nativeVersion` ≥ 0.16.0 |
|
|
540
367
|
| Address one device by `deviceId` when two share a `serialNo` (`realTime`, `syncTime`, `deleteMemory`, `deviceConnection`); `deviceId` in the device events; `systemStatus.deviceSyncErrorById` | `nativeVersion` ≥ 0.17.0 |
|
|
368
|
+
| `userDevice` always answers (re-sent after a cold start; `error` when the list could not load); `systemStatus.hasDevicesKnown`; payload limited to the contract fields | `nativeVersion` ≥ 0.18.0 |
|
|
541
369
|
| `nativeVersion` reaches the client at all | `shellVersion` ≥ 0.6.0 |
|
|
542
370
|
|
|
543
371
|
`CHANGELOG.md` is the precise record of when each of these landed.
|
|
@@ -571,7 +399,24 @@ you; the second is the one you receive. This is why `language` also exists as a
|
|
|
571
399
|
pull command — a client that mounts late can ask.
|
|
572
400
|
|
|
573
401
|
Render a skeleton synchronously and fill it as events arrive. Do not make first
|
|
574
|
-
paint depend on any event.
|
|
402
|
+
paint depend on any event. **No order is guaranteed between events.** On the
|
|
403
|
+
phone the post-handshake `systemStatus` usually arrives before the answer to
|
|
404
|
+
your first `userDevice` (that answer is a round trip to the background
|
|
405
|
+
service), but nothing promises it, and `systemStatus` is re-emitted on every
|
|
406
|
+
change. Don't hold a request waiting for `systemStatus`: a `sensorLog` sent
|
|
407
|
+
offline fails at once with an error you already handle.
|
|
408
|
+
|
|
409
|
+
`deviceLightSensors` arrives at the device's pace — about once per second, set
|
|
410
|
+
by the firmware, not throttled by the app (an observation, not a guarantee).
|
|
411
|
+
Treat "no reading for a few seconds" as the device leaving its session, and
|
|
412
|
+
let `deviceConnection` confirm it.
|
|
413
|
+
|
|
414
|
+
**`data.time`** is when the **phone** received the reading: epoch milliseconds
|
|
415
|
+
(UTC) from the phone clock, stamped on arrival — not the device clock, and not
|
|
416
|
+
shifted by the timezone like `log_time`. Each reading is forwarded as it
|
|
417
|
+
arrives, with no queue. To join live readings to the history, compare in the
|
|
418
|
+
same frame: format `data.time` with local `Date` getters (as you build
|
|
419
|
+
`log_time` ranges), or parse `log_time` as local time.
|
|
575
420
|
|
|
576
421
|
### `deviceLightSensors` is a discriminated union
|
|
577
422
|
|
|
@@ -583,6 +428,13 @@ if (payload.type === 'actlumus2') {
|
|
|
583
428
|
}
|
|
584
429
|
```
|
|
585
430
|
|
|
431
|
+
**Units.** `photopic` and `melanopic` are lux (photopic illuminance and
|
|
432
|
+
melanopic EDI) — the same as `lux_photopic` / `lux_melanopic` in the history.
|
|
433
|
+
The `wavelength*` and `light_sensor_*` channels have **no physical unit**: they
|
|
434
|
+
are relative values (arbitrary units). Use them for the shape of the spectrum
|
|
435
|
+
and for comparisons on the same device generation; do not label them W/m², µW/cm²
|
|
436
|
+
or lux, and do not compare an ActLumus with an ActLumus Plus channel by channel.
|
|
437
|
+
|
|
586
438
|
A `null` in a raw channel means **that channel does not exist on this hardware
|
|
587
439
|
generation** — never "the reading was zero". `0.0` is a valid reading. Do not
|
|
588
440
|
collapse `null` to `0`: doing so fabricates data, and it has produced wrong
|
|
@@ -604,23 +456,144 @@ conclusions before.
|
|
|
604
456
|
off. Render a blocking card; wire its button to
|
|
605
457
|
`BridgeClient.requestPermission(name)` with a name taken from that array.
|
|
606
458
|
|
|
459
|
+
The names, for your labels (never send a name you typed — take it from the
|
|
460
|
+
arrays):
|
|
461
|
+
|
|
462
|
+
| Name | Platform | Essential? | What `requestPermission` does |
|
|
463
|
+
| --- | --- | --- | --- |
|
|
464
|
+
| `bluetoothScan`, `bluetoothConnect` | Android | yes | system prompt; app settings if permanently denied |
|
|
465
|
+
| `fineLocation` | Android ≤ 11 | yes | system prompt; app settings if permanently denied |
|
|
466
|
+
| `bluetooth` | iOS | yes | system prompt; app settings as fallback |
|
|
467
|
+
| `notification` | both | no | system prompt (Android) / iOS permission request |
|
|
468
|
+
| `batteryOptimization` | Android | no | battery-optimization exemption screen |
|
|
469
|
+
| `exactAlarm` | Android | no | exact-alarm settings, when battery optimization is not exempt |
|
|
470
|
+
| `backgroundLocation` | Android ≤ 11 | no | system prompt, else app settings |
|
|
471
|
+
|
|
472
|
+
An unknown name is dropped.
|
|
473
|
+
|
|
474
|
+
`deviceSyncErrorById` (and `deviceSyncError`) hold only the **last** error per
|
|
475
|
+
device, already in the app language (the app translates the `messageCode`; an
|
|
476
|
+
error with `messageCode: null` never lands here). The entry is removed when
|
|
477
|
+
newer telemetry of that device arrives — and a failed sync saves telemetry too
|
|
478
|
+
(with the error added to its `syncErrors`), so the entry can vanish seconds
|
|
479
|
+
after it appeared. Use it for a live banner, not as the record of the last
|
|
480
|
+
failure: that is the newest item of `devicesTelemetry[].syncErrors` (compare
|
|
481
|
+
its `attemptDate` with the telemetry's `updataDateTime` to know whether a
|
|
482
|
+
later sync succeeded). `deviceNotification` is the live feed of
|
|
483
|
+
a sync — see the next section.
|
|
484
|
+
|
|
485
|
+
### Sync progress: `deviceNotification`
|
|
486
|
+
|
|
487
|
+
Every BLE session of a device is one sync, and the app reports it step by step
|
|
488
|
+
— dozens of events per session, for **every** linked device (no subscription
|
|
489
|
+
needed). Payload: `{ serialNo, deviceId?, isConnect, message, messageCode,
|
|
490
|
+
isError }`.
|
|
491
|
+
|
|
492
|
+
- **Branch on `messageCode`**, never on `message`. `message` is the app's log
|
|
493
|
+
line in English (`[Start] Get data log`, `[Error] Send data log: …`), not
|
|
494
|
+
localized and not stable; show your own text per code. Some progress events
|
|
495
|
+
have `messageCode: null` — ignore them.
|
|
496
|
+
- **`isConnect` is not the connection state** here: it is `!isError` on pipeline
|
|
497
|
+
events. Read connection from `deviceConnection`.
|
|
498
|
+
- `deviceId` only when the `serialNo` is shared, as in every device event.
|
|
499
|
+
|
|
500
|
+
A successful session, in order:
|
|
501
|
+
|
|
502
|
+
| `messageCode` | Step |
|
|
503
|
+
| --- | --- |
|
|
504
|
+
| `null`, `message: 'Device connected'` | the session starts |
|
|
505
|
+
| `enable_ble_periodic_notification`, then `…_not_needed_notification` if there was nothing to do | device setup |
|
|
506
|
+
| `set_ble_period_notification`, then `…_not_needed_notification` likewise | device setup |
|
|
507
|
+
| `set_sync_time_notification`, then `set_sync_time_not_needed_notification` likewise | the clock (a pending `syncTime` runs here) |
|
|
508
|
+
| `get_data_log_notification`, `send_data_log_notification`, `set_last_data_log_sent_to_server_notification` | reading and uploading the measurements |
|
|
509
|
+
| `get_system_log_notification`, `send_system_log_notification` | system log |
|
|
510
|
+
| `get_full_charge_log_notification`, `send_full_charge_log_notification` | charge log |
|
|
511
|
+
| `set_erase_memory_notification`, `erase_memory_notification` (or `escape_erase_memory_notification`: postponed) | a pending `deleteMemory`, after the upload |
|
|
512
|
+
| `get_device_telemetry_notification`, `save_device_telemetry_notification` | battery, memory, last sync |
|
|
513
|
+
| **`pipeline_done_notification`** | **the sync succeeded** — the only success marker |
|
|
514
|
+
| `null`, `message: 'Device disconnected'`, `isConnect: false` | the session ends |
|
|
515
|
+
|
|
516
|
+
Every stage emits its `…_notification` code when it **starts**, whether or not
|
|
517
|
+
it has work to do; it does not mean "done". A stage is done when a later stage
|
|
518
|
+
starts without its `…_error`. The three log stages run in parallel: their events
|
|
519
|
+
interleave in any order. Codes may repeat; treat every event as idempotent.
|
|
520
|
+
|
|
521
|
+
**When is a pending `syncTime` / `deleteMemory` done?** Count it done when the
|
|
522
|
+
sync reaches `pipeline_done_notification`. If the sync fails first, it may still
|
|
523
|
+
be pending — the app keeps a request it could not carry out (a clock it could
|
|
524
|
+
not set, an erase postponed by `escape_erase_memory_notification` or by a failed
|
|
525
|
+
upload) and runs it at the next session. Say "will happen at the next sync"
|
|
526
|
+
until then. With a `syncTime` pending, the clock stage never reports
|
|
527
|
+
`set_sync_time_not_needed_notification`: it sets the clock.
|
|
528
|
+
|
|
529
|
+
**History and live readings overlap in time, not in data.** Live readings are
|
|
530
|
+
not logs and never enter the history. While it streams, the device keeps
|
|
531
|
+
logging at its own period; those logs reach the history when a sync uploads
|
|
532
|
+
them (`send_data_log`). So the history ends at the last upload, live readings
|
|
533
|
+
cover the time after it, and the next sync fills that span with logs. Prefer
|
|
534
|
+
the history for any span it covers, and use live readings only after its last
|
|
535
|
+
`log_time`.
|
|
536
|
+
|
|
537
|
+
**A failed sync** has no "failed" event: the failing stage emits
|
|
538
|
+
`<stage>_error` (`isError: true`) and **`pipeline_done_notification` never
|
|
539
|
+
comes**. Every `*_error` ends the sync — `enable_ble_periodic_error`,
|
|
540
|
+
`set_ble_period_error`, `set_sync_time_error`, `get_data_log_error`,
|
|
541
|
+
`send_data_log_error`, `set_last_data_log_sent_to_server_error`,
|
|
542
|
+
`get_system_log_error`, `send_system_log_error`,
|
|
543
|
+
`set_last_system_log_sent_to_server_error`, `get_full_charge_log_error`,
|
|
544
|
+
`send_full_charge_log_error`, `set_erase_memory_error`,
|
|
545
|
+
`get_device_telemetry_error` — except two, which are `isError: true` while the
|
|
546
|
+
sync goes on: `config_write_not_persisted` (a setting did not stick; the sync
|
|
547
|
+
continues to `pipeline_done_notification`) and `save_device_telemetry_error`
|
|
548
|
+
(the final save failed; the outcome is already decided). A failure loading the
|
|
549
|
+
device list ends the sync with `messageCode: null`. After an ending error the
|
|
550
|
+
app mutes that device's **pipeline** events for about 3 s, so a second error
|
|
551
|
+
from a parallel log stage may never reach you; the first one is the cause.
|
|
552
|
+
|
|
553
|
+
**The session start and end are the two events with `messageCode: null` you
|
|
554
|
+
can rely on**, without reading `message`:
|
|
555
|
+
- **end:** `isConnect: false` **and** `isError: false` — no other event has
|
|
556
|
+
that pair (pipeline events have `isConnect === !isError`). It is not muted.
|
|
557
|
+
- **start:** the first event of the device after an end (or after your bundle
|
|
558
|
+
loaded). A `deviceConnection` with `isConnect: true` says the same if you
|
|
559
|
+
subscribed with `add`.
|
|
560
|
+
|
|
561
|
+
Rule for a progress UI: "syncing" from the start, the step from each code,
|
|
562
|
+
**success** on `pipeline_done_notification`, **failure** when the end arrives
|
|
563
|
+
after an ending `*_error` without `pipeline_done_notification`. Keep a time-out
|
|
564
|
+
of your own (a few minutes without events): if the disconnect itself fails, the
|
|
565
|
+
app sends no end event.
|
|
566
|
+
|
|
607
567
|
**`hasDevices` is a hint.** It is `true` only after the app loaded a non-empty
|
|
608
568
|
device list from the server. `false` means "no linked device" **or** "the app
|
|
609
|
-
could not ask" (no internet, session problem).
|
|
610
|
-
|
|
569
|
+
could not ask" (no internet, session problem). From `nativeVersion` 0.18.0,
|
|
570
|
+
`hasDevicesKnown: true` says `hasDevices` is the server's answer, and `false`
|
|
571
|
+
says the lookup failed. Do not show "you have no device" on `hasDevices: false`
|
|
572
|
+
alone; see the next section.
|
|
611
573
|
|
|
612
574
|
### `userDevice` in detail
|
|
613
575
|
|
|
614
|
-
**
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
576
|
+
**From `nativeVersion` 0.18.0 it always answers.** The app re-sends a request
|
|
577
|
+
lost while its background service starts, and a failed lookup arrives as the
|
|
578
|
+
three arrays empty plus `error`:
|
|
579
|
+
|
|
580
|
+
| `error` | Meaning |
|
|
581
|
+
| --- | --- |
|
|
582
|
+
| `NO_DEVICES` | the server says the account has no linked device |
|
|
583
|
+
| `NETWORK` | no answer from the server (offline, timeout) or a 5xx |
|
|
584
|
+
| `SESSION` | no session, or the server ended it |
|
|
585
|
+
| `UNKNOWN` | anything else; treat unknown values the same way |
|
|
586
|
+
|
|
587
|
+
**Below 0.18.0 it is not guaranteed to answer.** `userDevice` (and `ping`) are
|
|
588
|
+
handled by the background service, which starts after your bundle is running;
|
|
589
|
+
a request sent before it is up is lost. And a failed lookup — including an
|
|
590
|
+
account with no device — gets **no reply at all**. Use the helper, which covers
|
|
591
|
+
both versions:
|
|
620
592
|
|
|
621
593
|
```ts
|
|
622
594
|
const r = await BridgeClient.requestUserDevices(); // getFromServer: false
|
|
623
|
-
// { status: 'ok', devices } | { status: '
|
|
595
|
+
// { status: 'ok', devices } | { status: 'no-devices' } | { status: 'error', code }
|
|
596
|
+
// | { status: 'timeout', hasDevices: boolean | null }
|
|
624
597
|
```
|
|
625
598
|
|
|
626
599
|
**`getFromServer`.** `false` (default) answers from the app's memory or cache
|
|
@@ -634,7 +607,7 @@ the server first and falls back to the cache if that fails — use it for a
|
|
|
634
607
|
| Array | Holds | Notable fields |
|
|
635
608
|
| --- | --- | --- |
|
|
636
609
|
| `devicesConfig` | one item per linked device — **the list to render** | `deviceId`, `serialNo`, `modelId` (`'actlumus'`, `'actlumus_plus'`; absent = ActLumus), `deviceBodyPart` (`'wrist'` \| `'necklace'` \| `null`), `bleConnectionPeriod` (minutes) |
|
|
637
|
-
| `devicesTelemetry` | last state read from each device | `deviceName`, `batteryLevel` (0–100 %), `memoryDataUsage` (0–100
|
|
610
|
+
| `devicesTelemetry` | last state read from each device | `deviceName`, `batteryLevel` (0–100 %), `memoryDataUsage` (0–100 %, the measurement memory — the one to show as "memory used"; `memorySystemUsage` is the device's internal log, diagnostics only), `updataDateTime` (epoch ms, last sync — the misspelling is the real name), `deviceVersion`, `isCharging`, `isOnDock`, `syncErrors[]` |
|
|
638
611
|
| `devicesUserConfig` | per-device toggles | `syncTime`, `realTime`, `deleteMemory`, `readAllMemory`, `readAllSystemLog` |
|
|
639
612
|
|
|
640
613
|
The name and battery of a device are in **telemetry**, not in the config. A
|
|
@@ -646,15 +619,82 @@ device that never synced may have no telemetry item, or one with every reading
|
|
|
646
619
|
only when that `serialNo` appears once in `devicesConfig`.
|
|
647
620
|
|
|
648
621
|
**Sync errors.** `devicesTelemetry[].syncErrors` is the per-device history
|
|
649
|
-
(`attemptDate`, `message`, `messageCode`)
|
|
622
|
+
(`attemptDate`, `message`, `messageCode`). `messageCode` is the `…_error` code
|
|
623
|
+
of the stage that failed — the same vocabulary as `deviceNotification` (see
|
|
624
|
+
"Sync progress") — or `null`; `message` is the app's English log line, not for
|
|
625
|
+
display. Map the codes to your own words; `systemStatus.deviceSyncErrorById`
|
|
650
626
|
is the current error per device, already localized. Show the current one; use
|
|
651
627
|
the history for detail screens.
|
|
652
628
|
|
|
629
|
+
### `language` — which language to render
|
|
630
|
+
|
|
631
|
+
The app tells you **two** languages, and the distinction between them is the
|
|
632
|
+
whole point of the channel:
|
|
633
|
+
|
|
634
|
+
```typescript
|
|
635
|
+
BridgeClient.on(BRIDGE_KEYS.language, ({ deviceLocale, appLanguage, resolvedLanguage }) => {
|
|
636
|
+
// Your precedence rule. This one honours an explicit choice, else the device.
|
|
637
|
+
const lang = appLanguage ?? pickSupported(deviceLocale) ?? 'en';
|
|
638
|
+
renderIn(lang);
|
|
639
|
+
});
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
- **`deviceLocale`** — the raw device locale as a BCP-47 tag (`"pt-BR"`,
|
|
643
|
+
`"es-419"`, `"ja-JP"`), exactly as the OS reports it, region included.
|
|
644
|
+
- **`appLanguage`** — the language the user explicitly picked in the app
|
|
645
|
+
Settings (`"en" | "pt" | "de" | "fr"`), or **`null`** when they never picked
|
|
646
|
+
one.
|
|
647
|
+
- **`resolvedLanguage`** — the language the native chrome (splash, Settings,
|
|
648
|
+
loading/error screens) is rendering right now.
|
|
649
|
+
|
|
650
|
+
**Why two raw values instead of one answer.** The app itself ships four
|
|
651
|
+
languages and collapses everything else to English. Your bundle has no such
|
|
652
|
+
limit — it may ship Spanish, Italian, Japanese. If we sent you only the resolved
|
|
653
|
+
language, a device set to Spanish would reach you as `en` and you would render
|
|
654
|
+
English while holding a Spanish bundle. The information would have been
|
|
655
|
+
destroyed on our side before reaching you. So we send both and let you decide:
|
|
656
|
+
|
|
657
|
+
| `deviceLocale` | `appLanguage` | a reasonable decision |
|
|
658
|
+
|---|---|---|
|
|
659
|
+
| `es-ES` | `null` | render **Spanish** — you have it, we don't. Our chrome stays English while your content is Spanish, which is better, not worse |
|
|
660
|
+
| `es-ES` | `"pt"` | render **Portuguese** — an explicit choice outranks the device |
|
|
661
|
+
| `pt-BR` | `null` | render Portuguese |
|
|
662
|
+
| `ja-JP` | `"de"` | render German (the explicit choice), or Japanese if you'd rather honour the device — your call, with both values in hand |
|
|
663
|
+
|
|
664
|
+
`appLanguage` is `null` when — and only when — the user never chose. It is never
|
|
665
|
+
a fallback in disguise, so `null` is a real signal you can branch on. Use
|
|
666
|
+
`resolvedLanguage` when you'd rather stay visually consistent with the native
|
|
667
|
+
frame around your page than follow the device.
|
|
668
|
+
|
|
669
|
+
**When it arrives — and how to ask for it.** The app pushes this key on attach,
|
|
670
|
+
again right after the handshake ack, and on every change in the app Settings, so
|
|
671
|
+
a page already loaded re-renders without a restart. But the first push happens
|
|
672
|
+
while the WebView is still being created, before your bundle exists, so **send
|
|
673
|
+
the `language` command once after your handshake** to pull the current state:
|
|
674
|
+
|
|
675
|
+
```typescript
|
|
676
|
+
BridgeClient.send('language', {}); // replies with a `language` event
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
Without that pull, a first launch would show your default language until the
|
|
680
|
+
user changed it in Settings. Subscribe first, then pull.
|
|
681
|
+
|
|
682
|
+
Do not read `navigator.language` instead: it follows the system, not the in-app
|
|
683
|
+
choice, and you would end up in Spanish while the native chrome around you is in
|
|
684
|
+
Portuguese.
|
|
685
|
+
|
|
686
|
+
**Older app versions.** There is no version negotiation. An app older than
|
|
687
|
+
native `0.14.0` never emits this key, so the event simply never arrives — keep a
|
|
688
|
+
default language for that case, and read `nativeVersion` from the handshake ack
|
|
689
|
+
if you need to branch on it explicitly.
|
|
690
|
+
|
|
653
691
|
## Commands
|
|
654
692
|
|
|
655
693
|
`BridgeClient.send(command, args)` is **fire-and-forget**: no return value, no
|
|
656
694
|
exception, no delivery confirmation. Replies arrive on the key in the last
|
|
657
|
-
column.
|
|
695
|
+
column. Its arguments are typed per command (`CommandArgs`): a string
|
|
696
|
+
`serialNo`, a missing `value` or an unknown command is a compile error, not a
|
|
697
|
+
silent drop.
|
|
658
698
|
|
|
659
699
|
| Command | Arguments | Confirmation | Replies on |
|
|
660
700
|
| --- | --- | --- | --- |
|
|
@@ -663,7 +703,7 @@ column.
|
|
|
663
703
|
| `realTime` | `{ serialNo: number, deviceId?: number, value: boolean }` | no | `realTime` |
|
|
664
704
|
| `deviceConnection` | `{ type: 'add' \| 'remove', serialNo: number, deviceId?: number }` — subscribe to / stop following one device's connection state | no | `deviceConnection` |
|
|
665
705
|
| `sensorLog` | see below | no | `sensorLog` |
|
|
666
|
-
| `openSettings` | — | no | — |
|
|
706
|
+
| `openSettings` | — (opens the Canvas settings screen: language, notifications, Bluetooth, refresh device list, date format; not the OS settings, no logout) | no | — |
|
|
667
707
|
| `requestPermission` | `{ permission: string }` | no | — (re-emits `systemStatus`) |
|
|
668
708
|
| `setStatusBarColor` | `{ color: '#RRGGBB' }` | no | — |
|
|
669
709
|
| `navigationState` | `{ canGoBack: boolean }` | no | — |
|
|
@@ -678,9 +718,46 @@ column.
|
|
|
678
718
|
message in silence.
|
|
679
719
|
|
|
680
720
|
`deviceConnection` **pairs nothing.** `type: 'add'` asks the app to report the
|
|
681
|
-
BLE connection state of a device you already have from `userDevice
|
|
682
|
-
|
|
683
|
-
|
|
721
|
+
BLE connection state of a device you already have from `userDevice`. `'remove'`
|
|
722
|
+
stops it. Send `add` for each device after the list arrives.
|
|
723
|
+
|
|
724
|
+
- **`add` does not report the current state.** The app reports **changes**: a
|
|
725
|
+
device already connected (or already disconnected) when you subscribe sends
|
|
726
|
+
nothing until its next BLE session starts or ends. Start every device as
|
|
727
|
+
"connection unknown" and switch on the first event. The one exception: a
|
|
728
|
+
device the app does not know answers `isConnect: false` at once.
|
|
729
|
+
- **"Disconnected" is the normal state.** An ActLumus is not connected all the
|
|
730
|
+
time: it opens a short BLE session to sync every few minutes
|
|
731
|
+
(`devicesConfig[].bleConnectionPeriod`, in minutes) and the app disconnects
|
|
732
|
+
it as soon as the sync ends, successful or not — unless `realTime` is on for
|
|
733
|
+
that device (below).
|
|
734
|
+
Show the last sync time (`devicesTelemetry[].updataDateTime`) rather than a
|
|
735
|
+
red "offline" badge.
|
|
736
|
+
|
|
737
|
+
### What an ack means
|
|
738
|
+
|
|
739
|
+
`realTime`, `syncTime` and `deleteMemory` do not need the device to be
|
|
740
|
+
connected, and their ack does **not** mean "done":
|
|
741
|
+
|
|
742
|
+
| Command | `success: true` means | When it happens on the device |
|
|
743
|
+
| --- | --- | --- |
|
|
744
|
+
| `syncTime` | the request is recorded | at the next BLE session; the device clock is set to the phone's local time (its time zone included) |
|
|
745
|
+
| `deleteMemory` | the request is recorded | at the end of the next sync, **only after the data was uploaded**; if the upload fails, it stays pending and is retried. Unsent data is never erased. Not reversible once done. |
|
|
746
|
+
| `realTime` (`value: true`) | real time is switched on for that device | readings flow on `deviceLightSensors` while it is connected; it starts by itself at the next session if it is not connected now |
|
|
747
|
+
|
|
748
|
+
- `success: false` means the app could not record the request (for example,
|
|
749
|
+
a shared `serialNo` sent without `deviceId`), never "device not connected".
|
|
750
|
+
- **Do not disable these actions while the device is disconnected.** Say what
|
|
751
|
+
will happen instead: "The clock will be set at the next sync" / "The memory
|
|
752
|
+
will be erased after the next sync uploads its data".
|
|
753
|
+
- **`realTime` keeps the session open.** With it on, the app does not
|
|
754
|
+
disconnect the device after the sync: readings keep flowing until you switch
|
|
755
|
+
`realTime` off (then the session ends) or the BLE link drops. Readings stop
|
|
756
|
+
when the session ends and resume at its next session while `realTime` stays
|
|
757
|
+
on. There is no "last connection" field: the last sync
|
|
758
|
+
(`devicesTelemetry[].updataDateTime`) is the closest one. The app switches `realTime` off for every
|
|
759
|
+
device when it restarts — switch it on again from your screen when you need
|
|
760
|
+
it, and off when the person leaves that screen.
|
|
684
761
|
|
|
685
762
|
### Two devices with the same `serialNo`
|
|
686
763
|
|
|
@@ -706,7 +783,9 @@ the wrong device (erasing the wrong memory, for instance).
|
|
|
706
783
|
unaddressable).
|
|
707
784
|
- `sensorLog` takes no `deviceId` yet: for a shared `serialNo` the backend
|
|
708
785
|
answers with the error code `DEVICE_SERIAL_AMBIGUOUS`. Show your own copy for
|
|
709
|
-
it; do not retry
|
|
786
|
+
it ("history is not available for these two devices yet"); do not retry, and
|
|
787
|
+
do not filter another query's result to work around it. Real time, commands
|
|
788
|
+
and the device list still work for both devices, by `deviceId`.
|
|
710
789
|
|
|
711
790
|
### Commands that need human confirmation
|
|
712
791
|
|
|
@@ -717,6 +796,13 @@ which reaches you only as a `console.warn`. **The ack simply never arrives.**
|
|
|
717
796
|
Design for that: show an optimistic state only if you can undo it, and put a
|
|
718
797
|
timeout on any spinner.
|
|
719
798
|
|
|
799
|
+
**Do not add your own confirmation dialog in front of these three.** The app's
|
|
800
|
+
dialog is the confirmation; a second one makes the person confirm twice. Explain
|
|
801
|
+
the consequence on your screen, next to the button, before it is pressed.
|
|
802
|
+
|
|
803
|
+
**`logout` has no reply.** Once confirmed, the app returns to its login screen
|
|
804
|
+
and your page is unloaded; nothing comes back. Show nothing that waits for it.
|
|
805
|
+
|
|
720
806
|
### Typed helpers
|
|
721
807
|
|
|
722
808
|
Prefer these over raw `send` — they add correlation, timeouts and types:
|
|
@@ -735,7 +821,6 @@ BridgeClient.openSettings()
|
|
|
735
821
|
|
|
736
822
|
```ts
|
|
737
823
|
const page = await BridgeClient.fetchSensorLog({
|
|
738
|
-
requestId: crypto.randomUUID(),
|
|
739
824
|
serialNo,
|
|
740
825
|
bodyPart: 'wrist', // or 'necklace'; omit for no filter
|
|
741
826
|
limit: 200, // 1..1000, backend default 10
|
|
@@ -745,7 +830,15 @@ const page = await BridgeClient.fetchSensorLog({
|
|
|
745
830
|
```
|
|
746
831
|
|
|
747
832
|
It **resolves** with `{ success: false, error }` on failure and only **rejects**
|
|
748
|
-
on timeout
|
|
833
|
+
on timeout (10 s by default; the app's own server timeout is 50 s, so raise
|
|
834
|
+
`timeoutMs` on slow networks rather than retrying at once). Check `success`
|
|
835
|
+
before reading `logs`. Leave `requestId` out: `fetchSensorLog` creates one
|
|
836
|
+
(`createRequestId()`, which does not need `crypto.randomUUID`).
|
|
837
|
+
|
|
838
|
+
There is no rate limit on the app side, but every page is a server request
|
|
839
|
+
under the person's single session: fetch pages one after another (they depend
|
|
840
|
+
on each other anyway), never several sweeps in parallel, and refresh on a
|
|
841
|
+
human scale — minutes, not seconds.
|
|
749
842
|
|
|
750
843
|
Two things that have caused real bugs:
|
|
751
844
|
|
|
@@ -753,6 +846,11 @@ Two things that have caused real bugs:
|
|
|
753
846
|
device RTC. Build your range with local `Date` getters — `getFullYear()`,
|
|
754
847
|
`getHours()` — and **never** with `toISOString()`. Converting shifts every
|
|
755
848
|
series by the timezone offset, producing plausible data and no error at all.
|
|
849
|
+
A "day" is the date part of `log_time` (`log_time.slice(0, 10)`): the
|
|
850
|
+
device's local day, on the clock `syncTime` set to the phone's local time. The
|
|
851
|
+
log carries no timezone, so after the participant changes timezone the logs
|
|
852
|
+
stay on the old clock until the next `syncTime` — say so if your report is per
|
|
853
|
+
day.
|
|
756
854
|
|
|
757
855
|
**Cursors are opaque.** `startingAfter` and `endingBefore` are mutually
|
|
758
856
|
exclusive; echo back exactly what you received and never construct one.
|
|
@@ -765,7 +863,7 @@ const logs: SensorLogItem[] = [];
|
|
|
765
863
|
let startingAfter: string | undefined;
|
|
766
864
|
for (;;) {
|
|
767
865
|
const page = await BridgeClient.fetchSensorLog({
|
|
768
|
-
|
|
866
|
+
serialNo, limit: 1000,
|
|
769
867
|
startTime: '2026-10-13 00:00:00', endTime: '2026-10-20 00:00:00', // local time
|
|
770
868
|
...(startingAfter ? { startingAfter } : {}),
|
|
771
869
|
});
|
|
@@ -797,9 +895,25 @@ Pages depend on the previous cursor, so they cannot be fetched in parallel.
|
|
|
797
895
|
**Fields.** Each log is a `SensorLogItem`: `log_time`, `body_part`,
|
|
798
896
|
`temperature` (°C), `lux_photopic` and `lux_melanopic` (lux), the raw light
|
|
799
897
|
channels and the activity counters. `null` on a channel means the hardware does
|
|
800
|
-
not have it, never zero. `
|
|
898
|
+
not have it, never zero. `device_id` is the server device id — the same value as
|
|
899
|
+
`devicesConfig[].deviceId` — so you can check that a log belongs to the device
|
|
900
|
+
you asked for. `device_log_time` and `app_log_time` mark the upload
|
|
801
901
|
batch, not the log — do not plot them.
|
|
802
902
|
|
|
903
|
+
**Was the device worn?** Read `off_wrist`, and mind its name: it is the
|
|
904
|
+
device's raw wear flag, **`1` = worn, `0` = not worn** (on its dock); treat any
|
|
905
|
+
other value as not worn and `null` as unknown. `body_part` (`'wrist'` or
|
|
906
|
+
`'necklace'`) is where the device was *set* to be worn when the log was uploaded
|
|
907
|
+
— a setting, not a detection; it can differ from today's `deviceBodyPart`.
|
|
908
|
+
|
|
909
|
+
**Time worn per day** (one reasonable recipe, not an official metric): count the
|
|
910
|
+
logs of that local day with `off_wrist === 1` and multiply by the log period
|
|
911
|
+
derived from consecutive `log_time` values. A missing log is time not recorded,
|
|
912
|
+
not time worn. Ask for the day with `startTime` = local midnight and `endTime`
|
|
913
|
+
= the next local midnight: `startTime` is inclusive and `endTime` exclusive.
|
|
914
|
+
At the default 60 s, one day is about 1 440 logs — two pages of 1 000 — and 14
|
|
915
|
+
days about 20 pages per device, so compute once and refresh on a human scale.
|
|
916
|
+
|
|
803
917
|
## Error shapes
|
|
804
918
|
|
|
805
919
|
There are three, and none of them is a thrown exception.
|
|
@@ -863,10 +977,15 @@ session key. Do not try to detect it; the app handles re-authentication.
|
|
|
863
977
|
## "The device list never came" (stuck on loading)
|
|
864
978
|
|
|
865
979
|
The handshake worked, `systemStatus` arrived, and `userDevice` never answered.
|
|
866
|
-
Two different causes produce exactly this symptom, and the bundle cannot tell
|
|
867
|
-
them apart.
|
|
868
980
|
|
|
869
|
-
|
|
981
|
+
**From `nativeVersion` 0.18.0 the app fixes both causes below itself**: it
|
|
982
|
+
re-sends a request lost while its background service starts, and it answers a
|
|
983
|
+
failed lookup with empty arrays and `error` (`NO_DEVICES`, `NETWORK`, `SESSION`,
|
|
984
|
+
`UNKNOWN`). `requestUserDevices()` turns those into `no-devices` and `error`.
|
|
985
|
+
Below 0.18.0 the two causes produce exactly the same silence, and the bundle
|
|
986
|
+
cannot tell them apart.
|
|
987
|
+
|
|
988
|
+
### Cause 1 (below 0.18.0): the request was sent before the app's background service was up
|
|
870
989
|
|
|
871
990
|
`userDevice` and `ping` are not answered by the page that hosts you: the app
|
|
872
991
|
forwards them to a background service that reads the devices. On a cold start
|
|
@@ -875,12 +994,12 @@ request sent at boot is often **lost without a reply**. Nothing re-sends the
|
|
|
875
994
|
list when the service comes up. Meanwhile the handshake, `systemStatus` and
|
|
876
995
|
`language` work from the start, which makes the bridge look ready.
|
|
877
996
|
|
|
878
|
-
### Cause 2: the account has no linked device
|
|
997
|
+
### Cause 2 (below 0.18.0): the account has no linked device
|
|
879
998
|
|
|
880
999
|
The app replies to `userDevice` only when the list loads. When the server
|
|
881
1000
|
answers that the account has no device — or the request fails for any other
|
|
882
|
-
reason (no internet, expired session) — nothing is sent to the bundle.
|
|
883
|
-
`userDevice` with empty arrays
|
|
1001
|
+
reason (no internet, expired session) — nothing is sent to the bundle. An app
|
|
1002
|
+
below 0.18.0 never sends a `userDevice` with empty arrays.
|
|
884
1003
|
|
|
885
1004
|
### What to do
|
|
886
1005
|
|
|
@@ -889,16 +1008,19 @@ Use the helper, which handles both:
|
|
|
889
1008
|
```ts
|
|
890
1009
|
const result = await BridgeClient.requestUserDevices(); // resends with backoff for 30 s
|
|
891
1010
|
if (result.status === 'ok') render(result.devices);
|
|
892
|
-
else
|
|
1011
|
+
else if (result.status === 'no-devices') renderEmpty('none linked'); // confirmed (0.18.0+)
|
|
1012
|
+
else renderEmpty('could not load'); // error (0.18.0+) or timeout — always with "Try again"
|
|
893
1013
|
```
|
|
894
1014
|
|
|
895
1015
|
- It resends at 1.5 s, 3 s, 6 s and then every 10 s, and on every
|
|
896
1016
|
`systemStatus` while it waits. `BridgeClient.waitForService()` does the same
|
|
897
1017
|
with `ping` if you only need to know the service is up.
|
|
898
1018
|
- **`systemStatus.hasDevices` is a hint, not a verdict.** It is `true` only
|
|
899
|
-
after the app loaded a non-empty list from the server.
|
|
900
|
-
|
|
901
|
-
|
|
1019
|
+
after the app loaded a non-empty list from the server. From 0.18.0,
|
|
1020
|
+
`systemStatus.hasDevicesKnown` says whether it is the server's answer; below
|
|
1021
|
+
it, `false` means either "no device" or "the app could not ask". Word the
|
|
1022
|
+
empty state so it is true in both cases, and always offer a retry with
|
|
1023
|
+
`getFromServer: true`.
|
|
902
1024
|
- Keep the `userDevice` subscription for the life of the page: roster changes
|
|
903
1025
|
arrive on it without a request.
|
|
904
1026
|
|
|
@@ -1024,7 +1146,7 @@ First question: **which browser setup?**
|
|
|
1024
1146
|
run every scenario, not just `default`. `cold-start` and `no-devices` are the
|
|
1025
1147
|
ones that catch real bugs.
|
|
1026
1148
|
|
|
1027
|
-
In the harness `nativeVersion` is `0.
|
|
1149
|
+
In the harness `nativeVersion` is `0.18.0` by default. Run it with
|
|
1028
1150
|
`--native-version null` to reproduce a shell that could not reach the native
|
|
1029
1151
|
side; keep a branch that renders in that case too.
|
|
1030
1152
|
|
|
@@ -1152,6 +1274,26 @@ You need a **manager** user of the company. The command needs a terminal (it
|
|
|
1152
1274
|
prompts), so it does not run in CI; and an AI agent must not run it on its own
|
|
1153
1275
|
— it publishes to real people and asks for your password.
|
|
1154
1276
|
|
|
1277
|
+
## Loading the bundle from your machine (development builds)
|
|
1278
|
+
|
|
1279
|
+
From `nativeVersion` 0.18.0 a **development** build of the app can skip the
|
|
1280
|
+
published bundle and load yours straight from your computer, on every launch
|
|
1281
|
+
and every return to the foreground — no publish, no other user affected.
|
|
1282
|
+
|
|
1283
|
+
The address is fixed when Condor builds the app, so use one that is the same on
|
|
1284
|
+
every machine: the harness on `localhost`, forwarded over USB (Android).
|
|
1285
|
+
|
|
1286
|
+
1. Ask Condor for a development build made with
|
|
1287
|
+
`--dart-define=IS_DEV=true --dart-define=WL_BUNDLE_DEV_URL=http://localhost:5173/__condor/client.html`.
|
|
1288
|
+
2. Serve your bundle with the harness — it inlines it like `build` does:
|
|
1289
|
+
`npx condor-mobile-bridge dev -i dist/index.html`.
|
|
1290
|
+
3. Forward the port to the phone: `adb reverse tcp:5173 tcp:5173`.
|
|
1291
|
+
4. Rebuild your bundle, bring the app to the foreground: the new bundle loads.
|
|
1292
|
+
|
|
1293
|
+
The app still applies the 2 MB and self-contained checks; it never caches this
|
|
1294
|
+
bundle, so a normal build shows the published one again. Store builds refuse
|
|
1295
|
+
the flag.
|
|
1296
|
+
|
|
1155
1297
|
## Seeing what the bundle does on the phone
|
|
1156
1298
|
|
|
1157
1299
|
**Release builds of the app cannot be inspected.** Ask Condor for a
|
|
@@ -1174,6 +1316,220 @@ by the app (wrong argument type, unknown command, a request lost while the
|
|
|
1174
1316
|
background service starts) leaves no trace in the console — reproduce it in the
|
|
1175
1317
|
harness instead, whose console says what was dropped and why.
|
|
1176
1318
|
|
|
1319
|
+
## What `build` does
|
|
1320
|
+
|
|
1321
|
+
Generates the **self-contained `.zip` bundle** that the white-label app
|
|
1322
|
+
consumes. The app injects the client HTML via `iframe.srcdoc` (origin `null`,
|
|
1323
|
+
no base URL) → **relative references do not resolve** and the CSP blocks
|
|
1324
|
+
external fetches; therefore the `.zip` must contain **a single `index.html`**
|
|
1325
|
+
with inline JS/CSS and media as `data:` URIs.
|
|
1326
|
+
|
|
1327
|
+
The CLI does this from your build's `dist/`: it reads `index.html`, **inlines**
|
|
1328
|
+
`<script src>` (escaping `</script>`) and `<link rel=stylesheet>`, resolves CSS
|
|
1329
|
+
`url()` and local `<img>`/icons to `data:` URIs, and packs a `.zip` with
|
|
1330
|
+
`index.html` at the root. External refs (`https://…`) are **preserved** (and
|
|
1331
|
+
blocked by the app's CSP at runtime).
|
|
1332
|
+
|
|
1333
|
+
```bash
|
|
1334
|
+
npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
|
|
1335
|
+
```
|
|
1336
|
+
|
|
1337
|
+
| Option | Description |
|
|
1338
|
+
|---|---|
|
|
1339
|
+
| `-i, --html <path>` | Input HTML (e.g. `dist/index.html`). Required. |
|
|
1340
|
+
| `-o, --out <path>` | Output `.zip` (default: `dist/whitelabel-bundle.zip`). |
|
|
1341
|
+
| `--max-bytes <n>` | **Warning** threshold in bytes (default: `2097152` = 2 MB). |
|
|
1342
|
+
|
|
1343
|
+
Output (for registering the bundle with the backend — `bundleSize`/`bundleHash`):
|
|
1344
|
+
|
|
1345
|
+
```
|
|
1346
|
+
Built dist/bundle.zip
|
|
1347
|
+
entry: index.html (123.4 KB self-contained)
|
|
1348
|
+
bundleSize (zip): 45678 bytes
|
|
1349
|
+
bundleHash (zip): sha256-<hex>
|
|
1350
|
+
```
|
|
1351
|
+
|
|
1352
|
+
The hash is **deterministic** (fixed mtime on the zip entry) — the same input
|
|
1353
|
+
produces the same `bundleHash`. Above the threshold, the CLI **warns** (does
|
|
1354
|
+
not block): remember that **the app rejects** a `.zip` larger than 2 MB and
|
|
1355
|
+
falls back to the mock.
|
|
1356
|
+
|
|
1357
|
+
> ⚠️ **Constraint: single-chunk build.** The inliner is post-build and assumes
|
|
1358
|
+
> a `dist` with **one JS + one CSS + assets**. Builds with **code-splitting /
|
|
1359
|
+
> ES modules** that `import` across chunks **break** in `srcdoc` (the imports
|
|
1360
|
+
> do not resolve). Configure your build to emit a single file:
|
|
1361
|
+
> - **Vite:** `vite-plugin-singlefile` (or `build.rollupOptions.output.inlineDynamicImports: true` + `manualChunks: undefined`).
|
|
1362
|
+
> - **CRA:** build without code-splitting (no `React.lazy`/dynamic `import()` in routes), single bundle.
|
|
1363
|
+
>
|
|
1364
|
+
> Inlining inflates `data:` media by ~33% (base64) — size against the 2 MB limit.
|
|
1365
|
+
|
|
1366
|
+
## Scripted tests
|
|
1367
|
+
|
|
1368
|
+
`npx condor-mobile-bridge dev` is also a test target. On the harness page,
|
|
1369
|
+
`window.condorDev` drives the simulated app; your bundle runs in the iframe
|
|
1370
|
+
under it, as on the phone.
|
|
1371
|
+
|
|
1372
|
+
| `window.condorDev.…` | Does |
|
|
1373
|
+
| --- | --- |
|
|
1374
|
+
| `ready` | `true` once the bundle is mounted **and** its handshake was answered — wait for it |
|
|
1375
|
+
| `scenario` | the scenario in use, after every override |
|
|
1376
|
+
| `log` | everything sent, answered and dropped: `{ at, kind, command?, key?, detail, args?, payload? }[]`. `detail` is a shortened line; `args` (sent) and `payload` (reply) are complete |
|
|
1377
|
+
| `setStatus({ internet: false })` | changes `systemStatus` and emits it, as the app does; without internet `sensorLog` fails, and it recovers with `{ internet: true }` |
|
|
1378
|
+
| `setConnected(deviceId, true \| false)` | starts or ends a device's BLE session: `deviceConnection` (if you sent `add`), the `deviceLightSensors` stream (if `realTime` is on) and the sync's `deviceNotification` steps, 200 ms apart, ending in `pipeline_done_notification`. As on the phone, the session then **ends by itself** — unless `realTime` is on for that device, which keeps it open until `realTime` goes off |
|
|
1379
|
+
| `emit(key, payload)` | any native event, typed as in `docs/bridge-contract.md` — e.g. `emit('language', { deviceLocale: 'pt-BR', appLanguage: 'pt', resolvedLanguage: 'pt' })` |
|
|
1380
|
+
| `setConfirmations('accept' \| 'decline' \| 'ask')` | answers the app's confirmation dialog for you |
|
|
1381
|
+
| `pressBack()` | the OS back button |
|
|
1382
|
+
|
|
1383
|
+
URL parameters set the same things before the bundle loads:
|
|
1384
|
+
`?scenario=offline`, `?nativeVersion=0.17.0`, `?confirm=accept`,
|
|
1385
|
+
`?latency=0`, `?panel=0`. A `?scenario=` in the URL **replaces** a
|
|
1386
|
+
`--scenario file.json` given to the CLI (the file is not loaded at all), so
|
|
1387
|
+
with a file leave `scenario` out of the URL. The CLI equivalents are `--latency <ms>` (`0` makes
|
|
1388
|
+
a test suite several times faster) and `--no-panel` (the floating MOCK panel
|
|
1389
|
+
covers the bottom of your page and can swallow clicks).
|
|
1390
|
+
|
|
1391
|
+
**Your own scenario.** `--scenario edge.json` takes a file that extends a named
|
|
1392
|
+
scenario; every field is optional:
|
|
1393
|
+
|
|
1394
|
+
```json
|
|
1395
|
+
{
|
|
1396
|
+
"extends": "default",
|
|
1397
|
+
"nativeVersion": "0.17.0",
|
|
1398
|
+
"latencyMs": 0,
|
|
1399
|
+
"serviceStartMs": 4000,
|
|
1400
|
+
"logPeriodS": 300,
|
|
1401
|
+
"firstSessionAfterMs": null,
|
|
1402
|
+
"sessionFailAt": "get_data_log",
|
|
1403
|
+
"history": { "days": 14, "dockHours": [7], "gapHours": [2, 3], "gapDates": ["2026-10-18"] },
|
|
1404
|
+
"sensorLogFailOnRequest": 3,
|
|
1405
|
+
"userDeviceError": "NETWORK",
|
|
1406
|
+
"sensorLogError": "INVALID_TIME_RANGE",
|
|
1407
|
+
"systemStatus": { "bluetooth": false, "missingEssentialPermissions": ["bluetoothScan"] },
|
|
1408
|
+
"devices": { "lastUpdate": 0, "devicesConfig": [], "devicesTelemetry": [], "devicesUserConfig": [] }
|
|
1409
|
+
}
|
|
1410
|
+
```
|
|
1411
|
+
|
|
1412
|
+
`devices` is the `userDevice` payload to answer (`UserDevicePayload`).
|
|
1413
|
+
`firstSessionAfterMs` is when the first device starts a BLE session: `3000` in
|
|
1414
|
+
`default`, absent in every other scenario; `null` turns it off.
|
|
1415
|
+
`systemStatus` is merged into the base scenario's. `sessionFailAt` makes every
|
|
1416
|
+
simulated sync fail at that stage (`<stage>_error`, no
|
|
1417
|
+
`pipeline_done_notification`); without internet syncs fail at
|
|
1418
|
+
`send_data_log` anyway. A pending `syncTime`/`deleteMemory` shows up as its step
|
|
1419
|
+
in the next session. A failed sync also puts an English text in
|
|
1420
|
+
`systemStatus.deviceSyncErrorById` (and `deviceSyncError`) and emits it; the
|
|
1421
|
+
next successful sync of that device clears it.
|
|
1422
|
+
|
|
1423
|
+
`history` shapes the simulated `sensorLog` (`HistoryOptions`): `days` before
|
|
1424
|
+
now, `dockHours` (local hours with `off_wrist: 0` and no light), `gapHours`
|
|
1425
|
+
(local hours with no log, every day) and `gapDates` (whole local dates with no
|
|
1426
|
+
log). `sensorLogFailOnRequest: N` makes the Nth `sensorLog` request fail once
|
|
1427
|
+
with `Connection failed` — a failure in the middle of a sweep.
|
|
1428
|
+
|
|
1429
|
+
**The data the harness serves.** Two devices (`twoDevices()`: ActLumus 4534 /
|
|
1430
|
+
id 9001 set to the wrist, ActLumus Plus 5120 / id 9002 set to the necklace).
|
|
1431
|
+
Their `sensorLog`: `history.days` (14 by default; set it to 30 for a 30-day
|
|
1432
|
+
report) × 24 h back from **now** (not from midnight), one
|
|
1433
|
+
log per `logPeriodS` (60 s by default); `body_part` is the device's
|
|
1434
|
+
`deviceBodyPart`; worn (`off_wrist: 1`) all day except the dock hour 07:00–08:00
|
|
1435
|
+
local (`off_wrist: 0`); light follows a daylight curve on the browser's local
|
|
1436
|
+
clock — deterministic for a given timestamp, so a test can recompute what a
|
|
1437
|
+
chart should show. The `bodyPart` filter of a query is not simulated.
|
|
1438
|
+
`deviceLightSensors` arrives once per second.
|
|
1439
|
+
|
|
1440
|
+
**Other harness behaviour worth knowing in a test:**
|
|
1441
|
+
- `language` is answered from the **browser's** `navigator.language`, with
|
|
1442
|
+
`appLanguage: null`. Fix it in a test with
|
|
1443
|
+
`condorDev.emit('language', …)` before asserting on text.
|
|
1444
|
+
- A declined confirmation (`?confirm=decline`) produces nothing: no ack, no
|
|
1445
|
+
event — as on the phone.
|
|
1446
|
+
- With `nativeVersion` null the handshake resolves only after the shell's 2 s
|
|
1447
|
+
relay timeout, so such a test starts 2 s later.
|
|
1448
|
+
- The harness has no fake clock: a time-out of yours (45 s for a declined
|
|
1449
|
+
confirmation, minutes for a lost sync) belongs in a unit test with your test
|
|
1450
|
+
runner's fake timers, not in a browser test.
|
|
1451
|
+
- Event order is not the phone's: no order between events is guaranteed there
|
|
1452
|
+
either (`docs/bridge-contract.md`, "Emission cadence"), so don't let a test
|
|
1453
|
+
depend on it.
|
|
1454
|
+
- `condorDev.log` keeps the **last 500** entries: read it as you go in a long
|
|
1455
|
+
test. The
|
|
1456
|
+
fixtures are exported from `@condorinst/mobile-bridge/dev` (`twoDevices`,
|
|
1457
|
+
`sharedSerial`, `systemStatus`, `sensorLogPage`, `lightSensorsReading`,
|
|
1458
|
+
`formatLocalTimestamp`) for unit tests without a browser. Pass `sensorLogPage`
|
|
1459
|
+
the device's `deviceId` (`{ deviceId }`); without it `device_id` is `0`.
|
|
1460
|
+
|
|
1461
|
+
**Like the phone, not like `localhost`.** The harness removes
|
|
1462
|
+
`crypto.randomUUID` from your iframe: on `localhost` it would exist, and on the
|
|
1463
|
+
phone it probably does not.
|
|
1464
|
+
|
|
1465
|
+
A test with `puppeteer-core` and the Chrome already installed (`puppeteer`
|
|
1466
|
+
works the same and downloads its own browser):
|
|
1467
|
+
|
|
1468
|
+
```js
|
|
1469
|
+
import puppeteer from 'puppeteer-core';
|
|
1470
|
+
import { spawn } from 'node:child_process';
|
|
1471
|
+
|
|
1472
|
+
const port = 5180;
|
|
1473
|
+
const cli = 'node_modules/@condorinst/mobile-bridge/dist/cli.js';
|
|
1474
|
+
const dev = spawn('node', [cli, 'dev', '-i', 'dist/index.html',
|
|
1475
|
+
'--port', String(port), '--latency', '0', '--no-panel'], { stdio: 'inherit' });
|
|
1476
|
+
for (let i = 0; i < 50; i++) { // wait for the port, not a fixed sleep
|
|
1477
|
+
try { await fetch(`http://localhost:${port}/`); break; } catch { await new Promise((r) => setTimeout(r, 100)); }
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
const browser = await puppeteer.launch({
|
|
1481
|
+
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', // your Chrome
|
|
1482
|
+
});
|
|
1483
|
+
const page = await browser.newPage();
|
|
1484
|
+
await page.emulateTimezone('Pacific/Kiritimati'); // log_time is local: test far from UTC
|
|
1485
|
+
await page.goto(`http://localhost:${port}/?confirm=accept`); // the CLI's --scenario applies
|
|
1486
|
+
await page.waitForFunction(() => window.condorDev?.ready);
|
|
1487
|
+
|
|
1488
|
+
const bundle = page.frames().find((f) => f !== page.mainFrame()); // your page
|
|
1489
|
+
await bundle.waitForSelector('[data-device-id]'); // assert on your markup, not on text
|
|
1490
|
+
|
|
1491
|
+
await page.evaluate(() => window.condorDev.setStatus({ internet: false }));
|
|
1492
|
+
await bundle.waitForSelector('[data-offline]');
|
|
1493
|
+
|
|
1494
|
+
const queries = await page.evaluate(() =>
|
|
1495
|
+
window.condorDev.log.filter((e) => e.kind === 'sent' && e.command === 'sensorLog').map((e) => e.args));
|
|
1496
|
+
|
|
1497
|
+
await browser.close();
|
|
1498
|
+
dev.kill(); // node, not npx: no orphan process
|
|
1499
|
+
```
|
|
1500
|
+
|
|
1501
|
+
The harness does nothing on its own after load, except one session (the first
|
|
1502
|
+
device of the `default` scenario connects after 3 s, syncs, and disconnects
|
|
1503
|
+
about 4 s later unless `realTime` is on). Internet loss, a
|
|
1504
|
+
device disconnecting, or a reading arriving are things your test does through
|
|
1505
|
+
`window.condorDev`.
|
|
1506
|
+
|
|
1507
|
+
### What `validate` checks
|
|
1508
|
+
|
|
1509
|
+
Errors make the app reject the bundle; warnings are failures with `--strict`.
|
|
1510
|
+
The warnings are **textual**: they match strings and comments too, so do not
|
|
1511
|
+
leave these names in your code even unused.
|
|
1512
|
+
|
|
1513
|
+
| Rule | Severity | Matches |
|
|
1514
|
+
| --- | --- | --- |
|
|
1515
|
+
| `not-self-contained` | error | `<script src>` or `<link rel="stylesheet">` that is not inline |
|
|
1516
|
+
| `too-large` | error | a `.zip` over 2 MB |
|
|
1517
|
+
| `unreadable-zip` | error | a file that is not a valid `.zip` |
|
|
1518
|
+
| `entry-missing` | error | no HTML entry in the `.zip` |
|
|
1519
|
+
| `entry-fallback` | warning | no `index.html` at the root of the `.zip` |
|
|
1520
|
+
| `dev-harness-bundled` | error | the dev harness compiled into the bundle |
|
|
1521
|
+
| `relative-asset` | warning | a relative `<img src>` (use a `data:` URI) |
|
|
1522
|
+
| `web-storage` | warning | `localStorage`, `sessionStorage` |
|
|
1523
|
+
| `indexed-db` | warning | `indexedDB` |
|
|
1524
|
+
| `cookies` | warning | `document.cookie` |
|
|
1525
|
+
| `dynamic-eval` | warning | `eval(`, `new Function(` |
|
|
1526
|
+
| `service-worker` | warning | `serviceWorker` |
|
|
1527
|
+
| `send-beacon` | warning | `sendBeacon` |
|
|
1528
|
+
| `popups` | warning | `window.open(` |
|
|
1529
|
+
| `crypto-randomuuid` | warning | `crypto.randomUUID` |
|
|
1530
|
+
| `top-navigation` | warning | `window.top` |
|
|
1531
|
+
| `external-network` | warning | `fetch(`, `XMLHttpRequest`, `WebSocket(` |
|
|
1532
|
+
|
|
1177
1533
|
## Errors after the fact
|
|
1178
1534
|
|
|
1179
1535
|
`BridgeClient.send('jsError', { message, stack })` sends an error to Condor's
|
|
@@ -1314,532 +1670,55 @@ layout grow. A two-slot UI with the slots hard-coded is the failure this section
|
|
|
1314
1670
|
exists to prevent: it will look correct for as long as the rule holds, and break
|
|
1315
1671
|
silently on the day it does not.
|
|
1316
1672
|
|
|
1317
|
-
<!-- ===== docs/client-guide.md ===== -->
|
|
1318
|
-
|
|
1319
|
-
# Build your White Label Home
|
|
1320
|
-
|
|
1321
|
-
This guide walks you — a Condor White Label partner — through building the web
|
|
1322
|
-
Home that runs inside the Condor mobile app, using the
|
|
1323
|
-
[`@condorinst/mobile-bridge`](../README.md) SDK.
|
|
1324
|
-
|
|
1325
|
-
No Flutter, Android, or iOS knowledge is required. You build a normal web app;
|
|
1326
|
-
we package it and the app renders it.
|
|
1327
|
-
|
|
1328
|
-
> **This guide is the narrative path, written for a person reading start to
|
|
1329
|
-
> finish.** If you are working with an AI agent, point it at
|
|
1330
|
-
> [`AGENTS.md`](../AGENTS.md) and [`docs/agent-guide.md`](./agent-guide.md)
|
|
1331
|
-
> instead — same platform, organised as a procedure with checks. The normative
|
|
1332
|
-
> reference for keys, commands, errors and capabilities is
|
|
1333
|
-
> [`docs/bridge-contract.md`](./bridge-contract.md); the hard numbers are in
|
|
1334
|
-
> [`docs/limits.md`](./limits.md).
|
|
1335
|
-
|
|
1336
|
-
---
|
|
1337
|
-
|
|
1338
|
-
## 1. What you're building
|
|
1339
|
-
|
|
1340
|
-
The app's Home is a **web bundle** that the Condor app renders inside a
|
|
1341
|
-
sandboxed WebView (`<iframe sandbox="allow-scripts">`). You deliver a single
|
|
1342
|
-
self-contained `index.html`; the app downloads it, verifies it, and shows it.
|
|
1343
|
-
|
|
1344
|
-
Your web code talks to the device and the native app through the **bridge** —
|
|
1345
|
-
a typed message channel exposed by this SDK. You never touch Bluetooth or
|
|
1346
|
-
native APIs directly: you subscribe to keys (device list, connection status,
|
|
1347
|
-
real-time readings…) and send commands (refresh, start real-time, open
|
|
1348
|
-
settings…).
|
|
1349
|
-
|
|
1350
|
-
```
|
|
1351
|
-
┌────────────────────────────┐
|
|
1352
|
-
│ Condor app (native) │
|
|
1353
|
-
│ ┌──────────────────────┐ │
|
|
1354
|
-
│ │ iframe (your bundle) │ │ ← your index.html runs here
|
|
1355
|
-
│ │ BridgeClient ◄────────────► native bridge (devices, BLE, …)
|
|
1356
|
-
│ └──────────────────────┘ │
|
|
1357
|
-
└────────────────────────────┘
|
|
1358
|
-
```
|
|
1359
|
-
|
|
1360
|
-
**Two hard rules** that shape everything below:
|
|
1361
|
-
- The bundle must be **one `index.html`**, fully **self-contained** (JS/CSS
|
|
1362
|
-
inline, media as `data:` URIs). Relative paths and external URLs do **not**
|
|
1363
|
-
load inside the sandbox.
|
|
1364
|
-
- The bundle must be **≤ 2 MB** (zipped).
|
|
1365
|
-
|
|
1366
|
-
The CLI in this SDK turns your normal build into a compliant bundle for you
|
|
1367
|
-
(see §6) — you don't have to inline by hand.
|
|
1368
|
-
|
|
1369
|
-
---
|
|
1370
|
-
|
|
1371
|
-
## 2. Install the SDK
|
|
1372
|
-
|
|
1373
|
-
```bash
|
|
1374
|
-
npm install @condorinst/mobile-bridge
|
|
1375
|
-
```
|
|
1376
|
-
|
|
1377
|
-
Pin the version with a caret:
|
|
1378
|
-
|
|
1379
|
-
```jsonc
|
|
1380
|
-
// package.json
|
|
1381
|
-
"dependencies": {
|
|
1382
|
-
"@condorinst/mobile-bridge": "^0.22.0"
|
|
1383
|
-
}
|
|
1384
|
-
```
|
|
1385
|
-
|
|
1386
|
-
**Updates.** The SDK is on `0.x` during the pilot. With `^0.22.0`, running
|
|
1387
|
-
`npm install` (or your CI) auto-picks **patch** releases (`0.22.x`) — bug fixes,
|
|
1388
|
-
no action needed. A `0.23.0` (a larger change during beta) is an intentional
|
|
1389
|
-
opt-in: you bump the range when you're ready. `1.0.0` will mark the
|
|
1390
|
-
production-stable contract.
|
|
1391
|
-
|
|
1392
|
-
The published package ships prebuilt JS + TypeScript types — no build tooling
|
|
1393
|
-
of ours required on your side.
|
|
1394
|
-
|
|
1395
|
-
---
|
|
1396
|
-
|
|
1397
|
-
## 3. Talk to the app — the bridge
|
|
1398
|
-
|
|
1399
|
-
Import **`BridgeClient`** (the model for code running inside the app's iframe):
|
|
1400
|
-
|
|
1401
|
-
```typescript
|
|
1402
|
-
import { BridgeClient, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
|
|
1403
|
-
```
|
|
1404
|
-
|
|
1405
|
-
### Handshake
|
|
1406
|
-
|
|
1407
|
-
Announce yourself once at boot. The ack tells you the bridge is live.
|
|
1408
|
-
|
|
1409
|
-
```typescript
|
|
1410
|
-
const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
|
|
1411
|
-
// ack.accepted: boolean
|
|
1412
|
-
// ack.shellVersion: string
|
|
1413
|
-
// ack.nativeVersion: string | null — the native's schema version, relayed by
|
|
1414
|
-
// the shell (0.17.0+). `null` means the native has not answered or is absent
|
|
1415
|
-
// (browser mock). There is no version negotiation, so this is the only signal
|
|
1416
|
-
// telling you whether a capability exists — e.g. the raw `light_sensor_*`
|
|
1417
|
-
// channels on `deviceLightSensors` require native >= 0.16.0.
|
|
1418
|
-
```
|
|
1419
|
-
|
|
1420
|
-
### Listen for events (native → you)
|
|
1421
|
-
|
|
1422
|
-
```typescript
|
|
1423
|
-
const unsubscribe = BridgeClient.on(BRIDGE_KEYS.deviceConnection, (payload) => {
|
|
1424
|
-
// payload is typed: { serialNo: number, isConnect: boolean }
|
|
1425
|
-
console.log(payload.serialNo, payload.isConnect);
|
|
1426
|
-
});
|
|
1427
|
-
// later: unsubscribe();
|
|
1428
|
-
```
|
|
1429
|
-
|
|
1430
|
-
### Send commands (you → native)
|
|
1431
|
-
|
|
1432
|
-
```typescript
|
|
1433
|
-
const devices = await BridgeClient.requestUserDevices(); // device list, resent until it arrives
|
|
1434
|
-
BridgeClient.send('realTime', { serialNo: 1234, value: true }); // start real-time
|
|
1435
|
-
BridgeClient.openSettings(); // open native Settings
|
|
1436
|
-
BridgeClient.requestPermission('bluetoothScan'); // in-app permission dialog
|
|
1437
|
-
```
|
|
1438
|
-
|
|
1439
|
-
> Commands are gated by the app's policy. A command that isn't allowed is
|
|
1440
|
-
> silently ignored — design your UI to not depend on a response you won't get.
|
|
1441
|
-
>
|
|
1442
|
-
> Ask for the device list with `requestUserDevices()`, not with
|
|
1443
|
-
> `send('userDevice')`: the first request after the app starts is often lost,
|
|
1444
|
-
> and an account with no device gets no reply at all. The helper resends with
|
|
1445
|
-
> backoff and resolves `{ status: 'timeout', hasDevices }` after 30 s, so you can
|
|
1446
|
-
> show an empty state. See [Troubleshooting](./troubleshooting.md).
|
|
1447
|
-
|
|
1448
|
-
### Handle the system back button
|
|
1449
|
-
|
|
1450
|
-
Your page runs inside the app's Home screen. On Android, the OS back
|
|
1451
|
-
button/gesture would otherwise leave the Home — so the app delegates it to you
|
|
1452
|
-
when you opt in, letting the back button walk **your** internal navigation
|
|
1453
|
-
(e.g. a device detail page → back to the list) before it does anything else.
|
|
1454
|
-
|
|
1455
|
-
Two calls wire it up:
|
|
1456
|
-
|
|
1457
|
-
```typescript
|
|
1458
|
-
// 1. Tell the app whether you have an internal page to go back to.
|
|
1459
|
-
// Call this on EVERY route change (and once at boot).
|
|
1460
|
-
BridgeClient.reportNavigation(currentView !== 'home'); // true = you can go back
|
|
1461
|
-
|
|
1462
|
-
// 2. React to the OS back button. Called only when you last reported `true`.
|
|
1463
|
-
BridgeClient.onBack(() => {
|
|
1464
|
-
goToPreviousPage(); // pop one internal page, then reportNavigation(...) again
|
|
1465
|
-
});
|
|
1466
|
-
```
|
|
1467
|
-
|
|
1468
|
-
How it behaves:
|
|
1469
|
-
|
|
1470
|
-
- You reported `true` → the OS back button fires your `onBack` (go back one
|
|
1471
|
-
internal page). Nothing else happens — the app stays on Home.
|
|
1472
|
-
- You reported `false` (or never adopted this API) → the OS back button is
|
|
1473
|
-
ignored and the user stays on Home. **No regression if you skip it.**
|
|
1474
|
-
|
|
1475
|
-
Keep `reportNavigation` in sync with your router: the app caches the last value
|
|
1476
|
-
and reads it the instant back is pressed. A single choke point (call it at the
|
|
1477
|
-
end of your `render`/route function) is the simplest way to never miss a change.
|
|
1478
|
-
See `examples/client_demo/src/main.ts` for a worked home↔device example.
|
|
1479
|
-
|
|
1480
|
-
### Key catalog
|
|
1481
|
-
|
|
1482
|
-
These are the keys you can subscribe to / send. Payloads are fully typed in
|
|
1483
|
-
TypeScript; the table is the quick reference (see the
|
|
1484
|
-
[README](../README.md#key-catalog-strictly-typed) for exact shapes).
|
|
1485
|
-
|
|
1486
|
-
| Key | Payload (typed) | Direction |
|
|
1487
|
-
|---|---|---|
|
|
1488
|
-
| `userDevice` | device config / telemetry / user config arrays | native → you (also send to refresh) |
|
|
1489
|
-
| `deviceConnection` | `{ serialNo, isConnect }` | native → you (status) |
|
|
1490
|
-
| `deviceNotification` | `{ serialNo, isConnect, message, messageCode, isError }` | native → you |
|
|
1491
|
-
| `deviceLightSensors` | `{ serialNo, type, data }` — union on `type` (`'actlumus'` \| `'actlumus2'`); `data` has the spectrum plus the raw `light_sensor_*` channels | native → you |
|
|
1492
|
-
| `realTime` | `{ serialNo, success, value }` | both (command + state) |
|
|
1493
|
-
| `syncTime` | `{ serialNo, success }` | both |
|
|
1494
|
-
| `sensorLog` | paginated sensor log envelope | native → you |
|
|
1495
|
-
| `systemStatus` | `{ bluetooth, internet, permission, hasDevices, missingOptionalPermissions, missingEssentialPermissions, deviceSyncError }` | native → you (env status) |
|
|
1496
|
-
| `deleteMemory` | `{ serialNo, success }` | both |
|
|
1497
|
-
| `pingPong` | `{ data: 'ping' \| 'pong' }` | both |
|
|
1498
|
-
| `navigationBack` | `{}` (OS back pressed — via `onBack`) | native → you |
|
|
1499
|
-
| `language` | `{ deviceLocale, appLanguage, resolvedLanguage }` | native → you (language state) |
|
|
1500
|
-
|
|
1501
|
-
> **System back button.** Use the `reportNavigation(canGoBack)` /
|
|
1502
|
-
> `onBack(cb)` helpers (see [Handle the system back button](#handle-the-system-back-button))
|
|
1503
|
-
> rather than subscribing to `navigationBack` directly — `reportNavigation`
|
|
1504
|
-
> sends the `navigationState` command the app needs to route the back button.
|
|
1505
|
-
|
|
1506
|
-
**Permissions on `systemStatus`.** Two arrays of `AppPermission` names let you
|
|
1507
|
-
render permission prompts from your own UI:
|
|
1508
|
-
|
|
1509
|
-
- `missingEssentialPermissions` — **required** for the app to work; background
|
|
1510
|
-
collection stays off until granted. Render a **blocking "Required permissions"
|
|
1511
|
-
card** (e.g. `["bluetoothScan", "bluetoothConnect"]`).
|
|
1512
|
-
- `missingOptionalPermissions` — **recommended** (help background reliability);
|
|
1513
|
-
render a **dismissible "Recommended permissions" nudge** (e.g.
|
|
1514
|
-
`["notification", "backgroundLocation"]`).
|
|
1515
|
-
|
|
1516
|
-
Both are empty when nothing is missing. For the **required** card, wire the
|
|
1517
|
-
"Grant" button to `BridgeClient.requestPermission(name)` — it fires the **native
|
|
1518
|
-
in-app permission dialog** for that `AppPermission` (falls back to Settings only
|
|
1519
|
-
when the OS refuses the prompt). Pass a name straight from
|
|
1520
|
-
`missingEssentialPermissions` (e.g. `'bluetoothScan'`). There is no dedicated
|
|
1521
|
-
result key: the native side re-emits `systemStatus` once granted, so the card
|
|
1522
|
-
disappears when `missingEssentialPermissions` empties. `openSettings` remains
|
|
1523
|
-
available for the optional nudge (permissions not grantable by prompt on modern
|
|
1524
|
-
Android). See `examples/client_demo/src/render-home.ts` for a worked render.
|
|
1525
|
-
|
|
1526
|
-
### Translate your bundle — the `language` key
|
|
1527
|
-
|
|
1528
|
-
The app tells you **two** languages, and the distinction between them is the
|
|
1529
|
-
whole point of the channel:
|
|
1530
|
-
|
|
1531
|
-
```typescript
|
|
1532
|
-
BridgeClient.on(BRIDGE_KEYS.language, ({ deviceLocale, appLanguage, resolvedLanguage }) => {
|
|
1533
|
-
// Your precedence rule. This one honours an explicit choice, else the device.
|
|
1534
|
-
const lang = appLanguage ?? pickSupported(deviceLocale) ?? 'en';
|
|
1535
|
-
renderIn(lang);
|
|
1536
|
-
});
|
|
1537
|
-
```
|
|
1538
|
-
|
|
1539
|
-
- **`deviceLocale`** — the raw device locale as a BCP-47 tag (`"pt-BR"`,
|
|
1540
|
-
`"es-419"`, `"ja-JP"`), exactly as the OS reports it, region included.
|
|
1541
|
-
- **`appLanguage`** — the language the user explicitly picked in the app
|
|
1542
|
-
Settings (`"en" | "pt" | "de" | "fr"`), or **`null`** when they never picked
|
|
1543
|
-
one.
|
|
1544
|
-
- **`resolvedLanguage`** — the language the native chrome (splash, Settings,
|
|
1545
|
-
loading/error screens) is rendering right now.
|
|
1546
|
-
|
|
1547
|
-
**Why two raw values instead of one answer.** The app itself ships four
|
|
1548
|
-
languages and collapses everything else to English. Your bundle has no such
|
|
1549
|
-
limit — it may ship Spanish, Italian, Japanese. If we sent you only the resolved
|
|
1550
|
-
language, a device set to Spanish would reach you as `en` and you would render
|
|
1551
|
-
English while holding a Spanish bundle. The information would have been
|
|
1552
|
-
destroyed on our side before reaching you. So we send both and let you decide:
|
|
1553
|
-
|
|
1554
|
-
| `deviceLocale` | `appLanguage` | a reasonable decision |
|
|
1555
|
-
|---|---|---|
|
|
1556
|
-
| `es-ES` | `null` | render **Spanish** — you have it, we don't. Our chrome stays English while your content is Spanish, which is better, not worse |
|
|
1557
|
-
| `es-ES` | `"pt"` | render **Portuguese** — an explicit choice outranks the device |
|
|
1558
|
-
| `pt-BR` | `null` | render Portuguese |
|
|
1559
|
-
| `ja-JP` | `"de"` | render German (the explicit choice), or Japanese if you'd rather honour the device — your call, with both values in hand |
|
|
1560
|
-
|
|
1561
|
-
`appLanguage` is `null` when — and only when — the user never chose. It is never
|
|
1562
|
-
a fallback in disguise, so `null` is a real signal you can branch on. Use
|
|
1563
|
-
`resolvedLanguage` when you'd rather stay visually consistent with the native
|
|
1564
|
-
frame around your page than follow the device.
|
|
1565
|
-
|
|
1566
|
-
**When it arrives — and how to ask for it.** The app pushes this key on attach,
|
|
1567
|
-
again right after the handshake ack, and on every change in the app Settings, so
|
|
1568
|
-
a page already loaded re-renders without a restart. But the first push happens
|
|
1569
|
-
while the WebView is still being created, before your bundle exists, so **send
|
|
1570
|
-
the `language` command once after your handshake** to pull the current state:
|
|
1571
|
-
|
|
1572
|
-
```typescript
|
|
1573
|
-
BridgeClient.send('language', {}); // replies with a `language` event
|
|
1574
|
-
```
|
|
1575
|
-
|
|
1576
|
-
Without that pull, a first launch would show your default language until the
|
|
1577
|
-
user changed it in Settings. Subscribe first, then pull.
|
|
1578
|
-
|
|
1579
|
-
Do not read `navigator.language` instead: it follows the system, not the in-app
|
|
1580
|
-
choice, and you would end up in Spanish while the native chrome around you is in
|
|
1581
|
-
Portuguese.
|
|
1582
|
-
|
|
1583
|
-
**Older app versions.** There is no version negotiation. An app older than
|
|
1584
|
-
native `0.14.0` never emits this key, so the event simply never arrives — keep a
|
|
1585
|
-
default language for that case, and read `nativeVersion` from the handshake ack
|
|
1586
|
-
if you need to branch on it explicitly.
|
|
1587
|
-
|
|
1588
|
-
---
|
|
1589
|
-
|
|
1590
|
-
## 4. Develop in your browser (no app needed)
|
|
1591
|
-
|
|
1592
|
-
Build your page, then serve it with the harness:
|
|
1593
|
-
|
|
1594
|
-
```bash
|
|
1595
|
-
npx condor-mobile-bridge dev -i dist/index.html
|
|
1596
|
-
```
|
|
1597
|
-
|
|
1598
|
-
Open `http://localhost:5173`. Your page runs inside the same sandboxed iframe,
|
|
1599
|
-
shell, policy and CSP as in the app, and a simulated app answers your commands.
|
|
1600
|
-
Rebuild and reload — the harness re-reads the file.
|
|
1601
|
-
|
|
1602
|
-
Pick a **scenario** in the selector at the top (or with `--scenario`) to see
|
|
1603
|
-
what the real app does to your page:
|
|
1604
|
-
|
|
1605
|
-
- `cold-start` — the first device requests are lost while the app starts;
|
|
1606
|
-
- `no-devices` — an account with no device: the device list never comes;
|
|
1607
|
-
- `shared-serial` — two devices with the same serial number;
|
|
1608
|
-
- `offline` — no internet, history unavailable.
|
|
1609
|
-
|
|
1610
|
-
Your page is ready for a phone when it behaves in all of them. The page's
|
|
1611
|
-
console (`[condor dev]` lines) shows each command, reply and dropped request,
|
|
1612
|
-
and the dev panel in the corner lets you emit any event by hand.
|
|
1613
|
-
|
|
1614
|
-
> Opening `dist/index.html` directly in a browser does not work: there is no
|
|
1615
|
-
> shell around it, so `BridgeClient.handshake()` rejects at once with a message
|
|
1616
|
-
> pointing to the harness.
|
|
1617
|
-
|
|
1618
|
-
The [`examples/minimal-home`](../examples/minimal-home) example runs this way
|
|
1619
|
-
out of the box.
|
|
1620
|
-
|
|
1621
|
-
---
|
|
1622
|
-
|
|
1623
|
-
## 5. Bundle format & constraints
|
|
1624
|
-
|
|
1625
|
-
Your final artifact is a `.zip` containing exactly **one self-contained
|
|
1626
|
-
`index.html`** at the root. Why the constraints:
|
|
1627
|
-
|
|
1628
|
-
- The app injects your HTML via `iframe.srcdoc` with origin `null` and no base
|
|
1629
|
-
URL → **relative references don't resolve** (`<script src="app.js">`,
|
|
1630
|
-
`<img src="logo.png">` would break).
|
|
1631
|
-
- The app's Content-Security-Policy **blocks external fetches** → CDN scripts,
|
|
1632
|
-
Google Fonts, remote APIs called at load time won't work.
|
|
1633
|
-
|
|
1634
|
-
So the bundle must have:
|
|
1635
|
-
- ✅ One `index.html` at the zip root.
|
|
1636
|
-
- ✅ JS and CSS **inlined** into the HTML.
|
|
1637
|
-
- ✅ Images/icons/fonts as **`data:` URIs**.
|
|
1638
|
-
- ✅ Total size **≤ 2 MB** (zipped). Note base64 inflates media ~33%.
|
|
1639
|
-
- ❌ No external `https://` script/style/font references loaded at runtime.
|
|
1640
|
-
- ❌ No code-splitting / dynamic `import()` across chunks (see below).
|
|
1641
|
-
|
|
1642
|
-
### Single-chunk build
|
|
1643
|
-
|
|
1644
|
-
The bundler CLI is post-build and assumes **one JS + one CSS + assets**. If
|
|
1645
|
-
your framework code-splits, the split chunks won't resolve in the sandbox.
|
|
1646
|
-
Configure a single-file build:
|
|
1647
|
-
|
|
1648
|
-
- **Vite:** add [`vite-plugin-singlefile`](https://github.com/richardtallent/vite-plugin-singlefile),
|
|
1649
|
-
or set `build.rollupOptions.output.inlineDynamicImports: true` +
|
|
1650
|
-
`manualChunks: undefined`.
|
|
1651
|
-
- **Create React App:** avoid `React.lazy` / dynamic `import()` in routes so the
|
|
1652
|
-
build emits a single bundle.
|
|
1653
|
-
- **esbuild:** bundle to a single IIFE/ESM file (see the example's `build.mjs`).
|
|
1654
|
-
|
|
1655
|
-
---
|
|
1656
|
-
|
|
1657
|
-
## 6. Build the bundle
|
|
1658
|
-
|
|
1659
|
-
Once your build produces a `dist/index.html` (referencing a local `main.js`,
|
|
1660
|
-
CSS, images), run the CLI to inline everything and pack the zip:
|
|
1661
|
-
|
|
1662
|
-
```bash
|
|
1663
|
-
npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
|
|
1664
|
-
```
|
|
1665
|
-
|
|
1666
|
-
It inlines `<script src>`, `<link rel=stylesheet>`, CSS `url()`, and local
|
|
1667
|
-
`<img>`/icons into a single `index.html`, then zips it. Output:
|
|
1668
|
-
|
|
1669
|
-
```
|
|
1670
|
-
Built dist/bundle.zip
|
|
1671
|
-
entry: index.html (123.4 KB self-contained)
|
|
1672
|
-
bundleSize (zip): 45678 bytes
|
|
1673
|
-
bundleHash (zip): sha256-<hex>
|
|
1674
|
-
```
|
|
1675
|
-
|
|
1676
|
-
| Option | Description |
|
|
1677
|
-
|---|---|
|
|
1678
|
-
| `-i, --html <path>` | Input HTML. Required. |
|
|
1679
|
-
| `-o, --out <path>` | Output `.zip` (default `dist/whitelabel-bundle.zip`). |
|
|
1680
|
-
| `--max-bytes <n>` | Warning threshold (default 2 MB). |
|
|
1681
|
-
|
|
1682
|
-
The hash is deterministic — the same input always yields the same
|
|
1683
|
-
`bundleHash`. The CLI **warns** above 2 MB but the **app rejects** oversized
|
|
1684
|
-
bundles and falls back to a default screen, so keep under the limit.
|
|
1685
|
-
|
|
1686
|
-
---
|
|
1687
|
-
|
|
1688
|
-
## 7. Deliver
|
|
1689
|
-
|
|
1690
|
-
Publish it yourself with a manager user of your company —
|
|
1691
|
-
`npx condor-mobile-bridge canvas publish dist/index.html --version 1.0.0` — or
|
|
1692
|
-
send the resulting `dist/bundle.zip` through the publication channel Condor
|
|
1693
|
-
gave you (the admin upload). The backend validates structure, self-containment,
|
|
1694
|
-
and size on upload, and records the `bundleSize`/`bundleHash`. Once published,
|
|
1695
|
-
the app downloads, verifies, and renders your Home — for **every user of your
|
|
1696
|
-
company**, so test on a development test company first. See
|
|
1697
|
-
[Testing on a device](./device-testing.md).
|
|
1698
|
-
|
|
1699
|
-
---
|
|
1700
|
-
|
|
1701
|
-
## 8. Troubleshooting
|
|
1702
|
-
|
|
1703
|
-
| Symptom | Likely cause |
|
|
1704
|
-
|---|---|
|
|
1705
|
-
| Blank screen / nothing loads | Relative `<script src>` or external URL — must be inlined. Run the build CLI. |
|
|
1706
|
-
| Images missing | Referenced by path instead of `data:` URI — the CLI inlines local images; remote images are blocked. |
|
|
1707
|
-
| Works in browser, not in app | You relied on a network call or external asset at load time (CSP blocks it), or a code-split chunk didn't resolve. Go single-chunk + self-contained. |
|
|
1708
|
-
| Bundle rejected on upload / falls back | Over 2 MB, multiple files at root, or not self-contained. |
|
|
1709
|
-
| `handshake` rejects in the browser | The page is not inside the shell. Serve it with `npx condor-mobile-bridge dev`. |
|
|
1710
|
-
| Stuck on "loading devices" on the phone | The device request was lost or the account has no device. Use `requestUserDevices()` and test the `cold-start` and `no-devices` scenarios. |
|
|
1711
|
-
| A command does nothing | The command may not be allowed by the app policy; it's dropped silently. |
|
|
1712
|
-
|
|
1713
|
-
---
|
|
1714
|
-
|
|
1715
|
-
## Next steps
|
|
1716
|
-
|
|
1717
|
-
- Copy [`examples/minimal-home`](../examples/minimal-home) and start from there.
|
|
1718
|
-
- Full API reference: the [README](../README.md).
|
|
1719
|
-
|
|
1720
1673
|
<!-- ===== docs/pilot-onboarding.md ===== -->
|
|
1721
1674
|
|
|
1722
|
-
#
|
|
1723
|
-
|
|
1724
|
-
Welcome! This is the fast path to building and shipping your **White Label
|
|
1725
|
-
Home** — the screen that runs inside the Condor app. You build a normal web app;
|
|
1726
|
-
we package and render it. No Flutter / Android / iOS needed.
|
|
1675
|
+
# Canvas — pilot onboarding
|
|
1727
1676
|
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
## What you'll end up with
|
|
1732
|
-
|
|
1733
|
-
A self-contained `bundle.zip` that Condor publishes to your tenant — your Home
|
|
1734
|
-
renders inside the app, talking to the device through the Condor bridge.
|
|
1677
|
+
The steps from zero to your Home live for your company, and who does what. The
|
|
1678
|
+
how of each step lives in one place, linked from it.
|
|
1735
1679
|
|
|
1736
1680
|
## Who does what
|
|
1737
1681
|
|
|
1738
|
-
| Condor provides | You build |
|
|
1682
|
+
| Condor provides | You build and run |
|
|
1739
1683
|
|---|---|
|
|
1740
|
-
| The SDK (`@condorinst/mobile-bridge`),
|
|
1741
|
-
| The app that
|
|
1742
|
-
|
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
bridge
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
### 4. Test in your browser (no app needed)
|
|
1777
|
-
```bash
|
|
1778
|
-
npx condor-mobile-bridge dev -i dist/index.html
|
|
1779
|
-
```
|
|
1780
|
-
Serves your page inside the same sandbox, shell and policy as the app, with a
|
|
1781
|
-
simulated app behind it. Check every scenario in the selector at the top:
|
|
1782
|
-
`default`, `cold-start`, `no-devices`, `shared-serial`, `offline`.
|
|
1783
|
-
|
|
1784
|
-
### 5. Build the bundle
|
|
1785
|
-
```bash
|
|
1786
|
-
npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
|
|
1787
|
-
```
|
|
1788
|
-
This inlines everything into a single self-contained `index.html` and zips it.
|
|
1789
|
-
It prints `bundleSize` + `bundleHash`.
|
|
1790
|
-
|
|
1791
|
-
### 5b. Validate before you deliver
|
|
1792
|
-
```bash
|
|
1793
|
-
npx condor-mobile-bridge validate -i dist/bundle.zip --strict
|
|
1794
|
-
```
|
|
1795
|
-
Applies the same gates the app applies on download, plus a lint for the APIs the
|
|
1796
|
-
sandbox and the CSP block at runtime. **A non-zero exit means do not deliver.**
|
|
1797
|
-
Errors are outright rejections by the app; warnings install fine and then
|
|
1798
|
-
misbehave on the device, which is harder to diagnose.
|
|
1799
|
-
|
|
1800
|
-
### 6. Publish
|
|
1801
|
-
With a manager user of your company:
|
|
1802
|
-
```bash
|
|
1803
|
-
npx condor-mobile-bridge canvas publish dist/index.html --version 1.0.0
|
|
1804
|
-
```
|
|
1805
|
-
It validates, shows what goes where, asks for confirmation and your password,
|
|
1806
|
-
and publishes. Publish to your **test company** first: a publish reaches every
|
|
1807
|
-
user of the company at once. (Without a manager user, send `dist/bundle.zip`
|
|
1808
|
-
with its `bundleSize` and `bundleHash` to your Condor contact.)
|
|
1809
|
-
|
|
1810
|
-
### 7. Verify on a device
|
|
1811
|
-
Condor publishes → the app downloads, validates and renders your Home the next
|
|
1812
|
-
time it comes to the foreground. [Testing on a device](./device-testing.md)
|
|
1813
|
-
explains the environments, how to inspect the WebView, and where errors go.
|
|
1814
|
-
|
|
1815
|
-
## Hard rules (or the app rejects the bundle)
|
|
1816
|
-
|
|
1817
|
-
- ✅ Exactly **one `index.html`** at the zip root
|
|
1818
|
-
- ✅ JS/CSS **inline**, media as **`data:` URIs**, no external/relative refs loaded at runtime
|
|
1819
|
-
- ✅ **≤ 2 MB** (zipped)
|
|
1820
|
-
- ✅ **Single-chunk build** (no code-splitting): Vite → `vite-plugin-singlefile`; CRA → no `React.lazy` / dynamic `import()`
|
|
1821
|
-
|
|
1822
|
-
The CLI handles the inlining/zipping for you — just keep your build single-file.
|
|
1823
|
-
|
|
1824
|
-
## Reference
|
|
1825
|
-
|
|
1826
|
-
- [Client Guide](./client-guide.md) — full walkthrough (bridge API, key catalog, dev harness)
|
|
1827
|
-
- [Testing on a device](./device-testing.md) — from a published bundle to an inspected WebView
|
|
1828
|
-
- [Troubleshooting](./troubleshooting.md) — starts from the symptom
|
|
1829
|
-
- [`examples/minimal-home`](../examples/minimal-home) — runnable minimal client
|
|
1830
|
-
- [README](../README.md) — API reference
|
|
1831
|
-
|
|
1832
|
-
## Pilot checklist
|
|
1833
|
-
|
|
1834
|
-
- [ ] SDK installed (`npm i @condorinst/mobile-bridge`)
|
|
1835
|
-
- [ ] Example runs in the harness (`npx condor-mobile-bridge dev`), in every scenario
|
|
1836
|
-
- [ ] Home built with `BridgeClient` (handshake + at least one key + one command)
|
|
1837
|
-
- [ ] Constraints respected (1 index.html, ≤2 MB, self-contained, single-chunk)
|
|
1838
|
-
- [ ] `bundle.zip` generated by the CLI, and `validate --strict` exits 0
|
|
1839
|
-
- [ ] `.zip` delivered to Condor
|
|
1840
|
-
- [ ] Home verified on a device
|
|
1684
|
+
| The SDK (`@condorinst/mobile-bridge`), its documentation and example | Your Home: one self-contained web page |
|
|
1685
|
+
| The Canvas app that shows your page, and the device bridge | The `.zip` bundle, built and checked with the SDK CLI |
|
|
1686
|
+
| A **test company** in production, and a manager user for it | Publishing to the test company, then to your real one |
|
|
1687
|
+
|
|
1688
|
+
You need Node.js 18+. The package is public on npm: no credentials.
|
|
1689
|
+
|
|
1690
|
+
## Steps
|
|
1691
|
+
|
|
1692
|
+
1. **Start from the example and the procedure.** Follow
|
|
1693
|
+
[`AGENTS.md`](../AGENTS.md), "The golden path": copy
|
|
1694
|
+
`examples/minimal-home`, install, build.
|
|
1695
|
+
2. **Build your Home in the harness.**
|
|
1696
|
+
`npx condor-mobile-bridge dev -i dist/index.html` runs your page as the app
|
|
1697
|
+
does. It must work in every scenario — `AGENTS.md`, "Develop in the harness".
|
|
1698
|
+
Remember that your page is the whole screen: navigation, logout and the way
|
|
1699
|
+
to the app settings are yours (`AGENTS.md`).
|
|
1700
|
+
3. **Build and validate.** `build` writes the `.zip` and prints its size and
|
|
1701
|
+
hash; `validate --strict` must exit 0 — `AGENTS.md`, "The validation loop".
|
|
1702
|
+
4. **Publish to your test company.**
|
|
1703
|
+
`npx condor-mobile-bridge canvas publish dist/index.html --version 1.0.0`
|
|
1704
|
+
with the manager user of the **test** company. A publish reaches every user
|
|
1705
|
+
of that company at once — [`docs/device-testing.md`](./device-testing.md),
|
|
1706
|
+
"Publishing from the command line".
|
|
1707
|
+
5. **Check it on a phone** logged in with a test user —
|
|
1708
|
+
[`docs/device-testing.md`](./device-testing.md).
|
|
1709
|
+
6. **Publish to your company** with a new version number, the same way.
|
|
1710
|
+
|
|
1711
|
+
## Checklist
|
|
1712
|
+
|
|
1713
|
+
- [ ] The example runs in the harness, in every scenario
|
|
1714
|
+
- [ ] Your Home works in every scenario, with `--native-version null` too
|
|
1715
|
+
- [ ] Back navigation, logout and the way to the app settings are in your screens
|
|
1716
|
+
- [ ] `validate --strict` exits 0 on the `.zip`
|
|
1717
|
+
- [ ] Published to the test company and checked on a phone
|
|
1718
|
+
- [ ] Published to your company
|
|
1841
1719
|
- [ ] Feedback shared with Condor
|
|
1842
|
-
|
|
1720
|
+
|
|
1721
|
+
When something does not work: [`docs/troubleshooting.md`](./troubleshooting.md).
|
|
1843
1722
|
|
|
1844
1723
|
<!-- ===== examples/minimal-home/src/main.ts ===== -->
|
|
1845
1724
|
|
|
@@ -1878,7 +1757,7 @@ interface DeviceRow {
|
|
|
1878
1757
|
realTimeOn: boolean;
|
|
1879
1758
|
}
|
|
1880
1759
|
|
|
1881
|
-
type Phase = 'loading' | 'ready' | 'empty' | 'outside-shell';
|
|
1760
|
+
type Phase = 'loading' | 'ready' | 'empty' | 'error' | 'outside-shell';
|
|
1882
1761
|
|
|
1883
1762
|
const state = {
|
|
1884
1763
|
phase: 'loading' as Phase,
|
|
@@ -1920,6 +1799,19 @@ function telemetryFor(
|
|
|
1920
1799
|
}
|
|
1921
1800
|
|
|
1922
1801
|
function applyDevices(payload: UserDevicePayload): void {
|
|
1802
|
+
// From nativeVersion 0.18.0 a failed lookup also arrives here, with `error`
|
|
1803
|
+
// and empty arrays. It is not an empty roster: keep what is on screen.
|
|
1804
|
+
if (payload.error !== undefined) {
|
|
1805
|
+
if (payload.error === 'NO_DEVICES') {
|
|
1806
|
+
state.devices = new Map();
|
|
1807
|
+
state.probablyNoDevices = true;
|
|
1808
|
+
state.phase = 'empty';
|
|
1809
|
+
} else if (state.devices.size === 0) {
|
|
1810
|
+
state.phase = 'error';
|
|
1811
|
+
}
|
|
1812
|
+
render();
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1923
1815
|
const next = new Map<number, DeviceRow>();
|
|
1924
1816
|
for (const cfg of payload.devicesConfig) {
|
|
1925
1817
|
const telemetry = telemetryFor(cfg, payload);
|
|
@@ -1986,11 +1878,14 @@ function body(): string {
|
|
|
1986
1878
|
case 'loading':
|
|
1987
1879
|
return '<p class="empty">Loading your devices…</p>';
|
|
1988
1880
|
case 'empty':
|
|
1881
|
+
// probablyNoDevices: confirmed (0.18.0+) or suggested by an older app.
|
|
1989
1882
|
return `<p class="empty">${
|
|
1990
1883
|
state.probablyNoDevices
|
|
1991
1884
|
? 'No device is linked to your account yet.'
|
|
1992
1885
|
: 'Could not load your devices.'
|
|
1993
1886
|
}</p><p class="empty"><button onclick="__home.retry()">Try again</button></p>`;
|
|
1887
|
+
case 'error':
|
|
1888
|
+
return '<p class="empty">Could not load your devices.</p><p class="empty"><button onclick="__home.retry()">Try again</button></p>';
|
|
1994
1889
|
case 'ready':
|
|
1995
1890
|
return [...state.devices.values()].map(renderCard).join('');
|
|
1996
1891
|
}
|
|
@@ -2038,9 +1933,13 @@ BridgeClient.on(BRIDGE_KEYS.realTime, (payload) => {
|
|
|
2038
1933
|
|
|
2039
1934
|
async function loadDevices(getFromServer: boolean): Promise<void> {
|
|
2040
1935
|
const result = await BridgeClient.requestUserDevices({ getFromServer });
|
|
2041
|
-
|
|
1936
|
+
// ok, no-devices and error also reached applyDevices through the
|
|
1937
|
+
// subscription, which already rendered them.
|
|
1938
|
+
if (result.status !== 'timeout') return;
|
|
1939
|
+
// An older app gives only a hint; with devices on screen, keep them.
|
|
1940
|
+
if (state.devices.size > 0) return;
|
|
2042
1941
|
state.probablyNoDevices = result.hasDevices === false;
|
|
2043
|
-
state.phase = 'empty';
|
|
1942
|
+
state.phase = state.probablyNoDevices ? 'empty' : 'error';
|
|
2044
1943
|
render();
|
|
2045
1944
|
}
|
|
2046
1945
|
|