@guowenzhang/dsh-pocket 0.0.0-stage → 2.12.2
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/LICENSE +344 -0
- package/README.en.md +222 -0
- package/README.md +229 -3
- package/bin/dsh-pocket.mjs +216 -0
- package/client/api.js +76 -0
- package/client/build.mjs +51 -0
- package/client/client.js +2765 -0
- package/client/index.jsx +581 -0
- package/client/mobile/LICENSE.dsh-web-mobile +4 -0
- package/client/mobile/MobileDrawerFooter.tsx +69 -0
- package/client/mobile/MobileIcon.tsx +31 -0
- package/client/mobile/MobileNavOverlay.tsx +299 -0
- package/client/mobile/MobileNavToggle.tsx +57 -0
- package/client/mobile/fileGuard.ts +200 -0
- package/client/mobile/icon-compat.mjs +90 -0
- package/client/mobile/layout-mode.mjs +46 -0
- package/client/mobile/locales.ts +23 -0
- package/client/mobile/mobile-apply.tsx +413 -0
- package/client/mobile/mobile.css.ts +1052 -0
- package/client/mobile/nav-targets.mjs +117 -0
- package/client/pocket-locales.js +165 -0
- package/cordis.patch.yml +5 -0
- package/lib/index.js +420 -0
- package/lib/ip.mjs +11 -0
- package/lib/proxy.mjs +1223 -0
- package/lib/restart.js +118 -0
- package/lib/service.mjs +454 -0
- package/lib/settings.mjs +252 -0
- package/lib/tunnel.mjs +636 -0
- package/lib/web-rpc.js +475 -0
- package/package.json +85 -4
package/lib/restart.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// dsh-pocket 自重启:重新拉起启动本宿主的确切 dsh 调用(detached 交接),
|
|
2
|
+
// 让更新后的插件代码生效——用户无需离开界面手动重启。
|
|
3
|
+
//
|
|
4
|
+
// 方案借鉴 dshmarket 的 self-restart(lib/restart.js,MIT):不直接拉起新
|
|
5
|
+
// 进程,而是先拉一个 detached 的 node 辅助进程,等旧进程退出、端口释放
|
|
6
|
+
// 后再拉起新 dsh,并把新进程输出写入临时日志——避免端口竞争
|
|
7
|
+
// (EADDRINUSE)导致新进程静默崩溃。
|
|
8
|
+
//
|
|
9
|
+
// 与 dshmarket 的差异:不赌固定 1.5s 延时,而是轮询探测端口真正释放
|
|
10
|
+
// (ECONNREFUSED)再拉起,旧进程退出慢也不会撞端口。
|
|
11
|
+
//
|
|
12
|
+
// 注意:新进程 detached,不挂终端——停止方式:lsof -ti :3080 | xargs kill -9。
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
/** 从启动参数里解析 dsh web 端口(--port/-p,含 --port=3080 形式),默认 3080。 */
|
|
19
|
+
export function dshPortFromArgs(args) {
|
|
20
|
+
for (let i = 0; i < args.length; i++) {
|
|
21
|
+
const a = args[i];
|
|
22
|
+
if (a === '--port' || a === '-p') {
|
|
23
|
+
const n = Number(args[i + 1]);
|
|
24
|
+
if (Number.isInteger(n) && n > 0 && n < 65536) return n;
|
|
25
|
+
} else if (a.startsWith('--port=')) {
|
|
26
|
+
const n = Number(a.slice('--port='.length));
|
|
27
|
+
if (Number.isInteger(n) && n > 0 && n < 65536) return n;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return 3080;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 重建启动调用(与当前宿主相同的命令,含 node 运行参数)。 */
|
|
34
|
+
export function restartLaunch() {
|
|
35
|
+
return {
|
|
36
|
+
file: process.argv[0], // node
|
|
37
|
+
args: [...process.execArgv, process.argv[1], ...process.argv.slice(2)], // [flags] <bin.js> + web [flags]
|
|
38
|
+
cwd: process.cwd(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 辅助进程代码:等 dsh 端口真正释放(最多 20s)→ 拉起新 dsh → 输出写日志。 */
|
|
43
|
+
function helperCode(launch, logOut, logErr, port) {
|
|
44
|
+
const spawn = "const { spawn } = require('node:child_process')";
|
|
45
|
+
const fs = "const fs = require('node:fs')";
|
|
46
|
+
const net = "const net = require('node:net')";
|
|
47
|
+
const file = `const file = ${JSON.stringify(launch.file)}`;
|
|
48
|
+
const args = `const args = ${JSON.stringify(launch.args)}`;
|
|
49
|
+
const cwd = `const cwd = ${JSON.stringify(launch.cwd)}`;
|
|
50
|
+
const o = `const logOut = ${JSON.stringify(logOut)}`;
|
|
51
|
+
const e = `const logErr = ${JSON.stringify(logErr)}`;
|
|
52
|
+
const body = [
|
|
53
|
+
'function portFree(p, cb) {',
|
|
54
|
+
' const s = net.connect(p, "127.0.0.1")',
|
|
55
|
+
' s.once("connect", () => { s.destroy(); cb(false) })', // 还能连上 = 端口仍被占用
|
|
56
|
+
' s.once("error", () => cb(true))', // 连接被拒 = 已释放
|
|
57
|
+
'}',
|
|
58
|
+
'function waitPort(p, tries, cb) {',
|
|
59
|
+
' portFree(p, (free) => {',
|
|
60
|
+
' if (free || tries <= 0) cb(free)',
|
|
61
|
+
' else setTimeout(() => waitPort(p, tries - 1, cb), 200)',
|
|
62
|
+
' })',
|
|
63
|
+
'}',
|
|
64
|
+
`waitPort(${port}, 100, (free) => {`, // 最多 100×200ms = 20s;超时也照常拉起(宁可试拉、日志留痕,也不让 dsh 起不来)
|
|
65
|
+
' setTimeout(() => {',
|
|
66
|
+
' try {',
|
|
67
|
+
' const out = fs.openSync(logOut, "a")',
|
|
68
|
+
' const err = fs.openSync(logErr, "a")',
|
|
69
|
+
' const child = spawn(file, args, { cwd, detached: true, stdio: ["ignore", out, err], env: process.env })',
|
|
70
|
+
' child.unref()',
|
|
71
|
+
' } catch (ex) {',
|
|
72
|
+
' try { fs.appendFileSync(logErr, "restart helper failed: " + (ex && ex.message) + "\\n") } catch {}',
|
|
73
|
+
' }',
|
|
74
|
+
' }, 300)',
|
|
75
|
+
'})',
|
|
76
|
+
].join('\n');
|
|
77
|
+
return [spawn, fs, net, file, args, cwd, o, e, body].join('\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 拉起替代宿主(detached 辅助进程交接),随后结束当前进程。
|
|
82
|
+
* @param {object} opts
|
|
83
|
+
* @param {number} [opts.handoffMs] 保留参数(兼容旧调用;现由端口探测接管)
|
|
84
|
+
* @param {object} [opts.internals] 测试注入:spawn / kill
|
|
85
|
+
* @returns {{helperPid:number|null, logOut:string, logErr:string}}
|
|
86
|
+
*/
|
|
87
|
+
export function restartHost({ handoffMs = 1500, internals = {} } = {}) {
|
|
88
|
+
const spawnFn = internals.spawn ?? spawn;
|
|
89
|
+
const killFn = internals.kill ?? ((pid) => process.kill(pid, 'SIGTERM'));
|
|
90
|
+
// Electron 桌面宿主里 pocket 跑在 Electron 的 node 上下文:杀 process.pid 会杀掉宿主,
|
|
91
|
+
// 并由 detached 孤儿进程抢宿主端口(19387)→ EADDRINUSE 崩溃循环(issue #177 / #167)。
|
|
92
|
+
// 桌面端应由 DSH Desktop 自己管理重启,这里直接拒绝,交还控制权。
|
|
93
|
+
if (Boolean(process.versions.electron) && internals.spawn === undefined) {
|
|
94
|
+
return { helperPid: null, logOut: '', logErr: '', error: 'desktop-host-restart-not-supported' };
|
|
95
|
+
}
|
|
96
|
+
const launch = restartLaunch();
|
|
97
|
+
const port = dshPortFromArgs(launch.args);
|
|
98
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
99
|
+
const logOut = join(tmpdir(), `dsh-pocket-restart-${stamp}.out.log`);
|
|
100
|
+
const logErr = join(tmpdir(), `dsh-pocket-restart-${stamp}.err.log`);
|
|
101
|
+
|
|
102
|
+
let helperPid = null;
|
|
103
|
+
try {
|
|
104
|
+
const helper = spawnFn(process.execPath, ['-e', helperCode(launch, logOut, logErr, port)], {
|
|
105
|
+
detached: true,
|
|
106
|
+
stdio: 'ignore',
|
|
107
|
+
env: process.env,
|
|
108
|
+
});
|
|
109
|
+
helper.unref?.();
|
|
110
|
+
helper.on?.('error', () => {}); // 参数异常等异步错误兜底,别让旧进程先崩
|
|
111
|
+
helperPid = helper.pid ?? null;
|
|
112
|
+
// 短暂等待后结束当前进程(释放端口);由辅助进程探测到端口释放后拉起新宿主
|
|
113
|
+
setTimeout(() => { try { killFn(process.pid); } catch { /* 忽略 */ } }, 500);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return { helperPid: null, logOut, logErr, error: err?.message ?? String(err) };
|
|
116
|
+
}
|
|
117
|
+
return { helperPid, logOut, logErr };
|
|
118
|
+
}
|
package/lib/service.mjs
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
// dsh-pocket 服务:在 dsh web 进程内跑改头代理 + 公网隧道
|
|
2
|
+
//
|
|
3
|
+
// - 代理:监听 0.0.0.0:<port>(默认 3081),把入站 Host/Origin 改写成
|
|
4
|
+
// 127.0.0.1:<dshPort>(dsh web 实际端口),HTTP + WebSocket 全透传。
|
|
5
|
+
// 这样 DSH 的 /api 浏览器信任栅栏永远看到 loopback,局域网/公网都能进,
|
|
6
|
+
// 且不需要改 dsh 的任何配置(0.0.0.0 绑定被 dsh 官方禁用)。
|
|
7
|
+
// - 隧道:cloudflared 快速隧道(可选),公网 https URL,供人在外面访问。
|
|
8
|
+
|
|
9
|
+
import { networkInterfaces } from 'node:os';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { execFile } from 'node:child_process';
|
|
13
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { join, dirname } from 'node:path';
|
|
15
|
+
import { createPocketProxy } from './proxy.mjs';
|
|
16
|
+
import { startQuickTunnel, startNamedTunnel } from './tunnel.mjs';
|
|
17
|
+
import { isValidIpv4 } from './ip.mjs';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
|
|
21
|
+
/** URL → 二维码 data URL(浏览器 <img> 直接显示,全本地不依赖第三方)。 */
|
|
22
|
+
export async function qrDataUrl(text, { width = 220, margin = 1 } = {}) {
|
|
23
|
+
const QRCode = require('qrcode');
|
|
24
|
+
return QRCode.toDataURL(text, { errorCorrectionLevel: 'M', margin, width, type: 'image/png' });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// RFC1918 私网地址:手机与电脑连同一局域网时通常可直连。
|
|
28
|
+
// 另含 CGNAT 100.64/10(RFC 6598,Tailscale/ZeroTier 默认网段,公网不可路由),保持一致(issue #79)。
|
|
29
|
+
const PRIVATE_IPV4_RE = /^(?:10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|100\.(?:6[4-9]|[7-9]\d|1(?:0\d|1\d|2[0-7]))\.)/;
|
|
30
|
+
|
|
31
|
+
/** 名称像真实物理网卡的接口(WLAN / Wi-Fi / Ethernet / 以太网 / 有线 / 无线 / en / eth)。
|
|
32
|
+
* 注意:`本地连接` 用负前瞻 `(?!\*)` 排除 Windows 的「移动热点 / Internet 连接共享(ICS)」
|
|
33
|
+
* 虚拟适配器 `本地连接* N`(IP 固定 192.168.137.1,手机连真实 Wi-Fi 时与它不在同一网段,
|
|
34
|
+
* 扫码不可达)——issue #176。 */
|
|
35
|
+
const PHYSICAL_IFACE_RE = /^(?:wlan|wi-?fi|wireless|ethernet|eth\d|en\d|wlp\d|以太网|有线|无线|本地连接(?!\*))/i;
|
|
36
|
+
|
|
37
|
+
/** 常见的 VPN / 虚拟网卡名称:手机通常无法通过它们直连电脑。
|
|
38
|
+
* 含 Windows ICS / 移动热点(`本地连接*`)与承载网络 / Wi-Fi Direct 虚拟适配器——issue #176。 */
|
|
39
|
+
const VPN_IFACE_RE = /(?:radmin|tailscale|zerotier|easytier|et_|tun|tap|vpn|vethernet|virtual|vmware|virtualbox|wsl|docker|teredo|hamachi|bluetooth|bridge|本地连接\*|hostednetwork|wifidirect)/i;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 从 networkInterfaces() 返回的接口表里选出手机最可能可达的 IPv4。
|
|
43
|
+
*
|
|
44
|
+
* `os.networkInterfaces()` 的枚举顺序不可靠:Windows 上 Radmin VPN / Tailscale /
|
|
45
|
+
* vEthernet 等虚拟网卡常排在 WLAN 前面,旧实现直接取第一张非回环网卡,会生成
|
|
46
|
+
* 手机打不开的二维码。这里按以下规则打分排序:
|
|
47
|
+
* - RFC1918 私网地址优先(10/8、172.16/12、192.168/16);
|
|
48
|
+
* - 名称像物理网卡再加分;
|
|
49
|
+
* - 名称像 VPN/虚拟网卡减分;
|
|
50
|
+
* - 同分保持原枚举顺序。
|
|
51
|
+
* 没有任何私网地址时回退到最高分地址(例如纯 VPN 环境仍可用)。
|
|
52
|
+
*
|
|
53
|
+
* @param {ReturnType<typeof networkInterfaces>} interfaces
|
|
54
|
+
* @returns {string|null}
|
|
55
|
+
*/
|
|
56
|
+
export function selectLanIPv4(interfaces) {
|
|
57
|
+
const candidates = [];
|
|
58
|
+
for (const [name, addrs] of Object.entries(interfaces ?? {})) {
|
|
59
|
+
for (const addr of addrs ?? []) {
|
|
60
|
+
if (addr.family !== 'IPv4' || addr.internal) continue;
|
|
61
|
+
const ip = addr.address;
|
|
62
|
+
// 排除 loopback 与 link-local;其余地址即使不是私网(如 Radmin 的 26.x)也保留兜底
|
|
63
|
+
if (!ip || ip.startsWith('127.') || ip.startsWith('169.254.')) continue;
|
|
64
|
+
|
|
65
|
+
let score = 0;
|
|
66
|
+
if (PRIVATE_IPV4_RE.test(ip)) score += 100;
|
|
67
|
+
if (PHYSICAL_IFACE_RE.test(name)) score += 20;
|
|
68
|
+
else if (VPN_IFACE_RE.test(name)) score -= 50;
|
|
69
|
+
|
|
70
|
+
candidates.push({ ip, score, order: candidates.length });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 兜底:同分时不选 ICS 保留段 192.168.137.x(移动热点 / 共享虚拟网卡),避免误指向不可达地址。
|
|
75
|
+
const isIcsSubnet = (ip) => ip.startsWith('192.168.137.');
|
|
76
|
+
candidates.sort((a, b) =>
|
|
77
|
+
b.score - a.score ||
|
|
78
|
+
(isIcsSubnet(a.ip) === isIcsSubnet(b.ip) ? a.order - b.order : isIcsSubnet(a.ip) ? 1 : -1),
|
|
79
|
+
);
|
|
80
|
+
return candidates[0]?.ip ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------- WSL 局域网 IP(issue #39) ----------
|
|
84
|
+
// WSL2 是 NAT 模式:WSL 内部 os.networkInterfaces() 只能看到自己的虚拟网卡
|
|
85
|
+
// (172.x.x.x),看不到 Windows 宿主机的物理网卡 IP(192.168.x.x)——手机在
|
|
86
|
+
// 同一 WiFi 下访问的是 Windows 宿主机,拿 WSL 的 IP 生成的二维码必然打不开。
|
|
87
|
+
// 解法:检测到 WSL 时,通过 WSL interop 直接执行 Windows 的 ipconfig.exe,
|
|
88
|
+
// 解析出 Windows 侧非虚拟网卡的 IPv4 作为局域网地址;失败回退本机探测。
|
|
89
|
+
|
|
90
|
+
/** WSL 检测:/proc/version 含 microsoft/wsl,或 WSL 专属环境变量存在。 */
|
|
91
|
+
export function detectWsl() {
|
|
92
|
+
try {
|
|
93
|
+
const v = readFileSync('/proc/version', 'utf8').toLowerCase();
|
|
94
|
+
if (v.includes('microsoft') || v.includes('wsl')) return true;
|
|
95
|
+
} catch { /* 非 Linux:无 /proc/version */ }
|
|
96
|
+
// 注意:**不能**用 WSLENV 判据——Windows Terminal 在原生 Windows 上也会设置
|
|
97
|
+
// WSLENV(如 WT_SESSION:WT_PROFILE_ID:),会误判成 WSL。只认 WSL 内部才有的变量。
|
|
98
|
+
return Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 解析 ipconfig.exe 输出,默认取非虚拟网卡块的 IPv4 地址(保持输出顺序)。
|
|
103
|
+
* 支持中文(`IPv4 地址 . . . :`)与英文(`IPv4 Address. . . :`)两种格式。
|
|
104
|
+
* @param {string} text ipconfig.exe 的完整输出
|
|
105
|
+
* @param {{ includeVpn?: boolean }} [opts] 传 includeVpn 时保留 Tailscale/VPN 等候选
|
|
106
|
+
* @returns {string[]} 候选 IPv4 列表
|
|
107
|
+
*/
|
|
108
|
+
export function parseIpconfig(text, { includeVpn = false } = {}) {
|
|
109
|
+
const out = [];
|
|
110
|
+
// 网卡块:块标题行顶格(行首无缩进),其后内容行带缩进
|
|
111
|
+
const blocks = String(text).split(/\r?\n(?=\S)/);
|
|
112
|
+
const ipRe = /IPv4[^0-9]{0,40}((?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)/;
|
|
113
|
+
for (const block of blocks) {
|
|
114
|
+
const title = String(block.split(/\r?\n/)[0] ?? '');
|
|
115
|
+
// 跳过虚拟网卡块(vEthernet (WSL)、Docker、VirtualBox、VPN 等)
|
|
116
|
+
if (!includeVpn && VPN_IFACE_RE.test(title)) continue;
|
|
117
|
+
const m = block.match(ipRe);
|
|
118
|
+
if (m) out.push(m[0].replace(/^IPv4[^0-9]*/i, ''));
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function runIpconfig() {
|
|
124
|
+
// WSL 内 PATH 可能不含 Windows System32;用绝对路径兜底
|
|
125
|
+
const candidates = ['ipconfig.exe', '/mnt/c/Windows/System32/ipconfig.exe'];
|
|
126
|
+
return new Promise((resolve) => {
|
|
127
|
+
const tryNext = (i) => {
|
|
128
|
+
if (i >= candidates.length) return resolve(null);
|
|
129
|
+
execFile(candidates[i], [], { timeout: 5000, windowsHide: true }, (err, stdout) => {
|
|
130
|
+
if (err || !stdout) return tryNext(i + 1);
|
|
131
|
+
resolve(String(stdout));
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
tryNext(0);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function lanIPv4() {
|
|
139
|
+
// WSL:优先 Windows 物理网卡 IP(手机可达);超时/失败回退本机探测
|
|
140
|
+
if (detectWsl()) {
|
|
141
|
+
try {
|
|
142
|
+
const out = await runIpconfig();
|
|
143
|
+
const candidates = parseIpconfig(out ?? '');
|
|
144
|
+
const ip = candidates.find((c) => PRIVATE_IPV4_RE.test(c)) ?? candidates[0];
|
|
145
|
+
if (ip) return ip;
|
|
146
|
+
} catch { /* 回退 */ }
|
|
147
|
+
}
|
|
148
|
+
return selectLanIPv4(networkInterfaces());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 收集所有可手动选择的局域网/Tailnet 候选 IP(WSL 下以 Windows ipconfig 为准)。 */
|
|
152
|
+
async function listLanCandidates() {
|
|
153
|
+
if (detectWsl()) {
|
|
154
|
+
try {
|
|
155
|
+
const out = await runIpconfig();
|
|
156
|
+
const ips = parseIpconfig(out ?? '', { includeVpn: true });
|
|
157
|
+
if (ips.length) return [...new Set(ips)];
|
|
158
|
+
} catch { /* 回退 */ }
|
|
159
|
+
}
|
|
160
|
+
const ips = [];
|
|
161
|
+
for (const [name, addrs] of Object.entries(networkInterfaces())) {
|
|
162
|
+
for (const addr of addrs ?? []) {
|
|
163
|
+
if (addr.family !== 'IPv4' || addr.internal) continue;
|
|
164
|
+
const ip = addr.address;
|
|
165
|
+
if (!ip || ip.startsWith('127.') || ip.startsWith('169.254.')) continue;
|
|
166
|
+
if (!ips.includes(ip)) ips.push(ip);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return ips;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 创建 Pocket 服务。
|
|
174
|
+
* @param {object} opts
|
|
175
|
+
* @param {number} opts.dshPort dsh web 实际端口(从 ctx.webServer.port 取)
|
|
176
|
+
* @param {number} [opts.port] 代理端口(默认 3081)
|
|
177
|
+
* @param {object} [opts.internals] 测试注入:createProxy / startTunnel / lanIPv4
|
|
178
|
+
* @returns {PocketService}
|
|
179
|
+
*/
|
|
180
|
+
export function createPocketService({
|
|
181
|
+
dshPort,
|
|
182
|
+
port = 3081,
|
|
183
|
+
home,
|
|
184
|
+
internals = {},
|
|
185
|
+
/** 局域网地址手动覆盖:返回 IPv4 字符串;空值表示自动选择 */
|
|
186
|
+
getLanIpOverride,
|
|
187
|
+
/** 局域网访问总开关:返回 boolean;默认开启。关闭后代理拒绝局域网 Host 请求 */
|
|
188
|
+
getLanEnabled = () => true,
|
|
189
|
+
/** 代理注入 HTML 的内容(桌面端补丁等由 lib/index.js 传入;默认 randomUUID polyfill) */
|
|
190
|
+
injectHtml,
|
|
191
|
+
/** 访问令牌认证配置(issue #13):{ getToken, isProtected },传给代理 */
|
|
192
|
+
auth,
|
|
193
|
+
/** @type {() => string} dsh web 浏览器会话启动 token(issue #77;老版本返回空字符串) */
|
|
194
|
+
launchToken = () => '',
|
|
195
|
+
/** 隧道配置(issue #66):() => ({ mode:'quick'|'named', token, hostname });named 用固定域名 */
|
|
196
|
+
getTunnelConfig,
|
|
197
|
+
/** 隧道就绪回调(lib/index.js 用它轮换公网密码;参数为 'quick'|'named') */
|
|
198
|
+
onTunnelReady,
|
|
199
|
+
/** 日志(DSH 宿主 ctx.logger 有 info/warn/error;默认 console 无 info,自动退回 log)。
|
|
200
|
+
* 隧道自动恢复的成败必须走这里:console 输出在 DSH Desktop 里不进日志文件,
|
|
201
|
+
* 出问题时无法排查(2026-09-07 实测教训)。 */
|
|
202
|
+
log = console,
|
|
203
|
+
} = {}) {
|
|
204
|
+
// ctx.logger 形状是 info/warn/error;console 只有 log/warn/error——info 做兜底兼容
|
|
205
|
+
const logInfo = (...args) => (log.info ?? log.log).call(log, ...args);
|
|
206
|
+
const logWarn = (...args) => log.warn(...args);
|
|
207
|
+
const createProxy = internals.createProxy ?? createPocketProxy;
|
|
208
|
+
const startTunnel = internals.startTunnel ?? startQuickTunnel;
|
|
209
|
+
const startNamed = internals.startNamedTunnel ?? startNamedTunnel;
|
|
210
|
+
const getLanOverride = () => {
|
|
211
|
+
const value = String(getLanIpOverride?.() ?? '').trim();
|
|
212
|
+
return isValidIpv4(value) ? value : '';
|
|
213
|
+
};
|
|
214
|
+
const getLan = async () => getLanOverride() || (internals.lanIPv4 ? internals.lanIPv4() : lanIPv4());
|
|
215
|
+
let lanCandidateCache = null;
|
|
216
|
+
const getLanCandidates = async () => {
|
|
217
|
+
if (internals.lanCandidates) return internals.lanCandidates();
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
if (!lanCandidateCache || now - lanCandidateCache.at > 15000) {
|
|
220
|
+
lanCandidateCache = { at: now, ips: await listLanCandidates() };
|
|
221
|
+
}
|
|
222
|
+
return lanCandidateCache.ips;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
let proxy = null;
|
|
226
|
+
let tunnel = null;
|
|
227
|
+
let tunnelAbort = null;
|
|
228
|
+
/** in-flight 隧道启动(单飞):并发调用复用同一次,避免 spawn 多个 cloudflared 孤儿进程 */
|
|
229
|
+
let tunnelPromise = null;
|
|
230
|
+
/** 隧道进度:{ phase: idle|downloading|starting|registering|ready|error, detail, startedAt } */
|
|
231
|
+
const tunnelState = { phase: 'idle', detail: '', startedAt: null };
|
|
232
|
+
/** 二维码缓存:URL → data URL promise。status() 每 3 秒轮询一次,不能每次都重新生成(CPU 密集)。 */
|
|
233
|
+
const qrCache = new Map();
|
|
234
|
+
const encodeQr = internals.encodeQr ?? qrDataUrl;
|
|
235
|
+
async function qrCached(text) {
|
|
236
|
+
if (!text) return null;
|
|
237
|
+
if (!qrCache.has(text)) {
|
|
238
|
+
if (qrCache.size >= 8) {
|
|
239
|
+
// 只淘汰最旧一条(隧道 URL 每次重启换新),别殃及稳定的 LAN 二维码
|
|
240
|
+
const oldest = qrCache.keys().next().value;
|
|
241
|
+
qrCache.delete(oldest);
|
|
242
|
+
}
|
|
243
|
+
qrCache.set(text, encodeQr(text).catch(() => null));
|
|
244
|
+
}
|
|
245
|
+
return qrCache.get(text);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 公网隧道自动恢复(issue #11):DSH 重启后 cloudflared 子进程被杀、隧道消失,
|
|
249
|
+
// 插件无从知晓。启动时检查持久化的「隧道开启中」标记,自动重新拉起。
|
|
250
|
+
const autoStatePath = home ? join(home, 'dsh-pocket', 'tunnel-auto.json') : null;
|
|
251
|
+
// 写入与删除必须串行:隧道刚就绪就立即关闭时,异步写不能晚于删除重新落盘,
|
|
252
|
+
// 否则标记被晚到的写操作复活,下次启动误恢复(writeFile 与 rm 的完成顺序不保证)。
|
|
253
|
+
let autoStateQueue = Promise.resolve();
|
|
254
|
+
function persistAutoTunnel() {
|
|
255
|
+
if (!autoStatePath) return;
|
|
256
|
+
autoStateQueue = autoStateQueue.then(async () => {
|
|
257
|
+
try {
|
|
258
|
+
await mkdir(dirname(autoStatePath), { recursive: true });
|
|
259
|
+
await writeFile(autoStatePath, JSON.stringify({ at: Date.now() }), 'utf8');
|
|
260
|
+
} catch { /* 忽略 */ }
|
|
261
|
+
});
|
|
262
|
+
return autoStateQueue;
|
|
263
|
+
}
|
|
264
|
+
function clearAutoTunnel() {
|
|
265
|
+
if (!autoStatePath) return;
|
|
266
|
+
autoStateQueue = autoStateQueue.then(async () => {
|
|
267
|
+
try { await rm(autoStatePath, { force: true }); } catch { /* 忽略 */ }
|
|
268
|
+
});
|
|
269
|
+
return autoStateQueue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
dshPort,
|
|
274
|
+
/** 启动局域网代理(幂等)。端口被占(EADDRINUSE,如桌面版与普通环境同时运行)时自动尝试下一个端口。 */
|
|
275
|
+
async startProxy() {
|
|
276
|
+
if (proxy) return proxy;
|
|
277
|
+
let lastErr = null;
|
|
278
|
+
for (let p = port; p < port + 10; p++) {
|
|
279
|
+
try {
|
|
280
|
+
proxy = await createProxy({
|
|
281
|
+
port: p,
|
|
282
|
+
host: '0.0.0.0',
|
|
283
|
+
upstream: { host: '127.0.0.1', port: dshPort },
|
|
284
|
+
...(injectHtml ? { injectHtml } : {}),
|
|
285
|
+
...(auth ? { auth } : {}),
|
|
286
|
+
// 每次请求实时读开关:设置页切换后立即生效,无需重启代理
|
|
287
|
+
lanAccessEnabled: () => getLanEnabled(),
|
|
288
|
+
// dsh web 浏览器会话启动 token(issue #77):实时取,新版 dsh 才有
|
|
289
|
+
...(launchToken ? { launchToken } : {}),
|
|
290
|
+
});
|
|
291
|
+
if (p !== port) {
|
|
292
|
+
logInfo(`dsh-pocket: port ${port} busy, proxy on ${p} | 端口 ${port} 被占用,代理改用 ${p}`);
|
|
293
|
+
}
|
|
294
|
+
break;
|
|
295
|
+
} catch (err) {
|
|
296
|
+
if (err?.code !== 'EADDRINUSE') throw err; // 非端口冲突直接失败
|
|
297
|
+
lastErr = err;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (!proxy) throw lastErr ?? new Error('proxy start failed | 代理启动失败');
|
|
301
|
+
return proxy;
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
/** 启动公网隧道(幂等;返回公网 URL)。进度写进 tunnelState。并发调用单飞。 */
|
|
305
|
+
async startTunnel() {
|
|
306
|
+
await this.startProxy();
|
|
307
|
+
if (tunnel) return tunnel.url;
|
|
308
|
+
if (tunnelPromise) return tunnelPromise; // 复用 in-flight,防孤儿 cloudflared
|
|
309
|
+
const controller = new AbortController();
|
|
310
|
+
tunnelAbort = controller;
|
|
311
|
+
tunnelState.startedAt = Date.now();
|
|
312
|
+
const onPhase = (phase) => {
|
|
313
|
+
tunnelState.phase = phase;
|
|
314
|
+
if (phase === 'downloading') tunnelState.detail = '首次下载 cloudflared(约 20MB)| first run downloads cloudflared (~20MB)';
|
|
315
|
+
else if (phase === 'starting') tunnelState.detail = '启动隧道进程… | starting tunnel…';
|
|
316
|
+
else if (phase === 'registering') tunnelState.detail = '连接 Cloudflare 边缘(通常 5-30 秒)| connecting to Cloudflare edge (usually 5-30s)';
|
|
317
|
+
else if (phase === 'ready') tunnelState.detail = '隧道就绪 | ready';
|
|
318
|
+
};
|
|
319
|
+
// 先 `await null` 让函数体从微任务开始执行:否则在 `p` 初始化之前(getTunnelConfig
|
|
320
|
+
// 之类的同步抛错)就会走到 finally,引用未初始化的 const 触发 TDZ 报错,
|
|
321
|
+
// tunnelPromise 会永久停在 rejected —— 此后 startTunnel 一直复用这个失败的
|
|
322
|
+
// promise,隧道再也起不来。
|
|
323
|
+
const p = (async () => {
|
|
324
|
+
await null;
|
|
325
|
+
try {
|
|
326
|
+
// 命名隧道(issue #66):固定域名模式——URL 由设置里的域名拼出(cloudflared 不打印)
|
|
327
|
+
const cfg = typeof getTunnelConfig === 'function' ? (getTunnelConfig() ?? {}) : {};
|
|
328
|
+
if (cfg?.mode === 'named') {
|
|
329
|
+
if (!cfg.token || !cfg.hostname) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
'命名隧道未配置完整:需要 Tunnel Token 和固定域名(设置页「固定域名」里填写) | '
|
|
332
|
+
+ 'named tunnel is incomplete — set the Tunnel Token and the fixed hostname in Settings',
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
const result = await startNamed({ token: cfg.token, home, signal: controller.signal, onPhase });
|
|
336
|
+
tunnel = { url: `https://${cfg.hostname}`, kill: result.kill, onExit: result.onExit };
|
|
337
|
+
} else {
|
|
338
|
+
const result = await startTunnel({ port: proxy.port, home, signal: controller.signal, onPhase });
|
|
339
|
+
// 归一化:startTunnel 契约返回 {url, kill}(字符串也兼容)
|
|
340
|
+
tunnel = typeof result === 'string' ? { url: result, kill: () => {} } : result;
|
|
341
|
+
}
|
|
342
|
+
tunnelState.phase = 'ready';
|
|
343
|
+
// M1:隧道进程运行中死亡(崩溃/被杀)→ 状态打回,别让 UI 永远显示"可用"
|
|
344
|
+
tunnel.onExit?.((code) => {
|
|
345
|
+
if (controller.signal.aborted) return; // 主动停止(stopTunnel)不算故障
|
|
346
|
+
tunnelState.phase = 'error';
|
|
347
|
+
tunnelState.detail = `隧道进程退出(code=${code}) | tunnel process exited (code=${code})`;
|
|
348
|
+
});
|
|
349
|
+
// 记录「隧道开启中」,供重启后自动恢复(issue #11)
|
|
350
|
+
void persistAutoTunnel();
|
|
351
|
+
// 公网隧道就绪 → 回调(快速模式轮换访问密码;命名模式不轮换,见 lib/index.js)
|
|
352
|
+
try { onTunnelReady?.(cfg?.mode === 'named' ? 'named' : 'quick'); } catch { /* 忽略 */ }
|
|
353
|
+
return tunnel.url;
|
|
354
|
+
} catch (err) {
|
|
355
|
+
// stopTunnel 触发的 abort 不算错误:保持 idle,别把状态刷成 error
|
|
356
|
+
if (!controller.signal.aborted) {
|
|
357
|
+
tunnelState.phase = 'error';
|
|
358
|
+
tunnelState.detail = err?.message ?? String(err);
|
|
359
|
+
}
|
|
360
|
+
tunnelState.startedAt = null; // 失败后清掉计时,避免 UI 误显"启动中"
|
|
361
|
+
throw err;
|
|
362
|
+
} finally {
|
|
363
|
+
// 只清自己的引用:stopTunnel 后立即 startTunnel 可能已建了新的 in-flight
|
|
364
|
+
// (tunnelPromise=B),A 的 finally 不能把 B 清掉,否则第三次调用会并发 spawn
|
|
365
|
+
if (tunnelPromise === p) tunnelPromise = null;
|
|
366
|
+
}
|
|
367
|
+
})();
|
|
368
|
+
tunnelPromise = p;
|
|
369
|
+
return p;
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* 停止公网隧道(代理保持)。
|
|
374
|
+
* @param {{ keepAutoMarker?: boolean }} [opts] - keepAutoMarker=true 用于进程退出/
|
|
375
|
+
* 自重启(dispose / pocketRestart):隧道是随进程被动消失的,不是用户主动关闭,
|
|
376
|
+
* 必须保留 tunnel-auto.json,下次启动才能自动恢复(issue #11 的本意)。
|
|
377
|
+
* 默认(设置页手动关闭、恢复出厂)删除标记。
|
|
378
|
+
*/
|
|
379
|
+
stopTunnel({ keepAutoMarker = false } = {}) {
|
|
380
|
+
tunnelAbort?.abort();
|
|
381
|
+
tunnelAbort = null;
|
|
382
|
+
tunnelPromise = null; // 丢弃已 abort 的 in-flight(其 finally 会再清一次,无害)
|
|
383
|
+
if (tunnel) tunnel.kill();
|
|
384
|
+
tunnel = null;
|
|
385
|
+
tunnelState.phase = 'idle';
|
|
386
|
+
tunnelState.detail = '';
|
|
387
|
+
tunnelState.startedAt = null;
|
|
388
|
+
if (!keepAutoMarker) void clearAutoTunnel(); // 手动关闭后不再自动恢复
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
/** 启动时自动恢复上次开启的公网隧道(DSH 重启后 cloudflared 子进程被杀,issue #11)。 */
|
|
392
|
+
async restoreTunnelIfNeeded() {
|
|
393
|
+
logInfo('dsh-pocket: auto-restore check | 自动恢复检查:读取上次隧道状态');
|
|
394
|
+
if (!autoStatePath || tunnel || tunnelPromise) return;
|
|
395
|
+
let has = false;
|
|
396
|
+
try {
|
|
397
|
+
const raw = await readFile(autoStatePath, 'utf8');
|
|
398
|
+
has = /"at"\s*:/.test(raw);
|
|
399
|
+
} catch { return; } // 无标记 → 不恢复
|
|
400
|
+
if (!has) return;
|
|
401
|
+
logInfo('dsh-pocket: auto-restore: marker found, starting tunnel | 发现上次开启标记,尝试自动恢复');
|
|
402
|
+
try {
|
|
403
|
+
await this.startTunnel();
|
|
404
|
+
logInfo('dsh-pocket: public tunnel auto-restored | 已自动恢复公网隧道');
|
|
405
|
+
} catch (err) {
|
|
406
|
+
// 恢复失败保留标记(下次启动再试);网络问题见 README 排障
|
|
407
|
+
logWarn('dsh-pocket: tunnel auto-restore failed | 自动恢复隧道失败: %s', err?.message ?? err);
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
/** 状态快照(RPC 返回,不含敏感信息;二维码 data URL 本地生成 + 缓存)。 */
|
|
412
|
+
async status() {
|
|
413
|
+
const lan = await getLan();
|
|
414
|
+
const proxyPort = proxy?.port ?? null;
|
|
415
|
+
const lanUrl = lan && proxyPort ? `http://${lan}:${proxyPort}` : null;
|
|
416
|
+
const lanIpOverride = getLanOverride();
|
|
417
|
+
const lanCandidates = [...new Set(await getLanCandidates())];
|
|
418
|
+
if (lanIpOverride && !lanCandidates.includes(lanIpOverride)) lanCandidates.push(lanIpOverride);
|
|
419
|
+
return {
|
|
420
|
+
proxyRunning: proxy !== null,
|
|
421
|
+
proxyPort,
|
|
422
|
+
lanUrl,
|
|
423
|
+
lanQr: await qrCached(lanUrl),
|
|
424
|
+
lanCandidates,
|
|
425
|
+
lanIpOverride,
|
|
426
|
+
tunnelRunning: tunnel !== null,
|
|
427
|
+
tunnelUrl: tunnel?.url ?? null,
|
|
428
|
+
tunnelQr: await qrCached(tunnel?.url ?? null),
|
|
429
|
+
tunnelState: { ...tunnelState },
|
|
430
|
+
// 隧道配置脱敏视图(issue #66):token 永不回显,只有 tokenSet 布尔值
|
|
431
|
+
tunnelConfig: (() => {
|
|
432
|
+
const cfg = typeof getTunnelConfig === 'function' ? (getTunnelConfig() ?? {}) : {};
|
|
433
|
+
return {
|
|
434
|
+
mode: cfg?.mode === 'named' ? 'named' : 'quick',
|
|
435
|
+
hostname: typeof cfg?.hostname === 'string' ? cfg.hostname : '',
|
|
436
|
+
tokenSet: Boolean(cfg?.token),
|
|
437
|
+
};
|
|
438
|
+
})(),
|
|
439
|
+
dshPort,
|
|
440
|
+
};
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
/** 停止一切(插件卸载时)。 */
|
|
444
|
+
/** 停止一切(插件卸载时)。进程退出不是用户手动关闭:保留自动恢复标记。 */
|
|
445
|
+
async dispose() {
|
|
446
|
+
this.stopTunnel({ keepAutoMarker: true });
|
|
447
|
+
if (proxy) {
|
|
448
|
+
const p = proxy;
|
|
449
|
+
proxy = null;
|
|
450
|
+
try { await p.close(); } catch { /* server 已关闭等边缘情况 */ }
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
}
|