@qontinui/ui-bridge-native 0.8.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 +50 -2
- package/dist/core/index.js +13 -3
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +13 -3
- package/dist/core/index.mjs.map +1 -1
- package/dist/core/sha256.d.ts +29 -7
- package/dist/core/sha256.d.ts.map +1 -1
- package/dist/debug/index.js.map +1 -1
- package/dist/debug/index.mjs.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +996 -32
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +986 -34
- package/dist/index.mjs.map +1 -1
- package/dist/observation/observation.d.ts +15 -1
- package/dist/observation/observation.d.ts.map +1 -1
- package/dist/react/UIBridgeNativeProvider.d.ts +25 -3
- package/dist/react/UIBridgeNativeProvider.d.ts.map +1 -1
- package/dist/react/index.js +967 -32
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +968 -33
- package/dist/react/index.mjs.map +1 -1
- package/dist/server/handlers.d.ts.map +1 -1
- package/dist/server/http-server.d.ts +120 -2
- package/dist/server/http-server.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +934 -16
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +916 -17
- package/dist/server/index.mjs.map +1 -1
- package/dist/server/pairing.d.ts +295 -0
- package/dist/server/pairing.d.ts.map +1 -0
- package/dist/server/types.d.ts +20 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/transport/DeviceAnnouncer.d.ts +0 -2
- package/dist/transport/DeviceAnnouncer.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createContext, useRef, useState,
|
|
1
|
+
import { createContext, useRef, useState, useEffect, useCallback, useMemo, use } from 'react';
|
|
2
2
|
import { Dimensions, StyleSheet, Platform, PixelRatio, TouchableOpacity, Text, Modal, View, ScrollView } from 'react-native';
|
|
3
3
|
import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
|
|
4
4
|
|
|
@@ -119,8 +119,7 @@ function utf8Bytes(input) {
|
|
|
119
119
|
return Uint8Array.from(out);
|
|
120
120
|
}
|
|
121
121
|
var rotr = (x, n) => x >>> n | x << 32 - n;
|
|
122
|
-
function
|
|
123
|
-
const bytes = utf8Bytes(input);
|
|
122
|
+
function sha256Bytes(bytes) {
|
|
124
123
|
const blockBytes = Math.ceil((bytes.length + 9) / 64) * 64;
|
|
125
124
|
const buffer = new Uint8Array(blockBytes);
|
|
126
125
|
buffer.set(bytes);
|
|
@@ -164,7 +163,38 @@ function sha256Hex(input) {
|
|
|
164
163
|
h[6] = h[6] + g | 0;
|
|
165
164
|
h[7] = h[7] + hh | 0;
|
|
166
165
|
}
|
|
167
|
-
|
|
166
|
+
const digest = new Uint8Array(32);
|
|
167
|
+
const digestView = new DataView(digest.buffer);
|
|
168
|
+
h.forEach((word, i) => digestView.setUint32(i * 4, word >>> 0));
|
|
169
|
+
return digest;
|
|
170
|
+
}
|
|
171
|
+
function bytesToHex(bytes) {
|
|
172
|
+
let out = "";
|
|
173
|
+
for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0");
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
function sha256Hex(input) {
|
|
177
|
+
return bytesToHex(sha256Bytes(utf8Bytes(input)));
|
|
178
|
+
}
|
|
179
|
+
var SHA256_BLOCK_BYTES = 64;
|
|
180
|
+
function hmacSha256(key, message) {
|
|
181
|
+
const normalizedKey = key.length > SHA256_BLOCK_BYTES ? sha256Bytes(key) : key;
|
|
182
|
+
const inner = new Uint8Array(SHA256_BLOCK_BYTES + message.length);
|
|
183
|
+
const outer = new Uint8Array(SHA256_BLOCK_BYTES + 32);
|
|
184
|
+
for (let i = 0; i < SHA256_BLOCK_BYTES; i++) {
|
|
185
|
+
const keyByte = i < normalizedKey.length ? normalizedKey[i] : 0;
|
|
186
|
+
inner[i] = keyByte ^ 54;
|
|
187
|
+
outer[i] = keyByte ^ 92;
|
|
188
|
+
}
|
|
189
|
+
inner.set(message, SHA256_BLOCK_BYTES);
|
|
190
|
+
outer.set(sha256Bytes(inner), SHA256_BLOCK_BYTES);
|
|
191
|
+
return sha256Bytes(outer);
|
|
192
|
+
}
|
|
193
|
+
function constantTimeEqual(a, b) {
|
|
194
|
+
if (a.length !== b.length) return false;
|
|
195
|
+
let diff = 0;
|
|
196
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
197
|
+
return diff === 0;
|
|
168
198
|
}
|
|
169
199
|
|
|
170
200
|
// src/core/vision-fields.ts
|
|
@@ -3503,7 +3533,14 @@ var UNKNOWN_CODES = [
|
|
|
3503
3533
|
"below_confidence_floor",
|
|
3504
3534
|
"model_reply_unparseable",
|
|
3505
3535
|
"needs_multi_frame_input",
|
|
3506
|
-
"stale_input"
|
|
3536
|
+
"stale_input",
|
|
3537
|
+
"frontier_open",
|
|
3538
|
+
"ledger_schema_absent",
|
|
3539
|
+
"ledger_write_failing",
|
|
3540
|
+
"no_observations",
|
|
3541
|
+
"to_node_unobserved",
|
|
3542
|
+
"performance_observer_unsupported",
|
|
3543
|
+
"no_event_before_timeout"
|
|
3507
3544
|
];
|
|
3508
3545
|
function isUnknownCode(value) {
|
|
3509
3546
|
return typeof value === "string" && UNKNOWN_CODES.includes(value);
|
|
@@ -3693,7 +3730,7 @@ var NATIVE_PAGE_HEALTH_PRODUCER_ID = "sdk-native/page-health";
|
|
|
3693
3730
|
function defaultProducer() {
|
|
3694
3731
|
return {
|
|
3695
3732
|
id: NATIVE_PAGE_HEALTH_PRODUCER_ID,
|
|
3696
|
-
version: "0.
|
|
3733
|
+
version: "0.11.0"
|
|
3697
3734
|
};
|
|
3698
3735
|
}
|
|
3699
3736
|
function pageHealthUnknown(code, detail, options = {}) {
|
|
@@ -4596,6 +4633,16 @@ function createServerHandlers(registry, executor, config) {
|
|
|
4596
4633
|
// the envelope agree; an unmounted path answers 404 instead. Each error
|
|
4597
4634
|
// message names an in-tree mobile replacement so operators aren't left
|
|
4598
4635
|
// guessing what to use instead.
|
|
4636
|
+
// The real pairing route lives in the server's network entry, ahead of this
|
|
4637
|
+
// table, because only that entry knows the socket peer. This answer is what
|
|
4638
|
+
// JSON-RPC over a WebSocket and the cloud relay get, so neither can mint a
|
|
4639
|
+
// token or spend a code attempt.
|
|
4640
|
+
pair: async () => {
|
|
4641
|
+
return error2(
|
|
4642
|
+
"Pairing is served only on a direct HTTP connection to the device, not over JSON-RPC or the cloud relay.",
|
|
4643
|
+
"NOT_SUPPORTED"
|
|
4644
|
+
);
|
|
4645
|
+
},
|
|
4599
4646
|
aiForms: async () => {
|
|
4600
4647
|
return error2(
|
|
4601
4648
|
'ai/forms (form discovery) is runner-only; mobile React Native has no DOM to walk. Use GET /ui-bridge/control/snapshot and filter for type === "input" instead.',
|
|
@@ -4990,7 +5037,7 @@ function createServerHandlers(registry, executor, config) {
|
|
|
4990
5037
|
};
|
|
4991
5038
|
if (config?.appInfo) {
|
|
4992
5039
|
response.uiBridge = {
|
|
4993
|
-
version: "0.
|
|
5040
|
+
version: "0.11.0",
|
|
4994
5041
|
...config.appInfo,
|
|
4995
5042
|
capabilities: ["elements", "components", "actions", "design"],
|
|
4996
5043
|
elementCount: stats.elements,
|
|
@@ -5002,6 +5049,527 @@ function createServerHandlers(registry, executor, config) {
|
|
|
5002
5049
|
};
|
|
5003
5050
|
}
|
|
5004
5051
|
|
|
5052
|
+
// src/server/pairing.ts
|
|
5053
|
+
var UI_BRIDGE_AUTH_HEADER = "X-UI-Bridge-Auth";
|
|
5054
|
+
var SIGNATURE_WINDOW_MS = 3e5;
|
|
5055
|
+
var PAIRING_CODE_TTL_MS = 12e4;
|
|
5056
|
+
var PAIRING_CODE_MAX_ATTEMPTS = 5;
|
|
5057
|
+
var SIGNED_BODY_MAX_CHARS = 256 * 1024;
|
|
5058
|
+
var SIGNED_METHOD_MAX_CHARS = 16;
|
|
5059
|
+
var SIGNED_TARGET_MAX_CHARS = 8192;
|
|
5060
|
+
var TOKEN_BYTES = 32;
|
|
5061
|
+
var NONCE_CACHE_MAX = 4096;
|
|
5062
|
+
var STORE_TIMEOUT_MS = 1e4;
|
|
5063
|
+
var CODE_SAMPLE_ATTEMPTS = 32;
|
|
5064
|
+
var CODE_SAMPLE_LIMIT = Math.floor(4294967296 / 1e6) * 1e6;
|
|
5065
|
+
var CLOCK_STEP_TOLERANCE_MS = 5e3;
|
|
5066
|
+
function monotonicNow() {
|
|
5067
|
+
const clock = globalThis.performance;
|
|
5068
|
+
return typeof clock?.now === "function" ? clock.now() : null;
|
|
5069
|
+
}
|
|
5070
|
+
var PairingError = class extends Error {
|
|
5071
|
+
constructor(code, message) {
|
|
5072
|
+
super(message);
|
|
5073
|
+
this.code = code;
|
|
5074
|
+
this.name = "PairingError";
|
|
5075
|
+
}
|
|
5076
|
+
};
|
|
5077
|
+
function canonicalSignatureInput(parts) {
|
|
5078
|
+
return [parts.method, parts.pathAndQuery, parts.ts, parts.nonce, parts.bodySha256Hex].join("\n");
|
|
5079
|
+
}
|
|
5080
|
+
function signatureHex(token, parts) {
|
|
5081
|
+
const input = canonicalSignatureInput({
|
|
5082
|
+
method: parts.method,
|
|
5083
|
+
pathAndQuery: parts.pathAndQuery,
|
|
5084
|
+
ts: parts.ts,
|
|
5085
|
+
nonce: parts.nonce,
|
|
5086
|
+
bodySha256Hex: sha256Hex(parts.body)
|
|
5087
|
+
});
|
|
5088
|
+
return bytesToHex(hmacSha256(utf8Bytes(token), utf8Bytes(input)));
|
|
5089
|
+
}
|
|
5090
|
+
function signUIBridgeRequest(token, request) {
|
|
5091
|
+
const ts = String(request.ts);
|
|
5092
|
+
const sig = signatureHex(token, {
|
|
5093
|
+
method: request.method,
|
|
5094
|
+
pathAndQuery: request.pathAndQuery,
|
|
5095
|
+
ts,
|
|
5096
|
+
nonce: request.nonce,
|
|
5097
|
+
body: request.body ?? ""
|
|
5098
|
+
});
|
|
5099
|
+
return `${ts}.${request.nonce}.${sig}`;
|
|
5100
|
+
}
|
|
5101
|
+
function headerValues(headers, name) {
|
|
5102
|
+
if (!headers) return [];
|
|
5103
|
+
const wanted = name.toLowerCase();
|
|
5104
|
+
return Object.keys(headers).filter((key) => key.toLowerCase() === wanted).map((key) => headers[key]);
|
|
5105
|
+
}
|
|
5106
|
+
function ipv4Octets(address) {
|
|
5107
|
+
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(address);
|
|
5108
|
+
if (!match) return null;
|
|
5109
|
+
const octets = match.slice(1).map(Number);
|
|
5110
|
+
return octets.every((octet) => octet <= 255) ? octets : null;
|
|
5111
|
+
}
|
|
5112
|
+
function isLoopbackIpv4(address) {
|
|
5113
|
+
return ipv4Octets(address)?.[0] === 127;
|
|
5114
|
+
}
|
|
5115
|
+
function parseIpv6(address) {
|
|
5116
|
+
if (!address.includes(":")) return null;
|
|
5117
|
+
let text = address;
|
|
5118
|
+
const trailing = [];
|
|
5119
|
+
const lastColon = text.lastIndexOf(":");
|
|
5120
|
+
const tail = text.slice(lastColon + 1);
|
|
5121
|
+
if (tail.includes(".")) {
|
|
5122
|
+
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(tail);
|
|
5123
|
+
if (!match) return null;
|
|
5124
|
+
const octets = match.slice(1).map(Number);
|
|
5125
|
+
if (octets.some((octet) => octet > 255)) return null;
|
|
5126
|
+
trailing.push(octets[0] << 8 | octets[1], octets[2] << 8 | octets[3]);
|
|
5127
|
+
text = text.slice(0, lastColon + 1);
|
|
5128
|
+
if (!text.endsWith("::")) text = text.slice(0, -1);
|
|
5129
|
+
}
|
|
5130
|
+
const groupsOf = (part) => {
|
|
5131
|
+
if (part === "") return [];
|
|
5132
|
+
const groups = [];
|
|
5133
|
+
for (const group of part.split(":")) {
|
|
5134
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null;
|
|
5135
|
+
groups.push(parseInt(group, 16));
|
|
5136
|
+
}
|
|
5137
|
+
return groups;
|
|
5138
|
+
};
|
|
5139
|
+
const gapAt = text.indexOf("::");
|
|
5140
|
+
if (gapAt !== text.lastIndexOf("::")) return null;
|
|
5141
|
+
const width = 8 - trailing.length;
|
|
5142
|
+
if (gapAt === -1) {
|
|
5143
|
+
const groups = groupsOf(text);
|
|
5144
|
+
return groups && groups.length === width ? [...groups, ...trailing] : null;
|
|
5145
|
+
}
|
|
5146
|
+
const head = groupsOf(text.slice(0, gapAt));
|
|
5147
|
+
const rest = groupsOf(text.slice(gapAt + 2));
|
|
5148
|
+
if (!head || !rest || head.length + rest.length >= width) return null;
|
|
5149
|
+
const zeros = new Array(width - head.length - rest.length).fill(0);
|
|
5150
|
+
return [...head, ...zeros, ...rest, ...trailing];
|
|
5151
|
+
}
|
|
5152
|
+
function isLoopbackIpv6(address) {
|
|
5153
|
+
const zone = address.indexOf("%");
|
|
5154
|
+
const groups = parseIpv6(zone === -1 ? address : address.slice(0, zone));
|
|
5155
|
+
if (!groups) return false;
|
|
5156
|
+
const leadingZeros = (count) => groups.slice(0, count).every((g) => g === 0);
|
|
5157
|
+
if (leadingZeros(7) && groups[7] === 1) return true;
|
|
5158
|
+
return leadingZeros(5) && groups[5] === 65535 && groups[6] >> 8 === 127;
|
|
5159
|
+
}
|
|
5160
|
+
function isLoopbackAddress(address) {
|
|
5161
|
+
return isLoopbackIpv4(address) || isLoopbackIpv6(address);
|
|
5162
|
+
}
|
|
5163
|
+
function isLoopbackHost(host) {
|
|
5164
|
+
const value = host.trim().toLowerCase();
|
|
5165
|
+
const bracketed = /^\[([^\]]+)\](?::(\d{1,5}))?$/.exec(value);
|
|
5166
|
+
if (bracketed) {
|
|
5167
|
+
return isLoopbackIpv6(bracketed[1]) && isValidPort(bracketed[2]);
|
|
5168
|
+
}
|
|
5169
|
+
const plain = /^([^:]+)(?::(\d{1,5}))?$/.exec(value);
|
|
5170
|
+
if (!plain) return false;
|
|
5171
|
+
return (plain[1] === "localhost" || isLoopbackIpv4(plain[1])) && isValidPort(plain[2]);
|
|
5172
|
+
}
|
|
5173
|
+
function isValidPort(port) {
|
|
5174
|
+
return port === void 0 || Number(port) <= 65535;
|
|
5175
|
+
}
|
|
5176
|
+
function isUnrebindableHost(host) {
|
|
5177
|
+
const value = host.trim().toLowerCase();
|
|
5178
|
+
const bracketed = /^\[([^\]%]+)(?:%[^\]]*)?\](?::(\d{1,5}))?$/.exec(value);
|
|
5179
|
+
if (bracketed) return parseIpv6(bracketed[1]) !== null && isValidPort(bracketed[2]);
|
|
5180
|
+
const plain = /^([^:]+)(?::(\d{1,5}))?$/.exec(value);
|
|
5181
|
+
if (!plain) return false;
|
|
5182
|
+
const named = plain[1] === "localhost" || plain[1].endsWith(".local") || ipv4Octets(plain[1]) !== null;
|
|
5183
|
+
return named && isValidPort(plain[2]);
|
|
5184
|
+
}
|
|
5185
|
+
function carriesWebContext(request) {
|
|
5186
|
+
const { headers } = request;
|
|
5187
|
+
if (headerValues(headers, "origin").length > 0) return true;
|
|
5188
|
+
if (headerValues(headers, "referer").length > 0) return true;
|
|
5189
|
+
return headerValues(headers, "sec-fetch-site").some(
|
|
5190
|
+
(site) => site.trim().toLowerCase() !== "none"
|
|
5191
|
+
);
|
|
5192
|
+
}
|
|
5193
|
+
function isTrustedLoopbackRequest(request) {
|
|
5194
|
+
if (!request.remoteAddress || !isLoopbackAddress(request.remoteAddress)) return false;
|
|
5195
|
+
if (carriesWebContext(request)) return false;
|
|
5196
|
+
const hosts = headerValues(request.headers, "host");
|
|
5197
|
+
return hosts.length > 0 && hosts.every(isLoopbackHost);
|
|
5198
|
+
}
|
|
5199
|
+
function isWebReachableRequest(request) {
|
|
5200
|
+
if (carriesWebContext(request)) return true;
|
|
5201
|
+
const hosts = headerValues(request.headers, "host");
|
|
5202
|
+
return hosts.length === 0 || !hosts.every(isUnrebindableHost);
|
|
5203
|
+
}
|
|
5204
|
+
async function bounded(work) {
|
|
5205
|
+
let timer;
|
|
5206
|
+
try {
|
|
5207
|
+
return await Promise.race([
|
|
5208
|
+
Promise.resolve().then(work),
|
|
5209
|
+
new Promise((_, reject) => {
|
|
5210
|
+
timer = setTimeout(() => reject(new Error("store call timed out")), STORE_TIMEOUT_MS);
|
|
5211
|
+
})
|
|
5212
|
+
]);
|
|
5213
|
+
} finally {
|
|
5214
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
5215
|
+
}
|
|
5216
|
+
}
|
|
5217
|
+
var UNAVAILABLE = {
|
|
5218
|
+
ok: false,
|
|
5219
|
+
code: "PAIRING_UNAVAILABLE",
|
|
5220
|
+
error: "The pairing store is not available yet. Try again."
|
|
5221
|
+
};
|
|
5222
|
+
var TOKEN_NOT_LOADED = /* @__PURE__ */ Symbol("token-not-loaded");
|
|
5223
|
+
var PairingAuthority = class {
|
|
5224
|
+
constructor(store) {
|
|
5225
|
+
this.store = store;
|
|
5226
|
+
this.armedCode = null;
|
|
5227
|
+
/** A correct code waiting on the store step, restored if that step fails. */
|
|
5228
|
+
this.heldCode = null;
|
|
5229
|
+
this.disarmReason = null;
|
|
5230
|
+
this.consumedBy = null;
|
|
5231
|
+
this.consumedAt = null;
|
|
5232
|
+
this.lastWrite = Promise.resolve();
|
|
5233
|
+
/** Nonce to its timestamp, oldest insertion first. */
|
|
5234
|
+
this.nonces = /* @__PURE__ */ new Map();
|
|
5235
|
+
/**
|
|
5236
|
+
* The oldest timestamp still accepted. A nonce this process did not record
|
|
5237
|
+
* cannot be checked for a replay, so a signature from before it existed is
|
|
5238
|
+
* refused: otherwise an app restart, which empties the cache, would reopen
|
|
5239
|
+
* the whole window to a captured request. Eviction raises it for the same
|
|
5240
|
+
* reason. A clock step re-bases it to the new now, or a clock set back past it
|
|
5241
|
+
* would refuse every signature until the app restarted. That leaves one gap with
|
|
5242
|
+
* two arms: a request captured before a step back can replay once, if its
|
|
5243
|
+
* timestamp is at or above the new now and this process does not remember its
|
|
5244
|
+
* nonce, because it has forgotten it, or because the app restarted since and it
|
|
5245
|
+
* never recorded it. Without `performance.now` no step is seen, and the floor
|
|
5246
|
+
* holds until the clock passes it.
|
|
5247
|
+
*/
|
|
5248
|
+
this.oldestAcceptedTs = Date.now();
|
|
5249
|
+
/**
|
|
5250
|
+
* The wall clock minus the monotonic clock at the last re-base. It moves only
|
|
5251
|
+
* then, so small steps add up until they cross the tolerance, and a clock step
|
|
5252
|
+
* leaves the floor at most twice the tolerance ahead of the clock: the clock can
|
|
5253
|
+
* swing from just over the baseline to just under it without crossing it.
|
|
5254
|
+
*/
|
|
5255
|
+
this.clockOffset = this.readClockOffset();
|
|
5256
|
+
/** Moves when a revoke ends, so a read of the old token that spans one cannot admit. */
|
|
5257
|
+
this.tokenGeneration = 0;
|
|
5258
|
+
/** Revokes under way. The old token may already be dying, so nothing verifies. */
|
|
5259
|
+
this.revokesInFlight = 0;
|
|
5260
|
+
/** A revoke failed in a way that may have left the old token in the store. */
|
|
5261
|
+
this.storeSuspect = false;
|
|
5262
|
+
}
|
|
5263
|
+
/**
|
|
5264
|
+
* The stored token, `null` when there is none, or `TOKEN_NOT_LOADED`. Only
|
|
5265
|
+
* `readStore` tells these apart: an empty or non-string value is never a
|
|
5266
|
+
* token, because an empty key would verify anything signed with it.
|
|
5267
|
+
*/
|
|
5268
|
+
async readToken() {
|
|
5269
|
+
return this.storeSuspect ? TOKEN_NOT_LOADED : this.readStore();
|
|
5270
|
+
}
|
|
5271
|
+
async readStore() {
|
|
5272
|
+
try {
|
|
5273
|
+
const token = await bounded(() => this.store.get());
|
|
5274
|
+
if (token === null) return null;
|
|
5275
|
+
return typeof token === "string" && token.length > 0 ? token : TOKEN_NOT_LOADED;
|
|
5276
|
+
} catch {
|
|
5277
|
+
return TOKEN_NOT_LOADED;
|
|
5278
|
+
}
|
|
5279
|
+
}
|
|
5280
|
+
async randomBytes(n) {
|
|
5281
|
+
const unusable = new PairingError(
|
|
5282
|
+
"PAIRING_UNAVAILABLE",
|
|
5283
|
+
"The secure random source returned no usable bytes."
|
|
5284
|
+
);
|
|
5285
|
+
let bytes;
|
|
5286
|
+
try {
|
|
5287
|
+
bytes = await bounded(() => this.store.randomBytes(n));
|
|
5288
|
+
} catch {
|
|
5289
|
+
throw unusable;
|
|
5290
|
+
}
|
|
5291
|
+
if (!ArrayBuffer.isView(bytes) || bytes.length !== n || bytes.every((byte) => byte === 0)) {
|
|
5292
|
+
throw unusable;
|
|
5293
|
+
}
|
|
5294
|
+
return bytes;
|
|
5295
|
+
}
|
|
5296
|
+
async mintAndStoreToken() {
|
|
5297
|
+
const token = bytesToHex(await this.randomBytes(TOKEN_BYTES));
|
|
5298
|
+
await bounded(() => this.store.set(token));
|
|
5299
|
+
return token;
|
|
5300
|
+
}
|
|
5301
|
+
/** Run token writes one at a time, so two first pairs cannot mint two tokens. */
|
|
5302
|
+
exclusive(work) {
|
|
5303
|
+
const run = this.lastWrite.then(work, work);
|
|
5304
|
+
this.lastWrite = run.catch(() => void 0);
|
|
5305
|
+
return run;
|
|
5306
|
+
}
|
|
5307
|
+
/** Check a `<ts>.<nonce>.<sig>` value against this request and the stored token. */
|
|
5308
|
+
async verifySignature(request, header) {
|
|
5309
|
+
const match = /^(\d{1,16})\.([A-Za-z0-9_-]{16,64})\.([0-9a-f]{64})$/.exec(header);
|
|
5310
|
+
if (!match) return "refused";
|
|
5311
|
+
const [, ts, nonce, sig] = match;
|
|
5312
|
+
const tsMs = Number(ts);
|
|
5313
|
+
this.noticeClockStep();
|
|
5314
|
+
if (Math.abs(Date.now() - tsMs) > SIGNATURE_WINDOW_MS) return "timestamp_refused";
|
|
5315
|
+
if (tsMs < this.oldestAcceptedTs) return "timestamp_refused";
|
|
5316
|
+
const pathAndQuery = signedTarget(request);
|
|
5317
|
+
const body = signedBody(request);
|
|
5318
|
+
if (pathAndQuery === null || body === null) return "refused";
|
|
5319
|
+
if (body.length > SIGNED_BODY_MAX_CHARS) return "body_too_large";
|
|
5320
|
+
if (request.method.length > SIGNED_METHOD_MAX_CHARS) return "target_too_long";
|
|
5321
|
+
if (pathAndQuery.length > SIGNED_TARGET_MAX_CHARS) return "target_too_long";
|
|
5322
|
+
const generation = this.tokenGeneration;
|
|
5323
|
+
const token = await this.readToken();
|
|
5324
|
+
if (this.revokesInFlight > 0) return "refused";
|
|
5325
|
+
if (token === TOKEN_NOT_LOADED) return "store_unavailable";
|
|
5326
|
+
if (token === null) return "refused";
|
|
5327
|
+
const expected = signatureHex(token, { method: request.method, pathAndQuery, ts, nonce, body });
|
|
5328
|
+
if (!constantTimeEqual(expected, sig)) return "refused";
|
|
5329
|
+
if (generation !== this.tokenGeneration) return "refused";
|
|
5330
|
+
if (this.nonces.has(nonce)) return "refused";
|
|
5331
|
+
this.rememberNonce(nonce, tsMs);
|
|
5332
|
+
return "valid";
|
|
5333
|
+
}
|
|
5334
|
+
readClockOffset() {
|
|
5335
|
+
const monotonic = monotonicNow();
|
|
5336
|
+
return monotonic === null ? null : Date.now() - monotonic;
|
|
5337
|
+
}
|
|
5338
|
+
/**
|
|
5339
|
+
* Re-base the floor when the wall clock has stepped against the monotonic one.
|
|
5340
|
+
* A nonce remembered from above the new now is clamped to just below the new
|
|
5341
|
+
* floor. Left as it is, forgetting it later would raise the floor back to a time
|
|
5342
|
+
* before the step, and the lockout would return when the cache overflowed.
|
|
5343
|
+
*/
|
|
5344
|
+
noticeClockStep() {
|
|
5345
|
+
const offset = this.readClockOffset();
|
|
5346
|
+
if (offset === null || this.clockOffset === null) return;
|
|
5347
|
+
if (Math.abs(offset - this.clockOffset) > CLOCK_STEP_TOLERANCE_MS) {
|
|
5348
|
+
this.clockOffset = offset;
|
|
5349
|
+
this.oldestAcceptedTs = Date.now();
|
|
5350
|
+
for (const [nonce, tsMs] of this.nonces) {
|
|
5351
|
+
if (tsMs >= this.oldestAcceptedTs) this.nonces.set(nonce, this.oldestAcceptedTs - 1);
|
|
5352
|
+
}
|
|
5353
|
+
}
|
|
5354
|
+
}
|
|
5355
|
+
rememberNonce(nonce, tsMs) {
|
|
5356
|
+
if (this.nonces.size >= NONCE_CACHE_MAX) {
|
|
5357
|
+
const now = Date.now();
|
|
5358
|
+
for (const [seen, seenTs] of this.nonces) {
|
|
5359
|
+
if (seenTs + SIGNATURE_WINDOW_MS < now) this.forgetNonce(seen, seenTs);
|
|
5360
|
+
}
|
|
5361
|
+
}
|
|
5362
|
+
while (this.nonces.size >= NONCE_CACHE_MAX) {
|
|
5363
|
+
const [seen, seenTs] = this.nonces.entries().next().value;
|
|
5364
|
+
this.forgetNonce(seen, seenTs);
|
|
5365
|
+
}
|
|
5366
|
+
this.nonces.set(nonce, tsMs);
|
|
5367
|
+
}
|
|
5368
|
+
/** Forgetting a nonce must not readmit it, so its timestamp stops being accepted. */
|
|
5369
|
+
forgetNonce(nonce, tsMs) {
|
|
5370
|
+
this.nonces.delete(nonce);
|
|
5371
|
+
this.oldestAcceptedTs = Math.max(this.oldestAcceptedTs, tsMs + 1);
|
|
5372
|
+
}
|
|
5373
|
+
/**
|
|
5374
|
+
* The route's decision. A trusted loopback caller needs no code. Anyone else
|
|
5375
|
+
* spends the armed code, and an unarmed one refuses without comparing.
|
|
5376
|
+
*/
|
|
5377
|
+
async pair(input) {
|
|
5378
|
+
if (!input.trustedLoopback) {
|
|
5379
|
+
const refusal = this.spendCode(input.body);
|
|
5380
|
+
if (refusal) return refusal;
|
|
5381
|
+
}
|
|
5382
|
+
const spent = input.trustedLoopback ? null : this.heldCode;
|
|
5383
|
+
const outcome = await this.exclusive(async () => {
|
|
5384
|
+
const existing = await this.readToken();
|
|
5385
|
+
if (existing === TOKEN_NOT_LOADED) return UNAVAILABLE;
|
|
5386
|
+
if (existing !== null) return { ok: true, token: existing };
|
|
5387
|
+
try {
|
|
5388
|
+
return { ok: true, token: await this.mintAndStoreToken() };
|
|
5389
|
+
} catch {
|
|
5390
|
+
return UNAVAILABLE;
|
|
5391
|
+
}
|
|
5392
|
+
});
|
|
5393
|
+
if (spent) this.settleSpentCode(spent, outcome.ok, input.peer);
|
|
5394
|
+
return outcome;
|
|
5395
|
+
}
|
|
5396
|
+
/**
|
|
5397
|
+
* The store step has answered. A failure gives the code back, since the user
|
|
5398
|
+
* typed the right one and the phone let them down, unless the app has since
|
|
5399
|
+
* cancelled it or armed another.
|
|
5400
|
+
*/
|
|
5401
|
+
settleSpentCode(spent, paired, peer) {
|
|
5402
|
+
const stillHeld = this.heldCode === spent;
|
|
5403
|
+
if (stillHeld) this.heldCode = null;
|
|
5404
|
+
if (paired) {
|
|
5405
|
+
if (stillHeld) this.disarmReason = "consumed";
|
|
5406
|
+
this.consumedBy = peer ?? "unknown";
|
|
5407
|
+
this.consumedAt = Date.now();
|
|
5408
|
+
} else if (stillHeld) {
|
|
5409
|
+
this.armedCode = spent;
|
|
5410
|
+
}
|
|
5411
|
+
}
|
|
5412
|
+
/** No await in here: the attempt count has to move with the comparison. */
|
|
5413
|
+
spendCode(body) {
|
|
5414
|
+
this.expireCode();
|
|
5415
|
+
const armed = this.armedCode;
|
|
5416
|
+
if (!armed) {
|
|
5417
|
+
return {
|
|
5418
|
+
ok: false,
|
|
5419
|
+
code: "PAIRING_NOT_ARMED",
|
|
5420
|
+
error: "No pairing code is armed. Arm one in the app, or pair over a cable."
|
|
5421
|
+
};
|
|
5422
|
+
}
|
|
5423
|
+
const code = body?.code;
|
|
5424
|
+
if (typeof code !== "string" || !/^\d{6}$/.test(code)) {
|
|
5425
|
+
return {
|
|
5426
|
+
ok: false,
|
|
5427
|
+
code: "PAIRING_CODE_REQUIRED",
|
|
5428
|
+
error: 'Send the 6-digit code shown in the app, as { "code": "123456" }.'
|
|
5429
|
+
};
|
|
5430
|
+
}
|
|
5431
|
+
if (constantTimeEqual(armed.code, code)) {
|
|
5432
|
+
this.armedCode = null;
|
|
5433
|
+
this.heldCode = armed;
|
|
5434
|
+
return null;
|
|
5435
|
+
}
|
|
5436
|
+
armed.wrongAttempts += 1;
|
|
5437
|
+
if (armed.wrongAttempts >= PAIRING_CODE_MAX_ATTEMPTS) {
|
|
5438
|
+
this.armedCode = null;
|
|
5439
|
+
this.disarmReason = "attempts_exhausted";
|
|
5440
|
+
}
|
|
5441
|
+
return { ok: false, code: "PAIRING_CODE_INVALID", error: "That code is not correct." };
|
|
5442
|
+
}
|
|
5443
|
+
/** Either clock ends the code, so a clock set back cannot extend it. */
|
|
5444
|
+
expireCode() {
|
|
5445
|
+
if (!this.armedCode) return;
|
|
5446
|
+
const { expiresAt, monotonicDeadline } = this.armedCode;
|
|
5447
|
+
const monotonic = monotonicNow();
|
|
5448
|
+
const monotonicExpired = monotonic !== null && monotonicDeadline !== null && monotonic >= monotonicDeadline;
|
|
5449
|
+
if (Date.now() >= expiresAt || monotonicExpired) {
|
|
5450
|
+
this.armedCode = null;
|
|
5451
|
+
this.disarmReason = "expired";
|
|
5452
|
+
}
|
|
5453
|
+
}
|
|
5454
|
+
/** Arm a fresh code, replacing any live one. */
|
|
5455
|
+
async arm() {
|
|
5456
|
+
const stored = await this.readToken();
|
|
5457
|
+
if (stored === TOKEN_NOT_LOADED) {
|
|
5458
|
+
throw new PairingError("PAIRING_UNAVAILABLE", "The pairing store is not available yet.");
|
|
5459
|
+
}
|
|
5460
|
+
const code = await this.drawCode();
|
|
5461
|
+
const expiresAt = Date.now() + PAIRING_CODE_TTL_MS;
|
|
5462
|
+
const monotonic = monotonicNow();
|
|
5463
|
+
this.armedCode = {
|
|
5464
|
+
code,
|
|
5465
|
+
expiresAt,
|
|
5466
|
+
monotonicDeadline: monotonic === null ? null : monotonic + PAIRING_CODE_TTL_MS,
|
|
5467
|
+
wrongAttempts: 0
|
|
5468
|
+
};
|
|
5469
|
+
this.heldCode = null;
|
|
5470
|
+
this.disarmReason = null;
|
|
5471
|
+
this.consumedBy = null;
|
|
5472
|
+
this.consumedAt = null;
|
|
5473
|
+
return { code, expiresAt };
|
|
5474
|
+
}
|
|
5475
|
+
/** A uniform 6-digit code, by rejection sampling so that no digit is favoured. */
|
|
5476
|
+
async drawCode() {
|
|
5477
|
+
for (let attempt = 0; attempt < CODE_SAMPLE_ATTEMPTS; attempt++) {
|
|
5478
|
+
const bytes = await this.randomBytes(4);
|
|
5479
|
+
const value = new DataView(bytes.buffer, bytes.byteOffset, 4).getUint32(0);
|
|
5480
|
+
if (value < CODE_SAMPLE_LIMIT) return String(value % 1e6).padStart(6, "0");
|
|
5481
|
+
}
|
|
5482
|
+
throw new PairingError(
|
|
5483
|
+
"PAIRING_UNAVAILABLE",
|
|
5484
|
+
"The secure random source returned no usable bytes."
|
|
5485
|
+
);
|
|
5486
|
+
}
|
|
5487
|
+
/** Cancel a live code. */
|
|
5488
|
+
disarm() {
|
|
5489
|
+
this.dropCode("cancelled");
|
|
5490
|
+
}
|
|
5491
|
+
dropCode(reason) {
|
|
5492
|
+
if (this.armedCode || this.heldCode) {
|
|
5493
|
+
this.armedCode = null;
|
|
5494
|
+
this.heldCode = null;
|
|
5495
|
+
this.disarmReason = reason;
|
|
5496
|
+
}
|
|
5497
|
+
}
|
|
5498
|
+
/**
|
|
5499
|
+
* Replace the token, so every signature made with the old one stops verifying.
|
|
5500
|
+
* If it cannot be replaced it is cleared. If that fails too, signatures and
|
|
5501
|
+
* pairing stay refused until a revoke succeeds: the user asked for the old
|
|
5502
|
+
* token to die, and a store that would not say so cannot be trusted to have
|
|
5503
|
+
* done it.
|
|
5504
|
+
*
|
|
5505
|
+
* Nothing verifies from the moment this starts until it ends. The generation
|
|
5506
|
+
* moves when it ends, whichever way, so a check that read the old token before
|
|
5507
|
+
* then cannot admit its request afterwards. A request a check has already
|
|
5508
|
+
* admitted runs to its end.
|
|
5509
|
+
*/
|
|
5510
|
+
async revoke() {
|
|
5511
|
+
this.revokesInFlight += 1;
|
|
5512
|
+
this.dropCode("revoked");
|
|
5513
|
+
try {
|
|
5514
|
+
await this.exclusive(async () => {
|
|
5515
|
+
const stored = await this.readStore();
|
|
5516
|
+
if (stored === TOKEN_NOT_LOADED) {
|
|
5517
|
+
throw new PairingError("PAIRING_UNAVAILABLE", "The pairing store is not available yet.");
|
|
5518
|
+
}
|
|
5519
|
+
try {
|
|
5520
|
+
await this.mintAndStoreToken();
|
|
5521
|
+
this.storeSuspect = false;
|
|
5522
|
+
} catch {
|
|
5523
|
+
try {
|
|
5524
|
+
await bounded(() => this.store.clear());
|
|
5525
|
+
this.storeSuspect = false;
|
|
5526
|
+
} catch {
|
|
5527
|
+
this.storeSuspect = true;
|
|
5528
|
+
}
|
|
5529
|
+
throw new PairingError("PAIRING_UNAVAILABLE", "The pairing token could not be replaced.");
|
|
5530
|
+
}
|
|
5531
|
+
});
|
|
5532
|
+
} finally {
|
|
5533
|
+
this.revokesInFlight -= 1;
|
|
5534
|
+
this.tokenGeneration += 1;
|
|
5535
|
+
}
|
|
5536
|
+
}
|
|
5537
|
+
/** What Settings shows. */
|
|
5538
|
+
getState() {
|
|
5539
|
+
this.expireCode();
|
|
5540
|
+
return {
|
|
5541
|
+
configured: true,
|
|
5542
|
+
armed: this.armedCode !== null,
|
|
5543
|
+
expiresAt: this.armedCode?.expiresAt ?? null,
|
|
5544
|
+
attemptsRemaining: this.armedCode ? PAIRING_CODE_MAX_ATTEMPTS - this.armedCode.wrongAttempts : null,
|
|
5545
|
+
consumedBy: this.consumedBy,
|
|
5546
|
+
consumedAt: this.consumedAt,
|
|
5547
|
+
disarmReason: this.disarmReason
|
|
5548
|
+
};
|
|
5549
|
+
}
|
|
5550
|
+
};
|
|
5551
|
+
var UNCONFIGURED_PAIRING_STATE = {
|
|
5552
|
+
configured: false,
|
|
5553
|
+
armed: false,
|
|
5554
|
+
expiresAt: null,
|
|
5555
|
+
attemptsRemaining: null,
|
|
5556
|
+
consumedBy: null,
|
|
5557
|
+
consumedAt: null,
|
|
5558
|
+
disarmReason: null
|
|
5559
|
+
};
|
|
5560
|
+
function signedTarget(request) {
|
|
5561
|
+
if (request.rawTarget === void 0) {
|
|
5562
|
+
return Object.keys(request.query ?? {}).length === 0 ? request.path : null;
|
|
5563
|
+
}
|
|
5564
|
+
const queryAt = request.rawTarget.indexOf("?");
|
|
5565
|
+
const path = queryAt === -1 ? request.rawTarget : request.rawTarget.slice(0, queryAt);
|
|
5566
|
+
return path === request.path ? request.rawTarget : null;
|
|
5567
|
+
}
|
|
5568
|
+
function signedBody(request) {
|
|
5569
|
+
if (request.rawBody !== void 0) return request.rawBody;
|
|
5570
|
+
return request.body === void 0 ? "" : null;
|
|
5571
|
+
}
|
|
5572
|
+
|
|
5005
5573
|
// src/server/observability.ts
|
|
5006
5574
|
var RingBuffer = class {
|
|
5007
5575
|
constructor(capacity) {
|
|
@@ -5332,7 +5900,20 @@ function isUnsubscribe(msg) {
|
|
|
5332
5900
|
var ERROR_CODE_HTTP_STATUS = /* @__PURE__ */ new Map([
|
|
5333
5901
|
["NOT_SUPPORTED", 501],
|
|
5334
5902
|
["NOT_FOUND", 404],
|
|
5335
|
-
["METHOD_NOT_ALLOWED", 405]
|
|
5903
|
+
["METHOD_NOT_ALLOWED", 405],
|
|
5904
|
+
// Device pairing, all answered by the network entry. A 401 means this caller
|
|
5905
|
+
// has not proved it may act; a 403 means proving it would change nothing; a 503
|
|
5906
|
+
// means the phone cannot check yet.
|
|
5907
|
+
["PAIRING_REQUIRED", 401],
|
|
5908
|
+
["PAIRING_UNCONFIGURED", 401],
|
|
5909
|
+
["PAIRING_NOT_ARMED", 401],
|
|
5910
|
+
["PAIRING_CODE_INVALID", 401],
|
|
5911
|
+
["PAIRING_TIMESTAMP_REFUSED", 401],
|
|
5912
|
+
["PAIRING_WS_LOOPBACK_ONLY", 403],
|
|
5913
|
+
["PAIRING_WEB_CALLER_REFUSED", 403],
|
|
5914
|
+
["PAIRING_BODY_TOO_LARGE", 413],
|
|
5915
|
+
["PAIRING_TARGET_TOO_LONG", 414],
|
|
5916
|
+
["PAIRING_UNAVAILABLE", 503]
|
|
5336
5917
|
]);
|
|
5337
5918
|
function httpStatusForResponse(response) {
|
|
5338
5919
|
if (response.success) return 200;
|
|
@@ -5358,6 +5939,10 @@ function parsePath(pattern, actual) {
|
|
|
5358
5939
|
var WS_ROUTES = [
|
|
5359
5940
|
// Health
|
|
5360
5941
|
{ pattern: "health", handler: "health", httpMethods: ["GET"] },
|
|
5942
|
+
// Device pairing. Always NOT_SUPPORTED from this table: the real route is in
|
|
5943
|
+
// `handleNetworkRequest`, because route context carries no peer, and this
|
|
5944
|
+
// entry is what JSON-RPC and the relay reach instead.
|
|
5945
|
+
{ pattern: "pair", handler: "pair", httpMethods: ["POST"] },
|
|
5361
5946
|
// Elements
|
|
5362
5947
|
{ pattern: "control/elements", handler: "getElements", httpMethods: ["GET"] },
|
|
5363
5948
|
// Flat alias — matches the runner UI Bridge's `/ui-bridge/elements` path so
|
|
@@ -5520,6 +6105,7 @@ function allowedMethodsForPath(path, testHooks) {
|
|
|
5520
6105
|
}
|
|
5521
6106
|
var HANDLER_DESCRIPTIONS = {
|
|
5522
6107
|
health: "Liveness probe; returns app metadata + element count",
|
|
6108
|
+
pair: "Get the pairing token: loopback with no code, otherwise body { code }. Direct HTTP only",
|
|
5523
6109
|
getElements: "Flat list of registered UI elements",
|
|
5524
6110
|
getElement: "Single element by id",
|
|
5525
6111
|
getElementState: "Dynamic state (value, focused, checked) for one element",
|
|
@@ -5621,10 +6207,24 @@ function buildHelpPayload(testHooks) {
|
|
|
5621
6207
|
]
|
|
5622
6208
|
};
|
|
5623
6209
|
}
|
|
6210
|
+
var HEALTH_PATH = "/ui-bridge/health";
|
|
6211
|
+
var PAIR_PATH = "/ui-bridge/pair";
|
|
6212
|
+
function jsonRpcIdOf(rawMessage) {
|
|
6213
|
+
try {
|
|
6214
|
+
const id = JSON.parse(rawMessage)?.id;
|
|
6215
|
+
return typeof id === "number" || typeof id === "string" ? id : 0;
|
|
6216
|
+
} catch {
|
|
6217
|
+
return 0;
|
|
6218
|
+
}
|
|
6219
|
+
}
|
|
5624
6220
|
var NativeUIBridgeServer = class {
|
|
5625
6221
|
constructor(registry, executor, config = {}) {
|
|
5626
6222
|
this.registry = registry;
|
|
5627
6223
|
this.executor = executor;
|
|
6224
|
+
/** Whether this run's adapter has called `onWebSocketUpgrade` at all. */
|
|
6225
|
+
this.upgradeGateSeen = false;
|
|
6226
|
+
/** Warned once per run rather than per request, which would flood the error ring. */
|
|
6227
|
+
this.warnedMissingPeer = false;
|
|
5628
6228
|
this.running = false;
|
|
5629
6229
|
/**
|
|
5630
6230
|
* Per-connection cleanup thunks for in-flight waiters (waitForElement,
|
|
@@ -5637,6 +6237,9 @@ var NativeUIBridgeServer = class {
|
|
|
5637
6237
|
cors: true,
|
|
5638
6238
|
...config
|
|
5639
6239
|
};
|
|
6240
|
+
if (this.config.pairingStore) {
|
|
6241
|
+
this.pairing = new PairingAuthority(this.config.pairingStore);
|
|
6242
|
+
}
|
|
5640
6243
|
this.consoleErrorBuffer = new ConsoleErrorBuffer();
|
|
5641
6244
|
this.networkRequestBuffer = new NetworkRequestBuffer();
|
|
5642
6245
|
if (this.config.appInfo) {
|
|
@@ -5682,6 +6285,14 @@ var NativeUIBridgeServer = class {
|
|
|
5682
6285
|
setAdapter(adapter) {
|
|
5683
6286
|
this.adapter = adapter;
|
|
5684
6287
|
}
|
|
6288
|
+
/**
|
|
6289
|
+
* Set the device's stable id. An app usually loads it from storage after the
|
|
6290
|
+
* server has started, and this spares it a restart that would drop every
|
|
6291
|
+
* socket and any armed code.
|
|
6292
|
+
*/
|
|
6293
|
+
setDeviceId(deviceId) {
|
|
6294
|
+
this.config.deviceId = deviceId;
|
|
6295
|
+
}
|
|
5685
6296
|
/**
|
|
5686
6297
|
* Set a navigation provider for programmatic route navigation.
|
|
5687
6298
|
* This enables `control/page/navigate` and `control/page/back` on native.
|
|
@@ -5915,7 +6526,18 @@ var NativeUIBridgeServer = class {
|
|
|
5915
6526
|
this.consoleErrorBuffer.install();
|
|
5916
6527
|
this.networkRequestBuffer.install();
|
|
5917
6528
|
}
|
|
5918
|
-
|
|
6529
|
+
this.upgradeGateSeen = false;
|
|
6530
|
+
this.warnedMissingPeer = false;
|
|
6531
|
+
if (this.config.allowUnauthenticatedNetwork) {
|
|
6532
|
+
console.warn(
|
|
6533
|
+
"[ui-bridge-native] allowUnauthenticatedNetwork is on: anyone who can reach this port can drive the app over HTTP."
|
|
6534
|
+
);
|
|
6535
|
+
} else if (!this.pairing) {
|
|
6536
|
+
console.warn(
|
|
6537
|
+
"[ui-bridge-native] No pairingStore configured: non-loopback callers are refused with PAIRING_UNCONFIGURED."
|
|
6538
|
+
);
|
|
6539
|
+
}
|
|
6540
|
+
await this.adapter.start(this.config.serverPort, this.handleNetworkRequest.bind(this));
|
|
5919
6541
|
this.running = true;
|
|
5920
6542
|
console.log(`[ui-bridge-native] HTTP server started on port ${this.config.serverPort}`);
|
|
5921
6543
|
}
|
|
@@ -5944,6 +6566,26 @@ var NativeUIBridgeServer = class {
|
|
|
5944
6566
|
setEventBridge(bridge) {
|
|
5945
6567
|
this.eventBridge = bridge;
|
|
5946
6568
|
}
|
|
6569
|
+
/**
|
|
6570
|
+
* The entry a socket's messages come through. An adapter that never called
|
|
6571
|
+
* `onWebSocketUpgrade` admitted the socket with no check, and a message carries
|
|
6572
|
+
* no peer to check now, so it is refused: the same fail-closed reading as a
|
|
6573
|
+
* missing peer address. An adapter that gates sees a call before its first socket.
|
|
6574
|
+
*/
|
|
6575
|
+
async handleNetworkWebSocketMessage(connId, rawMessage) {
|
|
6576
|
+
if (!this.upgradeGateSeen) {
|
|
6577
|
+
return JSON.stringify({
|
|
6578
|
+
id: jsonRpcIdOf(rawMessage),
|
|
6579
|
+
result: {
|
|
6580
|
+
success: false,
|
|
6581
|
+
error: "This server adapter does not gate WebSocket upgrades (onWebSocketUpgrade).",
|
|
6582
|
+
code: "PAIRING_UNCONFIGURED",
|
|
6583
|
+
timestamp: Date.now()
|
|
6584
|
+
}
|
|
6585
|
+
});
|
|
6586
|
+
}
|
|
6587
|
+
return this.handleWebSocketMessage(connId, rawMessage);
|
|
6588
|
+
}
|
|
5947
6589
|
/**
|
|
5948
6590
|
* Handle a JSON-RPC message from a WebSocket client.
|
|
5949
6591
|
* Parses the message, routes to the appropriate handler, and returns
|
|
@@ -6472,21 +7114,272 @@ var NativeUIBridgeServer = class {
|
|
|
6472
7114
|
timestamp: Date.now()
|
|
6473
7115
|
};
|
|
6474
7116
|
}
|
|
7117
|
+
// ── Device pairing ──────────────────────────────────────────────────────
|
|
6475
7118
|
/**
|
|
6476
|
-
*
|
|
7119
|
+
* What `start()` hands the adapter: every request off the socket comes through
|
|
7120
|
+
* here, and `handleRequest` sits behind it.
|
|
7121
|
+
*
|
|
7122
|
+
* | Caller | Admitted |
|
|
7123
|
+
* |----------------------------------------------------------|----------|
|
|
7124
|
+
* | Loopback peer, no web context, loopback `Host` | yes |
|
|
7125
|
+
* | Anyone with a valid `X-UI-Bridge-Auth` signature | yes |
|
|
7126
|
+
* | Anyone, `GET /ui-bridge/health` (trimmed) | yes |
|
|
7127
|
+
* | Anyone with no web context, `POST /ui-bridge/pair` | yes |
|
|
7128
|
+
* | A web page, `POST /ui-bridge/pair` | 403 `PAIRING_WEB_CALLER_REFUSED` |
|
|
7129
|
+
* | Signature outside the window or below the floor | 401 `PAIRING_TIMESTAMP_REFUSED` |
|
|
7130
|
+
* | Signed body over `SIGNED_BODY_MAX_CHARS` | 413 `PAIRING_BODY_TOO_LARGE` |
|
|
7131
|
+
* | Signed method or target over its cap | 414 `PAIRING_TARGET_TOO_LONG` |
|
|
7132
|
+
* | Everyone else | 401 `PAIRING_REQUIRED` |
|
|
7133
|
+
* | No store configured, not loopback | 401 `PAIRING_UNCONFIGURED` |
|
|
7134
|
+
* | Under `allowUnauthenticatedNetwork`, a web page or a rebindable `Host` | 403 `PAIRING_WEB_CALLER_REFUSED` |
|
|
7135
|
+
*
|
|
7136
|
+
* The gate is here rather than in `handleRequest` because `CloudRelayClient`
|
|
7137
|
+
* calls that directly and must keep working: relay access already means the
|
|
7138
|
+
* same user account, checked by the backend.
|
|
7139
|
+
*/
|
|
7140
|
+
async handleNetworkRequest(request) {
|
|
7141
|
+
try {
|
|
7142
|
+
if (request.method === "POST" && request.path === PAIR_PATH) {
|
|
7143
|
+
return await this.handlePair(request);
|
|
7144
|
+
}
|
|
7145
|
+
const admission = await this.admit(request);
|
|
7146
|
+
if (request.method === "GET" && request.path === HEALTH_PATH) {
|
|
7147
|
+
return await this.handleNetworkHealth(request, admission);
|
|
7148
|
+
}
|
|
7149
|
+
if (!admission.admitted) {
|
|
7150
|
+
return this.refusal(admission.code, admission.error, { data: admission.data });
|
|
7151
|
+
}
|
|
7152
|
+
return await this.handleRequest(request);
|
|
7153
|
+
} catch {
|
|
7154
|
+
return this.refusal("INTERNAL_ERROR", "Internal server error", { status: 500 });
|
|
7155
|
+
}
|
|
7156
|
+
}
|
|
7157
|
+
/**
|
|
7158
|
+
* The WebSocket door: trusted loopback or nothing, whatever else the request
|
|
7159
|
+
* carries. A signature covers no upgrade intent, so one swallowed GET re-sent
|
|
7160
|
+
* with `Upgrade: websocket` would become a permanent control socket, and a socket
|
|
7161
|
+
* outlives the signature's window. `allowUnauthenticatedNetwork` opens HTTP only,
|
|
7162
|
+
* and no runner dials a device's socket.
|
|
7163
|
+
*/
|
|
7164
|
+
handleWebSocketUpgrade(request) {
|
|
7165
|
+
this.upgradeGateSeen = true;
|
|
7166
|
+
if (this.isTrustedLoopback(request)) return { admit: true };
|
|
7167
|
+
return {
|
|
7168
|
+
admit: false,
|
|
7169
|
+
response: this.refusal(
|
|
7170
|
+
"PAIRING_WS_LOOPBACK_ONLY",
|
|
7171
|
+
"WebSocket upgrades are accepted only from this machine. Use signed HTTP from anywhere else."
|
|
7172
|
+
)
|
|
7173
|
+
};
|
|
7174
|
+
}
|
|
7175
|
+
isTrustedLoopback(request) {
|
|
7176
|
+
if (!request.remoteAddress && !this.warnedMissingPeer) {
|
|
7177
|
+
this.warnedMissingPeer = true;
|
|
7178
|
+
console.warn(
|
|
7179
|
+
"[ui-bridge-native] A request arrived with no peer address and is treated as not loopback. The server adapter must pass remoteAddress."
|
|
7180
|
+
);
|
|
7181
|
+
}
|
|
7182
|
+
return isTrustedLoopbackRequest(request);
|
|
7183
|
+
}
|
|
7184
|
+
async admit(request) {
|
|
7185
|
+
if (this.isTrustedLoopback(request)) return { admitted: true, as: "loopback" };
|
|
7186
|
+
if (this.config.allowUnauthenticatedNetwork) {
|
|
7187
|
+
if (isWebReachableRequest(request)) {
|
|
7188
|
+
return {
|
|
7189
|
+
admitted: false,
|
|
7190
|
+
code: "PAIRING_WEB_CALLER_REFUSED",
|
|
7191
|
+
error: "A web page cannot drive this device."
|
|
7192
|
+
};
|
|
7193
|
+
}
|
|
7194
|
+
return { admitted: true, as: "unauthenticated-network" };
|
|
7195
|
+
}
|
|
7196
|
+
if (!this.pairing) {
|
|
7197
|
+
return {
|
|
7198
|
+
admitted: false,
|
|
7199
|
+
code: "PAIRING_UNCONFIGURED",
|
|
7200
|
+
error: "This app has not configured device pairing, so it accepts only local callers."
|
|
7201
|
+
};
|
|
7202
|
+
}
|
|
7203
|
+
const required = {
|
|
7204
|
+
admitted: false,
|
|
7205
|
+
code: "PAIRING_REQUIRED",
|
|
7206
|
+
error: "This device requires pairing. Sign the request with the token from POST /ui-bridge/pair."
|
|
7207
|
+
};
|
|
7208
|
+
const signatures = headerValues(request.headers, UI_BRIDGE_AUTH_HEADER);
|
|
7209
|
+
if (signatures.length !== 1) return required;
|
|
7210
|
+
const verdict = await this.pairing.verifySignature(request, signatures[0]);
|
|
7211
|
+
if (verdict === "valid") return { admitted: true, as: "signed" };
|
|
7212
|
+
if (verdict === "store_unavailable") {
|
|
7213
|
+
return {
|
|
7214
|
+
admitted: false,
|
|
7215
|
+
code: "PAIRING_UNAVAILABLE",
|
|
7216
|
+
error: "The pairing store is not available yet. Try again."
|
|
7217
|
+
};
|
|
7218
|
+
}
|
|
7219
|
+
if (verdict === "timestamp_refused") {
|
|
7220
|
+
return {
|
|
7221
|
+
admitted: false,
|
|
7222
|
+
code: "PAIRING_TIMESTAMP_REFUSED",
|
|
7223
|
+
error: "The signature timestamp is not acceptable. Re-sync to data.serverTime and sign again.",
|
|
7224
|
+
data: { serverTime: Date.now() }
|
|
7225
|
+
};
|
|
7226
|
+
}
|
|
7227
|
+
if (verdict === "body_too_large") {
|
|
7228
|
+
return {
|
|
7229
|
+
admitted: false,
|
|
7230
|
+
code: "PAIRING_BODY_TOO_LARGE",
|
|
7231
|
+
error: `A signed body may be at most ${SIGNED_BODY_MAX_CHARS} characters.`
|
|
7232
|
+
};
|
|
7233
|
+
}
|
|
7234
|
+
if (verdict === "target_too_long") {
|
|
7235
|
+
return {
|
|
7236
|
+
admitted: false,
|
|
7237
|
+
code: "PAIRING_TARGET_TOO_LONG",
|
|
7238
|
+
error: `A signed target may be at most ${SIGNED_TARGET_MAX_CHARS} characters.`
|
|
7239
|
+
};
|
|
7240
|
+
}
|
|
7241
|
+
return required;
|
|
7242
|
+
}
|
|
7243
|
+
corsHeaders() {
|
|
7244
|
+
if (!this.config.cors) return {};
|
|
7245
|
+
return {
|
|
7246
|
+
"Access-Control-Allow-Origin": this.config.allowedOrigins ? this.config.allowedOrigins.join(",") : "*",
|
|
7247
|
+
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
7248
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
|
7249
|
+
};
|
|
7250
|
+
}
|
|
7251
|
+
/** No CORS header: a page must not be able to read what the gate says. */
|
|
7252
|
+
refusal(code, error3, extra = {}) {
|
|
7253
|
+
const body = {
|
|
7254
|
+
success: false,
|
|
7255
|
+
error: error3,
|
|
7256
|
+
code,
|
|
7257
|
+
timestamp: Date.now(),
|
|
7258
|
+
...extra.data ? { data: extra.data } : {}
|
|
7259
|
+
};
|
|
7260
|
+
return {
|
|
7261
|
+
status: extra.status ?? httpStatusForResponse(body),
|
|
7262
|
+
headers: { "Content-Type": "application/json" },
|
|
7263
|
+
body: JSON.stringify(body)
|
|
7264
|
+
};
|
|
7265
|
+
}
|
|
7266
|
+
/**
|
|
7267
|
+
* Open health. An admitted caller gets the full answer. Anyone else gets what
|
|
7268
|
+
* discovery needs and nothing about the app's contents, and that answer is
|
|
7269
|
+
* built without going through `handleRequest`, whose keep-awake auto-arm is no
|
|
7270
|
+
* business of an anonymous LAN probe. It carries no CORS header, so a web page
|
|
7271
|
+
* cannot read the stable device id.
|
|
7272
|
+
*/
|
|
7273
|
+
async handleNetworkHealth(request, admission) {
|
|
7274
|
+
const pairing = {
|
|
7275
|
+
required: !(admission.admitted && (admission.as === "loopback" || admission.as === "unauthenticated-network")),
|
|
7276
|
+
authenticated: admission.admitted && admission.as !== "unauthenticated-network"
|
|
7277
|
+
};
|
|
7278
|
+
const identity = {
|
|
7279
|
+
deviceId: this.config.deviceId ?? null,
|
|
7280
|
+
serverTime: Date.now(),
|
|
7281
|
+
pairing
|
|
7282
|
+
};
|
|
7283
|
+
if (admission.admitted) {
|
|
7284
|
+
const response = await this.handleRequest(request);
|
|
7285
|
+
const parsed = JSON.parse(response.body);
|
|
7286
|
+
if (!parsed.success || !parsed.data) return response;
|
|
7287
|
+
return {
|
|
7288
|
+
...response,
|
|
7289
|
+
body: JSON.stringify({ ...parsed, data: { ...parsed.data, ...identity } })
|
|
7290
|
+
};
|
|
7291
|
+
}
|
|
7292
|
+
const health = await this.handlers.health({ params: {}, query: {}, body: void 0 });
|
|
7293
|
+
const full = health.data ?? {};
|
|
7294
|
+
const trimmed = { status: full.status, ...identity };
|
|
7295
|
+
if (full.uiBridge) {
|
|
7296
|
+
const { version, appId, appName, appType, framework } = full.uiBridge;
|
|
7297
|
+
trimmed.uiBridge = { version, appId, appName, appType, framework };
|
|
7298
|
+
}
|
|
7299
|
+
return {
|
|
7300
|
+
status: 200,
|
|
7301
|
+
headers: { "Content-Type": "application/json" },
|
|
7302
|
+
body: JSON.stringify({ success: true, data: trimmed, timestamp: Date.now() })
|
|
7303
|
+
};
|
|
7304
|
+
}
|
|
7305
|
+
/**
|
|
7306
|
+
* `POST /ui-bridge/pair`, the only way to a token, and only ever from here.
|
|
7307
|
+
* Its answer carries no CORS header, because no web page has any business
|
|
7308
|
+
* reading a token, and it answers only after the store write has finished. A web
|
|
7309
|
+
* page is refused first of all, so it cannot burn an attempt or the armed code.
|
|
7310
|
+
*/
|
|
7311
|
+
async handlePair(request) {
|
|
7312
|
+
if (carriesWebContext(request)) {
|
|
7313
|
+
return this.refusal("PAIRING_WEB_CALLER_REFUSED", "A web page cannot pair with this device.");
|
|
7314
|
+
}
|
|
7315
|
+
if (!this.pairing) {
|
|
7316
|
+
return this.refusal(
|
|
7317
|
+
"PAIRING_UNCONFIGURED",
|
|
7318
|
+
"This app has not configured device pairing, so it cannot issue a token."
|
|
7319
|
+
);
|
|
7320
|
+
}
|
|
7321
|
+
const deviceId = this.config.deviceId;
|
|
7322
|
+
if (!deviceId) {
|
|
7323
|
+
return this.refusal("PAIRING_UNAVAILABLE", "The device id is not available yet. Try again.");
|
|
7324
|
+
}
|
|
7325
|
+
const outcome = await this.pairing.pair({
|
|
7326
|
+
trustedLoopback: this.isTrustedLoopback(request),
|
|
7327
|
+
peer: request.remoteAddress,
|
|
7328
|
+
body: request.body
|
|
7329
|
+
});
|
|
7330
|
+
if (!outcome.ok) return this.refusal(outcome.code, outcome.error);
|
|
7331
|
+
const body = {
|
|
7332
|
+
success: true,
|
|
7333
|
+
data: { token: outcome.token, deviceId, serverTime: Date.now() },
|
|
7334
|
+
timestamp: Date.now()
|
|
7335
|
+
};
|
|
7336
|
+
return {
|
|
7337
|
+
status: 200,
|
|
7338
|
+
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
|
|
7339
|
+
body: JSON.stringify(body)
|
|
7340
|
+
};
|
|
7341
|
+
}
|
|
7342
|
+
/**
|
|
7343
|
+
* Arm a 6-digit code for a LAN pairing. In memory and single use. It stops
|
|
7344
|
+
* working after about 2 minutes, or after 5 wrong attempts from anyone. Rejects
|
|
7345
|
+
* with a `PairingError` when no store is configured or it has not loaded.
|
|
7346
|
+
*/
|
|
7347
|
+
async armPairingCode() {
|
|
7348
|
+
return this.requirePairing().arm();
|
|
7349
|
+
}
|
|
7350
|
+
/** Cancel a live code. */
|
|
7351
|
+
disarmPairingCode() {
|
|
7352
|
+
this.pairing?.disarm();
|
|
7353
|
+
}
|
|
7354
|
+
/** What Settings shows, including the peer that consumed the last code. */
|
|
7355
|
+
getPairingState() {
|
|
7356
|
+
return this.pairing?.getState() ?? UNCONFIGURED_PAIRING_STATE;
|
|
7357
|
+
}
|
|
7358
|
+
/**
|
|
7359
|
+
* Replace the token, so every signature made with the old one stops verifying.
|
|
7360
|
+
* There are no network sockets to close, because none is ever admitted.
|
|
7361
|
+
*/
|
|
7362
|
+
async revokePairing() {
|
|
7363
|
+
await this.requirePairing().revoke();
|
|
7364
|
+
}
|
|
7365
|
+
requirePairing() {
|
|
7366
|
+
if (!this.pairing) {
|
|
7367
|
+
throw new PairingError("PAIRING_UNCONFIGURED", "No pairingStore is configured.");
|
|
7368
|
+
}
|
|
7369
|
+
return this.pairing;
|
|
7370
|
+
}
|
|
7371
|
+
/**
|
|
7372
|
+
* Handle incoming HTTP request, past the gate.
|
|
6477
7373
|
*
|
|
6478
7374
|
* Exposed as public so that CloudRelayClient can call it directly (without
|
|
6479
|
-
* loopback TCP overhead) when processing tunneled requests.
|
|
7375
|
+
* loopback TCP overhead) when processing tunneled requests. Anything reached
|
|
7376
|
+
* from a socket goes through `handleNetworkRequest` first.
|
|
6480
7377
|
*/
|
|
6481
7378
|
async handleRequest(request) {
|
|
6482
7379
|
const headers = {
|
|
6483
|
-
"Content-Type": "application/json"
|
|
7380
|
+
"Content-Type": "application/json",
|
|
7381
|
+
...this.corsHeaders()
|
|
6484
7382
|
};
|
|
6485
|
-
if (this.config.cors) {
|
|
6486
|
-
headers["Access-Control-Allow-Origin"] = this.config.allowedOrigins ? this.config.allowedOrigins.join(",") : "*";
|
|
6487
|
-
headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS";
|
|
6488
|
-
headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization";
|
|
6489
|
-
}
|
|
6490
7383
|
if (request.method === "OPTIONS") {
|
|
6491
7384
|
return { status: 204, headers, body: "" };
|
|
6492
7385
|
}
|
|
@@ -6956,7 +7849,6 @@ var DeviceAnnouncer = class {
|
|
|
6956
7849
|
*/
|
|
6957
7850
|
this.publishInFlight = false;
|
|
6958
7851
|
this.config = config;
|
|
6959
|
-
this.pairingToken = generateHex(16);
|
|
6960
7852
|
}
|
|
6961
7853
|
// ── mDNS ──────────────────────────────────────────────────────────────────
|
|
6962
7854
|
/**
|
|
@@ -6992,8 +7884,7 @@ var DeviceAnnouncer = class {
|
|
|
6992
7884
|
result = await zeroconf.publishService("uibridge", "tcp", "local.", serviceName, port, {
|
|
6993
7885
|
device_id: this.config.deviceId,
|
|
6994
7886
|
app_id: this.config.appId,
|
|
6995
|
-
version: this.config.version ?? "unknown"
|
|
6996
|
-
pairing_token: this.pairingToken
|
|
7887
|
+
version: this.config.version ?? "unknown"
|
|
6997
7888
|
});
|
|
6998
7889
|
} finally {
|
|
6999
7890
|
this.publishInFlight = false;
|
|
@@ -7117,13 +8008,6 @@ var DeviceAnnouncer = class {
|
|
|
7117
8008
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_DELAY_MS);
|
|
7118
8009
|
}
|
|
7119
8010
|
};
|
|
7120
|
-
function generateHex(bytes) {
|
|
7121
|
-
const arr = new Uint8Array(bytes);
|
|
7122
|
-
for (let i = 0; i < bytes; i++) {
|
|
7123
|
-
arr[i] = Math.floor(Math.random() * 256);
|
|
7124
|
-
}
|
|
7125
|
-
return Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
7126
|
-
}
|
|
7127
8011
|
function getPlatform() {
|
|
7128
8012
|
return Platform?.OS ?? "unknown";
|
|
7129
8013
|
}
|
|
@@ -7495,6 +8379,26 @@ function readWindowViewport() {
|
|
|
7495
8379
|
const win = Dimensions.get("window");
|
|
7496
8380
|
return { width: win.width, height: win.height };
|
|
7497
8381
|
}
|
|
8382
|
+
function useStablePairingStore(store) {
|
|
8383
|
+
const latestRef = useRef(store);
|
|
8384
|
+
useEffect(() => {
|
|
8385
|
+
latestRef.current = store;
|
|
8386
|
+
});
|
|
8387
|
+
const hasStore = store !== void 0;
|
|
8388
|
+
return useMemo(() => {
|
|
8389
|
+
if (!hasStore) return void 0;
|
|
8390
|
+
const current = () => {
|
|
8391
|
+
if (!latestRef.current) throw new Error("pairingStore was removed");
|
|
8392
|
+
return latestRef.current;
|
|
8393
|
+
};
|
|
8394
|
+
return {
|
|
8395
|
+
get: () => current().get(),
|
|
8396
|
+
set: (token) => current().set(token),
|
|
8397
|
+
clear: () => current().clear(),
|
|
8398
|
+
randomBytes: (n) => current().randomBytes(n)
|
|
8399
|
+
};
|
|
8400
|
+
}, [hasStore]);
|
|
8401
|
+
}
|
|
7498
8402
|
function UIBridgeNativeProvider({
|
|
7499
8403
|
children,
|
|
7500
8404
|
features: featuresProp = EMPTY_FEATURES,
|
|
@@ -7508,10 +8412,13 @@ function UIBridgeNativeProvider({
|
|
|
7508
8412
|
cloudRelayConfig,
|
|
7509
8413
|
enableMdnsAnnounce,
|
|
7510
8414
|
zeroconf,
|
|
7511
|
-
deviceId
|
|
8415
|
+
deviceId,
|
|
8416
|
+
pairingStore: pairingStoreProp,
|
|
8417
|
+
allowUnauthenticatedNetwork
|
|
7512
8418
|
}) {
|
|
7513
8419
|
const features = useValueStable(featuresProp);
|
|
7514
8420
|
const config = useValueStable(configProp);
|
|
8421
|
+
const pairingStore = useStablePairingStore(pairingStoreProp);
|
|
7515
8422
|
const registryRef = useRef(null);
|
|
7516
8423
|
const executorRef = useRef(null);
|
|
7517
8424
|
const modalDetectorRef = useRef(null);
|
|
@@ -7542,6 +8449,11 @@ function UIBridgeNativeProvider({
|
|
|
7542
8449
|
const eventBridgeRef = useRef(null);
|
|
7543
8450
|
const announcerRef = useRef(null);
|
|
7544
8451
|
const cloudRelayRef = useRef(null);
|
|
8452
|
+
const deviceIdRef = useRef(deviceId);
|
|
8453
|
+
useEffect(() => {
|
|
8454
|
+
deviceIdRef.current = deviceId;
|
|
8455
|
+
serverRef.current?.setDeviceId(deviceId);
|
|
8456
|
+
}, [deviceId]);
|
|
7545
8457
|
const startServer = useCallback(async () => {
|
|
7546
8458
|
if (!features.server) return;
|
|
7547
8459
|
if (!serverAdapter) {
|
|
@@ -7562,7 +8474,10 @@ function UIBridgeNativeProvider({
|
|
|
7562
8474
|
// Injected device-viewport getter for POST /control/page-health and the
|
|
7563
8475
|
// design handlers. Same source as the registry's clipping bounds — see
|
|
7564
8476
|
// `readWindowViewport`.
|
|
7565
|
-
viewportProvider: readWindowViewport
|
|
8477
|
+
viewportProvider: readWindowViewport,
|
|
8478
|
+
pairingStore,
|
|
8479
|
+
allowUnauthenticatedNetwork,
|
|
8480
|
+
deviceId: deviceIdRef.current
|
|
7566
8481
|
});
|
|
7567
8482
|
server.setAdapter(serverAdapter);
|
|
7568
8483
|
if (navigationProvider) {
|
|
@@ -7604,8 +8519,9 @@ function UIBridgeNativeProvider({
|
|
|
7604
8519
|
server.abortWaitersForConnection(connId);
|
|
7605
8520
|
eventBridge.removeConnection(connId);
|
|
7606
8521
|
};
|
|
8522
|
+
wsAdapter.onWebSocketUpgrade = (req) => server.handleWebSocketUpgrade(req);
|
|
7607
8523
|
wsAdapter.onWebSocketMessage = async (connId, message) => {
|
|
7608
|
-
return server.
|
|
8524
|
+
return server.handleNetworkWebSocketMessage(connId, message);
|
|
7609
8525
|
};
|
|
7610
8526
|
eventBridge.start();
|
|
7611
8527
|
eventBridgeRef.current = eventBridge;
|
|
@@ -7613,6 +8529,7 @@ function UIBridgeNativeProvider({
|
|
|
7613
8529
|
try {
|
|
7614
8530
|
await server.start();
|
|
7615
8531
|
serverRef.current = server;
|
|
8532
|
+
server.setDeviceId(deviceIdRef.current);
|
|
7616
8533
|
setServerRunning(true);
|
|
7617
8534
|
} catch (err) {
|
|
7618
8535
|
console.warn("[ui-bridge-native] Failed to start HTTP server:", err);
|
|
@@ -7629,7 +8546,9 @@ function UIBridgeNativeProvider({
|
|
|
7629
8546
|
navigationProvider,
|
|
7630
8547
|
screenshotProvider,
|
|
7631
8548
|
keepAwakeProvider,
|
|
7632
|
-
routeProvider
|
|
8549
|
+
routeProvider,
|
|
8550
|
+
pairingStore,
|
|
8551
|
+
allowUnauthenticatedNetwork
|
|
7633
8552
|
]);
|
|
7634
8553
|
const stopServer = useCallback(() => {
|
|
7635
8554
|
if (eventBridgeRef.current) {
|
|
@@ -7737,6 +8656,25 @@ function UIBridgeNativeProvider({
|
|
|
7737
8656
|
const getCurrentRoute = useCallback(() => {
|
|
7738
8657
|
return routeProvider?.getCurrentRoute() ?? null;
|
|
7739
8658
|
}, [routeProvider]);
|
|
8659
|
+
const armPairingCode = useCallback(async () => {
|
|
8660
|
+
if (!serverRef.current) {
|
|
8661
|
+
throw new PairingError("PAIRING_UNCONFIGURED", "The UI Bridge server is not running.");
|
|
8662
|
+
}
|
|
8663
|
+
return serverRef.current.armPairingCode();
|
|
8664
|
+
}, []);
|
|
8665
|
+
const disarmPairingCode = useCallback(() => {
|
|
8666
|
+
serverRef.current?.disarmPairingCode();
|
|
8667
|
+
}, []);
|
|
8668
|
+
const getPairingState = useCallback(
|
|
8669
|
+
() => serverRef.current?.getPairingState() ?? UNCONFIGURED_PAIRING_STATE,
|
|
8670
|
+
[]
|
|
8671
|
+
);
|
|
8672
|
+
const revokePairing = useCallback(async () => {
|
|
8673
|
+
if (!serverRef.current) {
|
|
8674
|
+
throw new PairingError("PAIRING_UNCONFIGURED", "The UI Bridge server is not running.");
|
|
8675
|
+
}
|
|
8676
|
+
await serverRef.current.revokePairing();
|
|
8677
|
+
}, []);
|
|
7740
8678
|
useEffect(() => {
|
|
7741
8679
|
if (!routeProvider) return;
|
|
7742
8680
|
let lastRoute = routeProvider.getCurrentRoute();
|
|
@@ -7789,7 +8727,11 @@ function UIBridgeNativeProvider({
|
|
|
7789
8727
|
serverRunning,
|
|
7790
8728
|
startServer,
|
|
7791
8729
|
stopServer,
|
|
7792
|
-
getCurrentRoute
|
|
8730
|
+
getCurrentRoute,
|
|
8731
|
+
armPairingCode,
|
|
8732
|
+
disarmPairingCode,
|
|
8733
|
+
getPairingState,
|
|
8734
|
+
revokePairing
|
|
7793
8735
|
}),
|
|
7794
8736
|
[
|
|
7795
8737
|
features,
|
|
@@ -7807,7 +8749,11 @@ function UIBridgeNativeProvider({
|
|
|
7807
8749
|
serverRunning,
|
|
7808
8750
|
startServer,
|
|
7809
8751
|
stopServer,
|
|
7810
|
-
getCurrentRoute
|
|
8752
|
+
getCurrentRoute,
|
|
8753
|
+
armPairingCode,
|
|
8754
|
+
disarmPairingCode,
|
|
8755
|
+
getPairingState,
|
|
8756
|
+
revokePairing
|
|
7811
8757
|
]
|
|
7812
8758
|
);
|
|
7813
8759
|
return /* @__PURE__ */ jsx(UIBridgeNativeContext, { value: contextValue, children });
|
|
@@ -8912,6 +9858,12 @@ var UI_BRIDGE_NATIVE_ROUTES = {
|
|
|
8912
9858
|
method: "GET",
|
|
8913
9859
|
path: "/ui-bridge/health",
|
|
8914
9860
|
description: "Health check"
|
|
9861
|
+
},
|
|
9862
|
+
// Device pairing
|
|
9863
|
+
PAIR: {
|
|
9864
|
+
method: "POST",
|
|
9865
|
+
path: "/ui-bridge/pair",
|
|
9866
|
+
description: "Get the pairing token: loopback with no code, otherwise body { code }. Direct HTTP only"
|
|
8915
9867
|
}
|
|
8916
9868
|
};
|
|
8917
9869
|
|
|
@@ -9719,6 +10671,6 @@ var styles = StyleSheet.create({
|
|
|
9719
10671
|
}
|
|
9720
10672
|
});
|
|
9721
10673
|
|
|
9722
|
-
export { CloudRelayClient, DEFAULT_PARAM_VALIDATION_MODE, DefaultNativeActionExecutor, DeviceAnnouncer, MAX_ACTION_TIMEOUT_MS, ModalDetector, NATIVE_STANDARD_ACTION_EFFECTS, NativeActionError, NativeUIBridgeRegistry, NativeUIBridgeServer, State, ToastCapture, TransitionTo, UIBridgeInspector, UIBridgeNativeProvider, UI_BRIDGE_NATIVE_ROUTES, UndoTracker, WebSocketConnection, WebSocketEventBridge, bindWithRetry, buildTreePath, buildUpgradeResponse, captureNativeResponsiveSnapshot, captureNativeStateVariations, computeAcceptKey, computeInViewport, computeVisibility, createDesignHandlers, createNativeActionExecutor, createNativeElementIdentifier, createNativeServer, createServerHandlers, deriveInteractable, deriveText, expoRouterPattern, expoRouterRouteStack, extractHandlerNames, findAllByPattern, findElementByIdentifier, formatParamValidationFailure, getDefaultParamValidationMode, getGlobalRegistry, getNativeElementDesignData, httpStatusForResponse, inertAbortSignal, intersectRects, isAddrInUseError, isEmptyRect, isExpoRouterUnmatched, isHiddenFromAccessibility, mapNativeStyleToExtended, matchesIdentifier, nativeStandardActionEffect, normalizeActionTimeoutMs, pageRectOf, parseColorToRgb, parseFontSizePx, parseTreePath, projectMedia, projectVisionFields, reactNavigationPattern, resetDefaultParamValidationMode, resetGlobalRegistry, resolveActionEffect, resolveImageLabel, resolveImageSource, resolveObjectFit, roleForType, runAbortable, setDefaultParamValidationMode, setGlobalRegistry, useRouteAwareness, useToastRecorder, useUIBridge, useUIBridgeModal, useUIBridgeNative, useUIBridgeNativeOptional, useUIBridgeNativeRequired, useUIBridgeRequired, useUIBridgeToast, useUIComponent, useUIComponentAction, useUIElement, useUIElementWithProps, validateActionParams };
|
|
10674
|
+
export { CloudRelayClient, DEFAULT_PARAM_VALIDATION_MODE, DefaultNativeActionExecutor, DeviceAnnouncer, MAX_ACTION_TIMEOUT_MS, ModalDetector, NATIVE_STANDARD_ACTION_EFFECTS, NativeActionError, NativeUIBridgeRegistry, NativeUIBridgeServer, PAIRING_CODE_MAX_ATTEMPTS, PAIRING_CODE_TTL_MS, PairingError, SIGNATURE_WINDOW_MS, SIGNED_BODY_MAX_CHARS, SIGNED_METHOD_MAX_CHARS, SIGNED_TARGET_MAX_CHARS, State, ToastCapture, TransitionTo, UIBridgeInspector, UIBridgeNativeProvider, UI_BRIDGE_AUTH_HEADER, UI_BRIDGE_NATIVE_ROUTES, UndoTracker, WebSocketConnection, WebSocketEventBridge, bindWithRetry, buildTreePath, buildUpgradeResponse, canonicalSignatureInput, captureNativeResponsiveSnapshot, captureNativeStateVariations, computeAcceptKey, computeInViewport, computeVisibility, createDesignHandlers, createNativeActionExecutor, createNativeElementIdentifier, createNativeServer, createServerHandlers, deriveInteractable, deriveText, expoRouterPattern, expoRouterRouteStack, extractHandlerNames, findAllByPattern, findElementByIdentifier, formatParamValidationFailure, getDefaultParamValidationMode, getGlobalRegistry, getNativeElementDesignData, httpStatusForResponse, inertAbortSignal, intersectRects, isAddrInUseError, isEmptyRect, isExpoRouterUnmatched, isHiddenFromAccessibility, isLoopbackAddress, isLoopbackHost, mapNativeStyleToExtended, matchesIdentifier, nativeStandardActionEffect, normalizeActionTimeoutMs, pageRectOf, parseColorToRgb, parseFontSizePx, parseTreePath, projectMedia, projectVisionFields, reactNavigationPattern, resetDefaultParamValidationMode, resetGlobalRegistry, resolveActionEffect, resolveImageLabel, resolveImageSource, resolveObjectFit, roleForType, runAbortable, setDefaultParamValidationMode, setGlobalRegistry, signUIBridgeRequest, useRouteAwareness, useToastRecorder, useUIBridge, useUIBridgeModal, useUIBridgeNative, useUIBridgeNativeOptional, useUIBridgeNativeRequired, useUIBridgeRequired, useUIBridgeToast, useUIComponent, useUIComponentAction, useUIElement, useUIElementWithProps, validateActionParams };
|
|
9723
10675
|
//# sourceMappingURL=index.mjs.map
|
|
9724
10676
|
//# sourceMappingURL=index.mjs.map
|