@mrrisega/dsh-remote 0.7.0-beta.4 → 0.7.0-beta.6
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/dsh-setup.mjs +156 -12
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +23 -7
- package/packages/dsh-remote-web/lib/index.js +15 -0
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +156 -12
- 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 +25 -1
- package/packages/dsh-remote-web/test/setup-output.test.mjs +33 -0
package/dsh-setup.mjs
CHANGED
|
@@ -173,10 +173,13 @@ function printHelp() {
|
|
|
173
173
|
dsh-remote doctor 一条命令自检「为什么连不上」(只读;含上游/进程/微信/日志尾部)
|
|
174
174
|
dsh-remote --help 显示本用法(等同 dsh-remote help)
|
|
175
175
|
|
|
176
|
-
目标 profile
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
176
|
+
目标 profile(默认:**本机 Web 版 + 桌面端一起装**):
|
|
177
|
+
不带 --profile 时,会把插件装进本机**已有的** Web 版与桌面端 profile(谁在用就装给谁,
|
|
178
|
+
避免"命令跑完了、桌面端却没有面板"这类装错地方的坑);机器上的实验/自动化 profile 不会被动。
|
|
179
|
+
dsh-remote --profile desktop 只装进 DeepSeek Harness **桌面端**
|
|
180
|
+
(~/.dsh/profiles/desktop)
|
|
181
|
+
dsh-remote --profile web 只装进 Web 版
|
|
182
|
+
桌面端也可以在应用内「插件」页直接安装 dsh-remote-web —— 那是官方路径,也最省事。
|
|
180
183
|
⚠️ 桌面端与 Web 版共享 ~/.dsh(会话/设置/凭据),但**插件互相隔离**,两边要各自安装。
|
|
181
184
|
|
|
182
185
|
自建模式(可选):
|
|
@@ -712,7 +715,7 @@ function writeAutostartFile() {
|
|
|
712
715
|
<key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
|
|
713
716
|
<key>StandardOutPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
714
717
|
<key>StandardErrorPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
715
|
-
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string><key>DSH_BRIDGE_HARNESS_VERSION</key><string>${HARNESS_VERSION}</string></dict>
|
|
718
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}${PINNED_UPSTREAM ? `<key>DSH_BRIDGE_UPSTREAM</key><string>${PINNED_UPSTREAM}</string>` : ""}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string><key>DSH_BRIDGE_HARNESS_VERSION</key><string>${HARNESS_VERSION}</string></dict>
|
|
716
719
|
</dict></plist>`;
|
|
717
720
|
// ~/Library/LaunchAgents 在全新账户/精简系统上可能不存在,必须先建目录,
|
|
718
721
|
// 否则 writeFileSync 抛 ENOENT 直接把安装流程打断(用户反馈里也踩过类似的路径问题)。
|
|
@@ -760,19 +763,46 @@ function launchdState(target) {
|
|
|
760
763
|
};
|
|
761
764
|
}
|
|
762
765
|
|
|
766
|
+
/**
|
|
767
|
+
* 等 label **真的从 launchd 里摘干净**。
|
|
768
|
+
*
|
|
769
|
+
* 🔴 2026-10-11 实测的坑:`launchctl bootout` 是**异步**的 —— 紧接着 bootstrap 同一个 label 会
|
|
770
|
+
* 随机失败(`Bootstrap failed: 5: Input/output error`),表现为"安装时灵时不灵":
|
|
771
|
+
* 手工在两条命令之间加个 sleep 就 100% 成功,安装器里没等就大约一半失败 →
|
|
772
|
+
* 失败后走 `spawnDetachedBridge()` 兜底 → 用户看到"⚠️ 未被系统托管 / 不会开机自启"。
|
|
773
|
+
* 所以 bootout 之后必须轮询到 `launchctl print` 真的查不到,再 bootstrap。
|
|
774
|
+
*/
|
|
775
|
+
function waitLaunchdGone(target, tries = 12, gapMs = 200) {
|
|
776
|
+
for (let i = 0; i < tries; i += 1) {
|
|
777
|
+
if (!sh(`launchctl print ${target}`).ok) return true;
|
|
778
|
+
sleepSync(gapMs);
|
|
779
|
+
}
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
|
|
763
783
|
/**
|
|
764
784
|
* 轮询等待 job 起来。
|
|
765
785
|
* launchd 派生是异步的:bootstrap 成功后立刻 print 必然看到 not running(旧实现因此误报"启动失败")。
|
|
766
786
|
* 明确"被挂起且没有派生"时提前放弃(等满也没用),否则给足 ~8 秒。
|
|
767
787
|
*/
|
|
768
|
-
function waitLaunchdRunning(target, tries =
|
|
788
|
+
function waitLaunchdRunning(target, tries = 25, gapMs = 400) {
|
|
769
789
|
let st = launchdState(target);
|
|
770
790
|
for (let i = 0; i < tries; i += 1) {
|
|
771
791
|
st = launchdState(target);
|
|
772
792
|
if (st.running) return st;
|
|
773
|
-
|
|
793
|
+
/* 🔴 2026-10-11 修:刚 bootstrap/kickstart 后 launchd 会先进入**过渡态**
|
|
794
|
+
(`state = xpcproxy` / `spawn scheduled` / `pending`),此时 runs 还是 0。
|
|
795
|
+
旧判据"登记了却一次都没派生 → 判定被挂起"在 1.2 秒后就放弃,于是
|
|
796
|
+
`installAutostart()` 误报"launchd 无法托管本机 bridge",转而拉起一个**脱离进程** ——
|
|
797
|
+
用户看到的是"安装没装好/不会开机自启",而实际上 launchd 下一秒就把它拉起来了
|
|
798
|
+
(现场实测:state=xpcproxy,几秒后 state=running)。
|
|
799
|
+
现在:过渡态一律继续等;只有"确实派生过又退出"或"等满窗口都没起来"才算失败。 */
|
|
800
|
+
const transitional = /state\s*=\s*(xpcproxy|spawn scheduled|pending|running\s*\()/i.test(String(st.raw || ""));
|
|
801
|
+
if (transitional) { sleepSync(gapMs); continue; }
|
|
802
|
+
// 已派生过、现在没在跑、也没被挂起 → 是启动后立刻退出(真失败),不必等满
|
|
774
803
|
if (st.exists && st.runs > 0 && !st.deferred) break;
|
|
775
|
-
|
|
804
|
+
// 登记了却一次都没派生,且已经等了足够久(给足 4 秒让 launchd 调度)→ 判为被挂起
|
|
805
|
+
if (i >= 10 && st.exists && !st.deferred && st.runs === 0) break;
|
|
776
806
|
sleepSync(gapMs);
|
|
777
807
|
}
|
|
778
808
|
return st;
|
|
@@ -850,7 +880,14 @@ function startBridgeDarwin(plistPath) {
|
|
|
850
880
|
};
|
|
851
881
|
}
|
|
852
882
|
sh(`launchctl bootout ${target}`);
|
|
883
|
+
waitLaunchdGone(target); // 关键:不等干净就 bootstrap → 随机 "5: Input/output error"
|
|
853
884
|
let boot = sh(`launchctl bootstrap ${domain} ${q(plistPath)}`);
|
|
885
|
+
if (!boot.ok) {
|
|
886
|
+
// 摘除竞态可能还没结束:再等一拍重试一次(仍失败才走老写法兜底)
|
|
887
|
+
sleepSync(600);
|
|
888
|
+
waitLaunchdGone(target, 6, 200);
|
|
889
|
+
boot = sh(`launchctl bootstrap ${domain} ${q(plistPath)}`);
|
|
890
|
+
}
|
|
854
891
|
if (!boot.ok) {
|
|
855
892
|
// 老写法兜底(部分系统上 load -w 仍可用)
|
|
856
893
|
sh(`launchctl unload ${q(plistPath)}`);
|
|
@@ -959,6 +996,33 @@ function restartBridgeService() {
|
|
|
959
996
|
* 统一在 clients/dsh-remote/upstream-discovery.mjs,watcher 与 bridge 共用同一份实现。
|
|
960
997
|
*/
|
|
961
998
|
const UPSTREAM_FILE = ".dsh-upstream";
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* 本次安装要**钉住**的上游(null = 不钉,运行时按 env → .dsh-upstream → 探测 取值)。
|
|
1002
|
+
* 见 desktopAppPort() 的注释:插件装进多个 profile 后,各宿主会互相覆盖 `.dsh-upstream`。
|
|
1003
|
+
*/
|
|
1004
|
+
let PINNED_UPSTREAM = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim().replace(/\/+$/, "") || null;
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* 探测桌面端(Electron 应用)当前监听的**回环端口**。
|
|
1008
|
+
*
|
|
1009
|
+
* 为什么需要(2026-10-11 实测事故):插件同时装进 web 与 desktop 后,**每个宿主**都会在装载时
|
|
1010
|
+
* 把自己 `ctx.webServer.port` 写进 `<relayDir>/.dsh-upstream` —— 谁最后写谁生效。于是桥的上游
|
|
1011
|
+
* 会在两个应用之间来回跳:那次桥跑去连了 Web 版的 3080,而业主面对的是桌面端 19387,
|
|
1012
|
+
* 表现就是"命令跑完了但没装上 / 手机连上看到的不是我在用的界面"。
|
|
1013
|
+
* 装桌面端时把端口**钉进自启动配置**(环境变量优先级最高),两个宿主就抢不动了。
|
|
1014
|
+
*/
|
|
1015
|
+
function desktopAppPort() {
|
|
1016
|
+
if (process.platform !== "darwin") return null;
|
|
1017
|
+
try {
|
|
1018
|
+
const r = sh("lsof -nP -iTCP -sTCP:LISTEN -a -c DeepSeek 2>/dev/null");
|
|
1019
|
+
for (const line of String(r.stdout || "").split("\n")) {
|
|
1020
|
+
const m = line.match(/(?:127\.0\.0\.1|\[::1\]):(\d+)\s/);
|
|
1021
|
+
if (m) return Number(m[1]);
|
|
1022
|
+
}
|
|
1023
|
+
} catch { /* 拿不到就算了,退回运行时探测 */ }
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
962
1026
|
/** 运行时动态发现到的上游(优于可能过期的端口文件;进程内缓存)。 */
|
|
963
1027
|
let discoveredUpstream = "";
|
|
964
1028
|
function upstreamUrl() {
|
|
@@ -1075,6 +1139,50 @@ function resolveProfileDir(argv = []) {
|
|
|
1075
1139
|
* `pluginCmd()` 时原本传的是空数组 —— 于是"指定 desktop profile"会被静默忽略、
|
|
1076
1140
|
* 插件仍然装进 Web 版 profile(桌面端用户装了却看不到面板,且极难自查)。
|
|
1077
1141
|
*/
|
|
1142
|
+
/**
|
|
1143
|
+
* 探测本机**已存在**的 profile(可装载 = 同时有 package.json 与 cordis.patch.yml)。
|
|
1144
|
+
* desktop 排在前面:业主主用的是桌面应用,多个 profile 一起装时它是"首选/权威"那个。
|
|
1145
|
+
*/
|
|
1146
|
+
function detectProfileDirs() {
|
|
1147
|
+
const root = path.join(os.homedir(), ".dsh", "profiles");
|
|
1148
|
+
let names = [];
|
|
1149
|
+
try {
|
|
1150
|
+
names = fs.readdirSync(root, { withFileTypes: true })
|
|
1151
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
1152
|
+
.map((e) => e.name)
|
|
1153
|
+
.filter((n) => fs.existsSync(path.join(root, n, "package.json")) && fs.existsSync(path.join(root, n, "cordis.patch.yml")));
|
|
1154
|
+
} catch { return []; }
|
|
1155
|
+
names.sort((a, b) => (a === "desktop" ? -1 : b === "desktop" ? 1 : a.localeCompare(b)));
|
|
1156
|
+
return names.map((n) => ({ name: n, dir: profileDirOfName(n) }));
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* 本次要操作的 profile 列表。
|
|
1161
|
+
*
|
|
1162
|
+
* 业主口径(2026-10-11):「默认安装的时候也能同步安装给 desktop」。
|
|
1163
|
+
* 以前不带 `--profile` 时**写死装 web** —— 桌面端用户执行官方那条 `npx @mrrisega/dsh-remote@beta`
|
|
1164
|
+
* 之后,插件全进了 Web 版 profile,而用户面对的是桌面应用 → 面板永远不出现,且极难自查。
|
|
1165
|
+
*
|
|
1166
|
+
* · `--profile X` / `DSH_PROFILE_DIR` → 只动那一个(脚本化与既有用法不变)
|
|
1167
|
+
* · 都不给 → **本机所有已存在的 profile**(desktop 优先)
|
|
1168
|
+
* · 一个都没探测到 → 退回 web(保留"首次安装"的引导文案)
|
|
1169
|
+
*/
|
|
1170
|
+
function targetProfileDirs(argv = []) {
|
|
1171
|
+
const i = argv.indexOf("--profile");
|
|
1172
|
+
if (i > -1 && argv[i + 1]) return [{ name: argv[i + 1], dir: resolveProfileDir(argv) }];
|
|
1173
|
+
if (process.env.DSH_PROFILE_DIR) return [{ name: "env", dir: process.env.DSH_PROFILE_DIR }];
|
|
1174
|
+
const all = detectProfileDirs();
|
|
1175
|
+
if (!all.length) return [{ name: "web", dir: profileDirOfName("web") }];
|
|
1176
|
+
/* 默认装哪些?**不是"全部"** —— 机器上常有过实验/自动化用的 profile(headless、web02…),
|
|
1177
|
+
往那些里面塞插件是越界。规则:
|
|
1178
|
+
· 已知的面向用户的 profile(web / desktop):**有就装**(这是业主的核心诉求:默认也要给 desktop)
|
|
1179
|
+
· 其他 profile:**只有本来就已经装了插件**才继续管(升级不能悄悄漏掉它)
|
|
1180
|
+
· 上面并集为空(例如只有 headless 一种)→ 退回"全部已存在",避免什么都不装 */
|
|
1181
|
+
const chosen = all.filter((p) => KNOWN_PROFILE_NAMES.includes(p.name)
|
|
1182
|
+
|| fs.existsSync(path.join(p.dir, PLUGIN_LOCAL_DIR)));
|
|
1183
|
+
return chosen.length ? chosen : all;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1078
1186
|
function profileArgvOf(argv = []) {
|
|
1079
1187
|
const i = argv.indexOf("--profile");
|
|
1080
1188
|
return i > -1 && argv[i + 1] ? ["--profile", argv[i + 1]] : [];
|
|
@@ -1666,6 +1774,19 @@ async function setup(argv) {
|
|
|
1666
1774
|
|
|
1667
1775
|
let svc = { path: null, status: { ok: true, status: "skipped", detail: "--no-autostart" } };
|
|
1668
1776
|
if (!noAutostart) {
|
|
1777
|
+
// ⚠️ 顺序要紧:**先**探测并钉住上游,**再**写自启动文件 ——
|
|
1778
|
+
// 否则 plist 里不会带上 DSH_BRIDGE_UPSTREAM(实测踩到:打印说已钉住,plist 里却没有)。
|
|
1779
|
+
try {
|
|
1780
|
+
const pluginTargets = targetProfileDirs(argv);
|
|
1781
|
+
if (!PINNED_UPSTREAM && pluginTargets.some((t) => t.name === "desktop")) {
|
|
1782
|
+
const p = desktopAppPort();
|
|
1783
|
+
if (p) {
|
|
1784
|
+
PINNED_UPSTREAM = `http://127.0.0.1:${p}`;
|
|
1785
|
+
try { fs.writeFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), PINNED_UPSTREAM + "\n"); } catch { /* 忽略 */ }
|
|
1786
|
+
console.log(`ℹ 桌面端在 127.0.0.1:${p} 监听 → 桥的上游已钉到它(避免与 Web 版互抢)`);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
} catch { /* 探测失败不阻断安装 */ }
|
|
1669
1790
|
svc = installAutostart();
|
|
1670
1791
|
} else {
|
|
1671
1792
|
console.log("ℹ --no-autostart:跳过自启动服务安装(可用 `dsh-remote run` 手动运行 bridge)。");
|
|
@@ -1678,7 +1799,9 @@ async function setup(argv) {
|
|
|
1678
1799
|
try {
|
|
1679
1800
|
// ⚠️ 必须把 `--profile` **透传下去**:否则 `--profile desktop`(桌面端)会被静默忽略,
|
|
1680
1801
|
// 插件装进 Web 版 profile,而用户面对的是桌面端 —— 面板永远不出现。
|
|
1681
|
-
|
|
1802
|
+
// 未指定 --profile 时目标是本机全部 profile:桌面端提醒与后续判定都要按同一份列表来
|
|
1803
|
+
const pluginTargets = targetProfileDirs(argv);
|
|
1804
|
+
for (const t of pluginTargets) warnIfDesktopProfile(t.dir);
|
|
1682
1805
|
pluginResult = await pluginCmd(profileArgvOf(argv));
|
|
1683
1806
|
} catch (e) {
|
|
1684
1807
|
console.warn(`⚠️ 插件安装未完成:${e.message}`);
|
|
@@ -1688,7 +1811,8 @@ async function setup(argv) {
|
|
|
1688
1811
|
// 插件是**进程启动时**装载的:刚装进 profile 的插件,运行中的 dsh web 里并没有
|
|
1689
1812
|
// (既没有 /dsh-remote/* 路由,也没有「设置 → 远程访问」面板项)。
|
|
1690
1813
|
// 这里做一个确定性判断:进程启动时间早于插件落盘时间 → 必须重启 dsh web 才生效。
|
|
1691
|
-
const
|
|
1814
|
+
const primaryProfileDir = targetProfileDirs(argv)[0].dir; // desktop 优先 = 用户面对的那个宿主
|
|
1815
|
+
const pluginDir = path.join(primaryProfileDir, PLUGIN_LOCAL_DIR);
|
|
1692
1816
|
const pluginMtime = fs.existsSync(path.join(pluginDir, "lib", "index.js"))
|
|
1693
1817
|
? fs.statSync(path.join(pluginDir, "lib", "index.js")).mtimeMs
|
|
1694
1818
|
: 0;
|
|
@@ -2656,13 +2780,32 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
|
|
|
2656
2780
|
|
|
2657
2781
|
async function pluginCmd(argv) {
|
|
2658
2782
|
const uninstall = hasFlag(argv, "--uninstall");
|
|
2659
|
-
const
|
|
2783
|
+
const targets = targetProfileDirs(argv);
|
|
2784
|
+
const multi = targets.length > 1;
|
|
2785
|
+
if (multi) {
|
|
2786
|
+
console.log(`ℹ 未指定 --profile:同步到本机**全部 ${targets.length} 个 profile**(${targets.map((t) => t.name).join(" → ")})`);
|
|
2787
|
+
}
|
|
2788
|
+
let last = null;
|
|
2789
|
+
let ok = 0;
|
|
2790
|
+
for (const t of targets) {
|
|
2791
|
+
if (multi) console.log(`\n── profile: ${t.name}(${t.dir})`);
|
|
2792
|
+
const r = await pluginApplyTo(t.dir, uninstall, multi);
|
|
2793
|
+
if (r === "skip") continue;
|
|
2794
|
+
ok += 1;
|
|
2795
|
+
if (r !== undefined && r !== null) last = r;
|
|
2796
|
+
}
|
|
2797
|
+
if (ok === 0) process.exit(1);
|
|
2798
|
+
return last;
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
/** 对**单个** profile 执行插件安装/卸载(原 pluginCmd 主体,逐字保留行为)。 */
|
|
2802
|
+
async function pluginApplyTo(profileDir, uninstall, multi) {
|
|
2660
2803
|
const pkgFile = path.join(profileDir, "package.json");
|
|
2661
2804
|
const patchFile = path.join(profileDir, "cordis.patch.yml");
|
|
2662
2805
|
const pluginDir = path.join(THIS_DIR, "packages", PLUGIN_ID);
|
|
2663
2806
|
|
|
2664
2807
|
if (!fs.existsSync(pkgFile) || !fs.existsSync(patchFile)) {
|
|
2665
|
-
console.error(
|
|
2808
|
+
console.error(`${multi ? "⚠️ 跳过(未找到 profile)" : "❌ 未找到 profile"}(${profileDir})。`);
|
|
2666
2809
|
if (isDesktopProfileDir(profileDir)) {
|
|
2667
2810
|
// 桌面端 profile 由 Electron 应用**自己初始化**:npm 版 `dsh` 明确拒绝管理它
|
|
2668
2811
|
// (apps/cli 的 rejectElectronProfile),所以 Web 版那句 "npx @deepseek-ai/dsh web"
|
|
@@ -2675,6 +2818,7 @@ async function pluginCmd(argv) {
|
|
|
2675
2818
|
} else {
|
|
2676
2819
|
console.error(" 请先安装 DeepSeek Harness(npx @deepseek-ai/dsh web)并初始化默认 profile。");
|
|
2677
2820
|
}
|
|
2821
|
+
if (multi) return "skip";
|
|
2678
2822
|
process.exit(1);
|
|
2679
2823
|
}
|
|
2680
2824
|
if (!fs.existsSync(pluginDir)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.7.0-beta.
|
|
3
|
+
"version": "0.7.0-beta.6",
|
|
4
4
|
"description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -834,22 +834,34 @@ window.__ModuleLoader__.load({
|
|
|
834
834
|
* 其中 **带 aria-keyshortcuts / 文案或 aria-label 含「设置」** 的优先(语言无关 + 精确)。
|
|
835
835
|
*/
|
|
836
836
|
function settingsTriggerCandidates() {
|
|
837
|
-
var
|
|
837
|
+
var ranked = [];
|
|
838
838
|
try {
|
|
839
|
+
/* 排名而不是「谁先扫到谁」(2026-10-11 业主实测「点入口弹出的是 token 用量小弹窗」):
|
|
840
|
+
① 官方设置槽位里的按钮 = 确定是设置触发器(sidebar.settings / settings.launcher)
|
|
841
|
+
② 带快捷键或文案含「设置」的弹层触发器
|
|
842
|
+
③ 其它弹层触发器(最后兜底)
|
|
843
|
+
并且**排除我们自己所在的底部槽**(sidebar.footer.action)—— token 用量、其它插件按钮
|
|
844
|
+
都在那一块,它们同样带 aria-haspopup=dialog,正是「点错弹窗」的来源。
|
|
845
|
+
只用 closest 判容器、不做后代选择器:任何 DOM 实现(含测试替身)都支持。 */
|
|
839
846
|
var cands = document.querySelectorAll('button[aria-haspopup="dialog"], [aria-haspopup="dialog"]');
|
|
840
|
-
var rest = [];
|
|
841
847
|
for (var i = 0; i < cands.length; i++) {
|
|
842
848
|
var el = cands[i];
|
|
843
849
|
if (!el || el.id === NAV_ENTRY_ID) continue; // 排除我们自己的入口
|
|
844
850
|
if (navInDialog(el)) continue; // 弹层内的(关闭键/栏目)一律不要
|
|
851
|
+
try { if (el.closest && el.closest('[data-slot="sidebar.footer.action"]')) continue; } catch (e0) { /* 忽略 */ }
|
|
845
852
|
if (NAV_BAD_RE.test(String(el.className || ""))) continue;
|
|
853
|
+
var inSettingsSlot = false;
|
|
854
|
+
try {
|
|
855
|
+
inSettingsSlot = !!(el.closest && el.closest('[data-slot="sidebar.settings"], [data-slot="settings.launcher"]'));
|
|
856
|
+
} catch (e1) { inSettingsSlot = false; }
|
|
846
857
|
var label = String(navAttrOf(el) || navCleanText(el) || "");
|
|
847
858
|
var hasShortcut = !!(el.getAttribute && el.getAttribute("aria-keyshortcuts"));
|
|
848
|
-
|
|
849
|
-
|
|
859
|
+
var rank = inSettingsSlot ? 0 : ((hasShortcut || /设置|Settings/i.test(label)) ? 1 : 2);
|
|
860
|
+
ranked.push({ el: el, rank: rank, order: i });
|
|
850
861
|
}
|
|
851
|
-
return
|
|
852
|
-
|
|
862
|
+
ranked.sort(function (a, b) { return (a.rank - b.rank) || (a.order - b.order); });
|
|
863
|
+
return ranked.map(function (x) { return x.el; });
|
|
864
|
+
} catch (e) { return ranked.map(function (x) { return x.el; }); }
|
|
853
865
|
}
|
|
854
866
|
/** 官方「设置」触发器(首选候选,保持旧名字给测试与调用方用)。 */
|
|
855
867
|
function pickSettingsTrigger() {
|
|
@@ -2718,7 +2730,11 @@ window.__ModuleLoader__.load({
|
|
|
2718
2730
|
// 在那边**做不到也不该做**(强行退出会弹原生致命错误)。面板据此:
|
|
2719
2731
|
// ① 不自动倒计时重启;② 保留提醒但改文案,引导"退出并重开桌面应用"。
|
|
2720
2732
|
// 旧版 host 的响应里没有该字段 → false,行为与改动前完全一致。
|
|
2721
|
-
|
|
2733
|
+
// 🔴 2026-10-11 修:node 半把该字段下在 **connect** 里(`connect.desktopHost`),
|
|
2734
|
+
// 这里以前读顶层 `st.desktopHost` → **永远 false** → 桌面端被当成 Web 版:
|
|
2735
|
+
// 面板照样摆「立即重启」按钮、照样走倒计时自动重启那条路(业主实测"装插件时窗口被突兀关掉")。
|
|
2736
|
+
// 兼容两种位置,桌面端的"不自动重启/只提示用户自己重启"这才真正生效。
|
|
2737
|
+
var desktopHost = !!(st && (st.desktopHost || (st.connect && st.connect.desktopHost)));
|
|
2722
2738
|
// kind === "refresh" = 插件文件在运行中被改写(装完/更新完),只需刷新页面;
|
|
2723
2739
|
// 其余情况(极为罕见)才提重启 dsh web。
|
|
2724
2740
|
var isRefreshOnly = restartPending && restartInfo.kind === "refresh";
|
|
@@ -6626,6 +6626,21 @@ function registerRoutes(ctx, relayDir) {
|
|
|
6626
6626
|
handler: async (_req, res) => {
|
|
6627
6627
|
// 首次安装/在线更新后重启 DeepSeek harness:插件本体与浏览器半在进程启动时装载,
|
|
6628
6628
|
// 不重启则面板入口与 /dsh-remote/* 路由都不会出现。只重启承载本插件的进程。
|
|
6629
|
+
//
|
|
6630
|
+
// 🔴 2026-10-11 业主口径:**桌面端绝不代为重启**。
|
|
6631
|
+
// "自动重启会很突兀地把窗口给关掉,关没了……我们应该提示用户自己重启,不要由我们来重启。"
|
|
6632
|
+
// 桌面端 Host 由 Electron 托管,重启 = 强杀承载窗口的进程(还会弹原生致命错误)。
|
|
6633
|
+
// 这里做成**硬拒绝**(不只是前端藏按钮):任何调用方都拿不到"被重启"的结果,
|
|
6634
|
+
// 只会收到一句明确的"请手动重启",前端据此展示提示。
|
|
6635
|
+
if (IS_ELECTRON_HOST) {
|
|
6636
|
+
sendJson(res, 200, {
|
|
6637
|
+
...(await composeStatus(relayDir)),
|
|
6638
|
+
ok: false,
|
|
6639
|
+
status: "manual-restart-required",
|
|
6640
|
+
detail: "桌面端不代为重启:请手动退出并重新打开 DeepSeek Harness(自动重启会突兀地关掉窗口)",
|
|
6641
|
+
});
|
|
6642
|
+
return;
|
|
6643
|
+
}
|
|
6629
6644
|
const r = await restartHarness(relayDir);
|
|
6630
6645
|
const payload = { ...(await composeStatus(relayDir)), ...r };
|
|
6631
6646
|
if (!r.ok) payload.error = r.detail || r.status || "重启失败";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.7.0-beta.
|
|
3
|
+
"version": "0.7.0-beta.6",
|
|
4
4
|
"description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -173,10 +173,13 @@ function printHelp() {
|
|
|
173
173
|
dsh-remote doctor 一条命令自检「为什么连不上」(只读;含上游/进程/微信/日志尾部)
|
|
174
174
|
dsh-remote --help 显示本用法(等同 dsh-remote help)
|
|
175
175
|
|
|
176
|
-
目标 profile
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
176
|
+
目标 profile(默认:**本机 Web 版 + 桌面端一起装**):
|
|
177
|
+
不带 --profile 时,会把插件装进本机**已有的** Web 版与桌面端 profile(谁在用就装给谁,
|
|
178
|
+
避免"命令跑完了、桌面端却没有面板"这类装错地方的坑);机器上的实验/自动化 profile 不会被动。
|
|
179
|
+
dsh-remote --profile desktop 只装进 DeepSeek Harness **桌面端**
|
|
180
|
+
(~/.dsh/profiles/desktop)
|
|
181
|
+
dsh-remote --profile web 只装进 Web 版
|
|
182
|
+
桌面端也可以在应用内「插件」页直接安装 dsh-remote-web —— 那是官方路径,也最省事。
|
|
180
183
|
⚠️ 桌面端与 Web 版共享 ~/.dsh(会话/设置/凭据),但**插件互相隔离**,两边要各自安装。
|
|
181
184
|
|
|
182
185
|
自建模式(可选):
|
|
@@ -712,7 +715,7 @@ function writeAutostartFile() {
|
|
|
712
715
|
<key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
|
|
713
716
|
<key>StandardOutPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
714
717
|
<key>StandardErrorPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
715
|
-
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string><key>DSH_BRIDGE_HARNESS_VERSION</key><string>${HARNESS_VERSION}</string></dict>
|
|
718
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}${PINNED_UPSTREAM ? `<key>DSH_BRIDGE_UPSTREAM</key><string>${PINNED_UPSTREAM}</string>` : ""}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string><key>DSH_BRIDGE_HARNESS_VERSION</key><string>${HARNESS_VERSION}</string></dict>
|
|
716
719
|
</dict></plist>`;
|
|
717
720
|
// ~/Library/LaunchAgents 在全新账户/精简系统上可能不存在,必须先建目录,
|
|
718
721
|
// 否则 writeFileSync 抛 ENOENT 直接把安装流程打断(用户反馈里也踩过类似的路径问题)。
|
|
@@ -760,19 +763,46 @@ function launchdState(target) {
|
|
|
760
763
|
};
|
|
761
764
|
}
|
|
762
765
|
|
|
766
|
+
/**
|
|
767
|
+
* 等 label **真的从 launchd 里摘干净**。
|
|
768
|
+
*
|
|
769
|
+
* 🔴 2026-10-11 实测的坑:`launchctl bootout` 是**异步**的 —— 紧接着 bootstrap 同一个 label 会
|
|
770
|
+
* 随机失败(`Bootstrap failed: 5: Input/output error`),表现为"安装时灵时不灵":
|
|
771
|
+
* 手工在两条命令之间加个 sleep 就 100% 成功,安装器里没等就大约一半失败 →
|
|
772
|
+
* 失败后走 `spawnDetachedBridge()` 兜底 → 用户看到"⚠️ 未被系统托管 / 不会开机自启"。
|
|
773
|
+
* 所以 bootout 之后必须轮询到 `launchctl print` 真的查不到,再 bootstrap。
|
|
774
|
+
*/
|
|
775
|
+
function waitLaunchdGone(target, tries = 12, gapMs = 200) {
|
|
776
|
+
for (let i = 0; i < tries; i += 1) {
|
|
777
|
+
if (!sh(`launchctl print ${target}`).ok) return true;
|
|
778
|
+
sleepSync(gapMs);
|
|
779
|
+
}
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
|
|
763
783
|
/**
|
|
764
784
|
* 轮询等待 job 起来。
|
|
765
785
|
* launchd 派生是异步的:bootstrap 成功后立刻 print 必然看到 not running(旧实现因此误报"启动失败")。
|
|
766
786
|
* 明确"被挂起且没有派生"时提前放弃(等满也没用),否则给足 ~8 秒。
|
|
767
787
|
*/
|
|
768
|
-
function waitLaunchdRunning(target, tries =
|
|
788
|
+
function waitLaunchdRunning(target, tries = 25, gapMs = 400) {
|
|
769
789
|
let st = launchdState(target);
|
|
770
790
|
for (let i = 0; i < tries; i += 1) {
|
|
771
791
|
st = launchdState(target);
|
|
772
792
|
if (st.running) return st;
|
|
773
|
-
|
|
793
|
+
/* 🔴 2026-10-11 修:刚 bootstrap/kickstart 后 launchd 会先进入**过渡态**
|
|
794
|
+
(`state = xpcproxy` / `spawn scheduled` / `pending`),此时 runs 还是 0。
|
|
795
|
+
旧判据"登记了却一次都没派生 → 判定被挂起"在 1.2 秒后就放弃,于是
|
|
796
|
+
`installAutostart()` 误报"launchd 无法托管本机 bridge",转而拉起一个**脱离进程** ——
|
|
797
|
+
用户看到的是"安装没装好/不会开机自启",而实际上 launchd 下一秒就把它拉起来了
|
|
798
|
+
(现场实测:state=xpcproxy,几秒后 state=running)。
|
|
799
|
+
现在:过渡态一律继续等;只有"确实派生过又退出"或"等满窗口都没起来"才算失败。 */
|
|
800
|
+
const transitional = /state\s*=\s*(xpcproxy|spawn scheduled|pending|running\s*\()/i.test(String(st.raw || ""));
|
|
801
|
+
if (transitional) { sleepSync(gapMs); continue; }
|
|
802
|
+
// 已派生过、现在没在跑、也没被挂起 → 是启动后立刻退出(真失败),不必等满
|
|
774
803
|
if (st.exists && st.runs > 0 && !st.deferred) break;
|
|
775
|
-
|
|
804
|
+
// 登记了却一次都没派生,且已经等了足够久(给足 4 秒让 launchd 调度)→ 判为被挂起
|
|
805
|
+
if (i >= 10 && st.exists && !st.deferred && st.runs === 0) break;
|
|
776
806
|
sleepSync(gapMs);
|
|
777
807
|
}
|
|
778
808
|
return st;
|
|
@@ -850,7 +880,14 @@ function startBridgeDarwin(plistPath) {
|
|
|
850
880
|
};
|
|
851
881
|
}
|
|
852
882
|
sh(`launchctl bootout ${target}`);
|
|
883
|
+
waitLaunchdGone(target); // 关键:不等干净就 bootstrap → 随机 "5: Input/output error"
|
|
853
884
|
let boot = sh(`launchctl bootstrap ${domain} ${q(plistPath)}`);
|
|
885
|
+
if (!boot.ok) {
|
|
886
|
+
// 摘除竞态可能还没结束:再等一拍重试一次(仍失败才走老写法兜底)
|
|
887
|
+
sleepSync(600);
|
|
888
|
+
waitLaunchdGone(target, 6, 200);
|
|
889
|
+
boot = sh(`launchctl bootstrap ${domain} ${q(plistPath)}`);
|
|
890
|
+
}
|
|
854
891
|
if (!boot.ok) {
|
|
855
892
|
// 老写法兜底(部分系统上 load -w 仍可用)
|
|
856
893
|
sh(`launchctl unload ${q(plistPath)}`);
|
|
@@ -959,6 +996,33 @@ function restartBridgeService() {
|
|
|
959
996
|
* 统一在 clients/dsh-remote/upstream-discovery.mjs,watcher 与 bridge 共用同一份实现。
|
|
960
997
|
*/
|
|
961
998
|
const UPSTREAM_FILE = ".dsh-upstream";
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* 本次安装要**钉住**的上游(null = 不钉,运行时按 env → .dsh-upstream → 探测 取值)。
|
|
1002
|
+
* 见 desktopAppPort() 的注释:插件装进多个 profile 后,各宿主会互相覆盖 `.dsh-upstream`。
|
|
1003
|
+
*/
|
|
1004
|
+
let PINNED_UPSTREAM = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim().replace(/\/+$/, "") || null;
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* 探测桌面端(Electron 应用)当前监听的**回环端口**。
|
|
1008
|
+
*
|
|
1009
|
+
* 为什么需要(2026-10-11 实测事故):插件同时装进 web 与 desktop 后,**每个宿主**都会在装载时
|
|
1010
|
+
* 把自己 `ctx.webServer.port` 写进 `<relayDir>/.dsh-upstream` —— 谁最后写谁生效。于是桥的上游
|
|
1011
|
+
* 会在两个应用之间来回跳:那次桥跑去连了 Web 版的 3080,而业主面对的是桌面端 19387,
|
|
1012
|
+
* 表现就是"命令跑完了但没装上 / 手机连上看到的不是我在用的界面"。
|
|
1013
|
+
* 装桌面端时把端口**钉进自启动配置**(环境变量优先级最高),两个宿主就抢不动了。
|
|
1014
|
+
*/
|
|
1015
|
+
function desktopAppPort() {
|
|
1016
|
+
if (process.platform !== "darwin") return null;
|
|
1017
|
+
try {
|
|
1018
|
+
const r = sh("lsof -nP -iTCP -sTCP:LISTEN -a -c DeepSeek 2>/dev/null");
|
|
1019
|
+
for (const line of String(r.stdout || "").split("\n")) {
|
|
1020
|
+
const m = line.match(/(?:127\.0\.0\.1|\[::1\]):(\d+)\s/);
|
|
1021
|
+
if (m) return Number(m[1]);
|
|
1022
|
+
}
|
|
1023
|
+
} catch { /* 拿不到就算了,退回运行时探测 */ }
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
962
1026
|
/** 运行时动态发现到的上游(优于可能过期的端口文件;进程内缓存)。 */
|
|
963
1027
|
let discoveredUpstream = "";
|
|
964
1028
|
function upstreamUrl() {
|
|
@@ -1075,6 +1139,50 @@ function resolveProfileDir(argv = []) {
|
|
|
1075
1139
|
* `pluginCmd()` 时原本传的是空数组 —— 于是"指定 desktop profile"会被静默忽略、
|
|
1076
1140
|
* 插件仍然装进 Web 版 profile(桌面端用户装了却看不到面板,且极难自查)。
|
|
1077
1141
|
*/
|
|
1142
|
+
/**
|
|
1143
|
+
* 探测本机**已存在**的 profile(可装载 = 同时有 package.json 与 cordis.patch.yml)。
|
|
1144
|
+
* desktop 排在前面:业主主用的是桌面应用,多个 profile 一起装时它是"首选/权威"那个。
|
|
1145
|
+
*/
|
|
1146
|
+
function detectProfileDirs() {
|
|
1147
|
+
const root = path.join(os.homedir(), ".dsh", "profiles");
|
|
1148
|
+
let names = [];
|
|
1149
|
+
try {
|
|
1150
|
+
names = fs.readdirSync(root, { withFileTypes: true })
|
|
1151
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
1152
|
+
.map((e) => e.name)
|
|
1153
|
+
.filter((n) => fs.existsSync(path.join(root, n, "package.json")) && fs.existsSync(path.join(root, n, "cordis.patch.yml")));
|
|
1154
|
+
} catch { return []; }
|
|
1155
|
+
names.sort((a, b) => (a === "desktop" ? -1 : b === "desktop" ? 1 : a.localeCompare(b)));
|
|
1156
|
+
return names.map((n) => ({ name: n, dir: profileDirOfName(n) }));
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* 本次要操作的 profile 列表。
|
|
1161
|
+
*
|
|
1162
|
+
* 业主口径(2026-10-11):「默认安装的时候也能同步安装给 desktop」。
|
|
1163
|
+
* 以前不带 `--profile` 时**写死装 web** —— 桌面端用户执行官方那条 `npx @mrrisega/dsh-remote@beta`
|
|
1164
|
+
* 之后,插件全进了 Web 版 profile,而用户面对的是桌面应用 → 面板永远不出现,且极难自查。
|
|
1165
|
+
*
|
|
1166
|
+
* · `--profile X` / `DSH_PROFILE_DIR` → 只动那一个(脚本化与既有用法不变)
|
|
1167
|
+
* · 都不给 → **本机所有已存在的 profile**(desktop 优先)
|
|
1168
|
+
* · 一个都没探测到 → 退回 web(保留"首次安装"的引导文案)
|
|
1169
|
+
*/
|
|
1170
|
+
function targetProfileDirs(argv = []) {
|
|
1171
|
+
const i = argv.indexOf("--profile");
|
|
1172
|
+
if (i > -1 && argv[i + 1]) return [{ name: argv[i + 1], dir: resolveProfileDir(argv) }];
|
|
1173
|
+
if (process.env.DSH_PROFILE_DIR) return [{ name: "env", dir: process.env.DSH_PROFILE_DIR }];
|
|
1174
|
+
const all = detectProfileDirs();
|
|
1175
|
+
if (!all.length) return [{ name: "web", dir: profileDirOfName("web") }];
|
|
1176
|
+
/* 默认装哪些?**不是"全部"** —— 机器上常有过实验/自动化用的 profile(headless、web02…),
|
|
1177
|
+
往那些里面塞插件是越界。规则:
|
|
1178
|
+
· 已知的面向用户的 profile(web / desktop):**有就装**(这是业主的核心诉求:默认也要给 desktop)
|
|
1179
|
+
· 其他 profile:**只有本来就已经装了插件**才继续管(升级不能悄悄漏掉它)
|
|
1180
|
+
· 上面并集为空(例如只有 headless 一种)→ 退回"全部已存在",避免什么都不装 */
|
|
1181
|
+
const chosen = all.filter((p) => KNOWN_PROFILE_NAMES.includes(p.name)
|
|
1182
|
+
|| fs.existsSync(path.join(p.dir, PLUGIN_LOCAL_DIR)));
|
|
1183
|
+
return chosen.length ? chosen : all;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1078
1186
|
function profileArgvOf(argv = []) {
|
|
1079
1187
|
const i = argv.indexOf("--profile");
|
|
1080
1188
|
return i > -1 && argv[i + 1] ? ["--profile", argv[i + 1]] : [];
|
|
@@ -1666,6 +1774,19 @@ async function setup(argv) {
|
|
|
1666
1774
|
|
|
1667
1775
|
let svc = { path: null, status: { ok: true, status: "skipped", detail: "--no-autostart" } };
|
|
1668
1776
|
if (!noAutostart) {
|
|
1777
|
+
// ⚠️ 顺序要紧:**先**探测并钉住上游,**再**写自启动文件 ——
|
|
1778
|
+
// 否则 plist 里不会带上 DSH_BRIDGE_UPSTREAM(实测踩到:打印说已钉住,plist 里却没有)。
|
|
1779
|
+
try {
|
|
1780
|
+
const pluginTargets = targetProfileDirs(argv);
|
|
1781
|
+
if (!PINNED_UPSTREAM && pluginTargets.some((t) => t.name === "desktop")) {
|
|
1782
|
+
const p = desktopAppPort();
|
|
1783
|
+
if (p) {
|
|
1784
|
+
PINNED_UPSTREAM = `http://127.0.0.1:${p}`;
|
|
1785
|
+
try { fs.writeFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), PINNED_UPSTREAM + "\n"); } catch { /* 忽略 */ }
|
|
1786
|
+
console.log(`ℹ 桌面端在 127.0.0.1:${p} 监听 → 桥的上游已钉到它(避免与 Web 版互抢)`);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
} catch { /* 探测失败不阻断安装 */ }
|
|
1669
1790
|
svc = installAutostart();
|
|
1670
1791
|
} else {
|
|
1671
1792
|
console.log("ℹ --no-autostart:跳过自启动服务安装(可用 `dsh-remote run` 手动运行 bridge)。");
|
|
@@ -1678,7 +1799,9 @@ async function setup(argv) {
|
|
|
1678
1799
|
try {
|
|
1679
1800
|
// ⚠️ 必须把 `--profile` **透传下去**:否则 `--profile desktop`(桌面端)会被静默忽略,
|
|
1680
1801
|
// 插件装进 Web 版 profile,而用户面对的是桌面端 —— 面板永远不出现。
|
|
1681
|
-
|
|
1802
|
+
// 未指定 --profile 时目标是本机全部 profile:桌面端提醒与后续判定都要按同一份列表来
|
|
1803
|
+
const pluginTargets = targetProfileDirs(argv);
|
|
1804
|
+
for (const t of pluginTargets) warnIfDesktopProfile(t.dir);
|
|
1682
1805
|
pluginResult = await pluginCmd(profileArgvOf(argv));
|
|
1683
1806
|
} catch (e) {
|
|
1684
1807
|
console.warn(`⚠️ 插件安装未完成:${e.message}`);
|
|
@@ -1688,7 +1811,8 @@ async function setup(argv) {
|
|
|
1688
1811
|
// 插件是**进程启动时**装载的:刚装进 profile 的插件,运行中的 dsh web 里并没有
|
|
1689
1812
|
// (既没有 /dsh-remote/* 路由,也没有「设置 → 远程访问」面板项)。
|
|
1690
1813
|
// 这里做一个确定性判断:进程启动时间早于插件落盘时间 → 必须重启 dsh web 才生效。
|
|
1691
|
-
const
|
|
1814
|
+
const primaryProfileDir = targetProfileDirs(argv)[0].dir; // desktop 优先 = 用户面对的那个宿主
|
|
1815
|
+
const pluginDir = path.join(primaryProfileDir, PLUGIN_LOCAL_DIR);
|
|
1692
1816
|
const pluginMtime = fs.existsSync(path.join(pluginDir, "lib", "index.js"))
|
|
1693
1817
|
? fs.statSync(path.join(pluginDir, "lib", "index.js")).mtimeMs
|
|
1694
1818
|
: 0;
|
|
@@ -2656,13 +2780,32 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
|
|
|
2656
2780
|
|
|
2657
2781
|
async function pluginCmd(argv) {
|
|
2658
2782
|
const uninstall = hasFlag(argv, "--uninstall");
|
|
2659
|
-
const
|
|
2783
|
+
const targets = targetProfileDirs(argv);
|
|
2784
|
+
const multi = targets.length > 1;
|
|
2785
|
+
if (multi) {
|
|
2786
|
+
console.log(`ℹ 未指定 --profile:同步到本机**全部 ${targets.length} 个 profile**(${targets.map((t) => t.name).join(" → ")})`);
|
|
2787
|
+
}
|
|
2788
|
+
let last = null;
|
|
2789
|
+
let ok = 0;
|
|
2790
|
+
for (const t of targets) {
|
|
2791
|
+
if (multi) console.log(`\n── profile: ${t.name}(${t.dir})`);
|
|
2792
|
+
const r = await pluginApplyTo(t.dir, uninstall, multi);
|
|
2793
|
+
if (r === "skip") continue;
|
|
2794
|
+
ok += 1;
|
|
2795
|
+
if (r !== undefined && r !== null) last = r;
|
|
2796
|
+
}
|
|
2797
|
+
if (ok === 0) process.exit(1);
|
|
2798
|
+
return last;
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
/** 对**单个** profile 执行插件安装/卸载(原 pluginCmd 主体,逐字保留行为)。 */
|
|
2802
|
+
async function pluginApplyTo(profileDir, uninstall, multi) {
|
|
2660
2803
|
const pkgFile = path.join(profileDir, "package.json");
|
|
2661
2804
|
const patchFile = path.join(profileDir, "cordis.patch.yml");
|
|
2662
2805
|
const pluginDir = path.join(THIS_DIR, "packages", PLUGIN_ID);
|
|
2663
2806
|
|
|
2664
2807
|
if (!fs.existsSync(pkgFile) || !fs.existsSync(patchFile)) {
|
|
2665
|
-
console.error(
|
|
2808
|
+
console.error(`${multi ? "⚠️ 跳过(未找到 profile)" : "❌ 未找到 profile"}(${profileDir})。`);
|
|
2666
2809
|
if (isDesktopProfileDir(profileDir)) {
|
|
2667
2810
|
// 桌面端 profile 由 Electron 应用**自己初始化**:npm 版 `dsh` 明确拒绝管理它
|
|
2668
2811
|
// (apps/cli 的 rejectElectronProfile),所以 Web 版那句 "npx @deepseek-ai/dsh web"
|
|
@@ -2675,6 +2818,7 @@ async function pluginCmd(argv) {
|
|
|
2675
2818
|
} else {
|
|
2676
2819
|
console.error(" 请先安装 DeepSeek Harness(npx @deepseek-ai/dsh web)并初始化默认 profile。");
|
|
2677
2820
|
}
|
|
2821
|
+
if (multi) return "skip";
|
|
2678
2822
|
process.exit(1);
|
|
2679
2823
|
}
|
|
2680
2824
|
if (!fs.existsSync(pluginDir)) {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「默认安装装给谁」契约测试(2026-10-11 业主口径)。
|
|
3
|
+
*
|
|
4
|
+
* 事故/反馈:业主执行官方那条 `npx --yes @mrrisega/dsh-remote@beta`(不带参数)后,
|
|
5
|
+
* 插件**全进了 Web 版 profile**,而他面对的是**桌面应用** —— 面板永远不出现,
|
|
6
|
+
* 且面板/文档里都看不出来"装错了地方"。根因:`resolveProfileDir()` 在没有 `--profile`
|
|
7
|
+
* 与 `DSH_PROFILE_DIR` 时**写死 `profileDirOfName("web")`**。
|
|
8
|
+
*
|
|
9
|
+
* 现在的契约:
|
|
10
|
+
* · `--profile X` / `DSH_PROFILE_DIR` → 只动那一个(脚本化语义不变)
|
|
11
|
+
* · 都不给 → **已知的面向用户的 profile(web / desktop,有就装)+ 本来就已经装了插件的 profile**
|
|
12
|
+
* (升级不能悄悄漏掉它);机器上的实验/自动化 profile(headless、web02…)**不碰**
|
|
13
|
+
* · 一个可装载 profile 都没有 → 退回 web(保留首次安装引导)
|
|
14
|
+
*
|
|
15
|
+
* 用例全程在**假 HOME** 里跑真实子进程,绝不触碰开发者真实的 ~/.dsh/profiles/**。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { test } from "node:test";
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
|
|
26
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
27
|
+
const SETUP = join(ROOT, "dsh-setup.mjs");
|
|
28
|
+
const PLUGIN_LOCAL_DIR = "dsh-remote-web-plugin";
|
|
29
|
+
|
|
30
|
+
/** 造一个"可装载"的 profile(必须同时有 package.json 与 cordis.patch.yml)。 */
|
|
31
|
+
function makeProfile(home, name, { withPlugin = false } = {}) {
|
|
32
|
+
const dir = join(home, ".dsh", "profiles", name);
|
|
33
|
+
mkdirSync(join(dir, "node_modules"), { recursive: true });
|
|
34
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify({
|
|
35
|
+
name: `dsh-profile-${name}`, private: true, dependencies: {},
|
|
36
|
+
dsh: { profile: { bundles: ["@deepseek-ai/dsh-base"], patchReload: "live" } }
|
|
37
|
+
}, null, 2) + "\n");
|
|
38
|
+
writeFileSync(join(dir, "cordis.patch.yml"), "# dsh profile patch\n[]\n");
|
|
39
|
+
if (withPlugin) mkdirSync(join(dir, PLUGIN_LOCAL_DIR), { recursive: true });
|
|
40
|
+
return dir;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 跑真实的 `plugin` 子进程。**显式清掉 DSH_PROFILE_DIR**:宿主环境天然带着它(会盖掉默认多选)。 */
|
|
44
|
+
function runPlugin(home, args = []) {
|
|
45
|
+
const env = { ...process.env, HOME: home, DSH_PROFILE_DIR: "", DSH_RELAY_DIR: join(home, ".dsh-remote"), DSH_RELAY_SKIP_SERVICE: "1" };
|
|
46
|
+
const r = spawnSync(process.execPath, [SETUP, "plugin", ...args], { encoding: "utf8", env, timeout: 120000 });
|
|
47
|
+
return { out: String(r.stdout || ""), err: String(r.stderr || ""), code: r.status };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const hasPlugin = (dir) => existsSync(join(dir, PLUGIN_LOCAL_DIR, "lib", "client.js"));
|
|
51
|
+
const patched = (dir) => /id:\s*dsh-remote-web/.test(readFileSync(join(dir, "cordis.patch.yml"), "utf8"));
|
|
52
|
+
|
|
53
|
+
test("默认(不带 --profile):桌面端与 Web 版**都装**,实验 profile 不碰", async () => {
|
|
54
|
+
const home = mkdtempSync(join(tmpdir(), "dshprofiles-"));
|
|
55
|
+
try {
|
|
56
|
+
const desktop = makeProfile(home, "desktop");
|
|
57
|
+
const web = makeProfile(home, "web");
|
|
58
|
+
const headless = makeProfile(home, "headless");
|
|
59
|
+
const r = runPlugin(home);
|
|
60
|
+
assert.equal(r.code, 0, `安装应成功:${r.err.slice(0, 300)}`);
|
|
61
|
+
// ★ 业主的核心诉求:默认也要装给 desktop
|
|
62
|
+
assert.ok(hasPlugin(desktop), "★ 默认安装必须装进 desktop profile(否则桌面端用户看不到面板)");
|
|
63
|
+
assert.ok(patched(desktop), "desktop 的 patch 必须写入激活行");
|
|
64
|
+
assert.ok(hasPlugin(web), "Web 版 profile 同样要装");
|
|
65
|
+
assert.ok(!hasPlugin(headless), "实验/自动化 profile(headless)不该被塞插件");
|
|
66
|
+
assert.match(r.out, /全部 2 个 profile/, "应当如实说明装了哪几个 profile");
|
|
67
|
+
assert.match(r.out, /desktop/, "输出里应点名 desktop");
|
|
68
|
+
} finally { rmSync(home, { recursive: true, force: true }); }
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("--profile X:只动那一个(脚本化语义不变)", async () => {
|
|
72
|
+
const home = mkdtempSync(join(tmpdir(), "dshprofiles-"));
|
|
73
|
+
try {
|
|
74
|
+
const desktop = makeProfile(home, "desktop");
|
|
75
|
+
const web = makeProfile(home, "web");
|
|
76
|
+
const r = runPlugin(home, ["--profile", web]);
|
|
77
|
+
assert.equal(r.code, 0, `安装应成功:${r.err.slice(0, 300)}`);
|
|
78
|
+
assert.ok(hasPlugin(web), "指定了 web 就该装 web");
|
|
79
|
+
assert.ok(!hasPlugin(desktop), "--profile 指定后**不得**顺手装 desktop");
|
|
80
|
+
} finally { rmSync(home, { recursive: true, force: true }); }
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("非知名 profile 但**本来已装过** → 继续管它(升级不能悄悄漏掉)", async () => {
|
|
84
|
+
const home = mkdtempSync(join(tmpdir(), "dshprofiles-"));
|
|
85
|
+
try {
|
|
86
|
+
const web = makeProfile(home, "web");
|
|
87
|
+
const custom = makeProfile(home, "web02", { withPlugin: true }); // 之前装过
|
|
88
|
+
const desktop = makeProfile(home, "desktop");
|
|
89
|
+
const r = runPlugin(home);
|
|
90
|
+
assert.equal(r.code, 0, `安装应成功:${r.err.slice(0, 300)}`);
|
|
91
|
+
assert.ok(hasPlugin(web) && hasPlugin(desktop), "已知 profile 照装");
|
|
92
|
+
assert.ok(hasPlugin(custom), "已装过插件的 profile 必须一起升级(否则它会停在旧版)");
|
|
93
|
+
} finally { rmSync(home, { recursive: true, force: true }); }
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("一个可装载 profile 都没有 → 退回 web,并给出首次安装引导", async () => {
|
|
97
|
+
const home = mkdtempSync(join(tmpdir(), "dshprofiles-"));
|
|
98
|
+
try {
|
|
99
|
+
mkdirSync(join(home, ".dsh", "profiles"), { recursive: true });
|
|
100
|
+
const r = runPlugin(home);
|
|
101
|
+
// 没有任何 profile → 目标退回 web(不存在)→ 单 profile 模式报错退出并给引导
|
|
102
|
+
assert.notEqual(r.code, 0, "没有可用 profile 时应失败退出");
|
|
103
|
+
assert.match(r.err + r.out, /profile/i, "要明确说找不到 profile");
|
|
104
|
+
} finally { rmSync(home, { recursive: true, force: true }); }
|
|
105
|
+
});
|
|
@@ -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
|
});
|
|
@@ -100,7 +100,7 @@ function makeDocument(roots) {
|
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
102
|
function loadNavHelpers(doc) {
|
|
103
|
-
const src = navHelpersSource() + "\nreturn { pickSettingsTrigger, pickNavByToken, navInDialog };";
|
|
103
|
+
const src = navHelpersSource() + "\nreturn { pickSettingsTrigger, settingsTriggerCandidates, pickNavByToken, navInDialog };";
|
|
104
104
|
// eslint-disable-next-line no-new-func
|
|
105
105
|
return new Function("document", "window", src)(doc, {});
|
|
106
106
|
}
|
|
@@ -135,6 +135,30 @@ test("★ 事故锁:官方「设置」触发器只认语义锚点,且必须
|
|
|
135
135
|
assert.equal(pickSettingsTrigger(), trigger, "必须命中侧栏那颗 aria-haspopup=dialog 的齿轮按钮");
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
+
test("★ 2026-10-11 业主实测:点入口弹出的是 token 用量弹窗 —— 官方设置槽位必须优先于底部槽邻居", () => {
|
|
139
|
+
// 现场:入口挂在 sidebar.footer.action,而同一底部区域的「token 本轮用量」按钮
|
|
140
|
+
// 也带 aria-haspopup="dialog"(甚至带快捷键)→ 旧实现"谁先扫到谁"就点中了它。
|
|
141
|
+
// 现在:① 官方设置槽位里的按钮排第一;② 底部槽(我们所在那块)里的候选直接排除。
|
|
142
|
+
const sidebar = new El("div", { cls: "sidebarRoot" });
|
|
143
|
+
const footSlot = new El("div", { attrs: { "data-slot": "sidebar.footer.action" } });
|
|
144
|
+
const tokenBtn = new El("button", { cls: "tokTrigger", attrs: { "aria-haspopup": "dialog", "aria-keyshortcuts": "Alt+Meta+T", "aria-label": "本轮用量" } });
|
|
145
|
+
const entry = new El("button", { id: "dru-nav-remote", cls: "dru-foot-entry", text: "📱 远程访问" });
|
|
146
|
+
footSlot.append(tokenBtn, entry);
|
|
147
|
+
const settingsArea = new El("div", { cls: "settingsArea" });
|
|
148
|
+
const settingsSlot = new El("div", { attrs: { "data-slot": "sidebar.settings" } });
|
|
149
|
+
const trigger = new El("button", { cls: "gearTrigger", attrs: { "aria-haspopup": "dialog", "aria-label": "设置" } });
|
|
150
|
+
settingsSlot.append(trigger);
|
|
151
|
+
settingsArea.append(settingsSlot);
|
|
152
|
+
sidebar.append(footSlot, settingsArea);
|
|
153
|
+
|
|
154
|
+
const doc = makeDocument([sidebar]);
|
|
155
|
+
const { pickSettingsTrigger, settingsTriggerCandidates } = loadNavHelpers(doc);
|
|
156
|
+
const cands = settingsTriggerCandidates();
|
|
157
|
+
assert.ok(!cands.includes(tokenBtn), "★ 底部槽里的 token 用量按钮必须被排除(点它会弹错窗)");
|
|
158
|
+
assert.ok(!cands.includes(entry), "我们自己的入口不能成为候选(否则点自己→再点自己)");
|
|
159
|
+
assert.equal(pickSettingsTrigger(), trigger, "★ 必须选中官方设置槽位里的齿轮,而不是 token 用量按钮");
|
|
160
|
+
});
|
|
161
|
+
|
|
138
162
|
test("★ 事故锁:弹层里的「通用设置」永远不能成为落点", () => {
|
|
139
163
|
const { doc, general } = desktopFixture();
|
|
140
164
|
const { pickSettingsTrigger, pickNavByToken } = loadNavHelpers(doc);
|
|
@@ -193,3 +193,36 @@ test("★ 插件拷贝必须保留**自带的 runtime/node_modules**(ws 是桥
|
|
|
193
193
|
function require$path() {
|
|
194
194
|
return { join: (...a) => a.join("/"), sep: "/", relative: (from, to) => to.startsWith(from + "/") ? to.slice(from.length + 1) : to };
|
|
195
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
|
+
});
|