@chainlesschain/personal-data-hub 0.4.1 → 0.4.2

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.
@@ -89,14 +89,52 @@ async function collect(iter) {
89
89
  }
90
90
 
91
91
  describe("WeChatPcAdapter — readiness + construction", () => {
92
- it("constructs no-arg and reports DB_NOT_PULLED via readinessOnly", async () => {
92
+ // Synthetic "nothing installed" filesystem so auto-discovery is
93
+ // deterministic regardless of what's on the host running the tests.
94
+ const EMPTY_FS = {
95
+ existsSync: () => false,
96
+ readdirSync: () => [],
97
+ statSync: () => ({ size: 0 }),
98
+ constants: { R_OK: 4 },
99
+ };
100
+
101
+ it("constructs no-arg and reports APP_NOT_INSTALLED when nothing is discoverable", async () => {
93
102
  const a = new WeChatPcAdapter();
103
+ a._deps.discoveryDeps = { fs: EMPTY_FS, home: "/no-home", env: {} };
94
104
  expect(a.name).toBe("wechat-pc");
95
105
  expect(a.extractMode).toBe("device-pull");
96
106
  expect(a.dataDisclosure.legalGate).toBe(true);
97
107
  const r = await a.authenticate({ readinessOnly: true });
98
108
  expect(r.ok).toBe(false);
99
- expect(r.reason).toBe("DB_NOT_PULLED");
109
+ expect(r.reason).toBe("APP_NOT_INSTALLED");
110
+ });
111
+
112
+ it("auto-discovers an installed WeChat 4.x DB → DB_FOUND_NEEDS_KEY", async () => {
113
+ // Minimal synthetic 4.x layout: ~/Documents/xwechat_files/<wxid>_N/db_storage/message/message_0.db
114
+ const dirs = {
115
+ "/h/Documents/xwechat_files": [{ name: "wxid_abc_1", isDirectory: () => true, isFile: () => false }],
116
+ "/h/Documents/xwechat_files/wxid_abc_1/db_storage/message": [
117
+ { name: "message_0.db", isDirectory: () => false, isFile: () => true },
118
+ ],
119
+ };
120
+ const exist = new Set([
121
+ "/h/Documents/xwechat_files",
122
+ "/h/Documents/xwechat_files/wxid_abc_1/db_storage",
123
+ "/h/Documents/xwechat_files/wxid_abc_1/db_storage/contact/contact.db",
124
+ ]);
125
+ const fakeFs = {
126
+ existsSync: (p) => exist.has(p.replace(/\\/g, "/")),
127
+ readdirSync: (p) => dirs[p.replace(/\\/g, "/")] || [],
128
+ statSync: () => ({ size: 1234 }),
129
+ constants: { R_OK: 4 },
130
+ };
131
+ const a = new WeChatPcAdapter();
132
+ a._deps.discoveryDeps = { fs: fakeFs, home: "/h", env: {}, path: require("node:path").posix };
133
+ const r = await a.authenticate({ readinessOnly: true });
134
+ expect(r.ok).toBe(false);
135
+ expect(r.reason).toBe("DB_FOUND_NEEDS_KEY");
136
+ expect(r.discovered.installed).toBe(true);
137
+ expect(r.discovered.primaryDb).toContain("message_0.db");
100
138
  });
101
139
 
102
140
  it("readinessOnly with a configured dbPath reports configured (no DB open)", async () => {
@@ -205,3 +243,123 @@ describe("WeChatPcAdapter — options + edge cases", () => {
205
243
  );
206
244
  });
207
245
  });
246
+
247
+ describe("WeChatPcAdapter — WeChat 4.x sidecar path", () => {
248
+ function fakeCollector(result) {
249
+ return async (_opts) => result;
250
+ }
251
+
252
+ it("opts.mode='v4' routes through the injected collector and yields messages", async () => {
253
+ const a = new WeChatPcAdapter({
254
+ v4Collector: fakeCollector({
255
+ account: "wxid_me",
256
+ messageCount: 2,
257
+ dbs: [{ db: "message_0.db", messageCount: 2, hmacFailures: 0 }],
258
+ messages: [
259
+ {
260
+ conversation: "wxid_friend",
261
+ sender: "wxid_friend",
262
+ type: 1,
263
+ createTime: 1700000002,
264
+ text: "hello from 4.0",
265
+ originalId: "wechat-pc:wxid_friend:1001",
266
+ },
267
+ {
268
+ conversation: "39354004187@chatroom",
269
+ sender: "wxid_other",
270
+ type: 1,
271
+ createTime: 1700000003,
272
+ text: "group line",
273
+ originalId: "wechat-pc:39354004187@chatroom:1002",
274
+ },
275
+ ],
276
+ }),
277
+ });
278
+ const raws = await collect(a.sync({ mode: "v4" }));
279
+ expect(raws).toHaveLength(2);
280
+ expect(raws[0].originalId).toBe("wechat-pc:wxid_friend:1001");
281
+ expect(raws[0].payload.text).toBe("hello from 4.0");
282
+ expect(raws[0].payload.isGroup).toBe(false);
283
+ // group message: peer is the chatroom, sender preserved
284
+ expect(raws[1].payload.isGroup).toBe(true);
285
+ expect(raws[1].payload.senderWxid).toBe("wxid_other");
286
+
287
+ // normalize reuses the 3.x path → produces a valid message event
288
+ const merged = { events: [], persons: [], places: [], items: [], topics: [] };
289
+ for (const r of raws) {
290
+ const n = a.normalize(r);
291
+ for (const k of Object.keys(merged)) if (Array.isArray(n[k])) merged[k].push(...n[k]);
292
+ }
293
+ const { valid } = partitionBatch(merged);
294
+ expect(valid.events.length).toBe(2);
295
+ const texts = valid.events.map((e) => e.content && e.content.text);
296
+ expect(texts).toContain("hello from 4.0");
297
+ expect(texts).toContain("group line");
298
+ });
299
+
300
+ it("v4 self-sent message marks isSend=1 when sender == account", async () => {
301
+ const a = new WeChatPcAdapter({
302
+ v4Collector: fakeCollector({
303
+ account: "wxid_me",
304
+ messages: [
305
+ { conversation: "wxid_friend", sender: "wxid_me", type: 1, createTime: 1700000004, text: "mine", originalId: "id-3" },
306
+ ],
307
+ }),
308
+ });
309
+ const raws = await collect(a.sync({ mode: "v4" }));
310
+ expect(raws[0].payload.isSend).toBe(1);
311
+ });
312
+
313
+ it("v4 forwards limit to the sidecar collector (sidecar owns the cap)", async () => {
314
+ const msgs = Array.from({ length: 5 }, (_v, i) => ({
315
+ conversation: "wxid_f", sender: "wxid_f", type: 1, createTime: 1700000000 + i, text: "m" + i, originalId: "id-" + i,
316
+ }));
317
+ let seenLimit = null;
318
+ // Collector that honors the limit, like the real Python sidecar does.
319
+ const collector = async (opts) => {
320
+ seenLimit = opts.limit;
321
+ return { account: "wxid_me", messages: msgs.slice(0, opts.limit || msgs.length), contacts: [] };
322
+ };
323
+ const a = new WeChatPcAdapter({ v4Collector: collector });
324
+ const raws = await collect(a.sync({ mode: "v4", limit: 3 }));
325
+ expect(seenLimit).toBe(3);
326
+ expect(raws.filter((r) => r.kind === "message")).toHaveLength(3);
327
+ });
328
+
329
+ it("v4 yields contacts from contact.db as Person entities", async () => {
330
+ const a = new WeChatPcAdapter({
331
+ v4Collector: fakeCollector({
332
+ account: "wxid_me",
333
+ messages: [
334
+ { conversation: "wxid_friend", sender: "wxid_friend", type: 1, createTime: 1700000002, text: "hi", originalId: "m-1" },
335
+ ],
336
+ contacts: [
337
+ { wxid: "wxid_friend", nickname: "昵称", remark: "备注名", alias: "alias1", type: 3 },
338
+ { wxid: "12345@chatroom", nickname: "群", remark: null, alias: null, type: 2 }, // skipped (chatroom)
339
+ ],
340
+ }),
341
+ });
342
+ const raws = await collect(a.sync({ mode: "v4" }));
343
+ const contactRaws = raws.filter((r) => r.kind === "contact");
344
+ expect(contactRaws).toHaveLength(1); // chatroom filtered out
345
+ expect(contactRaws[0].payload.wxid).toBe("wxid_friend");
346
+ expect(contactRaws[0].payload.remark).toBe("备注名");
347
+
348
+ // normalize → Person entity
349
+ const n = a.normalize(contactRaws[0]);
350
+ expect(n.persons.length).toBeGreaterThanOrEqual(1);
351
+ expect(n.persons[0].identifiers.wechatId).toBe("wxid_friend");
352
+ });
353
+
354
+ it("v4 can opt out of contacts via include.contact=false", async () => {
355
+ const a = new WeChatPcAdapter({
356
+ v4Collector: fakeCollector({
357
+ account: "wxid_me",
358
+ messages: [],
359
+ contacts: [{ wxid: "wxid_x", nickname: "n", remark: null, alias: null, type: 3 }],
360
+ }),
361
+ });
362
+ const raws = await collect(a.sync({ mode: "v4", include: { contact: false } }));
363
+ expect(raws.filter((r) => r.kind === "contact")).toHaveLength(0);
364
+ });
365
+ });
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect } from "vitest";
4
+
5
+ const { collectWeChatV4, _internals } = require("../../lib/adapters/wechat-pc/v4-sidecar");
6
+
7
+ // Fake SidecarSupervisor: scripted per python exe (command[0]).
8
+ function makeFactory(behaviorByPython, calls) {
9
+ return (command, _cwd) => {
10
+ const py = command[0];
11
+ calls.push(py);
12
+ const behavior = behaviorByPython[py] || { throwOn: "start", error: new Error("ENOENT spawn " + py) };
13
+ return {
14
+ async start() {
15
+ if (behavior.throwOn === "start") throw behavior.error;
16
+ },
17
+ async invoke(_method, _params, _opts) {
18
+ if (behavior.throwOn === "invoke") throw behavior.error;
19
+ return behavior.result;
20
+ },
21
+ async stop() {},
22
+ };
23
+ };
24
+ }
25
+
26
+ describe("collectWeChatV4 — python fallback + error routing", () => {
27
+ it("falls through to the next python when the first lacks cryptography", async () => {
28
+ const calls = [];
29
+ const result = await collectWeChatV4({
30
+ pythonExe: "python", // tried first
31
+ _supervisorFactory: makeFactory(
32
+ {
33
+ python: { throwOn: "invoke", error: new Error("ModuleNotFoundError: No module named 'cryptography'") },
34
+ python3: { throwOn: null, result: { account: "wxid_x", messages: [{ text: "ok" }] } },
35
+ },
36
+ calls,
37
+ ),
38
+ });
39
+ expect(result.account).toBe("wxid_x");
40
+ expect(calls[0]).toBe("python");
41
+ expect(calls).toContain("python3");
42
+ });
43
+
44
+ it("surfaces a WeChat data error immediately (no fallback)", async () => {
45
+ const calls = [];
46
+ await expect(
47
+ collectWeChatV4({
48
+ pythonExe: "python",
49
+ _supervisorFactory: makeFactory(
50
+ { python: { throwOn: "invoke", error: new Error("KEY_NOT_FOUND: key not found in Weixin.exe memory") } },
51
+ calls,
52
+ ),
53
+ }),
54
+ ).rejects.toThrow(/KEY_NOT_FOUND/);
55
+ expect(calls).toEqual(["python"]); // did NOT try other pythons
56
+ });
57
+
58
+ it("throws SIDECAR_UNAVAILABLE when no python works", async () => {
59
+ const calls = [];
60
+ await expect(
61
+ collectWeChatV4({
62
+ _supervisorFactory: makeFactory({}, calls), // all spawn-fail
63
+ }),
64
+ ).rejects.toThrow(/SIDECAR_UNAVAILABLE|could not run/);
65
+ });
66
+
67
+ it("pythonCandidates dedupes + honors explicit/env order", () => {
68
+ const list = _internals.pythonCandidates("my-python");
69
+ expect(list[0]).toBe("my-python");
70
+ expect(new Set(list).size).toBe(list.length);
71
+ });
72
+ });
@@ -231,3 +231,62 @@ describe("AdapterRegistry.readiness()", () => {
231
231
  expect(byName(reports, "messaging-telegram").reason).toBe("DB_NOT_PULLED");
232
232
  });
233
233
  });
234
+
235
+ describe("AdapterRegistry.readiness() — ADB one-click (social)", () => {
236
+ const oneClick = { oneClickNames: new Set(["social-bilibili"]) };
237
+
238
+ it("device connected → ready via adb-oneclick", async () => {
239
+ const reg = new AdapterRegistry({
240
+ vault: stubVault(),
241
+ adbReadiness: { ...oneClick, probe: async () => ({ deviceConnected: true, serial: "ABC123" }) },
242
+ });
243
+ reg.register(new BilibiliAdapter());
244
+ const [r] = await reg.readiness();
245
+ expect(r.ready).toBe(true);
246
+ expect(r.status).toBe(READINESS_STATUS.READY);
247
+ expect(r.mode).toBe("adb-oneclick");
248
+ expect(r.category).toBe(READINESS_CATEGORY.DEVICE);
249
+ expect(r.message).toMatch(/一键采集/);
250
+ });
251
+
252
+ it("no device → ADB_DEVICE_NEEDED (actionable, not the snapshot message)", async () => {
253
+ const reg = new AdapterRegistry({
254
+ vault: stubVault(),
255
+ adbReadiness: { ...oneClick, probe: async () => ({ deviceConnected: false }) },
256
+ });
257
+ reg.register(new BilibiliAdapter());
258
+ const [r] = await reg.readiness();
259
+ expect(r.ready).toBe(false);
260
+ expect(r.reason).toBe("ADB_DEVICE_NEEDED");
261
+ expect(r.category).toBe(READINESS_CATEGORY.DEVICE);
262
+ expect(r.message).toMatch(/root|USB|手机/);
263
+ });
264
+
265
+ it("a probe that throws degrades to ADB_DEVICE_NEEDED (never crashes)", async () => {
266
+ const reg = new AdapterRegistry({
267
+ vault: stubVault(),
268
+ adbReadiness: { ...oneClick, probe: async () => { throw new Error("adb missing"); } },
269
+ });
270
+ reg.register(new BilibiliAdapter());
271
+ const [r] = await reg.readiness();
272
+ expect(r.reason).toBe("ADB_DEVICE_NEEDED");
273
+ });
274
+
275
+ it("non-one-click adapter is unaffected by ADB readiness", async () => {
276
+ const reg = new AdapterRegistry({
277
+ vault: stubVault(),
278
+ adbReadiness: { oneClickNames: new Set(["social-bilibili"]), probe: async () => ({ deviceConnected: true }) },
279
+ });
280
+ reg.register(new TelegramAdapter());
281
+ const [r] = await reg.readiness();
282
+ expect(r.name).toBe("messaging-telegram");
283
+ expect(r.reason).toBe("DB_NOT_PULLED"); // unchanged
284
+ });
285
+
286
+ it("without adbReadiness config, social adapter still reports NO_INPUT", async () => {
287
+ const reg = new AdapterRegistry({ vault: stubVault() });
288
+ reg.register(new BilibiliAdapter());
289
+ const [r] = await reg.readiness();
290
+ expect(r.reason).toBe("NO_INPUT");
291
+ });
292
+ });
@@ -95,6 +95,34 @@ function localImPcGuide(platform) {
95
95
  };
96
96
  }
97
97
 
98
+ // Shared guide for the social platforms that have a dedicated one-click ADB
99
+ // sync (B站/微博/小红书/头条/快手) — root 手机 + USB → 界面一键按钮直接采集。
100
+ function socialAdbGuide(platform, dataDesc) {
101
+ return {
102
+ summary: `采集${platform}的${dataDesc}。最快路径:插上已 root 的安卓手机(USB 调试)→ 在中台点该平台的「一键采集」按钮,自动从手机抓登录态并拉取数据入库——无需在网页端手动操作。`,
103
+ methods: [
104
+ {
105
+ label: `方式一:root 手机 + USB 一键采集(推荐)`,
106
+ recommended: true,
107
+ steps: [
108
+ "手机已 root,开启「开发者选项 → USB 调试」,用数据线连接电脑。",
109
+ "确保电脑能看到设备(命令行 `adb devices` 列出你的手机)。",
110
+ `手机上已登录${platform} App。`,
111
+ `在中台点该平台的「一键采集」按钮(或对应的 *AdbSync 操作),自动抓取登录态 + 拉取数据入库。`,
112
+ ],
113
+ note: "登录态 / cookie 仅在本地处理,不上传服务器。纯个人使用。",
114
+ },
115
+ {
116
+ label: "方式二:手机 App 内采集快照",
117
+ steps: [
118
+ "在手机 ChainlessChain App 内进入「数据源」,找到该平台点「采集」。",
119
+ "按提示在内置浏览器登录,App 采完生成快照并同步到中台。",
120
+ ],
121
+ },
122
+ ],
123
+ };
124
+ }
125
+
98
126
  function displayName(name) {
99
127
  return DISPLAY_NAMES[name] || name;
100
128
  }
@@ -340,24 +368,23 @@ const ADAPTER_OVERRIDES = Object.freeze({
340
368
 
341
369
  "wechat-pc": {
342
370
  summary:
343
- "采集电脑版微信的聊天记录 + 联系人(来自本地 MSG*.db MicroMsg.db)。数据库经 SQLCipher 加密,需先解密成明文或提供 32 字节密钥再本地直读。",
371
+ "采集电脑版微信的聊天记录 + 公众号 + 朋友圈 + 收藏 + 联系人。微信 4.0(xwechat_files)已支持全自动一键采集:中台自动发现本机数据库、从运行中的微信进程提取密钥、解密入库——无需手动解密或装第三方工具。",
344
372
  methods: [
345
373
  {
346
- label: "方式一:先解密成明文再直读(推荐,最可靠)",
374
+ label: "方式一:一键采集(微信 4.0,推荐,全自动)",
347
375
  recommended: true,
348
376
  steps: [
349
- "在电脑登录微信 PC 版,定位数据目录(默认 文档\\WeChat Files\\<wxid>\\Msg\\)。",
350
- "用工具(如 PyWxDump)从运行中的微信进程提取 32 字节密钥并解密 MSG0.db / MicroMsg.db 为明文 SQLite。",
351
- "执行 `cc hub sync-adapter wechat-pc --input <解密后的 MSG0.db>` 采集聊天记录;再对 MicroMsg.db 跑一次采集联系人。",
352
- "中台直接读取消息 + 联系人入库(明文 SQLite,无需再解密)。",
377
+ "在这台电脑上打开并登录微信(4.0 版,数据在 文档\\xwechat_files\\)。",
378
+ "回到中台,点 wechat-pc 这一行的「一键采集」(或 `cc hub sync-adapter wechat-pc`)。",
379
+ "中台自动定位各数据库 从微信进程内存按库取密钥 解密 聊天/公众号/朋友圈/收藏/联系人全部入库。",
353
380
  ],
354
- note: "纯个人使用、全程本地。聊天记录敏感,首次会要求法律确认。多个 MSG*.db 逐个采集即可累积。",
381
+ note: "需要微信保持登录运行(密钥在内存里)。聊天记录含压缩消息与图片/文件/链接/引用等均会解析成可读文本。纯个人使用、全程本地,首次会要求法律确认。依赖随中台分发的 Python(含 cryptography)。",
355
382
  },
356
383
  {
357
- label: "方式二:提供密钥让中台直接解密(试验性)",
384
+ label: "方式二:旧版微信 3.x / 手动解密",
358
385
  steps: [
359
- "提取到 64 位十六进制密钥后,采集时附带 `--key <64位hex>`。",
360
- "中台用 SQLCipher 配置尝试直接打开加密库;部分微信版本可直接读,失败则回退方式一。",
386
+ "微信 3.x(文档\\WeChat Files\\<wxid>\\Msg\\)用工具(如 PyWxDump)解密 MSG0.db / MicroMsg.db 为明文。",
387
+ "执行 `cc hub sync-adapter wechat-pc --input <解密后的 .db>`(或附 `--key <64位hex>` 让中台尝试直接解密)。",
361
388
  ],
362
389
  },
363
390
  ],
@@ -366,6 +393,12 @@ const ADAPTER_OVERRIDES = Object.freeze({
366
393
  "dingtalk-pc": localImPcGuide("钉钉"),
367
394
  "feishu-pc": localImPcGuide("飞书"),
368
395
 
396
+ "social-bilibili": socialAdbGuide("哔哩哔哩", "观看历史 / 收藏 / 动态 / 关注"),
397
+ "social-weibo": socialAdbGuide("微博", "微博 / 收藏 / 关注"),
398
+ "social-xiaohongshu": socialAdbGuide("小红书", "笔记 / 点赞收藏 / 关注"),
399
+ "social-toutiao": socialAdbGuide("今日头条", "阅读 feed / 收藏 / 搜索历史"),
400
+ "social-kuaishou": socialAdbGuide("快手", "作品 / 推荐 / 个人主页"),
401
+
369
402
  "social-douyin": {
370
403
  summary:
371
404
  "采集抖音私信 + 联系人(来自 App 本地明文数据库 <uid>_im.db)。明文 SQLite、无加密、无 X-Bogus 签名——本地直读是最可靠的方式。",
@@ -86,6 +86,29 @@ const REASONS = Object.freeze({
86
86
  actionHint: "通过 ADB / 本地 DB 解密导出数据库后再同步",
87
87
  appendDetail: true,
88
88
  },
89
+ // 自动发现:已在本机找到 App 的加密数据库,只差解密密钥即可一键采集。
90
+ DB_FOUND_NEEDS_KEY: {
91
+ status: READINESS_STATUS.NEEDS_SETUP,
92
+ category: READINESS_CATEGORY.DEVICE,
93
+ message: "已自动找到本机数据库(已加密),仅需解密密钥即可一键采集",
94
+ actionHint: "提取该 App 的数据库密钥后点「一键采集」(密钥可从运行中的 App 提取)",
95
+ appendDetail: true,
96
+ },
97
+ // ADB 一键平台:后端支持 root 手机 USB 一键采集,但当前未检测到设备。
98
+ ADB_DEVICE_NEEDED: {
99
+ status: READINESS_STATUS.NEEDS_SETUP,
100
+ category: READINESS_CATEGORY.DEVICE,
101
+ message: "可一键采集:请插上已 root 的安卓手机并开启 USB 调试(adb 可见后点「一键采集」)",
102
+ actionHint: "连接手机后刷新,即可一键拉取",
103
+ },
104
+ // 自动发现:未检测到 App 的本机数据(未安装 / 未登录 / 非默认目录)。
105
+ APP_NOT_INSTALLED: {
106
+ status: READINESS_STATUS.UNAVAILABLE,
107
+ category: READINESS_CATEGORY.DEVICE,
108
+ message: "未检测到该 App 的本机数据(可能未安装、未登录或装在非默认目录)",
109
+ actionHint: "在本机安装并登录该 App 后重试,或改用手机端采集",
110
+ appendDetail: true,
111
+ },
89
112
  NO_KEY_PROVIDER: {
90
113
  status: READINESS_STATUS.NEEDS_SETUP,
91
114
  category: READINESS_CATEGORY.DEVICE,
@@ -58,12 +58,41 @@ function createLocalImPcAdapter(cfg) {
58
58
  this._deps = { fs, dbDriverFactory: opts.dbDriverFactory || null };
59
59
  }
60
60
 
61
+ _autoDiscover() {
62
+ if (this._discovered !== undefined) return this._discovered;
63
+ try {
64
+ // eslint-disable-next-line global-require
65
+ const { discover } = require("./_pc-local-discovery");
66
+ this._discovered = discover(NAME, this._deps.discoveryDeps || {});
67
+ } catch (_e) {
68
+ this._discovered = null;
69
+ }
70
+ return this._discovered;
71
+ }
72
+
73
+ _resolveDiscoveredDbPath() {
74
+ const disc = this._autoDiscover();
75
+ return disc && disc.installed && disc.primaryDb ? disc.primaryDb : null;
76
+ }
77
+
61
78
  async authenticate(ctx = {}) {
62
79
  if (ctx && ctx.readinessOnly) {
63
80
  if (this._dbPath) return { ok: true, mode: "configured" };
64
- return { ok: false, reason: "DB_NOT_PULLED", message: cfg.needHint };
81
+ const disc = this._autoDiscover();
82
+ if (disc && disc.installed) {
83
+ // best-effort plaintext DB → one-click ready; encrypted → needs key
84
+ if (!disc.encrypted) return { ok: true, mode: "auto-discovered" };
85
+ return {
86
+ ok: false,
87
+ reason: "DB_FOUND_NEEDS_KEY",
88
+ message: `已找到本机 ${PLATFORM} 库(主库 ${disc.primaryDb}),可能需解密`,
89
+ discovered: disc,
90
+ };
91
+ }
92
+ return { ok: false, reason: "APP_NOT_INSTALLED", message: (disc && disc.note) || cfg.needHint };
65
93
  }
66
- const dbPath = (ctx && ctx.inputPath) || (ctx && ctx.dbPath) || this._dbPath;
94
+ const dbPath =
95
+ (ctx && ctx.inputPath) || (ctx && ctx.dbPath) || this._dbPath || this._resolveDiscoveredDbPath();
67
96
  if (dbPath) {
68
97
  try {
69
98
  this._deps.fs.accessSync(dbPath, this._deps.fs.constants.R_OK);
@@ -72,7 +101,7 @@ function createLocalImPcAdapter(cfg) {
72
101
  }
73
102
  return { ok: true, mode: "sqlite" };
74
103
  }
75
- return { ok: false, reason: "DB_NOT_PULLED", message: `${NAME}.authenticate: needs opts.dbPath / inputPath` };
104
+ return { ok: false, reason: "APP_NOT_INSTALLED", message: `${NAME}.authenticate: 未检测到本机 ${PLATFORM} 库,也未提供 dbPath / inputPath` };
76
105
  }
77
106
 
78
107
  async healthCheck() {
@@ -80,8 +109,8 @@ function createLocalImPcAdapter(cfg) {
80
109
  }
81
110
 
82
111
  async *sync(opts = {}) {
83
- const dbPath = opts.dbPath || opts.inputPath || this._dbPath;
84
- if (!dbPath) throw new Error(`${NAME}.sync: needs opts.dbPath / opts.inputPath`);
112
+ const dbPath = opts.dbPath || opts.inputPath || this._dbPath || this._resolveDiscoveredDbPath();
113
+ if (!dbPath) throw new Error(`${NAME}.sync: 未找到本机 ${PLATFORM} 库且未提供 opts.dbPath / opts.inputPath`);
85
114
  if (!this._deps.fs.existsSync(dbPath)) return;
86
115
 
87
116
  // eslint-disable-next-line global-require