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 CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://unpkg.com/dsh-mobile@0.1.0-alpha.1/assets/brand/repository-hero.png" alt="A lively whale girl using a phone beside a desktop running DSH" width="100%">
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
- | Custom mobile CSS | Yes | Yes |
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
- Prerequisites: stock DSH can run through either an installed `dsh` command or the source checkout's `pnpm dsh` script, and the computer and Android device share a trusted private LAN. DSH requires Node.js 22.19.x or Node.js 24 or later.
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
- corepack enable
54
- pnpm install
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
- Then replace `dsh` in every example with `pnpm dsh`. The complete source-checkout flow is:
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
- You may also invoke the source checkout from another directory without creating a global command:
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
- ```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
- ```
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
- This file loads only through the authenticated mobile gateway. It does not modify the DSH installation and survives DSH upgrades.
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 CSS variables, spacing, typography, and touch sizes. Do not depend on generated class names. Delete `mobile.css` and refresh to return to the built-in defaults.
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 must cover the LAN address in use.
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.1/assets/brand/repository-hero.png" alt="鲸鱼娘拿着手机连接桌面上的 DSH" width="100%">
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
- | 自定义移动端 CSS | 支持 | 支持 |
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
- 前置条件:能够通过已经安装的 `dsh` 命令或源码仓库中的 `pnpm dsh` 启动原生 DSH,电脑和 Android 手机位于同一可信局域网。DSH 需要 Node.js 22.19.x,或 Node.js 24 以上版本。
45
-
46
- ### 选择 DSH 命令
47
-
48
- 如果终端中能够直接运行 `dsh`,后续命令保持原样即可。
49
-
50
- 如果你直接克隆了 DSH 源码,没有全局 `dsh` 命令,请先在 DSH 源码根目录安装工作区依赖:
46
+ 终端中可以直接运行 `dsh` 时,使用第一组命令:
51
47
 
52
48
  ```powershell
53
- corepack enable
54
- pnpm install
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
- 然后把后续示例中的 `dsh` 全部替换为 `pnpm dsh`。源码版的完整快速启动命令如下:
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
- 如果当前终端不在 DSH 源码目录,也不需要创建全局命令:
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
- ```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
- ```
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
- 它只在通过移动网关访问时加载,不修改 DSH 安装目录,升级或卸载 DSH 时也不会覆盖它。修改后刷新手机页面即可看到效果。
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
- 建议优先调整 CSS 变量、字体比例、间距和触控尺寸,不要依赖构建后生成的类名。写错样式不会影响 Host 网关;删除 `mobile.css` 后刷新即可恢复插件默认样式。
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 校验;证书必须覆盖实际访问的局域网 IP。
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 { X509Certificate } from "node:crypto";
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/cli.ts
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
- function availableAddresses() {
23
- const candidates = Object.values(networkInterfaces()).flatMap((entries) => entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
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, entry])).values()];
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 = selectNetwork(options.address);
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 now = /* @__PURE__ */ new Date();
87
- const notAfter = new Date(now);
88
- notAfter.setDate(notAfter.getDate() + 397);
89
- const certificate = await generate([{
90
- name: "commonName",
91
- value: network.address
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
- writeFile(certFile, certificate.cert, { mode: 384 }),
129
- writeFile(keyFile, certificate.private, { mode: 384 }),
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. Edit these values, then refresh the phone. */",
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
- version: 1,
155
- publicOrigin: origin,
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 before connecting: ${androidCertificate}`);
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 CSS.");
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) throw new Error(typeof body.error === "string" ? body.error : `HTTP ${String(response.status)}`);
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("link");
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 both the desktop and authenticated mobile UI. */
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 customCss = target.search === "" && request.method === "GET" && target.decodedPathname === `/mobile-access/custom.css`;
1530
- if (!customCss && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
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 (customCss) {
1564
+ if (customAsset !== void 0) {
1553
1565
  let body;
1554
1566
  try {
1555
- body = await readFile(this.config.customCssFile);
1567
+ body = await readFile(customAsset.file);
1556
1568
  } catch (error) {
1557
1569
  if (error.code !== "ENOENT") throw error;
1558
- body = Buffer.from("/* Add mobile overrides in the DSH home mobile-access/mobile.css file. */\n");
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": "text/css; charset=utf-8",
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
- return error instanceof HttpError ? error : new HttpError(500, "internal_error");
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.1",
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",