@mrrisega/dsh-remote 0.3.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.
@@ -0,0 +1,282 @@
1
+ // dsh-remote-ui 浏览器半回归:面板入口迁移进「设置」页 settings.section 官方扩展点。
2
+ // 覆盖:栏目注册(id/order/label)、侧边栏入口与浮动面板移除、账号区无「切换账号」、
3
+ // 「关于 dsh-remote」说明卡片、退出登录/切换连接账号清理反馈线程凭据、首次安装红点引导。
4
+ import assert from "node:assert/strict";
5
+ import { readFileSync } from "node:fs";
6
+ import test from "node:test";
7
+ import vm from "node:vm";
8
+
9
+ const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
10
+
11
+ function walk(node, visit) {
12
+ if (!node || typeof node !== "object") return;
13
+ visit(node);
14
+ for (const child of node.children || []) {
15
+ if (Array.isArray(child)) child.forEach((item) => walk(item, visit));
16
+ else walk(child, visit);
17
+ }
18
+ }
19
+
20
+ function find(tree, predicate) {
21
+ let match;
22
+ walk(tree, (node) => { if (!match && predicate(node)) match = node; });
23
+ return match;
24
+ }
25
+
26
+ /** 极简 DOM 元素假件(够 client.js 的红点注入用)。 */
27
+ function makeEl(tag) {
28
+ return {
29
+ tag,
30
+ className: "",
31
+ attributes: {},
32
+ style: {},
33
+ children: [],
34
+ parentNode: null,
35
+ listeners: {},
36
+ setAttribute(k, v) { this.attributes[k] = v; },
37
+ addEventListener(t, f) { this.listeners[t] = f; },
38
+ removeEventListener(t, f) { delete this.listeners[t]; },
39
+ appendChild(c) { c.parentNode = this; this.children.push(c); },
40
+ removeChild(c) {
41
+ const i = this.children.indexOf(c);
42
+ if (i >= 0) { this.children.splice(i, 1); c.parentNode = null; }
43
+ },
44
+ querySelector(sel) {
45
+ const cls = sel.charAt(0) === "." ? sel.slice(1) : "";
46
+ return this.children.find((c) => c.className === cls) || null;
47
+ },
48
+ };
49
+ }
50
+
51
+ /**
52
+ * 在 vm 沙箱中加载 client.js 并 apply,返回可控句柄。
53
+ * @param {object} opts - navCells(document.querySelectorAll("button") 返回值,可后续 push)、
54
+ * localStorageSeed(预置 key/value)。
55
+ */
56
+ function loadPlugin(opts = {}) {
57
+ let moduleFactory;
58
+ const registered = new Map();
59
+ const metas = new Map();
60
+ const injects = new Map();
61
+ const requests = [];
62
+ const removed = [];
63
+ const states = [];
64
+ let hook = 0;
65
+
66
+ const react = {
67
+ createElement(type, props, ...children) { return { type, props: props || {}, children }; },
68
+ useState(initial) {
69
+ const index = hook++;
70
+ if (!(index in states)) states[index] = initial;
71
+ return [states[index], (value) => { states[index] = typeof value === "function" ? value(states[index]) : value; }];
72
+ },
73
+ useEffect() {},
74
+ useCallback(fn) { return fn; },
75
+ useSyncExternalStore(_subscribe, getSnapshot) { return getSnapshot(); },
76
+ };
77
+
78
+ const response = (status, body) => Promise.resolve({
79
+ ok: status >= 200 && status < 300,
80
+ status,
81
+ text: async () => JSON.stringify(body),
82
+ });
83
+
84
+ const allCreated = [];
85
+ const doc = {
86
+ createElement(tag) { const el = makeEl(tag); allCreated.push(el); return el; },
87
+ head: makeEl("head"),
88
+ body: makeEl("body"),
89
+ querySelector(sel) {
90
+ const cls = sel.charAt(0) === "." ? sel.slice(1) : "";
91
+ return allCreated.find((el) => el.className === cls) || null;
92
+ },
93
+ querySelectorAll(sel) { return sel === "button" ? (opts.navCells || []) : []; },
94
+ };
95
+
96
+ let lastObserver = null;
97
+ class MutationObserverMock {
98
+ constructor(cb) { this.cb = cb; lastObserver = this; }
99
+ observe() {}
100
+ disconnect() { this.disconnected = true; }
101
+ }
102
+
103
+ const localStorage = {
104
+ _store: new Map(Object.entries(opts.localStorageSeed || {})),
105
+ getItem(k) { return this._store.has(k) ? this._store.get(k) : null; },
106
+ setItem(k, v) { this._store.set(k, String(v)); },
107
+ removeItem(k) { removed.push(k); this._store.delete(k); },
108
+ };
109
+
110
+ const sandbox = {
111
+ window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } } },
112
+ document: doc,
113
+ localStorage,
114
+ MutationObserver: MutationObserverMock,
115
+ fetch(path, options = {}) {
116
+ requests.push({ path, body: options.body ? JSON.parse(options.body) : null });
117
+ if (path === "/dsh-remote/captcha") return response(200, { captcha_id: "cap-1", svg: "<svg></svg>" });
118
+ if (path === "/dsh-remote/logout") {
119
+ return response(200, { ok: true, config: { phone: "", deviceId: "dev-x" }, service: { running: false } });
120
+ }
121
+ if (path === "/dsh-remote/status") {
122
+ return response(200, { ok: true, config: { phone: "", deviceId: "dev-x" }, service: { running: false } });
123
+ }
124
+ return response(200, { body: { ok: true } });
125
+ },
126
+ navigator: { clipboard: { writeText: async () => {} } },
127
+ setInterval() { return 1; },
128
+ clearInterval() {},
129
+ setTimeout() { return 1; },
130
+ Set,
131
+ Symbol,
132
+ };
133
+
134
+ vm.runInNewContext(SOURCE, sandbox);
135
+ const plugin = moduleFactory((name) => {
136
+ assert.equal(name, "react");
137
+ return react;
138
+ });
139
+ plugin.apply({ slots: {
140
+ inject(name, cb) { injects.set(name, cb); cb(); }, // 立即执行(模拟槽已声明、可注册)
141
+ register(meta, component) {
142
+ metas.set(meta.id, meta);
143
+ registered.set(meta.id, component);
144
+ return () => {};
145
+ },
146
+ } });
147
+
148
+ return {
149
+ registered, metas, injects, requests, removed, states, localStorage,
150
+ lastObserver: () => lastObserver,
151
+ renderSection() { hook = 0; return registered.get("dsh-remote")({ close() {} }); },
152
+ };
153
+ }
154
+
155
+ test("入口迁移:注册 settings.section 栏目(id/order/label),移除侧边栏入口与浮动面板", () => {
156
+ const plugin = loadPlugin();
157
+
158
+ // 不再注入侧边栏入口槽
159
+ assert.equal(plugin.injects.has("sidebar.footer.action"), false, "侧边栏入口槽不应再注入");
160
+
161
+ // settings.section 官方扩展点:id=dsh-remote、order=30(> Agent 预设 20,位于其下方)、label=🖥 远程控制
162
+ assert.ok(plugin.injects.has("settings.section"), "应注入 settings.section 扩展点");
163
+ const disposeSection = plugin.injects.get("settings.section")();
164
+ assert.equal(typeof disposeSection, "function", "register 应返回 disposer");
165
+ const meta = plugin.metas.get("dsh-remote");
166
+ assert.ok(meta, "栏目条目应以 id=dsh-remote 注册");
167
+ assert.equal(meta.name, "settings.section");
168
+ assert.equal(meta.order, 30);
169
+ assert.equal(typeof meta.label, "function");
170
+ const label = meta.label();
171
+ assert.ok(String(label).includes("远程控制"), `栏目名应含「远程控制」,实际: ${label}`);
172
+ assert.ok(String(label).includes("🖥"), "栏目名应带 🖥 通用远程控制图标");
173
+
174
+ // shell.overlay 仅保留满意度弹窗,浮动面板已移除
175
+ assert.ok(plugin.injects.has("shell.overlay"));
176
+ plugin.injects.get("shell.overlay")();
177
+ assert.ok(plugin.registered.has("dsh-feedback-popup"), "满意度弹窗应保留在 shell.overlay");
178
+ assert.equal(plugin.registered.has("dsh-remote-panel"), false, "浮动配置面板不应再注册");
179
+ });
180
+
181
+ test("登录态账号区:无「切换账号」,有「退出登录」,关于卡片文案完整", () => {
182
+ const plugin = loadPlugin();
183
+ plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-test" }, service: { running: false } };
184
+ let tree = plugin.renderSection();
185
+
186
+ // 账号区按钮
187
+ assert.ok(find(tree, (n) => n.children?.includes("退出登录")), "应保留「退出登录」");
188
+ assert.ok(!find(tree, (n) => n.children?.includes("切换账号")), "「切换账号」按钮应移除");
189
+
190
+ // 关于 dsh-remote 说明卡片(面板底部,4 条要点)
191
+ assert.ok(find(tree, (n) => n.children?.includes("📖 关于 dsh-remote")), "应渲染「关于 dsh-remote」卡片标题");
192
+ const points = [
193
+ "① 为什么推荐用 SaaS:不用自己买服务器、不用折腾部署,装好客户端就能用,最省心。",
194
+ "② 会员费去向:付的是网络带宽/服务器成本,也是给开发者的合理支持,让项目持续维护。",
195
+ "③ 也可以自建:项目完全开源,有服务器可自行部署,流量走自己的服务器,闭环自控。",
196
+ "④ 一句话总结:简单省心用 SaaS,技术玩家可自建。",
197
+ ];
198
+ for (const p of points) {
199
+ assert.ok(find(tree, (n) => n.children?.includes(p)), `说明卡片应含要点: ${p.slice(0, 12)}…`);
200
+ }
201
+
202
+ // 面板主体仍在(连接模式 tab 不受影响)
203
+ assert.ok(find(tree, (n) => n.children?.includes("☁️ 云端服务")), "云端服务 tab 应保留");
204
+ assert.ok(find(tree, (n) => n.children?.includes("🖥 自建服务")), "自建服务 tab 应保留");
205
+ });
206
+
207
+ test("退出登录清除用户反馈线程凭据(localStorage dsh-feedback-threads)", async () => {
208
+ const plugin = loadPlugin({ localStorageSeed: { "dsh-feedback-threads": '[{"id":"fb_1","token":"tok-abc","at":1}]' } });
209
+ plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-test" }, service: { running: false } };
210
+
211
+ let tree = plugin.renderSection();
212
+ const logoutBtn = find(tree, (n) => n.children?.includes("退出登录"));
213
+ assert.ok(logoutBtn, "应找到「退出登录」按钮");
214
+ logoutBtn.props.onClick();
215
+ await new Promise((resolve) => setImmediate(resolve));
216
+
217
+ assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/logout"), "退出登录应请求 /dsh-remote/logout");
218
+ assert.ok(plugin.removed.includes("dsh-feedback-threads"), "退出登录应清除 dsh-feedback-threads 凭据");
219
+ assert.equal(plugin.localStorage.getItem("dsh-feedback-threads"), null, "本地不应再持有线程凭据");
220
+ });
221
+
222
+ test("首次安装红点:设置页导航栏目出现后注入,点击后写 key、移除红点并停止监听", () => {
223
+ const navCells = [];
224
+ const navCell = makeEl("button");
225
+ navCell.className = "VOzbGW_navCell"; // shell hashed 类名(含 navCell 子串)
226
+ navCell.textContent = "🖥 远程控制";
227
+ const plugin = loadPlugin({ navCells });
228
+
229
+ // apply 时设置页未打开:观察器就位、无红点
230
+ assert.ok(plugin.lastObserver(), "应创建 MutationObserver");
231
+ assert.equal(plugin.lastObserver().disconnected, undefined, "未点击前观察器不应断开");
232
+ assert.equal(navCell.children.length, 0, "栏目未渲染前不应有红点");
233
+
234
+ // 设置页打开:栏目按钮进入 DOM → mutation 回调注入红点
235
+ navCells.push(navCell);
236
+ plugin.lastObserver().cb([]);
237
+ assert.equal(navCell.style.position, "relative", "红点需要 relative 定位容器");
238
+ assert.equal(navCell.children.length, 1, "应注入一个红点");
239
+ assert.equal(navCell.children[0].className, "dru-reddot");
240
+ assert.equal(navCell.children[0].attributes["aria-hidden"], "true");
241
+
242
+ // 再次触发观察器:幂等,不重复注入
243
+ plugin.lastObserver().cb([]);
244
+ assert.equal(navCell.children.length, 1, "重复扫描不应重复注入红点");
245
+
246
+ // 点击栏目/红点 → 写 localStorage key、红点移除、观察器断开
247
+ assert.equal(typeof navCell.listeners.click, "function", "栏目按钮应挂点击监听");
248
+ navCell.listeners.click();
249
+ assert.equal(plugin.localStorage.getItem("dsh-remote-seen-dot"), "1", "点击后应写入 seen key");
250
+ assert.equal(plugin.lastObserver().disconnected, true, "点击后观察器应断开");
251
+ assert.equal(navCell.children.length, 0, "点击后红点应移除");
252
+ });
253
+
254
+ test("红点已看过(localStorage 有 key)时不注入,重启 DSH Web 不复发", () => {
255
+ const navCells = [];
256
+ const navCell = makeEl("button");
257
+ navCell.className = "VOzbGW_navCell";
258
+ navCell.textContent = "🖥 远程控制";
259
+ navCells.push(navCell);
260
+ // 预置 seen key(模拟“首次点击后重启”)
261
+ const plugin = loadPlugin({ navCells, localStorageSeed: { "dsh-remote-seen-dot": "1" } });
262
+ assert.equal(plugin.lastObserver(), null, "已看过时不应创建 MutationObserver");
263
+ assert.equal(navCell.children.length, 0, "已看过时不应再注入红点");
264
+ });
265
+
266
+ test("源码约束:无侧边栏入口/浮动面板/切换账号;登录与自建切换也清理线程凭据", () => {
267
+ // 只断言“代码形态”不存在(注释里允许出现说明文字)
268
+ assert.doesNotMatch(SOURCE, /slots\.inject\("sidebar\.footer\.action"/);
269
+ assert.doesNotMatch(SOURCE, /dru-backdrop\{/);
270
+ assert.doesNotMatch(SOURCE, /切换账号/);
271
+ assert.doesNotMatch(SOURCE, /dsh-remote-panel/);
272
+ assert.match(SOURCE, /settings\.section/);
273
+ assert.match(SOURCE, /dsh-remote-seen-dot/);
274
+ assert.match(SOURCE, /fbClearThreads/);
275
+ assert.match(SOURCE, /关于 dsh-remote/);
276
+ assert.match(SOURCE, /简单省心用 SaaS/);
277
+ assert.match(SOURCE, /order: 30/);
278
+ // 清理时机:退出登录成功回调内、登录账号变化时、切换自建服务时
279
+ assert.match(SOURCE, /post\("\/dsh-remote\/logout"\)\.then\(function \(body\) \{[\s\S]*?fbClearThreads\(\);/);
280
+ assert.match(SOURCE, /if \(prevPhone !== phone\.trim\(\)\) fbClearThreads\(\);/);
281
+ assert.match(SOURCE, /post\("\/dsh-remote\/config", \{ mode: "local"[\s\S]*?fbClearThreads\(\);/);
282
+ });
@@ -0,0 +1,123 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import test from "node:test";
4
+ import vm from "node:vm";
5
+
6
+ function walk(node, visit) {
7
+ if (!node || typeof node !== "object") return;
8
+ visit(node);
9
+ for (const child of node.children || []) {
10
+ if (Array.isArray(child)) child.forEach((item) => walk(item, visit));
11
+ else walk(child, visit);
12
+ }
13
+ }
14
+
15
+ function find(tree, predicate) {
16
+ let match;
17
+ walk(tree, (node) => { if (!match && predicate(node)) match = node; });
18
+ return match;
19
+ }
20
+
21
+ test("账号状态未返回前只显示加载态且不暴露配置路径", () => {
22
+ const source = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
23
+ assert.match(source, /st === null/);
24
+ assert.match(source, /正在读取远控状态/);
25
+ assert.match(source, /st\.config\.deviceId/);
26
+ assert.doesNotMatch(source, /configPath/);
27
+ assert.match(source, /st !== null && !loggedIn/);
28
+ });
29
+
30
+ test("短信防刷要求图形验证码时,注册面板展示验证码并随重试提交", async () => {
31
+ let moduleFactory;
32
+ const registered = new Map();
33
+ const requests = [];
34
+ const states = [];
35
+ let hook = 0;
36
+
37
+ const react = {
38
+ createElement(type, props, ...children) { return { type, props: props || {}, children }; },
39
+ useState(initial) {
40
+ const index = hook++;
41
+ if (!(index in states)) states[index] = initial;
42
+ return [states[index], (value) => { states[index] = typeof value === "function" ? value(states[index]) : value; }];
43
+ },
44
+ useEffect() {},
45
+ useCallback(fn) { return fn; },
46
+ useSyncExternalStore(_subscribe, getSnapshot) { return getSnapshot(); },
47
+ };
48
+
49
+ const response = (status, body) => Promise.resolve({
50
+ ok: status >= 200 && status < 300,
51
+ status,
52
+ text: async () => JSON.stringify(body),
53
+ });
54
+
55
+ const sandbox = {
56
+ window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } } },
57
+ document: {
58
+ createElement() { return { setAttribute() {}, textContent: "" }; },
59
+ head: { appendChild() {} },
60
+ body: {},
61
+ querySelector() { return null; },
62
+ querySelectorAll() { return []; },
63
+ },
64
+ localStorage: {
65
+ _store: new Map(),
66
+ getItem(k) { return this._store.has(k) ? this._store.get(k) : null; },
67
+ setItem(k, v) { this._store.set(k, String(v)); },
68
+ removeItem(k) { this._store.delete(k); },
69
+ },
70
+ MutationObserver: class { constructor() {} observe() {} disconnect() {} },
71
+ fetch(path, options = {}) {
72
+ requests.push({ path, body: options.body ? JSON.parse(options.body) : null });
73
+ if (path === "/dsh-remote/captcha") return response(200, { captcha_id: "cap-1", svg: "<svg></svg>" });
74
+ if (path === "/dsh-remote/sms-code" && requests.filter((r) => r.path === path).length === 1) {
75
+ return response(400, { body: { error: { code: "captcha_invalid", message: "验证码错误或已过期" } } });
76
+ }
77
+ return response(200, { body: { ok: true, test_code: "123456" } });
78
+ },
79
+ navigator: { clipboard: { writeText: async () => {} } },
80
+ setInterval() { return 1; },
81
+ clearInterval() {},
82
+ setTimeout() { return 1; },
83
+ Set,
84
+ Symbol,
85
+ };
86
+
87
+ vm.runInNewContext(readFileSync(new URL("../lib/client.js", import.meta.url), "utf8"), sandbox);
88
+ const plugin = moduleFactory((name) => {
89
+ assert.equal(name, "react");
90
+ return react;
91
+ });
92
+ plugin.apply({ slots: {
93
+ inject(_name, register) { register(); },
94
+ register(meta, component) { registered.set(meta.id, component); return () => {}; },
95
+ } });
96
+
97
+ // 面板入口已迁入设置页 settings.section 栏目(id=dsh-remote),不再有侧边栏入口/浮动面板
98
+ const section = registered.get("dsh-remote");
99
+ assert.ok(section, "settings.section 栏目组件应已注册");
100
+ const render = () => { hook = 0; return section({ close() {} }); };
101
+
102
+ states[0] = { config: { phone: "", deviceId: "dev-test" }, service: { running: false, plistExists: true } };
103
+ let tree = render();
104
+ find(tree, (node) => node.children?.includes("注册")).props.onClick();
105
+ tree = render();
106
+ find(tree, (node) => node.props?.placeholder === "11 位手机号").props.onChange({ target: { value: "13800000000" } });
107
+ tree = render();
108
+ find(tree, (node) => node.children?.includes("获取验证码")).props.onClick();
109
+ await new Promise((resolve) => setImmediate(resolve));
110
+
111
+ tree = render();
112
+ const captchaInput = find(tree, (node) => node.props?.placeholder === "图中数字");
113
+ assert.ok(captchaInput, "服务端要求图形验证码后应显示输入框");
114
+ captchaInput.props.onChange({ target: { value: "654321" } });
115
+ tree = render();
116
+ find(tree, (node) => node.children?.includes("获取验证码")).props.onClick();
117
+ await new Promise((resolve) => setImmediate(resolve));
118
+
119
+ assert.deepEqual(requests.at(-1), {
120
+ path: "/dsh-remote/sms-code",
121
+ body: { phone: "13800000000", captcha_id: "cap-1", captcha_answer: "654321" },
122
+ });
123
+ });