@qontinui/ui-bridge-native 0.9.0 → 0.11.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/README.md CHANGED
@@ -55,8 +55,8 @@ function SubmitButton({ onPress }) {
55
55
  ```python
56
56
  from ui_bridge import UIBridgeClient
57
57
 
58
- # Connect to device (use 10.0.2.2 for Android emulator)
59
- client = UIBridgeClient("http://10.0.2.2:8087")
58
+ # Over a cable or an emulator, after `adb forward tcp:8087 tcp:8087`. No token needed.
59
+ client = UIBridgeClient("http://localhost:8087")
60
60
 
61
61
  # Find and interact with elements
62
62
  client.press("submit-button")
@@ -225,6 +225,8 @@ table:
225
225
  | GET | `/ui-bridge/_help` | The same list plus per-handler descriptions, actions and tips |
226
226
 
227
227
  ```bash
228
+ # Over `adb forward`, which arrives as loopback. On a device IP this route answers
229
+ # 401 PAIRING_REQUIRED; see "Device Pairing".
228
230
  curl -s http://localhost:8087/ui-bridge/_routes | jq '.data.routes'
229
231
  ```
230
232
 
@@ -244,6 +246,50 @@ A wrong verb on a path the bridge does publish answers **405** with an `Allow`
244
246
  header naming the verbs it accepts (and the same verbs in the JSON envelope's
245
247
  `error`); a path it does not publish answers **404**.
246
248
 
249
+ ### Device Pairing
250
+
251
+ The server admits callers as follows:
252
+
253
+ | Caller | Answer |
254
+ | --- | --- |
255
+ | Loopback peer with a loopback `Host` and nothing a web page sends (`Origin`, `Referer`, a `Sec-Fetch-Site` other than `none`) | Admitted, no token |
256
+ | Any other caller with a valid `X-UI-Bridge-Auth` signature | Admitted |
257
+ | Anyone, `GET /ui-bridge/health` (trimmed) | Answered |
258
+ | Anyone but a web page, `POST /ui-bridge/pair` | Answered |
259
+ | A web page, `POST /ui-bridge/pair` | `403 PAIRING_WEB_CALLER_REFUSED` |
260
+ | Signature outside the 300 s window, or older than this server will accept | `401 PAIRING_TIMESTAMP_REFUSED` with `data.serverTime`: re-sync and sign again, no new pairing needed |
261
+ | Signed body over `SIGNED_BODY_MAX_CHARS` (262,144 characters) | `413 PAIRING_BODY_TOO_LARGE` |
262
+ | Signed method over 16 characters, or target over `SIGNED_TARGET_MAX_CHARS` (8,192) | `414 PAIRING_TARGET_TOO_LONG` |
263
+ | Everyone else | `401 PAIRING_REQUIRED` |
264
+ | Not loopback, on a server with no `pairingStore` | `401 PAIRING_UNCONFIGURED` |
265
+ | WebSocket upgrade from anywhere but a trusted loopback caller, signed or not | `403 PAIRING_WS_LOOPBACK_ONLY` |
266
+
267
+ `adb forward` was measured arriving as `::ffff:127.0.0.1` on an Android 11 emulator on 2026-10-31, which counts as loopback. Physical phones are unmeasured.
268
+
269
+ A `curl` over a cable keeps working with nothing new. A `curl` to a device IP can reach only health, because a hand `curl` cannot sign.
270
+
271
+ To accept LAN callers, pass a `pairingStore`. The app supplies persistence and secure randomness, since the SDK's only generator is `Math.random`:
272
+
273
+ ```tsx
274
+ const pairingStore: PairingStore = {
275
+ get: () => readToken(), // throw until loaded; return null only when there is no token
276
+ set: (token) => writeToken(token), // pairing answers only after this settles
277
+ clear: () => deleteToken(),
278
+ randomBytes: (n) => Crypto.getRandomBytes(n),
279
+ };
280
+
281
+ <UIBridgeNativeProvider
282
+ serverAdapter={adapter}
283
+ deviceId={deviceId}
284
+ pairingStore={pairingStore}
285
+ // ...
286
+ />;
287
+ ```
288
+
289
+ `useUIBridgeNative()` gives Settings `armPairingCode()`, `disarmPairingCode()`, `getPairingState()` and `revokePairing()`. A code lasts about 2 minutes, works once, and is disarmed by 5 wrong attempts from anyone. `getPairingState().consumedBy` names the peer that used it. A correct code is not used up when the store fails, so the user can try it again. A revoke replaces the token. No signature verifies while it runs, and one checked against the old token is not served after it.
290
+
291
+ A sample on a trusted network can opt out with `allowUnauthenticatedNetwork`. Anything that can reach the port can then drive the app over HTTP, with two exceptions. A web page is refused with `403 PAIRING_WEB_CALLER_REFUSED`: anything carrying `Origin`, `Referer` or a `Sec-Fetch-Site` other than `none`, and any `Host` other than an IP address, `localhost` or a `.local` name, which is how DNS rebinding shows up. A WebSocket upgrade stays loopback-only.
292
+
247
293
  ### Custom Server Adapter
248
294
 
249
295
  The HTTP server requires a platform-specific adapter. See [documentation](./docs/server-adapters.md) for examples with:
@@ -251,6 +297,8 @@ The HTTP server requires a platform-specific adapter. See [documentation](./docs
251
297
  - `react-native-http-bridge`
252
298
  - `@aspect/react-native-http-server`
253
299
 
300
+ An adapter must also pass what the gate reads. Fill each `HTTPRequest` with `remoteAddress` from the accepted socket, never from a header, and with `rawTarget` and `rawBody` exactly as received, because a signature covers those bytes. Without `remoteAddress` a caller is treated as not loopback. A WebSocket adapter must call `onWebSocketUpgrade(req)` before sending the 101 and answer `decision.response` when `decision.admit` is false. Messages are refused on an adapter that never calls `onWebSocketUpgrade`.
301
+
254
302
  ## Debug Inspector
255
303
 
256
304
  Built-in visual inspector for development:
@@ -572,8 +572,7 @@ function utf8Bytes(input) {
572
572
  return Uint8Array.from(out);
573
573
  }
574
574
  var rotr = (x, n) => x >>> n | x << 32 - n;
575
- function sha256Hex(input) {
576
- const bytes = utf8Bytes(input);
575
+ function sha256Bytes(bytes) {
577
576
  const blockBytes = Math.ceil((bytes.length + 9) / 64) * 64;
578
577
  const buffer = new Uint8Array(blockBytes);
579
578
  buffer.set(bytes);
@@ -617,7 +616,18 @@ function sha256Hex(input) {
617
616
  h[6] = h[6] + g | 0;
618
617
  h[7] = h[7] + hh | 0;
619
618
  }
620
- return h.map((word) => (word >>> 0).toString(16).padStart(8, "0")).join("");
619
+ const digest = new Uint8Array(32);
620
+ const digestView = new DataView(digest.buffer);
621
+ h.forEach((word, i) => digestView.setUint32(i * 4, word >>> 0));
622
+ return digest;
623
+ }
624
+ function bytesToHex(bytes) {
625
+ let out = "";
626
+ for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0");
627
+ return out;
628
+ }
629
+ function sha256Hex(input) {
630
+ return bytesToHex(sha256Bytes(utf8Bytes(input)));
621
631
  }
622
632
 
623
633
  // src/core/vision-fields.ts