dsh-mobile 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,7 @@
1
1
  # Cloudflare named tunnel (a stable public hostname)
2
2
 
3
+ [中文指南](CLOUDFLARE_TUNNEL.md)
4
+
3
5
  The built-in cloudflared provider runs in one of two modes, chosen in the panel under **Mobile access → Remote → cloudflared → Tunnel type**:
4
6
 
5
7
  | | Quick tunnel (default) | Named tunnel |
@@ -52,11 +54,11 @@ Moving to a fixed hostname means **an already paired phone must pair again**: a
52
54
  >
53
55
  > Scanning is optional: the full link (`https://your-domain/mobile-access/pair#instance=…&token=…`) can be copied to the phone and pasted, because the app accepts a complete link in its input field.
54
56
 
55
- The old remote entry in the device list will show **Address may have changed** or unreachable; delete it once the new pairing succeeds.
57
+ The old address may show **Address may have changed** or temporarily unreachable before re-pairing. After pairing the same computer again, the app updates its existing remote-device record by device identity and keeps the custom name; there is no old entry to delete.
56
58
 
57
59
  ## Security boundary
58
60
 
59
- - The token is written only into the DSH Mobile private directory (`~/.dsh/mobile-access/remote/cloudflared/tunnel.json`, mode 0600) and is passed to `cloudflared` **only** through the `TUNNEL_TOKEN` environment variable, never on the command line, which any local process can read.
61
+ - The token is written only into the DSH Mobile private directory (by default `$DSH_HOME/mobile-access/remote/cloudflared/tunnel.json`; Unix mode `0600`, restricted ACL on Windows) and is passed to `cloudflared` **only** through the `TUNNEL_TOKEN` environment variable, never on the command line.
60
62
  - The token is never returned to a browser or a phone. Panel status carries only whether it is configured, plus the hostname and port.
61
63
  - Behind the tunnel sits DSH Mobile's own authenticated gateway: the public hostname exposes that gateway, and DSH still requires paired-device credentials.
62
64
  - The plugin adds no system service, startup item, registry entry or PATH entry. Disabling the channel ends the process.
@@ -90,24 +92,17 @@ The old remote entry in the device list will show **Address may have changed** o
90
92
 
91
93
  ## Troubleshooting
92
94
 
93
- - **Stuck on "connecting"**: a named tunnel prints no banner, so it only becomes ready once the connector registers. Read the cloudflared output in the DSH log; the `CONNECTIVITY PRE-CHECKS` table says whether DNS, UDP/QUIC or TCP is the problem.
95
+ - **Stuck on "connecting"**: the named tunnel becomes ready only after the connector registers with Cloudflare. Check cloudflared output in the DSH log and test DNS, UDP/QUIC and TCP reachability from the computer to a Cloudflare edge.
94
96
  - **Public access returns 1033**: Cloudflare believes no connector is healthy for that hostname. Check the tunnel shows Healthy in Zero Trust, and that its ingress port matches the panel.
95
97
  - **`cloudflared` reports `Unauthorized`, or the tunnel id does not exist**: the token belongs to a different tunnel, for example one that was deleted and recreated. Copy the token again.
96
- - **With a TUN or transparent proxy running (Clash, Mihomo, Clash Verge), the phone sticks on "Loading plugins" and retries forever.** This is a real failure that was measured, and it is easy to misread as a pairing or plugin problem:
97
- - `cloudflared`'s connection to `region*.v2.argotunnel.com` has no dedicated rule, so it falls through to the catch-all `Match`/`MATCH` rule and is sent into a proxy node. The core's own API shows it plainly:
98
- ```
99
- cloudflared.exe -> region2.v2.argotunnel.com
100
- rule=Match chains=["<some airport node>","漏网之鱼"] up/down = 103 MB / 2.1 MB
101
- ```
102
- - Downstream throughput then collapses to **8–100 KB/s**. The phone's DSH boot pulls a **4.5 MB** (compressed) boot bundle in one shot, the loader gives up, and the app shows "load, fail, retry".
103
- - The same file took **0.2 s** on the LAN gateway, and **9.9 s / 454 KB/s** through the tunnel once routing was fixed — two orders of magnitude apart.
104
- - Fix: route cloudflared and its edge domains directly. In Clash Verge's **global merge profile** (`profiles/Merge.yaml`, which subscription updates do not overwrite):
105
- ```yaml
106
- prepend-rules:
107
- - PROCESS-NAME,cloudflared.exe,DIRECT
108
- - DOMAIN-SUFFIX,argotunnel.com,DIRECT
109
- - DOMAIN-SUFFIX,trycloudflare.com,DIRECT
110
- ```
111
- Then make cloudflared **reconnect**: hot-reloading the core does not migrate long-lived connections (use the panel's reconnect action, or toggle the provider off and on).
112
- - To confirm it is this and not the phone: fetch the boot bundle from the computer with the same session and watch the rate. If the computer is just as slow, the phone and pairing are innocent.
98
+ - **The phone stalls on "Loading plugins" or the remote page is slow while a TUN or transparent proxy is active**: check whether a catch-all rule sends `cloudflared` connections to `*.argotunnel.com` through a proxy node. This routing has caused slow boot-resource transfers before; it does not by itself indicate a pairing failure. This Clash-rule example is for Windows; on Linux, adapt the process name and syntax to your proxy client, and put direct-route rules before catch-all rules:
99
+
100
+ ```yaml
101
+ prepend-rules:
102
+ - PROCESS-NAME,cloudflared.exe,DIRECT
103
+ - DOMAIN-SUFFIX,argotunnel.com,DIRECT
104
+ - DOMAIN-SUFFIX,trycloudflare.com,DIRECT
105
+ ```
106
+
107
+ Then make cloudflared **reconnect** so existing connections take the new route (use the panel's reconnect action, or toggle the provider off and on). Compare the same resource through the LAN and remote entries. If the remote entry is also slow on the computer, inspect the computer-to-Cloudflare route first; if only the phone is slow, inspect its network.
113
108
  - **The domain still resolves to the old address**: after the nameserver change Cloudflare must move the zone from Pending to Active, and records in a pending zone are not served publicly.
@@ -1,5 +1,7 @@
1
1
  # Cloudflare 命名隧道(固定公网域名)
2
2
 
3
+ [English guide](CLOUDFLARE_TUNNEL.en.md)
4
+
3
5
  内置的 cloudflared 通道有两种模式,在面板 **移动访问 → 远程 → cloudflared → 隧道类型** 中切换:
4
6
 
5
7
  | | 快速隧道(默认) | 命名隧道 |
@@ -52,11 +54,11 @@
52
54
  >
53
55
  > 不想扫码时,完整链接(`https://你的域名/mobile-access/pair#instance=…&token=…`)可以整条复制到手机上粘贴,App 的输入框接受完整链接。
54
56
 
55
- 设备列表里原来那条远程记录会显示「地址可能已变化 / Address may have changed」或不可达,重新配对成功后删掉即可。
57
+ 旧地址在重新配对前可能显示「地址可能已变化」或暂不可达。重新配对同一台电脑后,App 会按设备标识更新原远程记录,保留自定义名称;无需删除它。
56
58
 
57
59
  ## 安全边界
58
60
 
59
- - 令牌只写入 DSH Mobile 私有目录(`~/.dsh/mobile-access/remote/cloudflared/tunnel.json`,权限 0600),并且**只**通过子进程环境变量 `TUNNEL_TOKEN` 传给 `cloudflared`,不出现在命令行里(本机任何进程都能读到命令行)。
61
+ - 令牌只写入 DSH Mobile 私有目录(默认 `$DSH_HOME/mobile-access/remote/cloudflared/tunnel.json`;Unix 权限 `0600`,Windows 使用受限 ACL),并且**只**通过子进程环境变量 `TUNNEL_TOKEN` 传给 `cloudflared`,不出现在命令行里。
60
62
  - 令牌从不回传给浏览器或手机端;面板状态里只有「是否已配置」、域名和端口。
61
63
  - 隧道背后是 DSH Mobile 自己的认证网关:公网主机名只暴露该网关,DSH 本体仍然需要配对设备凭据。
62
64
  - 插件不会创建系统服务、开机启动项、注册表项或 PATH 项;关闭通道即结束进程。
@@ -90,24 +92,17 @@
90
92
 
91
93
  ## 排错
92
94
 
93
- - **连接一直停在「正在连接」**:命名隧道没有横幅,只有 connector 真正注册后才算就绪。查看 DSH 日志里 cloudflared 的输出;预检表(`CONNECTIVITY PRE-CHECKS`)会指出是 DNS、UDP/QUIC 还是 TCP 不通。
95
+ - **连接一直停在「正在连接」**:命名隧道只有在 connector 向 Cloudflare 注册后才显示就绪。检查 DSH 日志中的 cloudflared 输出,以及本机到 Cloudflare 边缘的 DNS、UDP/QUIC 和 TCP 连通性。
94
96
  - **公网访问返回 1033**:Cloudflare 认为该主机名没有健康的 connector。确认隧道在 Zero Trust 里显示 Healthy,且 ingress 指向的端口与面板一致。
95
97
  - **`cloudflared` 报 `Unauthorized` 或隧道 ID 不存在**:令牌与控制台里的隧道不匹配(例如隧道被删除后重建)。重新复制令牌。
96
- - **本机开着 TUN/透明代理(Clash、Mihomo、Clash Verge 等):手机端会卡在「正在加载插件」并反复重试。** 这是实测到过的一类真实故障,症状很容易被误判成配对或插件问题:
97
- - `cloudflared` 到 `region*.v2.argotunnel.com` 的连接没有任何专属规则,于是落到兜底的 `Match`/`MATCH` 规则,被送进代理节点。用内核 API 看得很清楚:
98
- ```
99
- cloudflared.exe -> region2.v2.argotunnel.com
100
- rule=Match chains=["<某个机场节点>","漏网之鱼"] up/down = 103 MB / 2.1 MB
101
- ```
102
- - 结果下行被压到 **8–100 KB/s**。而手机端 DSH 启动要一次拉 **4.5 MB**(压缩后)的 boot bundle,加载器等不到就报 `bundle script failed to load`,App 表现为「加载 → 失败 → 重试」。
103
- - 同一份文件在局域网网关上是 **0.2 秒**,加上直连规则后走隧道是 **9.9 秒 / 454 KB/s** —— 差了两个数量级。
104
- - 修法:让 cloudflared 与其边缘域名走直连。Clash Verge 的**全局扩展配置**(`profiles/Merge.yaml`,订阅更新不会覆盖):
105
- ```yaml
106
- prepend-rules:
107
- - PROCESS-NAME,cloudflared.exe,DIRECT
108
- - DOMAIN-SUFFIX,argotunnel.com,DIRECT
109
- - DOMAIN-SUFFIX,trycloudflare.com,DIRECT
110
- ```
111
- 改完要让 cloudflared **重连**才会换路由:内核热重载不会迁移已建立的长连接(面板里点重连,或开关一次提供方)。
112
- - 判断方法:如果手机端一直卡在加载插件,先在**本机**用同一份会话拉一次 boot bundle 看速率。若本机也很慢,就不是手机或配对的问题。
98
+ - **手机卡在「正在加载插件」或远程页面明显慢,而本机开着 TUN/透明代理**:先检查 `cloudflared` 到 `*.argotunnel.com` 的连接是否被兜底规则送进代理节点。曾有这种路由导致启动资源传输很慢;它不等于配对失败。以下是 Windows 上 Clash 客户端的规则示例;Linux 的进程名与具体规则语法应按所用客户端调整,并让直连规则排在兜底规则前:
99
+
100
+ ```yaml
101
+ prepend-rules:
102
+ - PROCESS-NAME,cloudflared.exe,DIRECT
103
+ - DOMAIN-SUFFIX,argotunnel.com,DIRECT
104
+ - DOMAIN-SUFFIX,trycloudflare.com,DIRECT
105
+ ```
106
+
107
+ 改完后让 cloudflared **重连**,使已建立的连接使用新路由(面板里点重连,或开关一次提供方)。对比同一资源经局域网与远程入口的加载速度;若远程入口在电脑上也慢,优先检查电脑到 Cloudflare 的路径,若只有手机慢,再检查手机网络。
113
108
  - **域名解析还是旧地址**:改完 NS 后 Cloudflare 需要把 zone 从 Pending 变为 Active;A/CNAME 在 zone 激活前不会对外生效。
@@ -1,6 +1,6 @@
1
1
  # DeepSeek Harness 0.1.5 局域网验证
2
2
 
3
- 本页记录 DSH Mobile 0.3.15 对 DeepSeek Harness 0.1.5 的局域网兼容性证据,以及该次实测没有覆盖的远程路径。
3
+ 本页是 DSH Mobile 0.3.15 对 DeepSeek Harness 0.1.5 的历史局域网验证记录,不表示当前版本或远程通道已通过同一测试。当前支持组合见[项目兼容性表](../README.md#兼容性)。
4
4
 
5
5
  ## 验证环境
6
6
 
package/docs/README.md CHANGED
@@ -19,10 +19,10 @@
19
19
 
20
20
  | 文档 | 内容 |
21
21
  | --- | --- |
22
- | [DSH 0.1.5 局域网验证记录](DSH_0.1.5_LAN.md) | DSH Mobile 0.3.15 对 DeepSeek Harness 0.1.5 的局域网兼容验证 |
22
+ | [DSH 0.1.5 局域网验证记录](DSH_0.1.5_LAN.md) | DSH Mobile 0.3.15 的历史局域网实测;当前版本组合请看[兼容性表](../README.md#兼容性) |
23
23
 
24
24
  ## 面向维护者 / Maintainer notes
25
25
 
26
26
  维护者文档面向改代码的人,**不随 npm 包发布**,只在仓库中阅读:
27
27
 
28
- - `docs/HANDOFF_SELF_HOSTED_FRP.md` —— 自建 FRP 的代码地图、错误码、安全边界与真机验证记录。
28
+ - [自建 FRP 维护记录](HANDOFF_SELF_HOSTED_FRP.md) —— 0.4.6 开发时的代码地图、安全检查点与历史真机验证;当前使用步骤以本页上方的用户指南为准。
@@ -20,7 +20,7 @@ Android app
20
20
  - **Domain mode**: use your own public domain; Caddy obtains and renews the certificate automatically.
21
21
  - **Public IPv4 mode**: use the VPS public IPv4 address directly. The plugin uses Certbot to request a roughly six-day Let's Encrypt IP certificate and installs a daily renewal timer. Documentation ranges such as `203.0.113.10`, private addresses, and other reserved addresses are rejected; enter a real routable public IP.
22
22
 
23
- The publicly trusted entry in this guide requires Android app 0.3.3 or later for custom remote origins. Self-signed passthrough requires the 0.4.6 app with remote CA pinning; older apps cannot use that entry and continue to work with LAN, cpolar, and Tailscale.
23
+ The publicly trusted entry in this guide requires Android app 0.3.3 or later for custom remote origins. The separate self-signed TCP passthrough in the [existing-frps attachment guide](ATTACH_EXISTING_FRPS.en.md) is attach-only and requires the 0.4.6 or later app with remote CA pinning; older apps cannot use that entry and continue to work with LAN, cpolar, and Tailscale.
24
24
 
25
25
  ## Manual deployment vs automatic deployment
26
26
 
@@ -53,12 +53,14 @@ Public IPv4 mode also removes the corresponding Let's Encrypt IP certificate. Do
53
53
  On the VPS:
54
54
 
55
55
  ```bash
56
- sudo systemctl status dsh-mobile-frps.service caddy dsh-mobile-cert-renew.timer
56
+ sudo systemctl status dsh-mobile-frps.service caddy
57
57
  sudo journalctl -u dsh-mobile-frps.service -u caddy -n 200 --no-pager
58
58
  sudo ss -lntp
59
- curl -vk https://PUBLIC_HOST/
59
+ curl -v https://PUBLIC_HOST/mobile-access/discovery
60
60
  ```
61
61
 
62
+ Only public IPv4 mode installs `dsh-mobile-cert-renew.timer`; in that mode, also run `sudo systemctl status dsh-mobile-cert-renew.timer`. Replace `PUBLIC_HOST` with your actual domain or public IPv4 address. Do not add `-k`: verify the public certificate as well as the response. Check the same address from an independent external network and confirm discovery reports this computer's installation identifier; success on the VPS alone does not prove the phone's network can reach it.
63
+
62
64
  On the computer, start with the plugin log at `$DSH_HOME/mobile-access/logs/dsh-mobile.log` (JSONL, 5 MB rotation, tokens and keys redacted) and the panel diagnostic report. Windows antivirus software can quarantine frpc; if it does, create the smallest possible exception for the verified component directory only.
63
65
 
64
66
  ## Limits
@@ -69,11 +71,6 @@ On the computer, start with the plugin log at `$DSH_HOME/mobile-access/logs/dsh-
69
71
  - VPS SSH targets currently cannot use IPv6.
70
72
  - The frps plaintext vhost must bind to `127.0.0.1`. The plugin rejects a publicly reachable plaintext port and reports readiness only after public discovery identifies the current computer.
71
73
 
72
- ## Manual end-to-end checklist (before release)
74
+ ## Acceptance check
73
75
 
74
- - [x] Ubuntu IP mode: one-click redeploy on a VPS → public IPv4 ready → App discovery returned this computer (2026-09-03, Tencent Cloud).
75
- - [ ] Ubuntu domain mode: fresh VPS deployment → public domain ready → App pairing → send and receive messages.
76
- - [x] Existing Caddy coexistence: deploy into a Caddyfile containing an existing site and import line; verify user content is byte-for-byte preserved, the import occurs exactly once, and validation passes (2026-09-03, Tencent Cloud; concurrent writes use `flock`).
77
- - [x] Host-key change abort: an unconfirmed key set stops deployment/cleanup with `vps_host_key_mismatch` before network authentication (unit-test coverage plus full device fingerprint review).
78
- - [x] One-click cleanup: after deployment, services, files, timers, marked firewall rules, the owned system user, and the LE certificate were removed while the user's Caddy installation remained (2026-09-03, Tencent Cloud).
79
- - [x] Uninstall-script review: only DSH Mobile-owned paths and rules are touched (unit assertions plus device review).
76
+ After deployment, check the certificate, public discovery endpoint, app pairing, and a live conversation on your own VPS and phone network. The repository's historical tests are recorded in the [self-hosted FRP handoff](HANDOFF_SELF_HOSTED_FRP.md); they cannot replace your own acceptance test.
@@ -24,7 +24,7 @@
24
24
  - **域名模式**:公开地址是自己的域名,Caddy 自动申请并续期证书。
25
25
  - **公网 IPv4 模式**:公开地址直接是 VPS 公网 IPv4(例如你自己的 VPS 地址),插件用 Certbot 申请约 6 天有效的 Let's Encrypt IP 证书,并安装每日自动续期定时器。注意:文档示例地址(如 `203.0.113.10`)和内网、保留地址会被拒绝,必须填写真实可路由的公网 IP。
26
26
 
27
- 本页的公开证书入口需要 Android App 0.3.3 或更高版本(远程自定义入口)。自签穿透档需要 0.4.6 Android App 固定远程 CA;旧版 App 不支持该档,仍可使用局域网、cpolar 和 Tailscale。
27
+ 本页的公开证书入口需要 Android App 0.3.3 或更高版本(远程自定义入口)。另见[接入既有 frps](ATTACH_EXISTING_FRPS.md)中的自签 TCP 穿透档:它仅适用于接入模式,需要 0.4.6 或更新的 Android App 固定远程 CA;旧版 App 不支持该档,仍可使用局域网、cpolar 和 Tailscale。
28
28
 
29
29
  ## 手动部署 vs 自动部署
30
30
 
@@ -57,12 +57,14 @@ SSH 用户、端口和私钥路径只保存在当前浏览器的 `localStorage`
57
57
  VPS 上:
58
58
 
59
59
  ```bash
60
- sudo systemctl status dsh-mobile-frps.service caddy dsh-mobile-cert-renew.timer
60
+ sudo systemctl status dsh-mobile-frps.service caddy
61
61
  sudo journalctl -u dsh-mobile-frps.service -u caddy -n 200 --no-pager
62
62
  sudo ss -lntp
63
- curl -vk https://PUBLIC_HOST/
63
+ curl -v https://PUBLIC_HOST/mobile-access/discovery
64
64
  ```
65
65
 
66
+ 只有公网 IPv4 模式会安装 `dsh-mobile-cert-renew.timer`,该模式另查 `sudo systemctl status dsh-mobile-cert-renew.timer`。将 `PUBLIC_HOST` 换成实际域名或公网 IPv4;不加 `-k`,以便同时验证公开证书。还应从独立外部网络检查同一地址,并确认 discovery 返回当前电脑的安装标识;只在 VPS 本机成功不代表手机网络可达。
67
+
66
68
  本机优先看插件日志(`$DSH_HOME/mobile-access/logs/dsh-mobile.log`,JSONL,超 5 MB 轮转,自动脱敏 Token 与密钥)与面板诊断报告。Windows 杀毒软件可能隔离 frpc;如确有拦截,只为已校验的组件目录设置最小范围例外。
67
69
 
68
70
  ## 限制
@@ -73,11 +75,6 @@ curl -vk https://PUBLIC_HOST/
73
75
  - VPS 的 SSH 目前不支持 IPv6 地址。
74
76
  - frps 明文 vhost 必须只监听 `127.0.0.1`;插件会拒绝公网可达的明文端口,并在公开发现接口确认是当前电脑后才显示“已就绪”。
75
77
 
76
- ## 手动端到端验证清单(发布前)
78
+ ## 上线前验收
77
79
 
78
- - [x] Ubuntu IP 模式:已部署 VPS 上一键重部署 → 公网 IP 就绪 → App 级 discovery 返回当前电脑(2026-09-03,腾讯云实测)。
79
- - [ ] Ubuntu 域名模式:全新 VPS 一键部署 → 公网域名就绪 → App 远程配对 → 收发消息。
80
- - [x] 既有 Caddy 共存:在已有自有站点 + import 行的 Caddyfile 上部署,确认自有内容逐字节保留、import 恰好一行、validate 通过(2026-09-03,腾讯云实测;并发写有 flock 互斥)。
81
- - [x] 指纹变更中止:未确认密钥集合部署/清理直接 `vps_host_key_mismatch` 中止且零网络碰触(单元测试锁定;真机指纹全量核对通过)。
82
- - [x] 一键清理:部署后执行一键清理,服务、文件、定时器、标记防火墙规则、系统用户、LE 证书均已移除,自有 Caddy 本体保留(2026-09-03,腾讯云实测)。
83
- - [x] 卸载脚本审阅:仅触碰 DSH Mobile 拥有的路径与规则(单元测试断言 + 真机复核)。
80
+ 部署后,请在你的实际 VPS 与手机网络上检查证书、外网发现接口、App 配对和实时对话;本仓库的历史实测记录见[自建 FRP 交接记录](HANDOFF_SELF_HOSTED_FRP.md),不能替代你自己的验收。
@@ -13,7 +13,7 @@ This provider appears under **Mobile Access → Remote → Self-hosted connectio
13
13
  - Separate LAN proxy: listen on the DSH computer's private IPv4, e.g. `192.168.50.10:3444`, and allow the proxy's actual direct source, e.g. `192.168.50.1/32`.
14
14
  - Only explicit loopback/RFC1918 IPv4 binds are supported. Wildcard, public and IPv6 binds are rejected. The port must be 1024–65535 and may not be 3443 (the LAN gateway) or 3080 (DSH's own WebServer); note that 3444 is also the cloudflared named tunnel's default forward port. Source CIDRs must be canonical private/loopback networks, at most 16; prefer a single-host /32. Forwarded headers do not determine source authorization.
15
15
 
16
- Select **Save and start backend**, then point the proxy at the displayed HTTP backend. **Never expose/port-forward that HTTP listener publicly**, and never bypass it by proxying to DSH 8080 (or its configured WebServer port) or the LAN 3443 gateway. HTTP between separate devices is suitable only for a trusted private network. Pairing, authentication, normalized-exact Host/Origin enforcement, CSRF, Secure cookies and WebSocket path policy remain active. Do not open the plain HTTP backend in a browser to verify it: cookies are marked `Secure`, so a browser discards them over `http://` and login or CSRF will fail with no visible reason.
16
+ Select **Save and start backend**, then point the proxy at the displayed HTTP backend. **Never expose/port-forward that HTTP listener publicly**, and never bypass it by proxying to DSH's WebServer (default port 3080) or the LAN 3443 gateway. HTTP between separate devices is suitable only for a trusted private network. Pairing, authentication, normalized-exact Host/Origin enforcement, CSRF, Secure cookies and WebSocket path policy remain active. Do not open the plain HTTP backend in a browser to verify it: it only accepts requests proxied from the configured public HTTPS origin; direct access has the wrong Host, and its cookie/CSRF flow may fail.
17
17
 
18
18
  ## Proxy contract
19
19
 
@@ -15,7 +15,7 @@
15
15
  ```
16
16
 
17
17
  - **只有第一段允许暴露公网。HTTP 后端不能做公网端口映射。** HTTP 段仅适用于可信私网;局域网其他设备上的代理也会接触明文流量。
18
- - 后端不是裸 DSH:仍执行设备配对、认证、归一化后精确匹配的 Host/Origin 校验、CSRF、Secure Cookie 和 WebSocket 路径策略。没有配对的访问不能直接进入 DSH。**不要用浏览器直接打开这个明文 HTTP 后端做验证**:Cookie 带 `Secure`,浏览器在 `http://` 下会丢弃它们,登录或 CSRF 会无提示失败。
18
+ - 后端不是裸 DSH:仍执行设备配对、认证、归一化后精确匹配的 Host/Origin 校验、CSRF、Secure Cookie 和 WebSocket 路径策略。没有配对的访问不能直接进入 DSH。**不要用浏览器直接打开这个明文 HTTP 后端做验证**:它只支持由指定 HTTPS 公网地址代理的请求;直接打开时 Host 不匹配,Cookie 与 CSRF 流程也可能失效。
19
19
  - 不要把公网反代目标改成 DSH 的 WebServer 端口(默认 3080),也不要反代现有 LAN HTTPS 3443。新后端默认 3444,**明确拒绝 3443 和 3080**,并拒绝 1024 以下需要特权的端口。注意 3444 同时是 cloudflared 命名隧道的默认转发端口:同一时刻只有一个远程提供方能运行,但切换到本提供方时请确认该端口已释放。DSH 与 LAN 的原有设置不变。
20
20
  - 公网 TLS、证书续期、DNS、路由和代理由你管理。插件不会安装隧道组件、部署 VPS、修改代理/防火墙/路由器,也不会为该提供方发起公网探测。
21
21
 
package/lib/client.js CHANGED
@@ -2792,6 +2792,48 @@ window.__ModuleLoader__.load({
2792
2792
  function firstByClassSuffix(root, suffix) {
2793
2793
  return Array.from(root.querySelectorAll("[class]")).find((element) => classToken(element, suffix));
2794
2794
  }
2795
+ /**
2796
+ * Whether this composer is the landing hero rather than the composer of an open
2797
+ * conversation. DSH marks the hero composer's root with a `_hero` class and drops
2798
+ * it once a session owns the surface, which is the only difference available
2799
+ * without the layout's session id.
2800
+ */
2801
+ function composerIsLandingHero(composer, depth = 4) {
2802
+ let node = composer;
2803
+ for (let step = 0; step < depth && node !== null; step += 1) {
2804
+ if (classToken(node, "_hero")) return true;
2805
+ node = node.parentElement;
2806
+ }
2807
+ return false;
2808
+ }
2809
+ /** Resolve only identities published by the current conversation or selected session row. */
2810
+ function resolveComposerSessionOrigin(composer, selectedRow, selectedRowToken) {
2811
+ const conversationRoot = composer?.closest("[data-conversation-session]") ?? null;
2812
+ const conversationId = conversationRoot?.getAttribute("data-conversation-session");
2813
+ if (conversationRoot !== null && conversationId) return {
2814
+ sessionRoot: conversationRoot,
2815
+ sessionId: conversationId
2816
+ };
2817
+ const dedicatedRoot = composer?.closest("[data-dsh-mobile-session]") ?? null;
2818
+ const dedicatedId = dedicatedRoot?.getAttribute("data-dsh-mobile-session");
2819
+ if (dedicatedRoot !== null && dedicatedId) return {
2820
+ sessionRoot: dedicatedRoot,
2821
+ sessionId: dedicatedId
2822
+ };
2823
+ if (selectedRow === null) return {
2824
+ sessionRoot: null,
2825
+ sessionId: null
2826
+ };
2827
+ const identity = selectedRow.getAttribute("data-session-id") ?? selectedRow.getAttribute("aria-label") ?? selectedRow.textContent?.trim() ?? "";
2828
+ return {
2829
+ sessionRoot: selectedRow,
2830
+ sessionId: `${selectedRowToken(selectedRow)}:${identity}`
2831
+ };
2832
+ }
2833
+ /** An id-less non-hero composer still accepts App soft-keyboard line breaks, but not camera results. */
2834
+ function activeSessionForSoftEnter(composer, sessionId) {
2835
+ return sessionId !== null || composer !== null && composer.closest(".dshm-main") !== null && !composerIsLandingHero(composer);
2836
+ }
2795
2837
  /** Find the stock DSH application frame without mistaking a feature card for the shell. */
2796
2838
  function resolveNativeMobileFrame(root, dedicatedCenter) {
2797
2839
  if (dedicatedCenter !== void 0) return void 0;
@@ -2962,6 +3004,38 @@ window.__ModuleLoader__.load({
2962
3004
  function drawerScrimVisible(sidebarCollapsed, overlayActive) {
2963
3005
  return !sidebarCollapsed && overlayActive;
2964
3006
  }
3007
+ /** Let the stock page close one visible layer before Android navigates its WebView. */
3008
+ function installStockMobileBack(view, current, actions) {
3009
+ const onBack = (event) => {
3010
+ if (!event.cancelable || event.defaultPrevented) return;
3011
+ const layers = current();
3012
+ if (layers === null) return;
3013
+ if (layers.detailsOpen) {
3014
+ event.preventDefault();
3015
+ actions.closeDetails();
3016
+ } else if (layers.drawerOpen) {
3017
+ event.preventDefault();
3018
+ actions.closeDrawer();
3019
+ } else if (layers.mainPanelOpen) {
3020
+ event.preventDefault();
3021
+ actions.closeMainPanel();
3022
+ }
3023
+ };
3024
+ view.addEventListener("dsh-mobile:native-back", onBack);
3025
+ return () => {
3026
+ view.removeEventListener("dsh-mobile:native-back", onBack);
3027
+ };
3028
+ }
3029
+ function stockLayoutBackControl(value) {
3030
+ return typeof value === "object" && value !== null ? value : void 0;
3031
+ }
3032
+ /** Whether the stock layout can return from a selected main panel. */
3033
+ function stockMainPanelOpen(value) {
3034
+ const layout = stockLayoutBackControl(value);
3035
+ if (typeof layout?.selectPanel !== "function" || typeof layout.panelInfo?.getSnapshot !== "function") return false;
3036
+ const snapshot = layout.panelInfo.getSnapshot();
3037
+ return typeof snapshot === "object" && snapshot !== null && typeof snapshot.activePanelId === "string";
3038
+ }
2965
3039
  /** Only a known on-screen keyboard in an active App composer may change Enter into a line break. */
2966
3040
  function isSoftKeyboardEnterLineBreak(event, context) {
2967
3041
  if (context.nativeState?.imeVisible !== true || context.nativeState.noHardwareKeyboard !== true || !context.editable || !context.activeSession || context.commandMenuOpen || context.recentlyComposing) return false;
@@ -3009,7 +3083,7 @@ window.__ModuleLoader__.load({
3009
3083
  return !current.disposed && origin.generation === current.generation && origin.href === current.href && origin.composer !== null && current.composerConnected && origin.composer === current.composer && origin.sessionRoot !== null && origin.sessionRoot === current.sessionRoot && origin.sessionId !== null && origin.sessionId === current.sessionId;
3010
3084
  }
3011
3085
  /** Add mobile semantics without replacing feature trees. */
3012
- function installNativeMobileSurface() {
3086
+ function installNativeMobileSurface(backServices) {
3013
3087
  document.documentElement.classList.add("dsh-native-mobile-active");
3014
3088
  const browserLanguages = navigator.languages.length > 0 ? navigator.languages : [navigator.language];
3015
3089
  const language = resolveNativeMobileLanguage(document.documentElement.lang, browserLanguages);
@@ -3106,27 +3180,15 @@ window.__ModuleLoader__.load({
3106
3180
  const sessionTokens = /* @__PURE__ */ new WeakMap();
3107
3181
  let nextSessionToken = 0;
3108
3182
  const currentSessionOrigin = () => {
3109
- const dedicatedRoot = boundComposer?.closest("[data-dsh-mobile-session]") ?? null;
3110
- const dedicatedId = dedicatedRoot?.getAttribute("data-dsh-mobile-session");
3111
- if (dedicatedRoot !== null && typeof dedicatedId === "string" && dedicatedId !== "") return {
3112
- sessionRoot: dedicatedRoot,
3113
- sessionId: dedicatedId
3114
- };
3115
3183
  const selectedRow = document.querySelector("[role=\"treeitem\"][aria-selected=\"true\"]");
3116
- if (selectedRow === null) return {
3117
- sessionRoot: null,
3118
- sessionId: null
3119
- };
3120
- let token = sessionTokens.get(selectedRow);
3121
- if (token === void 0) {
3122
- token = `stock-${String(++nextSessionToken)}`;
3123
- sessionTokens.set(selectedRow, token);
3124
- }
3125
- const identity = selectedRow.getAttribute("data-session-id") ?? selectedRow.getAttribute("aria-label") ?? selectedRow.textContent?.trim() ?? "";
3126
- return {
3127
- sessionRoot: selectedRow,
3128
- sessionId: `${token}:${identity}`
3129
- };
3184
+ return resolveComposerSessionOrigin(boundComposer, selectedRow, (row) => {
3185
+ let token = sessionTokens.get(row);
3186
+ if (token === void 0) {
3187
+ token = `stock-${String(++nextSessionToken)}`;
3188
+ sessionTokens.set(row, token);
3189
+ }
3190
+ return token;
3191
+ });
3130
3192
  };
3131
3193
  const mediaRequestContext = () => {
3132
3194
  const session = currentSessionOrigin();
@@ -3472,7 +3534,7 @@ window.__ModuleLoader__.load({
3472
3534
  disposeEnterBinding = editor === null || composerCard === null ? void 0 : bindComposerSoftEnter(editor, () => ({
3473
3535
  nativeState: window.__DSH_MOBILE_NATIVE__ === void 0 ? null : window.__DSH_MOBILE_KEYBOARD_STATE__,
3474
3536
  editable: editor.isConnected && editor.getAttribute("contenteditable") === "true" && editor.getAttribute("aria-disabled") !== "true" && editor.getAttribute("aria-haspopup") !== "menu" && composerCard.getAttribute("aria-busy") !== "true",
3475
- activeSession: currentSessionOrigin().sessionId !== null,
3537
+ activeSession: activeSessionForSoftEnter(composerCard, currentSessionOrigin().sessionId),
3476
3538
  commandMenuOpen: composerCard.querySelector("[data-trigger-menu],button[aria-haspopup=\"listbox\"][aria-expanded=\"true\"]") !== null
3477
3539
  }));
3478
3540
  };
@@ -3621,6 +3683,33 @@ window.__ModuleLoader__.load({
3621
3683
  if (sidebar?.dataset.open === "true") toggle?.click();
3622
3684
  });
3623
3685
  sync();
3686
+ const stopStockBack = installStockMobileBack(window, () => {
3687
+ if (frame?.isConnected !== true) return null;
3688
+ const lastColumn = frame.style.gridTemplateColumns.trim().split(/\s+/).at(-1);
3689
+ return {
3690
+ detailsOpen: overlayQuery.matches && lastColumn !== void 0 && lastColumn !== "" && lastColumn !== "0px" && lastColumn !== "0",
3691
+ drawerOpen: overlayQuery.matches && sidebarRoot?.isConnected === true && toggle?.isConnected === true && !classToken(sidebarRoot, "_collapsed"),
3692
+ mainPanelOpen: stockMainPanelOpen(backServices.getLayout())
3693
+ };
3694
+ }, {
3695
+ closeDetails: () => {
3696
+ const layout = stockLayoutBackControl(backServices.getLayout());
3697
+ if (typeof layout?.closeRightbar !== "function") return;
3698
+ const sidebarRight = backServices.getSidebarRight();
3699
+ if (typeof sidebarRight === "object" && sidebarRight !== null) {
3700
+ const control = sidebarRight;
3701
+ if (typeof control.isExpanded === "function" && typeof control.toggleExpanded === "function" && control.isExpanded()) control.toggleExpanded();
3702
+ }
3703
+ layout.closeRightbar();
3704
+ },
3705
+ closeDrawer: () => {
3706
+ toggle?.click();
3707
+ },
3708
+ closeMainPanel: () => {
3709
+ const layout = stockLayoutBackControl(backServices.getLayout());
3710
+ if (typeof layout?.selectPanel === "function") layout.selectPanel(null);
3711
+ }
3712
+ });
3624
3713
  const disposeTaskWatcher = installTaskCompletionWatcher({
3625
3714
  completionFallback: false,
3626
3715
  label: (kind, sessionLabel) => kind === "done" ? {
@@ -3633,6 +3722,7 @@ window.__ModuleLoader__.load({
3633
3722
  });
3634
3723
  return () => {
3635
3724
  disposed = true;
3725
+ stopStockBack();
3636
3726
  disposeTaskWatcher();
3637
3727
  mediaRequestGeneration += 1;
3638
3728
  mediaPickerAbortController.abort();
@@ -3737,6 +3827,64 @@ window.__ModuleLoader__.load({
3737
3827
  function parseMobileRemoteProvider(value) {
3738
3828
  return value === "cpolar" || value === "cloudflared" || value === "frp" || value === "origin" ? value : "tailscale";
3739
3829
  }
3830
+ /**
3831
+ * Set a surface's visibility; opening a page or overlay brings it to the front.
3832
+ * @param container - the registered surface element.
3833
+ * @param open - whether to show it.
3834
+ */
3835
+ function setMobileSurfaceOpen(container, open) {
3836
+ container.hidden = !open;
3837
+ if (open && container.isConnected && (container.dataset.dshMobileSurfacePlacement === "page" || container.dataset.dshMobileSurfacePlacement === "overlay")) container.parentElement?.append(container);
3838
+ }
3839
+ function visibleBackLayer(view, element) {
3840
+ if (!element.isConnected || element.hidden || element.closest("[hidden],[inert],[aria-hidden=\"true\"]") !== null) return false;
3841
+ const style = view.getComputedStyle(element);
3842
+ return style.display !== "none" && style.visibility !== "hidden" && element.getClientRects().length > 0;
3843
+ }
3844
+ /**
3845
+ * Consume page-level surfaces and dismissible DSH overlays before layout navigation.
3846
+ * @param view - the page window receiving the native Back request.
3847
+ * @param page - the current DSH document.
3848
+ * @param blocked - feedback for a foreground layer without a safe close action.
3849
+ * @returns a disposer for the capture listener.
3850
+ */
3851
+ function installMobileNativeBack(view, page, blocked) {
3852
+ const onBack = (event) => {
3853
+ if (!event.cancelable || event.defaultPrevented) return;
3854
+ const claim = () => {
3855
+ event.preventDefault();
3856
+ event.stopImmediatePropagation();
3857
+ };
3858
+ const surface = [...page.querySelectorAll("[data-dsh-mobile-surface-placement=\"page\"], [data-dsh-mobile-surface-placement=\"overlay\"]")].filter((element) => visibleBackLayer(view, element)).at(-1);
3859
+ if (surface !== void 0) {
3860
+ setMobileSurfaceOpen(surface, false);
3861
+ claim();
3862
+ return;
3863
+ }
3864
+ const layers = [...page.querySelectorAll("[role=\"dialog\"][aria-modal=\"true\"], [role=\"menu\"]")].filter((element) => visibleBackLayer(view, element));
3865
+ const top = layers.at(-1);
3866
+ if (top === void 0) return;
3867
+ claim();
3868
+ if (top.getAttribute("role") === "dialog") {
3869
+ const wrapper = top.parentElement;
3870
+ const mask = wrapper?.firstElementChild;
3871
+ const maskStyle = mask === void 0 || mask === null ? void 0 : view.getComputedStyle(mask);
3872
+ if (wrapper?.getAttribute("role") === "presentation" && wrapper.children.length === 2 && wrapper.lastElementChild === top && mask?.tagName === "DIV" && mask.getAttribute("aria-hidden") === "true" && mask.isConnected && !mask.hidden && maskStyle?.display !== "none" && maskStyle?.visibility !== "hidden" && mask.getClientRects().length > 0) {
3873
+ mask.click();
3874
+ return;
3875
+ }
3876
+ } else if (top.getAttribute("data-menu-material") === "translucent" && layers.filter((layer) => layer.getAttribute("role") === "menu").length === 1) {
3877
+ page.dispatchEvent(new Event("pointerdown", { bubbles: true }));
3878
+ return;
3879
+ }
3880
+ blocked();
3881
+ };
3882
+ const capture = { capture: true };
3883
+ view.addEventListener("dsh-mobile:native-back", onBack, capture);
3884
+ return () => {
3885
+ view.removeEventListener("dsh-mobile:native-back", onBack, capture);
3886
+ };
3887
+ }
3740
3888
  const queuedDefinitions = [];
3741
3889
  let queuedLegacyMount;
3742
3890
  if (typeof window !== "undefined" && window.dshMobile === void 0) window.dshMobile = {
@@ -3757,6 +3905,18 @@ window.__ModuleLoader__.load({
3757
3905
  function selectedMobileControlLocale() {
3758
3906
  return selectMobileControlLocale(document.documentElement.lang, navigator.languages?.length ? navigator.languages : [navigator.language]);
3759
3907
  }
3908
+ /**
3909
+ * Guidance when an open third-party layer has no safe close action.
3910
+ * @param locale - current Mobile UI language.
3911
+ * @returns localized guidance.
3912
+ */
3913
+ function mobileBackBlockedMessage(locale) {
3914
+ return {
3915
+ en: "Close the open panel first.",
3916
+ it: "Chiudi prima il pannello aperto.",
3917
+ zh: "请先关闭当前弹层。"
3918
+ }[locale];
3919
+ }
3760
3920
  /** Remount one plugin-owned surface when DSH changes the document language. */
3761
3921
  function installDshLanguageBoundSurface(install) {
3762
3922
  let locale = selectedMobileControlLocale();
@@ -7880,6 +8040,9 @@ window.__ModuleLoader__.load({
7880
8040
  shellLayer().append(node);
7881
8041
  window.setTimeout(() => node.remove(), 2600);
7882
8042
  };
8043
+ const stopNativeBack = installMobileNativeBack(window, document, () => {
8044
+ toast(mobileBackBlockedMessage(selectedMobileControlLocale()));
8045
+ });
7883
8046
  const materializeNativeFile = (value) => {
7884
8047
  if (typeof value !== "object" || value === null || !("base64" in value) || !("name" in value)) return value;
7885
8048
  const candidate = value;
@@ -8012,6 +8175,7 @@ window.__ModuleLoader__.load({
8012
8175
  return registerUniqueDisposable(surfaces, surfaceIds, surface.id, () => {
8013
8176
  const container = element("section");
8014
8177
  container.dataset.dshMobileSurface = surface.id;
8178
+ container.dataset.dshMobileSurfacePlacement = surface.placement;
8015
8179
  container.hidden = surface.placement === "page" || surface.placement === "overlay";
8016
8180
  container.style.cssText = surface.placement === "page" || surface.placement === "overlay" ? "position:absolute;inset:0;overflow:auto;background:var(--dsw-alias-bg-layer-1,#fff);padding:16px;pointer-events:auto" : "pointer-events:auto";
8017
8181
  const host = () => surface.placement === "page" || surface.placement === "overlay" ? shellLayer() : surfaceHost(surface.placement) ?? shellLayer();
@@ -8064,12 +8228,12 @@ window.__ModuleLoader__.load({
8064
8228
  open: (surfaceId) => {
8065
8229
  ensureCurrent();
8066
8230
  const entry = surfaces.get(surfaceId);
8067
- if (entry !== void 0) entry.container.hidden = false;
8231
+ if (entry !== void 0) setMobileSurfaceOpen(entry.container, true);
8068
8232
  },
8069
8233
  close: (surfaceId) => {
8070
8234
  ensureCurrent();
8071
8235
  const entry = surfaces.get(surfaceId);
8072
- if (entry !== void 0) entry.container.hidden = true;
8236
+ if (entry !== void 0) setMobileSurfaceOpen(entry.container, false);
8073
8237
  },
8074
8238
  toast: (message) => {
8075
8239
  ensureCurrent();
@@ -8441,6 +8605,7 @@ window.__ModuleLoader__.load({
8441
8605
  });
8442
8606
  return () => {
8443
8607
  disposed = true;
8608
+ stopNativeBack();
8444
8609
  stopEvents();
8445
8610
  stopRefresh();
8446
8611
  started = false;
@@ -8560,7 +8725,10 @@ window.__ModuleLoader__.load({
8560
8725
  };
8561
8726
  });
8562
8727
  const removeCustom = installCustomAssets();
8563
- const removeSurface = installDshLanguageBoundSurface(installNativeMobileSurface);
8728
+ const removeSurface = installDshLanguageBoundSurface(() => installNativeMobileSurface({
8729
+ getLayout: () => ctx.get("layout"),
8730
+ getSidebarRight: () => ctx.get("sidebarRight")
8731
+ }));
8564
8732
  return () => {
8565
8733
  removeSettingsAction();
8566
8734
  removeCustom();
@@ -8643,6 +8811,8 @@ window.__ModuleLoader__.load({
8643
8811
  exports.handleMissingExtensionManifest = handleMissingExtensionManifest;
8644
8812
  exports.inject = inject;
8645
8813
  exports.installDshLanguageBoundSurface = installDshLanguageBoundSurface;
8814
+ exports.installMobileNativeBack = installMobileNativeBack;
8815
+ exports.mobileBackBlockedMessage = mobileBackBlockedMessage;
8646
8816
  exports.normalizeDiagnosticOverall = normalizeDiagnosticOverall;
8647
8817
  exports.normalizeDiagnosticStatus = normalizeDiagnosticStatus;
8648
8818
  exports.parseMobileExtensionManifest = parseMobileExtensionManifest;
@@ -8653,6 +8823,7 @@ window.__ModuleLoader__.load({
8653
8823
  exports.renderDiagnosticPayloadSafely = renderDiagnosticPayloadSafely;
8654
8824
  exports.selectMobileControlLocale = selectMobileControlLocale;
8655
8825
  exports.selectedMobileControlLocale = selectedMobileControlLocale;
8826
+ exports.setMobileSurfaceOpen = setMobileSurfaceOpen;
8656
8827
  exports.startExtensionChangeStream = startExtensionChangeStream;
8657
8828
  exports.startLifecycleRefreshScheduler = startLifecycleRefreshScheduler;
8658
8829
  exports.trustAuthenticatedGatewayConnection = trustAuthenticatedGatewayConnection;