dsh-mobile 0.1.0-alpha.1 → 0.1.0-alpha.3
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/README.md +56 -62
- package/README.zh.md +55 -62
- package/SECURITY.md +1 -0
- package/assets/brand/app-icon-master.png +0 -0
- package/cordis.patch.yml +1 -0
- package/lib/cli.js +202 -80
- package/lib/client.js +124 -4
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +4 -1
- package/lib/index.mjs +206 -9
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://unpkg.com/dsh-mobile@0.1.0-alpha.
|
|
2
|
+
<img src="https://unpkg.com/dsh-mobile@0.1.0-alpha.2/assets/brand/repository-hero.png" alt="A lively whale girl using a phone beside a desktop running DSH" width="100%">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<h1 align="center">DSH Mobile</h1>
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
|
|
27
27
|
DSH Mobile reuses DSH's own Web application, sessions, and plugin slots. It adds an authenticated HTTPS gateway, device pairing, session revocation, and a small responsive Client face. Use the Android shell without browser chrome, or open the same LAN address directly in any mobile browser.
|
|
28
28
|
|
|
29
|
+
The mobile client is self-editable: ask DSH in an ordinary conversation to change `mobile.css` for presentation or `mobile.js` for behavior, keep the phone open, and watch the Android app or mobile browser apply the result automatically within about one second.
|
|
30
|
+
|
|
29
31
|
## Features
|
|
30
32
|
|
|
31
33
|
| Capability | Android app | Mobile browser |
|
|
@@ -33,7 +35,7 @@ DSH Mobile reuses DSH's own Web application, sessions, and plugin slots. It adds
|
|
|
33
35
|
| Live DSH Web UI | Yes | Yes |
|
|
34
36
|
| Authenticated HTTPS LAN access | Yes | Yes |
|
|
35
37
|
| One-time device pairing | Yes | Yes |
|
|
36
|
-
|
|
|
38
|
+
| Extend mobile UI and Web functions through a DSH conversation with live preview | Yes | Yes |
|
|
37
39
|
| File selection and downloads | Yes | Browser-dependent |
|
|
38
40
|
| iOS native app | Not yet | Safari can use the Web UI |
|
|
39
41
|
|
|
@@ -41,75 +43,27 @@ Host-native actions such as opening desktop files remain subject to stock DSH an
|
|
|
41
43
|
|
|
42
44
|
## Quick start
|
|
43
45
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
### Choose the DSH command
|
|
47
|
-
|
|
48
|
-
If `dsh` is available in the terminal, use the commands below as written.
|
|
49
|
-
|
|
50
|
-
If you cloned the DSH source and do not have a global `dsh` command, first install its workspace dependencies from the DSH repository root:
|
|
46
|
+
Use the first block when `dsh` is available globally:
|
|
51
47
|
|
|
52
48
|
```powershell
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
dsh plugin --profile web add dsh-mobile
|
|
50
|
+
dsh plugin --profile web exec dsh-mobile setup
|
|
51
|
+
dsh --profile web
|
|
55
52
|
```
|
|
56
53
|
|
|
57
|
-
|
|
54
|
+
If you cloned the DSH source, run the second block from its repository root:
|
|
58
55
|
|
|
59
56
|
```powershell
|
|
57
|
+
corepack enable
|
|
58
|
+
pnpm install
|
|
60
59
|
pnpm dsh plugin --profile web add dsh-mobile
|
|
61
60
|
pnpm dsh plugin --profile web exec dsh-mobile setup
|
|
62
61
|
pnpm dsh --profile web
|
|
63
62
|
```
|
|
64
63
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
```powershell
|
|
68
|
-
pnpm --dir "<path-to-deepseek-harness>" dsh --profile web
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
### 1. Install
|
|
72
|
-
|
|
73
|
-
Install the public package directly from npm:
|
|
74
|
-
|
|
75
|
-
```powershell
|
|
76
|
-
dsh plugin --profile web add dsh-mobile
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
Repository collaborators and offline installations may instead install an exact tarball:
|
|
80
|
-
|
|
81
|
-
```powershell
|
|
82
|
-
dsh plugin --profile web add "<path-to-dsh-mobile.tgz>"
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
No DSH source patch or custom DSH build is required.
|
|
86
|
-
|
|
87
|
-
### 2. Run setup once
|
|
88
|
-
|
|
89
|
-
```powershell
|
|
90
|
-
dsh plugin --profile web exec dsh-mobile setup
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
The setup command selects a private LAN address, creates a local TLS certificate, and stores all plugin-owned configuration under `$DSH_HOME/mobile-access/`. If more than one LAN address is available, select one explicitly:
|
|
94
|
-
|
|
95
|
-
```powershell
|
|
96
|
-
dsh plugin --profile web exec dsh-mobile setup --address 192.168.1.20
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
For a non-default DSH Web port:
|
|
64
|
+
Both paths install the public npm package into stock DSH without patching its source. Setup remembers the selected LAN interface, creates one local CA, and stores plugin data under `$DSH_HOME/mobile-access/`; run `dsh-mobile setup --help` through the same `plugin exec` command for manual network options. When DHCP changes that interface's IP, the plugin follows the new address and signs a matching server certificate from the same CA on the next DSH start.
|
|
100
65
|
|
|
101
|
-
|
|
102
|
-
dsh plugin --profile web exec dsh-mobile setup --dsh-port 39080
|
|
103
|
-
dsh --profile web --port 39080
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
### 3. Start and pair
|
|
107
|
-
|
|
108
|
-
```powershell
|
|
109
|
-
dsh --profile web
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
1. Copy the generated `dsh-mobile-ca.cer` to Android and install it as a user CA certificate.
|
|
66
|
+
1. Copy the generated `dsh-mobile-ca.cer` to Android and install it once as a user CA certificate.
|
|
113
67
|
2. Open the Mobile card in the lower-left corner of the desktop DSH UI.
|
|
114
68
|
3. Start access and create a pairing link. The link is copied to the clipboard.
|
|
115
69
|
4. Paste the link into the Android app, or open it directly in a mobile browser.
|
|
@@ -127,13 +81,52 @@ The two access modes are equivalent:
|
|
|
127
81
|
|
|
128
82
|
## Customize the mobile Web UI
|
|
129
83
|
|
|
84
|
+
This is a same-origin Web extension surface, not a fixed theme editor. DSH can edit two files on the computer:
|
|
85
|
+
|
|
86
|
+
- `mobile.css` controls layout, visual hierarchy, safe areas, typography, and responsive behavior.
|
|
87
|
+
- `mobile.js` mounts mobile-only DOM and behavior, can use browser capabilities, and can call the same-origin DSH Web API as the paired page.
|
|
88
|
+
|
|
89
|
+
That is enough to build a one-handed command dock, a gesture command palette, a session dashboard, terminal shortcut keys, a floating context panel, voice input, a camera or QR workflow, or a completely different mobile navigation model. Camera, microphone, notifications, and similar capabilities still require browser or Android permission.
|
|
90
|
+
|
|
91
|
+
The easiest workflow stays entirely inside DSH. In a conversation with filesystem write access, try a broad request:
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
Turn DSH Mobile into a one-handed field console by editing
|
|
95
|
+
$DSH_HOME/mobile-access/mobile.css and mobile.js.
|
|
96
|
+
|
|
97
|
+
Add a bottom command dock, swipe shortcuts for switching conversations,
|
|
98
|
+
a push-to-talk button when the browser supports speech input, and a collapsible
|
|
99
|
+
session monitor. Keep every feature mobile-only. Register JavaScript through
|
|
100
|
+
window.dshMobile.register(), return a cleanup function, and do not modify DSH source.
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Keep the phone page open while DSH edits. The Android app and mobile browser check both authenticated files once per second and apply saved changes automatically—no DSH restart, new pairing, or manual page refresh. Invalid JavaScript does not replace the last successfully mounted extension.
|
|
104
|
+
|
|
130
105
|
Setup creates:
|
|
131
106
|
|
|
132
107
|
```text
|
|
133
108
|
$DSH_HOME/mobile-access/mobile.css
|
|
109
|
+
$DSH_HOME/mobile-access/mobile.js
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The files load only through the authenticated mobile gateway. They do not modify the DSH installation and survive DSH upgrades. Continue the same conversation with requests such as “turn the dock into a radial menu,” “add a full-screen monitoring view,” or “use the camera to prepare an attachment,” and inspect each revision directly on the phone.
|
|
113
|
+
|
|
114
|
+
`mobile.js` uses a small lifecycle entry point:
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
window.dshMobile.register(({ root, document, request }) => {
|
|
118
|
+
const workspace = document.createElement('section')
|
|
119
|
+
workspace.ariaLabel = 'My mobile workspace'
|
|
120
|
+
root.append(workspace)
|
|
121
|
+
|
|
122
|
+
// Build any mobile Web UI and behavior inside workspace. Use request()
|
|
123
|
+
// for same-origin DSH API calls with the paired Session and CSRF protection.
|
|
124
|
+
|
|
125
|
+
return () => workspace.remove()
|
|
126
|
+
})
|
|
134
127
|
```
|
|
135
128
|
|
|
136
|
-
|
|
129
|
+
The supplied `root` isolates mobile-only DOM, while `request()` rejects cross-origin targets and carries the paired session plus the gateway's CSRF header. An extension may still use ordinary secure-context browser APIs directly when the device grants permission.
|
|
137
130
|
|
|
138
131
|
Basic theme controls:
|
|
139
132
|
|
|
@@ -154,7 +147,7 @@ Narrow-screen adjustments:
|
|
|
154
147
|
}
|
|
155
148
|
```
|
|
156
149
|
|
|
157
|
-
Prefer
|
|
150
|
+
Prefer stable Web APIs and DSH extension points over generated class names. Delete `mobile.css` to restore the visual defaults and replace `mobile.js` with an empty registered mount to remove custom behavior; the open phone page updates automatically.
|
|
158
151
|
|
|
159
152
|
## A native DSH plugin
|
|
160
153
|
|
|
@@ -169,10 +162,11 @@ flowchart LR
|
|
|
169
162
|
## Security model
|
|
170
163
|
|
|
171
164
|
- The gateway accepts only the selected private LAN CIDR, exact Host, and same-origin browser requests.
|
|
172
|
-
- The Android app never bypasses TLS validation. The certificate
|
|
165
|
+
- The Android app never bypasses TLS validation. The stable local CA signs a server certificate for the selected interface's current LAN address at each DSH start.
|
|
173
166
|
- Pairing creates an HttpOnly session; devices and active sessions can be revoked from the computer.
|
|
174
167
|
- Control and device-management routes are loopback-only.
|
|
175
168
|
- Authentication happens before any request reaches the stock DSH loopback server. DSH itself is never rebound to `0.0.0.0`.
|
|
169
|
+
- `mobile.js` runs as application code inside the paired DSH page. Only let trusted DSH conversations edit it, and review generated integrations before keeping them.
|
|
176
170
|
|
|
177
171
|
Do not expose this gateway through public Wi-Fi, port forwarding, a public IP, or an untrusted VPN. See [SECURITY.md](SECURITY.md).
|
|
178
172
|
|
package/README.zh.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://unpkg.com/dsh-mobile@0.1.0-alpha.
|
|
2
|
+
<img src="https://unpkg.com/dsh-mobile@0.1.0-alpha.2/assets/brand/repository-hero.png" alt="鲸鱼娘拿着手机连接桌面上的 DSH" width="100%">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<h1 align="center">DSH Mobile</h1>
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
|
|
27
27
|
DSH Mobile 不是另一套远程控制面板。插件直接复用 DSH 自己的 Web 应用、会话和插件槽位,在外层增加 HTTPS、设备配对、会话撤销和移动端样式。你可以用 Android App 获得无浏览器栏的体验,也可以直接用手机浏览器访问同一个局域网地址。
|
|
28
28
|
|
|
29
|
+
移动端客户端可以“自己改自己”:直接在普通 DSH 对话中要求 DSH 修改负责外观的 `mobile.css` 或负责功能的 `mobile.js`,手机页面保持打开,Android App 和手机浏览器通常会在一秒内自动应用结果。
|
|
30
|
+
|
|
29
31
|
## 主要能力
|
|
30
32
|
|
|
31
33
|
| 能力 | Android App | 手机浏览器 |
|
|
@@ -33,7 +35,7 @@ DSH Mobile 不是另一套远程控制面板。插件直接复用 DSH 自己的
|
|
|
33
35
|
| 实时访问 DSH Web UI | 支持 | 支持 |
|
|
34
36
|
| HTTPS 局域网入口 | 支持 | 支持 |
|
|
35
37
|
| 一次性设备配对 | 支持 | 支持 |
|
|
36
|
-
|
|
|
38
|
+
| 在 DSH 对话中扩展移动 UI与网页功能并实时预览 | 支持 | 支持 |
|
|
37
39
|
| 文件选择与下载 | 支持 | 由浏览器决定 |
|
|
38
40
|
| iOS | 暂不支持 | 可用 Safari 访问网页 |
|
|
39
41
|
|
|
@@ -41,75 +43,27 @@ DSH Mobile 不是另一套远程控制面板。插件直接复用 DSH 自己的
|
|
|
41
43
|
|
|
42
44
|
## 快速开始
|
|
43
45
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
### 选择 DSH 命令
|
|
47
|
-
|
|
48
|
-
如果终端中能够直接运行 `dsh`,后续命令保持原样即可。
|
|
49
|
-
|
|
50
|
-
如果你直接克隆了 DSH 源码,没有全局 `dsh` 命令,请先在 DSH 源码根目录安装工作区依赖:
|
|
46
|
+
终端中可以直接运行 `dsh` 时,使用第一组命令:
|
|
51
47
|
|
|
52
48
|
```powershell
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
dsh plugin --profile web add dsh-mobile
|
|
50
|
+
dsh plugin --profile web exec dsh-mobile setup
|
|
51
|
+
dsh --profile web
|
|
55
52
|
```
|
|
56
53
|
|
|
57
|
-
|
|
54
|
+
如果你直接克隆了 DSH 源码,则在 DSH 源码根目录使用第二组命令:
|
|
58
55
|
|
|
59
56
|
```powershell
|
|
57
|
+
corepack enable
|
|
58
|
+
pnpm install
|
|
60
59
|
pnpm dsh plugin --profile web add dsh-mobile
|
|
61
60
|
pnpm dsh plugin --profile web exec dsh-mobile setup
|
|
62
61
|
pnpm dsh --profile web
|
|
63
62
|
```
|
|
64
63
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
```powershell
|
|
68
|
-
pnpm --dir "<DSH源码目录>" dsh --profile web
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
### 1. 安装插件
|
|
72
|
-
|
|
73
|
-
直接从公开 npm 安装:
|
|
74
|
-
|
|
75
|
-
```powershell
|
|
76
|
-
dsh plugin --profile web add dsh-mobile
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
仓库协作者或离线环境也可以安装指定 tarball:
|
|
80
|
-
|
|
81
|
-
```powershell
|
|
82
|
-
dsh plugin --profile web add "<插件安装包路径>"
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
这两种方式都使用 DSH 官方插件命令,不需要修改或替换 DSH 源码。
|
|
86
|
-
|
|
87
|
-
### 2. 运行一次设置向导
|
|
88
|
-
|
|
89
|
-
```powershell
|
|
90
|
-
dsh plugin --profile web exec dsh-mobile setup
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
向导会自动选择唯一的私有局域网地址,生成本地 TLS 证书,并把配置保存到 `$DSH_HOME/mobile-access/`。如果电脑有多个可用网卡,向导会列出候选地址并要求明确选择:
|
|
94
|
-
|
|
95
|
-
```powershell
|
|
96
|
-
dsh plugin --profile web exec dsh-mobile setup --address 192.168.1.20
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
如果 DSH Web 使用非默认端口,也可以一次指定:
|
|
64
|
+
两种方式都会把公开 npm 包安装到原生 DSH,不修改 DSH 源码。设置向导会记住所选局域网网卡、生成一份稳定的本地 CA,并把插件数据保存到 `$DSH_HOME/mobile-access/`;需要手动指定网卡或端口时,通过同一个 `plugin exec` 命令运行 `dsh-mobile setup --help`。DHCP 改变该网卡的 IP 后,插件会在下次启动 DSH 时跟随新地址,并用同一 CA 自动签发匹配的服务器证书。
|
|
100
65
|
|
|
101
|
-
|
|
102
|
-
dsh plugin --profile web exec dsh-mobile setup --dsh-port 39080
|
|
103
|
-
dsh --profile web --port 39080
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
### 3. 启动、配对与连接
|
|
107
|
-
|
|
108
|
-
```powershell
|
|
109
|
-
dsh --profile web
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
1. 将向导输出的 `dsh-mobile-ca.cer` 复制到 Android,并在系统设置中安装为用户 CA 证书。
|
|
66
|
+
1. 将向导输出的 `dsh-mobile-ca.cer` 复制到 Android,并在系统设置中一次性安装为用户 CA 证书。
|
|
113
67
|
2. 在电脑 DSH 左下角打开“移动端”卡片。
|
|
114
68
|
3. 确认服务已开启,点击“生成配对链接”。链接会复制到剪贴板。
|
|
115
69
|
4. 在 Android App 中粘贴链接,或直接在手机浏览器中打开链接。
|
|
@@ -127,13 +81,51 @@ dsh --profile web
|
|
|
127
81
|
|
|
128
82
|
## 自定义移动端 Web UI
|
|
129
83
|
|
|
84
|
+
这不是一个只能换颜色的主题编辑器,而是与 DSH 页面同源的 Web 扩展面。电脑上的 DSH 可以编辑两个文件:
|
|
85
|
+
|
|
86
|
+
- `mobile.css` 控制布局、视觉层级、安全区、字体和响应式行为。
|
|
87
|
+
- `mobile.js` 挂载移动端专属 DOM 与交互,可以使用浏览器能力,也可以用已配对页面的身份调用同源 DSH Web API。
|
|
88
|
+
|
|
89
|
+
自由度并不限于几个预设组件。你可以让 DSH 创建单手指令坞、手势命令面板、会话仪表盘、终端快捷键、悬浮上下文面板、语音输入、相机或二维码工作流,乃至完全不同的移动端导航。相机、麦克风、通知等能力仍需要浏览器或 Android 授权。
|
|
90
|
+
|
|
91
|
+
最简单的方式是不离开 DSH。打开一个具备文件写入权限的 DSH 对话,给它一个更大胆的目标:
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
请编辑 $DSH_HOME/mobile-access/mobile.css 和 mobile.js,
|
|
95
|
+
把 DSH Mobile 改造成适合单手操作的现场工作台。
|
|
96
|
+
|
|
97
|
+
增加底部指令坞、左右滑动切换会话、浏览器支持时可用的按住说话按钮,
|
|
98
|
+
以及可折叠的会话监控面板。所有功能只在移动端出现;JavaScript 通过
|
|
99
|
+
window.dshMobile.register() 注册并返回清理函数,不要修改 DSH 源码。
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
DSH 修改文件时让手机页面保持打开。Android App 和手机浏览器每秒检查一次这两个已认证文件,保存后的变化会自动应用,不需要重启 DSH、重新配对或手动刷新页面。JavaScript 写错时会保留上一次成功挂载的功能。
|
|
103
|
+
|
|
130
104
|
设置向导会创建:
|
|
131
105
|
|
|
132
106
|
```text
|
|
133
107
|
$DSH_HOME/mobile-access/mobile.css
|
|
108
|
+
$DSH_HOME/mobile-access/mobile.js
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
它们只通过已认证的移动网关加载,不修改 DSH 安装目录,升级或卸载 DSH 时也不会覆盖。你可以继续在同一个对话中说“把指令坞改成环形菜单”“增加全屏监控视图”或“用相机准备附件”,并直接在手机上观察每次修改。
|
|
112
|
+
|
|
113
|
+
`mobile.js` 只有一个很小的生命周期入口:
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
window.dshMobile.register(({ root, document, request }) => {
|
|
117
|
+
const workspace = document.createElement('section')
|
|
118
|
+
workspace.ariaLabel = '我的移动工作台'
|
|
119
|
+
root.append(workspace)
|
|
120
|
+
|
|
121
|
+
// 在 workspace 中构建任意移动 Web UI 和交互。调用同源 DSH API 时使用
|
|
122
|
+
// request(),它会携带已配对 Session 和 CSRF 防护信息。
|
|
123
|
+
|
|
124
|
+
return () => workspace.remove()
|
|
125
|
+
})
|
|
134
126
|
```
|
|
135
127
|
|
|
136
|
-
|
|
128
|
+
传入的 `root` 用来承载移动端专属 DOM;`request()` 会拒绝跨源目标,并自动携带已配对会话和网关 CSRF 请求头。需要设备权限的能力仍可直接使用安全上下文中的标准浏览器 API。
|
|
137
129
|
|
|
138
130
|
### 最简单的主题调整
|
|
139
131
|
|
|
@@ -160,7 +152,7 @@ $DSH_HOME/mobile-access/mobile.css
|
|
|
160
152
|
}
|
|
161
153
|
```
|
|
162
154
|
|
|
163
|
-
|
|
155
|
+
建议优先使用稳定的 Web API 和 DSH 扩展点,不要依赖构建后生成的类名。删除 `mobile.css` 可恢复默认外观;将 `mobile.js` 替换为空的注册函数即可移除自定义功能,打开的手机页面会自动更新。
|
|
164
156
|
|
|
165
157
|
## 为什么符合 DSH 的插件理念
|
|
166
158
|
|
|
@@ -175,10 +167,11 @@ flowchart LR
|
|
|
175
167
|
## 安全模型
|
|
176
168
|
|
|
177
169
|
- 网关只接受设置向导选定的私有局域网 CIDR、精确 Host 和同源浏览器请求。
|
|
178
|
-
- Android App 不跳过 TLS
|
|
170
|
+
- Android App 不跳过 TLS 校验;稳定的本地 CA 会在每次 DSH 启动时为所选网卡的当前局域网地址签发服务器证书。
|
|
179
171
|
- 配对设备获得短期 HttpOnly Session,设备和会话都可在电脑端撤销。
|
|
180
172
|
- 管理接口只允许从电脑回环地址访问,移动端无法打开、关闭网关或管理其他设备。
|
|
181
173
|
- 外层网关在认证后才代理到原生 DSH 的回环端口,不会把 DSH 自身绑定到 `0.0.0.0`。
|
|
174
|
+
- `mobile.js` 会作为应用代码在已配对的 DSH 页面中运行。只允许可信的 DSH 对话编辑它,并在长期保留前检查生成的集成。
|
|
182
175
|
|
|
183
176
|
不要在公共 Wi-Fi、端口转发、公网 IP 或不可信 VPN 中开放本插件。更多说明见 [SECURITY.md](SECURITY.md)。
|
|
184
177
|
|
package/SECURITY.md
CHANGED
|
@@ -21,6 +21,7 @@ The maintainer will acknowledge a complete report within seven days. Publication
|
|
|
21
21
|
- Revoke a lost device immediately and rotate the device registry if credential theft is suspected.
|
|
22
22
|
- Do not expose the gateway directly to the public Internet.
|
|
23
23
|
- 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.
|
|
24
|
+
- 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.
|
|
24
25
|
|
|
25
26
|
## Known alpha limitation
|
|
26
27
|
|
|
Binary file
|
package/cordis.patch.yml
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
stateFile: !!js dshHomePath('mobile-access/devices.json')
|
|
10
10
|
controlFile: !!js dshHomePath('mobile-access/control.json')
|
|
11
11
|
customCssFile: !!js dshHomePath('mobile-access/mobile.css')
|
|
12
|
+
customScriptFile: !!js dshHomePath('mobile-access/mobile.js')
|
|
12
13
|
initiallyEnabled: false
|
|
13
14
|
listenHost: 127.0.0.1
|
|
14
15
|
listenPort: 3443
|
package/lib/cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
3
|
import { homedir, networkInterfaces } from "node:os";
|
|
5
|
-
import { join, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { X509Certificate, createPrivateKey, createPublicKey } from "node:crypto";
|
|
6
6
|
import { generate } from "selfsigned";
|
|
7
|
-
//#region src/
|
|
7
|
+
//#region src/managed-setup.ts
|
|
8
8
|
function privateIpv4(value) {
|
|
9
9
|
const parts = value.split(".").map(Number);
|
|
10
10
|
return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
|
|
@@ -19,13 +19,157 @@ function networkCidr(address, cidr) {
|
|
|
19
19
|
0
|
|
20
20
|
].map((shift) => network >>> shift & 255).join(".")}/${String(prefix)}`;
|
|
21
21
|
}
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
/** List current private IPv4 candidates with their interface identity. */
|
|
23
|
+
function availableLanNetworks(table = networkInterfaces()) {
|
|
24
|
+
const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
|
|
25
|
+
name,
|
|
24
26
|
address: entry.address,
|
|
25
27
|
cidr: networkCidr(entry.address, entry.cidr)
|
|
26
|
-
}));
|
|
27
|
-
return [...new Map(candidates.map((entry) => [entry.address
|
|
28
|
+
})));
|
|
29
|
+
return [...new Map(candidates.map((entry) => [`${entry.name}\0${entry.address}`, entry])).values()];
|
|
30
|
+
}
|
|
31
|
+
/** Select an active LAN, optionally by address or by a previously saved interface name. */
|
|
32
|
+
function selectLanNetwork(requestedAddress, requestedInterface, table) {
|
|
33
|
+
const candidates = availableLanNetworks(table);
|
|
34
|
+
if (requestedAddress !== void 0) {
|
|
35
|
+
const match = candidates.find((candidate) => candidate.address === requestedAddress);
|
|
36
|
+
if (match === void 0) throw new Error(`--address ${requestedAddress} is not an active private LAN address`);
|
|
37
|
+
return match;
|
|
38
|
+
}
|
|
39
|
+
if (requestedInterface !== void 0) {
|
|
40
|
+
const matches = candidates.filter((candidate) => candidate.name === requestedInterface);
|
|
41
|
+
if (matches.length === 1) return matches[0];
|
|
42
|
+
if (matches.length === 0) throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`);
|
|
43
|
+
throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`);
|
|
44
|
+
}
|
|
45
|
+
if (candidates.length === 1) return candidates[0];
|
|
46
|
+
if (candidates.length === 0) throw new Error("no active private LAN address was found; connect to Wi-Fi or Ethernet");
|
|
47
|
+
throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => entry.address).join(", ")}`);
|
|
48
|
+
}
|
|
49
|
+
function assertMatchingCa(certPem, keyPem) {
|
|
50
|
+
const certificate = new X509Certificate(certPem);
|
|
51
|
+
if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
|
|
52
|
+
const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({
|
|
53
|
+
format: "der",
|
|
54
|
+
type: "spki"
|
|
55
|
+
});
|
|
56
|
+
const certificatePublic = certificate.publicKey.export({
|
|
57
|
+
format: "der",
|
|
58
|
+
type: "spki"
|
|
59
|
+
});
|
|
60
|
+
if (!privatePublic.equals(certificatePublic)) throw new Error("managed TLS CA certificate and key do not match");
|
|
61
|
+
if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) throw new Error("managed TLS CA certificate is not currently valid");
|
|
62
|
+
return certificate;
|
|
63
|
+
}
|
|
64
|
+
async function atomicWrite(file, contents) {
|
|
65
|
+
const directory = dirname(file);
|
|
66
|
+
await mkdir(directory, {
|
|
67
|
+
recursive: true,
|
|
68
|
+
mode: 448
|
|
69
|
+
});
|
|
70
|
+
const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`);
|
|
71
|
+
await writeFile(temporary, contents, { mode: 384 });
|
|
72
|
+
await chmod(temporary, 384);
|
|
73
|
+
await rename(temporary, file);
|
|
74
|
+
}
|
|
75
|
+
/** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
|
|
76
|
+
async function ensureManagedCa(setup, legacy) {
|
|
77
|
+
let certPem;
|
|
78
|
+
let keyPem;
|
|
79
|
+
try {
|
|
80
|
+
[certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.code !== "ENOENT") throw error;
|
|
83
|
+
let migrated = false;
|
|
84
|
+
if (legacy !== void 0) try {
|
|
85
|
+
[certPem, keyPem] = await Promise.all([readFile(legacy.certFile, "utf8"), readFile(legacy.keyFile, "utf8")]);
|
|
86
|
+
assertMatchingCa(certPem, keyPem);
|
|
87
|
+
migrated = true;
|
|
88
|
+
} catch (legacyError) {
|
|
89
|
+
if (legacyError.code !== "ENOENT") throw legacyError;
|
|
90
|
+
}
|
|
91
|
+
if (!migrated) {
|
|
92
|
+
const now = /* @__PURE__ */ new Date();
|
|
93
|
+
const notAfter = new Date(now);
|
|
94
|
+
notAfter.setFullYear(notAfter.getFullYear() + 5);
|
|
95
|
+
const generated = await generate([{
|
|
96
|
+
name: "commonName",
|
|
97
|
+
value: "DeepSeek Harness Mobile CA"
|
|
98
|
+
}], {
|
|
99
|
+
keyType: "ec",
|
|
100
|
+
curve: "P-256",
|
|
101
|
+
algorithm: "sha256",
|
|
102
|
+
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
103
|
+
notAfterDate: notAfter,
|
|
104
|
+
extensions: [{
|
|
105
|
+
name: "basicConstraints",
|
|
106
|
+
cA: true,
|
|
107
|
+
critical: true
|
|
108
|
+
}, {
|
|
109
|
+
name: "keyUsage",
|
|
110
|
+
digitalSignature: true,
|
|
111
|
+
keyCertSign: true,
|
|
112
|
+
cRLSign: true,
|
|
113
|
+
critical: true
|
|
114
|
+
}]
|
|
115
|
+
});
|
|
116
|
+
certPem = generated.cert;
|
|
117
|
+
keyPem = generated.private;
|
|
118
|
+
}
|
|
119
|
+
if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
|
|
120
|
+
await Promise.all([atomicWrite(setup.caCertFile, certPem), atomicWrite(setup.caKeyFile, keyPem)]);
|
|
121
|
+
}
|
|
122
|
+
if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
|
|
123
|
+
return assertMatchingCa(certPem, keyPem);
|
|
124
|
+
}
|
|
125
|
+
/** Sign and atomically install a server leaf for the interface's current address. */
|
|
126
|
+
async function refreshManagedServerCertificate(setup, address) {
|
|
127
|
+
const [caCert, caKey] = await Promise.all([readFile(setup.tls.caCertFile, "utf8"), readFile(setup.tls.caKeyFile, "utf8")]);
|
|
128
|
+
assertMatchingCa(caCert, caKey);
|
|
129
|
+
const now = /* @__PURE__ */ new Date();
|
|
130
|
+
const notAfter = new Date(now);
|
|
131
|
+
notAfter.setDate(notAfter.getDate() + 397);
|
|
132
|
+
const server = await generate([{
|
|
133
|
+
name: "commonName",
|
|
134
|
+
value: "DeepSeek Harness Mobile"
|
|
135
|
+
}], {
|
|
136
|
+
keyType: "ec",
|
|
137
|
+
curve: "P-256",
|
|
138
|
+
algorithm: "sha256",
|
|
139
|
+
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
140
|
+
notAfterDate: notAfter,
|
|
141
|
+
ca: {
|
|
142
|
+
cert: caCert,
|
|
143
|
+
key: caKey
|
|
144
|
+
},
|
|
145
|
+
extensions: [
|
|
146
|
+
{
|
|
147
|
+
name: "basicConstraints",
|
|
148
|
+
cA: false,
|
|
149
|
+
critical: true
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: "keyUsage",
|
|
153
|
+
digitalSignature: true,
|
|
154
|
+
critical: true
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "extKeyUsage",
|
|
158
|
+
serverAuth: true
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "subjectAltName",
|
|
162
|
+
altNames: [{
|
|
163
|
+
type: 7,
|
|
164
|
+
ip: address
|
|
165
|
+
}]
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
});
|
|
169
|
+
await Promise.all([atomicWrite(setup.tls.certFile, server.cert), atomicWrite(setup.tls.keyFile, server.private)]);
|
|
28
170
|
}
|
|
171
|
+
//#endregion
|
|
172
|
+
//#region src/cli.ts
|
|
29
173
|
function parseOptions(args) {
|
|
30
174
|
let address;
|
|
31
175
|
let port = 3443;
|
|
@@ -52,30 +196,18 @@ function parseOptions(args) {
|
|
|
52
196
|
}
|
|
53
197
|
if (!Number.isSafeInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be from 1024 through 65535");
|
|
54
198
|
if (!Number.isSafeInteger(dshPort) || dshPort < 1024 || dshPort > 65535) throw new Error("--dsh-port must be from 1024 through 65535");
|
|
55
|
-
if (address !== void 0 && !privateIpv4(address)) throw new Error("--address must be a private IPv4 address");
|
|
56
199
|
return {
|
|
57
200
|
...address === void 0 ? {} : { address },
|
|
58
201
|
port,
|
|
59
202
|
dshPort
|
|
60
203
|
};
|
|
61
204
|
}
|
|
62
|
-
function selectNetwork(requested) {
|
|
63
|
-
const candidates = availableAddresses();
|
|
64
|
-
if (requested !== void 0) {
|
|
65
|
-
const match = candidates.find((candidate) => candidate.address === requested);
|
|
66
|
-
if (match === void 0) throw new Error(`--address ${requested} is not an active private LAN address`);
|
|
67
|
-
return match;
|
|
68
|
-
}
|
|
69
|
-
if (candidates.length === 1) return candidates[0];
|
|
70
|
-
if (candidates.length === 0) throw new Error("no active private LAN address was found; connect to Wi-Fi or Ethernet");
|
|
71
|
-
throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => entry.address).join(", ")}`);
|
|
72
|
-
}
|
|
73
205
|
function dshHome() {
|
|
74
206
|
return resolve(process.env.DSH_HOME ?? join(homedir(), ".dsh"));
|
|
75
207
|
}
|
|
76
208
|
async function setup(args) {
|
|
77
209
|
const options = parseOptions(args);
|
|
78
|
-
const network =
|
|
210
|
+
const network = selectLanNetwork(options.address);
|
|
79
211
|
const home = dshHome();
|
|
80
212
|
const directory = join(home, "mobile-access");
|
|
81
213
|
const tls = join(directory, "tls");
|
|
@@ -83,53 +215,36 @@ async function setup(args) {
|
|
|
83
215
|
recursive: true,
|
|
84
216
|
mode: 448
|
|
85
217
|
});
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}], {
|
|
93
|
-
keyType: "ec",
|
|
94
|
-
curve: "P-256",
|
|
95
|
-
algorithm: "sha256",
|
|
96
|
-
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
97
|
-
notAfterDate: notAfter,
|
|
98
|
-
extensions: [
|
|
99
|
-
{
|
|
100
|
-
name: "basicConstraints",
|
|
101
|
-
cA: true,
|
|
102
|
-
critical: true
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
name: "keyUsage",
|
|
106
|
-
digitalSignature: true,
|
|
107
|
-
keyCertSign: true,
|
|
108
|
-
cRLSign: true,
|
|
109
|
-
critical: true
|
|
110
|
-
},
|
|
111
|
-
{
|
|
112
|
-
name: "extKeyUsage",
|
|
113
|
-
serverAuth: true
|
|
114
|
-
},
|
|
115
|
-
{
|
|
116
|
-
name: "subjectAltName",
|
|
117
|
-
altNames: [{
|
|
118
|
-
type: 7,
|
|
119
|
-
ip: network.address
|
|
120
|
-
}]
|
|
121
|
-
}
|
|
122
|
-
]
|
|
123
|
-
});
|
|
124
|
-
const certFile = join(tls, "cert.pem");
|
|
125
|
-
const keyFile = join(tls, "key.pem");
|
|
218
|
+
const legacyCertFile = join(tls, "cert.pem");
|
|
219
|
+
const legacyKeyFile = join(tls, "key.pem");
|
|
220
|
+
const certFile = join(tls, "server-cert.pem");
|
|
221
|
+
const keyFile = join(tls, "server-key.pem");
|
|
222
|
+
const caCertFile = join(tls, "ca.pem");
|
|
223
|
+
const caKeyFile = join(tls, "ca-key.pem");
|
|
126
224
|
const androidCertificate = join(tls, "dsh-mobile-ca.cer");
|
|
225
|
+
const managedTls = {
|
|
226
|
+
mode: "managed",
|
|
227
|
+
caCertFile,
|
|
228
|
+
caKeyFile,
|
|
229
|
+
certFile,
|
|
230
|
+
keyFile
|
|
231
|
+
};
|
|
232
|
+
const ca = await ensureManagedCa(managedTls, {
|
|
233
|
+
certFile: legacyCertFile,
|
|
234
|
+
keyFile: legacyKeyFile
|
|
235
|
+
});
|
|
236
|
+
const managedSetup = {
|
|
237
|
+
version: 2,
|
|
238
|
+
networkInterface: network.name,
|
|
239
|
+
listenPort: options.port,
|
|
240
|
+
upstreamOrigin: `http://127.0.0.1:${String(options.dshPort)}`,
|
|
241
|
+
tls: managedTls
|
|
242
|
+
};
|
|
243
|
+
await refreshManagedServerCertificate(managedSetup, network.address);
|
|
244
|
+
await writeFile(androidCertificate, ca.raw, { mode: 384 });
|
|
127
245
|
await Promise.all([
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
writeFile(androidCertificate, new X509Certificate(certificate.cert).raw, { mode: 384 })
|
|
131
|
-
]);
|
|
132
|
-
await Promise.all([
|
|
246
|
+
chmod(caCertFile, 384),
|
|
247
|
+
chmod(caKeyFile, 384),
|
|
133
248
|
chmod(certFile, 384),
|
|
134
249
|
chmod(keyFile, 384),
|
|
135
250
|
chmod(androidCertificate, 384)
|
|
@@ -140,7 +255,7 @@ async function setup(args) {
|
|
|
140
255
|
} catch (error) {
|
|
141
256
|
if (error.code !== "ENOENT") throw error;
|
|
142
257
|
await writeFile(customCss, [
|
|
143
|
-
"/* Safe mobile overrides.
|
|
258
|
+
"/* Safe mobile overrides. DSH Mobile applies saved changes on the phone automatically. */",
|
|
144
259
|
":root {",
|
|
145
260
|
" --dsh-mobile-accent: #2563eb;",
|
|
146
261
|
" --dsh-mobile-font-scale: 1;",
|
|
@@ -149,21 +264,28 @@ async function setup(args) {
|
|
|
149
264
|
""
|
|
150
265
|
].join("\n"), { mode: 384 });
|
|
151
266
|
}
|
|
267
|
+
const customScript = join(directory, "mobile.js");
|
|
268
|
+
try {
|
|
269
|
+
await readFile(customScript);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (error.code !== "ENOENT") throw error;
|
|
272
|
+
await writeFile(customScript, [
|
|
273
|
+
"/* Mount mobile-only Web features here. Saved changes are applied automatically. */",
|
|
274
|
+
"window.dshMobile.register(({ root }) => {",
|
|
275
|
+
" root.replaceChildren()",
|
|
276
|
+
" return () => root.replaceChildren()",
|
|
277
|
+
"})",
|
|
278
|
+
""
|
|
279
|
+
].join("\n"), { mode: 384 });
|
|
280
|
+
}
|
|
152
281
|
const origin = `https://${network.address}:${String(options.port)}`;
|
|
153
282
|
await Promise.all([writeFile(join(directory, "setup.json"), `${JSON.stringify({
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
listenHost: network.address,
|
|
157
|
-
upstreamOrigin: `http://127.0.0.1:${String(options.dshPort)}`,
|
|
158
|
-
allowedCidrs: [network.cidr],
|
|
159
|
-
tls: {
|
|
160
|
-
mode: "provided",
|
|
161
|
-
certFile: certFile.replaceAll("\\", "/"),
|
|
162
|
-
keyFile: keyFile.replaceAll("\\", "/")
|
|
163
|
-
}
|
|
283
|
+
...managedSetup,
|
|
284
|
+
tls: Object.fromEntries(Object.entries(managedSetup.tls).map(([key, value]) => [key, typeof value === "string" ? value.replaceAll("\\", "/") : value]))
|
|
164
285
|
}, null, 2)}\n`, { mode: 384 }), writeFile(join(directory, "control.json"), "{\"version\":1,\"enabled\":true}\n", { mode: 384 })]);
|
|
165
|
-
console.log(`DSH Mobile is configured for ${origin}`);
|
|
166
|
-
console.log(`Install this certificate on Android
|
|
286
|
+
console.log(`DSH Mobile follows ${network.name} and is currently configured for ${origin}`);
|
|
287
|
+
console.log(`Install this CA certificate on Android once: ${androidCertificate}`);
|
|
288
|
+
console.log(`Ask DSH to customize the mobile Web UI and features in: ${customCss} and ${customScript}`);
|
|
167
289
|
console.log("Start DSH with: dsh --profile web");
|
|
168
290
|
console.log("Then open the Mobile card in the lower-left corner and create a pairing link.");
|
|
169
291
|
}
|
|
@@ -174,7 +296,7 @@ async function purge(args) {
|
|
|
174
296
|
recursive: true,
|
|
175
297
|
force: true
|
|
176
298
|
});
|
|
177
|
-
console.log("Removed DSH Mobile certificates, devices, preferences, and custom
|
|
299
|
+
console.log("Removed DSH Mobile certificates, devices, preferences, and custom Web files.");
|
|
178
300
|
}
|
|
179
301
|
function help() {
|
|
180
302
|
console.log([
|
package/lib/client.js
CHANGED
|
@@ -92,9 +92,38 @@ window.__ModuleLoader__.load({
|
|
|
92
92
|
}
|
|
93
93
|
});
|
|
94
94
|
const body = await response.json();
|
|
95
|
-
if (!response.ok)
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
const code = typeof body.error === "string" ? body.error : `HTTP ${String(response.status)}`;
|
|
97
|
+
throw new Error(controlErrorMessage(code));
|
|
98
|
+
}
|
|
96
99
|
return body;
|
|
97
100
|
}
|
|
101
|
+
function controlErrorMessage(code) {
|
|
102
|
+
const zh = navigator.language.toLowerCase().startsWith("zh");
|
|
103
|
+
const message = {
|
|
104
|
+
network_address_changed: ["局域网地址已变化,请重启 DSH 后重试。", "The LAN address changed. Restart DSH and try again."],
|
|
105
|
+
network_interface_unavailable: ["保存的局域网网卡未连接,请连接网络后重启 DSH。", "The saved LAN interface is disconnected. Connect it and restart DSH."],
|
|
106
|
+
listen_port_in_use: ["移动访问端口已被占用。", "The mobile-access port is already in use."]
|
|
107
|
+
}[code];
|
|
108
|
+
return message === void 0 ? code : message[zh ? 0 : 1];
|
|
109
|
+
}
|
|
110
|
+
function mobileExtensionRequest(path, init = {}) {
|
|
111
|
+
const target = new URL(path, location.href);
|
|
112
|
+
if (target.origin !== location.origin) throw new TypeError("mobile extension requests must stay on the DSH origin");
|
|
113
|
+
const headers = new Headers(init.headers);
|
|
114
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
115
|
+
if (method !== "GET" && method !== "HEAD" && !headers.has("x-dsh-mobile-csrf")) {
|
|
116
|
+
const csrf = document.cookie.split(";").map((entry) => entry.trim()).find((entry) => entry.startsWith("dsh_ma_csrf="))?.slice(12);
|
|
117
|
+
if (csrf !== void 0) headers.set("x-dsh-mobile-csrf", csrf);
|
|
118
|
+
}
|
|
119
|
+
return fetch(target, {
|
|
120
|
+
...init,
|
|
121
|
+
cache: "no-store",
|
|
122
|
+
credentials: "same-origin",
|
|
123
|
+
headers,
|
|
124
|
+
redirect: "error"
|
|
125
|
+
});
|
|
126
|
+
}
|
|
98
127
|
function installControl() {
|
|
99
128
|
const zh = navigator.language.toLowerCase().startsWith("zh");
|
|
100
129
|
const root = element("div", "dsh-mobile-control");
|
|
@@ -164,6 +193,74 @@ window.__ModuleLoader__.load({
|
|
|
164
193
|
}
|
|
165
194
|
};
|
|
166
195
|
}
|
|
196
|
+
function installCustomExtension() {
|
|
197
|
+
let activeRoot;
|
|
198
|
+
let activeDispose;
|
|
199
|
+
let observedSource = "";
|
|
200
|
+
let loading = false;
|
|
201
|
+
let pendingMount;
|
|
202
|
+
const previousRegistry = window.dshMobile;
|
|
203
|
+
const registry = Object.freeze({ register: (mount) => {
|
|
204
|
+
if (typeof mount !== "function") throw new TypeError("dshMobile.register requires a mount function");
|
|
205
|
+
pendingMount = mount;
|
|
206
|
+
} });
|
|
207
|
+
const registeredMount = () => pendingMount;
|
|
208
|
+
window.dshMobile = registry;
|
|
209
|
+
const refresh = async () => {
|
|
210
|
+
if (loading || document.visibilityState === "hidden") return;
|
|
211
|
+
loading = true;
|
|
212
|
+
try {
|
|
213
|
+
const response = await fetch("/mobile-access/custom.js", {
|
|
214
|
+
cache: "no-store",
|
|
215
|
+
credentials: "same-origin",
|
|
216
|
+
redirect: "error"
|
|
217
|
+
});
|
|
218
|
+
if (!response.ok || !response.headers.get("content-type")?.startsWith("text/javascript")) return;
|
|
219
|
+
const source = await response.text();
|
|
220
|
+
if (source === observedSource) return;
|
|
221
|
+
observedSource = source;
|
|
222
|
+
pendingMount = void 0;
|
|
223
|
+
const script = document.createElement("script");
|
|
224
|
+
script.textContent = `${source}\n//# sourceURL=dsh-mobile-custom.js`;
|
|
225
|
+
document.head.append(script);
|
|
226
|
+
script.remove();
|
|
227
|
+
const mount = registeredMount();
|
|
228
|
+
if (mount === void 0) return;
|
|
229
|
+
const nextRoot = document.createElement("div");
|
|
230
|
+
nextRoot.dataset.dshMobileExtension = "true";
|
|
231
|
+
document.body.append(nextRoot);
|
|
232
|
+
try {
|
|
233
|
+
const nextDispose = mount(Object.freeze({
|
|
234
|
+
document,
|
|
235
|
+
request: mobileExtensionRequest,
|
|
236
|
+
root: nextRoot,
|
|
237
|
+
window
|
|
238
|
+
}));
|
|
239
|
+
if (nextDispose !== void 0 && typeof nextDispose !== "function") throw new TypeError("the mobile extension mount function must return a disposer or undefined");
|
|
240
|
+
activeDispose?.();
|
|
241
|
+
activeRoot?.remove();
|
|
242
|
+
activeRoot = nextRoot;
|
|
243
|
+
activeDispose = typeof nextDispose === "function" ? nextDispose : void 0;
|
|
244
|
+
} catch (error) {
|
|
245
|
+
nextRoot.remove();
|
|
246
|
+
console.error("DSH Mobile custom extension failed to mount", error);
|
|
247
|
+
}
|
|
248
|
+
} catch {} finally {
|
|
249
|
+
loading = false;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
refresh();
|
|
253
|
+
const timer = setInterval(() => {
|
|
254
|
+
refresh();
|
|
255
|
+
}, 1e3);
|
|
256
|
+
return () => {
|
|
257
|
+
clearInterval(timer);
|
|
258
|
+
activeDispose?.();
|
|
259
|
+
activeRoot?.remove();
|
|
260
|
+
if (previousRegistry === void 0) delete window.dshMobile;
|
|
261
|
+
else window.dshMobile = previousRegistry;
|
|
262
|
+
};
|
|
263
|
+
}
|
|
167
264
|
/** Install the responsive mobile layer and the loopback-only control card. */
|
|
168
265
|
function apply(ctx) {
|
|
169
266
|
ctx.effect(() => {
|
|
@@ -172,13 +269,34 @@ window.__ModuleLoader__.load({
|
|
|
172
269
|
style.textContent = BASE_STYLES;
|
|
173
270
|
document.head.append(style);
|
|
174
271
|
const loopback = isLoopbackHost(location.hostname);
|
|
175
|
-
const custom = loopback ? void 0 : document.createElement("
|
|
272
|
+
const custom = loopback ? void 0 : document.createElement("style");
|
|
273
|
+
let customRefresh;
|
|
274
|
+
let customLoading = false;
|
|
176
275
|
if (custom !== void 0) {
|
|
177
|
-
custom.rel = "stylesheet";
|
|
178
|
-
custom.href = "/mobile-access/custom.css";
|
|
179
276
|
custom.dataset.plugin = "dsh-mobile";
|
|
180
277
|
document.head.append(custom);
|
|
278
|
+
const refreshCustomCss = async () => {
|
|
279
|
+
if (customLoading || document.visibilityState === "hidden") return;
|
|
280
|
+
customLoading = true;
|
|
281
|
+
try {
|
|
282
|
+
const response = await fetch("/mobile-access/custom.css", {
|
|
283
|
+
cache: "no-store",
|
|
284
|
+
credentials: "same-origin",
|
|
285
|
+
redirect: "error"
|
|
286
|
+
});
|
|
287
|
+
if (!response.ok || !response.headers.get("content-type")?.startsWith("text/css")) return;
|
|
288
|
+
const css = await response.text();
|
|
289
|
+
if (custom.textContent !== css) custom.textContent = css;
|
|
290
|
+
} catch {} finally {
|
|
291
|
+
customLoading = false;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
refreshCustomCss();
|
|
295
|
+
customRefresh = setInterval(() => {
|
|
296
|
+
refreshCustomCss();
|
|
297
|
+
}, 1e3);
|
|
181
298
|
}
|
|
299
|
+
const removeCustomExtension = loopback ? void 0 : installCustomExtension();
|
|
182
300
|
const control = loopback ? installControl() : void 0;
|
|
183
301
|
const disposeSlot = control === void 0 ? void 0 : ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
|
|
184
302
|
name: "sidebar.footer.action",
|
|
@@ -190,8 +308,10 @@ window.__ModuleLoader__.load({
|
|
|
190
308
|
onClick: control.toggle
|
|
191
309
|
}, wide ? controlLabel() : "M")));
|
|
192
310
|
return () => {
|
|
311
|
+
if (customRefresh !== void 0) clearInterval(customRefresh);
|
|
193
312
|
disposeSlot?.();
|
|
194
313
|
control?.remove();
|
|
314
|
+
removeCustomExtension?.();
|
|
195
315
|
custom?.remove();
|
|
196
316
|
style.remove();
|
|
197
317
|
};
|
package/lib/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","names":["createElement"],"sources":["../src/client.ts"],"sourcesContent":["import { createElement } from 'react'\n\ninterface ClientContext {\n effect(effect: () => void | (() => void), label?: string): void\n slots: {\n inject(key: string, callback: () => (() => void)): () => void\n register(options: { name: string; id: string }, component: (props: { wide: boolean }) => unknown): () => void\n }\n}\n\nconst MOBILE_QUERY = '(max-width: 720px)'\n\nconst BASE_STYLES = `\n@media ${MOBILE_QUERY} {\n :root {\n --dsh-mobile-accent: #2563eb;\n --dsh-mobile-font-scale: 1;\n --dsh-mobile-radius: 14px;\n }\n html { font-size: calc(16px * var(--dsh-mobile-font-scale)); }\n body { overscroll-behavior: none; }\n button, [role='button'], input, textarea, select { min-height: 44px; }\n input, textarea, select { font-size: 16px !important; }\n [data-sidebar-collapsed] {\n padding-top: env(safe-area-inset-top);\n padding-right: env(safe-area-inset-right);\n padding-bottom: env(safe-area-inset-bottom);\n padding-left: env(safe-area-inset-left);\n }\n [data-shell-overlay] { inset: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }\n * { -webkit-tap-highlight-color: transparent; }\n}\n@media (prefers-reduced-motion: reduce) {\n *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }\n}\n.dsh-mobile-control {\n position: fixed;\n left: 68px;\n bottom: 12px;\n z-index: 90;\n color: #172554;\n font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n}\n.dsh-mobile-control button {\n min-width: 44px;\n min-height: 44px;\n border: 1px solid #bfdbfe;\n border-radius: 12px;\n background: #fff;\n color: #1e3a8a;\n cursor: pointer;\n}\n.dsh-mobile-control__trigger {\n width: 100%;\n min-width: 44px;\n min-height: 44px;\n padding: 0 12px;\n border: 0;\n border-radius: 10px;\n background: transparent;\n color: inherit;\n font: inherit;\n font-weight: 700;\n cursor: pointer;\n}\n.dsh-mobile-control__trigger:hover { background: rgb(37 99 235 / 8%); }\n.dsh-mobile-control__panel {\n width: min(320px, calc(100vw - 24px));\n margin-bottom: 8px;\n padding: 16px;\n border: 1px solid #dbeafe;\n border-radius: 16px;\n background: #fff;\n box-shadow: 0 10px 30px rgb(15 23 42 / 12%);\n}\n.dsh-mobile-control__title { margin: 0 0 4px; font-size: 16px; font-weight: 800; }\n.dsh-mobile-control__status { margin: 0 0 14px; color: #475569; overflow-wrap: anywhere; }\n.dsh-mobile-control__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }\n.dsh-mobile-control__actions button[data-primary='true'] { background: #2563eb; border-color: #2563eb; color: #fff; }\n.dsh-mobile-control[hidden], .dsh-mobile-control__panel[hidden] { display: none; }\n`\n\nfunction isLoopbackHost(hostname: string): boolean {\n return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1'\n}\n\nfunction element<K extends keyof HTMLElementTagNameMap>(name: K, className?: string): HTMLElementTagNameMap[K] {\n const node = document.createElement(name)\n if (className !== undefined) node.className = className\n return node\n}\n\nasync function requestJson(path: string, init?: RequestInit): Promise<Record<string, unknown>> {\n const response = await fetch(path, {\n ...init,\n headers: { 'content-type': 'application/json', ...init?.headers },\n })\n const body = await response.json() as Record<string, unknown>\n if (!response.ok) throw new Error(typeof body.error === 'string' ? body.error : `HTTP ${String(response.status)}`)\n return body\n}\n\nfunction installControl(): { remove: () => void; toggle: () => void } {\n const zh = navigator.language.toLowerCase().startsWith('zh')\n const root = element('div', 'dsh-mobile-control')\n const panel = element('section', 'dsh-mobile-control__panel')\n panel.hidden = true\n const title = element('h2', 'dsh-mobile-control__title')\n title.textContent = zh ? '移动访问' : 'Mobile access'\n const status = element('p', 'dsh-mobile-control__status')\n status.textContent = zh ? '正在读取状态…' : 'Reading status…'\n const actions = element('div', 'dsh-mobile-control__actions')\n const toggle = element('button')\n toggle.dataset.primary = 'true'\n const pair = element('button')\n pair.textContent = zh ? '生成配对链接' : 'Create pairing link'\n actions.append(toggle, pair)\n panel.append(title, status, actions)\n root.append(panel)\n document.body.append(root)\n\n let running = false\n const render = (data: Record<string, unknown>): void => {\n running = data.running === true\n const origin = typeof data.origin === 'string' ? data.origin : undefined\n status.textContent = running\n ? (zh ? `已开启:${origin ?? ''}` : `Running: ${origin ?? ''}`)\n : (zh ? '已关闭。DSH 仍只在本机可用。' : 'Stopped. DSH remains local-only.')\n toggle.textContent = running ? (zh ? '关闭' : 'Stop') : (zh ? '开启' : 'Start')\n pair.disabled = !running\n }\n const refresh = async (): Promise<void> => {\n try { render(await requestJson('/api/mobile-access/control')) }\n catch (error) { status.textContent = error instanceof Error ? error.message : String(error) }\n }\n toggle.addEventListener('click', () => {\n toggle.disabled = true\n void requestJson('/api/mobile-access/control', {\n method: 'POST',\n body: JSON.stringify({ running: !running }),\n }).then(render, (error: unknown) => {\n status.textContent = error instanceof Error ? error.message : String(error)\n }).finally(() => { toggle.disabled = false })\n })\n pair.addEventListener('click', () => {\n pair.disabled = true\n void requestJson('/api/mobile-access/pairing/open', {\n method: 'POST',\n body: '{}',\n }).then(async (data) => {\n const url = typeof data.pairUrl === 'string' ? data.pairUrl : ''\n if (url !== '' && navigator.clipboard !== undefined) await navigator.clipboard.writeText(url)\n status.textContent = url === '' ? (zh ? '无法生成链接' : 'Could not create link')\n : (zh ? `链接已复制:${url}` : `Copied: ${url}`)\n }, (error: unknown) => {\n status.textContent = error instanceof Error ? error.message : String(error)\n }).finally(() => { pair.disabled = !running })\n })\n void refresh()\n return {\n remove: () => { root.remove() },\n toggle: () => { panel.hidden = !panel.hidden },\n }\n}\n\n/** Install the responsive mobile layer and the loopback-only control card. */\nexport function apply(ctx: ClientContext): void {\n ctx.effect(() => {\n const style = document.createElement('style')\n style.dataset.plugin = 'dsh-mobile'\n style.textContent = BASE_STYLES\n document.head.append(style)\n\n const loopback = isLoopbackHost(location.hostname)\n const custom = loopback ? undefined : document.createElement('link')\n if (custom !== undefined) {\n custom.rel = 'stylesheet'\n custom.href = '/mobile-access/custom.css'\n custom.dataset.plugin = 'dsh-mobile'\n document.head.append(custom)\n }\n const control = loopback ? installControl() : undefined\n const disposeSlot = control === undefined ? undefined : ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({\n name: 'sidebar.footer.action',\n id: 'dsh-mobile',\n }, ({ wide }) => createElement('button', {\n className: 'dsh-mobile-control__trigger',\n type: 'button',\n title: controlLabel(),\n onClick: control.toggle,\n }, wide ? controlLabel() : 'M')))\n return () => {\n disposeSlot?.()\n control?.remove()\n custom?.remove()\n style.remove()\n }\n }, 'dsh-mobile: responsive UI and local control')\n}\n\n/** Client face has no service prerequisites. */\nexport const inject: readonly string[] = ['slots']\n\nfunction controlLabel(): string {\n return navigator.language.toLowerCase().startsWith('zh') ? '移动端' : 'Mobile access'\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"client.js","names":["createElement"],"sources":["../src/client.ts"],"sourcesContent":["import { createElement } from 'react'\n\ninterface ClientContext {\n effect(effect: () => void | (() => void), label?: string): void\n slots: {\n inject(key: string, callback: () => (() => void)): () => void\n register(options: { name: string; id: string }, component: (props: { wide: boolean }) => unknown): () => void\n }\n}\n\ninterface MobileExtensionContext {\n readonly document: Document\n readonly request: (path: string, init?: RequestInit) => Promise<Response>\n readonly root: HTMLElement\n readonly window: Window\n}\n\ntype MobileExtensionMount = (context: MobileExtensionContext) => void | (() => void)\n\ninterface MobileExtensionRegistry {\n register(mount: MobileExtensionMount): void\n}\n\ndeclare global {\n interface Window {\n dshMobile?: MobileExtensionRegistry\n }\n}\n\nconst MOBILE_QUERY = '(max-width: 720px)'\n\nconst BASE_STYLES = `\n@media ${MOBILE_QUERY} {\n :root {\n --dsh-mobile-accent: #2563eb;\n --dsh-mobile-font-scale: 1;\n --dsh-mobile-radius: 14px;\n }\n html { font-size: calc(16px * var(--dsh-mobile-font-scale)); }\n body { overscroll-behavior: none; }\n button, [role='button'], input, textarea, select { min-height: 44px; }\n input, textarea, select { font-size: 16px !important; }\n [data-sidebar-collapsed] {\n padding-top: env(safe-area-inset-top);\n padding-right: env(safe-area-inset-right);\n padding-bottom: env(safe-area-inset-bottom);\n padding-left: env(safe-area-inset-left);\n }\n [data-shell-overlay] { inset: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }\n * { -webkit-tap-highlight-color: transparent; }\n}\n@media (prefers-reduced-motion: reduce) {\n *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }\n}\n.dsh-mobile-control {\n position: fixed;\n left: 68px;\n bottom: 12px;\n z-index: 90;\n color: #172554;\n font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n}\n.dsh-mobile-control button {\n min-width: 44px;\n min-height: 44px;\n border: 1px solid #bfdbfe;\n border-radius: 12px;\n background: #fff;\n color: #1e3a8a;\n cursor: pointer;\n}\n.dsh-mobile-control__trigger {\n width: 100%;\n min-width: 44px;\n min-height: 44px;\n padding: 0 12px;\n border: 0;\n border-radius: 10px;\n background: transparent;\n color: inherit;\n font: inherit;\n font-weight: 700;\n cursor: pointer;\n}\n.dsh-mobile-control__trigger:hover { background: rgb(37 99 235 / 8%); }\n.dsh-mobile-control__panel {\n width: min(320px, calc(100vw - 24px));\n margin-bottom: 8px;\n padding: 16px;\n border: 1px solid #dbeafe;\n border-radius: 16px;\n background: #fff;\n box-shadow: 0 10px 30px rgb(15 23 42 / 12%);\n}\n.dsh-mobile-control__title { margin: 0 0 4px; font-size: 16px; font-weight: 800; }\n.dsh-mobile-control__status { margin: 0 0 14px; color: #475569; overflow-wrap: anywhere; }\n.dsh-mobile-control__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }\n.dsh-mobile-control__actions button[data-primary='true'] { background: #2563eb; border-color: #2563eb; color: #fff; }\n.dsh-mobile-control[hidden], .dsh-mobile-control__panel[hidden] { display: none; }\n`\n\nfunction isLoopbackHost(hostname: string): boolean {\n return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1'\n}\n\nfunction element<K extends keyof HTMLElementTagNameMap>(name: K, className?: string): HTMLElementTagNameMap[K] {\n const node = document.createElement(name)\n if (className !== undefined) node.className = className\n return node\n}\n\nasync function requestJson(path: string, init?: RequestInit): Promise<Record<string, unknown>> {\n const response = await fetch(path, {\n ...init,\n headers: { 'content-type': 'application/json', ...init?.headers },\n })\n const body = await response.json() as Record<string, unknown>\n if (!response.ok) {\n const code = typeof body.error === 'string' ? body.error : `HTTP ${String(response.status)}`\n throw new Error(controlErrorMessage(code))\n }\n return body\n}\n\nfunction controlErrorMessage(code: string): string {\n const zh = navigator.language.toLowerCase().startsWith('zh')\n const messages: Record<string, readonly [string, string]> = {\n network_address_changed: ['局域网地址已变化,请重启 DSH 后重试。', 'The LAN address changed. Restart DSH and try again.'],\n network_interface_unavailable: ['保存的局域网网卡未连接,请连接网络后重启 DSH。', 'The saved LAN interface is disconnected. Connect it and restart DSH.'],\n listen_port_in_use: ['移动访问端口已被占用。', 'The mobile-access port is already in use.'],\n }\n const message = messages[code]\n return message === undefined ? code : message[zh ? 0 : 1]\n}\n\nfunction mobileExtensionRequest(path: string, init: RequestInit = {}): Promise<Response> {\n const target = new URL(path, location.href)\n if (target.origin !== location.origin) throw new TypeError('mobile extension requests must stay on the DSH origin')\n const headers = new Headers(init.headers)\n const method = (init.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && !headers.has('x-dsh-mobile-csrf')) {\n const csrf = document.cookie.split(';').map(entry => entry.trim())\n .find(entry => entry.startsWith('dsh_ma_csrf='))?.slice('dsh_ma_csrf='.length)\n if (csrf !== undefined) headers.set('x-dsh-mobile-csrf', csrf)\n }\n return fetch(target, {\n ...init,\n cache: 'no-store',\n credentials: 'same-origin',\n headers,\n redirect: 'error',\n })\n}\n\nfunction installControl(): { remove: () => void; toggle: () => void } {\n const zh = navigator.language.toLowerCase().startsWith('zh')\n const root = element('div', 'dsh-mobile-control')\n const panel = element('section', 'dsh-mobile-control__panel')\n panel.hidden = true\n const title = element('h2', 'dsh-mobile-control__title')\n title.textContent = zh ? '移动访问' : 'Mobile access'\n const status = element('p', 'dsh-mobile-control__status')\n status.textContent = zh ? '正在读取状态…' : 'Reading status…'\n const actions = element('div', 'dsh-mobile-control__actions')\n const toggle = element('button')\n toggle.dataset.primary = 'true'\n const pair = element('button')\n pair.textContent = zh ? '生成配对链接' : 'Create pairing link'\n actions.append(toggle, pair)\n panel.append(title, status, actions)\n root.append(panel)\n document.body.append(root)\n\n let running = false\n const render = (data: Record<string, unknown>): void => {\n running = data.running === true\n const origin = typeof data.origin === 'string' ? data.origin : undefined\n status.textContent = running\n ? (zh ? `已开启:${origin ?? ''}` : `Running: ${origin ?? ''}`)\n : (zh ? '已关闭。DSH 仍只在本机可用。' : 'Stopped. DSH remains local-only.')\n toggle.textContent = running ? (zh ? '关闭' : 'Stop') : (zh ? '开启' : 'Start')\n pair.disabled = !running\n }\n const refresh = async (): Promise<void> => {\n try { render(await requestJson('/api/mobile-access/control')) }\n catch (error) { status.textContent = error instanceof Error ? error.message : String(error) }\n }\n toggle.addEventListener('click', () => {\n toggle.disabled = true\n void requestJson('/api/mobile-access/control', {\n method: 'POST',\n body: JSON.stringify({ running: !running }),\n }).then(render, (error: unknown) => {\n status.textContent = error instanceof Error ? error.message : String(error)\n }).finally(() => { toggle.disabled = false })\n })\n pair.addEventListener('click', () => {\n pair.disabled = true\n void requestJson('/api/mobile-access/pairing/open', {\n method: 'POST',\n body: '{}',\n }).then(async (data) => {\n const url = typeof data.pairUrl === 'string' ? data.pairUrl : ''\n if (url !== '' && navigator.clipboard !== undefined) await navigator.clipboard.writeText(url)\n status.textContent = url === '' ? (zh ? '无法生成链接' : 'Could not create link')\n : (zh ? `链接已复制:${url}` : `Copied: ${url}`)\n }, (error: unknown) => {\n status.textContent = error instanceof Error ? error.message : String(error)\n }).finally(() => { pair.disabled = !running })\n })\n void refresh()\n return {\n remove: () => { root.remove() },\n toggle: () => { panel.hidden = !panel.hidden },\n }\n}\n\nfunction installCustomExtension(): () => void {\n let activeRoot: HTMLElement | undefined\n let activeDispose: (() => void) | undefined\n let observedSource = ''\n let loading = false\n let pendingMount: MobileExtensionMount | undefined\n const previousRegistry = window.dshMobile\n const registry: MobileExtensionRegistry = Object.freeze({\n register: (mount: MobileExtensionMount): void => {\n if (typeof mount !== 'function') throw new TypeError('dshMobile.register requires a mount function')\n pendingMount = mount\n },\n })\n const registeredMount = (): MobileExtensionMount | undefined => pendingMount\n window.dshMobile = registry\n\n const refresh = async (): Promise<void> => {\n if (loading || document.visibilityState === 'hidden') return\n loading = true\n try {\n const response = await fetch('/mobile-access/custom.js', {\n cache: 'no-store',\n credentials: 'same-origin',\n redirect: 'error',\n })\n if (!response.ok || !response.headers.get('content-type')?.startsWith('text/javascript')) return\n const source = await response.text()\n if (source === observedSource) return\n observedSource = source\n pendingMount = undefined\n const script = document.createElement('script')\n script.textContent = `${source}\\n//# sourceURL=dsh-mobile-custom.js`\n document.head.append(script)\n script.remove()\n const mount = registeredMount()\n if (mount === undefined) return\n\n const nextRoot = document.createElement('div')\n nextRoot.dataset.dshMobileExtension = 'true'\n document.body.append(nextRoot)\n try {\n const nextDispose = mount(Object.freeze({\n document,\n request: mobileExtensionRequest,\n root: nextRoot,\n window,\n }))\n if (nextDispose !== undefined && typeof nextDispose !== 'function') {\n throw new TypeError('the mobile extension mount function must return a disposer or undefined')\n }\n activeDispose?.()\n activeRoot?.remove()\n activeRoot = nextRoot\n activeDispose = typeof nextDispose === 'function' ? nextDispose : undefined\n } catch (error) {\n nextRoot.remove()\n console.error('DSH Mobile custom extension failed to mount', error)\n }\n } catch {\n // A transient LAN disconnect keeps the last successfully mounted extension.\n } finally {\n loading = false\n }\n }\n void refresh()\n const timer = setInterval(() => { void refresh() }, 1_000)\n return () => {\n clearInterval(timer)\n activeDispose?.()\n activeRoot?.remove()\n if (previousRegistry === undefined) delete window.dshMobile\n else window.dshMobile = previousRegistry\n }\n}\n\n/** Install the responsive mobile layer and the loopback-only control card. */\nexport function apply(ctx: ClientContext): void {\n ctx.effect(() => {\n const style = document.createElement('style')\n style.dataset.plugin = 'dsh-mobile'\n style.textContent = BASE_STYLES\n document.head.append(style)\n\n const loopback = isLoopbackHost(location.hostname)\n const custom = loopback ? undefined : document.createElement('style')\n let customRefresh: ReturnType<typeof setInterval> | undefined\n let customLoading = false\n if (custom !== undefined) {\n custom.dataset.plugin = 'dsh-mobile'\n document.head.append(custom)\n const refreshCustomCss = async (): Promise<void> => {\n if (customLoading || document.visibilityState === 'hidden') return\n customLoading = true\n try {\n const response = await fetch('/mobile-access/custom.css', {\n cache: 'no-store',\n credentials: 'same-origin',\n redirect: 'error',\n })\n if (!response.ok || !response.headers.get('content-type')?.startsWith('text/css')) return\n const css = await response.text()\n if (custom.textContent !== css) custom.textContent = css\n } catch {\n // A transient LAN disconnect keeps the last successfully applied stylesheet.\n } finally {\n customLoading = false\n }\n }\n void refreshCustomCss()\n customRefresh = setInterval(() => { void refreshCustomCss() }, 1_000)\n }\n const removeCustomExtension = loopback ? undefined : installCustomExtension()\n const control = loopback ? installControl() : undefined\n const disposeSlot = control === undefined ? undefined : ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({\n name: 'sidebar.footer.action',\n id: 'dsh-mobile',\n }, ({ wide }) => createElement('button', {\n className: 'dsh-mobile-control__trigger',\n type: 'button',\n title: controlLabel(),\n onClick: control.toggle,\n }, wide ? controlLabel() : 'M')))\n return () => {\n if (customRefresh !== undefined) clearInterval(customRefresh)\n disposeSlot?.()\n control?.remove()\n removeCustomExtension?.()\n custom?.remove()\n style.remove()\n }\n }, 'dsh-mobile: responsive UI and local control')\n}\n\n/** Client face has no service prerequisites. */\nexport const inject: readonly string[] = ['slots']\n\nfunction controlLabel(): string {\n return navigator.language.toLowerCase().startsWith('zh') ? '移动端' : 'Mobile access'\n}\n"],"mappings":"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"}
|
package/lib/index.d.mts
CHANGED
|
@@ -235,8 +235,10 @@ interface PluginConfig {
|
|
|
235
235
|
stateFile: string;
|
|
236
236
|
/** Internal persisted on/off preference managed by the DSH plugin card. */
|
|
237
237
|
controlFile: string;
|
|
238
|
-
/** Optional user stylesheet served to
|
|
238
|
+
/** Optional user stylesheet served to the authenticated mobile UI. */
|
|
239
239
|
customCssFile?: string;
|
|
240
|
+
/** Optional user script that mounts authenticated mobile-only Web features. */
|
|
241
|
+
customScriptFile?: string;
|
|
240
242
|
/** First-run state used only while the control file does not exist. */
|
|
241
243
|
initiallyEnabled: boolean;
|
|
242
244
|
tls?: {
|
|
@@ -268,6 +270,7 @@ interface ResolvedGatewayConfig {
|
|
|
268
270
|
readonly allowedCidrs: readonly ParsedCidr[];
|
|
269
271
|
readonly stateFile: string;
|
|
270
272
|
readonly customCssFile: string;
|
|
273
|
+
readonly customScriptFile: string;
|
|
271
274
|
readonly tls: TlsConfig;
|
|
272
275
|
readonly pairingTtlMs: number;
|
|
273
276
|
readonly deviceTtlMs: number;
|
package/lib/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { X509Certificate, createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
1
|
+
import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
import z from "@deepseek-ai/schemastery";
|
|
4
4
|
import { connect, isIP } from "node:net";
|
|
@@ -7,6 +7,8 @@ import { createServer, request } from "node:http";
|
|
|
7
7
|
import { createServer as createServer$1 } from "node:https";
|
|
8
8
|
import { Transform } from "node:stream";
|
|
9
9
|
import { pipeline } from "node:stream/promises";
|
|
10
|
+
import { networkInterfaces } from "node:os";
|
|
11
|
+
import { generate } from "selfsigned";
|
|
10
12
|
//#region src/access.ts
|
|
11
13
|
/** Stable error categories converted to deliberately terse HTTP responses. */
|
|
12
14
|
var AccessError = class extends Error {
|
|
@@ -540,6 +542,7 @@ const Config = z.object({
|
|
|
540
542
|
stateFile: String,
|
|
541
543
|
controlFile: z.string().hidden().required(),
|
|
542
544
|
customCssFile: z.string().hidden(),
|
|
545
|
+
customScriptFile: z.string().hidden(),
|
|
543
546
|
initiallyEnabled: z.boolean().hidden().required(),
|
|
544
547
|
tls: z.object({
|
|
545
548
|
mode: z.union([z.const("provided"), z.const("disabled")]),
|
|
@@ -660,6 +663,7 @@ function parseGatewayConfig(raw) {
|
|
|
660
663
|
allowedCidrs: Object.freeze(allowedCidrs),
|
|
661
664
|
stateFile: absoluteFile(value.stateFile, "stateFile"),
|
|
662
665
|
customCssFile: value.customCssFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.css") : absoluteFile(value.customCssFile, "customCssFile"),
|
|
666
|
+
customScriptFile: value.customScriptFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.js") : absoluteFile(value.customScriptFile, "customScriptFile"),
|
|
663
667
|
tls,
|
|
664
668
|
pairingTtlMs: integer(value.pairingTtlMs, "pairingTtlMs", 12e4, 1e4, 6e5),
|
|
665
669
|
deviceTtlMs,
|
|
@@ -1526,8 +1530,16 @@ var MobileAccessGateway = class {
|
|
|
1526
1530
|
await this.handleLogout(request, response);
|
|
1527
1531
|
return;
|
|
1528
1532
|
}
|
|
1529
|
-
const
|
|
1530
|
-
|
|
1533
|
+
const customAsset = request.method === "GET" ? target.decodedPathname === `/mobile-access/custom.css` ? {
|
|
1534
|
+
file: this.config.customCssFile,
|
|
1535
|
+
contentType: "text/css; charset=utf-8",
|
|
1536
|
+
fallback: "/* Add mobile overrides in the DSH home mobile-access/mobile.css file. */\n"
|
|
1537
|
+
} : target.decodedPathname === `/mobile-access/custom.js` ? {
|
|
1538
|
+
file: this.config.customScriptFile,
|
|
1539
|
+
contentType: "text/javascript; charset=utf-8",
|
|
1540
|
+
fallback: "window.dshMobile?.register(() => undefined)\n"
|
|
1541
|
+
} : void 0 : void 0;
|
|
1542
|
+
if (customAsset === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
|
|
1531
1543
|
if (request.method !== "GET" && request.method !== "HEAD" && request.method !== "POST") throw new HttpError(405, "method_not_allowed");
|
|
1532
1544
|
if (request.method === "POST" && target.decodedPathname !== "/api" && !target.decodedPathname.startsWith("/api/")) throw new HttpError(405, "method_not_allowed");
|
|
1533
1545
|
let authorization;
|
|
@@ -1549,19 +1561,20 @@ var MobileAccessGateway = class {
|
|
|
1549
1561
|
}
|
|
1550
1562
|
throw error;
|
|
1551
1563
|
}
|
|
1552
|
-
if (
|
|
1564
|
+
if (customAsset !== void 0) {
|
|
1553
1565
|
let body;
|
|
1554
1566
|
try {
|
|
1555
|
-
body = await readFile(
|
|
1567
|
+
body = await readFile(customAsset.file);
|
|
1556
1568
|
} catch (error) {
|
|
1557
1569
|
if (error.code !== "ENOENT") throw error;
|
|
1558
|
-
body = Buffer.from(
|
|
1570
|
+
body = Buffer.from(customAsset.fallback);
|
|
1559
1571
|
}
|
|
1560
1572
|
if (body.byteLength > 262144) throw new HttpError(413, "payload_too_large");
|
|
1561
1573
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
1562
1574
|
response.writeHead(200, {
|
|
1563
|
-
"Content-Type":
|
|
1564
|
-
"Content-Length": body.byteLength
|
|
1575
|
+
"Content-Type": customAsset.contentType,
|
|
1576
|
+
"Content-Length": body.byteLength,
|
|
1577
|
+
"Cache-Control": "no-store"
|
|
1565
1578
|
});
|
|
1566
1579
|
response.end(body);
|
|
1567
1580
|
return;
|
|
@@ -2005,13 +2018,188 @@ var MemoryDeviceStore = class {
|
|
|
2005
2018
|
}
|
|
2006
2019
|
};
|
|
2007
2020
|
//#endregion
|
|
2021
|
+
//#region src/managed-setup.ts
|
|
2022
|
+
function requiredString(value, name) {
|
|
2023
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
|
|
2024
|
+
return value;
|
|
2025
|
+
}
|
|
2026
|
+
/** Validate the durable managed setup before it controls network and filesystem operations. */
|
|
2027
|
+
function parseManagedSetup(value) {
|
|
2028
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile setup file must be an object");
|
|
2029
|
+
const record = value;
|
|
2030
|
+
if (record.version !== 2 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || ![
|
|
2031
|
+
"version",
|
|
2032
|
+
"networkInterface",
|
|
2033
|
+
"listenPort",
|
|
2034
|
+
"upstreamOrigin",
|
|
2035
|
+
"tls"
|
|
2036
|
+
].includes(key))) throw new Error("mobile setup file has an unsupported format");
|
|
2037
|
+
if (!Number.isSafeInteger(record.listenPort) || record.listenPort < 1024 || record.listenPort > 65535) throw new Error("mobile setup listenPort must be from 1024 through 65535");
|
|
2038
|
+
if (typeof record.tls !== "object" || record.tls === null || Array.isArray(record.tls)) throw new Error("mobile setup tls must be an object");
|
|
2039
|
+
const tls = record.tls;
|
|
2040
|
+
if (tls.mode !== "managed" || Reflect.ownKeys(tls).some((key) => typeof key !== "string" || ![
|
|
2041
|
+
"mode",
|
|
2042
|
+
"caCertFile",
|
|
2043
|
+
"caKeyFile",
|
|
2044
|
+
"certFile",
|
|
2045
|
+
"keyFile"
|
|
2046
|
+
].includes(key))) throw new Error("mobile setup tls has an unsupported format");
|
|
2047
|
+
return Object.freeze({
|
|
2048
|
+
version: 2,
|
|
2049
|
+
networkInterface: requiredString(record.networkInterface, "mobile setup networkInterface"),
|
|
2050
|
+
listenPort: record.listenPort,
|
|
2051
|
+
upstreamOrigin: requiredString(record.upstreamOrigin, "mobile setup upstreamOrigin"),
|
|
2052
|
+
tls: Object.freeze({
|
|
2053
|
+
mode: "managed",
|
|
2054
|
+
caCertFile: requiredString(tls.caCertFile, "mobile setup tls.caCertFile"),
|
|
2055
|
+
caKeyFile: requiredString(tls.caKeyFile, "mobile setup tls.caKeyFile"),
|
|
2056
|
+
certFile: requiredString(tls.certFile, "mobile setup tls.certFile"),
|
|
2057
|
+
keyFile: requiredString(tls.keyFile, "mobile setup tls.keyFile")
|
|
2058
|
+
})
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
function privateIpv4(value) {
|
|
2062
|
+
const parts = value.split(".").map(Number);
|
|
2063
|
+
return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
|
|
2064
|
+
}
|
|
2065
|
+
function networkCidr(address, cidr) {
|
|
2066
|
+
const prefix = Number(cidr.slice(cidr.lastIndexOf("/") + 1));
|
|
2067
|
+
const network = (address.split(".").reduce((total, part) => (total << 8 | Number(part)) >>> 0, 0) & (prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0)) >>> 0;
|
|
2068
|
+
return `${[
|
|
2069
|
+
24,
|
|
2070
|
+
16,
|
|
2071
|
+
8,
|
|
2072
|
+
0
|
|
2073
|
+
].map((shift) => network >>> shift & 255).join(".")}/${String(prefix)}`;
|
|
2074
|
+
}
|
|
2075
|
+
/** List current private IPv4 candidates with their interface identity. */
|
|
2076
|
+
function availableLanNetworks(table = networkInterfaces()) {
|
|
2077
|
+
const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
|
|
2078
|
+
name,
|
|
2079
|
+
address: entry.address,
|
|
2080
|
+
cidr: networkCidr(entry.address, entry.cidr)
|
|
2081
|
+
})));
|
|
2082
|
+
return [...new Map(candidates.map((entry) => [`${entry.name}\0${entry.address}`, entry])).values()];
|
|
2083
|
+
}
|
|
2084
|
+
/** Select an active LAN, optionally by address or by a previously saved interface name. */
|
|
2085
|
+
function selectLanNetwork(requestedAddress, requestedInterface, table) {
|
|
2086
|
+
const candidates = availableLanNetworks(table);
|
|
2087
|
+
if (requestedAddress !== void 0) {
|
|
2088
|
+
const match = candidates.find((candidate) => candidate.address === requestedAddress);
|
|
2089
|
+
if (match === void 0) throw new Error(`--address ${requestedAddress} is not an active private LAN address`);
|
|
2090
|
+
return match;
|
|
2091
|
+
}
|
|
2092
|
+
if (requestedInterface !== void 0) {
|
|
2093
|
+
const matches = candidates.filter((candidate) => candidate.name === requestedInterface);
|
|
2094
|
+
if (matches.length === 1) return matches[0];
|
|
2095
|
+
if (matches.length === 0) throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`);
|
|
2096
|
+
throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`);
|
|
2097
|
+
}
|
|
2098
|
+
if (candidates.length === 1) return candidates[0];
|
|
2099
|
+
if (candidates.length === 0) throw new Error("no active private LAN address was found; connect to Wi-Fi or Ethernet");
|
|
2100
|
+
throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => entry.address).join(", ")}`);
|
|
2101
|
+
}
|
|
2102
|
+
function assertMatchingCa(certPem, keyPem) {
|
|
2103
|
+
const certificate = new X509Certificate(certPem);
|
|
2104
|
+
if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
|
|
2105
|
+
const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({
|
|
2106
|
+
format: "der",
|
|
2107
|
+
type: "spki"
|
|
2108
|
+
});
|
|
2109
|
+
const certificatePublic = certificate.publicKey.export({
|
|
2110
|
+
format: "der",
|
|
2111
|
+
type: "spki"
|
|
2112
|
+
});
|
|
2113
|
+
if (!privatePublic.equals(certificatePublic)) throw new Error("managed TLS CA certificate and key do not match");
|
|
2114
|
+
if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) throw new Error("managed TLS CA certificate is not currently valid");
|
|
2115
|
+
return certificate;
|
|
2116
|
+
}
|
|
2117
|
+
async function atomicWrite(file, contents) {
|
|
2118
|
+
const directory = dirname(file);
|
|
2119
|
+
await mkdir(directory, {
|
|
2120
|
+
recursive: true,
|
|
2121
|
+
mode: 448
|
|
2122
|
+
});
|
|
2123
|
+
const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`);
|
|
2124
|
+
await writeFile(temporary, contents, { mode: 384 });
|
|
2125
|
+
await chmod(temporary, 384);
|
|
2126
|
+
await rename(temporary, file);
|
|
2127
|
+
}
|
|
2128
|
+
/** Sign and atomically install a server leaf for the interface's current address. */
|
|
2129
|
+
async function refreshManagedServerCertificate(setup, address) {
|
|
2130
|
+
const [caCert, caKey] = await Promise.all([readFile(setup.tls.caCertFile, "utf8"), readFile(setup.tls.caKeyFile, "utf8")]);
|
|
2131
|
+
assertMatchingCa(caCert, caKey);
|
|
2132
|
+
const now = /* @__PURE__ */ new Date();
|
|
2133
|
+
const notAfter = new Date(now);
|
|
2134
|
+
notAfter.setDate(notAfter.getDate() + 397);
|
|
2135
|
+
const server = await generate([{
|
|
2136
|
+
name: "commonName",
|
|
2137
|
+
value: "DeepSeek Harness Mobile"
|
|
2138
|
+
}], {
|
|
2139
|
+
keyType: "ec",
|
|
2140
|
+
curve: "P-256",
|
|
2141
|
+
algorithm: "sha256",
|
|
2142
|
+
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
2143
|
+
notAfterDate: notAfter,
|
|
2144
|
+
ca: {
|
|
2145
|
+
cert: caCert,
|
|
2146
|
+
key: caKey
|
|
2147
|
+
},
|
|
2148
|
+
extensions: [
|
|
2149
|
+
{
|
|
2150
|
+
name: "basicConstraints",
|
|
2151
|
+
cA: false,
|
|
2152
|
+
critical: true
|
|
2153
|
+
},
|
|
2154
|
+
{
|
|
2155
|
+
name: "keyUsage",
|
|
2156
|
+
digitalSignature: true,
|
|
2157
|
+
critical: true
|
|
2158
|
+
},
|
|
2159
|
+
{
|
|
2160
|
+
name: "extKeyUsage",
|
|
2161
|
+
serverAuth: true
|
|
2162
|
+
},
|
|
2163
|
+
{
|
|
2164
|
+
name: "subjectAltName",
|
|
2165
|
+
altNames: [{
|
|
2166
|
+
type: 7,
|
|
2167
|
+
ip: address
|
|
2168
|
+
}]
|
|
2169
|
+
}
|
|
2170
|
+
]
|
|
2171
|
+
});
|
|
2172
|
+
await Promise.all([atomicWrite(setup.tls.certFile, server.cert), atomicWrite(setup.tls.keyFile, server.private)]);
|
|
2173
|
+
}
|
|
2174
|
+
/** Resolve the saved interface to the ordinary gateway config consumed by the Host plugin. */
|
|
2175
|
+
async function materializeManagedSetup(setup, table) {
|
|
2176
|
+
const network = selectLanNetwork(void 0, setup.networkInterface, table);
|
|
2177
|
+
await refreshManagedServerCertificate(setup, network.address);
|
|
2178
|
+
return {
|
|
2179
|
+
publicOrigin: `https://${network.address}:${String(setup.listenPort)}`,
|
|
2180
|
+
listenHost: network.address,
|
|
2181
|
+
upstreamOrigin: setup.upstreamOrigin,
|
|
2182
|
+
allowedCidrs: [network.cidr],
|
|
2183
|
+
tls: {
|
|
2184
|
+
mode: "provided",
|
|
2185
|
+
certFile: setup.tls.certFile,
|
|
2186
|
+
keyFile: setup.tls.keyFile
|
|
2187
|
+
}
|
|
2188
|
+
};
|
|
2189
|
+
}
|
|
2190
|
+
//#endregion
|
|
2008
2191
|
//#region src/plugin.ts
|
|
2009
2192
|
/** Stable Cordis plugin name. */
|
|
2010
2193
|
const name = "dsh-mobile";
|
|
2011
2194
|
/** The stock WebServer is the only DSH Host service this plugin requires. */
|
|
2012
2195
|
const inject = ["webServer"];
|
|
2013
2196
|
function mapAdminError(error) {
|
|
2014
|
-
|
|
2197
|
+
if (error instanceof HttpError) return error;
|
|
2198
|
+
const code = error.code;
|
|
2199
|
+
if (code === "EADDRNOTAVAIL") return new HttpError(409, "network_address_changed");
|
|
2200
|
+
if (code === "EADDRINUSE") return new HttpError(409, "listen_port_in_use");
|
|
2201
|
+
if (error instanceof Error && error.message.startsWith("saved LAN interface ")) return new HttpError(409, "network_interface_unavailable");
|
|
2202
|
+
return new HttpError(500, "internal_error");
|
|
2015
2203
|
}
|
|
2016
2204
|
const SETUP_KEYS = /* @__PURE__ */ new Set([
|
|
2017
2205
|
"version",
|
|
@@ -2041,6 +2229,15 @@ async function loadSetup(config) {
|
|
|
2041
2229
|
}
|
|
2042
2230
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("mobile setup file must be an object");
|
|
2043
2231
|
const record = parsed;
|
|
2232
|
+
if (record.version === 2) {
|
|
2233
|
+
const setup = await materializeManagedSetup(parseManagedSetup(record));
|
|
2234
|
+
const merged = { ...config };
|
|
2235
|
+
for (const key of SETUP_KEYS) if (key !== "version") delete merged[key];
|
|
2236
|
+
return {
|
|
2237
|
+
...merged,
|
|
2238
|
+
...setup
|
|
2239
|
+
};
|
|
2240
|
+
}
|
|
2044
2241
|
if (record.version !== 1 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || !SETUP_KEYS.has(key))) throw new Error("mobile setup file has an unsupported format");
|
|
2045
2242
|
const { version: _version, ...setup } = record;
|
|
2046
2243
|
const merged = { ...config };
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Authenticated Android and mobile-browser access for the stock DeepSeek Harness Web UI",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
|
-
"dsh-mobile": "lib/cli.js"
|
|
8
|
+
"dsh-mobile": "lib/cli.js"
|
|
9
9
|
},
|
|
10
10
|
"main": "./lib/index.mjs",
|
|
11
11
|
"types": "./lib/index.d.mts",
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
"./package.json": "./package.json"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
|
-
"lib/**/*.mjs",
|
|
24
|
-
"lib/cli.js",
|
|
23
|
+
"lib/**/*.mjs",
|
|
24
|
+
"lib/cli.js",
|
|
25
25
|
"lib/**/*.d.mts",
|
|
26
26
|
"lib/client.js",
|
|
27
27
|
"lib/client.js.map",
|