@condorinst/mobile-bridge 0.18.0 → 0.19.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.
@@ -0,0 +1,209 @@
1
+ # Agent guide — building a Canvas client
2
+
3
+ A working procedure for an AI agent (or a developer) building the client bundle
4
+ that renders as the Home of a Condor Canvas app.
5
+
6
+ Read `AGENTS.md` first — it is the contract in one screen. This document is the
7
+ procedure: what to do, in what order, and what to check at each stage.
8
+
9
+ ## What you are building
10
+
11
+ One `.zip` containing one self-contained `index.html`. Condor's app downloads
12
+ it, verifies its SHA-256, and renders it inside a sandboxed iframe as the Home
13
+ screen. Login, settings, Bluetooth and background collection are the app's job.
14
+ Yours is what the person sees when the app opens.
15
+
16
+ You are not building a website. You have no storage, no network, and no origin.
17
+ Everything you display comes through the bridge, and everything you hold lives
18
+ in memory until the next reload.
19
+
20
+ ## Stage 0 — set up so you can see what you are doing
21
+
22
+ ```bash
23
+ mkdir my-home && cd my-home
24
+ npm init -y
25
+ npm install @condorinst/mobile-bridge
26
+ npm install -D esbuild typescript
27
+ ```
28
+
29
+ Copy `examples/minimal-home/` from the package as your starting point — it is a
30
+ complete, working client in about 160 lines, with a build script that produces
31
+ a single-file bundle:
32
+
33
+ ```bash
34
+ cp -r node_modules/@condorinst/mobile-bridge/examples/minimal-home/. .
35
+ npm install
36
+ ```
37
+
38
+ Pin the SDK to the minor you are building against. Below `1.0.0`, a minor can
39
+ change the contract — `^0.19.0` gives you patches only, which is what you want.
40
+
41
+ **Open `dist/index.html` in a browser.** With no native host detected, the SDK
42
+ activates mock mode and installs a dev panel in the corner that lets you emit
43
+ native events by hand. This is your entire development loop; you do not need
44
+ the app to build a Home.
45
+
46
+ Remember that in mock mode `nativeVersion` is `null`. That is not a bug in the
47
+ mock — it is the same value you get from a real app whose shell is older than
48
+ 0.6.0, so your code has to handle it either way.
49
+
50
+ ## Stage 1 — render before you connect
51
+
52
+ Write the screen so it paints correctly with **no data at all**: empty roster,
53
+ unknown status, default language. Then fill it as events arrive.
54
+
55
+ This is not a nicety. Events are emitted on attach, before your bundle exists,
56
+ and again after the handshake — so there is always a window where you have
57
+ nothing. A Home whose first paint depends on an event shows a blank screen on
58
+ every cold start.
59
+
60
+ ## Stage 2 — handshake, and learn what this device can do
61
+
62
+ ```ts
63
+ import { BridgeClient, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
64
+
65
+ const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
66
+
67
+ const native = ack.nativeVersion; // string | null
68
+ const has = (min: string) => native !== null && cmp(native, min) >= 0;
69
+
70
+ const canReadRawChannels = has('0.16.0');
71
+ const canQueryTimeRange = has('0.15.0');
72
+ const hasLanguageKey = has('0.14.0');
73
+ ```
74
+
75
+ Write `cmp` as a plain numeric comparison of the three segments. Do not compare
76
+ version strings lexically — `'0.9.0' > '0.16.0'` is `true` as strings and false
77
+ as versions.
78
+
79
+ **Do not branch on `ack.accepted`.** It is always `true`; the native side never
80
+ refuses a handshake. Anything you gate on it is ungated.
81
+
82
+ ## Stage 3 — subscribe, then ask
83
+
84
+ Subscribe before sending the command that triggers the reply. The reply can
85
+ arrive before your next line runs.
86
+
87
+ ```ts
88
+ BridgeClient.on(BRIDGE_KEYS.userDevice, (p) => { setDevices(p); render(); });
89
+ BridgeClient.on(BRIDGE_KEYS.systemStatus, (s) => { setStatus(s); render(); });
90
+ BridgeClient.on(BRIDGE_KEYS.deviceConnection, (c) => { setConnected(c); render(); });
91
+
92
+ BridgeClient.send('userDevice', { getFromServer: false });
93
+ ```
94
+
95
+ `on` returns an unsubscribe function. If your UI has views that mount and
96
+ unmount, call it — otherwise you accumulate handlers that all re-render.
97
+
98
+ ## Stage 4 — handle the environment honestly
99
+
100
+ `systemStatus` tells you why things are not working. Surface it; do not swallow
101
+ it.
102
+
103
+ ```ts
104
+ if (status.missingEssentialPermissions.length > 0) {
105
+ // Background collection is OFF. This is blocking — say so.
106
+ renderBlockingCard(status.missingEssentialPermissions, (name) =>
107
+ BridgeClient.requestPermission(name));
108
+ } else if (!status.bluetooth) {
109
+ renderNotice('Bluetooth is off — devices cannot be reached.');
110
+ }
111
+ ```
112
+
113
+ There is no permission-result event. After the person answers the OS prompt,
114
+ the native side re-emits `systemStatus`; your card disappears because the array
115
+ emptied. If you wait for a dedicated ack, you wait forever.
116
+
117
+ ## Stage 5 — real-time, if you need it
118
+
119
+ Real-time is **off by default**, per device. Turn it on, wait for the ack, and
120
+ only then expect the stream.
121
+
122
+ ```ts
123
+ BridgeClient.on(BRIDGE_KEYS.realTime, (ack) => setRealTime(ack.serialNo, ack.value));
124
+ BridgeClient.on(BRIDGE_KEYS.deviceLightSensors, (p) => {
125
+ if (p.type === 'actlumus2') { /* 14 channels */ } else { /* 10 channels */ }
126
+ });
127
+
128
+ BridgeClient.send('realTime', { serialNo, value: true });
129
+ ```
130
+
131
+ `serialNo` is a **number**. A string is dropped in silence.
132
+
133
+ In a raw channel, `null` means the channel does not exist on that hardware
134
+ generation. It does not mean zero — `0.0` is a valid reading. Coercing `null`
135
+ to `0` invents data, and has previously led to a confident, wrong conclusion
136
+ about a device.
137
+
138
+ ## Stage 6 — history, if you need it
139
+
140
+ ```ts
141
+ const page = await BridgeClient.fetchSensorLog({
142
+ requestId: crypto.randomUUID(),
143
+ serialNo,
144
+ limit: 200,
145
+ });
146
+ if (!page.success) return showError(page.error); // resolves, does not reject
147
+ renderLogs(page.logs ?? []);
148
+ ```
149
+
150
+ Timestamps are **device-local**. Build ranges with local `Date` getters, never
151
+ `toISOString()` — converting to UTC shifts every series by the timezone offset
152
+ and produces data that looks entirely plausible.
153
+
154
+ Paginate with the cursors exactly as received. They are opaque.
155
+
156
+ ## Stage 7 — back button
157
+
158
+ ```ts
159
+ BridgeClient.onBack(() => goBackOnePage());
160
+ // call this on every internal navigation, not once at startup
161
+ BridgeClient.reportNavigation(hasInternalHistory);
162
+ ```
163
+
164
+ If you never report `true`, the OS back button stays with the app — which is
165
+ the correct behaviour for a single-screen Home.
166
+
167
+ ## Stage 8 — build and validate
168
+
169
+ ```bash
170
+ npm run build # your bundler → dist/index.html, single chunk
171
+ npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
172
+ npx condor-mobile-bridge validate -i dist/bundle.zip --strict
173
+ ```
174
+
175
+ `validate` applies the gates the app applies plus a lint for APIs the sandbox
176
+ blocks. A non-zero exit is a build failure. Errors mean the app would reject
177
+ the bundle outright; warnings mean it installs and then misbehaves on the
178
+ device, which is worse to debug.
179
+
180
+ Validation is textual, so it cannot see through minification and it cannot know
181
+ whether you checked `nativeVersion` before using a capability. A clean run is
182
+ necessary, not sufficient.
183
+
184
+ ## Stage 9 — before you call it done
185
+
186
+ - [ ] `validate --strict` exits 0.
187
+ - [ ] First paint works with no events received.
188
+ - [ ] Every capability past the baseline is guarded by a `nativeVersion` check,
189
+ and the `null` branch renders something sensible.
190
+ - [ ] No spinner can hang forever — the three confirmable commands
191
+ (`syncTime`, `deleteMemory`, `logout`) may never ack, because the person
192
+ can decline.
193
+ - [ ] No `localStorage`, no `fetch`, no `eval`. Not even behind a `try`.
194
+ - [ ] `serialNo` is a number everywhere.
195
+ - [ ] Errors are reported through `BridgeClient.send('jsError', …)`, the only
196
+ channel out.
197
+ - [ ] Tested in the browser mock with `nativeVersion === null`.
198
+
199
+ ## Delivery
200
+
201
+ Hand Condor the `.zip` along with its size and `sha256-<hex>` hash, both printed
202
+ by the `build` command. The app verifies the hash against the manifest and
203
+ rejects any mismatch. `docs/pilot-onboarding.md` has the full checklist.
204
+
205
+ ## When something does not work
206
+
207
+ `docs/troubleshooting.md`, which starts from the symptom. The most common one
208
+ is that a command did nothing and said nothing — there are six distinct causes
209
+ for that, and they are enumerated there.
@@ -0,0 +1,249 @@
1
+ # Bridge contract
2
+
3
+ The normative reference for everything the client can observe and everything it
4
+ can ask for. Payload *shapes* are canonical in `dist/index.d.ts` — your editor
5
+ shows them on hover. This document states what a type declaration cannot: what
6
+ is reachable, what needs human consent, what fails in silence, and which native
7
+ version carries which capability.
8
+
9
+ A machine-readable form of this document lives at
10
+ `contract/bridge-contract.json`, importable as `@condorinst/mobile-bridge/contract`.
11
+
12
+ ## Topology
13
+
14
+ ```
15
+ your bundle (iframe, sandbox="allow-scripts", origin null)
16
+ | window.postMessage, schema-validated both ways
17
+ Condor shell (top-level document, opaque origin)
18
+ | native channel, nonce-stamped
19
+ native app (Flutter)
20
+ ```
21
+
22
+ You never talk to the native side directly — the shell deletes the native
23
+ globals before your bundle runs. Use `BridgeClient`; it speaks the shell
24
+ protocol for you.
25
+
26
+ ## Versions and capability detection
27
+
28
+ Four numbers coexist. Only one tells you what the device can do.
29
+
30
+ | Number | Current | What it means |
31
+ | --- | --- | --- |
32
+ | `nativeVersion` | `0.16.0` | **The capability signal.** The bridge schema the app on this device implements. |
33
+ | `shellVersion` | `0.6.0` | The Condor shell wrapping your iframe. |
34
+ | client protocol | `0.5.0` | The postMessage protocol `BridgeClient` speaks. |
35
+ | package | `0.19.0` | The npm version you compiled against. |
36
+
37
+ ```ts
38
+ const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
39
+ // { shellVersion: string, nativeVersion: string | null, accepted: boolean }
40
+ ```
41
+
42
+ Two traps:
43
+
44
+ **`accepted` is always `true`.** There is no version negotiation and the native
45
+ side never refuses. Branching on it is branching on a constant.
46
+
47
+ **`nativeVersion` can be `null`.** In a plain browser (mock mode), and in
48
+ bundles compiled against SDK ≤ 0.16.x, where the shell did not relay it. Treat
49
+ `null` as "assume only the baseline" and keep a working default. Your browser
50
+ development environment always hits this path, so it is not a rare case — it is
51
+ the one you see most.
52
+
53
+ ### Capability matrix
54
+
55
+ | Capability | Requires |
56
+ | --- | --- |
57
+ | Baseline: devices, real-time, acks, `systemStatus`, `sensorLog` | any |
58
+ | `language` key and the `language` pull command | `nativeVersion` ≥ 0.14.0 |
59
+ | `sensorLog` accepts `startTime` / `endTime` | `nativeVersion` ≥ 0.15.0 |
60
+ | `deviceLightSensors` carries raw `light_sensor_*` channels, and the `actlumus2` variant | `nativeVersion` ≥ 0.16.0 |
61
+ | `nativeVersion` reaches the client at all | `shellVersion` ≥ 0.6.0 |
62
+
63
+ `CHANGELOG.md` is the precise record of when each of these landed.
64
+
65
+ ## Observable keys
66
+
67
+ Thirteen keys exist; **twelve are reachable** by a sandboxed client. Subscribe
68
+ with `BridgeClient.on(key, cb)`, which returns an unsubscribe function.
69
+
70
+ | Key | Payload type | Emitted when |
71
+ | --- | --- | --- |
72
+ | `pingPong` | `PingPongPayload` | reply to the `ping` command |
73
+ | `userDevice` | `UserDevicePayload` | reply to `userDevice`, and on roster changes |
74
+ | `deviceLightSensors` | `DeviceLightSensorsPayload` | real-time stream, only while `realTime` is on for that device |
75
+ | `deviceConnection` | `DeviceConnectionPayload` | a device connects or disconnects |
76
+ | `deviceNotification` | `DeviceNotificationPayload` | device-level notice, including errors |
77
+ | `realTime` | `RealTimeAckPayload` | ack of `realTime` |
78
+ | `syncTime` | `SyncTimeAckPayload` | ack of `syncTime` |
79
+ | `deleteMemory` | `DeleteMemoryAckPayload` | ack of `deleteMemory` |
80
+ | `sensorLog` | `SensorLogPayload` | reply to `sensorLog`; correlate by `requestId` |
81
+ | `systemStatus` | `SystemStatusPayload` | on attach, again right after handshake, and on every change |
82
+ | `language` | `LanguagePayload` | same cadence as `systemStatus`; needs native ≥ 0.14.0 |
83
+ | `navigationBack` | `NavigationBackPayload` (empty) | OS back pressed while you had reported `canGoBack: true` |
84
+ | `dfu` | — | **not reachable.** Firmware traffic. Subscribing is answered with `nack: not-read-only`. |
85
+
86
+ ### Emission cadence you must design around
87
+
88
+ `systemStatus` and `language` are emitted **on attach, before your bundle
89
+ exists**, then again right after the handshake. The first emission is lost to
90
+ you; the second is the one you receive. This is why `language` also exists as a
91
+ pull command — a client that mounts late can ask.
92
+
93
+ Render a skeleton synchronously and fill it as events arrive. Do not make first
94
+ paint depend on any event.
95
+
96
+ ### `deviceLightSensors` is a discriminated union
97
+
98
+ ```ts
99
+ if (payload.type === 'actlumus2') {
100
+ // 14 wavelength channels, 395–800 nm, plus raw light_sensor_f1..f8 / clear / nir
101
+ } else {
102
+ // 'actlumus': 10 channels, 390–750 nm
103
+ }
104
+ ```
105
+
106
+ A `null` in a raw channel means **that channel does not exist on this hardware
107
+ generation** — never "the reading was zero". `0.0` is a valid reading. Do not
108
+ collapse `null` to `0`: doing so fabricates data, and it has produced wrong
109
+ conclusions before.
110
+
111
+ ### `systemStatus` carries the permission state
112
+
113
+ ```ts
114
+ {
115
+ bluetooth: boolean; internet: boolean; permission: boolean; hasDevices: boolean;
116
+ missingEssentialPermissions: string[]; // e.g. ["bluetoothScan", "bluetoothConnect"]
117
+ missingOptionalPermissions: string[]; // e.g. ["notification", "backgroundLocation"]
118
+ deviceSyncError: Record<number, string>; // serialNo → message, already localized
119
+ }
120
+ ```
121
+
122
+ `missingEssentialPermissions` being non-empty means background collection is
123
+ off. Render a blocking card; wire its button to
124
+ `BridgeClient.requestPermission(name)` with a name taken from that array.
125
+
126
+ ## Commands
127
+
128
+ `BridgeClient.send(command, args)` is **fire-and-forget**: no return value, no
129
+ exception, no delivery confirmation. Replies arrive on the key in the last
130
+ column.
131
+
132
+ | Command | Arguments | Confirmation | Replies on |
133
+ | --- | --- | --- | --- |
134
+ | `ping` | — | no | `pingPong` |
135
+ | `userDevice` | `{ getFromServer?: boolean }` | no | `userDevice` |
136
+ | `realTime` | `{ serialNo: number, value: boolean }` | no | `realTime` |
137
+ | `deviceConnection` | `{ type: 'add' \| 'remove', serialNo: number }` | no | `deviceConnection` |
138
+ | `sensorLog` | see below | no | `sensorLog` |
139
+ | `openSettings` | — | no | — |
140
+ | `requestPermission` | `{ permission: string }` | no | — (re-emits `systemStatus`) |
141
+ | `setStatusBarColor` | `{ color: '#RRGGBB' }` | no | — |
142
+ | `navigationState` | `{ canGoBack: boolean }` | no | — |
143
+ | `language` | — | no | `language` |
144
+ | `jsError` | `{ message, stack?, source?, lineno?, colno? }` | no | — |
145
+ | `syncTime` | `{ serialNo: number }` | **yes** | `syncTime` |
146
+ | `deleteMemory` | `{ serialNo: number }` | **yes** | `deleteMemory` |
147
+ | `logout` | — | **yes** | — |
148
+ | `dfu` | — | denied | — |
149
+
150
+ `serialNo` is an integer. Passing a string makes the native side drop the
151
+ message in silence.
152
+
153
+ ### Commands that need human confirmation
154
+
155
+ `syncTime`, `deleteMemory` and `logout` open a modal owned by the Condor shell.
156
+ If the person declines, the shell answers `nack` with reason `user-rejected`,
157
+ which reaches you only as a `console.warn`. **The ack simply never arrives.**
158
+
159
+ Design for that: show an optimistic state only if you can undo it, and put a
160
+ timeout on any spinner.
161
+
162
+ ### Typed helpers
163
+
164
+ Prefer these over raw `send` — they add correlation, timeouts and types:
165
+
166
+ ```ts
167
+ BridgeClient.fetchSensorLog(query, { timeoutMs }) // correlates requestId, 10 s default
168
+ BridgeClient.requestPermission(name)
169
+ BridgeClient.reportNavigation(canGoBack)
170
+ BridgeClient.onBack(cb)
171
+ BridgeClient.openSettings()
172
+ ```
173
+
174
+ ### `sensorLog` in detail
175
+
176
+ ```ts
177
+ const page = await BridgeClient.fetchSensorLog({
178
+ requestId: crypto.randomUUID(),
179
+ serialNo,
180
+ bodyPart: 'wrist', // or 'necklace'; omit for no filter
181
+ limit: 200, // 1..1000, backend default 10
182
+ startTime: '2026-09-01 00:00:00', // native >= 0.15.0
183
+ endTime: '2026-09-02 00:00:00',
184
+ });
185
+ ```
186
+
187
+ It **resolves** with `{ success: false, error }` on failure and only **rejects**
188
+ on timeout. Check `success` before reading `logs`.
189
+
190
+ Two things that have caused real bugs:
191
+
192
+ **Timestamps are device-local, not UTC.** `log_time` passes through from the
193
+ device RTC. Build your range with local `Date` getters — `getFullYear()`,
194
+ `getHours()` — and **never** with `toISOString()`. Converting shifts every
195
+ series by the timezone offset, producing plausible data and no error at all.
196
+
197
+ **Cursors are opaque.** `startingAfter` and `endingBefore` are mutually
198
+ exclusive; echo back exactly what you received and never construct one.
199
+
200
+ With a time range, `hasMore` and `hasPrevious` are computed *within* the
201
+ period, so `hasMore: false` means "this period is complete", not "no more data
202
+ exists".
203
+
204
+ ## Error shapes
205
+
206
+ There are three, and none of them is a thrown exception.
207
+
208
+ **1. `nack`** — shell to client, reasons `not-read-only`, `denied`,
209
+ `no-confirmation-handler`, `confirmation-error`, `user-rejected`, `not-allowed`.
210
+ Visible only as `console.warn`.
211
+
212
+ **2. `sensorLog` failure** — `{ requestId, success: false, error }`. The codes
213
+ are `CONFLICTING_CURSORS`, `INVALID_BODY_PART`, `INVALID_LIMIT`,
214
+ `INVALID_START_TIME`, `INVALID_END_TIME`, `INVALID_TIME_RANGE`,
215
+ `MISSING_DEVICE_SERIAL_NO`, `invalid session`, `device not found for this user`.
216
+ `error` is a **diagnostic code, never user-facing text** — map it to your own
217
+ copy.
218
+
219
+ **3. Silence** — nothing reaches JavaScript. This is the dominant failure mode.
220
+ The six paths that produce it are enumerated in `docs/troubleshooting.md`.
221
+
222
+ ## The back button
223
+
224
+ The app owns the OS back button and will delegate it to you only if you ask.
225
+
226
+ ```ts
227
+ BridgeClient.reportNavigation(true); // "I have internal history"
228
+ BridgeClient.onBack(() => history.back()); // go back exactly one page
229
+ ```
230
+
231
+ Report `false` when you are at your root, and the app handles back itself.
232
+ Report state on every internal navigation, not once at startup.
233
+
234
+ ## Status bar
235
+
236
+ Two ways, in precedence order:
237
+
238
+ ```html
239
+ <meta name="theme-color" content="#0A2540"> <!-- static, read from the bundle -->
240
+ ```
241
+ ```ts
242
+ BridgeClient.send('setStatusBarColor', { color: '#0A2540' }); // runtime, wins
243
+ ```
244
+
245
+ ## Session
246
+
247
+ When the session token dies, the app shows its own native banner above your
248
+ bundle and keeps rendering you. **You receive no event for this** — there is no
249
+ session key. Do not try to detect it; the app handles re-authentication.