dsh-deeppilot 0.6.2 → 0.7.1

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/lib/client.js CHANGED
@@ -165,10 +165,16 @@ window.__ModuleLoader__.load({
165
165
  if (!Array.isArray(endpoints) || endpoints.some((entry) => typeof entry !== "string")) reject("local.endpoints");
166
166
  const message = s.message;
167
167
  if (message !== void 0 && typeof message !== "string") reject("local.message");
168
+ const tlsFingerprint = s.tlsFingerprint;
169
+ if (tlsFingerprint !== void 0 && typeof tlsFingerprint !== "string") reject("local.tlsFingerprint");
170
+ const tlsIdentityRegenerated = s.tlsIdentityRegenerated;
171
+ if (tlsIdentityRegenerated !== void 0 && typeof tlsIdentityRegenerated !== "boolean") reject("local.tlsIdentityRegenerated");
168
172
  return {
169
173
  phase,
170
174
  port: int(s, "port", "local.port"),
171
175
  endpoints,
176
+ ...typeof tlsFingerprint === "string" ? { tlsFingerprint } : {},
177
+ ...tlsIdentityRegenerated === true ? { tlsIdentityRegenerated: true } : {},
172
178
  ...typeof message === "string" ? { message } : {},
173
179
  updatedAt: int(s, "updatedAt", "local.updatedAt")
174
180
  };
@@ -280,6 +286,35 @@ window.__ModuleLoader__.load({
280
286
  if (!Array.isArray(value) || value.some((scope) => typeof scope !== "string" || !DEVICE_SCOPES.includes(scope))) reject("scopes");
281
287
  return normalizeDeviceScopes(value);
282
288
  } };
289
+ const booleanSchema = { parse(value) {
290
+ if (typeof value !== "boolean") reject("boolean");
291
+ return value;
292
+ } };
293
+ /**
294
+ * One strict boundary codec carrying both generation shapes of the supported
295
+ * `0.1.x` host family.
296
+ *
297
+ * Through `0.1.6-alpha.1` a descriptor published the schema value itself and
298
+ * every consumer parsed through `codec.schema.parse(value)`. `0.1.6-alpha.2`
299
+ * switched to a lazily materialized process-realm factory: `TypertRegistry`
300
+ * now rejects a strict codec without `create()`, and the Gateway parses
301
+ * through `codec.create().parse(value)`.
302
+ *
303
+ * Publishing both keys — `create()` returning the same hand-written,
304
+ * dependency-free codec — keeps one package valid across the entire declared
305
+ * peer range instead of forking the wire contract per host generation. The
306
+ * cast is required because each generation's `TypertCodec` declares only its
307
+ * own key, so a fresh object literal carrying both fails excess-property
308
+ * checking against either one.
309
+ */
310
+ function strictCodec(typeSymbol, schema) {
311
+ return {
312
+ mode: "strict",
313
+ typeSymbol,
314
+ schema,
315
+ create: () => schema
316
+ };
317
+ }
283
318
  const REPORT_REMOTE_CONTRIBUTION = {
284
319
  package: REPORT_REMOTE_PACKAGE,
285
320
  descriptors: [
@@ -290,11 +325,7 @@ window.__ModuleLoader__.load({
290
325
  method: "report",
291
326
  invocation: { kind: "direct" },
292
327
  parameters: [],
293
- result: {
294
- mode: "strict",
295
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeepPilotReport`,
296
- schema: reportSchema
297
- }
328
+ result: strictCodec(`${REPORT_REMOTE_PACKAGE}#DeepPilotReport`, reportSchema)
298
329
  },
299
330
  {
300
331
  id: `${REPORT_REMOTE_PACKAGE}#${BEGIN_PAIRING_ENDPOINT}`,
@@ -303,11 +334,7 @@ window.__ModuleLoader__.load({
303
334
  method: "beginPairing",
304
335
  invocation: { kind: "direct" },
305
336
  parameters: [],
306
- result: {
307
- mode: "strict",
308
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#PairingGrantSnapshot`,
309
- schema: pairingGrantSchema
310
- }
337
+ result: strictCodec(`${REPORT_REMOTE_PACKAGE}#PairingGrantSnapshot`, pairingGrantSchema)
311
338
  },
312
339
  {
313
340
  id: `${REPORT_REMOTE_PACKAGE}#${REVOKE_DEVICE_ENDPOINT}`,
@@ -319,20 +346,9 @@ window.__ModuleLoader__.load({
319
346
  name: "deviceId",
320
347
  wire: "deviceId",
321
348
  source: "json",
322
- codec: {
323
- mode: "strict",
324
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceId`,
325
- schema: deviceIdSchema
326
- }
349
+ codec: strictCodec(`${REPORT_REMOTE_PACKAGE}#DeviceId`, deviceIdSchema)
327
350
  }],
328
- result: {
329
- mode: "strict",
330
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#Boolean`,
331
- schema: { parse(value) {
332
- if (typeof value !== "boolean") reject("boolean");
333
- return value;
334
- } }
335
- }
351
+ result: strictCodec(`${REPORT_REMOTE_PACKAGE}#Boolean`, booleanSchema)
336
352
  },
337
353
  {
338
354
  id: `${REPORT_REMOTE_PACKAGE}#${SET_DEVICE_SCOPES_ENDPOINT}`,
@@ -344,26 +360,14 @@ window.__ModuleLoader__.load({
344
360
  name: "deviceId",
345
361
  wire: "deviceId",
346
362
  source: "json",
347
- codec: {
348
- mode: "strict",
349
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceId`,
350
- schema: deviceIdSchema
351
- }
363
+ codec: strictCodec(`${REPORT_REMOTE_PACKAGE}#DeviceId`, deviceIdSchema)
352
364
  }, {
353
365
  name: "scopes",
354
366
  wire: "scopes",
355
367
  source: "json",
356
- codec: {
357
- mode: "strict",
358
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceScopes`,
359
- schema: scopesSchema
360
- }
368
+ codec: strictCodec(`${REPORT_REMOTE_PACKAGE}#DeviceScopes`, scopesSchema)
361
369
  }],
362
- result: {
363
- mode: "strict",
364
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceScopes`,
365
- schema: scopesSchema
366
- }
370
+ result: strictCodec(`${REPORT_REMOTE_PACKAGE}#DeviceScopes`, scopesSchema)
367
371
  },
368
372
  {
369
373
  id: `${REPORT_REMOTE_PACKAGE}#deeppilot/testRelay`,
@@ -372,11 +376,7 @@ window.__ModuleLoader__.load({
372
376
  method: "testRelay",
373
377
  invocation: { kind: "direct" },
374
378
  parameters: [],
375
- result: {
376
- mode: "strict",
377
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#RelayTestResult`,
378
- schema: relayTestSchema
379
- }
379
+ result: strictCodec(`${REPORT_REMOTE_PACKAGE}#RelayTestResult`, relayTestSchema)
380
380
  },
381
381
  {
382
382
  id: `${REPORT_REMOTE_PACKAGE}#deeppilot/testPush`,
@@ -385,11 +385,7 @@ window.__ModuleLoader__.load({
385
385
  method: "testPush",
386
386
  invocation: { kind: "direct" },
387
387
  parameters: [],
388
- result: {
389
- mode: "strict",
390
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#PushTestResult`,
391
- schema: pushTestSchema
392
- }
388
+ result: strictCodec(`${REPORT_REMOTE_PACKAGE}#PushTestResult`, pushTestSchema)
393
389
  }
394
390
  ]
395
391
  };
@@ -436,15 +432,17 @@ window.__ModuleLoader__.load({
436
432
  "master.on": "已开启:接受手机连接。",
437
433
  "master.off": "已关闭:不接受手机连接。",
438
434
  "local.title": "局域网连接",
439
- "local.on": "已开启:手机可通过独立端口 {port} 连接。",
435
+ "local.on": "已开启:手机可通过独立端口 {port} 以 TLS 加密连接。",
440
436
  "local.off": "已关闭:不接受局域网连接。",
437
+ "local.tlsFingerprint": "证书指纹",
438
+ "local.tlsRegenerated": "局域网 TLS 身份已重新生成:此前通过内网配对的设备需要重新扫码配对。",
441
439
  "local.phaseDisabled": "局域网连接未启用",
442
440
  "local.phaseStarting": "局域网连接正在启动",
443
441
  "local.phaseOnline": "局域网连接已就绪",
444
442
  "local.phaseError": "局域网连接失败",
445
443
  "local.phaseStopped": "局域网连接已停止",
446
444
  "local.portTitle": "局域网端口",
447
- "local.portDescription": "默认 3098,范围 1024–65535。该端口只提供 DeepPilot 的三个连接端点,不会开放完整 DSH。",
445
+ "local.portDescription": "默认 3098,范围 1024–65535。该端口只以 TLS 提供 DeepPilot 的三个连接端点,不会开放完整 DSH。",
448
446
  "local.portApplied": "局域网端口已保存,监听器正在重新加载。",
449
447
  "local.portInvalid": "请输入 1024–65535 之间的整数。",
450
448
  "local.portFailed": "局域网端口保存失败:",
@@ -498,14 +496,13 @@ window.__ModuleLoader__.load({
498
496
  "pair.qrShow": "显示二维码",
499
497
  "pair.qrHide": "隐藏二维码",
500
498
  "pair.qrGenerating": "生成中…",
501
- "pair.qrAutoHidden": "配对二维码已自动隐藏",
499
+ "pair.qrExpired": "配对码已过期,请重新显示二维码",
502
500
  "pair.qrFailed": "二维码生成失败:",
503
- "pair.codeLabel": "配对码(模拟器可手动输入)",
504
- "pair.codeCopyDone": "配对码已复制",
505
- "pair.codeCopyFailed": "配对码复制失败:",
506
- "pair.publicCopyDone": "连接地址已复制",
507
- "pair.publicCopyFailed": "复制失败:",
508
- "pair.qrHint": "二维码包含{kind}地址和一次性配对码;配对码 5 分钟后失效,二维码将在 60 秒后自动隐藏。",
501
+ "pair.qrRetry": "重试",
502
+ "pair.infoLabel": "配对信息(复制一次,粘贴到 App 即可)",
503
+ "pair.infoCopyDone": "配对信息已复制",
504
+ "pair.infoCopyFailed": "配对信息复制失败:",
505
+ "pair.qrHint": "二维码与下面的「配对信息」内容相同:包含{kind}地址、一次性配对码、证书指纹(内网时)与主机标识。在局域网与公网之间切换会立即生成对应的新配对信息,配对码 5 分钟后失效。",
509
506
  "advanced.summary": "高级信息",
510
507
  "advanced.protocolVersion": "协议版本",
511
508
  "advanced.serverVersion": "服务器版本",
@@ -618,15 +615,17 @@ window.__ModuleLoader__.load({
618
615
  "master.on": "On: accepting phone connections.",
619
616
  "master.off": "Off: not accepting phone connections.",
620
617
  "local.title": "LAN connection",
621
- "local.on": "On: phones can connect through the independent port {port}.",
618
+ "local.on": "On: phones connect over TLS through the independent port {port}.",
622
619
  "local.off": "Off: LAN connections are not accepted.",
620
+ "local.tlsFingerprint": "Certificate fingerprint",
621
+ "local.tlsRegenerated": "The LAN TLS identity was regenerated: devices previously paired over LAN must scan a new pairing QR.",
623
622
  "local.phaseDisabled": "LAN connection disabled",
624
623
  "local.phaseStarting": "LAN connection starting",
625
624
  "local.phaseOnline": "LAN connection ready",
626
625
  "local.phaseError": "LAN connection failed",
627
626
  "local.phaseStopped": "LAN connection stopped",
628
627
  "local.portTitle": "LAN port",
629
- "local.portDescription": "Default 3098; range 1024–65535. This port exposes only the three DeepPilot endpoints, never the full DSH server.",
628
+ "local.portDescription": "Default 3098; range 1024–65535. This port serves only the three DeepPilot endpoints over TLS, never the full DSH server.",
630
629
  "local.portApplied": "LAN port saved; the listener is reloading.",
631
630
  "local.portInvalid": "Enter an integer from 1024 to 65535.",
632
631
  "local.portFailed": "Failed to save LAN port: ",
@@ -680,14 +679,13 @@ window.__ModuleLoader__.load({
680
679
  "pair.qrShow": "Show QR code",
681
680
  "pair.qrHide": "Hide QR code",
682
681
  "pair.qrGenerating": "Generating…",
683
- "pair.qrAutoHidden": "Pairing QR auto-hidden",
682
+ "pair.qrExpired": "Pairing code expired — show the QR code again",
684
683
  "pair.qrFailed": "QR generation failed: ",
685
- "pair.codeLabel": "Pairing code (enter manually in Simulator)",
686
- "pair.codeCopyDone": "Pairing code copied",
687
- "pair.codeCopyFailed": "Failed to copy pairing code: ",
688
- "pair.publicCopyDone": "Connection address copied",
689
- "pair.publicCopyFailed": "Copy failed: ",
690
- "pair.qrHint": "The QR contains a {kind} address and a single-use pairing code. The code expires after 5 minutes; the QR auto-hides after 60 seconds.",
684
+ "pair.qrRetry": "Retry",
685
+ "pair.infoLabel": "Pairing info (copy once, paste it in the app)",
686
+ "pair.infoCopyDone": "Pairing info copied",
687
+ "pair.infoCopyFailed": "Failed to copy pairing info: ",
688
+ "pair.qrHint": "The QR code and the pairing info below carry the same {kind} address, single-use code, certificate fingerprint (for LAN) and host identity. Switching between LAN and public issues a matching new string immediately; the code expires after 5 minutes.",
691
689
  "advanced.summary": "Advanced info",
692
690
  "advanced.protocolVersion": "Protocol version",
693
691
  "advanced.serverVersion": "Server version",
@@ -938,11 +936,29 @@ window.__ModuleLoader__.load({
938
936
  ".pbb-versionFooter a{color:var(--dsw-alias-state-success-primary,#22a06b);text-decoration:none}",
939
937
  ".pbb-versionFooter a:hover{text-decoration:underline;text-underline-offset:2px}"
940
938
  ].join("\n");
939
+ /**
940
+ * Client bundle id, identical to the `window.__ModuleLoader__.load({ id })`
941
+ * identity emitted by tsdown.config.ts. DSH's module system owns style
942
+ * teardown by package id, so this tag must carry the same value.
943
+ */
944
+ const PLUGIN_ID = "dsh-deeppilot";
945
+ /**
946
+ * Claim this plugin's page stylesheet against DSH's style ownership.
947
+ *
948
+ * `ClientModuleSystem` tags untagged `<style>` elements only during factory
949
+ * materialization; this sheet is injected from `apply()`, which runs after
950
+ * that point, so it would never be attributed to us. Stamping `data-plugin`
951
+ * ourselves lets the module system's `removeOwnedStyles(id)` delete the sheet
952
+ * when the plugin is unloaded at runtime — the Plugin Manager and the client
953
+ * entry reconciler both support live removal from 0.1.6-alpha.2 on, and an
954
+ * orphaned sheet would otherwise keep styling a page we no longer own.
955
+ */
941
956
  function injectCss() {
942
957
  if (typeof document === "undefined") return;
943
958
  const id = "dsh-deeppilot/page.css";
944
959
  if (document.querySelector("style[data-plugin-css=\"" + id + "\"]") !== null) return;
945
960
  const tag = document.createElement("style");
961
+ tag.setAttribute("data-plugin", PLUGIN_ID);
946
962
  tag.setAttribute("data-plugin-css", id);
947
963
  tag.textContent = CSS;
948
964
  document.head.appendChild(tag);
@@ -3351,18 +3367,25 @@ window.__ModuleLoader__.load({
3351
3367
  return SvgRenderer.render(data, opts);
3352
3368
  });
3353
3369
  })))(), 1);
3354
- const PAIRING_QR_TYPE = "deeppilot-pairing";
3370
+ /** `sha256:` + base64url(SHA-256(SubjectPublicKeyInfo DER)) of the LAN certificate. */
3371
+ const LAN_TLS_FINGERPRINT_PATTERN = /^sha256:[A-Za-z0-9_-]{43}$/;
3372
+ function isLanTlsFingerprint(value) {
3373
+ return typeof value === "string" && LAN_TLS_FINGERPRINT_PATTERN.test(value);
3374
+ }
3355
3375
  /**
3356
3376
  * Return every explicit DeepPilot transport target. LAN comes first because
3357
3377
  * it is private and lower latency; an online Funnel remains available as a
3358
- * separate choice instead of silently replacing the local address.
3378
+ * separate choice instead of silently replacing the local address. A LAN
3379
+ * listener without a certificate pin is never offered: the app could not
3380
+ * trust it.
3359
3381
  */
3360
3382
  function selectPairingTargets(local, remote) {
3361
3383
  const targets = [];
3362
- if (local.phase === "online") {
3384
+ if (local.phase === "online" && isLanTlsFingerprint(local.tlsFingerprint)) {
3363
3385
  for (const host of local.endpoints) if (validTargetHost(host)) targets.push({
3364
3386
  host,
3365
- kind: "lan"
3387
+ kind: "lan",
3388
+ tlsFingerprint: local.tlsFingerprint
3366
3389
  });
3367
3390
  }
3368
3391
  if (remote.phase === "online" && remote.publicURL && validTargetHost(remote.publicURL)) targets.push({
@@ -3372,40 +3395,115 @@ window.__ModuleLoader__.load({
3372
3395
  const seen = /* @__PURE__ */ new Set();
3373
3396
  return targets.filter(({ host }) => !seen.has(host) && seen.add(host));
3374
3397
  }
3398
+ /** Only encrypted transports are ever offered to the app. */
3399
+ const TARGET_PROTOCOLS = ["https:", "wss:"];
3375
3400
  function validTargetHost(value) {
3376
3401
  try {
3377
3402
  const parsed = new URL(value);
3378
- return [
3379
- "http:",
3380
- "https:",
3381
- "ws:",
3382
- "wss:"
3383
- ].includes(parsed.protocol) && parsed.hostname !== "" && parsed.username === "" && parsed.password === "";
3403
+ return TARGET_PROTOCOLS.includes(parsed.protocol) && parsed.hostname !== "" && parsed.username === "" && parsed.password === "";
3384
3404
  } catch {
3385
3405
  return false;
3386
3406
  }
3387
3407
  }
3388
- /** Encode a short-lived, single-use pairing grant without URL credentials. */
3389
- function encodePairingQRPayload(host, grant) {
3390
- const normalizedHost = host.trim();
3391
- const parsed = new URL(normalizedHost);
3392
- if (![
3393
- "http:",
3394
- "https:",
3395
- "ws:",
3396
- "wss:"
3397
- ].includes(parsed.protocol) || parsed.hostname === "" || parsed.username !== "" || parsed.password !== "") throw new TypeError("pairing QR requires a valid HTTP(S)/WS(S) host");
3408
+ /**
3409
+ * One copyable string carrying everything the app needs to pair: host, the
3410
+ * single-use code, the LAN certificate pin and the host audience.
3411
+ *
3412
+ * Users copy this once instead of moving three separate values, and the app
3413
+ * accepts it from the paste field, a deep link and (from this version on) the
3414
+ * QR code. Field names are short because the whole string ends up in a QR code
3415
+ * and in a text field: `h` host, `c` code, `f` fingerprint, `a` audience,
3416
+ * `e` expiry in milliseconds.
3417
+ */
3418
+ function encodePairingLink(host, grant, tlsFingerprint) {
3419
+ assertPairingInput(host, grant, tlsFingerprint);
3420
+ const params = new URLSearchParams();
3421
+ params.set("h", host.trim());
3422
+ params.set("c", grant.code.trim());
3423
+ if (tlsFingerprint !== void 0) params.set("f", tlsFingerprint);
3424
+ params.set("a", grant.audience);
3425
+ params.set("e", String(grant.expiresAt));
3426
+ return `${PAIRING_LINK_PREFIX}?${params.toString()}`;
3427
+ }
3428
+ /** `deeppilot://pair` — the deep-link route the iOS app parses. */
3429
+ const PAIRING_LINK_PREFIX = "deeppilot://pair";
3430
+ function assertPairingInput(host, grant, tlsFingerprint) {
3431
+ if (!validTargetHost(host.trim())) throw new TypeError("pairing QR requires a valid HTTPS/WSS host");
3398
3432
  if (grant.code.trim().length < 32 || !Number.isInteger(grant.expiresAt) || grant.expiresAt <= Date.now()) throw new TypeError("pairing grant is invalid or expired");
3399
3433
  if (!grant.audience.startsWith("deeppilot:")) throw new TypeError("pairing audience is invalid");
3400
- const payload = {
3401
- v: 2,
3402
- type: PAIRING_QR_TYPE,
3403
- host: normalizedHost,
3404
- code: grant.code.trim(),
3405
- expiresAt: grant.expiresAt,
3406
- audience: grant.audience
3434
+ if (tlsFingerprint !== void 0 && !isLanTlsFingerprint(tlsFingerprint)) throw new TypeError("pairing TLS fingerprint is invalid");
3435
+ }
3436
+ //#endregion
3437
+ //#region src/client/pairing-panel.ts
3438
+ const initialPairingPanelState = {
3439
+ target: null,
3440
+ busy: false,
3441
+ grant: null,
3442
+ link: null,
3443
+ qrDataURL: null,
3444
+ message: "",
3445
+ notice: "",
3446
+ requestId: 0
3447
+ };
3448
+ function pairingPanelReduce(state, action) {
3449
+ switch (action.type) {
3450
+ case "show": return {
3451
+ target: action.target,
3452
+ busy: true,
3453
+ grant: null,
3454
+ link: null,
3455
+ qrDataURL: null,
3456
+ message: "",
3457
+ notice: "",
3458
+ requestId: action.requestId
3459
+ };
3460
+ case "issued":
3461
+ if (action.requestId !== state.requestId) return state;
3462
+ return {
3463
+ ...state,
3464
+ busy: false,
3465
+ grant: action.grant,
3466
+ link: action.link,
3467
+ qrDataURL: action.qrDataURL
3468
+ };
3469
+ case "failed":
3470
+ if (action.requestId !== state.requestId) return state;
3471
+ return {
3472
+ ...state,
3473
+ busy: false,
3474
+ grant: null,
3475
+ link: null,
3476
+ qrDataURL: null,
3477
+ message: action.message
3478
+ };
3479
+ case "notice": return {
3480
+ ...state,
3481
+ notice: action.message
3482
+ };
3483
+ case "dismissNotice": return state.notice === "" ? state : {
3484
+ ...state,
3485
+ notice: ""
3486
+ };
3487
+ case "close": return {
3488
+ ...initialPairingPanelState,
3489
+ requestId: state.requestId + 1,
3490
+ message: action.message ?? ""
3491
+ };
3492
+ }
3493
+ }
3494
+ function pairingPanelView(state) {
3495
+ const open = state.target !== null;
3496
+ return {
3497
+ open,
3498
+ busy: state.busy,
3499
+ failed: open && !state.busy && state.grant === null,
3500
+ action: state.busy ? "pair.qrGenerating" : state.target === null ? "pair.qrShow" : "pair.qrHide",
3501
+ target: state.target,
3502
+ link: state.link,
3503
+ qrDataURL: state.qrDataURL,
3504
+ message: state.message,
3505
+ notice: state.notice
3407
3506
  };
3408
- return JSON.stringify(payload);
3409
3507
  }
3410
3508
  function normalizeFunnelConnectionLimit(value) {
3411
3509
  return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 16 ? value : 8;
@@ -3508,16 +3606,13 @@ window.__ModuleLoader__.load({
3508
3606
  };
3509
3607
  /** Slot component: hooks come from the slot renderer, named use<Key>. */
3510
3608
  function DeepPilotSettingsPage(props) {
3511
- const [addressMessage, setAddressMessage] = (0, react.useState)("");
3512
3609
  const [remoteLimitDraft, setRemoteLimitDraft] = (0, react.useState)(String(8));
3513
3610
  const [remoteLimitMessage, setRemoteLimitMessage] = (0, react.useState)("");
3514
3611
  const [localPortDraft, setLocalPortDraft] = (0, react.useState)(String(DEFAULT_LOCAL_PORT));
3515
3612
  const [localPortMessage, setLocalPortMessage] = (0, react.useState)("");
3516
3613
  const [selectedPairingHost, setSelectedPairingHost] = (0, react.useState)(null);
3517
- const [qrDataURL, setQRDataURL] = (0, react.useState)(null);
3518
- const [pairingGrant, setPairingGrant] = (0, react.useState)(null);
3519
- const [qrBusy, setQRBusy] = (0, react.useState)(false);
3520
- const [qrMessage, setQRMessage] = (0, react.useState)("");
3614
+ const [pairingPanel, dispatchPairingPanel] = (0, react.useReducer)(pairingPanelReduce, initialPairingPanelState);
3615
+ const qrRequestId = (0, react.useRef)(0);
3521
3616
  const [relayTestBusy, setRelayTestBusy] = (0, react.useState)(false);
3522
3617
  const [relayTestResult, setRelayTestResult] = (0, react.useState)(null);
3523
3618
  const [relayTestError, setRelayTestError] = (0, react.useState)("");
@@ -3564,11 +3659,6 @@ window.__ModuleLoader__.load({
3564
3659
  setRelayTestBusy(false);
3565
3660
  });
3566
3661
  };
3567
- (0, react.useEffect)(() => {
3568
- if (!addressMessage) return;
3569
- const timer = globalThis.setTimeout(() => setAddressMessage(""), 2500);
3570
- return () => globalThis.clearTimeout(timer);
3571
- }, [addressMessage]);
3572
3662
  (0, react.useEffect)(() => {
3573
3663
  if (!remoteLimitMessage) return;
3574
3664
  const timer = globalThis.setTimeout(() => setRemoteLimitMessage(""), 4e3);
@@ -3580,19 +3670,21 @@ window.__ModuleLoader__.load({
3580
3670
  return () => globalThis.clearTimeout(timer);
3581
3671
  }, [localPortMessage]);
3582
3672
  (0, react.useEffect)(() => {
3583
- if (qrDataURL === null) return;
3673
+ if (pairingPanel.grant === null) return;
3674
+ const remaining = Math.max(0, pairingPanel.grant.expiresAt - Date.now());
3584
3675
  const timer = globalThis.setTimeout(() => {
3585
- setQRDataURL(null);
3586
- setPairingGrant(null);
3587
- setQRMessage(translateWith(props.t, "pair.qrAutoHidden"));
3588
- }, 1728e5);
3676
+ dispatchPairingPanel({
3677
+ type: "close",
3678
+ message: translateWith(props.t, "pair.qrExpired")
3679
+ });
3680
+ }, remaining);
3589
3681
  return () => globalThis.clearTimeout(timer);
3590
- }, [qrDataURL]);
3682
+ }, [pairingPanel.grant]);
3591
3683
  (0, react.useEffect)(() => {
3592
- if (!qrMessage) return;
3593
- const timer = globalThis.setTimeout(() => setQRMessage(""), 2500);
3684
+ if (!pairingPanel.notice) return;
3685
+ const timer = globalThis.setTimeout(() => dispatchPairingPanel({ type: "dismissNotice" }), 2500);
3594
3686
  return () => globalThis.clearTimeout(timer);
3595
- }, [qrMessage]);
3687
+ }, [pairingPanel.notice]);
3596
3688
  const diag = [];
3597
3689
  let report = null;
3598
3690
  let enabled = true;
@@ -3694,50 +3786,75 @@ window.__ModuleLoader__.load({
3694
3786
  };
3695
3787
  const pairingTargets = report === null ? [] : selectPairingTargets(report.local, report.remote);
3696
3788
  const pairingTarget = pairingTargets.find(({ host }) => host === selectedPairingHost) ?? pairingTargets[0] ?? null;
3697
- (0, react.useEffect)(() => {
3698
- setQRDataURL(null);
3699
- setPairingGrant(null);
3700
- }, [pairingTarget?.host]);
3701
- const copyRemoteURL = (url) => {
3702
- setAddressMessage("");
3703
- writeClipboard(props.t, url).then(() => {
3704
- setAddressMessage(translateWith(props.t, "pair.publicCopyDone"));
3705
- }, (error) => {
3706
- setAddressMessage(translateWith(props.t, "pair.publicCopyFailed") + (error instanceof Error ? error.message : String(error)));
3789
+ const panel = pairingPanelView(pairingPanel);
3790
+ /**
3791
+ * Issue a grant and render the panel for one explicit target.
3792
+ *
3793
+ * The target is a parameter rather than component state on purpose: a click
3794
+ * handler must not depend on whether React has already re-rendered with the
3795
+ * newly selected host. A grant is bound to the host it was issued for, so
3796
+ * switching between LAN and public always mints a fresh one.
3797
+ */
3798
+ const showPairingQR = (target) => {
3799
+ if (typeof props.beginPairing !== "function") return;
3800
+ const requestId = qrRequestId.current + 1;
3801
+ qrRequestId.current = requestId;
3802
+ dispatchPairingPanel({
3803
+ type: "show",
3804
+ target,
3805
+ requestId
3707
3806
  });
3708
- };
3709
- const showPairingQR = () => {
3710
- if (typeof props.beginPairing !== "function" || pairingTarget === null) return;
3711
- setQRBusy(true);
3712
- setQRMessage("");
3713
- setQRDataURL(null);
3714
- setPairingGrant(null);
3715
- props.beginPairing().then(async (grant) => ({
3716
- grant,
3717
- svg: await import_browser.toString(encodePairingQRPayload(pairingTarget.host, grant), {
3718
- type: "svg",
3719
- errorCorrectionLevel: "M",
3720
- margin: 2,
3721
- width: 512
3722
- })
3723
- })).then(({ grant, svg }) => {
3724
- setPairingGrant(grant);
3725
- setQRDataURL("data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg));
3807
+ props.beginPairing().then(async (grant) => {
3808
+ const link = encodePairingLink(target.host, grant, target.tlsFingerprint);
3809
+ return {
3810
+ grant,
3811
+ link,
3812
+ svg: await import_browser.toString(link, {
3813
+ type: "svg",
3814
+ errorCorrectionLevel: "M",
3815
+ margin: 2,
3816
+ width: 512
3817
+ })
3818
+ };
3819
+ }).then(({ grant, link, svg }) => {
3820
+ if (qrRequestId.current !== requestId) return;
3821
+ dispatchPairingPanel({
3822
+ type: "issued",
3823
+ requestId,
3824
+ grant,
3825
+ link,
3826
+ qrDataURL: "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg)
3827
+ });
3726
3828
  }, (error) => {
3727
- setQRMessage(translateWith(props.t, "pair.qrFailed") + (error instanceof Error ? error.message : String(error)));
3728
- }).finally(() => setQRBusy(false));
3829
+ if (qrRequestId.current !== requestId) return;
3830
+ dispatchPairingPanel({
3831
+ type: "failed",
3832
+ requestId,
3833
+ message: translateWith(props.t, "pair.qrFailed") + (error instanceof Error ? error.message : String(error))
3834
+ });
3835
+ });
3729
3836
  };
3730
3837
  const hidePairingQR = () => {
3731
- setQRDataURL(null);
3732
- setPairingGrant(null);
3838
+ qrRequestId.current += 1;
3839
+ dispatchPairingPanel({ type: "close" });
3733
3840
  };
3734
- const copyPairingCode = () => {
3735
- if (pairingGrant === null) return;
3736
- setQRMessage("");
3737
- writeClipboard(props.t, pairingGrant.code).then(() => {
3738
- setQRMessage(translateWith(props.t, "pair.codeCopyDone"));
3841
+ const copyPairingInfo = () => {
3842
+ const link = pairingPanel.link;
3843
+ if (link === null) return;
3844
+ dispatchPairingPanel({
3845
+ type: "notice",
3846
+ message: ""
3847
+ });
3848
+ writeClipboard(props.t, link).then(() => {
3849
+ dispatchPairingPanel({
3850
+ type: "notice",
3851
+ message: translateWith(props.t, "pair.infoCopyDone")
3852
+ });
3739
3853
  }, (error) => {
3740
- setQRMessage(translateWith(props.t, "pair.codeCopyFailed") + (error instanceof Error ? error.message : String(error)));
3854
+ dispatchPairingPanel({
3855
+ type: "notice",
3856
+ message: translateWith(props.t, "pair.infoCopyFailed") + (error instanceof Error ? error.message : String(error))
3857
+ });
3741
3858
  });
3742
3859
  };
3743
3860
  const primaryRows = [];
@@ -3805,7 +3922,7 @@ window.__ModuleLoader__.load({
3805
3922
  role: "img",
3806
3923
  "aria-label": report !== null ? translateWith(props.t, LOCAL_PHASE_META[report.local.phase].labelKey) : translateWith(props.t, "phase.unknown"),
3807
3924
  title: report !== null ? translateWith(props.t, LOCAL_PHASE_META[report.local.phase].labelKey) : void 0
3808
- }), translateWith(props.t, "local.title")), (0, react.createElement)("span", { className: "pbb-switchDesc" }, localSwitchReady ? localEnabled ? translateWith(props.t, "local.on", { port: localPort }) : translateWith(props.t, "local.off") : translateWith(props.t, "master.loading")), report !== null && report.local.message && report.local.phase === "error" ? (0, react.createElement)("p", { className: "pbb-diag pbb-diagBad" }, report.local.message) : null), (0, react.createElement)("button", {
3925
+ }), translateWith(props.t, "local.title")), (0, react.createElement)("span", { className: "pbb-switchDesc" }, localSwitchReady ? localEnabled ? translateWith(props.t, "local.on", { port: localPort }) : translateWith(props.t, "local.off") : translateWith(props.t, "master.loading")), report !== null && report.local.message && report.local.phase === "error" ? (0, react.createElement)("p", { className: "pbb-diag pbb-diagBad" }, report.local.message) : null, report !== null && report.local.tlsIdentityRegenerated === true ? (0, react.createElement)("p", { className: "pbb-diag pbb-diagBad" }, translateWith(props.t, "local.tlsRegenerated")) : null, report !== null && report.local.phase === "online" && report.local.tlsFingerprint ? (0, react.createElement)("p", { className: "pbb-diag" }, translateWith(props.t, "local.tlsFingerprint"), " ", (0, react.createElement)("code", { className: "pbb-token" }, report.local.tlsFingerprint)) : null), (0, react.createElement)("button", {
3809
3926
  type: "button",
3810
3927
  role: "switch",
3811
3928
  "aria-checked": localEnabled,
@@ -3895,32 +4012,36 @@ window.__ModuleLoader__.load({
3895
4012
  }, translateWith(props.t, "help.funnelDocs"))))))), report === null ? null : (0, react.createElement)("div", { className: "pbb-card" }, (0, react.createElement)("div", { className: "pbb-field" }, (0, react.createElement)("div", { className: "pbb-row" }, (0, react.createElement)("span", { className: "pbb-label" }, translateWith(props.t, "pair.qrPanelTitle")), pairingTarget === null ? (0, react.createElement)("span", { className: "pbb-value pbb-bad" }, translateWith(props.t, "pair.noAddress")) : (0, react.createElement)("span", { className: "pbb-tokenActions" }, (0, react.createElement)("span", { className: "pbb-badge" }, pairingTarget.kind === "public" ? translateWith(props.t, "pair.kind.public") : translateWith(props.t, "pair.kind.lan")), (0, react.createElement)("button", {
3896
4013
  type: "button",
3897
4014
  className: "pbb-action",
3898
- disabled: qrBusy || !report.pairingReady,
4015
+ disabled: panel.busy || !report.pairingReady,
3899
4016
  onClick: () => {
3900
- if (qrDataURL === null) showPairingQR();
3901
- else hidePairingQR();
4017
+ if (panel.open) hidePairingQR();
4018
+ else showPairingQR(pairingTarget);
3902
4019
  }
3903
- }, qrBusy ? translateWith(props.t, "pair.qrGenerating") : qrDataURL === null ? translateWith(props.t, "pair.qrShow") : translateWith(props.t, "pair.qrHide")))), pairingTarget === null ? (0, react.createElement)("p", { className: "pbb-diag pbb-diagBad" }, translateWith(props.t, "pair.noAddressHelp")) : null, pairingTargets.length < 2 ? null : (0, react.createElement)("div", { className: "pbb-targetList" }, pairingTargets.map((target) => (0, react.createElement)("button", {
4020
+ }, translateWith(props.t, panel.action)))), pairingTarget === null ? (0, react.createElement)("p", { className: "pbb-diag pbb-diagBad" }, translateWith(props.t, "pair.noAddressHelp")) : null, pairingTargets.length < 2 ? null : (0, react.createElement)("div", { className: "pbb-targetList" }, pairingTargets.map((target) => (0, react.createElement)("button", {
3904
4021
  type: "button",
3905
4022
  key: target.host,
3906
4023
  className: "pbb-action" + (target.host === pairingTarget?.host ? " pbb-actionSelected" : ""),
4024
+ disabled: panel.busy,
3907
4025
  onClick: () => {
3908
4026
  setSelectedPairingHost(target.host);
3909
- hidePairingQR();
4027
+ if (panel.open) showPairingQR(target);
4028
+ else hidePairingQR();
3910
4029
  }
3911
- }, (target.kind === "public" ? translateWith(props.t, "pair.kind.public") : translateWith(props.t, "pair.kind.lan")) + " · " + target.host))), qrMessage ? (0, react.createElement)("p", { className: "pbb-diag" }, qrMessage) : null, pairingTarget === null || qrDataURL === null || pairingGrant === null ? null : (0, react.createElement)("div", { className: "pbb-qrPanel" }, (0, react.createElement)("img", {
3912
- className: "pbb-qrImage",
3913
- src: qrDataURL,
3914
- alt: translateWith(props.t, "pair.qrAlt")
3915
- }), (0, react.createElement)("div", { className: "pbb-pairCodeBlock" }, (0, react.createElement)("span", { className: "pbb-pairCodeLabel" }, translateWith(props.t, "pair.codeLabel")), (0, react.createElement)("div", { className: "pbb-pairCodeRow" }, (0, react.createElement)("code", { className: "pbb-pairCode" }, pairingGrant.code), (0, react.createElement)("button", {
4030
+ }, (target.kind === "public" ? translateWith(props.t, "pair.kind.public") : translateWith(props.t, "pair.kind.lan")) + " · " + target.host))), panel.message ? (0, react.createElement)("p", { className: "pbb-diag pbb-diagBad" }, panel.message) : null, panel.notice ? (0, react.createElement)("p", { className: "pbb-diag" }, panel.notice) : null, panel.failed && pairingTarget !== null ? (0, react.createElement)("div", { className: "pbb-rowAction" }, (0, react.createElement)("button", {
3916
4031
  type: "button",
3917
4032
  className: "pbb-action",
3918
- onClick: copyPairingCode
3919
- }, translateWith(props.t, "panel.tokenAction.copy")))), (0, react.createElement)("div", { className: "pbb-row" }, (0, react.createElement)("code", { className: "pbb-token" }, pairingTarget.host), (0, react.createElement)("button", {
4033
+ disabled: panel.busy || !report.pairingReady,
4034
+ onClick: () => showPairingQR(pairingTarget)
4035
+ }, translateWith(props.t, "pair.qrRetry"))) : null, panel.open ? (0, react.createElement)("div", { className: "pbb-qrPanel" }, panel.qrDataURL === null ? (0, react.createElement)("p", { className: "pbb-diag" }, translateWith(props.t, "pair.qrGenerating")) : (0, react.createElement)("img", {
4036
+ className: "pbb-qrImage",
4037
+ src: panel.qrDataURL,
4038
+ alt: translateWith(props.t, "pair.qrAlt")
4039
+ }), panel.link === null ? null : (0, react.createElement)("div", { className: "pbb-pairCodeBlock" }, (0, react.createElement)("span", { className: "pbb-pairCodeLabel" }, translateWith(props.t, "pair.infoLabel")), (0, react.createElement)("div", { className: "pbb-pairCodeRow" }, (0, react.createElement)("code", { className: "pbb-pairCode" }, panel.link), (0, react.createElement)("button", {
3920
4040
  type: "button",
3921
4041
  className: "pbb-action",
3922
- onClick: () => copyRemoteURL(pairingTarget.host)
3923
- }, translateWith(props.t, "panel.tokenAction.copy"))), addressMessage ? (0, react.createElement)("p", { className: "pbb-diag" }, addressMessage) : null, (0, react.createElement)("p", { className: "pbb-qrHint" }, translateWith(props.t, "pair.qrHint", { kind: pairingTarget.kind === "public" ? translateWith(props.t, "pair.kind.public") : translateWith(props.t, "pair.kind.lan") }))))), (0, react.createElement)("div", { className: "pbb-card" }, (0, react.createElement)("div", { className: "pbb-field" }, primaryRows, advancedRows.length > 0 ? (0, react.createElement)("details", { className: "pbb-help" }, (0, react.createElement)("summary", null, translateWith(props.t, "advanced.summary")), (0, react.createElement)("div", { className: "pbb-helpBody" }, advancedRows)) : null)), (0, react.createElement)("div", { className: "pbb-card" }, (0, react.createElement)("div", { className: "pbb-field" }, (0, react.createElement)("div", { className: "pbb-row" }, (0, react.createElement)("span", { className: "pbb-label" }, translateWith(props.t, "push.relayTitle")), (0, react.createElement)("span", { className: "pbb-tokenActions" }, (0, react.createElement)("button", {
4042
+ onClick: copyPairingInfo,
4043
+ "data-testid": "pairingInfoCopy"
4044
+ }, translateWith(props.t, "panel.tokenAction.copy")))), (0, react.createElement)("p", { className: "pbb-qrHint" }, translateWith(props.t, "pair.qrHint", { kind: panel.target?.kind === "public" ? translateWith(props.t, "pair.kind.public") : translateWith(props.t, "pair.kind.lan") }))) : null)), (0, react.createElement)("div", { className: "pbb-card" }, (0, react.createElement)("div", { className: "pbb-field" }, primaryRows, advancedRows.length > 0 ? (0, react.createElement)("details", { className: "pbb-help" }, (0, react.createElement)("summary", null, translateWith(props.t, "advanced.summary")), (0, react.createElement)("div", { className: "pbb-helpBody" }, advancedRows)) : null)), (0, react.createElement)("div", { className: "pbb-card" }, (0, react.createElement)("div", { className: "pbb-field" }, (0, react.createElement)("div", { className: "pbb-row" }, (0, react.createElement)("span", { className: "pbb-label" }, translateWith(props.t, "push.relayTitle")), (0, react.createElement)("span", { className: "pbb-tokenActions" }, (0, react.createElement)("button", {
3924
4045
  type: "button",
3925
4046
  className: "pbb-action",
3926
4047
  disabled: relayTestBusy,