@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.
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "contractVersion": "0.22.0",
3
+ "contractVersion": "0.23.0",
4
4
  "description": "Machine-readable contract of the Condor Canvas JS<->Native bridge. It states what the TypeScript declarations in dist/index.d.ts cannot express: which keys and commands a sandboxed client can actually reach, which ones need human confirmation, where messages are dropped in silence, the hard limits, and which capability each native version carries. The TypeScript types remain canonical for payload shapes.",
5
5
  "audience": "Developers and AI agents building the client bundle that renders inside the Canvas Home.",
6
6
 
7
7
  "versions": {
8
8
  "note": "Four independent numbers coexist. Only nativeVersion tells you what the device can do.",
9
- "package": "0.22.0",
9
+ "package": "0.23.0",
10
10
  "shellVersion": "0.6.0",
11
11
  "clientProtocolVersion": "0.5.0",
12
- "nativeVersion": "0.17.0",
12
+ "nativeVersion": "0.18.0",
13
13
  "capabilityDetection": {
14
14
  "how": "await BridgeClient.handshake({ clientVersion }) resolves to { shellVersion, nativeVersion, accepted }. Compare ack.nativeVersion against the minNativeVersion in capabilities[].",
15
15
  "acceptedIsAlwaysTrue": true,
16
16
  "acceptedWarning": "The native side never rejects a handshake, so `accepted` carries no information. Never branch on it.",
17
- "nativeVersionMayBeNull": "null when the shell could not reach the native side, and in bundles compiled against SDK <= 0.16.x, where the shell did not relay it. Treat null as 'assume nothing beyond the 0.13.0 baseline'. The dev harness reports 0.17.0 unless a scenario sets it to null."
17
+ "nativeVersionMayBeNull": "null when the shell could not reach the native side, and in bundles compiled against SDK <= 0.16.x, where the shell did not relay it. Treat null as 'assume nothing beyond the 0.13.0 baseline'. The dev harness reports 0.18.0 unless --native-version says otherwise."
18
18
  }
19
19
  },
20
20
 
@@ -81,12 +81,12 @@
81
81
  "note": "13 keys exist; 12 are reachable by the sandboxed client. Subscribe with BridgeClient.on(key, cb). Payload shapes are canonical in dist/index.d.ts under the listed type.",
82
82
  "entries": {
83
83
  "pingPong": { "reachableByClient": true, "payloadType": "PingPongPayload", "emittedWhen": "reply to the `ping` command, once the app's background service is up" },
84
- "userDevice": { "reachableByClient": true, "payloadType": "UserDevicePayload", "emittedWhen": "reply to the `userDevice` command when the device list loads, and on device roster changes. Not guaranteed: see silentDropPaths and prefer BridgeClient.requestUserDevices()." },
85
- "deviceLightSensors": { "reachableByClient": true, "payloadType": "DeviceLightSensorsPayload", "emittedWhen": "real-time stream, only while realTime is on for that device", "discriminator": "type", "variants": ["actlumus", "actlumus2"] },
84
+ "userDevice": { "reachableByClient": true, "payloadType": "UserDevicePayload", "emittedWhen": "reply to the `userDevice` command, and on device roster changes. From nativeVersion 0.18.0 always answered: with empty arrays and `error` when the list could not load. Below it, not guaranteed (see silentDropPaths). Prefer BridgeClient.requestUserDevices()." },
85
+ "deviceLightSensors": { "reachableByClient": true, "payloadType": "DeviceLightSensorsPayload", "emittedWhen": "real-time stream, only while realTime is on for that device", "discriminator": "type", "variants": ["actlumus", "actlumus2"], "units": { "photopic": "lux", "melanopic": "lux (melanopic EDI)", "wavelength*": "relative, no physical unit", "light_sensor_*": "relative, no physical unit" } },
86
86
  "syncTime": { "reachableByClient": true, "payloadType": "SyncTimeAckPayload", "emittedWhen": "ack of the `syncTime` command" },
87
87
  "realTime": { "reachableByClient": true, "payloadType": "RealTimeAckPayload", "emittedWhen": "ack of the `realTime` command" },
88
88
  "deleteMemory": { "reachableByClient": true, "payloadType": "DeleteMemoryAckPayload", "emittedWhen": "ack of the `deleteMemory` command" },
89
- "deviceNotification": { "reachableByClient": true, "payloadType": "DeviceNotificationPayload", "emittedWhen": "device-level notice, including errors" },
89
+ "deviceNotification": { "reachableByClient": true, "payloadType": "DeviceNotificationPayload", "emittedWhen": "each step of a device sync (BLE session), for every linked device; success = messageCode pipeline_done_notification, failure = a *_error without it; message is an English log line, not localized" },
90
90
  "deviceConnection": { "reachableByClient": true, "payloadType": "DeviceConnectionPayload", "emittedWhen": "a device connects or disconnects" },
91
91
  "sensorLog": { "reachableByClient": true, "payloadType": "SensorLogPayload", "emittedWhen": "reply to the `sensorLog` command; correlate by requestId" },
92
92
  "systemStatus": { "reachableByClient": true, "payloadType": "SystemStatusPayload", "emittedWhen": "on attach, again right after handshake-ack, and on every change" },
@@ -100,9 +100,9 @@
100
100
  "note": "Send with BridgeClient.send(command, args). It is fire-and-forget: there is no return value and no throw. Replies arrive on the key named in repliesOn.",
101
101
  "entries": {
102
102
  "ping": { "args": {}, "requiresConfirmation": false, "repliesOn": "pingPong", "note": "Handled by the app's background service: lost without a reply if sent before the service is up. BridgeClient.waitForService() resends it until a pingPong arrives." },
103
- "userDevice": { "args": { "getFromServer": "boolean, default false. false: memory or cache while fresh, else the server (use at boot). true: the server first, cache on failure (use for a refresh button)." }, "requiresConfirmation": false, "repliesOn": "userDevice", "prefer": "BridgeClient.requestUserDevices({ getFromServer }), which resends with backoff and resolves { status: 'timeout', hasDevices } instead of waiting forever.", "note": "Handled by the app's background service. A request sent before it is up is lost, and no reply is sent when the list fails to load — including an account with no linked device. An empty list is never sent." },
104
- "realTime": { "args": { "serialNo": "int", "deviceId": "int, optional, minNativeVersion 0.17.0", "value": "boolean" }, "requiresConfirmation": false, "repliesOn": "realTime", "note": "Off by default. The deviceLightSensors stream only flows while this is on. Always send serialNo; add deviceId to address one device when two share a serialNo." },
105
- "deviceConnection": { "args": { "type": "'add' | 'remove'", "serialNo": "int", "deviceId": "int, optional, minNativeVersion 0.17.0" }, "requiresConfirmation": false, "repliesOn": "deviceConnection", "note": "Pairs nothing. 'add' subscribes to the BLE connection state of a device from the userDevice list; the state arrives on the deviceConnection key on every change. 'remove' stops it." },
103
+ "userDevice": { "args": { "getFromServer": "boolean, default false. false: memory or cache while fresh, else the server (use at boot). true: the server first, cache on failure (use for a refresh button)." }, "requiresConfirmation": false, "repliesOn": "userDevice", "prefer": "BridgeClient.requestUserDevices({ getFromServer }), which resends with backoff and resolves { status: 'timeout', hasDevices } instead of waiting forever.", "note": "From nativeVersion 0.18.0 the app re-sends a request lost while its background service starts, and answers a failed lookup with empty arrays and `error` (NO_DEVICES | NETWORK | SESSION | UNKNOWN). Below 0.18.0 a request sent before the service is up is lost, and a failed lookup gets no reply." },
104
+ "realTime": { "args": { "serialNo": "int", "deviceId": "int, optional, minNativeVersion 0.17.0", "value": "boolean" }, "requiresConfirmation": false, "repliesOn": "realTime", "note": "Off by default. The deviceLightSensors stream only flows while this is on and the device is in a BLE session; while it is on, the app keeps the session open after the sync (it ends when realTime goes off or the link drops); the stream stops when the session ends and resumes at the next one. The app switches it off for every device when it restarts. The ack means the wish is recorded; it does not require a connected device. Always send serialNo; add deviceId to address one device when two share a serialNo." },
105
+ "deviceConnection": { "args": { "type": "'add' | 'remove'", "serialNo": "int", "deviceId": "int, optional, minNativeVersion 0.17.0" }, "requiresConfirmation": false, "repliesOn": "deviceConnection", "note": "Pairs nothing. 'add' subscribes to the BLE connection state of a device from the userDevice list; the state arrives on the deviceConnection key on every CHANGE only — the current state is not replayed (except isConnect false at once for a device the app does not know). Start every device as 'unknown'. An ActLumus is between sessions most of the time (bleConnectionPeriod, minutes). 'remove' stops it." },
106
106
  "sensorLog": { "args": { "requestId": "string", "serialNo": "int", "bodyPart": "'wrist' | 'necklace', optional", "limit": "1..1000, optional", "startingAfter": "opaque cursor, optional", "endingBefore": "opaque cursor, optional", "startTime": "timestamp, optional, minNativeVersion 0.15.0", "endTime": "timestamp, optional, minNativeVersion 0.15.0" }, "requiresConfirmation": false, "repliesOn": "sensorLog", "prefer": "BridgeClient.fetchSensorLog(query), which correlates requestId and applies a timeout for you.", "paging": { "orderWithinPage": "chronological ascending", "noCursorNoRange": "the most recent `limit` logs; older pages with endingBefore while hasPrevious", "withStartTime": "the oldest logs of the range first; newer pages with startingAfter while hasMore", "onlyEndTime": "the most recent logs before endTime", "defaultLimit": 10, "maxLimit": 1000, "logPeriod": "one log per period, programmable per device, default 60 s; derive it from consecutive log_time values" } },
107
107
  "openSettings": { "args": {}, "requiresConfirmation": false, "repliesOn": null },
108
108
  "requestPermission": { "args": { "permission": "string, must match a native AppPermission name" }, "requiresConfirmation": false, "repliesOn": null, "note": "There is no permission-result key. The OS shows its own prompt and the outcome reaches you as a fresh systemStatus emission." },
@@ -110,9 +110,9 @@
110
110
  "navigationState": { "args": { "canGoBack": "boolean" }, "requiresConfirmation": false, "repliesOn": null, "prefer": "BridgeClient.reportNavigation(canGoBack)" },
111
111
  "language": { "args": {}, "requiresConfirmation": false, "repliesOn": "language", "note": "Pull. The native push happens before window.CondorBridge exists, so a client that mounts later must ask.", "minNativeVersion": "0.14.0" },
112
112
  "jsError": { "args": { "message": "string", "stack": "optional", "source": "optional", "lineno": "optional", "colno": "optional" }, "requiresConfirmation": false, "repliesOn": null, "note": "Rate limited: 20 per 10 s, fields truncated at 4 KB." },
113
- "syncTime": { "args": { "serialNo": "int", "deviceId": "int, optional, minNativeVersion 0.17.0" }, "requiresConfirmation": true, "repliesOn": "syncTime" },
114
- "deleteMemory": { "args": { "serialNo": "int", "deviceId": "int, optional, minNativeVersion 0.17.0" }, "requiresConfirmation": true, "repliesOn": "deleteMemory" },
115
- "logout": { "args": {}, "requiresConfirmation": true, "repliesOn": null },
113
+ "syncTime": { "args": { "serialNo": "int", "deviceId": "int, optional, minNativeVersion 0.17.0" }, "requiresConfirmation": true, "repliesOn": "syncTime", "ackMeans": "recorded; the clock is set at the device's next BLE session. Does not require a connected device." },
114
+ "deleteMemory": { "args": { "serialNo": "int", "deviceId": "int, optional, minNativeVersion 0.17.0" }, "requiresConfirmation": true, "repliesOn": "deleteMemory", "ackMeans": "recorded; the memory is erased at the end of the next sync, only after its data was uploaded (a failed upload keeps it pending). Unsent data is never erased. Not reversible once done. Does not require a connected device." },
115
+ "logout": { "args": {}, "requiresConfirmation": true, "repliesOn": null, "note": "No reply: once confirmed, the app goes to its login screen and unloads the page." },
116
116
  "dfu": { "args": {}, "denied": true, "repliesOn": null }
117
117
  },
118
118
  "confirmationSemantics": "A command with requiresConfirmation true opens a modal owned by the Condor shell. If the person declines, the shell answers nack with reason user-rejected, which your code only sees as a console warning. The ack simply never arrives. Never block your UI waiting for it: drive the screen from the ack key when it comes, and keep a timeout."
@@ -149,8 +149,8 @@
149
149
  { "cause": "permission name outside the native enum", "trigger": "requestPermission with an unrecognized string", "observable": "nothing" },
150
150
  { "cause": "malformed args", "trigger": "realTime, syncTime, deleteMemory or deviceConnection with wrong types", "observable": "nothing" },
151
151
  { "cause": "postMessage failing schema validation", "trigger": "hand-rolled postMessage instead of BridgeClient", "observable": "nothing" },
152
- { "cause": "background service not running yet", "trigger": "userDevice or ping sent before the app's background service is up, typically right after a cold start; the app does not re-send the list when the service comes up", "observable": "nothing (the handshake, systemStatus and language already work)", "mitigation": "BridgeClient.requestUserDevices() / waitForService()" },
153
- { "cause": "device list did not load", "trigger": "userDevice for an account with no linked device, or when the server request fails (no internet, session problem); the app replies only on success", "observable": "nothing; systemStatus.hasDevices stays false", "mitigation": "BridgeClient.requestUserDevices() resolves { status: 'timeout', hasDevices } after 30 s: render an empty state with a retry" }
152
+ { "cause": "background service not running yet", "untilNativeVersion": "0.18.0", "trigger": "userDevice or ping sent before the app's background service is up, typically right after a cold start; the app does not re-send the list when the service comes up", "observable": "nothing (the handshake, systemStatus and language already work)", "mitigation": "BridgeClient.requestUserDevices() / waitForService()" },
153
+ { "cause": "device list did not load", "untilNativeVersion": "0.18.0", "trigger": "userDevice for an account with no linked device, or when the server request fails (no internet, session problem); the app replies only on success", "observable": "nothing; systemStatus.hasDevices stays false", "mitigation": "BridgeClient.requestUserDevices() resolves { status: 'timeout', hasDevices } after 30 s: render an empty state with a retry" }
154
154
  ],
155
155
 
156
156
  "capabilities": [
@@ -158,6 +158,9 @@
158
158
  { "feature": "sensorLogTimeRange", "minNativeVersion": "0.15.0", "description": "sensorLog accepts startTime and endTime." },
159
159
  { "feature": "lightSensorRawChannels", "minNativeVersion": "0.16.0", "description": "deviceLightSensors carries the raw light_sensor_* channels and the actlumus2 variant." },
160
160
  { "feature": "deviceIdAddressing", "minNativeVersion": "0.17.0", "description": "realTime, syncTime, deleteMemory and deviceConnection accept an optional deviceId, so one device can be addressed when an ActLumus and a Plus share a serialNo; the acks echo it; deviceConnection, deviceNotification and deviceLightSensors carry it when the serialNo is shared; systemStatus adds deviceSyncErrorById." },
161
+ { "feature": "userDeviceAlwaysReplies", "minNativeVersion": "0.18.0", "description": "userDevice is always answered: the app re-sends a request lost while its background service starts, and a failed lookup arrives as empty arrays plus error (NO_DEVICES | NETWORK | SESSION | UNKNOWN). requestUserDevices() resolves no-devices / error." },
162
+ { "feature": "hasDevicesKnown", "minNativeVersion": "0.18.0", "description": "systemStatus.hasDevicesKnown tells whether hasDevices is the server's answer (true) or the lookup failed (false)." },
163
+ { "feature": "userDevicePayloadAllowlist", "minNativeVersion": "0.18.0", "description": "The app sends only the fields listed in payloadItems; anything else (patient data included) is stripped before it reaches the bundle." },
161
164
  { "feature": "nativeVersionRelay", "minShellVersion": "0.6.0", "description": "The shell relays nativeVersion into the sandboxed client. Below this, handshake-ack carried only shellVersion and capability detection was impossible from inside the iframe." }
162
165
  ],
163
166
 
@@ -183,7 +186,8 @@
183
186
  "payloadItems": {
184
187
  "note": "Item shapes of userDevice and sensorLog. Canonical types in dist/index.d.ts. Fields not listed here are not part of the public contract — do not read them.",
185
188
  "joinKey": "deviceId. An item of devicesTelemetry or devicesUserConfig without deviceId is legacy data: fall back to serialNo only when that serialNo appears once in devicesConfig.",
186
- "hasDevices": "systemStatus.hasDevices is true only after the app loaded a non-empty list from the server; false means no device OR the lookup failed. A hint, never a verdict.",
189
+ "hasDevices": "systemStatus.hasDevices is true only after the app loaded a non-empty list from the server. From nativeVersion 0.18.0, systemStatus.hasDevicesKnown says whether it is the server's answer; below it (or when hasDevicesKnown is false), false means no device OR the lookup failed.",
190
+ "error": "UserDevicePayload.error (nativeVersion 0.18.0+): present only when the list could not load, with the three arrays empty. NO_DEVICES | NETWORK | SESSION | UNKNOWN; treat any other value as UNKNOWN.",
187
191
  "devicesConfig": {
188
192
  "type": "DeviceConfigItem",
189
193
  "role": "the list of linked devices to render",
@@ -201,7 +205,7 @@
201
205
  },
202
206
  "sensorLogItem": {
203
207
  "type": "SensorLogItem",
204
- "fields": { "id": "int", "log_time": "'YYYY-MM-DD HH:MM:SS', device-LOCAL time, not UTC", "log_type": "int", "body_part": "'wrist' | 'necklace' | null", "temperature": "°C | null", "lux_photopic": "lux | null", "lux_melanopic": "lux | null", "light_sensor_f1..f8, light_sensor_clear, light_sensor_nir": "raw channels | null (null = the hardware has no such channel)", "act_data_pim, act_data_tat, act_data_zcm": "int | null", "device_log_time, app_log_time": "upload batch markers, not per-log timestamps" }
208
+ "fields": { "id": "int", "log_time": "'YYYY-MM-DD HH:MM:SS', device-LOCAL time, not UTC", "log_type": "int", "body_part": "'wrist' | 'necklace' | null — where the device was SET to be worn when the log was uploaded, not a detection", "off_wrist": "raw wear flag, the name misleads: 1 = worn, 0 = not worn (dock); anything else = not worn; null = unknown", "temperature": "°C | null", "lux_photopic": "lux | null", "lux_melanopic": "lux | null", "light_sensor_f1..f8, light_sensor_clear, light_sensor_nir": "raw channels | null (null = the hardware has no such channel)", "act_data_pim, act_data_tat, act_data_zcm": "int | null", "device_log_time, app_log_time": "upload batch markers, not per-log timestamps" }
205
209
  }
206
210
  },
207
211
 
@@ -215,8 +219,8 @@
215
219
  "verifiedAgainst": {
216
220
  "monorepo": "condorinstprogdev/mobile_applications",
217
221
  "branch": "dev_canvas",
218
- "commit": "a1a524fd8491e4c266c7ea7bbcd9b1aa334620db",
219
- "verifiedOn": "2026-10-20",
222
+ "commit": "7b8ca8a1e736689c7abbb0cee5af63a24f7ebc23",
223
+ "verifiedOn": "2026-10-21",
220
224
  "sources": {
221
225
  "nativeVersion": "apps/canvas/lib/ui/pages/home/bridge/condor_bridge.dart:38",
222
226
  "jsToNativePayloadBytes": "apps/canvas/lib/ui/pages/home/bridge/condor_bridge.dart:94",
@@ -235,13 +239,20 @@
235
239
  "userDeviceToBackgroundService": "apps/canvas/lib/ui/pages/home/bridge/condor_bridge.dart:671-683",
236
240
  "userDeviceRepliesOnlyOnSuccess": "packages/condor_core/lib/src/data/services/user_devices_service.dart:28-39",
237
241
  "getFromServerSemantics": "packages/condor_core/lib/src/data/repositories/user_devices_repository.dart:49-85",
238
- "hasDevices": "apps/canvas/lib/data/services/sys_notification_service.dart:224-237",
242
+ "hasDevices": "apps/canvas/lib/data/services/sys_notification_service.dart:225-238",
239
243
  "deviceConfigItem": "packages/condor_core/lib/src/domain/entities/device_config_entity.dart:61-77",
240
244
  "deviceTelemetryItem": "packages/condor_core/lib/src/domain/entities/device_telemetry_entity.dart:100-117",
241
245
  "deviceUserConfigItem": "packages/condor_core/lib/src/domain/entities/device_user_config_entity.dart:78-88",
242
246
  "deviceSyncErrorItem": "packages/condor_core/lib/src/domain/entities/device_sync_error_entity.dart:16-23",
243
247
  "userDevicesEnvelope": "packages/condor_core/lib/src/domain/entities/user_devices_entity.dart:282-289",
244
- "bleConnectionPeriodMinutes": "packages/condor_core/lib/src/domain/entities/device_session_record_entity.dart:25"
248
+ "bleConnectionPeriodMinutes": "packages/condor_core/lib/src/domain/entities/device_session_record_entity.dart:25",
249
+ "nativeVersion018": "apps/canvas/lib/ui/pages/home/bridge/condor_bridge.dart:45",
250
+ "userDeviceResendOnPong": "apps/canvas/lib/ui/pages/home/bridge/condor_bridge.dart:741-755",
251
+ "userDeviceErrorReply": "apps/canvas/lib/ui/pages/home/bridge/condor_bridge.dart:433-447",
252
+ "userDeviceErrorFromBackground": "apps/canvas/lib/foreground/handler/main_handler.dart:98-116",
253
+ "userDeviceErrorCodes": "apps/canvas/lib/core/user_device_error.dart:41-61",
254
+ "userDevicePayloadAllowlist": "apps/canvas/lib/ui/pages/home/bridge/condor_bridge.dart:965-982",
255
+ "hasDevicesKnown": "apps/canvas/lib/data/services/sys_notification_service.dart:239,252-266"
245
256
  },
246
257
  "maintenance": "Re-verify every entry in sources before cutting a release. A published contract that drifts from the app is worse than no contract."
247
258
  }
package/dist/cli.cjs CHANGED
@@ -6,7 +6,7 @@ var import_node_fs4 = require("fs");
6
6
  var import_node_path4 = require("path");
7
7
 
8
8
  // package.json
9
- var version = "0.22.0";
9
+ var version = "0.23.0";
10
10
 
11
11
  // src/canvas-admin/command.ts
12
12
  var import_node_crypto2 = require("crypto");
@@ -165,6 +165,11 @@ var BLOCKED_API_RULES = [
165
165
  pattern: /window\s*\.\s*open\s*\(/,
166
166
  message: "The sandbox has no allow-popups; window.open() returns null."
167
167
  },
168
+ {
169
+ rule: "crypto-randomuuid",
170
+ pattern: /crypto\s*\.\s*randomUUID\b/,
171
+ message: "crypto.randomUUID exists only in a secure context, and the app's sandboxed iframe is probably not one: it can be undefined on the phone while it works in the harness. Use createRequestId() from the SDK, or crypto.getRandomValues."
172
+ },
168
173
  {
169
174
  rule: "top-navigation",
170
175
  pattern: /window\s*\.\s*top\b/,
@@ -304,6 +309,8 @@ function parseArgs(argv) {
304
309
  scenario: "default",
305
310
  port: DEFAULT_DEV_PORT,
306
311
  nativeVersion: void 0,
312
+ latencyMs: void 0,
313
+ panel: true,
307
314
  help: false
308
315
  };
309
316
  for (let i = 0; i < argv.length; i++) {
@@ -346,6 +353,14 @@ function parseArgs(argv) {
346
353
  case "--port":
347
354
  args.port = Number.parseInt(argv[++i] ?? "", 10) || DEFAULT_DEV_PORT;
348
355
  break;
356
+ case "--latency": {
357
+ const ms = Number.parseInt(argv[++i] ?? "", 10);
358
+ args.latencyMs = Number.isFinite(ms) && ms >= 0 ? ms : void 0;
359
+ break;
360
+ }
361
+ case "--no-panel":
362
+ args.panel = false;
363
+ break;
349
364
  default:
350
365
  if (token && !token.startsWith("-") && !args.htmlPath) args.htmlPath = token;
351
366
  }
@@ -358,7 +373,8 @@ Usage:
358
373
  npx condor-mobile-bridge build -i <html> [-o <zip>]
359
374
  npx condor-mobile-bridge validate -i <zip|html> [--strict]
360
375
  npx condor-mobile-bridge canvas publish|list|revoke ... (see: canvas --help)
361
- npx condor-mobile-bridge dev -i <html|zip|dir> [--scenario <name>] [--native-version <v|null>] [--port <n>]
376
+ npx condor-mobile-bridge dev -i <html|zip|dir> [--scenario <name|file.json>] [--native-version <v|null>]
377
+ [--port <n>] [--latency <ms>] [--no-panel]
362
378
 
363
379
  Commands:
364
380
  build Inline a dist/index.html into a self-contained .zip.
@@ -376,12 +392,15 @@ Options:
376
392
  -o, --out <path> build only: output .zip (default: ${DEFAULT_OUT}).
377
393
  --max-bytes <n> Size limit in bytes (default: ${DEFAULT_MAX_BYTES} = 2 MB).
378
394
  --strict validate only: exit 1 on warnings too, not just errors.
379
- --scenario <n> dev only: device behaviour to simulate (default: default).
395
+ --scenario <n> dev only: device behaviour to simulate (default: default),
396
+ or a .json file that extends one (see docs/device-testing.md).
380
397
  --port <n> dev only: HTTP port (default: ${DEFAULT_DEV_PORT}).
381
398
  --native-version <v|null>
382
399
  dev only: nativeVersion the harness reports (default
383
- 0.17.0). 'null' reproduces a shell that could not
400
+ 0.18.0). 'null' reproduces a shell that could not
384
401
  reach the native side.
402
+ --latency <ms> dev only: fixed reply latency (e.g. 0 for fast tests).
403
+ --no-panel dev only: hide the floating MOCK panel.
385
404
  -h, --help Show this help.`;
386
405
 
387
406
  // src/canvas-admin/client.ts
@@ -961,7 +980,7 @@ function telemetry(spec, now) {
961
980
  serialNo: spec.serialNo,
962
981
  attemptDate: now - 10 * 6e4,
963
982
  message: spec.syncError,
964
- messageCode: "SYNC_FAILED"
983
+ messageCode: "send_data_log_error"
965
984
  }
966
985
  ] : []
967
986
  };
@@ -1045,6 +1064,7 @@ function systemStatus(overrides = {}) {
1045
1064
  internet: true,
1046
1065
  permission: true,
1047
1066
  hasDevices: true,
1067
+ hasDevicesKnown: true,
1048
1068
  missingOptionalPermissions: [],
1049
1069
  missingEssentialPermissions: [],
1050
1070
  deviceSyncError: {},
@@ -1054,28 +1074,27 @@ function systemStatus(overrides = {}) {
1054
1074
  }
1055
1075
 
1056
1076
  // src/dev/scenarios.ts
1057
- var HARNESS_NATIVE_VERSION = "0.17.0";
1077
+ var HARNESS_NATIVE_VERSION = "0.18.0";
1058
1078
  var base = {
1059
- description: "Two devices, background service already running, realistic latency.",
1079
+ description: "Two devices, background service already running, realistic latency. The first device starts a BLE session 3 s after load; the second stays between sessions.",
1060
1080
  nativeVersion: HARNESS_NATIVE_VERSION,
1061
1081
  latencyMs: [80, 250],
1062
1082
  serviceStartMs: 0,
1063
- userDeviceSilent: false,
1064
1083
  logPeriodS: 60,
1065
1084
  systemStatus: systemStatus(),
1066
1085
  devices: () => twoDevices()
1067
1086
  };
1068
1087
  var SCENARIOS = {
1069
- default: base,
1088
+ default: { ...base, firstSessionAfterMs: 3e3 },
1070
1089
  "cold-start": {
1071
1090
  ...base,
1072
- description: "App launched from cold: the background service takes 4 s to start, and every ping/userDevice sent before that is lost. A bundle that asks once stays on its loading screen forever.",
1091
+ description: "App launched from cold: the background service takes 4 s to start, and every ping/userDevice sent before that is lost. From nativeVersion 0.18.0 the app re-sends the lost userDevice itself; below it, a bundle that asks once stays on its loading screen forever.",
1073
1092
  serviceStartMs: 4e3
1074
1093
  },
1075
1094
  "no-devices": {
1076
1095
  ...base,
1077
- description: "Account with no linked device: userDevice never answers and systemStatus.hasDevices is false. Your bundle must show an empty state.",
1078
- userDeviceSilent: true,
1096
+ description: "Account with no linked device: userDevice answers with error NO_DEVICES (below nativeVersion 0.18.0: never answers) and systemStatus.hasDevices is false. Your bundle must show an empty state.",
1097
+ userDeviceError: "NO_DEVICES",
1079
1098
  systemStatus: systemStatus({ hasDevices: false })
1080
1099
  },
1081
1100
  "shared-serial": {
@@ -1085,9 +1104,8 @@ var SCENARIOS = {
1085
1104
  },
1086
1105
  offline: {
1087
1106
  ...base,
1088
- description: "No internet. systemStatus.internet is false and sensorLog fails; userDevice answers from the cache.",
1089
- sensorLogError: "Connection failed",
1090
- systemStatus: systemStatus({ internet: false })
1107
+ description: "No internet. systemStatus.internet is false and sensorLog fails; userDevice answers from the cache. The internet comes back when a test calls window.condorDev.setStatus({ internet: true }).",
1108
+ systemStatus: systemStatus({ internet: false, hasDevicesKnown: false })
1091
1109
  }
1092
1110
  };
1093
1111
 
@@ -1107,11 +1125,18 @@ function readClientHtml(input) {
1107
1125
  return inlineHtml(path);
1108
1126
  }
1109
1127
  var escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1110
- function harnessPage(scenario, nativeVersion) {
1128
+ function harnessPage(scenario, nativeVersion, extra = {}) {
1111
1129
  const options = Object.entries(SCENARIOS).map(
1112
1130
  ([name, s]) => `<option value="${name}" title="${escapeHtml(s.description)}"${name === scenario ? " selected" : ""}>${name}</option>`
1113
1131
  ).join("");
1114
- const description = escapeHtml(SCENARIOS[scenario]?.description ?? "");
1132
+ const description = escapeHtml(
1133
+ extra.scenarioFile ? `Scenario file ${extra.scenarioFile}` : SCENARIOS[scenario]?.description ?? ""
1134
+ );
1135
+ const defaultsJson = JSON.stringify({
1136
+ latencyMs: extra.latencyMs ?? null,
1137
+ panel: extra.panel !== false,
1138
+ scenarioFile: Boolean(extra.scenarioFile)
1139
+ }).replace(/</g, "\\u003c");
1115
1140
  const scenarioJson = JSON.stringify(scenario).replace(/</g, "\\u003c");
1116
1141
  const nativeJson = JSON.stringify(
1117
1142
  nativeVersion === void 0 ? void 0 : nativeVersion === "null" ? null : nativeVersion
@@ -1147,6 +1172,11 @@ function harnessPage(scenario, nativeVersion) {
1147
1172
  <script>
1148
1173
  var scenario = ${scenarioJson};
1149
1174
  var nativeVersion = ${nativeJson};
1175
+ var defaults = ${defaultsJson};
1176
+ var params = new URLSearchParams(location.search);
1177
+ var latency = params.has('latency') ? Number(params.get('latency')) : defaults.latencyMs;
1178
+ var panel = params.has('panel') ? params.get('panel') !== '0' : defaults.panel;
1179
+ var useFile = defaults.scenarioFile && !params.has('scenario');
1150
1180
  var harness = null;
1151
1181
  document.getElementById('scenario').addEventListener('change', function (e) {
1152
1182
  var params = new URLSearchParams(location.search);
@@ -1156,14 +1186,55 @@ function harnessPage(scenario, nativeVersion) {
1156
1186
  document.getElementById('back').addEventListener('click', function () {
1157
1187
  if (harness) harness.pressBack();
1158
1188
  });
1159
- var chosen = CondorDevHarness.SCENARIOS[scenario];
1160
- if (nativeVersion !== undefined) chosen = Object.assign({}, chosen, { nativeVersion: nativeVersion });
1161
- fetch('/__condor/client.html')
1189
+ // A scenario file extends a named scenario: its fields replace the base's,
1190
+ // systemStatus is merged, and devices is the userDevice payload to answer.
1191
+ function fromFile(file) {
1192
+ var base = CondorDevHarness.SCENARIOS[file.extends || 'default'];
1193
+ if (!base) throw new Error('scenario file: unknown "extends": ' + file.extends);
1194
+ var out = Object.assign({}, base, file);
1195
+ out.systemStatus = Object.assign({}, base.systemStatus, file.systemStatus || {});
1196
+ if (file.devices) {
1197
+ var devices = JSON.stringify(file.devices);
1198
+ out.devices = function () { return JSON.parse(devices); };
1199
+ } else out.devices = base.devices;
1200
+ return out;
1201
+ }
1202
+ var chosen = null;
1203
+ (useFile ? fetch('/__condor/scenario.json').then(function (r) { return r.json(); }).then(fromFile)
1204
+ : Promise.resolve(CondorDevHarness.SCENARIOS[scenario]))
1205
+ .then(function (s) {
1206
+ chosen = s;
1207
+ if (nativeVersion !== undefined) chosen = Object.assign({}, chosen, { nativeVersion: nativeVersion });
1208
+ if (latency !== null && latency >= 0) chosen = Object.assign({}, chosen, { latencyMs: latency });
1209
+ return fetch('/__condor/client.html');
1210
+ })
1162
1211
  .then(function (r) { if (!r.ok) throw new Error('could not read the bundle: HTTP ' + r.status); return r.text(); })
1163
1212
  .then(function (html) {
1164
- return CondorDevHarness.createDevHarness({ html: html, scenario: chosen, mount: document.getElementById('device') });
1213
+ return CondorDevHarness.createDevHarness({ html: html, scenario: chosen, mount: document.getElementById('device'), devPanel: panel });
1214
+ })
1215
+ .then(function (h) {
1216
+ harness = h;
1217
+ var confirm = new URLSearchParams(location.search).get('confirm');
1218
+ if (confirm === 'accept' || confirm === 'decline') h.setConfirmations(confirm);
1219
+ // Scripted tests (Puppeteer, Playwright) drive the simulated app here.
1220
+ // See docs/device-testing.md, "Scripted tests".
1221
+ window.condorDev = {
1222
+ scenario: h.scenario,
1223
+ get log() { return h.log.slice(); },
1224
+ // true once the bundle is mounted AND its handshake was answered (or,
1225
+ // with nativeVersion null, left unanswered on purpose).
1226
+ get ready() {
1227
+ return h.log.some(function (e) {
1228
+ return (e.kind === 'reply' && e.key === 'handshake-ack') || /handshake unanswered/.test(e.detail);
1229
+ });
1230
+ },
1231
+ emit: function (key, payload) { h.emit(key, payload); },
1232
+ setStatus: function (patch) { h.setStatus(patch); },
1233
+ setConnected: function (deviceId, connected) { h.setConnected(deviceId, connected); },
1234
+ setConfirmations: function (mode) { h.setConfirmations(mode); },
1235
+ pressBack: function () { h.pressBack(); }
1236
+ };
1165
1237
  })
1166
- .then(function (h) { harness = h; })
1167
1238
  .catch(function (err) { document.getElementById('device').textContent = String(err); console.error(err); });
1168
1239
  </script>
1169
1240
  </body>
@@ -1174,7 +1245,10 @@ function harnessScriptPath() {
1174
1245
  return (0, import_node_path3.join)(here, "dev-harness.global.js");
1175
1246
  }
1176
1247
  function startDevServer(opts) {
1177
- if (!(opts.scenario in SCENARIOS)) {
1248
+ const scenarioFile = opts.scenario.endsWith(".json") ? (0, import_node_path3.resolve)(opts.scenario) : void 0;
1249
+ if (scenarioFile) {
1250
+ JSON.parse((0, import_node_fs3.readFileSync)(scenarioFile, "utf-8"));
1251
+ } else if (!(opts.scenario in SCENARIOS)) {
1178
1252
  throw new Error(
1179
1253
  `Unknown scenario "${opts.scenario}". Known: ${Object.keys(SCENARIOS).join(", ")}.`
1180
1254
  );
@@ -1191,6 +1265,14 @@ function startDevServer(opts) {
1191
1265
  res.end(readClientHtml(opts.input));
1192
1266
  return;
1193
1267
  }
1268
+ if (url.pathname === "/__condor/scenario.json" && scenarioFile) {
1269
+ res.writeHead(200, {
1270
+ "content-type": "application/json; charset=utf-8",
1271
+ "cache-control": "no-store"
1272
+ });
1273
+ res.end((0, import_node_fs3.readFileSync)(scenarioFile));
1274
+ return;
1275
+ }
1194
1276
  if (url.pathname === "/__condor/harness.js") {
1195
1277
  res.writeHead(200, {
1196
1278
  "content-type": "text/javascript; charset=utf-8",
@@ -1200,14 +1282,21 @@ function startDevServer(opts) {
1200
1282
  return;
1201
1283
  }
1202
1284
  if (url.pathname === "/") {
1203
- const scenario = url.searchParams.get("scenario") ?? opts.scenario;
1285
+ const named = url.searchParams.get("scenario");
1286
+ const scenario = named ?? (scenarioFile ? "default" : opts.scenario);
1204
1287
  res.writeHead(200, {
1205
1288
  "content-type": "text/html; charset=utf-8",
1206
1289
  "content-security-policy": CANVAS_CSP,
1207
1290
  "cache-control": "no-store"
1208
1291
  });
1209
1292
  const nativeVersion = url.searchParams.get("nativeVersion") ?? opts.nativeVersion;
1210
- res.end(harnessPage(scenario in SCENARIOS ? scenario : opts.scenario, nativeVersion));
1293
+ res.end(
1294
+ harnessPage(scenario in SCENARIOS ? scenario : "default", nativeVersion, {
1295
+ latencyMs: opts.latencyMs,
1296
+ panel: opts.panel,
1297
+ scenarioFile: named ? void 0 : scenarioFile && opts.scenario
1298
+ })
1299
+ );
1211
1300
  return;
1212
1301
  }
1213
1302
  res.writeHead(404).end();
@@ -1221,12 +1310,13 @@ function startDevServer(opts) {
1221
1310
  }
1222
1311
 
1223
1312
  // src/cli.ts
1224
- function runDev(input, scenario, port, nativeVersion) {
1225
- const server = startDevServer({ input, scenario, port, nativeVersion });
1313
+ function runDev(opts) {
1314
+ const { input, scenario, port, nativeVersion } = opts;
1315
+ const server = startDevServer(opts);
1226
1316
  server.on("listening", () => {
1227
1317
  const native = nativeVersion === void 0 ? "" : `&nativeVersion=${nativeVersion}`;
1228
1318
  console.log(
1229
- `Condor Canvas dev harness: http://localhost:${port}/?scenario=${scenario}${native}`
1319
+ `Condor Canvas dev harness: http://localhost:${port}/${scenario.endsWith(".json") ? "" : `?scenario=${scenario}`}${native}`
1230
1320
  );
1231
1321
  console.log(` bundle: ${input} (re-read on every reload)`);
1232
1322
  console.log(" sandbox, shell policy and CSP: same as the app");
@@ -1320,7 +1410,14 @@ function main() {
1320
1410
  }
1321
1411
  if (args.command === "dev") {
1322
1412
  try {
1323
- runDev(args.htmlPath, args.scenario, args.port, args.nativeVersion);
1413
+ runDev({
1414
+ input: args.htmlPath,
1415
+ scenario: args.scenario,
1416
+ port: args.port,
1417
+ nativeVersion: args.nativeVersion,
1418
+ latencyMs: args.latencyMs,
1419
+ panel: args.panel
1420
+ });
1324
1421
  } catch (err) {
1325
1422
  console.error(`dev: ${err instanceof Error ? err.message : String(err)}`);
1326
1423
  process.exit(1);