@mrrisega/dsh-remote 0.6.11 → 0.6.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/clients/dsh-remote/dsh-bridge.mjs +123 -2
- package/clients/dsh-remote/mobile-adapter.mjs +108 -4
- package/clients/dsh-remote/test/bridge-gzip.test.mjs +68 -1
- package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +388 -0
- package/dsh-setup.mjs +55 -15
- package/package.json +4 -4
- package/packages/dsh-remote-web/lib/client.js +137 -0
- package/packages/dsh-remote-web/lib/fix-prompt.js +160 -0
- package/packages/dsh-remote-web/lib/index.js +748 -18
- package/packages/dsh-remote-web/lib/offline-update.mjs +66 -0
- package/packages/dsh-remote-web/lib/tarball-update.js +442 -0
- package/packages/dsh-remote-web/package.json +5 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +1572 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +1991 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +728 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +767 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim.mjs +163 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +1679 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/package.json +11 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/src/lifecycle.mjs +45 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +2825 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +1984 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-web/native.html +2526 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +2390 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/LICENSE +20 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/README.md +548 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/browser.js +8 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/index.js +22 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/buffer-util.js +131 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/constants.js +19 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/event-target.js +292 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/extension.js +203 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/limiter.js +55 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/receiver.js +743 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/sender.js +607 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/stream.js +161 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/subprotocol.js +62 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/validation.js +152 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket-server.js +562 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket.js +1407 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/package.json +74 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/wrapper.mjs +21 -0
- package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +396 -0
- package/packages/dsh-remote-web/test/diag-report.test.mjs +236 -0
- package/packages/dsh-remote-web/test/fix-prompt.test.mjs +170 -0
- package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +198 -0
- package/packages/dsh-remote-web/test/tarball-update.test.mjs +460 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 离线更新通道(0.6.12):**完全不经过 npx / npm / shell**,直接从 npm registry 取 tarball、
|
|
3
|
+
// 用 Node 内置 zlib 解包,再执行包内的安装器。
|
|
4
|
+
//
|
|
5
|
+
// 为什么需要它(2026-09-23 生产取证):Windows 上「一键更新」走 npx,而 Node ≥20.12 起
|
|
6
|
+
// `spawn("npx.cmd")` 不带 shell 会同步抛 EINVAL —— 于是**升级通道本身坏掉**的那批机器
|
|
7
|
+
// (v0.6.5/0.6.6)永远拿不到修好的版本:近 14 天 157 台「装机失败且从未连上」的 Windows 机器里,
|
|
8
|
+
// 79 台点过「一键更新」、79 台全部 update_failed。这条通道是给它们(以及未来任何 npx/npm
|
|
9
|
+
// 环境出问题的机器)留的后路:只要能访问 HTTPS,就能更新。
|
|
10
|
+
//
|
|
11
|
+
// 用法(由插件半 spawn,stdout/stderr 直接进 .dsh-update.log):
|
|
12
|
+
// node offline-update.mjs <relayDir> [tag]
|
|
13
|
+
// 退出码:0 成功;2 参数错;3 下载/解包失败;4 包里没有安装器;5 安装器起不来;6 安装器非 0 退出。
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { stagePackage } from "./tarball-update.js";
|
|
19
|
+
|
|
20
|
+
const log = (m) => console.log(`[offline] ${m}`);
|
|
21
|
+
const [relayDir, tag = "latest"] = process.argv.slice(2);
|
|
22
|
+
if (!relayDir) {
|
|
23
|
+
console.error("[offline] 用法: node offline-update.mjs <relayDir> [tag]");
|
|
24
|
+
process.exit(2);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 每次都用**全新的空目录**(stagePackage 不清理已有内容,混用会串版本)
|
|
28
|
+
const staging = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-remote-offline-"));
|
|
29
|
+
log(`开始离线更新 @mrrisega/dsh-remote@${tag}(目标 ${relayDir})`);
|
|
30
|
+
|
|
31
|
+
let staged;
|
|
32
|
+
try {
|
|
33
|
+
staged = await stagePackage({
|
|
34
|
+
spec: `@mrrisega/dsh-remote@${tag}`,
|
|
35
|
+
destDir: staging,
|
|
36
|
+
dependencies: { ws: "^8.18.0" }, // 本包唯一运行时依赖;npm tarball 不含 node_modules,必须自己补
|
|
37
|
+
});
|
|
38
|
+
} catch (e) {
|
|
39
|
+
console.error(`[offline] 下载/解包失败:${e.message}${e.status ? `(HTTP ${e.status})` : ""}(code=${e.code || "-"})`);
|
|
40
|
+
try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* 非关键 */ }
|
|
41
|
+
process.exit(3);
|
|
42
|
+
}
|
|
43
|
+
log(`已解包 v${staged.version}(${staged.files} 个文件;依赖 ${staged.deps.map((d) => `${d.name}@${d.version}`).join(", ") || "无"})`);
|
|
44
|
+
|
|
45
|
+
const setup = path.join(staged.dir, "dsh-setup.mjs");
|
|
46
|
+
if (!fs.existsSync(setup)) {
|
|
47
|
+
console.error("[offline] 包里没有 dsh-setup.mjs,放弃(避免装出一个空壳运行环境)");
|
|
48
|
+
process.exit(4);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 与 `npx @mrrisega/dsh-remote@<tag>` 同一条代码路径:跑安装器默认命令(一键安装/收敛)
|
|
52
|
+
const child = spawn(process.execPath, [setup], {
|
|
53
|
+
cwd: staged.dir,
|
|
54
|
+
env: { ...process.env, DSH_BRIDGE_INSTALL_SOURCE: process.env.DSH_BRIDGE_INSTALL_SOURCE || "plugin_market" },
|
|
55
|
+
stdio: "inherit",
|
|
56
|
+
windowsHide: true,
|
|
57
|
+
});
|
|
58
|
+
child.on("error", (e) => {
|
|
59
|
+
console.error(`[offline] 安装器启动失败:${e.message}`);
|
|
60
|
+
process.exit(5);
|
|
61
|
+
});
|
|
62
|
+
child.on("exit", (code) => {
|
|
63
|
+
log(`安装器退出 code=${code ?? "?"}`);
|
|
64
|
+
try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* 非关键 */ }
|
|
65
|
+
process.exit(code === 0 ? 0 : 6);
|
|
66
|
+
});
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 零依赖更新通道:直接从 npm registry 取 tarball,用 Node 内置 zlib + 自解析 tar 落到 staging 目录。
|
|
3
|
+
*
|
|
4
|
+
* 为什么必须有这条通道(真实事故,生产遥测近 14 天):
|
|
5
|
+
* 插件面板的「一键更新」过去一律走 `npx @mrrisega/dsh-remote@<tag>`。在 Windows 上,
|
|
6
|
+
* Node ≥20.12(CVE-2024-27980 的修复)起 `spawn("npx.cmd")` 不带 shell 会**同步抛 EINVAL**,
|
|
7
|
+
* 0.6.6 及更早正是这么写的 —— 于是**升级通道本身坏掉**:427 台 Windows 里 157 台装机失败
|
|
8
|
+
* 且从未连上,其中 79 台点过「一键更新」、79 台全部 update_failed。这批人在产品内没有任何
|
|
9
|
+
* 出路:装不上、也更新不了。
|
|
10
|
+
* 所以这里提供一条**完全不依赖 npx/npm/子进程/shell** 的通路:HTTPS 取 tarball →
|
|
11
|
+
* 就地解开到 staging 目录 → 由调用方去跑 staging 里的 dsh-setup.mjs。
|
|
12
|
+
*
|
|
13
|
+
* 为什么零第三方依赖、也不用系统 tar:
|
|
14
|
+
* · 系统 tar 不可靠:Windows 直到 Win10 1803 才自带 bsdtar,PATH 里也不保证有;而且调它
|
|
15
|
+
* 同样要 spawn 子进程 —— 引号/空格/shell 那一串坑正是我们要躲开的东西;
|
|
16
|
+
* · node_modules 里的 `tar` 不是我们的依赖:插件市场装进 profile 的只有插件自己声明的依赖
|
|
17
|
+
* (本插件唯一依赖是 ws),require("tar") 会 MODULE_NOT_FOUND —— 不能在"救砖"路径上赌它存在;
|
|
18
|
+
* · Node 内置 zlib 已能解 gzip,tar 只是「512 字节头 + 数据(补齐 512)+ 零块收尾」,
|
|
19
|
+
* 自解析一百来行就够,而且**全同步**:没有子进程、没有 shell、三平台行为一致。
|
|
20
|
+
*
|
|
21
|
+
* 安全红线(详见 extractTarGz 注释):
|
|
22
|
+
* · 条目名含 `..` 段、绝对路径、或解析后不在 destDir 内 → **直接抛错拒绝**,且先整体校验再落盘
|
|
23
|
+
* (坏包不会留下一半文件);
|
|
24
|
+
* · 符号链接/硬链接/设备/FIFO 一律**跳过**,不写也不抛(防止用链接把写入引到目标目录之外)。
|
|
25
|
+
*
|
|
26
|
+
* 只用 node: 内置模块,零第三方依赖。
|
|
27
|
+
*/
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import zlib from "node:zlib";
|
|
31
|
+
|
|
32
|
+
/** npm 官方 registry;自建/镜像可通过 registry 参数覆盖。 */
|
|
33
|
+
export const DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
|
34
|
+
|
|
35
|
+
/** tarball 下载上限(防御性:坏镜像/错误响应别把内存打爆)。 */
|
|
36
|
+
export const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
|
|
37
|
+
|
|
38
|
+
const BLOCK = 512;
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// tar 解析
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** 取 NUL 结尾(也容忍不结尾)的字符串字段。 */
|
|
45
|
+
function cstr(buf) {
|
|
46
|
+
const end = buf.indexOf(0);
|
|
47
|
+
return buf.subarray(0, end === -1 ? buf.length : end).toString("utf8");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 八进制字段(含 GNU base-256 大数形式,够用且不引入依赖)。 */
|
|
51
|
+
function readOctal(buf, off, len) {
|
|
52
|
+
if (buf[off] & 0x80) { // GNU base-256(仅供大文件用,正常 tarball 不会走到)
|
|
53
|
+
let v = 0;
|
|
54
|
+
for (let i = off + 1; i < off + len; i++) v = v * 256 + buf[i];
|
|
55
|
+
return v;
|
|
56
|
+
}
|
|
57
|
+
const raw = buf.subarray(off, off + len).toString("latin1").replace(/\0.*$/s, "").trim();
|
|
58
|
+
if (!raw) return 0;
|
|
59
|
+
const n = parseInt(raw, 8);
|
|
60
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 整块 512 字节是否全零(tar 的结束标记)。 */
|
|
64
|
+
function isZeroBlock(buf, off) {
|
|
65
|
+
for (let i = off; i < off + BLOCK; i++) if (buf[i] !== 0) return false;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 解析 PaxHeader(typeflag `x`)的数据区:形如 `LEN key=value\n` 的记录序列,
|
|
71
|
+
* 我们只关心 `path=`(npm tarball 里长路径可能走这里)。
|
|
72
|
+
*/
|
|
73
|
+
function parsePax(data) {
|
|
74
|
+
const out = {};
|
|
75
|
+
const text = data.toString("utf8");
|
|
76
|
+
let pos = 0;
|
|
77
|
+
while (pos < text.length) {
|
|
78
|
+
const sp = text.indexOf(" ", pos);
|
|
79
|
+
if (sp === -1) break;
|
|
80
|
+
const len = parseInt(text.slice(pos, sp), 10);
|
|
81
|
+
if (!Number.isFinite(len) || len <= 0) break;
|
|
82
|
+
const record = text.slice(sp + 1, pos + len).replace(/\n$/, "");
|
|
83
|
+
const eq = record.indexOf("=");
|
|
84
|
+
if (eq > 0) out[record.slice(0, eq)] = record.slice(eq + 1);
|
|
85
|
+
pos += len;
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 头里的条目名:ustar 的 prefix 字段要拼回去(这也是长路径的一种落地方式)。 */
|
|
91
|
+
function headerName(header) {
|
|
92
|
+
const name = cstr(header.subarray(0, 100));
|
|
93
|
+
const magic = header.subarray(257, 262).toString("latin1");
|
|
94
|
+
if (magic !== "ustar") return name; // 老式 v7:没有 prefix
|
|
95
|
+
const prefix = cstr(header.subarray(345, 500));
|
|
96
|
+
return prefix ? `${prefix}/${name}` : name;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 解析(已解压的)tar 缓冲,返回全部条目 `{ name, size, type, data }`。
|
|
101
|
+
* name 已合并 GNU long name(typeflag `L`)与 PaxHeader 的 `path=`。
|
|
102
|
+
*/
|
|
103
|
+
function parseTar(buffer) {
|
|
104
|
+
const buf = buffer;
|
|
105
|
+
const entries = [];
|
|
106
|
+
let offset = 0;
|
|
107
|
+
let longName = null;
|
|
108
|
+
let pax = null;
|
|
109
|
+
while (offset + BLOCK <= buf.length) {
|
|
110
|
+
if (isZeroBlock(buf, offset)) {
|
|
111
|
+
offset += BLOCK;
|
|
112
|
+
if (offset + BLOCK > buf.length || isZeroBlock(buf, offset)) break; // 连续两个零块 = 结束
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const header = buf.subarray(offset, offset + BLOCK);
|
|
116
|
+
offset += BLOCK;
|
|
117
|
+
const size = readOctal(header, 124, 12);
|
|
118
|
+
const dataEnd = offset + size;
|
|
119
|
+
if (dataEnd > buf.length) {
|
|
120
|
+
throw new Error(`tar 数据截断:条目「${headerName(header)}」声明 ${size} 字节,但缓冲已到末尾`);
|
|
121
|
+
}
|
|
122
|
+
const data = buf.subarray(offset, dataEnd);
|
|
123
|
+
offset += Math.ceil(size / BLOCK) * BLOCK;
|
|
124
|
+
|
|
125
|
+
let type = String.fromCharCode(header[156]);
|
|
126
|
+
if (type === "\0") type = "0"; // 老式 tar 的普通文件
|
|
127
|
+
|
|
128
|
+
if (type === "L") { longName = cstr(data); continue; } // GNU long name
|
|
129
|
+
if (type === "K") continue; // GNU long linkname:我们用不到(链接条目本来就要跳过)
|
|
130
|
+
if (type === "x") { pax = parsePax(data); continue; } // PaxHeader(逐条目)
|
|
131
|
+
if (type === "g") continue; // PaxHeader(全局):不影响 path 解析,忽略
|
|
132
|
+
|
|
133
|
+
let name = headerName(header);
|
|
134
|
+
if (longName) name = longName;
|
|
135
|
+
else if (pax && pax.path) name = pax.path;
|
|
136
|
+
longName = null;
|
|
137
|
+
pax = null;
|
|
138
|
+
entries.push({ name, size, type, data });
|
|
139
|
+
}
|
|
140
|
+
return entries;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** gzip 缓冲 → 裸 tar;已经是裸 tar(少见,测试/诊断会用到)就原样返回。 */
|
|
144
|
+
function gunzipMaybe(buffer) {
|
|
145
|
+
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || []);
|
|
146
|
+
if (buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b) return zlib.gunzipSync(buf);
|
|
147
|
+
return buf;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 列出 tar(.gz) 里的条目,便于测试与故障归因。
|
|
152
|
+
* @returns {{name: string, size: number, type: string}[]}
|
|
153
|
+
*/
|
|
154
|
+
export function readTarEntries(buffer) {
|
|
155
|
+
return parseTar(gunzipMaybe(buffer)).map((e) => ({ name: e.name, size: e.size, type: e.type }));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// 解包
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
/** 路径穿越的专用错误码:调用方可以据此区分"包坏了"和"包是恶意的"。 */
|
|
163
|
+
function traversalError(rawName) {
|
|
164
|
+
const err = new Error(`拒绝解包:tar 条目「${rawName}」会写到目标目录之外(路径穿越)`);
|
|
165
|
+
err.code = "ETRAVERSAL";
|
|
166
|
+
return err;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 把条目名换算成 destDir 内的绝对路径。
|
|
171
|
+
* @returns {string|null} null = 处理后为空(如 `package/` 目录条目本身)→ 跳过
|
|
172
|
+
*/
|
|
173
|
+
function resolveEntryPath(root, rawName, stripPrefix) {
|
|
174
|
+
let name = String(rawName).replace(/\\/g, "/");
|
|
175
|
+
if (!name || name.includes("\0")) throw traversalError(rawName);
|
|
176
|
+
// 绝对路径(POSIX /… 与 Windows 盘符 C:…)直接拒 —— 这不可能是 npm tarball 的合法条目
|
|
177
|
+
if (name.startsWith("/") || /^[A-Za-z]:/.test(name)) throw traversalError(rawName);
|
|
178
|
+
|
|
179
|
+
if (stripPrefix) {
|
|
180
|
+
const prefix = String(stripPrefix).replace(/\/+$/, "");
|
|
181
|
+
if (name === prefix) name = "";
|
|
182
|
+
else if (name.startsWith(`${prefix}/`)) name = name.slice(prefix.length + 1);
|
|
183
|
+
// 顶层段与 stripPrefix 不一致时**原样保留**:宁可多一层目录,也不要把 lib/a.js 截成 a.js
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const segs = name.split("/").filter((s) => s.length > 0 && s !== ".");
|
|
187
|
+
if (segs.some((s) => s === "..")) throw traversalError(rawName);
|
|
188
|
+
const rel = segs.join("/");
|
|
189
|
+
if (!rel) return null;
|
|
190
|
+
|
|
191
|
+
const abs = path.resolve(root, rel);
|
|
192
|
+
const inside = root.endsWith(path.sep) ? root : root + path.sep;
|
|
193
|
+
if (abs !== root && !abs.startsWith(inside)) throw traversalError(rawName); // 兜底(含解析后的奇怪形态)
|
|
194
|
+
return abs;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 同步解压 gzip + 解析 tar,把文件写到 destDir(自动建目录)。
|
|
199
|
+
*
|
|
200
|
+
* 支持:ustar 的 `prefix` 字段、GNU long name(typeflag `L`)、PaxHeader(typeflag `x`,取 `path=`)。
|
|
201
|
+
* 只处理普通文件(`0`/`\0`)与目录(`5`);符号链接/硬链接/设备/FIFO **跳过**(不写、不抛)。
|
|
202
|
+
* 条目名含 `..`、绝对路径、或解析后不在 destDir 内 → **抛错拒绝**;校验在落盘之前整体完成,
|
|
203
|
+
* 因此坏包不会留下"写了一半"的目录。
|
|
204
|
+
*
|
|
205
|
+
* @param {Buffer|Uint8Array} buffer tar.gz(也容忍裸 tar)
|
|
206
|
+
* @param {string} destDir 目标目录
|
|
207
|
+
* @param {{stripPrefix?: string}} [options] stripPrefix 默认 "package"(去掉 npm tarball 顶层段);
|
|
208
|
+
* 传 "" 表示保留原样
|
|
209
|
+
* @returns {number} 写出的**普通文件**数(目录不计)
|
|
210
|
+
*/
|
|
211
|
+
export function extractTarGz(buffer, destDir, { stripPrefix = "package" } = {}) {
|
|
212
|
+
if (!destDir) throw new Error("extractTarGz: 缺少 destDir");
|
|
213
|
+
const root = path.resolve(destDir);
|
|
214
|
+
const entries = parseTar(gunzipMaybe(buffer));
|
|
215
|
+
|
|
216
|
+
// 第一步:整体校验并规划落点(恶意条目在这里就被拒,磁盘上什么都不留)
|
|
217
|
+
const planned = [];
|
|
218
|
+
for (const e of entries) {
|
|
219
|
+
if (e.type !== "0" && e.type !== "5") continue; // 链接/设备/其它一律跳过
|
|
220
|
+
const abs = resolveEntryPath(root, e.name, stripPrefix);
|
|
221
|
+
if (!abs) continue;
|
|
222
|
+
planned.push({ abs, dir: e.type === "5", data: e.data });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 第二步:落盘
|
|
226
|
+
fs.mkdirSync(root, { recursive: true });
|
|
227
|
+
let files = 0;
|
|
228
|
+
for (const p of planned) {
|
|
229
|
+
if (p.dir) { fs.mkdirSync(p.abs, { recursive: true }); continue; }
|
|
230
|
+
fs.mkdirSync(path.dirname(p.abs), { recursive: true });
|
|
231
|
+
fs.writeFileSync(p.abs, p.data);
|
|
232
|
+
files++;
|
|
233
|
+
}
|
|
234
|
+
return files;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// registry
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
/** 作用域包按 npm 规范编码:`@scope/name` → `@scope%2fname`(保留 @,斜杠转义)。 */
|
|
242
|
+
function encodePackageName(name) {
|
|
243
|
+
return encodeURIComponent(String(name).trim()).replace(/^%40/i, "@").replace(/%2F/g, "%2f");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function httpStatusError(what, res, url, extra) {
|
|
247
|
+
const status = res && res.status ? res.status : "?";
|
|
248
|
+
const err = new Error(`${what} 失败:HTTP ${status} ${url}${extra ? `(${extra})` : ""}`);
|
|
249
|
+
err.status = res && res.status;
|
|
250
|
+
err.url = url;
|
|
251
|
+
return err;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 解析 dist-tag / 版本号 → 具体 version 与 tarball URL。
|
|
256
|
+
*
|
|
257
|
+
* 请求 `<registry>/<name>/<tag>`(作用域包的 `/` 编码为 `%2f`)。registry 对
|
|
258
|
+
* 「dist-tag」和「具体版本号」都返回整份 packument,所以两种入参都能解析:
|
|
259
|
+
* · 命中 `dist-tags[tag]` → 取该版本;
|
|
260
|
+
* · 否则若 `versions[tag]` 存在 → 说明传的是精确版本号。
|
|
261
|
+
*
|
|
262
|
+
* @returns {Promise<{version: string, tarball: string, url: string}>}
|
|
263
|
+
*/
|
|
264
|
+
export async function resolveVersion({ name, tag = "latest", registry = DEFAULT_REGISTRY, fetchImpl = fetch } = {}) {
|
|
265
|
+
if (!name) throw new Error("resolveVersion: 缺少包名 name");
|
|
266
|
+
if (typeof fetchImpl !== "function") throw new Error("resolveVersion: fetchImpl 不是函数");
|
|
267
|
+
const base = String(registry || DEFAULT_REGISTRY).replace(/\/+$/, "");
|
|
268
|
+
const wantTag = tag == null || tag === "" ? "latest" : String(tag);
|
|
269
|
+
const url = `${base}/${encodePackageName(name)}/${encodeURIComponent(wantTag)}`;
|
|
270
|
+
|
|
271
|
+
let res;
|
|
272
|
+
try {
|
|
273
|
+
res = await fetchImpl(url, { headers: { accept: "application/json" } });
|
|
274
|
+
} catch (e) {
|
|
275
|
+
// 错误信息里带上 name/tag:遥测里归因时一眼能看出是哪个包哪个 tag 打不通
|
|
276
|
+
throw new Error(`resolveVersion 失败:请求出错 ${url}(name=${name}, tag=${wantTag}): ${e.message}`);
|
|
277
|
+
}
|
|
278
|
+
if (!res || !res.ok) throw httpStatusError("resolveVersion", res, url, `name=${name}, tag=${wantTag}`);
|
|
279
|
+
|
|
280
|
+
let doc;
|
|
281
|
+
try {
|
|
282
|
+
doc = await res.json();
|
|
283
|
+
} catch (e) {
|
|
284
|
+
throw new Error(`resolveVersion 失败:响应不是 JSON ${url}(name=${name}, tag=${wantTag}): ${e.message}`);
|
|
285
|
+
}
|
|
286
|
+
const distTags = (doc && doc["dist-tags"]) || {};
|
|
287
|
+
const versions = (doc && doc.versions) || {};
|
|
288
|
+
let version = distTags[wantTag];
|
|
289
|
+
if (!version && versions[wantTag]) version = wantTag; // 传的是精确版本号
|
|
290
|
+
const meta = version ? versions[version] : null;
|
|
291
|
+
const tarball = meta && meta.dist && meta.dist.tarball;
|
|
292
|
+
if (!version || !tarball) {
|
|
293
|
+
throw new Error(`resolveVersion 失败:${url} 的响应里没有 ${wantTag} 对应的版本或 tarball(name=${name}, tag=${wantTag})`);
|
|
294
|
+
}
|
|
295
|
+
return { version, tarball, url };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 下载 tarball 为 Buffer。
|
|
300
|
+
* 超过 maxBytes 抛错(含先看 content-length 的快速失败),避免坏响应把内存打爆。
|
|
301
|
+
*/
|
|
302
|
+
export async function downloadTarball({ tarballUrl, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES } = {}) {
|
|
303
|
+
if (!tarballUrl) throw new Error("downloadTarball: 缺少 tarballUrl");
|
|
304
|
+
if (typeof fetchImpl !== "function") throw new Error("downloadTarball: fetchImpl 不是函数");
|
|
305
|
+
const limit = Number(maxBytes) > 0 ? Number(maxBytes) : DEFAULT_MAX_BYTES;
|
|
306
|
+
const tooBig = (got) => {
|
|
307
|
+
const err = new Error(`tarball 过大:${got} > 上限 ${limit} 字节(${tarballUrl})`);
|
|
308
|
+
err.code = "E2BIG";
|
|
309
|
+
return err;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
let res;
|
|
313
|
+
try {
|
|
314
|
+
res = await fetchImpl(tarballUrl, { headers: { accept: "application/octet-stream" } });
|
|
315
|
+
} catch (e) {
|
|
316
|
+
throw new Error(`downloadTarball 失败:请求出错 ${tarballUrl}: ${e.message}`);
|
|
317
|
+
}
|
|
318
|
+
if (!res || !res.ok) throw httpStatusError("downloadTarball", res, tarballUrl);
|
|
319
|
+
|
|
320
|
+
const declared = res.headers && typeof res.headers.get === "function" ? Number(res.headers.get("content-length")) : 0;
|
|
321
|
+
if (Number.isFinite(declared) && declared > limit) throw tooBig(declared);
|
|
322
|
+
|
|
323
|
+
const chunks = [];
|
|
324
|
+
let total = 0;
|
|
325
|
+
const push = (chunk) => {
|
|
326
|
+
const c = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
327
|
+
total += c.length;
|
|
328
|
+
if (total > limit) throw tooBig(total); // 边下边判:别等下载完才发现超限
|
|
329
|
+
chunks.push(c);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const body = res.body;
|
|
333
|
+
if (body && typeof body.getReader === "function") {
|
|
334
|
+
const reader = body.getReader();
|
|
335
|
+
try {
|
|
336
|
+
for (;;) {
|
|
337
|
+
const { done, value } = await reader.read();
|
|
338
|
+
if (done) break;
|
|
339
|
+
if (value) push(value);
|
|
340
|
+
}
|
|
341
|
+
} catch (e) {
|
|
342
|
+
try { await reader.cancel(); } catch { /* 取消失败无所谓 */ }
|
|
343
|
+
throw e;
|
|
344
|
+
}
|
|
345
|
+
} else if (body && typeof body[Symbol.asyncIterator] === "function") {
|
|
346
|
+
for await (const chunk of body) push(chunk);
|
|
347
|
+
} else {
|
|
348
|
+
push(await res.arrayBuffer());
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const buf = Buffer.concat(chunks, total);
|
|
352
|
+
if (buf.length < 2 || buf[0] !== 0x1f || buf[1] !== 0x8b) {
|
|
353
|
+
// 200 但内容不是 gzip:通常是镜像返回了 HTML 错误页,报清楚比后面 gunzip 抛 zlib 错好归因
|
|
354
|
+
throw new Error(`downloadTarball 失败:内容不是 gzip(前 ${Math.min(buf.length, 16)} 字节) ${tarballUrl}`);
|
|
355
|
+
}
|
|
356
|
+
return buf;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// staging
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* 解析 `spec`:`"@scope/name"` / `"@scope/name@0.6.12"` / `"pkg@beta"`。
|
|
365
|
+
* 注意作用域包名里的 `@`:必须从**名字部分之后**再找版本分隔的 `@`。
|
|
366
|
+
* @returns {{name: string, tag: string}}
|
|
367
|
+
*/
|
|
368
|
+
export function parseSpec(spec) {
|
|
369
|
+
const raw = String(spec == null ? "" : spec).trim();
|
|
370
|
+
if (!raw) throw new Error("stagePackage: spec 不能为空");
|
|
371
|
+
if (raw.startsWith("@")) {
|
|
372
|
+
const slash = raw.indexOf("/");
|
|
373
|
+
if (slash <= 1) throw new Error(`stagePackage: 作用域包名不合法「${raw}」(应形如 @scope/name[@tag])`);
|
|
374
|
+
const at = raw.indexOf("@", slash);
|
|
375
|
+
if (at === -1) return { name: raw, tag: "latest" };
|
|
376
|
+
const name = raw.slice(0, at);
|
|
377
|
+
const tag = raw.slice(at + 1).trim();
|
|
378
|
+
if (!name.slice(slash + 1)) throw new Error(`stagePackage: 包名不合法「${raw}」`);
|
|
379
|
+
return { name, tag: tag || "latest" };
|
|
380
|
+
}
|
|
381
|
+
const at = raw.indexOf("@");
|
|
382
|
+
if (at === -1) return { name: raw, tag: "latest" };
|
|
383
|
+
const name = raw.slice(0, at);
|
|
384
|
+
if (!name) throw new Error(`stagePackage: 包名不合法「${raw}」`);
|
|
385
|
+
return { name, tag: raw.slice(at + 1).trim() || "latest" };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* 把 `spec` 对应的包(含 dependencies)落到本地 staging 目录,供调用方去跑里面的 dsh-setup.mjs。
|
|
390
|
+
*
|
|
391
|
+
* 依赖解析策略:先把依赖的**版本范围**(如 `^8.18.0`)当 dist-tag 去请求 —— registry 对
|
|
392
|
+
* 「精确版本号」能直接命中(`versions[tag]` 分支),而范围(`^`/`~`/`>=`)必然 404,此时
|
|
393
|
+
* 回退 `latest` 并在返回值里如实标注(`requested` / `resolvedBy` / `distTag`)。
|
|
394
|
+
* 为什么这样就够用:本项目唯一的运行时依赖是 `ws`,且它**没有子依赖**,
|
|
395
|
+
* 装 latest 不会拖出一条不一致的依赖图;真需要精确复现时,调用方可以直接传 dist-tag/精确版本。
|
|
396
|
+
*
|
|
397
|
+
* 注意:destDir 应当是一个**新建的空目录**(这里只保证 mkdir -p,不会删除已有内容)。
|
|
398
|
+
*
|
|
399
|
+
* @returns {Promise<{name: string, version: string, dir: string, files: number, deps: object[]}>}
|
|
400
|
+
*/
|
|
401
|
+
export async function stagePackage({ spec, destDir, registry = DEFAULT_REGISTRY, fetchImpl = fetch, dependencies = {} } = {}) {
|
|
402
|
+
if (!destDir) throw new Error("stagePackage: 缺少 destDir");
|
|
403
|
+
const { name, tag } = parseSpec(spec);
|
|
404
|
+
const root = path.resolve(destDir);
|
|
405
|
+
fs.mkdirSync(root, { recursive: true });
|
|
406
|
+
|
|
407
|
+
const main = await resolveVersion({ name, tag, registry, fetchImpl });
|
|
408
|
+
const mainBuf = await downloadTarball({ tarballUrl: main.tarball, fetchImpl });
|
|
409
|
+
const files = extractTarGz(mainBuf, root, { stripPrefix: "package" });
|
|
410
|
+
|
|
411
|
+
const deps = [];
|
|
412
|
+
for (const [depName, depRange] of Object.entries(dependencies || {})) {
|
|
413
|
+
if (!depName) continue;
|
|
414
|
+
const requested = depRange == null || depRange === "" ? "latest" : String(depRange);
|
|
415
|
+
let resolved;
|
|
416
|
+
let resolvedBy = "requested-tag";
|
|
417
|
+
let distTag = requested;
|
|
418
|
+
try {
|
|
419
|
+
resolved = await resolveVersion({ name: depName, tag: requested, registry, fetchImpl });
|
|
420
|
+
} catch {
|
|
421
|
+
// 版本范围当 tag 请求必然 404(registry 的 dist-tags 里没有 "^8.18.0")→ 回退 latest
|
|
422
|
+
resolved = await resolveVersion({ name: depName, tag: "latest", registry, fetchImpl });
|
|
423
|
+
resolvedBy = "latest-fallback";
|
|
424
|
+
distTag = "latest";
|
|
425
|
+
}
|
|
426
|
+
const buf = await downloadTarball({ tarballUrl: resolved.tarball, fetchImpl });
|
|
427
|
+
// @scope/x 要落到 node_modules/@scope/x(按段建目录,别把整个名字当一个目录名)
|
|
428
|
+
const depDir = path.join(root, "node_modules", ...depName.split("/").filter(Boolean));
|
|
429
|
+
const depFiles = extractTarGz(buf, depDir, { stripPrefix: "package" });
|
|
430
|
+
deps.push({
|
|
431
|
+
name: depName,
|
|
432
|
+
version: resolved.version,
|
|
433
|
+
requested, // 调用方原本写的(可能是 "^8.18.0")
|
|
434
|
+
resolvedBy, // "requested-tag" | "latest-fallback"
|
|
435
|
+
distTag, // 实际生效的 tag(回退时是 "latest")
|
|
436
|
+
dir: depDir,
|
|
437
|
+
files: depFiles,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return { name, version: main.version, dir: root, files, deps };
|
|
442
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.13",
|
|
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",
|
|
@@ -15,10 +15,14 @@
|
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"lib",
|
|
18
|
+
"runtime",
|
|
18
19
|
"cordis.patch.yml",
|
|
19
20
|
"screenshots.json",
|
|
20
21
|
"README.md"
|
|
21
22
|
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"prepack": "node ../../scripts/bundle-runtime.mjs"
|
|
25
|
+
},
|
|
22
26
|
"dsh": {
|
|
23
27
|
"bundle": {
|
|
24
28
|
"patch": "./cordis.patch.yml"
|