@mrrisega/dsh-remote 0.4.9 → 0.6.0-beta.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.
@@ -1,11 +1,12 @@
1
- // dsh-remote-ui — node half (host plugin)
1
+ // dsh-remote-web — node half (host plugin)(2026-09 由 dsh-remote-ui 更名 dsh-remote-web;卸载/清理兼容旧名)
2
2
  //
3
- // 提供 /dsh-remote/* 同源 HTTP 路由,供浏览器半的配置面板调用:
4
- // - 读写 dsh-remote-open/.dsh-config.json(0600)
3
+ // 提供 /dsh-remote/* 同源 HTTP 路由,供浏览器半的「远程访问」设置面板调用:
4
+ // - 读写配置目录下 .dsh-config.json(0600)
5
5
  // - 查询/启停 bridge(launchctl,plist 缺失时自动生成,逻辑与 dsh-setup.mjs 一致)
6
- // - 代理 relay API(captcha / register / login / public-config),直连、不走系统代理
6
+ // - 代理 relay API(captcha / register / login / public-config),直连、不走系统代理;
7
+ // 另代理企业端一次性访问密钥 / 授权设备(/api/auth-key、/api/mobile-sessions、…/revoke,Bearer)供面板「📱 远程访问」卡使用
7
8
  // - 自管理 self*(版本可见 / 新版检测 / 一键在线更新 / 彻底卸载):插件市场没有更新卸载按钮,
8
- // 面板内即官方管理入口;更新=后台 npx @mrrisega/dsh-remote@latest(幂等补齐运行环境并重启 bridge);
9
+ // 面板内即官方管理入口;更新=后台 npx 按 dist-tag(默认 latest,DSH_UPDATE_TAG 可切 beta/alpha)(幂等补齐运行环境并重启 bridge);
9
10
  // 彻底卸载=profile 插件清理(uninstallSelf)+ 运行时清理(uninstallRuntime:停 bridge 自启动 /
10
11
  // 删 plist|unit / 杀残留进程 / 清空配置目录 ~/.dsh-remote),0.4.7 起回归真正「未安装」状态
11
12
  // - 运行时自愈:缺运行环境自动后台安装、登录后自动拉起 bridge(0.4.2 起)
@@ -144,7 +145,7 @@ function sweepStaleMarkers(relayDir) {
144
145
  if (dead === false || (dead === null && expired)) {
145
146
  try { rmSync(p, { force: true }); } catch { /* ignore */ }
146
147
  appendLogLine(relayDir, AUTO_INSTALL_LOG,
147
- `[dsh-remote-ui] 清理残留标记 ${name}(pid=${info.pid ?? "?"}, at=${new Date(info.at).toISOString()}${dead === false ? ", 进程已死" : ", 已超时"})`);
148
+ `[dsh-remote-web] 清理残留标记 ${name}(pid=${info.pid ?? "?"}, at=${new Date(info.at).toISOString()}${dead === false ? ", 进程已死" : ", 已超时"})`);
148
149
  }
149
150
  }
150
151
  }
@@ -378,7 +379,7 @@ function ensureRuntime(relayDir) {
378
379
  try {
379
380
  mkdirSync(relayDir, { recursive: true });
380
381
  const log = join(relayDir, AUTO_INSTALL_LOG);
381
- const child = spawn(npxCommand(), ["--yes", "@mrrisega/dsh-remote"], {
382
+ const child = spawn(npxCommand(), ["--yes", UPDATE_SPEC], {
382
383
  detached: true,
383
384
  env: spawnEnv({ npm_config_registry: "https://registry.npmjs.org" }),
384
385
  stdio: ["ignore", openSync(log, "a"), openSync(log, "a")]
@@ -392,13 +393,13 @@ function ensureRuntime(relayDir) {
392
393
  child.on("error", (e) => {
393
394
  clear();
394
395
  appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] 启动失败: ${e.message}`);
395
- console.warn(`[dsh-remote-ui] 自动安装子进程启动失败: ${e.message}`);
396
+ console.warn(`[dsh-remote-web] 自动安装子进程启动失败: ${e.message}`);
396
397
  });
397
398
  child.unref();
398
- console.log(`[dsh-remote-ui] 检测到缺少桌面运行环境,已在后台自动安装(日志: ${log}),完成后将自动启动 bridge`);
399
+ console.log(`[dsh-remote-web] 检测到缺少桌面运行环境,已在后台自动安装(日志: ${log}),完成后将自动启动 bridge`);
399
400
  return false;
400
401
  } catch (e) {
401
- console.warn(`[dsh-remote-ui] 自动安装启动失败: ${e.message}`);
402
+ console.warn(`[dsh-remote-web] 自动安装启动失败: ${e.message}`);
402
403
  try { rmSync(marker, { force: true }); } catch { /* ignore */ }
403
404
  return false;
404
405
  }
@@ -698,6 +699,109 @@ async function relayInviteRecords(relayDir) {
698
699
  return { records: r.body.records || [], rewards: r.body.rewards || [] };
699
700
  }
700
701
 
702
+ // ---------- 一次性访问密钥 / 已授权设备代理(E1 企业端新增 auth-key / mobile-sessions) ----------
703
+
704
+ /** 从 relay 响应里尽量提取人类可读错误信息(兼容 {error:{message}} / {error:".."} / {message} / 纯文本)。 */
705
+ function relayErrorMessage(r) {
706
+ const b = r && typeof r === "object" ? r.body : null;
707
+ if (b && typeof b === "object") {
708
+ if (typeof b.error === "string" && b.error) return b.error;
709
+ if (b.error && typeof b.error === "object") {
710
+ if (typeof b.error.message === "string" && b.error.message) return b.error.message;
711
+ }
712
+ if (typeof b.message === "string" && b.message) return b.message;
713
+ }
714
+ if (typeof b === "string" && b.trim()) return b.trim();
715
+ const st = r && r.status;
716
+ return st ? `企业端请求失败(HTTP ${st})` : "企业端不可达,请稍后重试";
717
+ }
718
+
719
+ /** 未登录(无账号/自建密钥)时的统一返回文案。 */
720
+ function notLoggedInJson() {
721
+ return { ok: false, error: "尚未登录:请先在「账号」卡片登录手机号账号(或切换到自建服务)后重试", hint: "login_required" };
722
+ }
723
+
724
+ /**
725
+ * 透传企业端响应体:契约字段可能在顶层或 data 子对象里(容错)。
726
+ * 返回扁平对象;数组字段只取首层数组。
727
+ */
728
+ function flattenRelayBody(r) {
729
+ const b = r && typeof r === "object" && r.body && typeof r.body === "object" ? r.body : {};
730
+ const d = b.data && typeof b.data === "object" ? { ...b.data, ...b } : b;
731
+ return d;
732
+ }
733
+
734
+ /**
735
+ * GET /dsh-remote/access-key → 创建一次性访问密钥(企业端 POST /api/auth-key,Bearer device-login token)。
736
+ * 契约容错:url 必须可用;qr_data_url 取不到时返回 null(UI 只展示链接并说明“二维码暂不可用”,不报错)。
737
+ */
738
+ async function proxyCreateAccessKey(relayDir, res) {
739
+ const token = await relayToken(relayDir).catch(() => "");
740
+ if (!token) return sendJson(res, 401, notLoggedInJson());
741
+ const r = await relayFetch(relayDir, "/api/auth-key", {
742
+ method: "POST",
743
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
744
+ body: JSON.stringify({}),
745
+ });
746
+ const d = flattenRelayBody(r);
747
+ const url = typeof d.url === "string" ? d.url.trim() : "";
748
+ if (!r.ok || !url) {
749
+ // url 不可用是硬失败(企业端契约缺陷);错误码/状态透传给 UI 展示
750
+ const err = r.ok && !url ? "企业端未返回可用的访问地址(缺 url)" : relayErrorMessage(r);
751
+ return sendJson(res, (r && r.status) || 502, { ok: false, error: err, relayStatus: (r && r.status) || 0 });
752
+ }
753
+ return sendJson(res, 200, {
754
+ ok: true,
755
+ url,
756
+ key: d.key ?? null,
757
+ expires_at: d.expires_at ?? null,
758
+ ttl_ms: d.ttl_ms ?? null,
759
+ qr_data_url: d.qr_data_url ?? null,
760
+ relayStatus: (r && r.status) || 200,
761
+ });
762
+ }
763
+
764
+ /**
765
+ * GET /dsh-remote/mobile-sessions → 已授权设备列表(企业端 POST /api/mobile-sessions,Bearer)。
766
+ */
767
+ async function proxyMobileSessions(relayDir, res) {
768
+ const token = await relayToken(relayDir).catch(() => "");
769
+ if (!token) return sendJson(res, 401, notLoggedInJson());
770
+ const r = await relayFetch(relayDir, "/api/mobile-sessions", {
771
+ method: "POST",
772
+ headers: { authorization: `Bearer ${token}` },
773
+ });
774
+ const d = flattenRelayBody(r);
775
+ const sessions = Array.isArray(d.sessions) ? d.sessions : [];
776
+ if (!r.ok) {
777
+ return sendJson(res, (r && r.status) || 502, { ok: false, error: relayErrorMessage(r), relayStatus: (r && r.status) || 0, sessions });
778
+ }
779
+ return sendJson(res, 200, { ok: true, sessions, relayStatus: (r && r.status) || 200 });
780
+ }
781
+
782
+ /**
783
+ * POST /dsh-remote/mobile-sessions/revoke(body {id})→ 取消配对(企业端 POST /api/mobile-sessions/:id/revoke,Bearer)。
784
+ */
785
+ async function proxyRevokeMobileSession(relayDir, req, res) {
786
+ const body = await readJsonBody(req);
787
+ if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
788
+ const id = String(body.id ?? "").trim();
789
+ if (!id) return sendJson(res, 400, { ok: false, error: "缺少参数 id(会话 ID)" });
790
+ const token = await relayToken(relayDir).catch(() => "");
791
+ if (!token) return sendJson(res, 401, notLoggedInJson());
792
+ const r = await relayFetch(relayDir, `/api/mobile-sessions/${encodeURIComponent(id)}/revoke`, {
793
+ method: "POST",
794
+ headers: { authorization: `Bearer ${token}` },
795
+ });
796
+ const d = flattenRelayBody(r);
797
+ // 兼容两种成功形态:HTTP ok,或 body.ok === true(允许企业端 200 + ok:false 表示业务失败)
798
+ const ok = !!(r.ok && d.ok !== false);
799
+ if (!ok) {
800
+ return sendJson(res, (r && r.status) || 502, { ok: false, error: relayErrorMessage(r), relayStatus: (r && r.status) || 0 });
801
+ }
802
+ return sendJson(res, 200, { ok: true, relayStatus: (r && r.status) || 200 });
803
+ }
804
+
701
805
  // ---------- 综合状态 ----------
702
806
 
703
807
  async function composeStatus(relayDir) {
@@ -789,7 +893,7 @@ async function proxyFeedback(relayDir, req, res, pathname) {
789
893
  const url = new URL(suffix.replace(/^\//, ""), base);
790
894
  const headers = {
791
895
  "x-dsh-device": cfg.device_id || "",
792
- "x-dsh-client": "dsh-remote-ui/0.1.0",
896
+ "x-dsh-client": `dsh-remote-web/${PLUGIN_VERSION}`,
793
897
  };
794
898
  if (cfg.phone) headers["x-dsh-phone"] = String(cfg.phone);
795
899
  const auth = req.headers.authorization;
@@ -836,28 +940,41 @@ async function proxyFeedback(relayDir, req, res, pathname) {
836
940
 
837
941
  // ---------- 自管理:版本 / 在线更新 / 彻底卸载(面板内“版本与更新”卡片) ----------
838
942
 
943
+ /** 插件 id / 包名(2026-09 由 dsh-remote-ui 更名)。 */
944
+ const PLUGIN_ID = "dsh-remote-web";
945
+ /** 更名前 id(≤0.4.9):彻底卸载/清理时一并移除,防旧拷贝残留。 */
946
+ const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
947
+ const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
839
948
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
840
- const PLUGIN_VERSION = "0.4.9";
949
+ const PLUGIN_VERSION = "0.6.0-beta.0";
841
950
  const UPDATE_LOG = ".dsh-update.log";
842
951
  const UPDATE_MARKER = ".dsh-update-running";
843
952
 
844
- /** 查询 npm 最新版(官方源优先,失败回退 npmmirror;纯服务端无 CORS 限制)。 */
953
+ /**
954
+ * 更新通道(发布策略):普通用户只拉稳定 dist-tag `latest`;预发(alpha/beta)由作者/内测
955
+ * 通过 `DSH_UPDATE_TAG=beta`(或显式版本号)拉取。迭代一律先发 beta/alpha,稳定后才升 latest。
956
+ */
957
+ const UPDATE_TAG = (process.env.DSH_UPDATE_TAG || "latest").replace(/^@/, "");
958
+ const UPDATE_SPEC = `@mrrisega/dsh-remote@${UPDATE_TAG}`;
959
+
960
+ /** 查询所选通道(npm dist-tag)最新版(官方源优先,失败回退 npmmirror;纯服务端无 CORS 限制)。 */
845
961
  async function npmLatestVersion() {
846
962
  for (const reg of ["https://registry.npmjs.org/@mrrisega/dsh-remote", "https://registry.npmmirror.com/@mrrisega/dsh-remote"]) {
847
963
  try {
848
964
  const res = await fetch(reg, { signal: AbortSignal.timeout(8000) });
849
965
  if (!res.ok) continue;
850
966
  const j = await res.json();
851
- if (j && j["dist-tags"] && typeof j["dist-tags"].latest === "string") return j["dist-tags"].latest;
967
+ const tags = j && j["dist-tags"] ? j["dist-tags"] : {};
968
+ if (typeof tags[UPDATE_TAG] === "string") return tags[UPDATE_TAG];
852
969
  } catch { /* 试下一个源 */ }
853
970
  }
854
971
  return "";
855
972
  }
856
973
 
857
- /** 以 detached 子进程执行 `npx --yes @mrrisega/dsh-remote@latest`(env 可覆盖 npm 源)。 */
974
+ /** 以 detached 子进程执行 `npx --yes <UPDATE_SPEC>`(env 可覆盖 npm 源/更新通道)。 */
858
975
  function spawnUpdater(relayDir, extraEnv) {
859
976
  const log = join(relayDir, UPDATE_LOG);
860
- return spawn(npxCommand(), ["--yes", "@mrrisega/dsh-remote@latest"], {
977
+ return spawn(npxCommand(), ["--yes", UPDATE_SPEC], {
861
978
  detached: true,
862
979
  cwd: homedir(),
863
980
  env: spawnEnv(extraEnv), // PATH 补 node 目录:App 最小 PATH 下也能跑 npx
@@ -866,7 +983,7 @@ function spawnUpdater(relayDir, extraEnv) {
866
983
  }
867
984
 
868
985
  /**
869
- * 后台执行在线一键更新:npx @mrrisega/dsh-remote@latest(幂等自愈:补运行环境/更新 bridge/收敛 include)。
986
+ * 后台执行在线一键更新:npx 按 dist-tag(默认 latest)(幂等自愈:补运行环境/更新 bridge/收敛 include)。
870
987
  * 稳健性:
871
988
  * - npx 用绝对路径 + PATH 补全解析(App 拉起的 dsh web PATH 最小化时不再 ENOENT 静默失败);
872
989
  * - 【官方源优先】镜像(npmmirror)滞后时会把旧版(如 0.4.4)当成最新安装,旧 pluginCmd 会把
@@ -879,7 +996,7 @@ function runOnlineUpdate(relayDir) {
879
996
  mkdirSync(relayDir, { recursive: true });
880
997
  const marker = join(relayDir, UPDATE_MARKER);
881
998
  if (existsSync(marker)) return { ok: false, detail: "已有更新在进行中,请稍候" };
882
- appendLogLine(relayDir, UPDATE_LOG, `[update] 开始在线更新 @mrrisega/dsh-remote@latest (${new Date().toISOString()})`);
999
+ appendLogLine(relayDir, UPDATE_LOG, `[update] 开始在线更新 ${UPDATE_SPEC} (${new Date().toISOString()})`);
883
1000
 
884
1001
  let retried = false;
885
1002
  const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
@@ -925,25 +1042,37 @@ function uninstallSelf(relayDir, profileDir, patchFile, pkgFile) {
925
1042
  const out = { removedPatch: false, removedDep: false, removedDir: false, removedBundle: false };
926
1043
  try {
927
1044
  const patch = readFileSync(patchFile, "utf8");
1045
+ // 兼容当前与历史(dsh-remote-ui)两种管理标记
928
1046
  const cleaned = patch
929
- .replace(/\n?# >>> dsh-remote-ui .*?# <<< dsh-remote-ui\s*/s, "\n")
1047
+ .replace(/\n?# >>> dsh-remote-(?:web|ui) .*?# <<< dsh-remote-(?:web|ui)\s*/s, "\n")
930
1048
  .replace(/\n{3,}/g, "\n\n")
931
1049
  .trimEnd() + "\n";
932
1050
  if (cleaned !== patch) { writeFileSync(patchFile, cleaned); out.removedPatch = true; }
933
1051
  } catch { /* 无 patch 忽略 */ }
934
1052
  try {
935
1053
  const pkg = JSON.parse(readFileSync(pkgFile, "utf8"));
936
- if (pkg.dependencies && pkg.dependencies["dsh-remote-ui"]) { delete pkg.dependencies["dsh-remote-ui"]; out.removedDep = true; }
1054
+ if (pkg.dependencies) {
1055
+ let removed = false;
1056
+ for (const id of PLUGIN_ALL_IDS) {
1057
+ if (pkg.dependencies[id] !== undefined) { delete pkg.dependencies[id]; removed = true; }
1058
+ }
1059
+ if (removed) out.removedDep = true;
1060
+ }
937
1061
  const bundles = pkg.dsh && pkg.dsh.profile && Array.isArray(pkg.dsh.profile.bundles) ? pkg.dsh.profile.bundles : null;
938
1062
  if (bundles) {
939
- const i = bundles.indexOf("dsh-remote-ui");
940
- if (i >= 0) { bundles.splice(i, 1); out.removedBundle = true; }
1063
+ const filtered = bundles.filter((b) => !PLUGIN_ALL_IDS.includes(b));
1064
+ if (filtered.length !== bundles.length) {
1065
+ pkg.dsh.profile.bundles = filtered;
1066
+ out.removedBundle = true;
1067
+ }
941
1068
  }
942
1069
  if (out.removedDep || out.removedBundle) writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
943
1070
  } catch { /* 无 package.json 忽略 */ }
944
1071
  try {
945
- rmSync(join(profileDir, "dsh-remote-ui-plugin"), { recursive: true, force: true });
946
- rmSync(join(profileDir, "node_modules", "dsh-remote-ui"), { recursive: true, force: true });
1072
+ for (const id of PLUGIN_ALL_IDS) {
1073
+ rmSync(join(profileDir, `${id}-plugin`), { recursive: true, force: true });
1074
+ rmSync(join(profileDir, "node_modules", id), { recursive: true, force: true });
1075
+ }
947
1076
  out.removedDir = true;
948
1077
  } catch { /* ignore */ }
949
1078
  return out;
@@ -957,8 +1086,10 @@ function registerRoutes(ctx, relayDir) {
957
1086
  let profileDir = join(homedir(), ".dsh", "profiles", "web");
958
1087
  try {
959
1088
  const here = fileURLToPath(import.meta.url);
960
- // 依次尝试两种安装布局;split()[0] 未命中时返回原串,需显式判断后再试下一种
961
- let m = here.split("/dsh-remote-ui-plugin/")[0];
1089
+ // 依次尝试两种安装布局(当前名优先,历史名兜底);split()[0] 未命中时返回原串,需显式判断后再试下一种
1090
+ let m = here.split("/dsh-remote-web-plugin/")[0];
1091
+ if (m === here) m = here.split("/node_modules/dsh-remote-web/")[0];
1092
+ if (m === here) m = here.split("/dsh-remote-ui-plugin/")[0];
962
1093
  if (m === here) m = here.split("/node_modules/dsh-remote-ui/")[0];
963
1094
  if (m !== here) profileDir = m;
964
1095
  } catch { /* 保持默认 */ }
@@ -1011,7 +1142,7 @@ function registerRoutes(ctx, relayDir) {
1011
1142
  if (rt.removedPlist) bits.push("自启动项已删除");
1012
1143
  if (rt.killedPids.length) bits.push(`已结束 ${rt.killedPids.length} 个残留进程`);
1013
1144
  if (rt.removedDir) bits.push("配置目录已清空(账号/密钥/固化运行时等)");
1014
- bits.push("请重启 dsh web 后完全卸载生效(本插件与远程控制将消失);如需再次使用,在插件市场重新安装即可。");
1145
+ bits.push("请重启 dsh web 后完全卸载生效(本插件与「远程访问」面板将消失);如需再次使用,在插件市场重新安装即可。");
1015
1146
  sendJson(res, 200, {
1016
1147
  ok: true,
1017
1148
  ...prof, // removedPatch / removedDep / removedBundle / removedDir(profile 插件目录)
@@ -1067,6 +1198,30 @@ function registerRoutes(ctx, relayDir) {
1067
1198
  });
1068
1199
  },
1069
1200
  },
1201
+ // 一次性访问密钥(📱 远程访问卡):GET 即创建新 key,企业端 POST /api/auth-key(Bearer)
1202
+ {
1203
+ method: "GET",
1204
+ path: "/dsh-remote/access-key",
1205
+ handler: async (_req, res) => {
1206
+ await proxyCreateAccessKey(relayDir, res);
1207
+ },
1208
+ },
1209
+ // 已授权设备列表(企业端 POST /api/mobile-sessions,Bearer)
1210
+ {
1211
+ method: "GET",
1212
+ path: "/dsh-remote/mobile-sessions",
1213
+ handler: async (_req, res) => {
1214
+ await proxyMobileSessions(relayDir, res);
1215
+ },
1216
+ },
1217
+ // 取消已授权设备配对(企业端 POST /api/mobile-sessions/:id/revoke,Bearer)
1218
+ {
1219
+ method: "POST",
1220
+ path: "/dsh-remote/mobile-sessions/revoke",
1221
+ handler: async (req, res) => {
1222
+ await proxyRevokeMobileSession(relayDir, req, res);
1223
+ },
1224
+ },
1070
1225
  {
1071
1226
  method: "POST",
1072
1227
  path: "/dsh-remote/config",
@@ -1272,7 +1427,7 @@ function registerRoutes(ctx, relayDir) {
1272
1427
  return;
1273
1428
  }
1274
1429
  Promise.resolve(route.handler(req, res)).catch((e) => {
1275
- ctx.logger?.warn?.(`dsh-remote-ui: ${route.method} ${route.path} failed: ${e?.stack || e}`);
1430
+ ctx.logger?.warn?.(`dsh-remote-web: ${route.method} ${route.path} failed: ${e?.stack || e}`);
1276
1431
  if (!res.headersSent) sendJson(res, 500, { ok: false, error: String(e?.message || e) });
1277
1432
  else res.end();
1278
1433
  });
@@ -1297,10 +1452,10 @@ export function apply(ctx, config = {}) {
1297
1452
  UNINSTALLED_DIRS.delete(relayDir);
1298
1453
  // 清理上次进程残留的安装/更新 marker(宿主被重启/强杀时子进程清理回调会丢失)
1299
1454
  sweepStaleMarkers(relayDir);
1300
- ctx.effect(() => registerRoutes(ctx, relayDir), "dsh-remote-ui: /dsh-remote routes");
1455
+ ctx.effect(() => registerRoutes(ctx, relayDir), "dsh-remote-web: /dsh-remote routes");
1301
1456
  // 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
1302
- ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-ui: harness browser-session mint");
1457
+ ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-web: harness browser-session mint");
1303
1458
  // 插件市场一键全功能:缺桌面运行环境则自动安装,登录后自动拉起 bridge(不依赖用户跑 npx)
1304
- ctx.effect(() => scheduleRuntime(relayDir), "dsh-remote-ui: runtime self-provision");
1305
- ctx.logger?.info?.(`dsh-remote-ui: /dsh-remote routes ready (relayDir=${relayDir})`);
1459
+ ctx.effect(() => scheduleRuntime(relayDir), "dsh-remote-web: runtime self-provision");
1460
+ ctx.logger?.info?.(`dsh-remote-web: /dsh-remote routes ready (relayDir=${relayDir})`);
1306
1461
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "name": "dsh-remote-ui",
3
- "version": "0.1.0",
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).",
2
+ "name": "dsh-remote-web",
3
+ "version": "0.5.0",
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",
7
7
  "exports": {
@@ -33,11 +33,11 @@
33
33
  "repository": {
34
34
  "type": "git",
35
35
  "url": "git+https://github.com/mrRisega/dsh-remote.git",
36
- "directory": "packages/dsh-remote-ui"
36
+ "directory": "packages/dsh-remote-web"
37
37
  },
38
38
  "bugs": {
39
39
  "url": "https://github.com/mrRisega/dsh-remote/issues"
40
40
  },
41
- "homepage": "https://github.com/mrRisega/dsh-remote/tree/main/packages/dsh-remote-ui#readme",
41
+ "homepage": "https://github.com/mrRisega/dsh-remote/tree/main/packages/dsh-remote-web#readme",
42
42
  "license": "PolyForm-Noncommercial-1.0.0"
43
43
  }
@@ -0,0 +1,211 @@
1
+ // 插件 node 半新增代理回归:/dsh-remote/access-key | mobile-sessions | mobile-sessions/revoke
2
+ // (企业端 E1 契约:POST /api/auth-key、POST /api/mobile-sessions、POST /api/mobile-sessions/:id/revoke,Bearer device-login JWT)
3
+ // 覆盖:Bearer 透传、字段扁平化、未登录 401、qr 缺失容错、上游错误透传。
4
+ import assert from "node:assert/strict";
5
+ import http from "node:http";
6
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import test from "node:test";
10
+ import { apply } from "../lib/index.js";
11
+
12
+ const EXPIRES = 1893456000000;
13
+ const QR = "data:image/png;base64,AAAA";
14
+
15
+ /** 假企业端:记录收到的请求(method/path/authorization),按场景回包。 */
16
+ function startFakeRelay(opts = {}) {
17
+ const seen = [];
18
+ const srv = http.createServer(async (req, res) => {
19
+ const url = new URL(req.url, "http://x");
20
+ const send = (code, obj) => {
21
+ res.writeHead(code, { "content-type": "application/json" });
22
+ res.end(JSON.stringify(obj));
23
+ };
24
+ const rec = { method: req.method, path: url.pathname, authorization: req.headers.authorization || "" };
25
+ seen.push(rec);
26
+ if (req.method === "POST" && url.pathname === "/api/device-login") return send(200, { token: "jwt-abc" });
27
+ if (req.method === "POST" && url.pathname === "/api/auth-key") {
28
+ if (opts.authKeyFail) return send(500, { error: { message: "server boom" } });
29
+ const body = { ok: true, key: "K1", url: "https://app.test/a/K1", expires_at: EXPIRES, ttl_ms: 1800000 };
30
+ if (!opts.noQr) body.qr_data_url = QR;
31
+ return send(200, body);
32
+ }
33
+ if (req.method === "POST" && url.pathname === "/api/mobile-sessions") {
34
+ return send(200, {
35
+ ok: true,
36
+ sessions: [
37
+ { id: "ms_1", label: "iPhone 15", os: "iOS", browser: "Safari", created_at: 1700000000000, last_seen_at: 1700000600000, revoked_at: null },
38
+ { id: "ms_2", label: "Pixel", os: "Android", browser: "Chrome", created_at: 1700000000000, last_seen_at: null, revoked_at: 1700001000000 },
39
+ ],
40
+ });
41
+ }
42
+ if (req.method === "POST" && url.pathname === "/api/mobile-sessions/ms_1/revoke") return send(200, { ok: true });
43
+ if (req.method === "POST" && url.pathname === "/api/mobile-sessions/ghost/revoke") return send(404, { error: { message: "not_found" } });
44
+ send(404, { error: { code: "not_found" } });
45
+ });
46
+ return new Promise((resolve) => srv.listen(0, "127.0.0.1", () => resolve({ srv, seen, port: srv.address().port })));
47
+ }
48
+
49
+ /** 以假 relay 为 api_url 装载插件路由(boot),返回 http server base 与收集的 routes。 */
50
+ async function bootRelay(relayPort, cfgExtra = {}) {
51
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-aks-"));
52
+ await writeFile(path.join(tempDir, ".dsh-config.json"), JSON.stringify({
53
+ phone: "13800000000",
54
+ password: "pw",
55
+ device_id: "dev-aks",
56
+ api_url: `http://127.0.0.1:${relayPort}`,
57
+ ...cfgExtra,
58
+ }));
59
+ const routes = new Map();
60
+ apply({
61
+ webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
62
+ effect(register) { return register(); },
63
+ logger: { info() {}, warn() {} }
64
+ }, { relayDir: tempDir });
65
+ const host = http.createServer((req, res) => {
66
+ const url = new URL(req.url, "http://x");
67
+ const handler = routes.get(url.pathname);
68
+ (handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
69
+ });
70
+ await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
71
+ return { host, base: `http://127.0.0.1:${host.address().port}`, tempDir };
72
+ }
73
+
74
+ test("access-key 路由:创建一次性密钥并透传字段(含 qr_data_url),Bearer 已带上", async () => {
75
+ const relay = await startFakeRelay();
76
+ const { host, base, tempDir } = await bootRelay(relay.port);
77
+ try {
78
+ const r = await (await fetch(`${base}/dsh-remote/access-key`)).json();
79
+ assert.equal(r.ok, true);
80
+ assert.equal(r.url, "https://app.test/a/K1");
81
+ assert.equal(r.key, "K1");
82
+ assert.equal(r.expires_at, EXPIRES);
83
+ assert.equal(r.ttl_ms, 1800000);
84
+ assert.equal(r.qr_data_url, QR);
85
+ const up = relay.seen.find((s) => s.path === "/api/auth-key");
86
+ assert.equal(up.method, "POST");
87
+ assert.equal(up.authorization, "Bearer jwt-abc", "应携带 device-login JWT");
88
+ } finally {
89
+ host.close();
90
+ relay.srv.close();
91
+ await rm(tempDir, { recursive: true, force: true });
92
+ }
93
+ });
94
+
95
+ test("access-key 容错:企业端未返回 qr_data_url 时仍成功,qr 字段为 null", async () => {
96
+ const relay = await startFakeRelay({ noQr: true });
97
+ const { host, base, tempDir } = await bootRelay(relay.port);
98
+ try {
99
+ const r = await (await fetch(`${base}/dsh-remote/access-key`)).json();
100
+ assert.equal(r.ok, true);
101
+ assert.equal(r.url, "https://app.test/a/K1");
102
+ assert.equal(r.qr_data_url, null, "qr 缺失不致命(UI 只展示链接/复制/打开)");
103
+ } finally {
104
+ host.close();
105
+ relay.srv.close();
106
+ await rm(tempDir, { recursive: true, force: true });
107
+ }
108
+ });
109
+
110
+ test("mobile-sessions 路由:列表透传,Bearer 已带上", async () => {
111
+ const relay = await startFakeRelay();
112
+ const { host, base, tempDir } = await bootRelay(relay.port);
113
+ try {
114
+ const r = await (await fetch(`${base}/dsh-remote/mobile-sessions`)).json();
115
+ assert.equal(r.ok, true);
116
+ assert.equal(r.sessions.length, 2);
117
+ assert.equal(r.sessions[0].label, "iPhone 15");
118
+ assert.equal(r.sessions[0].os, "iOS");
119
+ const up = relay.seen.find((s) => s.path === "/api/mobile-sessions");
120
+ assert.equal(up.method, "POST");
121
+ assert.equal(up.authorization, "Bearer jwt-abc");
122
+ } finally {
123
+ host.close();
124
+ relay.srv.close();
125
+ await rm(tempDir, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ test("mobile-sessions/revoke 路由:body {id} 转发到 /api/mobile-sessions/:id/revoke,成功返回 ok", async () => {
130
+ const relay = await startFakeRelay();
131
+ const { host, base, tempDir } = await bootRelay(relay.port);
132
+ try {
133
+ const r = await (await fetch(`${base}/dsh-remote/mobile-sessions/revoke`, {
134
+ method: "POST",
135
+ headers: { "content-type": "application/json" },
136
+ body: JSON.stringify({ id: "ms_1" }),
137
+ })).json();
138
+ assert.equal(r.ok, true);
139
+ const up = relay.seen.find((s) => s.path === "/api/mobile-sessions/ms_1/revoke");
140
+ assert.ok(up, "应转发到 /api/mobile-sessions/ms_1/revoke");
141
+ assert.equal(up.authorization, "Bearer jwt-abc");
142
+
143
+ // 缺 id → 400
144
+ const bad = await (await fetch(`${base}/dsh-remote/mobile-sessions/revoke`, {
145
+ method: "POST",
146
+ headers: { "content-type": "application/json" },
147
+ body: JSON.stringify({}),
148
+ })).json();
149
+ assert.equal(bad.ok, false);
150
+ assert.match(String(bad.error), /id/);
151
+ } finally {
152
+ host.close();
153
+ relay.srv.close();
154
+ await rm(tempDir, { recursive: true, force: true });
155
+ }
156
+ });
157
+
158
+ test("未登录(无账号配置)→ 三条路由统一 401 提示登录,不请求企业端", async () => {
159
+ const relay = await startFakeRelay();
160
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-aks-401-"));
161
+ await writeFile(path.join(tempDir, ".dsh-config.json"), JSON.stringify({ api_url: `http://127.0.0.1:${relay.port}` }));
162
+ const routes = new Map();
163
+ apply({
164
+ webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
165
+ effect(register) { return register(); },
166
+ logger: { info() {}, warn() {} }
167
+ }, { relayDir: tempDir });
168
+ const host = http.createServer((req, res) => {
169
+ const url = new URL(req.url, "http://x");
170
+ const handler = routes.get(url.pathname);
171
+ (handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
172
+ });
173
+ await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
174
+ try {
175
+ const base = `http://127.0.0.1:${host.address().port}`;
176
+ for (const [method, p] of [["GET", "/dsh-remote/access-key"], ["GET", "/dsh-remote/mobile-sessions"]]) {
177
+ const r = await (await fetch(`${base}${p}`, { method })).json();
178
+ assert.equal(r.ok, false);
179
+ assert.equal((await (await fetch(`${base}${p}`, { method }))).status, 401, `${p} 应返回 401`);
180
+ assert.match(String(r.error), /尚未登录/);
181
+ }
182
+ const rev = await (await fetch(`${base}/dsh-remote/mobile-sessions/revoke`, {
183
+ method: "POST",
184
+ headers: { "content-type": "application/json" },
185
+ body: JSON.stringify({ id: "ms_1" }),
186
+ })).json();
187
+ assert.equal(rev.ok, false);
188
+ assert.match(String(rev.error), /尚未登录/);
189
+ assert.ok(!relay.seen.some((s) => s.path.startsWith("/api/auth-key") || s.path.startsWith("/api/mobile-sessions")), "未登录不应请求企业端");
190
+ } finally {
191
+ host.close();
192
+ relay.srv.close();
193
+ await rm(tempDir, { recursive: true, force: true });
194
+ }
195
+ });
196
+
197
+ test("上游错误透传:auth-key 5xx 时包装 ok:false + 服务端 error.message", async () => {
198
+ const relay = await startFakeRelay({ authKeyFail: true });
199
+ const { host, base, tempDir } = await bootRelay(relay.port);
200
+ try {
201
+ const res = await fetch(`${base}/dsh-remote/access-key`);
202
+ assert.equal(res.status, 500, "应透传上游状态码");
203
+ const r = await res.json();
204
+ assert.equal(r.ok, false);
205
+ assert.match(String(r.error), /server boom/);
206
+ } finally {
207
+ host.close();
208
+ relay.srv.close();
209
+ await rm(tempDir, { recursive: true, force: true });
210
+ }
211
+ });
@@ -58,7 +58,7 @@ test("反馈代理:附加设备身份/手机号,透传 thread_token", async
58
58
  assert.equal(echo.url, "/api/feedback");
59
59
  assert.equal(echo.headers["x-dsh-device"], "dev-testproxy123");
60
60
  assert.equal(echo.headers["x-dsh-phone"], "13800000000");
61
- assert.equal(echo.headers["x-dsh-client"], "dsh-remote-ui/0.1.0");
61
+ assert.equal(echo.headers["x-dsh-client"], "dsh-remote-web/0.5.0");
62
62
  assert.equal(echo.headers.authorization, "Bearer thread-token-abc");
63
63
  assert.equal(echo.body.category, "bug");
64
64