dsh-mobile 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/FUNNEL_THIRD_PARTY_LICENSES.txt +2551 -0
- package/README.en.md +22 -10
- package/README.md +19 -8
- package/SECURITY.md +6 -3
- package/THIRD_PARTY_NOTICES.md +6 -2
- package/bin/dsh-mobile-funnel-win32-x64.exe +0 -0
- package/cordis.patch.yml +3 -0
- package/lib/cli.js +4 -1
- package/lib/cli.js.map +1 -0
- package/lib/client.js +715 -58
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +228 -3
- package/lib/index.mjs +1966 -396
- package/lib/index.mjs.map +1 -0
- package/package.json +17 -11
- package/assets/brand/app-icon-master.png +0 -0
- package/assets/brand/repository-hero.png +0 -0
package/README.en.md
CHANGED
|
@@ -17,15 +17,15 @@
|
|
|
17
17
|
|
|
18
18
|
<p align="center"><a href="README.md">简体中文</a> · <a href="CHANGELOG.md">Changelog</a></p>
|
|
19
19
|
|
|
20
|
-
> DSH Mobile
|
|
20
|
+
> DSH Mobile is a DeepSeek Harness community plugin; the native app supports Android only.
|
|
21
21
|
>
|
|
22
|
-
> **0.3.
|
|
22
|
+
> **0.3.x updates**: connection diagnostics, image attachments, self-hosted FRP, and plugin updates; improved reconnection, mobile layouts, localization, extension hot reload, and compatibility with DSH Desktop and usage plugins. [Details and acknowledgements](CHANGELOG.md).
|
|
23
23
|
>
|
|
24
|
-
> **
|
|
24
|
+
> **Upgrade reminder**: the plugin is evolving rapidly; keep it and the app updated together. DSH 0.1.2-alpha.2 requires Mobile plugin 0.3.4 or later. [Compatibility notes](#compatibility).
|
|
25
25
|
|
|
26
|
-
<p align="center"><a href="https://github.com/saya-ch/dsh-mobile/releases/download/v0.3.
|
|
26
|
+
<p align="center"><a href="https://github.com/saya-ch/dsh-mobile/releases/download/v0.3.4/dsh-mobile-android-v0.3.4.apk"><strong>Download Android app 0.3.4</strong></a> · <a href="https://github.com/saya-ch/dsh-mobile/releases/tag/v0.3.4">Release notes and checksums</a></p>
|
|
27
27
|
|
|
28
|
-
DSH Mobile is a DeepSeek Harness plugin that lets a mobile browser or the Android app connect over a protected LAN or an optional Tailscale Funnel or
|
|
28
|
+
DSH Mobile is a DeepSeek Harness plugin that lets a mobile browser or the Android app connect over a protected LAN or an optional Tailscale Funnel, cpolar, or self-hosted FRP remote path. Local and remote access keep the same sessions, Workspaces, messages, and tools while using separate switches and paired-device stores without modifying DeepSeek Harness source.
|
|
29
29
|
|
|
30
30
|
Mobile access runs on its own HTTPS origin with pinned certificates; only paired devices pass validation.
|
|
31
31
|
|
|
@@ -75,6 +75,8 @@ Restart DSH, then search for **dsh-mobile** under **Settings → Plugin Market**
|
|
|
75
75
|
|
|
76
76
|
After installation, start DSH and use the connection guide below to choose LAN or remote access.
|
|
77
77
|
|
|
78
|
+
Registry-installed plugins check for updates when the desktop UI loads and show “Update plugin” beside the access-panel title when a newer release is available. Restart DSH after installation. The app download entry shows the latest version; local development packages are not overwritten, and Android does not send update notifications.
|
|
79
|
+
|
|
78
80
|
## Connection guide
|
|
79
81
|
|
|
80
82
|
LAN and remote access are independent connections. Prefer LAN while the phone is near the computer for the lowest latency, and enable remote access only when leaving that network. Each path keeps its own switch, paired devices, and sign-in state.
|
|
@@ -84,7 +86,7 @@ LAN and remote access are independent connections. Prefer LAN while the phone is
|
|
|
84
86
|
Use this when the phone and computer share Wi-Fi, Ethernet, or a phone hotspot. It is the default and simplest path.
|
|
85
87
|
|
|
86
88
|
<p align="center">
|
|
87
|
-
<img src="https://raw.githubusercontent.com/saya-ch/dsh-mobile/main/assets/screenshots/lan-access.png" width="82%" alt="DSH Mobile LAN access, pairing QR code, and device management">
|
|
89
|
+
<img src="https://raw.githubusercontent.com/saya-ch/dsh-mobile/main/assets/screenshots/lan-access-en.png" width="82%" alt="DSH Mobile LAN access, pairing QR code, and device management">
|
|
88
90
|
</p>
|
|
89
91
|
|
|
90
92
|
1. Connect the phone and computer to the same local network, then open **Mobile Access → Local network** in the lower-left corner of DeepSeek Harness.
|
|
@@ -96,24 +98,27 @@ The app is optional: select **Copy pairing link** and open it in a mobile browse
|
|
|
96
98
|
|
|
97
99
|
### Remote access
|
|
98
100
|
|
|
99
|
-
Use this after the phone leaves the computer's network. Remote access is disabled by default, and the phone needs
|
|
101
|
+
Use this after the phone leaves the computer's network. Remote access is disabled by default, and the phone needs no separate Tailscale, cpolar, or FRP app.
|
|
100
102
|
|
|
101
103
|
Remote providers may impose bandwidth and connection limits: the [cpolar Free plan](https://svip.cpolar.com/pricing) currently lists 1 Mbps, while [Tailscale Funnel](https://tailscale.com/docs/features/tailscale-funnel#requirements-and-limitations) has non-configurable bandwidth limits. DSH Mobile reduces transfer and waiting with 10-message pages, load-on-scroll history, gzip, and a persistent WebSocket, but it cannot raise provider quotas.
|
|
102
104
|
|
|
103
105
|
<p align="center">
|
|
104
|
-
<img src="https://raw.githubusercontent.com/saya-ch/dsh-mobile/main/assets/screenshots/remote-access.png" width="82%" alt="DSH Mobile remote access and provider selection">
|
|
106
|
+
<img src="https://raw.githubusercontent.com/saya-ch/dsh-mobile/main/assets/screenshots/remote-access-en.png" width="82%" alt="DSH Mobile remote access and provider selection">
|
|
105
107
|
</p>
|
|
106
108
|
|
|
107
109
|
1. Open **Mobile Access → Remote** in the lower-left corner of DeepSeek Harness and choose a provider:
|
|
108
110
|
- **Tailscale Funnel**: select **Enable remote access**, complete the one-time Tailscale sign-in on the official page, follow the panel prompt to allow Funnel, then return to DSH and wait until the connection is ready.
|
|
109
111
|
- **cpolar**: select **Install official component**, sign in to the cpolar dashboard and obtain an Authtoken, paste it, then select **Save and connect**. The component is downloaded into the plugin's private directory only after confirmation.
|
|
112
|
+
- **Self-hosted FRP (advanced)**: expand **Self-hosted connection**, enter the VPS, frps port, shared token, and your HTTPS domain; apply the generated restricted frps + Caddy template on the VPS, then install the official `frpc` on demand and verify the path. This requires Android app 0.3.3 or later.
|
|
110
113
|
2. When the panel reports that remote access is ready, select **Create remote pairing QR code**.
|
|
111
114
|
3. In the Android app, open **Remote access** and scan the QR code to create its separate pairing.
|
|
112
115
|
4. The app keeps device trust and reconnects automatically. Disable remote access when it is not needed; LAN access remains unchanged.
|
|
113
116
|
|
|
114
|
-
Tailscale Funnel has broad reach but may be unreliable from mainland China. Its runtime ties the public listener to the parent process and a bounded control channel; parent exit, channel closure, or an explicit stop ends the current generation and cleans up its resources. cpolar is better suited to mainland networks. The plugin validates
|
|
117
|
+
Tailscale Funnel has broad reach but may be unreliable from mainland China. Its runtime ties the public listener to the parent process and a bounded control channel; parent exit, channel closure, or an explicit stop ends the current generation and cleans up its resources. cpolar is better suited to mainland networks, while self-hosted FRP fits users who already have a VPS and domain and want to avoid public-provider bandwidth quotas. The plugin validates pinned on-demand components, stores their configuration and programs entirely under `$DSH_HOME/mobile-access/`, and can remove them completely from the panel.
|
|
118
|
+
|
|
119
|
+
Self-hosted FRP generates only one HTTP vhost to the DSH loopback gateway. It exposes no arbitrary FRP configuration, TCP/UDP proxy, or FRP plugin. The VPS plaintext vhost must bind to `127.0.0.1`, with Caddy providing public HTTPS; the plugin rejects a publicly reachable plaintext port and reports readiness only after public discovery identifies the current computer.
|
|
115
120
|
|
|
116
|
-
The public remote origin still requires DSH device pairing.
|
|
121
|
+
The public remote origin still requires DSH device pairing. The bundled Funnel and managed cpolar components currently support Windows x64; on-demand FRP 0.70.1 supports Windows, Linux, and macOS on x64 and arm64.
|
|
117
122
|
|
|
118
123
|
## Extend and customize
|
|
119
124
|
|
|
@@ -173,6 +178,7 @@ Three layers: the Host face for discovery, pairing, HTTPS, loopback proxying, an
|
|
|
173
178
|
- Use the LAN listener only on a trusted home, office, or hotspot network; do not add your own port forwarding.
|
|
174
179
|
- A remote origin is publicly reachable, but unpaired requests cannot enter DSH; turn the remote switch off when it is not needed.
|
|
175
180
|
- cpolar downloads a pinned official build only after confirmation and verifies its size and SHA-256. It installs no system service, PATH entry, or startup task, and plugin cleanup removes its managed files.
|
|
181
|
+
- Self-hosted FRP downloads pinned official `frpc` only after confirmation and verifies the origin, exact size, SHA-256, archive paths, and executable version. The shared token never appears in status, diagnostics, or logs. Copying the server template places it on the system clipboard, so clear the clipboard after use; cleanup removes only plugin-managed local files and does not change the VPS.
|
|
176
182
|
- A paired device is a fully trusted DeepSeek Harness operator and can run tools on the computer; revoke lost devices from the computer.
|
|
177
183
|
- The LAN gateway listens only while Mobile Access is enabled; with it off, DSH keeps running normally on the computer.
|
|
178
184
|
|
|
@@ -180,8 +186,12 @@ See [SECURITY.md](SECURITY.md).
|
|
|
180
186
|
|
|
181
187
|
## Compatibility
|
|
182
188
|
|
|
189
|
+
Use Mobile plugin 0.3.4 with DSH 0.1.2-alpha.2; plugin and app 0.3.2 or later are recommended for DSH 0.1.2-alpha.1. Plugin 0.3.4 remains compatible with existing 0.3.3 apps without re-pairing. Earlier apps use a different status-bar strategy, so updating both is recommended. App 0.1.3 or earlier requires reinstalling and pairing again.
|
|
190
|
+
|
|
183
191
|
| DSH Mobile | Verified DeepSeek Harness releases |
|
|
184
192
|
| --- | --- |
|
|
193
|
+
| `0.3.4` | `0.1.0-rc.5`, `0.1.0-rc.6`, `0.1.0-rc.7`, `0.1.1-rc.2`, `0.1.2-alpha.1`, `0.1.2-alpha.2` |
|
|
194
|
+
| `0.3.3` | `0.1.0-rc.5`, `0.1.0-rc.6`, `0.1.0-rc.7`, `0.1.1-rc.2`, `0.1.2-alpha.1` |
|
|
185
195
|
| `0.3.2` | `0.1.0-rc.5`, `0.1.0-rc.6`, `0.1.0-rc.7`, `0.1.1-rc.2`, `0.1.2-alpha.1` |
|
|
186
196
|
| `0.3.1` | `0.1.0-rc.5`, `0.1.0-rc.6`, `0.1.0-rc.7`, `0.1.1-rc.2`, `0.1.2-alpha.1` |
|
|
187
197
|
| `0.3.0` | `0.1.0-rc.5`, `0.1.0-rc.6`, `0.1.0-rc.7`, `0.1.1-rc.2`, `0.1.2-alpha.1` |
|
|
@@ -192,6 +202,8 @@ See [SECURITY.md](SECURITY.md).
|
|
|
192
202
|
|
|
193
203
|
At startup, the plugin verifies the DSH Host version and the frontend dependencies required by the mobile layout; an unverified release fails with a clear error instead of serving a broken page. CI also tracks the DSH main branch layout contract. If a DSH upgrade reports an incompatibility, update DSH Mobile first.
|
|
194
204
|
|
|
205
|
+
0.3.4 fixes duplicate directory-picker registration in Windows [DSH Desktop](https://github.com/anywhere-labs/dsh-desktop). It reuses the desktop host's in-page directory browser; standalone Web launches still let phones select workspaces on the computer. Mobile access through Desktop requires compatibility mode and browser access enabled, with Mobile's upstream address matching Desktop's listening port.
|
|
206
|
+
|
|
195
207
|
## Uninstall
|
|
196
208
|
|
|
197
209
|
```powershell
|
package/README.md
CHANGED
|
@@ -24,15 +24,15 @@
|
|
|
24
24
|
<a href="README.en.md">English</a>
|
|
25
25
|
</p>
|
|
26
26
|
|
|
27
|
-
> DSH Mobile
|
|
27
|
+
> DSH Mobile 是 DeepSeek Harness 社区插件,原生 App 仅支持 Android。
|
|
28
28
|
>
|
|
29
|
-
> **0.3.
|
|
29
|
+
> **0.3.x 更新**:新增连接诊断、图片附件、自建 FRP 和插件更新入口,优化重连、移动布局、多语言与扩展热更新,并完善 DSH Desktop 及用量插件兼容。[详细记录与致谢](CHANGELOG.md)。
|
|
30
30
|
>
|
|
31
|
-
>
|
|
31
|
+
> **升级提醒**:插件仍在快速迭代,建议与 App 同步更新;DSH 0.1.2-alpha.2 需使用 Mobile 插件 0.3.4 或更高版本。[兼容说明](#兼容性)。
|
|
32
32
|
|
|
33
|
-
<p align="center"><a href="https://github.com/saya-ch/dsh-mobile/releases/download/v0.3.
|
|
33
|
+
<p align="center"><a href="https://github.com/saya-ch/dsh-mobile/releases/download/v0.3.4/dsh-mobile-android-v0.3.4.apk"><strong>下载 Android App 0.3.4</strong></a> · <a href="https://github.com/saya-ch/dsh-mobile/releases/tag/v0.3.4">版本说明与校验文件</a></p>
|
|
34
34
|
|
|
35
|
-
DSH Mobile 是一个 DeepSeek Harness 插件,让手机浏览器或 Android App 通过局域网,或可选的 Tailscale Funnel、cpolar 远程通道连接电脑,继续使用同一份会话、工作区、消息和工具。局域网与远程访问分别启停、分别管理设备,且都不修改 DeepSeek Harness 源码。
|
|
35
|
+
DSH Mobile 是一个 DeepSeek Harness 插件,让手机浏览器或 Android App 通过局域网,或可选的 Tailscale Funnel、cpolar、自建 FRP 远程通道连接电脑,继续使用同一份会话、工作区、消息和工具。局域网与远程访问分别启停、分别管理设备,且都不修改 DeepSeek Harness 源码。
|
|
36
36
|
|
|
37
37
|
移动访问使用独立的 HTTPS 与证书固定,只有配对过的设备能通过校验接入。
|
|
38
38
|
|
|
@@ -82,6 +82,8 @@ dsh plugin --profile web add dshmarket
|
|
|
82
82
|
|
|
83
83
|
安装并启动 DSH 后,按照下一节选择局域网或远程连接。
|
|
84
84
|
|
|
85
|
+
通过 npm 安装的插件会在桌面界面加载时检查新版本,有更新时在访问面板标题右侧显示“更新插件”,安装后需重启 DSH。App 下载入口展示最新版本;本地开发包不会被自动覆盖,Android App 暂无主动更新通知。
|
|
86
|
+
|
|
85
87
|
## 连接教程
|
|
86
88
|
|
|
87
89
|
局域网和远程访问是两套相互独立的连接:在电脑附近优先使用局域网,延迟最低;离开当前网络时再启用远程访问。两边分别管理开关、设备和登录状态,互不影响。
|
|
@@ -103,7 +105,7 @@ dsh plugin --profile web add dshmarket
|
|
|
103
105
|
|
|
104
106
|
### 远程访问
|
|
105
107
|
|
|
106
|
-
适合手机离开电脑所在网络后使用。远程访问默认关闭,手机不需要另外安装 Tailscale 或
|
|
108
|
+
适合手机离开电脑所在网络后使用。远程访问默认关闭,手机不需要另外安装 Tailscale、cpolar 或 FRP。
|
|
107
109
|
|
|
108
110
|
远程服务可能受带宽和连接限额影响:[cpolar 免费方案](https://svip.cpolar.com/pricing) 当前为 1 Mbps,[Tailscale Funnel](https://tailscale.com/docs/features/tailscale-funnel#requirements-and-limitations) 也存在不可配置的带宽限制。DSH Mobile 通过 10 条分页、顶部按需加载、gzip 和 WebSocket 长连接减少流量与等待,但无法突破服务商限额。
|
|
109
111
|
|
|
@@ -114,13 +116,16 @@ dsh plugin --profile web add dshmarket
|
|
|
114
116
|
1. 在 DeepSeek Harness 左下角打开 **移动访问 → 远程**,选择一种连接方式:
|
|
115
117
|
- **Tailscale Funnel**:点击 **启用远程访问**,在打开的官方页面完成一次 Tailscale 登录;按面板提示继续允许 Funnel,然后返回 DSH 等待连接就绪。
|
|
116
118
|
- **cpolar**:点击 **安装官方组件**,登录 cpolar 控制台取得 Authtoken,粘贴后点击 **保存并连接**。组件只会在确认后下载到插件私有目录。
|
|
119
|
+
- **自建 FRP(高级)**:展开 **自建连接**,填写 VPS、frps 端口、共享 Token 和自己的 HTTPS 域名;复制插件生成的受限 frps + Caddy 模板到 VPS,再按提示安装官方 `frpc` 并验证连接。需要 Android App 0.3.3 或更高版本。
|
|
117
120
|
2. 状态变为“远程访问已就绪”后,点击 **生成远程配对二维码**。
|
|
118
121
|
3. 在 Android App 中进入 **远程访问**,扫描二维码完成独立配对。
|
|
119
122
|
4. 此后 App 会保存设备信任并自动重连;不使用时可以关闭远程访问,局域网连接不会受影响。
|
|
120
123
|
|
|
121
|
-
Tailscale Funnel 覆盖范围广,但在中国大陆网络下可能不稳定。其运行组件把公开监听生命周期绑定到父进程和受限控制通道;父进程退出、控制通道关闭或显式停止时会结束当前代次并清理资源。cpolar
|
|
124
|
+
Tailscale Funnel 覆盖范围广,但在中国大陆网络下可能不稳定。其运行组件把公开监听生命周期绑定到父进程和受限控制通道;父进程退出、控制通道关闭或显式停止时会结束当前代次并清理资源。cpolar 更适合国内网络;自建 FRP 适合已有 VPS 和域名、希望避开公共服务带宽限制的用户。插件会校验按需下载的固定版本组件,配置与程序均保存在 `$DSH_HOME/mobile-access/`,可随时在面板中彻底清除。
|
|
125
|
+
|
|
126
|
+
自建 FRP 只生成一个指向 DSH 回环网关的 HTTP vhost,不提供任意 FRP 配置、TCP/UDP 代理或 FRP 插件。VPS 的明文 vhost 必须只监听 `127.0.0.1`,由 Caddy 提供公网 HTTPS;插件会拒绝可从公网访问的明文端口,并在公开发现接口确认连接到当前电脑后才显示“已就绪”。
|
|
122
127
|
|
|
123
|
-
远程公开地址仍受 DSH
|
|
128
|
+
远程公开地址仍受 DSH 设备配对保护。内置 Funnel 与托管 cpolar 当前支持 Windows x64;按需安装的 FRP 0.70.1 支持 Windows、Linux、macOS 的 x64 与 arm64。
|
|
124
129
|
|
|
125
130
|
## 扩展与自定义
|
|
126
131
|
|
|
@@ -182,6 +187,7 @@ flowchart LR
|
|
|
182
187
|
- 局域网监听只用于可信家庭、办公网络或可信热点;不要自行做端口转发。
|
|
183
188
|
- 远程地址可从公网到达,但未配对请求无法进入 DSH;不使用时应关闭远程开关。
|
|
184
189
|
- cpolar 仅在用户确认后下载固定官方版本并校验大小和 SHA-256;不会安装系统服务、写入 PATH 或设置开机启动,插件清理会删除其托管文件。
|
|
190
|
+
- 自建 FRP 仅在用户确认后从官方 Release 下载固定版本 `frpc`,校验来源、精确大小、SHA-256、压缩包路径和可执行文件版本;共享 Token 不会出现在状态、诊断或日志中。复制服务器模板时 Token 会进入系统剪贴板,请粘贴后及时清除;清理只删除插件管理的本机文件,不会修改 VPS。
|
|
185
191
|
- 配对设备拥有控制电脑端 DeepSeek Harness 的能力,应视为完全可信设备;丢失手机后应在电脑端撤销设备。
|
|
186
192
|
- 移动网关开启时才监听局域网;关闭后 DeepSeek Harness 仍正常在电脑本机运行。
|
|
187
193
|
|
|
@@ -189,9 +195,12 @@ flowchart LR
|
|
|
189
195
|
|
|
190
196
|
## 兼容性
|
|
191
197
|
|
|
198
|
+
DSH 0.1.2-alpha.2 需搭配 Mobile 插件 0.3.4;DSH 0.1.2-alpha.1 建议搭配插件与 App 0.3.2 或更高版本。0.3.4 插件兼容现有 0.3.3 App,无需重新配对。更早的 App 使用不同的状态栏策略,建议同步升级;App 0.1.3 及更早版本需卸载重装并重新配对。
|
|
192
199
|
|
|
193
200
|
| DSH Mobile | 已验证的 DeepSeek Harness |
|
|
194
201
|
| ------------ | ------------------------------------------------------------------------- |
|
|
202
|
+
| `0.3.4` | `0.1.0-rc.5`、`0.1.0-rc.6`、`0.1.0-rc.7`、`0.1.1-rc.2`、`0.1.2-alpha.1`、`0.1.2-alpha.2` |
|
|
203
|
+
| `0.3.3` | `0.1.0-rc.5`、`0.1.0-rc.6`、`0.1.0-rc.7`、`0.1.1-rc.2`、`0.1.2-alpha.1` |
|
|
195
204
|
| `0.3.2` | `0.1.0-rc.5`、`0.1.0-rc.6`、`0.1.0-rc.7`、`0.1.1-rc.2`、`0.1.2-alpha.1` |
|
|
196
205
|
| `0.3.1` | `0.1.0-rc.5`、`0.1.0-rc.6`、`0.1.0-rc.7`、`0.1.1-rc.2`、`0.1.2-alpha.1` |
|
|
197
206
|
| `0.3.0` | `0.1.0-rc.5`、`0.1.0-rc.6`、`0.1.0-rc.7`、`0.1.1-rc.2`、`0.1.2-alpha.1` |
|
|
@@ -202,6 +211,8 @@ flowchart LR
|
|
|
202
211
|
|
|
203
212
|
插件启动时会检查 DSH Host 版本和移动布局所需的前端依赖,遇到未经验证的版本会直接报错而不是带病启动;CI 也会持续跟踪 DSH 主分支的布局契约。升级 DSH 后如遇兼容提示,请先升级 DSH Mobile。
|
|
204
213
|
|
|
214
|
+
0.3.4 修复 Windows [DSH Desktop](https://github.com/anywhere-labs/dsh-desktop) 的目录选择器重复注册问题:复用桌面宿主的页面内目录浏览器,普通 Web 启动仍保留手机选择电脑工作区的能力。Desktop 连接手机时需使用兼容模式并启用浏览器访问,Mobile 的上游地址应与 Desktop 的监听端口一致。
|
|
215
|
+
|
|
205
216
|
## 卸载
|
|
206
217
|
|
|
207
218
|
```powershell
|
package/SECURITY.md
CHANGED
|
@@ -17,13 +17,16 @@ The maintainer will acknowledge a complete report within seven days. Publication
|
|
|
17
17
|
- Keep the ordinary DSH Web listener on loopback.
|
|
18
18
|
- Expose only the plugin-owned HTTPS listener to the LAN.
|
|
19
19
|
- DNS-SD/mDNS, periodic UDP announcements, active UDP query replies, and HTTPS discovery return only the device name, public HTTPS origin, port, protocol version, and stable non-secret installation identifier. Discovery never returns the CA, a pairing key, a device token, Cookies, credentials, or private configuration.
|
|
20
|
-
-
|
|
21
|
-
-
|
|
20
|
+
- For LAN pairing, only after a user selects a device and enters the fingerprint-bound pairing key may Android fetch the public CA from that exact HTTPS origin. The bootstrap GET sends no key or credential. The app retains the CA in its encrypted credential record and never adds it to Android's system trust settings. Native requests use a private trust store; WebView accepts only the otherwise-untrusted leaf signed by that CA, for the exact origin and validity period. Every other TLS error is cancelled.
|
|
21
|
+
- Public remote origins provided by Funnel, cpolar, or self-hosted FRP use platform-trusted HTTPS. Android stores no private CA for those credentials and cancels every TLS error. Browser clients likewise require a certificate trusted by their platform.
|
|
22
22
|
- Keep pairing closed except during a short local onboarding action.
|
|
23
23
|
- Revoke a lost device immediately and rotate the device registry if credential theft is suspected.
|
|
24
|
-
- Do not expose the LAN gateway through router port forwarding. Optional remote access uses a separate loopback gateway behind the selected Tailscale Funnel or
|
|
24
|
+
- Do not expose the LAN gateway through router port forwarding. Optional remote access uses a separate loopback gateway behind the selected Tailscale Funnel, cpolar, or self-hosted FRP path. The outer provider or Caddy terminates public TLS, while DSH pairing, device authentication, CSRF checks, and session revocation remain enforced by the plugin gateway.
|
|
25
25
|
- The Funnel node stores its Tailscale login state under `$DSH_HOME/mobile-access/remote/tailscale/`. The plugin does not request or store a Tailscale password, Auth Key, or OAuth secret.
|
|
26
26
|
- cpolar is downloaded only after confirmation from a pinned official artifact whose size and SHA-256 are verified. Its Authtoken is stored in a private, self-update-disabled configuration under `$DSH_HOME/mobile-access/`, never returned by the admin API or written to logs. Cleanup removes the managed executable, configuration, logs, and independent remote device registry.
|
|
27
|
+
- Self-hosted FRP accepts only a VPS address, frps control port, operator-generated high-entropy shared token, and standard-port public HTTPS origin. The plugin validates the token's length and format; the operator remains responsible for its entropy. It generates one HTTP vhost pointed at the plugin's ephemeral loopback gateway; arbitrary FRP configuration, TCP/UDP forwarding, FRP plugins, services, PATH entries, and startup tasks are not supported.
|
|
28
|
+
- The generated frps template binds its plaintext vhost to `127.0.0.1` and expects Caddy to provide public HTTPS. Before starting `frpc`, the plugin rejects a vhost port reachable through the configured VPS address. It reports readiness only when the public discovery response carries this computer's exact non-secret installation identifier.
|
|
29
|
+
- The FRP token stays in a private file under `$DSH_HOME/mobile-access/`, is never returned by status or diagnostic responses, and is never logged. Copying the generated server template intentionally places the token on the system clipboard and into the VPS configuration; clear the clipboard after use and protect the VPS file. The FRP cleanup action removes the managed `frpc`, local token, runtime configuration, staging files, and logs, but VPS files remain the user's responsibility.
|
|
27
30
|
- Disabling remote access stops the selected provider process without affecting LAN access. Resetting remote access also removes provider state and the independent remote device registry.
|
|
28
31
|
- Treat every paired device as a fully trusted operator. Stock DSH methods reached through the authenticated loopback proxy may read configuration or run tools with the desktop user's authority.
|
|
29
32
|
- Treat `mobile.js` as application code with the paired page's same-origin authority. Restrict write access to trusted host-side DSH sessions and review generated API calls or browser-permission use.
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# Third-party notices
|
|
2
2
|
|
|
3
|
-
DSH Mobile includes a small Windows helper built from the source in `native/funnel-host`. The helper uses [Tailscale tsnet](https://pkg.go.dev/tailscale.com/tsnet) `v1.102.3`, distributed under the BSD 3-Clause license. Its exact
|
|
3
|
+
DSH Mobile includes a small Windows helper built from the source in `native/funnel-host`. The helper uses [Tailscale tsnet](https://pkg.go.dev/tailscale.com/tsnet) `v1.102.3`, distributed under the BSD 3-Clause license. Its exact statically linked Go module versions and complete license and notice texts are generated from the bundled executable into `FUNNEL_THIRD_PARTY_LICENSES.txt`, which is included in every npm package.
|
|
4
4
|
|
|
5
5
|
The optional cpolar component is not included in the npm package or Android App. When a user explicitly chooses cpolar installation, DSH Mobile downloads the pinned official archive shown in the UI, verifies its size and SHA-256 digest, and stores it only under the user's DSH Mobile data directory. cpolar remains subject to its [terms of service](https://www.cpolar.com/tos).
|
|
6
6
|
|
|
7
|
-
The
|
|
7
|
+
The optional [FRP](https://github.com/fatedier/frp) client is not included in the npm package or Android App. When a user explicitly chooses self-hosted FRP installation, DSH Mobile downloads the pinned official `frpc` 0.70.1 archive, verifies its origin, exact size, SHA-256 digest, archive paths, and executable version, then stores only `frpc` under the user's DSH Mobile data directory. FRP is distributed under the Apache License 2.0.
|
|
8
|
+
|
|
9
|
+
The npm package declares direct runtime dependencies on [Schemastery](https://github.com/shigma/schemastery) `^3.18.1`, [bonjour-service](https://github.com/onlxltd/bonjour-service) `^1.4.4`, [qrcode](https://github.com/soldair/node-qrcode) `^1.5.4`, and [selfsigned](https://github.com/jfromaniello/selfsigned) `^5.5.0`. These packages are distributed under the MIT License; the release checkout's exact transitive versions appear in the attached CycloneDX npm SBOM.
|
|
10
|
+
|
|
11
|
+
The Android App runtime contains [Kotlin standard library](https://github.com/JetBrains/kotlin) `2.1.0`, [kotlinx.coroutines](https://github.com/Kotlin/kotlinx.coroutines) `1.6.4`, [JetBrains annotations](https://github.com/JetBrains/java-annotations) `13.0`, [AndroidX Core](https://developer.android.com/jetpack/androidx/releases/core) `1.15.0`, [AndroidX WebKit](https://developer.android.com/jetpack/androidx/releases/webkit) `1.12.1`, their AndroidX transitive components, [Guava listenablefuture](https://github.com/google/guava) `1.0`, and [ZXing Core](https://github.com/zxing/zxing) `3.5.4`. These runtime libraries are distributed under the Apache License 2.0; the exact resolved tree is attached to each release as `dsh-mobile-android-v<version>-dependencies.txt`.
|
|
Binary file
|
package/cordis.patch.yml
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
- insert:
|
|
8
8
|
- id: directory-picker-mobile-host
|
|
9
9
|
name: '@deepseek-ai/dsh-host-directory-picker-browse'
|
|
10
|
+
# Windows DSH Desktop supplies its own browse pair after user patches.
|
|
11
|
+
disabled: !!js ctx.get('desktopRuntime')?.platform === 'win32'
|
|
10
12
|
|
|
11
13
|
- id: directory-picker-mobile-surface
|
|
12
14
|
name: '@deepseek-ai/dsh-client-ui-directory-picker-browse'
|
|
15
|
+
disabled: !!js ctx.get('desktopRuntime')?.platform === 'win32'
|
|
13
16
|
|
|
14
17
|
- id: mobile-access
|
|
15
18
|
name: dsh-mobile
|
package/lib/cli.js
CHANGED
|
@@ -302,7 +302,10 @@ Object.freeze({
|
|
|
302
302
|
manifest: 65536,
|
|
303
303
|
script: 1048576,
|
|
304
304
|
css: 524288,
|
|
305
|
-
asset: 8388608
|
|
305
|
+
asset: 8388608,
|
|
306
|
+
assetFiles: 256,
|
|
307
|
+
assetBytes: 33554432,
|
|
308
|
+
assetDepth: 8
|
|
306
309
|
});
|
|
307
310
|
/** A controlled business failure returned by an extension action or route. */
|
|
308
311
|
var MobileExtensionError = class extends Error {
|
package/lib/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","names":["execFile","execFileCallback","execFile","execFileCallback","execFile","execFileCallback"],"sources":["../src/private-file.ts","../src/managed-setup.ts","../src/extensions.ts","../src/cli.ts"],"sourcesContent":["import { execFile as execFileCallback } from 'node:child_process'\nimport { chmod } from 'node:fs/promises'\nimport { promisify } from 'node:util'\n\nconst execFile = promisify(execFileCallback)\nlet userSidTask: Promise<string> | undefined\n\nasync function currentWindowsUserSid(): Promise<string> {\n userSidTask ??= execFile('whoami.exe', ['/user', '/fo', 'csv', '/nh'], {\n encoding: 'utf8',\n windowsHide: true,\n }).then(({ stdout }) => {\n const match = /,\"(S-\\d(?:-\\d+)+)\"\\s*$/u.exec(stdout.trim())\n if (match?.[1] === undefined) throw new Error('unable to resolve the current Windows user SID')\n return match[1]\n })\n return userSidTask\n}\n\n/** Restrict a sensitive regular file to the current user and Windows administrators. */\nexport async function restrictPrivateFile(file: string, mode = 0o600): Promise<void> {\n await chmod(file, mode)\n if (process.platform !== 'win32') return\n const userSid = await currentWindowsUserSid()\n await execFile('icacls.exe', [\n file,\n '/inheritance:r',\n '/grant:r',\n `*${userSid}:(F)`,\n '*S-1-5-18:(F)',\n '*S-1-5-32-544:(F)',\n '/remove:g',\n '*S-1-1-0',\n '*S-1-5-11',\n '*S-1-5-32-545',\n ], { encoding: 'utf8', windowsHide: true })\n}\n","import {\n X509Certificate,\n createPrivateKey,\n createPublicKey,\n} from 'node:crypto'\nimport { execFile as execFileCallback } from 'node:child_process'\nimport { mkdir, readFile, rename, writeFile } from 'node:fs/promises'\nimport { networkInterfaces, type NetworkInterfaceInfo } from 'node:os'\nimport { basename, dirname, join } from 'node:path'\nimport { promisify } from 'node:util'\nimport { generate } from 'selfsigned'\nimport { restrictPrivateFile } from './private-file.js'\n\n/** One active private IPv4 address tied to a stable operating-system interface name. */\nexport interface LanNetwork {\n readonly name: string\n readonly address: string\n readonly cidr: string\n}\n\n/** Versioned setup that survives DHCP address changes on the selected interface. */\nexport interface ManagedSetup {\n readonly version: 2\n readonly networkInterface: string\n readonly listenPort: number\n readonly upstreamOrigin: string\n readonly tls: {\n readonly mode: 'managed'\n readonly caCertFile: string\n readonly caKeyFile: string\n readonly certFile: string\n readonly keyFile: string\n }\n}\n\ntype InterfaceTable = NodeJS.Dict<NetworkInterfaceInfo[]>\ntype RouteCommand = (file: string, args: readonly string[]) => Promise<string>\n\nconst execFile = promisify(execFileCallback)\nconst VIRTUAL_INTERFACE_MARKERS = [\n 'bridge', 'docker', 'hyper-v', 'mihomo', 'radmin', 'tailscale', 'tap', 'tun',\n 'utun', 'vbox', 'veth', 'virtual', 'vmware', 'vpn', 'vethernet', 'wsl', 'zerotier',\n]\n\nfunction requiredString(value: unknown, name: string): string {\n if (typeof value !== 'string' || value.length === 0) throw new Error(`${name} must be a non-empty string`)\n return value\n}\n\n/** Validate the durable managed setup before it controls network and filesystem operations. */\nexport function parseManagedSetup(value: unknown): ManagedSetup {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) {\n throw new Error('mobile setup file must be an object')\n }\n const record = value as Record<string, unknown>\n if (record.version !== 2 || Reflect.ownKeys(record)\n .some(key => typeof key !== 'string' || !['version', 'networkInterface', 'listenPort', 'upstreamOrigin', 'tls'].includes(key))) {\n throw new Error('mobile setup file has an unsupported format')\n }\n if (!Number.isSafeInteger(record.listenPort) || (record.listenPort as number) < 1024\n || (record.listenPort as number) > 65535) {\n throw new Error('mobile setup listenPort must be from 1024 through 65535')\n }\n if (typeof record.tls !== 'object' || record.tls === null || Array.isArray(record.tls)) {\n throw new Error('mobile setup tls must be an object')\n }\n const tls = record.tls as Record<string, unknown>\n if (tls.mode !== 'managed' || Reflect.ownKeys(tls)\n .some(key => typeof key !== 'string' || !['mode', 'caCertFile', 'caKeyFile', 'certFile', 'keyFile'].includes(key))) {\n throw new Error('mobile setup tls has an unsupported format')\n }\n return Object.freeze({\n version: 2,\n networkInterface: requiredString(record.networkInterface, 'mobile setup networkInterface'),\n listenPort: record.listenPort as number,\n upstreamOrigin: requiredString(record.upstreamOrigin, 'mobile setup upstreamOrigin'),\n tls: Object.freeze({\n mode: 'managed',\n caCertFile: requiredString(tls.caCertFile, 'mobile setup tls.caCertFile'),\n caKeyFile: requiredString(tls.caKeyFile, 'mobile setup tls.caKeyFile'),\n certFile: requiredString(tls.certFile, 'mobile setup tls.certFile'),\n keyFile: requiredString(tls.keyFile, 'mobile setup tls.keyFile'),\n }),\n })\n}\n\nfunction privateIpv4(value: string): boolean {\n const parts = value.split('.').map(Number)\n return parts.length === 4 && parts.every(part => Number.isInteger(part) && part >= 0 && part <= 255)\n && (parts[0] === 10\n || (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31)\n || (parts[0] === 192 && parts[1] === 168))\n}\n\nfunction networkCidr(address: string, cidr: string): string {\n const prefix = Number(cidr.slice(cidr.lastIndexOf('/') + 1))\n const value = address.split('.').reduce((total, part) => ((total << 8) | Number(part)) >>> 0, 0)\n const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0\n const network = (value & mask) >>> 0\n return `${[24, 16, 8, 0].map(shift => (network >>> shift) & 255).join('.')}/${String(prefix)}`\n}\n\n/** List current private IPv4 candidates with their interface identity. */\nexport function availableLanNetworks(table: InterfaceTable = networkInterfaces()): LanNetwork[] {\n const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? [])\n .filter(entry => entry.family === 'IPv4' && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null)\n .map(entry => ({ name, address: entry.address, cidr: networkCidr(entry.address, entry.cidr!) })))\n return [...new Map(candidates.map(entry => [`${entry.name}\\0${entry.address}`, entry])).values()]\n}\n\nfunction likelyVirtualInterface(name: string): boolean {\n const normalized = name.toLowerCase().replaceAll(/[^a-z0-9]+/gu, ' ')\n return VIRTUAL_INTERFACE_MARKERS.some(marker => normalized.includes(marker.replaceAll('-', ' ')))\n || /^(?:br|wg)\\d*\\b/u.test(normalized)\n}\n\nasync function runRouteCommand(file: string, args: readonly string[]): Promise<string> {\n const result = await execFile(file, [...args], { encoding: 'utf8', windowsHide: true })\n return result.stdout\n}\n\nfunction uniqueLines(output: string): string[] {\n return [...new Set(output.split(/\\r?\\n/gu).map(line => line.trim()).filter(Boolean))]\n}\n\n/** Return operating-system default-route interfaces in routing preference order. */\nexport async function preferredLanInterfaceNames(\n platform: NodeJS.Platform = process.platform,\n run: RouteCommand = runRouteCommand,\n): Promise<string[]> {\n try {\n if (platform === 'win32') {\n const script = [\n \"$routes = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop\",\n \"$ranked = $routes | Where-Object { $_.State -eq 'Alive' -and $_.NextHop -ne '0.0.0.0' } | ForEach-Object {\",\n ' $route = $_',\n ' $adapter = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue',\n ' $ip = Get-NetIPInterface -AddressFamily IPv4 -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue',\n \" if ($adapter -and $ip -and $adapter.Status -eq 'Up' -and $adapter.HardwareInterface -eq $true -and $adapter.Virtual -ne $true) {\",\n ' [pscustomobject]@{ Name = $route.InterfaceAlias; Metric = [int]$route.RouteMetric + [int]$ip.InterfaceMetric }',\n ' }',\n '}',\n '$ranked | Sort-Object Metric | Select-Object -ExpandProperty Name -Unique',\n ].join('; ')\n return uniqueLines(await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]))\n }\n if (platform === 'linux') {\n const routes = uniqueLines(await run('ip', ['-o', '-4', 'route', 'show', 'default']))\n .map(line => ({\n name: /(?:^|\\s)dev\\s+(\\S+)/u.exec(line)?.[1],\n metric: Number(/(?:^|\\s)metric\\s+(\\d+)/u.exec(line)?.[1] ?? 0),\n }))\n .filter((route): route is { name: string; metric: number } => route.name !== undefined\n && !likelyVirtualInterface(route.name))\n .sort((left, right) => left.metric - right.metric)\n return [...new Set(routes.map(route => route.name))]\n }\n if (platform === 'darwin') {\n const name = /^\\s*interface:\\s*(\\S+)\\s*$/mu.exec(await run('route', ['-n', 'get', 'default']))?.[1]\n return name === undefined || likelyVirtualInterface(name) ? [] : [name]\n }\n } catch {\n // Route discovery is advisory; deterministic candidate checks below remain the fallback.\n }\n return []\n}\n\n/** Select an active LAN, optionally by address or by a previously saved interface name. */\nexport function selectLanNetwork(\n requestedAddress?: string,\n requestedInterface?: string,\n table?: InterfaceTable,\n preferredInterfaces: readonly string[] = [],\n): LanNetwork {\n const candidates = availableLanNetworks(table)\n if (requestedAddress !== undefined) {\n const match = candidates.find(candidate => candidate.address === requestedAddress)\n if (match === undefined) throw new Error(`--address ${requestedAddress} is not an active private LAN address`)\n return match\n }\n if (requestedInterface !== undefined) {\n const matches = candidates.filter(candidate => candidate.name === requestedInterface)\n if (matches.length === 1) return matches[0]!\n if (matches.length === 0) {\n throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`)\n }\n throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`)\n }\n if (candidates.length === 1) return candidates[0]!\n if (candidates.length === 0) throw new Error('no active private LAN address was found; connect to Wi-Fi or Ethernet')\n for (const name of preferredInterfaces) {\n const matches = candidates.filter(candidate => candidate.name === name)\n if (matches.length === 1) return matches[0]!\n }\n const physicalCandidates = candidates.filter(candidate => !likelyVirtualInterface(candidate.name))\n if (physicalCandidates.length === 1) return physicalCandidates[0]!\n throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map(entry => `${entry.name}=${entry.address}`).join(', ')}`)\n}\n\nfunction assertMatchingCa(certPem: string, keyPem: string): X509Certificate {\n const certificate = new X509Certificate(certPem)\n if (!certificate.ca || certificate.subject !== certificate.issuer\n || !certificate.verify(certificate.publicKey)) {\n throw new Error('managed TLS CA must be a self-signed CA certificate')\n }\n const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({ format: 'der', type: 'spki' })\n const certificatePublic = certificate.publicKey.export({ format: 'der', type: 'spki' })\n if (!privatePublic.equals(certificatePublic)) throw new Error('managed TLS CA certificate and key do not match')\n if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) {\n throw new Error('managed TLS CA certificate is not currently valid')\n }\n return certificate\n}\n\nasync function atomicWrite(file: string, contents: string | Uint8Array): Promise<void> {\n const directory = dirname(file)\n await mkdir(directory, { recursive: true, mode: 0o700 })\n const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`)\n await writeFile(temporary, contents, { mode: 0o600 })\n await rename(temporary, file)\n await restrictPrivateFile(file)\n}\n\n/** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */\nexport async function ensureManagedCa(\n setup: ManagedSetup['tls'],\n legacy?: { readonly certFile: string; readonly keyFile: string },\n): Promise<X509Certificate> {\n let certPem: string | undefined\n let keyPem: string | undefined\n try {\n [certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, 'utf8'), readFile(setup.caKeyFile, 'utf8')])\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error\n let migrated = false\n if (legacy !== undefined) {\n try {\n [certPem, keyPem] = await Promise.all([readFile(legacy.certFile, 'utf8'), readFile(legacy.keyFile, 'utf8')])\n assertMatchingCa(certPem, keyPem)\n migrated = true\n } catch (legacyError) {\n if ((legacyError as NodeJS.ErrnoException).code !== 'ENOENT') throw legacyError\n }\n }\n if (!migrated) {\n const now = new Date()\n const notAfter = new Date(now)\n notAfter.setFullYear(notAfter.getFullYear() + 5)\n const generated = await generate([{ name: 'commonName', value: 'DeepSeek Harness Mobile CA' }], {\n keyType: 'ec',\n curve: 'P-256',\n algorithm: 'sha256',\n notBeforeDate: new Date(now.getTime() - 5 * 60_000),\n notAfterDate: notAfter,\n extensions: [\n { name: 'basicConstraints', cA: true, critical: true },\n { name: 'keyUsage', digitalSignature: true, keyCertSign: true, cRLSign: true, critical: true },\n ],\n })\n certPem = generated.cert\n keyPem = generated.private\n }\n if (certPem === undefined || keyPem === undefined) throw new Error('managed TLS CA creation did not produce key material')\n await Promise.all([atomicWrite(setup.caCertFile, certPem), atomicWrite(setup.caKeyFile, keyPem)])\n }\n if (certPem === undefined || keyPem === undefined) throw new Error('managed TLS CA creation did not produce key material')\n await Promise.all([restrictPrivateFile(setup.caCertFile), restrictPrivateFile(setup.caKeyFile)])\n return assertMatchingCa(certPem, keyPem)\n}\n\n/** Sign and atomically install a server leaf for the interface's current address. */\nexport async function refreshManagedServerCertificate(setup: ManagedSetup, address: string): Promise<void> {\n await Promise.all([restrictPrivateFile(setup.tls.caCertFile), restrictPrivateFile(setup.tls.caKeyFile)])\n const [caCert, caKey] = await Promise.all([\n readFile(setup.tls.caCertFile, 'utf8'),\n readFile(setup.tls.caKeyFile, 'utf8'),\n ])\n assertMatchingCa(caCert, caKey)\n const now = new Date()\n const notAfter = new Date(now)\n notAfter.setDate(notAfter.getDate() + 397)\n const server = await generate([{ name: 'commonName', value: 'DeepSeek Harness Mobile' }], {\n keyType: 'ec',\n curve: 'P-256',\n algorithm: 'sha256',\n notBeforeDate: new Date(now.getTime() - 5 * 60_000),\n notAfterDate: notAfter,\n ca: { cert: caCert, key: caKey },\n extensions: [\n { name: 'basicConstraints', cA: false, critical: true },\n { name: 'keyUsage', digitalSignature: true, critical: true },\n { name: 'extKeyUsage', serverAuth: true },\n { name: 'subjectAltName', altNames: [{ type: 7, ip: address }] },\n ],\n })\n await Promise.all([\n atomicWrite(setup.tls.certFile, server.cert),\n atomicWrite(setup.tls.keyFile, server.private),\n ])\n}\n\n/** Resolve the saved interface to the ordinary gateway config consumed by the Host plugin. */\nexport async function materializeManagedSetup(\n setup: ManagedSetup,\n table?: InterfaceTable,\n): Promise<Record<string, unknown>> {\n const network = selectLanNetwork(undefined, setup.networkInterface, table)\n await refreshManagedServerCertificate(setup, network.address)\n const ca = new X509Certificate(await readFile(setup.tls.caCertFile, 'utf8'))\n return {\n publicOrigin: `https://${network.address}:${String(setup.listenPort)}`,\n listenHost: network.address,\n upstreamOrigin: setup.upstreamOrigin,\n allowedCidrs: [network.cidr],\n instanceId: ca.fingerprint256.replaceAll(':', '').toLowerCase(),\n pairingCaFile: setup.tls.caCertFile,\n tls: {\n mode: 'provided',\n certFile: setup.tls.certFile,\n keyFile: setup.tls.keyFile,\n },\n }\n}\n","import { createHash } from 'node:crypto'\nimport type { Dirent } from 'node:fs'\nimport { lstat, mkdir, opendir, readFile, realpath } from 'node:fs/promises'\nimport { basename, isAbsolute, join, relative, resolve } from 'node:path'\nimport { pathToFileURL } from 'node:url'\nimport { Service, type Context } from '@deepseek-ai/cordis'\nimport z from '@deepseek-ai/schemastery'\nimport { finished, type Readable } from 'node:stream'\n\n/** Maximum sizes enforced at the local-extension filesystem boundary. */\nexport const EXTENSION_LIMITS = Object.freeze({\n manifest: 64 * 1024,\n script: 1024 * 1024,\n css: 512 * 1024,\n asset: 8 * 1024 * 1024,\n assetFiles: 256,\n assetBytes: 32 * 1024 * 1024,\n assetDepth: 8,\n})\n\n/** A misbehaving host activation must not wedge the local watcher forever. */\nconst HOST_ACTIVATION_TIMEOUT_MS = 5_000\n\n/** The previous Host outlives the hidden-page refresh interval and one timed refresh. */\nconst RETIRED_GENERATION_TTL_MS = 10 * 60_000\n\n/** Extension teardown is advisory and must never stop watcher progress. */\nconst HOST_TEARDOWN_TIMEOUT_MS = 2_000\n\nasync function withActivationTimeout<T>(promise: Promise<T>, id: string, signal: AbortSignal): Promise<T> {\n let timer: NodeJS.Timeout | undefined\n let onAbort: (() => void) | undefined\n try {\n return await Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timer = setTimeout(() => reject(new MobileExtensionError('host_load_timeout', `extension ${id} activation timed out`, 500)), HOST_ACTIVATION_TIMEOUT_MS)\n }),\n new Promise<never>((_, reject) => {\n const abort = (): void => { reject(new MobileExtensionError('host_activation_closed', `extension ${id} activation is closed`, 409)) }\n if (signal.aborted) abort()\n else { onAbort = abort; signal.addEventListener('abort', abort, { once: true }) }\n }),\n ])\n } finally {\n if (timer !== undefined) clearTimeout(timer)\n if (onAbort !== undefined) signal.removeEventListener('abort', onAbort)\n }\n}\n\n/** A controlled business failure returned by an extension action or route. */\nexport class MobileExtensionError extends Error {\n constructor(readonly code: string, message: string, readonly status = 400) {\n super(message)\n this.name = 'MobileExtensionError'\n }\n}\n\n/** One host-side action exposed by an extension. */\nexport interface MobileHostAction {\n readonly input?: { parse(value: unknown): unknown }\n readonly run: (context: MobileActionContext, input: unknown) => unknown | Promise<unknown>\n}\n\n/** Context supplied to a host action. */\nexport interface MobileActionContext {\n readonly signal: AbortSignal\n readonly deviceId: string\n}\n\n/** Safe request values supplied to a host route. */\nexport interface MobileRouteRequest {\n readonly method: string\n readonly pathname: string\n readonly query: Readonly<URLSearchParams>\n readonly headers: Readonly<Record<string, string>>\n readonly body: Uint8Array\n readonly signal: AbortSignal\n readonly deviceId: string\n}\n\n/** Values an extension route may return; status is a final HTTP code from 200 through 599. */\nexport interface MobileRouteResponse {\n readonly status?: number\n readonly contentType?: string\n readonly headers?: Readonly<Record<string, string>>\n readonly body: string | Uint8Array | Readable\n}\n\n/** One host-side route exposed by an extension. */\nexport interface MobileHostRoute {\n readonly method: string\n readonly path: string\n readonly kind?: 'exact' | 'prefix'\n readonly handle: (request: MobileRouteRequest) => MobileRouteResponse | Promise<MobileRouteResponse>\n}\n\n/** Metadata shared by local and npm-provided extensions. */\nexport interface MobileExtensionManifest {\n readonly schemaVersion: 1\n readonly id: string\n readonly name: string\n readonly version: string\n readonly description?: string\n}\n\n/** Definition registered by a normal Cordis plugin. */\nexport interface MobileExtensionDefinition extends MobileExtensionManifest {\n readonly actions?: Readonly<Record<string, MobileHostAction>>\n readonly routes?: readonly MobileHostRoute[]\n}\n\ndeclare module '@deepseek-ai/cordis' {\n interface Context {\n mobileAccess: MobileAccessService\n }\n}\n\n/** A local extension manifest read from extension.json. */\nexport interface LocalExtensionManifest extends MobileExtensionManifest {}\n\n/** Public snapshot sent to the mobile browser. */\nexport interface MobileExtensionClientEntry extends MobileExtensionManifest {\n readonly generation?: string\n readonly scriptUrl?: string\n readonly styleUrl?: string\n readonly assetsUrl?: string\n}\n\ninterface LocalAssetSnapshot {\n readonly body: Buffer\n readonly digest: string\n readonly name: string\n}\n\n/** Small status summary used by the desktop mobile-access card. */\nexport interface MobileExtensionStatus {\n readonly loaded: number\n readonly failed: number\n}\n\ninterface ActiveLocalExtension {\n readonly manifest: LocalExtensionManifest\n readonly directory: string\n readonly scriptBody?: Buffer\n readonly styleBody?: Buffer\n readonly assets: ReadonlyMap<string, LocalAssetSnapshot>\n readonly host: MobileExtensionDefinition\n readonly controller: AbortController\n readonly cleanups: readonly (() => void | Promise<void>)[]\n readonly digest: string\n}\n\ninterface RegisteredExtension {\n readonly definition: MobileExtensionDefinition\n readonly dispose: () => void\n}\n\ninterface RetiredLocalExtension {\n readonly active: ActiveLocalExtension\n readonly timer: NodeJS.Timeout\n}\n\ntype HostApi = {\n readonly manifest: LocalExtensionManifest\n readonly context: Context\n readonly schema: typeof z\n readonly signal: AbortSignal\n action(name: string, spec: MobileHostAction): void\n route(spec: MobileHostRoute): void\n effect(setup: () => void | (() => void | Promise<void>) | Promise<void | (() => void | Promise<void>)>): void\n}\n\ntype LocalHostModule = { readonly default?: (api: HostApi) => void | Promise<void> }\n\n/** Validate user-facing extension text without allowing control characters. */\nfunction text(value: unknown, field: string, maximum: number, required: boolean): string | undefined {\n if (value === undefined && !required) return undefined\n if (typeof value !== 'string' || (required && value.length === 0) || value.length > maximum\n || /[\\u0000-\\u001f\\u007f]/u.test(value)) throw new MobileExtensionError('invalid_manifest', `${field} is invalid`)\n return value\n}\n\n/** Validate a stable extension id. */\nexport function assertExtensionId(value: unknown): string {\n if (typeof value !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/u.test(value)) {\n throw new MobileExtensionError('invalid_manifest', 'extension id is invalid')\n }\n return value\n}\n\n/** Validate a manifest from JSON or a plugin definition. */\nexport function parseExtensionManifest(value: unknown): LocalExtensionManifest {\n if (value === null || typeof value !== 'object' || Array.isArray(value)) {\n throw new MobileExtensionError('invalid_manifest', 'extension.json must be an object')\n }\n const record = value as Record<string, unknown>\n if (record.schemaVersion !== 1) throw new MobileExtensionError('invalid_manifest', 'unsupported extension schema')\n const id = assertExtensionId(record.id)\n const name = text(record.name, 'name', 120, true) as string\n const version = text(record.version, 'version', 64, true) as string\n const description = text(record.description, 'description', 500, false)\n for (const key of Reflect.ownKeys(record)) {\n if (!['schemaVersion', 'id', 'name', 'version', 'description'].includes(String(key))) {\n throw new MobileExtensionError('invalid_manifest', 'extension.json has unknown fields')\n }\n }\n return Object.freeze({ schemaVersion: 1, id, name, version, ...(description === undefined ? {} : { description }) })\n}\n\nfunction normalizeRelativePath(value: string, field: string): string {\n if (value.length === 0 || value.includes('\\0') || isAbsolute(value)) throw new MobileExtensionError('invalid_extension_path', `${field} is invalid`)\n const normalized = value.replaceAll('\\\\', '/')\n if (normalized.split('/').some(part => part === '' || part === '.' || part === '..')) {\n throw new MobileExtensionError('invalid_extension_path', `${field} escapes extension directory`)\n }\n return normalized\n}\n\nasync function regularFile(path: string, maximum: number, field: string): Promise<{ readonly path: string; readonly size: number }> {\n let info\n try { info = await lstat(path) } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new MobileExtensionError('invalid_extension', `${field} is missing`)\n throw error\n }\n if (!info.isFile() || info.isSymbolicLink() || info.size > maximum) {\n throw new MobileExtensionError('invalid_extension', `${field} must be a regular file within its size limit`)\n }\n return { path, size: info.size }\n}\n\nasync function containedPath(root: string, relativePath: string, maximum: number, field: string): Promise<{ readonly path: string; readonly size: number }> {\n const normalized = normalizeRelativePath(relativePath, field)\n const target = resolve(root, normalized)\n const rootReal = await realpath(root)\n const targetReal = await realpath(target)\n const relation = relative(rootReal, targetReal)\n if (relation === '' || relation.startsWith('..') || isAbsolute(relation)) throw new MobileExtensionError('invalid_extension_path', `${field} escapes extension directory`)\n return regularFile(targetReal, maximum, field)\n}\n\nasync function optionalFile(root: string, name: string, maximum: number, field: string): Promise<string | undefined> {\n try {\n return (await containedPath(root, name, maximum, field)).path\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined\n if (error instanceof MobileExtensionError && error.message.includes('is missing')) return undefined\n throw error\n }\n}\n\nasync function optionalBytes(root: string, name: string, maximum: number, field: string): Promise<Buffer | undefined> {\n const path = await optionalFile(root, name, maximum, field)\n return path === undefined ? undefined : readFile(path)\n}\n\nfunction assertRealPathWithin(rootReal: string, targetReal: string, field: string): void {\n const relation = relative(rootReal, targetReal)\n if (relation === '' || relation.startsWith('..') || isAbsolute(relation)) {\n throw new MobileExtensionError('invalid_extension_path', `${field} escapes extension directory`)\n }\n}\n\nasync function realExtensionRoot(directory: string): Promise<string> {\n const root = resolve(directory)\n const info = await lstat(root)\n if (!info.isDirectory() || info.isSymbolicLink()) throw new MobileExtensionError('invalid_extension', 'extension directory must be real')\n return realpath(root)\n}\n\nasync function assetSnapshot(extensionRootReal: string): Promise<ReadonlyMap<string, LocalAssetSnapshot>> {\n const assetsPath = join(extensionRootReal, 'assets')\n let assetsInfo\n try { assetsInfo = await lstat(assetsPath) } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Map()\n throw error\n }\n if (!assetsInfo.isDirectory() || assetsInfo.isSymbolicLink()) {\n throw new MobileExtensionError('invalid_extension', 'assets must be a real directory')\n }\n const assetsReal = await realpath(assetsPath)\n assertRealPathWithin(extensionRootReal, assetsReal, 'assets')\n const snapshots = new Map<string, LocalAssetSnapshot>()\n let totalBytes = 0\n const visit = async (directoryReal: string, prefix: string, depth: number): Promise<void> => {\n if (depth > EXTENSION_LIMITS.assetDepth) {\n throw new MobileExtensionError('invalid_extension', 'asset tree exceeds its depth limit')\n }\n assertRealPathWithin(extensionRootReal, directoryReal, 'asset directory')\n const handle = await opendir(directoryReal)\n const entries: Dirent[] = []\n try { for await (const entry of handle) entries.push(entry) }\n finally { await handle.close().catch(() => undefined) }\n entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0)\n for (const entry of entries) {\n const path = join(directoryReal, entry.name)\n const info = await lstat(path)\n if (info.isSymbolicLink()) throw new MobileExtensionError('invalid_extension_path', 'asset escapes extension directory')\n const targetReal = await realpath(path)\n assertRealPathWithin(extensionRootReal, targetReal, 'asset')\n const key = prefix === '' ? entry.name : `${prefix}/${entry.name}`\n if (info.isDirectory()) {\n await visit(targetReal, key, depth + 1)\n continue\n }\n if (!info.isFile() || info.size > EXTENSION_LIMITS.asset) {\n throw new MobileExtensionError('invalid_extension', 'asset must be a regular file within its size limit')\n }\n const body = await readFile(targetReal)\n totalBytes += body.byteLength\n if (snapshots.size >= EXTENSION_LIMITS.assetFiles || totalBytes > EXTENSION_LIMITS.assetBytes) {\n throw new MobileExtensionError('invalid_extension', 'asset tree exceeds its aggregate limit')\n }\n snapshots.set(key, Object.freeze({ body, digest: createHash('sha256').update(body).digest('hex'), name: entry.name }))\n }\n }\n await visit(assetsReal, '', 0)\n return snapshots\n}\n\ninterface LocalExtensionFingerprint {\n readonly manifest: LocalExtensionManifest\n readonly digest: string\n readonly scriptBody?: Buffer\n readonly styleBody?: Buffer\n readonly assets: ReadonlyMap<string, LocalAssetSnapshot>\n}\n\nasync function extensionFingerprint(directory: string): Promise<LocalExtensionFingerprint> {\n const root = await realExtensionRoot(directory)\n const manifestFile = await regularFile(join(root, 'extension.json'), EXTENSION_LIMITS.manifest, 'extension.json')\n const manifestBody = await readFile(manifestFile.path)\n const manifest = parseExtensionManifest(JSON.parse(manifestBody.toString('utf8')) as unknown)\n if (manifest.id !== basename(root)) throw new MobileExtensionError('invalid_manifest', 'extension id must match its directory name')\n const [host, script, style, assets] = await Promise.all([\n optionalBytes(root, 'host.mjs', EXTENSION_LIMITS.script, 'host.mjs'),\n optionalBytes(root, 'mobile.js', EXTENSION_LIMITS.script, 'mobile.js'),\n optionalBytes(root, 'mobile.css', EXTENSION_LIMITS.css, 'mobile.css'),\n assetSnapshot(root),\n ])\n const digest = createHash('sha256').update(`manifest:${manifestBody.byteLength}:`).update(createHash('sha256').update(manifestBody).digest())\n for (const [name, body] of [['host', host], ['script', script], ['style', style]] as const) {\n digest.update(`\\0${name}:${body?.byteLength ?? -1}:`)\n if (body !== undefined) digest.update(createHash('sha256').update(body).digest())\n }\n for (const [name, asset] of assets) {\n digest.update(`\\0asset:${Buffer.byteLength(name)}:${name}:${asset.body.byteLength}:${asset.digest}`)\n }\n return {\n manifest,\n digest: digest.digest('hex'),\n assets,\n ...(script === undefined ? {} : { scriptBody: script }),\n ...(style === undefined ? {} : { styleBody: style }),\n }\n}\n\nfunction routeKey(route: MobileHostRoute): string {\n const method = route.method.toUpperCase()\n const path = normalizeRoutePath(route.path)\n return `${method} ${route.kind ?? 'exact'} ${path}`\n}\n\nfunction normalizeRoutePath(value: string): string {\n if (typeof value !== 'string' || value.length === 0 || value.length > 256\n || value.includes('?') || value.includes('#') || value.includes('\\\\') || value.includes('\\0')\n || /[\\u0000-\\u001f\\u007f]/u.test(value)) throw new MobileExtensionError('invalid_route', 'extension route path is invalid')\n const normalizedInput = value.startsWith('/') ? value : `/${value}`\n const parts = normalizedInput.split('/')\n if (parts.some(part => part === '..' || part === '.')) throw new MobileExtensionError('invalid_route', 'extension route path is invalid')\n return normalizedInput === '/' ? '/' : normalizedInput.replace(/\\/+$/u, '')\n}\n\nfunction validateDefinition(definition: MobileExtensionDefinition): MobileExtensionDefinition {\n const manifest = parseExtensionManifest({\n schemaVersion: definition.schemaVersion,\n id: definition.id,\n name: definition.name,\n version: definition.version,\n ...(definition.description === undefined ? {} : { description: definition.description }),\n })\n const actionNames = new Set<string>()\n for (const [name, action] of Object.entries(definition.actions ?? {})) {\n if (!/^[a-z][a-z0-9-]{0,63}$/u.test(name) || action === null || typeof action !== 'object' || typeof action.run !== 'function' || actionNames.has(name)) {\n throw new MobileExtensionError('invalid_action', `invalid action ${name}`)\n }\n actionNames.add(name)\n }\n const routeNames = new Set<string>()\n const routes = (definition.routes ?? []).map(route => {\n if (route === null || typeof route !== 'object' || typeof route.handle !== 'function') throw new MobileExtensionError('invalid_route', 'invalid extension route')\n const method = route.method.toUpperCase()\n if (!['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) throw new MobileExtensionError('invalid_route', 'unsupported extension route method')\n const normalized: MobileHostRoute = { ...route, method, path: normalizeRoutePath(route.path) }\n const key = routeKey(normalized)\n if (routeNames.has(key)) throw new MobileExtensionError('duplicate_route', `duplicate route ${key}`)\n routeNames.add(key)\n return normalized\n })\n return Object.freeze({ ...manifest, ...(definition.actions === undefined ? {} : { actions: Object.freeze({ ...definition.actions }) }), ...(routes.length === 0 ? {} : { routes: Object.freeze(routes) }) })\n}\n\ninterface CombinedSignalLifetime {\n readonly signal: AbortSignal\n readonly cleanup: () => void\n}\n\nfunction combineSignalLifetime(first: AbortSignal, second: AbortSignal): CombinedSignalLifetime {\n if (first.aborted || second.aborted) {\n const aborted = new AbortController()\n aborted.abort(first.aborted ? first.reason : second.reason)\n return { signal: aborted.signal, cleanup: () => undefined }\n }\n const controller = new AbortController()\n const cleanup = (): void => {\n first.removeEventListener('abort', abortFirst)\n second.removeEventListener('abort', abortSecond)\n }\n const abortFirst = (): void => { cleanup(); controller.abort(first.reason) }\n const abortSecond = (): void => { cleanup(); controller.abort(second.reason) }\n first.addEventListener('abort', abortFirst, { once: true })\n second.addEventListener('abort', abortSecond, { once: true })\n return { signal: controller.signal, cleanup }\n}\n\n/** Combine two abort lifetimes without relying on AbortSignal.any in older WebViews. */\nexport function combineSignals(first: AbortSignal, second: AbortSignal): AbortSignal {\n return combineSignalLifetime(first, second).signal\n}\n\n/** Host registry and service consumed by both npm plugins and local extensions. */\nexport class MobileAccessService extends Service {\n private readonly registered = new Map<string, RegisteredExtension>()\n private readonly local = new Map<string, ActiveLocalExtension>()\n private readonly retired = new Map<string, RetiredLocalExtension>()\n private readonly failures = new Map<string, string>()\n private readonly contentListeners = new Set<() => void>()\n private contentHash = createHash('sha256').update('').digest('hex')\n private localRoot: string | undefined\n private localContext: Context | undefined\n private localTimer: NodeJS.Timeout | undefined\n private localRefreshing: Promise<void> | undefined\n private localRefreshAbort: AbortController | undefined\n private localLifecycle = 0\n private localClosed = true\n\n constructor(ctx: Context) { super(ctx, 'mobileAccess') }\n\n /** Register a normal Cordis extension and return an idempotent disposer. */\n registerExtension(definition: MobileExtensionDefinition): () => void {\n const validated = validateDefinition(definition)\n if (this.registered.has(validated.id) || this.local.has(validated.id)) throw new Error(`mobile extension id already registered: ${validated.id}`)\n const dispose = (): void => {\n const current = this.registered.get(validated.id)\n if (current?.dispose === dispose) {\n this.registered.delete(validated.id)\n this.updateContentHash()\n }\n }\n this.registered.set(validated.id, { definition: validated, dispose })\n this.updateContentHash()\n return dispose\n }\n\n /** Aggregate digest covering every registered and active local extension. */\n contentDigest(): string {\n return this.contentHash\n }\n\n /** Subscribe to committed extension generation changes. */\n onContentChanged(listener: () => void): () => void {\n this.contentListeners.add(listener)\n return () => { this.contentListeners.delete(listener) }\n }\n\n private updateContentHash(): void {\n const parts = [\n ...[...this.registered.values()].map(entry => entry.definition.id),\n ...[...this.local.values()].map(active => `${active.manifest.id}:${active.digest}`),\n ]\n const next = createHash('sha256').update(parts.sort().join('|')).digest('hex')\n if (next === this.contentHash) return\n this.contentHash = next\n for (const listener of this.contentListeners) {\n try { listener() } catch { /* One observer cannot block a committed generation. */ }\n }\n }\n\n /** Return the current client-facing manifest, deterministically sorted by id. */\n manifest(): readonly MobileExtensionClientEntry[] {\n const entries = new Map<string, MobileExtensionClientEntry>()\n for (const { definition } of this.registered.values()) entries.set(definition.id, {\n schemaVersion: 1, id: definition.id, name: definition.name, version: definition.version,\n ...(definition.description === undefined ? {} : { description: definition.description }),\n })\n for (const active of this.local.values()) entries.set(active.manifest.id, {\n ...active.manifest,\n generation: active.digest,\n ...(active.scriptBody === undefined ? {} : { scriptUrl: `/mobile-access/extensions/${active.manifest.id}/mobile.js?generation=${active.digest}` }),\n ...(active.styleBody === undefined ? {} : { styleUrl: `/mobile-access/extensions/${active.manifest.id}/mobile.css?generation=${active.digest}` }),\n assetsUrl: `/mobile-access/extensions/${active.manifest.id}/assets/`,\n })\n return [...entries.values()].sort((left, right) => left.id.localeCompare(right.id))\n }\n\n /** Return loaded and failed local extension counts without exposing host errors. */\n status(): MobileExtensionStatus {\n return Object.freeze({ loaded: this.registered.size + this.local.size, failed: this.failures.size })\n }\n\n /** Locate one active extension. */\n extension(id: string, generation?: string): MobileExtensionDefinition | ActiveLocalExtension | undefined {\n if (generation !== undefined) {\n const current = this.local.get(id)\n if (current?.digest === generation) return current\n const previous = this.retired.get(id)?.active\n return previous?.digest === generation ? previous : undefined\n }\n return this.local.get(id) ?? this.registered.get(id)?.definition\n }\n\n /** Return the active local generation signal for gateway cancellation wiring. */\n signal(id: string, generation?: string): AbortSignal | undefined {\n const extension = this.extension(id, generation)\n return extension !== undefined && 'host' in extension ? extension.controller.signal : undefined\n }\n\n /** Read a local client entry after validating that it remains inside its directory. */\n async readClientFile(id: string, kind: 'script' | 'style', signal?: AbortSignal, generation?: string): Promise<{ readonly body: Buffer; readonly digest: string }> {\n signal?.throwIfAborted()\n const selected = this.extension(id, generation)\n const active = selected !== undefined && 'host' in selected ? selected : undefined\n if (active === undefined) throw new MobileExtensionError('extension_generation_not_found', 'extension generation not found', 404)\n const snapshot = kind === 'script' ? active.scriptBody : active.styleBody\n if (snapshot === undefined) throw new MobileExtensionError('extension_asset_not_found', 'extension asset not found', 404)\n const body = Buffer.from(snapshot)\n return { body, digest: createHash('sha256').update(body).digest('hex') }\n }\n\n /** Read a generation-pinned static asset from its validated snapshot. */\n async readAsset(id: string, assetPath: string, signal?: AbortSignal, generation?: string): Promise<{ readonly body: Buffer; readonly digest: string; readonly name: string }> {\n signal?.throwIfAborted()\n const selected = this.extension(id, generation)\n const active = selected !== undefined && 'host' in selected ? selected : undefined\n if (active === undefined) throw new MobileExtensionError('extension_generation_not_found', 'extension generation not found', 404)\n const normalized = normalizeRelativePath(assetPath, 'asset')\n const asset = active.assets.get(normalized)\n if (asset === undefined) throw new MobileExtensionError('extension_asset_not_found', 'extension asset not found', 404)\n return { body: Buffer.from(asset.body), digest: asset.digest, name: asset.name }\n }\n\n /** Invoke one action after parsing its input and binding the request lifetime. */\n async invoke(id: string, actionName: string, input: unknown, context: MobileActionContext, generation?: string): Promise<unknown> {\n const extension = this.extension(id, generation)\n if (extension === undefined) throw new MobileExtensionError('extension_not_found', 'extension not found', 404)\n const definition = 'host' in extension ? extension.host : extension\n const action = definition.actions?.[actionName]\n if (action === undefined) throw new MobileExtensionError('action_not_found', 'action not found', 404)\n let parsed = input\n try { parsed = action.input?.parse(input) ?? input } catch { throw new MobileExtensionError('invalid_action_input', 'action input is invalid', 400) }\n const lifetime = 'host' in extension ? combineSignalLifetime(extension.controller.signal, context.signal) : undefined\n const signal = lifetime?.signal ?? context.signal\n try { return await action.run({ ...context, signal }, parsed) } catch (error) {\n if (error instanceof MobileExtensionError) throw error\n throw new MobileExtensionError('extension_failed', 'extension action failed', 500)\n } finally { lifetime?.cleanup() }\n }\n\n /** Match one route and invoke it with a generation-bound abort signal. */\n async route(id: string, method: string, pathname: string, request: MobileRouteRequest, generation?: string): Promise<MobileRouteResponse> {\n const extension = this.extension(id, generation)\n if (extension === undefined) throw new MobileExtensionError('extension_not_found', 'extension not found', 404)\n const definition = 'host' in extension ? extension.host : extension\n const route = definition.routes?.find((candidate: MobileHostRoute) => {\n if (candidate.method !== method) return false\n return (candidate.kind ?? 'exact') === 'exact'\n ? candidate.path === pathname\n : pathname === candidate.path || pathname.startsWith(`${candidate.path}/`)\n })\n if (route === undefined) throw new MobileExtensionError('route_not_found', 'route not found', 404)\n const lifetime = 'host' in extension ? combineSignalLifetime(extension.controller.signal, request.signal) : undefined\n let releaseLifetime = true\n try {\n const routeRequest = lifetime === undefined ? request : { ...request, signal: lifetime.signal }\n const result = await route.handle(routeRequest)\n if (result === null || typeof result !== 'object' || typeof result.body !== 'string' && !(result.body instanceof Uint8Array) && !isReadable(result.body)) {\n throw new MobileExtensionError('invalid_route_response', 'extension returned an invalid response', 500)\n }\n if (lifetime !== undefined && isReadable(result.body)) {\n releaseLifetime = false\n releaseSignalLifetimeWhenStreamSettles(result.body, lifetime.cleanup)\n }\n return result\n } catch (error) {\n if (error instanceof MobileExtensionError) throw error\n throw new MobileExtensionError('extension_failed', 'extension route failed', 500)\n } finally { if (releaseLifetime) lifetime?.cleanup() }\n }\n\n /** Start the local directory watcher; an absent directory is intentionally inert. */\n async startLocal(root: string, context: Context): Promise<void> {\n const targetRoot = resolve(root)\n if (this.localRoot !== undefined && resolve(this.localRoot) !== targetRoot) await this.stopLocal()\n if (this.localTimer !== undefined) clearInterval(this.localTimer)\n const lifecycle = ++this.localLifecycle\n this.localRoot = targetRoot; this.localContext = context; this.localClosed = false\n await mkdir(this.localRoot, { recursive: true })\n if (this.localClosed || this.localLifecycle !== lifecycle || this.localRoot !== targetRoot || this.localContext !== context) return\n await this.refreshLocal()\n if (this.localClosed || this.localLifecycle !== lifecycle || this.localRoot !== targetRoot || this.localContext !== context) return\n this.localTimer = setInterval(() => { void this.refreshLocal() }, 2_000)\n this.localTimer.unref()\n }\n\n /** Stop the watcher and abort every local host generation. */\n async stopLocal(): Promise<void> {\n this.localClosed = true\n const lifecycle = ++this.localLifecycle\n if (this.localTimer !== undefined) clearInterval(this.localTimer)\n this.localTimer = undefined\n const refreshing = this.localRefreshing\n this.localRefreshAbort?.abort()\n const previous = [...this.local.values(), ...[...this.retired.values()].map(entry => entry.active)]\n this.local.clear()\n for (const entry of this.retired.values()) clearTimeout(entry.timer)\n this.retired.clear()\n this.failures.clear()\n this.updateContentHash()\n await Promise.allSettled([\n abortAndDisposeLocal(previous),\n ...(refreshing === undefined ? [] : [refreshing]),\n ])\n if (this.localLifecycle !== lifecycle) return\n const late = [...this.local.values(), ...[...this.retired.values()].map(entry => entry.active)]\n this.local.clear()\n for (const entry of this.retired.values()) clearTimeout(entry.timer)\n this.retired.clear()\n this.failures.clear()\n this.updateContentHash()\n await abortAndDisposeLocal(late)\n if (this.localTimer !== undefined) clearInterval(this.localTimer)\n this.localTimer = undefined\n }\n\n /** Refresh all local extensions atomically; failures keep the previous snapshot. */\n refreshLocal(): Promise<void> {\n if (this.localRefreshing !== undefined) return this.localRefreshing\n const controller = new AbortController()\n this.localRefreshAbort = controller\n const refreshing = this.stageAndCommit(controller.signal).finally(() => {\n if (this.localRefreshing === refreshing) this.localRefreshing = undefined\n if (this.localRefreshAbort === controller) this.localRefreshAbort = undefined\n })\n this.localRefreshing = refreshing\n return refreshing\n }\n\n private async stageAndCommit(signal: AbortSignal): Promise<void> {\n if (this.localClosed || signal.aborted || this.localRoot === undefined || this.localContext === undefined) return\n let names: string[] = []\n try {\n const directory = await opendir(this.localRoot)\n try { for await (const entry of directory) if (entry.isDirectory() && !entry.isSymbolicLink()) names.push(entry.name) }\n finally { await directory.close().catch(() => undefined) }\n } catch { return }\n names.sort()\n const staged: ActiveLocalExtension[] = []\n const stagedFresh: ActiveLocalExtension[] = []\n let failingName = 'local'\n try {\n for (const name of names) {\n signal.throwIfAborted()\n failingName = name\n const directory = join(this.localRoot, name)\n const fingerprint = await extensionFingerprint(directory)\n const current = this.local.get(fingerprint.manifest.id)\n const retired = this.retired.get(fingerprint.manifest.id)?.active\n const previous = current?.digest === fingerprint.digest ? current : retired?.digest === fingerprint.digest ? retired : undefined\n if (previous?.digest === fingerprint.digest) staged.push(previous)\n else {\n const fresh = await loadLocalExtension(directory, this.localContext, fingerprint, signal)\n try {\n signal.throwIfAborted()\n const confirmed = await extensionFingerprint(directory)\n if (confirmed.digest !== fingerprint.digest) {\n throw new MobileExtensionError('extension_changed_during_activation', `extension ${fingerprint.manifest.id} changed during activation`, 409)\n }\n } catch (error) {\n await abortAndDisposeLocal([fresh])\n throw error\n }\n staged.push(fresh); stagedFresh.push(fresh)\n }\n }\n if (this.localClosed || signal.aborted || this.localRoot === undefined || this.localContext === undefined) {\n await abortAndDisposeLocal(stagedFresh)\n return\n }\n const duplicate = new Set<string>()\n for (const entry of staged) {\n if (duplicate.has(entry.manifest.id) || this.registered.has(entry.manifest.id)) throw new MobileExtensionError('duplicate_extension', `duplicate extension id ${entry.manifest.id}`)\n duplicate.add(entry.manifest.id)\n }\n const previous = [...this.local.values()]\n for (const entry of staged) {\n const retired = this.retired.get(entry.manifest.id)\n if (retired?.active === entry) {\n clearTimeout(retired.timer)\n this.retired.delete(entry.manifest.id)\n }\n }\n const stagedIds = new Set(staged.map(entry => entry.manifest.id))\n const removed: ActiveLocalExtension[] = []\n for (const entry of previous) {\n if (staged.includes(entry)) continue\n if (stagedIds.has(entry.manifest.id)) {\n this.retire(entry)\n continue\n }\n removed.push(entry)\n const retired = this.retired.get(entry.manifest.id)\n if (retired !== undefined) {\n clearTimeout(retired.timer)\n this.retired.delete(entry.manifest.id)\n removed.push(retired.active)\n }\n }\n this.local.clear()\n for (const entry of staged) this.local.set(entry.manifest.id, entry)\n for (const entry of staged) this.failures.delete(entry.manifest.id)\n for (const name of names) this.failures.delete(name)\n for (const failure of this.failures.keys()) {\n if (failure !== 'local' && !names.includes(failure)) this.failures.delete(failure)\n }\n this.failures.delete('local')\n if (removed.length > 0) void abortAndDisposeLocal(removed)\n this.updateContentHash()\n } catch (error) {\n await abortAndDisposeLocal(stagedFresh)\n if (this.localClosed || signal.aborted) return\n const message = error instanceof Error ? error.message : String(error)\n this.failures.set(failingName, message)\n if (!(error instanceof MobileExtensionError)) this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))\n }\n }\n\n private retire(active: ActiveLocalExtension): void {\n const previous = this.retired.get(active.manifest.id)\n if (previous?.active === active) return\n if (previous !== undefined) {\n clearTimeout(previous.timer)\n this.retired.delete(active.manifest.id)\n void abortAndDisposeLocal([previous.active])\n }\n const timer = setTimeout(() => {\n const current = this.retired.get(active.manifest.id)\n if (current?.active !== active) return\n this.retired.delete(active.manifest.id)\n void abortAndDisposeLocal([active])\n }, RETIRED_GENERATION_TTL_MS)\n timer.unref()\n this.retired.set(active.manifest.id, { active, timer })\n }\n}\n\nfunction isReadable(value: unknown): value is Readable {\n return value !== null && typeof value === 'object' && typeof (value as { pipe?: unknown }).pipe === 'function'\n}\n\nfunction releaseSignalLifetimeWhenStreamSettles(stream: Readable, cleanup: () => void): void {\n let stopObserving: (() => void) | undefined\n stopObserving = finished(stream, () => {\n stopObserving?.()\n cleanup()\n })\n}\n\nfunction invokeCleanups(cleanups: readonly (() => void | Promise<void>)[]): Promise<unknown>[] {\n const pending: Promise<unknown>[] = []\n for (const cleanup of [...cleanups].reverse()) {\n try { pending.push(Promise.resolve(cleanup())) } catch { /* extension teardown cannot block the owner */ }\n }\n return pending\n}\n\nasync function settleBounded(pending: readonly Promise<unknown>[], timeoutMs: number): Promise<void> {\n if (pending.length === 0) return\n let timer: NodeJS.Timeout | undefined\n await Promise.race([\n Promise.allSettled(pending),\n new Promise<void>(resolveTimeout => { timer = setTimeout(resolveTimeout, timeoutMs) }),\n ])\n if (timer !== undefined) clearTimeout(timer)\n}\n\nasync function abortAndDisposeLocal(entries: readonly ActiveLocalExtension[]): Promise<void> {\n const pending: Promise<unknown>[] = []\n for (const entry of entries) {\n entry.controller.abort()\n pending.push(...invokeCleanups(entry.cleanups))\n }\n await settleBounded(pending, HOST_TEARDOWN_TIMEOUT_MS)\n}\n\nasync function loadLocalExtension(directory: string, context: Context, known?: LocalExtensionFingerprint, parentSignal?: AbortSignal): Promise<ActiveLocalExtension> {\n const root = await realExtensionRoot(directory)\n const manifestFile = await regularFile(join(root, 'extension.json'), EXTENSION_LIMITS.manifest, 'extension.json')\n const manifest = known?.manifest ?? parseExtensionManifest(JSON.parse(await readFile(manifestFile.path, 'utf8')) as unknown)\n if (manifest.id !== basename(root)) throw new MobileExtensionError('invalid_manifest', 'extension id must match its directory name')\n const scriptBody = known === undefined\n ? await optionalFile(root, 'mobile.js', EXTENSION_LIMITS.script, 'mobile.js').then(path => path === undefined ? undefined : readFile(path))\n : known.scriptBody\n const styleBody = known === undefined\n ? await optionalFile(root, 'mobile.css', EXTENSION_LIMITS.css, 'mobile.css').then(path => path === undefined ? undefined : readFile(path))\n : known.styleBody\n const assets = known?.assets ?? await assetSnapshot(root)\n const hostFile = await optionalFile(root, 'host.mjs', EXTENSION_LIMITS.script, 'host.mjs')\n const controller = new AbortController()\n const actions: Record<string, MobileHostAction> = {}\n const routes: MobileHostRoute[] = []\n const cleanups: (() => void | Promise<void>)[] = []\n const pendingEffects: Promise<void>[] = []\n let activationOpen = true\n const onParentAbort = (): void => { controller.abort(parentSignal?.reason) }\n if (parentSignal?.aborted === true) onParentAbort()\n else parentSignal?.addEventListener('abort', onParentAbort, { once: true })\n const ensureActivationOpen = (): void => {\n if (!activationOpen || controller.signal.aborted) throw new MobileExtensionError('host_activation_closed', `extension ${manifest.id} activation is closed`, 409)\n }\n const api: HostApi = {\n manifest,\n context,\n schema: z,\n signal: controller.signal,\n action(name, spec) { ensureActivationOpen(); if (actions[name] !== undefined) throw new MobileExtensionError('duplicate_action', `duplicate action ${name}`); actions[name] = spec },\n route(spec) { ensureActivationOpen(); routes.push(spec) },\n effect(setup) {\n ensureActivationOpen()\n const result = setup()\n if (result instanceof Promise) {\n pendingEffects.push(result.then(async cleanup => {\n if (typeof cleanup !== 'function') return\n if (activationOpen) cleanups.push(cleanup)\n else await cleanup()\n }))\n } else if (typeof result === 'function') {\n if (activationOpen) cleanups.push(result)\n else void Promise.resolve(result()).catch(() => undefined)\n }\n },\n }\n try {\n const activate = async (): Promise<void> => {\n controller.signal.throwIfAborted()\n if (hostFile !== undefined) {\n const digest = createHash('sha256').update(await readFile(hostFile)).digest('hex')\n controller.signal.throwIfAborted()\n let imported: LocalHostModule\n try { imported = await import(`${pathToFileURL(hostFile).href}?dsh_generation=${digest}`) as LocalHostModule }\n catch { throw new MobileExtensionError('host_load_failed', `could not load ${manifest.id}/host.mjs`, 500) }\n controller.signal.throwIfAborted()\n if (imported.default !== undefined) await imported.default(api)\n }\n await Promise.all(pendingEffects)\n }\n await withActivationTimeout(activate(), manifest.id, controller.signal)\n const host = validateDefinition({ ...manifest, actions, routes })\n activationOpen = false\n const digest = known?.digest ?? createHash('sha256').update(manifest.id).digest('hex')\n return Object.freeze({ manifest, directory: root, ...(scriptBody === undefined ? {} : { scriptBody }), ...(styleBody === undefined ? {} : { styleBody }), assets, host, controller, cleanups: Object.freeze(cleanups), digest })\n } catch (error) {\n activationOpen = false\n controller.abort()\n const cleanupPromises = invokeCleanups(cleanups.splice(0))\n await settleBounded([...pendingEffects, ...cleanupPromises], HOST_TEARDOWN_TIMEOUT_MS)\n throw error\n } finally {\n parentSignal?.removeEventListener('abort', onParentAbort)\n }\n}\n\n/** Construct the service in a Cordis plugin without importing DSH internals. */\nexport function createMobileAccessService(ctx: Context): MobileAccessService {\n return new MobileAccessService(ctx)\n}\n","#!/usr/bin/env node\nimport { execFile as execFileCallback } from 'node:child_process'\nimport { mkdir, readFile, rm, writeFile } from 'node:fs/promises'\nimport { homedir } from 'node:os'\nimport { join, resolve } from 'node:path'\nimport { promisify } from 'node:util'\nimport {\n ensureManagedCa,\n preferredLanInterfaceNames,\n refreshManagedServerCertificate,\n selectLanNetwork,\n type ManagedSetup,\n} from './managed-setup.js'\nimport { assertExtensionId } from './extensions.js'\nimport { restrictPrivateFile } from './private-file.js'\n\ninterface SetupOptions {\n readonly address?: string\n readonly port: number\n readonly dshPort: number\n readonly configureFirewall: boolean\n}\n\nconst execFile = promisify(execFileCallback)\nconst FIREWALL_TCP_RULE = 'DSH Mobile HTTPS'\nconst FIREWALL_UDP_RULE = 'DSH Mobile Discovery'\n\nfunction parseOptions(args: readonly string[]): SetupOptions {\n let address: string | undefined\n let port = 3443\n let dshPort = 3080\n let configureFirewall = true\n for (let index = 0; index < args.length; index += 1) {\n const name = args[index]\n const value = args[index + 1]\n if (name === '--address' && value !== undefined) {\n address = value\n index += 1\n continue\n }\n if (name === '--port' && value !== undefined) {\n port = Number(value)\n index += 1\n continue\n }\n if (name === '--dsh-port' && value !== undefined) {\n dshPort = Number(value)\n index += 1\n continue\n }\n if (name === '--no-firewall') {\n configureFirewall = false\n continue\n }\n throw new Error(`unknown setup option: ${name ?? ''}`)\n }\n if (!Number.isSafeInteger(port) || port < 1024 || port > 65535) throw new Error('--port must be from 1024 through 65535')\n if (!Number.isSafeInteger(dshPort) || dshPort < 1024 || dshPort > 65535) throw new Error('--dsh-port must be from 1024 through 65535')\n return { ...(address === undefined ? {} : { address }), port, dshPort, configureFirewall }\n}\n\nfunction dshHome(): string {\n return resolve(process.env.DSH_HOME ?? join(homedir(), '.dsh'))\n}\n\nasync function runElevatedPowerShell(script: string): Promise<void> {\n const encoded = Buffer.from(script, 'utf16le').toString('base64')\n const launch = [\n \"$ErrorActionPreference = 'Stop'; $process = Start-Process -FilePath 'powershell.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru\",\n ` -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','${encoded}')`,\n '; exit $process.ExitCode',\n ].join(' ')\n await execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', launch], { windowsHide: true })\n}\n\nasync function configureWindowsFirewall(port: number): Promise<void> {\n if (process.platform !== 'win32') return\n const script = [\n \"$ErrorActionPreference = 'Stop'\",\n `Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,\n `Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,\n `New-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`,\n `New-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -Direction Inbound -Action Allow -Protocol UDP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`,\n ].join('; ')\n console.log('Windows will request administrator approval for two LAN-only firewall rules.')\n await runElevatedPowerShell(script)\n}\n\nasync function removeWindowsFirewall(): Promise<void> {\n if (process.platform !== 'win32') return\n await runElevatedPowerShell([\n \"$ErrorActionPreference = 'Stop'\",\n `Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,\n `Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,\n ].join('; '))\n}\n\nasync function setup(args: readonly string[]): Promise<void> {\n const options = parseOptions(args)\n const preferredInterfaces = options.address === undefined ? await preferredLanInterfaceNames() : []\n const network = selectLanNetwork(options.address, undefined, undefined, preferredInterfaces)\n const home = dshHome()\n const directory = join(home, 'mobile-access')\n const tls = join(directory, 'tls')\n await mkdir(tls, { recursive: true, mode: 0o700 })\n\n const legacyCertFile = join(tls, 'cert.pem')\n const legacyKeyFile = join(tls, 'key.pem')\n const certFile = join(tls, 'server-cert.pem')\n const keyFile = join(tls, 'server-key.pem')\n const caCertFile = join(tls, 'ca.pem')\n const caKeyFile = join(tls, 'ca-key.pem')\n const androidCertificate = join(tls, 'dsh-mobile-ca.cer')\n const managedTls: ManagedSetup['tls'] = {\n mode: 'managed',\n caCertFile,\n caKeyFile,\n certFile,\n keyFile,\n }\n const ca = await ensureManagedCa(managedTls, { certFile: legacyCertFile, keyFile: legacyKeyFile })\n const managedSetup: ManagedSetup = {\n version: 2,\n networkInterface: network.name,\n listenPort: options.port,\n upstreamOrigin: `http://127.0.0.1:${String(options.dshPort)}`,\n tls: managedTls,\n }\n await refreshManagedServerCertificate(managedSetup, network.address)\n await writeFile(androidCertificate, ca.raw, { mode: 0o600 })\n await Promise.all([\n restrictPrivateFile(caCertFile),\n restrictPrivateFile(caKeyFile),\n restrictPrivateFile(certFile),\n restrictPrivateFile(keyFile),\n restrictPrivateFile(androidCertificate),\n ])\n if (options.configureFirewall) await configureWindowsFirewall(options.port)\n\n const customCss = join(directory, 'mobile.css')\n try {\n await readFile(customCss)\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error\n await writeFile(customCss, [\n '/* Safe mobile overrides. DSH Mobile applies saved changes on the phone automatically. */',\n ':root {',\n ' --dsh-mobile-accent: #2563eb;',\n ' --dsh-mobile-font-scale: 1;',\n ' --dsh-mobile-radius: 14px;',\n '}',\n '',\n ].join('\\n'), { mode: 0o600 })\n }\n\n const customScript = join(directory, 'mobile.js')\n try {\n await readFile(customScript)\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error\n await writeFile(customScript, [\n '/* Mount mobile-only Web features here. Saved changes are applied automatically. */',\n 'window.dshMobile.register(({ root }) => {',\n ' root.replaceChildren()',\n ' return () => root.replaceChildren()',\n '})',\n '',\n ].join('\\n'), { mode: 0o600 })\n }\n\n const extensions = join(directory, 'extensions')\n await mkdir(extensions, { recursive: true, mode: 0o700 })\n await createExtensionScaffold(extensions, 'custom', '自定义移动扩展', false)\n\n const origin = `https://${network.address}:${String(options.port)}`\n await Promise.all([\n writeFile(join(directory, 'setup.json'), `${JSON.stringify({\n ...managedSetup,\n tls: Object.fromEntries(Object.entries(managedSetup.tls)\n .map(([key, value]) => [key, typeof value === 'string' ? value.replaceAll('\\\\', '/') : value])),\n }, null, 2)}\\n`, { mode: 0o600 }),\n writeFile(join(directory, 'control.json'), '{\"version\":1,\"enabled\":true}\\n', { mode: 0o600 }),\n ])\n await Promise.all([\n restrictPrivateFile(join(directory, 'setup.json')),\n restrictPrivateFile(join(directory, 'control.json')),\n ])\n\n console.log(`DSH Mobile follows ${network.name} and is currently configured for ${origin}`)\n console.log(`Install this CA certificate on Android once: ${androidCertificate}`)\n console.log(`Ask DSH to customize the mobile Web UI and features in: ${customCss} and ${customScript}`)\n console.log(`Additional extensions live in: ${extensions}`)\n console.log('Start DSH with: dsh --profile web')\n console.log('Then open the Mobile card in the lower-left corner and create a pairing key.')\n}\n\nasync function createExtensionScaffold(root: string, id: string, name: string, refuseExisting = true): Promise<void> {\n assertExtensionId(id)\n await mkdir(root, { recursive: true, mode: 0o700 })\n const directory = join(root, id)\n try {\n await mkdir(directory, { recursive: false, mode: 0o700 })\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'EEXIST' && !refuseExisting) return\n if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error(`extension directory already exists: ${id}`)\n throw error\n }\n const files: Readonly<Record<string, string>> = {\n 'extension.json': `${JSON.stringify({ schemaVersion: 1, id, name, version: '0.1.0', description: '在手机端扩展 DSH' }, null, 2)}\\n`,\n 'host.mjs': `export default async function activate(api) {\\n api.action('hello', {\\n input: api.schema.object({ name: api.schema.string().max(80) }),\\n async run({ signal, deviceId }, input) {\\n void signal; void deviceId\\n return { message: \\`Hello, \\${input.name}\\` }\\n },\\n })\\n}\\n`,\n 'mobile.js': `window.dshMobile?.define?.({\\n apiVersion: 1,\\n id: '${id}',\\n activate(api) {\\n return api.ui.registerSurface({\\n id: '${id}-page', placement: 'page', label: ${JSON.stringify(name)},\\n mount(container) {\\n container.textContent = ${JSON.stringify(`这是 ${name} 的移动页面。`)}\\n return () => container.replaceChildren()\\n },\\n })\\n },\\n})\\n`,\n 'mobile.css': `/* ${name.replaceAll('*/', '* /')} 的移动端样式。保存后通常会在几秒内刷新。 */\\n`,\n }\n try {\n for (const [file, contents] of Object.entries(files)) await writeFile(join(directory, file), contents, { encoding: 'utf8', flag: 'wx', mode: 0o600 })\n } catch (error) {\n await rm(directory, { recursive: true, force: true })\n throw error\n }\n console.log(`Created extension: ${directory}`)\n}\n\nasync function extensionCommand(args: readonly string[]): Promise<void> {\n const [subcommand, id, ...rest] = args\n if (subcommand !== 'create' || id === undefined) throw new Error('usage: extension create <id> [--name <name>]')\n let name = id\n for (let index = 0; index < rest.length; index += 1) {\n if (rest[index] === '--name' && rest[index + 1] !== undefined) { name = rest[index + 1]!; index += 1; continue }\n throw new Error(`unknown extension option: ${rest[index] ?? ''}`)\n }\n if (name.length === 0 || name.length > 120 || /[\\u0000-\\u001f\\u007f]/u.test(name)) throw new Error('--name is invalid')\n await createExtensionScaffold(join(dshHome(), 'mobile-access', 'extensions'), id, name)\n}\n\nasync function purge(args: readonly string[]): Promise<void> {\n if (args.length !== 1 || args[0] !== '--yes') throw new Error('purge requires --yes')\n const home = dshHome()\n await rm(join(home, 'mobile-access'), { recursive: true, force: true })\n await removeWindowsFirewall()\n console.log('Removed DSH Mobile certificates, devices, preferences, and custom Web files.')\n}\n\nfunction help(): void {\n console.log([\n 'dsh-mobile setup [--address 192.168.x.x] [--port 3443] [--dsh-port 3080] [--no-firewall]',\n 'dsh-mobile extension create <id> [--name <name>]',\n 'dsh-mobile purge --yes',\n '',\n 'Run through the DSH profile:',\n ' dsh plugin --profile web exec dsh-mobile setup',\n ].join('\\n'))\n}\n\nasync function main(): Promise<void> {\n const [command = 'help', ...args] = process.argv.slice(2)\n if (command === 'setup') await setup(args)\n else if (command === 'extension') await extensionCommand(args)\n else if (command === 'purge') await purge(args)\n else if (command === 'help' || command === '--help' || command === '-h') help()\n else throw new Error(`unknown command: ${command}`)\n}\n\nmain().catch((error: unknown) => {\n console.error(error instanceof Error ? error.message : String(error))\n process.exitCode = 1\n})\n"],"mappings":"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"}
|