diff --git a/electron/entitlements.mac.plist b/electron/entitlements.mac.plist
new file mode 100644
index 0000000..02af842
--- /dev/null
+++ b/electron/entitlements.mac.plist
@@ -0,0 +1,14 @@
+
+
+
+
+ com.apple.security.cs.debugger
+
+ com.apple.security.get-task-allow
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.disable-library-validation
+
+
+
diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts
index dcc8519..764337f 100644
--- a/electron/services/keyServiceMac.ts
+++ b/electron/services/keyServiceMac.ts
@@ -1,9 +1,12 @@
-import { app } from 'electron'
+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
@@ -15,6 +18,31 @@ export class KeyServiceMac {
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[] = []
@@ -67,40 +95,42 @@ export class KeyServiceMac {
timeoutMs = 60_000,
onStatus?: (message: string, level: number) => void
): Promise {
- if (!this.initialized) {
- await this.initialize()
- }
-
try {
onStatus?.('正在获取数据库密钥...', 0)
-
- const result = this.GetDbKey()
- console.log('[KeyServiceMac] GetDbKey returned:', result)
-
- if (!result) {
- onStatus?.('获取失败:未知错误', 2)
- return { success: false, error: '未知错误' }
+ 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 (result.startsWith('ERROR:')) {
- const parts = result.split(':')
- let errorMsg = '未知错误'
-
- if (parts[1] === 'PROCESS_NOT_FOUND') {
- errorMsg = '微信进程未运行'
- } else if (parts[1] === 'ATTACH_FAILED') {
- errorMsg = `无法附加到进程 (${parts[2] || ''})\n可能需要授予调试权限:sudo /usr/sbin/DevToolsSecurity -enable`
- } else if (parts[1] === 'SCAN_FAILED') {
- errorMsg = '内存扫描失败'
- }
-
+ 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: result }
+ return { success: true, key: parsed.key }
} catch (e: any) {
console.error('[KeyServiceMac] Error:', e)
console.error('[KeyServiceMac] Stack:', e.stack)
@@ -109,6 +139,213 @@ export class KeyServiceMac {
}
}
+ 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 {
+ const helperPath = this.getHelperPath()
+ const waitMs = Math.max(timeoutMs, 30_000)
+ return await new Promise((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 | 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 {
+ 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 {
+ 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 {
+ 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,
diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts
index 8b15184..ed0862a 100644
--- a/electron/services/wcdbCore.ts
+++ b/electron/services/wcdbCore.ts
@@ -1,5 +1,6 @@
import { join, dirname, basename } from 'path'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
+import { tmpdir } from 'os'
// DLL 初始化错误信息,用于帮助用户诊断问题
let lastDllInitError: string | null = null
@@ -60,6 +61,7 @@ export class WcdbCore {
private currentPath: string | null = null
private currentKey: string | null = null
private currentWxid: string | null = null
+ private currentDbStoragePath: string | null = null
// 函数引用
private wcdbInitProtection: any = null
@@ -128,14 +130,17 @@ export class WcdbCore {
private readonly avatarCacheTtlMs = 10 * 60 * 1000
private logTimer: NodeJS.Timeout | null = null
private lastLogTail: string | null = null
+ private lastResolvedLogPath: string | null = null
setPaths(resourcesPath: string, userDataPath: string): void {
this.resourcesPath = resourcesPath
this.userDataPath = userDataPath
+ this.writeLog(`[bootstrap] setPaths resourcesPath=${resourcesPath} userDataPath=${userDataPath}`, true)
}
setLogEnabled(enabled: boolean): void {
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) {
this.startLogPolling()
} else {
@@ -301,14 +306,97 @@ export class WcdbCore {
private writeLog(message: string, force = false): void {
if (!force && !this.isLogEnabled()) return
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 {
try {
- const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
- const dir = join(base, 'logs')
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
- appendFileSync(join(dir, 'wcdb.log'), line + '\n', { encoding: 'utf8' })
- } catch { }
+ if (!this.ensureReady()) {
+ this.writeLog(`[diag:${tag}] db_status skipped: not connected`, true)
+ return
+ }
+ 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 {
+ 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)
+ }
}
/**
@@ -385,6 +473,51 @@ export class WcdbCore {
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, 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
*/
@@ -394,9 +527,11 @@ export class WcdbCore {
try {
this.koffi = require('koffi')
const dllPath = this.getDllPath()
+ this.writeLog(`[bootstrap] initialize platform=${process.platform} dllPath=${dllPath} resourcesPath=${this.resourcesPath || ''} userDataPath=${this.userDataPath || ''}`, true)
if (!existsSync(dllPath)) {
console.error('WCDB DLL 不存在:', dllPath)
+ this.writeLog(`[bootstrap] initialize failed: dll not found path=${dllPath}`, true)
return false
}
@@ -1007,7 +1142,7 @@ export class WcdbCore {
}
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)) {
console.error('数据库目录不存在:', dbPath)
@@ -1016,7 +1151,7 @@ export class WcdbCore {
}
const sessionDbPath = this.findSessionDb(dbStoragePath)
- this.writeLog(`open sessionDb=${sessionDbPath || 'null'}`)
+ this.writeLog(`open sessionDb=${sessionDbPath || 'null'}`, true)
if (!sessionDbPath) {
console.error('未找到 session.db 文件')
this.writeLog('open failed: session.db not found')
@@ -1042,6 +1177,7 @@ export class WcdbCore {
this.currentPath = dbPath
this.currentKey = hexKey
this.currentWxid = wxid
+ this.currentDbStoragePath = dbStoragePath
this.initialized = true
if (this.wcdbSetMyWxid && wxid) {
try {
@@ -1053,7 +1189,9 @@ export class WcdbCore {
if (this.isLogEnabled()) {
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
} catch (e) {
console.error('打开数据库异常:', e)
@@ -1078,6 +1216,7 @@ export class WcdbCore {
this.currentPath = null
this.currentKey = null
this.currentWxid = null
+ this.currentDbStoragePath = null
this.initialized = false
this.stopLogPolling()
}
@@ -1233,6 +1372,31 @@ export class WcdbCore {
}
if (usernames.length === 0) return { success: true, map: {} }
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 = {}
+ for (const row of (q.rows || []) as Array>) {
+ 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))
@@ -1281,6 +1445,34 @@ export class WcdbCore {
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>) {
+ 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))
@@ -1489,10 +1681,42 @@ export class WcdbCore {
return { success: false, error: 'WCDB 未连接' }
}
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 result = this.wcdbGetContact(this.handle, username, outPtr)
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])
if (!jsonStr) return { success: false, error: '解析联系人失败' }
@@ -1829,16 +2053,43 @@ export class WcdbCore {
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 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 (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}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析查询结果失败' }
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 }
} 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) }
}
}
diff --git a/package.json b/package.json
index 7e19d6f..1666f1e 100644
--- a/package.json
+++ b/package.json
@@ -70,6 +70,17 @@
"directories": {
"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": {
"target": [
"nsis"
diff --git a/resources/libwx_key.dylib b/resources/libwx_key.dylib
index d48ca82..10773c0 100755
Binary files a/resources/libwx_key.dylib and b/resources/libwx_key.dylib differ
diff --git a/resources/xkey_helper b/resources/xkey_helper
new file mode 100755
index 0000000..6ffda57
Binary files /dev/null and b/resources/xkey_helper differ