@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.
- package/README.md +2 -2
- package/clients/dsh-remote/dsh-bridge.mjs +1 -1
- package/dsh-setup.mjs +78 -42
- package/package.json +4 -4
- package/packages/{dsh-remote-ui → dsh-remote-web}/README.md +8 -4
- package/packages/{dsh-remote-ui → dsh-remote-web}/cordis.patch.yml +3 -3
- package/packages/{dsh-remote-ui → dsh-remote-web}/lib/client.js +340 -51
- package/packages/{dsh-remote-ui → dsh-remote-web}/lib/index.js +187 -32
- package/packages/{dsh-remote-ui → dsh-remote-web}/package.json +5 -5
- package/packages/dsh-remote-web/test/access-key-sessions.test.mjs +211 -0
- package/packages/{dsh-remote-ui → dsh-remote-web}/test/feedback-proxy.test.mjs +1 -1
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +253 -0
- package/packages/{dsh-remote-ui → dsh-remote-web}/test/settings-entry.test.mjs +33 -14
- /package/packages/{dsh-remote-ui → dsh-remote-web}/screenshots.json +0 -0
- /package/packages/{dsh-remote-ui → dsh-remote-web}/test/account-info.test.mjs +0 -0
- /package/packages/{dsh-remote-ui → dsh-remote-web}/test/account-switch.test.mjs +0 -0
- /package/packages/{dsh-remote-ui → dsh-remote-web}/test/register-proxy.test.mjs +0 -0
- /package/packages/{dsh-remote-ui → dsh-remote-web}/test/self-manage.test.mjs +0 -0
- /package/packages/{dsh-remote-ui → dsh-remote-web}/test/sms-captcha-ui.test.mjs +0 -0
- /package/packages/{dsh-remote-ui → dsh-remote-web}/test/uninstall-runtime.test.mjs +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
// dsh-remote-
|
|
1
|
+
// dsh-remote-web — node half (host plugin)(2026-09 由 dsh-remote-ui 更名 dsh-remote-web;卸载/清理兼容旧名)
|
|
2
2
|
//
|
|
3
|
-
// 提供 /dsh-remote/* 同源 HTTP
|
|
4
|
-
// -
|
|
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
|
|
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-
|
|
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",
|
|
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-
|
|
396
|
+
console.warn(`[dsh-remote-web] 自动安装子进程启动失败: ${e.message}`);
|
|
396
397
|
});
|
|
397
398
|
child.unref();
|
|
398
|
-
console.log(`[dsh-remote-
|
|
399
|
+
console.log(`[dsh-remote-web] 检测到缺少桌面运行环境,已在后台自动安装(日志: ${log}),完成后将自动启动 bridge`);
|
|
399
400
|
return false;
|
|
400
401
|
} catch (e) {
|
|
401
|
-
console.warn(`[dsh-remote-
|
|
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":
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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",
|
|
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
|
|
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] 开始在线更新
|
|
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
|
|
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
|
|
940
|
-
if (
|
|
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
|
-
|
|
946
|
-
|
|
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
|
-
//
|
|
961
|
-
let m = here.split("/dsh-remote-
|
|
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-
|
|
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-
|
|
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-
|
|
1457
|
+
ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-web: harness browser-session mint");
|
|
1303
1458
|
// 插件市场一键全功能:缺桌面运行环境则自动安装,登录后自动拉起 bridge(不依赖用户跑 npx)
|
|
1304
|
-
ctx.effect(() => scheduleRuntime(relayDir), "dsh-remote-
|
|
1305
|
-
ctx.logger?.info?.(`dsh-remote-
|
|
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-
|
|
3
|
-
"version": "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-
|
|
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-
|
|
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-
|
|
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
|
|