mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
feat: 支持 Windows WCDB 解密与打包
This commit is contained in:
+53
-7
@@ -1,8 +1,10 @@
|
||||
import './preload-env'
|
||||
import { app, shell, BrowserWindow, ipcMain, nativeImage, clipboard, Menu, Tray } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
import { WechatDb } from './wechat-db'
|
||||
import { bootstrapWcdbNative } from './wcdb4-client'
|
||||
import { VoiceService } from './voice-service'
|
||||
import { StickerService } from './sticker-service'
|
||||
import { parseMessageContent } from './message-parser'
|
||||
@@ -11,6 +13,7 @@ import { exportGroupReport } from './group-report-service'
|
||||
import { GroupReportExportRequest } from '../shared/group-report'
|
||||
import { DatabaseKeyStore } from './database-key-store'
|
||||
import { KeyServiceMac } from './key-service-mac'
|
||||
import { KeyService as KeyServiceWin } from './key-service-win'
|
||||
import * as chat from './services/chat-service'
|
||||
import {
|
||||
apiServer
|
||||
@@ -33,14 +36,17 @@ let imageDecryptService: ImageDecryptService | null = null
|
||||
let stickerService: StickerService | null = null
|
||||
const databaseKeyStore = new DatabaseKeyStore()
|
||||
const keyServiceMac = new KeyServiceMac()
|
||||
const keyServiceWin = new KeyServiceWin()
|
||||
let tray: Tray | null = null
|
||||
|
||||
// WCDB's Windows runtime checks the host application name during wcdb_init.
|
||||
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows.
|
||||
app.setName(process.platform === 'win32' ? 'WeFlow' : 'WechatExplorer')
|
||||
let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null = null
|
||||
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
|
||||
const TRAY_MODE =
|
||||
process.argv.includes('--tray') || (process.env['WXE_TRAY'] || '').toString() === '1'
|
||||
|
||||
// WechatExplorer's WCDB native library runs InitProtection before wcdb_init.
|
||||
// In dev, matching the host app name avoids failing the native protection gate.
|
||||
app.setName('WechatExplorer')
|
||||
|
||||
function createWindow(): void {
|
||||
// 创建浏览器窗口
|
||||
@@ -78,6 +84,17 @@ function createWindow(): void {
|
||||
// 某些 API 只能在此事件发生后使用
|
||||
app.whenReady().then(async () => {
|
||||
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
||||
|
||||
// WCDB's Windows runtime returns -1006 if wcdb_init is called more than once
|
||||
// per process. Bootstrap native once here so any later Wcdb4Client instance
|
||||
// reuses the already-initialized library and skips wcdb_init.
|
||||
try {
|
||||
bootstrapWcdbNative()
|
||||
console.log('[WCDB4] bootstrap complete at whenReady top')
|
||||
} catch (bootstrapError) {
|
||||
console.error('[WCDB4] bootstrap failed at whenReady top:', bootstrapError)
|
||||
}
|
||||
|
||||
// 为窗口设置应用程序用户模型 ID
|
||||
electronApp.setAppUserModelId('com.electron')
|
||||
|
||||
@@ -91,11 +108,27 @@ app.whenReady().then(async () => {
|
||||
// IPC test
|
||||
ipcMain.on('ping', () => console.log('pong'))
|
||||
|
||||
ipcMain.handle('db:init', (_, key: string) => {
|
||||
ipcMain.handle('db:init', async (_, key: string) => {
|
||||
if (dbInitInFlight) return dbInitInFlight
|
||||
|
||||
dbInitInFlight = (async () => {
|
||||
try {
|
||||
const trimmedKey = String(key || '').trim()
|
||||
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
||||
const nextWechatDb = new WechatDb(key)
|
||||
const settings = loadSettings()
|
||||
if (
|
||||
chat.isReady() &&
|
||||
chat.getCurrentKey().replace(/^0x/i, '').trim() === trimmedKey.replace(/^0x/i, '') &&
|
||||
(!settings.dbRoot || chat.getCurrentAccountRoot() === settings.dbRoot)
|
||||
) {
|
||||
console.log('[WCDB4] db:init reuse current connection')
|
||||
return { success: true, monitoring: true }
|
||||
}
|
||||
const nextWechatDb = await WechatDb.create(key, settings.dbRoot)
|
||||
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
|
||||
if (resolvedRoot && resolvedRoot !== settings.dbRoot) {
|
||||
saveSettings({ ...settings, dbRoot: resolvedRoot })
|
||||
}
|
||||
chat.setChatDb(nextWechatDb)
|
||||
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
||||
voiceService = new VoiceService(wcdb4Client)
|
||||
@@ -110,7 +143,12 @@ app.whenReady().then(async () => {
|
||||
} catch (error) {
|
||||
console.error('Failed to init DB:', error)
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
} finally {
|
||||
dbInitInFlight = null
|
||||
}
|
||||
})()
|
||||
|
||||
return dbInitInFlight
|
||||
})
|
||||
|
||||
ipcMain.handle('key:getSavedDbKey', async () => databaseKeyStore.load())
|
||||
@@ -125,9 +163,13 @@ app.whenReady().then(async () => {
|
||||
ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear())
|
||||
|
||||
ipcMain.handle('key:autoGetDbKey', async (event) => {
|
||||
const result = await keyServiceMac.autoGetDbKey((message) => {
|
||||
const onStatus = (message: string): void => {
|
||||
if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message })
|
||||
})
|
||||
}
|
||||
const result =
|
||||
process.platform === 'win32'
|
||||
? await keyServiceWin.autoGetDbKey(60_000, onStatus)
|
||||
: await keyServiceMac.autoGetDbKey(onStatus)
|
||||
if (!result.success || !result.key) return result
|
||||
|
||||
const saved = await databaseKeyStore.save(result.key)
|
||||
@@ -140,6 +182,10 @@ app.whenReady().then(async () => {
|
||||
|
||||
ipcMain.handle('db:getContacts', (_, filter?: string) => chat.listContacts(filter))
|
||||
|
||||
ipcMain.handle('db:getContactAvatars', (_, usernames: string[]) =>
|
||||
chat.getContactAvatars(usernames)
|
||||
)
|
||||
|
||||
ipcMain.handle('db:getMessages', (_, userMd5: string, startTime?: number, endTime?: number) =>
|
||||
chat.listMessages(userMd5, startTime, endTime)
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
function prependPath(values: string[]): void {
|
||||
if (process.platform !== 'win32') return
|
||||
|
||||
const existing = process.env.PATH || ''
|
||||
const next = Array.from(new Set(values.filter(Boolean))).join(path.delimiter)
|
||||
process.env.PATH = next ? `${next}${path.delimiter}${existing}` : existing
|
||||
process.env.Path = process.env.PATH
|
||||
}
|
||||
|
||||
try {
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const resourceRoots = [
|
||||
path.join(process.cwd(), 'resources'),
|
||||
path.join(process.cwd(), 'resources', 'resources'),
|
||||
path.join(process.resourcesPath || '', 'resources'),
|
||||
process.resourcesPath || ''
|
||||
].filter((value, index, list) => value && list.indexOf(value) === index && fs.existsSync(value))
|
||||
|
||||
const resourcesRoot = resourceRoots[0] || path.join(process.cwd(), 'resources')
|
||||
const dllDirs = resourceRoots.flatMap((root) => [
|
||||
root,
|
||||
path.join(root, 'wcdb', 'win32', archDir),
|
||||
path.join(root, 'wcdb', 'win32', 'x64'),
|
||||
path.join(root, 'key', 'win32', archDir),
|
||||
path.join(root, 'key', 'win32', 'x64'),
|
||||
path.join(root, 'runtime', 'win32')
|
||||
])
|
||||
|
||||
process.env.WCDB_RESOURCES_PATH = process.env.WCDB_RESOURCES_PATH || resourcesRoot
|
||||
process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
|
||||
prependPath(dllDirs.filter((dir) => fs.existsSync(dir)))
|
||||
} catch (error) {
|
||||
console.error('[WechatExplorer] failed to enforce local DLL priority:', error)
|
||||
}
|
||||
@@ -14,6 +14,15 @@ export function getCurrentKey(): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentAccountRoot(): string {
|
||||
if (!dbRef) return ''
|
||||
try {
|
||||
return dbRef.getWcdb4Client().getAccountRoot()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export interface FormattedContact {
|
||||
m_nsUsrName: string
|
||||
m_nsNickName: string
|
||||
@@ -31,6 +40,7 @@ export interface FormattedMessage {
|
||||
isSender: boolean
|
||||
img?: string
|
||||
name?: string
|
||||
senderId?: string
|
||||
contentData?: ReturnType<typeof parseMessageContent>
|
||||
voiceDataUrl?: string
|
||||
voiceDuration?: number
|
||||
@@ -133,6 +143,15 @@ export function listContacts(filter?: string): FormattedContact[] {
|
||||
return contacts
|
||||
}
|
||||
|
||||
export function getContactAvatars(usernames: string[]): Record<string, string> {
|
||||
if (!dbRef) return {}
|
||||
const normalized = Array.from(
|
||||
new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean))
|
||||
)
|
||||
if (normalized.length === 0) return {}
|
||||
return dbRef.getWcdb4Client().getAvatarUrls(normalized)
|
||||
}
|
||||
|
||||
export function listMessages(
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
@@ -140,16 +159,18 @@ export function listMessages(
|
||||
): FormattedMessage[] {
|
||||
if (!dbRef) return []
|
||||
|
||||
const startedAt = Date.now()
|
||||
const wcdb4Client = dbRef.getWcdb4Client()
|
||||
const username = wcdb4Client.getUsernameByMd5(userMd5)
|
||||
console.log(
|
||||
`[ChatService] listMessages begin md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0}`
|
||||
)
|
||||
const rawMessages = dbRef.getUserMessages(userMd5, startTime, endTime)
|
||||
const groupMembers = dbRef.getGroupMembersForChat(userMd5)
|
||||
const myAvatar = dbRef.getMyAvatarUrl()
|
||||
const myGroupNickname = username?.endsWith('@chatroom')
|
||||
? wcdb4Client.getMyGroupNickname(username)
|
||||
: undefined
|
||||
console.log(
|
||||
`[ChatService] listMessages native done md5=${userMd5} raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
|
||||
return rawMessages.map((msg: WechatMessage) => {
|
||||
const formatted = rawMessages.map((msg: WechatMessage) => {
|
||||
const rawMsgType = parseInt(msg.messageType)
|
||||
const msgType = normalizeMsgType(msg.messageType)
|
||||
const createTime = parseInt(msg.msgCreateTime)
|
||||
@@ -160,9 +181,9 @@ export function listMessages(
|
||||
let content = msg.msgContent
|
||||
let img = ''
|
||||
let name = ''
|
||||
let senderId = typeof msg.sender === 'string' ? msg.sender : ''
|
||||
if (isMine) {
|
||||
if (myAvatar) img = myAvatar
|
||||
name = myGroupNickname || (typeof msg.senderNickname === 'string' ? msg.senderNickname : '')
|
||||
name = typeof msg.senderNickname === 'string' ? msg.senderNickname : ''
|
||||
} else {
|
||||
if (typeof msg.senderAvatar === 'string') img = msg.senderAvatar
|
||||
if (typeof msg.senderNickname === 'string') name = msg.senderNickname
|
||||
@@ -172,15 +193,13 @@ export function listMessages(
|
||||
if (colonIndex > 0) {
|
||||
const potentialWxid = content.substring(0, colonIndex)
|
||||
if (potentialWxid.startsWith('wxid_')) {
|
||||
const member = dbRef!.getGroupMember(potentialWxid)
|
||||
if (member) img = member.m_nsHeadImgUrl
|
||||
if (groupMembers[potentialWxid]) {
|
||||
name = groupMembers[potentialWxid]
|
||||
content = content.substring(colonIndex + 1)
|
||||
}
|
||||
senderId = senderId || potentialWxid
|
||||
name = name || potentialWxid
|
||||
content = content.substring(colonIndex + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isMine && !name && senderId) name = senderId
|
||||
|
||||
let contentData: ReturnType<typeof parseMessageContent> | undefined
|
||||
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
|
||||
@@ -245,12 +264,18 @@ export function listMessages(
|
||||
content,
|
||||
img,
|
||||
name,
|
||||
senderId,
|
||||
sessionId: username,
|
||||
localId,
|
||||
createTime,
|
||||
contentData
|
||||
}
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[ChatService] listMessages end md5=${userMd5} formatted=${formatted.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
return formatted
|
||||
}
|
||||
|
||||
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
||||
|
||||
@@ -10,11 +10,100 @@ export interface AppSettings {
|
||||
apiPort: number
|
||||
}
|
||||
|
||||
function getDefaultDbRoot(): string {
|
||||
const home = os.homedir()
|
||||
const candidates = getDefaultDbRootCandidates(home)
|
||||
return candidates.find((candidate) => isUsableDbRoot(candidate)) || candidates[0]
|
||||
}
|
||||
|
||||
function getDefaultDbRootCandidates(home: string): string[] {
|
||||
if (process.platform !== 'win32') {
|
||||
return [path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')]
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
...getWeflowDbPathCandidates(home),
|
||||
path.join(home, 'Documents', 'WeChat Files'),
|
||||
path.join(home, 'Documents', 'xwechat_files'),
|
||||
path.join(home, 'WeChat Files'),
|
||||
path.join(os.homedir(), 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
|
||||
]
|
||||
|
||||
for (const drive of getWindowsDrives()) {
|
||||
candidates.push(path.join(`${drive}:\\`, 'xwechat_files'))
|
||||
candidates.push(path.join(`${drive}:\\`, 'WeChat Files'))
|
||||
for (const child of listDirectories(`${drive}:\\`)) {
|
||||
candidates.push(path.join(child, 'xwechat_files'))
|
||||
candidates.push(path.join(child, 'WeChat Files'))
|
||||
}
|
||||
}
|
||||
|
||||
return unique(candidates)
|
||||
}
|
||||
|
||||
function getWeflowDbPathCandidates(home: string): string[] {
|
||||
const configPaths = [
|
||||
path.join(home, 'AppData', 'Roaming', 'weflow', 'WeFlow-config.json'),
|
||||
path.join(home, 'AppData', 'Roaming', 'WeFlow', 'WeFlow-config.json')
|
||||
]
|
||||
const candidates: string[] = []
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
const config = fs.readJsonSync(configPath) as { dbPath?: unknown }
|
||||
if (typeof config.dbPath === 'string' && config.dbPath.trim()) {
|
||||
candidates.push(config.dbPath.trim())
|
||||
}
|
||||
} catch {
|
||||
// WeFlow is optional; ignore missing or unreadable config.
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
function getWindowsDrives(): string[] {
|
||||
const drives: string[] = []
|
||||
for (let code = 67; code <= 90; code += 1) {
|
||||
const drive = String.fromCharCode(code)
|
||||
if (fs.existsSync(`${drive}:\\`)) drives.push(drive)
|
||||
}
|
||||
return drives
|
||||
}
|
||||
|
||||
function listDirectories(root: string): string[] {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(root)
|
||||
.map((name) => path.join(root, name))
|
||||
.filter((candidate) => {
|
||||
try {
|
||||
return fs.statSync(candidate).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return Array.from(new Set(values))
|
||||
}
|
||||
|
||||
function isUsableDbRoot(candidate?: string): boolean {
|
||||
if (!candidate || !fs.existsSync(candidate)) return false
|
||||
if (fs.existsSync(path.join(candidate, 'db_storage'))) return true
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(candidate)
|
||||
.some((name) => fs.existsSync(path.join(candidate, name, 'db_storage')))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
dbRoot: path.join(
|
||||
os.homedir(),
|
||||
'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
|
||||
),
|
||||
dbRoot: getDefaultDbRoot(),
|
||||
apiEnabled: true,
|
||||
apiHost: '127.0.0.1',
|
||||
apiPort: 6131
|
||||
@@ -37,6 +126,9 @@ export function loadSettings(): AppSettings {
|
||||
if (fs.existsSync(SETTINGS_FILE)) {
|
||||
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings>
|
||||
cache = { ...DEFAULT_SETTINGS, ...raw }
|
||||
if (process.platform === 'win32' && !isUsableDbRoot(cache.dbRoot)) {
|
||||
cache.dbRoot = getDefaultDbRoot()
|
||||
}
|
||||
return cache
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -73,4 +165,4 @@ export function resetSettings(): AppSettings {
|
||||
|
||||
export function getSettingsPath(): string {
|
||||
return SETTINGS_FILE
|
||||
}
|
||||
}
|
||||
|
||||
+418
-81
@@ -41,6 +41,83 @@ type KoffiModule = {
|
||||
decode: (ptr: unknown, type: string, length: number) => string
|
||||
}
|
||||
|
||||
// Module-level singleton for WCDB native handle. WCDB's Windows runtime
|
||||
// returns -1006 if wcdb_init is called more than once per process, so we
|
||||
// perform InitProtection + wcdb_init exactly once and cache the resulting
|
||||
// library reference for every Wcdb4Client instance.
|
||||
let wcdbBootstrapLib: KoffiLibrary | null = null
|
||||
|
||||
export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string): KoffiLibrary {
|
||||
if (wcdbBootstrapLib) return wcdbBootstrapLib
|
||||
|
||||
const koffi = nodeRequire('koffi') as KoffiModule
|
||||
const resolvedLibPath = libPath || Wcdb4Client.resolveNativeLibrary()
|
||||
const libDir = libDirOverride || path.dirname(resolvedLibPath)
|
||||
console.log(
|
||||
`[WCDB4] bootstrap koffi.load begin ${resolvedLibPath} cwd=${process.cwd()} resourcesPath=${process.resourcesPath || ''}`
|
||||
)
|
||||
for (const name of process.platform === 'win32'
|
||||
? ['WCDB.dll', 'SDL2.dll']
|
||||
: process.platform === 'darwin'
|
||||
? ['libWCDB.dylib']
|
||||
: []) {
|
||||
const preloadPath = path.join(libDir, name)
|
||||
if (!fs.existsSync(preloadPath)) continue
|
||||
try {
|
||||
koffi.load(preloadPath)
|
||||
console.log(`[WCDB4] bootstrap preload ok ${preloadPath}`)
|
||||
} catch {
|
||||
console.warn(`[WCDB4] bootstrap preload failed ${preloadPath}`)
|
||||
}
|
||||
}
|
||||
const lib = koffi.load(resolvedLibPath)
|
||||
console.log(`[WCDB4] bootstrap koffi.load ok ${resolvedLibPath}`)
|
||||
|
||||
const initProtection = lib.func('int32 InitProtection(const char* resourcePath)') as (
|
||||
resourcePath: string
|
||||
) => number
|
||||
const resourceRoots = Array.from(
|
||||
new Set([
|
||||
libDir,
|
||||
path.dirname(libDir),
|
||||
process.env.WCDB_RESOURCES_PATH || '',
|
||||
path.join(process.resourcesPath || process.cwd(), 'resources'),
|
||||
process.resourcesPath || process.cwd(),
|
||||
path.join(process.cwd(), 'resources')
|
||||
])
|
||||
)
|
||||
let lastCode = -1
|
||||
let initOk = false
|
||||
for (const resourceRoot of resourceRoots) {
|
||||
try {
|
||||
console.log(`[WCDB4] bootstrap InitProtection call ${resourceRoot}`)
|
||||
lastCode = Number(initProtection(resourceRoot))
|
||||
console.log(`[WCDB4] bootstrap InitProtection rc=${lastCode} path=${resourceRoot}`)
|
||||
if (lastCode === 0) {
|
||||
initOk = true
|
||||
console.log(`[WCDB4] bootstrap InitProtection ok path=${resourceRoot}`)
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[WCDB4] bootstrap InitProtection exception path=${resourceRoot}`, error)
|
||||
}
|
||||
}
|
||||
if (!initOk) {
|
||||
throw new Error(`InitProtection 失败,错误码: ${lastCode}; tried=${resourceRoots.join(' | ')}`)
|
||||
}
|
||||
|
||||
const wcdbInit = lib.func('int32 wcdb_init()') as () => number
|
||||
console.log('[WCDB4] bootstrap wcdb_init begin')
|
||||
const initRc = Number(wcdbInit())
|
||||
console.log(`[WCDB4] bootstrap wcdb_init rc=${initRc}`)
|
||||
if (initRc !== 0) {
|
||||
throw new Error(`wcdb_init 失败,错误码: ${initRc}`)
|
||||
}
|
||||
|
||||
wcdbBootstrapLib = lib
|
||||
return lib
|
||||
}
|
||||
|
||||
type KoffiLibrary = {
|
||||
func: (signature: string) => (...args: unknown[]) => unknown
|
||||
}
|
||||
@@ -51,10 +128,7 @@ type WcdbHandleOut = [number]
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
export class Wcdb4Client {
|
||||
static readonly defaultRoot = path.join(
|
||||
os.homedir(),
|
||||
'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
|
||||
)
|
||||
static readonly defaultRoot = Wcdb4Client.findExistingDefaultRoot()
|
||||
|
||||
private readonly key: string
|
||||
private readonly accountRoot: string
|
||||
@@ -63,13 +137,12 @@ export class Wcdb4Client {
|
||||
private readonly sessionDbPath: string
|
||||
private koffi: KoffiModule | null = null
|
||||
private handle: number | null = null
|
||||
private initialized = false
|
||||
private displayNameCache = new Map<string, string>()
|
||||
private avatarCache = new Map<string, string>()
|
||||
private groupNicknameCache = new Map<string, Map<string, string>>()
|
||||
private cachedSessions: Wcdb4Session[] | null = null
|
||||
private cachedChatTables: { name: string; db_number: string }[] | null = null
|
||||
|
||||
private wcdbInit: (() => number) | null = null
|
||||
private wcdbShutdown: (() => number) | null = null
|
||||
private wcdbOpenAccount:
|
||||
| ((sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number)
|
||||
@@ -86,6 +159,9 @@ export class Wcdb4Client {
|
||||
outJson: WcdbVoidOut
|
||||
) => number)
|
||||
| null = null
|
||||
private wcdbGetMessageTableStats:
|
||||
| ((handle: number, username: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
private wcdbGetDisplayNames:
|
||||
| ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
@@ -158,11 +234,11 @@ export class Wcdb4Client {
|
||||
}
|
||||
|
||||
static resolveAccountRoot(accountRoot: string): string {
|
||||
const target = (accountRoot || '').trim().replace(/\/+$/, '')
|
||||
const target = (accountRoot || '').trim().replace(/[\\/]+$/, '')
|
||||
if (!target) {
|
||||
throw new Error('微信 4.0 账号目录不能为空')
|
||||
}
|
||||
if (fs.existsSync(path.join(target, 'db_storage'))) {
|
||||
if (Wcdb4Client.hasDbStorage(target)) {
|
||||
return target
|
||||
}
|
||||
if (!fs.existsSync(target)) {
|
||||
@@ -171,17 +247,8 @@ export class Wcdb4Client {
|
||||
const candidates = fs
|
||||
.readdirSync(target)
|
||||
.map((name) => path.join(target, name))
|
||||
.filter((candidate) => {
|
||||
try {
|
||||
return (
|
||||
fs.statSync(candidate).isDirectory() &&
|
||||
fs.existsSync(path.join(candidate, 'db_storage'))
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)
|
||||
.filter((candidate) => Wcdb4Client.hasDbStorage(candidate))
|
||||
.sort(Wcdb4Client.compareAccountDirs)
|
||||
if (!candidates[0]) {
|
||||
throw new Error(`未找到包含 db_storage 的微信 4.0 账号目录: ${target}`)
|
||||
}
|
||||
@@ -194,24 +261,15 @@ export class Wcdb4Client {
|
||||
throw new Error(`未找到微信 4.0 数据目录: ${root}`)
|
||||
}
|
||||
|
||||
if (fs.existsSync(path.join(root, 'db_storage'))) {
|
||||
if (Wcdb4Client.hasDbStorage(root)) {
|
||||
return root
|
||||
}
|
||||
|
||||
const candidates = fs
|
||||
.readdirSync(root)
|
||||
.map((name) => path.join(root, name))
|
||||
.filter((candidate) => {
|
||||
try {
|
||||
return (
|
||||
fs.statSync(candidate).isDirectory() &&
|
||||
fs.existsSync(path.join(candidate, 'db_storage'))
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)
|
||||
.filter((candidate) => Wcdb4Client.hasDbStorage(candidate))
|
||||
.sort(Wcdb4Client.compareAccountDirs)
|
||||
|
||||
if (!candidates[0]) {
|
||||
throw new Error(`未找到包含 db_storage 的微信 4.0 账号目录: ${root}`)
|
||||
@@ -220,6 +278,117 @@ export class Wcdb4Client {
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
private static findExistingDefaultRoot(): string {
|
||||
const roots = Wcdb4Client.getDefaultRootCandidates()
|
||||
return roots.find((root) => Wcdb4Client.isUsableRoot(root)) || roots[0]
|
||||
}
|
||||
|
||||
private static getDefaultRootCandidates(): string[] {
|
||||
const home = os.homedir()
|
||||
if (process.platform === 'win32') {
|
||||
const candidates = [
|
||||
...Wcdb4Client.getWeflowDbPathCandidates(home),
|
||||
path.join(home, 'Documents', 'WeChat Files'),
|
||||
path.join(home, 'Documents', 'xwechat_files'),
|
||||
path.join(home, 'WeChat Files'),
|
||||
path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
|
||||
]
|
||||
for (const drive of Wcdb4Client.getWindowsDrives()) {
|
||||
candidates.push(path.join(`${drive}:\\`, 'xwechat_files'))
|
||||
candidates.push(path.join(`${drive}:\\`, 'WeChat Files'))
|
||||
for (const child of Wcdb4Client.listDirectories(`${drive}:\\`)) {
|
||||
candidates.push(path.join(child, 'xwechat_files'))
|
||||
candidates.push(path.join(child, 'WeChat Files'))
|
||||
}
|
||||
}
|
||||
return Array.from(new Set(candidates))
|
||||
}
|
||||
return [path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')]
|
||||
}
|
||||
|
||||
private static getWeflowDbPathCandidates(home: string): string[] {
|
||||
const configPaths = [
|
||||
path.join(home, 'AppData', 'Roaming', 'weflow', 'WeFlow-config.json'),
|
||||
path.join(home, 'AppData', 'Roaming', 'WeFlow', 'WeFlow-config.json')
|
||||
]
|
||||
const candidates: string[] = []
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
const config = fs.readJsonSync(configPath) as { dbPath?: unknown }
|
||||
if (typeof config.dbPath === 'string' && config.dbPath.trim()) {
|
||||
candidates.push(config.dbPath.trim())
|
||||
}
|
||||
} catch {
|
||||
// WeFlow is optional; ignore missing or unreadable config.
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
private static getWindowsDrives(): string[] {
|
||||
const drives: string[] = []
|
||||
for (let code = 67; code <= 90; code += 1) {
|
||||
const drive = String.fromCharCode(code)
|
||||
if (fs.existsSync(`${drive}:\\`)) drives.push(drive)
|
||||
}
|
||||
return drives
|
||||
}
|
||||
|
||||
private static listDirectories(root: string): string[] {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(root)
|
||||
.map((name) => path.join(root, name))
|
||||
.filter((candidate) => {
|
||||
try {
|
||||
return fs.statSync(candidate).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private static hasDbStorage(candidate: string): boolean {
|
||||
try {
|
||||
return fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'db_storage'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static isUsableRoot(candidate: string): boolean {
|
||||
if (!fs.existsSync(candidate)) return false
|
||||
if (Wcdb4Client.hasDbStorage(candidate)) return true
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(candidate)
|
||||
.some((name) => Wcdb4Client.hasDbStorage(path.join(candidate, name)))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static hasSessionDb(candidate: string): boolean {
|
||||
return (
|
||||
fs.existsSync(path.join(candidate, 'db_storage', 'session', 'session.db')) ||
|
||||
fs.existsSync(path.join(candidate, 'db_storage', 'session.db'))
|
||||
)
|
||||
}
|
||||
|
||||
private static compareAccountDirs(a: string, b: string): number {
|
||||
const aHasSession = Wcdb4Client.hasSessionDb(a)
|
||||
const bHasSession = Wcdb4Client.hasSessionDb(b)
|
||||
if (aHasSession !== bHasSession) return aHasSession ? -1 : 1
|
||||
try {
|
||||
return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private static cleanAccountDirName(dirName: string): string {
|
||||
const trimmed = dirName.trim()
|
||||
if (!trimmed) return trimmed
|
||||
@@ -236,24 +405,21 @@ export class Wcdb4Client {
|
||||
|
||||
open(): void {
|
||||
this.loadNativeLibrary()
|
||||
if (!this.wcdbInit || !this.wcdbOpenAccount) {
|
||||
if (!this.wcdbOpenAccount) {
|
||||
throw new Error('WCDB 4.0 native 接口未就绪')
|
||||
}
|
||||
|
||||
if (!this.initialized) {
|
||||
const initResult = this.wcdbInit()
|
||||
if (initResult !== 0) {
|
||||
console.warn(`wcdb_init 返回 ${initResult},继续尝试 wcdb_open_account`)
|
||||
} else {
|
||||
this.initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
let openResult = -1
|
||||
const handleOut: WcdbHandleOut = [0]
|
||||
const openResult = this.wcdbOpenAccount(this.sessionDbPath, this.key, handleOut)
|
||||
openResult = this.wcdbOpenAccount(this.sessionDbPath, this.key, handleOut)
|
||||
|
||||
if (openResult !== 0 || handleOut[0] <= 0) {
|
||||
const hint =
|
||||
openResult === -1005
|
||||
? ';这通常表示密钥与当前账号数据库不匹配,请确认微信已登录目标账号,并重新自动获取密钥'
|
||||
: ''
|
||||
throw new Error(
|
||||
`wcdb_open_account 失败,错误码: ${openResult}; sessionDb=${this.sessionDbPath}; accountRoot=${this.accountRoot}; wxid=${this.wxid}`
|
||||
`wcdb_open_account 失败,错误码: ${openResult}${hint}; sessionDb=${this.sessionDbPath}; accountRoot=${this.accountRoot}; wxid=${this.wxid}; keyLength=${this.key.length}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -269,16 +435,17 @@ export class Wcdb4Client {
|
||||
|
||||
close(): void {
|
||||
this.stopMonitor()
|
||||
if (!this.initialized || !this.wcdbShutdown) return
|
||||
if (this.handle === null || !this.wcdbShutdown) return
|
||||
|
||||
try {
|
||||
this.wcdbShutdown()
|
||||
} catch {
|
||||
// Mirror WechatExplorer: shutdown is best-effort on app close.
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
this.wcdbShutdown()
|
||||
} catch {
|
||||
// Shutdown is best-effort on app close.
|
||||
}
|
||||
}
|
||||
|
||||
this.handle = null
|
||||
this.initialized = false
|
||||
this.cachedSessions = null
|
||||
this.displayNameCache.clear()
|
||||
this.avatarCache.clear()
|
||||
@@ -425,36 +592,51 @@ export class Wcdb4Client {
|
||||
if (this.cachedSessions) return this.cachedSessions
|
||||
if (!this.wcdbGetSessions) return []
|
||||
|
||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||
this.wcdbGetSessions!(handle, outJson)
|
||||
)
|
||||
|
||||
const rows = this.readSessionRows()
|
||||
const sessions = (Array.isArray(rows) ? rows : [])
|
||||
.map((row) => this.normalizeSession(row))
|
||||
.filter((session) => session.username)
|
||||
|
||||
const sessionUsernames = sessions.map((session) => session.username)
|
||||
this.hydrateDisplayNames(sessionUsernames)
|
||||
this.hydrateAvatarUrls(sessionUsernames)
|
||||
this.cachedSessions = sessions.map((session) => ({
|
||||
...session,
|
||||
nickname: this.displayNameCache.get(session.username) || session.nickname || session.username,
|
||||
avatar: this.avatarCache.get(session.username)
|
||||
nickname: this.displayNameCache.get(session.username) || session.nickname || session.username
|
||||
}))
|
||||
|
||||
return this.cachedSessions
|
||||
}
|
||||
|
||||
getChatTables(): { name: string; db_number: string }[] {
|
||||
return this.getSessions().map((session) => ({
|
||||
if (this.cachedChatTables) return this.cachedChatTables
|
||||
const sessions =
|
||||
this.cachedSessions ||
|
||||
this.readSessionRows()
|
||||
.map((row) => this.normalizeSession(row))
|
||||
.filter((session) => session.username)
|
||||
this.cachedChatTables = sessions.map((session) => ({
|
||||
name: `Chat_${this.md5(session.username)}`,
|
||||
db_number: session.username
|
||||
}))
|
||||
return this.cachedChatTables
|
||||
}
|
||||
|
||||
getMessages(username: string, startTime?: number, endTime?: number): Wcdb4Message[] {
|
||||
const cursorMessages = this.getMessagesByCursor(username, startTime, endTime)
|
||||
if (cursorMessages) return cursorMessages
|
||||
const startedAt = Date.now()
|
||||
console.log(
|
||||
`[WCDB4] getMessages begin username=${username} start=${startTime || 0} end=${endTime || 0}`
|
||||
)
|
||||
try {
|
||||
const cursorMessages = this.getMessagesByCursor(username, startTime, endTime)
|
||||
if (cursorMessages) {
|
||||
console.log(
|
||||
`[WCDB4] getMessages cursor ok username=${username} rows=${cursorMessages.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
return cursorMessages
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[WCDB4] cursor messages failed username=${username}:`, error)
|
||||
}
|
||||
|
||||
if (!this.wcdbGetMessages) return []
|
||||
|
||||
@@ -463,15 +645,96 @@ export class Wcdb4Client {
|
||||
let offset = 0
|
||||
|
||||
while (true) {
|
||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||
this.wcdbGetMessages!(handle, username, limit, offset, outJson)
|
||||
)
|
||||
let rows: Record<string, unknown>[]
|
||||
try {
|
||||
rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||
this.wcdbGetMessages!(handle, username, limit, offset, outJson)
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[WCDB4] get_messages failed username=${username} offset=${offset}:`,
|
||||
error
|
||||
)
|
||||
break
|
||||
}
|
||||
const batch = Array.isArray(rows) ? rows : []
|
||||
allRows.push(...batch)
|
||||
if (batch.length < limit) break
|
||||
offset += limit
|
||||
}
|
||||
|
||||
if (allRows.length === 0) {
|
||||
const tableRows = this.getMessagesByTableScan(username, startTime, endTime)
|
||||
if (tableRows.length > 0) {
|
||||
console.log(
|
||||
`[WCDB4] getMessages table scan ok username=${username} rows=${tableRows.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
return tableRows
|
||||
}
|
||||
}
|
||||
|
||||
const messages = this.finalizeMessages(username, allRows, startTime, endTime)
|
||||
console.log(
|
||||
`[WCDB4] getMessages direct ok username=${username} rows=${messages.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
return messages
|
||||
}
|
||||
|
||||
private readSessionRows(): Record<string, unknown>[] {
|
||||
if (!this.wcdbGetSessions) return []
|
||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||
this.wcdbGetSessions!(handle, outJson)
|
||||
)
|
||||
return Array.isArray(rows) ? rows : []
|
||||
}
|
||||
|
||||
private getMessagesByTableScan(
|
||||
username: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): Wcdb4Message[] {
|
||||
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
|
||||
|
||||
let tables: { tableName: string; dbPath: string }[] = []
|
||||
try {
|
||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||
this.wcdbGetMessageTableStats!(handle, username, outJson)
|
||||
)
|
||||
tables = (Array.isArray(rows) ? rows : [])
|
||||
.map((row) => ({
|
||||
tableName: this.pickString(row, ['table_name', 'tableName', 'name']),
|
||||
dbPath: this.pickString(row, ['db_path', 'dbPath', 'path'])
|
||||
}))
|
||||
.filter((row) => row.tableName && row.dbPath)
|
||||
} catch (error) {
|
||||
console.warn(`[WCDB4] message table stats failed username=${username}:`, error)
|
||||
return []
|
||||
}
|
||||
|
||||
const allRows: Record<string, unknown>[] = []
|
||||
const begin = this.normalizeTimestamp(startTime || 0)
|
||||
const end = this.normalizeTimestamp(endTime || 0)
|
||||
const where = [
|
||||
begin > 0 ? `"create_time" >= ${begin}` : '',
|
||||
end > 0 ? `"create_time" <= ${end}` : ''
|
||||
].filter(Boolean)
|
||||
const whereSql = where.length ? ` WHERE ${where.join(' AND ')}` : ''
|
||||
|
||||
for (const table of tables) {
|
||||
try {
|
||||
const sql = `SELECT * FROM ${this.quoteSqlIdentifier(table.tableName)}${whereSql} ORDER BY "create_time" ASC LIMIT 5000`
|
||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||
this.wcdbExecQuery!(handle, 'message', table.dbPath, sql, outJson)
|
||||
)
|
||||
if (Array.isArray(rows)) allRows.push(...rows)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[WCDB4] message table scan failed username=${username} db=${table.dbPath} table=${table.tableName}:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return this.finalizeMessages(username, allRows, startTime, endTime)
|
||||
}
|
||||
|
||||
@@ -487,6 +750,17 @@ export class Wcdb4Client {
|
||||
return undefined
|
||||
}
|
||||
|
||||
getAvatarUrls(usernames: string[]): Record<string, string> {
|
||||
const normalized = this.uniq(usernames)
|
||||
this.hydrateAvatarUrls(normalized)
|
||||
const result: Record<string, string> = {}
|
||||
for (const username of normalized) {
|
||||
const avatar = this.avatarCache.get(username)
|
||||
if (avatar) result[username] = avatar
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
getMyGroupNickname(chatroomId: string): string | undefined {
|
||||
const groupNicknames = this.getGroupNicknames(chatroomId)
|
||||
for (const candidate of this.getMyUsernameCandidates()) {
|
||||
@@ -565,9 +839,6 @@ export class Wcdb4Client {
|
||||
endTime?: number
|
||||
): Wcdb4Message[] {
|
||||
const messages = rows.map((row) => this.normalizeMessage(row))
|
||||
const senderIds = messages.map((message) => message.sender || '').filter(Boolean)
|
||||
this.hydrateDisplayNames(senderIds)
|
||||
this.hydrateAvatarUrls(senderIds)
|
||||
|
||||
return messages
|
||||
.filter((message) => {
|
||||
@@ -650,6 +921,12 @@ export class Wcdb4Client {
|
||||
.map((member) => member.m_nsUsrName)
|
||||
.filter(Boolean)
|
||||
this.hydrateDisplayNames(missingDisplayNames)
|
||||
this.hydrateAvatarUrls(
|
||||
members
|
||||
.filter((member) => !member.m_nsHeadImgUrl)
|
||||
.map((member) => member.m_nsUsrName)
|
||||
.filter(Boolean)
|
||||
)
|
||||
return members.map((member) => ({
|
||||
...member,
|
||||
nickname:
|
||||
@@ -815,19 +1092,49 @@ export class Wcdb4Client {
|
||||
|
||||
const libPath = this.findNativeLibrary()
|
||||
const libDir = path.dirname(libPath)
|
||||
const wcdbCorePath = path.join(libDir, 'libWCDB.dylib')
|
||||
if (fs.existsSync(wcdbCorePath)) {
|
||||
try {
|
||||
koffi.load(wcdbCorePath)
|
||||
} catch {
|
||||
// Some builds resolve this dependency through rpath.
|
||||
console.log(
|
||||
`[WCDB4] loadNativeLibrary platform=${process.platform} arch=${process.arch} libPath=${libPath} cwd=${process.cwd()} resourcesPath=${process.resourcesPath || ''} WCDB_RESOURCES_PATH=${process.env.WCDB_RESOURCES_PATH || ''}`
|
||||
)
|
||||
|
||||
// Reuse the module-level bootstrap if main.ts already performed
|
||||
// InitProtection + wcdb_init; WCDB returns -1006 if wcdb_init is called
|
||||
// twice in the same process.
|
||||
let lib: KoffiLibrary
|
||||
if (wcdbBootstrapLib) {
|
||||
console.log('[WCDB4] reusing bootstrap lib, skip koffi.load and wcdb_init')
|
||||
lib = wcdbBootstrapLib
|
||||
} else {
|
||||
const preloadLibraries =
|
||||
process.platform === 'win32'
|
||||
? ['WCDB.dll', 'SDL2.dll']
|
||||
: process.platform === 'darwin'
|
||||
? ['libWCDB.dylib']
|
||||
: []
|
||||
for (const name of preloadLibraries) {
|
||||
const preloadPath = path.join(libDir, name)
|
||||
if (!fs.existsSync(preloadPath)) continue
|
||||
try {
|
||||
koffi.load(preloadPath)
|
||||
console.log(`[WCDB4] preload ok ${preloadPath}`)
|
||||
} catch {
|
||||
console.warn(`[WCDB4] preload failed ${preloadPath}`)
|
||||
// Some builds resolve dependencies through the platform loader path.
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[WCDB4] koffi.load begin ${libPath}`)
|
||||
lib = koffi.load(libPath)
|
||||
console.log(`[WCDB4] koffi.load ok ${libPath}`)
|
||||
this.initProtection(lib, libDir)
|
||||
const wcdbInit = lib.func('int32 wcdb_init()') as () => number
|
||||
console.log('[WCDB4] wcdb_init begin')
|
||||
const initResult = wcdbInit()
|
||||
console.log(`[WCDB4] wcdb_init rc=${initResult}`)
|
||||
if (initResult !== 0) {
|
||||
throw new Error(`wcdb_init 失败,错误码: ${initResult}`)
|
||||
}
|
||||
}
|
||||
|
||||
const lib = koffi.load(libPath)
|
||||
this.initProtection(lib, libDir)
|
||||
|
||||
this.wcdbInit = lib.func('int32 wcdb_init()') as () => number
|
||||
this.wcdbShutdown = lib.func('int32 wcdb_shutdown()') as () => number
|
||||
this.wcdbOpenAccount = lib.func(
|
||||
'int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)'
|
||||
@@ -845,6 +1152,13 @@ export class Wcdb4Client {
|
||||
offset: number,
|
||||
outJson: WcdbVoidOut
|
||||
) => number
|
||||
try {
|
||||
this.wcdbGetMessageTableStats = lib.func(
|
||||
'int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)'
|
||||
) as (handle: number, username: string, outJson: WcdbVoidOut) => number
|
||||
} catch {
|
||||
this.wcdbGetMessageTableStats = null
|
||||
}
|
||||
this.wcdbGetDisplayNames = lib.func(
|
||||
'int32 wcdb_get_display_names(int64 handle, const char* usernamesJson, _Out_ void** outJson)'
|
||||
) as (handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number
|
||||
@@ -986,29 +1300,43 @@ export class Wcdb4Client {
|
||||
let lastCode = -1
|
||||
for (const resourceRoot of resourceRoots) {
|
||||
try {
|
||||
console.log(`[WCDB4] InitProtection call ${resourceRoot}`)
|
||||
lastCode = initProtection(resourceRoot)
|
||||
if (lastCode === 0) return
|
||||
} catch {
|
||||
console.log(`[WCDB4] InitProtection rc=${lastCode} path=${resourceRoot}`)
|
||||
if (lastCode === 0) {
|
||||
console.log(`[WCDB4] InitProtection ok path=${resourceRoot}`)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[WCDB4] InitProtection exception path=${resourceRoot}`, error)
|
||||
// Try next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`InitProtection 返回 ${lastCode},继续尝试 wcdb_init/open; tried=${resourceRoots.join(' | ')}`
|
||||
)
|
||||
throw new Error(`InitProtection 失败,错误码: ${lastCode}; tried=${resourceRoots.join(' | ')}`)
|
||||
}
|
||||
|
||||
private findNativeLibrary(): string {
|
||||
return Wcdb4Client.resolveNativeLibrary()
|
||||
}
|
||||
|
||||
static resolveNativeLibrary(): string {
|
||||
const libName =
|
||||
process.platform === 'darwin'
|
||||
? 'libwcdb_api.dylib'
|
||||
: process.platform === 'linux'
|
||||
? 'libwcdb_api.so'
|
||||
: 'wcdb_api.dll'
|
||||
const platformDir = process.platform === 'darwin' ? 'macos' : process.platform
|
||||
const platformDir =
|
||||
process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'win32' : process.platform
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const resourcesPath = process.resourcesPath || process.cwd()
|
||||
const candidates = [
|
||||
process.env.WCDB_DLL_PATH,
|
||||
path.join(resourcesPath, 'resources', 'wcdb', platformDir, archDir, libName),
|
||||
path.join(resourcesPath, 'resources', 'wcdb', platformDir, 'x64', libName),
|
||||
path.join(process.cwd(), 'resources', 'wcdb', platformDir, archDir, libName),
|
||||
path.join(process.cwd(), 'resources', 'wcdb', platformDir, 'x64', libName),
|
||||
path.join(resourcesPath, 'resources', platformDir, libName),
|
||||
path.join(resourcesPath, 'resources', libName),
|
||||
path.join(process.cwd(), 'resources', platformDir, libName),
|
||||
@@ -1019,6 +1347,11 @@ export class Wcdb4Client {
|
||||
if (!found) {
|
||||
throw new Error(`找不到 WCDB native 库: ${candidates.join(', ')}`)
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
const runtimeDir = path.join(resourcesPath, 'resources', 'runtime', 'win32')
|
||||
const dllDir = path.dirname(found)
|
||||
process.env.PATH = [dllDir, runtimeDir, process.env.PATH || ''].filter(Boolean).join(path.delimiter)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
@@ -1437,4 +1770,8 @@ export class Wcdb4Client {
|
||||
const normalized = input > 1e12 ? Math.floor(input / 1000) : Math.floor(input)
|
||||
return Math.min(Math.max(normalized, 0), 2147483647)
|
||||
}
|
||||
|
||||
private quoteSqlIdentifier(identifier: string): string {
|
||||
return `"${String(identifier || '').replace(/"/g, '""')}"`
|
||||
}
|
||||
}
|
||||
|
||||
+25
-4
@@ -34,12 +34,33 @@ export class WechatDb {
|
||||
private wcdb4Client: Wcdb4Client
|
||||
private chatMd5ToUsername = new Map<string, string>()
|
||||
|
||||
constructor(rawKey: string, accountRoot?: string) {
|
||||
static async create(rawKey: string, accountRoot?: string): Promise<WechatDb> {
|
||||
// WCDB native init must run on the Electron main process; worker threads
|
||||
// get -1006 from wcdb_init. Initialize the client synchronously here
|
||||
// (and keep create() async for callers that already await it).
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const client = new Wcdb4Client(rawKey, accountRoot)
|
||||
client.open()
|
||||
resolve(new WechatDb(rawKey, accountRoot, client))
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
constructor(
|
||||
rawKey: string,
|
||||
accountRoot?: string,
|
||||
clientOverride?: Wcdb4Client,
|
||||
initialChatTables?: { name: string; db_number: string }[]
|
||||
) {
|
||||
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
|
||||
const client = new Wcdb4Client(rawKey, accountRoot)
|
||||
client.open()
|
||||
const client =
|
||||
clientOverride || new Wcdb4Client(rawKey, accountRoot)
|
||||
if (!clientOverride) client.open()
|
||||
this.wcdb4Client = client
|
||||
for (const table of client.getChatTables()) {
|
||||
for (const table of initialChatTables || client.getChatTables()) {
|
||||
if (table.name.startsWith('Chat_')) {
|
||||
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user