@condorinst/mobile-bridge 0.19.2 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +100 -42
- package/CHANGELOG.md +106 -0
- package/README.md +64 -14
- package/contract/bridge-contract.json +70 -25
- package/dist/cli.cjs +436 -5
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +433 -3
- package/dist/cli.js.map +1 -1
- package/dist/dev-harness.global.js +2110 -0
- package/dist/dev.cjs +1662 -0
- package/dist/dev.cjs.map +1 -0
- package/dist/dev.d.cts +683 -0
- package/dist/dev.d.ts +683 -0
- package/dist/dev.js +1611 -0
- package/dist/dev.js.map +1 -0
- package/dist/index.cjs +96 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +265 -19
- package/dist/index.d.ts +265 -19
- package/dist/index.js +94 -3
- package/dist/index.js.map +1 -1
- package/docs/agent-guide.md +83 -25
- package/docs/bridge-contract.md +154 -25
- package/docs/client-guide.md +39 -28
- package/docs/device-testing.md +84 -0
- package/docs/pilot-onboarding.md +26 -12
- package/docs/troubleshooting.md +83 -18
- package/examples/minimal-home/AGENTS.md +9 -6
- package/examples/minimal-home/README.md +10 -5
- package/examples/minimal-home/build.mjs +2 -1
- package/examples/minimal-home/index.html +2 -0
- package/examples/minimal-home/package.json +3 -2
- package/examples/minimal-home/src/main.ts +151 -73
- package/llms-full.txt +2041 -0
- package/llms.txt +15 -13
- package/package.json +9 -3
package/llms-full.txt
ADDED
|
@@ -0,0 +1,2041 @@
|
|
|
1
|
+
# @condorinst/mobile-bridge 0.21.0 — full documentation
|
|
2
|
+
|
|
3
|
+
Generated from the files listed below, in reading order. Payload types are
|
|
4
|
+
canonical in dist/index.d.ts; the machine-readable contract is
|
|
5
|
+
contract/bridge-contract.json.
|
|
6
|
+
|
|
7
|
+
- AGENTS.md
|
|
8
|
+
- docs/agent-guide.md
|
|
9
|
+
- docs/bridge-contract.md
|
|
10
|
+
- docs/troubleshooting.md
|
|
11
|
+
- docs/device-testing.md
|
|
12
|
+
- docs/limits.md
|
|
13
|
+
- docs/client-guide.md
|
|
14
|
+
- docs/pilot-onboarding.md
|
|
15
|
+
- examples/minimal-home/src/main.ts
|
|
16
|
+
|
|
17
|
+
<!-- ===== AGENTS.md ===== -->
|
|
18
|
+
|
|
19
|
+
# AGENTS.md — building a Condor Canvas client
|
|
20
|
+
|
|
21
|
+
You are writing the **Home screen** of a Condor Canvas app. It is a single web
|
|
22
|
+
bundle that renders inside a sandboxed iframe in a native mobile app. The app
|
|
23
|
+
owns login, settings and the Bluetooth connection to the devices; you own what
|
|
24
|
+
the person sees on the Home.
|
|
25
|
+
|
|
26
|
+
This file is the contract. Read it before writing code. Everything here is
|
|
27
|
+
enforced, either by a gate in the app or by the browser runtime on a real
|
|
28
|
+
device, where mistakes are expensive to diagnose.
|
|
29
|
+
|
|
30
|
+
## The one thing that makes this different
|
|
31
|
+
|
|
32
|
+
**Failures are silent.** A malformed command, an oversized payload, a command
|
|
33
|
+
sent before the app is ready, a request for an account with no device: nothing
|
|
34
|
+
happens. No exception, no rejected promise, no console error.
|
|
35
|
+
|
|
36
|
+
So never write code whose correctness you infer from the absence of an error.
|
|
37
|
+
Drive the UI from the events that arrive, give every wait a deadline, and
|
|
38
|
+
always have a screen for "it did not come".
|
|
39
|
+
|
|
40
|
+
## Hard rules
|
|
41
|
+
|
|
42
|
+
1. **No storage.** `localStorage`, `sessionStorage`, `indexedDB` and cookies all
|
|
43
|
+
fail — the iframe has a `null` origin. Keep state in memory and accept that
|
|
44
|
+
it is lost on reload. Re-request what you need at startup.
|
|
45
|
+
2. **No network.** `connect-src 'self'` on a null origin blocks every `fetch`,
|
|
46
|
+
`XMLHttpRequest` and `WebSocket`. All data comes through the bridge.
|
|
47
|
+
3. **No `eval`, no `new Function`.** The CSP has no `'unsafe-eval'`. Configure
|
|
48
|
+
your bundler so it does not emit eval-based sourcemaps.
|
|
49
|
+
4. **One self-contained `index.html`.** Every script inline, every stylesheet
|
|
50
|
+
inline, every image and font as a `data:` URI. A `<script src>` or a
|
|
51
|
+
`<link rel="stylesheet">` pointing anywhere else makes the app **reject the
|
|
52
|
+
whole bundle**.
|
|
53
|
+
5. **Build single-chunk.** No code splitting, no ES module imports between
|
|
54
|
+
chunks — `srcdoc` cannot resolve them. Vite: `vite-plugin-singlefile`.
|
|
55
|
+
6. **The `.zip` must be at most 2 MB.** `data:` URIs inflate assets by about a
|
|
56
|
+
third.
|
|
57
|
+
7. **Use `BridgeClient`, never raw `postMessage`.** Hand-rolled messages fail
|
|
58
|
+
schema validation and are dropped without a trace.
|
|
59
|
+
8. **Never assume a capability.** Read `nativeVersion` from the handshake and
|
|
60
|
+
branch on it. See the capability matrix in `docs/bridge-contract.md`.
|
|
61
|
+
9. **Never ask once and wait.** Use `BridgeClient.requestUserDevices()` for the
|
|
62
|
+
device list; a single `send('userDevice')` can be lost (see below).
|
|
63
|
+
|
|
64
|
+
## What you cannot reach
|
|
65
|
+
|
|
66
|
+
`window.parent` properties · `window.top` · `window.open` ·
|
|
67
|
+
`navigator.sendBeacon` (neutralized before your code runs) · service workers ·
|
|
68
|
+
camera, microphone and geolocation (denied unconditionally) · the `dfu` key and
|
|
69
|
+
command (denied by policy).
|
|
70
|
+
|
|
71
|
+
## The golden path
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npm install @condorinst/mobile-bridge@^0.21.0
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import {
|
|
79
|
+
BridgeClient, BRIDGE_KEYS, versionAtLeast, type UserDevicePayload,
|
|
80
|
+
} from '@condorinst/mobile-bridge';
|
|
81
|
+
|
|
82
|
+
// 1. Subscribe before you ask for anything. Roster changes arrive here too.
|
|
83
|
+
BridgeClient.on(BRIDGE_KEYS.userDevice, (devices: UserDevicePayload) => renderDevices(devices));
|
|
84
|
+
BridgeClient.on(BRIDGE_KEYS.systemStatus, (s) => renderEnvironment(s)); // bluetooth, internet, permissions
|
|
85
|
+
|
|
86
|
+
// 2. Handshake. nativeVersion is the ONLY capability signal; ack.accepted is
|
|
87
|
+
// always true and carries no information — never branch on it.
|
|
88
|
+
const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
|
|
89
|
+
const canAddressById = versionAtLeast(ack.nativeVersion, '0.17.0'); // null → false
|
|
90
|
+
|
|
91
|
+
// 3. Ask for the devices with the helper. It resends until they arrive.
|
|
92
|
+
const result = await BridgeClient.requestUserDevices();
|
|
93
|
+
if (result.status === 'timeout') {
|
|
94
|
+
// No reply in 30 s. hasDevices === false suggests an account with no device;
|
|
95
|
+
// otherwise the app could not load the list. Either way: show an empty state
|
|
96
|
+
// with a "Try again" button that calls requestUserDevices({ getFromServer: true }).
|
|
97
|
+
renderEmpty(result.hasDevices === false);
|
|
98
|
+
} else {
|
|
99
|
+
// 4. Subscribe to the connection state of each device (`add` pairs nothing).
|
|
100
|
+
for (const d of result.devices.devicesConfig) {
|
|
101
|
+
BridgeClient.send('deviceConnection', {
|
|
102
|
+
type: 'add', serialNo: d.serialNo, ...(canAddressById ? { deviceId: d.deviceId } : {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 5. History: one page per call; it resolves { success: false, error } on
|
|
108
|
+
// failure and only rejects on timeout.
|
|
109
|
+
const page = await BridgeClient.fetchSensorLog({ requestId: crypto.randomUUID(), serialNo });
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Why the helper.** Device data comes from a background service that the app
|
|
113
|
+
starts *after* your bundle is already running. Anything you send to it before it
|
|
114
|
+
is up is lost without a reply — while the handshake, `systemStatus` and
|
|
115
|
+
`language` already work, so the bridge *looks* ready. And an account with no
|
|
116
|
+
linked device gets no `userDevice` reply at all.
|
|
117
|
+
|
|
118
|
+
**Identify devices by `deviceId`.** `serialNo` can repeat: an ActLumus and an
|
|
119
|
+
ActLumus Plus may share one. Join `devicesConfig`, `devicesTelemetry` and
|
|
120
|
+
`devicesUserConfig` on `deviceId`; send `serialNo` in every command, plus
|
|
121
|
+
`deviceId` when `nativeVersion` ≥ 0.17.0 (`versionAtLeast` does the comparison).
|
|
122
|
+
The typed shapes (`DeviceConfigItem`, `DeviceTelemetryItem`, …) show every field
|
|
123
|
+
and its unit — `devicesConfig` has no name or battery; those are in the
|
|
124
|
+
telemetry.
|
|
125
|
+
|
|
126
|
+
**Timestamps are device-local.** `log_time` is `'YYYY-MM-DD HH:MM:SS'` in the
|
|
127
|
+
device's wall-clock time. Parse and build it with local `Date` getters, never
|
|
128
|
+
with `toISOString()` or a `'Z'` suffix.
|
|
129
|
+
|
|
130
|
+
Three commands open a confirmation dialog owned by Condor: `syncTime`,
|
|
131
|
+
`deleteMemory` and `logout`. If the person declines, you get nothing — the ack
|
|
132
|
+
never arrives. Do not block your UI on them.
|
|
133
|
+
|
|
134
|
+
## Develop in the harness, not in a bare browser tab
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
npx condor-mobile-bridge dev -i dist/index.html --scenario cold-start
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
It serves your bundle at `http://localhost:5173` inside the **same sandbox,
|
|
141
|
+
shell and policy as the app**, with a simulated native side. Opening
|
|
142
|
+
`dist/index.html` directly does not work: `BridgeClient.handshake()` rejects at
|
|
143
|
+
once outside the shell. Rebuild and reload; the harness re-reads the file.
|
|
144
|
+
|
|
145
|
+
| Scenario | What it reproduces |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| `default` | two devices, realistic latency |
|
|
148
|
+
| `cold-start` | the background service takes 4 s; earlier `ping`/`userDevice` are lost |
|
|
149
|
+
| `no-devices` | `userDevice` never answers; `hasDevices: false` |
|
|
150
|
+
| `shared-serial` | an ActLumus and a Plus share serial 7047 |
|
|
151
|
+
| `offline` | no internet; `sensorLog` fails |
|
|
152
|
+
|
|
153
|
+
The harness console (`[condor dev]`) says what was sent (with its arguments),
|
|
154
|
+
answered and dropped. The **◀ OS back** button at the top presses the OS back
|
|
155
|
+
button. `--native-version null` (or `0.15.0`, …) makes the handshake report that
|
|
156
|
+
version, to test your capability branches.
|
|
157
|
+
|
|
158
|
+
## The validation loop — run this before you call the work done
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
|
|
162
|
+
npx condor-mobile-bridge validate -i dist/bundle.zip --strict
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`validate` applies the same gates the app applies on download, plus a lint for
|
|
166
|
+
the APIs the sandbox blocks at runtime. Under `--strict` it exits non-zero on
|
|
167
|
+
warnings too. **Treat a non-zero exit as a build failure.** It is a textual
|
|
168
|
+
check, so a clean run is necessary, not sufficient.
|
|
169
|
+
|
|
170
|
+
## Definition of done
|
|
171
|
+
|
|
172
|
+
- [ ] `validate --strict` exits 0.
|
|
173
|
+
- [ ] The bundle works in **every** harness scenario: it leaves the loading
|
|
174
|
+
screen in `cold-start`, shows an empty state in `no-devices`, keeps two
|
|
175
|
+
cards apart in `shared-serial`, and explains the failure in `offline`.
|
|
176
|
+
- [ ] The UI renders before any event arrives — first paint must not depend on
|
|
177
|
+
the bridge.
|
|
178
|
+
- [ ] Every capability beyond the baseline is guarded by a `nativeVersion` check
|
|
179
|
+
(`versionAtLeast`), and the page still renders with
|
|
180
|
+
`--native-version null`.
|
|
181
|
+
- [ ] Nothing waits forever on a reply that may never come.
|
|
182
|
+
|
|
183
|
+
To put the bundle on a phone, read `docs/device-testing.md`.
|
|
184
|
+
|
|
185
|
+
## Where to read more
|
|
186
|
+
|
|
187
|
+
| Question | File |
|
|
188
|
+
| --- | --- |
|
|
189
|
+
| Every key, command, error shape and capability | `docs/bridge-contract.md` |
|
|
190
|
+
| The same, as JSON you can parse | `contract/bridge-contract.json` |
|
|
191
|
+
| Step-by-step build of a Home | `docs/agent-guide.md` |
|
|
192
|
+
| "My command did nothing" / "the device list never came" | `docs/troubleshooting.md` |
|
|
193
|
+
| Getting the bundle onto a device, and seeing its errors | `docs/device-testing.md` |
|
|
194
|
+
| Every hard limit, with the code that enforces it | `docs/limits.md` |
|
|
195
|
+
| Payload shapes, authoritative | `dist/index.d.ts` (your editor shows them) |
|
|
196
|
+
| A working client you can copy | `examples/minimal-home/` |
|
|
197
|
+
| All of the above in one file | `llms-full.txt` |
|
|
198
|
+
|
|
199
|
+
<!-- ===== docs/agent-guide.md ===== -->
|
|
200
|
+
|
|
201
|
+
# Agent guide — building a Canvas client
|
|
202
|
+
|
|
203
|
+
A working procedure for an AI agent (or a developer) building the client bundle
|
|
204
|
+
that renders as the Home of a Condor Canvas app.
|
|
205
|
+
|
|
206
|
+
Read `AGENTS.md` first — it is the contract in one screen. This document is the
|
|
207
|
+
procedure: what to do, in what order, and what to check at each stage.
|
|
208
|
+
|
|
209
|
+
## What you are building
|
|
210
|
+
|
|
211
|
+
One `.zip` containing one self-contained `index.html`. Condor's app downloads
|
|
212
|
+
it, verifies its SHA-256, and renders it inside a sandboxed iframe as the Home
|
|
213
|
+
screen. Login, settings, Bluetooth and background collection are the app's job.
|
|
214
|
+
Yours is what the person sees when the app opens.
|
|
215
|
+
|
|
216
|
+
You are not building a website. You have no storage, no network, and no origin.
|
|
217
|
+
Everything you display comes through the bridge, and everything you hold lives
|
|
218
|
+
in memory until the next reload.
|
|
219
|
+
|
|
220
|
+
## Stage 0 — set up so you can see what you are doing
|
|
221
|
+
|
|
222
|
+
Start from `examples/minimal-home/` — a complete, working client with the
|
|
223
|
+
reference boot sequence, and a build script that produces a single-file bundle.
|
|
224
|
+
Copy it first, then install, so its `package.json` is the one you keep:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
mkdir my-home && cd my-home
|
|
228
|
+
npm pack @condorinst/mobile-bridge@^0.21.0 && tar -xzf condorinst-mobile-bridge-*.tgz
|
|
229
|
+
cp -r package/examples/minimal-home/. . && rm -rf package condorinst-mobile-bridge-*.tgz
|
|
230
|
+
npm install
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Or, in a project you already have: `npm install @condorinst/mobile-bridge@^0.21.0`
|
|
234
|
+
and copy only `src/main.ts`, `build.mjs` and `index.html` from
|
|
235
|
+
`node_modules/@condorinst/mobile-bridge/examples/minimal-home/`.
|
|
236
|
+
|
|
237
|
+
Pin the SDK to the minor you are building against. Below `1.0.0`, a minor can
|
|
238
|
+
change the contract — `^0.21.0` gives you patches only, which is what you want.
|
|
239
|
+
|
|
240
|
+
**Run it in the harness**, not in a bare browser tab:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
npm run build
|
|
244
|
+
npx condor-mobile-bridge dev -i dist/index.html --scenario default
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
The harness serves your bundle inside the same sandboxed iframe, shell, policy
|
|
248
|
+
and CSP as the app, with a simulated native side behind it, and a dev panel to
|
|
249
|
+
emit events by hand. It re-reads the file on every reload. Opening
|
|
250
|
+
`dist/index.html` directly does **not** work: there is no shell, so
|
|
251
|
+
`BridgeClient.handshake()` rejects at once.
|
|
252
|
+
|
|
253
|
+
The scenarios reproduce what the device does to you — switch with the selector
|
|
254
|
+
at the top or `--scenario`:
|
|
255
|
+
|
|
256
|
+
| Scenario | Your bundle must… |
|
|
257
|
+
| --- | --- |
|
|
258
|
+
| `default` | render two devices |
|
|
259
|
+
| `cold-start` | leave the loading screen although the first `userDevice` requests are lost |
|
|
260
|
+
| `no-devices` | show an empty state when `userDevice` never answers |
|
|
261
|
+
| `shared-serial` | keep two devices with the same `serialNo` apart |
|
|
262
|
+
| `offline` | explain that history is unavailable without internet |
|
|
263
|
+
|
|
264
|
+
Add `--native-version null` (or an older version such as `0.15.0`) to test
|
|
265
|
+
your capability branches, and use the **◀ OS back** button at the top to press
|
|
266
|
+
the OS back button.
|
|
267
|
+
|
|
268
|
+
If you already have a dev server (Vite), mount the same harness from your own
|
|
269
|
+
page with `createDevHarness()` from `@condorinst/mobile-bridge/dev` — in the
|
|
270
|
+
host page only, never in the bundle (`validate` rejects that).
|
|
271
|
+
|
|
272
|
+
## Stage 1 — render before you connect
|
|
273
|
+
|
|
274
|
+
Write the screen so it paints correctly with **no data at all**: empty roster,
|
|
275
|
+
unknown status, default language. Then fill it as events arrive.
|
|
276
|
+
|
|
277
|
+
This is not a nicety. Events are emitted on attach, before your bundle exists,
|
|
278
|
+
and again after the handshake — so there is always a window where you have
|
|
279
|
+
nothing. A Home whose first paint depends on an event shows a blank screen on
|
|
280
|
+
every cold start.
|
|
281
|
+
|
|
282
|
+
## Stage 2 — handshake, and learn what this device can do
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
import { BridgeClient, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
|
|
286
|
+
|
|
287
|
+
const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
|
|
288
|
+
|
|
289
|
+
const native = ack.nativeVersion; // string | null
|
|
290
|
+
const has = (min: string) => native !== null && cmp(native, min) >= 0;
|
|
291
|
+
|
|
292
|
+
const canReadRawChannels = has('0.16.0');
|
|
293
|
+
const canQueryTimeRange = has('0.15.0');
|
|
294
|
+
const hasLanguageKey = has('0.14.0');
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Or use the SDK's `versionAtLeast(ack.nativeVersion, '0.16.0')`, which does
|
|
298
|
+
exactly that and returns `false` for `null`. Do not compare version strings
|
|
299
|
+
lexically — `'0.9.0' > '0.16.0'` is `true` as strings and false as versions.
|
|
300
|
+
|
|
301
|
+
**Do not branch on `ack.accepted`.** It is always `true`; the native side never
|
|
302
|
+
refuses a handshake. Anything you gate on it is ungated.
|
|
303
|
+
|
|
304
|
+
## Stage 3 — subscribe, then ask
|
|
305
|
+
|
|
306
|
+
Subscribe before sending the command that triggers the reply. The reply can
|
|
307
|
+
arrive before your next line runs.
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
BridgeClient.on(BRIDGE_KEYS.userDevice, (p) => { setDevices(p); render(); });
|
|
311
|
+
BridgeClient.on(BRIDGE_KEYS.systemStatus, (s) => { setStatus(s); render(); });
|
|
312
|
+
BridgeClient.on(BRIDGE_KEYS.deviceConnection, (c) => { setConnected(c); render(); });
|
|
313
|
+
|
|
314
|
+
const result = await BridgeClient.requestUserDevices();
|
|
315
|
+
if (result.status === 'timeout') renderEmptyState(result.hasDevices === false);
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
**Do not replace the helper with `send('userDevice')`.** The device list is
|
|
319
|
+
served by the app's background service, which starts after your bundle; a
|
|
320
|
+
request sent before it is up is lost, and an account with no device never gets
|
|
321
|
+
a reply. The helper resends with backoff for 30 s and then tells you to show the
|
|
322
|
+
empty state. Keep the `userDevice` subscription: roster changes arrive on it
|
|
323
|
+
later without a request. `docs/troubleshooting.md` has the details.
|
|
324
|
+
|
|
325
|
+
Read device data from the typed items: the list is `devicesConfig`, and the
|
|
326
|
+
name, battery and last sync are in `devicesTelemetry` — join them on
|
|
327
|
+
`deviceId`, never on `serialNo` alone.
|
|
328
|
+
|
|
329
|
+
Then follow the connection state of each device:
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
for (const d of result.devices.devicesConfig) {
|
|
333
|
+
BridgeClient.send('deviceConnection', { type: 'add', serialNo: d.serialNo, deviceId: d.deviceId });
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
`on` returns an unsubscribe function. If your UI has views that mount and
|
|
338
|
+
unmount, call it — otherwise you accumulate handlers that all re-render.
|
|
339
|
+
|
|
340
|
+
## Stage 4 — handle the environment honestly
|
|
341
|
+
|
|
342
|
+
`systemStatus` tells you why things are not working. Surface it; do not swallow
|
|
343
|
+
it.
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
if (status.missingEssentialPermissions.length > 0) {
|
|
347
|
+
// Background collection is OFF. This is blocking — say so.
|
|
348
|
+
renderBlockingCard(status.missingEssentialPermissions, (name) =>
|
|
349
|
+
BridgeClient.requestPermission(name));
|
|
350
|
+
} else if (!status.bluetooth) {
|
|
351
|
+
renderNotice('Bluetooth is off — devices cannot be reached.');
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
There is no permission-result event. After the person answers the OS prompt,
|
|
356
|
+
the native side re-emits `systemStatus`; your card disappears because the array
|
|
357
|
+
emptied. If you wait for a dedicated ack, you wait forever.
|
|
358
|
+
|
|
359
|
+
## Stage 5 — real-time, if you need it
|
|
360
|
+
|
|
361
|
+
Real-time is **off by default**, per device. Turn it on, wait for the ack, and
|
|
362
|
+
only then expect the stream.
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
BridgeClient.on(BRIDGE_KEYS.realTime, (ack) => setRealTime(ack.serialNo, ack.value));
|
|
366
|
+
BridgeClient.on(BRIDGE_KEYS.deviceLightSensors, (p) => {
|
|
367
|
+
if (p.type === 'actlumus2') { /* 14 channels */ } else { /* 10 channels */ }
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
BridgeClient.send('realTime', { serialNo, value: true });
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
`serialNo` is a **number**. A string is dropped in silence.
|
|
374
|
+
|
|
375
|
+
Two devices can share a `serialNo` (an ActLumus and an ActLumus Plus). To act on
|
|
376
|
+
one of them, send its `deviceId` **together with** `serialNo`, taken from the
|
|
377
|
+
`userDevice` list: `BridgeClient.send('realTime', { serialNo, deviceId, value })`.
|
|
378
|
+
A command with only the shared `serialNo` is ignored on purpose. Gate this on
|
|
379
|
+
`nativeVersion` ≥ 0.17.0; details in `docs/bridge-contract.md`.
|
|
380
|
+
|
|
381
|
+
In a raw channel, `null` means the channel does not exist on that hardware
|
|
382
|
+
generation. It does not mean zero — `0.0` is a valid reading. Coercing `null`
|
|
383
|
+
to `0` invents data, and has previously led to a confident, wrong conclusion
|
|
384
|
+
about a device.
|
|
385
|
+
|
|
386
|
+
## Stage 6 — history, if you need it
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
const page = await BridgeClient.fetchSensorLog({
|
|
390
|
+
requestId: crypto.randomUUID(),
|
|
391
|
+
serialNo,
|
|
392
|
+
limit: 200,
|
|
393
|
+
});
|
|
394
|
+
if (!page.success) return showError(page.error); // resolves, does not reject
|
|
395
|
+
renderLogs(page.logs ?? []);
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Timestamps are **device-local**. Build ranges with local `Date` getters, never
|
|
399
|
+
`toISOString()` — converting to UTC shifts every series by the timezone offset
|
|
400
|
+
and produces data that looks entirely plausible.
|
|
401
|
+
|
|
402
|
+
Paginate with the cursors exactly as received. They are opaque. Without a range
|
|
403
|
+
the first page is the **most recent** one (go older with `endingBefore`); with
|
|
404
|
+
`startTime` it is the **oldest** of the range (go newer with `startingAfter`).
|
|
405
|
+
A device writes one log per period — programmable, 60 s by default — so a week
|
|
406
|
+
is about 10 000 logs: use `limit: 1000` and do not assume the cadence.
|
|
407
|
+
|
|
408
|
+
## Stage 7 — back button
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
BridgeClient.onBack(() => goBackOnePage());
|
|
412
|
+
// call this on every internal navigation, not once at startup
|
|
413
|
+
BridgeClient.reportNavigation(hasInternalHistory);
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
If you never report `true`, the OS back button stays with the app — which is
|
|
417
|
+
the correct behaviour for a single-screen Home.
|
|
418
|
+
|
|
419
|
+
## Stage 8 — build and validate
|
|
420
|
+
|
|
421
|
+
```bash
|
|
422
|
+
npm run build # your bundler → dist/index.html, single chunk
|
|
423
|
+
npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
|
|
424
|
+
npx condor-mobile-bridge validate -i dist/bundle.zip --strict
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
`validate` applies the gates the app applies plus a lint for APIs the sandbox
|
|
428
|
+
blocks. A non-zero exit is a build failure. Errors mean the app would reject
|
|
429
|
+
the bundle outright; warnings mean it installs and then misbehaves on the
|
|
430
|
+
device, which is worse to debug.
|
|
431
|
+
|
|
432
|
+
Validation is textual, so it cannot see through minification and it cannot know
|
|
433
|
+
whether you checked `nativeVersion` before using a capability. A clean run is
|
|
434
|
+
necessary, not sufficient.
|
|
435
|
+
|
|
436
|
+
## Stage 9 — before you call it done
|
|
437
|
+
|
|
438
|
+
- [ ] `validate --strict` exits 0.
|
|
439
|
+
- [ ] First paint works with no events received.
|
|
440
|
+
- [ ] Every capability past the baseline is guarded by a `nativeVersion` check,
|
|
441
|
+
and the `null` branch renders something sensible.
|
|
442
|
+
- [ ] No spinner can hang forever — the three confirmable commands
|
|
443
|
+
(`syncTime`, `deleteMemory`, `logout`) may never ack, because the person
|
|
444
|
+
can decline.
|
|
445
|
+
- [ ] No `localStorage`, no `fetch`, no `eval`. Not even behind a `try`.
|
|
446
|
+
- [ ] `serialNo` is a number everywhere, and a command for a device whose `serialNo`
|
|
447
|
+
repeats on the account also carries its `deviceId` (`nativeVersion` ≥ 0.17.0).
|
|
448
|
+
- [ ] Errors are reported through `BridgeClient.send('jsError', …)`, the only
|
|
449
|
+
channel out.
|
|
450
|
+
- [ ] Every harness scenario passes: `default`, `cold-start`, `no-devices`,
|
|
451
|
+
`shared-serial`, `offline`.
|
|
452
|
+
- [ ] The `nativeVersion === null` branch renders something sensible.
|
|
453
|
+
|
|
454
|
+
## Delivery
|
|
455
|
+
|
|
456
|
+
Hand Condor the `.zip` along with its size and `sha256-<hex>` hash, both printed
|
|
457
|
+
by the `build` command. The app verifies the hash against the manifest and
|
|
458
|
+
rejects any mismatch. Condor publishes it for your company, which reaches every
|
|
459
|
+
user of that company — test on a development test company first.
|
|
460
|
+
`docs/device-testing.md` explains the path to the phone and how to inspect the
|
|
461
|
+
bundle there; `docs/pilot-onboarding.md` has the full checklist.
|
|
462
|
+
|
|
463
|
+
## When something does not work
|
|
464
|
+
|
|
465
|
+
`docs/troubleshooting.md`, which starts from the symptom. The most common one
|
|
466
|
+
is that a command did nothing and said nothing — there are six distinct causes
|
|
467
|
+
for that, and they are enumerated there.
|
|
468
|
+
|
|
469
|
+
<!-- ===== docs/bridge-contract.md ===== -->
|
|
470
|
+
|
|
471
|
+
# Bridge contract
|
|
472
|
+
|
|
473
|
+
The normative reference for everything the client can observe and everything it
|
|
474
|
+
can ask for. Payload *shapes* are canonical in `dist/index.d.ts` — your editor
|
|
475
|
+
shows them on hover. This document states what a type declaration cannot: what
|
|
476
|
+
is reachable, what needs human consent, what fails in silence, and which native
|
|
477
|
+
version carries which capability.
|
|
478
|
+
|
|
479
|
+
A machine-readable form of this document lives at
|
|
480
|
+
`contract/bridge-contract.json`, importable as `@condorinst/mobile-bridge/contract`.
|
|
481
|
+
|
|
482
|
+
## Topology
|
|
483
|
+
|
|
484
|
+
```
|
|
485
|
+
your bundle (iframe, sandbox="allow-scripts", origin null)
|
|
486
|
+
| window.postMessage, schema-validated both ways
|
|
487
|
+
Condor shell (top-level document, opaque origin)
|
|
488
|
+
| native channel, nonce-stamped
|
|
489
|
+
native app (Flutter)
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
You never talk to the native side directly — the shell deletes the native
|
|
493
|
+
globals before your bundle runs. Use `BridgeClient`; it speaks the shell
|
|
494
|
+
protocol for you.
|
|
495
|
+
|
|
496
|
+
## Versions and capability detection
|
|
497
|
+
|
|
498
|
+
Four numbers coexist. Only one tells you what the device can do.
|
|
499
|
+
|
|
500
|
+
| Number | Current | What it means |
|
|
501
|
+
| --- | --- | --- |
|
|
502
|
+
| `nativeVersion` | `0.17.0` | **The capability signal.** The bridge schema the app on this device implements. |
|
|
503
|
+
| `shellVersion` | `0.6.0` | The Condor shell wrapping your iframe. |
|
|
504
|
+
| client protocol | `0.5.0` | The postMessage protocol `BridgeClient` speaks. |
|
|
505
|
+
| package | `0.21.0` | The npm version you compiled against. |
|
|
506
|
+
|
|
507
|
+
```ts
|
|
508
|
+
const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
|
|
509
|
+
// { shellVersion: string, nativeVersion: string | null, accepted: boolean }
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
Two traps:
|
|
513
|
+
|
|
514
|
+
**`accepted` is always `true`.** There is no version negotiation and the native
|
|
515
|
+
side never refuses. Branching on it is branching on a constant.
|
|
516
|
+
|
|
517
|
+
**`nativeVersion` can be `null`.** When the shell could not reach the native
|
|
518
|
+
side, and in bundles compiled against SDK ≤ 0.16.x, where the shell did not
|
|
519
|
+
relay it. Treat `null` as "assume only the baseline" and keep a working
|
|
520
|
+
default. The dev harness reports `0.17.0` by default; `--native-version null`
|
|
521
|
+
exercises this path.
|
|
522
|
+
|
|
523
|
+
### Capability matrix
|
|
524
|
+
|
|
525
|
+
| Capability | Requires |
|
|
526
|
+
| --- | --- |
|
|
527
|
+
| Baseline: devices, real-time, acks, `systemStatus`, `sensorLog` | any |
|
|
528
|
+
| `language` key and the `language` pull command | `nativeVersion` ≥ 0.14.0 |
|
|
529
|
+
| `sensorLog` accepts `startTime` / `endTime` | `nativeVersion` ≥ 0.15.0 |
|
|
530
|
+
| `deviceLightSensors` carries raw `light_sensor_*` channels, and the `actlumus2` variant | `nativeVersion` ≥ 0.16.0 |
|
|
531
|
+
| 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 |
|
|
532
|
+
| `nativeVersion` reaches the client at all | `shellVersion` ≥ 0.6.0 |
|
|
533
|
+
|
|
534
|
+
`CHANGELOG.md` is the precise record of when each of these landed.
|
|
535
|
+
|
|
536
|
+
## Observable keys
|
|
537
|
+
|
|
538
|
+
Thirteen keys exist; **twelve are reachable** by a sandboxed client. Subscribe
|
|
539
|
+
with `BridgeClient.on(key, cb)`, which returns an unsubscribe function.
|
|
540
|
+
|
|
541
|
+
| Key | Payload type | Emitted when |
|
|
542
|
+
| --- | --- | --- |
|
|
543
|
+
| `pingPong` | `PingPongPayload` | reply to `ping`, once the app's background service is up |
|
|
544
|
+
| `userDevice` | `UserDevicePayload` | reply to `userDevice` **when the list loads** (not guaranteed — see below), and on roster changes |
|
|
545
|
+
| `deviceLightSensors` | `DeviceLightSensorsPayload` | real-time stream, only while `realTime` is on for that device |
|
|
546
|
+
| `deviceConnection` | `DeviceConnectionPayload` | a device connects or disconnects |
|
|
547
|
+
| `deviceNotification` | `DeviceNotificationPayload` | device-level notice, including errors |
|
|
548
|
+
| `realTime` | `RealTimeAckPayload` | ack of `realTime` |
|
|
549
|
+
| `syncTime` | `SyncTimeAckPayload` | ack of `syncTime` |
|
|
550
|
+
| `deleteMemory` | `DeleteMemoryAckPayload` | ack of `deleteMemory` |
|
|
551
|
+
| `sensorLog` | `SensorLogPayload` | reply to `sensorLog`; correlate by `requestId` |
|
|
552
|
+
| `systemStatus` | `SystemStatusPayload` | on attach, again right after handshake, and on every change |
|
|
553
|
+
| `language` | `LanguagePayload` | same cadence as `systemStatus`; needs native ≥ 0.14.0 |
|
|
554
|
+
| `navigationBack` | `NavigationBackPayload` (empty) | OS back pressed while you had reported `canGoBack: true` |
|
|
555
|
+
| `dfu` | — | **not reachable.** Firmware traffic. Subscribing is answered with `nack: not-read-only`. |
|
|
556
|
+
|
|
557
|
+
### Emission cadence you must design around
|
|
558
|
+
|
|
559
|
+
`systemStatus` and `language` are emitted **on attach, before your bundle
|
|
560
|
+
exists**, then again right after the handshake. The first emission is lost to
|
|
561
|
+
you; the second is the one you receive. This is why `language` also exists as a
|
|
562
|
+
pull command — a client that mounts late can ask.
|
|
563
|
+
|
|
564
|
+
Render a skeleton synchronously and fill it as events arrive. Do not make first
|
|
565
|
+
paint depend on any event.
|
|
566
|
+
|
|
567
|
+
### `deviceLightSensors` is a discriminated union
|
|
568
|
+
|
|
569
|
+
```ts
|
|
570
|
+
if (payload.type === 'actlumus2') {
|
|
571
|
+
// 14 wavelength channels, 395–800 nm, plus raw light_sensor_f1..f8 / clear / nir
|
|
572
|
+
} else {
|
|
573
|
+
// 'actlumus': 10 channels, 390–750 nm
|
|
574
|
+
}
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
A `null` in a raw channel means **that channel does not exist on this hardware
|
|
578
|
+
generation** — never "the reading was zero". `0.0` is a valid reading. Do not
|
|
579
|
+
collapse `null` to `0`: doing so fabricates data, and it has produced wrong
|
|
580
|
+
conclusions before.
|
|
581
|
+
|
|
582
|
+
### `systemStatus` carries the permission state
|
|
583
|
+
|
|
584
|
+
```ts
|
|
585
|
+
{
|
|
586
|
+
bluetooth: boolean; internet: boolean; permission: boolean; hasDevices: boolean;
|
|
587
|
+
missingEssentialPermissions: string[]; // e.g. ["bluetoothScan", "bluetoothConnect"]
|
|
588
|
+
missingOptionalPermissions: string[]; // e.g. ["notification", "backgroundLocation"]
|
|
589
|
+
deviceSyncError: Record<number, string>; // serialNo → message, already localized; a shared serialNo is left out
|
|
590
|
+
deviceSyncErrorById?: Record<number, string>; // deviceId → message, every device (nativeVersion ≥ 0.17.0)
|
|
591
|
+
}
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
`missingEssentialPermissions` being non-empty means background collection is
|
|
595
|
+
off. Render a blocking card; wire its button to
|
|
596
|
+
`BridgeClient.requestPermission(name)` with a name taken from that array.
|
|
597
|
+
|
|
598
|
+
**`hasDevices` is a hint.** It is `true` only after the app loaded a non-empty
|
|
599
|
+
device list from the server. `false` means "no linked device" **or** "the app
|
|
600
|
+
could not ask" (no internet, session problem). Do not show "you have no device"
|
|
601
|
+
on `false` alone; see the next section.
|
|
602
|
+
|
|
603
|
+
### `userDevice` in detail
|
|
604
|
+
|
|
605
|
+
**It is not guaranteed to answer.** `userDevice` (and `ping`) are handled by
|
|
606
|
+
the app's background service, which starts after your bundle is running; a
|
|
607
|
+
request sent before it is up is lost. And the app replies only when the list
|
|
608
|
+
loads: an account with no linked device, or a failed request, gets **no reply
|
|
609
|
+
at all** — never an empty list. Use the helper, which resends with backoff and
|
|
610
|
+
tells you when to give up:
|
|
611
|
+
|
|
612
|
+
```ts
|
|
613
|
+
const r = await BridgeClient.requestUserDevices(); // getFromServer: false
|
|
614
|
+
// { status: 'ok', devices } | { status: 'timeout', hasDevices: boolean | null }
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
**`getFromServer`.** `false` (default) answers from the app's memory or cache
|
|
618
|
+
while it is fresh, and asks the server otherwise — use it at boot. `true` asks
|
|
619
|
+
the server first and falls back to the cache if that fails — use it for a
|
|
620
|
+
"Refresh" button.
|
|
621
|
+
|
|
622
|
+
**Shape.** Three arrays, typed as `DeviceConfigItem`, `DeviceTelemetryItem` and
|
|
623
|
+
`DeviceUserConfigItem` in `dist/index.d.ts`:
|
|
624
|
+
|
|
625
|
+
| Array | Holds | Notable fields |
|
|
626
|
+
| --- | --- | --- |
|
|
627
|
+
| `devicesConfig` | one item per linked device — **the list to render** | `deviceId`, `serialNo`, `modelId` (`'actlumus'`, `'actlumus_plus'`; absent = ActLumus), `deviceBodyPart` (`'wrist'` \| `'necklace'` \| `null`), `bleConnectionPeriod` (minutes) |
|
|
628
|
+
| `devicesTelemetry` | last state read from each device | `deviceName`, `batteryLevel` (0–100 %), `memoryDataUsage` (0–100 %), `updataDateTime` (epoch ms, last sync — the misspelling is the real name), `deviceVersion`, `isCharging`, `isOnDock`, `syncErrors[]` |
|
|
629
|
+
| `devicesUserConfig` | per-device toggles | `syncTime`, `realTime`, `deleteMemory`, `readAllMemory`, `readAllSystemLog` |
|
|
630
|
+
|
|
631
|
+
The name and battery of a device are in **telemetry**, not in the config. A
|
|
632
|
+
device that never synced may have no telemetry item, or one with every reading
|
|
633
|
+
`null`: render it anyway.
|
|
634
|
+
|
|
635
|
+
**Joining the arrays.** Join on `deviceId`. An item of `devicesTelemetry` or
|
|
636
|
+
`devicesUserConfig` without `deviceId` is legacy data: fall back to `serialNo`
|
|
637
|
+
only when that `serialNo` appears once in `devicesConfig`.
|
|
638
|
+
|
|
639
|
+
**Sync errors.** `devicesTelemetry[].syncErrors` is the per-device history
|
|
640
|
+
(`attemptDate`, `message`, `messageCode`); `systemStatus.deviceSyncErrorById`
|
|
641
|
+
is the current error per device, already localized. Show the current one; use
|
|
642
|
+
the history for detail screens.
|
|
643
|
+
|
|
644
|
+
## Commands
|
|
645
|
+
|
|
646
|
+
`BridgeClient.send(command, args)` is **fire-and-forget**: no return value, no
|
|
647
|
+
exception, no delivery confirmation. Replies arrive on the key in the last
|
|
648
|
+
column.
|
|
649
|
+
|
|
650
|
+
| Command | Arguments | Confirmation | Replies on |
|
|
651
|
+
| --- | --- | --- | --- |
|
|
652
|
+
| `ping` | — | no | `pingPong` |
|
|
653
|
+
| `userDevice` | `{ getFromServer?: boolean }` | no | `userDevice` |
|
|
654
|
+
| `realTime` | `{ serialNo: number, deviceId?: number, value: boolean }` | no | `realTime` |
|
|
655
|
+
| `deviceConnection` | `{ type: 'add' \| 'remove', serialNo: number, deviceId?: number }` — subscribe to / stop following one device's connection state | no | `deviceConnection` |
|
|
656
|
+
| `sensorLog` | see below | no | `sensorLog` |
|
|
657
|
+
| `openSettings` | — | no | — |
|
|
658
|
+
| `requestPermission` | `{ permission: string }` | no | — (re-emits `systemStatus`) |
|
|
659
|
+
| `setStatusBarColor` | `{ color: '#RRGGBB' }` | no | — |
|
|
660
|
+
| `navigationState` | `{ canGoBack: boolean }` | no | — |
|
|
661
|
+
| `language` | — | no | `language` |
|
|
662
|
+
| `jsError` | `{ message, stack?, source?, lineno?, colno? }` | no | — |
|
|
663
|
+
| `syncTime` | `{ serialNo: number, deviceId?: number }` | **yes** | `syncTime` |
|
|
664
|
+
| `deleteMemory` | `{ serialNo: number, deviceId?: number }` | **yes** | `deleteMemory` |
|
|
665
|
+
| `logout` | — | **yes** | — |
|
|
666
|
+
| `dfu` | — | denied | — |
|
|
667
|
+
|
|
668
|
+
`serialNo` is an integer. Passing a string makes the native side drop the
|
|
669
|
+
message in silence.
|
|
670
|
+
|
|
671
|
+
`deviceConnection` **pairs nothing.** `type: 'add'` asks the app to report the
|
|
672
|
+
BLE connection state of a device you already have from `userDevice`; the state
|
|
673
|
+
then arrives on the `deviceConnection` key whenever it changes. `'remove'` stops
|
|
674
|
+
it. Send `add` for each device after the list arrives.
|
|
675
|
+
|
|
676
|
+
### Two devices with the same `serialNo`
|
|
677
|
+
|
|
678
|
+
An ActLumus and an ActLumus Plus have separate serial sequences, so one account
|
|
679
|
+
can hold both with the **same** `serialNo` (the server `deviceId` is always
|
|
680
|
+
unique). A command that carries only `serialNo` cannot say which one you mean:
|
|
681
|
+
the app **ignores it** instead of guessing, because guessing wrong would act on
|
|
682
|
+
the wrong device (erasing the wrong memory, for instance).
|
|
683
|
+
|
|
684
|
+
- **Send `deviceId` together with `serialNo`**, taken from the `userDevice`
|
|
685
|
+
list (`devicesConfig[].deviceId`). Always keep `serialNo` in the message: an
|
|
686
|
+
app older than 0.17.0 drops in silence a command that lacks it.
|
|
687
|
+
- **Read `deviceId` when it is there.** `deviceConnection`,
|
|
688
|
+
`deviceNotification` and `deviceLightSensors` carry it **only when the
|
|
689
|
+
`serialNo` is shared**; with a unique `serialNo` it is absent and `serialNo`
|
|
690
|
+
alone identifies the device. Never treat a missing `deviceId` as "unknown
|
|
691
|
+
device".
|
|
692
|
+
- **Acks always carry `serialNo`**, plus the `deviceId` you sent.
|
|
693
|
+
- **`systemStatus.deviceSyncError` leaves a shared `serialNo` out**, because no
|
|
694
|
+
single device owns it. Read `deviceSyncErrorById` for every device.
|
|
695
|
+
- **Gate on `nativeVersion` ≥ 0.17.0** before relying on any of this, and
|
|
696
|
+
degrade to `serialNo` only below it (where a shared `serialNo` was already
|
|
697
|
+
unaddressable).
|
|
698
|
+
- `sensorLog` takes no `deviceId` yet: for a shared `serialNo` the backend
|
|
699
|
+
answers with the error code `DEVICE_SERIAL_AMBIGUOUS`. Show your own copy for
|
|
700
|
+
it; do not retry.
|
|
701
|
+
|
|
702
|
+
### Commands that need human confirmation
|
|
703
|
+
|
|
704
|
+
`syncTime`, `deleteMemory` and `logout` open a modal owned by the Condor shell.
|
|
705
|
+
If the person declines, the shell answers `nack` with reason `user-rejected`,
|
|
706
|
+
which reaches you only as a `console.warn`. **The ack simply never arrives.**
|
|
707
|
+
|
|
708
|
+
Design for that: show an optimistic state only if you can undo it, and put a
|
|
709
|
+
timeout on any spinner.
|
|
710
|
+
|
|
711
|
+
### Typed helpers
|
|
712
|
+
|
|
713
|
+
Prefer these over raw `send` — they add correlation, timeouts and types:
|
|
714
|
+
|
|
715
|
+
```ts
|
|
716
|
+
BridgeClient.requestUserDevices({ getFromServer }) // resends until the list arrives, 30 s default
|
|
717
|
+
BridgeClient.waitForService() // resends ping until the background service answers
|
|
718
|
+
BridgeClient.fetchSensorLog(query, { timeoutMs }) // correlates requestId, 10 s default
|
|
719
|
+
BridgeClient.requestPermission(name)
|
|
720
|
+
BridgeClient.reportNavigation(canGoBack)
|
|
721
|
+
BridgeClient.onBack(cb)
|
|
722
|
+
BridgeClient.openSettings()
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
### `sensorLog` in detail
|
|
726
|
+
|
|
727
|
+
```ts
|
|
728
|
+
const page = await BridgeClient.fetchSensorLog({
|
|
729
|
+
requestId: crypto.randomUUID(),
|
|
730
|
+
serialNo,
|
|
731
|
+
bodyPart: 'wrist', // or 'necklace'; omit for no filter
|
|
732
|
+
limit: 200, // 1..1000, backend default 10
|
|
733
|
+
startTime: '2026-09-01 00:00:00', // native >= 0.15.0
|
|
734
|
+
endTime: '2026-09-02 00:00:00',
|
|
735
|
+
});
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
It **resolves** with `{ success: false, error }` on failure and only **rejects**
|
|
739
|
+
on timeout. Check `success` before reading `logs`.
|
|
740
|
+
|
|
741
|
+
Two things that have caused real bugs:
|
|
742
|
+
|
|
743
|
+
**Timestamps are device-local, not UTC.** `log_time` passes through from the
|
|
744
|
+
device RTC. Build your range with local `Date` getters — `getFullYear()`,
|
|
745
|
+
`getHours()` — and **never** with `toISOString()`. Converting shifts every
|
|
746
|
+
series by the timezone offset, producing plausible data and no error at all.
|
|
747
|
+
|
|
748
|
+
**Cursors are opaque.** `startingAfter` and `endingBefore` are mutually
|
|
749
|
+
exclusive; echo back exactly what you received and never construct one.
|
|
750
|
+
|
|
751
|
+
**Sweeping a period.** Keep `startTime` and `endTime` on **every** page and add
|
|
752
|
+
the cursor; the cursors are valid only inside the range they came from:
|
|
753
|
+
|
|
754
|
+
```ts
|
|
755
|
+
const logs: SensorLogItem[] = [];
|
|
756
|
+
let startingAfter: string | undefined;
|
|
757
|
+
for (;;) {
|
|
758
|
+
const page = await BridgeClient.fetchSensorLog({
|
|
759
|
+
requestId: crypto.randomUUID(), serialNo, limit: 1000,
|
|
760
|
+
startTime: '2026-10-13 00:00:00', endTime: '2026-10-20 00:00:00', // local time
|
|
761
|
+
...(startingAfter ? { startingAfter } : {}),
|
|
762
|
+
});
|
|
763
|
+
if (!page.success) break; // see the error codes below
|
|
764
|
+
logs.push(...(page.logs ?? []));
|
|
765
|
+
if (!page.hasMore || !page.startingAfter) break;
|
|
766
|
+
startingAfter = page.startingAfter;
|
|
767
|
+
}
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
**Which page comes first.** `logs` is always chronological ascending inside a
|
|
771
|
+
page. Which page you get first depends on the query:
|
|
772
|
+
|
|
773
|
+
| Query | First page | Next page |
|
|
774
|
+
| --- | --- | --- |
|
|
775
|
+
| no cursor, no range | the **most recent** `limit` logs | older: `endingBefore` = the reply's `endingBefore`, while `hasPrevious` |
|
|
776
|
+
| `startTime` (with or without `endTime`) | the **oldest** logs of the range | newer: `startingAfter` = the reply's `startingAfter`, while `hasMore` |
|
|
777
|
+
| only `endTime` | the most recent logs before it | older, as in the first row |
|
|
778
|
+
|
|
779
|
+
`limit` defaults to 10. With a time range, `hasMore` and `hasPrevious` are
|
|
780
|
+
computed *within* the period, so `hasMore: false` means "this period is
|
|
781
|
+
complete", not "no more data exists".
|
|
782
|
+
|
|
783
|
+
**How many logs to expect.** One per log period, which is **programmable per
|
|
784
|
+
device** (default 60 s, so about 1 440 per day). Do not hard-code it: derive the
|
|
785
|
+
period from consecutive `log_time` values, and page with `limit` up to 1000.
|
|
786
|
+
Pages depend on the previous cursor, so they cannot be fetched in parallel.
|
|
787
|
+
|
|
788
|
+
**Fields.** Each log is a `SensorLogItem`: `log_time`, `body_part`,
|
|
789
|
+
`temperature` (°C), `lux_photopic` and `lux_melanopic` (lux), the raw light
|
|
790
|
+
channels and the activity counters. `null` on a channel means the hardware does
|
|
791
|
+
not have it, never zero. `device_log_time` and `app_log_time` mark the upload
|
|
792
|
+
batch, not the log — do not plot them.
|
|
793
|
+
|
|
794
|
+
## Error shapes
|
|
795
|
+
|
|
796
|
+
There are three, and none of them is a thrown exception.
|
|
797
|
+
|
|
798
|
+
**1. `nack`** — shell to client, reasons `not-read-only`, `denied`,
|
|
799
|
+
`no-confirmation-handler`, `confirmation-error`, `user-rejected`, `not-allowed`.
|
|
800
|
+
Visible only as `console.warn`.
|
|
801
|
+
|
|
802
|
+
**2. `sensorLog` failure** — `{ requestId, success: false, error }`. The
|
|
803
|
+
contract codes are `CONFLICTING_CURSORS`, `INVALID_BODY_PART`, `INVALID_LIMIT`,
|
|
804
|
+
`INVALID_START_TIME`, `INVALID_END_TIME`, `INVALID_TIME_RANGE`,
|
|
805
|
+
`MISSING_DEVICE_SERIAL_NO`, `DEVICE_SERIAL_AMBIGUOUS`, `invalid session` and
|
|
806
|
+
`device not found for this user`. Some carry a suffix (`INVALID_LIMIT: 5000`),
|
|
807
|
+
so **match by prefix**, not equality. When the request never reached the
|
|
808
|
+
server (no internet, timeout), `error` is free text from the transport — so
|
|
809
|
+
treat any value outside the list as "history unavailable right now" and offer a
|
|
810
|
+
retry. `error` is a **diagnostic, never user-facing text** — map it to your own
|
|
811
|
+
copy.
|
|
812
|
+
|
|
813
|
+
**3. Silence** — nothing reaches JavaScript. This is the dominant failure mode.
|
|
814
|
+
The six paths that produce it are enumerated in `docs/troubleshooting.md`.
|
|
815
|
+
|
|
816
|
+
## The back button
|
|
817
|
+
|
|
818
|
+
The app owns the OS back button and will delegate it to you only if you ask.
|
|
819
|
+
|
|
820
|
+
```ts
|
|
821
|
+
const stack: string[] = ['home']; // keep navigation in memory
|
|
822
|
+
function go(view: string) { stack.push(view); render(); BridgeClient.reportNavigation(stack.length > 1); }
|
|
823
|
+
BridgeClient.onBack(() => { stack.pop(); render(); BridgeClient.reportNavigation(stack.length > 1); });
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
Keep the navigation stack in memory rather than in the History API: the
|
|
827
|
+
bundle lives in a sandboxed `srcdoc` iframe, where `history.pushState` is not
|
|
828
|
+
something to rely on. Report `false` when you are at your root, and the app
|
|
829
|
+
handles back itself. Report state on every internal navigation, not once at
|
|
830
|
+
startup. In the harness, the **◀ OS back** button at the top presses the OS
|
|
831
|
+
back button for you.
|
|
832
|
+
|
|
833
|
+
## Status bar
|
|
834
|
+
|
|
835
|
+
Two ways, in precedence order:
|
|
836
|
+
|
|
837
|
+
```html
|
|
838
|
+
<meta name="theme-color" content="#0A2540"> <!-- static, read from the bundle -->
|
|
839
|
+
```
|
|
840
|
+
```ts
|
|
841
|
+
BridgeClient.send('setStatusBarColor', { color: '#0A2540' }); // runtime, wins
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
## Session
|
|
845
|
+
|
|
846
|
+
When the session token dies, the app shows its own native banner above your
|
|
847
|
+
bundle and keeps rendering you. **You receive no event for this** — there is no
|
|
848
|
+
session key. Do not try to detect it; the app handles re-authentication.
|
|
849
|
+
|
|
850
|
+
<!-- ===== docs/troubleshooting.md ===== -->
|
|
851
|
+
|
|
852
|
+
# Troubleshooting
|
|
853
|
+
|
|
854
|
+
## "The device list never came" (stuck on loading)
|
|
855
|
+
|
|
856
|
+
The handshake worked, `systemStatus` arrived, and `userDevice` never answered.
|
|
857
|
+
Two different causes produce exactly this symptom, and the bundle cannot tell
|
|
858
|
+
them apart.
|
|
859
|
+
|
|
860
|
+
### Cause 1: the request was sent before the app's background service was up
|
|
861
|
+
|
|
862
|
+
`userDevice` and `ping` are not answered by the page that hosts you: the app
|
|
863
|
+
forwards them to a background service that reads the devices. On a cold start
|
|
864
|
+
the app creates your WebView first and starts that service afterwards, so a
|
|
865
|
+
request sent at boot is often **lost without a reply**. Nothing re-sends the
|
|
866
|
+
list when the service comes up. Meanwhile the handshake, `systemStatus` and
|
|
867
|
+
`language` work from the start, which makes the bridge look ready.
|
|
868
|
+
|
|
869
|
+
### Cause 2: the account has no linked device
|
|
870
|
+
|
|
871
|
+
The app replies to `userDevice` only when the list loads. When the server
|
|
872
|
+
answers that the account has no device — or the request fails for any other
|
|
873
|
+
reason (no internet, expired session) — nothing is sent to the bundle. A
|
|
874
|
+
`userDevice` with empty arrays never arrives today.
|
|
875
|
+
|
|
876
|
+
### What to do
|
|
877
|
+
|
|
878
|
+
Use the helper, which handles both:
|
|
879
|
+
|
|
880
|
+
```ts
|
|
881
|
+
const result = await BridgeClient.requestUserDevices(); // resends with backoff for 30 s
|
|
882
|
+
if (result.status === 'ok') render(result.devices);
|
|
883
|
+
else renderEmpty({ probablyNoDevices: result.hasDevices === false }); // + a "Try again" button
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
- It resends at 1.5 s, 3 s, 6 s and then every 10 s, and on every
|
|
887
|
+
`systemStatus` while it waits. `BridgeClient.waitForService()` does the same
|
|
888
|
+
with `ping` if you only need to know the service is up.
|
|
889
|
+
- **`systemStatus.hasDevices` is a hint, not a verdict.** It is `true` only
|
|
890
|
+
after the app loaded a non-empty list from the server. `false` means either
|
|
891
|
+
"no device" or "the app could not ask". Word the empty state so it is true in
|
|
892
|
+
both cases, and always offer a retry with `getFromServer: true`.
|
|
893
|
+
- Keep the `userDevice` subscription for the life of the page: roster changes
|
|
894
|
+
arrive on it without a request.
|
|
895
|
+
|
|
896
|
+
Reproduce both causes in the browser before going to a device:
|
|
897
|
+
|
|
898
|
+
```bash
|
|
899
|
+
npx condor-mobile-bridge dev -i dist/index.html --scenario cold-start
|
|
900
|
+
npx condor-mobile-bridge dev -i dist/index.html --scenario no-devices
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
## "My command did nothing"
|
|
904
|
+
|
|
905
|
+
This is the most common report, and it is usually correct: the command did
|
|
906
|
+
nothing, and nothing told you. The native side drops malformed or unauthorized
|
|
907
|
+
traffic with a log on the device and no reply to JavaScript.
|
|
908
|
+
|
|
909
|
+
There are six paths to silence. Work down the list.
|
|
910
|
+
|
|
911
|
+
### 1. The payload was over 64 KB
|
|
912
|
+
|
|
913
|
+
The whole JSON message is measured, not just your arguments. Over the limit it
|
|
914
|
+
is discarded before dispatch.
|
|
915
|
+
|
|
916
|
+
**Check:** `JSON.stringify(args).length` at the call site.
|
|
917
|
+
|
|
918
|
+
### 2. The command name is unknown to this device
|
|
919
|
+
|
|
920
|
+
A typo, or — more often — a command that exists in a newer native version than
|
|
921
|
+
the device runs. The native switch has no default branch that reports back.
|
|
922
|
+
|
|
923
|
+
**Check:** the command against `docs/bridge-contract.md`, then check
|
|
924
|
+
`ack.nativeVersion` from your handshake against the capability matrix. A device
|
|
925
|
+
in the field can be several versions behind your development device.
|
|
926
|
+
|
|
927
|
+
### 3. The arguments had the wrong shape
|
|
928
|
+
|
|
929
|
+
`realTime`, `syncTime`, `deleteMemory` and `deviceConnection` validate their
|
|
930
|
+
payload natively and drop it on mismatch. `serialNo` is an **integer**, not a
|
|
931
|
+
string; `value` and `canGoBack` are **booleans**, not strings.
|
|
932
|
+
|
|
933
|
+
**Also check:** if the account holds an ActLumus and an ActLumus Plus with the same
|
|
934
|
+
`serialNo`, a command without `deviceId` is ignored (never guessed). Send both
|
|
935
|
+
(`nativeVersion` ≥ 0.17.0).
|
|
936
|
+
|
|
937
|
+
**Check:** `typeof` at the call site. `serialNo: "1234"` fails; `serialNo: 1234`
|
|
938
|
+
works.
|
|
939
|
+
|
|
940
|
+
### 4. The permission name is not in the native enum
|
|
941
|
+
|
|
942
|
+
`requestPermission` takes a name the native side recognizes. Anything else is
|
|
943
|
+
dropped.
|
|
944
|
+
|
|
945
|
+
**Check:** that the name came from `systemStatus.missingEssentialPermissions`
|
|
946
|
+
or `missingOptionalPermissions` — those arrays are the authoritative list of
|
|
947
|
+
names, so pass them through unchanged and never type one by hand. Show a
|
|
948
|
+
generic label for a name you do not recognise. And note there is
|
|
949
|
+
no permission-result event at all — the outcome reaches you as a fresh
|
|
950
|
+
`systemStatus` emission. If you were waiting for a dedicated ack, you will wait
|
|
951
|
+
forever.
|
|
952
|
+
|
|
953
|
+
### 5. The command was denied, or the person declined
|
|
954
|
+
|
|
955
|
+
`syncTime`, `deleteMemory` and `logout` open a Condor-owned confirmation modal.
|
|
956
|
+
Declining produces a `nack` with reason `user-rejected`, which `BridgeClient`
|
|
957
|
+
surfaces as a `console.warn` and nothing else — `send` is fire-and-forget.
|
|
958
|
+
|
|
959
|
+
`dfu` is denied outright. Subscribing to a key outside the allowed set produces
|
|
960
|
+
`nack` with reason `not-read-only`.
|
|
961
|
+
|
|
962
|
+
**Check:** the browser console for a warning starting with `[BridgeClient]`. On
|
|
963
|
+
a device that console is only visible through the WebView inspector
|
|
964
|
+
(`docs/device-testing.md`).
|
|
965
|
+
Then re-read whether that command needs confirmation.
|
|
966
|
+
|
|
967
|
+
### 6. You bypassed `BridgeClient`
|
|
968
|
+
|
|
969
|
+
A hand-rolled `window.parent.postMessage` almost certainly fails the Valibot
|
|
970
|
+
schema at the shell boundary and is dropped without a reply. The shell validates
|
|
971
|
+
every message before it touches the native bridge.
|
|
972
|
+
|
|
973
|
+
**Check:** that every message goes through `BridgeClient`.
|
|
974
|
+
|
|
975
|
+
There is a seventh path you cannot cause by accident but should know about: a
|
|
976
|
+
message that reaches the native channel without the shell's nonce is dropped
|
|
977
|
+
and logged as a security event. This is what happens if you create a nested
|
|
978
|
+
iframe and try to talk to the native side from inside it.
|
|
979
|
+
|
|
980
|
+
## "My bundle was rejected and the app shows its default screen"
|
|
981
|
+
|
|
982
|
+
The app validates the bundle on download and keeps the previous version — or
|
|
983
|
+
falls back — on any failure.
|
|
984
|
+
|
|
985
|
+
```bash
|
|
986
|
+
npx condor-mobile-bridge validate -i dist/bundle.zip --strict
|
|
987
|
+
```
|
|
988
|
+
|
|
989
|
+
This applies the same gates. The failures it reports as **errors** are exactly
|
|
990
|
+
the ones that cause rejection:
|
|
991
|
+
|
|
992
|
+
| Finding | Cause |
|
|
993
|
+
| --- | --- |
|
|
994
|
+
| `too-large` | The `.zip` is over 2 MB. |
|
|
995
|
+
| `entry-missing` | No `.html` document in the archive. |
|
|
996
|
+
| `not-self-contained` | A `<script src>` or `<link rel="stylesheet">` pointing outside a `data:` URI. |
|
|
997
|
+
|
|
998
|
+
If `validate` passes and the app still falls back, the bundle never arrived:
|
|
999
|
+
check the manifest, the `bundleHash` (it must match the SHA-256 of the exact
|
|
1000
|
+
`.zip` bytes you uploaded), and `minAppVersion` — the minimum app build Condor
|
|
1001
|
+
sets when publishing, for a bundle that needs a newer app — against the app build on the
|
|
1002
|
+
device.
|
|
1003
|
+
|
|
1004
|
+
## "It works in the browser but not on the device"
|
|
1005
|
+
|
|
1006
|
+
First question: **which browser setup?**
|
|
1007
|
+
|
|
1008
|
+
- **A plain tab or your own dev server** (`vite dev`, opening `dist/index.html`):
|
|
1009
|
+
`BridgeClient.handshake()` rejects at once, because there is no shell. If you
|
|
1010
|
+
built a mock of your own to get past that, it is more permissive than the
|
|
1011
|
+
device in every way that matters: real origin (storage works), no CSP (CDN
|
|
1012
|
+
fonts and `fetch` work), instant replies (nothing is lost).
|
|
1013
|
+
- **The harness** (`npx condor-mobile-bridge dev`): same sandbox, shell, policy
|
|
1014
|
+
and CSP as the app. What it cannot reproduce is timing you did not ask for —
|
|
1015
|
+
run every scenario, not just `default`. `cold-start` and `no-devices` are the
|
|
1016
|
+
ones that catch real bugs.
|
|
1017
|
+
|
|
1018
|
+
In the harness `nativeVersion` is `0.17.0` by default. Run it with
|
|
1019
|
+
`--native-version null` to reproduce a shell that could not reach the native
|
|
1020
|
+
side; keep a branch that renders in that case too.
|
|
1021
|
+
|
|
1022
|
+
Run `validate --strict` before concluding that something works, and see
|
|
1023
|
+
`docs/device-testing.md` to inspect the bundle on a phone.
|
|
1024
|
+
|
|
1025
|
+
## "The screen is blank"
|
|
1026
|
+
|
|
1027
|
+
Almost always the bundle build, not the bridge.
|
|
1028
|
+
|
|
1029
|
+
**Code splitting.** If your bundler emitted more than one chunk, or ES module
|
|
1030
|
+
imports between chunks, `srcdoc` cannot resolve them and nothing executes. Build
|
|
1031
|
+
single-file: Vite with `vite-plugin-singlefile`, or an equivalent.
|
|
1032
|
+
|
|
1033
|
+
**A script that failed to parse.** Inlining a script that contains markup inside
|
|
1034
|
+
JavaScript strings can break if done by a naive tool — use
|
|
1035
|
+
`npx condor-mobile-bridge build`, which handles the escaping.
|
|
1036
|
+
|
|
1037
|
+
**First paint depends on the bridge.** If your render only runs inside an event
|
|
1038
|
+
handler and no event has arrived yet, you get a blank screen. Render a skeleton
|
|
1039
|
+
synchronously; fill it as events arrive.
|
|
1040
|
+
|
|
1041
|
+
## "An ack never arrived"
|
|
1042
|
+
|
|
1043
|
+
Check, in order: was the command one of the three that need confirmation
|
|
1044
|
+
(the person may have declined); did the command reach the native side at all
|
|
1045
|
+
(the six paths above); and is your `requestId` correlation correct for
|
|
1046
|
+
`sensorLog` — the reply carries back the id you sent, and a mismatch looks
|
|
1047
|
+
exactly like silence.
|
|
1048
|
+
|
|
1049
|
+
Always have a timeout. `BridgeClient.fetchSensorLog` has one built in; anything
|
|
1050
|
+
you correlate by hand needs your own.
|
|
1051
|
+
|
|
1052
|
+
## Getting an error out of the device
|
|
1053
|
+
|
|
1054
|
+
You cannot reach an external logging service — `connect-src 'self'` blocks it.
|
|
1055
|
+
Use the bridge:
|
|
1056
|
+
|
|
1057
|
+
```ts
|
|
1058
|
+
BridgeClient.send('jsError', { message: String(err), stack: err?.stack });
|
|
1059
|
+
```
|
|
1060
|
+
|
|
1061
|
+
That reaches Condor's logging and Crashlytics — **Condor reads them, you do
|
|
1062
|
+
not**; ask your Condor contact for the errors of a test account. It is rate
|
|
1063
|
+
limited to 20 per 10 seconds and truncated at 4 KB per field, so do not use it
|
|
1064
|
+
as a general telemetry channel — it will drop exactly when something is going
|
|
1065
|
+
wrong in a loop. To watch errors live, inspect the WebView: see
|
|
1066
|
+
`docs/device-testing.md`.
|
|
1067
|
+
|
|
1068
|
+
<!-- ===== docs/device-testing.md ===== -->
|
|
1069
|
+
|
|
1070
|
+
# Testing on a device
|
|
1071
|
+
|
|
1072
|
+
The harness (`npx condor-mobile-bridge dev`) catches most problems in the
|
|
1073
|
+
browser. This page covers the rest: how a bundle reaches a phone, what that
|
|
1074
|
+
costs, and how to see what it is doing once it is there.
|
|
1075
|
+
|
|
1076
|
+
## How a bundle reaches the screen
|
|
1077
|
+
|
|
1078
|
+
You do not install a bundle on a phone. The app downloads it:
|
|
1079
|
+
|
|
1080
|
+
1. You deliver `bundle.zip`, its `bundleSize` and its `bundleHash` (printed by
|
|
1081
|
+
`condor-mobile-bridge build`) to your Condor contact.
|
|
1082
|
+
2. Condor publishes it for **your company**. There is exactly one current
|
|
1083
|
+
bundle per company.
|
|
1084
|
+
3. Each copy of the app asks for its company's manifest when the Home opens
|
|
1085
|
+
and every time the app returns to the foreground. If the `bundleHash`
|
|
1086
|
+
changed, it downloads the `.zip`, checks size and hash, and swaps the Home
|
|
1087
|
+
without a restart. Otherwise it keeps the cached copy, also when offline.
|
|
1088
|
+
|
|
1089
|
+
Consequences:
|
|
1090
|
+
|
|
1091
|
+
- **Reinstalling the app changes nothing.** The bundle is not inside the app.
|
|
1092
|
+
- **Publishing reaches every user of that company** the next time their app
|
|
1093
|
+
comes to the foreground. There is no per-user or per-device preview today.
|
|
1094
|
+
- **A rejected bundle is invisible to the person.** The app keeps its previous
|
|
1095
|
+
bundle, or shows its default screen, if the download fails a gate — which is
|
|
1096
|
+
why `validate --strict` must pass before you hand anything over.
|
|
1097
|
+
|
|
1098
|
+
## Development and production are separate worlds
|
|
1099
|
+
|
|
1100
|
+
Condor runs a **development** environment next to production. An app build
|
|
1101
|
+
talks to exactly one of them, and accounts, devices and bundles do not cross
|
|
1102
|
+
over:
|
|
1103
|
+
|
|
1104
|
+
- a development build of the app sees only development accounts and bundles;
|
|
1105
|
+
- the store app sees only production.
|
|
1106
|
+
|
|
1107
|
+
So, to test on a phone, ask Condor for three things that match: a **test
|
|
1108
|
+
account** in a **test company** in the development environment, a
|
|
1109
|
+
**development build** of the app (Android), and to publish your bundle **to that
|
|
1110
|
+
test company**. Never iterate on a production company: every publish reaches
|
|
1111
|
+
real people.
|
|
1112
|
+
|
|
1113
|
+
Each iteration on the device is therefore: build → validate → hand over →
|
|
1114
|
+
Condor publishes → bring the app to the foreground. That is slow, so do every
|
|
1115
|
+
iteration you can in the harness first.
|
|
1116
|
+
|
|
1117
|
+
## Seeing what the bundle does on the phone
|
|
1118
|
+
|
|
1119
|
+
**Release builds of the app cannot be inspected.** Ask Condor for a
|
|
1120
|
+
development (debuggable) Android build. Then:
|
|
1121
|
+
|
|
1122
|
+
1. Connect the phone by USB with USB debugging on.
|
|
1123
|
+
2. Open `chrome://inspect/#devices` in desktop Chrome and click **inspect** on
|
|
1124
|
+
the app's WebView.
|
|
1125
|
+
3. In the DevTools console, switch the context selector from `top` to the
|
|
1126
|
+
frame `about:srcdoc` — that is your bundle. The `top` frame is the Condor
|
|
1127
|
+
shell.
|
|
1128
|
+
|
|
1129
|
+
There you see your own `console.*` output, uncaught errors, and the shell's
|
|
1130
|
+
refusals: a `nack` reaches you only as a `[BridgeClient] command nacked by
|
|
1131
|
+
shell` warning, so this is the one place to find out that a command was denied
|
|
1132
|
+
or declined.
|
|
1133
|
+
|
|
1134
|
+
What you will **not** see from the WebView: the native side. A command dropped
|
|
1135
|
+
by the app (wrong argument type, unknown command, a request lost while the
|
|
1136
|
+
background service starts) leaves no trace in the console — reproduce it in the
|
|
1137
|
+
harness instead, whose console says what was dropped and why.
|
|
1138
|
+
|
|
1139
|
+
## Errors after the fact
|
|
1140
|
+
|
|
1141
|
+
`BridgeClient.send('jsError', { message, stack })` sends an error to Condor's
|
|
1142
|
+
logging and crash reporting (rate limited: 20 per 10 s, 4 KB per field). Those
|
|
1143
|
+
are Condor's tools: when you need the errors of a test session, ask your Condor
|
|
1144
|
+
contact, with the test account and the time window.
|
|
1145
|
+
|
|
1146
|
+
## Before you ask for a publish
|
|
1147
|
+
|
|
1148
|
+
- [ ] `npx condor-mobile-bridge validate -i dist/bundle.zip --strict` exits 0.
|
|
1149
|
+
- [ ] Every harness scenario passes (`default`, `cold-start`, `no-devices`,
|
|
1150
|
+
`shared-serial`, `offline`).
|
|
1151
|
+
- [ ] You know which environment and which company the bundle is for.
|
|
1152
|
+
- [ ] You sent `bundleSize` and `bundleHash` with the `.zip`, so the published
|
|
1153
|
+
bundle can be checked against what you built.
|
|
1154
|
+
|
|
1155
|
+
<!-- ===== docs/limits.md ===== -->
|
|
1156
|
+
|
|
1157
|
+
# Limits
|
|
1158
|
+
|
|
1159
|
+
Every number here is enforced in code. The **On violation** column is what you
|
|
1160
|
+
actually observe — which matters more than the number, because most of these
|
|
1161
|
+
fail without telling you.
|
|
1162
|
+
|
|
1163
|
+
Each row cites the file and line in the Canvas app that applies it, as verified
|
|
1164
|
+
against monorepo commit `a6b19cd2` on 2026-09-22. The same values are available
|
|
1165
|
+
as JSON in `contract/bridge-contract.json`, including these citations under
|
|
1166
|
+
`verifiedAgainst.sources`.
|
|
1167
|
+
|
|
1168
|
+
## Messages
|
|
1169
|
+
|
|
1170
|
+
| Limit | Value | On violation | Enforced at |
|
|
1171
|
+
| --- | --- | --- | --- |
|
|
1172
|
+
| Payload, JS → native | 64 KB (65536 bytes) | **Silent drop.** Nothing reaches the native side and nothing comes back. | `condor_bridge.dart:88,229-238` |
|
|
1173
|
+
| `jsError` rate | 20 per rolling 10 s | Silent drop of the excess. | `condor_bridge.dart:93-94,311-317` |
|
|
1174
|
+
| `jsError` field length | 4 KB each for `message` and `stack` | Truncated, with a marker appended. | `condor_bridge.dart:97,319-320` |
|
|
1175
|
+
|
|
1176
|
+
64 KB is generous for commands but not for data you might try to push back.
|
|
1177
|
+
There is no chunking protocol: if you need to move something larger, you are
|
|
1178
|
+
solving the wrong problem — the native side is the one that holds data.
|
|
1179
|
+
|
|
1180
|
+
## Timeouts
|
|
1181
|
+
|
|
1182
|
+
| Operation | Timeout | On expiry |
|
|
1183
|
+
| --- | --- | --- |
|
|
1184
|
+
| `BridgeClient.handshake` | 5 s | Promise **rejects**. |
|
|
1185
|
+
| `BridgeClient.fetchSensorLog` | 10 s (override per call) | Promise **rejects**. This is the only rejection path — a failed query *resolves* with `success: false`. |
|
|
1186
|
+
| `BridgeShell.verifySandbox` | 2 s | Resolves with the failure recorded. |
|
|
1187
|
+
| Relay of `nativeVersion` into the iframe | 2 s | `ack.nativeVersion` comes back `null`. |
|
|
1188
|
+
|
|
1189
|
+
## Sensor log
|
|
1190
|
+
|
|
1191
|
+
| Parameter | Range | On violation |
|
|
1192
|
+
| --- | --- | --- |
|
|
1193
|
+
| `limit` | 1–1000 (backend default 10) | `success: false`, `error: "INVALID_LIMIT: <value>"` |
|
|
1194
|
+
| `bodyPart` | `wrist` or `necklace` | `error: "INVALID_BODY_PART: <value>"` |
|
|
1195
|
+
| `startingAfter` + `endingBefore` | mutually exclusive | `error: "CONFLICTING_CURSORS"` |
|
|
1196
|
+
| `startTime`, `endTime` | timestamp format, native ≥ 0.15.0 | `error: "INVALID_START_TIME"` / `"INVALID_END_TIME"` |
|
|
1197
|
+
|
|
1198
|
+
Cursors are **opaque**. Echo back exactly what you received; never construct or
|
|
1199
|
+
parse one.
|
|
1200
|
+
|
|
1201
|
+
Verified at `sensor_log_entity.dart:70-85`.
|
|
1202
|
+
|
|
1203
|
+
## Bundle
|
|
1204
|
+
|
|
1205
|
+
| Limit | Value | On violation |
|
|
1206
|
+
| --- | --- | --- |
|
|
1207
|
+
| `.zip` size | 2 MB (2097152 bytes) | **The app rejects the bundle**, keeps the previous cached version, or falls back to its default screen. |
|
|
1208
|
+
| Entry document | one `index.html` at the archive root | Falls back to the first `.html` found; if there is none, the bundle is rejected. |
|
|
1209
|
+
| External `<script src>` | forbidden | Bundle rejected. |
|
|
1210
|
+
| External `<link rel="stylesheet">` | forbidden | Bundle rejected. |
|
|
1211
|
+
| Presigned download URL | valid 24 h | The app re-fetches the manifest. |
|
|
1212
|
+
|
|
1213
|
+
Verified at `whitelabel_bundle_limits.dart:14` and
|
|
1214
|
+
`whitelabel_bundle_service.dart:227-398`.
|
|
1215
|
+
|
|
1216
|
+
`data:` URIs inflate binary assets by roughly a third over their raw bytes, so
|
|
1217
|
+
budget against the inflated size. `npx condor-mobile-bridge build` prints the
|
|
1218
|
+
final `.zip` size; `validate` fails on it.
|
|
1219
|
+
|
|
1220
|
+
## Storage: there is none
|
|
1221
|
+
|
|
1222
|
+
| API | Behaviour |
|
|
1223
|
+
| --- | --- |
|
|
1224
|
+
| `localStorage`, `sessionStorage` | throw `SecurityError` |
|
|
1225
|
+
| `indexedDB` | unavailable |
|
|
1226
|
+
| `document.cookie` | writes are silently ignored |
|
|
1227
|
+
| Service workers | cannot be registered |
|
|
1228
|
+
| Cache API | unavailable |
|
|
1229
|
+
|
|
1230
|
+
The shell is loaded with no base URL, giving it an opaque origin; your iframe is
|
|
1231
|
+
`sandbox="allow-scripts"` rendered from `srcdoc`, giving it a `null` origin.
|
|
1232
|
+
Both are deliberate — the opaque origin is what lets the app guarantee that
|
|
1233
|
+
logging out leaves nothing behind.
|
|
1234
|
+
|
|
1235
|
+
Hold state in memory. It is lost when the WebView reloads, which happens when
|
|
1236
|
+
the bundle is updated, when the kill switch fires, and when the person changes
|
|
1237
|
+
the app language while no client bundle is active.
|
|
1238
|
+
|
|
1239
|
+
Verified at `home_page.dart:296-298`.
|
|
1240
|
+
|
|
1241
|
+
## Network: there is none
|
|
1242
|
+
|
|
1243
|
+
The shell CSP is:
|
|
1244
|
+
|
|
1245
|
+
```
|
|
1246
|
+
default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';
|
|
1247
|
+
img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-src 'self';
|
|
1248
|
+
object-src 'none'; base-uri 'none'; form-action 'none';
|
|
1249
|
+
```
|
|
1250
|
+
|
|
1251
|
+
It is inherited by your `srcdoc` iframe and **an iframe cannot relax the policy
|
|
1252
|
+
it inherits**. With a `null` origin, `'self'` matches nothing, so `connect-src
|
|
1253
|
+
'self'` blocks every outbound request. No analytics, no CDN, no error reporting
|
|
1254
|
+
service.
|
|
1255
|
+
|
|
1256
|
+
For error reporting, use the `jsError` command — it reaches Condor's logging
|
|
1257
|
+
and Crashlytics, within the rate limit above.
|
|
1258
|
+
|
|
1259
|
+
Verified at `shell.template.html:28`.
|
|
1260
|
+
|
|
1261
|
+
## Devices
|
|
1262
|
+
|
|
1263
|
+
Two things are true at once here, and conflating them is how this goes wrong.
|
|
1264
|
+
|
|
1265
|
+
**The platform enforces nothing.** A search for `maxDevices` or `deviceLimit`
|
|
1266
|
+
across the Canvas app returns nothing. No code path rejects a third device.
|
|
1267
|
+
|
|
1268
|
+
**The current commercial rule caps a user at two linked devices.** That is a
|
|
1269
|
+
product decision, not a platform invariant. It can be raised without any change
|
|
1270
|
+
to this contract, to the app, or to a version number you could check.
|
|
1271
|
+
|
|
1272
|
+
So: design for a short list rather than a virtualised one — two cards will be
|
|
1273
|
+
the common case — but **read the length of the roster you receive** and let the
|
|
1274
|
+
layout grow. A two-slot UI with the slots hard-coded is the failure this section
|
|
1275
|
+
exists to prevent: it will look correct for as long as the rule holds, and break
|
|
1276
|
+
silently on the day it does not.
|
|
1277
|
+
|
|
1278
|
+
<!-- ===== docs/client-guide.md ===== -->
|
|
1279
|
+
|
|
1280
|
+
# Build your White Label Home
|
|
1281
|
+
|
|
1282
|
+
This guide walks you — a Condor White Label partner — through building the web
|
|
1283
|
+
Home that runs inside the Condor mobile app, using the
|
|
1284
|
+
[`@condorinst/mobile-bridge`](../README.md) SDK.
|
|
1285
|
+
|
|
1286
|
+
No Flutter, Android, or iOS knowledge is required. You build a normal web app;
|
|
1287
|
+
we package it and the app renders it.
|
|
1288
|
+
|
|
1289
|
+
> **This guide is the narrative path, written for a person reading start to
|
|
1290
|
+
> finish.** If you are working with an AI agent, point it at
|
|
1291
|
+
> [`AGENTS.md`](../AGENTS.md) and [`docs/agent-guide.md`](./agent-guide.md)
|
|
1292
|
+
> instead — same platform, organised as a procedure with checks. The normative
|
|
1293
|
+
> reference for keys, commands, errors and capabilities is
|
|
1294
|
+
> [`docs/bridge-contract.md`](./bridge-contract.md); the hard numbers are in
|
|
1295
|
+
> [`docs/limits.md`](./limits.md).
|
|
1296
|
+
|
|
1297
|
+
---
|
|
1298
|
+
|
|
1299
|
+
## 1. What you're building
|
|
1300
|
+
|
|
1301
|
+
The app's Home is a **web bundle** that the Condor app renders inside a
|
|
1302
|
+
sandboxed WebView (`<iframe sandbox="allow-scripts">`). You deliver a single
|
|
1303
|
+
self-contained `index.html`; the app downloads it, verifies it, and shows it.
|
|
1304
|
+
|
|
1305
|
+
Your web code talks to the device and the native app through the **bridge** —
|
|
1306
|
+
a typed message channel exposed by this SDK. You never touch Bluetooth or
|
|
1307
|
+
native APIs directly: you subscribe to keys (device list, connection status,
|
|
1308
|
+
real-time readings…) and send commands (refresh, start real-time, open
|
|
1309
|
+
settings…).
|
|
1310
|
+
|
|
1311
|
+
```
|
|
1312
|
+
┌────────────────────────────┐
|
|
1313
|
+
│ Condor app (native) │
|
|
1314
|
+
│ ┌──────────────────────┐ │
|
|
1315
|
+
│ │ iframe (your bundle) │ │ ← your index.html runs here
|
|
1316
|
+
│ │ BridgeClient ◄────────────► native bridge (devices, BLE, …)
|
|
1317
|
+
│ └──────────────────────┘ │
|
|
1318
|
+
└────────────────────────────┘
|
|
1319
|
+
```
|
|
1320
|
+
|
|
1321
|
+
**Two hard rules** that shape everything below:
|
|
1322
|
+
- The bundle must be **one `index.html`**, fully **self-contained** (JS/CSS
|
|
1323
|
+
inline, media as `data:` URIs). Relative paths and external URLs do **not**
|
|
1324
|
+
load inside the sandbox.
|
|
1325
|
+
- The bundle must be **≤ 2 MB** (zipped).
|
|
1326
|
+
|
|
1327
|
+
The CLI in this SDK turns your normal build into a compliant bundle for you
|
|
1328
|
+
(see §6) — you don't have to inline by hand.
|
|
1329
|
+
|
|
1330
|
+
---
|
|
1331
|
+
|
|
1332
|
+
## 2. Install the SDK
|
|
1333
|
+
|
|
1334
|
+
```bash
|
|
1335
|
+
npm install @condorinst/mobile-bridge
|
|
1336
|
+
```
|
|
1337
|
+
|
|
1338
|
+
Pin the version with a caret:
|
|
1339
|
+
|
|
1340
|
+
```jsonc
|
|
1341
|
+
// package.json
|
|
1342
|
+
"dependencies": {
|
|
1343
|
+
"@condorinst/mobile-bridge": "^0.21.0"
|
|
1344
|
+
}
|
|
1345
|
+
```
|
|
1346
|
+
|
|
1347
|
+
**Updates.** The SDK is on `0.x` during the pilot. With `^0.21.0`, running
|
|
1348
|
+
`npm install` (or your CI) auto-picks **patch** releases (`0.21.x`) — bug fixes,
|
|
1349
|
+
no action needed. A `0.22.0` (a larger change during beta) is an intentional
|
|
1350
|
+
opt-in: you bump the range when you're ready. `1.0.0` will mark the
|
|
1351
|
+
production-stable contract.
|
|
1352
|
+
|
|
1353
|
+
The published package ships prebuilt JS + TypeScript types — no build tooling
|
|
1354
|
+
of ours required on your side.
|
|
1355
|
+
|
|
1356
|
+
---
|
|
1357
|
+
|
|
1358
|
+
## 3. Talk to the app — the bridge
|
|
1359
|
+
|
|
1360
|
+
Import **`BridgeClient`** (the model for code running inside the app's iframe):
|
|
1361
|
+
|
|
1362
|
+
```typescript
|
|
1363
|
+
import { BridgeClient, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
|
|
1364
|
+
```
|
|
1365
|
+
|
|
1366
|
+
### Handshake
|
|
1367
|
+
|
|
1368
|
+
Announce yourself once at boot. The ack tells you the bridge is live.
|
|
1369
|
+
|
|
1370
|
+
```typescript
|
|
1371
|
+
const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
|
|
1372
|
+
// ack.accepted: boolean
|
|
1373
|
+
// ack.shellVersion: string
|
|
1374
|
+
// ack.nativeVersion: string | null — the native's schema version, relayed by
|
|
1375
|
+
// the shell (0.17.0+). `null` means the native has not answered or is absent
|
|
1376
|
+
// (browser mock). There is no version negotiation, so this is the only signal
|
|
1377
|
+
// telling you whether a capability exists — e.g. the raw `light_sensor_*`
|
|
1378
|
+
// channels on `deviceLightSensors` require native >= 0.16.0.
|
|
1379
|
+
```
|
|
1380
|
+
|
|
1381
|
+
### Listen for events (native → you)
|
|
1382
|
+
|
|
1383
|
+
```typescript
|
|
1384
|
+
const unsubscribe = BridgeClient.on(BRIDGE_KEYS.deviceConnection, (payload) => {
|
|
1385
|
+
// payload is typed: { serialNo: number, isConnect: boolean }
|
|
1386
|
+
console.log(payload.serialNo, payload.isConnect);
|
|
1387
|
+
});
|
|
1388
|
+
// later: unsubscribe();
|
|
1389
|
+
```
|
|
1390
|
+
|
|
1391
|
+
### Send commands (you → native)
|
|
1392
|
+
|
|
1393
|
+
```typescript
|
|
1394
|
+
const devices = await BridgeClient.requestUserDevices(); // device list, resent until it arrives
|
|
1395
|
+
BridgeClient.send('realTime', { serialNo: 1234, value: true }); // start real-time
|
|
1396
|
+
BridgeClient.openSettings(); // open native Settings
|
|
1397
|
+
BridgeClient.requestPermission('bluetoothScan'); // in-app permission dialog
|
|
1398
|
+
```
|
|
1399
|
+
|
|
1400
|
+
> Commands are gated by the app's policy. A command that isn't allowed is
|
|
1401
|
+
> silently ignored — design your UI to not depend on a response you won't get.
|
|
1402
|
+
>
|
|
1403
|
+
> Ask for the device list with `requestUserDevices()`, not with
|
|
1404
|
+
> `send('userDevice')`: the first request after the app starts is often lost,
|
|
1405
|
+
> and an account with no device gets no reply at all. The helper resends with
|
|
1406
|
+
> backoff and resolves `{ status: 'timeout', hasDevices }` after 30 s, so you can
|
|
1407
|
+
> show an empty state. See [Troubleshooting](./troubleshooting.md).
|
|
1408
|
+
|
|
1409
|
+
### Handle the system back button
|
|
1410
|
+
|
|
1411
|
+
Your page runs inside the app's Home screen. On Android, the OS back
|
|
1412
|
+
button/gesture would otherwise leave the Home — so the app delegates it to you
|
|
1413
|
+
when you opt in, letting the back button walk **your** internal navigation
|
|
1414
|
+
(e.g. a device detail page → back to the list) before it does anything else.
|
|
1415
|
+
|
|
1416
|
+
Two calls wire it up:
|
|
1417
|
+
|
|
1418
|
+
```typescript
|
|
1419
|
+
// 1. Tell the app whether you have an internal page to go back to.
|
|
1420
|
+
// Call this on EVERY route change (and once at boot).
|
|
1421
|
+
BridgeClient.reportNavigation(currentView !== 'home'); // true = you can go back
|
|
1422
|
+
|
|
1423
|
+
// 2. React to the OS back button. Called only when you last reported `true`.
|
|
1424
|
+
BridgeClient.onBack(() => {
|
|
1425
|
+
goToPreviousPage(); // pop one internal page, then reportNavigation(...) again
|
|
1426
|
+
});
|
|
1427
|
+
```
|
|
1428
|
+
|
|
1429
|
+
How it behaves:
|
|
1430
|
+
|
|
1431
|
+
- You reported `true` → the OS back button fires your `onBack` (go back one
|
|
1432
|
+
internal page). Nothing else happens — the app stays on Home.
|
|
1433
|
+
- You reported `false` (or never adopted this API) → the OS back button is
|
|
1434
|
+
ignored and the user stays on Home. **No regression if you skip it.**
|
|
1435
|
+
|
|
1436
|
+
Keep `reportNavigation` in sync with your router: the app caches the last value
|
|
1437
|
+
and reads it the instant back is pressed. A single choke point (call it at the
|
|
1438
|
+
end of your `render`/route function) is the simplest way to never miss a change.
|
|
1439
|
+
See `examples/client_demo/src/main.ts` for a worked home↔device example.
|
|
1440
|
+
|
|
1441
|
+
### Key catalog
|
|
1442
|
+
|
|
1443
|
+
These are the keys you can subscribe to / send. Payloads are fully typed in
|
|
1444
|
+
TypeScript; the table is the quick reference (see the
|
|
1445
|
+
[README](../README.md#key-catalog-strictly-typed) for exact shapes).
|
|
1446
|
+
|
|
1447
|
+
| Key | Payload (typed) | Direction |
|
|
1448
|
+
|---|---|---|
|
|
1449
|
+
| `userDevice` | device config / telemetry / user config arrays | native → you (also send to refresh) |
|
|
1450
|
+
| `deviceConnection` | `{ serialNo, isConnect }` | native → you (status) |
|
|
1451
|
+
| `deviceNotification` | `{ serialNo, isConnect, message, messageCode, isError }` | native → you |
|
|
1452
|
+
| `deviceLightSensors` | `{ serialNo, type, data }` — union on `type` (`'actlumus'` \| `'actlumus2'`); `data` has the spectrum plus the raw `light_sensor_*` channels | native → you |
|
|
1453
|
+
| `realTime` | `{ serialNo, success, value }` | both (command + state) |
|
|
1454
|
+
| `syncTime` | `{ serialNo, success }` | both |
|
|
1455
|
+
| `sensorLog` | paginated sensor log envelope | native → you |
|
|
1456
|
+
| `systemStatus` | `{ bluetooth, internet, permission, hasDevices, missingOptionalPermissions, missingEssentialPermissions, deviceSyncError }` | native → you (env status) |
|
|
1457
|
+
| `deleteMemory` | `{ serialNo, success }` | both |
|
|
1458
|
+
| `pingPong` | `{ data: 'ping' \| 'pong' }` | both |
|
|
1459
|
+
| `navigationBack` | `{}` (OS back pressed — via `onBack`) | native → you |
|
|
1460
|
+
| `language` | `{ deviceLocale, appLanguage, resolvedLanguage }` | native → you (language state) |
|
|
1461
|
+
|
|
1462
|
+
> **System back button.** Use the `reportNavigation(canGoBack)` /
|
|
1463
|
+
> `onBack(cb)` helpers (see [Handle the system back button](#handle-the-system-back-button))
|
|
1464
|
+
> rather than subscribing to `navigationBack` directly — `reportNavigation`
|
|
1465
|
+
> sends the `navigationState` command the app needs to route the back button.
|
|
1466
|
+
|
|
1467
|
+
**Permissions on `systemStatus`.** Two arrays of `AppPermission` names let you
|
|
1468
|
+
render permission prompts from your own UI:
|
|
1469
|
+
|
|
1470
|
+
- `missingEssentialPermissions` — **required** for the app to work; background
|
|
1471
|
+
collection stays off until granted. Render a **blocking "Required permissions"
|
|
1472
|
+
card** (e.g. `["bluetoothScan", "bluetoothConnect"]`).
|
|
1473
|
+
- `missingOptionalPermissions` — **recommended** (help background reliability);
|
|
1474
|
+
render a **dismissible "Recommended permissions" nudge** (e.g.
|
|
1475
|
+
`["notification", "backgroundLocation"]`).
|
|
1476
|
+
|
|
1477
|
+
Both are empty when nothing is missing. For the **required** card, wire the
|
|
1478
|
+
"Grant" button to `BridgeClient.requestPermission(name)` — it fires the **native
|
|
1479
|
+
in-app permission dialog** for that `AppPermission` (falls back to Settings only
|
|
1480
|
+
when the OS refuses the prompt). Pass a name straight from
|
|
1481
|
+
`missingEssentialPermissions` (e.g. `'bluetoothScan'`). There is no dedicated
|
|
1482
|
+
result key: the native side re-emits `systemStatus` once granted, so the card
|
|
1483
|
+
disappears when `missingEssentialPermissions` empties. `openSettings` remains
|
|
1484
|
+
available for the optional nudge (permissions not grantable by prompt on modern
|
|
1485
|
+
Android). See `examples/client_demo/src/render-home.ts` for a worked render.
|
|
1486
|
+
|
|
1487
|
+
### Translate your bundle — the `language` key
|
|
1488
|
+
|
|
1489
|
+
The app tells you **two** languages, and the distinction between them is the
|
|
1490
|
+
whole point of the channel:
|
|
1491
|
+
|
|
1492
|
+
```typescript
|
|
1493
|
+
BridgeClient.on(BRIDGE_KEYS.language, ({ deviceLocale, appLanguage, resolvedLanguage }) => {
|
|
1494
|
+
// Your precedence rule. This one honours an explicit choice, else the device.
|
|
1495
|
+
const lang = appLanguage ?? pickSupported(deviceLocale) ?? 'en';
|
|
1496
|
+
renderIn(lang);
|
|
1497
|
+
});
|
|
1498
|
+
```
|
|
1499
|
+
|
|
1500
|
+
- **`deviceLocale`** — the raw device locale as a BCP-47 tag (`"pt-BR"`,
|
|
1501
|
+
`"es-419"`, `"ja-JP"`), exactly as the OS reports it, region included.
|
|
1502
|
+
- **`appLanguage`** — the language the user explicitly picked in the app
|
|
1503
|
+
Settings (`"en" | "pt" | "de" | "fr"`), or **`null`** when they never picked
|
|
1504
|
+
one.
|
|
1505
|
+
- **`resolvedLanguage`** — the language the native chrome (splash, Settings,
|
|
1506
|
+
loading/error screens) is rendering right now.
|
|
1507
|
+
|
|
1508
|
+
**Why two raw values instead of one answer.** The app itself ships four
|
|
1509
|
+
languages and collapses everything else to English. Your bundle has no such
|
|
1510
|
+
limit — it may ship Spanish, Italian, Japanese. If we sent you only the resolved
|
|
1511
|
+
language, a device set to Spanish would reach you as `en` and you would render
|
|
1512
|
+
English while holding a Spanish bundle. The information would have been
|
|
1513
|
+
destroyed on our side before reaching you. So we send both and let you decide:
|
|
1514
|
+
|
|
1515
|
+
| `deviceLocale` | `appLanguage` | a reasonable decision |
|
|
1516
|
+
|---|---|---|
|
|
1517
|
+
| `es-ES` | `null` | render **Spanish** — you have it, we don't. Our chrome stays English while your content is Spanish, which is better, not worse |
|
|
1518
|
+
| `es-ES` | `"pt"` | render **Portuguese** — an explicit choice outranks the device |
|
|
1519
|
+
| `pt-BR` | `null` | render Portuguese |
|
|
1520
|
+
| `ja-JP` | `"de"` | render German (the explicit choice), or Japanese if you'd rather honour the device — your call, with both values in hand |
|
|
1521
|
+
|
|
1522
|
+
`appLanguage` is `null` when — and only when — the user never chose. It is never
|
|
1523
|
+
a fallback in disguise, so `null` is a real signal you can branch on. Use
|
|
1524
|
+
`resolvedLanguage` when you'd rather stay visually consistent with the native
|
|
1525
|
+
frame around your page than follow the device.
|
|
1526
|
+
|
|
1527
|
+
**When it arrives — and how to ask for it.** The app pushes this key on attach,
|
|
1528
|
+
again right after the handshake ack, and on every change in the app Settings, so
|
|
1529
|
+
a page already loaded re-renders without a restart. But the first push happens
|
|
1530
|
+
while the WebView is still being created, before your bundle exists, so **send
|
|
1531
|
+
the `language` command once after your handshake** to pull the current state:
|
|
1532
|
+
|
|
1533
|
+
```typescript
|
|
1534
|
+
BridgeClient.send('language', {}); // replies with a `language` event
|
|
1535
|
+
```
|
|
1536
|
+
|
|
1537
|
+
Without that pull, a first launch would show your default language until the
|
|
1538
|
+
user changed it in Settings. Subscribe first, then pull.
|
|
1539
|
+
|
|
1540
|
+
Do not read `navigator.language` instead: it follows the system, not the in-app
|
|
1541
|
+
choice, and you would end up in Spanish while the native chrome around you is in
|
|
1542
|
+
Portuguese.
|
|
1543
|
+
|
|
1544
|
+
**Older app versions.** There is no version negotiation. An app older than
|
|
1545
|
+
native `0.14.0` never emits this key, so the event simply never arrives — keep a
|
|
1546
|
+
default language for that case, and read `nativeVersion` from the handshake ack
|
|
1547
|
+
if you need to branch on it explicitly.
|
|
1548
|
+
|
|
1549
|
+
---
|
|
1550
|
+
|
|
1551
|
+
## 4. Develop in your browser (no app needed)
|
|
1552
|
+
|
|
1553
|
+
Build your page, then serve it with the harness:
|
|
1554
|
+
|
|
1555
|
+
```bash
|
|
1556
|
+
npx condor-mobile-bridge dev -i dist/index.html
|
|
1557
|
+
```
|
|
1558
|
+
|
|
1559
|
+
Open `http://localhost:5173`. Your page runs inside the same sandboxed iframe,
|
|
1560
|
+
shell, policy and CSP as in the app, and a simulated app answers your commands.
|
|
1561
|
+
Rebuild and reload — the harness re-reads the file.
|
|
1562
|
+
|
|
1563
|
+
Pick a **scenario** in the selector at the top (or with `--scenario`) to see
|
|
1564
|
+
what the real app does to your page:
|
|
1565
|
+
|
|
1566
|
+
- `cold-start` — the first device requests are lost while the app starts;
|
|
1567
|
+
- `no-devices` — an account with no device: the device list never comes;
|
|
1568
|
+
- `shared-serial` — two devices with the same serial number;
|
|
1569
|
+
- `offline` — no internet, history unavailable.
|
|
1570
|
+
|
|
1571
|
+
Your page is ready for a phone when it behaves in all of them. The page's
|
|
1572
|
+
console (`[condor dev]` lines) shows each command, reply and dropped request,
|
|
1573
|
+
and the dev panel in the corner lets you emit any event by hand.
|
|
1574
|
+
|
|
1575
|
+
> Opening `dist/index.html` directly in a browser does not work: there is no
|
|
1576
|
+
> shell around it, so `BridgeClient.handshake()` rejects at once with a message
|
|
1577
|
+
> pointing to the harness.
|
|
1578
|
+
|
|
1579
|
+
The [`examples/minimal-home`](../examples/minimal-home) example runs this way
|
|
1580
|
+
out of the box.
|
|
1581
|
+
|
|
1582
|
+
---
|
|
1583
|
+
|
|
1584
|
+
## 5. Bundle format & constraints
|
|
1585
|
+
|
|
1586
|
+
Your final artifact is a `.zip` containing exactly **one self-contained
|
|
1587
|
+
`index.html`** at the root. Why the constraints:
|
|
1588
|
+
|
|
1589
|
+
- The app injects your HTML via `iframe.srcdoc` with origin `null` and no base
|
|
1590
|
+
URL → **relative references don't resolve** (`<script src="app.js">`,
|
|
1591
|
+
`<img src="logo.png">` would break).
|
|
1592
|
+
- The app's Content-Security-Policy **blocks external fetches** → CDN scripts,
|
|
1593
|
+
Google Fonts, remote APIs called at load time won't work.
|
|
1594
|
+
|
|
1595
|
+
So the bundle must have:
|
|
1596
|
+
- ✅ One `index.html` at the zip root.
|
|
1597
|
+
- ✅ JS and CSS **inlined** into the HTML.
|
|
1598
|
+
- ✅ Images/icons/fonts as **`data:` URIs**.
|
|
1599
|
+
- ✅ Total size **≤ 2 MB** (zipped). Note base64 inflates media ~33%.
|
|
1600
|
+
- ❌ No external `https://` script/style/font references loaded at runtime.
|
|
1601
|
+
- ❌ No code-splitting / dynamic `import()` across chunks (see below).
|
|
1602
|
+
|
|
1603
|
+
### Single-chunk build
|
|
1604
|
+
|
|
1605
|
+
The bundler CLI is post-build and assumes **one JS + one CSS + assets**. If
|
|
1606
|
+
your framework code-splits, the split chunks won't resolve in the sandbox.
|
|
1607
|
+
Configure a single-file build:
|
|
1608
|
+
|
|
1609
|
+
- **Vite:** add [`vite-plugin-singlefile`](https://github.com/richardtallent/vite-plugin-singlefile),
|
|
1610
|
+
or set `build.rollupOptions.output.inlineDynamicImports: true` +
|
|
1611
|
+
`manualChunks: undefined`.
|
|
1612
|
+
- **Create React App:** avoid `React.lazy` / dynamic `import()` in routes so the
|
|
1613
|
+
build emits a single bundle.
|
|
1614
|
+
- **esbuild:** bundle to a single IIFE/ESM file (see the example's `build.mjs`).
|
|
1615
|
+
|
|
1616
|
+
---
|
|
1617
|
+
|
|
1618
|
+
## 6. Build the bundle
|
|
1619
|
+
|
|
1620
|
+
Once your build produces a `dist/index.html` (referencing a local `main.js`,
|
|
1621
|
+
CSS, images), run the CLI to inline everything and pack the zip:
|
|
1622
|
+
|
|
1623
|
+
```bash
|
|
1624
|
+
npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
|
|
1625
|
+
```
|
|
1626
|
+
|
|
1627
|
+
It inlines `<script src>`, `<link rel=stylesheet>`, CSS `url()`, and local
|
|
1628
|
+
`<img>`/icons into a single `index.html`, then zips it. Output:
|
|
1629
|
+
|
|
1630
|
+
```
|
|
1631
|
+
Built dist/bundle.zip
|
|
1632
|
+
entry: index.html (123.4 KB self-contained)
|
|
1633
|
+
bundleSize (zip): 45678 bytes
|
|
1634
|
+
bundleHash (zip): sha256-<hex>
|
|
1635
|
+
```
|
|
1636
|
+
|
|
1637
|
+
| Option | Description |
|
|
1638
|
+
|---|---|
|
|
1639
|
+
| `-i, --html <path>` | Input HTML. Required. |
|
|
1640
|
+
| `-o, --out <path>` | Output `.zip` (default `dist/whitelabel-bundle.zip`). |
|
|
1641
|
+
| `--max-bytes <n>` | Warning threshold (default 2 MB). |
|
|
1642
|
+
|
|
1643
|
+
The hash is deterministic — the same input always yields the same
|
|
1644
|
+
`bundleHash`. The CLI **warns** above 2 MB but the **app rejects** oversized
|
|
1645
|
+
bundles and falls back to a default screen, so keep under the limit.
|
|
1646
|
+
|
|
1647
|
+
---
|
|
1648
|
+
|
|
1649
|
+
## 7. Deliver
|
|
1650
|
+
|
|
1651
|
+
Send the resulting `dist/bundle.zip` through the publication channel Condor
|
|
1652
|
+
gave you (the admin upload). The backend validates structure, self-containment,
|
|
1653
|
+
and size on upload, and records the `bundleSize`/`bundleHash`. Once published,
|
|
1654
|
+
the app downloads, verifies, and renders your Home — for **every user of your
|
|
1655
|
+
company**, so test on a development test company first. See
|
|
1656
|
+
[Testing on a device](./device-testing.md).
|
|
1657
|
+
|
|
1658
|
+
---
|
|
1659
|
+
|
|
1660
|
+
## 8. Troubleshooting
|
|
1661
|
+
|
|
1662
|
+
| Symptom | Likely cause |
|
|
1663
|
+
|---|---|
|
|
1664
|
+
| Blank screen / nothing loads | Relative `<script src>` or external URL — must be inlined. Run the build CLI. |
|
|
1665
|
+
| Images missing | Referenced by path instead of `data:` URI — the CLI inlines local images; remote images are blocked. |
|
|
1666
|
+
| Works in browser, not in app | You relied on a network call or external asset at load time (CSP blocks it), or a code-split chunk didn't resolve. Go single-chunk + self-contained. |
|
|
1667
|
+
| Bundle rejected on upload / falls back | Over 2 MB, multiple files at root, or not self-contained. |
|
|
1668
|
+
| `handshake` rejects in the browser | The page is not inside the shell. Serve it with `npx condor-mobile-bridge dev`. |
|
|
1669
|
+
| Stuck on "loading devices" on the phone | The device request was lost or the account has no device. Use `requestUserDevices()` and test the `cold-start` and `no-devices` scenarios. |
|
|
1670
|
+
| A command does nothing | The command may not be allowed by the app policy; it's dropped silently. |
|
|
1671
|
+
|
|
1672
|
+
---
|
|
1673
|
+
|
|
1674
|
+
## Next steps
|
|
1675
|
+
|
|
1676
|
+
- Copy [`examples/minimal-home`](../examples/minimal-home) and start from there.
|
|
1677
|
+
- Full API reference: the [README](../README.md).
|
|
1678
|
+
|
|
1679
|
+
<!-- ===== docs/pilot-onboarding.md ===== -->
|
|
1680
|
+
|
|
1681
|
+
# White Label — Pilot Onboarding
|
|
1682
|
+
|
|
1683
|
+
Welcome! This is the fast path to building and shipping your **White Label
|
|
1684
|
+
Home** — the screen that runs inside the Condor app. You build a normal web app;
|
|
1685
|
+
we package and render it. No Flutter / Android / iOS needed.
|
|
1686
|
+
|
|
1687
|
+
For the full reference, see the [Client Guide](./client-guide.md). This page is
|
|
1688
|
+
the quick checklist to get a pilot through the whole loop.
|
|
1689
|
+
|
|
1690
|
+
## What you'll end up with
|
|
1691
|
+
|
|
1692
|
+
A self-contained `bundle.zip` that Condor publishes to your tenant — your Home
|
|
1693
|
+
renders inside the app, talking to the device through the Condor bridge.
|
|
1694
|
+
|
|
1695
|
+
## Who does what
|
|
1696
|
+
|
|
1697
|
+
| Condor provides | You build |
|
|
1698
|
+
|---|---|
|
|
1699
|
+
| The SDK (`@condorinst/mobile-bridge`), guide & example | Your Home web app (HTML/CSS/JS or React) |
|
|
1700
|
+
| The app that renders your bundle + the device bridge | The `bundle.zip` (via our CLI) |
|
|
1701
|
+
| The publish channel (admin upload) | Delivering the `.zip` to us |
|
|
1702
|
+
|
|
1703
|
+
## Prerequisites
|
|
1704
|
+
|
|
1705
|
+
- Node.js 18+
|
|
1706
|
+
- npm access (the package is public — no credentials needed)
|
|
1707
|
+
|
|
1708
|
+
## Step-by-step
|
|
1709
|
+
|
|
1710
|
+
### 1. Install the SDK
|
|
1711
|
+
```bash
|
|
1712
|
+
npm install @condorinst/mobile-bridge
|
|
1713
|
+
```
|
|
1714
|
+
Pin `"@condorinst/mobile-bridge": "^0.21.0"` (beta; auto-updates patches).
|
|
1715
|
+
|
|
1716
|
+
### 2. Start from the example
|
|
1717
|
+
Copy [`examples/minimal-home`](../examples/minimal-home) — it already wires the
|
|
1718
|
+
bridge, renders devices, and builds a bundle.
|
|
1719
|
+
|
|
1720
|
+
### 3. Build your Home
|
|
1721
|
+
Talk to the app via `BridgeClient`:
|
|
1722
|
+
```ts
|
|
1723
|
+
import { BridgeClient, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
|
|
1724
|
+
|
|
1725
|
+
BridgeClient.on(BRIDGE_KEYS.userDevice, (d) => render(d)); // listen first
|
|
1726
|
+
const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' }); // ack.nativeVersion = capabilities
|
|
1727
|
+
const r = await BridgeClient.requestUserDevices(); // resends until the list arrives
|
|
1728
|
+
if (r.status === 'timeout') renderEmptyState(); // no reply in 30 s
|
|
1729
|
+
BridgeClient.openSettings(); // open native settings
|
|
1730
|
+
```
|
|
1731
|
+
Never ask for the device list with a single `send('userDevice')`: the first
|
|
1732
|
+
request after the app starts is often lost, and an account with no device gets
|
|
1733
|
+
no reply. `AGENTS.md` has the full boot sequence.
|
|
1734
|
+
|
|
1735
|
+
### 4. Test in your browser (no app needed)
|
|
1736
|
+
```bash
|
|
1737
|
+
npx condor-mobile-bridge dev -i dist/index.html
|
|
1738
|
+
```
|
|
1739
|
+
Serves your page inside the same sandbox, shell and policy as the app, with a
|
|
1740
|
+
simulated app behind it. Check every scenario in the selector at the top:
|
|
1741
|
+
`default`, `cold-start`, `no-devices`, `shared-serial`, `offline`.
|
|
1742
|
+
|
|
1743
|
+
### 5. Build the bundle
|
|
1744
|
+
```bash
|
|
1745
|
+
npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
|
|
1746
|
+
```
|
|
1747
|
+
This inlines everything into a single self-contained `index.html` and zips it.
|
|
1748
|
+
It prints `bundleSize` + `bundleHash`.
|
|
1749
|
+
|
|
1750
|
+
### 5b. Validate before you deliver
|
|
1751
|
+
```bash
|
|
1752
|
+
npx condor-mobile-bridge validate -i dist/bundle.zip --strict
|
|
1753
|
+
```
|
|
1754
|
+
Applies the same gates the app applies on download, plus a lint for the APIs the
|
|
1755
|
+
sandbox and the CSP block at runtime. **A non-zero exit means do not deliver.**
|
|
1756
|
+
Errors are outright rejections by the app; warnings install fine and then
|
|
1757
|
+
misbehave on the device, which is harder to diagnose.
|
|
1758
|
+
|
|
1759
|
+
### 6. Deliver
|
|
1760
|
+
Send `dist/bundle.zip`, with the `bundleSize` and `bundleHash` printed by
|
|
1761
|
+
`build`, through the channel Condor gave you. We publish it to your company —
|
|
1762
|
+
first to a **development test company**, because a publish reaches every user
|
|
1763
|
+
of the company.
|
|
1764
|
+
|
|
1765
|
+
### 7. Verify on a device
|
|
1766
|
+
Condor publishes → the app downloads, validates and renders your Home the next
|
|
1767
|
+
time it comes to the foreground. [Testing on a device](./device-testing.md)
|
|
1768
|
+
explains the environments, how to inspect the WebView, and where errors go.
|
|
1769
|
+
|
|
1770
|
+
## Hard rules (or the app rejects the bundle)
|
|
1771
|
+
|
|
1772
|
+
- ✅ Exactly **one `index.html`** at the zip root
|
|
1773
|
+
- ✅ JS/CSS **inline**, media as **`data:` URIs**, no external/relative refs loaded at runtime
|
|
1774
|
+
- ✅ **≤ 2 MB** (zipped)
|
|
1775
|
+
- ✅ **Single-chunk build** (no code-splitting): Vite → `vite-plugin-singlefile`; CRA → no `React.lazy` / dynamic `import()`
|
|
1776
|
+
|
|
1777
|
+
The CLI handles the inlining/zipping for you — just keep your build single-file.
|
|
1778
|
+
|
|
1779
|
+
## Reference
|
|
1780
|
+
|
|
1781
|
+
- [Client Guide](./client-guide.md) — full walkthrough (bridge API, key catalog, dev harness)
|
|
1782
|
+
- [Testing on a device](./device-testing.md) — from a published bundle to an inspected WebView
|
|
1783
|
+
- [Troubleshooting](./troubleshooting.md) — starts from the symptom
|
|
1784
|
+
- [`examples/minimal-home`](../examples/minimal-home) — runnable minimal client
|
|
1785
|
+
- [README](../README.md) — API reference
|
|
1786
|
+
|
|
1787
|
+
## Pilot checklist
|
|
1788
|
+
|
|
1789
|
+
- [ ] SDK installed (`npm i @condorinst/mobile-bridge`)
|
|
1790
|
+
- [ ] Example runs in the harness (`npx condor-mobile-bridge dev`), in every scenario
|
|
1791
|
+
- [ ] Home built with `BridgeClient` (handshake + at least one key + one command)
|
|
1792
|
+
- [ ] Constraints respected (1 index.html, ≤2 MB, self-contained, single-chunk)
|
|
1793
|
+
- [ ] `bundle.zip` generated by the CLI, and `validate --strict` exits 0
|
|
1794
|
+
- [ ] `.zip` delivered to Condor
|
|
1795
|
+
- [ ] Home verified on a device
|
|
1796
|
+
- [ ] Feedback shared with Condor
|
|
1797
|
+
</content>
|
|
1798
|
+
|
|
1799
|
+
<!-- ===== examples/minimal-home/src/main.ts ===== -->
|
|
1800
|
+
|
|
1801
|
+
```ts
|
|
1802
|
+
/**
|
|
1803
|
+
* Minimal Canvas Home — the reference boot sequence for @condorinst/mobile-bridge.
|
|
1804
|
+
*
|
|
1805
|
+
* This is the kind of code a Canvas client writes: it runs inside the app's
|
|
1806
|
+
* sandboxed iframe (origin `null`, no network, no storage) and talks to the
|
|
1807
|
+
* native side only through `BridgeClient`.
|
|
1808
|
+
*
|
|
1809
|
+
* Develop it in the browser with the harness, which runs it under the same
|
|
1810
|
+
* sandbox and shell as the app:
|
|
1811
|
+
*
|
|
1812
|
+
* npm run build
|
|
1813
|
+
* npx condor-mobile-bridge dev -i dist/index.html --scenario cold-start
|
|
1814
|
+
*
|
|
1815
|
+
* It must work in every scenario (`default`, `cold-start`, `no-devices`,
|
|
1816
|
+
* `shared-serial`, `offline`) before it goes to a device.
|
|
1817
|
+
*/
|
|
1818
|
+
import {
|
|
1819
|
+
BRIDGE_KEYS,
|
|
1820
|
+
BridgeClient,
|
|
1821
|
+
type DeviceConfigItem,
|
|
1822
|
+
type DeviceTelemetryItem,
|
|
1823
|
+
type UserDevicePayload,
|
|
1824
|
+
versionAtLeast,
|
|
1825
|
+
} from '@condorinst/mobile-bridge';
|
|
1826
|
+
|
|
1827
|
+
interface DeviceRow {
|
|
1828
|
+
deviceId: number;
|
|
1829
|
+
serialNo: number;
|
|
1830
|
+
name: string;
|
|
1831
|
+
battery: number | null;
|
|
1832
|
+
connected: boolean;
|
|
1833
|
+
realTimeOn: boolean;
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
type Phase = 'loading' | 'ready' | 'empty' | 'outside-shell';
|
|
1837
|
+
|
|
1838
|
+
const state = {
|
|
1839
|
+
phase: 'loading' as Phase,
|
|
1840
|
+
/** `null` = unknown (older shell, or the native side did not answer). */
|
|
1841
|
+
nativeVersion: null as string | null,
|
|
1842
|
+
/** Whether the last timeout came with `systemStatus.hasDevices === false`. */
|
|
1843
|
+
probablyNoDevices: false,
|
|
1844
|
+
devices: new Map<number, DeviceRow>(),
|
|
1845
|
+
};
|
|
1846
|
+
|
|
1847
|
+
declare global {
|
|
1848
|
+
interface Window {
|
|
1849
|
+
__home: {
|
|
1850
|
+
retry: () => void;
|
|
1851
|
+
settings: () => void;
|
|
1852
|
+
toggle: (deviceId: number) => void;
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// ── Data ───────────────────────────────────────────────────────────────
|
|
1858
|
+
|
|
1859
|
+
/**
|
|
1860
|
+
* Joins the three arrays of `userDevice`. `deviceId` is the key: an ActLumus
|
|
1861
|
+
* and an ActLumus Plus may share a `serialNo`. Fall back to `serialNo` only for
|
|
1862
|
+
* an item without `deviceId` whose `serialNo` is unique.
|
|
1863
|
+
*/
|
|
1864
|
+
function telemetryFor(
|
|
1865
|
+
cfg: DeviceConfigItem,
|
|
1866
|
+
payload: UserDevicePayload,
|
|
1867
|
+
): DeviceTelemetryItem | undefined {
|
|
1868
|
+
const byId = payload.devicesTelemetry.find((t) => t.deviceId === cfg.deviceId);
|
|
1869
|
+
if (byId) return byId;
|
|
1870
|
+
const sameSerial = payload.devicesConfig.filter((c) => c.serialNo === cfg.serialNo);
|
|
1871
|
+
if (sameSerial.length !== 1) return undefined;
|
|
1872
|
+
return payload.devicesTelemetry.find(
|
|
1873
|
+
(t) => t.deviceId === undefined && t.serialNo === cfg.serialNo,
|
|
1874
|
+
);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
function applyDevices(payload: UserDevicePayload): void {
|
|
1878
|
+
const next = new Map<number, DeviceRow>();
|
|
1879
|
+
for (const cfg of payload.devicesConfig) {
|
|
1880
|
+
const telemetry = telemetryFor(cfg, payload);
|
|
1881
|
+
const previous = state.devices.get(cfg.deviceId);
|
|
1882
|
+
next.set(cfg.deviceId, {
|
|
1883
|
+
deviceId: cfg.deviceId,
|
|
1884
|
+
serialNo: cfg.serialNo,
|
|
1885
|
+
name: telemetry?.deviceName ?? `Device ${cfg.serialNo}`,
|
|
1886
|
+
battery: telemetry?.batteryLevel ?? null,
|
|
1887
|
+
connected: previous?.connected ?? false,
|
|
1888
|
+
realTimeOn: previous?.realTimeOn ?? false,
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
state.devices = next;
|
|
1892
|
+
state.phase = next.size > 0 ? 'ready' : 'empty';
|
|
1893
|
+
render();
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
/**
|
|
1897
|
+
* `deviceId` in commands needs nativeVersion >= 0.17.0; `serialNo` always goes.
|
|
1898
|
+
* Below that, a shared serial cannot be addressed at all.
|
|
1899
|
+
*/
|
|
1900
|
+
function address(d: DeviceRow): { serialNo: number; deviceId?: number } {
|
|
1901
|
+
return versionAtLeast(state.nativeVersion, '0.17.0')
|
|
1902
|
+
? { serialNo: d.serialNo, deviceId: d.deviceId }
|
|
1903
|
+
: { serialNo: d.serialNo };
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
/** Events carry `deviceId` only when the serial is shared; resolve both ways. */
|
|
1907
|
+
function findRow(serialNo: number, deviceId?: number): DeviceRow | undefined {
|
|
1908
|
+
if (deviceId !== undefined) return state.devices.get(deviceId);
|
|
1909
|
+
return [...state.devices.values()].find((d) => d.serialNo === serialNo);
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// ── Render ─────────────────────────────────────────────────────────────
|
|
1913
|
+
|
|
1914
|
+
function esc(value: string): string {
|
|
1915
|
+
return value.replace(
|
|
1916
|
+
/[&<>"]/g,
|
|
1917
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] as string,
|
|
1918
|
+
);
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
function render(): void {
|
|
1922
|
+
const el = document.getElementById('app');
|
|
1923
|
+
if (!el) return;
|
|
1924
|
+
el.innerHTML = `
|
|
1925
|
+
<header class="hero">
|
|
1926
|
+
<h1>My Canvas Home</h1>
|
|
1927
|
+
<p class="status">${state.nativeVersion ? `app ${esc(state.nativeVersion)}` : ''}</p>
|
|
1928
|
+
</header>
|
|
1929
|
+
<div class="toolbar">
|
|
1930
|
+
<button onclick="__home.retry()">Refresh</button>
|
|
1931
|
+
<button onclick="__home.settings()">Settings</button>
|
|
1932
|
+
</div>
|
|
1933
|
+
<main>${body()}</main>
|
|
1934
|
+
`;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
function body(): string {
|
|
1938
|
+
switch (state.phase) {
|
|
1939
|
+
case 'outside-shell':
|
|
1940
|
+
return '<p class="empty">Open this page with <code>npx condor-mobile-bridge dev</code>.</p>';
|
|
1941
|
+
case 'loading':
|
|
1942
|
+
return '<p class="empty">Loading your devices…</p>';
|
|
1943
|
+
case 'empty':
|
|
1944
|
+
return `<p class="empty">${
|
|
1945
|
+
state.probablyNoDevices
|
|
1946
|
+
? 'No device is linked to your account yet.'
|
|
1947
|
+
: 'Could not load your devices.'
|
|
1948
|
+
}</p><p class="empty"><button onclick="__home.retry()">Try again</button></p>`;
|
|
1949
|
+
case 'ready':
|
|
1950
|
+
return [...state.devices.values()].map(renderCard).join('');
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
function renderCard(d: DeviceRow): string {
|
|
1955
|
+
return `
|
|
1956
|
+
<section class="card">
|
|
1957
|
+
<div class="card-head">
|
|
1958
|
+
<span class="name">${esc(d.name)} <small>#${d.serialNo}</small></span>
|
|
1959
|
+
<span class="badge ${d.connected ? 'on' : 'off'}">${
|
|
1960
|
+
d.connected ? '● connected' : '○ disconnected'
|
|
1961
|
+
}</span>
|
|
1962
|
+
</div>
|
|
1963
|
+
<p class="meta">Battery: ${d.battery === null ? '—' : `${Math.round(d.battery)}%`}</p>
|
|
1964
|
+
<div class="actions">
|
|
1965
|
+
<button onclick="__home.toggle(${d.deviceId})">${
|
|
1966
|
+
d.realTimeOn ? 'Stop real-time' : 'Start real-time'
|
|
1967
|
+
}</button>
|
|
1968
|
+
</div>
|
|
1969
|
+
</section>
|
|
1970
|
+
`;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
// ── Subscribers (native → iframe) — before any command ─────────────────
|
|
1974
|
+
|
|
1975
|
+
// Roster changes arrive here too, not only replies to requestUserDevices().
|
|
1976
|
+
BridgeClient.on(BRIDGE_KEYS.userDevice, applyDevices);
|
|
1977
|
+
|
|
1978
|
+
BridgeClient.on(BRIDGE_KEYS.deviceConnection, (payload) => {
|
|
1979
|
+
const d = findRow(payload.serialNo, payload.deviceId);
|
|
1980
|
+
if (!d) return;
|
|
1981
|
+
d.connected = payload.isConnect;
|
|
1982
|
+
render();
|
|
1983
|
+
});
|
|
1984
|
+
|
|
1985
|
+
BridgeClient.on(BRIDGE_KEYS.realTime, (payload) => {
|
|
1986
|
+
const d = findRow(payload.serialNo, payload.deviceId);
|
|
1987
|
+
if (!d || !payload.success) return;
|
|
1988
|
+
d.realTimeOn = payload.value;
|
|
1989
|
+
render();
|
|
1990
|
+
});
|
|
1991
|
+
|
|
1992
|
+
// ── Actions (iframe → native) ───────────────────────────────────────────
|
|
1993
|
+
|
|
1994
|
+
async function loadDevices(getFromServer: boolean): Promise<void> {
|
|
1995
|
+
const result = await BridgeClient.requestUserDevices({ getFromServer });
|
|
1996
|
+
if (result.status === 'ok') return; // applyDevices already rendered it
|
|
1997
|
+
state.probablyNoDevices = result.hasDevices === false;
|
|
1998
|
+
state.phase = 'empty';
|
|
1999
|
+
render();
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
window.__home = {
|
|
2003
|
+
retry: () => {
|
|
2004
|
+
state.phase = 'loading';
|
|
2005
|
+
render();
|
|
2006
|
+
void loadDevices(true);
|
|
2007
|
+
},
|
|
2008
|
+
settings: () => BridgeClient.openSettings(),
|
|
2009
|
+
toggle: (deviceId) => {
|
|
2010
|
+
const d = state.devices.get(deviceId);
|
|
2011
|
+
if (!d) return;
|
|
2012
|
+
BridgeClient.send('realTime', { ...address(d), value: !d.realTimeOn });
|
|
2013
|
+
},
|
|
2014
|
+
};
|
|
2015
|
+
|
|
2016
|
+
// ── Boot ────────────────────────────────────────────────────────────────
|
|
2017
|
+
|
|
2018
|
+
async function boot(): Promise<void> {
|
|
2019
|
+
render();
|
|
2020
|
+
try {
|
|
2021
|
+
const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
|
|
2022
|
+
state.nativeVersion = ack.nativeVersion; // branch on this; the accepted flag is always true
|
|
2023
|
+
} catch (err) {
|
|
2024
|
+
console.error('[home] handshake failed:', err);
|
|
2025
|
+
state.phase = 'outside-shell';
|
|
2026
|
+
render();
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
await loadDevices(false);
|
|
2030
|
+
for (const d of state.devices.values()) {
|
|
2031
|
+
// `add` subscribes to the connection state of one device; it pairs nothing.
|
|
2032
|
+
BridgeClient.send('deviceConnection', { type: 'add', ...address(d) });
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
if (document.readyState === 'loading') {
|
|
2037
|
+
document.addEventListener('DOMContentLoaded', () => void boot());
|
|
2038
|
+
} else {
|
|
2039
|
+
void boot();
|
|
2040
|
+
}
|
|
2041
|
+
```
|