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 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,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
- 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:
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
- 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
 
@@ -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.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,73 +43,25 @@ 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 使用非默认端口,也可以一次指定:
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
- 它只在通过移动网关访问时加载,不修改 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
 
@@ -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. Edit these values, then refresh the phone. */",
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 CSS.");
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("link");
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 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
@@ -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 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");
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 (customCss) {
1562
+ if (customAsset !== void 0) {
1553
1563
  let body;
1554
1564
  try {
1555
- body = await readFile(this.config.customCssFile);
1565
+ body = await readFile(customAsset.file);
1556
1566
  } catch (error) {
1557
1567
  if (error.code !== "ENOENT") throw error;
1558
- body = Buffer.from("/* Add mobile overrides in the DSH home mobile-access/mobile.css file. */\n");
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": "text/css; charset=utf-8",
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.2",
4
4
  "private": false,
5
5
  "description": "Authenticated Android and mobile-browser access for the stock DeepSeek Harness Web UI",
6
6
  "type": "module",