@condorinst/mobile-bridge 0.18.0 → 0.19.1

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,387 @@
1
+ # Build your White Label Home
2
+
3
+ This guide walks you — a Condor White Label partner — through building the web
4
+ Home that runs inside the Condor mobile app, using the
5
+ [`@condorinst/mobile-bridge`](../README.md) SDK.
6
+
7
+ No Flutter, Android, or iOS knowledge is required. You build a normal web app;
8
+ we package it and the app renders it.
9
+
10
+ > **This guide is the narrative path, written for a person reading start to
11
+ > finish.** If you are working with an AI agent, point it at
12
+ > [`AGENTS.md`](../AGENTS.md) and [`docs/agent-guide.md`](./agent-guide.md)
13
+ > instead — same platform, organised as a procedure with checks. The normative
14
+ > reference for keys, commands, errors and capabilities is
15
+ > [`docs/bridge-contract.md`](./bridge-contract.md); the hard numbers are in
16
+ > [`docs/limits.md`](./limits.md).
17
+
18
+ ---
19
+
20
+ ## 1. What you're building
21
+
22
+ The app's Home is a **web bundle** that the Condor app renders inside a
23
+ sandboxed WebView (`<iframe sandbox="allow-scripts">`). You deliver a single
24
+ self-contained `index.html`; the app downloads it, verifies it, and shows it.
25
+
26
+ Your web code talks to the device and the native app through the **bridge** —
27
+ a typed message channel exposed by this SDK. You never touch Bluetooth or
28
+ native APIs directly: you subscribe to keys (device list, connection status,
29
+ real-time readings…) and send commands (refresh, start real-time, open
30
+ settings…).
31
+
32
+ ```
33
+ ┌────────────────────────────┐
34
+ │ Condor app (native) │
35
+ │ ┌──────────────────────┐ │
36
+ │ │ iframe (your bundle) │ │ ← your index.html runs here
37
+ │ │ BridgeClient ◄────────────► native bridge (devices, BLE, …)
38
+ │ └──────────────────────┘ │
39
+ └────────────────────────────┘
40
+ ```
41
+
42
+ **Two hard rules** that shape everything below:
43
+ - The bundle must be **one `index.html`**, fully **self-contained** (JS/CSS
44
+ inline, media as `data:` URIs). Relative paths and external URLs do **not**
45
+ load inside the sandbox.
46
+ - The bundle must be **≤ 2 MB** (zipped).
47
+
48
+ The CLI in this SDK turns your normal build into a compliant bundle for you
49
+ (see §6) — you don't have to inline by hand.
50
+
51
+ ---
52
+
53
+ ## 2. Install the SDK
54
+
55
+ ```bash
56
+ npm install @condorinst/mobile-bridge
57
+ ```
58
+
59
+ Pin the version with a caret:
60
+
61
+ ```jsonc
62
+ // package.json
63
+ "dependencies": {
64
+ "@condorinst/mobile-bridge": "^0.19.0"
65
+ }
66
+ ```
67
+
68
+ **Updates.** The SDK is on `0.x` during the pilot. With `^0.19.0`, running
69
+ `npm install` (or your CI) auto-picks **patch** releases (`0.8.x`) — bug fixes,
70
+ no action needed. A `0.9.0` (a larger change during beta) is an intentional
71
+ opt-in: you bump the range when you're ready. `1.0.0` will mark the
72
+ production-stable contract.
73
+
74
+ The published package ships prebuilt JS + TypeScript types — no build tooling
75
+ of ours required on your side.
76
+
77
+ ---
78
+
79
+ ## 3. Talk to the app — the bridge
80
+
81
+ Import **`BridgeClient`** (the model for code running inside the app's iframe):
82
+
83
+ ```typescript
84
+ import { BridgeClient, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
85
+ ```
86
+
87
+ ### Handshake
88
+
89
+ Announce yourself once at boot. The ack tells you the bridge is live.
90
+
91
+ ```typescript
92
+ const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
93
+ // ack.accepted: boolean
94
+ // ack.shellVersion: string
95
+ // ack.nativeVersion: string | null — the native's schema version, relayed by
96
+ // the shell (0.17.0+). `null` means the native has not answered or is absent
97
+ // (browser mock). There is no version negotiation, so this is the only signal
98
+ // telling you whether a capability exists — e.g. the raw `light_sensor_*`
99
+ // channels on `deviceLightSensors` require native >= 0.16.0.
100
+ ```
101
+
102
+ ### Listen for events (native → you)
103
+
104
+ ```typescript
105
+ const unsubscribe = BridgeClient.on(BRIDGE_KEYS.deviceConnection, (payload) => {
106
+ // payload is typed: { serialNo: number, isConnect: boolean }
107
+ console.log(payload.serialNo, payload.isConnect);
108
+ });
109
+ // later: unsubscribe();
110
+ ```
111
+
112
+ ### Send commands (you → native)
113
+
114
+ ```typescript
115
+ BridgeClient.send('userDevice', { getFromServer: true }); // refresh device list
116
+ BridgeClient.send('realTime', { serialNo: 1234, value: true }); // start real-time
117
+ BridgeClient.openSettings(); // open native Settings
118
+ BridgeClient.requestPermission('bluetoothScan'); // in-app permission dialog
119
+ ```
120
+
121
+ > Commands are gated by the app's policy. A command that isn't allowed is
122
+ > silently ignored — design your UI to not depend on a response you won't get.
123
+
124
+ ### Handle the system back button
125
+
126
+ Your page runs inside the app's Home screen. On Android, the OS back
127
+ button/gesture would otherwise leave the Home — so the app delegates it to you
128
+ when you opt in, letting the back button walk **your** internal navigation
129
+ (e.g. a device detail page → back to the list) before it does anything else.
130
+
131
+ Two calls wire it up:
132
+
133
+ ```typescript
134
+ // 1. Tell the app whether you have an internal page to go back to.
135
+ // Call this on EVERY route change (and once at boot).
136
+ BridgeClient.reportNavigation(currentView !== 'home'); // true = you can go back
137
+
138
+ // 2. React to the OS back button. Called only when you last reported `true`.
139
+ BridgeClient.onBack(() => {
140
+ goToPreviousPage(); // pop one internal page, then reportNavigation(...) again
141
+ });
142
+ ```
143
+
144
+ How it behaves:
145
+
146
+ - You reported `true` → the OS back button fires your `onBack` (go back one
147
+ internal page). Nothing else happens — the app stays on Home.
148
+ - You reported `false` (or never adopted this API) → the OS back button is
149
+ ignored and the user stays on Home. **No regression if you skip it.**
150
+
151
+ Keep `reportNavigation` in sync with your router: the app caches the last value
152
+ and reads it the instant back is pressed. A single choke point (call it at the
153
+ end of your `render`/route function) is the simplest way to never miss a change.
154
+ See `examples/client_demo/src/main.ts` for a worked home↔device example.
155
+
156
+ ### Key catalog
157
+
158
+ These are the keys you can subscribe to / send. Payloads are fully typed in
159
+ TypeScript; the table is the quick reference (see the
160
+ [README](../README.md#key-catalog-strictly-typed) for exact shapes).
161
+
162
+ | Key | Payload (typed) | Direction |
163
+ |---|---|---|
164
+ | `userDevice` | device config / telemetry / user config arrays | native → you (also send to refresh) |
165
+ | `deviceConnection` | `{ serialNo, isConnect }` | native → you (status) |
166
+ | `deviceNotification` | `{ serialNo, isConnect, message, messageCode, isError }` | native → you |
167
+ | `deviceLightSensors` | `{ serialNo, type, data }` — union on `type` (`'actlumus'` \| `'actlumus2'`); `data` has the spectrum plus the raw `light_sensor_*` channels | native → you |
168
+ | `realTime` | `{ serialNo, success, value }` | both (command + state) |
169
+ | `syncTime` | `{ serialNo, success }` | both |
170
+ | `sensorLog` | paginated sensor log envelope | native → you |
171
+ | `systemStatus` | `{ bluetooth, internet, permission, hasDevices, missingOptionalPermissions, missingEssentialPermissions, deviceSyncError }` | native → you (env status) |
172
+ | `deleteMemory` | `{ serialNo, success }` | both |
173
+ | `pingPong` | `{ data: 'ping' \| 'pong' }` | both |
174
+ | `navigationBack` | `{}` (OS back pressed — via `onBack`) | native → you |
175
+ | `language` | `{ deviceLocale, appLanguage, resolvedLanguage }` | native → you (language state) |
176
+
177
+ > **System back button.** Use the `reportNavigation(canGoBack)` /
178
+ > `onBack(cb)` helpers (see [Handle the system back button](#handle-the-system-back-button))
179
+ > rather than subscribing to `navigationBack` directly — `reportNavigation`
180
+ > sends the `navigationState` command the app needs to route the back button.
181
+
182
+ **Permissions on `systemStatus`.** Two arrays of `AppPermission` names let you
183
+ render permission prompts from your own UI:
184
+
185
+ - `missingEssentialPermissions` — **required** for the app to work; background
186
+ collection stays off until granted. Render a **blocking "Required permissions"
187
+ card** (e.g. `["bluetoothScan", "bluetoothConnect"]`).
188
+ - `missingOptionalPermissions` — **recommended** (help background reliability);
189
+ render a **dismissible "Recommended permissions" nudge** (e.g.
190
+ `["notification", "backgroundLocation"]`).
191
+
192
+ Both are empty when nothing is missing. For the **required** card, wire the
193
+ "Grant" button to `BridgeClient.requestPermission(name)` — it fires the **native
194
+ in-app permission dialog** for that `AppPermission` (falls back to Settings only
195
+ when the OS refuses the prompt). Pass a name straight from
196
+ `missingEssentialPermissions` (e.g. `'bluetoothScan'`). There is no dedicated
197
+ result key: the native side re-emits `systemStatus` once granted, so the card
198
+ disappears when `missingEssentialPermissions` empties. `openSettings` remains
199
+ available for the optional nudge (permissions not grantable by prompt on modern
200
+ Android). See `examples/client_demo/src/render-home.ts` for a worked render.
201
+
202
+ ### Translate your bundle — the `language` key
203
+
204
+ The app tells you **two** languages, and the distinction between them is the
205
+ whole point of the channel:
206
+
207
+ ```typescript
208
+ BridgeClient.on(BRIDGE_KEYS.language, ({ deviceLocale, appLanguage, resolvedLanguage }) => {
209
+ // Your precedence rule. This one honours an explicit choice, else the device.
210
+ const lang = appLanguage ?? pickSupported(deviceLocale) ?? 'en';
211
+ renderIn(lang);
212
+ });
213
+ ```
214
+
215
+ - **`deviceLocale`** — the raw device locale as a BCP-47 tag (`"pt-BR"`,
216
+ `"es-419"`, `"ja-JP"`), exactly as the OS reports it, region included.
217
+ - **`appLanguage`** — the language the user explicitly picked in the app
218
+ Settings (`"en" | "pt" | "de" | "fr"`), or **`null`** when they never picked
219
+ one.
220
+ - **`resolvedLanguage`** — the language the native chrome (splash, Settings,
221
+ loading/error screens) is rendering right now.
222
+
223
+ **Why two raw values instead of one answer.** The app itself ships four
224
+ languages and collapses everything else to English. Your bundle has no such
225
+ limit — it may ship Spanish, Italian, Japanese. If we sent you only the resolved
226
+ language, a device set to Spanish would reach you as `en` and you would render
227
+ English while holding a Spanish bundle. The information would have been
228
+ destroyed on our side before reaching you. So we send both and let you decide:
229
+
230
+ | `deviceLocale` | `appLanguage` | a reasonable decision |
231
+ |---|---|---|
232
+ | `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 |
233
+ | `es-ES` | `"pt"` | render **Portuguese** — an explicit choice outranks the device |
234
+ | `pt-BR` | `null` | render Portuguese |
235
+ | `ja-JP` | `"de"` | render German (the explicit choice), or Japanese if you'd rather honour the device — your call, with both values in hand |
236
+
237
+ `appLanguage` is `null` when — and only when — the user never chose. It is never
238
+ a fallback in disguise, so `null` is a real signal you can branch on. Use
239
+ `resolvedLanguage` when you'd rather stay visually consistent with the native
240
+ frame around your page than follow the device.
241
+
242
+ **When it arrives — and how to ask for it.** The app pushes this key on attach,
243
+ again right after the handshake ack, and on every change in the app Settings, so
244
+ a page already loaded re-renders without a restart. But the first push happens
245
+ while the WebView is still being created, before your bundle exists, so **send
246
+ the `language` command once after your handshake** to pull the current state:
247
+
248
+ ```typescript
249
+ BridgeClient.send('language', {}); // replies with a `language` event
250
+ ```
251
+
252
+ Without that pull, a first launch would show your default language until the
253
+ user changed it in Settings. Subscribe first, then pull.
254
+
255
+ Do not read `navigator.language` instead: it follows the system, not the in-app
256
+ choice, and you would end up in Spanish while the native chrome around you is in
257
+ Portuguese.
258
+
259
+ **Older app versions.** There is no version negotiation. An app older than
260
+ native `0.14.0` never emits this key, so the event simply never arrives — keep a
261
+ default language for that case, and read `nativeVersion` from the handshake ack
262
+ if you need to branch on it explicitly.
263
+
264
+ ---
265
+
266
+ ## 4. Develop in your browser (no app needed)
267
+
268
+ When your code runs in a normal browser (not inside the app), the SDK
269
+ auto-activates **mock mode**: the bridge no longer expects a native side, and
270
+ you get tooling to simulate it.
271
+
272
+ ```typescript
273
+ import { Bridge, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
274
+
275
+ if (Bridge.isMock) {
276
+ // Floating dev panel: inspect sent messages and emit native events by hand.
277
+ Bridge.installDevPanel({ position: 'bottom-right', theme: 'dark' });
278
+ }
279
+ ```
280
+
281
+ The dev panel lets you:
282
+ - See every command your UI sends.
283
+ - Emit any `BRIDGE_KEYS` event with a JSON payload (simulate the device).
284
+ - Enable **auto-respond** so a `send` triggers a canned `emit` — closing the
285
+ full loop without manual clicks (provide an `onSendForAutoRespond` policy;
286
+ see the [README](../README.md#auto-respond-toggle)).
287
+
288
+ > In mock mode the handshake does **not** resolve on its own — emit a
289
+ > `handshake-ack` (the dev panel and auto-respond can do this for you). This is
290
+ > intentional, so your tests are explicit about the native state.
291
+
292
+ The [`examples/minimal-home`](../examples/minimal-home) example is set up to run
293
+ this way out of the box.
294
+
295
+ ---
296
+
297
+ ## 5. Bundle format & constraints
298
+
299
+ Your final artifact is a `.zip` containing exactly **one self-contained
300
+ `index.html`** at the root. Why the constraints:
301
+
302
+ - The app injects your HTML via `iframe.srcdoc` with origin `null` and no base
303
+ URL → **relative references don't resolve** (`<script src="app.js">`,
304
+ `<img src="logo.png">` would break).
305
+ - The app's Content-Security-Policy **blocks external fetches** → CDN scripts,
306
+ Google Fonts, remote APIs called at load time won't work.
307
+
308
+ So the bundle must have:
309
+ - ✅ One `index.html` at the zip root.
310
+ - ✅ JS and CSS **inlined** into the HTML.
311
+ - ✅ Images/icons/fonts as **`data:` URIs**.
312
+ - ✅ Total size **≤ 2 MB** (zipped). Note base64 inflates media ~33%.
313
+ - ❌ No external `https://` script/style/font references loaded at runtime.
314
+ - ❌ No code-splitting / dynamic `import()` across chunks (see below).
315
+
316
+ ### Single-chunk build
317
+
318
+ The bundler CLI is post-build and assumes **one JS + one CSS + assets**. If
319
+ your framework code-splits, the split chunks won't resolve in the sandbox.
320
+ Configure a single-file build:
321
+
322
+ - **Vite:** add [`vite-plugin-singlefile`](https://github.com/richardtallent/vite-plugin-singlefile),
323
+ or set `build.rollupOptions.output.inlineDynamicImports: true` +
324
+ `manualChunks: undefined`.
325
+ - **Create React App:** avoid `React.lazy` / dynamic `import()` in routes so the
326
+ build emits a single bundle.
327
+ - **esbuild:** bundle to a single IIFE/ESM file (see the example's `build.mjs`).
328
+
329
+ ---
330
+
331
+ ## 6. Build the bundle
332
+
333
+ Once your build produces a `dist/index.html` (referencing a local `main.js`,
334
+ CSS, images), run the CLI to inline everything and pack the zip:
335
+
336
+ ```bash
337
+ npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
338
+ ```
339
+
340
+ It inlines `<script src>`, `<link rel=stylesheet>`, CSS `url()`, and local
341
+ `<img>`/icons into a single `index.html`, then zips it. Output:
342
+
343
+ ```
344
+ Built dist/bundle.zip
345
+ entry: index.html (123.4 KB self-contained)
346
+ bundleSize (zip): 45678 bytes
347
+ bundleHash (zip): sha256-<hex>
348
+ ```
349
+
350
+ | Option | Description |
351
+ |---|---|
352
+ | `-i, --html <path>` | Input HTML. Required. |
353
+ | `-o, --out <path>` | Output `.zip` (default `dist/whitelabel-bundle.zip`). |
354
+ | `--max-bytes <n>` | Warning threshold (default 2 MB). |
355
+
356
+ The hash is deterministic — the same input always yields the same
357
+ `bundleHash`. The CLI **warns** above 2 MB but the **app rejects** oversized
358
+ bundles and falls back to a default screen, so keep under the limit.
359
+
360
+ ---
361
+
362
+ ## 7. Deliver
363
+
364
+ Send the resulting `dist/bundle.zip` through the publication channel Condor
365
+ gave you (the admin upload). The backend validates structure, self-containment,
366
+ and size on upload, and records the `bundleSize`/`bundleHash`. Once published,
367
+ the app downloads, verifies, and renders your Home.
368
+
369
+ ---
370
+
371
+ ## 8. Troubleshooting
372
+
373
+ | Symptom | Likely cause |
374
+ |---|---|
375
+ | Blank screen / nothing loads | Relative `<script src>` or external URL — must be inlined. Run the build CLI. |
376
+ | Images missing | Referenced by path instead of `data:` URI — the CLI inlines local images; remote images are blocked. |
377
+ | Works in browser, not in app | You relied on a network call or external asset at load time (CSP blocks it), or a code-split chunk didn't resolve. Go single-chunk + self-contained. |
378
+ | Bundle rejected on upload / falls back | Over 2 MB, multiple files at root, or not self-contained. |
379
+ | `handshake` never resolves in browser | Expected in mock mode — emit a `handshake-ack` (dev panel / auto-respond). |
380
+ | A command does nothing | The command may not be allowed by the app policy; it's dropped silently. |
381
+
382
+ ---
383
+
384
+ ## Next steps
385
+
386
+ - Copy [`examples/minimal-home`](../examples/minimal-home) and start from there.
387
+ - Full API reference: the [README](../README.md).
package/docs/limits.md ADDED
@@ -0,0 +1,120 @@
1
+ # Limits
2
+
3
+ Every number here is enforced in code. The **On violation** column is what you
4
+ actually observe — which matters more than the number, because most of these
5
+ fail without telling you.
6
+
7
+ Each row cites the file and line in the Canvas app that applies it, as verified
8
+ against monorepo commit `a6b19cd2` on 2026-09-22. The same values are available
9
+ as JSON in `contract/bridge-contract.json`, including these citations under
10
+ `verifiedAgainst.sources`.
11
+
12
+ ## Messages
13
+
14
+ | Limit | Value | On violation | Enforced at |
15
+ | --- | --- | --- | --- |
16
+ | Payload, JS → native | 64 KB (65536 bytes) | **Silent drop.** Nothing reaches the native side and nothing comes back. | `condor_bridge.dart:88,229-238` |
17
+ | `jsError` rate | 20 per rolling 10 s | Silent drop of the excess. | `condor_bridge.dart:93-94,311-317` |
18
+ | `jsError` field length | 4 KB each for `message` and `stack` | Truncated, with a marker appended. | `condor_bridge.dart:97,319-320` |
19
+
20
+ 64 KB is generous for commands but not for data you might try to push back.
21
+ There is no chunking protocol: if you need to move something larger, you are
22
+ solving the wrong problem — the native side is the one that holds data.
23
+
24
+ ## Timeouts
25
+
26
+ | Operation | Timeout | On expiry |
27
+ | --- | --- | --- |
28
+ | `BridgeClient.handshake` | 5 s | Promise **rejects**. |
29
+ | `BridgeClient.fetchSensorLog` | 10 s (override per call) | Promise **rejects**. This is the only rejection path — a failed query *resolves* with `success: false`. |
30
+ | `BridgeShell.verifySandbox` | 2 s | Resolves with the failure recorded. |
31
+ | Relay of `nativeVersion` into the iframe | 2 s | `ack.nativeVersion` comes back `null`. |
32
+
33
+ ## Sensor log
34
+
35
+ | Parameter | Range | On violation |
36
+ | --- | --- | --- |
37
+ | `limit` | 1–1000 (backend default 10) | `success: false`, `error: "INVALID_LIMIT: <value>"` |
38
+ | `bodyPart` | `wrist` or `necklace` | `error: "INVALID_BODY_PART: <value>"` |
39
+ | `startingAfter` + `endingBefore` | mutually exclusive | `error: "CONFLICTING_CURSORS"` |
40
+ | `startTime`, `endTime` | timestamp format, native ≥ 0.15.0 | `error: "INVALID_START_TIME"` / `"INVALID_END_TIME"` |
41
+
42
+ Cursors are **opaque**. Echo back exactly what you received; never construct or
43
+ parse one.
44
+
45
+ Verified at `sensor_log_entity.dart:70-85`.
46
+
47
+ ## Bundle
48
+
49
+ | Limit | Value | On violation |
50
+ | --- | --- | --- |
51
+ | `.zip` size | 2 MB (2097152 bytes) | **The app rejects the bundle**, keeps the previous cached version, or falls back to its default screen. |
52
+ | Entry document | one `index.html` at the archive root | Falls back to the first `.html` found; if there is none, the bundle is rejected. |
53
+ | External `<script src>` | forbidden | Bundle rejected. |
54
+ | External `<link rel="stylesheet">` | forbidden | Bundle rejected. |
55
+ | Presigned download URL | valid 24 h | The app re-fetches the manifest. |
56
+
57
+ Verified at `whitelabel_bundle_limits.dart:14` and
58
+ `whitelabel_bundle_service.dart:227-398`.
59
+
60
+ `data:` URIs inflate binary assets by roughly a third over their raw bytes, so
61
+ budget against the inflated size. `npx condor-mobile-bridge build` prints the
62
+ final `.zip` size; `validate` fails on it.
63
+
64
+ ## Storage: there is none
65
+
66
+ | API | Behaviour |
67
+ | --- | --- |
68
+ | `localStorage`, `sessionStorage` | throw `SecurityError` |
69
+ | `indexedDB` | unavailable |
70
+ | `document.cookie` | writes are silently ignored |
71
+ | Service workers | cannot be registered |
72
+ | Cache API | unavailable |
73
+
74
+ The shell is loaded with no base URL, giving it an opaque origin; your iframe is
75
+ `sandbox="allow-scripts"` rendered from `srcdoc`, giving it a `null` origin.
76
+ Both are deliberate — the opaque origin is what lets the app guarantee that
77
+ logging out leaves nothing behind.
78
+
79
+ Hold state in memory. It is lost when the WebView reloads, which happens when
80
+ the bundle is updated, when the kill switch fires, and when the person changes
81
+ the app language while no client bundle is active.
82
+
83
+ Verified at `home_page.dart:296-298`.
84
+
85
+ ## Network: there is none
86
+
87
+ The shell CSP is:
88
+
89
+ ```
90
+ default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';
91
+ img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-src 'self';
92
+ object-src 'none'; base-uri 'none'; form-action 'none';
93
+ ```
94
+
95
+ It is inherited by your `srcdoc` iframe and **an iframe cannot relax the policy
96
+ it inherits**. With a `null` origin, `'self'` matches nothing, so `connect-src
97
+ 'self'` blocks every outbound request. No analytics, no CDN, no error reporting
98
+ service.
99
+
100
+ For error reporting, use the `jsError` command — it reaches Condor's logging
101
+ and Crashlytics, within the rate limit above.
102
+
103
+ Verified at `shell.template.html:28`.
104
+
105
+ ## Devices
106
+
107
+ Two things are true at once here, and conflating them is how this goes wrong.
108
+
109
+ **The platform enforces nothing.** A search for `maxDevices` or `deviceLimit`
110
+ across the Canvas app returns nothing. No code path rejects a third device.
111
+
112
+ **The current commercial rule caps a user at two linked devices.** That is a
113
+ product decision, not a platform invariant. It can be raised without any change
114
+ to this contract, to the app, or to a version number you could check.
115
+
116
+ So: design for a short list rather than a virtualised one — two cards will be
117
+ the common case — but **read the length of the roster you receive** and let the
118
+ layout grow. A two-slot UI with the slots hard-coded is the failure this section
119
+ exists to prevent: it will look correct for as long as the rule holds, and break
120
+ silently on the day it does not.
@@ -0,0 +1,103 @@
1
+ # White Label — Pilot Onboarding
2
+
3
+ Welcome! This is the fast path to building and shipping your **White Label
4
+ Home** — the screen that runs inside the Condor app. You build a normal web app;
5
+ we package and render it. No Flutter / Android / iOS needed.
6
+
7
+ For the full reference, see the [Client Guide](./client-guide.md). This page is
8
+ the quick checklist to get a pilot through the whole loop.
9
+
10
+ ## What you'll end up with
11
+
12
+ A self-contained `bundle.zip` that Condor publishes to your tenant — your Home
13
+ renders inside the app, talking to the device through the Condor bridge.
14
+
15
+ ## Who does what
16
+
17
+ | Condor provides | You build |
18
+ |---|---|
19
+ | The SDK (`@condorinst/mobile-bridge`), guide & example | Your Home web app (HTML/CSS/JS or React) |
20
+ | The app that renders your bundle + the device bridge | The `bundle.zip` (via our CLI) |
21
+ | The publish channel (admin upload) | Delivering the `.zip` to us |
22
+
23
+ ## Prerequisites
24
+
25
+ - Node.js 18+
26
+ - npm access (the package is public — no credentials needed)
27
+
28
+ ## Step-by-step
29
+
30
+ ### 1. Install the SDK
31
+ ```bash
32
+ npm install @condorinst/mobile-bridge
33
+ ```
34
+ Pin `"@condorinst/mobile-bridge": "^0.8.0"` (beta; auto-updates patches).
35
+
36
+ ### 2. Start from the example
37
+ Copy [`examples/minimal-home`](../examples/minimal-home) — it already wires the
38
+ bridge, renders devices, and builds a bundle.
39
+
40
+ ### 3. Build your Home
41
+ Talk to the app via `BridgeClient`:
42
+ ```ts
43
+ import { BridgeClient, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
44
+
45
+ await BridgeClient.handshake({ clientVersion: '1.0.0' }); // announce yourself
46
+ BridgeClient.on(BRIDGE_KEYS.deviceConnection, (p) => { /* ... */ }); // listen
47
+ BridgeClient.send('userDevice', { getFromServer: true }); // command
48
+ BridgeClient.openSettings(); // open native settings
49
+ ```
50
+
51
+ ### 4. Test in your browser (no app needed)
52
+ Outside the app the SDK auto-enables **mock mode** + a dev panel (bottom-right)
53
+ to simulate device events and close the loop.
54
+
55
+ ### 5. Build the bundle
56
+ ```bash
57
+ npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
58
+ ```
59
+ This inlines everything into a single self-contained `index.html` and zips it.
60
+ It prints `bundleSize` + `bundleHash`.
61
+
62
+ ### 5b. Validate before you deliver
63
+ ```bash
64
+ npx condor-mobile-bridge validate -i dist/bundle.zip --strict
65
+ ```
66
+ Applies the same gates the app applies on download, plus a lint for the APIs the
67
+ sandbox and the CSP block at runtime. **A non-zero exit means do not deliver.**
68
+ Errors are outright rejections by the app; warnings install fine and then
69
+ misbehave on the device, which is harder to diagnose.
70
+
71
+ ### 6. Deliver
72
+ Send `dist/bundle.zip` through the channel Condor gave you. We publish it to your
73
+ tenant.
74
+
75
+ ### 7. Verify on a device
76
+ Condor publishes → the app downloads, validates and renders your Home.
77
+
78
+ ## Hard rules (or the app rejects the bundle)
79
+
80
+ - ✅ Exactly **one `index.html`** at the zip root
81
+ - ✅ JS/CSS **inline**, media as **`data:` URIs**, no external/relative refs loaded at runtime
82
+ - ✅ **≤ 2 MB** (zipped)
83
+ - ✅ **Single-chunk build** (no code-splitting): Vite → `vite-plugin-singlefile`; CRA → no `React.lazy` / dynamic `import()`
84
+
85
+ The CLI handles the inlining/zipping for you — just keep your build single-file.
86
+
87
+ ## Reference
88
+
89
+ - [Client Guide](./client-guide.md) — full walkthrough (bridge API, key catalog, mock mode, dev panel)
90
+ - [`examples/minimal-home`](../examples/minimal-home) — runnable minimal client
91
+ - [README](../README.md) — API reference
92
+
93
+ ## Pilot checklist
94
+
95
+ - [ ] SDK installed (`npm i @condorinst/mobile-bridge`)
96
+ - [ ] Example runs in the browser (mock mode)
97
+ - [ ] Home built with `BridgeClient` (handshake + at least one key + one command)
98
+ - [ ] Constraints respected (1 index.html, ≤2 MB, self-contained, single-chunk)
99
+ - [ ] `bundle.zip` generated by the CLI
100
+ - [ ] `.zip` delivered to Condor
101
+ - [ ] Home verified on a device
102
+ - [ ] Feedback shared with Condor
103
+ </content>