dsh-deeppilot 0.6.0-alpha.2 → 0.6.0-alpha.4

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
@@ -151,6 +151,28 @@ window.__ModuleLoader__.load({
151
151
  updatedAt: int(s, "updatedAt", "remote.updatedAt")
152
152
  };
153
153
  }
154
+ function parseLocal(value) {
155
+ const s = rec(value, "local");
156
+ const phase = str(s, "phase", "local.phase");
157
+ if (![
158
+ "disabled",
159
+ "starting",
160
+ "online",
161
+ "error",
162
+ "stopped"
163
+ ].includes(phase)) reject("local.phase");
164
+ const endpoints = s.endpoints;
165
+ if (!Array.isArray(endpoints) || endpoints.some((entry) => typeof entry !== "string")) reject("local.endpoints");
166
+ const message = s.message;
167
+ if (message !== void 0 && typeof message !== "string") reject("local.message");
168
+ return {
169
+ phase,
170
+ port: int(s, "port", "local.port"),
171
+ endpoints,
172
+ ...typeof message === "string" ? { message } : {},
173
+ updatedAt: int(s, "updatedAt", "local.updatedAt")
174
+ };
175
+ }
154
176
  function parseRelayTestStep(value) {
155
177
  const st = rec(value, "step");
156
178
  const id = str(st, "id", "step.id");
@@ -232,6 +254,7 @@ window.__ModuleLoader__.load({
232
254
  historyBufferMax: int(s, "historyBufferMax", "historyBufferMax"),
233
255
  debug: bool(s, "debug", "debug"),
234
256
  lanAddresses,
257
+ local: parseLocal(s.local),
235
258
  remote: parseRemote(s.remote),
236
259
  devices: devices.map(parseDevice)
237
260
  };
@@ -412,6 +435,19 @@ window.__ModuleLoader__.load({
412
435
  "master.loading": "正在读取配置…",
413
436
  "master.on": "已开启:接受手机连接。",
414
437
  "master.off": "已关闭:不接受手机连接。",
438
+ "local.title": "局域网连接",
439
+ "local.on": "已开启:手机可通过独立端口 {port} 连接。",
440
+ "local.off": "已关闭:不接受局域网连接。",
441
+ "local.phaseDisabled": "局域网连接未启用",
442
+ "local.phaseStarting": "局域网连接正在启动",
443
+ "local.phaseOnline": "局域网连接已就绪",
444
+ "local.phaseError": "局域网连接失败",
445
+ "local.phaseStopped": "局域网连接已停止",
446
+ "local.portTitle": "局域网端口",
447
+ "local.portDescription": "默认 3098,范围 1024–65535。该端口只提供 DeepPilot 的三个连接端点,不会开放完整 DSH。",
448
+ "local.portApplied": "局域网端口已保存,监听器正在重新加载。",
449
+ "local.portInvalid": "请输入 1024–65535 之间的整数。",
450
+ "local.portFailed": "局域网端口保存失败:",
415
451
  "remote.title": "远程连接(Tailscale Funnel)",
416
452
  "remote.on": "已配置:内嵌 Funnel 会自动启动并同步状态。",
417
453
  "remote.off": "关闭:仅保留局域网连接。",
@@ -457,7 +493,7 @@ window.__ModuleLoader__.load({
457
493
  "pair.kind.public": "公网",
458
494
  "pair.kind.lan": "内网",
459
495
  "pair.noAddress": "没有可用地址",
460
- "pair.noAddressHelp": "未发现可供手机访问的局域网地址,请确认这台电脑已连接局域网。",
496
+ "pair.noAddressHelp": "没有可用的局域网或远程入口。请确认局域网连接已启动,或启用远程连接。",
461
497
  "pair.qrAlt": "DeepPilot 配对二维码",
462
498
  "pair.qrShow": "显示二维码",
463
499
  "pair.qrHide": "隐藏二维码",
@@ -467,7 +503,7 @@ window.__ModuleLoader__.load({
467
503
  "pair.codeLabel": "配对码(模拟器可手动输入)",
468
504
  "pair.codeCopyDone": "配对码已复制",
469
505
  "pair.codeCopyFailed": "配对码复制失败:",
470
- "pair.publicCopyDone": "公网地址已复制",
506
+ "pair.publicCopyDone": "连接地址已复制",
471
507
  "pair.publicCopyFailed": "复制失败:",
472
508
  "pair.qrHint": "二维码包含{kind}地址和一次性配对码;配对码 5 分钟后失效,二维码将在 60 秒后自动隐藏。",
473
509
  "advanced.summary": "高级信息",
@@ -534,8 +570,8 @@ window.__ModuleLoader__.load({
534
570
  "help.securityTitle": "安全说明",
535
571
  "help.security1": "Funnel 公网地址可从互联网访问,但连接必须使用已配对设备的 P-256 私钥签名;不要分享仍在有效期内的配对二维码。",
536
572
  "help.security2": "丢失设备时可在设备列表中单独删除;该设备会立即断开,不影响其他手机。",
537
- "help.security3": "插件只通过 Funnel 转发 /phone、/phone/pair 和 /phone/health,不会新增 3098 端口。",
538
- "help.security4": "局域网连接沿用 DSH 的 3080 端口;仅在可信网络中使用,不建议直接把 3080 暴露到公网。",
573
+ "help.security3": "Funnel 和局域网监听器都只提供 /phone、/phone/pair 和 /phone/health,不会暴露完整 DSH Web/API。",
574
+ "help.security4": "局域网连接使用插件独立的 3098 端口和明文 WebSocket。只在可信网络中使用,不要把 3098 转发到公网。",
539
575
  "help.security5": "关闭远程连接开关会停止 Funnel,但不会影响可用的局域网连接。",
540
576
  "help.funnelDocs": "Tailscale Funnel 官方文档",
541
577
  "help.docsPrefix": "更多信息:",
@@ -547,6 +583,8 @@ window.__ModuleLoader__.load({
547
583
  "diag.settingsUnavailable": "设置命名空间不可用(settingsScope 未提供)",
548
584
  "diag.missingReportHook": "useDeepPilotReport hook 缺失",
549
585
  "diag.missingEnabledHook": "useDeepPilotEnabled hook 缺失",
586
+ "diag.missingLocalEnabledHook": "useDeepPilotLocalEnabled hook 缺失",
587
+ "diag.missingLocalPortHook": "useDeepPilotLocalPort hook 缺失",
550
588
  "diag.missingRemoteEnabledHook": "useDeepPilotRemoteEnabled hook 缺失",
551
589
  "diag.missingRemoteLimitHook": "useDeepPilotRemoteConnectionLimit hook 缺失",
552
590
  "diag.missingRefresh": "refresh 回调缺失",
@@ -555,6 +593,8 @@ window.__ModuleLoader__.load({
555
593
  "diag.missingTestRelay": "testRelay 回调缺失",
556
594
  "diag.missingTestPush": "testPush 回调缺失",
557
595
  "diag.missingSetEnabled": "setDeepPilotEnabled 回调缺失",
596
+ "diag.missingSetLocal": "setDeepPilotLocalEnabled 回调缺失",
597
+ "diag.missingSetLocalPort": "setDeepPilotLocalPort 回调缺失",
558
598
  "diag.missingSetRemote": "setDeepPilotRemoteEnabled 回调缺失",
559
599
  "diag.missingSetRemoteLimit": "setDeepPilotRemoteConnectionLimit 回调缺失",
560
600
  "diag.renderError": "渲染异常: ",
@@ -575,6 +615,19 @@ window.__ModuleLoader__.load({
575
615
  "master.loading": "Loading settings…",
576
616
  "master.on": "On: accepting phone connections.",
577
617
  "master.off": "Off: not accepting phone connections.",
618
+ "local.title": "LAN connection",
619
+ "local.on": "On: phones can connect through the independent port {port}.",
620
+ "local.off": "Off: LAN connections are not accepted.",
621
+ "local.phaseDisabled": "LAN connection disabled",
622
+ "local.phaseStarting": "LAN connection starting",
623
+ "local.phaseOnline": "LAN connection ready",
624
+ "local.phaseError": "LAN connection failed",
625
+ "local.phaseStopped": "LAN connection stopped",
626
+ "local.portTitle": "LAN port",
627
+ "local.portDescription": "Default 3098; range 1024–65535. This port exposes only the three DeepPilot endpoints, never the full DSH server.",
628
+ "local.portApplied": "LAN port saved; the listener is reloading.",
629
+ "local.portInvalid": "Enter an integer from 1024 to 65535.",
630
+ "local.portFailed": "Failed to save LAN port: ",
578
631
  "remote.title": "Remote connection (Tailscale Funnel)",
579
632
  "remote.on": "Configured: the embedded Funnel will start and sync state automatically.",
580
633
  "remote.off": "Off: LAN connections only.",
@@ -620,7 +673,7 @@ window.__ModuleLoader__.load({
620
673
  "pair.kind.public": "Public",
621
674
  "pair.kind.lan": "LAN",
622
675
  "pair.noAddress": "No address available",
623
- "pair.noAddressHelp": "No LAN address reachable from a phone. Make sure this Mac is on the local network.",
676
+ "pair.noAddressHelp": "No LAN or remote endpoint is available. Make sure LAN is running or enable remote access.",
624
677
  "pair.qrAlt": "DeepPilot pairing QR code",
625
678
  "pair.qrShow": "Show QR code",
626
679
  "pair.qrHide": "Hide QR code",
@@ -630,7 +683,7 @@ window.__ModuleLoader__.load({
630
683
  "pair.codeLabel": "Pairing code (enter manually in Simulator)",
631
684
  "pair.codeCopyDone": "Pairing code copied",
632
685
  "pair.codeCopyFailed": "Failed to copy pairing code: ",
633
- "pair.publicCopyDone": "Public URL copied",
686
+ "pair.publicCopyDone": "Connection address copied",
634
687
  "pair.publicCopyFailed": "Copy failed: ",
635
688
  "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.",
636
689
  "advanced.summary": "Advanced info",
@@ -697,8 +750,8 @@ window.__ModuleLoader__.load({
697
750
  "help.securityTitle": "Security notes",
698
751
  "help.security1": "A Funnel URL is public, but every connection requires a signature from a paired device P-256 private key. Do not share a pairing QR while it is valid.",
699
752
  "help.security2": "If a device is lost, delete only that device from the list. It disconnects immediately; other phones stay paired.",
700
- "help.security3": "The plugin only forwards /phone, /phone/pair, and /phone/health through Funnel; no new port 3098 is opened.",
701
- "help.security4": "LAN connections reuse DSH's existing 3080 port. Use them only on trusted networks; do not expose 3080 directly to the public Internet.",
753
+ "help.security3": "Funnel and the LAN listener expose only /phone, /phone/pair, and /phone/health, never the full DSH Web/API surface.",
754
+ "help.security4": "LAN uses the plugin's independent port 3098 over plaintext WebSocket. Use trusted networks only and never forward 3098 to the public Internet.",
702
755
  "help.security5": "Disabling the remote switch stops Funnel but does not affect any LAN connection you already have.",
703
756
  "help.funnelDocs": "Tailscale Funnel docs",
704
757
  "help.docsPrefix": "More info: ",
@@ -710,6 +763,8 @@ window.__ModuleLoader__.load({
710
763
  "diag.settingsUnavailable": "Settings namespace unavailable (settingsScope not provided)",
711
764
  "diag.missingReportHook": "useDeepPilotReport hook missing",
712
765
  "diag.missingEnabledHook": "useDeepPilotEnabled hook missing",
766
+ "diag.missingLocalEnabledHook": "useDeepPilotLocalEnabled hook missing",
767
+ "diag.missingLocalPortHook": "useDeepPilotLocalPort hook missing",
713
768
  "diag.missingRemoteEnabledHook": "useDeepPilotRemoteEnabled hook missing",
714
769
  "diag.missingRemoteLimitHook": "useDeepPilotRemoteConnectionLimit hook missing",
715
770
  "diag.missingRefresh": "refresh callback missing",
@@ -718,6 +773,8 @@ window.__ModuleLoader__.load({
718
773
  "diag.missingTestRelay": "testRelay callback missing",
719
774
  "diag.missingTestPush": "testPush callback missing",
720
775
  "diag.missingSetEnabled": "setDeepPilotEnabled callback missing",
776
+ "diag.missingSetLocal": "setDeepPilotLocalEnabled callback missing",
777
+ "diag.missingSetLocalPort": "setDeepPilotLocalPort callback missing",
721
778
  "diag.missingSetRemote": "setDeepPilotRemoteEnabled callback missing",
722
779
  "diag.missingSetRemoteLimit": "setDeepPilotRemoteConnectionLimit callback missing",
723
780
  "diag.renderError": "Render error: ",
@@ -822,7 +879,12 @@ window.__ModuleLoader__.load({
822
879
  ".pbb-action{font:inherit;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:none;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;padding:3px 8px}",
823
880
  ".pbb-action:hover:not(:disabled){color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-module-platform)}",
824
881
  ".pbb-action:disabled{cursor:default;opacity:.5}",
825
- ".pbb-actionDanger:not(:disabled){color:#fff;background:var(--dsw-alias-label-error);border-color:var(--dsw-alias-label-error)}",
882
+ ".pbb-actionSelected{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-state-success-primary,#22a06b);background:var(--dsw-alias-bg-module-platform)}",
883
+ ".pbb-targetList{display:flex;flex-wrap:wrap;gap:6px;padding-top:4px}",
884
+ ".pbb-actionDanger{color:#fff;background:#dc2626;border-color:#dc2626;line-height:1.35;padding:4px 10px;box-shadow:0 1px 2px rgba(127,29,29,.18);transition:background-color .15s ease,border-color .15s ease,box-shadow .15s ease,transform .1s ease}",
885
+ ".pbb-actionDanger:hover:not(:disabled){color:#fff;background:#b91c1c;border-color:#b91c1c;box-shadow:0 2px 5px rgba(127,29,29,.24)}",
886
+ ".pbb-actionDanger:active:not(:disabled){background:#991b1b;border-color:#991b1b;box-shadow:0 1px 2px rgba(127,29,29,.2);transform:translateY(1px)}",
887
+ ".pbb-actionDanger:focus-visible{outline:2px solid rgba(220,38,38,.32);outline-offset:2px}",
826
888
  ".pbb-qrPanel{display:flex;flex-direction:column;align-items:center;gap:9px;padding:12px 0 4px}",
827
889
  ".pbb-qrImage{width:240px;height:240px;max-width:100%;background:#fff;border-radius:10px;padding:8px;box-sizing:border-box}",
828
890
  ".pbb-pairCodeBlock{width:min(100%,420px);display:flex;flex-direction:column;gap:5px}",
@@ -3281,34 +3343,38 @@ window.__ModuleLoader__.load({
3281
3343
  });
3282
3344
  })))(), 1);
3283
3345
  const PAIRING_QR_TYPE = "deeppilot-pairing";
3284
- function isLoopbackHostname(hostname) {
3285
- const normalized = hostname.toLowerCase();
3286
- return normalized === "localhost" || normalized.endsWith(".localhost") || normalized === "[::1]" || normalized === "::1" || normalized.startsWith("127.");
3287
- }
3288
- /** Prefer an online Funnel; otherwise turn the current web origin into a LAN target. */
3289
- function selectPairingTarget(remote, lanAddresses, currentOrigin) {
3290
- let origin;
3291
- try {
3292
- if (currentOrigin) origin = new URL(currentOrigin);
3293
- } catch {}
3294
- if (remote.publicURL && origin?.origin === remote.publicURL) return {
3295
- host: remote.publicURL,
3296
- kind: "public"
3297
- };
3298
- if (remote.phase === "online" && remote.publicURL) return {
3346
+ /**
3347
+ * Return every explicit DeepPilot transport target. LAN comes first because
3348
+ * it is private and lower latency; an online Funnel remains available as a
3349
+ * separate choice instead of silently replacing the local address.
3350
+ */
3351
+ function selectPairingTargets(local, remote) {
3352
+ const targets = [];
3353
+ if (local.phase === "online") {
3354
+ for (const host of local.endpoints) if (validTargetHost(host)) targets.push({
3355
+ host,
3356
+ kind: "lan"
3357
+ });
3358
+ }
3359
+ if (remote.phase === "online" && remote.publicURL && validTargetHost(remote.publicURL)) targets.push({
3299
3360
  host: remote.publicURL,
3300
3361
  kind: "public"
3301
- };
3302
- if (origin && ["http:", "https:"].includes(origin.protocol) && !isLoopbackHostname(origin.hostname)) return {
3303
- host: origin.origin,
3304
- kind: "lan"
3305
- };
3306
- const address = lanAddresses[0];
3307
- if (!address) return null;
3308
- return {
3309
- host: `${origin?.protocol === "https:" ? "https:" : "http:"}//${address}${origin?.port ? `:${origin.port}` : ""}`,
3310
- kind: "lan"
3311
- };
3362
+ });
3363
+ const seen = /* @__PURE__ */ new Set();
3364
+ return targets.filter(({ host }) => !seen.has(host) && seen.add(host));
3365
+ }
3366
+ function validTargetHost(value) {
3367
+ try {
3368
+ const parsed = new URL(value);
3369
+ return [
3370
+ "http:",
3371
+ "https:",
3372
+ "ws:",
3373
+ "wss:"
3374
+ ].includes(parsed.protocol) && parsed.hostname !== "" && parsed.username === "" && parsed.password === "";
3375
+ } catch {
3376
+ return false;
3377
+ }
3312
3378
  }
3313
3379
  /** Encode a short-lived, single-use pairing grant without URL credentials. */
3314
3380
  function encodePairingQRPayload(host, grant) {
@@ -3336,6 +3402,14 @@ window.__ModuleLoader__.load({
3336
3402
  return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 16 ? value : 8;
3337
3403
  }
3338
3404
  //#endregion
3405
+ //#region src/local-policy.ts
3406
+ const DEFAULT_LOCAL_PORT = 3098;
3407
+ const MIN_LOCAL_PORT = 1024;
3408
+ const MAX_LOCAL_PORT = 65535;
3409
+ function normalizeLocalPort(value) {
3410
+ return typeof value === "number" && Number.isInteger(value) && value >= 1024 && value <= 65535 ? value : DEFAULT_LOCAL_PORT;
3411
+ }
3412
+ //#endregion
3339
3413
  //#region src/client/settings-page.ts
3340
3414
  async function writeClipboard(t, value) {
3341
3415
  try {
@@ -3386,11 +3460,36 @@ window.__ModuleLoader__.load({
3386
3460
  labelKey: "phase.stopped"
3387
3461
  }
3388
3462
  };
3463
+ const LOCAL_PHASE_META = {
3464
+ disabled: {
3465
+ dot: "",
3466
+ labelKey: "local.phaseDisabled"
3467
+ },
3468
+ starting: {
3469
+ dot: " pbb-dotWarn",
3470
+ labelKey: "local.phaseStarting"
3471
+ },
3472
+ online: {
3473
+ dot: " pbb-dotOk",
3474
+ labelKey: "local.phaseOnline"
3475
+ },
3476
+ error: {
3477
+ dot: " pbb-dotBad",
3478
+ labelKey: "local.phaseError"
3479
+ },
3480
+ stopped: {
3481
+ dot: "",
3482
+ labelKey: "local.phaseStopped"
3483
+ }
3484
+ };
3389
3485
  /** Slot component: hooks come from the slot renderer, named use<Key>. */
3390
3486
  function DeepPilotSettingsPage(props) {
3391
3487
  const [addressMessage, setAddressMessage] = (0, react.useState)("");
3392
3488
  const [remoteLimitDraft, setRemoteLimitDraft] = (0, react.useState)(String(8));
3393
3489
  const [remoteLimitMessage, setRemoteLimitMessage] = (0, react.useState)("");
3490
+ const [localPortDraft, setLocalPortDraft] = (0, react.useState)(String(DEFAULT_LOCAL_PORT));
3491
+ const [localPortMessage, setLocalPortMessage] = (0, react.useState)("");
3492
+ const [selectedPairingHost, setSelectedPairingHost] = (0, react.useState)(null);
3394
3493
  const [qrDataURL, setQRDataURL] = (0, react.useState)(null);
3395
3494
  const [pairingGrant, setPairingGrant] = (0, react.useState)(null);
3396
3495
  const [qrBusy, setQRBusy] = (0, react.useState)(false);
@@ -3451,13 +3550,18 @@ window.__ModuleLoader__.load({
3451
3550
  const timer = globalThis.setTimeout(() => setRemoteLimitMessage(""), 4e3);
3452
3551
  return () => globalThis.clearTimeout(timer);
3453
3552
  }, [remoteLimitMessage]);
3553
+ (0, react.useEffect)(() => {
3554
+ if (!localPortMessage) return;
3555
+ const timer = globalThis.setTimeout(() => setLocalPortMessage(""), 4e3);
3556
+ return () => globalThis.clearTimeout(timer);
3557
+ }, [localPortMessage]);
3454
3558
  (0, react.useEffect)(() => {
3455
3559
  if (qrDataURL === null) return;
3456
3560
  const timer = globalThis.setTimeout(() => {
3457
3561
  setQRDataURL(null);
3458
3562
  setPairingGrant(null);
3459
3563
  setQRMessage(translateWith(props.t, "pair.qrAutoHidden"));
3460
- }, 6e4);
3564
+ }, 1728e5);
3461
3565
  return () => globalThis.clearTimeout(timer);
3462
3566
  }, [qrDataURL]);
3463
3567
  (0, react.useEffect)(() => {
@@ -3469,6 +3573,10 @@ window.__ModuleLoader__.load({
3469
3573
  let report = null;
3470
3574
  let enabled = true;
3471
3575
  let switchReady = false;
3576
+ let localEnabled = true;
3577
+ let localSwitchReady = false;
3578
+ let localPort = DEFAULT_LOCAL_PORT;
3579
+ let localPortReady = false;
3472
3580
  let remoteEnabled = false;
3473
3581
  let remoteSwitchReady = false;
3474
3582
  let remoteConnectionLimit = 8;
@@ -3498,6 +3606,18 @@ window.__ModuleLoader__.load({
3498
3606
  if (typeof props.testRelay !== "function") diag.push(translateWith(props.t, "diag.missingTestRelay"));
3499
3607
  if (typeof props.testPush !== "function") diag.push(translateWith(props.t, "diag.missingTestPush"));
3500
3608
  if (typeof props.setDeepPilotEnabled !== "function") diag.push(translateWith(props.t, "diag.missingSetEnabled"));
3609
+ if (typeof props.useDeepPilotLocalEnabled === "function") {
3610
+ const state = props.useDeepPilotLocalEnabled((s) => s);
3611
+ localEnabled = state.enabled;
3612
+ localSwitchReady = state.status === "ready";
3613
+ } else diag.push(translateWith(props.t, "diag.missingLocalEnabledHook"));
3614
+ if (typeof props.useDeepPilotLocalPort === "function") {
3615
+ const state = props.useDeepPilotLocalPort((s) => s);
3616
+ localPort = state.value;
3617
+ localPortReady = state.status === "ready";
3618
+ } else diag.push(translateWith(props.t, "diag.missingLocalPortHook"));
3619
+ if (typeof props.setDeepPilotLocalEnabled !== "function") diag.push(translateWith(props.t, "diag.missingSetLocal"));
3620
+ if (typeof props.setDeepPilotLocalPort !== "function") diag.push(translateWith(props.t, "diag.missingSetLocalPort"));
3501
3621
  if (typeof props.useDeepPilotRemoteEnabled === "function") {
3502
3622
  const state = props.useDeepPilotRemoteEnabled((s) => s);
3503
3623
  remoteEnabled = state.enabled;
@@ -3517,6 +3637,23 @@ window.__ModuleLoader__.load({
3517
3637
  (0, react.useEffect)(() => {
3518
3638
  setRemoteLimitDraft(String(remoteConnectionLimit));
3519
3639
  }, [remoteConnectionLimit]);
3640
+ (0, react.useEffect)(() => {
3641
+ setLocalPortDraft(String(localPort));
3642
+ }, [localPort]);
3643
+ const parsedLocalPort = Number(localPortDraft);
3644
+ const localPortValid = Number.isInteger(parsedLocalPort) && parsedLocalPort >= 1024 && parsedLocalPort <= 65535;
3645
+ const applyLocalPort = () => {
3646
+ if (!localPortValid || typeof props.setDeepPilotLocalPort !== "function") {
3647
+ setLocalPortMessage(translateWith(props.t, "local.portInvalid"));
3648
+ return;
3649
+ }
3650
+ setLocalPortMessage("");
3651
+ props.setDeepPilotLocalPort(parsedLocalPort).then(() => {
3652
+ setLocalPortMessage(translateWith(props.t, "local.portApplied"));
3653
+ }, (error) => {
3654
+ setLocalPortMessage(translateWith(props.t, "local.portFailed") + (error instanceof Error ? error.message : String(error)));
3655
+ });
3656
+ };
3520
3657
  const parsedRemoteLimit = Number(remoteLimitDraft);
3521
3658
  const remoteLimitValid = Number.isInteger(parsedRemoteLimit) && parsedRemoteLimit >= 1 && parsedRemoteLimit <= 16;
3522
3659
  const applyRemoteLimit = () => {
@@ -3531,7 +3668,8 @@ window.__ModuleLoader__.load({
3531
3668
  setRemoteLimitMessage(translateWith(props.t, "remote.limitFailed") + (error instanceof Error ? error.message : String(error)));
3532
3669
  });
3533
3670
  };
3534
- const pairingTarget = report === null ? null : selectPairingTarget(report.remote, report.lanAddresses, typeof window === "undefined" ? void 0 : window.location.origin);
3671
+ const pairingTargets = report === null ? [] : selectPairingTargets(report.local, report.remote);
3672
+ const pairingTarget = pairingTargets.find(({ host }) => host === selectedPairingHost) ?? pairingTargets[0] ?? null;
3535
3673
  (0, react.useEffect)(() => {
3536
3674
  setQRDataURL(null);
3537
3675
  setPairingGrant(null);
@@ -3637,6 +3775,21 @@ window.__ModuleLoader__.load({
3637
3775
  onClick: () => {
3638
3776
  if (typeof props.setDeepPilotEnabled === "function") props.setDeepPilotEnabled(!enabled);
3639
3777
  }
3778
+ })), (0, react.createElement)("div", { className: "pbb-switchRow" }, (0, react.createElement)("div", { className: "pbb-switchText" }, (0, react.createElement)("span", { className: "pbb-switchTitle pbb-dotRow" }, (0, react.createElement)("span", {
3779
+ className: "pbb-dot" + (report !== null ? LOCAL_PHASE_META[report.local.phase].dot : ""),
3780
+ role: "img",
3781
+ "aria-label": report !== null ? translateWith(props.t, LOCAL_PHASE_META[report.local.phase].labelKey) : translateWith(props.t, "phase.unknown"),
3782
+ title: report !== null ? translateWith(props.t, LOCAL_PHASE_META[report.local.phase].labelKey) : void 0
3783
+ }), 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", {
3784
+ type: "button",
3785
+ role: "switch",
3786
+ "aria-checked": localEnabled,
3787
+ "aria-label": translateWith(props.t, "local.title"),
3788
+ disabled: !localSwitchReady,
3789
+ className: "pbb-switch" + (localEnabled ? " pbb-switchOn" : ""),
3790
+ onClick: () => {
3791
+ if (typeof props.setDeepPilotLocalEnabled === "function") props.setDeepPilotLocalEnabled(!localEnabled);
3792
+ }
3640
3793
  })), (0, react.createElement)("div", { className: "pbb-switchRow" }, (0, react.createElement)("div", { className: "pbb-switchText" }, (0, react.createElement)("span", { className: "pbb-switchTitle pbb-dotRow" }, (0, react.createElement)("span", {
3641
3794
  className: "pbb-dot" + (report !== null ? REMOTE_PHASE_META[report.remote.phase].dot : ""),
3642
3795
  role: "img",
@@ -3658,6 +3811,32 @@ window.__ModuleLoader__.load({
3658
3811
  if (typeof props.setDeepPilotRemoteEnabled === "function") props.setDeepPilotRemoteEnabled(!remoteEnabled);
3659
3812
  }
3660
3813
  })), (0, react.createElement)("details", { className: "pbb-help" }, (0, react.createElement)("summary", null, translateWith(props.t, "remote.advancedSettings")), (0, react.createElement)("div", { className: "pbb-helpBody" }, (0, react.createElement)("div", { className: "pbb-limitRow pbb-limitNested" }, (0, react.createElement)("div", { className: "pbb-switchText" }, (0, react.createElement)("label", {
3814
+ className: "pbb-switchTitle",
3815
+ htmlFor: "deeppilot-local-port"
3816
+ }, translateWith(props.t, "local.portTitle")), (0, react.createElement)("span", { className: "pbb-switchDesc" }, translateWith(props.t, "local.portDescription")), !localPortValid ? (0, react.createElement)("p", { className: "pbb-diag pbb-diagBad" }, translateWith(props.t, "local.portInvalid")) : localPortMessage ? (0, react.createElement)("p", { className: "pbb-diag" + (localPortMessage.startsWith(translateWith(props.t, "local.portFailed")) ? " pbb-diagBad" : "") }, localPortMessage) : null), (0, react.createElement)("div", { className: "pbb-limitControl" }, (0, react.createElement)("input", {
3817
+ id: "deeppilot-local-port",
3818
+ className: "pbb-numberInput",
3819
+ type: "number",
3820
+ min: MIN_LOCAL_PORT,
3821
+ max: MAX_LOCAL_PORT,
3822
+ step: 1,
3823
+ inputMode: "numeric",
3824
+ value: localPortDraft,
3825
+ disabled: !localPortReady,
3826
+ "aria-label": translateWith(props.t, "local.portTitle"),
3827
+ onChange: (event) => setLocalPortDraft(event.currentTarget.value),
3828
+ onKeyDown: (event) => {
3829
+ if (event.key === "Enter") {
3830
+ event.preventDefault();
3831
+ applyLocalPort();
3832
+ }
3833
+ }
3834
+ }), (0, react.createElement)("button", {
3835
+ type: "button",
3836
+ className: "pbb-action",
3837
+ disabled: !localPortReady || !localPortValid || parsedLocalPort === localPort,
3838
+ onClick: applyLocalPort
3839
+ }, translateWith(props.t, "remote.limitApply")))), (0, react.createElement)("div", { className: "pbb-limitRow pbb-limitNested" }, (0, react.createElement)("div", { className: "pbb-switchText" }, (0, react.createElement)("label", {
3661
3840
  className: "pbb-switchTitle",
3662
3841
  htmlFor: "deeppilot-funnel-source-limit"
3663
3842
  }, translateWith(props.t, "remote.limitTitle")), (0, react.createElement)("span", { className: "pbb-switchDesc" }, translateWith(props.t, "remote.limitDescription")), !remoteLimitValid ? (0, react.createElement)("p", { className: "pbb-diag pbb-diagBad" }, translateWith(props.t, "remote.limitInvalid")) : remoteLimitMessage ? (0, react.createElement)("p", { className: "pbb-diag" + (remoteLimitMessage.startsWith(translateWith(props.t, "remote.limitFailed")) ? " pbb-diagBad" : "") }, remoteLimitMessage) : null), (0, react.createElement)("div", { className: "pbb-limitControl" }, (0, react.createElement)("input", {
@@ -3696,7 +3875,15 @@ window.__ModuleLoader__.load({
3696
3875
  if (qrDataURL === null) showPairingQR();
3697
3876
  else hidePairingQR();
3698
3877
  }
3699
- }, 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, 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", {
3878
+ }, 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", {
3879
+ type: "button",
3880
+ key: target.host,
3881
+ className: "pbb-action" + (target.host === pairingTarget?.host ? " pbb-actionSelected" : ""),
3882
+ onClick: () => {
3883
+ setSelectedPairingHost(target.host);
3884
+ hidePairingQR();
3885
+ }
3886
+ }, (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", {
3700
3887
  className: "pbb-qrImage",
3701
3888
  src: qrDataURL,
3702
3889
  alt: translateWith(props.t, "pair.qrAlt")
@@ -3885,6 +4072,24 @@ window.__ModuleLoader__.load({
3885
4072
  if (snap?.status === "ready" && snap.value !== void 0) return snap.value.enabled !== false;
3886
4073
  return enabledStore.getSnapshot().enabled;
3887
4074
  };
4075
+ const localEnabledStore = createSnapshotStore({
4076
+ status: "loading",
4077
+ enabled: true
4078
+ });
4079
+ const localPortStore = createSnapshotStore({
4080
+ status: "loading",
4081
+ value: DEFAULT_LOCAL_PORT
4082
+ });
4083
+ const lastConfirmedLocalEnabled = () => {
4084
+ const snap = scope?.getSnapshot();
4085
+ if (snap?.status === "ready") return snap.value?.local?.enabled !== false;
4086
+ return localEnabledStore.getSnapshot().enabled;
4087
+ };
4088
+ const lastConfirmedLocalPort = () => {
4089
+ const snap = scope?.getSnapshot();
4090
+ if (snap?.status === "ready") return normalizeLocalPort(snap.value?.local?.port);
4091
+ return localPortStore.getSnapshot().value;
4092
+ };
3888
4093
  const lastConfirmedRemoteEnabled = () => {
3889
4094
  const snap = scope?.getSnapshot();
3890
4095
  if (snap?.status === "ready") return snap.value?.remote?.enabled === true;
@@ -3911,6 +4116,76 @@ window.__ModuleLoader__.load({
3911
4116
  console.error("[deeppilot] failed to persist enabled=" + String(value) + ": " + message);
3912
4117
  });
3913
4118
  };
4119
+ const adoptLocal = () => {
4120
+ if (scope === void 0) return;
4121
+ const snap = scope.getSnapshot();
4122
+ if (snap.status === "ready") {
4123
+ localEnabledStore.set({
4124
+ status: "ready",
4125
+ enabled: snap.value?.local?.enabled !== false
4126
+ });
4127
+ localPortStore.set({
4128
+ status: "ready",
4129
+ value: normalizeLocalPort(snap.value?.local?.port)
4130
+ });
4131
+ } else if (snap.status === "unavailable") {
4132
+ localEnabledStore.set({
4133
+ status: "unavailable",
4134
+ enabled: true
4135
+ });
4136
+ localPortStore.set({
4137
+ status: "unavailable",
4138
+ value: DEFAULT_LOCAL_PORT
4139
+ });
4140
+ }
4141
+ };
4142
+ if (scope !== void 0) {
4143
+ scope.subscribe(adoptLocal);
4144
+ adoptLocal();
4145
+ }
4146
+ const setDeepPilotLocalEnabled = (value) => {
4147
+ const previous = lastConfirmedLocalEnabled();
4148
+ localEnabledStore.set({
4149
+ status: "ready",
4150
+ enabled: value
4151
+ });
4152
+ if (scope === void 0) return;
4153
+ const currentLocal = scope.getSnapshot().value?.local ?? {};
4154
+ scope.set("local", {
4155
+ ...currentLocal,
4156
+ enabled: value
4157
+ }).then(() => {}, (error) => {
4158
+ localEnabledStore.set({
4159
+ status: "ready",
4160
+ enabled: previous
4161
+ });
4162
+ console.error("[deeppilot] failed to persist local.enabled=" + String(value) + ": " + (error instanceof Error ? error.message : String(error)));
4163
+ });
4164
+ };
4165
+ const setDeepPilotLocalPort = async (value) => {
4166
+ const next = normalizeLocalPort(value);
4167
+ if (next !== value) throw new RangeError("local port must be an integer between 1024 and 65535");
4168
+ if (scope === void 0) throw new Error("settings scope unavailable");
4169
+ const previous = lastConfirmedLocalPort();
4170
+ localPortStore.set({
4171
+ status: "ready",
4172
+ value: next
4173
+ });
4174
+ const currentLocal = scope.getSnapshot().value?.local ?? {};
4175
+ try {
4176
+ await scope.set("local", {
4177
+ ...currentLocal,
4178
+ port: next
4179
+ });
4180
+ } catch (error) {
4181
+ localPortStore.set({
4182
+ status: "ready",
4183
+ value: previous
4184
+ });
4185
+ console.error("[deeppilot] failed to persist local.port=" + String(next) + ": " + (error instanceof Error ? error.message : String(error)));
4186
+ throw error;
4187
+ }
4188
+ };
3914
4189
  const remoteEnabledStore = createSnapshotStore({
3915
4190
  status: "loading",
3916
4191
  enabled: false
@@ -4005,6 +4280,8 @@ window.__ModuleLoader__.load({
4005
4280
  hooks: {
4006
4281
  deepPilotReport: store,
4007
4282
  deepPilotEnabled: enabledStore,
4283
+ deepPilotLocalEnabled: localEnabledStore,
4284
+ deepPilotLocalPort: localPortStore,
4008
4285
  deepPilotRemoteEnabled: remoteEnabledStore,
4009
4286
  deepPilotRemoteConnectionLimit: remoteConnectionLimitStore
4010
4287
  },
@@ -4016,6 +4293,8 @@ window.__ModuleLoader__.load({
4016
4293
  testRelay: testRelayConnection,
4017
4294
  testPush: sendTestPush,
4018
4295
  setDeepPilotEnabled,
4296
+ setDeepPilotLocalEnabled,
4297
+ setDeepPilotLocalPort,
4019
4298
  setDeepPilotRemoteEnabled,
4020
4299
  setDeepPilotRemoteConnectionLimit,
4021
4300
  t: (key, vars) => t(ctx, key, vars)