@condorinst/mobile-bridge 0.19.2 → 0.21.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/dist/index.d.ts CHANGED
@@ -225,23 +225,126 @@ interface HandshakeOptions {
225
225
  interface PingPongPayload {
226
226
  data: 'ping' | 'pong';
227
227
  }
228
+ /**
229
+ * One linked device, as configured on the server. Mirrors
230
+ * `DeviceConfig.toMap()` in `condor_core`
231
+ * (`domain/entities/device_config_entity.dart`).
232
+ *
233
+ * `deviceId` is the server id and is unique. `serialNo` is printed on the
234
+ * device and **can repeat**: an ActLumus and an ActLumus Plus may share one.
235
+ */
236
+ interface DeviceConfigItem {
237
+ /** Server id. Unique — the join key across the three arrays. */
238
+ deviceId: number;
239
+ /** Printed serial number. Not unique: an ActLumus and a Plus may share one. */
240
+ serialNo: number;
241
+ /** Where the device is worn. `null` when not set. */
242
+ deviceBodyPart: 'wrist' | 'necklace' | null;
243
+ /**
244
+ * Device model id. Absent when the server did not send one, which means an
245
+ * ActLumus (gen1); the Plus always carries it. Never a display name.
246
+ */
247
+ modelId?: 'actlumus' | 'actlumus_plus' | (string & {});
248
+ /** BLE advertising period, in minutes, set by the Condor web system. */
249
+ bleConnectionPeriod: number;
250
+ autosync: boolean;
251
+ eraseDeviceMemory: boolean;
252
+ autoEraseDeviceMemory: boolean;
253
+ autoEraseDeviceMemoryThreshold: number;
254
+ readSensorsMemory: boolean;
255
+ startDevice: boolean;
256
+ readFullMemory: boolean;
257
+ allowFirmwareUpdate: boolean;
258
+ }
259
+ /**
260
+ * One failed sync attempt. Mirrors `DeviceSyncError.toMap()`
261
+ * (`domain/entities/device_sync_error_entity.dart`).
262
+ */
263
+ interface DeviceSyncErrorItem {
264
+ serialNo: number;
265
+ /** Epoch milliseconds. */
266
+ attemptDate: number;
267
+ /** Already localized by the app. Show it; do not branch on it. */
268
+ message: string;
269
+ /** Stable identifier to branch on. `null` for legacy errors. */
270
+ messageCode: string | null;
271
+ }
272
+ /**
273
+ * Last known state of one device, as read over BLE. Mirrors
274
+ * `DeviceTelemetry.toMap()` (`domain/entities/device_telemetry_entity.dart`).
275
+ *
276
+ * Every reading is `null` until the device has synced at least once.
277
+ */
278
+ interface DeviceTelemetryItem {
279
+ serialNo: number;
280
+ /**
281
+ * Server id. **Omitted** on legacy data the app could not attribute, so join
282
+ * by `deviceId` when present and fall back to `serialNo` only when that
283
+ * `serialNo` is unique in `devicesConfig`.
284
+ */
285
+ deviceId?: number;
286
+ /**
287
+ * Last successful sync, epoch milliseconds. The misspelling (`updata`) is the
288
+ * real field name — do not "fix" it.
289
+ */
290
+ updataDateTime: number | null;
291
+ /** Display name reported by the device. Not in `devicesConfig`. */
292
+ deviceName: string | null;
293
+ /** Firmware version. */
294
+ deviceVersion: string | null;
295
+ hardwareVersion: string | null;
296
+ /** Device clock at the last sync, epoch milliseconds. */
297
+ deviceDateTime: number | null;
298
+ /** Data memory used, percent 0–100. */
299
+ memoryDataUsage: number | null;
300
+ /** System memory used, percent 0–100. */
301
+ memorySystemUsage: number | null;
302
+ /** Battery charge, percent 0–100. */
303
+ batteryLevel: number | null;
304
+ /** Battery voltage, volts. */
305
+ batteryVoltage: number | null;
306
+ isCharging: boolean | null;
307
+ isOnDock: boolean | null;
308
+ /** Sync failures of this device. Empty when there are none. */
309
+ syncErrors: DeviceSyncErrorItem[];
310
+ }
311
+ /**
312
+ * Per-device toggles of the user. Mirrors `DeviceUserConfig.toMap()`
313
+ * (`domain/entities/device_user_config_entity.dart`).
314
+ */
315
+ interface DeviceUserConfigItem {
316
+ serialNo: number;
317
+ /** Server id. Omitted on legacy data — see `DeviceTelemetryItem.deviceId`. */
318
+ deviceId?: number;
319
+ syncTime: boolean;
320
+ realTime: boolean;
321
+ deleteMemory: boolean;
322
+ readAllMemory: boolean;
323
+ readAllSystemLog: boolean;
324
+ }
228
325
  /**
229
326
  * Snapshot of the user's device store. Mirrors `UserDevices.toMap()` in
230
327
  * `condor_core`.
231
328
  *
232
- * The inner arrays are `Record<string, unknown>`: each item has its own
233
- * `toMap()` on the Dart side (device config, telemetry, user config), and
234
- * strict typing for them is deferred until a client needs it.
329
+ * **Joining the three arrays.** `devicesConfig` lists the devices; the other
330
+ * two describe them. Join on `deviceId`. When an item of `devicesTelemetry` or
331
+ * `devicesUserConfig` has no `deviceId` (legacy data), fall back to `serialNo`
332
+ * — but only when that `serialNo` appears once in `devicesConfig`. A device may
333
+ * have no telemetry yet: render it with empty readings, never drop it.
334
+ *
335
+ * **It is not guaranteed to answer.** Use `BridgeClient.requestUserDevices()`
336
+ * instead of `send('userDevice')`; see `docs/troubleshooting.md`.
235
337
  *
236
338
  * The app enforces no device cap. A commercial rule currently limits a user to
237
339
  * two linked devices, but that can change with no signal in this contract — read
238
340
  * the length of the array rather than hard-coding it.
239
341
  */
240
342
  interface UserDevicePayload {
343
+ /** Epoch milliseconds of the snapshot. */
241
344
  lastUpdate: number;
242
- devicesConfig: Array<Record<string, unknown>>;
243
- devicesTelemetry: Array<Record<string, unknown>>;
244
- devicesUserConfig: Array<Record<string, unknown>>;
345
+ devicesConfig: DeviceConfigItem[];
346
+ devicesTelemetry: DeviceTelemetryItem[];
347
+ devicesUserConfig: DeviceUserConfigItem[];
245
348
  }
246
349
  /**
247
350
  * Raw light-sensor channels, under the names the backend uses in
@@ -341,7 +444,12 @@ type LightSensorsActlumus2Data = LightSensorsRawChannels & {
341
444
  };
342
445
  /**
343
446
  * Real-time light-sensor stream. Shape:
344
- * `{ serialNo, type, data }`.
447
+ * `{ serialNo, deviceId?, type, data }`.
448
+ *
449
+ * `deviceId` is the server id of the device and is **optional**: the app only
450
+ * sends it when more than one device on the account shares that `serialNo` (an
451
+ * ActLumus and an ActLumus Plus can). With a unique `serialNo` it is absent, so
452
+ * keep matching on `serialNo` in that case. Present from `nativeVersion` 0.17.0.
345
453
  *
346
454
  * A union discriminated by `type`, which is the `DeviceType.name` on the Dart
347
455
  * side. `data` carries **both the derived spectrum and the raw channels it came
@@ -353,27 +461,35 @@ type LightSensorsActlumus2Data = LightSensorsRawChannels & {
353
461
  */
354
462
  type DeviceLightSensorsPayload = {
355
463
  serialNo: number;
464
+ deviceId?: number;
356
465
  type: 'actlumus';
357
466
  data: LightSensorsActlumus1Data;
358
467
  } | {
359
468
  serialNo: number;
469
+ deviceId?: number;
360
470
  type: 'actlumus2';
361
471
  data: LightSensorsActlumus2Data;
362
472
  };
363
- /** Ack de comando `syncTime`. */
473
+ /**
474
+ * Ack de comando `syncTime`. `deviceId` echoes the one you sent (see
475
+ * `DeviceLightSensorsPayload`); `serialNo` is always present.
476
+ */
364
477
  interface SyncTimeAckPayload {
365
478
  serialNo: number;
479
+ deviceId?: number;
366
480
  success: boolean;
367
481
  }
368
- /** Ack/state do toggle `realTime`. */
482
+ /** Ack/state do toggle `realTime`. `deviceId` as in `SyncTimeAckPayload`. */
369
483
  interface RealTimeAckPayload {
370
484
  serialNo: number;
485
+ deviceId?: number;
371
486
  success: boolean;
372
487
  value: boolean;
373
488
  }
374
- /** Ack de comando `deleteMemory`. */
489
+ /** Ack de comando `deleteMemory`. `deviceId` as in `SyncTimeAckPayload`. */
375
490
  interface DeleteMemoryAckPayload {
376
491
  serialNo: number;
492
+ deviceId?: number;
377
493
  success: boolean;
378
494
  }
379
495
  /**
@@ -382,6 +498,8 @@ interface DeleteMemoryAckPayload {
382
498
  */
383
499
  interface DeviceConnectionPayload {
384
500
  serialNo: number;
501
+ /** Only when the `serialNo` is shared by more than one device. */
502
+ deviceId?: number;
385
503
  isConnect: boolean;
386
504
  }
387
505
  /**
@@ -391,6 +509,8 @@ interface DeviceConnectionPayload {
391
509
  */
392
510
  interface DeviceNotificationPayload {
393
511
  serialNo: number;
512
+ /** Only when the `serialNo` is shared by more than one device. */
513
+ deviceId?: number;
394
514
  isConnect: boolean;
395
515
  message: string | null;
396
516
  messageCode: string | null;
@@ -427,6 +547,63 @@ interface SensorLogQueryPayload {
427
547
  /** Exclusive, `YYYY-MM-DD HH:MM:SS`, in device-local time. */
428
548
  endTime?: string;
429
549
  }
550
+ /**
551
+ * One sensor log, passed through from the backend `get_log` contract.
552
+ *
553
+ * **Cadence.** The device writes one log per period, which is programmable per
554
+ * device (default 60 s). Never assume one per minute: size pagination from the
555
+ * gaps between `log_time` values you actually receive.
556
+ *
557
+ * `null` on a channel means the hardware does not have it, never zero.
558
+ */
559
+ interface SensorLogItem {
560
+ /** Stable id of the log. */
561
+ id: number;
562
+ /** `'YYYY-MM-DD HH:MM:SS'` in **device-local time**, not UTC. */
563
+ log_time: string;
564
+ log_type: number;
565
+ body_part: 'wrist' | 'necklace' | null;
566
+ /** Degrees Celsius, already converted by the backend. */
567
+ temperature: number | null;
568
+ /** Photopic illuminance, lux. */
569
+ lux_photopic: number | null;
570
+ /** Melanopic illuminance (melanopic EDI), lux. */
571
+ lux_melanopic: number | null;
572
+ company_id?: number;
573
+ user_id?: number;
574
+ device_id?: number;
575
+ orientation?: number | null;
576
+ light_sensor_f1?: number | null;
577
+ light_sensor_f2?: number | null;
578
+ light_sensor_f3?: number | null;
579
+ light_sensor_f4?: number | null;
580
+ light_sensor_f5?: number | null;
581
+ light_sensor_f6?: number | null;
582
+ light_sensor_f7?: number | null;
583
+ light_sensor_f8?: number | null;
584
+ light_sensor_clear?: number | null;
585
+ light_sensor_nir?: number | null;
586
+ /** ActLumus Plus only (`null` on gen1). Approximates CIE X; not an exact CIE value. */
587
+ light_sensor_x?: number | null;
588
+ /** ActLumus Plus only (`null` on gen1). */
589
+ light_sensor_y?: number | null;
590
+ /** ActLumus Plus only (`null` on gen1). */
591
+ light_sensor_z?: number | null;
592
+ /** ActLumus Plus only (`null` on gen1). Reserved by the firmware; do not interpret. */
593
+ light_sensor_fd?: number | null;
594
+ act_data_pim?: number | null;
595
+ act_data_tat?: number | null;
596
+ act_data_zcm?: number | null;
597
+ off_wrist?: number | null;
598
+ cap_sensor_1?: number | null;
599
+ cap_sensor_2?: number | null;
600
+ event?: number | null;
601
+ state?: number | null;
602
+ from_service?: boolean;
603
+ /** Batch markers of the upload, not per-log timestamps. Do not plot them. */
604
+ device_log_time?: string | null;
605
+ app_log_time?: string | null;
606
+ }
430
607
  /**
431
608
  * Reply to the `sensorLog` command.
432
609
  *
@@ -447,7 +624,8 @@ interface SensorLogQueryPayload {
447
624
  interface SensorLogPayload {
448
625
  requestId: string;
449
626
  success: boolean;
450
- logs?: Array<Record<string, unknown>>;
627
+ /** Chronological ascending. Without a cursor or range: the most recent page. */
628
+ logs?: SensorLogItem[];
451
629
  startingAfter?: string | null;
452
630
  endingBefore?: string | null;
453
631
  hasMore?: boolean;
@@ -461,7 +639,9 @@ interface SensorLogPayload {
461
639
  *
462
640
  * Emitted on attach (before your bundle exists), again right after
463
641
  * `handshake-ack`, and on every change. `deviceSyncError` maps `serialNo` to an
464
- * error message the app has already localized.
642
+ * error message the app has already localized. A `serialNo` shared by two
643
+ * devices has no single owner, so it is **left out** of `deviceSyncError` and
644
+ * only appears in `deviceSyncErrorById`.
465
645
  */
466
646
  interface SystemStatusPayload {
467
647
  bluetooth: boolean;
@@ -485,6 +665,11 @@ interface SystemStatusPayload {
485
665
  */
486
666
  missingEssentialPermissions: string[];
487
667
  deviceSyncError: Record<number, string>;
668
+ /**
669
+ * Same errors keyed by the server `deviceId`, for **every** device, shared
670
+ * `serialNo` included. Absent below `nativeVersion` 0.17.0, so guard it.
671
+ */
672
+ deviceSyncErrorById?: Record<number, string>;
488
673
  }
489
674
  /**
490
675
  * Arguments of the `requestPermission` command (JS → native).
@@ -711,7 +896,9 @@ declare const Bridge: CondorBridge;
711
896
  * deletes them before your bundle runs.
712
897
  *
713
898
  * It deliberately does **not** expose `installDevPanel`, `mockEmit`, `isMock`
714
- * or `getMockState`: those belong to the shell (`BridgeShell`).
899
+ * or `getMockState`: those belong to the shell (`BridgeShell`). To run a bundle
900
+ * in a browser, use the dev harness (`npx condor-mobile-bridge dev`, or
901
+ * `createDevHarness` from `@condorinst/mobile-bridge/dev`).
715
902
  *
716
903
  * The module also answers the shell's `verify-sandbox` probe — the iframe
717
904
  * attesting whether it can still see the native globals. If any is reachable,
@@ -723,14 +910,14 @@ interface BridgeClientHandshakeAck {
723
910
  shellVersion: string;
724
911
  /**
725
912
  * Schema version of the **native** side, relayed by the shell. `null` when
726
- * the native side has not answered, does not exist (browser mock mode), or
727
- * the shell is older than 0.6.0.
913
+ * the native side has not answered, or the shell is older than 0.6.0.
728
914
  *
729
915
  * **This number is the only capability signal a client gets.** There is no
730
916
  * version negotiation, and a sandboxed client reaches the native side by no
731
917
  * other path. For example, the raw light channels (`light_sensor_*` in
732
918
  * `deviceLightSensors`) exist from `0.16.0`. Always keep a working branch
733
- * for `null`: your browser development environment always hits it.
919
+ * for `null`. The dev harness reports `0.17.0` unless a scenario says
920
+ * otherwise.
734
921
  */
735
922
  nativeVersion: string | null;
736
923
  /**
@@ -791,13 +978,62 @@ interface CondorBridgeClient {
791
978
  * native enum is dropped in silence.
792
979
  */
793
980
  requestPermission(permission: string): void;
981
+ /**
982
+ * Resolves when the app's **background service** answers a `ping`.
983
+ *
984
+ * Commands that need device data (`userDevice`, `ping`) travel to a service
985
+ * the app starts after your bundle is already running. A command sent before
986
+ * the service is up is **lost without a reply** — the handshake, `systemStatus`
987
+ * and `language` work earlier, which makes the bridge look ready when it is
988
+ * not. This helper resends `ping` with backoff (1.5 s, 3 s, 6 s, then every
989
+ * 10 s) until a `pingPong` arrives.
990
+ *
991
+ * Never rejects: resolves `{ status: 'timeout' }` after `timeoutMs`
992
+ * (default 30 s).
993
+ */
994
+ waitForService(opts?: {
995
+ timeoutMs?: number;
996
+ }): Promise<{
997
+ status: 'ready' | 'timeout';
998
+ }>;
999
+ /**
1000
+ * Requests the user's devices and **keeps asking** until they arrive. Use
1001
+ * this instead of `send('userDevice')`, which may never be answered:
1002
+ *
1003
+ * - on a cold start the first request is lost while the background service
1004
+ * comes up;
1005
+ * - an account with no linked device gets no reply at all.
1006
+ *
1007
+ * Resends with backoff (1.5 s, 3 s, 6 s, then every 10 s) and on every
1008
+ * `systemStatus` while pending. Never rejects. Resolves with
1009
+ * `{ status: 'ok', devices }`, or `{ status: 'timeout', hasDevices }` after
1010
+ * `timeoutMs` (default 30 s), where `hasDevices` is the last
1011
+ * `systemStatus.hasDevices` seen (`null` if none). `false` there **suggests**
1012
+ * an account with no device — show an empty state with a retry button — but
1013
+ * the app also reports `false` when it could not reach the server.
1014
+ *
1015
+ * `getFromServer: true` asks the app to refresh from the server; leave it
1016
+ * `false` (default) at boot, where the cached list is enough.
1017
+ */
1018
+ requestUserDevices(opts?: {
1019
+ timeoutMs?: number;
1020
+ getFromServer?: boolean;
1021
+ }): Promise<{
1022
+ status: 'ok';
1023
+ devices: UserDevicePayload;
1024
+ } | {
1025
+ status: 'timeout';
1026
+ hasDevices: boolean | null;
1027
+ }>;
794
1028
  /**
795
1029
  * Handshakes with the shell. Resolves with
796
1030
  * `{ shellVersion, nativeVersion, accepted }` — read `nativeVersion` to learn
797
1031
  * what this device can do, and ignore `accepted`, which is always `true`.
798
1032
  *
799
- * Rejects after 5 s if the shell does not answer. Only one handshake may be
800
- * in flight at a time.
1033
+ * Rejects after 5 s if the shell does not answer, and **at once** when the
1034
+ * page is not inside the Condor shell (opened directly in a browser) — run
1035
+ * `npx condor-mobile-bridge dev` instead. Only one handshake may be in flight
1036
+ * at a time.
801
1037
  */
802
1038
  handshake(opts: {
803
1039
  clientVersion: string;
@@ -1072,4 +1308,14 @@ declare class BridgeShell {
1072
1308
  private postToClient;
1073
1309
  }
1074
1310
 
1075
- export { type AutoRespondResponse, BRIDGE_KEYS, Bridge, BridgeClient, type BridgeClientHandshakeAck, type BridgeIncomingMessage, type BridgeKey, type BridgeOutgoingEvent, BridgeShell, type BridgeShellOptions, type ClientToShellMessage, type CondorBridge, type CondorBridgeClient, type ConfirmationRequest, type DeleteMemoryAckPayload, type DevPanelOptions, type DeviceConnectionPayload, type DeviceLightSensorsPayload, type DeviceNotificationPayload, type HandshakeAck, type HandshakeOptions, type KeyPayloads, type LanguagePayload, type LightSensorsActlumus1Data, type LightSensorsActlumus2Data, type LightSensorsRawChannels, type MockMessage, type MockState, NATIVE_CHANNEL_NAME, type NavigationBackPayload, type NavigationStatePayload, type OutgoingStamp, type PayloadOf, type PingPongPayload, type RealTimeAckPayload, type RequestPermissionPayload, type SandboxVerifyResult, type SensorLogPayload, type SensorLogQueryPayload, type ShellPolicy, type ShellToClientMessage, type SyncTimeAckPayload, type SystemStatusPayload, type UserDevicePayload };
1311
+ /**
1312
+ * `versionAtLeast(ack.nativeVersion, '0.17.0')` — the capability check every
1313
+ * client needs. Compares the three numeric segments; `null` (an unknown native
1314
+ * side) is never at least anything, so it always takes the baseline branch.
1315
+ *
1316
+ * Do not compare version strings directly: `'0.9.0' > '0.16.0'` is `true` as
1317
+ * strings and false as versions.
1318
+ */
1319
+ declare function versionAtLeast(version: string | null | undefined, min: string): boolean;
1320
+
1321
+ export { type AutoRespondResponse, BRIDGE_KEYS, Bridge, BridgeClient, type BridgeClientHandshakeAck, type BridgeIncomingMessage, type BridgeKey, type BridgeOutgoingEvent, BridgeShell, type BridgeShellOptions, type ClientToShellMessage, type CondorBridge, type CondorBridgeClient, type ConfirmationRequest, type DeleteMemoryAckPayload, type DevPanelOptions, type DeviceConfigItem, type DeviceConnectionPayload, type DeviceLightSensorsPayload, type DeviceNotificationPayload, type DeviceSyncErrorItem, type DeviceTelemetryItem, type DeviceUserConfigItem, type HandshakeAck, type HandshakeOptions, type KeyPayloads, type LanguagePayload, type LightSensorsActlumus1Data, type LightSensorsActlumus2Data, type LightSensorsRawChannels, type MockMessage, type MockState, NATIVE_CHANNEL_NAME, type NavigationBackPayload, type NavigationStatePayload, type OutgoingStamp, type PayloadOf, type PingPongPayload, type RealTimeAckPayload, type RequestPermissionPayload, type SandboxVerifyResult, type SensorLogItem, type SensorLogPayload, type SensorLogQueryPayload, type ShellPolicy, type ShellToClientMessage, type SyncTimeAckPayload, type SystemStatusPayload, type UserDevicePayload, versionAtLeast };
package/dist/index.js CHANGED
@@ -616,17 +616,27 @@ var Bridge = {
616
616
  var PROTOCOL_VERSION2 = "0.5.0";
617
617
  var HANDSHAKE_TIMEOUT_MS = 5e3;
618
618
  var SENSOR_LOG_TIMEOUT_MS = 1e4;
619
+ var SERVICE_TIMEOUT_MS = 3e4;
620
+ var RESEND_BACKOFF_MS = [1500, 3e3, 6e3, 1e4];
621
+ var NOT_IN_SHELL_HINT = 'Not running inside the Condor shell. To develop in a browser, run `npx condor-mobile-bridge dev -i dist/index.html`, or mount the page with createDevHarness() from "@condorinst/mobile-bridge/dev".';
622
+ function insideShell() {
623
+ return typeof window !== "undefined" && window.parent !== window;
624
+ }
619
625
  var subscribers2 = /* @__PURE__ */ new Map();
626
+ var lastSystemStatus = null;
620
627
  var pendingHandshake2 = null;
621
628
  var messageListenerInstalled = false;
622
629
  function postToShell(msg) {
623
- if (typeof window === "undefined" || window.parent === window) {
624
- console.warn("[BridgeClient] no parent window; message dropped:", msg.type);
630
+ if (!insideShell()) {
631
+ console.warn(`[BridgeClient] message dropped (${msg.type}). ${NOT_IN_SHELL_HINT}`);
625
632
  return;
626
633
  }
627
634
  window.parent.postMessage(msg, "*");
628
635
  }
629
636
  function dispatchEvent(key, payload) {
637
+ if (key === BRIDGE_KEYS.systemStatus && payload && typeof payload === "object") {
638
+ lastSystemStatus = payload;
639
+ }
630
640
  const arr = subscribers2.get(key);
631
641
  if (!arr || arr.length === 0) return;
632
642
  for (const sub of arr) {
@@ -732,8 +742,44 @@ var BridgeClient = {
732
742
  requestPermission(permission) {
733
743
  this.send("requestPermission", { permission });
734
744
  },
745
+ waitForService(opts) {
746
+ return retryUntilReply({
747
+ timeoutMs: opts?.timeoutMs ?? SERVICE_TIMEOUT_MS,
748
+ send: () => this.send("ping"),
749
+ listen: (done) => this.on(BRIDGE_KEYS.pingPong, (payload) => {
750
+ if (payload.data === "pong") done({ status: "ready" });
751
+ }),
752
+ onTimeout: () => ({ status: "timeout" })
753
+ });
754
+ },
755
+ requestUserDevices(opts) {
756
+ let hasDevices = typeof lastSystemStatus?.hasDevices === "boolean" ? lastSystemStatus.hasDevices : null;
757
+ const getFromServer = opts?.getFromServer ?? false;
758
+ return retryUntilReply({
759
+ timeoutMs: opts?.timeoutMs ?? SERVICE_TIMEOUT_MS,
760
+ send: () => this.send("userDevice", { getFromServer }),
761
+ listen: (done, resend) => {
762
+ const offDevices = this.on(
763
+ BRIDGE_KEYS.userDevice,
764
+ (devices) => done({ status: "ok", devices })
765
+ );
766
+ const offStatus = this.on(BRIDGE_KEYS.systemStatus, (status) => {
767
+ hasDevices = status.hasDevices;
768
+ resend();
769
+ });
770
+ return () => {
771
+ offDevices();
772
+ offStatus();
773
+ };
774
+ },
775
+ onTimeout: () => ({ status: "timeout", hasDevices })
776
+ });
777
+ },
735
778
  handshake(opts) {
736
779
  ensureMessageListener();
780
+ if (!insideShell()) {
781
+ return Promise.reject(new Error(`BridgeClient: handshake impossible. ${NOT_IN_SHELL_HINT}`));
782
+ }
737
783
  if (pendingHandshake2) {
738
784
  return Promise.reject(
739
785
  new Error(
@@ -760,6 +806,37 @@ var BridgeClient = {
760
806
  return this.on(BRIDGE_KEYS.navigationBack, () => cb());
761
807
  }
762
808
  };
809
+ function retryUntilReply(opts) {
810
+ return new Promise((resolve) => {
811
+ let settled = false;
812
+ let attempt = 0;
813
+ let lastSend = 0;
814
+ let resendTimer;
815
+ const finish = (result) => {
816
+ if (settled) return;
817
+ settled = true;
818
+ clearTimeout(deadline);
819
+ clearTimeout(resendTimer);
820
+ unlisten();
821
+ resolve(result);
822
+ };
823
+ const sendNow = () => {
824
+ if (settled) return;
825
+ lastSend = Date.now();
826
+ opts.send();
827
+ const delay = RESEND_BACKOFF_MS[Math.min(attempt, RESEND_BACKOFF_MS.length - 1)] ?? 1e4;
828
+ attempt += 1;
829
+ clearTimeout(resendTimer);
830
+ resendTimer = setTimeout(sendNow, delay);
831
+ };
832
+ const resend = () => {
833
+ if (Date.now() - lastSend >= 1e3) sendNow();
834
+ };
835
+ const unlisten = opts.listen(finish, resend);
836
+ const deadline = setTimeout(() => finish(opts.onTimeout()), opts.timeoutMs);
837
+ sendNow();
838
+ });
839
+ }
763
840
 
764
841
  // src/bridge-shell.ts
765
842
  import * as v2 from "valibot";
@@ -1137,11 +1214,25 @@ var BridgeShell = class {
1137
1214
  this.iframe.contentWindow?.postMessage(msg, "*");
1138
1215
  }
1139
1216
  };
1217
+
1218
+ // src/version.ts
1219
+ function versionAtLeast(version, min) {
1220
+ if (!version) return false;
1221
+ const a = version.split(".").map((n) => Number.parseInt(n, 10) || 0);
1222
+ const b = min.split(".").map((n) => Number.parseInt(n, 10) || 0);
1223
+ for (let i = 0; i < 3; i++) {
1224
+ const x = a[i] ?? 0;
1225
+ const y = b[i] ?? 0;
1226
+ if (x !== y) return x > y;
1227
+ }
1228
+ return true;
1229
+ }
1140
1230
  export {
1141
1231
  BRIDGE_KEYS,
1142
1232
  Bridge,
1143
1233
  BridgeClient,
1144
1234
  BridgeShell,
1145
- NATIVE_CHANNEL_NAME
1235
+ NATIVE_CHANNEL_NAME,
1236
+ versionAtLeast
1146
1237
  };
1147
1238
  //# sourceMappingURL=index.js.map