@condorinst/mobile-bridge 0.20.0 → 0.22.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.
@@ -31,7 +31,7 @@ renders inside the app, talking to the device through the Condor bridge.
31
31
  ```bash
32
32
  npm install @condorinst/mobile-bridge
33
33
  ```
34
- Pin `"@condorinst/mobile-bridge": "^0.8.0"` (beta; auto-updates patches).
34
+ Pin `"@condorinst/mobile-bridge": "^0.22.0"` (beta; auto-updates patches).
35
35
 
36
36
  ### 2. Start from the example
37
37
  Copy [`examples/minimal-home`](../examples/minimal-home) — it already wires the
@@ -42,15 +42,23 @@ Talk to the app via `BridgeClient`:
42
42
  ```ts
43
43
  import { BridgeClient, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
44
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
45
+ BridgeClient.on(BRIDGE_KEYS.userDevice, (d) => render(d)); // listen first
46
+ const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' }); // ack.nativeVersion = capabilities
47
+ const r = await BridgeClient.requestUserDevices(); // resends until the list arrives
48
+ if (r.status === 'timeout') renderEmptyState(); // no reply in 30 s
48
49
  BridgeClient.openSettings(); // open native settings
49
50
  ```
51
+ Never ask for the device list with a single `send('userDevice')`: the first
52
+ request after the app starts is often lost, and an account with no device gets
53
+ no reply. `AGENTS.md` has the full boot sequence.
50
54
 
51
55
  ### 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.
56
+ ```bash
57
+ npx condor-mobile-bridge dev -i dist/index.html
58
+ ```
59
+ Serves your page inside the same sandbox, shell and policy as the app, with a
60
+ simulated app behind it. Check every scenario in the selector at the top:
61
+ `default`, `cold-start`, `no-devices`, `shared-serial`, `offline`.
54
62
 
55
63
  ### 5. Build the bundle
56
64
  ```bash
@@ -68,12 +76,20 @@ sandbox and the CSP block at runtime. **A non-zero exit means do not deliver.**
68
76
  Errors are outright rejections by the app; warnings install fine and then
69
77
  misbehave on the device, which is harder to diagnose.
70
78
 
71
- ### 6. Deliver
72
- Send `dist/bundle.zip` through the channel Condor gave you. We publish it to your
73
- tenant.
79
+ ### 6. Publish
80
+ With a manager user of your company:
81
+ ```bash
82
+ npx condor-mobile-bridge canvas publish dist/index.html --version 1.0.0
83
+ ```
84
+ It validates, shows what goes where, asks for confirmation and your password,
85
+ and publishes. Publish to your **test company** first: a publish reaches every
86
+ user of the company at once. (Without a manager user, send `dist/bundle.zip`
87
+ with its `bundleSize` and `bundleHash` to your Condor contact.)
74
88
 
75
89
  ### 7. Verify on a device
76
- Condor publishes → the app downloads, validates and renders your Home.
90
+ Condor publishes → the app downloads, validates and renders your Home the next
91
+ time it comes to the foreground. [Testing on a device](./device-testing.md)
92
+ explains the environments, how to inspect the WebView, and where errors go.
77
93
 
78
94
  ## Hard rules (or the app rejects the bundle)
79
95
 
@@ -86,17 +102,19 @@ The CLI handles the inlining/zipping for you — just keep your build single-fil
86
102
 
87
103
  ## Reference
88
104
 
89
- - [Client Guide](./client-guide.md) — full walkthrough (bridge API, key catalog, mock mode, dev panel)
105
+ - [Client Guide](./client-guide.md) — full walkthrough (bridge API, key catalog, dev harness)
106
+ - [Testing on a device](./device-testing.md) — from a published bundle to an inspected WebView
107
+ - [Troubleshooting](./troubleshooting.md) — starts from the symptom
90
108
  - [`examples/minimal-home`](../examples/minimal-home) — runnable minimal client
91
109
  - [README](../README.md) — API reference
92
110
 
93
111
  ## Pilot checklist
94
112
 
95
113
  - [ ] SDK installed (`npm i @condorinst/mobile-bridge`)
96
- - [ ] Example runs in the browser (mock mode)
114
+ - [ ] Example runs in the harness (`npx condor-mobile-bridge dev`), in every scenario
97
115
  - [ ] Home built with `BridgeClient` (handshake + at least one key + one command)
98
116
  - [ ] Constraints respected (1 index.html, ≤2 MB, self-contained, single-chunk)
99
- - [ ] `bundle.zip` generated by the CLI
117
+ - [ ] `bundle.zip` generated by the CLI, and `validate --strict` exits 0
100
118
  - [ ] `.zip` delivered to Condor
101
119
  - [ ] Home verified on a device
102
120
  - [ ] Feedback shared with Condor
@@ -1,5 +1,54 @@
1
1
  # Troubleshooting
2
2
 
3
+ ## "The device list never came" (stuck on loading)
4
+
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
+
9
+ ### Cause 1: the request was sent before the app's background service was up
10
+
11
+ `userDevice` and `ping` are not answered by the page that hosts you: the app
12
+ forwards them to a background service that reads the devices. On a cold start
13
+ the app creates your WebView first and starts that service afterwards, so a
14
+ request sent at boot is often **lost without a reply**. Nothing re-sends the
15
+ list when the service comes up. Meanwhile the handshake, `systemStatus` and
16
+ `language` work from the start, which makes the bridge look ready.
17
+
18
+ ### Cause 2: the account has no linked device
19
+
20
+ The app replies to `userDevice` only when the list loads. When the server
21
+ 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.
24
+
25
+ ### What to do
26
+
27
+ Use the helper, which handles both:
28
+
29
+ ```ts
30
+ const result = await BridgeClient.requestUserDevices(); // resends with backoff for 30 s
31
+ if (result.status === 'ok') render(result.devices);
32
+ else renderEmpty({ probablyNoDevices: result.hasDevices === false }); // + a "Try again" button
33
+ ```
34
+
35
+ - It resends at 1.5 s, 3 s, 6 s and then every 10 s, and on every
36
+ `systemStatus` while it waits. `BridgeClient.waitForService()` does the same
37
+ with `ping` if you only need to know the service is up.
38
+ - **`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`.
42
+ - Keep the `userDevice` subscription for the life of the page: roster changes
43
+ arrive on it without a request.
44
+
45
+ Reproduce both causes in the browser before going to a device:
46
+
47
+ ```bash
48
+ npx condor-mobile-bridge dev -i dist/index.html --scenario cold-start
49
+ npx condor-mobile-bridge dev -i dist/index.html --scenario no-devices
50
+ ```
51
+
3
52
  ## "My command did nothing"
4
53
 
5
54
  This is the most common report, and it is usually correct: the command did
@@ -42,7 +91,10 @@ works.
42
91
  `requestPermission` takes a name the native side recognizes. Anything else is
43
92
  dropped.
44
93
 
45
- **Check:** the permission list in `docs/bridge-contract.md`. And note there is
94
+ **Check:** that the name came from `systemStatus.missingEssentialPermissions`
95
+ or `missingOptionalPermissions` — those arrays are the authoritative list of
96
+ names, so pass them through unchanged and never type one by hand. Show a
97
+ generic label for a name you do not recognise. And note there is
46
98
  no permission-result event at all — the outcome reaches you as a fresh
47
99
  `systemStatus` emission. If you were waiting for a dedicated ack, you will wait
48
100
  forever.
@@ -56,7 +108,9 @@ surfaces as a `console.warn` and nothing else — `send` is fire-and-forget.
56
108
  `dfu` is denied outright. Subscribing to a key outside the allowed set produces
57
109
  `nack` with reason `not-read-only`.
58
110
 
59
- **Check:** the browser console for a warning starting with `BridgeClient:`.
111
+ **Check:** the browser console for a warning starting with `[BridgeClient]`. On
112
+ a device that console is only visible through the WebView inspector
113
+ (`docs/device-testing.md`).
60
114
  Then re-read whether that command needs confirmation.
61
115
 
62
116
  ### 6. You bypassed `BridgeClient`
@@ -92,25 +146,30 @@ the ones that cause rejection:
92
146
 
93
147
  If `validate` passes and the app still falls back, the bundle never arrived:
94
148
  check the manifest, the `bundleHash` (it must match the SHA-256 of the exact
95
- `.zip` bytes you uploaded), and `minAppVersion` against the app build on the
149
+ `.zip` bytes you uploaded), and `minAppVersion` — the minimum app build Condor
150
+ sets when publishing, for a bundle that needs a newer app — against the app build on the
96
151
  device.
97
152
 
98
153
  ## "It works in the browser but not on the device"
99
154
 
100
- The browser mock is more permissive than the device in three ways that matter.
101
-
102
- **`nativeVersion` is `null` in mock mode.** Code that reads it without a null
103
- branch behaves differently in the two environments. Test both.
155
+ First question: **which browser setup?**
104
156
 
105
- **Storage works in your browser and does not on the device.** Your dev server
106
- serves from a real origin; the device gives you a `null` one. Anything that
107
- touches `localStorage` works locally and throws in production. `validate` warns
108
- about this — do not dismiss the warning.
157
+ - **A plain tab or your own dev server** (`vite dev`, opening `dist/index.html`):
158
+ `BridgeClient.handshake()` rejects at once, because there is no shell. If you
159
+ built a mock of your own to get past that, it is more permissive than the
160
+ device in every way that matters: real origin (storage works), no CSP (CDN
161
+ fonts and `fetch` work), instant replies (nothing is lost).
162
+ - **The harness** (`npx condor-mobile-bridge dev`): same sandbox, shell, policy
163
+ and CSP as the app. What it cannot reproduce is timing you did not ask for —
164
+ run every scenario, not just `default`. `cold-start` and `no-devices` are the
165
+ ones that catch real bugs.
109
166
 
110
- **The CSP is not applied by your dev server.** External fonts, CDN scripts and
111
- API calls all work locally and are blocked on the device.
167
+ In the harness `nativeVersion` is `0.17.0` by default. Run it with
168
+ `--native-version null` to reproduce a shell that could not reach the native
169
+ side; keep a branch that renders in that case too.
112
170
 
113
- Run `validate --strict` before concluding that something works.
171
+ Run `validate --strict` before concluding that something works, and see
172
+ `docs/device-testing.md` to inspect the bundle on a phone.
114
173
 
115
174
  ## "The screen is blank"
116
175
 
@@ -148,7 +207,9 @@ Use the bridge:
148
207
  BridgeClient.send('jsError', { message: String(err), stack: err?.stack });
149
208
  ```
150
209
 
151
- That reaches Condor's logging and Crashlytics. It is rate limited to 20 per
152
- 10 seconds and truncated at 4 KB per field, so do not use it as a general
153
- telemetry channel — it will drop exactly when something is going wrong in a
154
- loop.
210
+ That reaches Condor's logging and Crashlytics — **Condor reads them, you do
211
+ not**; ask your Condor contact for the errors of a test account. It is rate
212
+ limited to 20 per 10 seconds and truncated at 4 KB per field, so do not use it
213
+ as a general telemetry channel — it will drop exactly when something is going
214
+ wrong in a loop. To watch errors live, inspect the WebView: see
215
+ `docs/device-testing.md`.
@@ -1,7 +1,7 @@
1
1
  # AGENTS.md — minimal Canvas home
2
2
 
3
- This is a working Condor Canvas client. Copy it as the starting point for a new
4
- one.
3
+ This is a working Condor Canvas client with the reference boot sequence. Copy it
4
+ as the starting point for a new one.
5
5
 
6
6
  The full contract you must respect is in the SDK package:
7
7
  `node_modules/@condorinst/mobile-bridge/AGENTS.md`. Read it before changing
@@ -12,17 +12,20 @@ anything here. The short version:
12
12
  - Everything inline: one self-contained `index.html`, built single-chunk.
13
13
  - Failures are silent. Drive the UI from events that arrive; never infer success
14
14
  from the absence of an error.
15
- - `serialNo` is a number, never a string.
15
+ - Ask for devices with `BridgeClient.requestUserDevices()`, never with a single
16
+ `send('userDevice')`, and always render an empty state for its timeout.
17
+ - Identify devices by `deviceId`; `serialNo` can repeat. Name and battery are in
18
+ `devicesTelemetry`, not in `devicesConfig`.
16
19
 
17
20
  ## Commands
18
21
 
19
22
  ```bash
20
23
  npm run build # esbuild → dist/index.html (single chunk)
24
+ npm run dev # harness at http://localhost:5173 (scenario cold-start; switch at the top)
21
25
  npm run bundle # → dist/bundle.zip
22
26
  npm run typecheck
23
27
  npx condor-mobile-bridge validate -i dist/bundle.zip --strict # must exit 0
24
28
  ```
25
29
 
26
- Open `dist/index.html` in a browser to develop against the mock, including the
27
- dev panel that emits native events by hand. Note that `nativeVersion` is `null`
28
- there — the same value a real but older app reports, so handle it.
30
+ Done means: `validate --strict` exits 0 and the page behaves in every harness
31
+ scenario — `default`, `cold-start`, `no-devices`, `shared-serial`, `offline`.
@@ -3,7 +3,8 @@
3
3
  A runnable, standalone example of a White Label Home built on
4
4
  [`@condorinst/mobile-bridge`](../../README.md). It renders a device list,
5
5
  handshakes with the native side, listens for connection/real-time events, and
6
- sends commands back — all through `BridgeClient`.
6
+ sends commands back — all through `BridgeClient`, with the boot sequence that
7
+ survives a cold start and an account with no device.
7
8
 
8
9
  Use it as a starting skeleton for your own bundle. See the
9
10
  [Client Guide](../../docs/client-guide.md) for the full walkthrough.
@@ -13,12 +14,16 @@ Use it as a starting skeleton for your own bundle. See the
13
14
  ```bash
14
15
  npm install
15
16
  npm run build # bundles src/main.ts → dist/main.js + dist/index.html
17
+ npm run dev # serves it in the harness at http://localhost:5173
16
18
  ```
17
19
 
18
- Open `dist/index.html` in any browser. Outside the app, the SDK activates
19
- **mock mode** automatically: a dev panel appears (bottom-right) where you can
20
- emit native events (e.g. `userDevice`, `deviceConnection`) by hand or enable
21
- auto-respond to close the loop.
20
+ The harness runs the page inside the same sandboxed iframe, shell and policy as
21
+ the app, with a simulated app behind it. Switch scenarios at the top:
22
+ `cold-start` loses the first device requests, `no-devices` never answers —
23
+ this example handles both, which is the point of copying it.
24
+
25
+ Opening `dist/index.html` directly does not work: outside the shell,
26
+ `BridgeClient.handshake()` rejects at once.
22
27
 
23
28
  ## Produce the app bundle
24
29
 
@@ -8,7 +8,8 @@
8
8
  * into `index.html` and pack the self-contained `dist/bundle.zip` the app
9
9
  * consumes.
10
10
  *
11
- * For browser dev: `npm run build` then open `dist/index.html` (mock mode).
11
+ * For browser dev: `npm run build` then `npm run dev` (the harness, which runs
12
+ * the page under the same sandbox and shell as the app).
12
13
  */
13
14
  import * as esbuild from 'esbuild';
14
15
  import { copyFileSync, mkdirSync } from 'node:fs';
@@ -38,6 +38,8 @@
38
38
  .toolbar button:active { background: #334155; }
39
39
  main { padding: 12px 16px 24px; }
40
40
  .empty { text-align: center; color: #94a3b8; padding: 32px 16px; }
41
+ .empty button { background: #0d9488; color: #fff; border: none; border-radius: 9px; padding: 10px 16px; font-weight: 600; }
42
+ .meta { margin: 8px 0 0; font-size: 13px; color: #94a3b8; }
41
43
  .card {
42
44
  background: #1e293b;
43
45
  border: 1px solid #334155;
@@ -7,13 +7,14 @@
7
7
  "scripts": {
8
8
  "build": "node build.mjs",
9
9
  "bundle": "condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip",
10
- "typecheck": "tsc --noEmit"
10
+ "typecheck": "tsc --noEmit",
11
+ "dev": "condor-mobile-bridge dev -i dist/index.html --scenario cold-start"
11
12
  },
12
13
  "engines": {
13
14
  "node": ">=18"
14
15
  },
15
16
  "dependencies": {
16
- "@condorinst/mobile-bridge": "^0.19.0"
17
+ "@condorinst/mobile-bridge": "^0.22.0"
17
18
  },
18
19
  "devDependencies": {
19
20
  "@types/node": "^22.0.0",
@@ -1,80 +1,155 @@
1
1
  /**
2
- * Minimal White Label Home — runnable example for @condorinst/mobile-bridge.
2
+ * Minimal Canvas Home — the reference boot sequence for @condorinst/mobile-bridge.
3
3
  *
4
- * This is the kind of code a White Label client writes: it runs inside the
5
- * app's sandboxed iframe (origin `null`, no `window.flutter_inappwebview`) and
6
- * talks to the native side exclusively through `BridgeClient`
7
- * (postMessage → shell → native bridge).
4
+ * This is the kind of code a Canvas client writes: it runs inside the app's
5
+ * sandboxed iframe (origin `null`, no network, no storage) and talks to the
6
+ * native side only through `BridgeClient`.
8
7
  *
9
- * Open `dist/index.html` in a browser and the SDK auto-activates **mock mode**,
10
- * so you can develop the whole flow without building the app. Use the dev panel
11
- * (bottom-right) to emit native events by hand or enable auto-respond.
8
+ * Develop it in the browser with the harness, which runs it under the same
9
+ * sandbox and shell as the app:
10
+ *
11
+ * npm run build
12
+ * npx condor-mobile-bridge dev -i dist/index.html --scenario cold-start
13
+ *
14
+ * It must work in every scenario (`default`, `cold-start`, `no-devices`,
15
+ * `shared-serial`, `offline`) before it goes to a device.
12
16
  */
13
17
  import {
14
18
  BRIDGE_KEYS,
15
19
  BridgeClient,
20
+ type DeviceConfigItem,
21
+ type DeviceTelemetryItem,
16
22
  type UserDevicePayload,
23
+ versionAtLeast,
17
24
  } from '@condorinst/mobile-bridge';
18
25
 
19
26
  interface DeviceRow {
27
+ deviceId: number;
20
28
  serialNo: number;
21
29
  name: string;
30
+ battery: number | null;
22
31
  connected: boolean;
23
32
  realTimeOn: boolean;
24
33
  }
25
34
 
35
+ type Phase = 'loading' | 'ready' | 'empty' | 'outside-shell';
36
+
26
37
  const state = {
38
+ phase: 'loading' as Phase,
39
+ /** `null` = unknown (older shell, or the native side did not answer). */
40
+ nativeVersion: null as string | null,
41
+ /** Whether the last timeout came with `systemStatus.hasDevices === false`. */
42
+ probablyNoDevices: false,
27
43
  devices: new Map<number, DeviceRow>(),
28
- shellVersion: '—',
29
- connected: false,
30
44
  };
31
45
 
32
46
  declare global {
33
47
  interface Window {
34
48
  __home: {
35
- refresh: () => void;
49
+ retry: () => void;
36
50
  settings: () => void;
37
- toggle: (serialNo: number) => void;
51
+ toggle: (deviceId: number) => void;
38
52
  };
39
53
  }
40
54
  }
41
55
 
56
+ // ── Data ───────────────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * Joins the three arrays of `userDevice`. `deviceId` is the key: an ActLumus
60
+ * and an ActLumus Plus may share a `serialNo`. Fall back to `serialNo` only for
61
+ * an item without `deviceId` whose `serialNo` is unique.
62
+ */
63
+ function telemetryFor(
64
+ cfg: DeviceConfigItem,
65
+ payload: UserDevicePayload,
66
+ ): DeviceTelemetryItem | undefined {
67
+ const byId = payload.devicesTelemetry.find((t) => t.deviceId === cfg.deviceId);
68
+ if (byId) return byId;
69
+ const sameSerial = payload.devicesConfig.filter((c) => c.serialNo === cfg.serialNo);
70
+ if (sameSerial.length !== 1) return undefined;
71
+ return payload.devicesTelemetry.find(
72
+ (t) => t.deviceId === undefined && t.serialNo === cfg.serialNo,
73
+ );
74
+ }
75
+
76
+ function applyDevices(payload: UserDevicePayload): void {
77
+ const next = new Map<number, DeviceRow>();
78
+ for (const cfg of payload.devicesConfig) {
79
+ const telemetry = telemetryFor(cfg, payload);
80
+ const previous = state.devices.get(cfg.deviceId);
81
+ next.set(cfg.deviceId, {
82
+ deviceId: cfg.deviceId,
83
+ serialNo: cfg.serialNo,
84
+ name: telemetry?.deviceName ?? `Device ${cfg.serialNo}`,
85
+ battery: telemetry?.batteryLevel ?? null,
86
+ connected: previous?.connected ?? false,
87
+ realTimeOn: previous?.realTimeOn ?? false,
88
+ });
89
+ }
90
+ state.devices = next;
91
+ state.phase = next.size > 0 ? 'ready' : 'empty';
92
+ render();
93
+ }
94
+
95
+ /**
96
+ * `deviceId` in commands needs nativeVersion >= 0.17.0; `serialNo` always goes.
97
+ * Below that, a shared serial cannot be addressed at all.
98
+ */
99
+ function address(d: DeviceRow): { serialNo: number; deviceId?: number } {
100
+ return versionAtLeast(state.nativeVersion, '0.17.0')
101
+ ? { serialNo: d.serialNo, deviceId: d.deviceId }
102
+ : { serialNo: d.serialNo };
103
+ }
104
+
105
+ /** Events carry `deviceId` only when the serial is shared; resolve both ways. */
106
+ function findRow(serialNo: number, deviceId?: number): DeviceRow | undefined {
107
+ if (deviceId !== undefined) return state.devices.get(deviceId);
108
+ return [...state.devices.values()].find((d) => d.serialNo === serialNo);
109
+ }
110
+
42
111
  // ── Render ─────────────────────────────────────────────────────────────
43
112
 
44
113
  function esc(value: string): string {
45
114
  return value.replace(
46
115
  /[&<>"]/g,
47
- (c) =>
48
- ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] as string,
116
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] as string,
49
117
  );
50
118
  }
51
119
 
52
120
  function render(): void {
53
121
  const el = document.getElementById('app');
54
122
  if (!el) return;
55
-
56
- const rows = [...state.devices.values()];
57
- const cards = rows.length
58
- ? rows.map(renderCard).join('')
59
- : '<p class="empty">No devices for this user yet.</p>';
60
-
61
123
  el.innerHTML = `
62
124
  <header class="hero">
63
- <h1>My White Label Home</h1>
64
- <p class="status ${state.connected ? 'ok' : ''}">${
65
- state.connected
66
- ? `bridge connected &bull; shell ${esc(state.shellVersion)}`
67
- : 'connecting to the native bridge…'
68
- }</p>
125
+ <h1>My Canvas Home</h1>
126
+ <p class="status">${state.nativeVersion ? `app ${esc(state.nativeVersion)}` : ''}</p>
69
127
  </header>
70
128
  <div class="toolbar">
71
- <button onclick="__home.refresh()">Refresh devices</button>
129
+ <button onclick="__home.retry()">Refresh</button>
72
130
  <button onclick="__home.settings()">Settings</button>
73
131
  </div>
74
- <main>${cards}</main>
132
+ <main>${body()}</main>
75
133
  `;
76
134
  }
77
135
 
136
+ function body(): string {
137
+ switch (state.phase) {
138
+ case 'outside-shell':
139
+ return '<p class="empty">Open this page with <code>npx condor-mobile-bridge dev</code>.</p>';
140
+ case 'loading':
141
+ return '<p class="empty">Loading your devices…</p>';
142
+ case 'empty':
143
+ return `<p class="empty">${
144
+ state.probablyNoDevices
145
+ ? 'No device is linked to your account yet.'
146
+ : 'Could not load your devices.'
147
+ }</p><p class="empty"><button onclick="__home.retry()">Try again</button></p>`;
148
+ case 'ready':
149
+ return [...state.devices.values()].map(renderCard).join('');
150
+ }
151
+ }
152
+
78
153
  function renderCard(d: DeviceRow): string {
79
154
  return `
80
155
  <section class="card">
@@ -84,8 +159,9 @@ function renderCard(d: DeviceRow): string {
84
159
  d.connected ? '● connected' : '○ disconnected'
85
160
  }</span>
86
161
  </div>
162
+ <p class="meta">Battery: ${d.battery === null ? '—' : `${Math.round(d.battery)}%`}</p>
87
163
  <div class="actions">
88
- <button onclick="__home.toggle(${d.serialNo})">${
164
+ <button onclick="__home.toggle(${d.deviceId})">${
89
165
  d.realTimeOn ? 'Stop real-time' : 'Start real-time'
90
166
  }</button>
91
167
  </div>
@@ -93,69 +169,71 @@ function renderCard(d: DeviceRow): string {
93
169
  `;
94
170
  }
95
171
 
96
- // ── Subscribers (native → iframe) ───────────────────────────────────────
97
-
98
- BridgeClient.on(BRIDGE_KEYS.userDevice, (payload: UserDevicePayload) => {
99
- for (const cfg of payload.devicesConfig ?? []) {
100
- const serialNo = Number((cfg as { serialNo?: number }).serialNo);
101
- if (Number.isNaN(serialNo)) continue;
102
- const existing = state.devices.get(serialNo);
103
- state.devices.set(serialNo, {
104
- serialNo,
105
- name: String((cfg as { deviceName?: string }).deviceName ?? 'Device'),
106
- connected: existing?.connected ?? false,
107
- realTimeOn: existing?.realTimeOn ?? false,
108
- });
109
- }
110
- render();
111
- });
172
+ // ── Subscribers (native → iframe) — before any command ─────────────────
173
+
174
+ // Roster changes arrive here too, not only replies to requestUserDevices().
175
+ BridgeClient.on(BRIDGE_KEYS.userDevice, applyDevices);
112
176
 
113
177
  BridgeClient.on(BRIDGE_KEYS.deviceConnection, (payload) => {
114
- const d = state.devices.get(payload.serialNo);
115
- if (d) {
116
- d.connected = payload.isConnect;
117
- render();
118
- }
178
+ const d = findRow(payload.serialNo, payload.deviceId);
179
+ if (!d) return;
180
+ d.connected = payload.isConnect;
181
+ render();
119
182
  });
120
183
 
121
184
  BridgeClient.on(BRIDGE_KEYS.realTime, (payload) => {
122
- const { serialNo, success, value } = payload;
123
- const d = state.devices.get(serialNo);
124
- if (d && success) {
125
- d.realTimeOn = value;
126
- render();
127
- }
185
+ const d = findRow(payload.serialNo, payload.deviceId);
186
+ if (!d || !payload.success) return;
187
+ d.realTimeOn = payload.value;
188
+ render();
128
189
  });
129
190
 
130
191
  // ── Actions (iframe → native) ───────────────────────────────────────────
131
192
 
193
+ async function loadDevices(getFromServer: boolean): Promise<void> {
194
+ const result = await BridgeClient.requestUserDevices({ getFromServer });
195
+ if (result.status === 'ok') return; // applyDevices already rendered it
196
+ state.probablyNoDevices = result.hasDevices === false;
197
+ state.phase = 'empty';
198
+ render();
199
+ }
200
+
132
201
  window.__home = {
133
- refresh: () => BridgeClient.send('userDevice', { getFromServer: true }),
202
+ retry: () => {
203
+ state.phase = 'loading';
204
+ render();
205
+ void loadDevices(true);
206
+ },
134
207
  settings: () => BridgeClient.openSettings(),
135
- toggle: (serialNo) => {
136
- const d = state.devices.get(serialNo);
137
- BridgeClient.send('realTime', { serialNo, value: !(d?.realTimeOn ?? false) });
208
+ toggle: (deviceId) => {
209
+ const d = state.devices.get(deviceId);
210
+ if (!d) return;
211
+ BridgeClient.send('realTime', { ...address(d), value: !d.realTimeOn });
138
212
  },
139
213
  };
140
214
 
141
215
  // ── Boot ────────────────────────────────────────────────────────────────
142
216
 
143
- function boot(): void {
217
+ async function boot(): Promise<void> {
144
218
  render();
145
- BridgeClient.handshake({ clientVersion: '1.0.0' })
146
- .then((ack) => {
147
- state.connected = ack.accepted;
148
- state.shellVersion = ack.shellVersion;
149
- render();
150
- })
151
- .catch((err: unknown) => console.error('[home] handshake failed:', err));
152
-
153
- // Ask the native side for the current device list.
154
- BridgeClient.send('userDevice', { getFromServer: false });
219
+ try {
220
+ const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
221
+ state.nativeVersion = ack.nativeVersion; // branch on this; the accepted flag is always true
222
+ } catch (err) {
223
+ console.error('[home] handshake failed:', err);
224
+ state.phase = 'outside-shell';
225
+ render();
226
+ return;
227
+ }
228
+ await loadDevices(false);
229
+ for (const d of state.devices.values()) {
230
+ // `add` subscribes to the connection state of one device; it pairs nothing.
231
+ BridgeClient.send('deviceConnection', { type: 'add', ...address(d) });
232
+ }
155
233
  }
156
234
 
157
235
  if (document.readyState === 'loading') {
158
- document.addEventListener('DOMContentLoaded', boot);
236
+ document.addEventListener('DOMContentLoaded', () => void boot());
159
237
  } else {
160
- boot();
238
+ void boot();
161
239
  }