@zoreal/oauth2-react 0.2.8 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -37,8 +37,25 @@ interface PairingState {
37
37
  * carrying nothing to render. Present on every callback of a QR/link flow.
38
38
  */
39
39
  pairUrl?: string;
40
- /** The provider-served SVG of pairUrl. Put it in an <img>; do not draw your own. */
40
+ /**
41
+ * The provider-served SVG to show right now. Put it in an <img>; do not draw
42
+ * your own.
43
+ *
44
+ * IT CHANGES DURING THE PAIRING. The code on screen is one frame of a
45
+ * rotating sequence, and a frame the provider has moved past is refused when
46
+ * the phone tries to claim it, which is what stops a screenshot of the code
47
+ * from being relayed to someone else's browser. So render the qrUrl of the
48
+ * state you are handed, every time you are handed one; a UI that caches the
49
+ * first value shows a code that stops working seconds later.
50
+ */
41
51
  qrUrl?: string;
52
+ /**
53
+ * How often, in seconds, `qrUrl` is replaced while the pairing is pending.
54
+ * Informational: the SDK already emits a new state on that cadence, so a
55
+ * custom UI does not need a timer of its own. Absent on the app link, which
56
+ * has no QR.
57
+ */
58
+ qrRefreshSeconds?: number;
42
59
  /** True when the flow resolved to the app link (mobile) rather than a QR. */
43
60
  appLink?: boolean;
44
61
  /** Abandons this pairing: stops the poll. Wire it to your UI's cancel control. */
package/dist/index.d.ts CHANGED
@@ -37,8 +37,25 @@ interface PairingState {
37
37
  * carrying nothing to render. Present on every callback of a QR/link flow.
38
38
  */
39
39
  pairUrl?: string;
40
- /** The provider-served SVG of pairUrl. Put it in an <img>; do not draw your own. */
40
+ /**
41
+ * The provider-served SVG to show right now. Put it in an <img>; do not draw
42
+ * your own.
43
+ *
44
+ * IT CHANGES DURING THE PAIRING. The code on screen is one frame of a
45
+ * rotating sequence, and a frame the provider has moved past is refused when
46
+ * the phone tries to claim it, which is what stops a screenshot of the code
47
+ * from being relayed to someone else's browser. So render the qrUrl of the
48
+ * state you are handed, every time you are handed one; a UI that caches the
49
+ * first value shows a code that stops working seconds later.
50
+ */
41
51
  qrUrl?: string;
52
+ /**
53
+ * How often, in seconds, `qrUrl` is replaced while the pairing is pending.
54
+ * Informational: the SDK already emits a new state on that cadence, so a
55
+ * custom UI does not need a timer of its own. Absent on the app link, which
56
+ * has no QR.
57
+ */
58
+ qrRefreshSeconds?: number;
42
59
  /** True when the flow resolved to the app link (mobile) rather than a QR. */
43
60
  appLink?: boolean;
44
61
  /** Abandons this pairing: stops the poll. Wire it to your UI's cancel control. */
package/dist/index.js CHANGED
@@ -5,10 +5,11 @@ import { createContext, useContext, useMemo, useState as useState2 } from "react
5
5
 
6
6
  // src/wire.ts
7
7
  var WIRE_VERSION = 1;
8
- var SDK_VERSION = "0.2.8";
8
+ var SDK_VERSION = "0.2.10";
9
9
  var DEFAULT_ISSUER = "https://id.zoreal.com";
10
10
  var POLL_INTERVAL_MS = 2e3;
11
11
  var POLL_INTERVAL_ENROLLING_MS = 5e3;
12
+ var DEFAULT_QR_REFRESH_SECONDS = 3;
12
13
 
13
14
  // src/PairingModal.tsx
14
15
  import { useEffect, useId, useRef, useState } from "react";
@@ -905,20 +906,110 @@ var CSS = `
905
906
  color: var(--zrl-ink-soft);
906
907
  }
907
908
 
909
+ /* The light around the well: a thin ring in the accent, one bright arc
910
+ travelling round it, and a soft glow of the same colour bleeding a few
911
+ pixels outward. It says the pairing is live without a word.
912
+
913
+ It sits BELOW the well, which is why the well is wrapped instead of given a
914
+ pseudo-element. Anything drawn inside the well paints over the white quiet
915
+ zone the camera needs, and a negative z-index from inside the well lands
916
+ wherever the card's stacking context happens to be that frame (the card's
917
+ entrance animation makes and unmakes one). The wrapper is the well's old
918
+ box exactly, same size, same margin, so nothing moves; the well keeps its
919
+ own stacking order on top, and the ring cannot touch the QR. */
920
+ .${PREFIX}-qr-halo {
921
+ position: relative;
922
+ isolation: isolate;
923
+ width: 204px;
924
+ height: 204px;
925
+ margin: 20px auto 0;
926
+ }
927
+
908
928
  .${PREFIX}-qr-well {
909
929
  position: relative;
930
+ z-index: 1;
910
931
  display: grid;
911
932
  place-items: center;
912
933
  box-sizing: border-box;
913
934
  width: 204px;
914
935
  height: 204px;
915
- margin: 20px auto 0;
936
+ margin: 0;
916
937
  padding: 12px;
917
938
  border: 1px solid var(--zrl-line);
918
939
  border-radius: 16px;
919
940
  background: var(--zrl-qr-bg);
920
941
  }
921
942
 
943
+ /* The glow is the ring's own alpha, blurred and drawn beneath it, so it
944
+ follows the arc round and fades with it. It has to be a parent of the
945
+ masked band: a filter is applied before a mask, so a shadow cast from the
946
+ band itself would be cut off at the band's edge. Two shadows because one
947
+ 2px source blurred over 10px is too faint to read as light; the second
948
+ blurs the first. */
949
+ .${PREFIX}-qr-glow {
950
+ position: absolute;
951
+ inset: -2px;
952
+ pointer-events: none;
953
+ filter: drop-shadow(0 0 3px var(--zrl-accent)) drop-shadow(0 0 10px var(--zrl-accent));
954
+ }
955
+
956
+ /* The band: a 2px ring just outside the well, cut with a mask that keeps the
957
+ padding and drops the content box. The gradient is on a pseudo-element
958
+ rather than on the band, because in the fallback it has to rotate as a
959
+ whole while the band stays put. The prefixed mask is for Chrome before 120;
960
+ the unprefixed one, declared after it, wins everywhere else. */
961
+ .${PREFIX}-qr-arc {
962
+ position: absolute;
963
+ inset: 0;
964
+ padding: 2px;
965
+ border-radius: 18px;
966
+ overflow: hidden;
967
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
968
+ -webkit-mask-composite: xor;
969
+ mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
970
+ mask-composite: exclude;
971
+ }
972
+ .${PREFIX}-qr-arc::before,
973
+ .${PREFIX}-qr-arc::after {
974
+ content: '';
975
+ position: absolute;
976
+ transition: opacity 400ms cubic-bezier(0.23, 1, 0.32, 1);
977
+ }
978
+
979
+ /* The light. Mostly transparent, a tail that builds over a quarter turn, a
980
+ short full-strength head and a drop-off within a few degrees, so it reads
981
+ as travelling rather than blinking. One revolution every 3s, linear:
982
+ continuous motion has no ease. Oversized so its corners still cover the
983
+ band mid-rotation in the transform fallback; the property path shrinks it
984
+ back to the band. */
985
+ .${PREFIX}-qr-arc::before {
986
+ inset: -50%;
987
+ background: conic-gradient(
988
+ from var(--zrl-angle, 0deg),
989
+ transparent 0deg 245deg,
990
+ var(--zrl-accent) 332deg 346deg,
991
+ transparent 356deg 360deg
992
+ );
993
+ animation: ${PREFIX}-orbit 3s linear infinite;
994
+ }
995
+
996
+ /* The rest state: the same ring, evenly lit. Faint under the moving light so
997
+ the ring reads as a ring; stronger once the light has stopped. */
998
+ .${PREFIX}-qr-arc::after {
999
+ inset: 0;
1000
+ background: var(--zrl-accent);
1001
+ opacity: 0.16;
1002
+ }
1003
+
1004
+ /* Spent: the light stops where it is and fades, and the ring settles to a
1005
+ dim, even glow. Paused rather than removed, so it does not jump back to
1006
+ its start on the way out. */
1007
+ .${PREFIX}-qr-halo[data-spent="true"] .${PREFIX}-qr-arc::before {
1008
+ animation-play-state: paused;
1009
+ opacity: 0;
1010
+ }
1011
+ .${PREFIX}-qr-halo[data-spent="true"] .${PREFIX}-qr-arc::after { opacity: 0.34; }
1012
+
922
1013
  .${PREFIX}-qr {
923
1014
  display: block;
924
1015
  width: 100%;
@@ -1061,6 +1152,33 @@ var CSS = `
1061
1152
  70%, 100% { transform: scale(2.6); opacity: 0 }
1062
1153
  }
1063
1154
 
1155
+ /* Two ways to turn the light, one look.
1156
+ Where the browser can register a typed custom property, the gradient's own
1157
+ angle animates and the element never moves. Where it cannot (Firefox before
1158
+ 128), an untyped custom property cannot interpolate and the light would
1159
+ snap once a cycle, so the oversized pseudo-element rotates instead and the
1160
+ band's mask keeps the ring in place. At rest the two are the same pixels:
1161
+ same gradient, same centre, angle zero.
1162
+ There is no direct query for @property support. transition-behavior
1163
+ shipped after it in every engine (Chrome 117 vs 85, Firefox 129 vs 128,
1164
+ Safari 17.4 vs 16.4), so it stands in: a yes is never wrong, and a no only
1165
+ sends a capable browser down the fallback, which looks the same.
1166
+ inherits: false so the angle stays on the element that animates it and
1167
+ never reaches the host page or the well. */
1168
+ @property --zrl-angle {
1169
+ syntax: '<angle>';
1170
+ inherits: false;
1171
+ initial-value: 0deg;
1172
+ }
1173
+ @keyframes ${PREFIX}-orbit { to { transform: rotate(360deg) } }
1174
+ @keyframes ${PREFIX}-orbit-angle { to { --zrl-angle: 360deg } }
1175
+ @supports (transition-behavior: allow-discrete) {
1176
+ .${PREFIX}-qr-arc::before {
1177
+ inset: 0;
1178
+ animation-name: ${PREFIX}-orbit-angle;
1179
+ }
1180
+ }
1181
+
1064
1182
  @media (prefers-reduced-motion: reduce) {
1065
1183
  .${PREFIX}-scrim,
1066
1184
  .${PREFIX}-card,
@@ -1070,6 +1188,10 @@ var CSS = `
1070
1188
  .${PREFIX}-cancel,
1071
1189
  .${PREFIX}-close,
1072
1190
  .${PREFIX}-timer { transition: none }
1191
+ /* No travelling light. The ring keeps a still, soft glow instead, a step
1192
+ brighter than the spent state so pending and spent still read apart. */
1193
+ .${PREFIX}-qr-arc::before { animation: none; opacity: 0 }
1194
+ .${PREFIX}-qr-arc::after { opacity: 0.5 }
1073
1195
  }
1074
1196
  `;
1075
1197
  function ensureStyles() {
@@ -1113,6 +1235,23 @@ function PairingModal({
1113
1235
  const deadlineRef = useRef(0);
1114
1236
  const [remaining, setRemaining] = useState(Math.round(timeoutMs / 1e3));
1115
1237
  const settled = state.status === "claimed" || state.status === "enrolling";
1238
+ const [frameUrl, setFrameUrl] = useState(qrUrl);
1239
+ useEffect(() => {
1240
+ if (settled || frameUrl === qrUrl) return;
1241
+ let abandoned = false;
1242
+ const next = new Image();
1243
+ next.onload = () => {
1244
+ if (!abandoned) setFrameUrl(qrUrl);
1245
+ };
1246
+ next.onerror = () => {
1247
+ };
1248
+ next.src = qrUrl;
1249
+ return () => {
1250
+ abandoned = true;
1251
+ next.onload = null;
1252
+ next.onerror = null;
1253
+ };
1254
+ }, [qrUrl, settled, frameUrl]);
1116
1255
  const onCancelRef = useRef(onCancel);
1117
1256
  onCancelRef.current = onCancel;
1118
1257
  useEffect(() => {
@@ -1163,9 +1302,12 @@ function PairingModal({
1163
1302
  /* @__PURE__ */ jsx3(ZorealLockup, { height: 44, className: cx("lockup") }),
1164
1303
  /* @__PURE__ */ jsx3("h2", { id: titleId, className: cx("title"), children: settled ? t.titleApprove : t.title }),
1165
1304
  /* @__PURE__ */ jsx3("p", { className: cx("body-text"), children: body }),
1166
- /* @__PURE__ */ jsxs3("div", { className: cx("qr-well"), children: [
1167
- /* @__PURE__ */ jsx3("img", { className: cx("qr"), "data-spent": settled, src: qrUrl, alt: t.qrAlt, width: 180, height: 180 }),
1168
- settled && /* @__PURE__ */ jsx3("span", { className: cx("qr-overlay"), children: /* @__PURE__ */ jsx3("span", { className: cx("qr-badge"), children: /* @__PURE__ */ jsx3(IconPhone, {}) }) })
1305
+ /* @__PURE__ */ jsxs3("div", { className: cx("qr-halo"), "data-spent": settled, children: [
1306
+ /* @__PURE__ */ jsx3("span", { className: cx("qr-glow"), "aria-hidden": true, children: /* @__PURE__ */ jsx3("span", { className: cx("qr-arc") }) }),
1307
+ /* @__PURE__ */ jsxs3("div", { className: cx("qr-well"), children: [
1308
+ /* @__PURE__ */ jsx3("img", { className: cx("qr"), "data-spent": settled, src: frameUrl, alt: t.qrAlt, width: 180, height: 180 }),
1309
+ settled && /* @__PURE__ */ jsx3("span", { className: cx("qr-overlay"), children: /* @__PURE__ */ jsx3("span", { className: cx("qr-badge"), children: /* @__PURE__ */ jsx3(IconPhone, {}) }) })
1310
+ ] })
1169
1311
  ] }),
1170
1312
  /* @__PURE__ */ jsxs3("div", { className: cx("status"), children: [
1171
1313
  /* @__PURE__ */ jsxs3("span", { className: cx("dot"), children: [
@@ -1302,55 +1444,99 @@ async function startPairing(issuer, params) {
1302
1444
  }
1303
1445
  return body;
1304
1446
  }
1447
+ function qrRefreshSecondsOf(started) {
1448
+ const seconds = started.qr_refresh_seconds;
1449
+ return typeof seconds === "number" && seconds > 0 ? seconds : DEFAULT_QR_REFRESH_SECONDS;
1450
+ }
1451
+ function qrFrameUrl(issuer, requestId) {
1452
+ return `${issuer}/pair/${encodeURIComponent(requestId)}/qr.svg?t=${Date.now()}`;
1453
+ }
1305
1454
  var sleep = (ms, signal) => new Promise((resolve, reject) => {
1306
1455
  if (signal?.aborted) {
1307
1456
  reject(new DOMException("aborted", "AbortError"));
1308
1457
  return;
1309
1458
  }
1310
- const t = setTimeout(resolve, ms);
1311
- signal?.addEventListener("abort", () => {
1312
- clearTimeout(t);
1459
+ const onAbort = () => {
1460
+ clearTimeout(timer);
1313
1461
  reject(new DOMException("aborted", "AbortError"));
1314
- });
1462
+ };
1463
+ const timer = setTimeout(() => {
1464
+ signal?.removeEventListener("abort", onAbort);
1465
+ resolve();
1466
+ }, ms);
1467
+ signal?.addEventListener("abort", onAbort, { once: true });
1315
1468
  });
1316
- async function pollUntilApproved(issuer, requestId, onState, signal) {
1317
- for (; ; ) {
1318
- const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {
1319
- signal
1320
- });
1321
- const body = await parseJson(response);
1322
- if (!response.ok) {
1323
- throw new OAuthFlowError(
1324
- body.error ?? "server_error",
1325
- body.error_description ?? `Pairing status failed (${response.status})`
1326
- );
1327
- }
1328
- onState?.({
1329
- status: body.status,
1330
- expiresIn: body.expires_in,
1331
- enrolmentDeadline: body.enrolment_deadline
1469
+ async function pollUntilApproved(issuer, requestId, onState, signal, options = {}) {
1470
+ let last = { status: "pending" };
1471
+ const emit = (state) => {
1472
+ last = state;
1473
+ onState?.(state);
1474
+ };
1475
+ let frames = null;
1476
+ const stopFrames = () => {
1477
+ frames?.abort();
1478
+ frames = null;
1479
+ };
1480
+ const startFrames = () => {
1481
+ const seconds = options.qrRefreshSeconds;
1482
+ if (frames || signal?.aborted || typeof seconds !== "number" || !(seconds > 0)) return;
1483
+ const period = seconds * 1e3;
1484
+ const controller = new AbortController();
1485
+ frames = controller;
1486
+ signal?.addEventListener("abort", stopFrames, { signal: controller.signal });
1487
+ void (async () => {
1488
+ let due = Date.now() + period;
1489
+ for (; ; ) {
1490
+ await sleep(Math.max(0, due - Date.now()), controller.signal);
1491
+ emit({ ...last, qrUrl: qrFrameUrl(issuer, requestId) });
1492
+ due = Date.now() + period;
1493
+ }
1494
+ })().catch(() => {
1332
1495
  });
1333
- switch (body.status) {
1334
- case "approved":
1335
- if (!body.code) {
1336
- throw new OAuthFlowError("server_error", "approved with no authorization code");
1337
- }
1338
- return body.code;
1339
- case "denied":
1340
- throw new FlowAbandonedError({ type: "request_denied", description: body.error_description });
1341
- case "expired":
1342
- throw new FlowAbandonedError({ type: "request_expired", description: body.error_description });
1343
- case "cancelled":
1344
- throw new FlowAbandonedError({
1345
- type: "request_expired",
1346
- description: body.error_description ?? "the provider cancelled the pairing request"
1347
- });
1348
- case "enrolling":
1349
- await sleep(POLL_INTERVAL_ENROLLING_MS, signal);
1350
- break;
1351
- default:
1352
- await sleep(POLL_INTERVAL_MS, signal);
1496
+ };
1497
+ try {
1498
+ for (; ; ) {
1499
+ const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {
1500
+ signal
1501
+ });
1502
+ const body = await parseJson(response);
1503
+ if (!response.ok) {
1504
+ throw new OAuthFlowError(
1505
+ body.error ?? "server_error",
1506
+ body.error_description ?? `Pairing status failed (${response.status})`
1507
+ );
1508
+ }
1509
+ emit({
1510
+ status: body.status,
1511
+ expiresIn: body.expires_in,
1512
+ enrolmentDeadline: body.enrolment_deadline
1513
+ });
1514
+ if (body.status === "pending") startFrames();
1515
+ else stopFrames();
1516
+ switch (body.status) {
1517
+ case "approved":
1518
+ if (!body.code) {
1519
+ throw new OAuthFlowError("server_error", "approved with no authorization code");
1520
+ }
1521
+ return body.code;
1522
+ case "denied":
1523
+ throw new FlowAbandonedError({ type: "request_denied", description: body.error_description });
1524
+ case "expired":
1525
+ throw new FlowAbandonedError({ type: "request_expired", description: body.error_description });
1526
+ case "cancelled":
1527
+ throw new FlowAbandonedError({
1528
+ type: "request_expired",
1529
+ description: body.error_description ?? "the provider cancelled the pairing request"
1530
+ });
1531
+ case "enrolling":
1532
+ await sleep(POLL_INTERVAL_ENROLLING_MS, signal);
1533
+ break;
1534
+ default:
1535
+ await sleep(POLL_INTERVAL_MS, signal);
1536
+ }
1353
1537
  }
1538
+ } finally {
1539
+ stopFrames();
1354
1540
  }
1355
1541
  }
1356
1542
  async function exchangeCode(issuer, input) {
@@ -1377,6 +1563,11 @@ function isMobileUserAgent() {
1377
1563
  if (typeof navigator === "undefined") return false;
1378
1564
  return /android|iphone|ipad|ipod/i.test(navigator.userAgent);
1379
1565
  }
1566
+ function resolveDisplay(display) {
1567
+ if (display === "link") return "link";
1568
+ if (display === "qr") return "qr";
1569
+ return isMobileUserAgent() ? "link" : "qr";
1570
+ }
1380
1571
 
1381
1572
  // src/pkce.ts
1382
1573
  var VERIFIER_BYTES = 32;
@@ -1423,6 +1614,8 @@ function useZorealFlow(options) {
1423
1614
  const verifier = generateVerifier();
1424
1615
  const state = generateState();
1425
1616
  const nonce = generateState();
1617
+ const display = resolveDisplay(opts.display);
1618
+ const useAppLink = display === "link";
1426
1619
  try {
1427
1620
  const started = await startPairing(issuer, {
1428
1621
  client_id: clientId,
@@ -1434,7 +1627,8 @@ function useZorealFlow(options) {
1434
1627
  acr_values: Array.isArray(opts.acr_values) ? opts.acr_values.join(" ") : opts.acr_values,
1435
1628
  max_age: opts.max_age,
1436
1629
  prompt: opts.prompt,
1437
- locale
1630
+ locale,
1631
+ display
1438
1632
  });
1439
1633
  let code;
1440
1634
  let selectBy = "device";
@@ -1442,8 +1636,8 @@ function useZorealFlow(options) {
1442
1636
  code = started.code;
1443
1637
  selectBy = "session";
1444
1638
  } else {
1445
- const useAppLink = opts.display === "link" || opts.display !== "qr" && isMobileUserAgent();
1446
1639
  selectBy = useAppLink ? "app_link" : "qr";
1640
+ const qrRefreshSeconds = qrRefreshSecondsOf(started);
1447
1641
  const cancel = () => {
1448
1642
  controller.abort();
1449
1643
  setPairing(null);
@@ -1451,21 +1645,23 @@ function useZorealFlow(options) {
1451
1645
  };
1452
1646
  const surface = {
1453
1647
  pairUrl: started.pair_url,
1454
- qrUrl: `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`,
1455
1648
  appLink: useAppLink,
1456
- cancel
1649
+ cancel,
1650
+ // The app link has no QR and therefore no cadence to report.
1651
+ ...useAppLink ? null : { qrRefreshSeconds }
1457
1652
  };
1653
+ let qrUrl = `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`;
1458
1654
  const active = {
1459
1655
  requestId: started.request_id,
1460
1656
  pairUrl: surface.pairUrl,
1461
- qrUrl: surface.qrUrl,
1462
- state: { status: "pending", expiresIn: started.expires_in, ...surface },
1657
+ qrUrl,
1658
+ state: { status: "pending", expiresIn: started.expires_in, qrUrl, ...surface },
1463
1659
  appLink: useAppLink,
1464
1660
  cancel
1465
1661
  };
1466
1662
  setPairing(active);
1467
1663
  if (!useAppLink) {
1468
- publishRef.current?.({ state: active.state, qrUrl: surface.qrUrl, cancel });
1664
+ publishRef.current?.({ state: active.state, qrUrl, cancel });
1469
1665
  }
1470
1666
  opts.onPairingStateChange?.(active.state);
1471
1667
  if (useAppLink) {
@@ -1475,16 +1671,20 @@ function useZorealFlow(options) {
1475
1671
  issuer,
1476
1672
  started.request_id,
1477
1673
  (s) => {
1478
- const enriched = { ...s, ...surface };
1674
+ if (s.qrUrl) qrUrl = s.qrUrl;
1675
+ const enriched = { ...s, ...surface, qrUrl };
1479
1676
  setPairing(
1480
- (p) => p && p.requestId === started.request_id ? { ...p, state: enriched } : p
1677
+ (p) => p && p.requestId === started.request_id ? { ...p, qrUrl, state: enriched } : p
1481
1678
  );
1482
1679
  if (!useAppLink) {
1483
- publishRef.current?.({ state: enriched, qrUrl: surface.qrUrl, cancel });
1680
+ publishRef.current?.({ state: enriched, qrUrl, cancel });
1484
1681
  }
1485
1682
  opts.onPairingStateChange?.(enriched);
1486
1683
  },
1487
- controller.signal
1684
+ controller.signal,
1685
+ // The app link has no QR to animate, and the provider answers 404
1686
+ // for one, so the frames are asked for only on the QR surface.
1687
+ { qrRefreshSeconds: useAppLink ? void 0 : qrRefreshSeconds }
1488
1688
  );
1489
1689
  }
1490
1690
  setPairing(null);