@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.
- package/AGENTS.md +109 -42
- package/CHANGELOG.md +99 -0
- package/README.md +80 -14
- package/contract/bridge-contract.json +59 -14
- package/dist/cli.cjs +933 -3
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +932 -3
- package/dist/cli.js.map +1 -1
- package/dist/dev-harness.global.js +2110 -0
- package/dist/dev.cjs +1662 -0
- package/dist/dev.cjs.map +1 -0
- package/dist/dev.d.cts +683 -0
- package/dist/dev.d.ts +683 -0
- package/dist/dev.js +1611 -0
- package/dist/dev.js.map +1 -0
- package/dist/index.cjs +96 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +236 -14
- package/dist/index.d.ts +236 -14
- package/dist/index.js +94 -3
- package/dist/index.js.map +1 -1
- package/docs/agent-guide.md +75 -24
- package/docs/bridge-contract.md +121 -20
- package/docs/client-guide.md +42 -29
- package/docs/device-testing.md +114 -0
- package/docs/pilot-onboarding.md +31 -13
- package/docs/troubleshooting.md +79 -18
- package/examples/minimal-home/AGENTS.md +9 -6
- package/examples/minimal-home/README.md +10 -5
- package/examples/minimal-home/build.mjs +2 -1
- package/examples/minimal-home/index.html +2 -0
- package/examples/minimal-home/package.json +3 -2
- package/examples/minimal-home/src/main.ts +151 -73
- package/llms-full.txt +2086 -0
- package/llms.txt +15 -13
- package/package.json +9 -3
package/dist/dev.d.cts
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One linked device, as configured on the server. Mirrors
|
|
3
|
+
* `DeviceConfig.toMap()` in `condor_core`
|
|
4
|
+
* (`domain/entities/device_config_entity.dart`).
|
|
5
|
+
*
|
|
6
|
+
* `deviceId` is the server id and is unique. `serialNo` is printed on the
|
|
7
|
+
* device and **can repeat**: an ActLumus and an ActLumus Plus may share one.
|
|
8
|
+
*/
|
|
9
|
+
interface DeviceConfigItem {
|
|
10
|
+
/** Server id. Unique — the join key across the three arrays. */
|
|
11
|
+
deviceId: number;
|
|
12
|
+
/** Printed serial number. Not unique: an ActLumus and a Plus may share one. */
|
|
13
|
+
serialNo: number;
|
|
14
|
+
/** Where the device is worn. `null` when not set. */
|
|
15
|
+
deviceBodyPart: 'wrist' | 'necklace' | null;
|
|
16
|
+
/**
|
|
17
|
+
* Device model id. Absent when the server did not send one, which means an
|
|
18
|
+
* ActLumus (gen1); the Plus always carries it. Never a display name.
|
|
19
|
+
*/
|
|
20
|
+
modelId?: 'actlumus' | 'actlumus_plus' | (string & {});
|
|
21
|
+
/** BLE advertising period, in minutes, set by the Condor web system. */
|
|
22
|
+
bleConnectionPeriod: number;
|
|
23
|
+
autosync: boolean;
|
|
24
|
+
eraseDeviceMemory: boolean;
|
|
25
|
+
autoEraseDeviceMemory: boolean;
|
|
26
|
+
autoEraseDeviceMemoryThreshold: number;
|
|
27
|
+
readSensorsMemory: boolean;
|
|
28
|
+
startDevice: boolean;
|
|
29
|
+
readFullMemory: boolean;
|
|
30
|
+
allowFirmwareUpdate: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* One failed sync attempt. Mirrors `DeviceSyncError.toMap()`
|
|
34
|
+
* (`domain/entities/device_sync_error_entity.dart`).
|
|
35
|
+
*/
|
|
36
|
+
interface DeviceSyncErrorItem {
|
|
37
|
+
serialNo: number;
|
|
38
|
+
/** Epoch milliseconds. */
|
|
39
|
+
attemptDate: number;
|
|
40
|
+
/** Already localized by the app. Show it; do not branch on it. */
|
|
41
|
+
message: string;
|
|
42
|
+
/** Stable identifier to branch on. `null` for legacy errors. */
|
|
43
|
+
messageCode: string | null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Last known state of one device, as read over BLE. Mirrors
|
|
47
|
+
* `DeviceTelemetry.toMap()` (`domain/entities/device_telemetry_entity.dart`).
|
|
48
|
+
*
|
|
49
|
+
* Every reading is `null` until the device has synced at least once.
|
|
50
|
+
*/
|
|
51
|
+
interface DeviceTelemetryItem {
|
|
52
|
+
serialNo: number;
|
|
53
|
+
/**
|
|
54
|
+
* Server id. **Omitted** on legacy data the app could not attribute, so join
|
|
55
|
+
* by `deviceId` when present and fall back to `serialNo` only when that
|
|
56
|
+
* `serialNo` is unique in `devicesConfig`.
|
|
57
|
+
*/
|
|
58
|
+
deviceId?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Last successful sync, epoch milliseconds. The misspelling (`updata`) is the
|
|
61
|
+
* real field name — do not "fix" it.
|
|
62
|
+
*/
|
|
63
|
+
updataDateTime: number | null;
|
|
64
|
+
/** Display name reported by the device. Not in `devicesConfig`. */
|
|
65
|
+
deviceName: string | null;
|
|
66
|
+
/** Firmware version. */
|
|
67
|
+
deviceVersion: string | null;
|
|
68
|
+
hardwareVersion: string | null;
|
|
69
|
+
/** Device clock at the last sync, epoch milliseconds. */
|
|
70
|
+
deviceDateTime: number | null;
|
|
71
|
+
/** Data memory used, percent 0–100. */
|
|
72
|
+
memoryDataUsage: number | null;
|
|
73
|
+
/** System memory used, percent 0–100. */
|
|
74
|
+
memorySystemUsage: number | null;
|
|
75
|
+
/** Battery charge, percent 0–100. */
|
|
76
|
+
batteryLevel: number | null;
|
|
77
|
+
/** Battery voltage, volts. */
|
|
78
|
+
batteryVoltage: number | null;
|
|
79
|
+
isCharging: boolean | null;
|
|
80
|
+
isOnDock: boolean | null;
|
|
81
|
+
/** Sync failures of this device. Empty when there are none. */
|
|
82
|
+
syncErrors: DeviceSyncErrorItem[];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Per-device toggles of the user. Mirrors `DeviceUserConfig.toMap()`
|
|
86
|
+
* (`domain/entities/device_user_config_entity.dart`).
|
|
87
|
+
*/
|
|
88
|
+
interface DeviceUserConfigItem {
|
|
89
|
+
serialNo: number;
|
|
90
|
+
/** Server id. Omitted on legacy data — see `DeviceTelemetryItem.deviceId`. */
|
|
91
|
+
deviceId?: number;
|
|
92
|
+
syncTime: boolean;
|
|
93
|
+
realTime: boolean;
|
|
94
|
+
deleteMemory: boolean;
|
|
95
|
+
readAllMemory: boolean;
|
|
96
|
+
readAllSystemLog: boolean;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Snapshot of the user's device store. Mirrors `UserDevices.toMap()` in
|
|
100
|
+
* `condor_core`.
|
|
101
|
+
*
|
|
102
|
+
* **Joining the three arrays.** `devicesConfig` lists the devices; the other
|
|
103
|
+
* two describe them. Join on `deviceId`. When an item of `devicesTelemetry` or
|
|
104
|
+
* `devicesUserConfig` has no `deviceId` (legacy data), fall back to `serialNo`
|
|
105
|
+
* — but only when that `serialNo` appears once in `devicesConfig`. A device may
|
|
106
|
+
* have no telemetry yet: render it with empty readings, never drop it.
|
|
107
|
+
*
|
|
108
|
+
* **It is not guaranteed to answer.** Use `BridgeClient.requestUserDevices()`
|
|
109
|
+
* instead of `send('userDevice')`; see `docs/troubleshooting.md`.
|
|
110
|
+
*
|
|
111
|
+
* The app enforces no device cap. A commercial rule currently limits a user to
|
|
112
|
+
* two linked devices, but that can change with no signal in this contract — read
|
|
113
|
+
* the length of the array rather than hard-coding it.
|
|
114
|
+
*/
|
|
115
|
+
interface UserDevicePayload {
|
|
116
|
+
/** Epoch milliseconds of the snapshot. */
|
|
117
|
+
lastUpdate: number;
|
|
118
|
+
devicesConfig: DeviceConfigItem[];
|
|
119
|
+
devicesTelemetry: DeviceTelemetryItem[];
|
|
120
|
+
devicesUserConfig: DeviceUserConfigItem[];
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Parameters of the `sensorLog` command.
|
|
124
|
+
*
|
|
125
|
+
* `startTime` / `endTime` delimit a period — `startTime` is **inclusive**,
|
|
126
|
+
* `endTime` **exclusive**, both in the strict format `YYYY-MM-DD HH:MM:SS`
|
|
127
|
+
* (no `T`, no offset, no date-only form). Requires nativeVersion >= 0.15.0.
|
|
128
|
+
*
|
|
129
|
+
* ⚠️ These timestamps are **device-local time, not UTC**: `log_time` passes
|
|
130
|
+
* through from the device RTC. Build the range with local `Date` getters
|
|
131
|
+
* (`getFullYear()`, `getHours()`, …) and **never** with `toISOString()` —
|
|
132
|
+
* converting to UTC shifts every series by the timezone offset, producing
|
|
133
|
+
* plausible data and no visible error.
|
|
134
|
+
*
|
|
135
|
+
* The `startingAfter` / `endingBefore` cursors are **opaque** and mutually
|
|
136
|
+
* exclusive: echo back the value you received, never construct one.
|
|
137
|
+
*/
|
|
138
|
+
interface SensorLogQueryPayload {
|
|
139
|
+
/** Echoed back in the reply — correlates concurrent requests. */
|
|
140
|
+
requestId: string;
|
|
141
|
+
serialNo: number;
|
|
142
|
+
/** `wrist` or `necklace`. Omit for no filter. */
|
|
143
|
+
bodyPart?: string;
|
|
144
|
+
/** 1..1000 (backend default is 10). Outside the range: `INVALID_LIMIT`. */
|
|
145
|
+
limit?: number;
|
|
146
|
+
startingAfter?: string;
|
|
147
|
+
endingBefore?: string;
|
|
148
|
+
/** Inclusive, `YYYY-MM-DD HH:MM:SS`, in device-local time. */
|
|
149
|
+
startTime?: string;
|
|
150
|
+
/** Exclusive, `YYYY-MM-DD HH:MM:SS`, in device-local time. */
|
|
151
|
+
endTime?: string;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* One sensor log, passed through from the backend `get_log` contract.
|
|
155
|
+
*
|
|
156
|
+
* **Cadence.** The device writes one log per period, which is programmable per
|
|
157
|
+
* device (default 60 s). Never assume one per minute: size pagination from the
|
|
158
|
+
* gaps between `log_time` values you actually receive.
|
|
159
|
+
*
|
|
160
|
+
* `null` on a channel means the hardware does not have it, never zero.
|
|
161
|
+
*/
|
|
162
|
+
interface SensorLogItem {
|
|
163
|
+
/** Stable id of the log. */
|
|
164
|
+
id: number;
|
|
165
|
+
/** `'YYYY-MM-DD HH:MM:SS'` in **device-local time**, not UTC. */
|
|
166
|
+
log_time: string;
|
|
167
|
+
log_type: number;
|
|
168
|
+
body_part: 'wrist' | 'necklace' | null;
|
|
169
|
+
/** Degrees Celsius, already converted by the backend. */
|
|
170
|
+
temperature: number | null;
|
|
171
|
+
/** Photopic illuminance, lux. */
|
|
172
|
+
lux_photopic: number | null;
|
|
173
|
+
/** Melanopic illuminance (melanopic EDI), lux. */
|
|
174
|
+
lux_melanopic: number | null;
|
|
175
|
+
company_id?: number;
|
|
176
|
+
user_id?: number;
|
|
177
|
+
device_id?: number;
|
|
178
|
+
orientation?: number | null;
|
|
179
|
+
light_sensor_f1?: number | null;
|
|
180
|
+
light_sensor_f2?: number | null;
|
|
181
|
+
light_sensor_f3?: number | null;
|
|
182
|
+
light_sensor_f4?: number | null;
|
|
183
|
+
light_sensor_f5?: number | null;
|
|
184
|
+
light_sensor_f6?: number | null;
|
|
185
|
+
light_sensor_f7?: number | null;
|
|
186
|
+
light_sensor_f8?: number | null;
|
|
187
|
+
light_sensor_clear?: number | null;
|
|
188
|
+
light_sensor_nir?: number | null;
|
|
189
|
+
/** ActLumus Plus only (`null` on gen1). Approximates CIE X; not an exact CIE value. */
|
|
190
|
+
light_sensor_x?: number | null;
|
|
191
|
+
/** ActLumus Plus only (`null` on gen1). */
|
|
192
|
+
light_sensor_y?: number | null;
|
|
193
|
+
/** ActLumus Plus only (`null` on gen1). */
|
|
194
|
+
light_sensor_z?: number | null;
|
|
195
|
+
/** ActLumus Plus only (`null` on gen1). Reserved by the firmware; do not interpret. */
|
|
196
|
+
light_sensor_fd?: number | null;
|
|
197
|
+
act_data_pim?: number | null;
|
|
198
|
+
act_data_tat?: number | null;
|
|
199
|
+
act_data_zcm?: number | null;
|
|
200
|
+
off_wrist?: number | null;
|
|
201
|
+
cap_sensor_1?: number | null;
|
|
202
|
+
cap_sensor_2?: number | null;
|
|
203
|
+
event?: number | null;
|
|
204
|
+
state?: number | null;
|
|
205
|
+
from_service?: boolean;
|
|
206
|
+
/** Batch markers of the upload, not per-log timestamps. Do not plot them. */
|
|
207
|
+
device_log_time?: string | null;
|
|
208
|
+
app_log_time?: string | null;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Reply to the `sensorLog` command.
|
|
212
|
+
*
|
|
213
|
+
* An envelope carrying the echoed `requestId` for correlation (so concurrent
|
|
214
|
+
* keyset pagination works) plus `success` / `error`. On success it brings the
|
|
215
|
+
* page of logs — chronological ascending, temperature in °C, naive timestamps
|
|
216
|
+
* in **device-local time** — and the opaque cursors.
|
|
217
|
+
*
|
|
218
|
+
* With a time range, `hasMore` / `hasPrevious` are computed **within the
|
|
219
|
+
* period**, so `hasMore: false` means "this period is complete", not "no more
|
|
220
|
+
* data exists".
|
|
221
|
+
*
|
|
222
|
+
* On failure, `error` carries the contract code (`CONFLICTING_CURSORS`,
|
|
223
|
+
* `INVALID_TIME_RANGE`, `invalid session`, …) — a **diagnostic code, never
|
|
224
|
+
* text to show a user**. Note that `fetchSensorLog` RESOLVES with
|
|
225
|
+
* `success: false`; it only rejects on timeout.
|
|
226
|
+
*/
|
|
227
|
+
interface SensorLogPayload {
|
|
228
|
+
requestId: string;
|
|
229
|
+
success: boolean;
|
|
230
|
+
/** Chronological ascending. Without a cursor or range: the most recent page. */
|
|
231
|
+
logs?: SensorLogItem[];
|
|
232
|
+
startingAfter?: string | null;
|
|
233
|
+
endingBefore?: string | null;
|
|
234
|
+
hasMore?: boolean;
|
|
235
|
+
hasPrevious?: boolean;
|
|
236
|
+
error?: string;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Aggregate environment status (native → JS). Mirrors `SysNotification` in the
|
|
240
|
+
* app: the flags a client uses to explain why things are not working — for
|
|
241
|
+
* instance, Bluetooth being off prevents scanning and connecting to devices.
|
|
242
|
+
*
|
|
243
|
+
* Emitted on attach (before your bundle exists), again right after
|
|
244
|
+
* `handshake-ack`, and on every change. `deviceSyncError` maps `serialNo` to an
|
|
245
|
+
* error message the app has already localized. A `serialNo` shared by two
|
|
246
|
+
* devices has no single owner, so it is **left out** of `deviceSyncError` and
|
|
247
|
+
* only appears in `deviceSyncErrorById`.
|
|
248
|
+
*/
|
|
249
|
+
interface SystemStatusPayload {
|
|
250
|
+
bluetooth: boolean;
|
|
251
|
+
internet: boolean;
|
|
252
|
+
permission: boolean;
|
|
253
|
+
hasDevices: boolean;
|
|
254
|
+
/**
|
|
255
|
+
* Optional permissions not yet granted, as `AppPermission` names
|
|
256
|
+
* (e.g. `["notification", "backgroundLocation"]`). Additive and
|
|
257
|
+
* backward-compatible; render your own optional-permissions nudge from it.
|
|
258
|
+
* Empty array when nothing is missing. Battery is intentionally excluded
|
|
259
|
+
* (covered by the app's native OEM guidance, not this channel).
|
|
260
|
+
*/
|
|
261
|
+
missingOptionalPermissions: string[];
|
|
262
|
+
/**
|
|
263
|
+
* Essential permissions not yet granted, as `AppPermission` names
|
|
264
|
+
* (e.g. `["bluetoothScan", "bluetoothConnect"]`). These are REQUIRED for the
|
|
265
|
+
* app to work — background collection stays off without them. Render your own
|
|
266
|
+
* blocking "required permissions" card from it. Additive and
|
|
267
|
+
* backward-compatible. Empty array when all essentials are granted.
|
|
268
|
+
*/
|
|
269
|
+
missingEssentialPermissions: string[];
|
|
270
|
+
deviceSyncError: Record<number, string>;
|
|
271
|
+
/**
|
|
272
|
+
* Same errors keyed by the server `deviceId`, for **every** device, shared
|
|
273
|
+
* `serialNo` included. Absent below `nativeVersion` 0.17.0, so guard it.
|
|
274
|
+
*/
|
|
275
|
+
deviceSyncErrorById?: Record<number, string>;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Realistic fixture data for the development harness.
|
|
280
|
+
*
|
|
281
|
+
* Shapes follow the typed payloads in `../payloads`, which mirror the Dart
|
|
282
|
+
* `toMap()` of `condor_core`. Timestamps that the device produces (`log_time`)
|
|
283
|
+
* are **device-local** wall-clock strings, built with local `Date` getters —
|
|
284
|
+
* never `toISOString()`.
|
|
285
|
+
*/
|
|
286
|
+
|
|
287
|
+
/** Default log period of an ActLumus, in seconds. Programmable per device. */
|
|
288
|
+
declare const DEFAULT_LOG_PERIOD_S = 60;
|
|
289
|
+
/** `YYYY-MM-DD HH:MM:SS` in local time — the format of `log_time`. */
|
|
290
|
+
declare function formatLocalTimestamp(date: Date): string;
|
|
291
|
+
/** Parses a local `YYYY-MM-DD HH:MM:SS` back to epoch ms. */
|
|
292
|
+
declare function parseLocalTimestamp(value: string): number;
|
|
293
|
+
/** Two devices with distinct serial numbers: the common case. */
|
|
294
|
+
declare function twoDevices(now?: number): UserDevicePayload;
|
|
295
|
+
/**
|
|
296
|
+
* An ActLumus and an ActLumus Plus sharing serial number 7047 — real data
|
|
297
|
+
* looked like this for the first pilot account. Only `deviceId` tells them
|
|
298
|
+
* apart.
|
|
299
|
+
*/
|
|
300
|
+
declare function sharedSerial(now?: number): UserDevicePayload;
|
|
301
|
+
declare function systemStatus(overrides?: Partial<SystemStatusPayload>): SystemStatusPayload;
|
|
302
|
+
/**
|
|
303
|
+
* Answers a `sensorLog` query with the backend's paging semantics:
|
|
304
|
+
* - no range: the most recent `limit` logs; older pages through `endingBefore`;
|
|
305
|
+
* - with `startTime`: the oldest logs of the range first; newer pages through
|
|
306
|
+
* `startingAfter` (with only `endTime`: the most recent before it);
|
|
307
|
+
* - `logs` always chronological ascending; `hasMore` / `hasPrevious` computed
|
|
308
|
+
* within the range.
|
|
309
|
+
*/
|
|
310
|
+
declare function sensorLogPage(query: SensorLogQueryPayload, opts?: {
|
|
311
|
+
now?: number;
|
|
312
|
+
periodS?: number;
|
|
313
|
+
deviceId?: number;
|
|
314
|
+
}): SensorLogPayload;
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Valibot schemas for the shell↔client `postMessage` protocol.
|
|
318
|
+
*
|
|
319
|
+
* Client content runs inside `<iframe sandbox="allow-scripts">` with a `null`
|
|
320
|
+
* origin, so it talks to the Condor-controlled shell over `window.postMessage`.
|
|
321
|
+
* These schemas are the **only accepted contract** — any malformed message is
|
|
322
|
+
* discarded by the shell in silence, which is why hand-rolled `postMessage`
|
|
323
|
+
* calls appear to do nothing. Use `BridgeClient`.
|
|
324
|
+
*
|
|
325
|
+
* **Threat model:** the iframe may be hostile (a compromised supply chain, XSS
|
|
326
|
+
* in the client code). These schemas plus the `ShellPolicy` enforced by
|
|
327
|
+
* `BridgeShell` form the structural boundary.
|
|
328
|
+
*/
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Default-deny policy enforced by `BridgeShell`. Every key and command coming
|
|
332
|
+
* from the iframe is checked against these lists before it touches the native
|
|
333
|
+
* bridge.
|
|
334
|
+
*
|
|
335
|
+
* - `readOnlyKeys` — keys the client may subscribe to.
|
|
336
|
+
* - `allowedCommands` — pass-through commands, no human confirmation.
|
|
337
|
+
* - `requiresConfirmation` — commands that fire the shell's
|
|
338
|
+
* `onConfirmationRequest` callback, so a person approves or declines.
|
|
339
|
+
* - `deniedCommands` — explicit block, defence in depth: destructive commands
|
|
340
|
+
* such as `dfu` that must never flow from the iframe.
|
|
341
|
+
*
|
|
342
|
+
* **Not embedded in the package** — the `BridgeShell` host supplies the
|
|
343
|
+
* concrete policy. That keeps the package agnostic to the catalogue and lets
|
|
344
|
+
* the policy evolve without a release.
|
|
345
|
+
*/
|
|
346
|
+
interface ShellPolicy {
|
|
347
|
+
readOnlyKeys: string[];
|
|
348
|
+
allowedCommands: string[];
|
|
349
|
+
requiresConfirmation: string[];
|
|
350
|
+
deniedCommands: string[];
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Argument of the `BridgeShell` `onConfirmationRequest` callback.
|
|
354
|
+
*
|
|
355
|
+
* Resolving `approve: true` lets the command flow to the native side.
|
|
356
|
+
* Resolving `approve: false` makes the shell send the iframe a `nack` with
|
|
357
|
+
* `reason: 'user-rejected'` — which client code only ever sees as a console
|
|
358
|
+
* warning, so the ack it was waiting for simply never arrives.
|
|
359
|
+
*/
|
|
360
|
+
interface ConfirmationRequest {
|
|
361
|
+
command: string;
|
|
362
|
+
args: Record<string, unknown>;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Result of `BridgeShell.verifySandbox()` after a round trip with the iframe.
|
|
366
|
+
* If any `*Reachable` flag comes back `true`, the boundary is leaking: the
|
|
367
|
+
* override prefix injected by `bootstrapClientIframe` failed, and the app must
|
|
368
|
+
* not render client code with a path to the native layer.
|
|
369
|
+
*/
|
|
370
|
+
interface SandboxVerifyResult {
|
|
371
|
+
flutterBridgeReachable: boolean;
|
|
372
|
+
condorBridgeReachable: boolean;
|
|
373
|
+
/** Whether `window.CondorNative` is visible inside the iframe. */
|
|
374
|
+
nativeChannelReachable: boolean;
|
|
375
|
+
/** `true` only when none of the three is reachable. */
|
|
376
|
+
ok: boolean;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* `BridgeShell` — the API for the **Condor-controlled shell** that wraps
|
|
381
|
+
* external client content inside `<iframe sandbox="allow-scripts">`.
|
|
382
|
+
*
|
|
383
|
+
* This is not client-facing. If you are writing the bundle that renders as the
|
|
384
|
+
* app's Home, you want `BridgeClient`.
|
|
385
|
+
*
|
|
386
|
+
* Responsibilities:
|
|
387
|
+
* 1. **Layer 1 — `bootstrapClientIframe(iframe, clientPath)`:** fetches the
|
|
388
|
+
* client HTML, prepends an override script that removes the native globals
|
|
389
|
+
* from the iframe, and sets it as `srcdoc`. The override runs at parse time,
|
|
390
|
+
* before any other script.
|
|
391
|
+
* 2. **Layer 2 — `verifySandbox(iframe)`:** a `postMessage` round trip asking
|
|
392
|
+
* the iframe to attest that the native globals really are `undefined`.
|
|
393
|
+
* 3. **Routing between iframe and native:** listens for `message` events from
|
|
394
|
+
* the iframe, validates them with Valibot, applies the default-deny
|
|
395
|
+
* `ShellPolicy`, and forwards to `Bridge`. Native events go back to the
|
|
396
|
+
* iframe over `postMessage`.
|
|
397
|
+
* 4. **Human confirmation for sensitive commands:** commands listed in
|
|
398
|
+
* `policy.requiresConfirmation` fire the `onConfirmationRequest` callback
|
|
399
|
+
* before touching the native side, so the shell can present UI.
|
|
400
|
+
*/
|
|
401
|
+
|
|
402
|
+
interface BridgeShellOptions {
|
|
403
|
+
/**
|
|
404
|
+
* The iframe hosting the client. It may be empty at construction time —
|
|
405
|
+
* `bootstrapClientIframe` is called later to populate it.
|
|
406
|
+
*/
|
|
407
|
+
iframe: HTMLIFrameElement;
|
|
408
|
+
/**
|
|
409
|
+
* The default-deny policy. Every key and command arriving from the iframe is
|
|
410
|
+
* checked against these lists. See `ShellPolicy`.
|
|
411
|
+
*/
|
|
412
|
+
policy: ShellPolicy;
|
|
413
|
+
/**
|
|
414
|
+
* Synchronous or asynchronous callback invoked when the client issues a
|
|
415
|
+
* command listed in `policy.requiresConfirmation`. The shell presents its
|
|
416
|
+
* confirmation UI and resolves `true` to approve or `false` to decline.
|
|
417
|
+
*
|
|
418
|
+
* If it is not provided, such commands are **denied** automatically —
|
|
419
|
+
* fail-safe.
|
|
420
|
+
*/
|
|
421
|
+
onConfirmationRequest?: (req: ConfirmationRequest) => boolean | Promise<boolean>;
|
|
422
|
+
}
|
|
423
|
+
declare class BridgeShell {
|
|
424
|
+
private readonly iframe;
|
|
425
|
+
private readonly policy;
|
|
426
|
+
private readonly onConfirm?;
|
|
427
|
+
private readonly subscriptions;
|
|
428
|
+
private detached;
|
|
429
|
+
/**
|
|
430
|
+
* The native version, fetched once and cached. `null` until the native side
|
|
431
|
+
* answers, and permanently `null` in a browser with no native host.
|
|
432
|
+
*/
|
|
433
|
+
private nativeVersion;
|
|
434
|
+
/** In-flight native handshake; guarantees a single call. */
|
|
435
|
+
private nativeHandshake;
|
|
436
|
+
private pendingVerifies;
|
|
437
|
+
constructor(opts: BridgeShellOptions);
|
|
438
|
+
/**
|
|
439
|
+
* **Layer 1.** Bootstraps by fetching a URL.
|
|
440
|
+
*
|
|
441
|
+
* A shortcut for the common HTTP/HTTPS case: it runs `fetch(clientPath)` and
|
|
442
|
+
* delegates to `bootstrapClientIframeWithHtml`.
|
|
443
|
+
*
|
|
444
|
+
* **It does not work on the `file://` scheme** — Android's Chromium WebView
|
|
445
|
+
* blocks it with "URL scheme 'file' is not supported". For an asset bundled
|
|
446
|
+
* in the APK, or a bundle inlined at build time, use
|
|
447
|
+
* `bootstrapClientIframeWithHtml(htmlString)` instead.
|
|
448
|
+
*
|
|
449
|
+
* **Call it once at shell boot**, before any other interaction. After it
|
|
450
|
+
* returns, invoke `verifySandbox()` for attestation (Layer 2).
|
|
451
|
+
*/
|
|
452
|
+
bootstrapClientIframe(clientPath?: string): Promise<void>;
|
|
453
|
+
/**
|
|
454
|
+
* **Layer 1.** Bootstraps from an HTML string.
|
|
455
|
+
*
|
|
456
|
+
* Takes the client HTML directly — already read from a file, embedded at
|
|
457
|
+
* build time, read from storage — and sets it as the iframe's `srcdoc`,
|
|
458
|
+
* prefixed by the override script, which runs at parse time before any
|
|
459
|
+
* client script.
|
|
460
|
+
*
|
|
461
|
+
* Use this when `fetch` does not apply: an asset bundled under `file://`, a
|
|
462
|
+
* bundle inlined at build time, any scenario without network. Android
|
|
463
|
+
* WebView blocks `fetch` on the `file://` scheme, so this is the path the
|
|
464
|
+
* app actually takes. For HTTP, use `bootstrapClientIframe(clientPath)`.
|
|
465
|
+
*
|
|
466
|
+
* **Call it once at shell boot**, before any other interaction. After it
|
|
467
|
+
* returns, invoke `verifySandbox()` for attestation (Layer 2).
|
|
468
|
+
*/
|
|
469
|
+
bootstrapClientIframeWithHtml(clientHtml: string): Promise<void>;
|
|
470
|
+
private waitForIframeLoad;
|
|
471
|
+
/**
|
|
472
|
+
* **Layer 2.** A `postMessage` round trip asking the iframe for an
|
|
473
|
+
* authoritative attestation of whether the native globals are reachable.
|
|
474
|
+
* Times out after 2 s.
|
|
475
|
+
*
|
|
476
|
+
* `ok` is `true` only when none of them is reachable. If it comes back
|
|
477
|
+
* `false`, **the shell must abort the app boot**: Layer 1 failed, and client
|
|
478
|
+
* code would run with a path to the native layer.
|
|
479
|
+
*/
|
|
480
|
+
verifySandbox(): Promise<SandboxVerifyResult>;
|
|
481
|
+
/**
|
|
482
|
+
* Tears the shell down: removes the `message` listener, cancels pending
|
|
483
|
+
* subscriptions on the native `Bridge`, and clears internal state.
|
|
484
|
+
*/
|
|
485
|
+
detach(): void;
|
|
486
|
+
private onMessage;
|
|
487
|
+
private handleSubscribe;
|
|
488
|
+
private handleUnsubscribe;
|
|
489
|
+
private handleSend;
|
|
490
|
+
/**
|
|
491
|
+
* Resolves `nativeVersion`, firing the native handshake the first time and
|
|
492
|
+
* caching the result.
|
|
493
|
+
*
|
|
494
|
+
* ⚠️ `Bridge.handshake` has **no timeout**: its promise only settles when the
|
|
495
|
+
* native side emits `handshake-ack`. In a browser with no native host it
|
|
496
|
+
* never settles, so the race below is what guarantees the client always gets
|
|
497
|
+
* an answer — `null` — instead of hanging.
|
|
498
|
+
*/
|
|
499
|
+
private resolveNativeVersion;
|
|
500
|
+
private postToClient;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Mock adapter — active when no native host is detected: a plain browser,
|
|
505
|
+
* jsdom without an installed host, Storybook, and so on.
|
|
506
|
+
*
|
|
507
|
+
* Behaviour:
|
|
508
|
+
* - `callHandler` records `{ handlerName, payload, timestamp }` in an internal
|
|
509
|
+
* buffer (capped at 100, FIFO), logs through `console.debug`, and dispatches
|
|
510
|
+
* to listeners registered with `onCallHandler`.
|
|
511
|
+
* - `triggerEmit` dispatches through `window.CondorBridge.emit`, so the real
|
|
512
|
+
* bridge treats it as if it came from the native side: `handshake-ack`
|
|
513
|
+
* resolves the handshake, every other key reaches its subscribers.
|
|
514
|
+
* - `getMessages` returns a read-only snapshot of the buffer.
|
|
515
|
+
* - `clearMessages` empties it, which is useful in tests.
|
|
516
|
+
* - `onCallHandler(listener)` registers a listener invoked on every send and
|
|
517
|
+
* returns an unsubscribe function — used by the dev panel's auto-respond
|
|
518
|
+
* toggle, and for assertions in tests.
|
|
519
|
+
*/
|
|
520
|
+
interface MockMessage {
|
|
521
|
+
handlerName: string;
|
|
522
|
+
payload: unknown;
|
|
523
|
+
timestamp: number;
|
|
524
|
+
}
|
|
525
|
+
interface MockAdapter {
|
|
526
|
+
readonly isMock: true;
|
|
527
|
+
callHandler(handlerName: string, payload: unknown): void;
|
|
528
|
+
getMessages(): readonly MockMessage[];
|
|
529
|
+
clearMessages(): void;
|
|
530
|
+
triggerEmit(key: string, payload: unknown): void;
|
|
531
|
+
/**
|
|
532
|
+
* Registers a listener invoked on every `callHandler` (a JS → native
|
|
533
|
+
* message). Returns an unsubscribe function.
|
|
534
|
+
*
|
|
535
|
+
* Errors thrown by a listener are caught and logged; they affect neither the
|
|
536
|
+
* other listeners nor the bridge pipeline.
|
|
537
|
+
*/
|
|
538
|
+
onCallHandler(listener: (msg: MockMessage) => void): () => void;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Scenarios for the development harness — the device behaviours that a plain
|
|
543
|
+
* "answer everything at once" mock hides.
|
|
544
|
+
*
|
|
545
|
+
* Each one reproduces something the Condor Canvas app really does, so a bundle
|
|
546
|
+
* that survives every scenario in the browser survives them on a device.
|
|
547
|
+
*/
|
|
548
|
+
|
|
549
|
+
/** Native schema version the harness reports by default. */
|
|
550
|
+
declare const HARNESS_NATIVE_VERSION = "0.17.0";
|
|
551
|
+
interface Scenario {
|
|
552
|
+
/** Shown in the harness toolbar. */
|
|
553
|
+
description: string;
|
|
554
|
+
/**
|
|
555
|
+
* Relayed to the client by the handshake. `null` reproduces a shell that
|
|
556
|
+
* could not reach the native side: the client receives `nativeVersion: null`.
|
|
557
|
+
*/
|
|
558
|
+
nativeVersion: string | null;
|
|
559
|
+
/** Delay of every native reply, in ms. A pair draws uniformly from the range. */
|
|
560
|
+
latencyMs: number | [min: number, max: number];
|
|
561
|
+
/**
|
|
562
|
+
* Time until the app's background service is up, in ms after the page loads.
|
|
563
|
+
* Until then `ping` and `userDevice` are **dropped without a reply** — on
|
|
564
|
+
* the device they travel to that service, and a message sent before it runs
|
|
565
|
+
* is lost.
|
|
566
|
+
*/
|
|
567
|
+
serviceStartMs: number;
|
|
568
|
+
/**
|
|
569
|
+
* `userDevice` never answers. Today the app replies only when the device
|
|
570
|
+
* list loads, so an account with no linked device gets silence.
|
|
571
|
+
*/
|
|
572
|
+
userDeviceSilent: boolean;
|
|
573
|
+
/** When set, every `sensorLog` reply fails with this `error`. */
|
|
574
|
+
sensorLogError?: string;
|
|
575
|
+
/** Log period of the devices, in seconds (programmable; default 60). */
|
|
576
|
+
logPeriodS: number;
|
|
577
|
+
systemStatus: SystemStatusPayload;
|
|
578
|
+
devices: () => UserDevicePayload;
|
|
579
|
+
}
|
|
580
|
+
type ScenarioName = 'default' | 'cold-start' | 'no-devices' | 'shared-serial' | 'offline';
|
|
581
|
+
declare const SCENARIOS: Record<ScenarioName, Scenario>;
|
|
582
|
+
declare function resolveScenario(scenario: ScenarioName | Scenario | undefined): Scenario;
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Development harness — runs a client bundle in the browser the way the Condor
|
|
586
|
+
* Canvas app runs it on a device.
|
|
587
|
+
*
|
|
588
|
+
* The bundle goes into a real `<iframe sandbox="allow-scripts">` through the
|
|
589
|
+
* real `BridgeShell`, under the app's policy. Behind the shell, a simulated
|
|
590
|
+
* native side answers commands as the app does — including the parts that
|
|
591
|
+
* hurt: replies lost while the background service starts, silence for an
|
|
592
|
+
* account with no device, latency. Opening `dist/index.html` directly gives
|
|
593
|
+
* none of this, and `BridgeClient` refuses to run there.
|
|
594
|
+
*
|
|
595
|
+
* Use it through the CLI (`npx condor-mobile-bridge dev -i dist/index.html`)
|
|
596
|
+
* or, with your own dev server, from `@condorinst/mobile-bridge/dev`:
|
|
597
|
+
*
|
|
598
|
+
* ```ts
|
|
599
|
+
* import { createDevHarness } from '@condorinst/mobile-bridge/dev';
|
|
600
|
+
* const html = await (await fetch('/index.html')).text();
|
|
601
|
+
* await createDevHarness({ html, scenario: 'cold-start' });
|
|
602
|
+
* ```
|
|
603
|
+
*
|
|
604
|
+
* The harness page is the **top** frame. Never import this module into the
|
|
605
|
+
* bundle itself: `condor-mobile-bridge validate` rejects a bundle that does.
|
|
606
|
+
*/
|
|
607
|
+
|
|
608
|
+
/** Marker the `validate` CLI looks for: the harness must never ship in a bundle. */
|
|
609
|
+
declare const DEV_HARNESS_MARKER = "__condorDevHarness__";
|
|
610
|
+
interface HarnessLogEntry {
|
|
611
|
+
at: number;
|
|
612
|
+
/** `sent`: the bundle's command reached the simulated native side. */
|
|
613
|
+
kind: 'sent' | 'reply' | 'dropped' | 'info';
|
|
614
|
+
command?: string;
|
|
615
|
+
key?: string;
|
|
616
|
+
detail: string;
|
|
617
|
+
}
|
|
618
|
+
interface DevHarnessOptions {
|
|
619
|
+
/** The bundle's `index.html`, self-contained, as a string. */
|
|
620
|
+
html: string;
|
|
621
|
+
/** A preset name or a full scenario. Defaults to `'default'`. */
|
|
622
|
+
scenario?: ScenarioName | Scenario;
|
|
623
|
+
/** Where the iframe goes. Defaults to `document.body`. */
|
|
624
|
+
mount?: HTMLElement;
|
|
625
|
+
/** Shows the floating dev panel (sent log + manual emit). Defaults to `true`. */
|
|
626
|
+
devPanel?: boolean;
|
|
627
|
+
/**
|
|
628
|
+
* Answers the confirmation the app asks for `syncTime`, `deleteMemory` and
|
|
629
|
+
* `logout`. Defaults to an in-page dialog.
|
|
630
|
+
*/
|
|
631
|
+
onConfirmationRequest?: (req: ConfirmationRequest) => boolean | Promise<boolean>;
|
|
632
|
+
/** Called for every harness log entry, besides `console.info`. */
|
|
633
|
+
onLog?: (entry: HarnessLogEntry) => void;
|
|
634
|
+
}
|
|
635
|
+
interface DevHarness {
|
|
636
|
+
readonly scenario: Scenario;
|
|
637
|
+
readonly iframe: HTMLIFrameElement;
|
|
638
|
+
readonly shell: BridgeShell;
|
|
639
|
+
readonly log: readonly HarnessLogEntry[];
|
|
640
|
+
/** Emits a native event to the bundle, as the app would. */
|
|
641
|
+
emit(key: string, payload: unknown): void;
|
|
642
|
+
/**
|
|
643
|
+
* Presses the OS back button. Emits `navigationBack` only when the bundle
|
|
644
|
+
* last reported `canGoBack: true`, as the app does; otherwise the app keeps
|
|
645
|
+
* the Home and the bundle gets nothing.
|
|
646
|
+
*/
|
|
647
|
+
pressBack(): void;
|
|
648
|
+
/** Tears everything down. */
|
|
649
|
+
destroy(): void;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* The simulated native side. Exported for tests; most callers want
|
|
653
|
+
* {@link createDevHarness}.
|
|
654
|
+
*/
|
|
655
|
+
declare function createSimulatedNative(adapter: MockAdapter, scenario: Scenario, hooks?: {
|
|
656
|
+
onLog?: (entry: HarnessLogEntry) => void;
|
|
657
|
+
now?: () => number;
|
|
658
|
+
}): {
|
|
659
|
+
readonly log: readonly HarnessLogEntry[];
|
|
660
|
+
pressBack(): void;
|
|
661
|
+
dispose(): void;
|
|
662
|
+
};
|
|
663
|
+
/**
|
|
664
|
+
* Mounts the bundle in a sandboxed iframe behind the real `BridgeShell` and a
|
|
665
|
+
* simulated native side. See the module comment.
|
|
666
|
+
*/
|
|
667
|
+
declare function createDevHarness(opts: DevHarnessOptions): Promise<DevHarness>;
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* The shell policy of the Condor Canvas app, reproduced for the development
|
|
671
|
+
* harness so a command the app refuses is refused in the browser too.
|
|
672
|
+
*
|
|
673
|
+
* Mirrors `apps/canvas/web-mock-src/src/shell.ts` (see
|
|
674
|
+
* `contract/bridge-contract.json` → `verifiedAgainst.sources.shellPolicy`).
|
|
675
|
+
* `tests/dev-harness.test.ts` derives the same lists from the contract and
|
|
676
|
+
* fails if they drift.
|
|
677
|
+
*/
|
|
678
|
+
|
|
679
|
+
declare const CANVAS_POLICY: ShellPolicy;
|
|
680
|
+
/** CSP of the app shell, inherited by the client's `srcdoc` iframe. */
|
|
681
|
+
declare const CANVAS_CSP: string;
|
|
682
|
+
|
|
683
|
+
export { CANVAS_CSP, CANVAS_POLICY, DEFAULT_LOG_PERIOD_S, DEV_HARNESS_MARKER, type DevHarness, type DevHarnessOptions, HARNESS_NATIVE_VERSION, type HarnessLogEntry, SCENARIOS, type Scenario, type ScenarioName, createDevHarness, createSimulatedNative, formatLocalTimestamp, parseLocalTimestamp, resolveScenario, sensorLogPage, sharedSerial, systemStatus, twoDevices };
|