dsh-deeppilot 0.7.0 → 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/COMPATIBILITY.md CHANGED
@@ -7,7 +7,7 @@ This file separates tested evidence from intended behavior. Passing unit tests d
7
7
  | Component | Baseline | Evidence |
8
8
  |---|---|---|
9
9
  | Node.js | 22 or newer | package engine and CI |
10
- | DSH CLI and Host API | the `0.1.5-rc.*` and `0.1.6-*`/`0.1.6` lines, `0.1.5-rc.1` minimum, for plugin `0.7.x` | plugin source typecheck, unit suite (240 tests) and `tsdown` build run against both the `0.1.5-rc.2` and the `0.1.6-alpha.1` package families; bridge and `/phone` protocol tests |
10
+ | DSH CLI and Host API | the `0.1.5-rc.*` and `0.1.6-*`/`0.1.6` lines, `0.1.5-rc.1` minimum, for plugin `0.7.x` | plugin source typecheck, unit suite (265 tests) and `tsdown` build run against the `0.1.5-rc.2`, `0.1.6-alpha.1` and `0.1.6-alpha.2` package families; the `deeppilot/report` contribution registered through both the `0.1.6-alpha.1` and the `0.1.6-alpha.2` `TypertRegistry`; bridge and `/phone` protocol tests |
11
11
  | Host OS | macOS, Linux, Windows on packaged amd64/arm64 helper targets | helper checksums plus user-confirmed Windows/Linux Funnel launch and connection; local LAN validation remains part of alpha testing |
12
12
  | Remote access | Tailscale Funnel, ports 443/8443/10000 | helper and supervisor tests |
13
13
  | iOS | native DeepPilot client, protocol v2 | simulator build and v2 pairing/challenge evidence |
@@ -15,6 +15,7 @@ This file separates tested evidence from intended behavior. Passing unit tests d
15
15
  ## Protocol boundary
16
16
 
17
17
  - Protocol v2 is the only supported wire version. Existing protocol-v1 devices must pair again after upgrading.
18
+ - The pairing QR now encodes a `deeppilot://pair` link, and the settings page shows that same string in one copy field. An app build that predates the link only parses the JSON payload, so it must be updated before pairing against this plugin; a link pasted into an older app is rejected instead of being half-applied. The current app still accepts the JSON payload.
18
19
  - Plugin `0.7.x` serves the LAN listener over self-signed TLS only; devices paired over LAN with `0.6.x` or older must pair again so the app receives the certificate fingerprint. The DSH web server no longer carries `/phone` compatibility routes. Funnel pairings are unaffected.
19
20
  - Bearer authentication, URL credentials, and first-frame shared tokens are rejected. A supported client registers a P-256 public key through `/phone/pair` and signs each WebSocket challenge.
20
21
  - Without a compatible embedded helper, the core bridge and trusted-LAN mode can still run; remote Funnel reports `unavailable`.
@@ -36,6 +37,28 @@ This file separates tested evidence from intended behavior. Passing unit tests d
36
37
  from the Cordis context (`sessionController`, `workspaceController`,
37
38
  `directoryPickerController`, `sessionProjections`) and the `session/event`
38
39
  and `api-session/*` events, all of which kept their `0.1.5-rc` shape.
40
+ - DSH `0.1.6-alpha.2` changed the strict codec shape carried by an
41
+ `InvocationDescriptor`. Through `0.1.6-alpha.1` a descriptor published the
42
+ schema value itself and every consumer called `codec.schema.parse(value)`;
43
+ `alpha.2` instead validates a lazy `create()` factory at registration and the
44
+ Gateway parses through `codec.create().parse(value)`. A codec carrying only
45
+ `schema` is rejected by the `alpha.2` registry
46
+ (`typert: … strict codec has no create() factory`), which aborts plugin
47
+ activation. One `strictCodec()` helper in `src/report-wire.ts` emits both
48
+ keys — the same hand-written, dependency-free codec — so a single published
49
+ package registers and parses on either generation, and
50
+ `tests/report-wire.test.ts` pins both access paths.
51
+ The other `alpha.2` Typert change — a contributed schema's materialized
52
+ `schema` becoming a lazy `create()` factory — does not apply here: this
53
+ Remote contributes no schemas, which the same test asserts.
54
+ - DSH `0.1.6-alpha.2` made runtime plugin removal real, through both the
55
+ Plugin Manager and the client entry reconciler. Every long-lived resource is
56
+ registered through `ctx.effect`, and the settings-page stylesheet stamps
57
+ `data-plugin="dsh-deeppilot"` so the module system's `removeOwnedStyles()`
58
+ can delete it; the module system only auto-claims styles present during
59
+ factory materialization, while this sheet is injected from `apply()`.
60
+ Without that stamp, disabling the plugin would leave its sheet styling the
61
+ page.
39
62
  - The compatibility facade converts the
40
63
  current Session controller's raw event arrays into the stable wrapped
41
64
  history entries consumed by the phone bridge. Sessions persisted by earlier
package/README.md CHANGED
@@ -58,8 +58,12 @@ dsh web
58
58
  ```
59
59
 
60
60
  After DSH restarts, open **Settings → DeepPilot**, enable the connection, show
61
- the pairing QR code, and scan it in the DeepPilot app. The same panel also
62
- shows a copyable pairing code for Simulator or manual entry.
61
+ the pairing QR code, and scan it in the DeepPilot app. Below the QR code the same
62
+ panel shows one copyable piece of pairing info (address, single-use code and
63
+ certificate fingerprint) that can be pasted into the app in one step — useful for
64
+ Simulator or when the camera is unavailable. Switching between the LAN and
65
+ public address issues a matching new QR code and string immediately, with the
66
+ panel staying open.
63
67
 
64
68
  LAN access is enabled by default on the plugin's independent TCP port `3098`
65
69
  and is TLS-only. The plugin generates a self-signed certificate on first start
package/README.zh-CN.md CHANGED
@@ -54,7 +54,9 @@ dsh web
54
54
  ```
55
55
 
56
56
  DSH 重启后,打开 **设置 → DeepPilot**,启用连接并显示配对二维码,然后在
57
- DeepPilot App 中扫码。同一面板也会显示可复制的配对码,供模拟器或手动输入使用。
57
+ DeepPilot App 中扫码。二维码下方同时显示一段可复制的「配对信息」(含地址、一次性配对码
58
+ 与证书指纹),在 App 里粘贴一次即可完成配对,适合模拟器或相机不可用时使用;在「内网 /
59
+ 公网」之间切换会立即为所选地址生成新的二维码与配对信息,面板保持展开。
58
60
 
59
61
  局域网连接默认使用插件独立的 TCP `3098` 端口,且只提供 TLS。插件首次启动时生成
60
62
  自签证书(私钥位于 `$DSH_HOME/deeppilot/lan-tls/`),配对二维码携带其公钥指纹,
package/lib/client.js CHANGED
@@ -286,6 +286,35 @@ window.__ModuleLoader__.load({
286
286
  if (!Array.isArray(value) || value.some((scope) => typeof scope !== "string" || !DEVICE_SCOPES.includes(scope))) reject("scopes");
287
287
  return normalizeDeviceScopes(value);
288
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
+ }
289
318
  const REPORT_REMOTE_CONTRIBUTION = {
290
319
  package: REPORT_REMOTE_PACKAGE,
291
320
  descriptors: [
@@ -296,11 +325,7 @@ window.__ModuleLoader__.load({
296
325
  method: "report",
297
326
  invocation: { kind: "direct" },
298
327
  parameters: [],
299
- result: {
300
- mode: "strict",
301
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeepPilotReport`,
302
- schema: reportSchema
303
- }
328
+ result: strictCodec(`${REPORT_REMOTE_PACKAGE}#DeepPilotReport`, reportSchema)
304
329
  },
305
330
  {
306
331
  id: `${REPORT_REMOTE_PACKAGE}#${BEGIN_PAIRING_ENDPOINT}`,
@@ -309,11 +334,7 @@ window.__ModuleLoader__.load({
309
334
  method: "beginPairing",
310
335
  invocation: { kind: "direct" },
311
336
  parameters: [],
312
- result: {
313
- mode: "strict",
314
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#PairingGrantSnapshot`,
315
- schema: pairingGrantSchema
316
- }
337
+ result: strictCodec(`${REPORT_REMOTE_PACKAGE}#PairingGrantSnapshot`, pairingGrantSchema)
317
338
  },
318
339
  {
319
340
  id: `${REPORT_REMOTE_PACKAGE}#${REVOKE_DEVICE_ENDPOINT}`,
@@ -325,20 +346,9 @@ window.__ModuleLoader__.load({
325
346
  name: "deviceId",
326
347
  wire: "deviceId",
327
348
  source: "json",
328
- codec: {
329
- mode: "strict",
330
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceId`,
331
- schema: deviceIdSchema
332
- }
349
+ codec: strictCodec(`${REPORT_REMOTE_PACKAGE}#DeviceId`, deviceIdSchema)
333
350
  }],
334
- result: {
335
- mode: "strict",
336
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#Boolean`,
337
- schema: { parse(value) {
338
- if (typeof value !== "boolean") reject("boolean");
339
- return value;
340
- } }
341
- }
351
+ result: strictCodec(`${REPORT_REMOTE_PACKAGE}#Boolean`, booleanSchema)
342
352
  },
343
353
  {
344
354
  id: `${REPORT_REMOTE_PACKAGE}#${SET_DEVICE_SCOPES_ENDPOINT}`,
@@ -350,26 +360,14 @@ window.__ModuleLoader__.load({
350
360
  name: "deviceId",
351
361
  wire: "deviceId",
352
362
  source: "json",
353
- codec: {
354
- mode: "strict",
355
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceId`,
356
- schema: deviceIdSchema
357
- }
363
+ codec: strictCodec(`${REPORT_REMOTE_PACKAGE}#DeviceId`, deviceIdSchema)
358
364
  }, {
359
365
  name: "scopes",
360
366
  wire: "scopes",
361
367
  source: "json",
362
- codec: {
363
- mode: "strict",
364
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceScopes`,
365
- schema: scopesSchema
366
- }
368
+ codec: strictCodec(`${REPORT_REMOTE_PACKAGE}#DeviceScopes`, scopesSchema)
367
369
  }],
368
- result: {
369
- mode: "strict",
370
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceScopes`,
371
- schema: scopesSchema
372
- }
370
+ result: strictCodec(`${REPORT_REMOTE_PACKAGE}#DeviceScopes`, scopesSchema)
373
371
  },
374
372
  {
375
373
  id: `${REPORT_REMOTE_PACKAGE}#deeppilot/testRelay`,
@@ -378,11 +376,7 @@ window.__ModuleLoader__.load({
378
376
  method: "testRelay",
379
377
  invocation: { kind: "direct" },
380
378
  parameters: [],
381
- result: {
382
- mode: "strict",
383
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#RelayTestResult`,
384
- schema: relayTestSchema
385
- }
379
+ result: strictCodec(`${REPORT_REMOTE_PACKAGE}#RelayTestResult`, relayTestSchema)
386
380
  },
387
381
  {
388
382
  id: `${REPORT_REMOTE_PACKAGE}#deeppilot/testPush`,
@@ -391,11 +385,7 @@ window.__ModuleLoader__.load({
391
385
  method: "testPush",
392
386
  invocation: { kind: "direct" },
393
387
  parameters: [],
394
- result: {
395
- mode: "strict",
396
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#PushTestResult`,
397
- schema: pushTestSchema
398
- }
388
+ result: strictCodec(`${REPORT_REMOTE_PACKAGE}#PushTestResult`, pushTestSchema)
399
389
  }
400
390
  ]
401
391
  };
@@ -506,14 +496,13 @@ window.__ModuleLoader__.load({
506
496
  "pair.qrShow": "显示二维码",
507
497
  "pair.qrHide": "隐藏二维码",
508
498
  "pair.qrGenerating": "生成中…",
509
- "pair.qrAutoHidden": "配对二维码已自动隐藏",
499
+ "pair.qrExpired": "配对码已过期,请重新显示二维码",
510
500
  "pair.qrFailed": "二维码生成失败:",
511
- "pair.codeLabel": "配对码(模拟器可手动输入)",
512
- "pair.codeCopyDone": "配对码已复制",
513
- "pair.codeCopyFailed": "配对码复制失败:",
514
- "pair.publicCopyDone": "连接地址已复制",
515
- "pair.publicCopyFailed": "复制失败:",
516
- "pair.qrHint": "二维码包含{kind}地址、一次性配对码以及(内网时)证书指纹;配对码 5 分钟后失效,二维码将在 60 秒后自动隐藏。",
501
+ "pair.qrRetry": "重试",
502
+ "pair.infoLabel": "配对信息(复制一次,粘贴到 App 即可)",
503
+ "pair.infoCopyDone": "配对信息已复制",
504
+ "pair.infoCopyFailed": "配对信息复制失败:",
505
+ "pair.qrHint": "二维码与下面的「配对信息」内容相同:包含{kind}地址、一次性配对码、证书指纹(内网时)与主机标识。在局域网与公网之间切换会立即生成对应的新配对信息,配对码 5 分钟后失效。",
517
506
  "advanced.summary": "高级信息",
518
507
  "advanced.protocolVersion": "协议版本",
519
508
  "advanced.serverVersion": "服务器版本",
@@ -690,14 +679,13 @@ window.__ModuleLoader__.load({
690
679
  "pair.qrShow": "Show QR code",
691
680
  "pair.qrHide": "Hide QR code",
692
681
  "pair.qrGenerating": "Generating…",
693
- "pair.qrAutoHidden": "Pairing QR auto-hidden",
682
+ "pair.qrExpired": "Pairing code expired — show the QR code again",
694
683
  "pair.qrFailed": "QR generation failed: ",
695
- "pair.codeLabel": "Pairing code (enter manually in Simulator)",
696
- "pair.codeCopyDone": "Pairing code copied",
697
- "pair.codeCopyFailed": "Failed to copy pairing code: ",
698
- "pair.publicCopyDone": "Connection address copied",
699
- "pair.publicCopyFailed": "Copy failed: ",
700
- "pair.qrHint": "The QR contains a {kind} address, a single-use pairing code and, for LAN, the certificate fingerprint. 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.",
701
689
  "advanced.summary": "Advanced info",
702
690
  "advanced.protocolVersion": "Protocol version",
703
691
  "advanced.serverVersion": "Server version",
@@ -948,11 +936,29 @@ window.__ModuleLoader__.load({
948
936
  ".pbb-versionFooter a{color:var(--dsw-alias-state-success-primary,#22a06b);text-decoration:none}",
949
937
  ".pbb-versionFooter a:hover{text-decoration:underline;text-underline-offset:2px}"
950
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
+ */
951
956
  function injectCss() {
952
957
  if (typeof document === "undefined") return;
953
958
  const id = "dsh-deeppilot/page.css";
954
959
  if (document.querySelector("style[data-plugin-css=\"" + id + "\"]") !== null) return;
955
960
  const tag = document.createElement("style");
961
+ tag.setAttribute("data-plugin", PLUGIN_ID);
956
962
  tag.setAttribute("data-plugin-css", id);
957
963
  tag.textContent = CSS;
958
964
  document.head.appendChild(tag);
@@ -3361,7 +3367,6 @@ window.__ModuleLoader__.load({
3361
3367
  return SvgRenderer.render(data, opts);
3362
3368
  });
3363
3369
  })))(), 1);
3364
- const PAIRING_QR_TYPE = "deeppilot-pairing";
3365
3370
  /** `sha256:` + base64url(SHA-256(SubjectPublicKeyInfo DER)) of the LAN certificate. */
3366
3371
  const LAN_TLS_FINGERPRINT_PATTERN = /^sha256:[A-Za-z0-9_-]{43}$/;
3367
3372
  function isLanTlsFingerprint(value) {
@@ -3400,23 +3405,105 @@ window.__ModuleLoader__.load({
3400
3405
  return false;
3401
3406
  }
3402
3407
  }
3403
- /** Encode a short-lived, single-use pairing grant without URL credentials. */
3404
- function encodePairingQRPayload(host, grant, tlsFingerprint) {
3405
- const normalizedHost = host.trim();
3406
- if (!validTargetHost(normalizedHost)) throw new TypeError("pairing QR requires a valid HTTPS/WSS 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");
3407
3432
  if (grant.code.trim().length < 32 || !Number.isInteger(grant.expiresAt) || grant.expiresAt <= Date.now()) throw new TypeError("pairing grant is invalid or expired");
3408
3433
  if (!grant.audience.startsWith("deeppilot:")) throw new TypeError("pairing audience is invalid");
3409
3434
  if (tlsFingerprint !== void 0 && !isLanTlsFingerprint(tlsFingerprint)) throw new TypeError("pairing TLS fingerprint is invalid");
3410
- const payload = {
3411
- v: 2,
3412
- type: PAIRING_QR_TYPE,
3413
- host: normalizedHost,
3414
- code: grant.code.trim(),
3415
- expiresAt: grant.expiresAt,
3416
- audience: grant.audience,
3417
- ...tlsFingerprint !== void 0 ? { tlsFingerprint } : {}
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
3418
3506
  };
3419
- return JSON.stringify(payload);
3420
3507
  }
3421
3508
  function normalizeFunnelConnectionLimit(value) {
3422
3509
  return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 16 ? value : 8;
@@ -3519,16 +3606,13 @@ window.__ModuleLoader__.load({
3519
3606
  };
3520
3607
  /** Slot component: hooks come from the slot renderer, named use<Key>. */
3521
3608
  function DeepPilotSettingsPage(props) {
3522
- const [addressMessage, setAddressMessage] = (0, react.useState)("");
3523
3609
  const [remoteLimitDraft, setRemoteLimitDraft] = (0, react.useState)(String(8));
3524
3610
  const [remoteLimitMessage, setRemoteLimitMessage] = (0, react.useState)("");
3525
3611
  const [localPortDraft, setLocalPortDraft] = (0, react.useState)(String(DEFAULT_LOCAL_PORT));
3526
3612
  const [localPortMessage, setLocalPortMessage] = (0, react.useState)("");
3527
3613
  const [selectedPairingHost, setSelectedPairingHost] = (0, react.useState)(null);
3528
- const [qrDataURL, setQRDataURL] = (0, react.useState)(null);
3529
- const [pairingGrant, setPairingGrant] = (0, react.useState)(null);
3530
- const [qrBusy, setQRBusy] = (0, react.useState)(false);
3531
- const [qrMessage, setQRMessage] = (0, react.useState)("");
3614
+ const [pairingPanel, dispatchPairingPanel] = (0, react.useReducer)(pairingPanelReduce, initialPairingPanelState);
3615
+ const qrRequestId = (0, react.useRef)(0);
3532
3616
  const [relayTestBusy, setRelayTestBusy] = (0, react.useState)(false);
3533
3617
  const [relayTestResult, setRelayTestResult] = (0, react.useState)(null);
3534
3618
  const [relayTestError, setRelayTestError] = (0, react.useState)("");
@@ -3575,11 +3659,6 @@ window.__ModuleLoader__.load({
3575
3659
  setRelayTestBusy(false);
3576
3660
  });
3577
3661
  };
3578
- (0, react.useEffect)(() => {
3579
- if (!addressMessage) return;
3580
- const timer = globalThis.setTimeout(() => setAddressMessage(""), 2500);
3581
- return () => globalThis.clearTimeout(timer);
3582
- }, [addressMessage]);
3583
3662
  (0, react.useEffect)(() => {
3584
3663
  if (!remoteLimitMessage) return;
3585
3664
  const timer = globalThis.setTimeout(() => setRemoteLimitMessage(""), 4e3);
@@ -3591,19 +3670,21 @@ window.__ModuleLoader__.load({
3591
3670
  return () => globalThis.clearTimeout(timer);
3592
3671
  }, [localPortMessage]);
3593
3672
  (0, react.useEffect)(() => {
3594
- if (qrDataURL === null) return;
3673
+ if (pairingPanel.grant === null) return;
3674
+ const remaining = Math.max(0, pairingPanel.grant.expiresAt - Date.now());
3595
3675
  const timer = globalThis.setTimeout(() => {
3596
- setQRDataURL(null);
3597
- setPairingGrant(null);
3598
- setQRMessage(translateWith(props.t, "pair.qrAutoHidden"));
3599
- }, 1728e5);
3676
+ dispatchPairingPanel({
3677
+ type: "close",
3678
+ message: translateWith(props.t, "pair.qrExpired")
3679
+ });
3680
+ }, remaining);
3600
3681
  return () => globalThis.clearTimeout(timer);
3601
- }, [qrDataURL]);
3682
+ }, [pairingPanel.grant]);
3602
3683
  (0, react.useEffect)(() => {
3603
- if (!qrMessage) return;
3604
- const timer = globalThis.setTimeout(() => setQRMessage(""), 2500);
3684
+ if (!pairingPanel.notice) return;
3685
+ const timer = globalThis.setTimeout(() => dispatchPairingPanel({ type: "dismissNotice" }), 2500);
3605
3686
  return () => globalThis.clearTimeout(timer);
3606
- }, [qrMessage]);
3687
+ }, [pairingPanel.notice]);
3607
3688
  const diag = [];
3608
3689
  let report = null;
3609
3690
  let enabled = true;
@@ -3705,50 +3786,75 @@ window.__ModuleLoader__.load({
3705
3786
  };
3706
3787
  const pairingTargets = report === null ? [] : selectPairingTargets(report.local, report.remote);
3707
3788
  const pairingTarget = pairingTargets.find(({ host }) => host === selectedPairingHost) ?? pairingTargets[0] ?? null;
3708
- (0, react.useEffect)(() => {
3709
- setQRDataURL(null);
3710
- setPairingGrant(null);
3711
- }, [pairingTarget?.host]);
3712
- const copyRemoteURL = (url) => {
3713
- setAddressMessage("");
3714
- writeClipboard(props.t, url).then(() => {
3715
- setAddressMessage(translateWith(props.t, "pair.publicCopyDone"));
3716
- }, (error) => {
3717
- 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
3718
3806
  });
3719
- };
3720
- const showPairingQR = () => {
3721
- if (typeof props.beginPairing !== "function" || pairingTarget === null) return;
3722
- setQRBusy(true);
3723
- setQRMessage("");
3724
- setQRDataURL(null);
3725
- setPairingGrant(null);
3726
- props.beginPairing().then(async (grant) => ({
3727
- grant,
3728
- svg: await import_browser.toString(encodePairingQRPayload(pairingTarget.host, grant, pairingTarget.tlsFingerprint), {
3729
- type: "svg",
3730
- errorCorrectionLevel: "M",
3731
- margin: 2,
3732
- width: 512
3733
- })
3734
- })).then(({ grant, svg }) => {
3735
- setPairingGrant(grant);
3736
- 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
+ });
3737
3828
  }, (error) => {
3738
- setQRMessage(translateWith(props.t, "pair.qrFailed") + (error instanceof Error ? error.message : String(error)));
3739
- }).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
+ });
3740
3836
  };
3741
3837
  const hidePairingQR = () => {
3742
- setQRDataURL(null);
3743
- setPairingGrant(null);
3838
+ qrRequestId.current += 1;
3839
+ dispatchPairingPanel({ type: "close" });
3744
3840
  };
3745
- const copyPairingCode = () => {
3746
- if (pairingGrant === null) return;
3747
- setQRMessage("");
3748
- writeClipboard(props.t, pairingGrant.code).then(() => {
3749
- 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
+ });
3750
3853
  }, (error) => {
3751
- 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
+ });
3752
3858
  });
3753
3859
  };
3754
3860
  const primaryRows = [];
@@ -3906,36 +4012,36 @@ window.__ModuleLoader__.load({
3906
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", {
3907
4013
  type: "button",
3908
4014
  className: "pbb-action",
3909
- disabled: qrBusy || !report.pairingReady,
4015
+ disabled: panel.busy || !report.pairingReady,
3910
4016
  onClick: () => {
3911
- if (qrDataURL === null) showPairingQR();
3912
- else hidePairingQR();
4017
+ if (panel.open) hidePairingQR();
4018
+ else showPairingQR(pairingTarget);
3913
4019
  }
3914
- }, 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", {
3915
4021
  type: "button",
3916
4022
  key: target.host,
3917
4023
  className: "pbb-action" + (target.host === pairingTarget?.host ? " pbb-actionSelected" : ""),
4024
+ disabled: panel.busy,
3918
4025
  onClick: () => {
3919
4026
  setSelectedPairingHost(target.host);
3920
- hidePairingQR();
4027
+ if (panel.open) showPairingQR(target);
4028
+ else hidePairingQR();
3921
4029
  }
3922
- }, (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", {
3923
- className: "pbb-qrImage",
3924
- src: qrDataURL,
3925
- alt: translateWith(props.t, "pair.qrAlt")
3926
- }), (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", {
3927
4031
  type: "button",
3928
4032
  className: "pbb-action",
3929
- onClick: copyPairingCode
3930
- }, 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", {
3931
- type: "button",
3932
- className: "pbb-action",
3933
- onClick: () => copyRemoteURL(pairingTarget.host)
3934
- }, translateWith(props.t, "panel.tokenAction.copy"))), addressMessage ? (0, react.createElement)("p", { className: "pbb-diag" }, addressMessage) : null, pairingTarget.tlsFingerprint === void 0 ? null : (0, react.createElement)("div", { className: "pbb-row" }, (0, react.createElement)("code", { className: "pbb-token" }, pairingTarget.tlsFingerprint), (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", {
3935
4040
  type: "button",
3936
4041
  className: "pbb-action",
3937
- onClick: () => copyRemoteURL(pairingTarget.tlsFingerprint ?? "")
3938
- }, translateWith(props.t, "panel.tokenAction.copy"))), (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", {
3939
4045
  type: "button",
3940
4046
  className: "pbb-action",
3941
4047
  disabled: relayTestBusy,