weflow-cli 1.8.2 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +62 -26
- package/CHANGELOG.md +1290 -0
- package/OPERATIONS.md +144 -11
- package/README.en.md +20 -4
- package/README.md +30 -5
- package/bin/weflow-cli-electron.cjs +2 -0
- package/bin/weflow-cli.ts +996 -118
- package/dist/bin/weflow-cli.js +1038 -146
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/http.d.ts +14 -0
- package/dist/mcp-server/http.d.ts.map +1 -0
- package/dist/mcp-server/http.js +84 -0
- package/dist/mcp-server/http.js.map +1 -0
- package/dist/mcp-server/httpConfig.d.ts +50 -0
- package/dist/mcp-server/httpConfig.d.ts.map +1 -0
- package/dist/mcp-server/httpConfig.js +104 -0
- package/dist/mcp-server/httpConfig.js.map +1 -0
- package/dist/mcp-server/index.d.ts +13 -1
- package/dist/mcp-server/index.d.ts.map +1 -1
- package/dist/mcp-server/index.js +58 -374
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/dbPathService.d.ts +12 -0
- package/dist/src/core/dbPathService.d.ts.map +1 -1
- package/dist/src/core/dbPathService.js +20 -3
- package/dist/src/core/dbPathService.js.map +1 -1
- package/dist/src/panel/server.d.ts.map +1 -1
- package/dist/src/panel/server.js +61 -1
- package/dist/src/panel/server.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts +69 -0
- package/dist/src/services/assistantEval.d.ts.map +1 -1
- package/dist/src/services/assistantEval.js +270 -8
- package/dist/src/services/assistantEval.js.map +1 -1
- package/dist/src/services/assistantScenes.d.ts +81 -0
- package/dist/src/services/assistantScenes.d.ts.map +1 -0
- package/dist/src/services/assistantScenes.js +322 -0
- package/dist/src/services/assistantScenes.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +33 -4
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +102 -6
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantSkills.d.ts +94 -0
- package/dist/src/services/assistantSkills.d.ts.map +1 -0
- package/dist/src/services/assistantSkills.js +330 -0
- package/dist/src/services/assistantSkills.js.map +1 -0
- package/dist/src/services/assistantTools.d.ts +34 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +931 -88
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/assistantTrace.d.ts +4 -0
- package/dist/src/services/assistantTrace.d.ts.map +1 -1
- package/dist/src/services/assistantTrace.js +9 -0
- package/dist/src/services/assistantTrace.js.map +1 -1
- package/dist/src/services/configService.d.ts +9 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +5 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/messageQuery.d.ts +18 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -1
- package/dist/src/services/messageQuery.js +29 -0
- package/dist/src/services/messageQuery.js.map +1 -1
- package/dist/src/services/outboundSend.d.ts +53 -0
- package/dist/src/services/outboundSend.d.ts.map +1 -0
- package/dist/src/services/outboundSend.js +90 -0
- package/dist/src/services/outboundSend.js.map +1 -0
- package/dist/src/services/pythonBridge.d.ts.map +1 -1
- package/dist/src/services/pythonBridge.js +1 -0
- package/dist/src/services/pythonBridge.js.map +1 -1
- package/dist/src/services/wechatClient.d.ts +125 -0
- package/dist/src/services/wechatClient.d.ts.map +1 -0
- package/dist/src/services/wechatClient.js +344 -0
- package/dist/src/services/wechatClient.js.map +1 -0
- package/dist/src/services/wechatMedia.d.ts +44 -0
- package/dist/src/services/wechatMedia.d.ts.map +1 -0
- package/dist/src/services/wechatMedia.js +155 -0
- package/dist/src/services/wechatMedia.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +11 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +55 -11
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/types.d.ts +4 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/utils/python.js +2 -2
- package/dist/src/utils/python.js.map +1 -1
- package/docs/BRANCHES.md +37 -0
- package/docs/CONTACT_DB_SCHEMA.md +1085 -0
- package/docs/DECISIONS.md +1624 -0
- package/docs/EXTENDING.md +98 -10
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +6 -0
- package/docs/HEALTH-CHECK.md +12 -7
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
- package/docs/MCP.md +58 -4
- package/docs/PROJECT_STATE.md +180 -11
- package/docs/RELEASING.md +115 -1
- package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
- package/logo.png +0 -0
- package/mcp-server/http.ts +108 -0
- package/mcp-server/httpConfig.ts +126 -0
- package/mcp-server/index.ts +359 -681
- package/package.json +5 -3
- package/resources/js/graph3d/NOTICE.txt +35 -0
- package/resources/js/graph3d/d3-binarytree.min.js +2 -0
- package/resources/js/graph3d/d3-dispatch.min.js +2 -0
- package/resources/js/graph3d/d3-force-3d.min.js +2 -0
- package/resources/js/graph3d/d3-octree.min.js +2 -0
- package/resources/js/graph3d/d3-quadtree.min.js +2 -0
- package/resources/js/graph3d/d3-timer.min.js +2 -0
- package/resources/js/graph3d/layout.mjs +63 -0
- package/resources/js/graph3d/three.min.js +6 -0
- package/resources/js/graph3d/viewer2d.js +448 -0
- package/resources/panel/ball-position.cjs +48 -2
- package/resources/panel/index.html +18 -4
- package/resources/panel/main.cjs +137 -5
- package/resources/panel/mascot-base.png +0 -0
- package/resources/panel/mascot-blink-1.png +0 -0
- package/resources/panel/mascot-blink-2.png +0 -0
- package/resources/panel/mascot-iris.png +0 -0
- package/resources/panel/mascot-lift-down-1.png +0 -0
- package/resources/panel/mascot-lift-down-2.png +0 -0
- package/resources/panel/mascot-lift-down-3.png +0 -0
- package/resources/panel/mascot-lift-down-4.png +0 -0
- package/resources/panel/mascot-lift-down-5.png +0 -0
- package/resources/panel/mascot-lift-down-6.png +0 -0
- package/resources/panel/mascot-lift-up-1.png +0 -0
- package/resources/panel/mascot-lift-up-2.png +0 -0
- package/resources/panel/mascot-lift-up-3.png +0 -0
- package/resources/panel/mascot-lift-up-4.png +0 -0
- package/resources/panel/mascot-lift-up-5.png +0 -0
- package/resources/panel/mascot-lift-up-6.png +0 -0
- package/resources/panel/mascot-peek.png +0 -0
- package/resources/panel/mascot-tickle-1.png +0 -0
- package/resources/panel/mascot-tickle-2.png +0 -0
- package/resources/panel/mascot-tickle-3.png +0 -0
- package/resources/panel/mascot-tickle-4.png +0 -0
- package/resources/panel/panel.css +205 -59
- package/resources/panel/preload.cjs +26 -0
- package/resources/panel/renderer.js +646 -17
- package/scripts/_utils.py +1203 -931
- package/scripts/backfill_articles.py +202 -21
- package/scripts/biz_daily.py +26 -34
- package/scripts/bonds.py +331 -0
- package/scripts/chat_notes.py +561 -470
- package/scripts/chat_stats.py +8 -7
- package/scripts/check_readme_commands.py +426 -0
- package/scripts/compile_wiki.py +187 -59
- package/scripts/contact_schema.py +339 -0
- package/scripts/data/wechat_emoji.json +225 -0
- package/scripts/export_chat_html.py +3 -1
- package/scripts/fav_notes.py +4 -1
- package/scripts/graph_2d.py +151 -0
- package/scripts/graph_3d.py +646 -0
- package/scripts/health_check.py +163 -26
- package/scripts/mcp_bridge.py +6 -7
- package/scripts/nt_decrypt.py +3 -3
- package/scripts/panel_frames.py +683 -0
- package/scripts/pipeline.py +21 -10
- package/scripts/quality_eval.py +5 -0
- package/scripts/rag_chat.py +33 -16
- package/scripts/reply_debt.py +26 -6
- package/scripts/semantic_search.py +130 -22
- package/scripts/vault_rag.py +42 -16
- package/scripts/vault_search.py +44 -8
- package/scripts/wechat_emoticon.py +6 -0
- package/scripts/wechat_voice.py +9 -0
- package/scripts/wiki_lint.py +124 -24
- package/src/core/dbPathService.ts +22 -4
- package/src/panel/server.ts +58 -1
- package/src/services/assistantEval.ts +323 -12
- package/src/services/assistantScenes.ts +342 -0
- package/src/services/assistantService.ts +107 -6
- package/src/services/assistantSkills.ts +364 -0
- package/src/services/assistantTools.ts +2452 -1640
- package/src/services/assistantTrace.ts +9 -0
- package/src/services/configService.ts +415 -403
- package/src/services/messageQuery.ts +29 -0
- package/src/services/outboundSend.ts +126 -0
- package/src/services/pythonBridge.ts +1 -0
- package/src/services/wechatClient.ts +380 -0
- package/src/services/wechatMedia.ts +166 -0
- package/src/services/wechatMessageService.ts +62 -11
- package/src/types.ts +4 -0
- package/src/utils/python.ts +2 -2
- package/dist/src/utils/wechatEmoji.d.ts +0 -5
- package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
- package/dist/src/utils/wechatEmoji.js +0 -35
- package/dist/src/utils/wechatEmoji.js.map +0 -1
|
@@ -5,6 +5,35 @@ export interface MessageTimeRange {
|
|
|
5
5
|
to?: number
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* 这三种形状的输入会被 `resolveTalker` **原样返回**(见 `src/utils/talkerUtils.ts`),
|
|
10
|
+
* 于是拼错的 ID 会一路走到「取不到消息」,和「这条会话在窗口内确实没有消息」长得一模一样。
|
|
11
|
+
* 只看形状,不碰 IO —— 判定所需的名单由调用方传进来。
|
|
12
|
+
*/
|
|
13
|
+
export function looksLikeTalkerId(input: string): boolean {
|
|
14
|
+
const value = String(input || '')
|
|
15
|
+
return value.startsWith('wxid_') || value.includes('@chatroom') || value.includes('@openim')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 空结果时补的一句话:**只在"看起来是 ID、但会话与联系人都没有它"时**才给。
|
|
20
|
+
* 返回空串表示没什么可补的 —— 名字形状的输入本来就由 `resolveTalker` 报错,认识的 ID
|
|
21
|
+
* 取不到消息是正常结果。
|
|
22
|
+
*
|
|
23
|
+
* 这里**不改退出码、也不改 `success`**:既有契约里「没有消息」不是错误(脚本与 MCP 依赖它,
|
|
24
|
+
* 见 `docs/HEALTH-CHECK.md` 的那一行),所以只把歧义说出来,不动语义。
|
|
25
|
+
*/
|
|
26
|
+
export function emptyMessagesNote(
|
|
27
|
+
input: string,
|
|
28
|
+
known: { sessionUsernames: Iterable<string>; contactUsernames: Iterable<string> },
|
|
29
|
+
): string {
|
|
30
|
+
if (!looksLikeTalkerId(input)) return ''
|
|
31
|
+
for (const id of known.sessionUsernames) if (id === input) return ''
|
|
32
|
+
for (const id of known.contactUsernames) if (id === input) return ''
|
|
33
|
+
return `这个 ID 既不在会话里、也不在联系人里:${input}` +
|
|
34
|
+
' —— 先确认有没有拼错(weflow-cli sessions / weflow-cli contacts)'
|
|
35
|
+
}
|
|
36
|
+
|
|
8
37
|
export async function collectMessagesInRange(
|
|
9
38
|
fetchPage: (limit: number, offset: number) => Promise<Message[]>,
|
|
10
39
|
limit: number,
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 出站发送的**那一份策略**:黑名单 → 白名单 → 限速,以及"每次尝试都写审计"。
|
|
3
|
+
*
|
|
4
|
+
* 为什么抽成一份:这条链是一条**安全策略**,而 2026-10-01 起有了第二条发送路
|
|
5
|
+
* (`draft --pick N --send`,见 D-063)。两份写同一件事迟早分叉,而这里分叉的后果具体是
|
|
6
|
+
* "有一条路绕过了白名单" —— 悄悄多出一个能发给任何人的入口。
|
|
7
|
+
*
|
|
8
|
+
* **它不面向模型。** 助手与 MCP 的工具表里没有任何发送工具(`docs/EXTENDING.md`:
|
|
9
|
+
* *sending is structurally unreachable from a model-driven path*)。这个模块只被**人在命令行上
|
|
10
|
+
* 显式驱动**的命令调用,而且第二条路还要求先点名"发第几条"。
|
|
11
|
+
*
|
|
12
|
+
* 一条实测出来的硬约束写在错误信息里:发送需要**对方的 context_token**,也就是对方得先给你
|
|
13
|
+
* 发过消息。没有它,"主动发给任何人"这条路本来就不通。
|
|
14
|
+
*/
|
|
15
|
+
import { configService } from './configService.js'
|
|
16
|
+
import { whitelistService } from './whitelistService.js'
|
|
17
|
+
import { WechatMessageService } from './wechatMessageService.js'
|
|
18
|
+
|
|
19
|
+
export type OutboundKind = 'text' | 'image' | 'file'
|
|
20
|
+
|
|
21
|
+
export interface OutboundAttempt {
|
|
22
|
+
wxid: string
|
|
23
|
+
displayName: string
|
|
24
|
+
kind: OutboundKind
|
|
25
|
+
/** 审计与预览里那一行(文本截断后的样子,或 `[image] 文件名 (字节)`) */
|
|
26
|
+
preview: string
|
|
27
|
+
/** 纯文本内容(kind 为 text 时用;媒体走 mediaPath) */
|
|
28
|
+
text?: string
|
|
29
|
+
mediaPath?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface OutboundPolicy {
|
|
33
|
+
windowMs: number
|
|
34
|
+
max: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type OutboundRefusalCode =
|
|
38
|
+
| 'INVALID_RATE_LIMIT'
|
|
39
|
+
| 'TARGET_BLOCKED'
|
|
40
|
+
| 'TARGET_NOT_ALLOWED'
|
|
41
|
+
| 'RATE_LIMITED'
|
|
42
|
+
|
|
43
|
+
export interface OutboundRefusal {
|
|
44
|
+
code: OutboundRefusalCode
|
|
45
|
+
error: string
|
|
46
|
+
/** 限速时带上窗口与计数,好让调用方原样报出来 */
|
|
47
|
+
rateLimit?: { count: number; max: number; windowMs: number }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function auditRefusal(attempt: OutboundAttempt, error: string): void {
|
|
51
|
+
whitelistService.auditSend({
|
|
52
|
+
timestamp: Date.now(), action: 'send', targetWxid: attempt.wxid,
|
|
53
|
+
targetName: attempt.displayName, kind: attempt.kind, success: false,
|
|
54
|
+
preview: attempt.preview, error,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 速率参数合法性。`send` 与 `draft --send` 用的是同一条规则。 */
|
|
59
|
+
export function parseOutboundPolicy(windowMs: number, max: number): OutboundPolicy | OutboundRefusal {
|
|
60
|
+
if (!Number.isInteger(windowMs) || windowMs < 1000 || windowMs > 3_600_000
|
|
61
|
+
|| !Number.isInteger(max) || max < 1 || max > 100) {
|
|
62
|
+
return { code: 'INVALID_RATE_LIMIT', error: 'rate-window 必须为 1000-3600000,rate-max 必须为 1-100' }
|
|
63
|
+
}
|
|
64
|
+
return { windowMs, max }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface OutboundRate { count: number; max: number; windowMs: number }
|
|
68
|
+
export type OutboundDecision =
|
|
69
|
+
| { ok: true; rate: OutboundRate }
|
|
70
|
+
| { ok: false; refusal: OutboundRefusal }
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 发送前的三道闸。拒绝时**先写审计**再返回原因("每次尝试都留痕"是策略的一部分,不只是成功的那些)。
|
|
74
|
+
*
|
|
75
|
+
* 放行时把**当时的速率计数**一起带回去 —— 调用方要拿它写进预览(原来那一条命令里
|
|
76
|
+
* 是先 `checkRateLimit` 再复用同一个对象)。少一次重复查询,也免得两处看到的数不一样。
|
|
77
|
+
*/
|
|
78
|
+
export function authorizeOutbound(attempt: OutboundAttempt, policy: OutboundPolicy): OutboundDecision {
|
|
79
|
+
if (whitelistService.isBlocked(attempt.wxid)) {
|
|
80
|
+
auditRefusal(attempt, 'blocked by blacklist')
|
|
81
|
+
return { ok: false, refusal: { code: 'TARGET_BLOCKED', error: '目标在黑名单中' } }
|
|
82
|
+
}
|
|
83
|
+
if (!whitelistService.isAllowed(attempt.wxid)) {
|
|
84
|
+
// **白名单是这一层真正的边界**:不在名单上的人,任何一条命令行都发不出去。
|
|
85
|
+
// 这一条**不写审计** —— 与黑名单那条不一样,原实现就是如此,保持一致。
|
|
86
|
+
return { ok: false, refusal: { code: 'TARGET_NOT_ALLOWED', error: '目标不在白名单中' } }
|
|
87
|
+
}
|
|
88
|
+
const rate = whitelistService.checkRateLimit(policy.windowMs, policy.max)
|
|
89
|
+
if (!rate.allowed) {
|
|
90
|
+
auditRefusal(attempt, `rate limited (${rate.count}/${rate.max})`)
|
|
91
|
+
return { ok: false, refusal: { code: 'RATE_LIMITED', error: '触发发送速率限制', rateLimit: rate } }
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, rate }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 真的发出去,并按结果写审计。返回 `{success, error}`(不抛)。 */
|
|
97
|
+
export async function performOutbound(attempt: OutboundAttempt): Promise<{ success: boolean; error?: string }> {
|
|
98
|
+
const token = configService.get('wechatOcToken')
|
|
99
|
+
const service = new WechatMessageService({ token })
|
|
100
|
+
let success = false
|
|
101
|
+
let error: string | undefined
|
|
102
|
+
try {
|
|
103
|
+
if (attempt.kind === 'image' && attempt.mediaPath) {
|
|
104
|
+
success = await service.sendImage(attempt.wxid, attempt.mediaPath)
|
|
105
|
+
} else if (attempt.kind === 'file' && attempt.mediaPath) {
|
|
106
|
+
success = await service.sendFile(attempt.wxid, attempt.mediaPath)
|
|
107
|
+
} else {
|
|
108
|
+
success = await service.sendText(attempt.wxid, attempt.text ?? '')
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
error = '消息通道调用失败'
|
|
112
|
+
}
|
|
113
|
+
if (!success && !error) {
|
|
114
|
+
// 最像的原因就是缺 context_token —— 说清楚,别让人以为是"发失败"这种含糊的锅
|
|
115
|
+
const hasToken = !!(configService.getContextTokens()[attempt.wxid])
|
|
116
|
+
error = hasToken
|
|
117
|
+
? '发送失败 (接口返回错误, 可能 token 过期)'
|
|
118
|
+
: '缺少 context_token — 需先收到对方一条消息 (或运行 weflow-cli listen 等待对方消息)'
|
|
119
|
+
}
|
|
120
|
+
whitelistService.auditSend({
|
|
121
|
+
timestamp: Date.now(), action: 'send', targetWxid: attempt.wxid,
|
|
122
|
+
targetName: attempt.displayName, kind: attempt.kind, success,
|
|
123
|
+
preview: attempt.preview, error: success ? undefined : error,
|
|
124
|
+
})
|
|
125
|
+
return { success, error }
|
|
126
|
+
}
|
|
@@ -45,6 +45,7 @@ function realRunner(scriptPath: string, args: string[],
|
|
|
45
45
|
options: { timeoutMs: number; stdin?: string; env?: Record<string, string> }): Promise<ScriptRun> {
|
|
46
46
|
return new Promise((resolve, reject) => {
|
|
47
47
|
const child = spawn(getPythonCommand(), [scriptPath, ...args], {
|
|
48
|
+
windowsHide: true,
|
|
48
49
|
// 用户输入走**环境变量**而不是 argv:这是仓库写进测试的隐私纪律(进程列表里看不到正文)
|
|
49
50
|
env: createPythonProcessEnv(options.env ?? {}),
|
|
50
51
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **这台机器上的微信客户端,运行时到底加载的是哪一份。**
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:`check` / `health_check` 一直在报"数据版本 4.x"(那是**数据库格式**),
|
|
5
|
+
* 而"客户端是哪个版本"从来没人报过 —— 出问题时(数据读不出来、客户端起不来)这恰恰是
|
|
6
|
+
* 第一个要问的。
|
|
7
|
+
*
|
|
8
|
+
* **判据来自一份实测的启动器逆向分析**(报告在仓库外),它纠正了一个很自然的错法:
|
|
9
|
+
*
|
|
10
|
+
* - `Weixin.exe` 里**确实**硬编码着一个版本字面量,而且**恰好与本机的版本目录同名** ——
|
|
11
|
+
* 看起来"读它就能知道版本"。但控制流显示它只是**兜底**:主路径读
|
|
12
|
+
* `%APPDATA%\Tencent\xwechat\update\update.data` 里记的目录,只有那次失败才会用到它。
|
|
13
|
+
* ⇒ **读字面量判版本是错的**,哪怕它在这台机器上碰巧对。(报告里那条"2/2 实测"就是先
|
|
14
|
+
* 写错、后被控制流推翻的。)
|
|
15
|
+
* - 正确的判据是**活进程命令行里的 `--user-lib-dir`** —— 那是真的被加载的那个目录。
|
|
16
|
+
* - 而"磁盘上有个版本目录"**不代表它能用**:本机 `D:\WeiXin\4.1.15.11\` 是升级残留,
|
|
17
|
+
* 只剩 `crashpad_handler.exe` 一个文件(**本机实测** 1 个文件 / 2.1 MB;报告 §2.3 记了同一件事)。
|
|
18
|
+
*
|
|
19
|
+
* 所以这个模块只做两件事,且都**不猜**:从进程命令行取实际目录;把一个安装根下的版本目录
|
|
20
|
+
* 分成"能用/不能用"(判据是**有没有 `Weixin.dll`** —— 启动器加载的就是它)。
|
|
21
|
+
* 取不到就是 `null` + 一句 `note`,不拿别的来源顶替。
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'
|
|
24
|
+
import { join, win32 } from 'node:path'
|
|
25
|
+
import { execFile } from 'node:child_process'
|
|
26
|
+
import { promisify } from 'node:util'
|
|
27
|
+
|
|
28
|
+
const execFileAsync = promisify(execFile)
|
|
29
|
+
|
|
30
|
+
/** 一个 `Weixin.exe` 进程的画像(只留判版本要用的两个字段)。 */
|
|
31
|
+
export interface WechatClientProcess {
|
|
32
|
+
pid: number
|
|
33
|
+
exe: string
|
|
34
|
+
/** 命令行里的 `--user-lib-dir`;进程没带就是 null(**逆向报告实测**:`--type=xplayer` 那个不带)。 */
|
|
35
|
+
loadedDir: string | null
|
|
36
|
+
/** 命令行里的 `--wechat-files-path`:客户端自己说数据目录在哪(小程序那组进程会带)。 */
|
|
37
|
+
dataDir: string | null
|
|
38
|
+
/** 命令行里的 `--client_version`:客户端自己的版本 token(十进制,如 `0xF2541F0D`)。 */
|
|
39
|
+
versionToken: string | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 版本的判据与结论 —— 只跟"哪个进程加载了哪个目录"有关,读不到**别的来源**。 */
|
|
43
|
+
export interface WechatVersionFacts {
|
|
44
|
+
/** 本机有没有在跑的 `Weixin.exe`(没跑 ⇒ 版本未知,不是"没问题")。 */
|
|
45
|
+
running: boolean
|
|
46
|
+
/** 去重后的实际加载目录,按出现顺序。 */
|
|
47
|
+
loadedDirs: string[]
|
|
48
|
+
/** 版本号 = 加载目录的名字;**恰好一个**目录时才给,否则 null。 */
|
|
49
|
+
version: string | null
|
|
50
|
+
/** `version` 为 null 时说清为什么。 */
|
|
51
|
+
note: string
|
|
52
|
+
/** 安装根下**不能用的**版本目录(没有 `Weixin.dll`)。 */
|
|
53
|
+
unusableVersionDirs: Array<{ dir: string; files: number }>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface WechatClientFacts extends WechatVersionFacts {
|
|
57
|
+
/** 客户端自报的构建 token(`--client_version`,如 `0xF2541F0D`)—— 与目录名并列的**第二个来源**。 */
|
|
58
|
+
versionTokens: string[]
|
|
59
|
+
/** 进程命令行里的 `--wechat-files-path`:**客户端自己说**数据目录在哪。 */
|
|
60
|
+
dataDirs: string[]
|
|
61
|
+
/** `update\update.data` 在不在(在 ⇒ 加载路径可能不由命令行决定;见 `caveats`)。 */
|
|
62
|
+
updateData: { path: string; present: boolean }
|
|
63
|
+
/** 客户端崩溃记录:只报条数与最近时间,**不读内容**。 */
|
|
64
|
+
crashRecords: { dir: string; reports: number; records: number; newest: string | null }
|
|
65
|
+
/** 读这个版本时**必须一起说出去**的不确定(目前只有一条:`update.data` 在场)。 */
|
|
66
|
+
caveats: string[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 版本目录名:`4.1.15.13` 这种。别的一律不当版本目录(不猜)。 */
|
|
70
|
+
const VERSION_DIR = /^\d+(\.\d+)+$/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* PowerShell 一行取所有 `Weixin.exe` 的 pid / 可执行路径 / 命令行。
|
|
74
|
+
*
|
|
75
|
+
* 与 `keyService.isProcessRunning` 用同一种调法(`-NoProfile -NonInteractive`):这台机器上
|
|
76
|
+
* 那是仓库既有做法,不引第二种。`ConvertTo-Json` 在只有一个结果时会给对象而不是数组,
|
|
77
|
+
* 所以下面 `parseProcessRows` 两种都收。
|
|
78
|
+
*/
|
|
79
|
+
// **两个镜像名都要问**:`--user-lib-dir` 在 `Weixin.exe` 那组上,而 `--wechat-files-path`
|
|
80
|
+
// 只在 `WeChatAppEx.exe`(小程序运行时那组)的命令行上(**本机实测**:拿它去筛 `Weixin.exe` 会一条都取不到,
|
|
81
|
+
// 于是"客户端自报数据目录"这一项**永远是空的**而看不出来)。版本那一支只认 `Weixin.exe`(见 `summarize`)。
|
|
82
|
+
const PS_LIST = "$p = Get-CimInstance Win32_Process -Filter (\"Name='Weixin.exe' or Name='WeChatAppEx.exe'\") | "
|
|
83
|
+
+ 'Select-Object ProcessId, ExecutablePath, CommandLine; '
|
|
84
|
+
+ 'if ($p) { $p | ConvertTo-Json -Compress }'
|
|
85
|
+
|
|
86
|
+
export function parseProcessRows(raw: string): WechatClientProcess[] {
|
|
87
|
+
const text = String(raw || '').trim()
|
|
88
|
+
if (!text) return []
|
|
89
|
+
let data: unknown
|
|
90
|
+
try {
|
|
91
|
+
data = JSON.parse(text)
|
|
92
|
+
} catch {
|
|
93
|
+
return []
|
|
94
|
+
}
|
|
95
|
+
const rows = Array.isArray(data) ? data : [data]
|
|
96
|
+
const out: WechatClientProcess[] = []
|
|
97
|
+
for (const row of rows) {
|
|
98
|
+
if (!row || typeof row !== 'object') continue
|
|
99
|
+
const r = row as Record<string, unknown>
|
|
100
|
+
const pid = Number(r.ProcessId)
|
|
101
|
+
if (!Number.isFinite(pid)) continue
|
|
102
|
+
const commandLine = String(r.CommandLine || '')
|
|
103
|
+
out.push({
|
|
104
|
+
pid,
|
|
105
|
+
exe: String(r.ExecutablePath || ''),
|
|
106
|
+
loadedDir: userLibDir(commandLine),
|
|
107
|
+
dataDir: wechatFilesPath(commandLine),
|
|
108
|
+
versionToken: clientVersionToken(commandLine),
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 从一条命令行里取 `--user-lib-dir` 的值。**两种写法都认**(`=` 与空格、带引号与不带),
|
|
116
|
+
* 取不到返回 null。
|
|
117
|
+
*
|
|
118
|
+
* 注意这个参数**不在 `Weixin.exe` 的字符串表里**(**逆向报告实测**:0 命中)—— 它是被加载的 DLL
|
|
119
|
+
* 自己解析的。也就是说:只有**进程**能告诉我们它,二进制里搜不到。
|
|
120
|
+
*/
|
|
121
|
+
export function userLibDir(commandLine: string): string | null {
|
|
122
|
+
const m = /--user-lib-dir\s*=\s*(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(String(commandLine || ''))
|
|
123
|
+
if (!m) return null
|
|
124
|
+
const value = (m[1] || m[2] || m[3] || '').trim().replace(/["']/g, '')
|
|
125
|
+
return value || null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 从命令行里取 `--wechat-files-path`:**客户端自己说数据目录在哪**。
|
|
130
|
+
*
|
|
131
|
+
* 用处在排查:它和配置里的数据目录不一致时,说明两边指向的不是同一份库(本机数据目录
|
|
132
|
+
* 还有 D 盘软链,同一份库两条路径)。只报出来,**不拿它去改配置**。
|
|
133
|
+
*/
|
|
134
|
+
export function wechatFilesPath(commandLine: string): string | null {
|
|
135
|
+
const m = /--wechat-files-path\s*=\s*(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(String(commandLine || ''))
|
|
136
|
+
if (!m) return null
|
|
137
|
+
const value = (m[1] || m[2] || m[3] || '').trim().replace(/["']/g, '')
|
|
138
|
+
return value || null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 命令行里的 `--client_version`:客户端**自己的版本 token**(十进制)。
|
|
143
|
+
*
|
|
144
|
+
* 为什么要报它:它是"哪个构建"的**另一个独立来源** —— 版本目录名(`4.1.15.13`)是装载位置,
|
|
145
|
+
* 而这个是客户端自报的构建标识,两者来自不同的地方。顺带:实测本机这个值 `4065599245`
|
|
146
|
+
* 正是 `0xF2541F0D`,也就是那份逆向报告从三处(反编译 / 插件 manifest / UA)定下来的版本号。
|
|
147
|
+
*
|
|
148
|
+
* ⚠️ **不给"版本号"**:token 与三段式版本号之间的编码规则**没人解出来**(报告只确认"旧版
|
|
149
|
+
* `0xF2541D0C` → 新版 `0xF2541F0D`,高 16 位两版都是 `F254`")。所以它只作为**并列的第二来源**
|
|
150
|
+
* 报出去,不拿它去推版本号。
|
|
151
|
+
*/
|
|
152
|
+
export function clientVersionToken(commandLine: string): string | null {
|
|
153
|
+
const m = /--client_version\s*=\s*(\d+)/.exec(String(commandLine || ''))
|
|
154
|
+
if (!m) return null
|
|
155
|
+
const value = Number(m[1])
|
|
156
|
+
if (!Number.isFinite(value) || value <= 0) return null
|
|
157
|
+
return '0x' + value.toString(16).toUpperCase().padStart(8, '0')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 客户端崩溃记录:只数文件、只取最近时间,**不读任何内容**。 */
|
|
161
|
+
export function crashSummary(root: string): WechatClientFacts['crashRecords'] {
|
|
162
|
+
const reports = countFiles(join(root, 'reports'))
|
|
163
|
+
const records = countFiles(join(root, 'records')) + countFiles(join(root, 'metadata'))
|
|
164
|
+
let newest: string | null = null
|
|
165
|
+
try {
|
|
166
|
+
for (const sub of ['reports', 'records', 'metadata']) {
|
|
167
|
+
const dir = join(root, sub)
|
|
168
|
+
if (!existsSync(dir)) continue
|
|
169
|
+
for (const name of readdirSync(dir)) {
|
|
170
|
+
const at = statSync(join(dir, name)).mtime
|
|
171
|
+
if (!newest || at > new Date(newest)) newest = at.toISOString()
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
// 读不到时间就给 null —— 不拿当前时间顶替(那会造出一个假的"最近崩溃")
|
|
176
|
+
}
|
|
177
|
+
return { dir: root, reports, records, newest }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function countFiles(dir: string): number {
|
|
181
|
+
try {
|
|
182
|
+
return readdirSync(dir).filter(name => {
|
|
183
|
+
try {
|
|
184
|
+
return statSync(join(dir, name)).isFile()
|
|
185
|
+
} catch {
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
}).length
|
|
189
|
+
} catch {
|
|
190
|
+
return 0
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 两条路径是不是同一个位置。**按真身比**:本机数据目录有 D 盘软链,同一份库有两条路径。 */
|
|
195
|
+
export function samePath(a: string, b: string): boolean {
|
|
196
|
+
try {
|
|
197
|
+
return realpathSync(a).toLowerCase() === realpathSync(b).toLowerCase()
|
|
198
|
+
} catch {
|
|
199
|
+
return false
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** `%APPDATA%\Tencent\xwechat` —— 崩溃记录与 `update.data` 都在这棵树下。 */
|
|
204
|
+
export function xwechatRoot(appData: string | undefined = process.env.APPDATA): string | null {
|
|
205
|
+
return appData ? join(appData, 'Tencent', 'xwechat') : null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* `update.data` 在场时**必须一起说出去**的那句话。
|
|
210
|
+
*
|
|
211
|
+
* 报告把这条明确留成未解:"若该文件存在,exe 侧第一次 `LoadLibraryExW` 就会用它,
|
|
212
|
+
* 根本不会看命令行 —— 两条路谁优先【未知】"。所以这个函数的存在意义是:
|
|
213
|
+
* 别让新报出去的那个版本在那种机器上**悄悄变成错的**。
|
|
214
|
+
*/
|
|
215
|
+
export function updateDataCaveats(present: boolean): string[] {
|
|
216
|
+
if (!present) return []
|
|
217
|
+
return ['update\\update.data 存在:加载路径**可能**由它决定,命令行给的版本未必是实际加载的那个'
|
|
218
|
+
+ '(那份逆向没解开它的内部格式,也没判定两条路谁优先)']
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 一个版本目录能不能用:**有没有 `Weixin.dll`**(启动器加载的就是它)。 */
|
|
222
|
+
export function versionDirFiles(dir: string): string[] | null {
|
|
223
|
+
try {
|
|
224
|
+
if (!statSync(dir).isDirectory()) return null
|
|
225
|
+
return readdirSync(dir)
|
|
226
|
+
} catch {
|
|
227
|
+
return null
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 把一堆目录名分成"能用的版本目录 / 不能用的 / 与版本无关的"。纯函数,便于测。 */
|
|
232
|
+
export function classifyVersionDirs(entries: Array<{ name: string; files: string[] }>): {
|
|
233
|
+
usable: string[]
|
|
234
|
+
unusable: Array<{ dir: string; files: number }>
|
|
235
|
+
} {
|
|
236
|
+
const usable: string[] = []
|
|
237
|
+
const unusable: Array<{ dir: string; files: number }> = []
|
|
238
|
+
for (const entry of entries) {
|
|
239
|
+
if (!VERSION_DIR.test(entry.name)) continue
|
|
240
|
+
if (entry.files.includes('Weixin.dll')) usable.push(entry.name)
|
|
241
|
+
else unusable.push({ dir: entry.name, files: entry.files.length })
|
|
242
|
+
}
|
|
243
|
+
return { usable, unusable }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 安装根(`D:\WeiXin`)→ 它的版本目录分类。读不出来就当空(不猜)。 */
|
|
247
|
+
export function versionDirsUnder(installRoot: string): ReturnType<typeof classifyVersionDirs> {
|
|
248
|
+
let names: string[]
|
|
249
|
+
try {
|
|
250
|
+
names = readdirSync(installRoot)
|
|
251
|
+
} catch {
|
|
252
|
+
return { usable: [], unusable: [] }
|
|
253
|
+
}
|
|
254
|
+
const entries = names.map(name => ({ name, files: versionDirFiles(join(installRoot, name)) || [] }))
|
|
255
|
+
return classifyVersionDirs(entries)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function summarize(processes: WechatClientProcess[],
|
|
259
|
+
unusable: Array<{ dir: string; files: number }>): WechatVersionFacts {
|
|
260
|
+
// 去重按**规范形**(去掉尾部分隔符、大小写折叠 —— Windows 路径大小写不敏感):
|
|
261
|
+
// 同一个目录写成 `D:\WeiXin\4.1.15.13` 与 `…\4.1.15.13\` 是**一个**目录,
|
|
262
|
+
// 不折算的话会被当成"两个进程指向不同目录"从而拒绝给出版本号。
|
|
263
|
+
// **版本只跟 `Weixin.exe` 有关**:`WeChatAppEx.exe`(小程序那组)与它在同一张进程表里
|
|
264
|
+
// (后者的命令行上带着 `--wechat-files-path`),但它不加载 `Weixin.dll` —— 拿它的
|
|
265
|
+
// "没带 `--user-lib-dir`"去说事会把"客户端在跑、版本取得到"讲成"取不到"。
|
|
266
|
+
// 用 `win32` 而不是平台 `path`:这里拆的**永远是 Windows 路径**(进程表/命令行给的),
|
|
267
|
+
// 而平台 `basename` 在别的平台上会把 `D:\WeiXin\4.1.15.13\Weixin.exe` 当成**一个文件名**
|
|
268
|
+
// —— 于是过滤出 0 个启动器、版本退回"未知",在 Windows 上还看不出来(CI 的 ubuntu 上红了 4 条)。
|
|
269
|
+
// 在 Windows 上 `win32.*` 与平台版完全等价,所以目标机器的行为一点没变。
|
|
270
|
+
const launchers = processes.filter(p => win32.basename(p.exe).toLowerCase() === 'weixin.exe')
|
|
271
|
+
const seen = new Map<string, string>()
|
|
272
|
+
for (const p of launchers) {
|
|
273
|
+
if (!p.loadedDir) continue
|
|
274
|
+
const shown = p.loadedDir.replace(/[\\/]+$/, '')
|
|
275
|
+
if (!shown) continue
|
|
276
|
+
const key = shown.toLowerCase()
|
|
277
|
+
if (!seen.has(key)) seen.set(key, shown)
|
|
278
|
+
}
|
|
279
|
+
const loadedDirs = [...seen.values()]
|
|
280
|
+
let version: string | null = null
|
|
281
|
+
let note = ''
|
|
282
|
+
if (!launchers.length) {
|
|
283
|
+
note = '客户端没在运行 ⇒ 版本未知(`--user-lib-dir` 只有进程知道)'
|
|
284
|
+
} else if (!loadedDirs.length) {
|
|
285
|
+
note = `在跑 ${launchers.length} 个进程,但命令行里都没有 --user-lib-dir`
|
|
286
|
+
} else if (loadedDirs.length > 1) {
|
|
287
|
+
// **不猜**:多个目录各有各的来源(子进程可以指到不同的库),挑一个报出去就是编。
|
|
288
|
+
// 目录本身照旧列出来(那是实测到的),只是不给"版本号"这个单一结论。
|
|
289
|
+
note = `有 ${loadedDirs.length} 个不同的目录在使用,不挑一个报版本`
|
|
290
|
+
} else {
|
|
291
|
+
version = win32.basename(loadedDirs[0])
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
running: processes.length > 0,
|
|
295
|
+
loadedDirs,
|
|
296
|
+
version,
|
|
297
|
+
note,
|
|
298
|
+
unusableVersionDirs: unusable,
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** 默认的执行器:`Get-CimInstance`。测试里换掉它,别真去问系统。 */
|
|
303
|
+
export type PowershellRunner = (command: string) => Promise<string>
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* **只要那一句话**:客户端自报的数据目录。
|
|
307
|
+
*
|
|
308
|
+
* 给 `init` 的数据目录发现用(`DbPathService.autoDetect({ hintedRoots })`)—— 它是唯一
|
|
309
|
+
* **来自客户端自己**的来源,别的都是按目录名猜的。找不到就返回空数组(调用方走老路),
|
|
310
|
+
* **不抛**:这只是个线索,不该让初始化失败。
|
|
311
|
+
*/
|
|
312
|
+
export async function reportedDataDirs(run: PowershellRunner = defaultRunner): Promise<string[]> {
|
|
313
|
+
if (process.platform !== 'win32') return []
|
|
314
|
+
let raw = ''
|
|
315
|
+
try {
|
|
316
|
+
raw = await run(PS_LIST)
|
|
317
|
+
} catch {
|
|
318
|
+
return []
|
|
319
|
+
}
|
|
320
|
+
const dirs = parseProcessRows(raw).map(p => p.dataDir).filter((d): d is string => !!d)
|
|
321
|
+
return [...new Set(dirs.map(d => d.replace(/[\\/]+$/, '')).filter(Boolean))]
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const defaultRunner: PowershellRunner = async (command) => {
|
|
325
|
+
const { stdout } = await execFileAsync('powershell.exe',
|
|
326
|
+
['-NoProfile', '-NonInteractive', '-Command', command], { timeout: 15_000 })
|
|
327
|
+
return String(stdout || '')
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* 汇总:进程画像 + 每个安装根下"不能用的版本目录"。
|
|
332
|
+
*
|
|
333
|
+
* 非 Windows 直接返回"平台不支持"(`Get-CimInstance` 是 Windows 的),**不是**返回"没问题"。
|
|
334
|
+
*/
|
|
335
|
+
export async function detectWechatClient(run: PowershellRunner = defaultRunner): Promise<WechatClientFacts> {
|
|
336
|
+
const root = xwechatRoot()
|
|
337
|
+
const nothing: WechatClientFacts = {
|
|
338
|
+
running: false, loadedDirs: [], version: null, note: '', unusableVersionDirs: [],
|
|
339
|
+
versionTokens: [],
|
|
340
|
+
dataDirs: [], updateData: { path: root ? join(root, 'update', 'update.data') : '', present: false },
|
|
341
|
+
crashRecords: { dir: root ? join(root, 'crashinfo') : '', reports: 0, records: 0, newest: null },
|
|
342
|
+
caveats: [],
|
|
343
|
+
}
|
|
344
|
+
if (process.platform !== 'win32') {
|
|
345
|
+
return { ...nothing, note: `判版本只做了 Windows(当前平台 ${process.platform})` }
|
|
346
|
+
}
|
|
347
|
+
let raw = ''
|
|
348
|
+
try {
|
|
349
|
+
raw = await run(PS_LIST)
|
|
350
|
+
} catch {
|
|
351
|
+
raw = ''
|
|
352
|
+
}
|
|
353
|
+
const processes = parseProcessRows(raw)
|
|
354
|
+
// 安装根从**启动器**的可执行路径推(**别去猜**:注册表与常见路径都不可靠 ——
|
|
355
|
+
// 注册表那条空的是**我们自己**的旧结论,出处是 `docs/DECISIONS.md` 里那句"`WeChat Files`
|
|
356
|
+
// 与注册表 InstallPath 都为空"(讲的是 3.x);**别把它记成那份逆向报告的结论**,
|
|
357
|
+
// 报告里没有注册表这一段)。只取 `Weixin.exe` 的目录:小程序的目录是插件目录,
|
|
358
|
+
// 那里的版本号子目录不是客户端版本。
|
|
359
|
+
const roots = [...new Set(processes
|
|
360
|
+
.filter(p => win32.basename(p.exe).toLowerCase() === 'weixin.exe')
|
|
361
|
+
.map(p => win32.dirname(p.exe))
|
|
362
|
+
.filter(dir => dir && existsSync(dir)))]
|
|
363
|
+
const unusable = roots.flatMap(r => versionDirsUnder(r).unusable)
|
|
364
|
+
const version = summarize(processes, unusable)
|
|
365
|
+
|
|
366
|
+
// `update.data` 在场 ⇒ **它可能才是决定加载路径的那个**,命令行给的版本就未必是加载的那个。
|
|
367
|
+
// 报告把这条明确留成未解("两条路谁优先 —— 【未知】"),所以这里**不猜**:只说存在,
|
|
368
|
+
// 并把不确定性跟着版本一起带出去(否则新报的这个版本会在那种机器上悄悄变成错的)。
|
|
369
|
+
const updateFile = nothing.updateData.path
|
|
370
|
+
const present = !!updateFile && existsSync(updateFile)
|
|
371
|
+
return {
|
|
372
|
+
...version,
|
|
373
|
+
// 客户端自报的构建 token(与"目录名"并列的第二来源)——**不拿它推版本号**,编码规则没人解出来
|
|
374
|
+
versionTokens: [...new Set(processes.map(p => p.versionToken).filter((t): t is string => !!t))],
|
|
375
|
+
dataDirs: [...new Set(processes.map(p => p.dataDir).filter((d): d is string => !!d))],
|
|
376
|
+
updateData: { path: updateFile, present },
|
|
377
|
+
crashRecords: crashSummary(nothing.crashRecords.dir),
|
|
378
|
+
caveats: updateDataCaveats(present),
|
|
379
|
+
}
|
|
380
|
+
}
|