dsh-mini-remote 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.en.md +13 -7
- package/README.md +13 -7
- package/lib/index.js +149 -5
- package/lib/mirror.js +1422 -0
- package/lib/page.html +624 -6
- package/lib/server.js +373 -1
- package/lib/store.js +41 -0
- package/package.json +2 -2
package/lib/server.js
CHANGED
|
@@ -13,10 +13,29 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { createServer } from 'node:http'
|
|
15
15
|
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
16
|
+
import { appendFile, mkdir } from 'node:fs/promises'
|
|
17
|
+
import { dirname } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
16
19
|
import { renderPage, readArt, readImage, readSw } from './page.js'
|
|
17
20
|
|
|
18
21
|
const COOKIE = 'dsh_mini_token'
|
|
19
22
|
|
|
23
|
+
/**
|
|
24
|
+
* 镜像页「诊断模式」那份样本落在哪。
|
|
25
|
+
*
|
|
26
|
+
* **`scratch/` 是 gitignore 的**,所以诊断日志扔在这里不会入库;
|
|
27
|
+
* 路径按本文件的位置算(`lib/server.js` 的上一级就是插件根目录),
|
|
28
|
+
* 装到哪儿都不用再配一次。
|
|
29
|
+
*
|
|
30
|
+
* `DSH_MINI_DIAG_LOG` 可以整体换掉它——**测试用它写到临时文件**,
|
|
31
|
+
* 免得测试的样本混进真机上那一份里,让读日志的人分不清哪条是真机报的。
|
|
32
|
+
*/
|
|
33
|
+
function diagLogPath() {
|
|
34
|
+
const override = process.env.DSH_MINI_DIAG_LOG
|
|
35
|
+
if (override) return override
|
|
36
|
+
return fileURLToPath(new URL('../scratch/diag.log', import.meta.url))
|
|
37
|
+
}
|
|
38
|
+
|
|
20
39
|
/**
|
|
21
40
|
* 配置的端口被占时,往后最多再试几个。
|
|
22
41
|
*
|
|
@@ -137,6 +156,97 @@ export function sendJson(res, status, payload) {
|
|
|
137
156
|
res.end(body)
|
|
138
157
|
}
|
|
139
158
|
|
|
159
|
+
/**
|
|
160
|
+
* 镜像那几条路径**此刻走不通**时,回给手机的那一页(调用处见 `handle` 里的兜底 404)。
|
|
161
|
+
*
|
|
162
|
+
* ## 为什么非要有它(2026-10-08 真机截图)
|
|
163
|
+
*
|
|
164
|
+
* `/mini/mirror` 那一整块路由是挂在开关上的(见 `handle` 里 `mirrorEnabled()` 那一行),
|
|
165
|
+
* 所以「开关关着」和「电脑端还没就绪」这两种时候,`/mini/mirror/…` 会掉进兜底 404,
|
|
166
|
+
* 原来回的是 `{"error":"not found"}`。**而手机那一页是把镜像嵌在 iframe 里的**:
|
|
167
|
+
* 这个 JSON 于是成了框里的**文档**,被浏览器当成页面渲染出来——用户看到的是框里一条
|
|
168
|
+
* 外星文(华为浏览器还给它套了个「美观输出」的 JSON 查看器),外面那条
|
|
169
|
+
* 「电脑端界面 / 返回」却还好端端地在,看起来就像界面坏了。**给程序看的正文跑到了人眼前。**
|
|
170
|
+
*
|
|
171
|
+
* ## 三条要求
|
|
172
|
+
*
|
|
173
|
+
* ① **人话**:现在打不开、以及下一步做什么;
|
|
174
|
+
* ② **自己会重试**:宿主刚重启那几十秒里上游地址还没解析出来,等一下它自己就好了
|
|
175
|
+
* ——真机上「有时候打得开、有时候打不开」就是这一段(见 lib/mirror.js 的 resolveReady);
|
|
176
|
+
* ③ **有出口**:那个框里没有别的按钮,所以给一个「返回手机页」——它给外层页发一条消息,
|
|
177
|
+
* `lib/page.html` 里接住、把那整屏收起来。
|
|
178
|
+
*
|
|
179
|
+
* **状态码仍然是 404**:这条路此刻确实不存在(开关关着时它就该像不存在一样),
|
|
180
|
+
* 这里换掉的只是**正文**,不是「这条路存不存在」这件事本身。
|
|
181
|
+
*/
|
|
182
|
+
export function sendMirrorUnavailable(res) {
|
|
183
|
+
// 重试次数放在地址里(`&retry=n`):不依赖 localStorage,也不会让框里的历史变长
|
|
184
|
+
// (用 location.replace)。**次数用尽就停下**——一直空转会烧手机的电,也永远不给交代。
|
|
185
|
+
// 入口那一下会把整个查询串丢掉,所以这个参数绝不会漏到上游去。
|
|
186
|
+
const html = `<!doctype html>
|
|
187
|
+
<html lang="zh-CN">
|
|
188
|
+
<head>
|
|
189
|
+
<meta charset="utf-8">
|
|
190
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
191
|
+
<title>电脑端界面现在打不开</title>
|
|
192
|
+
<style>
|
|
193
|
+
:root { color-scheme: light dark; }
|
|
194
|
+
body { margin: 0; padding: 24px 18px; color: #1b1b1b; background: #fff;
|
|
195
|
+
font: 15px/1.7 system-ui, -apple-system, "Noto Sans CJK SC", sans-serif; }
|
|
196
|
+
h1 { margin: 0 0 12px; font-size: 17px; }
|
|
197
|
+
p { margin: 0 0 10px; color: #555; }
|
|
198
|
+
.acts { display: flex; gap: 10px; margin-top: 20px; }
|
|
199
|
+
button { font: inherit; padding: 10px 16px; border-radius: 2px; color: inherit;
|
|
200
|
+
border: 1px solid #d6d6d6; background: #f6f6f6; }
|
|
201
|
+
@media (prefers-color-scheme: dark) {
|
|
202
|
+
body { color: #eee; background: #111; }
|
|
203
|
+
p { color: #aaa; }
|
|
204
|
+
button { border-color: #3a3a3a; background: #1e1e1e; }
|
|
205
|
+
}
|
|
206
|
+
</style>
|
|
207
|
+
</head>
|
|
208
|
+
<body>
|
|
209
|
+
<h1>电脑端界面现在打不开</h1>
|
|
210
|
+
<p id="why">正在等电脑端就绪…</p>
|
|
211
|
+
<p>电脑端刚起来的那几十秒里会是这样,等一下通常就好。如果一直打不开:回到手机页,在「进阶设置」里确认「电脑端界面」是开着的,再点一次「打开」。</p>
|
|
212
|
+
<div class="acts">
|
|
213
|
+
<button id="retry" type="button">重试</button>
|
|
214
|
+
<button id="back" type="button">返回手机页</button>
|
|
215
|
+
</div>
|
|
216
|
+
<script>(function () {
|
|
217
|
+
var MAX = 5; // 自己最多重试几次
|
|
218
|
+
var GAP = 2500; // 每次之间隔多久
|
|
219
|
+
var why = document.getElementById('why');
|
|
220
|
+
var url = new URL(location.href);
|
|
221
|
+
var n = Number(url.searchParams.get('retry') || 0);
|
|
222
|
+
if (!(n >= 0)) n = 0;
|
|
223
|
+
document.getElementById('retry').addEventListener('click', function () {
|
|
224
|
+
var clean = new URL(location.href);
|
|
225
|
+
clean.searchParams.delete('retry');
|
|
226
|
+
location.replace(clean.toString());
|
|
227
|
+
});
|
|
228
|
+
document.getElementById('back').addEventListener('click', function () {
|
|
229
|
+
try { window.parent.postMessage('mini-mirror-close', '*'); } catch (e) {}
|
|
230
|
+
});
|
|
231
|
+
if (n >= MAX) {
|
|
232
|
+
why.textContent = '还没等到电脑端就绪(也可能是手机那边的开关关着)。';
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
why.textContent = '正在等电脑端就绪…(第 ' + (n + 1) + ' 次,共 ' + MAX + ' 次)';
|
|
236
|
+
url.searchParams.set('retry', String(n + 1));
|
|
237
|
+
setTimeout(function () { location.replace(url.toString()); }, GAP);
|
|
238
|
+
})();</script>
|
|
239
|
+
</body>
|
|
240
|
+
</html>`
|
|
241
|
+
const buf = Buffer.from(html, 'utf8')
|
|
242
|
+
res.writeHead(404, {
|
|
243
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
244
|
+
'Content-Length': buf.length,
|
|
245
|
+
'Cache-Control': 'no-store',
|
|
246
|
+
})
|
|
247
|
+
res.end(buf)
|
|
248
|
+
}
|
|
249
|
+
|
|
140
250
|
/**
|
|
141
251
|
* @param {object} opts
|
|
142
252
|
* @param {import('./store.js').createStore extends (...a:any)=>infer R ? R : never} opts.store
|
|
@@ -172,7 +282,7 @@ export const miniControl = {
|
|
|
172
282
|
* (由插件递进「当前已注册工作区的目录」,见 extraImageRoots 那段)
|
|
173
283
|
* @returns {Promise<{port:number, addresses:string[], failed:{address:string,message:string}[], close:()=>Promise<void>, broadcast:(event:string, data:unknown)=>void, broadcastTrajectory:(payload:object)=>void, broadcastTrajectoryLive:(payload:object)=>void}>}
|
|
174
284
|
*/
|
|
175
|
-
export async function createMiniServer({ store, config, token, bindAddresses, tunnelHost, onInstruction, onCommands, onCommand, onStop, onUnqueue, onUpload, onSessionOpened, log, tree, browse, build, imageRoots }) {
|
|
285
|
+
export async function createMiniServer({ store, config, token, bindAddresses, tunnelHost, onInstruction, onCommands, onCommand, onStop, onUnqueue, onUpload, onSessionOpened, log, tree, browse, build, imageRoots, mirror, mirrorEnabled }) {
|
|
176
286
|
/** @type {Set<import('node:http').ServerResponse>} */
|
|
177
287
|
const clients = new Set()
|
|
178
288
|
|
|
@@ -698,6 +808,49 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
698
808
|
attempts.delete(clientKey(req, tunnelHost?.()))
|
|
699
809
|
}
|
|
700
810
|
|
|
811
|
+
/**
|
|
812
|
+
* 把**我们自己那个** `token` 查询参数摘掉,其余一个字符都不动。
|
|
813
|
+
*
|
|
814
|
+
* 为什么必须摘:那个界面的插件脚本走一条合并加载路径(`plugins/??a/client.js&rev=x`),
|
|
815
|
+
* 上游对它是**精确匹配**——多一个 `&token=...` 就当场 404,脚本一 404,界面就卡在
|
|
816
|
+
* 启动画面报「HTML did not preload @deepseek-ai/dsh-client-modules/client.js」。
|
|
817
|
+
* 这是真机上试出来的,不是推的。
|
|
818
|
+
*
|
|
819
|
+
* **逐段切、再拼回去,不走 URLSearchParams**:那条合并路径里有 `@`、`/`、`,` 这些字符,
|
|
820
|
+
* 交给它解析再序列化会被百分号编码,路径就变了——那等于换个法子把它弄坏。
|
|
821
|
+
* 只在值确实等于我们的令牌时摘,免得误伤界面自己可能用到的同名参数。
|
|
822
|
+
*/
|
|
823
|
+
function stripOurToken(rawUrl) {
|
|
824
|
+
const at = rawUrl.indexOf('?')
|
|
825
|
+
if (at < 0) return rawUrl
|
|
826
|
+
const head = rawUrl.slice(0, at)
|
|
827
|
+
const parts = rawUrl.slice(at + 1).split('&')
|
|
828
|
+
const encoded = encodeURIComponent(token)
|
|
829
|
+
const kept = parts.filter((p) => p !== `token=${token}` && p !== `token=${encoded}`)
|
|
830
|
+
if (kept.length === parts.length) return rawUrl
|
|
831
|
+
return kept.length ? `${head}?${kept.join('&')}` : head
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* 把**我们自己那个** `diag=1` 摘掉,其余一个字符都不动。
|
|
836
|
+
*
|
|
837
|
+
* 和上面的 token 是同一个道理(真机上试出来的):上游对一部分地址是**精确匹配**,
|
|
838
|
+
* 多带一个参数就当场 404。`diag` 是我们读的开关,上游不认识它,
|
|
839
|
+
* 所以往上游转之前必须摘掉。**入口那一下本来就把查询串整个丢了**,
|
|
840
|
+
* 这里管的是前缀下面的那些地址(`/mini/mirror/assets/x?diag=1` 这种)。
|
|
841
|
+
*
|
|
842
|
+
* 只摘 `diag=1` 这一个形状,别的一律不碰——上游自己要用同名参数也不至于被误伤。
|
|
843
|
+
*/
|
|
844
|
+
function stripDiagFlag(rawUrl) {
|
|
845
|
+
const at = rawUrl.indexOf('?')
|
|
846
|
+
if (at < 0) return rawUrl
|
|
847
|
+
const head = rawUrl.slice(0, at)
|
|
848
|
+
const parts = rawUrl.slice(at + 1).split('&')
|
|
849
|
+
const kept = parts.filter((p) => p !== 'diag=1')
|
|
850
|
+
if (kept.length === parts.length) return rawUrl
|
|
851
|
+
return kept.length ? `${head}?${kept.join('&')}` : head
|
|
852
|
+
}
|
|
853
|
+
|
|
701
854
|
async function handle(req, res) {
|
|
702
855
|
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`)
|
|
703
856
|
const path = url.pathname
|
|
@@ -822,8 +975,150 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
822
975
|
return
|
|
823
976
|
}
|
|
824
977
|
|
|
978
|
+
// ---- 「进阶设置」:电脑端界面的镜像 --------------------------------
|
|
979
|
+
//
|
|
980
|
+
// 见 lib/mirror.js 开头的说明。这里只负责三件事:**认路、鉴权、放行**。
|
|
981
|
+
//
|
|
982
|
+
// ## 认路这件事比想的绕,两次才做对
|
|
983
|
+
//
|
|
984
|
+
// 那个界面里的请求有**两种**写法,两种都要能通:
|
|
985
|
+
//
|
|
986
|
+
// ① **相对的**:外壳里写的是 `<base href="./">` + `./assets/index-xxx.js`。
|
|
987
|
+
// 它本来就是按「可以被挂在任意子路径下」设计的。所以入口必须**带尾斜杠**,
|
|
988
|
+
// 并且把 `/mini/mirror/` 这个前缀剥掉再转发——否则 `./` 会解析到 `/mini/`,
|
|
989
|
+
// 那些脚本就跑到`/mini/assets/...` 上、被当成我们自己的地盘,全 404。
|
|
990
|
+
// **第一版就是这么白屏的**(真浏览器打开验出来的:DOM 拿到了、但一片空白)。
|
|
991
|
+
// ② **绝对的**:它调 API 走 `/api/...`、插件资源走 `/plugins/...`。
|
|
992
|
+
// 这些没法靠前缀,所以反过来——**凡是不是我们自己的路径,一律转给它**。
|
|
993
|
+
// 我们自己的地盘只有 `/`、`/mini`、`/mini/*`。
|
|
994
|
+
//
|
|
995
|
+
// **鉴权不能省**:这条路由等于把整个电脑端设置面(模型配置、凭据页、
|
|
996
|
+
// 插件装卸、会话记录……)交给持有我们令牌的人。所以要求和 `/mini/api/*` 同一道门。
|
|
997
|
+
// 手机那边不必每次都带参数——入口那一下写好了 cookie,而 cookie 的 Path 是 `/`,
|
|
998
|
+
// 界面自己发的那些请求会自然带上。
|
|
999
|
+
//
|
|
1000
|
+
// ## 还有一条:**我们自己的 `token` 参数不许跟着转发上去**
|
|
1001
|
+
//
|
|
1002
|
+
// 这是真机上试出来的:那个界面的插件脚本走一条合并加载路径
|
|
1003
|
+
// (`plugins/??@a/client.js&rev=xxx`),**上游对它是精确匹配**——多带一个
|
|
1004
|
+
// `&token=...` 就当场 404。而脚本一 404,界面就卡在启动画面报
|
|
1005
|
+
// 「HTML did not preload @deepseek-ai/dsh-client-modules/client.js」。
|
|
1006
|
+
// 所以转发前先把自己那个参数摘掉(**只在它确实是我们的令牌时摘**,
|
|
1007
|
+
// 免得误伤界面自己可能用到的同名参数)。
|
|
1008
|
+
if (mirror && typeof mirrorEnabled === 'function' && mirrorEnabled()) {
|
|
1009
|
+
const MIRROR_PREFIX = '/mini/mirror'
|
|
1010
|
+
/**
|
|
1011
|
+
* **诊断模式开关**(`?diag=1`)。
|
|
1012
|
+
*
|
|
1013
|
+
* 之后注入的那段采样脚本只看这一个参数(见 lib/mirror.js 的 DIAG_SCRIPT),
|
|
1014
|
+
* **不带它时行为完全不变**——这一条有测试钉着。
|
|
1015
|
+
*
|
|
1016
|
+
* 必须**在这里、在改写 `req.url` 之前**读出来:入口那一下会把查询串整个丢掉
|
|
1017
|
+
* (上游那条精确匹配的路由不认我们这些参数),丢掉之后就没得读了。
|
|
1018
|
+
*/
|
|
1019
|
+
const diag = url.searchParams.get('diag') === '1'
|
|
1020
|
+
const mirrorEntry = req.method === 'GET'
|
|
1021
|
+
&& (path === MIRROR_PREFIX || path === `${MIRROR_PREFIX}/`)
|
|
1022
|
+
const mirrorUnder = path.startsWith(`${MIRROR_PREFIX}/`)
|
|
1023
|
+
const ours = path === '/' || path === '/mini' || path === '/mini/'
|
|
1024
|
+
|| path === '/mini/sw.js' || path.startsWith('/mini/art/')
|
|
1025
|
+
// 三种都要放行:入口、挂在 `/mini/mirror/` 下面的相对路径、以及界面自己发的
|
|
1026
|
+
// 绝对路径(`/api/...`、`/plugins/...`)。**第二种漏掉过一次**——剥前缀那段
|
|
1027
|
+
// 就成了死代码,测试当场抓住。
|
|
1028
|
+
//
|
|
1029
|
+
// **第二种曾经还写错过一次**(2026-10-06 真机):当时限制成「只收 GET」,
|
|
1030
|
+
// 而界面外壳写着 `<base href="./">`,**它所有请求都带这个前缀**——于是那些 POST
|
|
1031
|
+
// (`/mini/mirror/api/session/list`、`settings/describe`、`agentPresets/list` …)
|
|
1032
|
+
// 全掉进我们自己的 404,界面上就是一片「加载不出来」。
|
|
1033
|
+
// 前缀下面的路径**本来就只可能是界面的**(我们自己的接口都在 `/mini/api/`),
|
|
1034
|
+
// 所以这里不该按方法设限。
|
|
1035
|
+
// **我们自己的客户端插件在镜像里也在跑**,它按同源取 `/mini-remote/*`
|
|
1036
|
+
// ——那几条是**宿主那边的**路由(`lib/index.js` 注册的),不是我们的。
|
|
1037
|
+
//
|
|
1038
|
+
// 可这些路径以 `/mini` 开头,会被下面那条「凡不是我们自己的地盘就转上游」判成
|
|
1039
|
+
// **我们的地盘**,于是回我们自己的 404 JSON:镜像里的「手机遥控」那一节就永远读不到
|
|
1040
|
+
// 配对/隧道/飞书的状态。2026-10-08 在活服务上量到:同一条路径在宿主那边是 200
|
|
1041
|
+
// (7360 字节 JSON),在我们这边是 `{"error":"not found"}`。
|
|
1042
|
+
//
|
|
1043
|
+
// 所以把它一并转上游。**它挂在开关里面**——开关关着时一个都不接,和别的路径同一条口径。
|
|
1044
|
+
const miniRemote = path === '/mini-remote' || path.startsWith('/mini-remote/')
|
|
1045
|
+
const toUpstream = mirrorEntry || mirrorUnder || miniRemote
|
|
1046
|
+
|| (!ours && !path.startsWith('/mini'))
|
|
1047
|
+
if (toUpstream) {
|
|
1048
|
+
if (!isAuthorized(req, url)) {
|
|
1049
|
+
if (hasCredential(req, url)) noteFailure(req)
|
|
1050
|
+
sendJson(res, 401, { error: '未授权:token 不正确' })
|
|
1051
|
+
return
|
|
1052
|
+
}
|
|
1053
|
+
noteSuccess(req)
|
|
1054
|
+
|
|
1055
|
+
if (mirrorEntry) {
|
|
1056
|
+
// **入口这一下要把 cookie 写上**,和首页那一条同款。
|
|
1057
|
+
//
|
|
1058
|
+
// 为什么非要这样:那个界面进去之后,它自己会发一堆 `/api/...`、
|
|
1059
|
+
// `/plugins/...` 的请求——**那些请求不会带 `?token=`**,只能靠 cookie 认。
|
|
1060
|
+
// 入口这一刻是唯一一次「用户带着 token 主动进来」的机会,错过它,
|
|
1061
|
+
// 界面能打开但里面每一格都是 401。
|
|
1062
|
+
const provided = url.searchParams.get('token')
|
|
1063
|
+
if (provided && tokenEquals(provided, token)) {
|
|
1064
|
+
res.setHeader('Set-Cookie',
|
|
1065
|
+
`${COOKIE}=${encodeURIComponent(token)}; Path=/; Max-Age=31536000; SameSite=Lax`)
|
|
1066
|
+
}
|
|
1067
|
+
if (path === MIRROR_PREFIX) {
|
|
1068
|
+
// 补上尾斜杠。**这一步不是洁癖**:外壳写着 `<base href="./">`,
|
|
1069
|
+
// 在 `/mini/mirror` 上它解析成 `/mini/`,在 `/mini/mirror/` 上才是
|
|
1070
|
+
// `/mini/mirror/`——差这一个字符,里面的脚本会全 404、界面白屏。
|
|
1071
|
+
// 补完就带上 cookie 了,所以这里不再带查询串——
|
|
1072
|
+
// **只有诊断开关要带过去**:用户拿到的那条链接可能没写尾斜杠,
|
|
1073
|
+
// 跳一下就把「诊断模式」丢了的话,真机上跑一次就白跑了。
|
|
1074
|
+
res.writeHead(302, {
|
|
1075
|
+
location: diag ? `${MIRROR_PREFIX}/?diag=1` : `${MIRROR_PREFIX}/`,
|
|
1076
|
+
'cache-control': 'no-store',
|
|
1077
|
+
})
|
|
1078
|
+
res.end()
|
|
1079
|
+
return
|
|
1080
|
+
}
|
|
1081
|
+
// 带斜杠的入口:就是上游的首页,**查询串整个丢掉**(里面只有我们的令牌,
|
|
1082
|
+
// 上游不认识它,留着反而会被它的精确路由判成不匹配)。
|
|
1083
|
+
const saved = req.url
|
|
1084
|
+
req.url = '/'
|
|
1085
|
+
try {
|
|
1086
|
+
await mirror.handle(req, res, { diag })
|
|
1087
|
+
} finally {
|
|
1088
|
+
req.url = saved
|
|
1089
|
+
}
|
|
1090
|
+
return
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// 剥掉挂载前缀:`/mini/mirror/assets/x` → 上游的 `/assets/x`。
|
|
1094
|
+
// **只有这一种要改路径。** 绝对路径(`/api/...`、`/plugins/...`)原样转发——
|
|
1095
|
+
// 第一版这里写成了「不是前缀就映射到 `/`」,等于把每个 API 调用都打到首页上,
|
|
1096
|
+
// 是测试当场抓住的。
|
|
1097
|
+
const saved = req.url
|
|
1098
|
+
req.url = stripDiagFlag(stripOurToken(mirrorUnder ? req.url.slice(MIRROR_PREFIX.length) : req.url))
|
|
1099
|
+
try {
|
|
1100
|
+
await mirror.handle(req, res)
|
|
1101
|
+
} finally {
|
|
1102
|
+
req.url = saved
|
|
1103
|
+
}
|
|
1104
|
+
return
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
825
1108
|
// ---- 其余一律要鉴权 -------------------------------------------------
|
|
826
1109
|
if (!path.startsWith('/mini/api/')) {
|
|
1110
|
+
// **镜像那几条路径走不通时,不许回那个给程序看的 JSON。**
|
|
1111
|
+
//
|
|
1112
|
+
// 上面那一整块镜像路由挂在开关上(`mirrorEnabled()` 同时看「开关」和「上游地址
|
|
1113
|
+
// 解没解析出来」),所以「开关关着」和「电脑端还没就绪」这两种时候,`/mini/mirror/…`
|
|
1114
|
+
// 会掉到这里。原来回的是 `{"error":"not found"}`——而手机那边是把镜像**嵌在 iframe 里**的,
|
|
1115
|
+
// 这个 JSON 就成了框里的**文档**,被浏览器当页面渲染(2026-10-08 用户真机截图:
|
|
1116
|
+
// 框里一条 `{"error":"not found"}`、外面那条「电脑端界面 / 返回」还好好地在)。
|
|
1117
|
+
// 这里改回一页人话;**状态码仍然是 404**(此刻这条路确实不存在),换的只是正文。
|
|
1118
|
+
if (path === '/mini/mirror' || path.startsWith('/mini/mirror/')) {
|
|
1119
|
+
sendMirrorUnavailable(res)
|
|
1120
|
+
return
|
|
1121
|
+
}
|
|
827
1122
|
sendJson(res, 404, { error: 'not found' })
|
|
828
1123
|
return
|
|
829
1124
|
}
|
|
@@ -1062,6 +1357,48 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
1062
1357
|
return
|
|
1063
1358
|
}
|
|
1064
1359
|
|
|
1360
|
+
// ---- 镜像页「诊断模式」的上报口 ---------------------------------------
|
|
1361
|
+
//
|
|
1362
|
+
// 只有 `?diag=1` 打开的那一页会往这里发东西(见 lib/mirror.js 的 DIAG_SCRIPT)。
|
|
1363
|
+
// **样本追加写到 scratch/diag.log**,一行一条、带时间戳——用户不用看,
|
|
1364
|
+
// 开发者读它就能把「首帧没字」的根因定死(点击前/点击后两份样本一比,
|
|
1365
|
+
// 到底哪个属性变了就露出来了)。
|
|
1366
|
+
//
|
|
1367
|
+
// 它不是「诊断模式」本身,只是一个**只写不读**的收件口,所以:
|
|
1368
|
+
// · 走和其他 `/mini/api/*` 同一道门(没令牌进不来);
|
|
1369
|
+
// · 不跟着镜像开关关——开关关着时根本没有哪一页会发东西过来,跟着关没意义;
|
|
1370
|
+
// · 写失败就**如实回错误**(前端那条小条会把原话显示出来),
|
|
1371
|
+
// 免得「真机跑一次、日志里什么都没有」这种事还要再猜一轮。
|
|
1372
|
+
if (req.method === 'POST' && path === '/mini/api/mirror-diag') {
|
|
1373
|
+
try {
|
|
1374
|
+
const payload = await readBody(req)
|
|
1375
|
+
const file = diagLogPath()
|
|
1376
|
+
await mkdir(dirname(file), { recursive: true })
|
|
1377
|
+
await appendFile(file, `\n=== ${new Date().toISOString()} ===\n${JSON.stringify(payload, null, 2)}\n`, 'utf8')
|
|
1378
|
+
sendJson(res, 200, { ok: true })
|
|
1379
|
+
} catch (err) {
|
|
1380
|
+
sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
1381
|
+
}
|
|
1382
|
+
return
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// ---- 「进阶设置(电脑端界面)」的开关 ---------------------------------
|
|
1386
|
+
// 只是那个开关的状态与读写;**真正的代理在 handle() 上面那一段**。
|
|
1387
|
+
// 两件事分开是有意的:状态随时可查(界面进来就要知道该画成什么样),
|
|
1388
|
+
// 而那条代理路由**关着时根本不存在**。
|
|
1389
|
+
if (req.method === 'GET' && path === '/mini/api/mirror') {
|
|
1390
|
+
const result = await nav.mirrorSetting()
|
|
1391
|
+
sendJson(res, result.ok ? 200 : 503, result)
|
|
1392
|
+
return
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
if (req.method === 'POST' && path === '/mini/api/mirror') {
|
|
1396
|
+
const body = await readBody(req)
|
|
1397
|
+
const result = await nav.setMirror(body?.enabled)
|
|
1398
|
+
sendJson(res, result.ok ? 200 : 400, result)
|
|
1399
|
+
return
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1065
1402
|
// ---- 斜杠指令列表 ----------------------------------------------------
|
|
1066
1403
|
// 手机在输入框里打 `/` 时要显示的那一份。**名单来自 DSH 自己的指令账本**,
|
|
1067
1404
|
// 所以电脑上新装一个带指令的插件,手机上刷新就有了,不用改这个插件。
|
|
@@ -1618,6 +1955,41 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
1618
1955
|
let port = startPort
|
|
1619
1956
|
for (const address of wanted) {
|
|
1620
1957
|
const server = createServer(onRequest)
|
|
1958
|
+
// WebSocket 不走 onRequest——它是一条 `Upgrade` 握手,Node 单独发这个事件。
|
|
1959
|
+
//
|
|
1960
|
+
// **手机和宿主之间那条常驻连接就是 WebSocket**(`/api/remote.mux`)。少了这一段,
|
|
1961
|
+
// 镜像里的界面会「一直正在重新连接」,插件那一列只剩图标没有文字、权限那栏写
|
|
1962
|
+
// 「permission catalog has no active Host connection」——**看起来像一堆显示毛病,
|
|
1963
|
+
// 其实全是同一条连接没通**(2026-10-06 真机报上来的)。
|
|
1964
|
+
//
|
|
1965
|
+
// 路由口径和 HTTP 那边保持一致:**我们自己的地盘(`/mini/...`)不转**,
|
|
1966
|
+
// 开关关着就一个都不接(不给一个绕过开关的通道)。
|
|
1967
|
+
// 路由口径和 HTTP 那边一致,但有一处不能照抄:官方那条连接是按页面的 base 解析出来的,
|
|
1968
|
+
// 而 base 是 `/mini/mirror/`(外壳写着 base href="./"),所以它实际请求的是
|
|
1969
|
+
// `/mini/mirror/api/remote.mux`——挂在镜像前缀**下面**。照 HTTP 那样「凡是 /mini/ 都拒掉」
|
|
1970
|
+
// 会把它一起拒掉,界面就永远在重连。(第一版就是这么写的,靠测试改成真实路径形态才发现。)
|
|
1971
|
+
const MIRROR_PREFIX = '/mini/mirror'
|
|
1972
|
+
server.on('upgrade', (req, socket, head) => {
|
|
1973
|
+
if (!mirror || typeof mirror.upgrade !== 'function'
|
|
1974
|
+
|| typeof mirrorEnabled !== 'function' || !mirrorEnabled()) {
|
|
1975
|
+
socket.destroy()
|
|
1976
|
+
return
|
|
1977
|
+
}
|
|
1978
|
+
const upPath = String(req.url ?? '/').split('?')[0]
|
|
1979
|
+
const under = upPath === MIRROR_PREFIX || upPath.startsWith(`${MIRROR_PREFIX}/`)
|
|
1980
|
+
const ours = !under
|
|
1981
|
+
&& (upPath === '/mini' || upPath === '/mini/' || upPath.startsWith('/mini/'))
|
|
1982
|
+
if (ours) {
|
|
1983
|
+
socket.destroy()
|
|
1984
|
+
return
|
|
1985
|
+
}
|
|
1986
|
+
// 挂在挂载点下面的剥掉前缀,绝对路径(`/api/...`)原样转——和 HTTP 那边一致。
|
|
1987
|
+
// `diag=1` 同样要摘掉(和 HTTP 那边同一个道理:上游不认它)。
|
|
1988
|
+
req.url = stripDiagFlag(under
|
|
1989
|
+
? stripOurToken(req.url.slice(MIRROR_PREFIX.length) || '/')
|
|
1990
|
+
: stripOurToken(req.url))
|
|
1991
|
+
mirror.upgrade(req, socket, head)
|
|
1992
|
+
})
|
|
1621
1993
|
try {
|
|
1622
1994
|
await new Promise((resolve, reject) => {
|
|
1623
1995
|
server.once('error', reject)
|
package/lib/store.js
CHANGED
|
@@ -27,6 +27,18 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
27
27
|
historyBySession: {}, // sessionId -> [{ role, text, timestamp, sessionId }]
|
|
28
28
|
/** 手机当前遥控的那个会话。 */
|
|
29
29
|
boundSessionId: null,
|
|
30
|
+
/**
|
|
31
|
+
* 「进阶设置(电脑端界面)」的开关。**默认关**。
|
|
32
|
+
*
|
|
33
|
+
* 为什么默认关:打开它 = 把这个令牌的权限**从「我们那张手机页暴露的那点事」
|
|
34
|
+
* 扩到整个电脑端设置面**(模型配置、凭据页、插件装卸、会话记录……)。
|
|
35
|
+
* 那是能力的大幅扩张,不该在用户不知情的时候发生——所以要他自己开,
|
|
36
|
+
* 而且界面上会把后果写清楚。
|
|
37
|
+
*
|
|
38
|
+
* 关着的时候那条代理路由**根本不存在**(见 server.js 里的判断),
|
|
39
|
+
* 不是「前端藏起来、后端还开着」。
|
|
40
|
+
*/
|
|
41
|
+
mirrorEnabled: false,
|
|
30
42
|
/** 手机会话在线状态,用于决定要不要发外部推送。 */
|
|
31
43
|
presence: 'foreground',
|
|
32
44
|
lastSeen: 0,
|
|
@@ -353,6 +365,9 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
353
365
|
}
|
|
354
366
|
}
|
|
355
367
|
if (raw.boundSessionId) state.boundSessionId = raw.boundSessionId
|
|
368
|
+
// 进阶模式:**只有明确是 true 才算开**。缺失、损坏、写成别的值一律当关——
|
|
369
|
+
// 这个开关关系到权限大小,读不出来时必须往「关」那边倒。
|
|
370
|
+
if (raw.mirrorEnabled === true) state.mirrorEnabled = true
|
|
356
371
|
}
|
|
357
372
|
} catch {
|
|
358
373
|
// 首次运行没有文件,或文件损坏——都不该让插件起不来。
|
|
@@ -372,6 +387,7 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
372
387
|
latestBySession: state.latestBySession,
|
|
373
388
|
historyBySession: state.historyBySession,
|
|
374
389
|
boundSessionId: state.boundSessionId,
|
|
390
|
+
mirrorEnabled: state.mirrorEnabled,
|
|
375
391
|
}))
|
|
376
392
|
renameSync(tmp, file)
|
|
377
393
|
} catch {
|
|
@@ -407,6 +423,31 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
407
423
|
s.running = running
|
|
408
424
|
},
|
|
409
425
|
|
|
426
|
+
/**
|
|
427
|
+
* 开/关「进阶设置(电脑端界面)」。**立即落盘**,不等那 500 毫秒的合并写。
|
|
428
|
+
*
|
|
429
|
+
* 为什么破例:这是个权限开关。用户开了之后马上就会去点那个入口,
|
|
430
|
+
* 万一这中间进程没了,重启后应当还是他最后选的那个状态——
|
|
431
|
+
* 合并写那 500 毫秒的窗口在这里不值得省。
|
|
432
|
+
*/
|
|
433
|
+
setMirrorEnabled(enabled) {
|
|
434
|
+
state.mirrorEnabled = enabled === true
|
|
435
|
+
try {
|
|
436
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
437
|
+
const tmp = `${file}.tmp`
|
|
438
|
+
writeFileSync(tmp, JSON.stringify({
|
|
439
|
+
latestBySession: state.latestBySession,
|
|
440
|
+
historyBySession: state.historyBySession,
|
|
441
|
+
boundSessionId: state.boundSessionId,
|
|
442
|
+
mirrorEnabled: state.mirrorEnabled,
|
|
443
|
+
}))
|
|
444
|
+
renameSync(tmp, file)
|
|
445
|
+
} catch {
|
|
446
|
+
// 落盘失败不影响本次运行——内存里那个值照常生效。
|
|
447
|
+
}
|
|
448
|
+
return state.mirrorEnabled
|
|
449
|
+
},
|
|
450
|
+
|
|
410
451
|
setSubagentRunning(parentSessionId, childSessionId, running) {
|
|
411
452
|
if (!parentSessionId || !childSessionId) return
|
|
412
453
|
const group = state.subagentRunningBySession[parentSessionId]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mini-remote",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "DSH 极简移动端遥控器:只把「你的指令」和「AI 的最终回复」送到手机,屏蔽全部中间过程。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"engines": {
|
|
26
26
|
"node": "^22.19.0 || >=24.0.0",
|
|
27
|
-
"dsh": ">=0.1.5-rc.2"
|
|
27
|
+
"dsh": ">=0.1.5-rc.2 <0.3.0"
|
|
28
28
|
},
|
|
29
29
|
"author": "xingzhen199186",
|
|
30
30
|
"repository": {
|