@mrrisega/dsh-remote 0.7.0-beta.3 → 0.7.0-beta.5
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/clients/dsh-remote/mobile-adapter.mjs +111 -39
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +36 -3
- package/dsh-setup.mjs +201 -14
- package/package.json +7 -6
- package/packages/dsh-remote-web/lib/client.js +116 -71
- package/packages/dsh-remote-web/lib/index.js +85 -4
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +111 -39
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +201 -14
- package/packages/dsh-remote-web/test/install-profile-targets.test.mjs +105 -0
- package/packages/dsh-remote-web/test/launchd-domain.test.mjs +5 -1
- package/packages/dsh-remote-web/test/settings-entry-placement.test.mjs +15 -8
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +22 -8
- package/packages/dsh-remote-web/test/setup-output.test.mjs +70 -0
- package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +9 -1
|
@@ -66,7 +66,11 @@ test("launchd 修复:不再只 print 一次就判定失败(异步派生必
|
|
|
66
66
|
const fn = src.slice(src.indexOf("function launchdState"), src.indexOf("function spawnDetachedBridge"));
|
|
67
67
|
assert.ok(/for \(let i = 0; i < tries; i \+= 1\)/.test(fn), "应循环轮询而非单次检查");
|
|
68
68
|
assert.ok(/sleepSync\(gapMs\)/.test(fn), "轮询之间要真正等待");
|
|
69
|
-
|
|
69
|
+
// 断言"总等待窗口足够"而不是写死某个数字:2026-10-11 把窗口放宽到 25×400ms(过渡态更常见)
|
|
70
|
+
const win = fn.match(/waitLaunchdRunning\(target, tries = (\d+), gapMs = (\d+)\)/);
|
|
71
|
+
assert.ok(win, "waitLaunchdRunning 应有显式的 tries/gapMs 默认值");
|
|
72
|
+
assert.ok(Number(win[1]) * Number(win[2]) >= 8000,
|
|
73
|
+
`总等待窗口应 ≥8 秒(launchd 派生是异步的),实际 ${Number(win[1]) * Number(win[2])}ms`);
|
|
70
74
|
// 必须能识别「登记了但从未派生」这一 macOS 26 特征,才能给出正确建议而不是干等
|
|
71
75
|
assert.ok(/pended nondemand spawn/.test(fn) && /on-demand-only/.test(fn), "应识别 on-demand-only 挂起特征");
|
|
72
76
|
});
|
|
@@ -19,7 +19,8 @@ const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8"
|
|
|
19
19
|
/** 把 `var NAV_ENTRY_ID` 到 `openRemoteSettings` 之前的那段辅助函数切出来(含依赖的常量)。 */
|
|
20
20
|
function navHelpersSource() {
|
|
21
21
|
const start = SOURCE.indexOf('var NAV_ENTRY_ID = "dru-nav-remote";');
|
|
22
|
-
|
|
22
|
+
// 末端锚点用**函数声明**而不是注释(注释会被重写,2026-10-10 就因此失效过一次)
|
|
23
|
+
const end = SOURCE.indexOf("function openRemoteSettings()");
|
|
23
24
|
assert.ok(start > 0 && end > start, "切片锚点失效:侧栏入口辅助函数结构变了");
|
|
24
25
|
return SOURCE.slice(start, end);
|
|
25
26
|
}
|
|
@@ -168,11 +169,17 @@ test("精确相等优先于子串:给了 exactOnly 时子串一律不算", ()
|
|
|
168
169
|
assert.equal(pickNavByToken("设置", { exactOnly: true, insideDialog: false }), settings);
|
|
169
170
|
});
|
|
170
171
|
|
|
171
|
-
test("
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
assert.match(SOURCE, /
|
|
175
|
-
|
|
176
|
-
assert.match(SOURCE, /
|
|
177
|
-
assert.
|
|
172
|
+
test("源码约束:入口不再自己找 DOM 落点,而是注册官方槽位(2026-10-10 三修后的事实)", () => {
|
|
173
|
+
// 一修/二修都是"自己找落点",先后落错到设置面板顶部、以及"找不到锚点干脆不注入"。
|
|
174
|
+
// 三修起改用官方槽位 sidebar.footer.action —— 本用例把这条设计锁住,防止有人再退回 DOM 注入。
|
|
175
|
+
assert.match(SOURCE, /ctx\.slots\.inject\("sidebar\.footer\.action"/,
|
|
176
|
+
"侧栏入口必须注册进官方槽位 sidebar.footer.action");
|
|
177
|
+
assert.match(SOURCE, /id:\s*"dsh-remote-nav"/, "槽位条目 id 应为 dsh-remote-nav");
|
|
178
|
+
assert.ok(!/var host = pickSettingsTrigger\(\)/.test(SOURCE),
|
|
179
|
+
"不得再出现「每 2s 找锚点注入」的老写法(那正是两次落错位置的来源)");
|
|
180
|
+
assert.ok(!/if \(navInDialog\(host\)\) return;/.test(SOURCE),
|
|
181
|
+
"老的 DOM 注入兜底应已删除(入口改由槽位渲染)");
|
|
182
|
+
// 仍保留的:打开栏目的点击路径必须还能工作,且不会把「通用设置」当成设置触发器
|
|
183
|
+
assert.match(SOURCE, /aria-haspopup="dialog"/, "打开栏目时仍按 aria-haspopup=dialog 认官方设置触发器");
|
|
184
|
+
assert.match(SOURCE, /\[role="dialog"\], \[aria-modal="true"\]/, "必须保留「在设置弹层内」的判据");
|
|
178
185
|
});
|
|
@@ -162,11 +162,22 @@ function loadPlugin(opts = {}) {
|
|
|
162
162
|
};
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
test("
|
|
165
|
+
test("入口:settings.section 栏目(id/order/label)+ 侧栏底部官方槽 sidebar.footer.action", () => {
|
|
166
166
|
const plugin = loadPlugin();
|
|
167
167
|
|
|
168
|
-
//
|
|
169
|
-
|
|
168
|
+
// 2026-10-10 三修(业主口径「就要放侧栏底部那个位置」):入口改注册进**官方列表槽**
|
|
169
|
+
// `sidebar.footer.action`(宿主为侧栏底部区域提供的扩展点;0.2 实测该槽当前为空)。
|
|
170
|
+
// 不再自己注入 DOM —— 前两次都是"自己找落点"找错的。
|
|
171
|
+
assert.ok(plugin.injects.has("sidebar.footer.action"), "应注入官方侧栏底部槽");
|
|
172
|
+
const disposeNav = plugin.injects.get("sidebar.footer.action")();
|
|
173
|
+
assert.equal(typeof disposeNav, "function", "register 应返回 disposer");
|
|
174
|
+
const navMeta = plugin.metas.get("dsh-remote-nav");
|
|
175
|
+
assert.ok(navMeta, "侧栏入口应以 id=dsh-remote-nav 注册");
|
|
176
|
+
assert.equal(navMeta.name, "sidebar.footer.action");
|
|
177
|
+
assert.equal(typeof navMeta.label, "function");
|
|
178
|
+
assert.ok(String(navMeta.label()).includes("远程访问"), `槽位 label 应为「远程访问」,实际 ${navMeta.label()}`);
|
|
179
|
+
// 按钮 id 必须仍复用 NAV_ENTRY_ID(=dru-nav-remote):公告徽标/首次红点按 id 取节点的逻辑依赖它
|
|
180
|
+
assert.match(SOURCE, /id:\s*NAV_ENTRY_ID/, "入口按钮的 id 必须复用 NAV_ENTRY_ID(=dru-nav-remote)");
|
|
170
181
|
|
|
171
182
|
// settings.section 官方扩展点:id=dsh-remote、order=30(> Agent 预设 20,位于其下方)、label=📱 远程访问
|
|
172
183
|
assert.ok(plugin.injects.has("settings.section"), "应注入 settings.section 扩展点");
|
|
@@ -202,8 +213,9 @@ test("入口迁移:settings.section 里**只有一个**栏目(id=dsh-remote
|
|
|
202
213
|
// 搬进去之后仍然存在:面板 tab 条里的第三个 tab + 微信绿泡泡图标(行为断言见 wechat-bot-ui.test.mjs)
|
|
203
214
|
assert.match(SOURCE, /"微信机器人通道"/, "微信机器人 tab 必须在面板里");
|
|
204
215
|
assert.match(SOURCE, /dru-wx-ico/, "微信 tab 必须带绿泡泡图标 class");
|
|
205
|
-
//
|
|
206
|
-
assert.equal(plugin.injects.size,
|
|
216
|
+
// 槽注入次数:settings.section + shell.overlay + sidebar.footer.action(2026-10-10 加的侧栏入口)
|
|
217
|
+
assert.equal(plugin.injects.size, 3,
|
|
218
|
+
`inject 的槽名应是 settings.section + shell.overlay + sidebar.footer.action,实际:${[...plugin.injects.keys()].join(",")}`);
|
|
207
219
|
});
|
|
208
220
|
|
|
209
221
|
test("登录态账号区:无「切换账号」,有「退出登录」,头部/关于卡文案为「远程访问」", () => {
|
|
@@ -345,9 +357,11 @@ test("源码约束:侧栏注入「远程访问」按钮与官方「设置」
|
|
|
345
357
|
assert.match(SOURCE, /dsh-remote-seen-dot/);
|
|
346
358
|
});
|
|
347
359
|
|
|
348
|
-
test("
|
|
349
|
-
//
|
|
350
|
-
|
|
360
|
+
test("源码约束:侧栏入口走官方槽位/无浮动面板/无切换账号;命名统一为「远程访问」", () => {
|
|
361
|
+
// 2026-10-10 三修:侧栏入口**改回**官方槽位(业主口径「就要放侧栏底部那个位置」),
|
|
362
|
+
// 所以这条从"不得出现 footer.action"翻转为"必须走官方槽位、不得自己注入 DOM"。
|
|
363
|
+
assert.match(SOURCE, /slots\.inject\("sidebar\.footer\.action"/);
|
|
364
|
+
assert.doesNotMatch(SOURCE, /insertBefore\(btn, host\)/, "不得再自己插 DOM 找落点");
|
|
351
365
|
assert.doesNotMatch(SOURCE, /dru-backdrop\{/);
|
|
352
366
|
assert.doesNotMatch(SOURCE, /切换账号/);
|
|
353
367
|
assert.doesNotMatch(SOURCE, /dsh-remote-panel/);
|
|
@@ -156,3 +156,73 @@ test("★ 插件覆盖必须原子:staging → 必需文件自检 → rename
|
|
|
156
156
|
"🔴 不得再出现「先删 dest 再原地逐文件拷贝」——那正是这次事故的成因");
|
|
157
157
|
assert.match(fn, /\.staging-\$\{process\.pid\}|\.staging-/, "被强杀留下的 staging 要能清理");
|
|
158
158
|
});
|
|
159
|
+
|
|
160
|
+
test("★ 插件拷贝必须保留**自带的 runtime/node_modules**(ws 是桥唯一的裸依赖)", () => {
|
|
161
|
+
// 2026-10-10 事故形态:过滤规则写的是"任何一段叫 node_modules 就剔除",
|
|
162
|
+
// 本意只想去掉插件自己的依赖目录(那些由 profile 的包管理器管),
|
|
163
|
+
// 却把 `runtime/node_modules/ws` 一起剔了 —— 而这份运行时正是"只有插件半"的机器
|
|
164
|
+
// **自愈补装时的唯一来源**。剔掉它 → 自愈出来的运行环境缺 ws
|
|
165
|
+
// (bridge 顶层 `import WebSocket from "ws"`)→ 一启动就崩,而且自检还说"已就绪"。
|
|
166
|
+
const src = readFileSync(SETUP, "utf8");
|
|
167
|
+
const i = src.indexOf("const cpOpts = {");
|
|
168
|
+
assert.ok(i > 0, "应能定位 cpOpts");
|
|
169
|
+
const j = src.indexOf("};", i);
|
|
170
|
+
const objSrc = src.slice(i, j + 2);
|
|
171
|
+
const pluginDir = "/some/node_modules/dsh-remote-web"; // 源本身就在 node_modules 之下(历史坑)
|
|
172
|
+
const path = require$path();
|
|
173
|
+
// eslint-disable-next-line no-new-func
|
|
174
|
+
const make = new Function("path", "pluginDir", `${objSrc}\nreturn cpOpts;`);
|
|
175
|
+
const cpOpts = make(path, pluginDir);
|
|
176
|
+
const keep = (p) => cpOpts.filter(p) === true;
|
|
177
|
+
|
|
178
|
+
assert.equal(keep(pluginDir), true, "插件根目录本身必须保留");
|
|
179
|
+
assert.equal(keep(path.join(pluginDir, "lib", "index.js")), true, "普通文件必须保留");
|
|
180
|
+
// ① 自带运行时的 node_modules **必须保留**(本次修复的核心)
|
|
181
|
+
assert.equal(keep(path.join(pluginDir, "runtime", "node_modules")), true, "runtime/node_modules 必须保留");
|
|
182
|
+
assert.equal(keep(path.join(pluginDir, "runtime", "node_modules", "ws", "package.json")), true,
|
|
183
|
+
"★ runtime/node_modules/ws 必须被拷进 profile —— 否则自愈出来的运行环境起不来");
|
|
184
|
+
// ② runtime 之外的 node_modules 仍然要剔除(profile 的包管理器管那部分)
|
|
185
|
+
assert.equal(keep(path.join(pluginDir, "node_modules", "foo", "index.js")), false,
|
|
186
|
+
"runtime 之外的依赖目录仍应剔除(避免把解析器裁决权抢过来)");
|
|
187
|
+
// ③ 垃圾照旧剔除
|
|
188
|
+
assert.equal(keep(path.join(pluginDir, ".git", "config")), false, ".git 必须剔除");
|
|
189
|
+
assert.equal(keep(path.join(pluginDir, "lib", ".DS_Store")), false, ".DS_Store 必须剔除");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
/** 只在本用例里用到的 path 注入(避免为一行断言引入模块级 require 语义)。 */
|
|
193
|
+
function require$path() {
|
|
194
|
+
return { join: (...a) => a.join("/"), sep: "/", relative: (from, to) => to.startsWith(from + "/") ? to.slice(from.length + 1) : to };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
test("★ launchd bootout 后必须等 label 摘干净再 bootstrap(否则随机 5: Input/output error)", () => {
|
|
198
|
+
// 2026-10-11 实测:bootout 是**异步**的,紧接着 bootstrap 同一个 label 大约一半次数失败 →
|
|
199
|
+
// 失败后走 spawnDetachedBridge() 兜底 → 用户看到"⚠️ 未被系统托管 / 不会开机自启"。
|
|
200
|
+
// 手工在两条命令之间加 sleep 就 100% 成功 —— 所以这里必须有"等到摘干净"的轮询。
|
|
201
|
+
const src = readFileSync(SETUP, "utf8");
|
|
202
|
+
assert.match(src, /function waitLaunchdGone\(/, "必须有 waitLaunchdGone(等 label 从 launchd 摘干净)");
|
|
203
|
+
const start = src.indexOf("function startBridgeDarwin(");
|
|
204
|
+
const end = src.indexOf("function restartBridgeService(");
|
|
205
|
+
assert.ok(start > 0 && end > start, "应能定位 startBridgeDarwin");
|
|
206
|
+
const fn = src.slice(start, end);
|
|
207
|
+
const bootoutAt = fn.indexOf("launchctl bootout ${target}");
|
|
208
|
+
const waitAt = fn.indexOf("waitLaunchdGone(target)");
|
|
209
|
+
const bootAt = fn.indexOf("launchctl bootstrap ${domain}");
|
|
210
|
+
assert.ok(bootoutAt > 0 && waitAt > 0 && bootAt > 0, "bootout / waitLaunchdGone / bootstrap 三者都要在");
|
|
211
|
+
assert.ok(bootoutAt < waitAt && waitAt < bootAt,
|
|
212
|
+
"顺序必须是 bootout → waitLaunchdGone → bootstrap(差一步就会随机失败)");
|
|
213
|
+
// 失败重试:摘除竞态可能还没结束,要能再试一次,而不是直接掉进脱离进程兜底
|
|
214
|
+
assert.match(fn, /bootok|!boot\.ok[\s\S]{0,400}launchctl bootstrap/, "bootstrap 失败应重试一次再兜底");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("★ 自启动配置里可以钉住上游(多 profile 时代,各宿主会互抢 .dsh-upstream)", () => {
|
|
218
|
+
const src = readFileSync(SETUP, "utf8");
|
|
219
|
+
assert.match(src, /function desktopAppPort\(/, "要有桌面端端口探测(lsof -c DeepSeek)");
|
|
220
|
+
assert.match(src, /PINNED_UPSTREAM/, "要有钉住上游的机制");
|
|
221
|
+
// plist 模板里必须把它写进 EnvironmentVariables(环境变量优先级高于 .dsh-upstream 文件)
|
|
222
|
+
assert.match(src, /DSH_BRIDGE_UPSTREAM<\/key><string>\$\{PINNED_UPSTREAM\}/,
|
|
223
|
+
"plist 里要写入 DSH_BRIDGE_UPSTREAM,否则两端宿主会互相覆盖上游");
|
|
224
|
+
// ⚠️ 顺序:必须在写自启动文件**之前**算出来,否则 plist 里带不上(实测踩到过)
|
|
225
|
+
const pinAt = src.indexOf("PINNED_UPSTREAM = `http://127.0.0.1:${p}`");
|
|
226
|
+
const svcAt = src.indexOf("svc = installAutostart()");
|
|
227
|
+
assert.ok(pinAt > 0 && svcAt > 0 && pinAt < svcAt, "钉定必须发生在 installAutostart() 之前");
|
|
228
|
+
});
|
|
@@ -264,7 +264,15 @@ exit 0
|
|
|
264
264
|
// 当前 HOME 的 LaunchAgents 里有 plist(服务此前装过,但现在未运行)
|
|
265
265
|
const plist = path.join(tempHome, "Library", "LaunchAgents", "com.dshremote.bridge.plist");
|
|
266
266
|
await mkdir(path.dirname(plist), { recursive: true });
|
|
267
|
-
|
|
267
|
+
/* ⚠️ 2026-10-10:夹具必须写成**真实形态的 plist** —— 内容里要含 `<relayDir>/dsh-setup.mjs`。
|
|
268
|
+
清理助手(与插件里的两条路径)现在会先校验"这份 plist 是不是给本 relayDir 装的":
|
|
269
|
+
只有含本 relayDir 入口脚本的 plist 才允许删。以前这里写的是空 `<dict/>`,
|
|
270
|
+
于是它同时**掩盖**了两件事:① 夹具不像真 plist;② 真实世界里那个守卫若缺失,
|
|
271
|
+
这条件会真的去删开发者的 plist(当天就发生了)。 */
|
|
272
|
+
await writeFile(plist, `<?xml version="1.0" encoding="UTF-8"?>
|
|
273
|
+
<plist version="1.0"><dict>
|
|
274
|
+
<key>ProgramArguments</key><array><string>/usr/bin/node</string><string>${path.join(relayDir, "dsh-setup.mjs")}</string><string>run</string></array>
|
|
275
|
+
</dict></plist>`);
|
|
268
276
|
|
|
269
277
|
const routes = boot(relayDir);
|
|
270
278
|
const { host, base } = await serve(routes);
|