dsh-mobile 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -87,6 +87,9 @@ window.__ModuleLoader__.load({
87
87
  [data-dsh-mobile-center] pre { max-width:100%; overflow-x:auto; }
88
88
  [data-dsh-mobile-center] :is(img,video,canvas,svg) { max-width:100%; }
89
89
  [data-dsh-mobile-message-scroll] { box-sizing:border-box !important; width:100% !important; padding:8px 10px 20px !important; }
90
+ [data-dsh-mobile-history-loader] { position:relative !important; min-height:1px !important; }
91
+ [data-dsh-mobile-history-loader] button:not(:disabled) { position:absolute !important; width:1px !important; height:1px !important; margin:-1px !important; padding:0 !important; clip-path:inset(50%) !important; opacity:0 !important; overflow:hidden !important; pointer-events:none !important; }
92
+ [data-dsh-mobile-history-loader] button:disabled { min-height:28px !important; padding:4px 12px !important; }
90
93
  [data-dsh-mobile-message-column] { box-sizing:border-box !important; width:100% !important; max-width:none !important; margin:0 !important; padding:0 !important; gap:10px !important; }
91
94
  [data-dsh-mobile-message-column] > * { width:100% !important; max-width:100% !important; }
92
95
  [data-dsh-mobile-message-column] [data-disclosure-row] { box-sizing:border-box !important; display:grid !important; grid-template-columns:16px minmax(0,1fr) !important; grid-auto-rows:auto !important; align-items:center !important; column-gap:6px !important; width:100% !important; height:auto !important; min-height:40px !important; padding:4px 0 !important; }
@@ -159,6 +162,11 @@ window.__ModuleLoader__.load({
159
162
  function firstByClassSuffix(root, suffix) {
160
163
  return Array.from(root.querySelectorAll("[class]")).find((element) => classToken(element, suffix));
161
164
  }
165
+ const AUTO_HISTORY_THRESHOLD_PX = 64;
166
+ /** Whether a user-driven scroll moved upward into the automatic history-loading zone. */
167
+ function shouldAutoLoadEarlier(previousTop, currentTop) {
168
+ return currentTop <= AUTO_HISTORY_THRESHOLD_PX && currentTop < previousTop - .5;
169
+ }
162
170
  /** Add mobile semantics without replacing feature trees. */
163
171
  function installNativeMobileSurface() {
164
172
  document.documentElement.classList.add("dsh-native-mobile-active");
@@ -213,6 +221,28 @@ window.__ModuleLoader__.load({
213
221
  let transitionRestartFrame = 0;
214
222
  let transitionTimer = 0;
215
223
  let transitionTarget;
224
+ let historyScroller;
225
+ let historyPreviousTop = 0;
226
+ const historyLoadButton = () => {
227
+ return (historyScroller === void 0 ? void 0 : firstByClassSuffix(historyScroller, "_older"))?.querySelector("button") ?? void 0;
228
+ };
229
+ const onHistoryScroll = () => {
230
+ if (historyScroller === void 0) return;
231
+ const currentTop = Math.max(0, historyScroller.scrollTop);
232
+ const shouldLoad = shouldAutoLoadEarlier(historyPreviousTop, currentTop);
233
+ historyPreviousTop = currentTop;
234
+ if (!shouldLoad) return;
235
+ const button = historyLoadButton();
236
+ if (button === void 0 || button.disabled || button.getAttribute("aria-disabled") === "true") return;
237
+ button.click();
238
+ };
239
+ const bindHistoryScroller = (next) => {
240
+ if (historyScroller === next) return;
241
+ historyScroller?.removeEventListener("scroll", onHistoryScroll);
242
+ historyScroller = next;
243
+ historyPreviousTop = next?.scrollTop ?? 0;
244
+ historyScroller?.addEventListener("scroll", onHistoryScroll, { passive: true });
245
+ };
216
246
  const animateNavigation = (event) => {
217
247
  if (!(event.target instanceof Element)) return;
218
248
  const trigger = event.target.closest("button,a,[role=\"tab\"],[aria-selected]");
@@ -254,13 +284,28 @@ window.__ModuleLoader__.load({
254
284
  const center = frame === void 0 ? dedicatedCenter : firstByClassSuffix(frame, "_centerCol");
255
285
  const details = frame === void 0 ? void 0 : firstByClassSuffix(frame, "_detailsCol");
256
286
  const handle = frame === void 0 ? void 0 : firstByClassSuffix(frame, "_handle");
257
- if (center === void 0) return;
287
+ if (center === void 0) {
288
+ bindHistoryScroller(void 0);
289
+ return;
290
+ }
258
291
  if (center !== void 0) {
259
292
  center.dataset.dshMobileCenter = "true";
260
293
  center.querySelector("header")?.setAttribute("data-dsh-mobile-header", "true");
261
294
  viewArea = firstByClassSuffix(center, "_viewArea");
262
295
  if (viewArea !== void 0) viewArea.dataset.dshMobileView = "true";
263
296
  const conversation = center.querySelector("[data-conversation-scroll]");
297
+ bindHistoryScroller(conversation ?? void 0);
298
+ const historyLoader = conversation === null ? void 0 : firstByClassSuffix(conversation, "_older");
299
+ if (historyLoader !== void 0) {
300
+ historyLoader.dataset.dshMobileHistoryLoader = "true";
301
+ historyLoader.setAttribute("aria-live", "polite");
302
+ const button = historyLoader.querySelector("button");
303
+ if (button !== null) {
304
+ button.tabIndex = -1;
305
+ if (button.disabled) button.removeAttribute("aria-hidden");
306
+ else button.setAttribute("aria-hidden", "true");
307
+ }
308
+ }
264
309
  const messageColumn = conversation === null ? void 0 : firstByClassSuffix(conversation, "_column");
265
310
  const messageScroll = messageColumn?.parentElement;
266
311
  if (messageColumn !== void 0 && messageScroll !== null && messageScroll !== void 0 && classToken(messageScroll, "_scroll")) {
@@ -337,7 +382,11 @@ window.__ModuleLoader__.load({
337
382
  childList: true,
338
383
  subtree: true,
339
384
  attributes: true,
340
- attributeFilter: ["class", "style"]
385
+ attributeFilter: [
386
+ "class",
387
+ "style",
388
+ "disabled"
389
+ ]
341
390
  });
342
391
  backdrop.addEventListener("click", () => {
343
392
  if (sidebar?.dataset.open === "true") toggle?.click();
@@ -353,6 +402,7 @@ window.__ModuleLoader__.load({
353
402
  if (transitionRestartFrame !== 0) cancelAnimationFrame(transitionRestartFrame);
354
403
  if (transitionTimer !== 0) clearTimeout(transitionTimer);
355
404
  transitionTarget?.removeAttribute("data-dsh-mobile-view-transition");
405
+ historyScroller?.removeEventListener("scroll", onHistoryScroll);
356
406
  document.removeEventListener("pointerdown", onPointerDown, true);
357
407
  document.removeEventListener("keydown", onKeyDown, true);
358
408
  document.removeEventListener("click", animateNavigation);
@@ -406,6 +456,19 @@ window.__ModuleLoader__.load({
406
456
  if (!response.ok) throw new Error(typeof body.error === "string" ? body.error : `HTTP ${String(response.status)}`);
407
457
  return body;
408
458
  }
459
+ function officialFunnelSetupUrl(value) {
460
+ if (typeof value !== "string" || value.length > 2048) return "";
461
+ let url;
462
+ try {
463
+ url = new URL(value);
464
+ } catch {
465
+ return "";
466
+ }
467
+ const normalized = url.toString().replace(/\/$/u, "");
468
+ if (normalized === "https://tailscale.com/s/no-funnel" || normalized === "https://tailscale.com/s/https") return normalized;
469
+ if (url.protocol !== "https:" || url.hostname !== "login.tailscale.com" || url.port !== "" || url.username !== "" || url.password !== "") return "";
470
+ return url.toString();
471
+ }
409
472
  function installControl() {
410
473
  const root = element("div", "dsh-mobile-control");
411
474
  const panel = element("section", "dsh-mobile-control__panel");
@@ -418,6 +481,17 @@ window.__ModuleLoader__.load({
418
481
  close.type = "button";
419
482
  close.textContent = "×";
420
483
  close.setAttribute("aria-label", "收起移动访问");
484
+ const switcher = element("div", "dsh-mobile-control__switcher");
485
+ const lanTab = element("button", "dsh-mobile-control__tab is-active");
486
+ lanTab.type = "button";
487
+ lanTab.textContent = "局域网";
488
+ const remoteTab = element("button", "dsh-mobile-control__tab");
489
+ remoteTab.type = "button";
490
+ remoteTab.textContent = "远程";
491
+ lanTab.setAttribute("aria-pressed", "true");
492
+ remoteTab.setAttribute("aria-pressed", "false");
493
+ switcher.append(lanTab, remoteTab);
494
+ const lanView = element("div", "dsh-mobile-control__view");
421
495
  const access = element("div", "dsh-mobile-control__access");
422
496
  access.hidden = true;
423
497
  const accessLabel = element("span", "dsh-mobile-control__access-label");
@@ -451,13 +525,269 @@ window.__ModuleLoader__.load({
451
525
  manageRow.append(manageDevices, resetAll);
452
526
  const devicePanel = element("div", "dsh-mobile-control__devices");
453
527
  devicePanel.hidden = true;
528
+ const remoteView = element("div", "dsh-mobile-control__view is-remote");
529
+ remoteView.hidden = true;
530
+ const remoteIntro = element("p", "dsh-mobile-control__intro");
531
+ remoteIntro.textContent = "选择更适合你的远程通道。切换或关闭远程访问不会影响局域网。";
532
+ const providerSection = element("section", "dsh-mobile-control__provider-section");
533
+ const providerHeading = element("h3", "dsh-mobile-control__section-title");
534
+ providerHeading.textContent = "选择连接方式";
535
+ const providerInfo = element("div", "dsh-mobile-control__provider-info");
536
+ const providerInfoButton = element("button", "dsh-mobile-control__provider-info-button");
537
+ providerInfoButton.type = "button";
538
+ providerInfoButton.setAttribute("aria-label", "查看远程连接安全与网络说明");
539
+ providerInfoButton.setAttribute("aria-expanded", "false");
540
+ providerInfoButton.setAttribute("aria-controls", "dsh-mobile-provider-info");
541
+ providerInfoButton.setAttribute("aria-describedby", "dsh-mobile-provider-info");
542
+ const providerInfoGlyph = element("span", "dsh-mobile-control__provider-info-glyph");
543
+ providerInfoGlyph.textContent = "i";
544
+ providerInfoGlyph.setAttribute("aria-hidden", "true");
545
+ const providerInfoPopover = element("div", "dsh-mobile-control__provider-info-popover");
546
+ providerInfoPopover.id = "dsh-mobile-provider-info";
547
+ providerInfoPopover.setAttribute("role", "tooltip");
548
+ providerInfoPopover.hidden = true;
549
+ const providerInfoTitle = element("strong");
550
+ providerInfoTitle.textContent = "你始终可以放心";
551
+ const providerInfoText = element("span");
552
+ providerInfoText.textContent = "只有已配对设备能进入 DSH。cpolar 按需安装并可彻底清理;Tailscale 在中国大陆网络下可能连接缓慢、中断或无法使用,国内网络建议优先尝试 cpolar。";
553
+ providerInfoButton.append(providerInfoGlyph);
554
+ providerInfoPopover.append(providerInfoTitle, providerInfoText);
555
+ providerInfo.append(providerInfoButton, providerInfoPopover);
556
+ const providerChoices = element("div", "dsh-mobile-control__provider-choices");
557
+ providerChoices.setAttribute("role", "radiogroup");
558
+ providerChoices.setAttribute("aria-label", "远程连接方式");
559
+ const tailscaleChoice = element("button", "dsh-mobile-control__provider");
560
+ tailscaleChoice.type = "button";
561
+ tailscaleChoice.setAttribute("role", "radio");
562
+ tailscaleChoice.setAttribute("aria-checked", "true");
563
+ const tailscaleChoiceTop = element("span", "dsh-mobile-control__provider-top");
564
+ const tailscaleChoiceName = element("strong");
565
+ tailscaleChoiceName.textContent = "Tailscale Funnel";
566
+ const tailscaleChoiceBadge = element("span", "dsh-mobile-control__provider-badge");
567
+ tailscaleChoiceBadge.textContent = "内置";
568
+ const tailscaleChoiceDescription = element("span", "dsh-mobile-control__provider-description");
569
+ tailscaleChoiceDescription.textContent = "覆盖更广;中国大陆网络可能不稳定,首次需登录并允许 Funnel。";
570
+ tailscaleChoiceTop.append(tailscaleChoiceName, tailscaleChoiceBadge);
571
+ tailscaleChoice.append(tailscaleChoiceTop, tailscaleChoiceDescription);
572
+ const cpolarChoice = element("button", "dsh-mobile-control__provider");
573
+ cpolarChoice.type = "button";
574
+ cpolarChoice.setAttribute("role", "radio");
575
+ cpolarChoice.setAttribute("aria-checked", "false");
576
+ const cpolarChoiceTop = element("span", "dsh-mobile-control__provider-top");
577
+ const cpolarChoiceName = element("strong");
578
+ cpolarChoiceName.textContent = "cpolar";
579
+ const cpolarChoiceBadge = element("span", "dsh-mobile-control__provider-badge is-cpolar");
580
+ cpolarChoiceBadge.textContent = "国内网络优先";
581
+ const cpolarChoiceDescription = element("span", "dsh-mobile-control__provider-description");
582
+ cpolarChoiceDescription.textContent = "按需安装官方组件,适合国内网络环境。";
583
+ cpolarChoiceTop.append(cpolarChoiceName, cpolarChoiceBadge);
584
+ cpolarChoice.append(cpolarChoiceTop, cpolarChoiceDescription);
585
+ providerChoices.append(tailscaleChoice, cpolarChoice);
586
+ providerSection.append(providerHeading, providerInfo, providerChoices);
587
+ const cpolarSetup = element("section", "dsh-mobile-control__cpolar-setup");
588
+ cpolarSetup.hidden = true;
589
+ const cpolarSetupTitle = element("h3", "dsh-mobile-control__section-title");
590
+ cpolarSetupTitle.textContent = "准备 cpolar";
591
+ const cpolarComponentStatus = element("p", "dsh-mobile-control__component-status");
592
+ cpolarComponentStatus.textContent = "正在检查组件…";
593
+ const cpolarInstall = element("button", "dsh-mobile-control__primary");
594
+ cpolarInstall.type = "button";
595
+ cpolarInstall.textContent = "安装官方组件";
596
+ const cpolarAccount = element("div", "dsh-mobile-control__cpolar-account");
597
+ cpolarAccount.hidden = true;
598
+ const cpolarAccountText = element("p", "dsh-mobile-control__component-note");
599
+ cpolarAccountText.textContent = "登录 cpolar 官网后复制 Authtoken。令牌只保存在本机插件私有目录,不会显示在页面或日志中。";
600
+ const cpolarAccountLinks = element("div", "dsh-mobile-control__link-row");
601
+ const cpolarSignup = element("a", "dsh-mobile-control__text-link");
602
+ cpolarSignup.href = "https://dashboard.cpolar.com/signup";
603
+ cpolarSignup.target = "_blank";
604
+ cpolarSignup.rel = "noopener noreferrer";
605
+ cpolarSignup.textContent = "注册 cpolar";
606
+ const cpolarDashboard = element("a", "dsh-mobile-control__text-link");
607
+ cpolarDashboard.href = "https://dashboard.cpolar.com/auth";
608
+ cpolarDashboard.target = "_blank";
609
+ cpolarDashboard.rel = "noopener noreferrer";
610
+ cpolarDashboard.textContent = "打开控制台获取令牌";
611
+ cpolarAccountLinks.append(cpolarSignup, cpolarDashboard);
612
+ const cpolarTokenLabel = element("label", "dsh-mobile-control__token-label");
613
+ cpolarTokenLabel.textContent = "Authtoken";
614
+ const cpolarToken = element("input", "dsh-mobile-control__token");
615
+ cpolarToken.type = "password";
616
+ cpolarToken.autocomplete = "off";
617
+ cpolarToken.spellcheck = false;
618
+ cpolarToken.placeholder = "粘贴 cpolar Authtoken";
619
+ cpolarTokenLabel.append(cpolarToken);
620
+ const cpolarConfigure = element("button", "dsh-mobile-control__primary dsh-mobile-control__cpolar-connect");
621
+ cpolarConfigure.type = "button";
622
+ cpolarConfigure.textContent = "保存并连接";
623
+ cpolarAccount.append(cpolarAccountText, cpolarAccountLinks, cpolarTokenLabel, cpolarConfigure);
624
+ const cpolarDetails = element("details", "dsh-mobile-control__details");
625
+ const cpolarDetailsSummary = element("summary");
626
+ cpolarDetailsSummary.textContent = "组件来源与清理说明";
627
+ const cpolarDetailsBody = element("div", "dsh-mobile-control__details-body");
628
+ const cpolarDetailsText = element("p");
629
+ cpolarDetailsText.textContent = "仅在你点击安装后从 cpolar 官网下载并校验固定版本。不会写入系统服务、开机启动、注册表或 PATH。";
630
+ const cpolarStorage = element("code", "dsh-mobile-control__storage");
631
+ cpolarStorage.textContent = "插件私有目录";
632
+ const cpolarOfficial = element("a", "dsh-mobile-control__text-link");
633
+ cpolarOfficial.href = "https://www.cpolar.com/download";
634
+ cpolarOfficial.target = "_blank";
635
+ cpolarOfficial.rel = "noopener noreferrer";
636
+ cpolarOfficial.textContent = "官方下载安装页";
637
+ const cpolarTerms = element("a", "dsh-mobile-control__text-link");
638
+ cpolarTerms.href = "https://www.cpolar.com/tos";
639
+ cpolarTerms.target = "_blank";
640
+ cpolarTerms.rel = "noopener noreferrer";
641
+ cpolarTerms.textContent = "服务条款";
642
+ const cpolarPurge = element("button", "dsh-mobile-control__danger");
643
+ cpolarPurge.type = "button";
644
+ cpolarPurge.textContent = "彻底移除 cpolar 组件与配置";
645
+ cpolarDetailsBody.append(cpolarDetailsText, cpolarStorage, cpolarOfficial, cpolarTerms, cpolarPurge);
646
+ cpolarDetails.append(cpolarDetailsSummary, cpolarDetailsBody);
647
+ cpolarSetup.append(cpolarSetupTitle, cpolarComponentStatus, cpolarInstall, cpolarAccount, cpolarDetails);
648
+ const tailscaleInfo = element("details", "dsh-mobile-control__details");
649
+ const tailscaleInfoSummary = element("summary");
650
+ tailscaleInfoSummary.textContent = "Tailscale 使用说明";
651
+ const tailscaleInfoBody = element("div", "dsh-mobile-control__details-body");
652
+ const tailscaleInfoText = element("p");
653
+ tailscaleInfoText.textContent = "运行组件已随插件提供。首次连接会打开 Tailscale 官方登录和 Funnel 授权页;插件不会接触你的账号密码。";
654
+ tailscaleInfoBody.append(tailscaleInfoText);
655
+ tailscaleInfo.append(tailscaleInfoSummary, tailscaleInfoBody);
656
+ const remoteAccess = element("div", "dsh-mobile-control__access");
657
+ remoteAccess.hidden = true;
658
+ const remoteAccessLabel = element("span", "dsh-mobile-control__access-label");
659
+ remoteAccessLabel.textContent = "远程地址";
660
+ const remoteAccessLink = element("a", "dsh-mobile-control__access-link");
661
+ remoteAccessLink.target = "_blank";
662
+ remoteAccessLink.rel = "noreferrer";
663
+ remoteAccess.append(remoteAccessLabel, remoteAccessLink);
664
+ const remoteQr = element("div", "dsh-mobile-control__qr");
665
+ remoteQr.hidden = true;
666
+ const remoteStatus = element("p", "dsh-mobile-control__status");
667
+ remoteStatus.textContent = "正在读取远程状态…";
668
+ remoteStatus.setAttribute("aria-live", "polite");
669
+ const remoteGuide = element("section", "dsh-mobile-control__guide");
670
+ remoteGuide.hidden = true;
671
+ remoteGuide.setAttribute("aria-label", "Tailscale Funnel 启用步骤");
672
+ const remoteGuideTitle = element("h3", "dsh-mobile-control__guide-title");
673
+ remoteGuideTitle.textContent = "远程访问设置 · 第 2 步";
674
+ const remoteGuideSummary = element("p", "dsh-mobile-control__guide-summary");
675
+ remoteGuideSummary.textContent = "Tailscale 登录已完成。还需为这台电脑允许 Funnel,官方页面会同时启用 HTTPS。";
676
+ const remoteGuideSteps = element("ol", "dsh-mobile-control__guide-steps");
677
+ for (const text of [
678
+ "打开当前节点的 Tailscale 官方授权页。",
679
+ "确认启用 Funnel;无需再次登录 DSH。",
680
+ "返回 DSH,插件会自动检查并建立连接。"
681
+ ]) {
682
+ const item = element("li");
683
+ item.textContent = text;
684
+ remoteGuideSteps.append(item);
685
+ }
686
+ const remoteGuideNote = element("p", "dsh-mobile-control__guide-note");
687
+ remoteGuideNote.textContent = "需要使用 Owner、Admin 或 Network admin 账号。";
688
+ const remoteGuideActions = element("div", "dsh-mobile-control__guide-actions");
689
+ const remoteSetup = element("button", "dsh-mobile-control__primary");
690
+ remoteSetup.type = "button";
691
+ remoteSetup.textContent = "继续完成 Funnel 授权";
692
+ const remoteSetupRetry = element("button", "dsh-mobile-control__secondary");
693
+ remoteSetupRetry.type = "button";
694
+ remoteSetupRetry.textContent = "已完成,立即重试";
695
+ remoteGuideActions.append(remoteSetup, remoteSetupRetry);
696
+ remoteGuide.append(remoteGuideTitle, remoteGuideSummary, remoteGuideSteps, remoteGuideNote, remoteGuideActions);
697
+ const remoteActions = element("div", "dsh-mobile-control__actions");
698
+ const remoteToggle = element("button", "dsh-mobile-control__primary");
699
+ remoteToggle.type = "button";
700
+ remoteToggle.textContent = "启用远程访问";
701
+ const remoteLogin = element("button", "dsh-mobile-control__primary");
702
+ remoteLogin.type = "button";
703
+ remoteLogin.textContent = "继续登录";
704
+ remoteLogin.hidden = true;
705
+ const remoteReconnect = element("button", "dsh-mobile-control__secondary");
706
+ remoteReconnect.type = "button";
707
+ remoteReconnect.textContent = "重新连接";
708
+ remoteReconnect.hidden = true;
709
+ const remotePair = element("button", "dsh-mobile-control__secondary");
710
+ remotePair.type = "button";
711
+ remotePair.textContent = "生成远程配对二维码";
712
+ remotePair.disabled = true;
713
+ remoteActions.append(remoteToggle, remoteLogin, remoteReconnect, remotePair);
714
+ const remoteManageRow = element("div", "dsh-mobile-control__manage-row");
715
+ const remoteDevices = element("button", "dsh-mobile-control__manage");
716
+ remoteDevices.type = "button";
717
+ remoteDevices.textContent = "管理远程设备";
718
+ remoteDevices.disabled = true;
719
+ const remoteReset = element("button", "dsh-mobile-control__manage");
720
+ remoteReset.type = "button";
721
+ remoteReset.textContent = "退出并清除远程登录";
722
+ remoteManageRow.append(remoteDevices, remoteReset);
723
+ const remoteDevicePanel = element("div", "dsh-mobile-control__devices");
724
+ remoteDevicePanel.hidden = true;
454
725
  header.append(title, close);
455
726
  actions.append(toggle, pair, linkPair);
456
- panel.append(header, access, qrBox, status, extensionStatus, actions, manageRow, devicePanel);
727
+ lanView.append(access, qrBox, status, extensionStatus, actions, manageRow, devicePanel);
728
+ remoteView.append(remoteIntro, providerSection, cpolarSetup, tailscaleInfo, remoteAccess, remoteQr, remoteStatus, remoteGuide, remoteActions, remoteManageRow, remoteDevicePanel);
729
+ panel.append(header, switcher, lanView, remoteView);
457
730
  root.append(panel);
458
731
  document.body.append(root);
459
732
  let running = false;
460
733
  let origin = "";
734
+ let remoteRunning = false;
735
+ let remoteReady = false;
736
+ let remoteProvider = "tailscale";
737
+ let remoteLoginUrl = "";
738
+ let remoteSetupUrl = "";
739
+ let remoteSetupPending = false;
740
+ let remoteSetupOpenedAt = 0;
741
+ let remoteReconnectBusy = false;
742
+ let remoteProviderBusy = false;
743
+ let cpolarInstalled = false;
744
+ let cpolarConfigured = false;
745
+ let providerInfoPinned = false;
746
+ let providerInfoHovered = false;
747
+ const syncProviderInfo = () => {
748
+ const open = providerInfoPinned || providerInfoHovered || providerInfo.contains(document.activeElement);
749
+ providerInfoPopover.hidden = !open;
750
+ providerInfoButton.setAttribute("aria-expanded", String(open));
751
+ };
752
+ providerInfo.addEventListener("pointerenter", () => {
753
+ providerInfoHovered = true;
754
+ syncProviderInfo();
755
+ });
756
+ providerInfo.addEventListener("pointerleave", () => {
757
+ providerInfoHovered = false;
758
+ syncProviderInfo();
759
+ });
760
+ providerInfo.addEventListener("focusin", syncProviderInfo);
761
+ providerInfo.addEventListener("focusout", () => {
762
+ window.setTimeout(syncProviderInfo, 0);
763
+ });
764
+ providerInfoButton.addEventListener("click", () => {
765
+ providerInfoPinned = !providerInfoPinned;
766
+ syncProviderInfo();
767
+ });
768
+ providerInfoButton.addEventListener("keydown", (event) => {
769
+ if (event.key !== "Escape") return;
770
+ providerInfoPinned = false;
771
+ providerInfoHovered = false;
772
+ providerInfoPopover.hidden = true;
773
+ providerInfoButton.setAttribute("aria-expanded", "false");
774
+ });
775
+ const selectView = (remote) => {
776
+ lanView.hidden = remote;
777
+ remoteView.hidden = !remote;
778
+ lanTab.classList.toggle("is-active", !remote);
779
+ remoteTab.classList.toggle("is-active", remote);
780
+ lanTab.setAttribute("aria-pressed", String(!remote));
781
+ remoteTab.setAttribute("aria-pressed", String(remote));
782
+ title.textContent = remote ? "远程访问" : "局域网访问";
783
+ };
784
+ lanTab.addEventListener("click", () => {
785
+ selectView(false);
786
+ });
787
+ remoteTab.addEventListener("click", () => {
788
+ selectView(true);
789
+ loadRemote();
790
+ });
461
791
  const setOpen = (open) => {
462
792
  panel.hidden = !open;
463
793
  for (const trigger of document.querySelectorAll(".dsh-mobile-control__trigger")) trigger.setAttribute("aria-expanded", String(open));
@@ -470,7 +800,7 @@ window.__ModuleLoader__.load({
470
800
  accessLink.textContent = origin;
471
801
  accessLink.title = origin;
472
802
  status.classList.toggle("is-running", running);
473
- status.textContent = running ? "移动访问已开启。" : "已关闭。DSH 仍只在本机可用。";
803
+ status.textContent = running ? "局域网访问已开启。" : "局域网访问已关闭。";
474
804
  const extensionData = data.extensions;
475
805
  if (extensionData !== null && typeof extensionData === "object") {
476
806
  const loaded = typeof extensionData.loaded === "number" ? extensionData.loaded : 0;
@@ -479,16 +809,16 @@ window.__ModuleLoader__.load({
479
809
  extensionStatus.textContent = failed === 0 ? `扩展:${String(loaded)} 个已加载` : `扩展:${String(loaded)} 个已加载,${String(failed)} 个加载失败`;
480
810
  } else extensionStatus.hidden = true;
481
811
  if (!running) qrBox.hidden = true;
482
- toggle.textContent = running ? "关闭移动访问" : "开启移动访问";
812
+ toggle.textContent = running ? "关闭局域网访问" : "开启局域网访问";
483
813
  pair.disabled = !running;
484
814
  linkPair.disabled = !running;
485
815
  manageDevices.disabled = !running;
486
816
  resetAll.disabled = !running;
487
817
  };
488
- const showQr = (svg) => {
489
- qrBox.replaceChildren();
818
+ const showQr = (svg, target = qrBox) => {
819
+ target.replaceChildren();
490
820
  if (svg === "") {
491
- qrBox.hidden = true;
821
+ target.hidden = true;
492
822
  return;
493
823
  }
494
824
  const image = element("img");
@@ -496,11 +826,11 @@ window.__ModuleLoader__.load({
496
826
  image.width = 176;
497
827
  image.height = 176;
498
828
  image.src = `data:image/svg+xml;base64,${btoa(svg)}`;
499
- qrBox.hidden = false;
500
- qrBox.append(image);
829
+ target.hidden = false;
830
+ target.append(image);
501
831
  };
502
832
  const openPairing = (target) => {
503
- requestJson("/api/mobile-access/pairing/open", {
833
+ requestJson("/api/mobile-access/lan/pairing/open", {
504
834
  method: "POST",
505
835
  body: "{}"
506
836
  }).then(async (data) => {
@@ -526,7 +856,7 @@ window.__ModuleLoader__.load({
526
856
  };
527
857
  toggle.addEventListener("click", () => {
528
858
  toggle.disabled = true;
529
- requestJson("/api/mobile-access/control", {
859
+ requestJson("/api/mobile-access/lan/control", {
530
860
  method: "POST",
531
861
  body: JSON.stringify({ running: !running })
532
862
  }).then(render, (error) => {
@@ -556,7 +886,7 @@ window.__ModuleLoader__.load({
556
886
  revoke.textContent = "撤销";
557
887
  const id = typeof device.id === "string" ? device.id : "";
558
888
  revoke.addEventListener("click", () => {
559
- requestJson("/api/mobile-access/devices/revoke", {
889
+ requestJson("/api/mobile-access/lan/devices/revoke", {
560
890
  method: "POST",
561
891
  body: JSON.stringify({ deviceId: id })
562
892
  }).then(loadDevices, (error) => {
@@ -568,7 +898,7 @@ window.__ModuleLoader__.load({
568
898
  }
569
899
  };
570
900
  const loadDevices = () => {
571
- requestJson("/api/mobile-access/devices").then(renderDevices, (error) => {
901
+ requestJson("/api/mobile-access/lan/devices").then(renderDevices, (error) => {
572
902
  status.textContent = String(error);
573
903
  });
574
904
  };
@@ -579,13 +909,315 @@ window.__ModuleLoader__.load({
579
909
  });
580
910
  resetAll.addEventListener("click", () => {
581
911
  if (!window.confirm("确定要移除所有配对设备吗?此操作会立即终止已连接设备。")) return;
582
- requestJson("/api/mobile-access/devices/reset", {
912
+ requestJson("/api/mobile-access/lan/devices/reset", {
583
913
  method: "POST",
584
914
  body: JSON.stringify({ confirm: true })
585
915
  }).then(loadDevices, (error) => {
586
916
  status.textContent = String(error);
587
917
  });
588
918
  });
919
+ const renderRemote = (data) => {
920
+ remoteRunning = data.running === true;
921
+ remoteProvider = data.provider === "cpolar" ? "cpolar" : "tailscale";
922
+ const cpolar = remoteProvider === "cpolar";
923
+ tailscaleChoice.classList.toggle("is-selected", !cpolar);
924
+ cpolarChoice.classList.toggle("is-selected", cpolar);
925
+ tailscaleChoice.setAttribute("aria-checked", String(!cpolar));
926
+ cpolarChoice.setAttribute("aria-checked", String(cpolar));
927
+ tailscaleChoice.disabled = remoteProviderBusy;
928
+ cpolarChoice.disabled = remoteProviderBusy;
929
+ cpolarSetup.hidden = !cpolar;
930
+ tailscaleInfo.hidden = cpolar;
931
+ remoteReset.textContent = cpolar ? "关闭并清除远程设备" : "退出并清除远程登录";
932
+ const providers = data.providers !== null && typeof data.providers === "object" ? data.providers : {};
933
+ const cpolarProvider = providers.cpolar !== null && typeof providers.cpolar === "object" ? providers.cpolar : {};
934
+ const component = cpolarProvider.component !== null && typeof cpolarProvider.component === "object" ? cpolarProvider.component : {};
935
+ cpolarInstalled = component.installed === true;
936
+ cpolarConfigured = component.configured === true;
937
+ cpolarChoiceBadge.textContent = cpolarConfigured ? "已就绪" : cpolarInstalled ? "已安装" : "国内网络优先";
938
+ const cpolarSupported = component.supported !== false;
939
+ const componentVersion = typeof component.version === "string" ? component.version : "";
940
+ const componentDownloadBytes = typeof component.downloadBytes === "number" ? component.downloadBytes : 0;
941
+ const componentStorage = typeof component.storagePath === "string" ? component.storagePath : "DSH Mobile 插件私有目录";
942
+ cpolarStorage.textContent = componentStorage;
943
+ cpolarStorage.title = componentStorage;
944
+ cpolarInstall.hidden = cpolarInstalled || !cpolarSupported;
945
+ cpolarInstall.textContent = componentDownloadBytes > 0 ? `安装官方组件 · ${(componentDownloadBytes / 1024 / 1024).toFixed(1)} MB` : "安装官方组件";
946
+ cpolarInstall.disabled = remoteProviderBusy;
947
+ cpolarAccount.hidden = !cpolarInstalled || cpolarConfigured;
948
+ cpolarConfigure.disabled = remoteProviderBusy;
949
+ cpolarPurge.hidden = !cpolarInstalled && !cpolarConfigured;
950
+ cpolarComponentStatus.textContent = !cpolarSupported ? "当前仅支持 Windows x64。你仍可选择内置的 Tailscale Funnel。" : !cpolarInstalled ? "尚未安装。只有点击下方按钮后,才会从 cpolar 官网下载固定版本。" : !cpolarConfigured ? `官方组件 ${componentVersion} 已校验,下一步只需保存账号令牌。` : `官方组件 ${componentVersion} 与本机账号配置已就绪。`;
951
+ const state = typeof data.state === "string" ? data.state : "error";
952
+ const errorCode = typeof data.errorCode === "string" ? data.errorCode : "";
953
+ const remoteOrigin = typeof data.origin === "string" ? data.origin : "";
954
+ remoteLoginUrl = typeof data.loginUrl === "string" ? data.loginUrl : "";
955
+ const candidateSetupUrl = cpolar ? "" : officialFunnelSetupUrl(data.setupUrl);
956
+ remoteSetupUrl = candidateSetupUrl !== "" ? candidateSetupUrl : {
957
+ funnel_permission_required: "https://tailscale.com/s/no-funnel",
958
+ funnel_https_required: "https://tailscale.com/s/https",
959
+ funnel_start_failed: "https://tailscale.com/s/no-funnel"
960
+ }[errorCode] ?? "";
961
+ const needsFunnelSetup = state === "error" && remoteSetupUrl !== "";
962
+ remoteReady = remoteRunning && state === "ready" && remoteOrigin !== "";
963
+ remoteAccess.hidden = !remoteReady;
964
+ remoteAccessLink.href = remoteOrigin;
965
+ remoteAccessLink.textContent = remoteOrigin;
966
+ remoteAccessLink.title = remoteOrigin;
967
+ remoteStatus.classList.toggle("is-running", remoteReady);
968
+ const labels = {
969
+ off: "远程访问未启用。局域网访问不受影响。",
970
+ unavailable: cpolar ? "cpolar 尚未安装或未完成本机账号配置。" : "当前电脑缺少 Funnel 运行组件,请重新安装完整插件包。",
971
+ starting: cpolar ? "正在连接 cpolar 国内节点…" : "正在启动 Tailscale 安全通道…",
972
+ "needs-login": "需要在浏览器完成一次 Tailscale 登录。插件不会读取你的密码。",
973
+ connecting: cpolar ? "公网地址已分配,正在启动 DSH 认证网关…" : "登录完成,正在建立公开 HTTPS 地址…",
974
+ ready: "远程访问已就绪。只有已配对设备可以进入 DSH。",
975
+ error: "远程连接未建立。可重新连接,局域网访问仍可正常使用。"
976
+ };
977
+ remoteStatus.textContent = remoteSetupPending && needsFunnelSetup ? "Tailscale 官方页面已打开。完成启用后返回 DSH,这里会自动重新连接。" : state === "error" ? {
978
+ funnel_permission_required: "登录已完成。请继续授权 Funnel,完成后会自动建立远程连接。",
979
+ funnel_https_required: "登录已完成。请继续授权 Funnel,官方页面会同时启用 HTTPS。",
980
+ funnel_start_failed: "登录已完成。请继续完成 Tailscale Funnel 的首次授权。",
981
+ tailscale_dns_missing: "Tailscale 暂未提供远程地址。请重新连接并确认已完成登录。",
982
+ gateway_start_failed: "远程网关启动失败。请重新连接,局域网访问不受影响。",
983
+ control_channel_failed: "远程组件连接中断。请重新连接。",
984
+ cpolar_component_missing: "cpolar 官方组件尚未安装。请先完成上方准备步骤。",
985
+ cpolar_component_invalid: "cpolar 组件校验失败。请彻底移除后重新安装。",
986
+ cpolar_config_missing: "cpolar 尚未保存账号令牌。请先完成上方准备步骤。",
987
+ cpolar_config_invalid: "cpolar 本机配置无效。请重新保存账号令牌。",
988
+ cpolar_port_unavailable: "无法分配本机远程网关端口,请重试。",
989
+ cpolar_launch_failed: "cpolar 客户端未能启动。",
990
+ cpolar_start_timeout: "连接 cpolar 国内节点超时,请重新连接。",
991
+ cpolar_stopped: "cpolar 连接已停止。",
992
+ cpolar_exited: "cpolar 连接意外退出,请重新连接。",
993
+ cpolar_invalid_output: "cpolar 返回了无法识别的状态。",
994
+ cpolar_invalid_origin: "cpolar 返回的公网地址未通过校验。"
995
+ }[errorCode] ?? labels.error : labels[state] ?? labels.error;
996
+ remoteGuide.hidden = !needsFunnelSetup;
997
+ remoteSetup.disabled = remoteSetupUrl === "" || remoteReconnectBusy;
998
+ remoteSetupRetry.disabled = remoteReconnectBusy;
999
+ remoteToggle.textContent = remoteRunning ? "关闭远程访问" : "启用远程访问";
1000
+ remoteToggle.disabled = remoteProviderBusy || cpolar && (!cpolarInstalled || !cpolarConfigured);
1001
+ remoteLogin.hidden = cpolar || state !== "needs-login" || remoteLoginUrl === "";
1002
+ remoteReconnect.hidden = needsFunnelSetup || state !== "error" && state !== "unavailable" || cpolar && (!cpolarInstalled || !cpolarConfigured);
1003
+ remoteActions.hidden = cpolar && (!cpolarInstalled || !cpolarConfigured);
1004
+ remotePair.disabled = !remoteReady;
1005
+ remoteDevices.disabled = !remoteReady;
1006
+ if (!remoteReady) remoteQr.hidden = true;
1007
+ if (!needsFunnelSetup) remoteSetupPending = false;
1008
+ };
1009
+ const loadRemote = () => {
1010
+ requestJson("/api/mobile-access/remote/control").then(renderRemote, (error) => {
1011
+ remoteStatus.textContent = String(error);
1012
+ });
1013
+ };
1014
+ const chooseRemoteProvider = (provider) => {
1015
+ if (remoteProviderBusy || provider === remoteProvider) return;
1016
+ if (remoteRunning && !window.confirm("切换连接方式会先关闭当前远程通道。局域网和配对设备不会受影响,是否继续?")) return;
1017
+ remoteProviderBusy = true;
1018
+ tailscaleChoice.disabled = true;
1019
+ cpolarChoice.disabled = true;
1020
+ remoteStatus.textContent = provider === "cpolar" ? "正在切换到 cpolar…" : "正在切换到 Tailscale Funnel…";
1021
+ requestJson("/api/mobile-access/remote/provider", {
1022
+ method: "POST",
1023
+ body: JSON.stringify({ provider })
1024
+ }).then(renderRemote, (error) => {
1025
+ remoteStatus.textContent = String(error);
1026
+ }).finally(() => {
1027
+ remoteProviderBusy = false;
1028
+ loadRemote();
1029
+ });
1030
+ };
1031
+ tailscaleChoice.addEventListener("click", () => {
1032
+ chooseRemoteProvider("tailscale");
1033
+ });
1034
+ cpolarChoice.addEventListener("click", () => {
1035
+ chooseRemoteProvider("cpolar");
1036
+ });
1037
+ cpolarInstall.addEventListener("click", () => {
1038
+ if (remoteProviderBusy) return;
1039
+ if (!window.confirm("将从 cpolar 官方网站下载并校验固定版本(约 7.3 MB),仅解压到 DSH Mobile 私有目录。不会安装系统服务、写入 PATH/注册表或设置开机启动。是否继续?")) return;
1040
+ remoteProviderBusy = true;
1041
+ cpolarInstall.disabled = true;
1042
+ cpolarInstall.textContent = "正在下载并校验…";
1043
+ remoteStatus.textContent = "正在安装 cpolar 官方组件。完成前请保持 DSH 运行。";
1044
+ requestJson("/api/mobile-access/remote/cpolar/component/install", {
1045
+ method: "POST",
1046
+ body: JSON.stringify({ confirm: true })
1047
+ }).then(renderRemote, (error) => {
1048
+ remoteStatus.textContent = `组件安装失败:${String(error)}`;
1049
+ }).finally(() => {
1050
+ remoteProviderBusy = false;
1051
+ loadRemote();
1052
+ });
1053
+ });
1054
+ cpolarConfigure.addEventListener("click", () => {
1055
+ if (remoteProviderBusy) return;
1056
+ const authtoken = cpolarToken.value.trim();
1057
+ if (authtoken.length < 20 || /\s/u.test(authtoken)) {
1058
+ remoteStatus.textContent = "请粘贴 cpolar 控制台提供的完整 Authtoken。";
1059
+ cpolarToken.focus();
1060
+ return;
1061
+ }
1062
+ remoteProviderBusy = true;
1063
+ cpolarConfigure.disabled = true;
1064
+ cpolarConfigure.setAttribute("aria-busy", "true");
1065
+ cpolarConfigure.textContent = "正在保存…";
1066
+ requestJson("/api/mobile-access/remote/cpolar/configure", {
1067
+ method: "POST",
1068
+ body: JSON.stringify({ authtoken })
1069
+ }).then(() => {
1070
+ cpolarToken.value = "";
1071
+ remoteStatus.textContent = "账号配置已保存,正在建立 cpolar 远程通道…";
1072
+ return requestJson("/api/mobile-access/remote/control", {
1073
+ method: "POST",
1074
+ body: JSON.stringify({ running: true })
1075
+ });
1076
+ }).then(renderRemote, (error) => {
1077
+ remoteStatus.textContent = `配置失败:${String(error)}`;
1078
+ }).finally(() => {
1079
+ remoteProviderBusy = false;
1080
+ cpolarConfigure.setAttribute("aria-busy", "false");
1081
+ cpolarConfigure.textContent = "保存并连接";
1082
+ loadRemote();
1083
+ });
1084
+ });
1085
+ cpolarPurge.addEventListener("click", () => {
1086
+ if (remoteProviderBusy) return;
1087
+ if (!window.confirm("彻底移除 DSH Mobile 私有目录中的 cpolar 组件、令牌配置和运行日志?不会影响局域网、DSH 数据或系统中的其他程序。")) return;
1088
+ remoteProviderBusy = true;
1089
+ cpolarPurge.disabled = true;
1090
+ remoteStatus.textContent = "正在关闭通道并清理 DSH Mobile 管理的 cpolar 文件…";
1091
+ requestJson("/api/mobile-access/remote/cpolar/component/purge", {
1092
+ method: "POST",
1093
+ body: JSON.stringify({ confirm: true })
1094
+ }).then(renderRemote, (error) => {
1095
+ remoteStatus.textContent = `清理失败:${String(error)}`;
1096
+ }).finally(() => {
1097
+ remoteProviderBusy = false;
1098
+ cpolarPurge.disabled = false;
1099
+ loadRemote();
1100
+ });
1101
+ });
1102
+ remoteToggle.addEventListener("click", () => {
1103
+ remoteToggle.disabled = true;
1104
+ requestJson("/api/mobile-access/remote/control", {
1105
+ method: "POST",
1106
+ body: JSON.stringify({ running: !remoteRunning })
1107
+ }).then(renderRemote, (error) => {
1108
+ remoteStatus.textContent = String(error);
1109
+ }).finally(loadRemote);
1110
+ });
1111
+ remoteLogin.addEventListener("click", () => {
1112
+ if (remoteLoginUrl !== "") window.open(remoteLoginUrl, "_blank", "noopener,noreferrer");
1113
+ });
1114
+ const reconnectRemote = () => {
1115
+ if (remoteReconnectBusy) return;
1116
+ remoteReconnectBusy = true;
1117
+ remoteReconnect.disabled = true;
1118
+ remoteSetup.disabled = true;
1119
+ remoteSetupRetry.disabled = true;
1120
+ remoteStatus.textContent = remoteProvider === "cpolar" ? "正在重新连接 cpolar 国内节点…" : "正在确认 Tailscale 设置并重新连接…";
1121
+ requestJson("/api/mobile-access/remote/reconnect", {
1122
+ method: "POST",
1123
+ body: "{}"
1124
+ }).then(renderRemote, (error) => {
1125
+ remoteStatus.textContent = String(error);
1126
+ }).finally(() => {
1127
+ remoteReconnectBusy = false;
1128
+ remoteReconnect.disabled = false;
1129
+ remoteSetup.disabled = remoteSetupUrl === "";
1130
+ remoteSetupRetry.disabled = false;
1131
+ });
1132
+ };
1133
+ remoteReconnect.addEventListener("click", reconnectRemote);
1134
+ remoteSetupRetry.addEventListener("click", () => {
1135
+ remoteSetupPending = false;
1136
+ reconnectRemote();
1137
+ });
1138
+ remoteSetup.addEventListener("click", () => {
1139
+ if (remoteSetupUrl === "") return;
1140
+ remoteSetupPending = true;
1141
+ remoteSetupOpenedAt = Date.now();
1142
+ remoteStatus.textContent = "Tailscale 官方页面已打开。完成启用后返回 DSH,这里会自动重新连接。";
1143
+ window.open(remoteSetupUrl, "_blank", "noopener,noreferrer");
1144
+ });
1145
+ const retryAfterSetup = () => {
1146
+ if (!remoteSetupPending || document.visibilityState === "hidden" || Date.now() - remoteSetupOpenedAt < 800) return;
1147
+ remoteSetupPending = false;
1148
+ reconnectRemote();
1149
+ };
1150
+ window.addEventListener("focus", retryAfterSetup);
1151
+ document.addEventListener("visibilitychange", retryAfterSetup);
1152
+ remotePair.addEventListener("click", () => {
1153
+ remotePair.disabled = true;
1154
+ requestJson("/api/mobile-access/remote/pairing/open", {
1155
+ method: "POST",
1156
+ body: "{}"
1157
+ }).then(async (data) => {
1158
+ const pairUrl = typeof data.pairUrl === "string" ? data.pairUrl : "";
1159
+ showQr(typeof data.qrSvg === "string" ? data.qrSvg : "", remoteQr);
1160
+ if (pairUrl !== "") try {
1161
+ await navigator.clipboard.writeText(pairUrl);
1162
+ } catch {}
1163
+ remoteStatus.textContent = "远程配对二维码已生成。请在 App 的“远程访问”中扫描。";
1164
+ }, (error) => {
1165
+ remoteStatus.textContent = String(error);
1166
+ }).finally(() => {
1167
+ remotePair.disabled = !remoteReady;
1168
+ });
1169
+ });
1170
+ const renderRemoteDevices = (data) => {
1171
+ const devices = Array.isArray(data.devices) ? data.devices : [];
1172
+ remoteDevicePanel.replaceChildren();
1173
+ if (devices.length === 0) {
1174
+ const empty = element("p", "dsh-mobile-control__device-empty");
1175
+ empty.textContent = "暂无远程配对设备。";
1176
+ remoteDevicePanel.append(empty);
1177
+ return;
1178
+ }
1179
+ for (const device of devices) {
1180
+ const row = element("div", "dsh-mobile-control__device");
1181
+ const label = element("span", "dsh-mobile-control__device-label");
1182
+ label.textContent = typeof device.label === "string" ? device.label : "设备";
1183
+ const meta = element("span", "dsh-mobile-control__device-meta");
1184
+ meta.textContent = `到期 ${formatTime(device.expiresAt)}`;
1185
+ const revoke = element("button", "dsh-mobile-control__device-revoke");
1186
+ revoke.type = "button";
1187
+ revoke.textContent = "撤销";
1188
+ const id = typeof device.id === "string" ? device.id : "";
1189
+ revoke.addEventListener("click", () => {
1190
+ requestJson("/api/mobile-access/remote/devices/revoke", {
1191
+ method: "POST",
1192
+ body: JSON.stringify({ deviceId: id })
1193
+ }).then(loadRemoteDevices, (error) => {
1194
+ remoteStatus.textContent = String(error);
1195
+ });
1196
+ });
1197
+ row.append(label, meta, revoke);
1198
+ remoteDevicePanel.append(row);
1199
+ }
1200
+ };
1201
+ const loadRemoteDevices = () => {
1202
+ requestJson("/api/mobile-access/remote/devices").then(renderRemoteDevices, (error) => {
1203
+ remoteStatus.textContent = String(error);
1204
+ });
1205
+ };
1206
+ remoteDevices.addEventListener("click", () => {
1207
+ const show = remoteDevicePanel.hidden;
1208
+ remoteDevicePanel.hidden = !show;
1209
+ if (show) loadRemoteDevices();
1210
+ });
1211
+ remoteReset.addEventListener("click", () => {
1212
+ const prompt = remoteProvider === "cpolar" ? "关闭 cpolar 远程通道并移除所有远程配对设备?不会修改你的 cpolar 账号或其他隧道。" : "退出电脑上的 Tailscale 登录并移除所有远程配对设备?局域网配置不会改变。";
1213
+ if (!window.confirm(prompt)) return;
1214
+ requestJson("/api/mobile-access/remote/reset", {
1215
+ method: "POST",
1216
+ body: JSON.stringify({ confirm: true })
1217
+ }).then(renderRemote, (error) => {
1218
+ remoteStatus.textContent = String(error);
1219
+ });
1220
+ });
589
1221
  pair.addEventListener("click", () => {
590
1222
  pair.disabled = true;
591
1223
  openPairing("key");
@@ -599,14 +1231,26 @@ window.__ModuleLoader__.load({
599
1231
  });
600
1232
  const dismiss = (event) => {
601
1233
  if (panel.hidden || !(event.target instanceof Node)) return;
1234
+ if (!providerInfo.contains(event.target)) {
1235
+ providerInfoPinned = false;
1236
+ providerInfoHovered = false;
1237
+ syncProviderInfo();
1238
+ }
602
1239
  if (!panel.contains(event.target) && !document.querySelector(".dsh-mobile-control__trigger")?.contains(event.target)) setOpen(false);
603
1240
  };
604
1241
  document.addEventListener("pointerdown", dismiss);
605
- requestJson("/api/mobile-access/control").then(render, (error) => {
1242
+ requestJson("/api/mobile-access/lan/control").then(render, (error) => {
606
1243
  status.textContent = String(error);
607
1244
  });
1245
+ loadRemote();
1246
+ const remotePoll = window.setInterval(() => {
1247
+ if (!panel.hidden && !remoteView.hidden) loadRemote();
1248
+ }, 1500);
608
1249
  return {
609
1250
  remove: () => {
1251
+ window.clearInterval(remotePoll);
1252
+ window.removeEventListener("focus", retryAfterSetup);
1253
+ document.removeEventListener("visibilitychange", retryAfterSetup);
610
1254
  document.removeEventListener("pointerdown", dismiss);
611
1255
  root.remove();
612
1256
  },
@@ -1005,14 +1649,20 @@ window.__ModuleLoader__.load({
1005
1649
  }
1006
1650
  const CONTROL_STYLES = `
1007
1651
  .dsh-mobile-control{position:fixed;z-index:1000;left:16px;bottom:112px;font:14px/1.45 system-ui;color:var(--dsw-alias-label-primary,#16181d)}
1008
- .dsh-mobile-control__panel{box-sizing:border-box;width:min(328px,calc(100vw - 32px));padding:16px;border:1px solid var(--dsw-alias-border-subtle,#e1e5eb);border-radius:18px;background:var(--dsw-alias-bg-layer-2,#fff);box-shadow:0 18px 50px rgb(15 23 42 / 18%)}
1652
+ .dsh-mobile-control__panel{box-sizing:border-box;width:min(380px,calc(100vw - 32px));max-height:calc(100vh - 140px);overflow-y:auto;padding:16px;border:1px solid var(--dsw-alias-border-subtle,#e1e5eb);border-radius:18px;background:var(--dsw-alias-bg-layer-2,#fff);box-shadow:0 18px 50px rgb(15 23 42 / 18%)}
1009
1653
  .dsh-mobile-control__header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px}.dsh-mobile-control__panel h2{margin:0;font-size:17px;line-height:24px}.dsh-mobile-control__close{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;border:0;border-radius:10px;background:transparent;color:inherit;font-size:24px;line-height:1;cursor:pointer}.dsh-mobile-control__close:hover{background:var(--dsw-alias-interactive-bg-hover,#f1f3f6)}
1654
+ .dsh-mobile-control__switcher{display:grid;grid-template-columns:1fr 1fr;gap:4px;margin:0 0 14px;padding:4px;border-radius:12px;background:var(--dsw-alias-bg-layer-1,#f3f5f8)}.dsh-mobile-control__tab{min-height:36px;border:0;border-radius:9px;background:transparent;color:var(--dsw-alias-label-secondary,#606873);font:600 13px/1 system-ui;cursor:pointer}.dsh-mobile-control__tab.is-active{background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-primary,#16181d);box-shadow:0 1px 3px rgb(15 23 42 / 10%)}.dsh-mobile-control__view[hidden]{display:none}.dsh-mobile-control__intro{margin:0 0 12px;color:var(--dsw-alias-label-secondary,#606873);font-size:12px;line-height:1.55}.dsh-mobile-control__view.is-remote .dsh-mobile-control__actions{display:grid;grid-template-columns:1fr 1fr;gap:8px}.dsh-mobile-control__view.is-remote .dsh-mobile-control__actions button[hidden]{display:none}
1655
+ .dsh-mobile-control__provider-section{position:relative;margin:0 0 12px}.dsh-mobile-control__section-title{margin:0 0 8px;color:var(--dsw-alias-label-primary,#16181d);font:650 13px/1.4 system-ui}.dsh-mobile-control__provider-section>.dsh-mobile-control__section-title{padding-right:42px}.dsh-mobile-control__provider-choices{display:grid;gap:8px}.dsh-mobile-control__provider{display:flex;flex-direction:column;gap:5px;min-height:68px;padding:11px 12px;border:1px solid var(--dsw-alias-border-subtle,#dbe1e8);border-radius:13px;background:#fff;color:inherit;text-align:left;cursor:pointer;transition:border-color 160ms ease,background-color 160ms ease,box-shadow 160ms ease}.dsh-mobile-control__provider:hover{border-color:#9fb9e8;background:#f8fbff}.dsh-mobile-control__provider.is-selected{border-color:#2563eb;background:#f5f8ff;box-shadow:0 0 0 1px #2563eb inset}.dsh-mobile-control__provider:disabled{cursor:wait;opacity:.62}.dsh-mobile-control__provider-top{display:flex;align-items:center;justify-content:space-between;gap:8px}.dsh-mobile-control__provider-top strong{font-size:13px}.dsh-mobile-control__provider-badge{flex:none;padding:3px 7px;border-radius:999px;background:#e8f0ff;color:#1d4ed8;font:650 10px/1.2 system-ui}.dsh-mobile-control__provider-badge.is-cpolar{background:#eaf8f2;color:#087454}.dsh-mobile-control__provider-description{color:var(--dsw-alias-label-secondary,#606873);font-size:11px;line-height:1.45}.dsh-mobile-control__provider-info{position:absolute;z-index:5;top:-13px;right:-8px}.dsh-mobile-control__provider-info-button{display:flex;align-items:center;justify-content:center;width:44px;height:44px;padding:0;border:0;border-radius:50%;background:transparent;color:#475569;cursor:pointer;touch-action:manipulation}.dsh-mobile-control__provider-info-button:hover{background:#f1f5f9;color:#1d4ed8}.dsh-mobile-control__provider-info-glyph{display:flex;align-items:center;justify-content:center;box-sizing:border-box;width:18px;height:18px;border:1.5px solid currentColor;border-radius:50%;font:700 12px/1 system-ui}.dsh-mobile-control__provider-info-popover{position:absolute;z-index:6;top:38px;right:4px;box-sizing:border-box;width:min(292px,calc(100vw - 72px));padding:10px 12px;border:1px solid var(--dsw-alias-border-subtle,#dbe1e8);border-radius:12px;background:var(--dsw-alias-bg-layer-2,#fff);box-shadow:0 10px 28px rgb(15 23 42 / 16%)}.dsh-mobile-control__provider-info-popover[hidden]{display:none}.dsh-mobile-control__provider-info-popover strong,.dsh-mobile-control__provider-info-popover span{display:block}.dsh-mobile-control__provider-info-popover strong{margin-bottom:3px;font-size:12px}.dsh-mobile-control__provider-info-popover span{color:var(--dsw-alias-label-secondary,#606873);font-size:11px;line-height:1.55}
1656
+ .dsh-mobile-control__cpolar-setup{margin:0 0 12px;padding:12px;border:1px solid var(--dsw-alias-border-subtle,#dbe1e8);border-radius:13px;background:#fff}.dsh-mobile-control__cpolar-setup[hidden],.dsh-mobile-control__cpolar-account[hidden],.dsh-mobile-control__details[hidden],.dsh-mobile-control__view.is-remote .dsh-mobile-control__actions[hidden],.dsh-mobile-control__danger[hidden]{display:none}.dsh-mobile-control__component-status,.dsh-mobile-control__component-note{margin:0 0 10px;color:var(--dsw-alias-label-secondary,#606873);font-size:11px;line-height:1.55}.dsh-mobile-control__cpolar-setup>.dsh-mobile-control__primary{width:100%;min-height:44px;padding:9px 12px;border-radius:10px;font:600 12px/1.3 system-ui;cursor:pointer}.dsh-mobile-control__cpolar-account{margin-top:10px}.dsh-mobile-control__link-row{display:flex;flex-wrap:wrap;gap:6px 12px;margin:0 0 10px}.dsh-mobile-control__text-link{color:#2563eb;font-size:11px;text-decoration:none}.dsh-mobile-control__text-link:hover{text-decoration:underline}.dsh-mobile-control__token-label{display:flex;flex-direction:column;gap:5px;margin:0 0 8px;color:var(--dsw-alias-label-secondary,#606873);font-size:11px}.dsh-mobile-control__token{box-sizing:border-box;width:100%;min-height:44px;padding:9px 10px;border:1px solid var(--dsw-alias-border-normal,#cfd5dd);border-radius:10px;background:#fff;color:inherit;font:16px/1.4 system-ui}.dsh-mobile-control__cpolar-connect{display:flex;align-items:center;justify-content:center;box-sizing:border-box;width:100%;min-height:44px;padding:10px 14px;border-radius:12px;font:650 13px/1.2 system-ui;cursor:pointer;transition:background-color 160ms ease,border-color 160ms ease,opacity 160ms ease}.dsh-mobile-control__cpolar-connect:hover:not(:disabled){border-color:#1d4ed8;background:#1d4ed8}.dsh-mobile-control__cpolar-connect:active:not(:disabled){border-color:#1e40af;background:#1e40af}.dsh-mobile-control__cpolar-connect:disabled{cursor:wait;opacity:.55}.dsh-mobile-control__details{margin:10px 0 0;border-top:1px solid var(--dsw-alias-border-subtle,#e1e5eb);padding-top:9px}.dsh-mobile-control__details>summary{min-height:30px;color:var(--dsw-alias-label-secondary,#606873);font-size:11px;line-height:30px;cursor:pointer}.dsh-mobile-control__details-body{display:flex;flex-wrap:wrap;align-items:center;gap:7px 12px;padding:4px 0}.dsh-mobile-control__details-body p{flex:1 0 100%;margin:0;color:var(--dsw-alias-label-secondary,#606873);font-size:11px;line-height:1.5}.dsh-mobile-control__storage{display:block;flex:1 0 100%;max-width:100%;overflow:hidden;padding:7px 8px;border-radius:8px;background:#f3f5f8;color:#475569;font:10px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;text-overflow:ellipsis;white-space:nowrap}.dsh-mobile-control__danger{flex:1 0 100%;min-height:38px;margin-top:3px;padding:7px 10px;border:1px solid #dc2626;border-radius:9px;background:transparent;color:#b91c1c;font:12px/1.3 system-ui;cursor:pointer}
1010
1657
  .dsh-mobile-control__access{display:flex;align-items:baseline;gap:6px;min-width:0;margin:0 0 12px}.dsh-mobile-control__access[hidden]{display:none}.dsh-mobile-control__access-label{flex:none;color:var(--dsw-alias-label-secondary,#606873);white-space:nowrap}.dsh-mobile-control__access-label::after{content:":"}.dsh-mobile-control__access-link{min-width:0;overflow:hidden;color:#2563eb;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.dsh-mobile-control__access-link:hover{text-decoration:underline}.dsh-mobile-control__qr{display:flex;justify-content:center;margin:0 0 12px}.dsh-mobile-control__qr[hidden]{display:none}.dsh-mobile-control__qr img{border-radius:12px;background:#fff;padding:8px}
1011
1658
  .dsh-mobile-control__status{margin:0 0 14px;overflow-wrap:anywhere;color:var(--dsw-alias-label-secondary,#606873)}.dsh-mobile-control__status::before{display:inline-block;width:8px;height:8px;margin-right:7px;border-radius:50%;background:#98a1ad;content:""}.dsh-mobile-control__status.is-running::before{background:#16a36a}.dsh-mobile-control__status.is-key{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;word-break:break-all}
1659
+ .dsh-mobile-control__guide{margin:0 0 14px;padding:12px;border:1px solid #bfdbfe;border-radius:12px;background:#eff6ff}.dsh-mobile-control__guide[hidden]{display:none}.dsh-mobile-control__guide-title{margin:0;color:#172554;font:650 13px/1.45 system-ui}.dsh-mobile-control__guide-summary,.dsh-mobile-control__guide-note{margin:4px 0 0;color:#475569;font-size:12px;line-height:1.5}.dsh-mobile-control__guide-steps{margin:8px 0 0;padding-left:20px;color:#1e293b;font-size:12px;line-height:1.6}.dsh-mobile-control__guide-note{color:#64748b}.dsh-mobile-control__guide-actions{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px}.dsh-mobile-control__guide-actions button{min-width:0;min-height:44px;padding:8px;border-radius:10px;font:12px/1.25 system-ui;cursor:pointer}.dsh-mobile-control__guide-actions button:disabled{cursor:not-allowed;opacity:.45}
1012
1660
  .dsh-mobile-control__extensions{margin:0 0 12px;color:var(--dsw-alias-label-secondary,#606873);font-size:12px}
1013
1661
  .dsh-mobile-control__actions{display:flex;flex-wrap:nowrap;gap:6px}.dsh-mobile-control__actions button{flex:1 1 0;min-width:0;min-height:40px;padding:8px 4px;border-radius:10px;font:12px/1.2 system-ui;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dsh-mobile-control__secondary{border:1px solid var(--dsw-alias-border-normal,#cfd5dd);background:transparent;color:inherit}.dsh-mobile-control__primary{border:1px solid #2563eb;background:#2563eb;color:#fff}.dsh-mobile-control__actions button:disabled{cursor:not-allowed;opacity:.45}
1662
+ .dsh-mobile-control button:focus-visible,.dsh-mobile-control a:focus-visible,.dsh-mobile-control input:focus-visible,.dsh-mobile-control summary:focus-visible{outline:3px solid rgb(37 99 235 / 28%);outline-offset:2px}
1014
1663
  .dsh-mobile-control__trigger{box-sizing:border-box;display:flex;align-items:center;gap:8px;width:calc(100% + 8px);height:34px;margin:4px -4px;padding:6px 2px 6px 10px;border:0;border-radius:12px;background:transparent;color:var(--dsw-alias-label-primary,#16181d);font:14px/22px system-ui;cursor:pointer}.dsh-mobile-control__trigger:hover{background:var(--dsw-alias-interactive-bg-hover,#f1f3f6)}.dsh-mobile-control__trigger.is-rail{width:36px;height:36px;margin:8px 0 10px;padding:0;justify-content:center;border-radius:50%}.dsh-mobile-control__trigger-icon{position:relative;box-sizing:border-box;flex:none;width:14px;height:19px;border:1.7px solid currentColor;border-radius:3px}.dsh-mobile-control__trigger-icon::after{position:absolute;right:4px;bottom:2px;width:4px;height:1.5px;border-radius:2px;background:currentColor;content:""}.dsh-mobile-control__trigger-label{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
1015
1664
  .dsh-mobile-control__manage-row{display:flex;justify-content:space-between;gap:8px;margin-top:10px}.dsh-mobile-control__manage{flex:1 1 0;min-width:0;min-height:34px;padding:6px 8px;border:1px solid var(--dsw-alias-border-normal,#cfd5dd);border-radius:10px;background:transparent;color:inherit;font:12px/1.3 system-ui;cursor:pointer}.dsh-mobile-control__devices{margin-top:10px;border:1px solid var(--dsw-alias-border-subtle,#e1e5eb);border-radius:10px;padding:8px;max-height:220px;overflow-y:auto}.dsh-mobile-control__device-empty{color:var(--dsw-alias-label-secondary,#606873);font-size:12px;margin:0}.dsh-mobile-control__device{display:flex;align-items:center;gap:8px;padding:6px 2px}.dsh-mobile-control__device + .dsh-mobile-control__device{border-top:1px solid var(--dsw-alias-border-subtle,#e1e5eb)}.dsh-mobile-control__device-label{flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}.dsh-mobile-control__device-meta{flex:none;color:var(--dsw-alias-label-secondary,#606873);font-size:11px;white-space:nowrap}.dsh-mobile-control__device-revoke{flex:none;min-height:28px;padding:4px 8px;border:1px solid #dc2626;border-radius:8px;background:transparent;color:#dc2626;font:12px/1.2 system-ui;cursor:pointer}
1665
+ @media (prefers-reduced-motion:reduce){.dsh-mobile-control__provider,.dsh-mobile-control__cpolar-connect{transition:none}}
1016
1666
  `;
1017
1667
  /** Mount the desktop control or mobile feature enhancements. */
1018
1668
  function apply(ctx) {