dsh-mobile 0.3.8 → 0.3.9

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
@@ -466,8 +466,35 @@ window.__ModuleLoader__.load({
466
466
  frpStep2Title: "2 · Prepare the VPS",
467
467
  frpStep2Text: "Copy the restricted frps and Caddy template, then apply it on your VPS.",
468
468
  copyServerTemplate: "Copy server template",
469
- templateCopied: "Server template copied. Apply frps.toml and the Caddyfile on the VPS.",
469
+ templateCopied: "Server template copied. Save frps.toml and the Caddy snippet on the VPS, then add the import line to the Caddyfile.",
470
470
  templateCopyFailed: "Could not copy the template. Check the four fields above.",
471
+ vpsDeployTitle: "Deploy the VPS automatically",
472
+ vpsDeployText: "Use an existing OpenSSH key or agent. The VPS must be Ubuntu/Debian with systemd; existing Caddy site configuration is never overwritten.",
473
+ vpsDeployChangesTitle: "Automatic deployment will change the following on the VPS (manual template changes nothing by itself):",
474
+ vpsDeployChangePackages: "Install Caddy from its official APT repository, plus a Python venv and Certbot for public-IPv4 certificates.",
475
+ vpsDeployChangeServices: "Create the dsh-mobile system user (only if missing, otherwise kept), the frps config and service, the Caddy snippet plus one import line, and a daily certificate-renewal timer.",
476
+ vpsDeployChangeFirewall: "Open the FRP control port, 80/tcp, and 443/tcp in UFW when it is active; other firewalls are left for you to adjust.",
477
+ vpsDeployChangeManual: "Manual deployment applies the same steps by hand from the copied template; automatic deployment runs them over SSH after you confirm the host keys.",
478
+ vpsSshUser: "SSH user",
479
+ vpsSshPort: "SSH port",
480
+ vpsSshKey: "Private key path (optional)",
481
+ vpsSshKeyPlaceholder: "Uses ssh-agent or ~/.ssh/config when empty",
482
+ vpsDeploy: "Deploy frps + Caddy",
483
+ vpsDeploying: "Deploying to the VPS. Keep DSH running…",
484
+ vpsDeploySuccess: "VPS deployment completed. Start FRP connection to verify the public endpoint.",
485
+ vpsDeployFailed: "VPS deployment failed: {error}",
486
+ vpsDeployNotReady: "Save the FRP settings before deploying the VPS.",
487
+ vpsHostKeyFetching: "Reading the VPS host keys…",
488
+ vpsHostKeyFailed: "Could not read the VPS host keys: {error}",
489
+ vpsDeployConfirmWithKeys: "Deploy DSH Mobile frps and Caddy to this VPS? Only DSH Mobile service/config files and firewall rules will be created. Existing non-empty Caddy configuration will stop the deployment.\n\nThe server presents these host keys. Confirm they match the VPS console before continuing:\n{fingerprints}",
490
+ vpsCopyUninstall: "Copy VPS uninstall script",
491
+ vpsUninstallScriptCopied: "Uninstall script copied. Review it before running with root on the VPS.",
492
+ vpsUninstallScriptFailed: "Could not build the uninstall script: {error}",
493
+ vpsUninstall: "Remove DSH Mobile from the VPS",
494
+ vpsUninstallConfirmWithKeys: "Remove all DSH Mobile services, configs, certificates, and owned firewall rules from this VPS? Your own Caddy content and unrelated rules are kept.\n\nThe server presents these host keys. Confirm they match the VPS console before continuing:\n{fingerprints}",
495
+ vpsUninstalling: "Removing DSH Mobile from the VPS. Keep DSH running…",
496
+ vpsUninstallSuccess: "VPS cleanup completed. The local FRP settings are unchanged.",
497
+ vpsUninstallFailed: "VPS cleanup failed: {error}",
471
498
  frpStep3Title: "3 · Prepare this computer",
472
499
  frpStep3Text: "Download and verify the pinned official frpc client only when requested.",
473
500
  installFrpc: "Install official frpc",
@@ -528,8 +555,35 @@ window.__ModuleLoader__.load({
528
555
  frpStep2Title: "2 · Prepara il VPS",
529
556
  frpStep2Text: "Copia il modello limitato per frps e Caddy e applicalo sul VPS.",
530
557
  copyServerTemplate: "Copia modello server",
531
- templateCopied: "Modello server copiato. Applica frps.toml e Caddyfile sul VPS.",
558
+ templateCopied: "Modello server copiato. Salva frps.toml e lo snippet Caddy sul VPS, poi aggiungi la riga import al Caddyfile.",
532
559
  templateCopyFailed: "Impossibile copiare il modello. Controlla i quattro campi sopra.",
560
+ vpsDeployTitle: "Prepara automaticamente il VPS",
561
+ vpsDeployText: "Usa una chiave OpenSSH o un agent esistente. Il VPS deve essere Ubuntu/Debian con systemd; una configurazione Caddy non vuota non viene sovrascritta.",
562
+ vpsDeployChangesTitle: "Il deploy automatico modifica quanto segue sul VPS (il modello manuale da solo non cambia nulla):",
563
+ vpsDeployChangePackages: "Installa Caddy dal repository APT ufficiale, più un venv Python e Certbot per i certificati IPv4 pubblici.",
564
+ vpsDeployChangeServices: "Crea l’utente di sistema dsh-mobile (solo se assente, altrimenti mantenuto), la configurazione e il servizio frps, lo snippet Caddy più una riga import, e un timer giornaliero di rinnovo certificati.",
565
+ vpsDeployChangeFirewall: "Apre la porta di controllo FRP, 80/tcp e 443/tcp in UFW quando attivo; gli altri firewall restano a tuo carico.",
566
+ vpsDeployChangeManual: "Il deploy manuale applica gli stessi passi a mano dal modello copiato; quello automatico li esegue via SSH dopo la conferma delle chiavi host.",
567
+ vpsSshUser: "Utente SSH",
568
+ vpsSshPort: "Porta SSH",
569
+ vpsSshKey: "Percorso chiave privata (opzionale)",
570
+ vpsSshKeyPlaceholder: "Usa ssh-agent o ~/.ssh/config se vuoto",
571
+ vpsDeploy: "Installa frps + Caddy",
572
+ vpsDeploying: "Deploy sul VPS in corso. Mantieni DSH attivo…",
573
+ vpsDeploySuccess: "Deploy VPS completato. Avvia FRP per verificare l’endpoint pubblico.",
574
+ vpsDeployFailed: "Deploy VPS fallito: {error}",
575
+ vpsDeployNotReady: "Salva le impostazioni FRP prima del deploy VPS.",
576
+ vpsHostKeyFetching: "Lettura delle chiavi host del VPS…",
577
+ vpsHostKeyFailed: "Impossibile leggere le chiavi host del VPS: {error}",
578
+ vpsDeployConfirmWithKeys: "Installare frps e Caddy DSH Mobile su questo VPS? Verranno creati solo file di servizio/configurazione e regole firewall DSH Mobile. Una configurazione Caddy esistente non vuota interromperà il deploy.\n\nIl server presenta queste chiavi host. Verifica che corrispondano alla console del VPS prima di continuare:\n{fingerprints}",
579
+ vpsCopyUninstall: "Copia script di disinstallazione VPS",
580
+ vpsUninstallScriptCopied: "Script di disinstallazione copiato. Controllalo prima di eseguirlo come root sul VPS.",
581
+ vpsUninstallScriptFailed: "Impossibile creare lo script di disinstallazione: {error}",
582
+ vpsUninstall: "Rimuovi DSH Mobile dal VPS",
583
+ vpsUninstallConfirmWithKeys: "Rimuovere tutti i servizi, le configurazioni, i certificati e le regole firewall di DSH Mobile da questo VPS? I tuoi contenuti Caddy e le altre regole restano invariati.\n\nIl server presenta queste chiavi host. Verifica che corrispondano alla console del VPS prima di continuare:\n{fingerprints}",
584
+ vpsUninstalling: "Rimozione di DSH Mobile dal VPS. Mantieni DSH attivo…",
585
+ vpsUninstallSuccess: "Pulizia VPS completata. Le impostazioni FRP locali restano invariate.",
586
+ vpsUninstallFailed: "Pulizia VPS fallita: {error}",
533
587
  frpStep3Title: "3 · Prepara questo computer",
534
588
  frpStep3Text: "Scarica e verifica il client frpc ufficiale fissato solo su richiesta.",
535
589
  installFrpc: "Installa frpc ufficiale",
@@ -573,41 +627,68 @@ window.__ModuleLoader__.load({
573
627
  });
574
628
  Object.assign(MOBILE_CONTROL_MESSAGES.zh, {
575
629
  selfHostedConnections: "自建连接",
576
- selfHostedDescription: "适合已有 VPS 和域名的用户,不受公共服务带宽限制。",
630
+ selfHostedDescription: "适合已有 VPS 的用户,可使用域名或公网 IPv4,不受公共服务带宽限制。",
577
631
  advanced: "高级",
578
632
  frpName: "自建 FRP",
579
- frpDescription: "只为当前 DSH 网关建立单用途 FRP 通道,VPS 和公开域名由你掌控。",
633
+ frpDescription: "只为当前 DSH 网关建立单用途 FRP 通道,VPS 和公开入口由你掌控。",
580
634
  prepareFrp: "配置自建 FRP",
581
- frpStep1Title: "1 · 填写连接信息",
582
- frpStep1Text: "填写 VPS 地址、frps 控制端口、共享 Token 和公开 HTTPS 地址。",
583
- frpServerAddress: "VPS 地址",
584
- frpServerAddressPlaceholder: "frp.example.com",
585
- frpServerPort: "frps 端口",
635
+ frpStep1Title: "1 · 连接信息",
636
+ frpStep1Text: "填写中转服务器地址、frps 控制端口、共享 Token 和手机访问地址。没有域名时,可直接使用公网 IPv4。",
637
+ frpServerAddress: "中转服务器地址",
638
+ frpServerAddressPlaceholder: "203.0.113.10",
639
+ frpServerPort: "frps 控制端口",
586
640
  frpToken: "共享 Token",
587
- frpTokenPlaceholder: "至少 16 个字符",
588
- frpPublicOrigin: "公开 HTTPS 地址",
589
- frpPublicOriginPlaceholder: "https://dsh.example.com",
590
- frpStep2Title: "2 · 准备 VPS",
591
- frpStep2Text: "复制受限的 frps 与 Caddy 模板,并在 VPS 上应用。",
592
- copyServerTemplate: "复制服务器模板",
593
- templateCopied: "服务器模板已复制,请在 VPS 应用 frps.toml 和 Caddyfile。",
594
- templateCopyFailed: "无法复制模板,请检查上方四项信息。",
595
- frpStep3Title: "3 · 准备这台电脑",
596
- frpStep3Text: "仅在你确认后下载并校验固定版本的官方 frpc。",
641
+ frpTokenPlaceholder: "已保存时可留空;新建至少 16 个字符",
642
+ frpPublicOrigin: "手机访问地址(HTTPS",
643
+ frpPublicOriginPlaceholder: "https://203.0.113.10",
644
+ frpStep2Title: "2 · 部署中转服务器",
645
+ frpStep2Text: "推荐通过 SSH 一键部署。需要手动操作时,也可以复制受限的 frps 与 Caddy 配置模板。",
646
+ copyServerTemplate: "复制手动部署模板",
647
+ templateCopied: "服务器模板已复制,请在 VPS 上保存 frps.toml 和 Caddy 片段,并在 Caddyfile 里加一行 import。",
648
+ templateCopyFailed: "无法复制模板,请检查上方连接信息。",
649
+ vpsDeployTitle: "中转服务器部署",
650
+ vpsDeployText: "填写 SSH 登录信息。服务器需为带 systemd 的 Ubuntu/Debian;不会覆盖已有的 Caddy 站点配置。",
651
+ vpsDeployChangesTitle: "自动部署会在 VPS 上做这些改动(只复制手动模板不会改变任何东西):",
652
+ vpsDeployChangePackages: "从官方 APT 源安装 Caddy,并安装 Python venv 与 Certbot(公网 IP 证书用)。",
653
+ vpsDeployChangeServices: "创建 dsh-mobile 系统用户(如已存在则复用,卸载时保留)、frps 配置与服务、Caddy 片段加一行 import,以及每天自动续证书的定时器。",
654
+ vpsDeployChangeFirewall: "UFW 开启时放行 FRP 控制端口、80/tcp 和 443/tcp;其他防火墙需要你自己放行。",
655
+ vpsDeployChangeManual: "手动部署是照着复制的模板一步步自己操作;自动部署是在你确认主机指纹后通过 SSH 执行同样的步骤。",
656
+ vpsSshUser: "SSH 用户",
657
+ vpsSshPort: "SSH 端口",
658
+ vpsSshKey: "私钥文件路径(可选)",
659
+ vpsSshKeyPlaceholder: "留空则使用 ssh-agent 或 SSH 配置",
660
+ vpsDeploy: "一键部署 frps + Caddy",
661
+ vpsDeploying: "正在部署中转服务器,请保持 DSH 运行…",
662
+ vpsDeploySuccess: "中转服务器部署完成,请继续完成本机组件安装和连接验证。",
663
+ vpsDeployFailed: "VPS 部署失败:{error}",
664
+ vpsDeployNotReady: "请先填写完整的连接信息。",
665
+ vpsHostKeyFetching: "正在读取中转服务器的主机指纹…",
666
+ vpsHostKeyFailed: "读取中转服务器主机指纹失败:{error}",
667
+ vpsDeployConfirmWithKeys: "要在这台 VPS 上部署 DSH Mobile 的 frps 和 Caddy 吗?只会创建 DSH Mobile 自己的服务、配置和防火墙规则;如果已有非空 Caddy 配置,部署会停止并保留原配置。\n\n服务器当前的主机指纹如下,请先到 VPS 控制台核对一致后再继续:\n{fingerprints}",
668
+ vpsCopyUninstall: "复制 VPS 卸载脚本",
669
+ vpsUninstallScriptCopied: "卸载脚本已复制,请审阅后再以 root 身份在 VPS 上执行。",
670
+ vpsUninstallScriptFailed: "生成卸载脚本失败:{error}",
671
+ vpsUninstall: "清理 VPS 上的 DSH Mobile",
672
+ vpsUninstallConfirmWithKeys: "要删除这台 VPS 上的 DSH Mobile 服务、配置、证书和专属防火墙规则吗?你自己的 Caddy 内容和其他规则会保留。\n\n服务器当前的主机指纹如下,请先到 VPS 控制台核对一致后再继续:\n{fingerprints}",
673
+ vpsUninstalling: "正在清理 VPS 上的 DSH Mobile,请保持 DSH 运行…",
674
+ vpsUninstallSuccess: "VPS 清理完成,本机 FRP 设置保持不变。",
675
+ vpsUninstallFailed: "VPS 清理失败:{error}",
676
+ frpStep3Title: "3 · 安装本机组件",
677
+ frpStep3Text: "下载并校验固定版本的官方 frpc,只安装到 DSH Mobile 私有目录。",
597
678
  installFrpc: "安装官方 frpc",
598
679
  frpInstallConfirm: "从 FRP 官方 Release 下载并校验 frpc 0.70.1(约 {size} MB),只提取 frpc 到 DSH Mobile 私有目录?不会安装服务、写入 PATH 或设置开机启动。",
599
680
  installingFrp: "正在安装官方 frpc,完成前请保持 DSH 运行。",
600
- frpNotInstalled: "frpc 尚未安装。点击安装前不会下载任何内容。",
681
+ frpNotInstalled: "frpc 尚未安装;点击下方按钮后才会开始下载。",
601
682
  frpUnsupported: "当前桌面平台暂不支持托管安装 frpc。",
602
- frpComponentReady: "官方 frpc {version} 已校验。",
603
- frpStep4Title: "4 · 验证并连接",
604
- frpStep4Text: "插件只启动 DSH 的 HTTP vhost,并确认公网端点确实属于这台电脑。",
605
- frpAppRequirement: "自定义远程域名需要 Android App 0.3.3 或更高版本;局域网、cpolar 和 Tailscale 仍兼容此前受支持的 App。",
606
- frpSaveConnect: "保存并验证连接",
683
+ frpComponentReady: "官方 frpc {version} 已安装并通过校验。",
684
+ frpStep4Title: "4 · 启用并验证",
685
+ frpStep4Text: "保存配置、启动受限的 FRP 通道,并确认手机访问地址确实连接到这台电脑。",
686
+ frpAppRequirement: "通过自定义域名或公网 IP 连接,需要 Android App 0.3.3 或更高版本。局域网、cpolar 和 Tailscale 不受影响。",
687
+ frpSaveConnect: "保存配置并验证连接",
607
688
  frpSavingConnecting: "正在保存私有配置并验证完整 FRP 链路…",
608
- frpConfigurationReady: "服务器配置已保存,Token 只保存在插件私有目录。",
609
- frpConfigurationMissing: "请填写完整信息、复制 VPS 模板,然后保存并连接。",
610
- frpInputInvalid: "请检查 VPS 地址、端口、HTTPS 地址和 Token。",
689
+ frpConfigurationReady: "连接配置已安全保存",
690
+ frpConfigurationMissing: "请按顺序完成连接信息、服务器部署和本机组件安装。",
691
+ frpInputInvalid: "请检查服务器地址、控制端口、手机访问地址和共享 Token。",
611
692
  frpComponentDetails: "FRP 来源与彻底清理",
612
693
  frpComponentDetailsText: "固定版本客户端来自 fatedier/frp 官方 Release。DSH Mobile 不安装系统服务,也不接受任意 FRP 配置。",
613
694
  frpOfficialRelease: "FRP 官方 Release",
@@ -895,33 +976,98 @@ window.__ModuleLoader__.load({
895
976
  //#region src/frp-template.ts
896
977
  /** Loopback-only HTTP vhost port used between Caddy and frps. */
897
978
  const FRP_VHOST_HTTP_PORT = 7080;
979
+ /** Caddy snippet owned entirely by DSH Mobile; the main Caddyfile only imports it. */
980
+ const FRP_CADDY_SNIPPET_PATH = "/etc/caddy/dsh-mobile-dsh.caddy";
981
+ /** First line of the owned snippet; also the legacy whole-file marker. */
982
+ const FRP_CADDY_SNIPPET_MARKER = "# Managed by DSH Mobile - snippet, safe to delete";
983
+ /** Exact line the main Caddyfile must contain (uncommented) for the site to load. */
984
+ const FRP_CADDY_IMPORT_LINE = `import ${FRP_CADDY_SNIPPET_PATH}`;
985
+ /** Directory holding the public-IPv4 certificates installed by certbot. */
986
+ const FRP_CADDY_IP_CERT_DIR = "/var/lib/caddy/dsh-mobile-certs";
987
+ function publicIpv4Address(value) {
988
+ const parts = value.split(".");
989
+ return parts.length === 4 && parts.every((part) => /^(?:0|[1-9][0-9]{0,2})$/u.test(part) && Number(part) <= 255);
990
+ }
898
991
  function publicDnsHostname(value) {
899
992
  return value.length <= 253 && value.includes(".") && !/^[0-9.]+$/u.test(value) && !value.includes(":") && value.split(".").every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label));
900
993
  }
901
- /** Build the only supported frps and Caddy configuration from validated user inputs. */
902
- function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
903
- if (!Number.isSafeInteger(serverPort) || serverPort < 1 || serverPort > 65535 || token.length < 16 || token.length > 512 || /[\s\u0000-\u001f\u007f]/u.test(token)) throw new Error("frp_template_input_invalid");
994
+ function parsePublicOrigin(publicOrigin) {
904
995
  let url;
905
996
  try {
906
997
  url = new URL(publicOrigin);
907
998
  } catch {
908
999
  throw new Error("frp_template_input_invalid");
909
1000
  }
910
- if (url.protocol !== "https:" || url.port !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "" || !publicDnsHostname(url.hostname)) throw new Error("frp_template_input_invalid");
1001
+ if (url.protocol !== "https:" || url.port !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "" || !publicIpv4Address(url.hostname) && !publicDnsHostname(url.hostname)) throw new Error("frp_template_input_invalid");
1002
+ return url.hostname;
1003
+ }
1004
+ /** Build the Caddy site for one public host (without markers or import wiring). */
1005
+ function createCaddySite(publicHost, certDir = FRP_CADDY_IP_CERT_DIR) {
1006
+ if (publicIpv4Address(publicHost)) return [
1007
+ "{",
1008
+ ` default_sni ${publicHost}`,
1009
+ "}",
1010
+ "",
1011
+ `http://${publicHost} {`,
1012
+ ` redir https://${publicHost}{uri} permanent`,
1013
+ "}",
1014
+ "",
1015
+ `https://${publicHost} {`,
1016
+ ` tls ${certDir}/fullchain.pem ${certDir}/privkey.pem`,
1017
+ ` reverse_proxy 127.0.0.1:${String(FRP_VHOST_HTTP_PORT)}`,
1018
+ "}",
1019
+ ""
1020
+ ].join("\n");
1021
+ if (!publicDnsHostname(publicHost)) throw new Error("frp_template_input_invalid");
1022
+ return [
1023
+ `${publicHost} {`,
1024
+ ` reverse_proxy 127.0.0.1:${String(FRP_VHOST_HTTP_PORT)}`,
1025
+ "}",
1026
+ ""
1027
+ ].join("\n");
1028
+ }
1029
+ /** Manual certbot steps for a public-IPv4 origin (Caddy cannot issue IP certificates itself). */
1030
+ function manualIpCertificateGuide(publicHost) {
911
1031
  return [
912
- "# frps.toml",
1032
+ "# Public-IPv4 manual HTTPS: Caddy cannot issue IP certificates by itself.",
1033
+ "# On the VPS (Ubuntu/Debian, port 80 reachable from the internet), run once as root:",
1034
+ "# apt-get install -y python3-venv",
1035
+ "# python3 -m venv /opt/dsh-mobile/certbot-venv",
1036
+ "# /opt/dsh-mobile/certbot-venv/bin/pip install 'certbot==5.8.0'",
1037
+ "# systemctl stop caddy || true",
1038
+ `# /opt/dsh-mobile/certbot-venv/bin/certbot certonly --standalone --preferred-profile shortlived --ip-address ${publicHost} --agree-tos --register-unsafely-without-email --non-interactive --keep-until-expiring`,
1039
+ "# install -d -m 0750 -o caddy -g caddy /var/lib/caddy/dsh-mobile-certs",
1040
+ `# install -m 0640 -o caddy -g caddy /etc/letsencrypt/live/${publicHost}/fullchain.pem /var/lib/caddy/dsh-mobile-certs/fullchain.pem`,
1041
+ `# install -m 0640 -o caddy -g caddy /etc/letsencrypt/live/${publicHost}/privkey.pem /var/lib/caddy/dsh-mobile-certs/privkey.pem`,
1042
+ "# systemctl start caddy",
1043
+ "# The site below already references those paths. Certificates last about 6 days: re-run certonly before expiry.",
1044
+ "#"
1045
+ ].join("\n");
1046
+ }
1047
+ /** Build the only supported frps config and Caddy snippet from validated user inputs. */
1048
+ function createRestrictedFrpServerTemplate(serverPort, token, publicOrigin) {
1049
+ if (!Number.isSafeInteger(serverPort) || serverPort < 1 || serverPort > 65535 || token.length < 16 || token.length > 512 || /[\s\u0000-\u001f\u007f]/u.test(token)) throw new Error("frp_template_input_invalid");
1050
+ const publicHost = parsePublicOrigin(publicOrigin);
1051
+ const lines = [
1052
+ "# frps.toml — save as /etc/dsh-mobile/frps.toml, then start the frps service.",
913
1053
  `bindPort = ${String(serverPort)}`,
914
1054
  "proxyBindAddr = \"127.0.0.1\"",
915
1055
  `vhostHTTPPort = ${String(FRP_VHOST_HTTP_PORT)}`,
916
1056
  "auth.method = \"token\"",
917
1057
  `auth.token = ${JSON.stringify(token)}`,
918
1058
  "",
919
- "# Caddyfile",
920
- `${url.hostname} {`,
921
- ` reverse_proxy 127.0.0.1:${String(FRP_VHOST_HTTP_PORT)}`,
922
- "}",
1059
+ `# Caddy — save the site below as ${FRP_CADDY_SNIPPET_PATH},`,
1060
+ "# then make sure your Caddyfile contains exactly this line at the TOP of the file",
1061
+ "# (create the file with just this line if needed; globals must precede sites):",
1062
+ `# ${FRP_CADDY_IMPORT_LINE}`,
1063
+ "# finally run: caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy",
1064
+ "# Uninstall later removes only this snippet file and the import line; your own Caddy content is kept.",
1065
+ `${FRP_CADDY_SNIPPET_MARKER}`,
1066
+ createCaddySite(publicHost).trimEnd(),
923
1067
  ""
924
- ].join("\n");
1068
+ ];
1069
+ if (publicIpv4Address(publicHost)) lines.push(manualIpCertificateGuide(publicHost), "");
1070
+ return lines.join("\n");
925
1071
  }
926
1072
  //#endregion
927
1073
  //#region src/native-mobile.ts
@@ -1032,6 +1178,8 @@ window.__ModuleLoader__.load({
1032
1178
  [data-dsh-mobile-center] [data-composer-card] ~ * [class*="_root"] { box-sizing:border-box !important; width:100% !important; max-width:100% !important; margin-bottom:-6px !important; padding:3px 4px 0 !important; font-size:11px !important; line-height:18px !important; white-space:normal !important; overflow:visible !important; text-overflow:clip !important; }
1033
1179
  [data-dsh-mobile-center] [data-composer-card] ~ [class*="_root"] [class*="_sep"],
1034
1180
  [data-dsh-mobile-center] [data-composer-card] ~ * [class*="_root"] [class*="_sep"] { margin:0 6px !important; }
1181
+ /* Composer dock stats strip (turns/steps/tokens) reads small on phones. */
1182
+ [data-dsh-mobile-center] [data-slot="conversation.composer.dock"] [class*="_root"] { font-size:10px !important; line-height:16px !important; }
1035
1183
  /* Message runtime details are inline on desktop. Give the clock/runtime
1036
1184
  label its own wrapping row on narrow screens so TTFT and throughput do
1037
1185
  not push the action buttons or clip at the viewport edge. */
@@ -1813,7 +1961,7 @@ window.__ModuleLoader__.load({
1813
1961
  return node;
1814
1962
  }
1815
1963
  const CONTROL_REQUEST_TIMEOUT_MS = 15e3;
1816
- const LONG_CONTROL_REQUEST_TIMEOUT_MS = 13e4;
1964
+ const LONG_CONTROL_REQUEST_TIMEOUT_MS = 21e4;
1817
1965
  const GITHUB_RELEASES_URL = "https://github.com/saya-ch/dsh-mobile/releases";
1818
1966
  const CONTROL_PANEL_ID = "dsh-mobile-control-panel";
1819
1967
  function releaseVersion(value) {
@@ -2165,7 +2313,60 @@ window.__ModuleLoader__.load({
2165
2313
  const frpCopyTemplate = element("button", "dsh-mobile-control__secondary dsh-mobile-control__frp-action");
2166
2314
  frpCopyTemplate.type = "button";
2167
2315
  frpCopyTemplate.textContent = t("copyServerTemplate");
2168
- frpStep2.append(frpStep2Title, frpStep2Text, frpCopyTemplate);
2316
+ const vpsDeployText = element("p");
2317
+ vpsDeployText.textContent = t("vpsDeployText");
2318
+ const vpsChangesTitle = element("p");
2319
+ vpsChangesTitle.textContent = t("vpsDeployChangesTitle");
2320
+ const vpsChanges = element("ul", "dsh-mobile-control__frp-changes");
2321
+ for (const key of [
2322
+ "vpsDeployChangePackages",
2323
+ "vpsDeployChangeServices",
2324
+ "vpsDeployChangeFirewall",
2325
+ "vpsDeployChangeManual"
2326
+ ]) {
2327
+ const item = element("li");
2328
+ item.textContent = t(key);
2329
+ vpsChanges.append(item);
2330
+ }
2331
+ const vpsDeployFields = element("div", "dsh-mobile-control__frp-fields");
2332
+ const vpsSshUserLabel = element("label", "dsh-mobile-control__field");
2333
+ vpsSshUserLabel.textContent = t("vpsSshUser");
2334
+ const vpsSshUser = element("input");
2335
+ vpsSshUser.type = "text";
2336
+ vpsSshUser.autocomplete = "username";
2337
+ vpsSshUser.value = "ubuntu";
2338
+ vpsSshUser.spellcheck = false;
2339
+ const vpsSshPortLabel = element("label", "dsh-mobile-control__field");
2340
+ vpsSshPortLabel.textContent = t("vpsSshPort");
2341
+ const vpsSshPort = element("input");
2342
+ vpsSshPort.type = "number";
2343
+ vpsSshPort.inputMode = "numeric";
2344
+ vpsSshPort.min = "1";
2345
+ vpsSshPort.max = "65535";
2346
+ vpsSshPort.value = "22";
2347
+ const vpsSshKeyLabel = element("label", "dsh-mobile-control__field");
2348
+ vpsSshKeyLabel.textContent = t("vpsSshKey");
2349
+ const vpsSshKey = element("input");
2350
+ vpsSshKey.type = "text";
2351
+ vpsSshKey.autocomplete = "off";
2352
+ vpsSshKey.spellcheck = false;
2353
+ vpsSshKey.placeholder = t("vpsSshKeyPlaceholder");
2354
+ vpsSshUserLabel.append(vpsSshUser);
2355
+ vpsSshPortLabel.append(vpsSshPort);
2356
+ vpsSshKeyLabel.append(vpsSshKey);
2357
+ vpsDeployFields.append(vpsSshUserLabel, vpsSshPortLabel, vpsSshKeyLabel);
2358
+ const vpsDeploy = element("button", "dsh-mobile-control__primary dsh-mobile-control__frp-action");
2359
+ vpsDeploy.type = "button";
2360
+ vpsDeploy.textContent = t("vpsDeploy");
2361
+ const vpsDeployStatus = element("p", "dsh-mobile-control__component-status");
2362
+ vpsDeployStatus.textContent = "";
2363
+ const vpsCopyUninstall = element("button", "dsh-mobile-control__secondary dsh-mobile-control__frp-action");
2364
+ vpsCopyUninstall.type = "button";
2365
+ vpsCopyUninstall.textContent = t("vpsCopyUninstall");
2366
+ const vpsUninstall = element("button", "dsh-mobile-control__danger dsh-mobile-control__frp-action");
2367
+ vpsUninstall.type = "button";
2368
+ vpsUninstall.textContent = t("vpsUninstall");
2369
+ frpStep2.append(frpStep2Title, frpStep2Text, frpCopyTemplate, vpsDeployText, vpsChangesTitle, vpsChanges, vpsDeployFields, vpsDeploy, vpsDeployStatus, vpsCopyUninstall, vpsUninstall);
2169
2370
  const frpStep3 = element("section", "dsh-mobile-control__frp-step");
2170
2371
  const frpStep3Title = element("strong");
2171
2372
  frpStep3Title.textContent = t("frpStep3Title");
@@ -2208,7 +2409,35 @@ window.__ModuleLoader__.load({
2208
2409
  frpPurge.textContent = t("purgeFrp");
2209
2410
  frpDetailsBody.append(frpDetailsText, frpStorage, frpOfficial, frpPurge);
2210
2411
  frpDetails.append(frpDetailsSummary, frpDetailsBody);
2211
- frpSetup.append(frpSetupTitle, frpStep1, frpStep2, frpStep3, frpStep4, frpDetails);
2412
+ const frpOverview = element("section", "dsh-mobile-control__frp-overview");
2413
+ frpOverview.hidden = true;
2414
+ const frpOverviewMark = element("span", "dsh-mobile-control__frp-overview-mark");
2415
+ frpOverviewMark.textContent = "✓";
2416
+ const frpOverviewBody = element("div", "dsh-mobile-control__frp-overview-body");
2417
+ const frpOverviewTitle = element("strong");
2418
+ frpOverviewTitle.textContent = t("frpConfigurationReady");
2419
+ const frpOverviewEndpoint = element("span");
2420
+ frpOverviewEndpoint.textContent = "";
2421
+ frpOverviewBody.append(frpOverviewTitle, frpOverviewEndpoint);
2422
+ frpOverview.append(frpOverviewMark, frpOverviewBody);
2423
+ const frpConnectionGroup = element("details", "dsh-mobile-control__frp-group");
2424
+ const frpConnectionSummary = element("summary");
2425
+ frpConnectionSummary.textContent = t("frpStep1Title");
2426
+ frpConnectionGroup.append(frpConnectionSummary, frpStep1);
2427
+ const frpVpsGroup = element("details", "dsh-mobile-control__frp-group");
2428
+ const frpVpsSummary = element("summary");
2429
+ frpVpsSummary.textContent = t("vpsDeployTitle");
2430
+ frpVpsGroup.append(frpVpsSummary, frpStep2);
2431
+ const frpComponentGroup = element("details", "dsh-mobile-control__frp-group");
2432
+ const frpComponentSummary = element("summary");
2433
+ frpComponentSummary.textContent = t("frpStep3Title");
2434
+ frpComponentGroup.append(frpComponentSummary, frpStep3);
2435
+ const frpVerifyGroup = element("details", "dsh-mobile-control__frp-group");
2436
+ frpVerifyGroup.open = true;
2437
+ const frpVerifySummary = element("summary");
2438
+ frpVerifySummary.textContent = t("frpStep4Title");
2439
+ frpVerifyGroup.append(frpVerifySummary, frpStep4);
2440
+ frpSetup.append(frpSetupTitle, frpOverview, frpConnectionGroup, frpVpsGroup, frpComponentGroup, frpVerifyGroup, frpDetails);
2212
2441
  const tailscaleInfo = element("details", "dsh-mobile-control__details");
2213
2442
  const tailscaleInfoSummary = element("summary");
2214
2443
  tailscaleInfoSummary.textContent = t("tailscaleHelp");
@@ -2364,6 +2593,7 @@ window.__ModuleLoader__.load({
2364
2593
  let cpolarConfigured = false;
2365
2594
  let frpInstalled = false;
2366
2595
  let frpConfigured = false;
2596
+ let frpLayoutInitialized = false;
2367
2597
  let frpDownloadSize = "14.0";
2368
2598
  let configuredFrpServer = "";
2369
2599
  let configuredFrpPort = 7e3;
@@ -2640,9 +2870,24 @@ window.__ModuleLoader__.load({
2640
2870
  frpInstall.textContent = frpDownloadBytes > 0 ? t("installWithSize", { size: formatMegabytes(frpDownloadBytes) }) : t("installFrpc");
2641
2871
  frpInstall.disabled = remoteProviderBusy;
2642
2872
  frpConfigure.disabled = remoteProviderBusy || !frpInstalled;
2873
+ vpsDeploy.disabled = remoteProviderBusy || !validVpsDeploymentForm();
2874
+ vpsCopyUninstall.disabled = vpsDeploy.disabled;
2875
+ vpsUninstall.disabled = vpsDeploy.disabled;
2643
2876
  frpPurge.hidden = !frpInstalled && !frpConfigured;
2644
2877
  frpComponentStatus.textContent = !frpSupported ? t("frpUnsupported") : frpInstalled ? t("frpComponentReady", { version: frpVersion }) : t("frpNotInstalled");
2645
2878
  frpConfigurationStatus.textContent = frpConfigured ? t("frpConfigurationReady") : t("frpConfigurationMissing");
2879
+ frpOverview.hidden = !frpConfigured;
2880
+ frpOverviewEndpoint.textContent = configuredFrpOrigin === "" ? configuredFrpServer : `${configuredFrpOrigin} · ${configuredFrpServer}:${String(configuredFrpPort)}`;
2881
+ frpToken.placeholder = frpConfigured ? locale === "zh" ? "已安全保存;留空保持不变" : locale === "it" ? "Salvato; lascia vuoto per mantenerlo" : "Saved securely; leave blank to keep it" : t("frpTokenPlaceholder");
2882
+ vpsDeploy.textContent = frpConfigured ? locale === "zh" ? "修复或重新部署 VPS" : locale === "it" ? "Ripara o ridistribuisci VPS" : "Repair or redeploy VPS" : t("vpsDeploy");
2883
+ frpConnectionSummary.textContent = `${frpConfigured ? "✓ " : ""}${t("frpStep1Title")}`;
2884
+ frpComponentSummary.textContent = `${frpInstalled ? "✓ " : ""}${t("frpStep3Title")}`;
2885
+ if (!frpLayoutInitialized) {
2886
+ frpConnectionGroup.open = !frpConfigured;
2887
+ frpComponentGroup.open = !frpInstalled;
2888
+ frpVpsGroup.open = false;
2889
+ frpLayoutInitialized = true;
2890
+ }
2646
2891
  selfHostedBadge.textContent = frpConfigured && frpInstalled ? t("ready") : t("advanced");
2647
2892
  const state = typeof data.state === "string" ? data.state : "error";
2648
2893
  const errorCode = typeof data.errorCode === "string" ? data.errorCode : "";
@@ -2825,10 +3070,10 @@ window.__ModuleLoader__.load({
2825
3070
  });
2826
3071
  const validFrpServer = (value) => value === value.trim() && value.length > 0 && value.length <= 253 && !/[\s\u0000-\u001f\u007f/\\@?#]/u.test(value);
2827
3072
  const frpForm = () => ({
2828
- serverAddress: frpServer.value.trim(),
3073
+ serverAddress: String(frpServer.value ?? "").trim(),
2829
3074
  serverPort: Number(frpPort.value),
2830
- token: frpToken.value,
2831
- publicOrigin: frpOrigin.value.trim()
3075
+ token: String(frpToken.value ?? ""),
3076
+ publicOrigin: String(frpOrigin.value ?? "").trim()
2832
3077
  });
2833
3078
  const validFrpForm = (form) => {
2834
3079
  if (!validFrpServer(form.serverAddress)) return false;
@@ -2851,6 +3096,260 @@ window.__ModuleLoader__.load({
2851
3096
  remoteStatus.textContent = t("templateCopyFailed");
2852
3097
  });
2853
3098
  });
3099
+ const validVpsSshUser = (value) => /^[a-z_][a-z0-9_.-]*[$]?$/iu.test(value) && value.length <= 64;
3100
+ const validVpsSshKey = (value) => value === "" || /^[a-zA-Z]:[\\/]/u.test(value) || value.startsWith("/");
3101
+ const vpsFormStorageKey = "dsh-mobile.frp-vps-form.v1";
3102
+ try {
3103
+ const saved = JSON.parse(localStorage.getItem(vpsFormStorageKey) ?? "{}");
3104
+ if (typeof saved.sshUser === "string" && validVpsSshUser(saved.sshUser)) vpsSshUser.value = saved.sshUser;
3105
+ if (typeof saved.sshPort === "number" && Number.isSafeInteger(saved.sshPort) && saved.sshPort >= 1 && saved.sshPort <= 65535) vpsSshPort.value = String(saved.sshPort);
3106
+ if (typeof saved.sshKeyPath === "string" && validVpsSshKey(saved.sshKeyPath)) vpsSshKey.value = saved.sshKeyPath;
3107
+ } catch {}
3108
+ const saveVpsForm = () => {
3109
+ try {
3110
+ localStorage.setItem(vpsFormStorageKey, JSON.stringify({
3111
+ sshUser: String(vpsSshUser.value ?? "").trim(),
3112
+ sshPort: Number(vpsSshPort.value),
3113
+ sshKeyPath: String(vpsSshKey.value ?? "").trim()
3114
+ }));
3115
+ } catch {}
3116
+ };
3117
+ const validVpsDeploymentForm = () => {
3118
+ const sshPort = Number(vpsSshPort.value);
3119
+ return validVpsFrpForm() && validVpsSshUser(String(vpsSshUser.value ?? "").trim()) && Number.isSafeInteger(sshPort) && sshPort >= 1 && sshPort <= 65535 && validVpsSshKey(String(vpsSshKey.value ?? "").trim());
3120
+ };
3121
+ /**
3122
+ * VPS actions accept blank connection fields when a configuration is already
3123
+ * saved; blanks keep their saved values ("已保存时可留空"). The token itself
3124
+ * is never readable here, so a blank token is merged server-side while the
3125
+ * remaining fields still pass the regular template check.
3126
+ */
3127
+ const vpsEffectiveForm = () => {
3128
+ const form = frpForm();
3129
+ if (!frpConfigured) return form;
3130
+ return {
3131
+ serverAddress: form.serverAddress === "" ? configuredFrpServer : form.serverAddress,
3132
+ serverPort: Number.isSafeInteger(form.serverPort) && form.serverPort >= 1 ? form.serverPort : configuredFrpPort,
3133
+ token: form.token,
3134
+ publicOrigin: form.publicOrigin === "" ? configuredFrpOrigin : form.publicOrigin
3135
+ };
3136
+ };
3137
+ const validVpsFrpForm = () => {
3138
+ const form = vpsEffectiveForm();
3139
+ const token = form.token === "" && frpConfigured ? "0123456789abcdef" : form.token;
3140
+ return validFrpForm({
3141
+ ...form,
3142
+ token
3143
+ });
3144
+ };
3145
+ const refreshVpsDeployButton = () => {
3146
+ const disabled = remoteProviderBusy || !validVpsDeploymentForm();
3147
+ vpsDeploy.disabled = disabled;
3148
+ vpsCopyUninstall.disabled = disabled;
3149
+ vpsUninstall.disabled = disabled;
3150
+ };
3151
+ for (const input of [
3152
+ frpServer,
3153
+ frpPort,
3154
+ frpToken,
3155
+ frpOrigin,
3156
+ vpsSshUser,
3157
+ vpsSshPort,
3158
+ vpsSshKey
3159
+ ]) input.addEventListener("input", refreshVpsDeployButton);
3160
+ for (const input of [
3161
+ vpsSshUser,
3162
+ vpsSshPort,
3163
+ vpsSshKey
3164
+ ]) input.addEventListener("input", saveVpsForm);
3165
+ const vpsCertName = (origin) => {
3166
+ try {
3167
+ const host = new URL(origin).hostname;
3168
+ return /^\d{1,3}(?:\.\d{1,3}){3}$/u.test(host) ? host : void 0;
3169
+ } catch {
3170
+ return;
3171
+ }
3172
+ };
3173
+ const requestVpsHostKeys = (serverAddress, sshUser, sshPort, sshKeyPath) => {
3174
+ const keyPayload = {
3175
+ serverAddress,
3176
+ sshUser,
3177
+ sshPort
3178
+ };
3179
+ if (sshKeyPath !== "") keyPayload.sshKeyPath = sshKeyPath;
3180
+ return controlRequestJson("/api/mobile-access/remote/frp/vps/host-keys", {
3181
+ method: "POST",
3182
+ body: JSON.stringify(keyPayload)
3183
+ }, LONG_CONTROL_REQUEST_TIMEOUT_MS).then((keys) => {
3184
+ const confirmed = (Array.isArray(keys.vpsHostKeys) ? keys.vpsHostKeys : []).filter((key) => typeof key.fingerprint === "string" && typeof key.keyType === "string").map((key) => ({
3185
+ display: `${String(key.keyType)} ${String(key.fingerprint)}`,
3186
+ fingerprint: String(key.fingerprint)
3187
+ }));
3188
+ if (confirmed.length === 0) throw new Error(String(t("vpsHostKeyFailed", { error: "empty" })));
3189
+ return confirmed;
3190
+ });
3191
+ };
3192
+ vpsDeploy.addEventListener("click", () => {
3193
+ if (remoteProviderBusy) return;
3194
+ const form = vpsEffectiveForm();
3195
+ if (!validVpsFrpForm()) {
3196
+ vpsDeployStatus.textContent = t("vpsDeployNotReady");
3197
+ return;
3198
+ }
3199
+ const sshUser = vpsSshUser.value.trim();
3200
+ const sshPort = Number(vpsSshPort.value);
3201
+ const sshKeyPath = vpsSshKey.value.trim();
3202
+ saveVpsForm();
3203
+ if (!validVpsSshUser(sshUser) || !Number.isSafeInteger(sshPort) || sshPort < 1 || sshPort > 65535 || !validVpsSshKey(sshKeyPath)) {
3204
+ vpsDeployStatus.textContent = t("vpsDeployFailed", { error: t("vpsSshKey") });
3205
+ return;
3206
+ }
3207
+ remoteProviderBusy = true;
3208
+ vpsDeploy.disabled = true;
3209
+ vpsCopyUninstall.disabled = true;
3210
+ vpsUninstall.disabled = true;
3211
+ vpsDeployStatus.textContent = t("vpsHostKeyFetching");
3212
+ remoteStatus.textContent = t("vpsHostKeyFetching");
3213
+ requestVpsHostKeys(form.serverAddress, sshUser, sshPort, sshKeyPath).then((hostKeys) => {
3214
+ const display = hostKeys.map((key) => key.display).join("\n");
3215
+ if (!window.confirm(t("vpsDeployConfirmWithKeys", { fingerprints: display }))) {
3216
+ remoteProviderBusy = false;
3217
+ refreshVpsDeployButton();
3218
+ loadRemote();
3219
+ return;
3220
+ }
3221
+ vpsDeployStatus.textContent = t("vpsDeploying");
3222
+ remoteStatus.textContent = t("vpsDeploying");
3223
+ const payload = {
3224
+ confirm: true,
3225
+ ...form,
3226
+ sshUser,
3227
+ sshPort,
3228
+ hostFingerprints: hostKeys.map((key) => key.fingerprint)
3229
+ };
3230
+ if (sshKeyPath !== "") payload.sshKeyPath = sshKeyPath;
3231
+ return controlRequestJson("/api/mobile-access/remote/frp/vps/deploy", {
3232
+ method: "POST",
3233
+ body: JSON.stringify(payload)
3234
+ }, LONG_CONTROL_REQUEST_TIMEOUT_MS).then((data) => {
3235
+ const deployment = data.vpsDeployment !== null && typeof data.vpsDeployment === "object" ? data.vpsDeployment : {};
3236
+ vpsDeployStatus.textContent = deployment.deployed === true ? t("vpsDeploySuccess") : t("vpsDeployFailed", { error: t("vpsDeployFailed", { error: "unknown result" }) });
3237
+ remoteStatus.textContent = vpsDeployStatus.textContent;
3238
+ renderRemote(data);
3239
+ }, (error) => {
3240
+ vpsDeployStatus.textContent = t("vpsDeployFailed", { error: String(error) });
3241
+ remoteStatus.textContent = vpsDeployStatus.textContent;
3242
+ }).finally(() => {
3243
+ remoteProviderBusy = false;
3244
+ loadRemote();
3245
+ });
3246
+ }, (error) => {
3247
+ vpsDeployStatus.textContent = t("vpsHostKeyFailed", { error: String(error) });
3248
+ remoteStatus.textContent = vpsDeployStatus.textContent;
3249
+ remoteProviderBusy = false;
3250
+ refreshVpsDeployButton();
3251
+ loadRemote();
3252
+ });
3253
+ });
3254
+ const readVpsSshForm = () => {
3255
+ const sshUser = vpsSshUser.value.trim();
3256
+ const sshPort = Number(vpsSshPort.value);
3257
+ const sshKeyPath = vpsSshKey.value.trim();
3258
+ saveVpsForm();
3259
+ if (!validVpsSshUser(sshUser) || !Number.isSafeInteger(sshPort) || sshPort < 1 || sshPort > 65535 || !validVpsSshKey(sshKeyPath)) {
3260
+ vpsDeployStatus.textContent = t("vpsDeployFailed", { error: t("vpsSshKey") });
3261
+ return;
3262
+ }
3263
+ return {
3264
+ sshUser,
3265
+ sshPort,
3266
+ sshKeyPath
3267
+ };
3268
+ };
3269
+ vpsCopyUninstall.addEventListener("click", () => {
3270
+ if (remoteProviderBusy) return;
3271
+ const form = vpsEffectiveForm();
3272
+ if (!validVpsFrpForm()) {
3273
+ vpsDeployStatus.textContent = t("vpsDeployNotReady");
3274
+ return;
3275
+ }
3276
+ remoteProviderBusy = true;
3277
+ refreshVpsDeployButton();
3278
+ const scriptPayload = { serverPort: form.serverPort };
3279
+ const certName = vpsCertName(form.publicOrigin);
3280
+ if (certName !== void 0) scriptPayload.certName = certName;
3281
+ controlRequestJson("/api/mobile-access/remote/frp/vps/uninstall-script", {
3282
+ method: "POST",
3283
+ body: JSON.stringify(scriptPayload)
3284
+ }, LONG_CONTROL_REQUEST_TIMEOUT_MS).then((data) => {
3285
+ const script = typeof data.vpsUninstallScript === "string" ? data.vpsUninstallScript : "";
3286
+ if (script === "") throw new Error("empty script");
3287
+ return navigator.clipboard.writeText(script);
3288
+ }).then(() => {
3289
+ vpsDeployStatus.textContent = t("vpsUninstallScriptCopied");
3290
+ }, (error) => {
3291
+ vpsDeployStatus.textContent = t("vpsUninstallScriptFailed", { error: String(error) });
3292
+ }).finally(() => {
3293
+ remoteProviderBusy = false;
3294
+ loadRemote();
3295
+ });
3296
+ });
3297
+ vpsUninstall.addEventListener("click", () => {
3298
+ if (remoteProviderBusy) return;
3299
+ const form = vpsEffectiveForm();
3300
+ if (!validVpsFrpForm()) {
3301
+ vpsDeployStatus.textContent = t("vpsDeployNotReady");
3302
+ return;
3303
+ }
3304
+ const ssh = readVpsSshForm();
3305
+ if (ssh === void 0) return;
3306
+ remoteProviderBusy = true;
3307
+ refreshVpsDeployButton();
3308
+ vpsDeployStatus.textContent = t("vpsHostKeyFetching");
3309
+ remoteStatus.textContent = t("vpsHostKeyFetching");
3310
+ requestVpsHostKeys(form.serverAddress, ssh.sshUser, ssh.sshPort, ssh.sshKeyPath).then((hostKeys) => {
3311
+ if (!window.confirm(t("vpsUninstallConfirmWithKeys", { fingerprints: hostKeys.map((key) => key.display).join("\n") }))) {
3312
+ remoteProviderBusy = false;
3313
+ refreshVpsDeployButton();
3314
+ loadRemote();
3315
+ return;
3316
+ }
3317
+ vpsDeployStatus.textContent = t("vpsUninstalling");
3318
+ remoteStatus.textContent = t("vpsUninstalling");
3319
+ const payload = {
3320
+ confirm: true,
3321
+ serverAddress: form.serverAddress,
3322
+ serverPort: form.serverPort,
3323
+ sshUser: ssh.sshUser,
3324
+ sshPort: ssh.sshPort,
3325
+ hostFingerprints: hostKeys.map((key) => key.fingerprint)
3326
+ };
3327
+ if (ssh.sshKeyPath !== "") payload.sshKeyPath = ssh.sshKeyPath;
3328
+ const certName = vpsCertName(form.publicOrigin);
3329
+ if (certName !== void 0) payload.certName = certName;
3330
+ return controlRequestJson("/api/mobile-access/remote/frp/vps/uninstall", {
3331
+ method: "POST",
3332
+ body: JSON.stringify(payload)
3333
+ }, LONG_CONTROL_REQUEST_TIMEOUT_MS).then((data) => {
3334
+ const removal = data.vpsUninstall !== null && typeof data.vpsUninstall === "object" ? data.vpsUninstall : {};
3335
+ vpsDeployStatus.textContent = removal.removed === true ? t("vpsUninstallSuccess") : t("vpsUninstallFailed", { error: t("vpsUninstallFailed", { error: "unknown result" }) });
3336
+ remoteStatus.textContent = vpsDeployStatus.textContent;
3337
+ renderRemote(data);
3338
+ }, (error) => {
3339
+ vpsDeployStatus.textContent = t("vpsUninstallFailed", { error: String(error) });
3340
+ remoteStatus.textContent = vpsDeployStatus.textContent;
3341
+ }).finally(() => {
3342
+ remoteProviderBusy = false;
3343
+ loadRemote();
3344
+ });
3345
+ }, (error) => {
3346
+ vpsDeployStatus.textContent = t("vpsHostKeyFailed", { error: String(error) });
3347
+ remoteStatus.textContent = vpsDeployStatus.textContent;
3348
+ remoteProviderBusy = false;
3349
+ refreshVpsDeployButton();
3350
+ loadRemote();
3351
+ });
3352
+ });
2854
3353
  frpInstall.addEventListener("click", () => {
2855
3354
  if (remoteProviderBusy) return;
2856
3355
  if (!window.confirm(t("frpInstallConfirm", { size: frpDownloadSize }))) return;
@@ -4490,6 +4989,7 @@ window.__ModuleLoader__.load({
4490
4989
  .dsh-mobile-control__self-hosted{margin:8px 0 0;border:1px solid var(--dsw-alias-border-subtle,#dbe1e8);border-radius:12px;background:var(--dsw-alias-bg-layer-1,#f8fafc)}.dsh-mobile-control__self-hosted-summary{display:flex;box-sizing:border-box;min-height:48px;align-items:center;justify-content:space-between;gap:10px;padding:8px 11px;cursor:pointer;list-style-position:inside}.dsh-mobile-control__self-hosted-summary>span:first-child{display:flex;min-width:0;flex-direction:column;gap:1px}.dsh-mobile-control__self-hosted-summary strong{font-size:11px}.dsh-mobile-control__self-hosted-summary span span{color:var(--dsw-alias-label-secondary,#606873);font-size:9px;line-height:1.35}.dsh-mobile-control__provider-badge.is-frp{background:#eef0f3;color:#475569}.dsh-mobile-control__self-hosted-body{padding:0 8px 8px}.dsh-mobile-control__provider.is-frp{width:100%;min-height:64px;background:var(--dsw-alias-bg-layer-2,#fff)}
4491
4990
  .dsh-mobile-control__cpolar-setup{margin:0 0 12px;padding:12px;border:1px solid var(--dsw-alias-border-subtle,#dbe1e8);border-radius:13px;background:var(--dsw-alias-bg-layer-2,#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:var(--dsw-alias-bg-layer-3,var(--dsw-alias-bg-layer-2,#fff));color:var(--dsw-alias-label-primary,#16181d);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:var(--dsw-alias-bg-layer-1,#f3f5f8);color:var(--dsw-alias-label-secondary,#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:#dc2626;font:12px/1.3 system-ui;cursor:pointer}
4492
4991
  .dsh-mobile-control__frp-setup{margin:0;padding:12px;border:1px solid var(--dsw-alias-border-subtle,#dbe1e8);border-radius:13px;background:var(--dsw-alias-bg-layer-2,#fff)}.dsh-mobile-control__frp-setup[hidden]{display:none}.dsh-mobile-control__frp-step{padding:11px 0}.dsh-mobile-control__frp-step + .dsh-mobile-control__frp-step{border-top:1px solid var(--dsw-alias-border-subtle,#e1e5eb)}.dsh-mobile-control__frp-step>strong{display:block;margin-bottom:3px;font-size:12px;line-height:1.4}.dsh-mobile-control__frp-step>p{margin:0 0 9px;color:var(--dsw-alias-label-secondary,#606873);font-size:11px;line-height:1.5}.dsh-mobile-control__frp-step>.dsh-mobile-control__frp-requirement{padding:8px 9px;border-radius:9px;background:var(--dsw-alias-bg-layer-1,#f3f5f8);color:var(--dsw-alias-label-primary,#384152);font-size:11px}.dsh-mobile-control__frp-fields{display:grid;grid-template-columns:minmax(0,1fr) 96px;gap:8px}.dsh-mobile-control__field{display:flex;min-width:0;flex-direction:column;gap:5px;color:var(--dsw-alias-label-secondary,#606873);font-size:11px}.dsh-mobile-control__field:nth-child(3),.dsh-mobile-control__field:nth-child(4){grid-column:1/-1}.dsh-mobile-control__field input{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:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-primary,#16181d);font:16px/1.4 system-ui}.dsh-mobile-control__frp-action{box-sizing:border-box;width:100%;min-height:44px;padding:9px 12px;border-radius:10px;font:650 12px/1.3 system-ui;cursor:pointer}.dsh-mobile-control__frp-action:disabled{cursor:not-allowed;opacity:.5}.dsh-mobile-control__remote-workspace{margin:0;padding:12px;border:1px solid var(--dsw-alias-border-subtle,#dbe1e8);border-radius:15px;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}.dsh-mobile-control__stage-header{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:9px}.dsh-mobile-control__stage-header .dsh-mobile-control__section-title{margin:0}.dsh-mobile-control__stage-meta{display:flex;min-width:0;align-items:center;justify-content:flex-end;gap:5px}.dsh-mobile-control__stage-value{max-width:115px;overflow:hidden;color:var(--dsw-alias-label-primary,#16181d);font:650 10px/1.3 system-ui;text-overflow:ellipsis;white-space:nowrap}.dsh-mobile-control__state-badge{flex:none;padding:3px 7px;border-radius:999px;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-secondary,#606873);font:650 9px/1.25 system-ui}.dsh-mobile-control__state-badge.is-ready{background:#e6f7f0;color:#087454}.dsh-mobile-control__state-badge.is-busy{background:#e8f0ff;color:#1d4ed8}.dsh-mobile-control__state-badge.is-attention{background:#fff4dc;color:#935100}.dsh-mobile-control__remote-workspace>.dsh-mobile-control__status{box-sizing:border-box;margin:0 0 10px;padding:9px 10px;border-radius:10px;background:var(--dsw-alias-bg-layer-2,#fff);font-size:11px;line-height:1.45}.dsh-mobile-control__provider-setup-body{margin:0 0 10px}.dsh-mobile-control__provider-setup-body>.dsh-mobile-control__cpolar-setup{margin:0}.dsh-mobile-control__provider-setup-body>.dsh-mobile-control__cpolar-setup>.dsh-mobile-control__section-title,.dsh-mobile-control__provider-setup-body>.dsh-mobile-control__frp-setup>.dsh-mobile-control__section-title{display:none}.dsh-mobile-control__provider-setup-body>.dsh-mobile-control__details{margin:0;padding:9px 10px;border:1px solid var(--dsw-alias-border-subtle,#dbe1e8);border-radius:11px;background:var(--dsw-alias-bg-layer-2,#fff)}.dsh-mobile-control__remote-workspace>.dsh-mobile-control__actions{margin-top:2px}.dsh-mobile-control__remote-workspace>.dsh-mobile-control__qr{margin:10px 0 0}.dsh-mobile-control__remote-workspace>.dsh-mobile-control__manage-row{margin-top:10px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-subtle,#e1e5eb)}
4992
+ .dsh-mobile-control__frp-overview{display:grid;grid-template-columns:32px minmax(0,1fr);align-items:center;gap:10px;margin:0 0 10px;padding:10px;border:1px solid #a9dfc9;border-radius:11px;background:#edf9f4}.dsh-mobile-control__frp-overview[hidden]{display:none}.dsh-mobile-control__frp-overview-mark{display:grid;width:32px;height:32px;place-items:center;border-radius:50%;background:#087454;color:#fff;font:700 15px/1 system-ui}.dsh-mobile-control__frp-overview-body{display:flex;min-width:0;flex-direction:column;gap:2px}.dsh-mobile-control__frp-overview-body strong{color:#075d46;font-size:12px;line-height:1.35}.dsh-mobile-control__frp-overview-body span{overflow:hidden;color:#357061;font:10px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;text-overflow:ellipsis;white-space:nowrap}.dsh-mobile-control__frp-group{margin:8px 0 0;overflow:hidden;border:1px solid var(--dsw-alias-border-subtle,#dbe1e8);border-radius:11px;background:var(--dsw-alias-bg-layer-1,#f8fafc)}.dsh-mobile-control__frp-group>summary{box-sizing:border-box;min-height:44px;padding:12px 34px 10px 12px;color:var(--dsw-alias-label-primary,#16181d);font:650 12px/1.4 system-ui;cursor:pointer}.dsh-mobile-control__frp-group[open]>summary{border-bottom:1px solid var(--dsw-alias-border-subtle,#e1e5eb);background:var(--dsw-alias-bg-layer-2,#fff)}.dsh-mobile-control__frp-group>.dsh-mobile-control__frp-step{padding:12px}.dsh-mobile-control__frp-group>.dsh-mobile-control__frp-step>strong:first-child{display:none}.dsh-mobile-control__frp-group .dsh-mobile-control__frp-step{border-top:0}.dsh-mobile-control__frp-setup>.dsh-mobile-control__frp-step{margin-bottom:8px;padding:10px;border:1px solid var(--dsw-alias-border-subtle,#dbe1e8);border-radius:11px;background:var(--dsw-alias-bg-layer-1,#f8fafc)}
4493
4993
  .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}
4494
4994
  .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}
4495
4995
  .dsh-mobile-control__guide{margin:0 0 14px;padding:12px;border:1px solid #6f96db;border-radius:12px;background:var(--dsw-alias-bg-layer-1,#eff6ff)}.dsh-mobile-control__guide[hidden]{display:none}.dsh-mobile-control__guide-title{margin:0;color:var(--dsw-alias-label-primary,#172554);font:650 13px/1.45 system-ui}.dsh-mobile-control__guide-summary,.dsh-mobile-control__guide-note{margin:4px 0 0;color:var(--dsw-alias-label-secondary,#475569);font-size:12px;line-height:1.5}.dsh-mobile-control__guide-steps{margin:8px 0 0;padding-left:20px;color:var(--dsw-alias-label-primary,#1e293b);font-size:12px;line-height:1.6}.dsh-mobile-control__guide-note{color:var(--dsw-alias-label-secondary,#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}