dsh-mini-remote 0.1.0 → 0.1.4
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 +82 -0
- package/README.en.md +160 -0
- package/README.md +81 -59
- package/client/client.js +148 -1
- package/lib/art/work-9-talking.webp +0 -0
- package/lib/browse.js +216 -0
- package/lib/commands.js +166 -0
- package/lib/events.js +52 -0
- package/lib/history.js +72 -0
- package/lib/index.js +801 -12
- package/lib/log-tail.js +235 -0
- package/lib/page.html +1699 -36
- package/lib/page.js +21 -0
- package/lib/pairing.js +110 -14
- package/lib/permissions.js +165 -0
- package/lib/serve.js +365 -0
- package/lib/server.js +465 -6
- package/lib/store.js +191 -8
- package/lib/sw.js +39 -0
- package/lib/tree.js +145 -3
- package/package.json +15 -6
- package/README.zh.md +0 -138
- package/client/verify.mjs +0 -514
package/README.zh.md
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
# dsh-mini-remote
|
|
2
|
-
|
|
3
|
-
> 一个 [DeepSeek Harness](https://github.com/deepseek-ai/DeepSeek-Harness)(DSH)插件,把一部手机变成**极简遥控器**——手机端只收到你发出去的指令和 AI 最后的结论,工具调用、文件读写、子 Agent 调度、思考过程全部留在电脑上。
|
|
4
|
-
|
|
5
|
-

|
|
6
|
-

|
|
7
|
-
|
|
8
|
-
[English →](README.md)
|
|
9
|
-
|
|
10
|
-
**要在手机端使用该插件,请将 PC 端 DSH 会话页面切换到【完全权限】**——手机上点不了审批弹窗,会话会一直卡在那里等。
|
|
11
|
-
|
|
12
|
-
---
|
|
13
|
-
|
|
14
|
-
## 为什么会有这个东西
|
|
15
|
-
|
|
16
|
-
做这个插件的初衷是因为现在AGENT任务执行一轮往往需要很久,而如果出门,则需要手机端来远程操作。
|
|
17
|
-
|
|
18
|
-
但现有的手机端,会把PC的执行步骤非常还原地展现出来。你发一条指令,它可能要想上几分钟、读几十个文件、调好几次工具,最后才给你一个结论。
|
|
19
|
-
|
|
20
|
-
我并不认为这种方式不好。信息完整、过程可控,如果你要认真处理任务,它是对的。
|
|
21
|
-
|
|
22
|
-
但出门在外的时候,就我个人而言,我需要的是另一种东西——**更轻便、更少占用注意力的交互方式**。手机屏幕就那么大,出门时的精力也就那么多。而大部分时候我真正需要的只有一件事:**这一轮跑完了,该发下一条指令了**。而且大部分时间,即使在电脑上,我也很少会去仔细阅读AI做执行过程中的“自言自语”。
|
|
23
|
-
|
|
24
|
-
所以这个插件的界面做得相当「原始」:它完全省略了电脑网页端的执行步骤,只把 AI 最后的结论送到你面前。
|
|
25
|
-
|
|
26
|
-
**某种意义上,它的存在,是为了让你更少地去看它。**
|
|
27
|
-
|
|
28
|
-
你可以更加自由地支配自己的时间,而不是当你和女儿玩耍时,疑惑出游时,被困在那个小小的屏幕里。
|
|
29
|
-
|
|
30
|
-
---
|
|
31
|
-
|
|
32
|
-
## 安装
|
|
33
|
-
|
|
34
|
-
```powershell
|
|
35
|
-
dsh plugin --profile web add github:xingzhen199186/dsh-mini-remote
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
包发布到 npm 之后,直接写包名也行:
|
|
39
|
-
|
|
40
|
-
```powershell
|
|
41
|
-
dsh plugin --profile web add dsh-mini-remote
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
手上有源码目录的话,把目录指给它也一样:
|
|
45
|
-
|
|
46
|
-
```powershell
|
|
47
|
-
dsh plugin --profile web add <你放源码的目录>
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
装完**必须重启一次 DSH**,设置页里才会出现新东西。重启后左栏会多出「手机遥控」,启动日志里也会打印手机访问地址和密码。
|
|
51
|
-
|
|
52
|
-
---
|
|
53
|
-
|
|
54
|
-
## 配置:三条路,按需要选
|
|
55
|
-
|
|
56
|
-
三条连接方式**同时存在**,选一条就行,也可以都开着。
|
|
57
|
-
|
|
58
|
-
打开 DSH 设置(侧边栏最下面)→ 左栏「手机遥控」。这一页上每条路都有一个二维码和一条链接,二维码底下那行小字写着**什么时候用这条**,看它就够了。
|
|
59
|
-
|
|
60
|
-
### 一、内网:只在家里用
|
|
61
|
-
|
|
62
|
-
手机和电脑连同一个 Wi-Fi 时,扫「内网」那个码就能用。**不需要任何额外安装**,最省事的一条。
|
|
63
|
-
|
|
64
|
-
代价是出了门就连不上——手机一切到流量,这条就断了。
|
|
65
|
-
|
|
66
|
-
### 二、Tailscale:出门也能连,地址还固定
|
|
67
|
-
|
|
68
|
-
电脑和手机都装上 [Tailscale](https://tailscale.com)、登录同一个账号,配对页上就会自动多出这一条。地址固定,配一次以后不用再管。
|
|
69
|
-
|
|
70
|
-
代价是两边都要装一个 App。如果你不介意装,**出门在外这条最稳**。
|
|
71
|
-
|
|
72
|
-
### 三、公网访问:什么都不用装
|
|
73
|
-
|
|
74
|
-
配对页最底下有个开关,打开它,Cloudflare 会给你一个公网网址。手机在任何网络下都能连,**不用装任何东西**。
|
|
75
|
-
|
|
76
|
-
两个代价,先知道再决定:
|
|
77
|
-
|
|
78
|
-
- **地址每次重启 DSH 都会换**,换了得回来重新扫一次码。
|
|
79
|
-
- **第一次打开会慢**——它在下载一个五十兆左右的组件,只下这一次。这期间状态那行会显示「正在打开…」,那是正常的,不是卡死。注册偶尔会失败,插件会自动重试 3 次。
|
|
80
|
-
|
|
81
|
-
打开之后**等半分钟再扫**。Cloudflare 需要一点时间把这个地址公布出去,太早扫会提示打不开——那不是坏了,是还没到时候。
|
|
82
|
-
|
|
83
|
-
---
|
|
84
|
-
|
|
85
|
-
<img width="1314" height="2186" alt="screenshot_20260922_233535_com huawei hmos brows" src="https://github.com/user-attachments/assets/8befa186-3e20-4c0e-8efc-8ea523f77648" />
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
## 手机端能做什么
|
|
89
|
-
|
|
90
|
-
打开链接就是全部界面:一个输入框,和最新的一条回复。
|
|
91
|
-
|
|
92
|
-
**发指令。** 打字发出去,AI 就在电脑上开始干活。
|
|
93
|
-
|
|
94
|
-
**等结论。** 跑的时候上面显示「运行中…」。这期间你可以接着发,指令会排队。任务结束,结论直接出现在页面上,同时响一声、震一下。
|
|
95
|
-
|
|
96
|
-
**两种显示模式。** 点右上角的 ⚙ 切换。「单帧」(默认)整屏只留最新一条回复,适合「我就想看这次的结果」;「聊天」是一来一回的气泡列表,适合连着追问好几轮。用一阵子你会知道自己偏哪个。
|
|
97
|
-
|
|
98
|
-
**看的是结论,不是过程。** 页面上没有工具调用链、没有文件 diff、没有审批弹窗——**需要你点确认的操作,手机上点不了**,得回电脑上处理。它也不替你在电脑上开新会话、切模型、改配置。它的定位是一根遥控器:电视还得在那儿开着,遥控器才有用。
|
|
99
|
-
|
|
100
|
-
---
|
|
101
|
-
|
|
102
|
-
## 常见问题
|
|
103
|
-
|
|
104
|
-
**扫码之后打不开。**
|
|
105
|
-
先看手机和电脑是不是同一个 Wi-Fi。
|
|
106
|
-
|
|
107
|
-
**出门扫「公网」那条打不开。**
|
|
108
|
-
等半分钟再试。还不行的话,配对页上会直接显示原因。最常见的一种是**电脑上开着代理或 VPN**——Clash 这类软件的「TUN 模式」会把隧道的连接掐断,进程看着好好的,其实一条连接都没建立起来,先把它关掉再试。还有一种:这条网络就是走不通 Cloudflare(国内时好时坏,不是你哪里配错了),那就别在公网上耗着,**改用 Tailscale 那条**。
|
|
109
|
-
|
|
110
|
-
**显示「未连接」。**
|
|
111
|
-
手机切后台再回来经常会这样,页面会自己重连,等几秒;还不行就进设置点「重连」。
|
|
112
|
-
|
|
113
|
-
**回复到了,但没声音。**
|
|
114
|
-
声音要你先碰过页面才会响,点一下就行。
|
|
115
|
-
|
|
116
|
-
**手机上一直让我输密码。**
|
|
117
|
-
说明你打开的链接里没带密码(比如自己手打的地址)。回电脑上重新扫一次二维码就好。
|
|
118
|
-
|
|
119
|
-
**想换一个自己记得住的密码。**
|
|
120
|
-
在「设置 → 手机遥控」的密码那一块直接改,**至少 12 位**。改完要重启一次 DSH,重启之后手机上要重新扫一次码。
|
|
121
|
-
|
|
122
|
-
---
|
|
123
|
-
|
|
124
|
-
## 安全
|
|
125
|
-
|
|
126
|
-
**配对页上带着密码,别截图发出去。** 密码直接编在二维码里,扫完不用手敲——方便是方便,代价是谁拿到这张图谁就能进。
|
|
127
|
-
|
|
128
|
-
除此之外:所有接口都要密码,密码比对用定长比较;同一来源一分钟错 5 次就挡一分钟;配对信息**只允许本机读取**,同一个 Wi-Fi 下的其他设备拿不到;公网隧道默认关着,要你自己在设置页里打开;手机拿不到你的文件系统。
|
|
129
|
-
|
|
130
|
-
---
|
|
131
|
-
|
|
132
|
-
## 致谢
|
|
133
|
-
|
|
134
|
-
这个插件在设计思路上参考过 [dsh-pocket](https://www.npmjs.com/package/dsh-pocket)(作者 shaobeichen)——尤其是「每条连接路径各配一条链接、一个二维码、一个密码」这种配置形态。
|
|
135
|
-
|
|
136
|
-
## 许可
|
|
137
|
-
|
|
138
|
-
MIT
|
package/client/verify.mjs
DELETED
|
@@ -1,514 +0,0 @@
|
|
|
1
|
-
// client/verify.mjs —— 手工验证 client/client.js 是否真的能用(零依赖、不需要测试框架)。
|
|
2
|
-
//
|
|
3
|
-
// 用法:node client/verify.mjs
|
|
4
|
-
//
|
|
5
|
-
// 做四件事:
|
|
6
|
-
// 1. 伪造 window.__ModuleLoader__,把整个文件 eval 一遍,确认模块外壳被注册;
|
|
7
|
-
// 2. 用 React 替身调用 factory,检查 exports(name / inject / apply);
|
|
8
|
-
// 3. 用假的 ctx 调 apply,检查注册进 settings.section 的 def 和组件;
|
|
9
|
-
// 4. 用替身 React 真渲染一遍组件:加载中 → ok:false → ok:true → 网络错误,
|
|
10
|
-
// 并点一次「复制」按钮,确认文案临时变成「已复制」。
|
|
11
|
-
|
|
12
|
-
import { readFileSync } from 'node:fs';
|
|
13
|
-
import { dirname, join } from 'node:path';
|
|
14
|
-
import { fileURLToPath } from 'node:url';
|
|
15
|
-
import assert from 'node:assert/strict';
|
|
16
|
-
|
|
17
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
-
const source = readFileSync(join(here, 'client.js'), 'utf8');
|
|
19
|
-
|
|
20
|
-
// ---------------------------------------------------------------- 小工具
|
|
21
|
-
|
|
22
|
-
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
23
|
-
|
|
24
|
-
const setGlobal = (name, value) =>
|
|
25
|
-
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
|
|
26
|
-
|
|
27
|
-
/** 把渲染树摊平成纯文本,用来断言界面上出现了哪些字。 */
|
|
28
|
-
function textOf(node) {
|
|
29
|
-
if (node === null || node === undefined || typeof node === 'boolean') return '';
|
|
30
|
-
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
|
31
|
-
if (Array.isArray(node)) return node.map(textOf).join(' ');
|
|
32
|
-
return textOf(node.children);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** 收集渲染树里所有元素节点(深度优先)。 */
|
|
36
|
-
function walk(node, out = []) {
|
|
37
|
-
if (node === null || node === undefined || typeof node !== 'object') return out;
|
|
38
|
-
if (Array.isArray(node)) {
|
|
39
|
-
for (const child of node) walk(child, out);
|
|
40
|
-
return out;
|
|
41
|
-
}
|
|
42
|
-
out.push(node);
|
|
43
|
-
walk(node.children, out);
|
|
44
|
-
return out;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const byType = (tree, type) => walk(tree).filter((node) => node.type === type);
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* 最小 React 替身 + 最小渲染循环。
|
|
51
|
-
* createElement 返回普通对象;useState 真的能改状态并触发重渲染;
|
|
52
|
-
* useEffect 不在渲染时执行,由 runEffects() 显式跑一次(和 React 一致)。
|
|
53
|
-
*/
|
|
54
|
-
function createHarness() {
|
|
55
|
-
const cells = [];
|
|
56
|
-
let cursor = 0;
|
|
57
|
-
let pending = [];
|
|
58
|
-
let cleanups = [];
|
|
59
|
-
let onUpdate = null;
|
|
60
|
-
|
|
61
|
-
const React = {
|
|
62
|
-
createElement(type, props, ...children) {
|
|
63
|
-
return { type, props: props || {}, children };
|
|
64
|
-
},
|
|
65
|
-
useState(initial) {
|
|
66
|
-
const i = cursor++;
|
|
67
|
-
if (i >= cells.length) cells.push(typeof initial === 'function' ? initial() : initial);
|
|
68
|
-
const set = (next) => {
|
|
69
|
-
cells[i] = typeof next === 'function' ? next(cells[i]) : next;
|
|
70
|
-
if (onUpdate) onUpdate();
|
|
71
|
-
};
|
|
72
|
-
return [cells[i], set];
|
|
73
|
-
},
|
|
74
|
-
useEffect(fn) { pending.push(fn); },
|
|
75
|
-
useRef(initial) { return { current: initial }; },
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
return {
|
|
79
|
-
React,
|
|
80
|
-
/** 渲染一次;重渲染会丢掉上一次登记的 effect(等价于依赖数组没变时不重跑)。 */
|
|
81
|
-
draw(Comp) { cursor = 0; pending = []; return Comp({}); },
|
|
82
|
-
runEffects() {
|
|
83
|
-
const fns = pending;
|
|
84
|
-
pending = [];
|
|
85
|
-
for (const fn of fns) {
|
|
86
|
-
const dispose = fn();
|
|
87
|
-
if (typeof dispose === 'function') cleanups.push(dispose);
|
|
88
|
-
}
|
|
89
|
-
},
|
|
90
|
-
unmount() { for (const dispose of cleanups) dispose(); cleanups = []; },
|
|
91
|
-
onUpdate(fn) { onUpdate = fn; },
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* 组件在 factory 里就把 React.useState/useEffect 绑进了工厂闭包,所以替身必须是
|
|
97
|
-
* 一个稳定的门面对象,内部转发给「当前这一套状态」——否则第二套 harness 永远不生效。
|
|
98
|
-
*/
|
|
99
|
-
let active = null;
|
|
100
|
-
const ReactFacade = {
|
|
101
|
-
createElement: (type, props, ...children) => active.React.createElement(type, props, ...children),
|
|
102
|
-
useState: (initial) => active.React.useState(initial),
|
|
103
|
-
useEffect: (fn) => active.React.useEffect(fn),
|
|
104
|
-
useRef: (initial) => active.React.useRef(initial),
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
/** 新建一套状态单元并设为当前(每个渲染用例一套,互不干扰)。 */
|
|
108
|
-
function useHarness() {
|
|
109
|
-
active = createHarness();
|
|
110
|
-
return active;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** 假的 fetch 响应。 */
|
|
114
|
-
const jsonResponse = (status, body) => ({
|
|
115
|
-
status,
|
|
116
|
-
json: async () => body,
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
// ---------------------------------------------------------------- 断言运行器
|
|
120
|
-
|
|
121
|
-
let passed = 0;
|
|
122
|
-
const failures = [];
|
|
123
|
-
|
|
124
|
-
async function check(label, fn) {
|
|
125
|
-
try {
|
|
126
|
-
await fn();
|
|
127
|
-
passed += 1;
|
|
128
|
-
console.log(` ✓ ${label}`);
|
|
129
|
-
} catch (err) {
|
|
130
|
-
failures.push({ label, err });
|
|
131
|
-
console.log(` ✗ ${label}`);
|
|
132
|
-
console.log(` ${err && err.message ? err.message : err}`);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// ---------------------------------------------------------------- 1. 模块外壳
|
|
137
|
-
|
|
138
|
-
let captured = null;
|
|
139
|
-
setGlobal('window', { __ModuleLoader__: { load: (registration) => { captured = registration; } } });
|
|
140
|
-
(0, eval)(source); // 间接 eval:在全局作用域执行,模拟浏览器加载这个脚本
|
|
141
|
-
|
|
142
|
-
console.log('\n[1] 模块外壳 window.__ModuleLoader__.load');
|
|
143
|
-
|
|
144
|
-
await check('文件执行后捕获到一次注册', () => {
|
|
145
|
-
assert.ok(captured, '没有调用 window.__ModuleLoader__.load');
|
|
146
|
-
});
|
|
147
|
-
await check('id === "dsh-mini-remote"', () => {
|
|
148
|
-
assert.equal(captured.id, 'dsh-mini-remote');
|
|
149
|
-
});
|
|
150
|
-
await check('factory 是函数', () => {
|
|
151
|
-
assert.equal(typeof captured.factory, 'function');
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
// ---------------------------------------------------------------- 2. factory / exports
|
|
155
|
-
|
|
156
|
-
const harness = useHarness();
|
|
157
|
-
const required = [];
|
|
158
|
-
const fakeRequire = (spec) => {
|
|
159
|
-
required.push(spec);
|
|
160
|
-
if (spec === 'react') return ReactFacade;
|
|
161
|
-
throw new Error(`客户端模块只允许 require("react"),实际请求了 "${spec}"`);
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
let mod = null;
|
|
165
|
-
console.log('\n[2] factory(require) 的返回值');
|
|
166
|
-
await check('factory 不抛错并返回 exports', () => {
|
|
167
|
-
mod = captured.factory(fakeRequire);
|
|
168
|
-
assert.ok(mod && typeof mod === 'object');
|
|
169
|
-
});
|
|
170
|
-
await check('只 require 了 react(零运行时依赖)', () => {
|
|
171
|
-
assert.deepEqual(required, ['react']);
|
|
172
|
-
});
|
|
173
|
-
await check('exports.name === "dsh-mini-remote"', () => {
|
|
174
|
-
assert.equal(mod.name, 'dsh-mini-remote');
|
|
175
|
-
});
|
|
176
|
-
await check("exports.inject 深等于 ['slots']", () => {
|
|
177
|
-
assert.deepEqual(mod.inject, ['slots']);
|
|
178
|
-
});
|
|
179
|
-
await check('exports.apply 是函数', () => {
|
|
180
|
-
assert.equal(typeof mod.apply, 'function');
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
// ---------------------------------------------------------------- 3. apply / slots 注册
|
|
184
|
-
|
|
185
|
-
const injected = [];
|
|
186
|
-
const registered = [];
|
|
187
|
-
const fakeCtx = {
|
|
188
|
-
slots: {
|
|
189
|
-
inject(name, callback) { injected.push(name); return callback(); },
|
|
190
|
-
register(def, Comp) { registered.push({ def, Comp }); return () => {}; },
|
|
191
|
-
},
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
let Comp = null;
|
|
195
|
-
let def = null;
|
|
196
|
-
console.log('\n[3] apply(ctx) 的 slots 注册');
|
|
197
|
-
await check('apply(fakeCtx) 不抛错', () => {
|
|
198
|
-
assert.doesNotThrow(() => mod.apply(fakeCtx));
|
|
199
|
-
});
|
|
200
|
-
await check('inject 的服务名是 "settings.section"', () => {
|
|
201
|
-
assert.deepEqual(injected, ['settings.section']);
|
|
202
|
-
});
|
|
203
|
-
await check('register 只调用了一次', () => {
|
|
204
|
-
assert.equal(registered.length, 1);
|
|
205
|
-
});
|
|
206
|
-
await check('def.name === "settings.section"', () => {
|
|
207
|
-
def = registered[0].def;
|
|
208
|
-
assert.equal(def.name, 'settings.section');
|
|
209
|
-
});
|
|
210
|
-
await check('def.id === "mini-remote"', () => {
|
|
211
|
-
assert.equal(def.id, 'mini-remote');
|
|
212
|
-
});
|
|
213
|
-
await check('def.order === 2', () => {
|
|
214
|
-
assert.equal(def.order, 2);
|
|
215
|
-
});
|
|
216
|
-
await check('def.label 是函数(typeof === "function")', () => {
|
|
217
|
-
assert.equal(typeof def.label, 'function');
|
|
218
|
-
});
|
|
219
|
-
await check('def.label() === "手机遥控"', () => {
|
|
220
|
-
assert.equal(def.label(), '手机遥控');
|
|
221
|
-
});
|
|
222
|
-
await check('第二个参数是组件函数', () => {
|
|
223
|
-
Comp = registered[0].Comp;
|
|
224
|
-
assert.equal(typeof Comp, 'function');
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
// ---------------------------------------------------------------- 4. 渲染组件
|
|
228
|
-
|
|
229
|
-
const TOKEN = '0123456789abcdef0123456789abcdef';
|
|
230
|
-
const URL_LAN = `http://192.168.1.2:3090/mini?token=${TOKEN}`;
|
|
231
|
-
const URL_TS = `http://100.92.105.99:3090/mini?token=${TOKEN}`;
|
|
232
|
-
const QR = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
|
233
|
-
|
|
234
|
-
console.log('\n[4] 组件渲染');
|
|
235
|
-
|
|
236
|
-
// 4a. 加载中
|
|
237
|
-
let resolveFetch = null;
|
|
238
|
-
setGlobal('fetch', () => new Promise((resolve) => { resolveFetch = resolve; }));
|
|
239
|
-
{
|
|
240
|
-
const h4 = useHarness();
|
|
241
|
-
let tree = null;
|
|
242
|
-
h4.onUpdate(() => { tree = h4.draw(Comp); });
|
|
243
|
-
await check('挂载时不抛错,并显示「正在读取配对信息…」', () => {
|
|
244
|
-
tree = h4.draw(Comp);
|
|
245
|
-
assert.notEqual(tree, undefined);
|
|
246
|
-
assert.match(textOf(tree), /正在读取配对信息…/);
|
|
247
|
-
});
|
|
248
|
-
await check('useEffect 里发起了同源 GET /mini-remote/pairing', () => {
|
|
249
|
-
h4.runEffects();
|
|
250
|
-
assert.equal(typeof resolveFetch, 'function', 'useEffect 里没有发起 fetch');
|
|
251
|
-
});
|
|
252
|
-
await check('顶部两句说明都在', () => {
|
|
253
|
-
const text = textOf(tree);
|
|
254
|
-
assert.match(text, /手机扫码就能用,不用输密码——密码已经编在二维码里了。/);
|
|
255
|
-
assert.match(text, /这个页面带着密码,别截图发出去。/);
|
|
256
|
-
});
|
|
257
|
-
await check('HTTP 403 + {ok:false,error} → 显示 error 原文', async () => {
|
|
258
|
-
resolveFetch(jsonResponse(403, { ok: false, error: '配对信息只能在这台电脑上看。' }));
|
|
259
|
-
await tick();
|
|
260
|
-
assert.match(textOf(tree), /配对信息只能在这台电脑上看。/);
|
|
261
|
-
assert.doesNotMatch(textOf(tree), /正在读取配对信息…/);
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// 4b. 网络错误(fetch 直接失败)
|
|
266
|
-
{
|
|
267
|
-
const h4 = useHarness();
|
|
268
|
-
setGlobal('fetch', () => Promise.reject(new Error('Failed to fetch')));
|
|
269
|
-
let tree = null;
|
|
270
|
-
h4.onUpdate(() => { tree = h4.draw(Comp); });
|
|
271
|
-
await check('网络错误 → 显示失败原因,不吞掉', async () => {
|
|
272
|
-
tree = h4.draw(Comp);
|
|
273
|
-
h4.runEffects();
|
|
274
|
-
await tick();
|
|
275
|
-
assert.match(textOf(tree), /连不上这台电脑上的遥控服务:Failed to fetch/);
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// 4c. 成功:两条 entry(第二条没有二维码)
|
|
280
|
-
{
|
|
281
|
-
const h4 = useHarness();
|
|
282
|
-
setGlobal('fetch', async () => jsonResponse(200, {
|
|
283
|
-
ok: true,
|
|
284
|
-
port: 3090,
|
|
285
|
-
token: TOKEN,
|
|
286
|
-
entries: [
|
|
287
|
-
{ kind: 'lan', label: '内网', hint: '手机连同一个 Wi-Fi 时用这个', url: URL_LAN, qr: QR },
|
|
288
|
-
{ kind: 'tailscale', label: 'Tailscale', hint: '在外面也能用', url: URL_TS, qr: null },
|
|
289
|
-
],
|
|
290
|
-
}));
|
|
291
|
-
let tree = null;
|
|
292
|
-
h4.onUpdate(() => { tree = h4.draw(Comp); });
|
|
293
|
-
|
|
294
|
-
await check('ok:true 时渲染两块 entry(label + hint)', async () => {
|
|
295
|
-
tree = h4.draw(Comp);
|
|
296
|
-
h4.runEffects();
|
|
297
|
-
await tick();
|
|
298
|
-
const text = textOf(tree);
|
|
299
|
-
assert.match(text, /内网/);
|
|
300
|
-
assert.match(text, /手机连同一个 Wi-Fi 时用这个/);
|
|
301
|
-
assert.match(text, /Tailscale/);
|
|
302
|
-
assert.match(text, /在外面也能用/);
|
|
303
|
-
assert.doesNotMatch(text, /正在读取配对信息…/);
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
await check('二维码渲染成 <img src={entry.qr}>(宽 180)', () => {
|
|
307
|
-
const imgs = byType(tree, 'img');
|
|
308
|
-
assert.equal(imgs.length, 1, '有 qr 的只有一条,qr:null 的那条应退化成文字');
|
|
309
|
-
assert.equal(imgs[0].props.src, QR);
|
|
310
|
-
assert.equal(imgs[0].props.style.width, 180);
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
await check('两条 URL 都在只读 <input> 里,不是纯文本 div', () => {
|
|
314
|
-
// 按内容找,不按位置也不数总数:面板上还有别的输入框(密码、改密码那两处),
|
|
315
|
-
// 数总数的话,每加一个输入框这里都要误报一次。
|
|
316
|
-
const urls = byType(tree, 'input').filter((n) => n.props.value === URL_LAN || n.props.value === URL_TS);
|
|
317
|
-
assert.equal(urls.length, 2);
|
|
318
|
-
assert.equal(urls[0].props.readOnly, true);
|
|
319
|
-
assert.equal(urls[1].props.readOnly, true);
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
await check('点输入框会全选地址', () => {
|
|
323
|
-
let selected = false;
|
|
324
|
-
const lan = byType(tree, 'input').find((n) => n.props.value === URL_LAN);
|
|
325
|
-
lan.props.onFocus({ target: { select() { selected = true; } } });
|
|
326
|
-
assert.equal(selected, true);
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
await check('qr 为 null 时退化成文字,界面不崩', () => {
|
|
330
|
-
assert.match(textOf(tree), /二维码没画出来/);
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
await check('每条链接各有一个「复制」按钮,初始文案是「复制」', () => {
|
|
334
|
-
// 同样按文案找,不数总数:密码那一块也有「复制」。
|
|
335
|
-
const copies = byType(tree, 'button').filter((b) => textOf(b) === '复制');
|
|
336
|
-
assert.ok(copies.length >= 2, '两条链接各要有一个「复制」按钮');
|
|
337
|
-
assert.equal(textOf(copies[0]), '复制');
|
|
338
|
-
assert.equal(textOf(copies[1]), '复制');
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
// 2026-09-22 用户报:「手机遥控页面里似乎根本没有密码,也无法自定义密码」。
|
|
342
|
-
// 配对接口一直返回着 token,只是没人渲染它——这三项钉住它真的露出来了。
|
|
343
|
-
await check('密码默认打码显示,不是明文', () => {
|
|
344
|
-
const masked = TOKEN.replace(/./g, '•');
|
|
345
|
-
const pw = byType(tree, 'input').find((n) => n.props.value === masked);
|
|
346
|
-
assert.ok(pw, '默认该显示打码后的密码');
|
|
347
|
-
assert.equal(pw.props.readOnly, true, '密码框只能看,不能改——改要走保存那条路');
|
|
348
|
-
assert.ok(!byType(tree, 'input').some((n) => n.props.value === TOKEN), '没点「显示」之前不该出现明文');
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
await check('点「显示」→ 明文,再点「藏起来」→ 又打码', async () => {
|
|
352
|
-
const masked = TOKEN.replace(/./g, '•');
|
|
353
|
-
const showBtn = byType(tree, 'button').find((b) => textOf(b) === '显示');
|
|
354
|
-
assert.ok(showBtn, '要有一个「显示」按钮');
|
|
355
|
-
showBtn.props.onClick();
|
|
356
|
-
await tick();
|
|
357
|
-
tree = h4.draw(Comp);
|
|
358
|
-
assert.ok(byType(tree, 'input').some((n) => n.props.value === TOKEN), '点过之后应显示明文');
|
|
359
|
-
const hideBtn = byType(tree, 'button').find((b) => textOf(b) === '藏起来');
|
|
360
|
-
assert.ok(hideBtn, '显示之后按钮该变成「藏起来」');
|
|
361
|
-
hideBtn.props.onClick();
|
|
362
|
-
await tick();
|
|
363
|
-
tree = h4.draw(Comp);
|
|
364
|
-
assert.ok(byType(tree, 'input').some((n) => n.props.value === masked), '再点一次该变回打码');
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
await check('点密码旁边的「复制」→ 复制的是密码本身', async () => {
|
|
368
|
-
const written = [];
|
|
369
|
-
setGlobal('navigator', { clipboard: { writeText: async (text) => { written.push(text); } } });
|
|
370
|
-
// 链接那两个「复制」在前,密码那个在最后
|
|
371
|
-
const copies = byType(tree, 'button').filter((b) => textOf(b) === '复制');
|
|
372
|
-
copies[copies.length - 1].props.onClick();
|
|
373
|
-
await tick();
|
|
374
|
-
assert.deepEqual(written, [TOKEN]);
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
await check('点「复制」→ navigator.clipboard.writeText(url),文案变「已复制」', async () => {
|
|
378
|
-
const written = [];
|
|
379
|
-
setGlobal('navigator', { clipboard: { writeText: async (text) => { written.push(text); } } });
|
|
380
|
-
byType(tree, 'button')[0].props.onClick();
|
|
381
|
-
await tick();
|
|
382
|
-
assert.deepEqual(written, [URL_LAN]);
|
|
383
|
-
const buttons = byType(tree, 'button');
|
|
384
|
-
assert.equal(textOf(buttons[0]), '已复制');
|
|
385
|
-
assert.equal(textOf(buttons[1]), '复制', '只改被点的那一个');
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
await check('复制失败时不谎报「已复制」', async () => {
|
|
389
|
-
const h5 = useHarness();
|
|
390
|
-
let tree5 = null;
|
|
391
|
-
h5.onUpdate(() => { tree5 = h5.draw(Comp); });
|
|
392
|
-
tree5 = h5.draw(Comp);
|
|
393
|
-
h5.runEffects();
|
|
394
|
-
await tick();
|
|
395
|
-
setGlobal('navigator', { clipboard: { writeText: async () => { throw new Error('NotAllowedError'); } } });
|
|
396
|
-
byType(tree5, 'button')[0].props.onClick();
|
|
397
|
-
await tick();
|
|
398
|
-
assert.equal(textOf(byType(tree5, 'button')[0]), '复制');
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// 4d. 公网访问:开关、状态、报错、以及 ok:false 时也要能关掉它
|
|
403
|
-
{
|
|
404
|
-
const withTunnel = (tunnel, extra) => Object.assign({
|
|
405
|
-
ok: true,
|
|
406
|
-
port: 3090,
|
|
407
|
-
token: TOKEN,
|
|
408
|
-
entries: [{ kind: 'lan', label: '内网', hint: '手机连同一个 Wi-Fi 时用这个', url: URL_LAN, qr: QR }],
|
|
409
|
-
tunnel,
|
|
410
|
-
}, extra || {});
|
|
411
|
-
|
|
412
|
-
const h6 = useHarness();
|
|
413
|
-
let tree6 = null;
|
|
414
|
-
let posted = [];
|
|
415
|
-
h6.onUpdate(() => { tree6 = h6.draw(Comp); });
|
|
416
|
-
|
|
417
|
-
const mount = async (payload) => {
|
|
418
|
-
setGlobal('fetch', async (url, init) => {
|
|
419
|
-
if (init && init.method === 'POST') {
|
|
420
|
-
posted.push({ url, body: JSON.parse(init.body) });
|
|
421
|
-
return jsonResponse(200, withTunnel({ enabled: JSON.parse(init.body).enabled, up: false, starting: false, error: null }));
|
|
422
|
-
}
|
|
423
|
-
return jsonResponse(200, payload);
|
|
424
|
-
});
|
|
425
|
-
tree6 = h6.draw(Comp);
|
|
426
|
-
h6.runEffects();
|
|
427
|
-
await tick();
|
|
428
|
-
};
|
|
429
|
-
|
|
430
|
-
await check('公网没开时:开关是未选中的,并且说明「出门进不来」', async () => {
|
|
431
|
-
await mount(withTunnel({ enabled: false, up: false, starting: false, error: null }));
|
|
432
|
-
const boxes = byType(tree6, 'input').filter((n) => n.props.type === 'checkbox');
|
|
433
|
-
assert.equal(boxes.length, 1, '要有一个公网开关');
|
|
434
|
-
assert.equal(boxes[0].props.checked, false);
|
|
435
|
-
const text = textOf(tree6);
|
|
436
|
-
assert.match(text, /公网访问/);
|
|
437
|
-
assert.match(text, /手机不连家里 Wi-Fi 的时候就进不来/);
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
await check('公网开着且有地址时:开关选中,提示去看「公网」那条', async () => {
|
|
441
|
-
await mount(withTunnel({ enabled: true, up: true, starting: false, error: null }));
|
|
442
|
-
const boxes = byType(tree6, 'input').filter((n) => n.props.type === 'checkbox');
|
|
443
|
-
assert.equal(boxes[0].props.checked, true);
|
|
444
|
-
const text = textOf(tree6);
|
|
445
|
-
assert.match(text, /上面那条「公网」/);
|
|
446
|
-
assert.match(text, /每次重启 DSH 都会换一个/, '要提前说清楚地址会变');
|
|
447
|
-
assert.match(text, /挂在公网上/, '开了要提醒它现在暴露在公网');
|
|
448
|
-
});
|
|
449
|
-
|
|
450
|
-
await check('公网开着但连不上时:原文报错照登,不吞掉', async () => {
|
|
451
|
-
await mount(withTunnel({ enabled: true, up: false, starting: false, error: '所有下载源都没成功:\n github.com — 超时' }));
|
|
452
|
-
const text = textOf(tree6);
|
|
453
|
-
assert.match(text, /开着,但公网那边连不上/, '要让用户知道这条路现在是死的');
|
|
454
|
-
assert.match(text, /所有下载源都没成功/, '底层原因要透出来,否则没法排查');
|
|
455
|
-
assert.match(text, /github\.com — 超时/);
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
// 2026-09-22 用户照着一个写着「已开启」的面板去扫码,扫出来是 Cloudflare 的 Error 1033。
|
|
459
|
-
// 这两种情况必须分开说:**还在等地址**和**地址拿到过、后来断了**,用户的下一步动作不同。
|
|
460
|
-
await check('公网开着、没有报错时:说的是「还没拿到地址」,不是「连不上」', async () => {
|
|
461
|
-
await mount(withTunnel({ enabled: true, up: false, starting: false, error: null }));
|
|
462
|
-
const text = textOf(tree6);
|
|
463
|
-
assert.match(text, /已开启,但还没拿到公网地址/);
|
|
464
|
-
assert.doesNotMatch(text, /公网那边连不上/, '还没报错就不该先吓唬人');
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
await check('正在打开时:开关禁用,避免连点起出好几条隧道', async () => {
|
|
468
|
-
await mount(withTunnel({ enabled: true, up: false, starting: true, error: null }));
|
|
469
|
-
const boxes = byType(tree6, 'input').filter((n) => n.props.type === 'checkbox');
|
|
470
|
-
assert.equal(boxes[0].props.disabled, true);
|
|
471
|
-
assert.match(textOf(tree6), /正在打开/);
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
await check('勾上开关 → POST /mini-remote/tunnel,body 是 {enabled:true}', async () => {
|
|
475
|
-
posted = [];
|
|
476
|
-
await mount(withTunnel({ enabled: false, up: false, starting: false, error: null }));
|
|
477
|
-
const box = byType(tree6, 'input').filter((n) => n.props.type === 'checkbox')[0];
|
|
478
|
-
box.props.onChange({ target: { checked: true } });
|
|
479
|
-
await tick();
|
|
480
|
-
assert.equal(posted.length, 1, '只该发一次请求');
|
|
481
|
-
assert.equal(posted[0].url, '/mini-remote/tunnel');
|
|
482
|
-
assert.deepEqual(posted[0].body, { enabled: true });
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
await check('关掉开关 → body 是 {enabled:false}', async () => {
|
|
486
|
-
posted = [];
|
|
487
|
-
await mount(withTunnel({ enabled: true, up: true, starting: false, error: null }));
|
|
488
|
-
const box = byType(tree6, 'input').filter((n) => n.props.type === 'checkbox')[0];
|
|
489
|
-
box.props.onChange({ target: { checked: false } });
|
|
490
|
-
await tick();
|
|
491
|
-
assert.equal(posted.length, 1);
|
|
492
|
-
assert.deepEqual(posted[0].body, { enabled: false });
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
await check('整块面板报错时,公网开关仍然露得出来', async () => {
|
|
496
|
-
// 否则「隧道起不来」会把面板变成一条报错,用户连关掉它的地方都找不到
|
|
497
|
-
await mount(withTunnel(
|
|
498
|
-
{ enabled: true, up: false, starting: false, error: '找不到 cloudflared' },
|
|
499
|
-
{ ok: false, error: '公网访问没起来:找不到 cloudflared', entries: undefined },
|
|
500
|
-
));
|
|
501
|
-
const boxes = byType(tree6, 'input').filter((n) => n.props.type === 'checkbox');
|
|
502
|
-
assert.equal(boxes.length, 1, '报错时也得能点得动开关');
|
|
503
|
-
assert.equal(boxes[0].props.checked, true);
|
|
504
|
-
assert.match(textOf(tree6), /公网访问没起来/);
|
|
505
|
-
});
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// ---------------------------------------------------------------- 结果
|
|
509
|
-
|
|
510
|
-
console.log(`\n通过 ${passed} 项,失败 ${failures.length} 项。`);
|
|
511
|
-
if (failures.length) {
|
|
512
|
-
for (const { label, err } of failures) console.log(` ✗ ${label}: ${err && err.stack ? err.stack : err}`);
|
|
513
|
-
process.exitCode = 1;
|
|
514
|
-
}
|