mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
14
electron/entitlements.mac.plist
Normal file
14
electron/entitlements.mac.plist
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.debugger</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.get-task-allow</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -16,6 +16,7 @@ import { groupAnalyticsService } from './services/groupAnalyticsService'
|
|||||||
import { annualReportService } from './services/annualReportService'
|
import { annualReportService } from './services/annualReportService'
|
||||||
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
||||||
import { KeyService } from './services/keyService'
|
import { KeyService } from './services/keyService'
|
||||||
|
import { KeyServiceMac } from './services/keyServiceMac'
|
||||||
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||||
import { videoService } from './services/videoService'
|
import { videoService } from './services/videoService'
|
||||||
import { snsService, isVideoUrl } from './services/snsService'
|
import { snsService, isVideoUrl } from './services/snsService'
|
||||||
@@ -88,7 +89,9 @@ let onboardingWindow: BrowserWindow | null = null
|
|||||||
let splashWindow: BrowserWindow | null = null
|
let splashWindow: BrowserWindow | null = null
|
||||||
const sessionChatWindows = new Map<string, BrowserWindow>()
|
const sessionChatWindows = new Map<string, BrowserWindow>()
|
||||||
const sessionChatWindowSources = new Map<string, 'chat' | 'export'>()
|
const sessionChatWindowSources = new Map<string, 'chat' | 'export'>()
|
||||||
const keyService = new KeyService()
|
const keyService = process.platform === 'darwin'
|
||||||
|
? new KeyServiceMac() as any
|
||||||
|
: new KeyService()
|
||||||
|
|
||||||
let mainWindowReady = false
|
let mainWindowReady = false
|
||||||
let shouldShowMain = true
|
let shouldShowMain = true
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ class CloudControlService {
|
|||||||
return `Windows ${release}`
|
return `Windows ${release}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
return `macOS ${os.release()}`
|
||||||
|
}
|
||||||
|
|
||||||
return platform
|
return platform
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,13 @@ export class DbPathService {
|
|||||||
const possiblePaths: string[] = []
|
const possiblePaths: string[] = []
|
||||||
const home = homedir()
|
const home = homedir()
|
||||||
|
|
||||||
// 微信4.x 数据目录
|
// macOS 微信路径(固定)
|
||||||
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
if (process.platform === 'darwin') {
|
||||||
|
possiblePaths.push(join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files'))
|
||||||
|
} else {
|
||||||
|
// Windows 微信4.x 数据目录
|
||||||
|
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
for (const path of possiblePaths) {
|
for (const path of possiblePaths) {
|
||||||
@@ -193,6 +198,9 @@ export class DbPathService {
|
|||||||
*/
|
*/
|
||||||
getDefaultPath(): string {
|
getDefaultPath(): string {
|
||||||
const home = homedir()
|
const home = homedir()
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')
|
||||||
|
}
|
||||||
return join(home, 'Documents', 'xwechat_files')
|
return join(home, 'Documents', 'xwechat_files')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: stri
|
|||||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
||||||
|
|
||||||
export class KeyService {
|
export class KeyService {
|
||||||
|
private readonly isMac = process.platform === 'darwin'
|
||||||
private koffi: any = null
|
private koffi: any = null
|
||||||
private lib: any = null
|
private lib: any = null
|
||||||
private initialized = false
|
private initialized = false
|
||||||
|
|||||||
485
electron/services/keyServiceMac.ts
Normal file
485
electron/services/keyServiceMac.ts
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
import { app, shell } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
||||||
|
import { execFile, spawn } from 'child_process'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
|
||||||
|
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
||||||
|
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
|
export class KeyServiceMac {
|
||||||
|
private koffi: any = null
|
||||||
|
private lib: any = null
|
||||||
|
private initialized = false
|
||||||
|
|
||||||
|
private GetDbKey: any = null
|
||||||
|
private ScanMemoryForImageKey: any = null
|
||||||
|
private FreeString: any = null
|
||||||
|
private ListWeChatProcesses: any = null
|
||||||
|
|
||||||
|
private getHelperPath(): string {
|
||||||
|
const isPackaged = app.isPackaged
|
||||||
|
const candidates: string[] = []
|
||||||
|
|
||||||
|
if (process.env.WX_KEY_HELPER_PATH) {
|
||||||
|
candidates.push(process.env.WX_KEY_HELPER_PATH)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPackaged) {
|
||||||
|
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper'))
|
||||||
|
candidates.push(join(process.resourcesPath, 'xkey_helper'))
|
||||||
|
} else {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
candidates.push(join(cwd, 'resources', 'xkey_helper'))
|
||||||
|
candidates.push(join(cwd, 'Xkey', 'build', 'xkey_helper'))
|
||||||
|
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper'))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of candidates) {
|
||||||
|
if (existsSync(path)) return path
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('xkey_helper not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDylibPath(): string {
|
||||||
|
const isPackaged = app.isPackaged
|
||||||
|
const candidates: string[] = []
|
||||||
|
|
||||||
|
if (process.env.WX_KEY_DYLIB_PATH) {
|
||||||
|
candidates.push(process.env.WX_KEY_DYLIB_PATH)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPackaged) {
|
||||||
|
candidates.push(join(process.resourcesPath, 'resources', 'libwx_key.dylib'))
|
||||||
|
candidates.push(join(process.resourcesPath, 'libwx_key.dylib'))
|
||||||
|
} else {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
candidates.push(join(cwd, 'resources', 'libwx_key.dylib'))
|
||||||
|
candidates.push(join(app.getAppPath(), 'resources', 'libwx_key.dylib'))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of candidates) {
|
||||||
|
if (existsSync(path)) return path
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('libwx_key.dylib not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (this.initialized) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.koffi = require('koffi')
|
||||||
|
const dylibPath = this.getDylibPath()
|
||||||
|
|
||||||
|
if (!existsSync(dylibPath)) {
|
||||||
|
throw new Error('libwx_key.dylib not found: ' + dylibPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lib = this.koffi.load(dylibPath)
|
||||||
|
|
||||||
|
this.GetDbKey = this.lib.func('const char* GetDbKey()')
|
||||||
|
this.ScanMemoryForImageKey = this.lib.func('const char* ScanMemoryForImageKey(int pid, const char* ciphertext)')
|
||||||
|
this.FreeString = this.lib.func('void FreeString(const char* str)')
|
||||||
|
this.ListWeChatProcesses = this.lib.func('const char* ListWeChatProcesses()')
|
||||||
|
|
||||||
|
this.initialized = true
|
||||||
|
} catch (e: any) {
|
||||||
|
throw new Error('Failed to initialize KeyServiceMac: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async autoGetDbKey(
|
||||||
|
timeoutMs = 60_000,
|
||||||
|
onStatus?: (message: string, level: number) => void
|
||||||
|
): Promise<DbKeyResult> {
|
||||||
|
try {
|
||||||
|
onStatus?.('正在获取数据库密钥...', 0)
|
||||||
|
let parsed = await this.getDbKeyParsed(timeoutMs, onStatus)
|
||||||
|
console.log('[KeyServiceMac] GetDbKey returned:', parsed.raw)
|
||||||
|
|
||||||
|
// ATTACH_FAILED 时自动走图形化授权,再重试一次
|
||||||
|
if (!parsed.success && parsed.code === 'ATTACH_FAILED') {
|
||||||
|
onStatus?.('检测到调试权限不足,正在请求系统授权...', 0)
|
||||||
|
const permissionOk = await this.enableDebugPermissionWithPrompt()
|
||||||
|
if (permissionOk) {
|
||||||
|
onStatus?.('授权完成,正在重试获取密钥...', 0)
|
||||||
|
parsed = await this.getDbKeyParsed(timeoutMs, onStatus)
|
||||||
|
console.log('[KeyServiceMac] GetDbKey retry returned:', parsed.raw)
|
||||||
|
} else {
|
||||||
|
onStatus?.('已取消系统授权', 2)
|
||||||
|
return { success: false, error: '已取消系统授权' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.success && parsed.code === 'ATTACH_FAILED') {
|
||||||
|
// DevToolsSecurity 仍不足时,自动拉起开发者工具权限页面
|
||||||
|
await this.openDeveloperToolsPrivacySettings()
|
||||||
|
await this.revealCurrentExecutableInFinder()
|
||||||
|
const msg = `无法附加到微信进程。已打开“开发者工具”设置,并在访达中定位当前运行程序。\n请在“隐私与安全性 -> 开发者工具”点击“+”添加并允许:${process.execPath}`
|
||||||
|
onStatus?.(msg, 2)
|
||||||
|
return { success: false, error: msg }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
const errorMsg = this.mapDbKeyErrorMessage(parsed.code, parsed.detail)
|
||||||
|
onStatus?.(errorMsg, 2)
|
||||||
|
return { success: false, error: errorMsg }
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus?.('密钥获取成功', 1)
|
||||||
|
return { success: true, key: parsed.key }
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[KeyServiceMac] Error:', e)
|
||||||
|
console.error('[KeyServiceMac] Stack:', e.stack)
|
||||||
|
onStatus?.('获取失败: ' + e.message, 2)
|
||||||
|
return { success: false, error: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDbKeyResult(raw: any): { success: boolean; key?: string; code?: string; detail?: string; raw: string } {
|
||||||
|
const text = typeof raw === 'string' ? raw : ''
|
||||||
|
if (!text) return { success: false, code: 'UNKNOWN', raw: text }
|
||||||
|
if (!text.startsWith('ERROR:')) return { success: true, key: text, raw: text }
|
||||||
|
|
||||||
|
const parts = text.split(':')
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: parts[1] || 'UNKNOWN',
|
||||||
|
detail: parts.slice(2).join(':') || undefined,
|
||||||
|
raw: text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDbKeyParsed(
|
||||||
|
timeoutMs: number,
|
||||||
|
onStatus?: (message: string, level: number) => void
|
||||||
|
): Promise<{ success: boolean; key?: string; code?: string; detail?: string; raw: string }> {
|
||||||
|
try {
|
||||||
|
const helperResult = await this.getDbKeyByHelper(timeoutMs, onStatus)
|
||||||
|
return this.parseDbKeyResult(helperResult)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn('[KeyServiceMac] helper unavailable, fallback to dylib:', e?.message || e)
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.initialize()
|
||||||
|
}
|
||||||
|
return this.parseDbKeyResult(this.GetDbKey())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDbKeyByHelper(
|
||||||
|
timeoutMs: number,
|
||||||
|
onStatus?: (message: string, level: number) => void
|
||||||
|
): Promise<string> {
|
||||||
|
const helperPath = this.getHelperPath()
|
||||||
|
const waitMs = Math.max(timeoutMs, 30_000)
|
||||||
|
return await new Promise<string>((resolve, reject) => {
|
||||||
|
const child = spawn(helperPath, [String(waitMs)], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
let stdoutBuf = ''
|
||||||
|
let stderrBuf = ''
|
||||||
|
let settled = false
|
||||||
|
let killTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let pidNotified = false
|
||||||
|
let locatedNotified = false
|
||||||
|
let hookNotified = false
|
||||||
|
|
||||||
|
const done = (fn: () => void) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
if (killTimer) clearTimeout(killTimer)
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
const processHelperLine = (line: string) => {
|
||||||
|
if (!line) return
|
||||||
|
console.log('[KeyServiceMac][helper][stderr]', line)
|
||||||
|
const pidMatch = line.match(/Selected PID=(\d+)/)
|
||||||
|
if (pidMatch && !pidNotified) {
|
||||||
|
pidNotified = true
|
||||||
|
onStatus?.(`已找到微信进程 PID=${pidMatch[1]},正在定位目标函数...`, 0)
|
||||||
|
}
|
||||||
|
if (!locatedNotified && (line.includes('strict hit=') || line.includes('sink matched by strict semantic signature'))) {
|
||||||
|
locatedNotified = true
|
||||||
|
onStatus?.('已定位到目标函数,正在安装 Hook...', 0)
|
||||||
|
}
|
||||||
|
if (line.includes('hook installed @')) {
|
||||||
|
hookNotified = true
|
||||||
|
onStatus?.('Hook 已安装,等待微信触发密钥调用...', 0)
|
||||||
|
}
|
||||||
|
if (line.includes('[MASTER] hex64=')) {
|
||||||
|
onStatus?.('检测到密钥回调,正在回填...', 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout.on('data', (chunk: Buffer | string) => {
|
||||||
|
const data = chunk.toString()
|
||||||
|
stdout += data
|
||||||
|
stdoutBuf += data
|
||||||
|
const parts = stdoutBuf.split(/\r?\n/)
|
||||||
|
stdoutBuf = parts.pop() || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
child.stderr.on('data', (chunk: Buffer | string) => {
|
||||||
|
const data = chunk.toString()
|
||||||
|
stderr += data
|
||||||
|
stderrBuf += data
|
||||||
|
const parts = stderrBuf.split(/\r?\n/)
|
||||||
|
stderrBuf = parts.pop() || ''
|
||||||
|
for (const line of parts) processHelperLine(line.trim())
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
done(() => reject(err))
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('close', () => {
|
||||||
|
if (stderrBuf.trim()) processHelperLine(stderrBuf.trim())
|
||||||
|
|
||||||
|
const lines = stdout.split(/\r?\n/).map(x => x.trim()).filter(Boolean)
|
||||||
|
const last = lines[lines.length - 1]
|
||||||
|
if (!last) {
|
||||||
|
done(() => reject(new Error(stderr.trim() || 'helper returned empty output')))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: any
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(last)
|
||||||
|
} catch {
|
||||||
|
done(() => reject(new Error('helper returned invalid json: ' + last)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload?.success === true && typeof payload?.key === 'string') {
|
||||||
|
if (!hookNotified) {
|
||||||
|
onStatus?.('Hook 已触发,正在回填密钥...', 0)
|
||||||
|
}
|
||||||
|
done(() => resolve(payload.key))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof payload?.result === 'string') {
|
||||||
|
done(() => resolve(payload.result))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
done(() => reject(new Error('helper json missing key/result')))
|
||||||
|
})
|
||||||
|
|
||||||
|
killTimer = setTimeout(() => {
|
||||||
|
try { child.kill('SIGTERM') } catch { }
|
||||||
|
done(() => reject(new Error(`helper timeout after ${waitMs}ms`)))
|
||||||
|
}, waitMs + 10_000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapDbKeyErrorMessage(code?: string, detail?: string): string {
|
||||||
|
if (code === 'PROCESS_NOT_FOUND') return '微信进程未运行'
|
||||||
|
if (code === 'ATTACH_FAILED') {
|
||||||
|
const isDevElectron = process.execPath.includes('/node_modules/electron/')
|
||||||
|
if ((detail || '').includes('task_for_pid:5')) {
|
||||||
|
if (isDevElectron) {
|
||||||
|
return `无法附加到微信进程(task_for_pid 被拒绝)。当前为开发环境 Electron:${process.execPath}\n建议使用打包后的 WeFlow.app(已携带调试 entitlements)再重试。`
|
||||||
|
}
|
||||||
|
return '无法附加到微信进程(task_for_pid 被系统拒绝)。请确认当前运行程序已正确签名并包含调试 entitlements。'
|
||||||
|
}
|
||||||
|
return `无法附加到进程 (${detail || ''})`
|
||||||
|
}
|
||||||
|
if (code === 'FRIDA_FAILED') {
|
||||||
|
if ((detail || '').includes('FRIDA_TIMEOUT')) {
|
||||||
|
return '定位已成功但在等待时间内未捕获到密钥调用。请保持微信前台并进行一次会话/数据库访问后重试。'
|
||||||
|
}
|
||||||
|
return `Frida 语义定位失败 (${detail || ''})`
|
||||||
|
}
|
||||||
|
if (code === 'HOOK_FAILED') {
|
||||||
|
if ((detail || '').includes('HOOK_TIMEOUT')) {
|
||||||
|
return 'Hook 已安装,但在等待时间内未触发目标函数。请保持微信前台并执行一次会话/数据库访问后重试。'
|
||||||
|
}
|
||||||
|
if ((detail || '').includes('attach_wait_timeout')) {
|
||||||
|
return '附加调试器超时,未能进入 Hook 阶段。请确认微信处于可交互状态并重试。'
|
||||||
|
}
|
||||||
|
return `原生 Hook 失败 (${detail || ''})`
|
||||||
|
}
|
||||||
|
if (code === 'HOOK_TARGET_ONLY') {
|
||||||
|
return `已定位到目标函数地址(${detail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。`
|
||||||
|
}
|
||||||
|
if (code === 'SCAN_FAILED') return '内存扫描失败'
|
||||||
|
return '未知错误'
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enableDebugPermissionWithPrompt(): Promise<boolean> {
|
||||||
|
const script = [
|
||||||
|
'do shell script "/usr/sbin/DevToolsSecurity -enable" with administrator privileges'
|
||||||
|
]
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execFileAsync('osascript', script.flatMap(line => ['-e', line]), {
|
||||||
|
timeout: 30_000
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = `${e?.stderr || ''}\n${e?.message || ''}`
|
||||||
|
const cancelled = msg.includes('User canceled') || msg.includes('(-128)')
|
||||||
|
if (!cancelled) {
|
||||||
|
console.error('[KeyServiceMac] enableDebugPermissionWithPrompt failed:', msg)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openDeveloperToolsPrivacySettings(): Promise<void> {
|
||||||
|
const url = 'x-apple.systempreferences:com.apple.preference.security?Privacy_DevTools'
|
||||||
|
try {
|
||||||
|
await shell.openExternal(url)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[KeyServiceMac] Failed to open settings page:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async revealCurrentExecutableInFinder(): Promise<void> {
|
||||||
|
try {
|
||||||
|
shell.showItemInFolder(process.execPath)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[KeyServiceMac] Failed to reveal executable in Finder:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async autoGetImageKey(
|
||||||
|
accountPath?: string,
|
||||||
|
onStatus?: (message: string) => void,
|
||||||
|
wxid?: string
|
||||||
|
): Promise<ImageKeyResult> {
|
||||||
|
onStatus?.('macOS 请使用内存扫描方式')
|
||||||
|
return { success: false, error: 'macOS 请使用内存扫描方式' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async autoGetImageKeyByMemoryScan(
|
||||||
|
userDir: string,
|
||||||
|
onProgress?: (message: string) => void
|
||||||
|
): Promise<ImageKeyResult> {
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 查找模板文件获取密文和 XOR 密钥
|
||||||
|
onProgress?.('正在查找模板文件...')
|
||||||
|
let result = await this._findTemplateData(userDir, 32)
|
||||||
|
let { ciphertext, xorKey } = result
|
||||||
|
|
||||||
|
if (ciphertext && xorKey === null) {
|
||||||
|
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
|
||||||
|
result = await this._findTemplateData(userDir, 100)
|
||||||
|
xorKey = result.xorKey
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
|
||||||
|
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥' }
|
||||||
|
|
||||||
|
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||||
|
|
||||||
|
// 2. 找微信 PID
|
||||||
|
const pid = await this.findWeChatPid()
|
||||||
|
if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' }
|
||||||
|
|
||||||
|
onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`)
|
||||||
|
|
||||||
|
// 3. 持续轮询内存扫描
|
||||||
|
const deadline = Date.now() + 60_000
|
||||||
|
let scanCount = 0
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
scanCount++
|
||||||
|
onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`)
|
||||||
|
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext)
|
||||||
|
if (aesKey) {
|
||||||
|
onProgress?.('密钥获取成功')
|
||||||
|
return { success: true, xorKey, aesKey }
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 5000))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: '60 秒内未找到 AES 密钥' }
|
||||||
|
} catch (e: any) {
|
||||||
|
return { success: false, error: `内存扫描失败: ${e.message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
|
||||||
|
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
||||||
|
|
||||||
|
const collect = (dir: string, results: string[], maxFiles: number) => {
|
||||||
|
if (results.length >= maxFiles) return
|
||||||
|
try {
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (results.length >= maxFiles) break
|
||||||
|
const full = join(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) collect(full, results, maxFiles)
|
||||||
|
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
const files: string[] = []
|
||||||
|
collect(userDir, files, limit)
|
||||||
|
|
||||||
|
files.sort((a, b) => {
|
||||||
|
try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
let ciphertext: Buffer | null = null
|
||||||
|
const tailCounts: Record<string, number> = {}
|
||||||
|
|
||||||
|
for (const f of files.slice(0, 32)) {
|
||||||
|
try {
|
||||||
|
const data = readFileSync(f)
|
||||||
|
if (data.length < 8) continue
|
||||||
|
|
||||||
|
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
|
||||||
|
const key = `${data[data.length - 2]}_${data[data.length - 1]}`
|
||||||
|
tailCounts[key] = (tailCounts[key] ?? 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) {
|
||||||
|
ciphertext = data.subarray(0xF, 0x1F)
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
let xorKey: number | null = null
|
||||||
|
let maxCount = 0
|
||||||
|
for (const [key, count] of Object.entries(tailCounts)) {
|
||||||
|
if (count > maxCount) {
|
||||||
|
maxCount = count
|
||||||
|
const [x, y] = key.split('_').map(Number)
|
||||||
|
const k = x ^ 0xFF
|
||||||
|
if (k === (y ^ 0xD9)) xorKey = k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ciphertext, xorKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _scanMemoryForAesKey(pid: number, ciphertext: Buffer): Promise<string | null> {
|
||||||
|
const ciphertextHex = ciphertext.toString('hex')
|
||||||
|
const aesKey = this.ScanMemoryForImageKey(pid, ciphertextHex)
|
||||||
|
return aesKey || null
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findWeChatPid(): Promise<number | null> {
|
||||||
|
const { execSync } = await import('child_process')
|
||||||
|
try {
|
||||||
|
const output = execSync('pgrep -x WeChat', { encoding: 'utf8' })
|
||||||
|
const pid = parseInt(output.trim())
|
||||||
|
return isNaN(pid) ? null : pid
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup(): void {
|
||||||
|
this.lib = null
|
||||||
|
this.initialized = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { join, dirname, basename } from 'path'
|
import { join, dirname, basename } from 'path'
|
||||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
|
||||||
// DLL 初始化错误信息,用于帮助用户诊断问题
|
// DLL 初始化错误信息,用于帮助用户诊断问题
|
||||||
let lastDllInitError: string | null = null
|
let lastDllInitError: string | null = null
|
||||||
@@ -60,6 +61,7 @@ export class WcdbCore {
|
|||||||
private currentPath: string | null = null
|
private currentPath: string | null = null
|
||||||
private currentKey: string | null = null
|
private currentKey: string | null = null
|
||||||
private currentWxid: string | null = null
|
private currentWxid: string | null = null
|
||||||
|
private currentDbStoragePath: string | null = null
|
||||||
|
|
||||||
// 函数引用
|
// 函数引用
|
||||||
private wcdbInitProtection: any = null
|
private wcdbInitProtection: any = null
|
||||||
@@ -128,14 +130,17 @@ export class WcdbCore {
|
|||||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||||
private logTimer: NodeJS.Timeout | null = null
|
private logTimer: NodeJS.Timeout | null = null
|
||||||
private lastLogTail: string | null = null
|
private lastLogTail: string | null = null
|
||||||
|
private lastResolvedLogPath: string | null = null
|
||||||
|
|
||||||
setPaths(resourcesPath: string, userDataPath: string): void {
|
setPaths(resourcesPath: string, userDataPath: string): void {
|
||||||
this.resourcesPath = resourcesPath
|
this.resourcesPath = resourcesPath
|
||||||
this.userDataPath = userDataPath
|
this.userDataPath = userDataPath
|
||||||
|
this.writeLog(`[bootstrap] setPaths resourcesPath=${resourcesPath} userDataPath=${userDataPath}`, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
setLogEnabled(enabled: boolean): void {
|
setLogEnabled(enabled: boolean): void {
|
||||||
this.logEnabled = enabled
|
this.logEnabled = enabled
|
||||||
|
this.writeLog(`[bootstrap] setLogEnabled=${enabled ? '1' : '0'} env.WCDB_LOG_ENABLED=${process.env.WCDB_LOG_ENABLED || ''}`, true)
|
||||||
if (this.isLogEnabled() && this.initialized) {
|
if (this.isLogEnabled() && this.initialized) {
|
||||||
this.startLogPolling()
|
this.startLogPolling()
|
||||||
} else {
|
} else {
|
||||||
@@ -143,8 +148,13 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用命名管道 IPC
|
// 使用命名管道 IPC (仅 Windows)
|
||||||
startMonitor(callback: (type: string, json: string) => void): boolean {
|
startMonitor(callback: (type: string, json: string) => void): boolean {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
console.warn('[wcdbCore] Monitor not supported on macOS')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.wcdbStartMonitorPipe) {
|
if (!this.wcdbStartMonitorPipe) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -251,9 +261,13 @@ export class WcdbCore {
|
|||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 DLL 路径
|
* 获取库文件路径(跨平台)
|
||||||
*/
|
*/
|
||||||
private getDllPath(): string {
|
private getDllPath(): string {
|
||||||
|
const isMac = process.platform === 'darwin'
|
||||||
|
const libName = isMac ? 'libwcdb_api.dylib' : 'wcdb_api.dll'
|
||||||
|
const subDir = isMac ? 'macos' : ''
|
||||||
|
|
||||||
const envDllPath = process.env.WCDB_DLL_PATH
|
const envDllPath = process.env.WCDB_DLL_PATH
|
||||||
if (envDllPath && envDllPath.length > 0) {
|
if (envDllPath && envDllPath.length > 0) {
|
||||||
return envDllPath
|
return envDllPath
|
||||||
@@ -265,22 +279,22 @@ export class WcdbCore {
|
|||||||
|
|
||||||
const candidates = [
|
const candidates = [
|
||||||
// 环境变量指定 resource 目录
|
// 环境变量指定 resource 目录
|
||||||
process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, 'wcdb_api.dll') : null,
|
process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, subDir, libName) : null,
|
||||||
// 显式 setPaths 设置的路径
|
// 显式 setPaths 设置的路径
|
||||||
this.resourcesPath ? join(this.resourcesPath, 'wcdb_api.dll') : null,
|
this.resourcesPath ? join(this.resourcesPath, subDir, libName) : null,
|
||||||
// text/resources/wcdb_api.dll (打包常见结构)
|
// resources/macos/libwcdb_api.dylib 或 resources/wcdb_api.dll
|
||||||
join(resourcesPath, 'resources', 'wcdb_api.dll'),
|
join(resourcesPath, 'resources', subDir, libName),
|
||||||
// items/resourcesPath/wcdb_api.dll (扁平结构)
|
// resources/libwcdb_api.dylib 或 resources/wcdb_api.dll (扁平结构)
|
||||||
join(resourcesPath, 'wcdb_api.dll'),
|
join(resourcesPath, subDir, libName),
|
||||||
// CWD fallback
|
// CWD fallback
|
||||||
join(process.cwd(), 'resources', 'wcdb_api.dll')
|
join(process.cwd(), 'resources', subDir, libName)
|
||||||
].filter(Boolean) as string[]
|
].filter(Boolean) as string[]
|
||||||
|
|
||||||
for (const path of candidates) {
|
for (const path of candidates) {
|
||||||
if (existsSync(path)) return path
|
if (existsSync(path)) return path
|
||||||
}
|
}
|
||||||
|
|
||||||
return candidates[0] || 'wcdb_api.dll'
|
return candidates[0] || libName
|
||||||
}
|
}
|
||||||
|
|
||||||
private isLogEnabled(): boolean {
|
private isLogEnabled(): boolean {
|
||||||
@@ -292,14 +306,97 @@ export class WcdbCore {
|
|||||||
private writeLog(message: string, force = false): void {
|
private writeLog(message: string, force = false): void {
|
||||||
if (!force && !this.isLogEnabled()) return
|
if (!force && !this.isLogEnabled()) return
|
||||||
const line = `[${new Date().toISOString()}] ${message}`
|
const line = `[${new Date().toISOString()}] ${message}`
|
||||||
// 同时输出到控制台和文件
|
|
||||||
|
|
||||||
|
const candidates: string[] = []
|
||||||
|
if (this.userDataPath) candidates.push(join(this.userDataPath, 'logs', 'wcdb.log'))
|
||||||
|
if (process.env.WCDB_LOG_DIR) candidates.push(join(process.env.WCDB_LOG_DIR, 'logs', 'wcdb.log'))
|
||||||
|
candidates.push(join(process.cwd(), 'logs', 'wcdb.log'))
|
||||||
|
candidates.push(join(tmpdir(), 'weflow-wcdb.log'))
|
||||||
|
|
||||||
|
const uniq = Array.from(new Set(candidates))
|
||||||
|
for (const filePath of uniq) {
|
||||||
|
try {
|
||||||
|
const dir = dirname(filePath)
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||||
|
appendFileSync(filePath, line + '\n', { encoding: 'utf8' })
|
||||||
|
this.lastResolvedLogPath = filePath
|
||||||
|
return
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[wcdbCore] writeLog failed path=${filePath}:`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[wcdbCore] writeLog failed for all candidates:', uniq.join(' | '))
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatSqlForLog(sql: string, maxLen = 240): string {
|
||||||
|
const compact = String(sql || '').replace(/\s+/g, ' ').trim()
|
||||||
|
if (compact.length <= maxLen) return compact
|
||||||
|
return compact.slice(0, maxLen) + '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dumpDbStatus(tag: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
|
if (!this.ensureReady()) {
|
||||||
const dir = join(base, 'logs')
|
this.writeLog(`[diag:${tag}] db_status skipped: not connected`, true)
|
||||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
return
|
||||||
appendFileSync(join(dir, 'wcdb.log'), line + '\n', { encoding: 'utf8' })
|
}
|
||||||
} catch { }
|
if (!this.wcdbGetDbStatus) {
|
||||||
|
this.writeLog(`[diag:${tag}] db_status skipped: api not supported`, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const rc = this.wcdbGetDbStatus(this.handle, outPtr)
|
||||||
|
if (rc !== 0 || !outPtr[0]) {
|
||||||
|
this.writeLog(`[diag:${tag}] db_status failed rc=${rc} outPtr=${outPtr[0] ? 'set' : 'null'}`, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (!jsonStr) {
|
||||||
|
this.writeLog(`[diag:${tag}] db_status decode failed`, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.writeLog(`[diag:${tag}] db_status=${jsonStr}`, true)
|
||||||
|
} catch (e) {
|
||||||
|
this.writeLog(`[diag:${tag}] db_status exception: ${String(e)}`, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runPostOpenDiagnostics(dbPath: string, dbStoragePath: string | null, sessionDbPath: string | null, wxid: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.writeLog(`[diag:open] input dbPath=${dbPath} wxid=${wxid}`, true)
|
||||||
|
this.writeLog(`[diag:open] resolved dbStorage=${dbStoragePath || 'null'}`, true)
|
||||||
|
this.writeLog(`[diag:open] resolved sessionDb=${sessionDbPath || 'null'}`, true)
|
||||||
|
if (!dbStoragePath) return
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(dbStoragePath)
|
||||||
|
const sample = entries.slice(0, 20).join(',')
|
||||||
|
this.writeLog(`[diag:open] dbStorage entries(${entries.length}) sample=${sample}`, true)
|
||||||
|
} catch (e) {
|
||||||
|
this.writeLog(`[diag:open] list dbStorage failed: ${String(e)}`, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactProbe = await this.execQuery(
|
||||||
|
'contact',
|
||||||
|
null,
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name LIMIT 50"
|
||||||
|
)
|
||||||
|
if (contactProbe.success) {
|
||||||
|
const names = (contactProbe.rows || []).map((r: any) => String(r?.name || '')).filter(Boolean)
|
||||||
|
this.writeLog(`[diag:open] contact sqlite_master rows=${names.length} names=${names.join(',')}`, true)
|
||||||
|
} else {
|
||||||
|
this.writeLog(`[diag:open] contact sqlite_master failed: ${contactProbe.error || 'unknown'}`, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactCount = await this.execQuery('contact', null, 'SELECT COUNT(1) AS cnt FROM contact')
|
||||||
|
if (contactCount.success && Array.isArray(contactCount.rows) && contactCount.rows.length > 0) {
|
||||||
|
this.writeLog(`[diag:open] contact count=${String((contactCount.rows[0] as any)?.cnt ?? '')}`, true)
|
||||||
|
} else {
|
||||||
|
this.writeLog(`[diag:open] contact count failed: ${contactCount.error || 'unknown'}`, true)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.writeLog(`[diag:open] post-open diagnostics exception: ${String(e)}`, true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -376,6 +473,51 @@ export class WcdbCore {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isRealDbFileName(name: string): boolean {
|
||||||
|
const lower = String(name || '').toLowerCase()
|
||||||
|
if (!lower.endsWith('.db')) return false
|
||||||
|
if (lower.endsWith('.db-shm')) return false
|
||||||
|
if (lower.endsWith('.db-wal')) return false
|
||||||
|
if (lower.endsWith('.db-journal')) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveContactDbPath(): string | null {
|
||||||
|
const dbStorage = this.currentDbStoragePath || this.resolveDbStoragePath(this.currentPath || '', this.currentWxid || '')
|
||||||
|
if (!dbStorage) return null
|
||||||
|
const contactDir = join(dbStorage, 'Contact')
|
||||||
|
if (!existsSync(contactDir)) return null
|
||||||
|
|
||||||
|
const preferred = [
|
||||||
|
join(contactDir, 'contact.db'),
|
||||||
|
join(contactDir, 'Contact.db')
|
||||||
|
]
|
||||||
|
for (const p of preferred) {
|
||||||
|
if (existsSync(p)) return p
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(contactDir)
|
||||||
|
const cands = entries
|
||||||
|
.filter((name) => this.isRealDbFileName(name))
|
||||||
|
.map((name) => join(contactDir, name))
|
||||||
|
if (cands.length > 0) return cands[0]
|
||||||
|
} catch { }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickFirstStringField(row: Record<string, any>, candidates: string[]): string {
|
||||||
|
for (const key of candidates) {
|
||||||
|
const v = row[key]
|
||||||
|
if (typeof v === 'string' && v.trim()) return v
|
||||||
|
if (v !== null && v !== undefined) {
|
||||||
|
const s = String(v).trim()
|
||||||
|
if (s) return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化 WCDB
|
* 初始化 WCDB
|
||||||
*/
|
*/
|
||||||
@@ -385,31 +527,49 @@ export class WcdbCore {
|
|||||||
try {
|
try {
|
||||||
this.koffi = require('koffi')
|
this.koffi = require('koffi')
|
||||||
const dllPath = this.getDllPath()
|
const dllPath = this.getDllPath()
|
||||||
|
this.writeLog(`[bootstrap] initialize platform=${process.platform} dllPath=${dllPath} resourcesPath=${this.resourcesPath || ''} userDataPath=${this.userDataPath || ''}`, true)
|
||||||
|
|
||||||
if (!existsSync(dllPath)) {
|
if (!existsSync(dllPath)) {
|
||||||
console.error('WCDB DLL 不存在:', dllPath)
|
console.error('WCDB DLL 不存在:', dllPath)
|
||||||
|
this.writeLog(`[bootstrap] initialize failed: dll not found path=${dllPath}`, true)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const dllDir = dirname(dllPath)
|
const dllDir = dirname(dllPath)
|
||||||
const wcdbCorePath = join(dllDir, 'WCDB.dll')
|
const isMac = process.platform === 'darwin'
|
||||||
if (existsSync(wcdbCorePath)) {
|
|
||||||
try {
|
// 预加载依赖库
|
||||||
this.koffi.load(wcdbCorePath)
|
if (isMac) {
|
||||||
this.writeLog('预加载 WCDB.dll 成功')
|
const wcdbCorePath = join(dllDir, 'libWCDB.dylib')
|
||||||
} catch (e) {
|
if (existsSync(wcdbCorePath)) {
|
||||||
console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e)
|
try {
|
||||||
this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`)
|
this.koffi.load(wcdbCorePath)
|
||||||
|
this.writeLog('预加载 libWCDB.dylib 成功')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('预加载 libWCDB.dylib 失败(可能不是致命的):', e)
|
||||||
|
this.writeLog(`预加载 libWCDB.dylib 失败: ${String(e)}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
const sdl2Path = join(dllDir, 'SDL2.dll')
|
const wcdbCorePath = join(dllDir, 'WCDB.dll')
|
||||||
if (existsSync(sdl2Path)) {
|
if (existsSync(wcdbCorePath)) {
|
||||||
try {
|
try {
|
||||||
this.koffi.load(sdl2Path)
|
this.koffi.load(wcdbCorePath)
|
||||||
this.writeLog('预加载 SDL2.dll 成功')
|
this.writeLog('预加载 WCDB.dll 成功')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e)
|
console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e)
|
||||||
this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`)
|
this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sdl2Path = join(dllDir, 'SDL2.dll')
|
||||||
|
if (existsSync(sdl2Path)) {
|
||||||
|
try {
|
||||||
|
this.koffi.load(sdl2Path)
|
||||||
|
this.writeLog('预加载 SDL2.dll 成功')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e)
|
||||||
|
this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -982,7 +1142,7 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid)
|
const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid)
|
||||||
this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`)
|
this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`, true)
|
||||||
|
|
||||||
if (!dbStoragePath || !existsSync(dbStoragePath)) {
|
if (!dbStoragePath || !existsSync(dbStoragePath)) {
|
||||||
console.error('数据库目录不存在:', dbPath)
|
console.error('数据库目录不存在:', dbPath)
|
||||||
@@ -991,7 +1151,7 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionDbPath = this.findSessionDb(dbStoragePath)
|
const sessionDbPath = this.findSessionDb(dbStoragePath)
|
||||||
this.writeLog(`open sessionDb=${sessionDbPath || 'null'}`)
|
this.writeLog(`open sessionDb=${sessionDbPath || 'null'}`, true)
|
||||||
if (!sessionDbPath) {
|
if (!sessionDbPath) {
|
||||||
console.error('未找到 session.db 文件')
|
console.error('未找到 session.db 文件')
|
||||||
this.writeLog('open failed: session.db not found')
|
this.writeLog('open failed: session.db not found')
|
||||||
@@ -1017,6 +1177,7 @@ export class WcdbCore {
|
|||||||
this.currentPath = dbPath
|
this.currentPath = dbPath
|
||||||
this.currentKey = hexKey
|
this.currentKey = hexKey
|
||||||
this.currentWxid = wxid
|
this.currentWxid = wxid
|
||||||
|
this.currentDbStoragePath = dbStoragePath
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
if (this.wcdbSetMyWxid && wxid) {
|
if (this.wcdbSetMyWxid && wxid) {
|
||||||
try {
|
try {
|
||||||
@@ -1028,7 +1189,9 @@ export class WcdbCore {
|
|||||||
if (this.isLogEnabled()) {
|
if (this.isLogEnabled()) {
|
||||||
this.startLogPolling()
|
this.startLogPolling()
|
||||||
}
|
}
|
||||||
this.writeLog(`open ok handle=${handle}`)
|
this.writeLog(`open ok handle=${handle}`, true)
|
||||||
|
await this.dumpDbStatus('open')
|
||||||
|
await this.runPostOpenDiagnostics(dbPath, dbStoragePath, sessionDbPath, wxid)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('打开数据库异常:', e)
|
console.error('打开数据库异常:', e)
|
||||||
@@ -1053,6 +1216,7 @@ export class WcdbCore {
|
|||||||
this.currentPath = null
|
this.currentPath = null
|
||||||
this.currentKey = null
|
this.currentKey = null
|
||||||
this.currentWxid = null
|
this.currentWxid = null
|
||||||
|
this.currentDbStoragePath = null
|
||||||
this.initialized = false
|
this.initialized = false
|
||||||
this.stopLogPolling()
|
this.stopLogPolling()
|
||||||
}
|
}
|
||||||
@@ -1208,6 +1372,31 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
if (usernames.length === 0) return { success: true, map: {} }
|
if (usernames.length === 0) return { success: true, map: {} }
|
||||||
try {
|
try {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const uniq = Array.from(new Set(usernames.map((x) => String(x || '').trim()).filter(Boolean)))
|
||||||
|
if (uniq.length === 0) return { success: true, map: {} }
|
||||||
|
const inList = uniq.map((u) => `'${u.replace(/'/g, "''")}'`).join(',')
|
||||||
|
const sql = `SELECT * FROM contact WHERE username IN (${inList})`
|
||||||
|
const q = await this.execQuery('contact', null, sql)
|
||||||
|
if (!q.success) return { success: false, error: q.error || '获取昵称失败' }
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
for (const row of (q.rows || []) as Array<Record<string, any>>) {
|
||||||
|
const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName'])
|
||||||
|
if (!username) continue
|
||||||
|
const display = this.pickFirstStringField(row, [
|
||||||
|
'remark', 'Remark',
|
||||||
|
'nick_name', 'nickName', 'nickname', 'NickName',
|
||||||
|
'alias', 'Alias'
|
||||||
|
]) || username
|
||||||
|
map[username] = display
|
||||||
|
}
|
||||||
|
// 保证每个请求用户名至少有回退值
|
||||||
|
for (const u of uniq) {
|
||||||
|
if (!map[u]) map[u] = u
|
||||||
|
}
|
||||||
|
return { success: true, map }
|
||||||
|
}
|
||||||
|
|
||||||
// 让出控制权,避免阻塞事件循环
|
// 让出控制权,避免阻塞事件循环
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
|
||||||
@@ -1256,6 +1445,34 @@ export class WcdbCore {
|
|||||||
return { success: true, map: resultMap }
|
return { success: true, map: resultMap }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const inList = toFetch.map((u) => `'${u.replace(/'/g, "''")}'`).join(',')
|
||||||
|
const sql = `SELECT * FROM contact WHERE username IN (${inList})`
|
||||||
|
const q = await this.execQuery('contact', null, sql)
|
||||||
|
if (!q.success) {
|
||||||
|
if (Object.keys(resultMap).length > 0) {
|
||||||
|
return { success: true, map: resultMap, error: q.error || '获取头像失败' }
|
||||||
|
}
|
||||||
|
return { success: false, error: q.error || '获取头像失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of (q.rows || []) as Array<Record<string, any>>) {
|
||||||
|
const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName'])
|
||||||
|
if (!username) continue
|
||||||
|
const url = this.pickFirstStringField(row, [
|
||||||
|
'big_head_img_url', 'bigHeadImgUrl', 'bigHeadUrl', 'big_head_url',
|
||||||
|
'small_head_img_url', 'smallHeadImgUrl', 'smallHeadUrl', 'small_head_url',
|
||||||
|
'head_img_url', 'headImgUrl',
|
||||||
|
'avatar_url', 'avatarUrl'
|
||||||
|
])
|
||||||
|
if (url) {
|
||||||
|
resultMap[username] = url
|
||||||
|
this.avatarUrlCache.set(username, { url, updatedAt: now })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, map: resultMap }
|
||||||
|
}
|
||||||
|
|
||||||
// 让出控制权,避免阻塞事件循环
|
// 让出控制权,避免阻塞事件循环
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
|
||||||
@@ -1464,10 +1681,42 @@ export class WcdbCore {
|
|||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const safe = String(username || '').replace(/'/g, "''")
|
||||||
|
const sql = `SELECT * FROM contact WHERE username='${safe}' LIMIT 1`
|
||||||
|
const q = await this.execQuery('contact', null, sql)
|
||||||
|
if (!q.success) {
|
||||||
|
return { success: false, error: q.error || '获取联系人失败' }
|
||||||
|
}
|
||||||
|
const row = Array.isArray(q.rows) && q.rows.length > 0 ? q.rows[0] : null
|
||||||
|
if (!row) {
|
||||||
|
return { success: false, error: `联系人不存在: ${username}` }
|
||||||
|
}
|
||||||
|
return { success: true, contact: row }
|
||||||
|
}
|
||||||
|
|
||||||
const outPtr = [null as any]
|
const outPtr = [null as any]
|
||||||
const result = this.wcdbGetContact(this.handle, username, outPtr)
|
const result = this.wcdbGetContact(this.handle, username, outPtr)
|
||||||
if (result !== 0 || !outPtr[0]) {
|
if (result !== 0 || !outPtr[0]) {
|
||||||
return { success: false, error: `获取联系人失败: ${result}` }
|
this.writeLog(`[diag:getContact] primary api failed username=${username} code=${result} outPtr=${outPtr[0] ? 'set' : 'null'}`, true)
|
||||||
|
await this.dumpDbStatus('getContact-primary-fail')
|
||||||
|
await this.printLogs(true)
|
||||||
|
|
||||||
|
// Fallback: 直接查询 contact 表,便于区分是接口失败还是 contact 库本身不可读。
|
||||||
|
const safe = String(username || '').replace(/'/g, "''")
|
||||||
|
const fallbackSql = `SELECT * FROM contact WHERE username='${safe}' LIMIT 1`
|
||||||
|
const fallback = await this.execQuery('contact', null, fallbackSql)
|
||||||
|
if (fallback.success) {
|
||||||
|
const row = Array.isArray(fallback.rows) ? fallback.rows[0] : null
|
||||||
|
if (row) {
|
||||||
|
this.writeLog(`[diag:getContact] fallback sql hit username=${username}`, true)
|
||||||
|
return { success: true, contact: row }
|
||||||
|
}
|
||||||
|
this.writeLog(`[diag:getContact] fallback sql no row username=${username}`, true)
|
||||||
|
return { success: false, error: `联系人不存在: ${username}` }
|
||||||
|
}
|
||||||
|
this.writeLog(`[diag:getContact] fallback sql failed username=${username} err=${fallback.error || 'unknown'}`, true)
|
||||||
|
return { success: false, error: `获取联系人失败: ${result}; fallback=${fallback.error || 'unknown'}` }
|
||||||
}
|
}
|
||||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
if (!jsonStr) return { success: false, error: '解析联系人失败' }
|
if (!jsonStr) return { success: false, error: '解析联系人失败' }
|
||||||
@@ -1804,16 +2053,43 @@ export class WcdbCore {
|
|||||||
console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL(可能存在注入风险)')
|
console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL(可能存在注入风险)')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedKind = String(kind || '').toLowerCase()
|
||||||
|
const isContactQuery = normalizedKind === 'contact' || /\bfrom\s+contact\b/i.test(String(sql))
|
||||||
|
let effectivePath = path || ''
|
||||||
|
if (normalizedKind === 'contact' && !effectivePath) {
|
||||||
|
const resolvedContactDb = this.resolveContactDbPath()
|
||||||
|
if (resolvedContactDb) {
|
||||||
|
effectivePath = resolvedContactDb
|
||||||
|
this.writeLog(`[diag:execQuery] contact path override -> ${effectivePath}`, true)
|
||||||
|
} else {
|
||||||
|
this.writeLog('[diag:execQuery] contact path override miss: Contact/contact.db not found', true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const outPtr = [null as any]
|
const outPtr = [null as any]
|
||||||
const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outPtr)
|
const result = this.wcdbExecQuery(this.handle, kind, effectivePath, sql, outPtr)
|
||||||
if (result !== 0 || !outPtr[0]) {
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
if (isContactQuery) {
|
||||||
|
this.writeLog(`[diag:execQuery] contact query failed code=${result} kind=${kind} path=${effectivePath} sql="${this.formatSqlForLog(sql)}"`, true)
|
||||||
|
await this.dumpDbStatus('execQuery-contact-fail')
|
||||||
|
await this.printLogs(true)
|
||||||
|
}
|
||||||
return { success: false, error: `执行查询失败: ${result}` }
|
return { success: false, error: `执行查询失败: ${result}` }
|
||||||
}
|
}
|
||||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
if (!jsonStr) return { success: false, error: '解析查询结果失败' }
|
if (!jsonStr) return { success: false, error: '解析查询结果失败' }
|
||||||
const rows = JSON.parse(jsonStr)
|
const rows = JSON.parse(jsonStr)
|
||||||
|
if (isContactQuery) {
|
||||||
|
const count = Array.isArray(rows) ? rows.length : -1
|
||||||
|
this.writeLog(`[diag:execQuery] contact query ok rows=${count} kind=${kind} path=${effectivePath} sql="${this.formatSqlForLog(sql)}"`, true)
|
||||||
|
}
|
||||||
return { success: true, rows }
|
return { success: true, rows }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
const isContactQuery = String(kind).toLowerCase() === 'contact' || /\bfrom\s+contact\b/i.test(String(sql))
|
||||||
|
if (isContactQuery) {
|
||||||
|
this.writeLog(`[diag:execQuery] contact query exception kind=${kind} path=${path || ''} sql="${this.formatSqlForLog(sql)}" err=${String(e)}`, true)
|
||||||
|
await this.dumpDbStatus('execQuery-contact-exception')
|
||||||
|
}
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -70,6 +70,17 @@
|
|||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release"
|
||||||
},
|
},
|
||||||
|
"mac": {
|
||||||
|
"target": [
|
||||||
|
"dmg",
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"category": "public.app-category.utilities",
|
||||||
|
"hardenedRuntime": false,
|
||||||
|
"gatekeeperAssess": false,
|
||||||
|
"entitlements": "electron/entitlements.mac.plist",
|
||||||
|
"entitlementsInherit": "electron/entitlements.mac.plist"
|
||||||
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
"nsis"
|
"nsis"
|
||||||
|
|||||||
BIN
resources/libwx_key.dylib
Executable file
BIN
resources/libwx_key.dylib
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
resources/xkey_helper
Executable file
BIN
resources/xkey_helper
Executable file
Binary file not shown.
Reference in New Issue
Block a user