diff --git a/resources/xkey_helper_4_1_13 b/resources/xkey_helper_4_1_13 new file mode 100755 index 0000000..ada9a32 Binary files /dev/null and b/resources/xkey_helper_4_1_13 differ diff --git a/resources/xkey_helper_4_1_13.sha256 b/resources/xkey_helper_4_1_13.sha256 new file mode 100644 index 0000000..47562e3 --- /dev/null +++ b/resources/xkey_helper_4_1_13.sha256 @@ -0,0 +1 @@ +f1bbd88584075c0e6d3487357a06424949de26d7e6dcad803d210aab83b0cbda xkey_helper_4_1_13 diff --git a/src/main/index.ts b/src/main/index.ts index 73d5248..a84f495 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -895,7 +895,7 @@ app.whenReady().then(async () => { wechatVersion: await detectWechatVersion(), dataStructureVersion: detectDataStructureVersion(settings.dbRoot), dataDirectoryDetected: validateDbRoot(settings.dbRoot).valid, - autoDetectSupported: process.platform === 'win32' || intelMac, + autoDetectSupported: process.platform === 'win32' || process.platform === 'darwin', wechatRunning: await isWechatRunning(), accountIdentified: Boolean(self?.wxid), dbConnected: chat.isReady(), diff --git a/src/main/key-service-mac.ts b/src/main/key-service-mac.ts index 7a88fa4..f274c60 100644 --- a/src/main/key-service-mac.ts +++ b/src/main/key-service-mac.ts @@ -6,6 +6,7 @@ import { promisify } from 'util' import { isValidDatabaseKey } from './database-key-store' import crypto from 'crypto' import { findResource, getResourceCandidates } from './resource-paths' +import { detectWechatVersion } from './services/connection-diagnostics' const execFileAsync = promisify(execFile) @@ -24,6 +25,115 @@ export interface ImageKeyResult { error?: string } +export type XkeyHelperMode = 'legacy' | 'wechat-4.1.13' + +export function resolveXkeyHelperMode(wechatVersion: string): XkeyHelperMode { + return /^4\.1\.13(?:\.|$)/.test(wechatVersion.trim()) ? 'wechat-4.1.13' : 'legacy' +} + +export function buildXkeyHelperArguments(pid: number, timeoutMs: number): string[] { + return [String(pid), String(timeoutMs), '--profile', 'wechat-4.1.13', '--account'] +} + +export function buildAppleSiliconXkeyInvocation( + wechatVersion: string, + pid: number, + timeoutMs: number +): { + mode: XkeyHelperMode + resourceName: string + args: string[] + waitMs: number + timeoutSeconds: number + execTimeoutMs: number +} { + const mode = resolveXkeyHelperMode(wechatVersion) + const isWechat413 = mode === 'wechat-4.1.13' + const waitMs = Math.max(isWechat413 ? 120_000 : 30_000, timeoutMs) + const timeoutSeconds = Math.ceil(waitMs / 1000) + (isWechat413 ? 10 : 30) + return { + mode, + resourceName: isWechat413 ? 'xkey_helper_4_1_13' : 'xkey_helper', + args: isWechat413 ? buildXkeyHelperArguments(pid, waitMs) : [String(pid), String(waitMs)], + waitMs, + timeoutSeconds, + execTimeoutMs: isWechat413 ? timeoutSeconds * 1000 + 5_000 : waitMs + 20_000 + } +} + +export function mapXkeyHelperFailure( + rawError: string, + fallbackCode = 'HELPER_RESULT_INVALID' +): DatabaseKeyResult { + const normalizedError = rawError.trim().toLowerCase() + const parsedError = rawError.match(/(?:^|[\s"])(?:\\?"?)ERROR:([^:\s"}]+):?([^"}\r\n]*)/i) + const code = parsedError?.[1]?.toUpperCase() + const detail = parsedError?.[2]?.trim() || '' + + if ( + code === 'CAPTURE_TIMEOUT' || + normalizedError.includes('timeout waiting for breakpoint hit') || + normalizedError.includes('timeout waiting for sink hit') || + normalizedError.includes('no_breakpoint_hit') + ) { + return { + success: false, + code: 'CAPTURE_TIMEOUT', + error: + '已完成管理员授权,但监听期间微信没有触发账号密钥派生。请先停留在微信登录界面,在 TraceMemo 点击“自动获取密钥”,授权后点击微信“登录”;已有登录凭据时通常不需要扫码。' + } + } + if (code === 'SCAN_FAILED' && detail.toLowerCase().includes('sink pattern not found')) { + return { + success: false, + code, + error: + '内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。\n' + + '建议步骤:降级微信到 4.1.8 (点击顶部"上手教程"获取下载链接) -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' + + '请不要连续重试,以免触发微信安全模式或系统内存保护。' + } + } + if (code === 'SCAN_FAILED') { + return { + success: false, + code, + error: '内存扫描失败:当前微信版本或运行状态暂未适配。' + } + } + if (normalizedError.includes('permission denied') || code === 'PERMISSION_DENIED') { + return { + success: false, + code: code || 'PERMISSION_DENIED', + error: '管理员授权不足,无法读取微信进程内存。' + } + } + return { + success: false, + code: code || fallbackCode, + error: code + ? `密钥工具执行未完成(${code}),请确认微信仍在运行后重试。` + : '密钥工具未返回有效密钥,请确认微信仍在运行后重试。' + } +} + +export function parseXkeyHelperOutput(output: string): DatabaseKeyResult { + const payloads: Record[] = [] + for (const match of output.matchAll(/\{[^{}]*\}/g)) { + try { + payloads.push(JSON.parse(match[0]) as Record) + } catch { + // Ignore helper progress that is not JSON. + } + } + const payload = payloads.find((item) => item.success === true && typeof item.key === 'string') + const rawKey = typeof payload?.key === 'string' ? payload.key.trim().replace(/^0x/i, '') : '' + if (isValidDatabaseKey(rawKey)) return { success: true, key: rawKey } + + const errorPayload = payloads.find((item) => typeof item.result === 'string') + const rawError = typeof errorPayload?.result === 'string' ? errorPayload.result.trim() : '' + return mapXkeyHelperFailure(rawError) +} + export class KeyServiceMac { private getMacKeyRuntimeDir(): string { return path.join(app.getPath('userData'), 'key-runtime') @@ -183,11 +293,11 @@ export class KeyServiceMac { } } - private getHelperPath(): string { - const helperPath = findResource('xkey_helper') + private getHelperPath(resourceName = 'xkey_helper'): string { + const helperPath = findResource(resourceName) if (!helperPath) { throw new Error( - `找不到 xkey_helper(已检查:${getResourceCandidates('xkey_helper').join(';')})` + `找不到 ${resourceName}(已检查:${getResourceCandidates(resourceName).join(';')})` ) } return helperPath @@ -219,50 +329,7 @@ export class KeyServiceMac { // Try the next process lookup strategy. } } - throw new Error('未找到微信主进程,请先启动并登录微信') - } - - private parseHelperOutput(output: string): DatabaseKeyResult { - const payloads: Record[] = [] - for (const match of output.matchAll(/\{[^{}]*\}/g)) { - try { - payloads.push(JSON.parse(match[0]) as Record) - } catch { - // Ignore helper progress that is not JSON. - } - } - const payload = payloads.find((item) => item.success === true && typeof item.key === 'string') - const rawKey = typeof payload?.key === 'string' ? payload.key.trim().replace(/^0x/i, '') : '' - if (!isValidDatabaseKey(rawKey)) { - const errorPayload = payloads.find((item) => typeof item.result === 'string') - const rawError = typeof errorPayload?.result === 'string' ? errorPayload.result.trim() : '' - const parsedError = rawError.match(/^ERROR:([^:]+):?(.*)$/i) - const code = parsedError?.[1]?.toUpperCase() - const detail = parsedError?.[2]?.trim() || '' - if (code === 'SCAN_FAILED' && detail.toLowerCase().includes('sink pattern not found')) { - return { - success: false, - code, - error: - '内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。\n' + - '建议步骤:降级微信到 4.1.8 (点击顶部"上手教程"获取下载链接) -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' + - '请不要连续重试,以免触发微信安全模式或系统内存保护。' - } - } - if (code === 'SCAN_FAILED') { - return { - success: false, - code, - error: `内存扫描失败:${detail || '未匹配到可用特征,当前微信版本可能暂未适配。'}` - } - } - return { - success: false, - code, - error: rawError || '密钥工具未返回有效的 64 位密钥' - } - } - return { success: true, key: rawKey } + throw new Error('未找到微信主进程,请先启动微信并停留在登录界面') } async autoGetDbKey( @@ -292,16 +359,17 @@ export class KeyServiceMac { onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败') return result } + const wechatVersion = await detectWechatVersion() onStatus?.('正在查找微信进程...') const pid = await this.getWeChatPid() - const helperPath = this.getHelperPath() - const waitMs = Math.max(30_000, timeoutMs) - const timeoutSeconds = Math.ceil(waitMs / 1000) + 30 + const invocation = buildAppleSiliconXkeyInvocation(wechatVersion, pid, timeoutMs) + const isWechat413 = invocation.mode === 'wechat-4.1.13' + const helperPath = this.getHelperPath(invocation.resourceName) onStatus?.('正在请求管理员授权...') const scriptLines = [ `set helperPath to ${JSON.stringify(helperPath)}`, - `set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`, - `set timeoutSec to ${timeoutSeconds}`, + `set cmd to quoted form of helperPath & " ${invocation.args.join(' ')}"`, + `set timeoutSec to ${invocation.timeoutSeconds}`, 'try', 'with timeout of timeoutSec seconds', 'set outText to do shell script cmd with administrator privileges', @@ -311,25 +379,57 @@ export class KeyServiceMac { 'return "ERR::" & errNum & "::" & errMsg', 'end try' ] - onStatus?.('授权后 需要在微信登录界面 点击登录微信') + onStatus?.( + isWechat413 + ? '授权后请在微信登录界面点击“登录”,已有登录凭据时通常不需要扫码' + : '授权后 需要在微信登录界面 点击登录微信' + ) const { stdout } = await execFileAsync( '/usr/bin/osascript', scriptLines.flatMap((line) => ['-e', line]), - { timeout: waitMs + 20_000 } + { timeout: invocation.execTimeoutMs } ) const output = String(stdout).trim() - if (output.startsWith('ERR::-128')) return { success: false, error: '已取消管理员授权' } - if (output.startsWith('ERR::')) { - return { - success: false, - error: output.split('::').slice(2).join('::') || '密钥工具执行失败' - } + if (output.startsWith('ERR::-128')) { + return { success: false, error: '已取消管理员授权' } } - const result = this.parseHelperOutput(output.startsWith('OK::') ? output.slice(4) : output) + if (output.startsWith('ERR::')) { + const [, errorNumber = 'UNKNOWN', ...errorParts] = output.split('::') + const result = mapXkeyHelperFailure(errorParts.join('::'), `OSASCRIPT_${errorNumber}`) + onStatus?.('密钥获取失败') + return result + } + const result = parseXkeyHelperOutput(output.startsWith('OK::') ? output.slice(4) : output) onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败') return result } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) } + const processError = error as NodeJS.ErrnoException & { + killed?: boolean + signal?: NodeJS.Signals | null + } + if (processError.message?.includes('未找到微信主进程')) { + return { + success: false, + code: 'WECHAT_NOT_RUNNING', + error: '未找到微信主进程,请先启动微信并停留在登录界面。' + } + } + if ( + processError.killed || + processError.code === 'ETIMEDOUT' || + processError.signal === 'SIGTERM' + ) { + return { + success: false, + code: 'AUTH_TIMEOUT', + error: '管理员授权等待超时,请点击“自动获取密钥”后及时完成系统授权。' + } + } + return { + success: false, + code: typeof processError.code === 'string' ? processError.code : 'HELPER_EXEC_FAILED', + error: '密钥工具执行失败,请确认微信仍在运行后重试。' + } } } diff --git a/src/renderer/src/components/DatabaseConnectionPage.tsx b/src/renderer/src/components/DatabaseConnectionPage.tsx index 4038184..6d95df1 100644 --- a/src/renderer/src/components/DatabaseConnectionPage.tsx +++ b/src/renderer/src/components/DatabaseConnectionPage.tsx @@ -137,6 +137,7 @@ export function DatabaseConnectionPage({ }: DatabaseConnectionPageProps): React.ReactElement { const isMac = platform === 'darwin' const isIntelMac = isMac && environment?.architecture === 'x64' + const isAppleSiliconMac = isMac && !isIntelMac const defaultPath = isMac ? '~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/' : 'C:\\Users\\...\\WeChat Files\\Msg' @@ -189,18 +190,16 @@ export function DatabaseConnectionPage({
  • 2
    - {isIntelMac ? '让微信停在登录页面' : '准备连接组件'} + {isMac ? '让微信停在登录页面' : '准备连接组件'} - {isIntelMac - ? '启动微信,但暂时不要点击登录' - : '页面会按当前系统给出对应步骤'} + {isMac ? '启动微信,但暂时不要点击登录' : '页面会按当前系统给出对应步骤'}
  • 3
    - {isIntelMac ? '获取密钥并验证连接' : '登录并验证连接'} + {isMac ? '获取密钥并验证连接' : '登录并验证连接'} 验证通过后进入主界面
  • @@ -248,11 +247,13 @@ export function DatabaseConnectionPage({ : [ '检查本机环境', '让微信停在登录页面', - isIntelMac ? '确认开始获取密钥' : '确认开始准备', + isMac ? '确认开始获取密钥' : '确认开始准备', isIntelMac ? '正在获取 Intel Mac 密钥' - : `正在完成 ${isMac ? 'macOS' : 'Windows'} 授权`, - isIntelMac + : isAppleSiliconMac + ? '正在监听 macOS 登录密钥' + : '正在完成 Windows 授权', + isMac ? '获取成功,验证数据库连接' : '获取成功, 现在可以重新登录微信了', '验证数据库连接' @@ -264,14 +265,14 @@ export function DatabaseConnectionPage({ : status || [ '确认下方检测结果;没有找到目录时可以手动选择。', - isIntelMac + isMac ? '请启动微信并停留在未登录界面,暂时不要点击登录,然后点击“我已准备好”。' : '请退出当前微信账号,让微信停留在登录页面,然后点击“我已准备好”。', - isIntelMac ? '确认开始获取密钥。' : '开始后请按页面提示完成系统授权。', - isIntelMac + isMac ? '确认开始获取密钥。' : '开始后请按页面提示完成系统授权。', + isMac ? '等页面提示可以登录后,立即回到微信点击登录。' : '正在准备连接组件,请不要关闭微信或 TraceMemo。', - isIntelMac + isMac ? '密钥已获取,请继续验证数据库。' : '请回到微信完成登录,登录成功后再回来验证。', '正在验证密钥和本地数据库,请稍候。' @@ -445,12 +446,12 @@ export function DatabaseConnectionPage({ )} {guideStep === 3 && ( )} {guideStep === 4 && ( )} {guideStep === 5 && ( @@ -491,7 +492,7 @@ export function DatabaseConnectionPage({ <> {isIntelMac ? 'Intel Mac 首次使用需要先准备连接环境,按页面提示完成即可。' - : 'macOS 首次获取密钥需要关闭 SIP。'}{' '} + : 'macOS Apple 芯片首次获取密钥需要关闭 SIP,并在监听期间点击微信登录。'}{' '} 查看说明 diff --git a/src/renderer/src/features/settings/database-key/DatabaseKeyAutoDetect.tsx b/src/renderer/src/features/settings/database-key/DatabaseKeyAutoDetect.tsx index d0ad2ee..dd8742e 100644 --- a/src/renderer/src/features/settings/database-key/DatabaseKeyAutoDetect.tsx +++ b/src/renderer/src/features/settings/database-key/DatabaseKeyAutoDetect.tsx @@ -2,7 +2,14 @@ import type { DatabaseKeyState } from './types' import { runtimePlatform } from '../../../utils/runtime-environment' import { Button } from '../../../components/ui' -const PHASES = ['查找微信进程', '识别微信版本', '扫描候选密钥', '验证数据库', '获取完成'] +const DEFAULT_PHASES = ['查找微信进程', '识别微信版本', '扫描候选密钥', '验证数据库', '获取完成'] +const MAC_LOGIN_PHASES = [ + '查找微信进程', + '等待管理员授权', + '监听登录密钥', + '验证数据库', + '获取完成' +] export function DatabaseKeyAutoDetect({ state, @@ -17,8 +24,12 @@ export function DatabaseKeyAutoDetect({ }): React.ReactElement { const environment = state.environment const platform = environment?.platform || runtimePlatform + const isMac = platform === 'darwin' const isIntelMac = platform === 'darwin' && environment?.architecture === 'x64' - if (platform !== 'win32' && !isIntelMac) { + const isAppleSiliconMac = isMac && !isIntelMac + const supported = environment?.autoDetectSupported ?? (platform === 'win32' || isMac) + const phases = isAppleSiliconMac ? MAC_LOGIN_PHASES : DEFAULT_PHASES + if (!supported) { return (
    @@ -32,11 +43,15 @@ export function DatabaseKeyAutoDetect({
    - {isIntelMac ? 'Intel Mac 自动获取' : 'Windows 自动获取'} + + {isIntelMac ? 'Intel Mac 自动获取' : isMac ? 'macOS 自动获取' : 'Windows 自动获取'} +

    {isIntelMac ? '请让微信停在未登录界面,按页面提示操作即可。' - : 'TraceMemo 可在微信桌面端正在运行时,通过本机内存扫描尝试获取数据库密钥。'} + : isAppleSiliconMac + ? '执行时会请求管理员授权;授权后在微信登录界面点击“登录”即可,已有登录凭据时通常不需要扫码。监听最长两分钟,结束后会明确显示结果。' + : 'TraceMemo 可在微信桌面端正在运行时,通过本机内存扫描尝试获取数据库密钥。'}