@condorinst/mobile-bridge 0.22.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +133 -34
- package/CHANGELOG.md +187 -0
- package/README.md +30 -481
- package/contract/bridge-contract.json +32 -21
- package/dist/cli.cjs +126 -29
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +126 -29
- package/dist/cli.js.map +1 -1
- package/dist/dev-harness.global.js +402 -55
- package/dist/dev.cjs +403 -55
- package/dist/dev.cjs.map +1 -1
- package/dist/dev.d.cts +245 -9
- package/dist/dev.d.ts +245 -9
- package/dist/dev.js +402 -55
- package/dist/dev.js.map +1 -1
- package/dist/index.cjs +30 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +154 -26
- package/dist/index.d.ts +154 -26
- package/dist/index.js +29 -8
- package/dist/index.js.map +1 -1
- package/docs/agent-guide.md +9 -265
- package/docs/bridge-contract.md +317 -26
- package/docs/client-guide.md +34 -400
- package/docs/device-testing.md +235 -0
- package/docs/pilot-onboarding.md +41 -115
- package/docs/shell-integration.md +292 -0
- package/docs/troubleshooting.md +19 -11
- package/examples/minimal-home/README.md +2 -2
- package/examples/minimal-home/package.json +1 -1
- package/examples/minimal-home/src/main.ts +23 -3
- package/llms-full.txt +769 -865
- package/llms.txt +13 -15
- package/package.json +1 -1
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
-
"contractVersion": "0.
|
|
3
|
+
"contractVersion": "0.23.1",
|
|
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.
|
|
9
|
+
"package": "0.23.1",
|
|
10
10
|
"shellVersion": "0.6.0",
|
|
11
11
|
"clientProtocolVersion": "0.5.0",
|
|
12
|
-
"nativeVersion": "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
|
+
"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
|
|
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
|
|
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": "
|
|
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
|
|
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 does not end the session after the sync, but on a real device the session still ended about 3 minutes after it started (the link or the device ends it); switching it off ends the session; 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.
|
|
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": "
|
|
219
|
-
"verifiedOn": "2026-10-
|
|
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:
|
|
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.
|
|
9
|
+
var version = "0.23.1";
|
|
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>]
|
|
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.
|
|
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: "
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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(
|
|
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
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
1225
|
-
const
|
|
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}
|
|
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(
|
|
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);
|