@qrlwallet/connect 3.2.0 → 3.3.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 +14 -3
- package/dist/index.d.mts +128 -2
- package/dist/index.d.ts +128 -2
- package/dist/index.js +280 -7
- package/dist/index.mjs +280 -7
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -631,6 +631,7 @@ var KeyExchange = class extends import_eventemitter3.default {
|
|
|
631
631
|
var import_socket = require("socket.io-client");
|
|
632
632
|
var import_eventemitter32 = __toESM(require("eventemitter3"));
|
|
633
633
|
var PENDING_JOIN_TIMEOUT_MS = 2e4;
|
|
634
|
+
var SEND_FLUSH_TIMEOUT_MS = 600;
|
|
634
635
|
var SocketClient = class extends import_eventemitter32.default {
|
|
635
636
|
constructor(relayUrl, clientType) {
|
|
636
637
|
super();
|
|
@@ -821,6 +822,48 @@ var SocketClient = class extends import_eventemitter32.default {
|
|
|
821
822
|
);
|
|
822
823
|
});
|
|
823
824
|
}
|
|
825
|
+
/**
|
|
826
|
+
* Emit an event and resolve once the relay acks it, or after a bounded
|
|
827
|
+
* flush window. Lets a caller await transmission before tearing the
|
|
828
|
+
* socket down.
|
|
829
|
+
*/
|
|
830
|
+
flushEmit(event, payload) {
|
|
831
|
+
return new Promise((resolve) => {
|
|
832
|
+
if (!this.socket?.connected) {
|
|
833
|
+
resolve();
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
let settled = false;
|
|
837
|
+
const done = () => {
|
|
838
|
+
if (settled) return;
|
|
839
|
+
settled = true;
|
|
840
|
+
resolve();
|
|
841
|
+
};
|
|
842
|
+
const timer = setTimeout(done, SEND_FLUSH_TIMEOUT_MS);
|
|
843
|
+
this.socket.emit(event, payload, () => {
|
|
844
|
+
clearTimeout(timer);
|
|
845
|
+
done();
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Explicitly terminate the channel on the relay (durable tombstone), as
|
|
851
|
+
* opposed to the transient leaveChannel(). Used when the session is dead
|
|
852
|
+
* in a way an encrypted TERMINATE cannot communicate (AEAD desync: the
|
|
853
|
+
* peer could not open it). Resolves once the close is flushed or the
|
|
854
|
+
* bounded window elapses, so the caller can safely disconnect afterwards.
|
|
855
|
+
*/
|
|
856
|
+
closeChannel() {
|
|
857
|
+
const channelId = this.channelId;
|
|
858
|
+
this.channelId = null;
|
|
859
|
+
if (this.pendingJoin) {
|
|
860
|
+
clearTimeout(this.pendingJoin.watchdog);
|
|
861
|
+
this.pendingJoin.reject(new Error("Channel closed before join completed"));
|
|
862
|
+
this.pendingJoin = null;
|
|
863
|
+
}
|
|
864
|
+
if (!this.socket?.connected || !channelId) return Promise.resolve();
|
|
865
|
+
return this.flushEmit("close_channel", { channelId });
|
|
866
|
+
}
|
|
824
867
|
/**
|
|
825
868
|
* Leave the current channel.
|
|
826
869
|
*/
|
|
@@ -1121,11 +1164,13 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
|
|
|
1121
1164
|
this.connectedAccounts = [];
|
|
1122
1165
|
this.unresponsiveTimer = null;
|
|
1123
1166
|
this.reconnectProbeTimer = null;
|
|
1167
|
+
this.restoreInFlight = null;
|
|
1124
1168
|
this.failedReconnects = 0;
|
|
1125
1169
|
this.walletPresent = false;
|
|
1126
1170
|
this.pendingRestore = null;
|
|
1127
1171
|
this.messageQueue = Promise.resolve();
|
|
1128
1172
|
this.outboundQueue = Promise.resolve();
|
|
1173
|
+
this.consecutiveDecryptFailures = 0;
|
|
1129
1174
|
this.dappMetadata = options.dappMetadata;
|
|
1130
1175
|
this.relayUrl = options.relayUrl ?? DEFAULT_RELAY_URL;
|
|
1131
1176
|
this.chainId = options.chainId ?? "0x0";
|
|
@@ -1244,6 +1289,7 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
|
|
|
1244
1289
|
this.clearReconnectProbe();
|
|
1245
1290
|
this.pendingRestore = null;
|
|
1246
1291
|
this.walletPresent = false;
|
|
1292
|
+
this.consecutiveDecryptFailures = 0;
|
|
1247
1293
|
if (this.keyExchange) {
|
|
1248
1294
|
this.keyExchange.reset();
|
|
1249
1295
|
} else {
|
|
@@ -1282,9 +1328,18 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
|
|
|
1282
1328
|
* Returns false if there is nothing to restore.
|
|
1283
1329
|
*/
|
|
1284
1330
|
async reconnect() {
|
|
1331
|
+
if (this.restoreInFlight) return this.restoreInFlight;
|
|
1332
|
+
const task = this.reconnectNow().finally(() => {
|
|
1333
|
+
this.restoreInFlight = null;
|
|
1334
|
+
});
|
|
1335
|
+
this.restoreInFlight = task;
|
|
1336
|
+
return task;
|
|
1337
|
+
}
|
|
1338
|
+
async reconnectNow() {
|
|
1285
1339
|
if (!this.pendingRestore) return false;
|
|
1286
1340
|
this.setStatus("reconnecting" /* RECONNECTING */);
|
|
1287
1341
|
this.walletPresent = false;
|
|
1342
|
+
this.consecutiveDecryptFailures = 0;
|
|
1288
1343
|
try {
|
|
1289
1344
|
const session = await KeyExchange.sessionFromPersisted(this.pendingRestore.keyExchange);
|
|
1290
1345
|
this.keyExchange = new KeyExchange(true, session);
|
|
@@ -1360,15 +1415,53 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
|
|
|
1360
1415
|
}
|
|
1361
1416
|
}
|
|
1362
1417
|
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Make sure the socket is open and joined to the paired session's channel
|
|
1420
|
+
* so an outbound request can be routed - or relay-buffered if the wallet is
|
|
1421
|
+
* currently absent (its socket dies within seconds of the app
|
|
1422
|
+
* backgrounding; the relay holds channel traffic for it). Revives the
|
|
1423
|
+
* socket after a probe-timeout teardown and hydrates a cold stored
|
|
1424
|
+
* session. Returns false when there is no session to revive (never paired,
|
|
1425
|
+
* explicit disconnect, or the channel was tombstoned).
|
|
1426
|
+
*/
|
|
1427
|
+
async ensureChannelJoined() {
|
|
1428
|
+
if (this.keyExchange?.areKeysExchanged()) {
|
|
1429
|
+
if (this.socketClient.isConnected() && this.socketClient.getChannelId()) {
|
|
1430
|
+
return true;
|
|
1431
|
+
}
|
|
1432
|
+
this.setStatus("reconnecting" /* RECONNECTING */);
|
|
1433
|
+
this.walletPresent = false;
|
|
1434
|
+
return this.joinAndSettle();
|
|
1435
|
+
}
|
|
1436
|
+
if (this.pendingRestore) {
|
|
1437
|
+
return this.reconnect();
|
|
1438
|
+
}
|
|
1439
|
+
return false;
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* True while a pairing exists (handshake complete, session keys in
|
|
1443
|
+
* memory), even when the wallet's socket is momentarily out of the
|
|
1444
|
+
* channel. Survives the reconnect-probe teardown; false after an explicit
|
|
1445
|
+
* disconnect or a relay tombstone.
|
|
1446
|
+
*/
|
|
1447
|
+
isPaired() {
|
|
1448
|
+
return this.keyExchange?.areKeysExchanged() ?? false;
|
|
1449
|
+
}
|
|
1450
|
+
/** True while the relay roster last showed the wallet in the channel. */
|
|
1451
|
+
isWalletPresent() {
|
|
1452
|
+
return this.walletPresent;
|
|
1453
|
+
}
|
|
1363
1454
|
/**
|
|
1364
1455
|
* A session was terminated for good (wallet sent a relay 'close', or the
|
|
1365
1456
|
* join ack reported a tombstone). Clear local state and surface
|
|
1366
1457
|
* DISCONNECTED so the consumer drops to a fresh-pairing UI.
|
|
1367
1458
|
*/
|
|
1368
1459
|
handleSessionTerminated() {
|
|
1460
|
+
this.emit("session_terminated");
|
|
1369
1461
|
this.clearReconnectProbe();
|
|
1370
1462
|
this.clearUnresponsiveTimer();
|
|
1371
1463
|
this.walletPresent = false;
|
|
1464
|
+
this.consecutiveDecryptFailures = 0;
|
|
1372
1465
|
this.pendingRestore = null;
|
|
1373
1466
|
this.keyExchange = null;
|
|
1374
1467
|
this.socketClient.leaveChannel();
|
|
@@ -1397,22 +1490,28 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
|
|
|
1397
1490
|
}
|
|
1398
1491
|
}
|
|
1399
1492
|
/**
|
|
1400
|
-
* Send a JSON-RPC request to the wallet
|
|
1493
|
+
* Send a JSON-RPC request to the wallet. Returns the outbound send promise
|
|
1494
|
+
* (settled on the relay's ack) so callers that must sequence on delivery -
|
|
1495
|
+
* e.g. the provider's wallet-wake redirect, which cannot navigate away
|
|
1496
|
+
* before the ciphertext reaches the relay - can await it. Callers may also
|
|
1497
|
+
* ignore it: failures are logged here either way.
|
|
1401
1498
|
*/
|
|
1402
1499
|
sendJsonRpc(request) {
|
|
1403
1500
|
if (!this.keyExchange?.areKeysExchanged()) {
|
|
1404
1501
|
throw new Error("Not connected: key exchange not complete");
|
|
1405
1502
|
}
|
|
1406
|
-
this.sendEncrypted({
|
|
1503
|
+
const sent = this.sendEncrypted({
|
|
1407
1504
|
type: "jsonrpc" /* JSONRPC */,
|
|
1408
1505
|
jsonrpc: "2.0",
|
|
1409
1506
|
id: request.id,
|
|
1410
1507
|
method: request.method,
|
|
1411
1508
|
params: request.params
|
|
1412
|
-
})
|
|
1509
|
+
});
|
|
1510
|
+
sent.catch((err) => {
|
|
1413
1511
|
error("ConnectionManager", "Failed to send JSON-RPC:", err);
|
|
1414
1512
|
});
|
|
1415
1513
|
this.startUnresponsiveTimer();
|
|
1514
|
+
return sent;
|
|
1416
1515
|
}
|
|
1417
1516
|
getStatus() {
|
|
1418
1517
|
return this.status;
|
|
@@ -1526,8 +1625,19 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
|
|
|
1526
1625
|
}
|
|
1527
1626
|
}
|
|
1528
1627
|
if (typeof message === "string" && this.keyExchange?.areKeysExchanged()) {
|
|
1628
|
+
let decrypted;
|
|
1629
|
+
try {
|
|
1630
|
+
decrypted = await this.keyExchange.decryptMessage(message);
|
|
1631
|
+
} catch (err) {
|
|
1632
|
+
error("ConnectionManager", "Failed to decrypt message:", err);
|
|
1633
|
+
this.consecutiveDecryptFailures++;
|
|
1634
|
+
if (this.consecutiveDecryptFailures >= _ConnectionManager.MAX_DECRYPT_FAILURES) {
|
|
1635
|
+
await this.teardownDesyncedSession();
|
|
1636
|
+
}
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
this.consecutiveDecryptFailures = 0;
|
|
1529
1640
|
try {
|
|
1530
|
-
const decrypted = await this.keyExchange.decryptMessage(message);
|
|
1531
1641
|
await this.persistSession();
|
|
1532
1642
|
const parsed = JSON.parse(decrypted);
|
|
1533
1643
|
if (isRecord(parsed)) {
|
|
@@ -1536,10 +1646,24 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
|
|
|
1536
1646
|
warn("ConnectionManager", "Dropping non-object decrypted payload");
|
|
1537
1647
|
}
|
|
1538
1648
|
} catch (err) {
|
|
1539
|
-
error("ConnectionManager", "Failed to
|
|
1649
|
+
error("ConnectionManager", "Failed to handle decrypted message:", err);
|
|
1540
1650
|
}
|
|
1541
1651
|
}
|
|
1542
1652
|
}
|
|
1653
|
+
/**
|
|
1654
|
+
* The receive stream is cryptographically unrecoverable: the peer's send
|
|
1655
|
+
* counter is ahead of our recv counter for good (relay buffer TTL/cap
|
|
1656
|
+
* dropped a ciphertext), so every future message would fail its tag.
|
|
1657
|
+
* An encrypted TERMINATE cannot communicate this (the desynced peer could
|
|
1658
|
+
* not open it either); tombstone the channel on the relay instead so the
|
|
1659
|
+
* wallet learns the pairing is dead even if it only re-joins later, then
|
|
1660
|
+
* clear local state and surface the standard terminated teardown.
|
|
1661
|
+
*/
|
|
1662
|
+
async teardownDesyncedSession() {
|
|
1663
|
+
warn("ConnectionManager", "AEAD stream desynced beyond recovery; terminating session");
|
|
1664
|
+
await this.socketClient.closeChannel();
|
|
1665
|
+
this.handleSessionTerminated();
|
|
1666
|
+
}
|
|
1543
1667
|
async handleSynAck(msg) {
|
|
1544
1668
|
if (!this.keyExchange) return;
|
|
1545
1669
|
this.setStatus("key_exchange" /* KEY_EXCHANGE */);
|
|
@@ -1593,6 +1717,7 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
|
|
|
1593
1717
|
}
|
|
1594
1718
|
case "terminate" /* TERMINATE */: {
|
|
1595
1719
|
log("ConnectionManager", "Received terminate from wallet");
|
|
1720
|
+
this.emit("session_terminated");
|
|
1596
1721
|
void this.disconnect();
|
|
1597
1722
|
break;
|
|
1598
1723
|
}
|
|
@@ -1712,6 +1837,12 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
|
|
|
1712
1837
|
}
|
|
1713
1838
|
};
|
|
1714
1839
|
_ConnectionManager.MAX_RECONNECT_FAILURES = 5;
|
|
1840
|
+
// Nonces derive from the recv counter and there is no gap tolerance, so a
|
|
1841
|
+
// genuinely desynced stream (peer advanced past us, e.g. the relay's
|
|
1842
|
+
// buffer TTL dropped a ciphertext) fails EVERY subsequent open. Two in a
|
|
1843
|
+
// row cannot happen on a healthy stream; requiring the second guards
|
|
1844
|
+
// against a one-off injected junk ciphertext killing a live session.
|
|
1845
|
+
_ConnectionManager.MAX_DECRYPT_FAILURES = 2;
|
|
1715
1846
|
var ConnectionManager = _ConnectionManager;
|
|
1716
1847
|
|
|
1717
1848
|
// src/utils/platform.ts
|
|
@@ -1769,6 +1900,16 @@ var QRL_CONNECT_PROVIDER_INFO = {
|
|
|
1769
1900
|
};
|
|
1770
1901
|
var EIP6963_ANNOUNCE_EVENT = "eip6963:announceProvider";
|
|
1771
1902
|
var EIP6963_REQUEST_EVENT = "eip6963:requestProvider";
|
|
1903
|
+
function isRecordObj(v) {
|
|
1904
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1905
|
+
}
|
|
1906
|
+
function parseInflightRecord(v) {
|
|
1907
|
+
if (!isRecordObj(v)) return null;
|
|
1908
|
+
const { id, method, ts } = v;
|
|
1909
|
+
if (typeof id !== "string" && typeof id !== "number") return null;
|
|
1910
|
+
if (typeof method !== "string" || typeof ts !== "number") return null;
|
|
1911
|
+
return { id, method, ts };
|
|
1912
|
+
}
|
|
1772
1913
|
var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
1773
1914
|
constructor(options) {
|
|
1774
1915
|
super();
|
|
@@ -1777,6 +1918,7 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
|
1777
1918
|
this.eip6963RequestListener = null;
|
|
1778
1919
|
this.resumeListener = null;
|
|
1779
1920
|
this.resumeDebounce = null;
|
|
1921
|
+
this.orphanedRequests = /* @__PURE__ */ new Map();
|
|
1780
1922
|
// Random per-instance prefix keeps request ids unique across page loads.
|
|
1781
1923
|
// A bare counter restarts at 1 on reload, and the relay buffers messages
|
|
1782
1924
|
// for 5 minutes, so a stale buffered response could otherwise be matched
|
|
@@ -1785,6 +1927,10 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
|
1785
1927
|
this.requestCounter = 0;
|
|
1786
1928
|
this.isQRLConnect = true;
|
|
1787
1929
|
this.options = options;
|
|
1930
|
+
this.inflightKey = `${options.storageKey ?? `${STORAGE_KEY_PREFIX}:session`}:inflight`;
|
|
1931
|
+
for (const rec of this.readInflight()) {
|
|
1932
|
+
this.orphanedRequests.set(rec.id, { method: rec.method });
|
|
1933
|
+
}
|
|
1788
1934
|
if (options.debug) {
|
|
1789
1935
|
setDebug(true);
|
|
1790
1936
|
}
|
|
@@ -1897,6 +2043,16 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
|
1897
2043
|
this.connectionManager.on("jsonrpc_response", (response) => {
|
|
1898
2044
|
const pending = this.pendingRequests.get(response.id);
|
|
1899
2045
|
if (!pending) {
|
|
2046
|
+
const orphan = this.orphanedRequests.get(response.id);
|
|
2047
|
+
if (orphan) {
|
|
2048
|
+
this.settleInflight(response.id);
|
|
2049
|
+
this.emit("late_response", {
|
|
2050
|
+
id: response.id,
|
|
2051
|
+
method: orphan.method,
|
|
2052
|
+
...response.error ? { error: response.error } : { result: response.result }
|
|
2053
|
+
});
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
1900
2056
|
warn("Provider", `No pending request for id ${response.id}`);
|
|
1901
2057
|
return;
|
|
1902
2058
|
}
|
|
@@ -1925,6 +2081,13 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
|
1925
2081
|
}
|
|
1926
2082
|
this.pendingRequests.clear();
|
|
1927
2083
|
});
|
|
2084
|
+
this.connectionManager.on("session_terminated", () => {
|
|
2085
|
+
for (const [, pending] of this.pendingRequests) {
|
|
2086
|
+
pending.reject(new Error("Session terminated by wallet"));
|
|
2087
|
+
}
|
|
2088
|
+
this.pendingRequests.clear();
|
|
2089
|
+
this.clearAllInflight();
|
|
2090
|
+
});
|
|
1928
2091
|
}
|
|
1929
2092
|
/**
|
|
1930
2093
|
* Generate a connection URI for QR code display or deep link redirect.
|
|
@@ -1961,7 +2124,13 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
|
1961
2124
|
throw new Error(`Unsupported method: ${method}`);
|
|
1962
2125
|
}
|
|
1963
2126
|
if (this.connectionManager.getStatus() !== "connected" /* CONNECTED */) {
|
|
1964
|
-
|
|
2127
|
+
if (method === "qrl_requestAccounts" && this.connectionManager.isPaired() && this.connectionManager.getAccounts().length > 0) {
|
|
2128
|
+
return this.connectionManager.getAccounts();
|
|
2129
|
+
}
|
|
2130
|
+
const joined = await this.connectionManager.ensureChannelJoined();
|
|
2131
|
+
if (!joined) {
|
|
2132
|
+
throw new Error("Not connected to QRL Wallet");
|
|
2133
|
+
}
|
|
1965
2134
|
}
|
|
1966
2135
|
const id = `${this.requestIdPrefix}-${++this.requestCounter}`;
|
|
1967
2136
|
return new Promise((resolve, reject) => {
|
|
@@ -1984,20 +2153,109 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
|
1984
2153
|
const originalReject = pending.reject;
|
|
1985
2154
|
pending.resolve = (result) => {
|
|
1986
2155
|
clearTimeout(timeout);
|
|
2156
|
+
this.settleInflight(id);
|
|
1987
2157
|
originalResolve(result);
|
|
1988
2158
|
};
|
|
1989
2159
|
pending.reject = (error2) => {
|
|
1990
2160
|
clearTimeout(timeout);
|
|
2161
|
+
this.settleInflight(id);
|
|
1991
2162
|
originalReject(error2);
|
|
1992
2163
|
};
|
|
1993
|
-
|
|
2164
|
+
if (RESTRICTED_METHODS.has(method)) {
|
|
2165
|
+
this.persistInflight(id, method);
|
|
2166
|
+
}
|
|
2167
|
+
const sent = this.connectionManager.sendJsonRpc({
|
|
1994
2168
|
jsonrpc: "2.0",
|
|
1995
2169
|
id,
|
|
1996
2170
|
method,
|
|
1997
2171
|
params
|
|
1998
2172
|
});
|
|
2173
|
+
sent.catch((err) => {
|
|
2174
|
+
const failed = this.pendingRequests.get(id);
|
|
2175
|
+
if (!failed) return;
|
|
2176
|
+
this.pendingRequests.delete(id);
|
|
2177
|
+
failed.reject(err instanceof Error ? err : new Error(String(err)));
|
|
2178
|
+
});
|
|
2179
|
+
if (this.shouldRedirectToWallet(method)) {
|
|
2180
|
+
void sent.then(
|
|
2181
|
+
() => attemptWalletRedirect(this.walletWakeUri()),
|
|
2182
|
+
() => void 0
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
1999
2185
|
});
|
|
2000
2186
|
}
|
|
2187
|
+
/**
|
|
2188
|
+
* Redirect only for calls the user must approve, on mobile, and only when
|
|
2189
|
+
* the wallet is actually absent from the channel: right after pairing its
|
|
2190
|
+
* socket lingers and a pointless app flash would be jarring.
|
|
2191
|
+
*
|
|
2192
|
+
* qrl_requestAccounts is excluded even though it is restricted: dApps call
|
|
2193
|
+
* it on page load to restore a session, so it can run with no user gesture
|
|
2194
|
+
* (an unsolicited "Open in MyQRLWallet?" sheet on load) and pairing UIs
|
|
2195
|
+
* already own their own deep-link step.
|
|
2196
|
+
*/
|
|
2197
|
+
shouldRedirectToWallet(method) {
|
|
2198
|
+
return this.options.walletRedirectOnRequest !== false && RESTRICTED_METHODS.has(method) && method !== "qrl_requestAccounts" && isMobileBrowser() && !this.connectionManager.isWalletPresent();
|
|
2199
|
+
}
|
|
2200
|
+
// ── In-flight persistence (survive the return-redirect reload) ──────
|
|
2201
|
+
readInflight() {
|
|
2202
|
+
if (typeof localStorage === "undefined") return [];
|
|
2203
|
+
try {
|
|
2204
|
+
const raw = localStorage.getItem(this.inflightKey);
|
|
2205
|
+
if (!raw) return [];
|
|
2206
|
+
const parsed = JSON.parse(raw);
|
|
2207
|
+
if (!Array.isArray(parsed)) return [];
|
|
2208
|
+
const now = Date.now();
|
|
2209
|
+
const records = [];
|
|
2210
|
+
for (const v of parsed) {
|
|
2211
|
+
const rec = parseInflightRecord(v);
|
|
2212
|
+
if (rec && now - rec.ts <= REQUEST_TIMEOUT_MS) records.push(rec);
|
|
2213
|
+
}
|
|
2214
|
+
return records;
|
|
2215
|
+
} catch {
|
|
2216
|
+
return [];
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
writeInflight(records) {
|
|
2220
|
+
if (typeof localStorage === "undefined") return;
|
|
2221
|
+
try {
|
|
2222
|
+
if (records.length === 0) {
|
|
2223
|
+
localStorage.removeItem(this.inflightKey);
|
|
2224
|
+
} else {
|
|
2225
|
+
localStorage.setItem(this.inflightKey, JSON.stringify(records));
|
|
2226
|
+
}
|
|
2227
|
+
} catch {
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
persistInflight(id, method) {
|
|
2231
|
+
const records = this.readInflight().filter((r) => r.id !== id);
|
|
2232
|
+
records.push({ id, method, ts: Date.now() });
|
|
2233
|
+
this.writeInflight(records);
|
|
2234
|
+
}
|
|
2235
|
+
settleInflight(id) {
|
|
2236
|
+
this.orphanedRequests.delete(id);
|
|
2237
|
+
this.writeInflight(this.readInflight().filter((r) => r.id !== id));
|
|
2238
|
+
}
|
|
2239
|
+
clearAllInflight() {
|
|
2240
|
+
this.orphanedRequests.clear();
|
|
2241
|
+
this.writeInflight([]);
|
|
2242
|
+
}
|
|
2243
|
+
/**
|
|
2244
|
+
* Wake URI for the wallet app. Carries no pairing payload: the app
|
|
2245
|
+
* foregrounds on any `qrlconnect:` URL and re-joins its sessions (draining
|
|
2246
|
+
* the buffered request); the cid lets a future wallet jump straight to the
|
|
2247
|
+
* right approval.
|
|
2248
|
+
*
|
|
2249
|
+
* MUST stay query-only (no host/path). The mobile app routes deep links
|
|
2250
|
+
* with expo-router, which maps anything after the scheme to an in-app
|
|
2251
|
+
* route: `qrlconnect://resume?...` lands on the "+not-found" screen and
|
|
2252
|
+
* unmounts the WebView, so the wake achieves nothing. The hostless form
|
|
2253
|
+
* routes to the WebView tab exactly like the pairing URIs
|
|
2254
|
+
* (`qrlconnect://?q=...`), which is the whole point of the wake.
|
|
2255
|
+
*/
|
|
2256
|
+
walletWakeUri() {
|
|
2257
|
+
return `qrlconnect://?wake=${encodeURIComponent(this.connectionManager.getChannelId())}`;
|
|
2258
|
+
}
|
|
2001
2259
|
/**
|
|
2002
2260
|
* Get the current connection status.
|
|
2003
2261
|
*/
|
|
@@ -2028,6 +2286,19 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
|
2028
2286
|
hasStoredSession() {
|
|
2029
2287
|
return this.connectionManager.hasStoredSession();
|
|
2030
2288
|
}
|
|
2289
|
+
/**
|
|
2290
|
+
* True while a pairing exists (keys exchanged), even when the wallet app
|
|
2291
|
+
* is backgrounded and its socket is out of the channel. Combine with
|
|
2292
|
+
* getStatus() to tell "wallet away, session intact" (paired + WAITING)
|
|
2293
|
+
* apart from "waiting for a first scan" in pairing UIs.
|
|
2294
|
+
*/
|
|
2295
|
+
isPaired() {
|
|
2296
|
+
return this.connectionManager.isPaired();
|
|
2297
|
+
}
|
|
2298
|
+
/** True while the wallet is actually present in the relay channel. */
|
|
2299
|
+
isWalletPresent() {
|
|
2300
|
+
return this.connectionManager.isWalletPresent();
|
|
2301
|
+
}
|
|
2031
2302
|
/**
|
|
2032
2303
|
* Reset the connection and start a fresh pairing with a new channel.
|
|
2033
2304
|
* Use this when the user wants to create a new connection instead of
|
|
@@ -2038,6 +2309,7 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
|
2038
2309
|
pending.reject(new Error("Connection reset"));
|
|
2039
2310
|
}
|
|
2040
2311
|
this.pendingRequests.clear();
|
|
2312
|
+
this.clearAllInflight();
|
|
2041
2313
|
await this.connectionManager.resetForNewChannel();
|
|
2042
2314
|
return this.getConnectionURI();
|
|
2043
2315
|
}
|
|
@@ -2052,6 +2324,7 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
|
2052
2324
|
pending.reject(new Error("Disconnected"));
|
|
2053
2325
|
}
|
|
2054
2326
|
this.pendingRequests.clear();
|
|
2327
|
+
this.clearAllInflight();
|
|
2055
2328
|
this.teardownResumeListeners();
|
|
2056
2329
|
await this.connectionManager.disconnect();
|
|
2057
2330
|
}
|