dsh-mobile-plugin 0.5.21 → 0.5.23
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 +3 -12
- package/assets/pets/README.md +9 -0
- package/assets/pets/deepy-whale/attention.gif +0 -0
- package/assets/pets/deepy-whale/done.gif +0 -0
- package/assets/pets/deepy-whale/error.gif +0 -0
- package/assets/pets/deepy-whale/idle.gif +0 -0
- package/assets/pets/deepy-whale/pack.json +54 -0
- package/assets/pets/deepy-whale/sleeping.gif +0 -0
- package/assets/pets/deepy-whale/working-3.gif +0 -0
- package/assets/pets/deepy-whale/working.gif +0 -0
- package/extensions/README.md +64 -0
- package/extensions/catalog.json +1 -0
- package/lib/impl.js +21 -1
- package/lib/mobile/approvals.js +22 -2
- package/lib/mobile/cli/acp.js +34 -20
- package/lib/mobile/cli/mobile.js +8 -1
- package/lib/mobile/cli/service.js +151 -10
- package/lib/mobile/extension-catalog.js +244 -0
- package/lib/mobile/live-steps.js +35 -3
- package/lib/mobile/local-images.js +166 -87
- package/lib/mobile/pet-tools.js +215 -0
- package/lib/mobile/pets.js +559 -0
- package/lib/mobile/session-local-image.js +9 -1
- package/lib/mobile/stats-collect.cjs +15 -7
- package/lib/mobile/stats-ledger.cjs +44 -5
- package/lib/mobile/stream.js +8 -5
- package/lib/mobile/wechat-tools.js +257 -0
- package/lib/mobile/wechat.js +489 -0
- package/lib/panel/index.html +10 -10
- package/lib/relay/pairing-payload.cjs +13 -5
- package/lib/relay/snapshot-auth.js +82 -0
- package/lib/relay/status.js +3 -0
- package/lib/routes/control-plane.js +65 -3
- package/lib/routes/mobile-api.js +54 -1
- package/lib/sidecar-facade-worker.cjs +8 -0
- package/lib/tailcat/host-process.cjs +270 -0
- package/lib/tailcat/ingress.cjs +98 -0
- package/lib/tailcat/session.cjs +264 -0
- package/lib/tailcat/wiring.cjs +96 -0
- package/lib/update-install.cjs +43 -0
- package/package.json +10 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 隧道和插件之间的那道小门:在回环上占一个固定的端口,进来的每条连接都转给「当前」的
|
|
5
|
+
* 局域网端口。
|
|
6
|
+
*
|
|
7
|
+
* 为什么中间要隔这一层:局域网端口是会变的(首选 18787 被占就让内核另分一个,重启后还
|
|
8
|
+
* 可能再变),而隧道那边把它当固定地址写死。直接告诉助手那个会变的端口,端口一变隧道
|
|
9
|
+
* 就整段指向一个没人听的地方,而手机侧只会看到「连上就断」。隔一层之后助手只认这个
|
|
10
|
+
* 回环端口,端口怎么变都由这里在每次连接时临时决定。
|
|
11
|
+
*
|
|
12
|
+
* 只监听 127.0.0.1,不接受任何外部来源;隧道进来的是本进程内的连接。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const net = require('node:net');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} options
|
|
19
|
+
* @param {() => number} options.targetPort 当前局域网端口(每次连接现读,允许变化)
|
|
20
|
+
* @param {(message: string) => void} [options.log]
|
|
21
|
+
*/
|
|
22
|
+
function createLoopbackIngress(options) {
|
|
23
|
+
const log = options.log || (() => {});
|
|
24
|
+
let server = null;
|
|
25
|
+
let port = 0;
|
|
26
|
+
const live = new Set();
|
|
27
|
+
|
|
28
|
+
function pipe(socket) {
|
|
29
|
+
const target = Number(options.targetPort());
|
|
30
|
+
if (!Number.isInteger(target) || target <= 0) {
|
|
31
|
+
socket.destroy();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const upstream = net.connect({ host: '127.0.0.1', port: target });
|
|
35
|
+
live.add(socket);
|
|
36
|
+
live.add(upstream);
|
|
37
|
+
const drop = () => {
|
|
38
|
+
live.delete(socket);
|
|
39
|
+
live.delete(upstream);
|
|
40
|
+
socket.destroy();
|
|
41
|
+
upstream.destroy();
|
|
42
|
+
};
|
|
43
|
+
socket.on('error', drop);
|
|
44
|
+
upstream.on('error', drop);
|
|
45
|
+
socket.on('close', drop);
|
|
46
|
+
upstream.on('close', drop);
|
|
47
|
+
socket.pipe(upstream);
|
|
48
|
+
upstream.pipe(socket);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let starting = null;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
/**
|
|
55
|
+
* 把回环端口开起来,拿到端口之后才算成;失败回 0。
|
|
56
|
+
*
|
|
57
|
+
* **必须是异步的**:`listen(0)` 之后 `address()` 要等 `listening` 事件才有值,同步读回来
|
|
58
|
+
* 恒是 null,端口就成了 0——隧道那边于是拿到一个「没有地址」的配置,整条通道静默不可用。
|
|
59
|
+
*/
|
|
60
|
+
start() {
|
|
61
|
+
if (starting) return starting;
|
|
62
|
+
server = net.createServer(pipe);
|
|
63
|
+
server.on('error', error => {
|
|
64
|
+
log(`隧道入口没有开起来:${error && error.message ? error.message : error}`);
|
|
65
|
+
});
|
|
66
|
+
starting = new Promise(resolve => {
|
|
67
|
+
try {
|
|
68
|
+
server.listen(0, '127.0.0.1', () => {
|
|
69
|
+
const address = server.address();
|
|
70
|
+
port = address && typeof address === 'object' ? address.port : 0;
|
|
71
|
+
resolve(port);
|
|
72
|
+
});
|
|
73
|
+
} catch (error) {
|
|
74
|
+
log(`隧道入口监听失败:${error && error.message ? error.message : error}`);
|
|
75
|
+
resolve(0);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return starting;
|
|
79
|
+
},
|
|
80
|
+
port: () => port,
|
|
81
|
+
close() {
|
|
82
|
+
for (const socket of live) socket.destroy();
|
|
83
|
+
live.clear();
|
|
84
|
+
if (server) {
|
|
85
|
+
try {
|
|
86
|
+
server.close();
|
|
87
|
+
} catch {
|
|
88
|
+
/* already closed */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
server = null;
|
|
92
|
+
port = 0;
|
|
93
|
+
starting = null;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { createLoopbackIngress };
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 直连隧道这一整套东西的宿主侧会话:拉起助手进程、把它的身份存进 relay.json、
|
|
5
|
+
* 开配对窗口、同步白名单、给面板一份状态。
|
|
6
|
+
*
|
|
7
|
+
* 只做「把助手和插件的存储接起来」,不掺 HTTP、不认识路由:路由那边拿 `status()` 和
|
|
8
|
+
* `openPairingWindow()` 就行,面板那边同理。这样它能被单测直接驱动(假助手进程即可)。
|
|
9
|
+
*
|
|
10
|
+
* 三件必须一直成立:
|
|
11
|
+
* 1. 存的是**助手自己的身份**(节点私钥 / 预共享密钥 / 区域),助手不落盘 —— 插件永远是
|
|
12
|
+
* 唯一真相,用户重置插件不会留下一把孤儿钥匙。
|
|
13
|
+
* 2. 配对窗口是一次性的:每次开窗口都换一把临时钥匙,换完(或过期)就作废。
|
|
14
|
+
* 3. 手机被吊销时白名单立刻变,助手那边整体重启节点;失败就把白名单退回原样,不能出现
|
|
15
|
+
* 「界面上已吊销、实际上还能连」。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { createTailcatHost, resolveBinary, TUNNEL_PORT, PAIRING_PORT } = require('./host-process.cjs');
|
|
19
|
+
|
|
20
|
+
/** 开一个配对窗口最多等多久(拉起一个节点要 1~3 秒,弱网更久一点)。 */
|
|
21
|
+
const PAIRING_WINDOW_TIMEOUT_MS = 20_000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 让一台老设备进来时最多等多久(节点要整体重启一次)。
|
|
25
|
+
*
|
|
26
|
+
* 必须留在手机那一发的总预算之内(App 侧一次逻辑请求是 8 秒):等满 15 秒的话手机先超时,
|
|
27
|
+
* 界面上就变成「请求没送到电脑」,而电脑其实正在起节点。宁可早点认输 —— 手机 60 秒后会
|
|
28
|
+
* 自己再问一次,那时候节点已经起来了,第一发就能拿到地址。
|
|
29
|
+
*/
|
|
30
|
+
const ENROLL_TIMEOUT_MS = 6_000;
|
|
31
|
+
|
|
32
|
+
/** 状态里放白名单与服务端身份的那几格(键名)。 */
|
|
33
|
+
const STATE = {
|
|
34
|
+
enabled: 'tailcatEnabled',
|
|
35
|
+
identity: 'tailcatState',
|
|
36
|
+
allowed: 'tailcatAllowedClients',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {object} options
|
|
41
|
+
* @param {() => {enabled: boolean, identity: string|null, allowed: string[]}} options.readState 读盘上的隧道配置
|
|
42
|
+
* @param {(patch: object) => void} options.writeState 写回(只写这三个键)
|
|
43
|
+
* @param {number} options.localPort 插件本机端口,隧道里唯一的去处
|
|
44
|
+
* @param {() => string} [options.hostName] 电脑名(手机配对时显示)
|
|
45
|
+
* @param {(message: string) => void} [options.log] 诊断日志
|
|
46
|
+
* @param {object} [options.host] 测试注入的助手句柄
|
|
47
|
+
*/
|
|
48
|
+
function createTailcatSession(options) {
|
|
49
|
+
const log = options.log || (() => {});
|
|
50
|
+
const binary = options.host ? 'injected' : resolveBinary();
|
|
51
|
+
let host = null;
|
|
52
|
+
let lastError = '';
|
|
53
|
+
/** 正在等窗口的那次请求(一次只开一个窗口)。 */
|
|
54
|
+
let pendingWindow = null;
|
|
55
|
+
|
|
56
|
+
function snapshot() {
|
|
57
|
+
try {
|
|
58
|
+
const state = options.readState() || {};
|
|
59
|
+
return {
|
|
60
|
+
enabled: state[STATE.enabled] !== false,
|
|
61
|
+
identity: typeof state[STATE.identity] === 'string' ? state[STATE.identity] : null,
|
|
62
|
+
allowed: Array.isArray(state[STATE.allowed]) ? state[STATE.allowed] : [],
|
|
63
|
+
};
|
|
64
|
+
} catch {
|
|
65
|
+
return { enabled: true, identity: null, allowed: [] };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function persist(patch) {
|
|
70
|
+
try {
|
|
71
|
+
options.writeState(patch);
|
|
72
|
+
return true;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
log(`直连隧道:写配置失败 ${error && error.message ? error.message : error}`);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function handleEvent(event) {
|
|
80
|
+
switch (event.event) {
|
|
81
|
+
case 'ready':
|
|
82
|
+
lastError = '';
|
|
83
|
+
break;
|
|
84
|
+
case 'pairing':
|
|
85
|
+
if (pendingWindow) {
|
|
86
|
+
const done = pendingWindow;
|
|
87
|
+
pendingWindow = null;
|
|
88
|
+
done.resolve({
|
|
89
|
+
address: event.address,
|
|
90
|
+
port: PAIRING_PORT,
|
|
91
|
+
key: event.key,
|
|
92
|
+
proof: event.proof,
|
|
93
|
+
expiresAt: event.expiresAt,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
break;
|
|
97
|
+
case 'paired': {
|
|
98
|
+
// 助手已经把手机加进白名单;存下来,并让二维码作废(码里那把钥匙只服务这一次)。
|
|
99
|
+
const state = snapshot();
|
|
100
|
+
const allowed = state.allowed.includes(event.client) ? state.allowed : [...state.allowed, event.client];
|
|
101
|
+
persist({ [STATE.allowed]: allowed });
|
|
102
|
+
if (pendingWindowTimeout) clearTimeout(pendingWindowTimeout);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case 'persist': {
|
|
106
|
+
// 助手的身份(私钥 / 预共享密钥 / 区域)与白名单都落在这里;存住了才回执。
|
|
107
|
+
const patch = { [STATE.allowed]: event.allowedClients };
|
|
108
|
+
if (typeof event.state === 'string' && event.state) patch[STATE.identity] = event.state;
|
|
109
|
+
const ok = persist(patch);
|
|
110
|
+
host && host.ack(event.id, ok);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case 'pairingClosed':
|
|
114
|
+
if (pendingWindow) {
|
|
115
|
+
const done = pendingWindow;
|
|
116
|
+
pendingWindow = null;
|
|
117
|
+
done.resolve(null);
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
case 'error':
|
|
121
|
+
lastError = event.code;
|
|
122
|
+
if (pendingWindow) {
|
|
123
|
+
const done = pendingWindow;
|
|
124
|
+
pendingWindow = null;
|
|
125
|
+
done.resolve(null);
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
default:
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let pendingWindowTimeout = null;
|
|
134
|
+
|
|
135
|
+
function start() {
|
|
136
|
+
if (host) return;
|
|
137
|
+
if (!binary) {
|
|
138
|
+
lastError = 'helper-missing';
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const state = snapshot();
|
|
142
|
+
if (!state.enabled) return;
|
|
143
|
+
host = options.host || createTailcatHost({
|
|
144
|
+
localPort: options.localPort,
|
|
145
|
+
name: typeof options.hostName === 'function' ? options.hostName() : undefined,
|
|
146
|
+
state: state.identity,
|
|
147
|
+
allowedClients: state.allowed,
|
|
148
|
+
onEvent: handleEvent,
|
|
149
|
+
});
|
|
150
|
+
host.start();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function stop() {
|
|
154
|
+
if (pendingWindow) {
|
|
155
|
+
pendingWindow.resolve(null);
|
|
156
|
+
pendingWindow = null;
|
|
157
|
+
}
|
|
158
|
+
const current = host;
|
|
159
|
+
host = null;
|
|
160
|
+
if (current) return current.close();
|
|
161
|
+
return Promise.resolve();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 面板与路由看到的现状。 */
|
|
165
|
+
function status() {
|
|
166
|
+
const state = snapshot();
|
|
167
|
+
if (!binary) return { available: false, enabled: state.enabled, ready: false, address: '', paired: state.allowed.length, error: 'helper-missing' };
|
|
168
|
+
const live = host && typeof host.status === 'function' ? host.status() : { running: false, ready: false, address: '' };
|
|
169
|
+
return {
|
|
170
|
+
available: true,
|
|
171
|
+
enabled: state.enabled,
|
|
172
|
+
running: Boolean(live.running),
|
|
173
|
+
ready: Boolean(live.ready),
|
|
174
|
+
address: live.address || '',
|
|
175
|
+
paired: state.allowed.length,
|
|
176
|
+
error: lastError,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 开一个配对窗口。成功给出发给手机的那三样(地址 / 一次性私钥 / 证明串);
|
|
182
|
+
* 拉不起节点或超时就给 null,调用方照「这次只能走原来的路子」处理。
|
|
183
|
+
*/
|
|
184
|
+
function openPairingWindow(ttlSeconds) {
|
|
185
|
+
if (!binary) return Promise.resolve(null);
|
|
186
|
+
start();
|
|
187
|
+
if (!host) return Promise.resolve(null);
|
|
188
|
+
if (pendingWindow) return pendingWindow.promise;
|
|
189
|
+
let resolveFn;
|
|
190
|
+
const promise = new Promise(resolve => {
|
|
191
|
+
resolveFn = resolve;
|
|
192
|
+
});
|
|
193
|
+
pendingWindow = { promise, resolve: resolveFn };
|
|
194
|
+
pendingWindowTimeout = setTimeout(() => {
|
|
195
|
+
if (!pendingWindow) return;
|
|
196
|
+
pendingWindow = null;
|
|
197
|
+
resolveFn(null);
|
|
198
|
+
}, PAIRING_WINDOW_TIMEOUT_MS);
|
|
199
|
+
pendingWindowTimeout.unref?.();
|
|
200
|
+
host.pair(ttlSeconds);
|
|
201
|
+
return promise;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 让一台**已经配过对**的手机进来,并等回它的入口地址。
|
|
206
|
+
*
|
|
207
|
+
* 这是「插件更新之后手机不用重新扫码」的那条路:手机手上已经有这台电脑的凭据,只是
|
|
208
|
+
* 还不知道有隧道这回事。它把自己的公钥发上来,我们加进白名单、把节点拉起来,再把它该拨
|
|
209
|
+
* 的地址回给它 —— 与当初「宿主改了中转地址、手机跟着换」是同一套机制。
|
|
210
|
+
*
|
|
211
|
+
* 白名单变了节点要整体重启(v0.7 没有单独加人的接口),所以这里要等一小段。
|
|
212
|
+
*/
|
|
213
|
+
async function enroll(publicKey) {
|
|
214
|
+
if (!binary || typeof publicKey !== 'string' || !publicKey.startsWith('nodekey:')) return null;
|
|
215
|
+
start();
|
|
216
|
+
if (!host) return null;
|
|
217
|
+
const state = snapshot();
|
|
218
|
+
if (!state.allowed.includes(publicKey)) {
|
|
219
|
+
if (!setAllowed([...state.allowed, publicKey])) return null;
|
|
220
|
+
}
|
|
221
|
+
const deadline = Date.now() + ENROLL_TIMEOUT_MS;
|
|
222
|
+
while (Date.now() < deadline) {
|
|
223
|
+
const live = status();
|
|
224
|
+
if (live.address) return { address: live.address, port: TUNNEL_PORT };
|
|
225
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 换白名单(吊销手机、或者用户手动清空)。助手会整体重启节点。 */
|
|
231
|
+
function setAllowed(allowedClients) {
|
|
232
|
+
const allowed = Array.isArray(allowedClients) ? allowedClients : [];
|
|
233
|
+
if (!persist({ [STATE.allowed]: allowed })) return false;
|
|
234
|
+
host && host.setAllowed(allowed);
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 面板上的总开关。关掉就是停进程、白名单留着(再打开不用重新配对)。 */
|
|
239
|
+
function setEnabled(enabled) {
|
|
240
|
+
const next = Boolean(enabled);
|
|
241
|
+
if (!persist({ [STATE.enabled]: next })) return false;
|
|
242
|
+
if (next) start();
|
|
243
|
+
else stop();
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
start,
|
|
249
|
+
stop,
|
|
250
|
+
status,
|
|
251
|
+
enroll,
|
|
252
|
+
/** 测试缝:直接喂一条助手事件(真实路径是子进程 stdout)。 */
|
|
253
|
+
_event: handleEvent,
|
|
254
|
+
openPairingWindow,
|
|
255
|
+
setAllowed,
|
|
256
|
+
setEnabled,
|
|
257
|
+
/** 供测试与诊断:当前配置里的白名单。 */
|
|
258
|
+
allowedClients: () => snapshot().allowed,
|
|
259
|
+
constants: { TUNNEL_PORT, PAIRING_PORT },
|
|
260
|
+
stateKeys: STATE,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
module.exports = { createTailcatSession, STATE, TUNNEL_PORT, PAIRING_PORT };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 把直连隧道挂进装配层:回环入口 + 助手会话 + 一条自己的启停 effect。
|
|
5
|
+
*
|
|
6
|
+
* 单独一个文件是因为装配层有行数上限(`test/assembly-layer.test.cjs` 钉着)——那些上限不是
|
|
7
|
+
* 「装配层恰好该多长」,而是「实现不许搬回装配层」。这里的三样东西都是装配动作,实现分别
|
|
8
|
+
* 在 `ingress.cjs` 与 `session.cjs`。
|
|
9
|
+
*
|
|
10
|
+
* 任何一步起不来都只意味着「这台机器没有这条通道」:`mount` 兜住异常并留一行日志,中转与
|
|
11
|
+
* 局域网一行不受影响。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { createLoopbackIngress } = require('./ingress.cjs');
|
|
15
|
+
const { createTailcatSession } = require('./session.cjs');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} options
|
|
19
|
+
* @param {(label: string, fn: Function) => any} options.mount 装配层的兜底挂载
|
|
20
|
+
* @param {(message: string) => void} options.warn 告警出口(拿不到 logger 时为空实现)
|
|
21
|
+
* @param {object} options.state relay.json 那份内存状态(唯一真值)
|
|
22
|
+
* @param {(state: object) => void} options.saveState 落盘
|
|
23
|
+
* @param {() => number} options.lanPortInUse 当前局域网端口(会漂)
|
|
24
|
+
* @param {() => string} options.hostName 电脑名
|
|
25
|
+
* @param {object} options.ctx 插件 ctx(只为 `ctx.effect`)
|
|
26
|
+
* @param {string} options.name 插件名,用于 effect 标签
|
|
27
|
+
* @returns {object|undefined} 会话;起不来就是 undefined(路由与面板按「不可用」处理)
|
|
28
|
+
*/
|
|
29
|
+
function mountTailcat(options) {
|
|
30
|
+
const { mount, warn, state, saveState, lanPortInUse, hostName, ctx, name } = options;
|
|
31
|
+
const ingress = mount('直连隧道入口', () =>
|
|
32
|
+
createLoopbackIngress({ targetPort: () => lanPortInUse(), log: message => warn(message) }),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 端口是异步拿到的(`listen(0)` 要等 `listening`),所以会话也是异步建好的。
|
|
37
|
+
* 路由和面板在装配阶段就要拿到一个对象,于是对外交的是**门面**:里面那格可能还是空,
|
|
38
|
+
* 调用方问到的就是「暂不可用」,等挂上了自然就通了 —— 不需要谁去等一次 Promise。
|
|
39
|
+
*/
|
|
40
|
+
let session = null;
|
|
41
|
+
const door = {
|
|
42
|
+
status: () => (session ? session.status() : { available: Boolean(ingress), enabled: true, ready: false, address: '', paired: 0, error: 'starting' }),
|
|
43
|
+
openPairingWindow: ttl => (session ? session.openPairingWindow(ttl) : Promise.resolve(null)),
|
|
44
|
+
setAllowed: keys => (session ? session.setAllowed(keys) : false),
|
|
45
|
+
setEnabled: enabled => (session ? session.setEnabled(enabled) : false),
|
|
46
|
+
enroll: publicKey => (session ? session.enroll(publicKey) : Promise.resolve(null)),
|
|
47
|
+
allowedClients: () => (session ? session.allowedClients() : []),
|
|
48
|
+
start: () => (session ? session.start() : undefined),
|
|
49
|
+
stop: () => (session ? session.stop() : Promise.resolve()),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// 启停单独一条命:跟 sidecar 不是一件事,谁挂了都不该带走对方。
|
|
53
|
+
// 关的时候先停助手再关回环门——门是助手唯一的去处,反过来会让最后几条连接瞬间断在门上。
|
|
54
|
+
mount('直连隧道生命周期', () =>
|
|
55
|
+
ctx.effect(() => {
|
|
56
|
+
let closed = false;
|
|
57
|
+
const wiring = ingress
|
|
58
|
+
? Promise.resolve(ingress.start())
|
|
59
|
+
.then(port => {
|
|
60
|
+
if (closed || !port) return;
|
|
61
|
+
session = createTailcatSession({
|
|
62
|
+
readState: () => state,
|
|
63
|
+
writeState: patch => {
|
|
64
|
+
Object.assign(state, patch);
|
|
65
|
+
saveState(state);
|
|
66
|
+
},
|
|
67
|
+
localPort: port,
|
|
68
|
+
hostName,
|
|
69
|
+
log: message => warn(message),
|
|
70
|
+
});
|
|
71
|
+
session.start();
|
|
72
|
+
})
|
|
73
|
+
.catch(error => warn(`直连隧道没有起来:${error && error.message ? error.message : error}`))
|
|
74
|
+
: Promise.resolve();
|
|
75
|
+
return () => {
|
|
76
|
+
closed = true;
|
|
77
|
+
void wiring.then(() => {
|
|
78
|
+
try {
|
|
79
|
+
if (session) session.stop();
|
|
80
|
+
session = null;
|
|
81
|
+
} catch {
|
|
82
|
+
/* 已经在停 */
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
if (ingress) ingress.close();
|
|
86
|
+
} catch {
|
|
87
|
+
/* 已经关了 */
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
}, `${name}: tailcat`),
|
|
92
|
+
);
|
|
93
|
+
return door;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { mountTailcat };
|
package/lib/update-install.cjs
CHANGED
|
@@ -207,6 +207,41 @@ function installedVersion(profileNodeModules, name = DEFAULT_NAME) {
|
|
|
207
207
|
}
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* 这次装完后,插件「按当前平台装上的」可选依赖(例如 tailcat 平台包)各自的实际版本。
|
|
212
|
+
*
|
|
213
|
+
* 它们和插件本体一样可能刚发布就被装进来,登记排除清单时要一并写 —— 否则这些包自己的
|
|
214
|
+
* 锁条目会把后续的安装挡在 24 小时冷却窗口里(漏的正是刚发的那几个平台包)。只统计真的
|
|
215
|
+
* 落在 node_modules 里的那些:os / cpu 不匹配被包管理器跳过的平台包不登记。
|
|
216
|
+
*
|
|
217
|
+
* 读不到插件清单或读不到某个依赖都只跳过、不抛 —— 登记是善后,不该把装好的版本说成没装上。
|
|
218
|
+
*/
|
|
219
|
+
function installedOptionalDeps(profileNodeModules, name = DEFAULT_NAME) {
|
|
220
|
+
const readVersion = (dir) => {
|
|
221
|
+
try {
|
|
222
|
+
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
223
|
+
return typeof raw.version === 'string' && raw.version ? raw.version : null;
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
const out = [];
|
|
229
|
+
let optional;
|
|
230
|
+
try {
|
|
231
|
+
optional = JSON.parse(fs.readFileSync(path.join(profileNodeModules, name, 'package.json'), 'utf8')).optionalDependencies;
|
|
232
|
+
} catch {
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
if (!optional || typeof optional !== 'object') return out;
|
|
236
|
+
for (const depName of Object.keys(optional)) {
|
|
237
|
+
// hoisted 布局在 profile/node_modules 顶层;严格布局下在插件自己的 node_modules 里。
|
|
238
|
+
const version = readVersion(path.join(profileNodeModules, depName))
|
|
239
|
+
|| readVersion(path.join(profileNodeModules, name, 'node_modules', depName));
|
|
240
|
+
if (version) out.push({ name: depName, version });
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
210
245
|
/**
|
|
211
246
|
* profile 的依赖声明里这个包是怎么装的(`profileDir` 是 profile 根,不是它的 node_modules)。
|
|
212
247
|
*
|
|
@@ -719,6 +754,13 @@ async function installLatest({
|
|
|
719
754
|
const changed = after !== null && after !== beforeVersion;
|
|
720
755
|
// 这一版是我们绕开晾置期装进去的,登记进排除清单,免得它反过来把别的安装挡 24 小时。
|
|
721
756
|
const releaseAgeRecorded = changed ? recordReleaseAgeExclusion(profileDir, name, after) : false;
|
|
757
|
+
// 插件按平台带出来的可选依赖(如 tailcat 平台包)也是刚发就可能刚装上的,同批登记,
|
|
758
|
+
// 否则这些包自己的锁条目会把后续安装挡在冷却窗口里。逐条尽力写,失败不影响主结果。
|
|
759
|
+
if (changed) {
|
|
760
|
+
for (const dep of installedOptionalDeps(profileNodeModules, name)) {
|
|
761
|
+
recordReleaseAgeExclusion(profileDir, dep.name, dep.version);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
722
764
|
return {
|
|
723
765
|
installed: changed,
|
|
724
766
|
skipped: false,
|
|
@@ -845,6 +887,7 @@ module.exports = {
|
|
|
845
887
|
installLatest,
|
|
846
888
|
installLatestIfNeeded,
|
|
847
889
|
installedVersion,
|
|
890
|
+
installedOptionalDeps,
|
|
848
891
|
declaredSource,
|
|
849
892
|
// 「Windows 上怎么起包管理器」在本机跑不起来,守卫只能验拼好的那一行,所以这两样露出来。
|
|
850
893
|
cmdInvocation,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.23",
|
|
4
4
|
"description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"icon": "./icon.png",
|
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
"dsh-mobile-credentials",
|
|
43
43
|
"locale/*.json",
|
|
44
44
|
"icon.png",
|
|
45
|
+
"assets/pets/**",
|
|
46
|
+
"extensions/**",
|
|
45
47
|
"README.md"
|
|
46
48
|
],
|
|
47
49
|
"publishConfig": {
|
|
@@ -58,6 +60,13 @@
|
|
|
58
60
|
"qrcode": "1.5.4",
|
|
59
61
|
"ws": "8.18.0"
|
|
60
62
|
},
|
|
63
|
+
"optionalDependencies": {
|
|
64
|
+
"dsh-mobile-tailcat-darwin-arm64": "1.0.0",
|
|
65
|
+
"dsh-mobile-tailcat-darwin-x64": "1.0.0",
|
|
66
|
+
"dsh-mobile-tailcat-linux-arm64": "1.0.0",
|
|
67
|
+
"dsh-mobile-tailcat-linux-x64": "1.0.0",
|
|
68
|
+
"dsh-mobile-tailcat-win32-x64": "1.0.0"
|
|
69
|
+
},
|
|
61
70
|
"peerDependencies": {
|
|
62
71
|
"@deepseek-ai/cordis": ">=4.0.1",
|
|
63
72
|
"@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2 || ^0.2.0-rc.1 || ^0.2.1-rc.2",
|