@condorinst/mobile-bridge 0.16.0 → 0.18.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/CHANGELOG.md CHANGED
@@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.18.0] — 2026-09-16
11
+
12
+ ### Added
13
+
14
+ - **Native transport over the `CondorNative` JavaScript channel.** The app is
15
+ moving from `flutter_inappwebview` to the official `webview_flutter`, whose
16
+ only JS → native path is a named channel that accepts a string. When
17
+ `window.CondorNative.postMessage` exists, the SDK uses it and sends the
18
+ `{type, version, payload}` envelope as JSON. It takes precedence over
19
+ `window.flutter_inappwebview`, which keeps working for the transition.
20
+ - **`Bridge.setOutgoingStamp(stamp | null)`.** A single, supported hook that
21
+ transforms every JS → native message (handshake included) right before it is
22
+ sent. It exists so the shell can stamp its nonce. Until now the shell had to
23
+ monkey-patch `flutter_inappwebview.callHandler`, which has no equivalent on a
24
+ channel. Client code should not use it.
25
+ - `NATIVE_CHANNEL_NAME` and the `OutgoingStamp` type are exported.
26
+ - `SandboxVerifyResult.nativeChannelReachable`: the client now also reports
27
+ whether `window.CondorNative` is visible inside the iframe, and `ok` requires
28
+ it to be `false`. Clients built with an older SDK do not send the field; the
29
+ shell treats it as `false`.
30
+
31
+ ### Changed
32
+
33
+ - **Layer 1 override prefix also removes and locks `window.CondorNative`.**
34
+ Measured on a real iPhone (iOS 26.6) and an Android device: the prefix removes
35
+ the channel from the iframe, but the WebView injects the channel into every
36
+ frame, so a child iframe created by the client gets it back, and on iOS
37
+ `window.webkit.messageHandlers` cannot be locked. Layer 1 is defense in depth;
38
+ the guarantee is the nonce validated natively (layer 3), which discarded every
39
+ message sent through those paths.
40
+
41
+ ### Deprecated
42
+
43
+ - The `window.flutter_inappwebview` host. It will be removed in a future minor
44
+ once no app ships it.
45
+
46
+ ## [0.17.0] — 2026-09-01
47
+
48
+ ### Added
49
+
50
+ - **The shell now relays `nativeVersion` to the sandboxed client.**
51
+ `BridgeClientHandshakeAck` gains `nativeVersion: string | null`, and the shell
52
+ performs the native handshake on the client's behalf to fill it in.
53
+
54
+ This closes a hole the 0.16.0 docs walked straight into. Those docs told
55
+ consumers to "check `nativeVersion` from the handshake ack" before relying on
56
+ the new raw light channels — but a client lives inside a sandboxed iframe and
57
+ handshakes with the *shell*, not the native, and the shell answered with
58
+ `shellVersion` alone. The check was impossible to perform. Worse, nothing in
59
+ the SDK ever called `Bridge.handshake`, so the native version was not merely
60
+ unreachable from the client: nobody obtained it at all.
61
+
62
+ `null` means the native has not answered (or does not exist, as in a browser
63
+ mock) and is honest information, not a placeholder. `Bridge.handshake` has no
64
+ timeout of its own, so the shell bounds the wait at 2s and answers the client
65
+ either way — without that, a client in a browser would hang forever waiting
66
+ for a handshake that never resolves.
67
+
68
+ ### Changed
69
+
70
+ - **The shell touches the native during the client handshake.** Until 0.16.x it
71
+ answered immediately and deliberately did not; a test asserted exactly that.
72
+ That test now asserts the relay instead. Clients see one extra round-trip on
73
+ handshake, bounded at 2s.
74
+ - `SHELL_VERSION` 0.5.1 → 0.6.0.
75
+ - `client_demo` logs `nativeVersion` alongside `shellVersion`, which is how an
76
+ E2E confirms which native it is actually talking to.
77
+
10
78
  ## [0.16.0] — 2026-09-01
11
79
 
12
80
  ### Added
package/README.md CHANGED
@@ -146,13 +146,13 @@ if (!verify.ok) {
146
146
  // automatically. Nothing else to do at boot.
147
147
  ```
148
148
 
149
- ### Layered defense (propagation of `window.flutter_inappwebview`)
149
+ ### Layered defense (propagation of the native channel into the iframe)
150
150
 
151
151
  | Layer | Where | What it does |
152
152
  |---|---|---|
153
- | **1** | `BridgeShell.bootstrapClientIframe` | `srcdoc` with a prefix that deletes `window.flutter_inappwebview` + `window.CondorBridge` before any client script runs. |
154
- | **2** | `BridgeShell.verifySandbox` + `BridgeClient` handler | Round-trip `postMessage` to attest the iframe state. |
155
- | **3** | (Native side) | Shell nonce validated natively in the Flutter bridge. |
153
+ | **1** | `BridgeShell.bootstrapClientIframe` | `srcdoc` with a prefix that deletes `window.CondorNative`, `window.flutter_inappwebview` and `window.CondorBridge` before any client script runs. Defense in depth only: the WebView injects the channel into every frame, so a child iframe created by the client gets it back (and on iOS `window.webkit.messageHandlers` cannot be locked). |
154
+ | **2** | `BridgeShell.verifySandbox` + `BridgeClient` handler | Round-trip `postMessage` to attest the iframe state (`nativeChannelReachable` since 0.18.0). |
155
+ | **3** | (Native side) | Shell nonce, stamped on every outgoing message via `Bridge.setOutgoingStamp`, validated natively. **This is the guarantee.** |
156
156
  | **4** | (Fallback if 1+2 fail) | Dual `InAppWebView` — shell and client in separate WebViews. |
157
157
 
158
158
  ## Build CLI — `condor-mobile-bridge build`
@@ -253,7 +253,11 @@ land once there are 2+ versions in the field.
253
253
 
254
254
  The package detects automatically:
255
255
 
256
- - **Android / iOS** — `window.flutter_inappwebview` (via the `flutter_inappwebview` package on the Flutter side).
256
+ - **Android / iOS** — `window.CondorNative`, a JavaScript channel registered with
257
+ `webview_flutter` (`addJavaScriptChannel('CondorNative')`). Messages are sent as a
258
+ JSON string. Takes precedence when present.
259
+ - **Android / iOS (legacy)** — `window.flutter_inappwebview` (via the `flutter_inappwebview`
260
+ package). Still supported for the transition; will be removed in a future minor.
257
261
  - **Browser outside the app** (dev/test): **mock mode** activates automatically.
258
262
 
259
263
  ## Mock mode
package/dist/index.cjs CHANGED
@@ -33,7 +33,8 @@ __export(index_exports, {
33
33
  BRIDGE_KEYS: () => BRIDGE_KEYS,
34
34
  Bridge: () => Bridge,
35
35
  BridgeClient: () => BridgeClient,
36
- BridgeShell: () => BridgeShell
36
+ BridgeShell: () => BridgeShell,
37
+ NATIVE_CHANNEL_NAME: () => NATIVE_CHANNEL_NAME
37
38
  });
38
39
  module.exports = __toCommonJS(index_exports);
39
40
 
@@ -469,8 +470,22 @@ function createMockAdapter() {
469
470
  }
470
471
 
471
472
  // src/internal/native.ts
473
+ var NATIVE_CHANNEL_NAME = "CondorNative";
474
+ var outgoingStamp = null;
475
+ function setOutgoingStamp(stamp) {
476
+ outgoingStamp = stamp;
477
+ }
472
478
  var activeAdapter = null;
473
- function createRealAdapter(host) {
479
+ function createChannelAdapter(channel) {
480
+ return {
481
+ isMock: false,
482
+ // Canal único: o `handlerName` não viaja — o nativo roteia por `type`.
483
+ callHandler(_handlerName, payload) {
484
+ channel.postMessage(JSON.stringify(payload));
485
+ }
486
+ };
487
+ }
488
+ function createLegacyAdapter(host) {
474
489
  return {
475
490
  isMock: false,
476
491
  callHandler(handlerName, payload) {
@@ -482,9 +497,13 @@ function detectAdapter() {
482
497
  if (typeof window === "undefined") {
483
498
  return createMockAdapter();
484
499
  }
500
+ const channel = window.CondorNative;
501
+ if (channel && typeof channel.postMessage === "function") {
502
+ return createChannelAdapter(channel);
503
+ }
485
504
  const host = window.flutter_inappwebview;
486
505
  if (host && typeof host.callHandler === "function") {
487
- return createRealAdapter(host);
506
+ return createLegacyAdapter(host);
488
507
  }
489
508
  return createMockAdapter();
490
509
  }
@@ -495,7 +514,8 @@ function getActiveAdapter() {
495
514
  return activeAdapter;
496
515
  }
497
516
  function callNative(handlerName, payload) {
498
- getActiveAdapter().callHandler(handlerName, payload);
517
+ const stamped = outgoingStamp ? outgoingStamp(payload) : payload;
518
+ getActiveAdapter().callHandler(handlerName, stamped);
499
519
  }
500
520
 
501
521
  // src/internal/subscribers.ts
@@ -611,6 +631,9 @@ var Bridge = {
611
631
  openSettings() {
612
632
  this.send("openSettings");
613
633
  },
634
+ setOutgoingStamp(stamp) {
635
+ setOutgoingStamp(stamp);
636
+ },
614
637
  mockEmit(key, payload) {
615
638
  requireMockAdapter().triggerEmit(key, payload ?? {});
616
639
  },
@@ -671,6 +694,8 @@ function onMessage(event) {
671
694
  pendingHandshake2 = null;
672
695
  resolve({
673
696
  shellVersion: msg.ack?.shellVersion ?? "unknown",
697
+ // `?? null` cobre o shell anterior à 0.17.0, que não mandava o campo.
698
+ nativeVersion: msg.ack?.nativeVersion ?? null,
674
699
  accepted: msg.ack?.accepted ?? false
675
700
  });
676
701
  }
@@ -684,7 +709,8 @@ function onMessage(event) {
684
709
  postToShell({
685
710
  type: "sandbox-verify-response",
686
711
  flutterBridgeReachable: typeof w.flutter_inappwebview !== "undefined",
687
- condorBridgeReachable: typeof w.CondorBridge !== "undefined"
712
+ condorBridgeReachable: typeof w.CondorBridge !== "undefined",
713
+ nativeChannelReachable: typeof w.CondorNative !== "undefined"
688
714
  });
689
715
  break;
690
716
  }
@@ -810,7 +836,9 @@ var JsErrorMessage = v.object({
810
836
  var SandboxVerifyResponseMessage = v.object({
811
837
  type: v.literal("sandbox-verify-response"),
812
838
  flutterBridgeReachable: v.boolean(),
813
- condorBridgeReachable: v.boolean()
839
+ condorBridgeReachable: v.boolean(),
840
+ // Opcional: clientes com SDK < 0.18.0 não enviam.
841
+ nativeChannelReachable: v.optional(v.boolean())
814
842
  });
815
843
  var ClientToShellMessage = v.variant("type", [
816
844
  SubscribeMessage,
@@ -829,6 +857,15 @@ var HandshakeAckMessage = v.object({
829
857
  type: v.literal("handshake-ack"),
830
858
  ack: v.object({
831
859
  shellVersion: v.string(),
860
+ /**
861
+ * Versão do schema declarada pelo **nativo**, repassada pelo shell.
862
+ *
863
+ * `null` quando o nativo ainda não respondeu (ou não existe, como no
864
+ * mock de browser). O cliente roda dentro de um iframe sandboxed e não
865
+ * fala com o nativo diretamente, então este é o **único** caminho pelo
866
+ * qual ele descobre de qual versão são as capacidades disponíveis.
867
+ */
868
+ nativeVersion: v.nullable(v.string()),
832
869
  accepted: v.boolean()
833
870
  })
834
871
  });
@@ -849,12 +886,14 @@ var ShellToClientMessage = v.variant("type", [
849
886
  ]);
850
887
 
851
888
  // src/bridge-shell.ts
852
- var SHELL_VERSION = "0.5.1";
889
+ var SHELL_VERSION = "0.6.0";
890
+ var NATIVE_HANDSHAKE_TIMEOUT_MS = 2e3;
853
891
  var VERIFY_TIMEOUT_MS = 2e3;
854
892
  var OVERRIDE_PREFIX = `<script>
855
893
  (function(){
856
894
  try { delete window.flutter_inappwebview; } catch (_) {}
857
895
  try { delete window.CondorBridge; } catch (_) {}
896
+ try { delete window.CondorNative; } catch (_) {}
858
897
  try {
859
898
  Object.defineProperty(window, 'flutter_inappwebview', {
860
899
  get: function(){ return undefined; },
@@ -862,6 +901,13 @@ var OVERRIDE_PREFIX = `<script>
862
901
  configurable: false,
863
902
  });
864
903
  } catch (_) {}
904
+ try {
905
+ Object.defineProperty(window, 'CondorNative', {
906
+ get: function(){ return undefined; },
907
+ set: function(_){ /* no-op: bloqueia o canal do webview_flutter no iframe */ },
908
+ configurable: false,
909
+ });
910
+ } catch (_) {}
865
911
  try {
866
912
  Object.defineProperty(window, 'CondorBridge', {
867
913
  get: function(){ return undefined; },
@@ -887,6 +933,13 @@ var BridgeShell = class {
887
933
  constructor(opts) {
888
934
  this.subscriptions = /* @__PURE__ */ new Map();
889
935
  this.detached = false;
936
+ /**
937
+ * Versão do nativo, obtida uma vez e cacheada. `null` até o nativo
938
+ * responder — e permanentemente `null` num browser sem nativo.
939
+ */
940
+ this.nativeVersion = null;
941
+ /** Handshake com o nativo em voo; garante uma única chamada. */
942
+ this.nativeHandshake = null;
890
943
  // Map de `verify-sandbox` round-trips pendentes — key gerada por nonce.
891
944
  this.pendingVerifies = /* @__PURE__ */ new Map();
892
945
  this.onMessage = async (event) => {
@@ -911,9 +964,11 @@ var BridgeShell = class {
911
964
  await this.handleSend(msg.command, msg.args ?? {});
912
965
  break;
913
966
  case "handshake":
914
- this.postToClient({
915
- type: "handshake-ack",
916
- ack: { shellVersion: SHELL_VERSION, accepted: true }
967
+ void this.resolveNativeVersion().then((nativeVersion) => {
968
+ this.postToClient({
969
+ type: "handshake-ack",
970
+ ack: { shellVersion: SHELL_VERSION, nativeVersion, accepted: true }
971
+ });
917
972
  });
918
973
  break;
919
974
  case "jsError":
@@ -933,10 +988,12 @@ var BridgeShell = class {
933
988
  if (pending) {
934
989
  clearTimeout(pending.timer);
935
990
  this.pendingVerifies.delete(first);
936
- const ok = !msg.flutterBridgeReachable && !msg.condorBridgeReachable;
991
+ const nativeChannelReachable = msg.nativeChannelReachable ?? false;
992
+ const ok = !msg.flutterBridgeReachable && !msg.condorBridgeReachable && !nativeChannelReachable;
937
993
  pending.resolve({
938
994
  flutterBridgeReachable: msg.flutterBridgeReachable,
939
995
  condorBridgeReachable: msg.condorBridgeReachable,
996
+ nativeChannelReachable,
940
997
  ok
941
998
  });
942
999
  }
@@ -1092,6 +1149,29 @@ var BridgeShell = class {
1092
1149
  }
1093
1150
  Bridge.send(command, args);
1094
1151
  }
1152
+ /**
1153
+ * Obtém o `nativeVersion`, disparando o handshake com o nativo na primeira
1154
+ * vez e cacheando o resultado.
1155
+ *
1156
+ * ⚠️ `Bridge.handshake` **não tem timeout**: a Promise só resolve quando o
1157
+ * nativo emite `handshake-ack`. Num browser sem nativo ela nunca resolve,
1158
+ * então o `race` abaixo é o que garante que o cliente sempre receba
1159
+ * resposta — com `null` — em vez de ficar pendurado.
1160
+ */
1161
+ async resolveNativeVersion() {
1162
+ if (this.nativeVersion !== null) return this.nativeVersion;
1163
+ if (!this.nativeHandshake) {
1164
+ this.nativeHandshake = Bridge.handshake({ clientVersion: SHELL_VERSION }).then((ack) => {
1165
+ this.nativeVersion = ack.nativeVersion;
1166
+ }).catch(() => {
1167
+ });
1168
+ }
1169
+ await Promise.race([
1170
+ this.nativeHandshake,
1171
+ new Promise((resolve) => setTimeout(resolve, NATIVE_HANDSHAKE_TIMEOUT_MS))
1172
+ ]);
1173
+ return this.nativeVersion;
1174
+ }
1095
1175
  postToClient(msg) {
1096
1176
  if (this.detached) return;
1097
1177
  this.iframe.contentWindow?.postMessage(msg, "*");
@@ -1102,6 +1182,7 @@ var BridgeShell = class {
1102
1182
  BRIDGE_KEYS,
1103
1183
  Bridge,
1104
1184
  BridgeClient,
1105
- BridgeShell
1185
+ BridgeShell,
1186
+ NATIVE_CHANNEL_NAME
1106
1187
  });
1107
1188
  //# sourceMappingURL=index.cjs.map