@condorinst/mobile-bridge 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,292 @@
1
+ # Shell-side integration — for Condor's app shell
2
+
3
+ **Client bundles never use anything on this page.** A bundle talks to the app
4
+ only through `BridgeClient` — see [`AGENTS.md`](../AGENTS.md). This page is for
5
+ the Condor shell that hosts the bundle (`BridgeShell`), the legacy `Bridge`
6
+ singleton that talks to the native layer directly, and their mock mode and dev
7
+ panel. It is kept out of `llms-full.txt` on purpose.
8
+
9
+ ## Quickstart (legacy, shell-side `Bridge`)
10
+
11
+ > **Not for client bundles.** This is the API the Condor shell uses to talk to
12
+ > the native layer. A client bundle cannot reach it from the sandbox — use
13
+ > `BridgeClient` (above, and in the next section).
14
+
15
+ ```typescript
16
+ import { Bridge, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
17
+
18
+ // Versioned handshake with the native side (can be called at any time;
19
+ // recommended on the client's DOMContentLoaded)
20
+ const ack = await Bridge.handshake({ clientVersion: '1.0.0' });
21
+ console.log('Native bridge:', ack.nativeVersion, 'accepted:', ack.accepted);
22
+
23
+ // Subscribe to an observable key coming from the background
24
+ const unsubscribe = Bridge.on(BRIDGE_KEYS.deviceConnection, (payload) => {
25
+ console.log('Device:', payload);
26
+ });
27
+
28
+ // Command: enable real-time for one device
29
+ Bridge.send('realTime', { serialNo: 1234, value: true });
30
+
31
+ // Native hook: open the app's native Settings screen
32
+ Bridge.openSettings();
33
+
34
+ // Cleanup
35
+ unsubscribe();
36
+ ```
37
+
38
+ > The recommended model for white-label clients is **`BridgeClient`** (sandbox
39
+ > iframe), not the legacy `Bridge`. See below.
40
+
41
+ ## Sandbox iframe — `BridgeClient` + `BridgeShell`
42
+
43
+ The recommended model isolates the external client code inside an
44
+ `<iframe sandbox="allow-scripts">`, communicating over `window.postMessage`
45
+ with schema-validated messages and a default-deny policy.
46
+
47
+ ### For the external client — `BridgeClient`
48
+
49
+ API **identical** to the legacy `Bridge`, but over a postMessage transport.
50
+ The client imports it and never notices it is sandboxed:
51
+
52
+ ```typescript
53
+ import { BridgeClient, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
54
+
55
+ const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
56
+ console.log('Shell:', ack.shellVersion, 'accepted:', ack.accepted);
57
+
58
+ const unsubscribe = BridgeClient.on(BRIDGE_KEYS.userDevice, (payload) => {
59
+ console.log('Devices:', payload);
60
+ });
61
+
62
+ BridgeClient.send('realTime', { serialNo: 1234, value: true });
63
+ BridgeClient.openSettings();
64
+
65
+ // System back button: report whether you can go back, and react to the OS back.
66
+ BridgeClient.reportNavigation(currentView !== 'home'); // call on every route change
67
+ BridgeClient.onBack(() => goToPreviousPage()); // fired only when you reported true
68
+ ```
69
+
70
+ The `reportNavigation` / `onBack` pair lets your page own the Android back
71
+ button — walking your in-page navigation before the app leaves the Home. Opt-in;
72
+ skip it and the app just stays on Home when back is pressed. See
73
+ [docs/bridge-contract.md](./bridge-contract.md).
74
+
75
+ **Differences vs `Bridge`:**
76
+ - Does not expose `mockEmit`, `installDevPanel`, `isMock`, `getMockState` —
77
+ dev/mock APIs live only on the shell.
78
+ - `handshake` resolves with `{ shellVersion, nativeVersion, accepted }`. Since
79
+ 0.17.0 the shell relays `nativeVersion` into the iframe — it is the only
80
+ capability signal you get, and it is `null` when the shell could not reach
81
+ the native side and against shells older than 0.6.0. `accepted` is always `true`; never branch on it.
82
+ - Commands not allowed by the `ShellPolicy` are silently dropped (with a
83
+ `console.warn`). The external client has no visibility into which commands
84
+ do or do not exist.
85
+
86
+ ### For the Condor-controlled shell — `BridgeShell`
87
+
88
+ ```typescript
89
+ import { BridgeShell, BRIDGE_KEYS, type ShellPolicy } from '@condorinst/mobile-bridge';
90
+
91
+ const iframe = document.getElementById('client-frame') as HTMLIFrameElement;
92
+
93
+ const policy: ShellPolicy = {
94
+ readOnlyKeys: [
95
+ BRIDGE_KEYS.userDevice,
96
+ BRIDGE_KEYS.deviceConnection,
97
+ BRIDGE_KEYS.deviceLightSensors,
98
+ BRIDGE_KEYS.realTime,
99
+ ],
100
+ allowedCommands: ['userDevice', 'realTime', 'openSettings'],
101
+ requiresConfirmation: ['syncTime', 'deleteMemory'],
102
+ deniedCommands: ['dfu'],
103
+ };
104
+
105
+ const shell = new BridgeShell({
106
+ iframe,
107
+ policy,
108
+ onConfirmationRequest: async ({ command, args }) => {
109
+ return await showConfirmationModal(command, args); // shell UI
110
+ },
111
+ });
112
+
113
+ // Layer 1 — bootstrap with an override prefix.
114
+ //
115
+ // Use bootstrapClientIframe(clientPath) when HTML comes over HTTP:
116
+ await shell.bootstrapClientIframe('https://api.condor.example/whitelabel/bundle.html');
117
+ //
118
+ // Use bootstrapClientIframeWithHtml(htmlString) when the HTML is already in
119
+ // memory — required for an asset bundled at file:// (Android WebView cannot
120
+ // fetch over file://), a build-time inlined bundle, etc:
121
+ declare const __CLIENT_HTML__: string; // e.g. via esbuild.define
122
+ await shell.bootstrapClientIframeWithHtml(__CLIENT_HTML__);
123
+
124
+ // Layer 2 — iframe attestation.
125
+ const verify = await shell.verifySandbox();
126
+ if (!verify.ok) {
127
+ // Abort the boot — Layer 1 failed.
128
+ throw new Error('Sandbox compromised: bridges still reachable from iframe.');
129
+ }
130
+
131
+ // From here on, BridgeShell routes postMessage ↔ native Bridge
132
+ // automatically. Nothing else to do at boot.
133
+ ```
134
+
135
+ ### Layered defense (propagation of the native channel into the iframe)
136
+
137
+ | Layer | Where | What it does |
138
+ |---|---|---|
139
+ | **1** | `BridgeShell.bootstrapClientIframe` | `srcdoc` with a prefix that deletes `window.CondorNative`, `window.flutter_inappwebview` and `window.CondorBridge` before any client script runs. Defense in depth only: the WebView injects the channel into every frame, so a child iframe created by the client gets it back (and on iOS `window.webkit.messageHandlers` cannot be locked). |
140
+ | **2** | `BridgeShell.verifySandbox` + `BridgeClient` handler | Round-trip `postMessage` to attest the iframe state (`nativeChannelReachable` since 0.18.0). |
141
+ | **3** | (Native side) | Shell nonce, stamped on every outgoing message via `Bridge.setOutgoingStamp`, validated natively. **This is the guarantee.** |
142
+ | **4** | (Fallback if 1+2 fail) | Dual `InAppWebView` — shell and client in separate WebViews. |
143
+
144
+ ## Host detection
145
+
146
+ The package detects automatically:
147
+
148
+ - **Android / iOS** — `window.CondorNative`, a JavaScript channel registered with
149
+ `webview_flutter` (`addJavaScriptChannel('CondorNative')`). Messages are sent as a
150
+ JSON string. Takes precedence when present.
151
+ - **Android / iOS (legacy)** — `window.flutter_inappwebview` (via the `flutter_inappwebview`
152
+ package). Still supported for the transition; will be removed in a future minor.
153
+ - **Browser outside the app** (dev/test): **mock mode** activates automatically
154
+ for `Bridge`. A client bundle (`BridgeClient`) runs in the browser only
155
+ through the `dev` harness.
156
+
157
+ ## Mock mode (shell-side `Bridge`)
158
+
159
+ > For code that uses the legacy `Bridge` directly — the shell, tests,
160
+ > Storybook. A client bundle uses `BridgeClient`, which has no mock of its own:
161
+ > develop it with `condor-mobile-bridge dev` (above).
162
+
163
+ When the package runs outside the Flutter WebView (regular browser, Vite dev
164
+ server, Storybook), the adapter activates mock mode automatically. You can
165
+ develop in the browser without building the APK.
166
+
167
+ ```typescript
168
+ import { Bridge, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
169
+
170
+ if (Bridge.isMock) {
171
+ // Subscribe to a listener as usual
172
+ Bridge.on(BRIDGE_KEYS.deviceConnection, (payload) => {
173
+ console.log('Device:', payload);
174
+ });
175
+
176
+ // Simulate the native side sending an event
177
+ Bridge.mockEmit(BRIDGE_KEYS.deviceConnection, {
178
+ serialNo: 1234,
179
+ connected: true,
180
+ });
181
+ }
182
+ ```
183
+
184
+ ### Handshake in mock is EXPLICIT
185
+
186
+ ```typescript
187
+ // In mock, handshake does NOT resolve by itself — the client/test responds.
188
+ const ackPromise = Bridge.handshake({ clientVersion: '1.0.0' });
189
+
190
+ if (Bridge.isMock) {
191
+ Bridge.mockEmit('handshake-ack', {
192
+ nativeVersion: '0.1.0',
193
+ accepted: true,
194
+ });
195
+ }
196
+
197
+ const ack = await ackPromise;
198
+ ```
199
+
200
+ Design decision: it forces tests to be intentional about the native state. In
201
+ real mode (inside the Flutter WebView), the ack arrives naturally from native.
202
+
203
+ ### `Bridge.send` in mock
204
+
205
+ Messages sent via `Bridge.send` (or helpers like `openSettings`) are recorded
206
+ in an internal FIFO buffer (cap 100) + `console.debug`. Inspect via
207
+ `Bridge.getMockState()`:
208
+
209
+ ```typescript
210
+ Bridge.send('realTime', { enabled: true });
211
+
212
+ const state = Bridge.getMockState();
213
+ console.log(state.messages);
214
+ // → [{ handlerName: 'CondorBridge', payload: {...}, timestamp: ... }]
215
+ console.log(state.subscriberKeys);
216
+ // → ['deviceConnection']
217
+ ```
218
+
219
+ ## Dev panel (shell-side `Bridge`)
220
+
221
+ > The `dev` harness installs this panel for you. Call `installDevPanel` yourself
222
+ > only when hosting `Bridge` directly.
223
+
224
+ An opt-in HTML panel to inspect the bridge in the browser during development.
225
+
226
+ ```typescript
227
+ import { Bridge } from '@condorinst/mobile-bridge';
228
+
229
+ if (Bridge.isMock) {
230
+ Bridge.installDevPanel({
231
+ position: 'bottom-right', // 'bottom-right' | 'bottom-left' | 'top-*'
232
+ maxMessages: 20, // last N shown
233
+ theme: 'dark', // 'dark' | 'light'
234
+ startCollapsed: false,
235
+ });
236
+ }
237
+ ```
238
+
239
+ Contents:
240
+
241
+ - Header with a "MOCK" badge, sent counter, minimize button.
242
+ - Sent log — last N messages (timestamp, type, truncated payload).
243
+ - Trigger emit — dropdown of `BRIDGE_KEYS` + JSON textarea + "Emit" button
244
+ (fires `Bridge.mockEmit`) + "Clear sent" button.
245
+
246
+ Idempotent — calling `installDevPanel` again replaces the previous panel.
247
+ Throws if called in real mode (`!Bridge.isMock`).
248
+
249
+ ### Auto-respond toggle
250
+
251
+ To close the full loop in the browser (client clicks a button → native
252
+ responds → subscriber fires), pass a **policy** that maps `send` to `emit`.
253
+ The package provides the **mechanism** (checkbox UI + hook); you provide the
254
+ **policy** (app-specific mappings).
255
+
256
+ ```typescript
257
+ import { Bridge, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
258
+
259
+ if (Bridge.isMock) {
260
+ Bridge.installDevPanel({
261
+ startAutoRespondEnabled: true, // default false
262
+ autoRespondDelayMs: 50, // default 50 (simulates IPC)
263
+ onSendForAutoRespond(msg) {
264
+ const payload = msg.payload as { type?: string };
265
+ switch (payload?.type) {
266
+ case 'handshake':
267
+ return {
268
+ key: 'handshake-ack',
269
+ payload: { nativeVersion: '0.1.0 (mock)', accepted: true },
270
+ };
271
+ case 'ping':
272
+ return {
273
+ key: BRIDGE_KEYS.pingPong,
274
+ payload: { data: 'pong' },
275
+ };
276
+ default:
277
+ return null; // do not auto-respond to this send
278
+ }
279
+ },
280
+ });
281
+ }
282
+ ```
283
+
284
+ Behavior:
285
+
286
+ - The toggle appears in the panel **only when** `onSendForAutoRespond` is
287
+ passed — without a policy, the panel keeps the manual-emit UX.
288
+ - The toggle state persists in memory for the panel's lifetime. Re-install
289
+ resets to `startAutoRespondEnabled`.
290
+ - Errors thrown by the policy or the internal `triggerEmit` are caught and
291
+ logged (`console.error`) — they do not destabilize the bridge.
292
+ - Use `BRIDGE_KEYS` to avoid string-magic in the mappings.
@@ -3,10 +3,15 @@
3
3
  ## "The device list never came" (stuck on loading)
4
4
 
5
5
  The handshake worked, `systemStatus` arrived, and `userDevice` never answered.
6
- Two different causes produce exactly this symptom, and the bundle cannot tell
7
- them apart.
8
6
 
9
- ### Cause 1: the request was sent before the app's background service was up
7
+ **From `nativeVersion` 0.18.0 the app fixes both causes below itself**: it
8
+ re-sends a request lost while its background service starts, and it answers a
9
+ failed lookup with empty arrays and `error` (`NO_DEVICES`, `NETWORK`, `SESSION`,
10
+ `UNKNOWN`). `requestUserDevices()` turns those into `no-devices` and `error`.
11
+ Below 0.18.0 the two causes produce exactly the same silence, and the bundle
12
+ cannot tell them apart.
13
+
14
+ ### Cause 1 (below 0.18.0): the request was sent before the app's background service was up
10
15
 
11
16
  `userDevice` and `ping` are not answered by the page that hosts you: the app
12
17
  forwards them to a background service that reads the devices. On a cold start
@@ -15,12 +20,12 @@ request sent at boot is often **lost without a reply**. Nothing re-sends the
15
20
  list when the service comes up. Meanwhile the handshake, `systemStatus` and
16
21
  `language` work from the start, which makes the bridge look ready.
17
22
 
18
- ### Cause 2: the account has no linked device
23
+ ### Cause 2 (below 0.18.0): the account has no linked device
19
24
 
20
25
  The app replies to `userDevice` only when the list loads. When the server
21
26
  answers that the account has no device — or the request fails for any other
22
- reason (no internet, expired session) — nothing is sent to the bundle. A
23
- `userDevice` with empty arrays never arrives today.
27
+ reason (no internet, expired session) — nothing is sent to the bundle. An app
28
+ below 0.18.0 never sends a `userDevice` with empty arrays.
24
29
 
25
30
  ### What to do
26
31
 
@@ -29,16 +34,19 @@ Use the helper, which handles both:
29
34
  ```ts
30
35
  const result = await BridgeClient.requestUserDevices(); // resends with backoff for 30 s
31
36
  if (result.status === 'ok') render(result.devices);
32
- else renderEmpty({ probablyNoDevices: result.hasDevices === false }); // + a "Try again" button
37
+ else if (result.status === 'no-devices') renderEmpty('none linked'); // confirmed (0.18.0+)
38
+ else renderEmpty('could not load'); // error (0.18.0+) or timeout — always with "Try again"
33
39
  ```
34
40
 
35
41
  - It resends at 1.5 s, 3 s, 6 s and then every 10 s, and on every
36
42
  `systemStatus` while it waits. `BridgeClient.waitForService()` does the same
37
43
  with `ping` if you only need to know the service is up.
38
44
  - **`systemStatus.hasDevices` is a hint, not a verdict.** It is `true` only
39
- after the app loaded a non-empty list from the server. `false` means either
40
- "no device" or "the app could not ask". Word the empty state so it is true in
41
- both cases, and always offer a retry with `getFromServer: true`.
45
+ after the app loaded a non-empty list from the server. From 0.18.0,
46
+ `systemStatus.hasDevicesKnown` says whether it is the server's answer; below
47
+ it, `false` means either "no device" or "the app could not ask". Word the
48
+ empty state so it is true in both cases, and always offer a retry with
49
+ `getFromServer: true`.
42
50
  - Keep the `userDevice` subscription for the life of the page: roster changes
43
51
  arrive on it without a request.
44
52
 
@@ -164,7 +172,7 @@ First question: **which browser setup?**
164
172
  run every scenario, not just `default`. `cold-start` and `no-devices` are the
165
173
  ones that catch real bugs.
166
174
 
167
- In the harness `nativeVersion` is `0.17.0` by default. Run it with
175
+ In the harness `nativeVersion` is `0.18.0` by default. Run it with
168
176
  `--native-version null` to reproduce a shell that could not reach the native
169
177
  side; keep a branch that renders in that case too.
170
178
 
@@ -6,8 +6,8 @@ handshakes with the native side, listens for connection/real-time events, and
6
6
  sends commands back — all through `BridgeClient`, with the boot sequence that
7
7
  survives a cold start and an account with no device.
8
8
 
9
- Use it as a starting skeleton for your own bundle. See the
10
- [Client Guide](../../docs/client-guide.md) for the full walkthrough.
9
+ Use it as a starting skeleton for your own bundle. The procedure is in
10
+ [`AGENTS.md`](../../AGENTS.md).
11
11
 
12
12
  ## Run it
13
13
 
@@ -14,7 +14,7 @@
14
14
  "node": ">=18"
15
15
  },
16
16
  "dependencies": {
17
- "@condorinst/mobile-bridge": "^0.22.0"
17
+ "@condorinst/mobile-bridge": "^0.23.0"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@types/node": "^22.0.0",
@@ -32,7 +32,7 @@ interface DeviceRow {
32
32
  realTimeOn: boolean;
33
33
  }
34
34
 
35
- type Phase = 'loading' | 'ready' | 'empty' | 'outside-shell';
35
+ type Phase = 'loading' | 'ready' | 'empty' | 'error' | 'outside-shell';
36
36
 
37
37
  const state = {
38
38
  phase: 'loading' as Phase,
@@ -74,6 +74,19 @@ function telemetryFor(
74
74
  }
75
75
 
76
76
  function applyDevices(payload: UserDevicePayload): void {
77
+ // From nativeVersion 0.18.0 a failed lookup also arrives here, with `error`
78
+ // and empty arrays. It is not an empty roster: keep what is on screen.
79
+ if (payload.error !== undefined) {
80
+ if (payload.error === 'NO_DEVICES') {
81
+ state.devices = new Map();
82
+ state.probablyNoDevices = true;
83
+ state.phase = 'empty';
84
+ } else if (state.devices.size === 0) {
85
+ state.phase = 'error';
86
+ }
87
+ render();
88
+ return;
89
+ }
77
90
  const next = new Map<number, DeviceRow>();
78
91
  for (const cfg of payload.devicesConfig) {
79
92
  const telemetry = telemetryFor(cfg, payload);
@@ -140,11 +153,14 @@ function body(): string {
140
153
  case 'loading':
141
154
  return '<p class="empty">Loading your devices…</p>';
142
155
  case 'empty':
156
+ // probablyNoDevices: confirmed (0.18.0+) or suggested by an older app.
143
157
  return `<p class="empty">${
144
158
  state.probablyNoDevices
145
159
  ? 'No device is linked to your account yet.'
146
160
  : 'Could not load your devices.'
147
161
  }</p><p class="empty"><button onclick="__home.retry()">Try again</button></p>`;
162
+ case 'error':
163
+ return '<p class="empty">Could not load your devices.</p><p class="empty"><button onclick="__home.retry()">Try again</button></p>';
148
164
  case 'ready':
149
165
  return [...state.devices.values()].map(renderCard).join('');
150
166
  }
@@ -192,9 +208,13 @@ BridgeClient.on(BRIDGE_KEYS.realTime, (payload) => {
192
208
 
193
209
  async function loadDevices(getFromServer: boolean): Promise<void> {
194
210
  const result = await BridgeClient.requestUserDevices({ getFromServer });
195
- if (result.status === 'ok') return; // applyDevices already rendered it
211
+ // ok, no-devices and error also reached applyDevices through the
212
+ // subscription, which already rendered them.
213
+ if (result.status !== 'timeout') return;
214
+ // An older app gives only a hint; with devices on screen, keep them.
215
+ if (state.devices.size > 0) return;
196
216
  state.probablyNoDevices = result.hasDevices === false;
197
- state.phase = 'empty';
217
+ state.phase = state.probablyNoDevices ? 'empty' : 'error';
198
218
  render();
199
219
  }
200
220