weflow-cli 1.11.1 → 1.13.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +378 -0
  2. package/OPERATIONS.md +359 -11
  3. package/README.md +4 -0
  4. package/bin/weflow-cli.ts +289 -37
  5. package/dist/bin/weflow-cli.js +300 -35
  6. package/dist/bin/weflow-cli.js.map +1 -1
  7. package/dist/src/core/dbPathService.d.ts.map +1 -1
  8. package/dist/src/core/dbPathService.js +5 -0
  9. package/dist/src/core/dbPathService.js.map +1 -1
  10. package/dist/src/core/ntCore.d.ts +12 -1
  11. package/dist/src/core/ntCore.d.ts.map +1 -1
  12. package/dist/src/core/ntCore.js +23 -3
  13. package/dist/src/core/ntCore.js.map +1 -1
  14. package/dist/src/core/sqlcipherCore.d.ts +8 -0
  15. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  16. package/dist/src/core/sqlcipherCore.js +46 -11
  17. package/dist/src/core/sqlcipherCore.js.map +1 -1
  18. package/dist/src/services/assistantEval.d.ts.map +1 -1
  19. package/dist/src/services/assistantEval.js +3 -1
  20. package/dist/src/services/assistantEval.js.map +1 -1
  21. package/dist/src/services/assistantTools.d.ts.map +1 -1
  22. package/dist/src/services/assistantTools.js +25 -7
  23. package/dist/src/services/assistantTools.js.map +1 -1
  24. package/dist/src/services/configService.d.ts +3 -0
  25. package/dist/src/services/configService.d.ts.map +1 -1
  26. package/dist/src/services/configService.js +6 -3
  27. package/dist/src/services/configService.js.map +1 -1
  28. package/dist/src/services/diagnosticReport.d.ts +80 -0
  29. package/dist/src/services/diagnosticReport.d.ts.map +1 -0
  30. package/dist/src/services/diagnosticReport.js +191 -0
  31. package/dist/src/services/diagnosticReport.js.map +1 -0
  32. package/dist/src/services/exportService.d.ts +7 -0
  33. package/dist/src/services/exportService.d.ts.map +1 -1
  34. package/dist/src/services/exportService.js +1 -1
  35. package/dist/src/services/exportService.js.map +1 -1
  36. package/dist/src/services/wechatClient.d.ts +9 -3
  37. package/dist/src/services/wechatClient.d.ts.map +1 -1
  38. package/dist/src/services/wechatClient.js +55 -22
  39. package/dist/src/services/wechatClient.js.map +1 -1
  40. package/docs/DECISIONS.md +662 -1
  41. package/docs/MCP.md +1 -1
  42. package/docs/NEXT_ACTIONS.md +236 -0
  43. package/docs/POSITIONING.md +68 -0
  44. package/docs/PROJECT_STATE.md +119 -17
  45. package/docs/RELEASING.md +14 -0
  46. package/package.json +88 -86
  47. package/scripts/_utils.py +120 -4
  48. package/scripts/backfill_articles.py +64 -14
  49. package/scripts/biz_daily.py +118 -11
  50. package/scripts/daily_task.ps1 +132 -0
  51. package/scripts/export_chat_html.py +79 -31
  52. package/scripts/install_tasks.cmd +32 -0
  53. package/scripts/install_tasks.ps1 +91 -0
  54. package/scripts/jev_client.py +335 -3
  55. package/scripts/nt_decrypt.py +79 -14
  56. package/scripts/pipeline.py +21 -0
  57. package/scripts/recall_eval.py +247 -0
  58. package/scripts/repro_customer_init.cmd +86 -0
  59. package/scripts/semantic_search.py +926 -51
  60. package/src/core/dbPathService.ts +5 -0
  61. package/src/core/ntCore.ts +34 -3
  62. package/src/core/sqlcipherCore.ts +47 -11
  63. package/src/services/assistantEval.ts +3 -1
  64. package/src/services/assistantTools.ts +2471 -2452
  65. package/src/services/configService.ts +12 -3
  66. package/src/services/diagnosticReport.ts +237 -0
  67. package/src/services/exportService.ts +14 -2
  68. package/src/services/wechatClient.ts +418 -380
@@ -1,380 +1,418 @@
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
- }
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
+ * - **但 4.1.15.53 起主进程不再带这个参数**(2026-10-18 本机实测:命令行只有
17
+ * `"D:\WeiXin\Weixin.exe"`,一个开关都没有)。于是加一条**同一件事的第二种观测**:
18
+ * 进程实际映射的 `Weixin.dll` 在哪个目录 —— 那同样是"它现在加载了哪个",只是从
19
+ * 进程模块表看而不是从命令行看。命令行优先(那是客户端自己说的),取不到才看模块表。
20
+ * - 而"磁盘上有个版本目录"**不代表它能用**:本机 `D:\WeiXin\4.1.15.11\` 是升级残留,
21
+ * 只剩 `crashpad_handler.exe` 一个文件(**本机实测** 1 个文件 / 2.1 MB;报告 §2.3 记了同一件事)。
22
+ *
23
+ * 所以这个模块只做两件事,且都**不猜**:用上面两种观测取实际目录;把一个安装根下的版本目录
24
+ * 分成"能用/不能用"(判据是**有没有 `Weixin.dll`** —— 启动器加载的就是它)。
25
+ * 两种观测都取不到就是 `null` + 一句 `note`,不拿"磁盘上只有一个版本目录"这种推断顶替。
26
+ */
27
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'
28
+ import { join, win32 } from 'node:path'
29
+ import { execFile } from 'node:child_process'
30
+ import { promisify } from 'node:util'
31
+
32
+ const execFileAsync = promisify(execFile)
33
+
34
+ /** 一个 `Weixin.exe` 进程的画像(只留判版本要用的两个字段)。 */
35
+ export interface WechatClientProcess {
36
+ pid: number
37
+ exe: string
38
+ /** 命令行里的 `--user-lib-dir`;进程没带就是 null(**逆向报告实测**:`--type=xplayer` 那个不带)。 */
39
+ loadedDir: string | null
40
+ /** 命令行里的 `--wechat-files-path`:客户端自己说数据目录在哪(小程序那组进程会带)。 */
41
+ dataDir: string | null
42
+ /** 命令行里的 `--client_version`:客户端自己的版本 token(十进制,如 `0xF2541F0D`)。 */
43
+ versionToken: string | null
44
+ /**
45
+ * 进程实际映射的 `Weixin.dll` 路径。**命令行没带 `--user-lib-dir` 时的第二观测**
46
+ * (4.1.15.53 起主进程就不带了);读不到(权限/进程刚起)就是 null。
47
+ */
48
+ dllPath: string | null
49
+ }
50
+
51
+ /** 版本的判据与结论 —— 只跟"哪个进程加载了哪个目录"有关,读不到**别的来源**。 */
52
+ export interface WechatVersionFacts {
53
+ /** 本机有没有在跑的 `Weixin.exe`(没跑 ⇒ 版本未知,不是"没问题")。 */
54
+ running: boolean
55
+ /** 去重后的实际加载目录,按出现顺序。 */
56
+ loadedDirs: string[]
57
+ /** 版本号 = 加载目录的名字;**恰好一个**目录时才给,否则 null。 */
58
+ version: string | null
59
+ /** `version` 为 null 时说清为什么。 */
60
+ note: string
61
+ /** 安装根下**不能用的**版本目录(没有 `Weixin.dll`)。 */
62
+ unusableVersionDirs: Array<{ dir: string; files: number }>
63
+ }
64
+
65
+ export interface WechatClientFacts extends WechatVersionFacts {
66
+ /** 客户端自报的构建 token(`--client_version`,如 `0xF2541F0D`)—— 与目录名并列的**第二个来源**。 */
67
+ versionTokens: string[]
68
+ /** 进程命令行里的 `--wechat-files-path`:**客户端自己说**数据目录在哪。 */
69
+ dataDirs: string[]
70
+ /** `update\update.data` 在不在(在 ⇒ 加载路径可能不由命令行决定;见 `caveats`)。 */
71
+ updateData: { path: string; present: boolean }
72
+ /** 客户端崩溃记录:只报条数与最近时间,**不读内容**。 */
73
+ crashRecords: { dir: string; reports: number; records: number; newest: string | null }
74
+ /** 读这个版本时**必须一起说出去**的不确定(目前只有一条:`update.data` 在场)。 */
75
+ caveats: string[]
76
+ }
77
+
78
+ /** 版本目录名:`4.1.15.13` 这种。别的一律不当版本目录(不猜)。 */
79
+ const VERSION_DIR = /^\d+(\.\d+)+$/
80
+
81
+ /**
82
+ * PowerShell 一行取所有 `Weixin.exe` 的 pid / 可执行路径 / 命令行。
83
+ *
84
+ * 与 `keyService.isProcessRunning` 用同一种调法(`-NoProfile -NonInteractive`):这台机器上
85
+ * 那是仓库既有做法,不引第二种。`ConvertTo-Json` 在只有一个结果时会给对象而不是数组,
86
+ * 所以下面 `parseProcessRows` 两种都收。
87
+ */
88
+ // **两个镜像名都要问**:`--user-lib-dir` 在 `Weixin.exe` 那组上,而 `--wechat-files-path`
89
+ // 只在 `WeChatAppEx.exe`(小程序运行时那组)的命令行上(**本机实测**:拿它去筛 `Weixin.exe` 会一条都取不到,
90
+ // 于是"客户端自报数据目录"这一项**永远是空的**而看不出来)。版本那一支只认 `Weixin.exe`(见 `summarize`)。
91
+ // `WeixinDll` 是**同一件事的第二观测**:Win32_Process 只有命令行,模块表要问进程本身。
92
+ // 每条都单独 try —— 一个进程读不到模块不该让整张表为空(那会把"读不到"讲成"客户端没在跑")。
93
+ const PS_LIST = "$rows = Get-CimInstance Win32_Process -Filter (\"Name='Weixin.exe' or Name='WeChatAppEx.exe'\") | ForEach-Object { $dll = $null; try { $dll = (Get-Process -Id $_.ProcessId -ErrorAction Stop).Modules | Where-Object { $_.ModuleName -eq 'Weixin.dll' } | Select-Object -First 1 -ExpandProperty FileName } catch {}; [pscustomobject]@{ ProcessId = $_.ProcessId; ExecutablePath = $_.ExecutablePath; CommandLine = $_.CommandLine; WeixinDll = $dll } }; if ($rows) { $rows | ConvertTo-Json -Compress }"
94
+
95
+ export function parseProcessRows(raw: string): WechatClientProcess[] {
96
+ const text = String(raw || '').trim()
97
+ if (!text) return []
98
+ let data: unknown
99
+ try {
100
+ data = JSON.parse(text)
101
+ } catch {
102
+ return []
103
+ }
104
+ const rows = Array.isArray(data) ? data : [data]
105
+ const out: WechatClientProcess[] = []
106
+ for (const row of rows) {
107
+ if (!row || typeof row !== 'object') continue
108
+ const r = row as Record<string, unknown>
109
+ const pid = Number(r.ProcessId)
110
+ if (!Number.isFinite(pid)) continue
111
+ const commandLine = String(r.CommandLine || '')
112
+ out.push({
113
+ pid,
114
+ exe: String(r.ExecutablePath || ''),
115
+ loadedDir: userLibDir(commandLine),
116
+ dataDir: wechatFilesPath(commandLine),
117
+ versionToken: clientVersionToken(commandLine),
118
+ dllPath: String(r.WeixinDll || '').trim() || null,
119
+ })
120
+ }
121
+ return out
122
+ }
123
+
124
+ /**
125
+ * 从一条命令行里取 `--user-lib-dir` 的值。**两种写法都认**(`=` 与空格、带引号与不带),
126
+ * 取不到返回 null。
127
+ *
128
+ * 注意这个参数**不在 `Weixin.exe` 的字符串表里**(**逆向报告实测**:0 命中)—— 它是被加载的 DLL
129
+ * 自己解析的。也就是说:只有**进程**能告诉我们它,二进制里搜不到。
130
+ */
131
+ export function userLibDir(commandLine: string): string | null {
132
+ const m = /--user-lib-dir\s*=\s*(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(String(commandLine || ''))
133
+ if (!m) return null
134
+ const value = (m[1] || m[2] || m[3] || '').trim().replace(/["']/g, '')
135
+ return value || null
136
+ }
137
+
138
+ /**
139
+ * 从命令行里取 `--wechat-files-path`:**客户端自己说数据目录在哪**。
140
+ *
141
+ * 用处在排查:它和配置里的数据目录不一致时,说明两边指向的不是同一份库(本机数据目录
142
+ * 还有 D 盘软链,同一份库两条路径)。只报出来,**不拿它去改配置**。
143
+ */
144
+ export function wechatFilesPath(commandLine: string): string | null {
145
+ const m = /--wechat-files-path\s*=\s*(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(String(commandLine || ''))
146
+ if (!m) return null
147
+ const value = (m[1] || m[2] || m[3] || '').trim().replace(/["']/g, '')
148
+ return value || null
149
+ }
150
+
151
+ /**
152
+ * 命令行里的 `--client_version`:客户端**自己的版本 token**(十进制)。
153
+ *
154
+ * 为什么要报它:它是"哪个构建"的**另一个独立来源** —— 版本目录名(`4.1.15.13`)是装载位置,
155
+ * 而这个是客户端自报的构建标识,两者来自不同的地方。顺带:实测本机这个值 `4065599245`
156
+ * 正是 `0xF2541F0D`,也就是那份逆向报告从三处(反编译 / 插件 manifest / UA)定下来的版本号。
157
+ *
158
+ * ⚠️ **不给"版本号"**:低 16 位已解出(byte3 = 第四段;byte2 − 0x10 = 第三段 ——
159
+ * `0xF2541F0D`→`4.1.15.13`、`0xF2541F35`→`4.1.15.53` 等三个样本都吻合),但高 16 位在
160
+ * 所有样本里都是 `F254`、而它们都是 `4.1.x`,**主次版本分不开**。所以它只作为并列来源报出去,
161
+ * 不拿它去推版本号(模块表已经直接给出加载目录,没有理由再走一条半解码的路)。
162
+ */
163
+ export function clientVersionToken(commandLine: string): string | null {
164
+ const m = /--client_version\s*=\s*(\d+)/.exec(String(commandLine || ''))
165
+ if (!m) return null
166
+ const value = Number(m[1])
167
+ if (!Number.isFinite(value) || value <= 0) return null
168
+ return '0x' + value.toString(16).toUpperCase().padStart(8, '0')
169
+ }
170
+
171
+ /** 客户端崩溃记录:只数文件、只取最近时间,**不读任何内容**。 */
172
+ export function crashSummary(root: string): WechatClientFacts['crashRecords'] {
173
+ const reports = countFiles(join(root, 'reports'))
174
+ const records = countFiles(join(root, 'records')) + countFiles(join(root, 'metadata'))
175
+ let newest: string | null = null
176
+ try {
177
+ for (const sub of ['reports', 'records', 'metadata']) {
178
+ const dir = join(root, sub)
179
+ if (!existsSync(dir)) continue
180
+ for (const name of readdirSync(dir)) {
181
+ const at = statSync(join(dir, name)).mtime
182
+ if (!newest || at > new Date(newest)) newest = at.toISOString()
183
+ }
184
+ }
185
+ } catch {
186
+ // 读不到时间就给 null —— 不拿当前时间顶替(那会造出一个假的"最近崩溃")
187
+ }
188
+ return { dir: root, reports, records, newest }
189
+ }
190
+
191
+ function countFiles(dir: string): number {
192
+ try {
193
+ return readdirSync(dir).filter(name => {
194
+ try {
195
+ return statSync(join(dir, name)).isFile()
196
+ } catch {
197
+ return false
198
+ }
199
+ }).length
200
+ } catch {
201
+ return 0
202
+ }
203
+ }
204
+
205
+ /** 两条路径是不是同一个位置。**按真身比**:本机数据目录有 D 盘软链,同一份库有两条路径。 */
206
+ export function samePath(a: string, b: string): boolean {
207
+ try {
208
+ return realpathSync(a).toLowerCase() === realpathSync(b).toLowerCase()
209
+ } catch {
210
+ return false
211
+ }
212
+ }
213
+
214
+ /** `%APPDATA%\Tencent\xwechat` —— 崩溃记录与 `update.data` 都在这棵树下。 */
215
+ export function xwechatRoot(appData: string | undefined = process.env.APPDATA): string | null {
216
+ return appData ? join(appData, 'Tencent', 'xwechat') : null
217
+ }
218
+
219
+ /**
220
+ * `update.data` 在场时**必须一起说出去**的那句话。
221
+ *
222
+ * 报告把这条明确留成未解:"若该文件存在,exe 侧第一次 `LoadLibraryExW` 就会用它,
223
+ * 根本不会看命令行 —— 两条路谁优先【未知】"。所以这个函数的存在意义是:
224
+ * 别让新报出去的那个版本在那种机器上**悄悄变成错的**。
225
+ */
226
+ export function updateDataCaveats(present: boolean): string[] {
227
+ if (!present) return []
228
+ return ['update\\update.data 存在:加载路径**可能**由它决定,命令行给的版本未必是实际加载的那个'
229
+ + '(那份逆向没解开它的内部格式,也没判定两条路谁优先)']
230
+ }
231
+
232
+ /** 一个版本目录能不能用:**有没有 `Weixin.dll`**(启动器加载的就是它)。 */
233
+ export function versionDirFiles(dir: string): string[] | null {
234
+ try {
235
+ if (!statSync(dir).isDirectory()) return null
236
+ return readdirSync(dir)
237
+ } catch {
238
+ return null
239
+ }
240
+ }
241
+
242
+ /** 把一堆目录名分成"能用的版本目录 / 不能用的 / 与版本无关的"。纯函数,便于测。 */
243
+ export function classifyVersionDirs(entries: Array<{ name: string; files: string[] }>): {
244
+ usable: string[]
245
+ unusable: Array<{ dir: string; files: number }>
246
+ } {
247
+ const usable: string[] = []
248
+ const unusable: Array<{ dir: string; files: number }> = []
249
+ for (const entry of entries) {
250
+ if (!VERSION_DIR.test(entry.name)) continue
251
+ if (entry.files.includes('Weixin.dll')) usable.push(entry.name)
252
+ else unusable.push({ dir: entry.name, files: entry.files.length })
253
+ }
254
+ return { usable, unusable }
255
+ }
256
+
257
+ /** 安装根(`D:\WeiXin`)→ 它的版本目录分类。读不出来就当空(不猜)。 */
258
+ export function versionDirsUnder(installRoot: string): ReturnType<typeof classifyVersionDirs> {
259
+ let names: string[]
260
+ try {
261
+ names = readdirSync(installRoot)
262
+ } catch {
263
+ return { usable: [], unusable: [] }
264
+ }
265
+ const entries = names.map(name => ({ name, files: versionDirFiles(join(installRoot, name)) || [] }))
266
+ return classifyVersionDirs(entries)
267
+ }
268
+
269
+ export function summarize(processes: WechatClientProcess[],
270
+ unusable: Array<{ dir: string; files: number }>): WechatVersionFacts {
271
+ // 去重按**规范形**(去掉尾部分隔符、大小写折叠 —— Windows 路径大小写不敏感):
272
+ // 同一个目录写成 `D:\WeiXin\4.1.15.13` 与 `…\4.1.15.13\` 是**一个**目录,
273
+ // 不折算的话会被当成"两个进程指向不同目录"从而拒绝给出版本号。
274
+ // **版本只跟 `Weixin.exe` 有关**:`WeChatAppEx.exe`(小程序那组)与它在同一张进程表里
275
+ // (后者的命令行上带着 `--wechat-files-path`),但它不加载 `Weixin.dll` —— 拿它的
276
+ // "没带 `--user-lib-dir`"去说事会把"客户端在跑、版本取得到"讲成"取不到"。
277
+ // 用 `win32` 而不是平台 `path`:这里拆的**永远是 Windows 路径**(进程表/命令行给的),
278
+ // 而平台 `basename` 在别的平台上会把 `D:\WeiXin\4.1.15.13\Weixin.exe` 当成**一个文件名**
279
+ // —— 于是过滤出 0 个启动器、版本退回"未知",在 Windows 上还看不出来(CI 的 ubuntu 上红了 4 条)。
280
+ // 在 Windows 上 `win32.*` 与平台版完全等价,所以目标机器的行为一点没变。
281
+ const launchers = processes.filter(p => win32.basename(p.exe).toLowerCase() === 'weixin.exe')
282
+ // 规范形去重(去尾分隔符、大小写折叠):同一个目录的两种写法是**一个**目录,
283
+ // 不折算会被当成"两个进程指向不同目录"从而拒绝给出版本号。
284
+ const canonical = (dir: string | null): string | null => {
285
+ const shown = String(dir || '').replace(/[\\/]+$/, '')
286
+ return shown || null
287
+ }
288
+ const dedupe = (dirs: Array<string | null>): string[] => {
289
+ const seen = new Map<string, string>()
290
+ for (const dir of dirs) {
291
+ const shown = canonical(dir)
292
+ if (!shown) continue
293
+ const key = shown.toLowerCase()
294
+ if (!seen.has(key)) seen.set(key, shown)
295
+ }
296
+ return [...seen.values()]
297
+ }
298
+ // ① 命令行(客户端自己说的)。② 进程实际映射的 `Weixin.dll`(4.1.15.53 起只有这个可用)。
299
+ // **两种观测量的是同一件事**,所以先在①里找,①空了才看② —— 混在一起算会把"命令行说 A、
300
+ // 模块表说 A"这种一致情形误当成两个目录,反而把版本号弄丢。
301
+ let loadedDirs = dedupe(launchers.map(p => p.loadedDir))
302
+ let dirSource: 'flag' | 'dll' | null = loadedDirs.length ? 'flag' : null
303
+ if (!loadedDirs.length) {
304
+ loadedDirs = dedupe(launchers.map(p => (p.dllPath ? win32.dirname(p.dllPath) : null)))
305
+ if (loadedDirs.length) dirSource = 'dll'
306
+ }
307
+ let version: string | null = null
308
+ let note = ''
309
+ if (!launchers.length) {
310
+ // **`running` 与这句话必须说同一件事**:以前 `running` 看的是"进程表里有没有任何微信进程",
311
+ // 而 note 看的是"有没有 Weixin.exe",于是"只有 WeChatAppEx(小程序容器)在跑"时
312
+ // 输出会是"运行中: 是 / 客户端没在运行" —— 两句话互相打脸(2026-10-19 在 `check --report`
313
+ // 的实跑里一眼看到)。对用户来说"客户端在跑"就是主程序在跑,所以两个判据统一到 launchers。
314
+ note = processes.length
315
+ ? `只看到 ${processes.length} 个辅助进程(WeChatAppEx 等),主程序 Weixin.exe 没在运行 ⇒ 版本未知`
316
+ : '客户端没在运行 ⇒ 版本未知(`--user-lib-dir` 只有进程知道)'
317
+ } else if (!loadedDirs.length) {
318
+ note = `在跑 ${launchers.length} 个进程,但命令行里都没有 --user-lib-dir(也读不到已加载的 Weixin.dll)`
319
+ } else if (loadedDirs.length > 1) {
320
+ // **不猜**:多个目录各有各的来源(子进程可以指到不同的库),挑一个报出去就是编。
321
+ // 目录本身照旧列出来(那是实测到的),只是不给"版本号"这个单一结论。
322
+ note = `有 ${loadedDirs.length} 个不同的目录在使用,不挑一个报版本`
323
+ } else {
324
+ version = win32.basename(loadedDirs[0])
325
+ // 版本拿到了,但要说清它**从哪来** —— 两个来源都成立时结论一样,来源不同却影响排查。
326
+ if (dirSource === 'dll') {
327
+ note = '命令行里没有 --user-lib-dir,版本取自进程实际加载的 Weixin.dll'
328
+ }
329
+ }
330
+ return {
331
+ // 主程序在不在(与上面的 note 同一判据)。辅助进程不算"客户端在运行":读库、取版本都靠 Weixin.exe。
332
+ running: launchers.length > 0,
333
+ loadedDirs,
334
+ version,
335
+ note,
336
+ unusableVersionDirs: unusable,
337
+ }
338
+ }
339
+
340
+ /** 默认的执行器:`Get-CimInstance`。测试里换掉它,别真去问系统。 */
341
+ export type PowershellRunner = (command: string) => Promise<string>
342
+
343
+ /**
344
+ * **只要那一句话**:客户端自报的数据目录。
345
+ *
346
+ * 给 `init` 的数据目录发现用(`DbPathService.autoDetect({ hintedRoots })`)—— 它是唯一
347
+ * **来自客户端自己**的来源,别的都是按目录名猜的。找不到就返回空数组(调用方走老路),
348
+ * **不抛**:这只是个线索,不该让初始化失败。
349
+ */
350
+ export async function reportedDataDirs(run: PowershellRunner = defaultRunner): Promise<string[]> {
351
+ if (process.platform !== 'win32') return []
352
+ let raw = ''
353
+ try {
354
+ raw = await run(PS_LIST)
355
+ } catch {
356
+ return []
357
+ }
358
+ const dirs = parseProcessRows(raw).map(p => p.dataDir).filter((d): d is string => !!d)
359
+ return [...new Set(dirs.map(d => d.replace(/[\\/]+$/, '')).filter(Boolean))]
360
+ }
361
+
362
+ const defaultRunner: PowershellRunner = async (command) => {
363
+ const { stdout } = await execFileAsync('powershell.exe',
364
+ ['-NoProfile', '-NonInteractive', '-Command', command], { timeout: 15_000 })
365
+ return String(stdout || '')
366
+ }
367
+
368
+ /**
369
+ * 汇总:进程画像 + 每个安装根下"不能用的版本目录"。
370
+ *
371
+ * 非 Windows 直接返回"平台不支持"(`Get-CimInstance` 是 Windows 的),**不是**返回"没问题"。
372
+ */
373
+ export async function detectWechatClient(run: PowershellRunner = defaultRunner): Promise<WechatClientFacts> {
374
+ const root = xwechatRoot()
375
+ const nothing: WechatClientFacts = {
376
+ running: false, loadedDirs: [], version: null, note: '', unusableVersionDirs: [],
377
+ versionTokens: [],
378
+ dataDirs: [], updateData: { path: root ? join(root, 'update', 'update.data') : '', present: false },
379
+ crashRecords: { dir: root ? join(root, 'crashinfo') : '', reports: 0, records: 0, newest: null },
380
+ caveats: [],
381
+ }
382
+ if (process.platform !== 'win32') {
383
+ return { ...nothing, note: `判版本只做了 Windows(当前平台 ${process.platform})` }
384
+ }
385
+ let raw = ''
386
+ try {
387
+ raw = await run(PS_LIST)
388
+ } catch {
389
+ raw = ''
390
+ }
391
+ const processes = parseProcessRows(raw)
392
+ // 安装根从**启动器**的可执行路径推(**别去猜**:注册表与常见路径都不可靠 ——
393
+ // 注册表那条空的是**我们自己**的旧结论,出处是 `docs/DECISIONS.md` 里那句"`WeChat Files`
394
+ // 与注册表 InstallPath 都为空"(讲的是 3.x);**别把它记成那份逆向报告的结论**,
395
+ // 报告里没有注册表这一段)。只取 `Weixin.exe` 的目录:小程序的目录是插件目录,
396
+ // 那里的版本号子目录不是客户端版本。
397
+ const roots = [...new Set(processes
398
+ .filter(p => win32.basename(p.exe).toLowerCase() === 'weixin.exe')
399
+ .map(p => win32.dirname(p.exe))
400
+ .filter(dir => dir && existsSync(dir)))]
401
+ const unusable = roots.flatMap(r => versionDirsUnder(r).unusable)
402
+ const version = summarize(processes, unusable)
403
+
404
+ // `update.data` 在场 ⇒ **它可能才是决定加载路径的那个**,命令行给的版本就未必是加载的那个。
405
+ // 报告把这条明确留成未解("两条路谁优先 —— 【未知】"),所以这里**不猜**:只说存在,
406
+ // 并把不确定性跟着版本一起带出去(否则新报的这个版本会在那种机器上悄悄变成错的)。
407
+ const updateFile = nothing.updateData.path
408
+ const present = !!updateFile && existsSync(updateFile)
409
+ return {
410
+ ...version,
411
+ // 客户端自报的构建 token(与"目录名"并列的第二来源)——**不拿它推版本号**,编码规则没人解出来
412
+ versionTokens: [...new Set(processes.map(p => p.versionToken).filter((t): t is string => !!t))],
413
+ dataDirs: [...new Set(processes.map(p => p.dataDir).filter((d): d is string => !!d))],
414
+ updateData: { path: updateFile, present },
415
+ crashRecords: crashSummary(nothing.crashRecords.dir),
416
+ caveats: updateDataCaveats(present),
417
+ }
418
+ }