dsh-mobile 0.1.0-alpha.1 → 0.1.0-alpha.2
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 +54 -60
- package/README.zh.md +53 -60
- package/SECURITY.md +1 -0
- package/cordis.patch.yml +1 -0
- package/lib/cli.js +17 -2
- package/lib/client.js +111 -3
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +4 -1
- package/lib/index.mjs +18 -7
- package/package.json +1 -1
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,73 +43,25 @@ 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:
|
|
100
|
-
|
|
101
|
-
```powershell
|
|
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
|
-
```
|
|
64
|
+
Both paths install the public npm package into stock DSH without patching its source. Setup selects the LAN address, creates the local TLS certificate, and stores plugin data under `$DSH_HOME/mobile-access/`; run `dsh-mobile setup --help` through the same `plugin exec` command for manual network options.
|
|
111
65
|
|
|
112
66
|
1. Copy the generated `dsh-mobile-ca.cer` to Android and install it as a user CA certificate.
|
|
113
67
|
2. Open the Mobile card in the lower-left corner of the desktop DSH UI.
|
|
@@ -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
|
|
|
@@ -173,6 +166,7 @@ flowchart LR
|
|
|
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,73 +43,25 @@ 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 使用非默认端口,也可以一次指定:
|
|
100
|
-
|
|
101
|
-
```powershell
|
|
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
|
-
```
|
|
64
|
+
两种方式都会把公开 npm 包安装到原生 DSH,不修改 DSH 源码。设置向导会选择局域网地址、生成本地 TLS 证书,并把插件数据保存到 `$DSH_HOME/mobile-access/`;需要手动指定网卡或端口时,通过同一个 `plugin exec` 命令运行 `dsh-mobile setup --help`。
|
|
111
65
|
|
|
112
66
|
1. 将向导输出的 `dsh-mobile-ca.cer` 复制到 Android,并在系统设置中安装为用户 CA 证书。
|
|
113
67
|
2. 在电脑 DSH 左下角打开“移动端”卡片。
|
|
@@ -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
|
|
|
@@ -179,6 +171,7 @@ flowchart LR
|
|
|
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
|
|
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
|
@@ -140,7 +140,7 @@ async function setup(args) {
|
|
|
140
140
|
} catch (error) {
|
|
141
141
|
if (error.code !== "ENOENT") throw error;
|
|
142
142
|
await writeFile(customCss, [
|
|
143
|
-
"/* Safe mobile overrides.
|
|
143
|
+
"/* Safe mobile overrides. DSH Mobile applies saved changes on the phone automatically. */",
|
|
144
144
|
":root {",
|
|
145
145
|
" --dsh-mobile-accent: #2563eb;",
|
|
146
146
|
" --dsh-mobile-font-scale: 1;",
|
|
@@ -149,6 +149,20 @@ async function setup(args) {
|
|
|
149
149
|
""
|
|
150
150
|
].join("\n"), { mode: 384 });
|
|
151
151
|
}
|
|
152
|
+
const customScript = join(directory, "mobile.js");
|
|
153
|
+
try {
|
|
154
|
+
await readFile(customScript);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (error.code !== "ENOENT") throw error;
|
|
157
|
+
await writeFile(customScript, [
|
|
158
|
+
"/* Mount mobile-only Web features here. Saved changes are applied automatically. */",
|
|
159
|
+
"window.dshMobile.register(({ root }) => {",
|
|
160
|
+
" root.replaceChildren()",
|
|
161
|
+
" return () => root.replaceChildren()",
|
|
162
|
+
"})",
|
|
163
|
+
""
|
|
164
|
+
].join("\n"), { mode: 384 });
|
|
165
|
+
}
|
|
152
166
|
const origin = `https://${network.address}:${String(options.port)}`;
|
|
153
167
|
await Promise.all([writeFile(join(directory, "setup.json"), `${JSON.stringify({
|
|
154
168
|
version: 1,
|
|
@@ -164,6 +178,7 @@ async function setup(args) {
|
|
|
164
178
|
}, null, 2)}\n`, { mode: 384 }), writeFile(join(directory, "control.json"), "{\"version\":1,\"enabled\":true}\n", { mode: 384 })]);
|
|
165
179
|
console.log(`DSH Mobile is configured for ${origin}`);
|
|
166
180
|
console.log(`Install this certificate on Android before connecting: ${androidCertificate}`);
|
|
181
|
+
console.log(`Ask DSH to customize the mobile Web UI and features in: ${customCss} and ${customScript}`);
|
|
167
182
|
console.log("Start DSH with: dsh --profile web");
|
|
168
183
|
console.log("Then open the Mobile card in the lower-left corner and create a pairing link.");
|
|
169
184
|
}
|
|
@@ -174,7 +189,7 @@ async function purge(args) {
|
|
|
174
189
|
recursive: true,
|
|
175
190
|
force: true
|
|
176
191
|
});
|
|
177
|
-
console.log("Removed DSH Mobile certificates, devices, preferences, and custom
|
|
192
|
+
console.log("Removed DSH Mobile certificates, devices, preferences, and custom Web files.");
|
|
178
193
|
}
|
|
179
194
|
function help() {
|
|
180
195
|
console.log([
|
package/lib/client.js
CHANGED
|
@@ -95,6 +95,23 @@ window.__ModuleLoader__.load({
|
|
|
95
95
|
if (!response.ok) throw new Error(typeof body.error === "string" ? body.error : `HTTP ${String(response.status)}`);
|
|
96
96
|
return body;
|
|
97
97
|
}
|
|
98
|
+
function mobileExtensionRequest(path, init = {}) {
|
|
99
|
+
const target = new URL(path, location.href);
|
|
100
|
+
if (target.origin !== location.origin) throw new TypeError("mobile extension requests must stay on the DSH origin");
|
|
101
|
+
const headers = new Headers(init.headers);
|
|
102
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
103
|
+
if (method !== "GET" && method !== "HEAD" && !headers.has("x-dsh-mobile-csrf")) {
|
|
104
|
+
const csrf = document.cookie.split(";").map((entry) => entry.trim()).find((entry) => entry.startsWith("dsh_ma_csrf="))?.slice(12);
|
|
105
|
+
if (csrf !== void 0) headers.set("x-dsh-mobile-csrf", csrf);
|
|
106
|
+
}
|
|
107
|
+
return fetch(target, {
|
|
108
|
+
...init,
|
|
109
|
+
cache: "no-store",
|
|
110
|
+
credentials: "same-origin",
|
|
111
|
+
headers,
|
|
112
|
+
redirect: "error"
|
|
113
|
+
});
|
|
114
|
+
}
|
|
98
115
|
function installControl() {
|
|
99
116
|
const zh = navigator.language.toLowerCase().startsWith("zh");
|
|
100
117
|
const root = element("div", "dsh-mobile-control");
|
|
@@ -164,6 +181,74 @@ window.__ModuleLoader__.load({
|
|
|
164
181
|
}
|
|
165
182
|
};
|
|
166
183
|
}
|
|
184
|
+
function installCustomExtension() {
|
|
185
|
+
let activeRoot;
|
|
186
|
+
let activeDispose;
|
|
187
|
+
let observedSource = "";
|
|
188
|
+
let loading = false;
|
|
189
|
+
let pendingMount;
|
|
190
|
+
const previousRegistry = window.dshMobile;
|
|
191
|
+
const registry = Object.freeze({ register: (mount) => {
|
|
192
|
+
if (typeof mount !== "function") throw new TypeError("dshMobile.register requires a mount function");
|
|
193
|
+
pendingMount = mount;
|
|
194
|
+
} });
|
|
195
|
+
const registeredMount = () => pendingMount;
|
|
196
|
+
window.dshMobile = registry;
|
|
197
|
+
const refresh = async () => {
|
|
198
|
+
if (loading || document.visibilityState === "hidden") return;
|
|
199
|
+
loading = true;
|
|
200
|
+
try {
|
|
201
|
+
const response = await fetch("/mobile-access/custom.js", {
|
|
202
|
+
cache: "no-store",
|
|
203
|
+
credentials: "same-origin",
|
|
204
|
+
redirect: "error"
|
|
205
|
+
});
|
|
206
|
+
if (!response.ok || !response.headers.get("content-type")?.startsWith("text/javascript")) return;
|
|
207
|
+
const source = await response.text();
|
|
208
|
+
if (source === observedSource) return;
|
|
209
|
+
observedSource = source;
|
|
210
|
+
pendingMount = void 0;
|
|
211
|
+
const script = document.createElement("script");
|
|
212
|
+
script.textContent = `${source}\n//# sourceURL=dsh-mobile-custom.js`;
|
|
213
|
+
document.head.append(script);
|
|
214
|
+
script.remove();
|
|
215
|
+
const mount = registeredMount();
|
|
216
|
+
if (mount === void 0) return;
|
|
217
|
+
const nextRoot = document.createElement("div");
|
|
218
|
+
nextRoot.dataset.dshMobileExtension = "true";
|
|
219
|
+
document.body.append(nextRoot);
|
|
220
|
+
try {
|
|
221
|
+
const nextDispose = mount(Object.freeze({
|
|
222
|
+
document,
|
|
223
|
+
request: mobileExtensionRequest,
|
|
224
|
+
root: nextRoot,
|
|
225
|
+
window
|
|
226
|
+
}));
|
|
227
|
+
if (nextDispose !== void 0 && typeof nextDispose !== "function") throw new TypeError("the mobile extension mount function must return a disposer or undefined");
|
|
228
|
+
activeDispose?.();
|
|
229
|
+
activeRoot?.remove();
|
|
230
|
+
activeRoot = nextRoot;
|
|
231
|
+
activeDispose = typeof nextDispose === "function" ? nextDispose : void 0;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
nextRoot.remove();
|
|
234
|
+
console.error("DSH Mobile custom extension failed to mount", error);
|
|
235
|
+
}
|
|
236
|
+
} catch {} finally {
|
|
237
|
+
loading = false;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
refresh();
|
|
241
|
+
const timer = setInterval(() => {
|
|
242
|
+
refresh();
|
|
243
|
+
}, 1e3);
|
|
244
|
+
return () => {
|
|
245
|
+
clearInterval(timer);
|
|
246
|
+
activeDispose?.();
|
|
247
|
+
activeRoot?.remove();
|
|
248
|
+
if (previousRegistry === void 0) delete window.dshMobile;
|
|
249
|
+
else window.dshMobile = previousRegistry;
|
|
250
|
+
};
|
|
251
|
+
}
|
|
167
252
|
/** Install the responsive mobile layer and the loopback-only control card. */
|
|
168
253
|
function apply(ctx) {
|
|
169
254
|
ctx.effect(() => {
|
|
@@ -172,13 +257,34 @@ window.__ModuleLoader__.load({
|
|
|
172
257
|
style.textContent = BASE_STYLES;
|
|
173
258
|
document.head.append(style);
|
|
174
259
|
const loopback = isLoopbackHost(location.hostname);
|
|
175
|
-
const custom = loopback ? void 0 : document.createElement("
|
|
260
|
+
const custom = loopback ? void 0 : document.createElement("style");
|
|
261
|
+
let customRefresh;
|
|
262
|
+
let customLoading = false;
|
|
176
263
|
if (custom !== void 0) {
|
|
177
|
-
custom.rel = "stylesheet";
|
|
178
|
-
custom.href = "/mobile-access/custom.css";
|
|
179
264
|
custom.dataset.plugin = "dsh-mobile";
|
|
180
265
|
document.head.append(custom);
|
|
266
|
+
const refreshCustomCss = async () => {
|
|
267
|
+
if (customLoading || document.visibilityState === "hidden") return;
|
|
268
|
+
customLoading = true;
|
|
269
|
+
try {
|
|
270
|
+
const response = await fetch("/mobile-access/custom.css", {
|
|
271
|
+
cache: "no-store",
|
|
272
|
+
credentials: "same-origin",
|
|
273
|
+
redirect: "error"
|
|
274
|
+
});
|
|
275
|
+
if (!response.ok || !response.headers.get("content-type")?.startsWith("text/css")) return;
|
|
276
|
+
const css = await response.text();
|
|
277
|
+
if (custom.textContent !== css) custom.textContent = css;
|
|
278
|
+
} catch {} finally {
|
|
279
|
+
customLoading = false;
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
refreshCustomCss();
|
|
283
|
+
customRefresh = setInterval(() => {
|
|
284
|
+
refreshCustomCss();
|
|
285
|
+
}, 1e3);
|
|
181
286
|
}
|
|
287
|
+
const removeCustomExtension = loopback ? void 0 : installCustomExtension();
|
|
182
288
|
const control = loopback ? installControl() : void 0;
|
|
183
289
|
const disposeSlot = control === void 0 ? void 0 : ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
|
|
184
290
|
name: "sidebar.footer.action",
|
|
@@ -190,8 +296,10 @@ window.__ModuleLoader__.load({
|
|
|
190
296
|
onClick: control.toggle
|
|
191
297
|
}, wide ? controlLabel() : "M")));
|
|
192
298
|
return () => {
|
|
299
|
+
if (customRefresh !== void 0) clearInterval(customRefresh);
|
|
193
300
|
disposeSlot?.();
|
|
194
301
|
control?.remove();
|
|
302
|
+
removeCustomExtension?.();
|
|
195
303
|
custom?.remove();
|
|
196
304
|
style.remove();
|
|
197
305
|
};
|
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) throw new Error(typeof body.error === 'string' ? body.error : `HTTP ${String(response.status)}`)\n return body\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
|
@@ -540,6 +540,7 @@ const Config = z.object({
|
|
|
540
540
|
stateFile: String,
|
|
541
541
|
controlFile: z.string().hidden().required(),
|
|
542
542
|
customCssFile: z.string().hidden(),
|
|
543
|
+
customScriptFile: z.string().hidden(),
|
|
543
544
|
initiallyEnabled: z.boolean().hidden().required(),
|
|
544
545
|
tls: z.object({
|
|
545
546
|
mode: z.union([z.const("provided"), z.const("disabled")]),
|
|
@@ -660,6 +661,7 @@ function parseGatewayConfig(raw) {
|
|
|
660
661
|
allowedCidrs: Object.freeze(allowedCidrs),
|
|
661
662
|
stateFile: absoluteFile(value.stateFile, "stateFile"),
|
|
662
663
|
customCssFile: value.customCssFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.css") : absoluteFile(value.customCssFile, "customCssFile"),
|
|
664
|
+
customScriptFile: value.customScriptFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.js") : absoluteFile(value.customScriptFile, "customScriptFile"),
|
|
663
665
|
tls,
|
|
664
666
|
pairingTtlMs: integer(value.pairingTtlMs, "pairingTtlMs", 12e4, 1e4, 6e5),
|
|
665
667
|
deviceTtlMs,
|
|
@@ -1526,8 +1528,16 @@ var MobileAccessGateway = class {
|
|
|
1526
1528
|
await this.handleLogout(request, response);
|
|
1527
1529
|
return;
|
|
1528
1530
|
}
|
|
1529
|
-
const
|
|
1530
|
-
|
|
1531
|
+
const customAsset = request.method === "GET" ? target.decodedPathname === `/mobile-access/custom.css` ? {
|
|
1532
|
+
file: this.config.customCssFile,
|
|
1533
|
+
contentType: "text/css; charset=utf-8",
|
|
1534
|
+
fallback: "/* Add mobile overrides in the DSH home mobile-access/mobile.css file. */\n"
|
|
1535
|
+
} : target.decodedPathname === `/mobile-access/custom.js` ? {
|
|
1536
|
+
file: this.config.customScriptFile,
|
|
1537
|
+
contentType: "text/javascript; charset=utf-8",
|
|
1538
|
+
fallback: "window.dshMobile?.register(() => undefined)\n"
|
|
1539
|
+
} : void 0 : void 0;
|
|
1540
|
+
if (customAsset === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
|
|
1531
1541
|
if (request.method !== "GET" && request.method !== "HEAD" && request.method !== "POST") throw new HttpError(405, "method_not_allowed");
|
|
1532
1542
|
if (request.method === "POST" && target.decodedPathname !== "/api" && !target.decodedPathname.startsWith("/api/")) throw new HttpError(405, "method_not_allowed");
|
|
1533
1543
|
let authorization;
|
|
@@ -1549,19 +1559,20 @@ var MobileAccessGateway = class {
|
|
|
1549
1559
|
}
|
|
1550
1560
|
throw error;
|
|
1551
1561
|
}
|
|
1552
|
-
if (
|
|
1562
|
+
if (customAsset !== void 0) {
|
|
1553
1563
|
let body;
|
|
1554
1564
|
try {
|
|
1555
|
-
body = await readFile(
|
|
1565
|
+
body = await readFile(customAsset.file);
|
|
1556
1566
|
} catch (error) {
|
|
1557
1567
|
if (error.code !== "ENOENT") throw error;
|
|
1558
|
-
body = Buffer.from(
|
|
1568
|
+
body = Buffer.from(customAsset.fallback);
|
|
1559
1569
|
}
|
|
1560
1570
|
if (body.byteLength > 262144) throw new HttpError(413, "payload_too_large");
|
|
1561
1571
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
1562
1572
|
response.writeHead(200, {
|
|
1563
|
-
"Content-Type":
|
|
1564
|
-
"Content-Length": body.byteLength
|
|
1573
|
+
"Content-Type": customAsset.contentType,
|
|
1574
|
+
"Content-Length": body.byteLength,
|
|
1575
|
+
"Cache-Control": "no-store"
|
|
1565
1576
|
});
|
|
1566
1577
|
response.end(body);
|
|
1567
1578
|
return;
|