cortico-world-wechat 0.1.2 → 0.1.4

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/CHANGELOG.md CHANGED
@@ -2,11 +2,31 @@
2
2
 
3
3
  本文件的 `## <版本号>` 段落会显示在扩展市场的发布时间线上(索引仓的 Action 按同样规则切段)。
4
4
 
5
+
6
+ ## 0.1.4
7
+
8
+ - **补充作者字段 `author`**(`静音胡闹 <2310468741@qq.com>`),此前漏填,npm 页面上作者为空。
9
+ 说明:npm 的 "published by" 显示的是**账号名**,无法用该字段改写;本字段只影响作者展示。
10
+ - 无功能变化。
11
+
12
+ ## 0.1.3
13
+
14
+ - **修复:读取失败不会自愈**。此前一旦某次读取失败(并发写、微信正在写库、缓存半成品),
15
+ 扩展会每秒重试、每秒失败,直到重启 Coopanion —— 实测踩到过该状态。
16
+ 现在:缓存不可用即丢弃并全量重建;WAL 合并改为"临时文件 + 验证通过才原子替换",
17
+ 合并失败只降级为"少一次增量",不再污染缓存。
18
+ - **修复:多进程共用同一解密缓存会互相踩**(扩展 + 排查脚本同时跑 → 永久 ile is not a database)。
19
+ 缓存目录改为按进程隔离(cache/p<pid>)。
20
+ - **修复:用 mtime 判断"主库是否变化"导致每秒全量解密 20MB**。微信活着时 mtime 每秒都在变;
21
+ 改为按**文件大小**判断全量重建,内容增量靠 WAL 合并补齐。实测二次读取 5224 页库仅需 13ms。
22
+ - 依赖内容自省的 describe.json、 est/ 均未变。
23
+
5
24
  ## 0.1.2
6
25
 
7
26
  - 文档重写:安装给出三条路径(npm / 离线包 / 源码,按推荐顺序)、配置项逐条说明、
8
27
  "没反应时按顺序查"的排查表、以及"消息正文会进入模型上下文"的明确提示。
9
28
  - 无功能变化。
29
+
10
30
  ## 0.1.1
11
31
 
12
32
  - **打包内容调整**(无功能变化):
@@ -14,6 +34,7 @@
14
34
  - npm 包内补上 `test/`,方便使用者自行验证(尤其是微信升级后跑 `test/decrypt-verify.mjs`,
15
35
  它用 `PRAGMA integrity_check` 判断页布局是否仍被正确解密)。
16
36
 
37
+
17
38
  ## 0.1.0
18
39
 
19
40
  首个公开版本。
@@ -26,4 +47,6 @@
26
47
  - **取密钥**:微信的库密钥只存在于其进程内存中,由 `tools/fetch-keys.py` 按需取出
27
48
  (短命进程,取完即退;失效时自动重取)。这是本项目唯一用到 Python 的地方。
28
49
  - 实测:真机库解密 `PRAGMA integrity_check = ok`;测试 26 例(7 监听逻辑 + 6 文本格式化 + 13 World 行为)。
29
-
50
+
51
+
52
+
package/package.json CHANGED
@@ -1,52 +1,53 @@
1
- {
2
- "name": "cortico-world-wechat",
3
- "version": "0.1.2",
4
- "description": "把 Windows 微信新消息播报给桌宠:扩展自己解密并轮询微信本地库,把新消息推进桌宠的事件流,由桌宠决定怎么汇报。",
5
- "license": "MIT",
6
- "type": "module",
7
- "main": "src/index.js",
8
- "exports": {
9
- ".": "./src/index.js",
10
- "./package.json": "./package.json"
11
- },
12
- "files": [
13
- "src",
14
- "tools",
15
- "test",
16
- "describe.json",
17
- "CHANGELOG.md",
18
- "README.md"
19
- ],
20
- "os": [
21
- "win32"
22
- ],
23
- "engines": {
24
- "node": "^22.19.0 || >=24.0.0"
25
- },
26
- "dependencies": {
27
- "better-sqlite3-multiple-ciphers": "^13.0.3",
28
- "ws": "^8.21.3"
29
- },
30
- "keywords": [
31
- "cortico-world",
32
- "cortico",
33
- "wechat",
34
- "weixin",
35
- "notifier"
36
- ],
37
- "cortico": {
38
- "displayName": "微信消息提示",
39
- "kind": "world",
40
- "api": 6,
41
- "describe": "describe.json",
42
- "tags": [
43
- "im",
44
- "external-setup",
45
- "experimental",
46
- "sensitive-data"
47
- ]
48
- },
49
- "scripts": {
50
- "test": "node --test test/format.test.mjs test/world.test.mjs"
51
- }
52
- }
1
+ {
2
+ "name": "cortico-world-wechat",
3
+ "version": "0.1.4",
4
+ "author": "静音胡闹 <2310468741@qq.com>",
5
+ "description": "把 Windows 微信新消息播报给桌宠:扩展自己解密并轮询微信本地库,把新消息推进桌宠的事件流,由桌宠决定怎么汇报。",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "src/index.js",
9
+ "exports": {
10
+ ".": "./src/index.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "tools",
16
+ "test",
17
+ "describe.json",
18
+ "CHANGELOG.md",
19
+ "README.md"
20
+ ],
21
+ "os": [
22
+ "win32"
23
+ ],
24
+ "engines": {
25
+ "node": "^22.19.0 || >=24.0.0"
26
+ },
27
+ "dependencies": {
28
+ "better-sqlite3-multiple-ciphers": "^13.0.3",
29
+ "ws": "^8.21.3"
30
+ },
31
+ "keywords": [
32
+ "cortico-world",
33
+ "cortico",
34
+ "wechat",
35
+ "weixin",
36
+ "notifier"
37
+ ],
38
+ "cortico": {
39
+ "displayName": "微信消息提示",
40
+ "kind": "world",
41
+ "api": 6,
42
+ "describe": "describe.json",
43
+ "tags": [
44
+ "im",
45
+ "external-setup",
46
+ "experimental",
47
+ "sensitive-data"
48
+ ]
49
+ },
50
+ "scripts": {
51
+ "test": "node --test test/format.test.mjs test/world.test.mjs"
52
+ }
53
+ }
package/src/wechat-db.js CHANGED
@@ -9,10 +9,10 @@
9
9
  * 只读:不写微信库、不动微信进程;解密产物落在自己的缓存目录。
10
10
  */
11
11
  import { createRequire } from 'node:module';
12
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, openSync, readSync, closeSync } from 'node:fs';
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, openSync, readSync, closeSync, copyFileSync, renameSync } from 'node:fs';
13
13
  import { join, basename } from 'node:path';
14
14
 
15
- import { PAGE_SIZE, decryptPage, decryptFile, readPage1, verifyDbKey } from './wechat-crypto.js';
15
+ import { PAGE_SIZE, decryptPage, decryptFile } from './wechat-crypto.js';
16
16
 
17
17
  const require = createRequire(import.meta.url);
18
18
 
@@ -69,11 +69,17 @@ export function listMessageShards(dbStorage) {
69
69
  * 把 -wal 里的加密帧合并进已解密的主库(与上游 _merge_wal 等价)。
70
70
  * 帧:24 字节头 + 4096 页,数值全为**大端**;只合并 salt 与 WAL 头一致的帧
71
71
  * (checkpoint 会重置 WAL,旧世代帧合并会用过期页覆盖新数据 → 库损坏)。
72
+ *
73
+ * ⚠️ 用**临时文件 + 原子替换**:旧实现在正式缓存文件上原地 r+ 覆盖,
74
+ * 一旦中途被并发读取或异常打断,缓存就变成"半新半旧"的不可用状态,
75
+ * 并且调用方会每秒重试 → 永久失败(实测踩过)。
72
76
  */
73
77
  export function mergeWal(decryptedPath, walPath, key, fromFrame = 0) {
74
78
  if (!existsSync(decryptedPath) || !existsSync(walPath)) return { applied: 0, frames: 0 };
79
+ const tempPath = `${decryptedPath}.merge-tmp`;
80
+ copyFileSync(decryptedPath, tempPath);
75
81
  const walFd = openSync(walPath, 'r');
76
- const dstFd = openSync(decryptedPath, 'r+');
82
+ const dstFd = openSync(tempPath, 'r+');
77
83
  let applied = 0;
78
84
  let last = fromFrame;
79
85
  try {
@@ -105,7 +111,7 @@ export function mergeWal(decryptedPath, walPath, key, fromFrame = 0) {
105
111
  if (maxPgno > 0) {
106
112
  const page1 = Buffer.alloc(PAGE_SIZE);
107
113
  readSync(dstFd, page1, 0, PAGE_SIZE, 0);
108
- const fileSize = statSync(decryptedPath).size;
114
+ const fileSize = statSync(tempPath).size;
109
115
  const dbPages = Math.ceil(fileSize / PAGE_SIZE);
110
116
  const headerPages = page1.readUInt32BE(28);
111
117
  const newPages = Math.max(headerPages, maxPgno, dbPages);
@@ -115,11 +121,13 @@ export function mergeWal(decryptedPath, walPath, key, fromFrame = 0) {
115
121
  writeFileSync(dstFd, patched, { offset: 0 });
116
122
  }
117
123
  }
118
- return { applied, frames: last };
119
124
  } finally {
120
125
  closeSync(walFd);
121
126
  closeSync(dstFd);
122
127
  }
128
+ // 合并完成才替换正式缓存:中途异常时正式缓存保持原样
129
+ renameSync(tempPath, decryptedPath);
130
+ return { applied, frames: last };
123
131
  }
124
132
 
125
133
  /** 读取器:管理"密钥 + 解密缓存 + 增量读消息" */
@@ -130,9 +138,11 @@ export class WeChatReader {
130
138
  constructor(options) {
131
139
  this.dbStorage = options.dbStorage;
132
140
  this.keys = options.keys; // Map<'contact\\contact.db', Buffer>
133
- this.cacheDir = options.cacheDir;
141
+ // 缓存目录按进程隔离:多个进程(扩展 + 排查脚本)共用同一目录会互相踩
142
+ // ——一个在 WAL 合并中途写,另一个正好读到半成品,就永久报 "file is not a database"(实测踩过)
143
+ this.cacheDir = join(options.cacheDir, `p${process.pid}`);
134
144
  this.log = options.logger ?? { info: () => {}, warn: () => {}, debug: () => {} };
135
- this._stamps = new Map(); // rel -> { mtimeMs, size, walFrames }
145
+ this._stamps = new Map(); // rel -> { sourceSize, walSize, walFrames }
136
146
  mkdirSync(this.cacheDir, { recursive: true });
137
147
  }
138
148
 
@@ -140,7 +150,16 @@ export class WeChatReader {
140
150
  return this.keys.get(rel) ?? null;
141
151
  }
142
152
 
143
- /** 按需解密(含 WAL 合并);主库或 WAL 变化时增量重做 */
153
+ /**
154
+ * 按需解密(含 WAL 合并);主库或 WAL 变化时增量重做。
155
+ *
156
+ * 自愈设计(实测踩过:一次读取失败后每秒重试每秒失败,永久不自愈):
157
+ * ① WAL 合并**不直接改缓存**,先合并到临时文件、验证能打开、才原子替换 ——
158
+ * 合并中途被打断/读到半成品,都不会污染正式缓存;
159
+ * ② 任何一步探测到缓存不可用,就丢弃时间戳与缓存文件,下次全量重建;
160
+ * ③ 最坏情况(合并反复失败)退化到"只有主库"的缓存,仍能读到消息——
161
+ * 少的是最近未 checkpoint 的增量,而不是整个分片废掉。
162
+ */
144
163
  ensureDecrypted(rel) {
145
164
  const srcPath = join(this.dbStorage, rel);
146
165
  const walPath = `${srcPath}-wal`;
@@ -151,36 +170,56 @@ export class WeChatReader {
151
170
  const walStat = existsSync(walPath) ? statSync(walPath) : null;
152
171
  const stamp = this._stamps.get(rel);
153
172
 
154
- const sameMain = stamp && stamp.mtimeMs === srcStat.mtimeMs && stamp.size === srcStat.size;
173
+ const sameMain = stamp && stamp.sourceSize === srcStat.size;
155
174
  const sameWal = stamp && (stamp.walSize ?? 0) === (walStat?.size ?? 0);
156
- if (sameMain && sameWal && existsSync(dstPath)) return dstPath;
175
+ if (sameMain && sameWal && existsSync(dstPath) && this._isUsable(dstPath)) return dstPath;
157
176
 
158
- if (!sameMain || !existsSync(dstPath)) {
177
+ // 缓存不可用、或主库大小变了(= 微信做过 checkpoint):全量重建
178
+ //
179
+ // ⚠️ 判断依据是 **size 而不是 mtime**:微信活着时 mtime 几乎每秒都在变
180
+ // (WAL 写入/checkpoint 都会碰它),用 mtime 会导致每秒全量解密 20MB。
181
+ // 内容的新增靠 WAL 增量合并补齐,所以只看 size 既省又能拿到最新消息。
182
+ if (!sameMain || !existsSync(dstPath) || !this._isUsable(dstPath)) {
183
+ this._stamps.delete(rel);
159
184
  const pages = decryptFile(srcPath, dstPath, key);
160
185
  this.log.debug?.(`[reader] 解密 ${rel}(${pages} 页)`);
161
- this._stamps.set(rel, { mtimeMs: srcStat.mtimeMs, size: srcStat.size, walSize: 0, walFrames: 0 });
162
- this._verifyOrThrow(rel, dstPath);
186
+ this._stamps.set(rel, { sourceSize: srcStat.size, walSize: 0, walFrames: 0 });
187
+ if (!this._isUsable(dstPath)) {
188
+ throw new Error(`解密结果不可用(微信是否正在写入该库?稍后会重试): ${rel}`);
189
+ }
163
190
  }
191
+
164
192
  if (walStat) {
165
193
  const prevFrames = this._stamps.get(rel)?.walFrames ?? 0;
166
- const { frames } = mergeWal(dstPath, walPath, key, prevFrames);
167
- const current = this._stamps.get(rel) ?? {};
168
- this._stamps.set(rel, { ...current, walSize: walStat.size, walFrames: frames });
194
+ try {
195
+ const { frames } = mergeWal(dstPath, walPath, key, prevFrames);
196
+ if (!this._isUsable(dstPath)) {
197
+ throw new Error('合并 WAL 后缓存不可用,回退到上次可用状态');
198
+ }
199
+ const current = this._stamps.get(rel) ?? {};
200
+ this._stamps.set(rel, { ...current, walSize: walStat.size, walFrames: frames });
201
+ } catch (error) {
202
+ // 合并失败不致命:丢掉时间戳,下次全量重建(只损失未 checkpoint 的增量)
203
+ this.log.warn?.(`[reader] ${rel} WAL 合并失败(下次重建):${error.message}`);
204
+ this._stamps.delete(rel);
205
+ }
169
206
  }
170
207
  return dstPath;
171
208
  }
172
209
 
173
- /** 解密结果是否真是这个库(防止拿错密钥时静默读出垃圾) */
174
- _verifyOrThrow(rel, dstPath) {
175
- const page1 = readPage1(dstPath);
176
- if (!page1) throw new Error(`解密产物读不到首页: ${rel}`);
177
- if (!page1.subarray(0, 16).equals(Buffer.from('SQLite format 3\0', 'latin1'))) {
178
- throw new Error(`解密结果不是 SQLite 库(密钥可能已失效): ${rel}`);
179
- }
180
- const probe = join(this.dbStorage, rel);
181
- const enc = readPage1(probe);
182
- if (enc && !verifyDbKey(this.keyFor(rel), enc)) {
183
- throw new Error(`密钥已失效(微信可能重新登录过): ${rel}`);
210
+ /** 能否被 sqlite 真正打开(只看文件头不够:半成品也可能头是好的) */
211
+ _isUsable(dbPath) {
212
+ try {
213
+ const Sqlite = getDatabase();
214
+ const db = new Sqlite(dbPath, { readonly: true, fileMustExist: true });
215
+ try {
216
+ db.prepare("SELECT count(*) AS c FROM sqlite_master WHERE type='table'").get();
217
+ return true;
218
+ } finally {
219
+ db.close();
220
+ }
221
+ } catch {
222
+ return false;
184
223
  }
185
224
  }
186
225