dsh-mobile-plugin 0.5.9 → 0.5.11
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/lib/cli/pair.cjs +104 -0
- package/lib/client.js +68 -3
- package/lib/impl.js +9 -40
- package/lib/mobile/attachment-store.js +157 -0
- package/lib/mobile/attachments.js +7 -1
- package/lib/mobile/channels.js +3 -1
- package/lib/mobile/extension-dev.js +189 -0
- package/lib/mobile/history-derive.js +18 -0
- package/lib/mobile/host-settings.js +14 -3
- package/lib/mobile/local-images.js +57 -1
- package/lib/mobile/model-caps.js +239 -0
- package/lib/mobile/model-meta-table.cjs +21 -0
- package/lib/mobile/model-meta.js +133 -0
- package/lib/mobile/monitor.js +21 -0
- package/lib/mobile/plugins.js +7 -2
- package/lib/mobile/push-notify.js +5 -4
- package/lib/mobile/relay-control.js +17 -4
- package/lib/mobile/session-head.js +78 -0
- package/lib/mobile/session-presets.js +6 -2
- package/lib/mobile/session-send.js +5 -0
- package/lib/mobile/sessions.js +4 -31
- package/lib/mobile/stream.js +10 -0
- package/lib/panel/index.html +11 -11
- package/lib/relay/core.js +3 -0
- package/lib/relay/firewall-probe.js +40 -8
- package/lib/relay/lan-network.cjs +47 -0
- package/lib/relay/pairing-payload.cjs +67 -0
- package/lib/routes/control-plane.js +30 -19
- package/lib/routes/mobile-api.js +19 -3
- package/lib/shared/list-revision.js +19 -1
- package/lib/shared/sse-write.js +73 -0
- package/lib/update-install.cjs +70 -9
- package/package.json +3 -2
package/lib/relay/core.js
CHANGED
|
@@ -49,6 +49,9 @@ function defaultState() {
|
|
|
49
49
|
// 用户手填的直连地址(空 = 没填)。与上面那条同一条口径:也是 schema 里的一项、
|
|
50
50
|
// 也不进 sidecar 配置 —— 它只影响「报给手机的候选」与二维码,不影响隧道怎么连。
|
|
51
51
|
manualLanUrl: '',
|
|
52
|
+
// 用户手填的 IPv6 直连地址(空 = 没填)。与上一条同一条口径:schema 里的一项、不进
|
|
53
|
+
// sidecar 配置,只影响「报给手机的 IPv6 候选」与二维码。
|
|
54
|
+
manualIpv6Url: '',
|
|
52
55
|
// 首次接入引导走完没有。**刻意不进 RemoteControlSettingsSchema**:它不是一项
|
|
53
56
|
// 「远程控制设置」,改它不该触发 sidecar 重配(那条链上每加一个字段都要同步改
|
|
54
57
|
// schema、worker 的 allowed 数组与 buildConfig,漏一处就抛 unknown field)。
|
|
@@ -73,6 +73,12 @@ function asArray(value) {
|
|
|
73
73
|
return value === null || value === undefined ? [] : [value];
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/** PowerShell 报上来的条数:读不到是 `null`,不是 0 —— 「一条都没有」和「没读到」是两件事。 */
|
|
77
|
+
function countOf(value) {
|
|
78
|
+
const number = Number(value);
|
|
79
|
+
return Number.isFinite(number) && number >= 0 ? Math.floor(number) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
76
82
|
/**
|
|
77
83
|
* 解析 Windows 那份报告(脚本见 `WINDOWS_SCRIPT`)。
|
|
78
84
|
*
|
|
@@ -95,6 +101,9 @@ function parseWindowsReport(text) {
|
|
|
95
101
|
profile: String(item && item.profile ? item.profile : ''),
|
|
96
102
|
})),
|
|
97
103
|
activeProfiles: asArray(profileObject.activeProfiles).map((item) => String(item)),
|
|
104
|
+
// 这两个计数只用来区分「一条放行规则都没有」和「规则压根没读出来」,见 `decideWindowsAllows`。
|
|
105
|
+
filters: countOf(profileObject.filters),
|
|
106
|
+
rules: countOf(profileObject.rules),
|
|
98
107
|
};
|
|
99
108
|
}
|
|
100
109
|
|
|
@@ -125,11 +134,16 @@ function windowsRuleApplies(profileField, activeProfiles) {
|
|
|
125
134
|
*
|
|
126
135
|
* 顺序:显式 Block 优先 → 有生效中的 Allow → 防火墙开着却没有规则 = 默认拒绝(这是
|
|
127
136
|
* 第一次弹框点了取消之后的样子)→ 防火墙关着 = 不挡。
|
|
137
|
+
*
|
|
138
|
+
* 只有「确实一条这个程序的规则都没有」才走默认拒绝。查到了过滤器却一条规则都没关联上,
|
|
139
|
+
* 是查询出了问题,回 `null`(查不到)—— 报「挡住了」会把人引去关防火墙、加重复规则,
|
|
140
|
+
* 真正的原因反而被盖住。
|
|
128
141
|
*/
|
|
129
142
|
function decideWindowsAllows(report) {
|
|
130
143
|
if (!report || !Array.isArray(report.apps)) return null;
|
|
131
144
|
const enabled = report.profiles.some((item) => item.enabled === true);
|
|
132
145
|
if (!enabled) return true;
|
|
146
|
+
if (countOf(report.filters) > 0 && countOf(report.rules) === 0) return null;
|
|
133
147
|
const hits = report.apps.filter((item) => item.program && sameWindowsPath(item.program, report.appPath));
|
|
134
148
|
const live = hits.filter((item) => item.enabled !== false && windowsRuleApplies(item.profile, report.activeProfiles));
|
|
135
149
|
if (live.some((item) => /block/i.test(item.action))) return false;
|
|
@@ -150,17 +164,32 @@ function decideWindowsAllows(report) {
|
|
|
150
164
|
* 机器上,系统界面显示的防火墙开着,按本地库读出来三个 profile 可能全是「关」
|
|
151
165
|
* —— 用户看到的「实际防火墙开着、自检说关着」就是这一条;反过来,组策略下发的
|
|
152
166
|
* 放行规则也会被漏掉、报成「挡住」。
|
|
167
|
+
* 4. 从**应用过滤器**反查规则:过滤器对象上的 `Name` 恒为空(规则 ID 不在那儿),拿它当
|
|
168
|
+
* 规则名去查,一条都查不到 —— 于是「明明放行了」被报成「挡住了」。正确的是问「这个
|
|
169
|
+
* 过滤器属于哪几条规则」(`-AssociatedNetFirewallApplicationFilter`),见下面注释。
|
|
170
|
+
* 5. `$ErrorActionPreference = "Stop"`:查询本身失败(权限、服务没起)时必须**整个失败**。
|
|
171
|
+
* PowerShell 默认只是往错误流写一行接着往下跑,那样循环一条不转,结果跟「这台没有
|
|
172
|
+
* 放行规则」一模一样 —— 正是误报的来源。
|
|
153
173
|
*/
|
|
154
174
|
const WINDOWS_SCRIPT = [
|
|
175
|
+
'$ErrorActionPreference = "Stop";',
|
|
155
176
|
'$path = $env:DSH_FIREWALL_PROGRAM;',
|
|
156
177
|
'$profiles = @(Get-NetFirewallProfile -PolicyStore ActiveStore | ForEach-Object { @{ name = "$($_.Name)"; enabled = [bool]$_.Enabled } });',
|
|
157
|
-
|
|
178
|
+
// 读不到当前接入的网络类别不算失败:`windowsRuleApplies` 会按「管」算,没必要因此整条查不到。
|
|
179
|
+
'$active = @();',
|
|
180
|
+
'try { $active = @(Get-NetConnectionProfile | ForEach-Object { $c = "$($_.NetworkCategory)"; if ($c -eq "DomainAuthenticated") { "Domain" } else { $c } }) } catch {}',
|
|
158
181
|
'$apps = @();',
|
|
159
|
-
'
|
|
160
|
-
'
|
|
161
|
-
'
|
|
182
|
+
'$filters = @(Get-NetFirewallApplicationFilter -PolicyStore ActiveStore -Program $path);',
|
|
183
|
+
'$rules = 0;',
|
|
184
|
+
'foreach ($f in $filters) {',
|
|
185
|
+
' foreach ($r in @($f | Get-NetFirewallRule -PolicyStore ActiveStore)) {',
|
|
186
|
+
' $rules = $rules + 1;',
|
|
187
|
+
// 出站规则跟「手机连进来」无关;混进来会把「没有入站放行」看成「有规则」。
|
|
188
|
+
' if ("$($r.Direction)" -ne "Inbound") { continue; }',
|
|
189
|
+
' $apps += @{ program = "$($f.Program)"; action = "$($r.Action)"; enabled = ("$($r.Enabled)" -eq "True"); profile = "$($r.Profile)" }',
|
|
190
|
+
' }',
|
|
162
191
|
'}',
|
|
163
|
-
'@{ profiles = $profiles; apps = $apps; activeProfiles = $active } | ConvertTo-Json -Depth 6 -Compress',
|
|
192
|
+
'@{ profiles = $profiles; apps = $apps; filters = $filters.Count; rules = $rules; activeProfiles = $active } | ConvertTo-Json -Depth 6 -Compress',
|
|
164
193
|
].join(' ');
|
|
165
194
|
|
|
166
195
|
/** 默认执行器:只跑写死的可执行文件与参数,超时杀进程,输出按 UTF-8 收。 */
|
|
@@ -220,6 +249,8 @@ async function probeFirewall(options = {}) {
|
|
|
220
249
|
});
|
|
221
250
|
if (result.error === 'NO_COMMAND') return unknownFirewall(execPath, 'no-command');
|
|
222
251
|
if (result.error === 'TIMEOUT') return unknownFirewall(execPath, 'timeout');
|
|
252
|
+
// 脚本是 `$ErrorActionPreference = "Stop"`:查询本身失败就没有 JSON,如实报查不到。
|
|
253
|
+
if (result.error) return unknownFirewall(execPath, 'parse-failed');
|
|
223
254
|
let report = null;
|
|
224
255
|
try {
|
|
225
256
|
report = parseWindowsReport(result.stdout);
|
|
@@ -227,11 +258,12 @@ async function probeFirewall(options = {}) {
|
|
|
227
258
|
return unknownFirewall(execPath, 'parse-failed');
|
|
228
259
|
}
|
|
229
260
|
if (report.profiles.length === 0) return unknownFirewall(execPath, 'parse-failed');
|
|
230
|
-
const
|
|
261
|
+
const allowsApp = decideWindowsAllows({ ...report, appPath: execPath });
|
|
262
|
+
if (allowsApp === null) return unknownFirewall(execPath, 'parse-failed');
|
|
231
263
|
return {
|
|
232
264
|
supported: true,
|
|
233
|
-
enabled,
|
|
234
|
-
allowsApp
|
|
265
|
+
enabled: report.profiles.some((item) => item.enabled === true),
|
|
266
|
+
allowsApp,
|
|
235
267
|
appPath: execPath,
|
|
236
268
|
reason: null,
|
|
237
269
|
};
|
|
@@ -369,6 +369,51 @@ function withManualCandidate(list, value) {
|
|
|
369
369
|
return [...rest.filter(item => item && item.url !== manual.url), manual]
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
+
/**
|
|
373
|
+
* 用户**自己填的** IPv6 直连地址(设置项 `manualIpv6Url`)→ 候选。
|
|
374
|
+
*
|
|
375
|
+
* 与上面那条同一件事,只是拨的是 IPv6:网卡枚举只报这台机器此刻真实挂着的全球单播地址,
|
|
376
|
+
* 人可能想固定成「运营商分给我、但网卡上暂时没挂」的那一条(临时地址轮换、前缀换了、网卡
|
|
377
|
+
* 拔了),它只能由人手填。值形如 `http://[240e:338::998]:18787`,与 `ipv6Candidates` 产出的
|
|
378
|
+
* `url` 同形 —— 地址在 URL 里必须带方括号。
|
|
379
|
+
*
|
|
380
|
+
* 校验口径与 `manualCandidate` 一致:只挡明显不能用的(空、非 http(s)、解析不出主机),
|
|
381
|
+
* 另外要求主机确实是一个 IPv6 字面量:这一份只报 IPv6,混进域名 / v4 会让 App 那条独立
|
|
382
|
+
* 通道拿到一条不属于它的地址。**不校验是不是全球单播**:填内网 / ULA 地址也是人自己的
|
|
383
|
+
* 选择,能不能通由手机实测给出,宿主只负责如实上报。
|
|
384
|
+
*/
|
|
385
|
+
function manualIpv6Candidate(value) {
|
|
386
|
+
const url = typeof value === 'string' ? value.trim().replace(/\/+$/, '') : ''
|
|
387
|
+
if (!url || !/^https?:\/\/[^\s/]+/i.test(url)) return null
|
|
388
|
+
let host = ''
|
|
389
|
+
try { host = new URL(url).hostname } catch { return null }
|
|
390
|
+
// URL.hostname 对 IPv6 是带方括号的,而候选的 `address` 与网卡那份同形:裸地址。
|
|
391
|
+
const address = String(host).replace(/^\[/, '').replace(/\]$/, '')
|
|
392
|
+
if (ipv6Number(address) === null) return null
|
|
393
|
+
return { url, address, interface: 'manual', family: 'IPv6' }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* 候选 = 网卡那几条照旧 + 用户填的那条**排在最后**当兜底。**只追加、不替换**:手填的值
|
|
398
|
+
* 再准也只是多一条,网卡枚举出来的候选一条都不删 —— 它写错了、或者那条地址暂时不可达时,
|
|
399
|
+
* 手机还得能顺着网卡地址摸回这台机器。值不合法时原样退回网卡候选,不让一条坏配置把整份
|
|
400
|
+
* 候选清空。
|
|
401
|
+
*
|
|
402
|
+
* 去重按 `ipv6Number` 解出来的数值,与 `ipv6Candidates` / `collectEndpoints` 同一条口径:
|
|
403
|
+
* `[240E::1]` 与 `[240e:0:0:0:0:0:0:1]` 是同一个地址的两种写法,按 URL 逐字比会留下两条、
|
|
404
|
+
* 让手机对同一个地址各探一次。
|
|
405
|
+
*
|
|
406
|
+
* 排在最后与 `withManualCandidate` 同一个理由:手填的是兜底,同一个 Wi-Fi 下网卡地址快
|
|
407
|
+
* 一个数量级,排最前就会按「先回话的赢」永远选中手填那条。
|
|
408
|
+
*/
|
|
409
|
+
function withManualIpv6Candidate(list, value) {
|
|
410
|
+
const manual = manualIpv6Candidate(value)
|
|
411
|
+
const rest = Array.isArray(list) ? list : []
|
|
412
|
+
if (!manual) return rest
|
|
413
|
+
const key = String(ipv6Number(manual.address))
|
|
414
|
+
return [...rest.filter(item => item && String(ipv6Number(item.address)) !== key), manual]
|
|
415
|
+
}
|
|
416
|
+
|
|
372
417
|
/**
|
|
373
418
|
* 把用户选中的那条排到最前面(面板上的「手机优先用这条」,存在设置项 `preferredLanAddress`)。
|
|
374
419
|
*
|
|
@@ -429,6 +474,8 @@ module.exports = {
|
|
|
429
474
|
advertisedCandidates,
|
|
430
475
|
manualCandidate,
|
|
431
476
|
withManualCandidate,
|
|
477
|
+
manualIpv6Candidate,
|
|
478
|
+
withManualIpv6Candidate,
|
|
432
479
|
orderCandidates,
|
|
433
480
|
phoneCandidates,
|
|
434
481
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 配对二维码的载荷与原文。
|
|
5
|
+
*
|
|
6
|
+
* 面板(`/api/relay/pairing`)与终端命令(`lib/cli/pair.cjs`)都从这里取,**只此一份**:
|
|
7
|
+
* 两处各拼一遍的话,迟早出现「面板里的码能配上、终端里打出来的配不上」这种最难查的分叉。
|
|
8
|
+
*
|
|
9
|
+
* 只做「把 state 折成一张卡里要说的东西」,不认识 HTTP、不认识渲染:二维码图片怎么画由
|
|
10
|
+
* 调用方决定(面板要 SVG、终端要字符画)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const {
|
|
15
|
+
advertisedCandidates,
|
|
16
|
+
phoneCandidates,
|
|
17
|
+
ipv6Candidates,
|
|
18
|
+
withManualIpv6Candidate,
|
|
19
|
+
} = require('./lan-network.cjs');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} state relay.json 那份状态(至少要有 name/instanceId/pairingCode/pairingTicket/relayUrl/lanEnabled)
|
|
23
|
+
* @param {number} [lanPort] 实绑的局域网端口(首选端口被占时会与配置里那个不同)
|
|
24
|
+
* @returns {{payload: object, text: string}} `text` 就是二维码里那串 `dshpair://`
|
|
25
|
+
*/
|
|
26
|
+
function pairingPayload(state, lanPort) {
|
|
27
|
+
const port = Number.isInteger(lanPort) ? lanPort : state.lanPort;
|
|
28
|
+
// 面板上选过的「手机优先用这条」在这里兑现:只挪到最前,其余继续当兜底(App 按顺序挨个试)。
|
|
29
|
+
const candidates = state.lanEnabled
|
|
30
|
+
? phoneCandidates(advertisedCandidates(port), { preferred: state.preferredLanAddress, manual: state.manualLanUrl })
|
|
31
|
+
: [];
|
|
32
|
+
const urls = candidates.map(candidate => candidate.url).filter(Boolean);
|
|
33
|
+
const relayUrl = state.relayUrl || '';
|
|
34
|
+
const payload = {
|
|
35
|
+
type: 'dsh-pair',
|
|
36
|
+
version: 1,
|
|
37
|
+
name: state.name || os.hostname(),
|
|
38
|
+
instanceId: state.instanceId,
|
|
39
|
+
code: state.pairingCode,
|
|
40
|
+
// 协议版本与一次性票据(§8.3(1))。
|
|
41
|
+
//
|
|
42
|
+
// `proto: 2` 由**宿主**写、也由宿主生成,中转没有任何机会改写它 —— 所以「新 App 被
|
|
43
|
+
// 中转骗回明文」这个问题从一开始就不存在。`ticket` 是 32 字节随机数,手机拿它派生
|
|
44
|
+
// 配对密钥;二维码是屏幕对摄像头,这条通道完全绕开中转。
|
|
45
|
+
//
|
|
46
|
+
// 老 App 只取自己认识的那几个字段,多出来的两个会被忽略 —— 一张二维码同时服务新旧两种。
|
|
47
|
+
proto: 2,
|
|
48
|
+
ticket: state.pairingTicket || '',
|
|
49
|
+
relayUrl,
|
|
50
|
+
lanUrls: urls,
|
|
51
|
+
// 候选列表随二维码一起带走:App 配对后即使换了网段,也能按这份列表
|
|
52
|
+
// 依次重试而不用先回到公网中转去问。
|
|
53
|
+
lanCandidates: urls,
|
|
54
|
+
// IPv6 直连候选(理想情况下是手机**不在同一个 Wi-Fi 里**时唯一能拨到的地址):不参与上面
|
|
55
|
+
// 那份的排序,顺序由 App 侧拿它和公网中转实测比出来;直连监听没开就没有这条通道。
|
|
56
|
+
// 用户手填的那条缀在最后当兜底(只追加、不删网卡候选),见 `withManualIpv6Candidate`。
|
|
57
|
+
ipv6Candidates: state.lanEnabled
|
|
58
|
+
? withManualIpv6Candidate(ipv6Candidates(port), state.manualIpv6Url).map(candidate => candidate.url).filter(Boolean)
|
|
59
|
+
: [],
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
payload,
|
|
63
|
+
text: `dshpair://v1?data=${encodeURIComponent(JSON.stringify(payload))}`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { pairingPayload };
|
|
@@ -32,7 +32,7 @@ const { canListen } = require('../relay/port-allocator.cjs');
|
|
|
32
32
|
// ⚠️ 面板里**能被选中的**那份仍然只能是 `lan`(没有私网时才退 `public`)——它与
|
|
33
33
|
// `advertisedCandidates` 同口径。IPv6 那份只**显示**、不进那个选择:它由手机侧实测择优
|
|
34
34
|
// (和公网中转比速度),做成可选中等于骗人(同 A3-40 的刻意设计)。
|
|
35
|
-
const { lanCandidates, publicCandidates, ipv6Candidates } = require('../relay/lan-network.cjs');
|
|
35
|
+
const { lanCandidates, publicCandidates, ipv6Candidates, withManualIpv6Candidate } = require('../relay/lan-network.cjs');
|
|
36
36
|
// 「手机连不上时哪一环断了」这份连接自检:本机只读、不改任何东西(口径见
|
|
37
37
|
// connection-check.js 与 firewall-probe.js 的文件头)。
|
|
38
38
|
const { createConnectionCheck } = require('../relay/connection-check.js');
|
|
@@ -373,7 +373,19 @@ function registerControlPlaneRoutes(deps) {
|
|
|
373
373
|
const preferred = typeof state.preferredLanAddress === 'string' ? state.preferredLanAddress : ''
|
|
374
374
|
// `manual` 是用户手填的直连地址(内网穿透域名):网卡扫不出来,所以单列一格让面板回显与修改。
|
|
375
375
|
const manual = typeof state.manualLanUrl === 'string' ? state.manualLanUrl : ''
|
|
376
|
-
|
|
376
|
+
// `manualIpv6` 是同一件事的 IPv6 版:面板回显它,同时把它缀进 `ipv6` 候选最后
|
|
377
|
+
// (只追加、不删网卡候选;排最后的取舍见 lan-network.cjs 的 `withManualIpv6Candidate`)。
|
|
378
|
+
const manualIpv6 = typeof state.manualIpv6Url === 'string' ? state.manualIpv6Url : ''
|
|
379
|
+
sendJson(res, 200, {
|
|
380
|
+
ok: true,
|
|
381
|
+
port,
|
|
382
|
+
preferred,
|
|
383
|
+
manual,
|
|
384
|
+
manualIpv6,
|
|
385
|
+
lan: lanCandidates(port),
|
|
386
|
+
public: publicCandidates(port),
|
|
387
|
+
ipv6: withManualIpv6Candidate(ipv6Candidates(port), manualIpv6),
|
|
388
|
+
})
|
|
377
389
|
}) })
|
|
378
390
|
/**
|
|
379
391
|
* 公用中转候选的测速(`POST /api/relay/probe-relays`,body `{ urls: string[] }`)。
|
|
@@ -528,22 +540,14 @@ function registerControlPlaneRoutes(deps) {
|
|
|
528
540
|
return sendJson(res, 200, { ok: true, installed: true, faults: guard.faults(), stats: guard.stats() })
|
|
529
541
|
}) })
|
|
530
542
|
registerRoute({ kind: 'exact', path: '/api/relay/pairing', handler: localOnly('GET', async (req, res) => {
|
|
531
|
-
//
|
|
543
|
+
// 二维码里装的是一次性票据 —— 用它能派生出整条链路的密钥,所以它本来只该在本机屏幕上
|
|
544
|
+
// 出现。但「本机屏幕」这个判据在**服务器部署**上是空的:那台机器只有 SSH,人永远够
|
|
545
|
+
// 不到它,于是这台机器上的用户连一次都配不上(2026-11-01 现场)。
|
|
532
546
|
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
// 不到这张码 —— 窗口本身是从浏览器发起的,`trustedLocalUi` 对它不成立。
|
|
538
|
-
//
|
|
539
|
-
// 拿不到就回 423 而不是 403:面板那边据此显示「请在这台电脑上打开面板」,
|
|
540
|
-
// 也**不给**「点这里强制显示」——那等于把刚堵上的口子又开一遍。
|
|
541
|
-
if (typeof trustedLocalUi !== 'function' || !trustedLocalUi(req)) {
|
|
542
|
-
return sendJson(res, 423, {
|
|
543
|
-
ok: false,
|
|
544
|
-
error: { code: 'pairing_local_only', message: '配对二维码只在本机屏幕显示,请在这台电脑上打开面板' },
|
|
545
|
-
})
|
|
546
|
-
}
|
|
547
|
+
// 所以这条跟别的管理动作走同一套规矩:外面那层两小时窗口(`WEB_ACCESS_ROUTES`)说了算。
|
|
548
|
+
// 窗口由本机 DSH 设置页亲手开,开了之后远程本来就能踢掉全部设备、改中转地址、重启
|
|
549
|
+
// sidecar —— 单独把二维码挡在窗口外面既不自洽,也让那颗按钮名不副实(用户拍板)。
|
|
550
|
+
// 窗口没开时远程照旧拿不到码;判据不再自己造第二套。
|
|
547
551
|
if (!state.enabled) {
|
|
548
552
|
sendJson(res, 409, { ok: false, error: '请先启用远程控制并保存配置' })
|
|
549
553
|
return
|
|
@@ -616,8 +620,8 @@ function registerControlPlaneRoutes(deps) {
|
|
|
616
620
|
* 只回「面板画一行需要的东西」,**不回 `hash` / `hostToken`** —— 那些是凭据材料,
|
|
617
621
|
* 面板只是显示谁连着、什么时候来的。
|
|
618
622
|
*
|
|
619
|
-
*
|
|
620
|
-
*
|
|
623
|
+
* 与二维码那条同一条规矩:都在 `WEB_ACCESS_ROUTES` 里,两小时窗口打开时远程能用是它的
|
|
624
|
+
* 用途。设备列表里没有密钥材料;二维码那边多一层考虑,见 `/api/relay/pairing` 的注释。
|
|
621
625
|
*
|
|
622
626
|
* `serverNow` 是给面板算「几分钟前」用的:手机与宿主的时钟可能差得多,但面板只需要
|
|
623
627
|
* 一个同一台机器上的基准。
|
|
@@ -683,6 +687,10 @@ function registerControlPlaneRoutes(deps) {
|
|
|
683
687
|
if (typeof body?.manualLanUrl === 'string' && body.manualLanUrl.length <= 200) {
|
|
684
688
|
patch.manualLanUrl = body.manualLanUrl.trim().replace(/\/+$/, '')
|
|
685
689
|
}
|
|
690
|
+
// 用户手填的 IPv6 直连地址(`http://[240e::1]:18787`)。空串合法 = 清掉,口径同上一条。
|
|
691
|
+
if (typeof body?.manualIpv6Url === 'string' && body.manualIpv6Url.length <= 200) {
|
|
692
|
+
patch.manualIpv6Url = body.manualIpv6Url.trim().replace(/\/+$/, '')
|
|
693
|
+
}
|
|
686
694
|
await settings.update(patch)
|
|
687
695
|
// 换中转地址时宿主会**先在旧隧道上登记迁移、再重建**(顺序在 worker 的 configure 里,
|
|
688
696
|
// 见 lib/relay/migrate.js)—— 面板与手机走的是同一条下发路径,不需要各自实现一遍。
|
|
@@ -986,6 +994,9 @@ const WEB_ACCESS_ROUTES = new Set([
|
|
|
986
994
|
'/api/relay/panel/setup',
|
|
987
995
|
'/api/relay/panel/settings',
|
|
988
996
|
'/api/relay/faults',
|
|
997
|
+
// 配对二维码:里面是能派生整条链路密钥的一次性票据,所以它只在两小时窗口开着时给网页看
|
|
998
|
+
// ——窗口由本机设置页亲手开,服务器部署没有「本机屏幕」可看,这条是它唯一的出口。
|
|
999
|
+
'/api/relay/pairing',
|
|
989
1000
|
'/api/relay/toggle',
|
|
990
1001
|
'/api/relay/regenerate',
|
|
991
1002
|
'/api/relay/onboarded',
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
const { sendJson } = require('../http/routing.js');
|
|
15
|
-
const { orderCandidates, withManualCandidate } = require('../relay/lan-network.cjs');
|
|
15
|
+
const { orderCandidates, withManualCandidate, withManualIpv6Candidate } = require('../relay/lan-network.cjs');
|
|
16
16
|
|
|
17
17
|
const sessions = require('../mobile/sessions.js');
|
|
18
18
|
const sessionModelOptions = require('../mobile/session-model-options.js');
|
|
@@ -32,6 +32,7 @@ const archiveClean = require('../mobile/archive-clean.js');
|
|
|
32
32
|
const sessionLog = require('../mobile/session-log.js');
|
|
33
33
|
const hostSettings = require('../mobile/host-settings.js');
|
|
34
34
|
const channels = require('../mobile/channels.js');
|
|
35
|
+
const modelCaps = require('../mobile/model-caps.js');
|
|
35
36
|
const deepSeekAccount = require('../mobile/deepseek-account.js');
|
|
36
37
|
const skills = require('../mobile/skills.js');
|
|
37
38
|
const mobileCredentials = require('../mobile/credentials.js');
|
|
@@ -51,6 +52,7 @@ const pairing = require('../mobile/pairing.js');
|
|
|
51
52
|
const { credentialsEntry } = require('../shared/credentials-entry.js');
|
|
52
53
|
const { handleCredentialUpgrade } = require('../mobile/credential-upgrade.js');
|
|
53
54
|
const extensions = require('../mobile/extensions.js');
|
|
55
|
+
const extensionDev = require('../mobile/extension-dev.js');
|
|
54
56
|
const sessionDelivery = require('../mobile/session-delivery.js');
|
|
55
57
|
// 体检接口要自报身份:版本号只有一个来源,就是包自己的 package.json(与 relay/status.js 同)。
|
|
56
58
|
const pkg = require('../../package.json');
|
|
@@ -200,8 +202,9 @@ function registerMobileRoutes(deps) {
|
|
|
200
202
|
//
|
|
201
203
|
// 这份是手机配对之后**唯一**能拿到新 IPv6 地址的地方:二维码里带的候选只在配对那一刻
|
|
202
204
|
// 是真值,而隐私地址按小时轮换、运营商换前缀之后旧的那条就拨不通了。少回这一格,
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
+
// 手机上那条通道就只能靠配对当时的旧地址活着。手填的 IPv6 那条缀在**最后**当兜底
|
|
206
|
+
// (只追加、不删网卡候选),理由与上面手填的直连地址同一条。
|
|
207
|
+
const ipv6 = state.lanEnabled ? withManualIpv6Candidate(safeIpv6Candidates(port), state.manualIpv6Url) : []
|
|
205
208
|
const lanUpdatedAt = getSidecarSnapshot()?.lan?.updatedAt || Date.now()
|
|
206
209
|
sendJson(res, 200, {
|
|
207
210
|
ok: true,
|
|
@@ -348,6 +351,11 @@ function registerMobileRoutes(deps) {
|
|
|
348
351
|
// 渠道管理(见 mobile/channels.js):只对「本机自建」那一类(`llm-pi-ai` 里的路由)
|
|
349
352
|
// 提供增删;插件 / 宿主注册的提供方只列出来,一个写操作都不做。获取模型由宿主代问
|
|
350
353
|
// (端点常在内网,手机直连未必通),与桌面面板的 `discoverModels` 同一条路。
|
|
354
|
+
// 模型能力配置(见 mobile/model-caps.js):公开参数快照(App 拉一次、缓存后在 App 端搜索)
|
|
355
|
+
// 与「把选中的那份写回这条自建渠道」。写只到 `providers.<id>`,与上面的渠道保存同一条
|
|
356
|
+
// 边界 —— 内置渠道不在这份清单里,apply 一律 409。
|
|
357
|
+
route('/api/mobile/v1/model-meta', modelCaps.handleModelMeta)
|
|
358
|
+
route('/api/mobile/v1/model-meta/apply', modelCaps.handleModelMetaApply)
|
|
351
359
|
route('/api/mobile/v1/channels', channels.handleChannels)
|
|
352
360
|
route('/api/mobile/v1/channels/discover', channels.handleChannelDiscover)
|
|
353
361
|
route('/api/mobile/v1/channels/save', channels.handleChannelSave)
|
|
@@ -457,6 +465,14 @@ function registerMobileRoutes(deps) {
|
|
|
457
465
|
route('/api/mobile/v1/extension/card-state', extensions.handleExtensionCardState)
|
|
458
466
|
route('/api/mobile/v1/extension/action', extensions.handleExtensionAction)
|
|
459
467
|
|
|
468
|
+
// 扩展**开发包**:作者放在电脑上的本地扩展目录(`$DSH_HOME/extension-dev`),手机上直接
|
|
469
|
+
// 列包、取文件,不用每改一行都发一次分发系统(见 mobile/extension-dev.js)。
|
|
470
|
+
//
|
|
471
|
+
// 能力面只多「读这一个固定目录」:slug 与包内相对路径拼出来的路径,取文件前先 resolve
|
|
472
|
+
// 再判前缀。仍然不暴露 shell、系统设置、任意文件读写 —— 不接受任意路径,也不给写。
|
|
473
|
+
route('/api/mobile/v1/extension/dev/packages', extensionDev.handleDevPackages)
|
|
474
|
+
route('/api/mobile/v1/extension/dev/file', extensionDev.handleDevFile)
|
|
475
|
+
|
|
460
476
|
// 分享文件的落地通道(App 把手机分享进来的文件分片传上来,见 mobile/uploads.js)。
|
|
461
477
|
// 手机端没有「把文件交给宿主」的路:图片能进 session.send 的 base64 内容块,其它格式
|
|
462
478
|
// 一律进不去。四条端点只做「收字节 → 落盘」:默认落到 `~/.dsh/mobile-inbox/<日期>/<文件名>`,
|
|
@@ -58,4 +58,22 @@ function unchangedListBody(body, rev) {
|
|
|
58
58
|
return { ok: true, unchanged: true, revision: body.revision, generatedAt: body.generatedAt };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
/**
|
|
62
|
+
* 请求串里的 `?rev=`(条件刷新的客户端指纹);没有、为空或解析不出来都返回空串。
|
|
63
|
+
*
|
|
64
|
+
* 空串在 `unchangedListBody` 那里是「照常回整份」,所以老 App 不带这个参数时行为不变。
|
|
65
|
+
* 路径匹配发生在宿主那一侧、早就忽略了查询串,这里只负责取值。
|
|
66
|
+
*
|
|
67
|
+
* @param {{ url?: string }} req
|
|
68
|
+
*/
|
|
69
|
+
function revisionOf(req) {
|
|
70
|
+
const raw = req && typeof req.url === 'string' ? req.url : '';
|
|
71
|
+
if (!raw) return '';
|
|
72
|
+
try {
|
|
73
|
+
return (new URL(raw, 'http://x').searchParams.get('rev') || '').trim();
|
|
74
|
+
} catch {
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { stableRevision, listRevision, unchangedListBody, revisionOf };
|
package/lib/shared/sse-write.js
CHANGED
|
@@ -136,6 +136,53 @@ function takePadding(res, written, padBytes, padEveryMs) {
|
|
|
136
136
|
return padding;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* 合帧:把短时间内到达的多条草稿增量并成一帧发。
|
|
141
|
+
*
|
|
142
|
+
* 为什么要它:流式回复一秒几十条 `assistant/chunk`,一帧一条就是几十次「加密 + 写套接字 +
|
|
143
|
+
* 客户端逐帧解密与解析」。并成一帧之后这几步按批走,客户端那边只需要认出 `type:'batch'`。
|
|
144
|
+
*
|
|
145
|
+
* 只有客户端在流地址上带 `frameBatch=1` 时才启用(老 App 行为一个字不变)。只并
|
|
146
|
+
* `assistant/chunk` —— 心跳、审批、排队/任务、`resync-required` 这些帧没有「晚 40ms 无所谓」
|
|
147
|
+
* 的余地,它们必须立刻发,且发之前先把已攒的增量按原顺序放出去。
|
|
148
|
+
*/
|
|
149
|
+
const BATCH_WINDOW_MS = 40;
|
|
150
|
+
const BATCH_MAX_EVENTS = 8;
|
|
151
|
+
const BATCHABLE_TYPES = new Set(['assistant/chunk']);
|
|
152
|
+
/** 一条连接一份攒批状态;连接收掉后随 res 一起被回收。 */
|
|
153
|
+
const BATCH_STATES = new WeakMap();
|
|
154
|
+
|
|
155
|
+
/** 这一帧能不能进合帧(看内层事件类型;回放路径可能带一层 `session/event` 外壳)。 */
|
|
156
|
+
function isBatchablePayload(payload) {
|
|
157
|
+
if (!payload || typeof payload !== 'object') return false;
|
|
158
|
+
const inner = payload.type === 'session/event' && payload.event ? payload.event : payload;
|
|
159
|
+
return BATCHABLE_TYPES.has(inner.type);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function batchStateOf(res) {
|
|
163
|
+
let state = BATCH_STATES.get(res);
|
|
164
|
+
if (!state) {
|
|
165
|
+
state = { items: [], timer: null, options: null };
|
|
166
|
+
BATCH_STATES.set(res, state);
|
|
167
|
+
}
|
|
168
|
+
return state;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 把攒下的增量立刻发成一条 `batch` 帧(幂等:没东西就直接成功)。 */
|
|
172
|
+
function flushFrameBatch(res) {
|
|
173
|
+
const state = BATCH_STATES.get(res);
|
|
174
|
+
if (!state) return { ok: true };
|
|
175
|
+
if (state.timer) {
|
|
176
|
+
clearTimeout(state.timer);
|
|
177
|
+
state.timer = null;
|
|
178
|
+
}
|
|
179
|
+
if (state.items.length === 0) return { ok: true };
|
|
180
|
+
const events = state.items.splice(0);
|
|
181
|
+
const options = state.options || {};
|
|
182
|
+
// 合帧只做一次:`{batch:false}` 保证不会再被攒回去。
|
|
183
|
+
return writeSse(res, { type: 'batch', events }, { ...options, batch: false });
|
|
184
|
+
}
|
|
185
|
+
|
|
139
186
|
/**
|
|
140
187
|
* 写一条 SSE 记录。
|
|
141
188
|
*
|
|
@@ -170,7 +217,29 @@ function writeSse(res, payload, options = {}) {
|
|
|
170
217
|
* 字段,客户端要靠 `id:` 回填 `Last-Event-ID`,而 `id:` 里的 sessionId 本来就在 URL 上。
|
|
171
218
|
*/
|
|
172
219
|
sealFrame = null,
|
|
220
|
+
/**
|
|
221
|
+
* 这条连接要不要合帧(客户端在流地址上带 `frameBatch=1` 才给真)。
|
|
222
|
+
* 见 `flushFrameBatch` / `BATCHABLE_TYPES`。
|
|
223
|
+
*/
|
|
224
|
+
batch = false,
|
|
173
225
|
} = options;
|
|
226
|
+
if (batch) {
|
|
227
|
+
const state = batchStateOf(res);
|
|
228
|
+
state.options = options;
|
|
229
|
+
if (!isBatchablePayload(payload)) {
|
|
230
|
+
// 控制帧 / 心跳 / 收尾:先把攒着的增量按原顺序发完,自己再立刻发。
|
|
231
|
+
const flushed = flushFrameBatch(res);
|
|
232
|
+
if (!flushed.ok) return flushed;
|
|
233
|
+
} else {
|
|
234
|
+
state.items.push(payload);
|
|
235
|
+
if (state.items.length >= BATCH_MAX_EVENTS) return flushFrameBatch(res);
|
|
236
|
+
if (!state.timer) {
|
|
237
|
+
state.timer = setTimeout(() => { state.timer = null; flushFrameBatch(res); }, BATCH_WINDOW_MS);
|
|
238
|
+
if (typeof state.timer.unref === 'function') state.timer.unref();
|
|
239
|
+
}
|
|
240
|
+
return { ok: true, deferred: true };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
174
243
|
const buffered = bufferedBytesOf(res);
|
|
175
244
|
if (buffered > SSE_CLOSE_BUFFERED_BYTES) return { ok: false };
|
|
176
245
|
if (buffered > SSE_SHED_BUFFERED_BYTES && shedWhenBuffered(payload)) return { ok: true, shed: true }; const id = stableEventId(payload, frame, namespace);
|
|
@@ -229,6 +298,10 @@ module.exports = {
|
|
|
229
298
|
bufferedBytesOf,
|
|
230
299
|
stableEventId,
|
|
231
300
|
writeSse,
|
|
301
|
+
flushFrameBatch,
|
|
302
|
+
isBatchablePayload,
|
|
303
|
+
BATCH_WINDOW_MS,
|
|
304
|
+
BATCH_MAX_EVENTS,
|
|
232
305
|
waitForDrain,
|
|
233
306
|
shedWithoutSeq,
|
|
234
307
|
};
|
package/lib/update-install.cjs
CHANGED
|
@@ -36,6 +36,9 @@
|
|
|
36
36
|
* 现场不修的话,这台机器之后每一次更新都会撞同一堵墙。
|
|
37
37
|
*
|
|
38
38
|
* 只动 profile 的依赖树;找不到包管理器就如实失败;装完以盘上版本号为准,退出码 0 不算装上。
|
|
39
|
+
*
|
|
40
|
+
* Windows 上包管理器是 `.cmd`(pnpm 装出来的就是个批处理外壳),不是可执行文件:直接
|
|
41
|
+
* `spawn` 必然 `EINVAL`,得让 cmd 去跑它,见 `cmdInvocation`。
|
|
39
42
|
*/
|
|
40
43
|
|
|
41
44
|
const fs = require('node:fs');
|
|
@@ -184,6 +187,7 @@ async function profileRegistry({
|
|
|
184
187
|
cwd: profileDir,
|
|
185
188
|
env: { ...childEnv, ...candidate.extraEnv },
|
|
186
189
|
timeoutMs,
|
|
190
|
+
platform,
|
|
187
191
|
});
|
|
188
192
|
if (!result.ok) continue;
|
|
189
193
|
registry = parseRegistryOutput(result.output);
|
|
@@ -456,12 +460,70 @@ function withReason(message, output) {
|
|
|
456
460
|
return reason === '' ? message : `${message}\n${reason}`;
|
|
457
461
|
}
|
|
458
462
|
|
|
463
|
+
/**
|
|
464
|
+
* Windows 上「跑一个 `.cmd` / `.bat`」得先翻译成「让 cmd 去跑它」。
|
|
465
|
+
*
|
|
466
|
+
* 那不是可执行文件,是给 cmd 解释的批处理:`spawn('…\\pnpm.cmd', …)` 在 Windows 上必然失败
|
|
467
|
+
* (`EINVAL`,路径形态再不对一点是 `EFTYPE`)—— 包管理器一次都没跑起来,而用户只看到一句
|
|
468
|
+
* 「更新失败」。所以这里翻成 `cmd /d /s /c "<整行>"`。
|
|
469
|
+
*
|
|
470
|
+
* 引号是这个坑里唯一的细活:`/s /c` 会剥掉命令行的**最外一层**引号,所以整行外面包一层、
|
|
471
|
+
* 每个参数各自再包一层 —— 剥掉外层之后,带空格的路径(`C:\Program Files\…`)才认得出来。
|
|
472
|
+
* 参数仍然是数组、由这里拼,不经过 shell 的变量展开;`windowsVerbatimArguments` 让 Node
|
|
473
|
+
* 原样把这一行递过去,别再替我们补一层引号。
|
|
474
|
+
*/
|
|
475
|
+
function cmdInvocation(command, args, { env = process.env } = {}) {
|
|
476
|
+
const comspec = typeof env.ComSpec === 'string' && env.ComSpec.trim() !== '' ? env.ComSpec.trim() : 'cmd.exe';
|
|
477
|
+
const quote = (value) => `"${String(value).replace(/"/g, '""')}"`;
|
|
478
|
+
const line = [command, ...args].map(quote).join(' ');
|
|
479
|
+
return {
|
|
480
|
+
command: comspec,
|
|
481
|
+
args: ['/d', '/s', '/c', `"${line}"`],
|
|
482
|
+
// 这些值只有路径、包名和版本号,没有 `%`,不用担心 cmd 把它当变量展开。
|
|
483
|
+
options: { windowsVerbatimArguments: true },
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* 超时杀进程树。
|
|
489
|
+
*
|
|
490
|
+
* Windows 上 `child.kill()` 只杀我们自己起的 cmd.exe,包管理器还活着继续改 profile 的依赖
|
|
491
|
+
* 树 —— 界面说「超时失败」,盘还在被人写。所以那边按整棵树杀。
|
|
492
|
+
*/
|
|
493
|
+
function killTree(child, platform) {
|
|
494
|
+
if (platform === 'win32' && typeof child.pid === 'number') {
|
|
495
|
+
try {
|
|
496
|
+
// 挂一个空的 error 监听:taskkill 不在 PATH 上时,没人收的 error 事件会把宿主进程带崩。
|
|
497
|
+
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true });
|
|
498
|
+
killer.on('error', () => {});
|
|
499
|
+
return;
|
|
500
|
+
} catch {
|
|
501
|
+
// taskkill 起不来就退化成杀自己那一层。
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
child.kill('SIGKILL');
|
|
506
|
+
} catch {
|
|
507
|
+
// 已经退出了。
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
459
511
|
/** 跑一条命令,收 stdout+stderr 的尾巴。超时或非零退出码都如实带出来。 */
|
|
460
|
-
function run(command, args, { cwd, env, timeoutMs, spawnImpl = spawn }) {
|
|
512
|
+
function run(command, args, { cwd, env, timeoutMs, platform = process.platform, spawnImpl = spawn }) {
|
|
513
|
+
// `.cmd` / `.bat` 交给 cmd 解释;`.exe` 与别的平台照旧直接起。
|
|
514
|
+
const invocation = platform === 'win32' && /\.(?:cmd|bat)$/i.test(command)
|
|
515
|
+
? cmdInvocation(command, args, { env })
|
|
516
|
+
: { command, args, options: {} };
|
|
461
517
|
return new Promise(resolve => {
|
|
462
518
|
let child;
|
|
463
519
|
try {
|
|
464
|
-
child = spawnImpl(command, args, {
|
|
520
|
+
child = spawnImpl(invocation.command, invocation.args, {
|
|
521
|
+
cwd,
|
|
522
|
+
env,
|
|
523
|
+
windowsHide: true,
|
|
524
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
525
|
+
...invocation.options,
|
|
526
|
+
});
|
|
465
527
|
} catch (error) {
|
|
466
528
|
resolve({ ok: false, code: null, output: '', error: String(error?.message || error) });
|
|
467
529
|
return;
|
|
@@ -473,13 +535,7 @@ function run(command, args, { cwd, env, timeoutMs, spawnImpl = spawn }) {
|
|
|
473
535
|
};
|
|
474
536
|
child.stdout?.on('data', collect);
|
|
475
537
|
child.stderr?.on('data', collect);
|
|
476
|
-
const timer = setTimeout(() =>
|
|
477
|
-
try {
|
|
478
|
-
child.kill('SIGKILL');
|
|
479
|
-
} catch {
|
|
480
|
-
// 已经退出了。
|
|
481
|
-
}
|
|
482
|
-
}, timeoutMs);
|
|
538
|
+
const timer = setTimeout(() => killTree(child, platform), timeoutMs);
|
|
483
539
|
child.on('error', error => {
|
|
484
540
|
clearTimeout(timer);
|
|
485
541
|
resolve({ ok: false, code: null, output, error: String(error?.message || error) });
|
|
@@ -552,6 +608,7 @@ async function installLatest({
|
|
|
552
608
|
cwd: profileDir,
|
|
553
609
|
env: { ...childEnv, ...hostInvocation.env },
|
|
554
610
|
timeoutMs: 20000,
|
|
611
|
+
platform,
|
|
555
612
|
});
|
|
556
613
|
if (probe.ok) {
|
|
557
614
|
return { command: hostInvocation.command, prefix: hostInvocation.args, manager: 'pnpm', kind: 'host', env: hostInvocation.env };
|
|
@@ -597,6 +654,7 @@ async function installLatest({
|
|
|
597
654
|
cwd: profileDir,
|
|
598
655
|
env: { ...childEnv, ...picked.env, ...(picked.manager === 'pnpm' ? POLICY_ENV : {}) },
|
|
599
656
|
timeoutMs,
|
|
657
|
+
platform,
|
|
600
658
|
});
|
|
601
659
|
|
|
602
660
|
let result = await runManager([]);
|
|
@@ -788,6 +846,9 @@ module.exports = {
|
|
|
788
846
|
installLatestIfNeeded,
|
|
789
847
|
installedVersion,
|
|
790
848
|
declaredSource,
|
|
849
|
+
// 「Windows 上怎么起包管理器」在本机跑不起来,守卫只能验拼好的那一行,所以这两样露出来。
|
|
850
|
+
cmdInvocation,
|
|
851
|
+
runCommand: run,
|
|
791
852
|
toolSearchDirs,
|
|
792
853
|
resolveExecutable,
|
|
793
854
|
normalizeInvocation,
|