feat: update macos native integration

This commit is contained in:
ILoveBinglu
2026-04-06 21:31:29 +08:00
parent 448434fef5
commit b693d9563b
29 changed files with 921 additions and 266 deletions

2
WeFlow

Submodule WeFlow updated: 4da9f1e6cf...57fad47f27

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, nativeTheme, protocol, net, Tray, Menu } from 'electron'
import { app, BrowserWindow, ipcMain, nativeImage, nativeTheme, protocol, net, Tray, Menu } from 'electron'
import { join } from 'path'
import { randomBytes } from 'crypto'
import { readFileSync, existsSync, mkdirSync } from 'fs'
@@ -183,15 +183,54 @@ function getAppIconPath(): string {
}
}
function getDockIconPath(): string {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconName = configService?.get('appIcon') || 'default'
if (iconName === 'xinnian') {
const devPaddedPath = join(__dirname, '../public/xinnian-dock.png')
const devFallbackPath = join(__dirname, '../public/xinnian.png')
return isDev
? (existsSync(devPaddedPath) ? devPaddedPath : devFallbackPath)
: join(process.resourcesPath, 'icon.png')
}
const devPaddedPath = join(__dirname, '../public/icon-dock.png')
const devFallbackPath = join(__dirname, '../public/logo.png')
return isDev
? (existsSync(devPaddedPath) ? devPaddedPath : devFallbackPath)
: join(process.resourcesPath, 'icon.png')
}
function getTrayIconPath(): string {
if (process.platform === 'darwin') {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconName = configService?.get('appIcon') || 'default'
const devTrayPath = iconName === 'xinnian'
? join(__dirname, '../public/xinnian-tray.png')
: join(__dirname, '../public/tray-mac.png')
if (isDev && existsSync(devTrayPath)) {
return devTrayPath
}
}
return getAppIconPath()
}
/**
* 创建系统托盘
*/
function createTray() {
if (tray) return tray
const iconPath = getAppIconPath()
const iconPath = getTrayIconPath()
tray = new Tray(iconPath)
if (process.platform === 'darwin') {
tray.setIgnoreDoubleClickEvents(true)
}
const contextMenu = Menu.buildFromTemplate([
{
label: '显示主窗口',
@@ -1406,14 +1445,20 @@ function registerIpcHandlers() {
ipcMain.handle('app:setAppIcon', async (_, iconName: string) => {
try {
const iconPath = getAppIconPath()
const iconPath = process.platform === 'darwin' ? getDockIconPath() : getAppIconPath()
if (existsSync(iconPath)) {
const { nativeImage } = require('electron')
const image = nativeImage.createFromPath(iconPath)
BrowserWindow.getAllWindows().forEach(win => {
win.setIcon(image)
})
if (process.platform === 'darwin') {
if (!image.isEmpty()) {
app.dock.setIcon(image)
}
} else {
BrowserWindow.getAllWindows().forEach(win => {
win.setIcon(image)
})
}
// 尝试更新桌面快捷方式图标 (不阻塞主线程)
shortcutService.updateDesktopShortcutIcon(iconPath).catch(err => {
@@ -1713,20 +1758,111 @@ function registerIpcHandlers() {
return wxKeyService.waitForWeChatWindow(maxWaitSeconds)
})
ipcMain.handle('wxkey:startGetKey', async (event, customWechatPath?: string) => {
ipcMain.handle('wxkey:startGetKey', async (event, customWechatPath?: string, dbPath?: string) => {
logService?.info('WxKey', '开始获取微信密钥', { customWechatPath })
if (process.platform === 'darwin') {
try {
const isRunning = wxKeyServiceMac.isWeChatRunning()
if (isRunning) {
event.sender.send('wxkey:status', { status: '检测到微信正在运行,正在关闭微信...', level: 0 })
wxKeyServiceMac.killWeChat()
const exited = await wxKeyServiceMac.waitForWeChatExit(20)
if (!exited) {
return { success: false, error: '未能自动关闭微信,请先手动退出微信后重试' }
}
event.sender.send('wxkey:status', { status: '微信已关闭,正在重新启动微信...', level: 0 })
const relaunched = await wxKeyServiceMac.launchWeChat(customWechatPath)
if (!relaunched) {
return { success: false, error: '微信关闭后自动重启失败' }
}
event.sender.send('wxkey:status', { status: '微信已重新启动,等待主进程就绪...', level: 0 })
const ready = await wxKeyServiceMac.waitForWeChatWindow(20)
if (!ready) {
return { success: false, error: '微信已重新启动,但未检测到可用主进程,请确认微信已完成启动并显示主窗口' }
}
} else {
event.sender.send('wxkey:status', { status: '未检测到微信主进程,正在尝试启动微信...', level: 0 })
const launched = await wxKeyServiceMac.launchWeChat(customWechatPath)
if (!launched) {
return { success: false, error: '未找到微信主进程,且自动启动微信失败' }
}
event.sender.send('wxkey:status', { status: '微信已启动,等待主进程就绪...', level: 0 })
const ready = await wxKeyServiceMac.waitForWeChatWindow(20)
if (!ready) {
return { success: false, error: '微信已启动,但未检测到可用主进程,请确认微信已完成启动并显示主窗口' }
}
}
const result = await wxKeyServiceMac.autoGetDbKey(180_000, (status, level) => {
event.sender.send('wxkey:status', { status, level })
})
if (result.success) {
logService?.info('WxKey', 'macOS 数据库密钥获取成功', { keyLength: result.key?.length || 0 })
} else {
if (!result.success) {
logService?.warn('WxKey', 'macOS 数据库密钥获取失败', { error: result.error })
return result
}
if (result.key && dbPath) {
event.sender.send('wxkey:status', { status: '已获取候选密钥,正在验证数据库...', level: 0 })
const wxidCandidates: string[] = []
const pushWxid = (value?: string | null) => {
const wxid = String(value || '').trim()
if (!wxid || wxidCandidates.includes(wxid)) return
wxidCandidates.push(wxid)
}
let currentAccount = wxKeyServiceMac.detectCurrentAccount(dbPath, 10)
if (!currentAccount) {
currentAccount = wxKeyServiceMac.detectCurrentAccount(dbPath, 60)
}
pushWxid(currentAccount?.wxid)
try {
const scannedWxids = dbPathService.scanWxids(dbPath)
for (const wxid of scannedWxids) {
pushWxid(wxid)
}
} catch {
// ignore
}
let validatedWxid = ''
let lastError = ''
for (const wxid of wxidCandidates) {
event.sender.send('wxkey:status', { status: `正在验证账号目录: ${wxid}`, level: 0 })
const testResult = await wcdbService.testConnection(dbPath, result.key, wxid)
if (testResult.success) {
validatedWxid = wxid
break
}
lastError = testResult.error || ''
}
if (!validatedWxid) {
logService?.warn('WxKey', 'macOS 候选密钥未通过数据库验证', {
dbPath,
candidateCount: wxidCandidates.length
})
return {
success: false,
error: lastError || '已捕获到候选密钥,但未通过数据库验证。请在微信完成登录后进入任意聊天,让数据库访问真正触发,再重试。'
}
}
logService?.info('WxKey', 'macOS 候选密钥已通过数据库验证', { dbPath, wxid: validatedWxid })
return {
...result,
validatedWxid
}
}
logService?.info('WxKey', 'macOS 数据库密钥获取成功', { keyLength: result.key?.length || 0 })
return result
} catch (e) {
wxKeyServiceMac.dispose()
@@ -1889,6 +2025,26 @@ function registerIpcHandlers() {
return result
})
ipcMain.handle('wcdb:resolveValidWxid', async (_, dbPath: string, hexKey: string) => {
try {
const wxids = dbPathService.scanWxids(dbPath)
if (wxids.length === 0) {
return { success: false, error: '未检测到账号目录' }
}
for (const wxid of wxids) {
const result = await wcdbService.testConnection(dbPath, hexKey, wxid, true)
if (result.success) {
return { success: true, wxid }
}
}
return { success: false, error: '未找到可通过当前密钥验证的账号目录' }
} catch (e) {
return { success: false, error: String(e) }
}
})
ipcMain.handle('wcdb:open', async (_, dbPath: string, hexKey: string, wxid: string) => {
return wcdbService.open(dbPath, hexKey, wxid)
})
@@ -3963,6 +4119,16 @@ app.whenReady().then(async () => {
configService = new ConfigService()
}
if (process.platform === 'darwin') {
const dockIconPath = getDockIconPath()
if (existsSync(dockIconPath)) {
const dockIcon = nativeImage.createFromPath(dockIconPath)
if (!dockIcon.isEmpty()) {
app.dock.setIcon(dockIcon)
}
}
}
if (!configService.get('mcpProxyToken')) {
configService.set('mcpProxyToken', randomBytes(24).toString('hex'))
}

View File

@@ -176,7 +176,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
killWeChat: () => ipcRenderer.invoke('wxkey:killWeChat'),
launchWeChat: () => ipcRenderer.invoke('wxkey:launchWeChat'),
waitForWindow: (maxWaitSeconds?: number) => ipcRenderer.invoke('wxkey:waitForWindow', maxWaitSeconds),
startGetKey: (customWechatPath?: string) => ipcRenderer.invoke('wxkey:startGetKey', customWechatPath),
startGetKey: (customWechatPath?: string, dbPath?: string) => ipcRenderer.invoke('wxkey:startGetKey', customWechatPath, dbPath),
cancel: () => ipcRenderer.invoke('wxkey:cancel'),
detectCurrentAccount: (dbPath?: string, maxTimeDiffMinutes?: number) => ipcRenderer.invoke('wxkey:detectCurrentAccount', dbPath, maxTimeDiffMinutes),
onStatus: (callback: (data: { status: string; level: number }) => void) => {
@@ -197,6 +197,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
wcdb: {
testConnection: (dbPath: string, hexKey: string, wxid: string, isAutoConnect?: boolean) =>
ipcRenderer.invoke('wcdb:testConnection', dbPath, hexKey, wxid, isAutoConnect),
resolveValidWxid: (dbPath: string, hexKey: string) =>
ipcRenderer.invoke('wcdb:resolveValidWxid', dbPath, hexKey),
open: (dbPath: string, hexKey: string, wxid: string) =>
ipcRenderer.invoke('wcdb:open', dbPath, hexKey, wxid),
close: () => ipcRenderer.invoke('wcdb:close'),

View File

@@ -2,19 +2,19 @@ import { basename, join } from 'path'
import { existsSync, readdirSync, statSync } from 'fs'
import { homedir } from 'os'
type PathCandidate = {
path: string
accountCount: number
latestModified: number
score: number
}
export class DbPathService {
async autoDetect(): Promise<{ success: boolean; path?: string; error?: string }> {
try {
for (const candidate of this.getPossibleRoots()) {
if (!existsSync(candidate)) continue
if (this.isAccountDir(candidate)) {
return { success: true, path: candidate }
}
if (this.findAccountDirs(candidate).length > 0) {
return { success: true, path: candidate }
}
const candidates = this.collectCandidates()
if (candidates.length > 0) {
return { success: true, path: candidates[0].path }
}
return { success: false, error: '未能自动检测到微信数据库目录' }
@@ -36,6 +36,9 @@ export class DbPathService {
getDefaultPath(): string {
const home = homedir()
const detected = this.collectCandidates()[0]
if (detected) return detected.path
if (process.platform === 'darwin') {
const appSupportBase = join(
home,
@@ -96,6 +99,95 @@ export class DbPathService {
]
}
private collectCandidates(): PathCandidate[] {
const candidates: PathCandidate[] = []
const seen = new Set<string>()
const pushCandidate = (candidatePath: string) => {
const normalized = String(candidatePath || '').replace(/[\\/]+$/, '')
if (!normalized || seen.has(normalized) || !existsSync(normalized)) return
seen.add(normalized)
if (this.isAccountDir(normalized)) {
const latestModified = this.getAccountModifiedTime(normalized)
candidates.push({
path: normalized,
accountCount: 1,
latestModified,
score: 1_000_000 + latestModified
})
return
}
const accounts = this.findAccountDirs(normalized)
if (accounts.length === 0) return
let latestModified = 0
for (const account of accounts) {
latestModified = Math.max(latestModified, this.getAccountModifiedTime(join(normalized, account)))
}
const rootName = basename(normalized).toLowerCase()
const rootBonus =
process.platform === 'darwin' && this.isMacVersionDir(rootName) ? 50_000 :
rootName === 'xwechat_files' ? 30_000 :
rootName === 'wechat files' ? 20_000 :
0
candidates.push({
path: normalized,
accountCount: accounts.length,
latestModified,
score: rootBonus + accounts.length * 10_000 + latestModified
})
}
for (const candidate of this.getPossibleRoots()) {
pushCandidate(candidate)
}
if (process.platform === 'darwin') {
for (const candidate of this.getMacNestedRoots()) {
pushCandidate(candidate)
}
}
return candidates.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score
return a.path.localeCompare(b.path)
})
}
private getMacNestedRoots(): string[] {
const home = homedir()
const appSupportBase = join(
home,
'Library',
'Containers',
'com.tencent.xinWeChat',
'Data',
'Library',
'Application Support',
'com.tencent.xinWeChat'
)
const nestedRoots: string[] = []
for (const entry of this.safeReadDir(appSupportBase)) {
if (!this.isMacVersionDir(entry)) continue
const versionDir = join(appSupportBase, entry)
nestedRoots.push(versionDir)
for (const child of this.safeReadDir(versionDir)) {
if (!this.isPotentialAccountName(child)) continue
nestedRoots.push(join(versionDir, child))
}
}
return nestedRoots
}
private findAccountDirs(rootPath: string): string[] {
const accounts: string[] = []

View File

@@ -0,0 +1,10 @@
import type { ShortcutService, ShortcutUpdateResult } from './shortcutService'
class DarwinShortcutService implements ShortcutService {
async updateDesktopShortcutIcon(_iconPath: string): Promise<ShortcutUpdateResult> {
// macOS 没有与 Windows .lnk 对应的统一桌面快捷方式图标更新入口,这里保持成功返回即可。
return { success: true }
}
}
export const shortcutService = new DarwinShortcutService()

View File

@@ -1,79 +1,21 @@
import { app } from 'electron'
import { spawn } from 'child_process'
import { join } from 'path'
import { existsSync } from 'fs'
export class ShortcutService {
/**
* 更新桌面快捷方式的图标
* 注意:这需要调用 PowerShell可能会短暂显示控制台窗口或被杀毒软件拦截
* @param iconPath ICO 图标文件的绝对路径
*/
async updateDesktopShortcutIcon(iconPath: string): Promise<{ success: boolean; error?: string }> {
return new Promise((resolve) => {
try {
if (!existsSync(iconPath)) {
resolve({ success: false, error: '图标文件不存在' })
return
}
const desktopPath = app.getPath('desktop')
const exePath = process.execPath
// PowerShell 脚本:遍历桌面所有 .lnk如果目标指向当前 exe则修改图标
// 使用 -WindowStyle Hidden 隐藏窗口
const psScript = `
$WshShell = New-Object -comObject WScript.Shell
$DesktopPath = "${desktopPath}"
$TargetExe = "${exePath}"
$IconPath = "${iconPath}"
Get-ChildItem -Path $DesktopPath -Filter *.lnk | ForEach-Object {
try {
$Shortcut = $WshShell.CreateShortcut($_.FullName)
if ($Shortcut.TargetPath -eq $TargetExe) {
$Shortcut.IconLocation = $IconPath
$Shortcut.Save()
Write-Host "Updated: $($_.Name)"
}
} catch {
Write-Error $_.Exception.Message
}
}
`
const ps = spawn('powershell.exe', [
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-WindowStyle', 'Hidden',
'-Command', psScript
])
let output = ''
let errorOutput = ''
ps.stdout.on('data', (data) => {
output += data.toString()
})
ps.stderr.on('data', (data) => {
errorOutput += data.toString()
})
ps.on('close', (code) => {
if (code === 0) {
resolve({ success: true })
} else {
console.error('[ShortcutService] 更新快捷方式失败', errorOutput)
resolve({ success: false, error: errorOutput || 'Unknown PowerShell error' })
}
})
} catch (e) {
console.error('[ShortcutService] 执行出错', e)
resolve({ success: false, error: String(e) })
}
})
}
export type ShortcutUpdateResult = {
success: boolean
error?: string
}
export const shortcutService = new ShortcutService()
export interface ShortcutService {
updateDesktopShortcutIcon(iconPath: string): Promise<ShortcutUpdateResult>
}
import { shortcutService as windowsShortcutService } from './shortcutService.win32'
import { shortcutService as darwinShortcutService } from './shortcutService.darwin'
import { shortcutService as unsupportedShortcutService } from './shortcutService.unsupported'
const shortcutService: ShortcutService =
process.platform === 'win32'
? windowsShortcutService
: process.platform === 'darwin'
? darwinShortcutService
: unsupportedShortcutService
export { shortcutService }

View File

@@ -0,0 +1,12 @@
import type { ShortcutService, ShortcutUpdateResult } from './shortcutService'
class UnsupportedShortcutService implements ShortcutService {
async updateDesktopShortcutIcon(_iconPath: string): Promise<ShortcutUpdateResult> {
return {
success: false,
error: `Desktop shortcut icon update is not supported on ${process.platform}`
}
}
}
export const shortcutService = new UnsupportedShortcutService()

View File

@@ -0,0 +1,72 @@
import { app } from 'electron'
import { spawn } from 'child_process'
import { existsSync } from 'fs'
import type { ShortcutService, ShortcutUpdateResult } from './shortcutService'
class WindowsShortcutService implements ShortcutService {
async updateDesktopShortcutIcon(iconPath: string): Promise<ShortcutUpdateResult> {
return new Promise((resolve) => {
try {
if (!existsSync(iconPath)) {
resolve({ success: false, error: '图标文件不存在' })
return
}
const desktopPath = app.getPath('desktop')
const exePath = process.execPath
const psScript = `
$WshShell = New-Object -comObject WScript.Shell
$DesktopPath = "${desktopPath}"
$TargetExe = "${exePath}"
$IconPath = "${iconPath}"
Get-ChildItem -Path $DesktopPath -Filter *.lnk | ForEach-Object {
try {
$Shortcut = $WshShell.CreateShortcut($_.FullName)
if ($Shortcut.TargetPath -eq $TargetExe) {
$Shortcut.IconLocation = $IconPath
$Shortcut.Save()
Write-Host "Updated: $($_.Name)"
}
} catch {
Write-Error $_.Exception.Message
}
}
`
const ps = spawn('powershell.exe', [
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-WindowStyle', 'Hidden',
'-Command', psScript
])
let errorOutput = ''
ps.stderr.on('data', (data) => {
errorOutput += data.toString()
})
ps.on('error', (error) => {
console.error('[ShortcutService] PowerShell 启动失败', error)
resolve({ success: false, error: String(error) })
})
ps.on('close', (code) => {
if (code === 0) {
resolve({ success: true })
return
}
console.error('[ShortcutService] 更新快捷方式失败', errorOutput)
resolve({ success: false, error: errorOutput || 'Unknown PowerShell error' })
})
} catch (e) {
console.error('[ShortcutService] 执行出错', e)
resolve({ success: false, error: String(e) })
}
})
}
}
export const shortcutService = new WindowsShortcutService()

View File

@@ -7,6 +7,10 @@ export class WcdbService {
private koffi: any = null
private initialized = false
private handle: number | null = null
private currentPath: string | null = null
private currentKey: string | null = null
private currentWxid: string | null = null
private currentDbStoragePath: string | null = null
private wcdbInit: any = null
private wcdbShutdown: any = null
@@ -37,8 +41,8 @@ export class WcdbService {
return join(baseDir, 'WCDB.dll')
}
private findSessionDb(dir: string, depth = 0): string | null {
if (depth > 5) return null
private findSessionDbs(dir: string, depth = 0, results: string[] = []): string[] {
if (depth > 5) return results
try {
const entries = readdirSync(dir)
@@ -46,8 +50,8 @@ export class WcdbService {
for (const entry of entries) {
if (entry.toLowerCase() === 'session.db') {
const fullPath = join(dir, entry)
if (statSync(fullPath).isFile()) {
return fullPath
if (statSync(fullPath).isFile() && !results.includes(fullPath)) {
results.push(fullPath)
}
}
}
@@ -56,8 +60,7 @@ export class WcdbService {
const fullPath = join(dir, entry)
try {
if (statSync(fullPath).isDirectory()) {
const found = this.findSessionDb(fullPath, depth + 1)
if (found) return found
this.findSessionDbs(fullPath, depth + 1, results)
}
} catch {
// ignore
@@ -67,92 +70,89 @@ export class WcdbService {
console.error('查找 session.db 失败:', e)
}
return null
return results
}
private normalizeWxid(wxid: string): string {
const trimmed = String(wxid || '').trim()
if (!trimmed) return ''
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
return match?.[1] || trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
return suffixMatch ? suffixMatch[1] : trimmed
private scoreSessionDbPath(filePath: string): number {
const normalized = filePath.replace(/\\/g, '/').toLowerCase()
let score = 0
if (normalized.endsWith('/session/session.db')) score += 40
if (normalized.includes('/db_storage/session/')) score += 20
if (normalized.includes('/db_storage/')) score += 10
return score
}
private isAccountDir(dirPath: string): boolean {
return (
existsSync(join(dirPath, 'db_storage')) ||
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
existsSync(join(dirPath, 'FileStorage', 'Image2')) ||
existsSync(join(dirPath, 'msg', 'attach'))
)
private getCandidateSessionDbs(dbStoragePath: string): string[] {
return this.findSessionDbs(dbStoragePath)
.sort((a, b) => this.scoreSessionDbPath(b) - this.scoreSessionDbPath(a) || a.localeCompare(b))
}
private resolveAccountRoot(dbPath: string, wxid: string): string | null {
const normalizedDbPath = dbPath.replace(/[\\/]+$/, '')
const direct = join(normalizedDbPath, wxid)
if (existsSync(direct) && this.isAccountDir(direct)) {
return direct
}
private tryOpenWithCandidates(sessionDbPaths: string[], hexKey: string): { success: boolean; handle?: number; matchedPath?: string; errors: string[] } {
const errors: string[] = []
const normalizedWxid = this.normalizeWxid(wxid)
const directNormalized = join(normalizedDbPath, normalizedWxid)
if (existsSync(directNormalized) && this.isAccountDir(directNormalized)) {
return directNormalized
}
if (this.isAccountDir(normalizedDbPath) && basename(normalizedDbPath) === wxid) {
return normalizedDbPath
}
if (this.isAccountDir(normalizedDbPath) && basename(normalizedDbPath) === normalizedWxid) {
return normalizedDbPath
}
try {
for (const entry of readdirSync(normalizedDbPath, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const entryPath = join(normalizedDbPath, entry.name)
if (!this.isAccountDir(entryPath)) continue
const lowerEntry = entry.name.toLowerCase()
const lowerWxid = wxid.toLowerCase()
const lowerNormalizedWxid = normalizedWxid.toLowerCase()
const cleanedEntry = this.normalizeWxid(entry.name).toLowerCase()
if (
lowerEntry === lowerWxid ||
lowerEntry === lowerNormalizedWxid ||
cleanedEntry === lowerWxid ||
cleanedEntry === lowerNormalizedWxid ||
lowerEntry.startsWith(`${lowerWxid}_`) ||
lowerEntry.startsWith(`${lowerNormalizedWxid}_`)
) {
return entryPath
for (const sessionDbPath of sessionDbPaths) {
const handleOut = [0]
const result = this.wcdbOpenAccount(sessionDbPath, hexKey, handleOut)
if (result === 0 && handleOut[0] > 0) {
return {
success: true,
handle: handleOut[0],
matchedPath: sessionDbPath,
errors
}
}
} catch {
// ignore
errors.push(`${sessionDbPath} => ${this.mapStatusCode(result)}`)
}
return null
return { success: false, errors }
}
private resolveDbStoragePath(dbPath: string, wxid: string): string | null {
if (!dbPath) return null
const normalizedDbPath = dbPath.replace(/[\\/]+$/, '')
if (basename(normalizedDbPath).toLowerCase() === 'db_storage' && existsSync(normalizedDbPath)) {
return normalizedDbPath
}
const accountRoot = this.resolveAccountRoot(normalizedDbPath, wxid)
if (!accountRoot) return null
const direct = join(normalizedDbPath, 'db_storage')
if (existsSync(direct)) {
return direct
}
const dbStoragePath = join(accountRoot, 'db_storage')
return existsSync(dbStoragePath) ? dbStoragePath : null
if (wxid) {
const viaWxid = join(normalizedDbPath, wxid, 'db_storage')
if (existsSync(viaWxid)) {
return viaWxid
}
try {
const lowerWxid = wxid.toLowerCase()
for (const entry of readdirSync(normalizedDbPath)) {
const entryPath = join(normalizedDbPath, entry)
try {
if (!statSync(entryPath).isDirectory()) continue
} catch {
continue
}
const lowerEntry = entry.toLowerCase()
if (lowerEntry !== lowerWxid && !lowerEntry.startsWith(`${lowerWxid}_`)) {
continue
}
const candidate = join(entryPath, 'db_storage')
if (existsSync(candidate)) {
return candidate
}
}
} catch {
// ignore
}
}
return null
}
private async initialize(): Promise<{ success: boolean; error?: string }> {
@@ -201,6 +201,20 @@ export class WcdbService {
async testConnection(dbPath: string, hexKey: string, wxid: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
try {
if (
this.handle !== null &&
this.currentPath === dbPath &&
this.currentKey === hexKey &&
this.currentWxid === wxid
) {
return { success: true, sessionCount: 0 }
}
const hadActiveConnection = this.handle !== null
const prevPath = this.currentPath
const prevKey = this.currentKey
const prevWxid = this.currentWxid
const initRes = await this.initialize()
if (!initRes.success) {
return { success: false, error: initRes.error || 'WCDB 初始化失败' }
@@ -211,24 +225,45 @@ export class WcdbService {
return { success: false, error: `未找到账号目录或 db_storage: ${dbPath}` }
}
const sessionDbPath = this.findSessionDb(dbStoragePath)
if (!sessionDbPath) {
return { success: false, error: '未找到 session.db 文件' }
const sessionDbPaths = this.getCandidateSessionDbs(dbStoragePath)
if (sessionDbPaths.length === 0) {
return { success: false, error: `未找到 session.db 文件: ${dbStoragePath}` }
}
const handleOut = [0]
const result = this.wcdbOpenAccount(sessionDbPath, hexKey, handleOut)
if (result !== 0) {
await this.printLogs()
return { success: false, error: this.mapStatusCode(result) }
const openResult = this.tryOpenWithCandidates(sessionDbPaths, hexKey)
if (!openResult.success || !openResult.handle || !openResult.matchedPath) {
const logs = await this.printLogs()
return {
success: false,
error: `数据库打开失败 | db_storage=${dbStoragePath} | tried=${sessionDbPaths.join(', ')}${openResult.errors.length ? ` | details=${openResult.errors.join(' ; ')}` : ''}${logs ? ` | logs=${logs}` : ''}`
}
}
const handle = handleOut[0]
if (handle <= 0) {
const tempHandle = openResult.handle
if (tempHandle <= 0) {
return { success: false, error: '无效的数据库句柄' }
}
this.handle = handle
try {
this.wcdbShutdown()
this.handle = null
this.currentPath = null
this.currentKey = null
this.currentWxid = null
this.currentDbStoragePath = null
this.initialized = false
} catch (e) {
console.error('关闭测试数据库时出错:', e)
}
if (hadActiveConnection && prevPath && prevKey && prevWxid) {
try {
await this.open(prevPath, prevKey, prevWxid)
} catch {
// ignore restore failure
}
}
return { success: true, sessionCount: 0 }
} catch (e) {
console.error('测试连接异常:', e)
@@ -237,8 +272,63 @@ export class WcdbService {
}
async open(dbPath: string, hexKey: string, wxid: string): Promise<boolean> {
const result = await this.testConnection(dbPath, hexKey, wxid)
return result.success
try {
if (
this.handle !== null &&
this.currentPath === dbPath &&
this.currentKey === hexKey &&
this.currentWxid === wxid
) {
return true
}
const initRes = await this.initialize()
if (!initRes.success) {
return false
}
if (this.handle !== null) {
this.close()
const reinitRes = await this.initialize()
if (!reinitRes.success) {
return false
}
}
const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid)
if (!dbStoragePath) {
console.error('数据库目录不存在:', dbPath)
return false
}
const sessionDbPaths = this.getCandidateSessionDbs(dbStoragePath)
if (sessionDbPaths.length === 0) {
console.error('未找到 session.db 文件:', dbStoragePath)
return false
}
const openResult = this.tryOpenWithCandidates(sessionDbPaths, hexKey)
if (!openResult.success || !openResult.handle) {
await this.printLogs()
return false
}
const handle = openResult.handle
if (handle <= 0) {
return false
}
this.handle = handle
this.currentPath = dbPath
this.currentKey = hexKey
this.currentWxid = wxid
this.currentDbStoragePath = dbStoragePath
this.initialized = true
return true
} catch (e) {
console.error('打开数据库异常:', e)
return false
}
}
close(): void {
@@ -261,6 +351,10 @@ export class WcdbService {
this.handle = null
this.initialized = false
this.lib = null
this.currentPath = null
this.currentKey = null
this.currentWxid = null
this.currentDbStoragePath = null
}
shutdown(): void {
@@ -330,19 +424,21 @@ export class WcdbService {
return encryptedData
}
private async printLogs(): Promise<void> {
private async printLogs(): Promise<string> {
try {
if (!this.wcdbGetLogs) return
if (!this.wcdbGetLogs) return ''
const outPtr = [null as any]
const result = this.wcdbGetLogs(outPtr)
if (result === 0 && outPtr[0]) {
const jsonStr = this.koffi.decode(outPtr[0], 'char', -1)
console.error('WCDB 内部日志:', jsonStr)
this.wcdbFreeString(outPtr[0])
return jsonStr
}
} catch (e) {
console.error('获取 WCDB 日志失败:', e)
}
return ''
}
private mapStatusCode(code: number): string {

View File

@@ -81,16 +81,8 @@ export class WxKeyServiceMac {
}
async initialize(): Promise<boolean> {
if (this.initialized) return true
try {
this.koffi = require('koffi')
const dylibPath = this.getDylibPath()
this.lib = this.koffi.load(dylibPath)
this.GetDbKey = this.lib.func('const char* GetDbKey()')
this.ListWeChatProcesses = this.lib.func('const char* ListWeChatProcesses()')
this.initialized = true
return true
return this.initializeFromRuntime()
} catch (e) {
console.error('[WxKeyServiceMac] 初始化失败:', e)
return false
@@ -127,6 +119,16 @@ export class WxKeyServiceMac {
// ignore
}
try {
if (this.initializeFromRuntime()) {
const raw = this.ListWeChatProcesses?.()
const parsed = this.parseWeChatProcessList(typeof raw === 'string' ? raw : '')
if (parsed.length > 0) return Math.max(...parsed)
}
} catch {
// ignore
}
try {
const output = execSync('/bin/ps -A -o pid,comm,command', { encoding: 'utf8' })
const lines = output.split(/\r?\n/).slice(1)
@@ -156,6 +158,39 @@ export class WxKeyServiceMac {
return null
}
private initializeFromRuntime(): boolean {
if (this.initialized) return true
try {
this.koffi = require('koffi')
const dylibPath = this.getDylibPath()
this.lib = this.koffi.load(dylibPath)
this.GetDbKey = this.lib.func('const char* GetDbKey()')
this.ListWeChatProcesses = this.lib.func('const char* ListWeChatProcesses()')
this.initialized = true
return true
} catch {
return false
}
}
private parseWeChatProcessList(raw: string): number[] {
return String(raw || '')
.split(';')
.map(item => item.trim())
.filter(Boolean)
.map(item => {
const lastColon = item.lastIndexOf(':')
if (lastColon < 0) return null
const name = item.slice(0, lastColon)
const pid = Number(item.slice(lastColon + 1))
if (!Number.isFinite(pid) || pid <= 0) return null
if (name.includes('Helper') || name.includes('crashpad_handler') || name.includes('WeChatAppEx')) return null
return pid
})
.filter((pid): pid is number => pid !== null)
}
killWeChat(): boolean {
try {
execSync('/usr/bin/pkill -x WeChat', { stdio: 'ignore' })
@@ -165,6 +200,16 @@ export class WxKeyServiceMac {
}
}
async waitForWeChatExit(maxWaitSeconds = 15): Promise<boolean> {
for (let i = 0; i < maxWaitSeconds * 2; i++) {
if (!this.isWeChatRunning()) {
return true
}
await new Promise(resolve => setTimeout(resolve, 500))
}
return !this.isWeChatRunning()
}
async launchWeChat(customPath?: string): Promise<boolean> {
try {
if (customPath && existsSync(customPath)) {
@@ -198,18 +243,20 @@ export class WxKeyServiceMac {
if (sipStatus.enabled) {
return {
success: false,
error: 'SIP 已开启,无法抓取 macOS 微信数据库密钥。请关闭 SIP 后重试。'
error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法\n1. Intel 芯片:重启 Mac 并按住 Command + R 进入恢复模式\n2. Apple 芯片M 系列):关机后长按开机(指纹)键,选择“设置(选项)”进入恢复模式\n3. 打开终端,输入: csrutil disable\n4. 重启电脑'
}
}
onStatus?.('正在请求管理员授权并启动 helper...', 0)
onStatus?.('正在获取数据库密钥...', 0)
onStatus?.('正在请求管理员授权并执行 helper...', 0)
let parsed: { success: boolean; key?: string; code?: string; detail?: string; raw: string }
try {
const helperResult = await this.getDbKeyByHelperElevated(timeoutMs, onStatus)
parsed = this.parseDbKeyResult(helperResult)
console.log('[WxKeyServiceMac] GetDbKey elevated returned:', parsed.raw)
} catch (e: any) {
const msg = String(e?.message || e)
const msg = `${e?.message || e}`
if (msg.includes('(-128)') || msg.includes('User canceled')) {
return { success: false, error: '已取消管理员授权' }
}
@@ -217,9 +264,11 @@ export class WxKeyServiceMac {
}
if (!parsed.success) {
const errorMsg = this.mapDbKeyErrorMessage(parsed.code, parsed.detail)
onStatus?.(errorMsg, 2)
return {
success: false,
error: this.mapDbKeyErrorMessage(parsed.code, parsed.detail)
error: errorMsg
}
}
@@ -227,8 +276,9 @@ export class WxKeyServiceMac {
return { success: true, key: parsed.key }
} catch (e: any) {
console.error('[WxKeyServiceMac] 获取密钥失败:', e)
console.error('[WxKeyServiceMac] Stack:', e.stack)
onStatus?.(`获取失败: ${e.message}`, 2)
return { success: false, error: e.message || String(e) }
return { success: false, error: e.message }
}
}
@@ -251,7 +301,7 @@ export class WxKeyServiceMac {
`set timeoutSec to ${timeoutSec}`,
'try',
'with timeout of timeoutSec seconds',
'set outText to do shell script (cmd & " 2>&1") with administrator privileges',
'set outText to do shell script cmd with administrator privileges',
'end timeout',
'return "WF_OK::" & outText',
'on error errMsg number errNum partial result pr',
@@ -259,42 +309,47 @@ export class WxKeyServiceMac {
'end try'
]
onStatus?.(`已找到微信进程 PID=${pid},正在请求管理员授权...`, 0)
onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0)
const result = await execFileAsync(
'/usr/bin/osascript',
scriptLines.flatMap(line => ['-e', line]),
{ timeout: waitMs + 20_000 }
)
const lines = String(result.stdout || '').split(/\r?\n/).map(x => x.trim()).filter(Boolean)
if (lines.length === 0) {
throw new Error('helper 返回空输出')
let stdout = ''
try {
const result = await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {
timeout: waitMs + 20_000
})
stdout = result.stdout
} catch (e: any) {
const msg = `${e?.stderr || ''}\n${e?.stdout || ''}\n${e?.message || ''}`.trim()
throw new Error(msg || 'elevated helper execution failed')
}
const lines = String(stdout).split(/\r?\n/).map(x => x.trim()).filter(Boolean)
if (!lines.length) throw new Error('elevated helper returned empty output')
const joined = lines.join('\n')
if (joined.startsWith('WF_ERR::')) {
const parts = joined.split('::')
throw new Error(`elevated helper failed: errNum=${parts[1] || 'unknown'}, errMsg=${parts[2] || 'unknown'}, partial=${parts.slice(3).join('::') || '(empty)'}`)
const errNum = parts[1] || 'unknown'
const errMsg = parts[2] || 'unknown'
const partial = parts.slice(3).join('::')
throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${errMsg}, partial=${partial || '(empty)'}`)
}
const normalizedOutput = joined.startsWith('WF_OK::') ? joined.slice('WF_OK::'.length) : joined
const payloads = normalizedOutput.match(/\{[^{}]*\}/g) ?? []
for (const item of payloads) {
try {
const parsed = JSON.parse(item)
if (parsed?.success === true && typeof parsed?.key === 'string') {
return parsed.key
}
if (typeof parsed?.result === 'string') {
return parsed.result
}
} catch {
// ignore
const extractJsonObjects = (s: string): any[] => {
const results: any[] = []
const re = /\{[^{}]*\}/g
let m: RegExpExecArray | null
while ((m = re.exec(s)) !== null) {
try { results.push(JSON.parse(m[0])) } catch { }
}
return results
}
throw new Error(`elevated helper returned invalid output: ${normalizedOutput}`)
const allJson = extractJsonObjects(normalizedOutput)
const successPayload = allJson.find(p => p?.success === true && typeof p?.key === 'string')
if (successPayload) return successPayload.key
const resultPayload = allJson.find(p => typeof p?.result === 'string')
if (resultPayload) return resultPayload.result
throw new Error('elevated helper returned invalid json: ' + lines[lines.length - 1])
}
private parseDbKeyResult(raw: any): { success: boolean; key?: string; code?: string; detail?: string; raw: string } {

2
package-lock.json generated
View File

@@ -57,7 +57,7 @@
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4",
"adm-zip": "^0.5.16",
"electron": "^39.2.7",
"electron": "^39.6.0",
"electron-builder": "^25.1.8",
"sass": "^1.83.0",
"sharp": "^0.34.5",

View File

@@ -7,6 +7,7 @@
"main": "dist-electron/main.js",
"scripts": {
"dev": "vite",
"icon:mac": "bash scripts/build-macos-icon.sh",
"prebuild": "node scripts/update-readme-version.js && node scripts/prepare-release-announcement.js",
"build": "tsc && vite build && electron-builder && node scripts/add-size-to-yml.js",
"build:ci": "node scripts/prepare-release-announcement.js && tsc && vite build && electron-builder --publish never && node scripts/add-size-to-yml.js",
@@ -24,9 +25,9 @@
"postinstall": "electron-rebuild"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@modelcontextprotocol/sdk": "^1.27.1",
"@mui/material": "^7.3.9",
"@types/dompurify": "^3.0.5",
"@types/marked": "^5.0.2",
@@ -71,7 +72,7 @@
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4",
"adm-zip": "^0.5.16",
"electron": "^39.2.7",
"electron": "^39.6.0",
"electron-builder": "^25.1.8",
"sass": "^1.83.0",
"sharp": "^0.34.5",
@@ -101,6 +102,7 @@
"requestedExecutionLevel": "asInvoker"
},
"mac": {
"icon": "public/icon.icns",
"category": "public.app-category.utilities",
"hardenedRuntime": true,
"gatekeeperAssess": false,

BIN
public/icon-dock.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
public/icon.icns Normal file

Binary file not shown.

BIN
public/tray-mac.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
public/xinnian-dock.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
public/xinnian-tray.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

BIN
public/xinnian.icns Normal file

Binary file not shown.

BIN
resources/macos/image_scan_helper Executable file

Binary file not shown.

BIN
resources/macos/libdobby.dylib Executable file

Binary file not shown.

BIN
resources/macos/libwcdb_api.dylib Executable file

Binary file not shown.

Binary file not shown.

BIN
resources/macos/libwx_key.dylib Executable file

Binary file not shown.

BIN
resources/macos/xkey_helper Executable file

Binary file not shown.

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC_PNG="${1:-${ROOT_DIR}/public/logo.png}"
OUT_ICNS="${2:-${ROOT_DIR}/public/icon.icns}"
OUT_DOCK_PNG="${3:-${ROOT_DIR}/public/icon-dock.png}"
OUT_TRAY_PNG="${4:-${ROOT_DIR}/public/tray-mac.png}"
ICONSET_DIR="${ROOT_DIR}/public/icon.iconset"
TMP_DIR="$(mktemp -d)"
PADDED_MASTER="${TMP_DIR}/padded-master.png"
INNER_SIZE="${INNER_SIZE:-824}"
TRAY_INNER_SIZE="${TRAY_INNER_SIZE:-44}"
if [[ ! -f "${SRC_PNG}" ]]; then
echo "source png not found: ${SRC_PNG}" >&2
exit 1
fi
cleanup() {
rm -rf "${ICONSET_DIR}" "${TMP_DIR}"
}
trap cleanup EXIT
rm -rf "${ICONSET_DIR}"
mkdir -p "${ICONSET_DIR}"
sips -z "${INNER_SIZE}" "${INNER_SIZE}" "${SRC_PNG}" --out "${PADDED_MASTER}" >/dev/null
sips -p 1024 1024 "${PADDED_MASTER}" --out "${OUT_DOCK_PNG}" >/dev/null
TRAY_MASTER="${TMP_DIR}/tray-master.png"
sips -z "${TRAY_INNER_SIZE}" "${TRAY_INNER_SIZE}" "${SRC_PNG}" --out "${TRAY_MASTER}" >/dev/null
sips -p 64 64 "${TRAY_MASTER}" --out "${OUT_TRAY_PNG}" >/dev/null
for size in 16 32 128 256 512; do
sips -z "${size}" "${size}" "${OUT_DOCK_PNG}" --out "${ICONSET_DIR}/icon_${size}x${size}.png" >/dev/null
retina_size=$((size * 2))
sips -z "${retina_size}" "${retina_size}" "${OUT_DOCK_PNG}" --out "${ICONSET_DIR}/icon_${size}x${size}@2x.png" >/dev/null
done
iconutil -c icns "${ICONSET_DIR}" -o "${OUT_ICNS}"
echo "generated ${OUT_ICNS}"
echo "generated ${OUT_DOCK_PNG}"
echo "generated ${OUT_TRAY_PNG}"

View File

@@ -691,13 +691,34 @@ function SettingsPage() {
setKeyStatus(status)
})
const result = await window.electronAPI.wxKey.startGetKey()
const result = await window.electronAPI.wxKey.startGetKey(undefined, dbPath || undefined)
removeListener()
if (result.success && result.key) {
setDecryptKey(result.key)
await configService.setDecryptKey(result.key)
if (dbPath) {
const resolved = await window.electronAPI.wcdb.resolveValidWxid(dbPath, result.key)
if (resolved.success && resolved.wxid) {
setWxid(resolved.wxid)
setIsAccountVerified(true)
await configService.setMyWxid(resolved.wxid)
showMessage(`密钥获取成功!已验证账号: ${resolved.wxid}`, true)
setKeyStatus('')
return
}
}
if (result.validatedWxid) {
setWxid(result.validatedWxid)
setIsAccountVerified(true)
await configService.setMyWxid(result.validatedWxid)
showMessage(`密钥获取成功!已验证账号: ${result.validatedWxid}`, true)
setKeyStatus('')
return
}
setKeyStatus('正在检测当前登录账号...')
let accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 10)
@@ -771,7 +792,7 @@ function SettingsPage() {
})
setKeyStatus('Hook 已安装,请登录微信...')
const result = await window.electronAPI.wxKey.startGetKey()
const result = await window.electronAPI.wxKey.startGetKey(undefined, dbPath || undefined)
removeListener()
if (result.success && result.key) {
@@ -893,10 +914,36 @@ function SettingsPage() {
setWxidOptions([])
setShowWxidDropdown(false)
} else {
// 多个账号,显示选择下拉框
let selectedWxid = ''
if (decryptKey.length === 64) {
const resolved = await window.electronAPI.wcdb.resolveValidWxid(dbPath, decryptKey)
if (resolved.success && resolved.wxid && wxids.includes(resolved.wxid)) {
selectedWxid = resolved.wxid
setWxid(selectedWxid)
}
}
if (!selectedWxid) {
let accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 10)
if (!accountInfo) {
accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 60)
}
if (accountInfo && wxids.includes(accountInfo.wxid)) {
selectedWxid = accountInfo.wxid
setWxid(selectedWxid)
}
}
setWxidOptions(wxids)
setShowWxidDropdown(true)
showMessage(`检测到 ${wxids.length} 个候选账号目录,请选择后验证`, true)
showMessage(
selectedWxid
? `检测到 ${wxids.length} 个候选账号目录,已按最新活动优先选择:${selectedWxid}`
: `检测到 ${wxids.length} 个候选账号目录,请选择后验证`,
true
)
}
} catch (e) {
showMessage(`扫描失败: ${e}`, false)

View File

@@ -25,7 +25,7 @@
.welcome-page.is-standalone .window-controls {
position: absolute;
top: 12px;
right: 18px;
left: 18px;
display: inline-flex;
gap: 8px;
padding: 6px;
@@ -306,6 +306,7 @@
gap: 14px;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
/* 自定义滚动条 */
&::-webkit-scrollbar {
@@ -398,6 +399,7 @@
.info-content {
margin-top: 6px;
min-width: 0;
}
.info-content h3 {
@@ -417,6 +419,7 @@
margin: 0;
padding-left: 20px;
list-style: none;
min-width: 0;
}
.info-list li {
@@ -425,6 +428,9 @@
color: var(--text-secondary);
line-height: 1.8;
padding-left: 8px;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.info-list li::before {
@@ -435,12 +441,16 @@
}
.info-list code {
display: inline;
padding: 2px 6px;
border-radius: 4px;
background: rgba(139, 115, 85, 0.1);
color: var(--primary);
font-size: 12px;
font-family: 'Consolas', monospace;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.info-tips {

View File

@@ -44,6 +44,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const [error, setError] = useState('')
const [isScanningWxid, setIsScanningWxid] = useState(false)
const [isDetectingPath, setIsDetectingPath] = useState(false)
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
const [isFetchingImageKey, setIsFetchingImageKey] = useState(false)
const [showDecryptKey, setShowDecryptKey] = useState(false)
@@ -199,6 +200,14 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const rootClassName = `welcome-page${isClosing ? ' is-closing' : ''}${standalone ? ' is-standalone' : ''}`
const showWindowControls = standalone
useEffect(() => {
if (currentStep.id !== 'db') return
if (dbPath) return
if (isDetectingPath) return
void handleAutoDetectPath(true)
}, [currentStep.id, dbPath, isDetectingPath])
const handleMinimize = () => {
window.electronAPI.window.minimize()
}
@@ -234,6 +243,48 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
}
}
const handleAutoDetectPath = async (silent = false) => {
if (isDetectingPath) return
setIsDetectingPath(true)
if (!silent) setError('')
try {
const result = await window.electronAPI.dbPath.autoDetect()
if (result.success && result.path) {
setDbPath(result.path)
setError('')
return
}
if (!silent) {
setError(result.error || '未能自动检测到微信数据库目录')
}
} catch (e) {
if (!silent) {
setError(`自动检测失败: ${e}`)
}
} finally {
setIsDetectingPath(false)
}
}
const handleOpenDetectedPath = async () => {
if (!dbPath) {
setError('当前没有可打开的数据库目录')
return
}
try {
const result = await window.electronAPI.shell.openPath(dbPath)
if (result) {
setError(result)
}
} catch (e) {
setError(`打开目录失败: ${e}`)
}
}
const handleSelectCachePath = async () => {
@@ -265,11 +316,38 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
setWxidOptions(wxids)
setIsAccountVerified(false)
if (wxids.length > 0) {
// 密钥前仅做候选识别,默认优先 wxid_ 前缀目录
const wxidAccount = wxids.find(id => id.startsWith('wxid_'))
const selectedWxid = wxidAccount || wxids[0]
setWxid(selectedWxid)
if (!silent) setError('')
let selectedWxid = ''
if (decryptKey.length === 64) {
const resolved = await window.electronAPI.wcdb.resolveValidWxid(dbPath, decryptKey)
if (resolved.success && resolved.wxid && wxids.includes(resolved.wxid)) {
selectedWxid = resolved.wxid
}
}
if (!selectedWxid) {
let accountInfo: { wxid: string; dbPath: string } | null = null
accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 10)
if (!accountInfo) {
accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 60)
}
if (accountInfo && wxids.includes(accountInfo.wxid)) {
selectedWxid = accountInfo.wxid
}
}
if (!selectedWxid) {
const wxidAccount = wxids.find(id => id.startsWith('wxid_'))
selectedWxid = wxidAccount || wxids[0]
}
if (selectedWxid) {
setWxid(selectedWxid)
if (!silent) setError('')
} else {
if (!silent) setError('未能自动确定正确账号目录,请手动选择')
}
} else {
if (!silent) setError('未检测到账号目录,请检查路径')
}
@@ -288,13 +366,29 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
setError('')
setDbKeyStatus('正在准备获取密钥...')
try {
const result = await window.electronAPI.wxKey.startGetKey(wechatPath)
const result = await window.electronAPI.wxKey.startGetKey(wechatPath, dbPath || undefined)
if (result.success && result.key) {
setDecryptKey(result.key)
setDbKeyStatus('密钥获取成功,正在验证账号目录...')
setError('')
setShowWechatPathPrompt(false)
if (dbPath) {
const resolved = await window.electronAPI.wcdb.resolveValidWxid(dbPath, result.key)
if (resolved.success && resolved.wxid) {
setWxid(resolved.wxid)
setIsAccountVerified(true)
setDbKeyStatus(`密钥获取成功,已验证账号目录: ${resolved.wxid}`)
return
}
}
if (result.validatedWxid) {
setWxid(result.validatedWxid)
setDbKeyStatus(`密钥获取成功,已验证账号目录: ${result.validatedWxid}`)
return
}
// 先尝试当前登录账号检测(强信号)
let accountInfo: { wxid: string; dbPath: string } | null = null
if (dbPath) {
@@ -593,12 +687,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
<div className={rootClassName}>
{showWindowControls && (
<div className="window-controls">
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
<Minus size={14} />
</button>
<button type="button" className="window-btn is-close" onClick={handleCloseWindow} aria-label="关闭">
<X size={14} />
</button>
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
<Minus size={14} />
</button>
</div>
)}
@@ -678,20 +772,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{currentStep.id === 'db' && (
<div className="info-content">
<h3></h3>
<p></p>
<h3></h3>
<p></p>
<ul className="info-list">
{isMac ? (
<li><code>~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/&lt;version&gt;</code> <code>xwechat_files</code></li>
) : (
<li> </li>
)}
{isMac ? (
<li> 4.0.5+ <code>xwechat_files</code> </li>
) : (
<li> <code>xwechat_files</code> </li>
)}
<li>{isMac ? '建议优先选择版本目录或账号根目录' : '路径中不能包含中文字符'}</li>
<li></li>
<li></li>
<li>{isMac ? '若未命中,再手动选择版本目录或账号目录' : '若未命中,再按微信存储位置手动选择'}</li>
</ul>
{!isMac && (
<div className="info-warning">
@@ -718,15 +804,15 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{currentStep.id === 'key' && (
<div className="info-content">
<h3></h3>
<p>64</p>
<p></p>
<ul className="info-list">
<li>{isMac ? 'macOS 通过 helper + 断点捕获获取 DbKey' : '点击"自动获取"会自动启动微信'}</li>
<li>{isMac ? '此流程要求先关闭 SIP并允许管理员提权' : '等待提示"hook安装成功"后登录'}</li>
<li>{isMac ? '成功后会自动回填 64 位 DbKey 并尝试识别账号' : '登录后会自动识别账号'}</li>
<li>{isMac ? '建议先启动微信,并按界面提示完成授权' : '点击自动获取”后按提示操作'}</li>
<li>{isMac ? '识别完成后会自动尝试匹配账号目录' : '完成后会自动识别账号目录'}</li>
<li></li>
</ul>
<div className="info-warning">
<ShieldCheck size={16} />
<span>{isMac ? '若 SIP 未关闭,自动获取会直接失败并给出提示' : '密钥仅保存在本地,不会上传'}</span>
<span>{isMac ? '若系统环境不满足要求,界面会直接给出提示' : '密钥不会上传到服务器'}</span>
</div>
</div>
)}
@@ -822,9 +908,25 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
value={dbPath}
onChange={(e) => setDbPath(e.target.value)}
/>
<button className="btn btn-primary btn-full" onClick={handleSelectPath}>
<FolderOpen size={16} />
</button>
<div className="button-row">
<button
className="btn btn-primary"
onClick={() => handleAutoDetectPath()}
disabled={isDetectingPath}
>
<Wand2 size={16} /> {isDetectingPath ? '自动检测中...' : '自动检测'}
</button>
<button className="btn btn-secondary" onClick={handleSelectPath}>
<FolderOpen size={16} />
</button>
</div>
{dbPath && (
<div className="button-row">
<button className="btn btn-secondary" onClick={handleOpenDetectedPath}>
<FolderOpen size={16} />
</button>
</div>
)}
<div className="field-hint">{isMac ? '请选择微信版本目录或账号根目录' : '请选择微信-设置-存储位置对应的目录'}</div>
{!isMac && (
<div className="field-hint" style={{ color: '#ff6b6b', marginTop: '4px' }}> --</div>

View File

@@ -294,7 +294,7 @@ export interface ElectronAPI {
killWeChat: () => Promise<boolean>
launchWeChat: () => Promise<boolean>
waitForWindow: (maxWaitSeconds?: number) => Promise<boolean>
startGetKey: (customWechatPath?: string) => Promise<{ success: boolean; key?: string; error?: string; needManualPath?: boolean }>
startGetKey: (customWechatPath?: string, dbPath?: string) => Promise<{ success: boolean; key?: string; error?: string; needManualPath?: boolean; validatedWxid?: string }>
cancel: () => Promise<boolean>
detectCurrentAccount: (dbPath?: string, maxTimeDiffMinutes?: number) => Promise<{ wxid: string; dbPath: string } | null>
onStatus: (callback: (data: { status: string; level: number }) => void) => () => void
@@ -307,6 +307,7 @@ export interface ElectronAPI {
}
wcdb: {
testConnection: (dbPath: string, hexKey: string, wxid: string, isAutoConnect?: boolean) => Promise<{ success: boolean; error?: string; sessionCount?: number }>
resolveValidWxid: (dbPath: string, hexKey: string) => Promise<{ success: boolean; wxid?: string; error?: string }>
open: (dbPath: string, hexKey: string, wxid: string) => Promise<boolean>
close: () => Promise<boolean>
decryptDatabase: (dbPath: string, hexKey: string, wxid: string) => Promise<{ success: boolean; error?: string; totalFiles?: number; successCount?: number; failCount?: number }>